Working un reactifying the conversations

canary
WolverinDEV 2020-07-12 16:31:57 +02:00
parent 4365e03dbe
commit f3f8d1cae9
29 changed files with 2331 additions and 373 deletions

View File

@ -1,4 +1,11 @@
# Changelog:
* **XX.XX.20**
- Rewrote the channel conversation UI
- Several bug fixes like the scrollbar
- Updated the channel history view mode
- Improved chat box behaviour
- Automatically crawling all channels on server join for new messages (requires TeaSpeak 1.4.16-b2 or higher)
* **15.06.20**
- Recoded the permission editor with react
- Fixed sever permission editor display bugs

99
package-lock.json generated
View File

@ -4,6 +4,14 @@
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@babel/runtime": {
"version": "7.10.3",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.3.tgz",
"integrity": "sha512-RzGO0RLSdokm9Ipe/YD+7ww8X2Ro79qiXZF3HU9ljrM+qnJmH1Vqth+hbiQZy761LnMJTMitHDuKVYTk3k4dLw==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
},
"@google-cloud/common": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-2.4.0.tgz",
@ -192,6 +200,14 @@
"integrity": "sha512-+nriFZYDz+C+6SWzJp0jHrGvyzL3Sg63u1vRlH1AfViyaT4oTod+MtfZ0YMNYXzO7ybFkdx4ZaBwBtLwdYkReQ==",
"dev": true
},
"@types/emoji-mart": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/emoji-mart/-/emoji-mart-3.0.2.tgz",
"integrity": "sha512-Cmq8xpPK5Va+fjQE7ZaE5oykXzACBQ64CpNnYOIU7gWcR6nYTxWjMR3yPhnAMzw4yQn9R9761FpTvAyi/SH9MQ==",
"requires": {
"@types/react": "*"
}
},
"@types/emscripten": {
"version": "1.39.2",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.39.2.tgz",
@ -258,6 +274,12 @@
"@types/sizzle": "*"
}
},
"@types/json-schema": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.5.tgz",
"integrity": "sha512-7+2BITlgjgDhH0vvwZU/HZJVyk+2XUlvxXe8dFMedNX/aMkaOq++rMAFXc0tM7ij15QaWlbdQASBR9dihi+bDQ==",
"dev": true
},
"@types/loader-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@types/loader-utils/-/loader-utils-1.1.3.tgz",
@ -304,14 +326,12 @@
"@types/prop-types": {
"version": "15.7.3",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz",
"integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==",
"dev": true
"integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw=="
},
"@types/react": {
"version": "16.9.26",
"resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.26.tgz",
"integrity": "sha512-dGuSM+B0Pq1MKXYUMlUQWeS6Jj9IhSAUf9v8Ikaimj+YhkBcQrihWBkmyEhK/1fzkJTwZQkhZp5YhmWa2CH+Rw==",
"dev": true,
"requires": {
"@types/prop-types": "*",
"csstype": "^2.2.0"
@ -2192,9 +2212,9 @@
"dev": true
},
"css-loader": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/css-loader/-/css-loader-3.4.2.tgz",
"integrity": "sha512-jYq4zdZT0oS0Iykt+fqnzVLRIeiPWhka+7BqPn+oSIpWJAHak5tmB/WZrJ2a21JhCeFyNnnlroSl8c+MtVndzA==",
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/css-loader/-/css-loader-3.6.0.tgz",
"integrity": "sha512-M5lSukoWi1If8dhQAUCvj4H8vUt3vOnwbQBH9DdTm/s4Ym2B/3dPMtYZeJmq7Q3S3Pa+I94DcZ7pc9bP14cWIQ==",
"dev": true,
"requires": {
"camelcase": "^5.3.1",
@ -2202,15 +2222,28 @@
"icss-utils": "^4.1.1",
"loader-utils": "^1.2.3",
"normalize-path": "^3.0.0",
"postcss": "^7.0.23",
"postcss": "^7.0.32",
"postcss-modules-extract-imports": "^2.0.0",
"postcss-modules-local-by-default": "^3.0.2",
"postcss-modules-scope": "^2.1.1",
"postcss-modules-scope": "^2.2.0",
"postcss-modules-values": "^3.0.0",
"postcss-value-parser": "^4.0.2",
"schema-utils": "^2.6.0"
"postcss-value-parser": "^4.1.0",
"schema-utils": "^2.7.0",
"semver": "^6.3.0"
},
"dependencies": {
"ajv": {
"version": "6.12.2",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.2.tgz",
"integrity": "sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ==",
"dev": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
}
},
"camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
@ -2222,6 +2255,23 @@
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true
},
"schema-utils": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz",
"integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==",
"dev": true,
"requires": {
"@types/json-schema": "^7.0.4",
"ajv": "^6.12.2",
"ajv-keywords": "^3.4.1"
}
},
"semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
"dev": true
}
}
},
@ -2317,8 +2367,7 @@
"csstype": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.9.tgz",
"integrity": "sha512-xz39Sb4+OaTsULgUERcCk+TJj8ylkL4aSVDQiX/ksxbELSqwkgt4d4RD7fovIdgJGSuNYqwZEiVjYY5l0ask+Q==",
"dev": true
"integrity": "sha512-xz39Sb4+OaTsULgUERcCk+TJj8ylkL4aSVDQiX/ksxbELSqwkgt4d4RD7fovIdgJGSuNYqwZEiVjYY5l0ask+Q=="
},
"currently-unhandled": {
"version": "0.4.1",
@ -2731,6 +2780,15 @@
"minimalistic-crypto-utils": "^1.0.0"
}
},
"emoji-mart": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-3.0.0.tgz",
"integrity": "sha512-r5DXyzOLJttdwRYfJmPq/XL3W5tiAE/VsRnS0Hqyn27SqPA/GOYwVUSx50px/dXdJyDSnvmoPbuJ/zzhwSaU4A==",
"requires": {
"@babel/runtime": "^7.0.0",
"prop-types": "^15.6.0"
}
},
"emoji-regex": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz",
@ -8412,9 +8470,9 @@
"dev": true
},
"postcss": {
"version": "7.0.27",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.27.tgz",
"integrity": "sha512-WuQETPMcW9Uf1/22HWUWP9lgsIC+KEHg2kozMflKjbeUtw9ujvFX6QmIfozaErDkmLWS9WEnEdEe6Uo9/BNTdQ==",
"version": "7.0.32",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.32.tgz",
"integrity": "sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw==",
"dev": true,
"requires": {
"chalk": "^2.4.2",
@ -8517,9 +8575,9 @@
}
},
"postcss-value-parser": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.0.3.tgz",
"integrity": "sha512-N7h4pG+Nnu5BEIzyeaaIYWs0LI5XC40OrRh5L60z0QjFsqGWcHcbkBvpe1WYpcIS9yQ8sOi/vIPt1ejQCrMVrg==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz",
"integrity": "sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==",
"dev": true
},
"prepend-http": {
@ -9170,6 +9228,11 @@
"strip-indent": "^1.0.1"
}
},
"regenerator-runtime": {
"version": "0.13.5",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz",
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA=="
},
"regex-cache": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/regex-cache/-/regex-cache-0.4.4.tgz",

View File

@ -42,7 +42,7 @@
"circular-dependency-plugin": "^5.2.0",
"clean-css": "^4.2.1",
"clean-webpack-plugin": "^3.0.0",
"css-loader": "^3.4.2",
"css-loader": "^3.6.0",
"csso-cli": "^2.0.2",
"ejs": "^3.0.2",
"exports-loader": "^0.7.0",
@ -81,7 +81,9 @@
},
"homepage": "https://www.teaspeak.de",
"dependencies": {
"@types/emoji-mart": "^3.0.2",
"dompurify": "^2.0.8",
"emoji-mart": "^3.0.0",
"moment": "^2.24.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",

View File

@ -26,14 +26,14 @@ file=$(find "$PACKAGES_DIRECTORY" -maxdepth 1 -name "TeaWeb-release*.zip" -print
}
#TeaSpeak-Travis-Web
# ssh -oStrictHostKeyChecking=no $h TeaSpeak-Travis-Web@dev.web.teaspeak.de
ssh -oStrictHostKeyChecking=no -oIdentitiesOnly=yes -i /tmp/sftp_key TeaSpeak-Travis-Web@dev.web.teaspeak.de rm "tmp-upload/*.zip" # Cleanup the old files
ssh -oStrictHostKeyChecking=no -oIdentitiesOnly=yes -i /tmp/sftp_key TeaSpeak-Travis-Web@web.teaspeak.dev rm "tmp-upload/*.zip" # Cleanup the old files
_exit_code=$?
[[ $_exit_code -ne 0 ]] && {
echo "Failed to delete the old .zip files ($_exit_code)"
}
filename="TeaWeb-Release-$(git rev-parse --short HEAD).zip"
sftp -oStrictHostKeyChecking=no -oIdentitiesOnly=yes -i /tmp/sftp_key TeaSpeak-Travis-Web@dev.web.teaspeak.de << EOF
sftp -oStrictHostKeyChecking=no -oIdentitiesOnly=yes -i /tmp/sftp_key TeaSpeak-Travis-Web@web.teaspeak.dev << EOF
put $file tmp-upload/$filename
EOF
_exit_code=$?
@ -41,5 +41,5 @@ _exit_code=$?
echo "Failed to upload the .zip file ($_exit_code)"
exit 1
}
ssh -oStrictHostKeyChecking=no -oIdentitiesOnly=yes -i /tmp/sftp_key TeaSpeak-Travis-Web@dev.web.teaspeak.de "./unpack.sh tmp-upload/$filename"
ssh -oStrictHostKeyChecking=no -oIdentitiesOnly=yes -i /tmp/sftp_key TeaSpeak-Travis-Web@web.teaspeak.dev "./unpack.sh tmp-upload/$filename"
exit $?

View File

@ -1,5 +1,4 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg preserveAspectRatio="xMinYMid" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g transform="matrix(0.933313,-4.93038e-32,-4.93038e-32,0.933313,2.134,2.13353)">
<path d="M16.272,21.06L16.3,50.447C16.3,52.687 18.113,54.501 20.354,54.501L43.648,54.501C45.888,54.501 47.702,52.689 47.702,50.448L47.728,21.06" style="fill:none;stroke-width:2px;stroke:rgb(211,84,68);"/>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -65,7 +65,7 @@ export enum ConnectionState {
}
export namespace ConnectionState {
export function socket_connected(state: ConnectionState) {
export function socketConnected(state: ConnectionState) {
switch (state) {
case ConnectionState.CONNECTED:
case ConnectionState.AUTHENTICATING:
@ -75,6 +75,10 @@ export namespace ConnectionState {
return false;
}
}
export function fullyConnected(state: ConnectionState) {
return state === ConnectionState.CONNECTED;
}
}
export enum ViewReasonId {
@ -173,18 +177,17 @@ export class ConnectionHandler {
this.settings = new ServerSettings();
this.log = new ServerLog(this);
this.channelTree = new ChannelTree(this);
this.side_bar = new Frame(this);
this.sound = new SoundManager(this);
this.hostbanner = new Hostbanner(this);
this.serverConnection = connection.spawn_server_connection(this);
this.serverConnection.onconnectionstatechanged = this.on_connection_state_changed.bind(this);
this.channelTree = new ChannelTree(this);
this.fileManager = new FileManager(this);
this.permissions = new PermissionManager(this);
this.side_bar.channel_conversations().initialize_needed_listener();
this.log = new ServerLog(this);
this.side_bar = new Frame(this);
this.sound = new SoundManager(this);
this.hostbanner = new Hostbanner(this);
this.groups = new GroupManager(this);
this._local_client = new LocalClientEntry(this);
@ -641,7 +644,6 @@ export class ConnectionHandler {
this.serverConnection.disconnect();
this.side_bar.private_conversations().clear_client_ids();
this.side_bar.channel_conversations().set_current_channel(0);
this.hostbanner.update();
if(auto_reconnect) {

View File

@ -6,7 +6,14 @@ import * as loader from "tc-loader";
import * as image_preview from "./ui/frames/image_preview"
import * as DOMPurify from "dompurify";
declare const xbbcode;
import { parse as parseBBCode } from "vendor/xbbcode/parser";
import ReactRenderer from "vendor/xbbcode/renderer/react";
import HTMLRenderer from "vendor/xbbcode/renderer/html";
const rendererReact = new ReactRenderer();
const rendererHTML = new HTMLRenderer();
export namespace bbcode {
const sanitizer_escaped = (key: string) => "[-- sescaped: " + key + " --]";
const sanitizer_escaped_regex = /\[-- sescaped: ([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}) --]/;
@ -56,7 +63,7 @@ export namespace bbcode {
}
}
const result = xbbcode.parse(message, {
const result = parseBBCode(message, {
tag_whitelist: [
"b", "big",
"i", "italic",
@ -81,7 +88,7 @@ export namespace bbcode {
"img"
]
});
let html = result.build_html();
let html = result.map(e => rendererHTML.render(e)).join("");
if(typeof(window.twemoji) !== "undefined" && settings.static_global(Settings.KEY_CHAT_COLORED_EMOJIES))
html = twemoji.parse(html);
@ -164,7 +171,7 @@ export namespace bbcode {
icon_class: "client-copy"
})
});
parent.css("cursor", "pointer").on('click', event => image_preview.preview_image(proxy_url, url));
parent.css("cursor", "pointer").on('click', () => image_preview.preview_image(proxy_url, url));
}
loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
@ -216,6 +223,8 @@ export namespace bbcode {
}
});
const element: HTMLElement;
element.onended
const load_callback = guid();
/* the image parse & displayer */
xbbcode.register.register_parser({

View File

@ -22,12 +22,15 @@ export abstract class AbstractCommandHandler {
abstract handle_command(command: ServerCommand) : boolean;
}
export type ExplicitCommandHandler = (command: ServerCommand, consumed: boolean) => void | boolean;
export abstract class AbstractCommandHandlerBoss {
readonly connection: AbstractServerConnection;
protected command_handlers: AbstractCommandHandler[] = [];
/* TODO: Timeout */
protected single_command_handler: SingleCommandHandler[] = [];
protected explicitHandlers: {[key: string]:ExplicitCommandHandler[]} = {};
protected constructor(connection: AbstractServerConnection) {
this.connection = connection;
}
@ -37,6 +40,21 @@ export abstract class AbstractCommandHandlerBoss {
this.single_command_handler = undefined;
}
register_explicit_handler(command: string, callback: ExplicitCommandHandler) {
this.explicitHandlers[command] = this.explicitHandlers[command] || [];
this.explicitHandlers[command].push(callback);
return () => this.explicitHandlers[command].remove(callback);
}
unregister_explicit_handler(command: string, callback: ExplicitCommandHandler) {
if(!this.explicitHandlers[command])
return false;
this.explicitHandlers[command].remove(callback);
return true;
}
register_handler(handler: AbstractCommandHandler) {
if(!handler.volatile_handler_boss && handler.handler_boss)
throw "handler already registered";
@ -77,12 +95,23 @@ export abstract class AbstractCommandHandlerBoss {
for(const handler of this.command_handlers) {
try {
if(!flag_consumed || handler.ignore_consumed)
flag_consumed = flag_consumed || handler.handle_command(command);
flag_consumed = handler.handle_command(command) || flag_consumed;
} catch(error) {
console.error(tr("Failed to invoke command handler. Invocation results in an exception: %o"), error);
}
}
const explHandlers = this.explicitHandlers[command.command];
if(Array.isArray(explHandlers)) {
for(const handler of explHandlers) {
try {
flag_consumed = handler(command, flag_consumed) || flag_consumed;
} catch(error) {
console.error(tr("Failed to invoke command handler. Invocation results in an exception: %o"), error);
}
}
}
for(const handler of [...this.single_command_handler]) {
// We already know that handler.command must be an array (It will be converted within the register single handler methode)
if(handler.command && (handler.command as string[]).findIndex(e => e === command.command) == -1)

View File

@ -19,7 +19,6 @@ import {bbcode_chat, formatMessage} from "tc-shared/ui/frames/chat";
import {server_connections} from "tc-shared/ui/frames/connection_handlers";
import {spawnPoke} from "tc-shared/ui/modal/ModalPoke";
import {PrivateConversationState} from "tc-shared/ui/frames/side/private_conversations";
import {Conversation} from "tc-shared/ui/frames/side/conversations";
import {AbstractCommandHandler, AbstractCommandHandlerBoss} from "tc-shared/connection/AbstractCommandHandler";
import {batch_updates, BatchUpdateType, flush_batched_updates} from "tc-shared/ui/react-elements/ReactComponentBase";
@ -72,8 +71,8 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
this["notifychannelsubscribed"] = this.handleNotifyChannelSubscribed;
this["notifychannelunsubscribed"] = this.handleNotifyChannelUnsubscribed;
this["notifyconversationhistory"] = this.handleNotifyConversationHistory;
this["notifyconversationmessagedelete"] = this.handleNotifyConversationMessageDelete;
//this["notifyconversationhistory"] = this.handleNotifyConversationHistory;
//this["notifyconversationmessagedelete"] = this.handleNotifyConversationMessageDelete;
this["notifymusicstatusupdate"] = this.handleNotifyMusicStatusUpdate;
this["notifymusicplayersongchange"] = this.handleMusicPlayerSongChange;
@ -191,7 +190,6 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
json = json[0]; //Only one bulk
this.connection.client.side_bar.channel_conversations().reset();
this.connection.client.initializeLocalClient(parseInt(json["aclid"]), json["acn"]);
let updates: {
@ -362,7 +360,9 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
}
handleCommandChannelListFinished(json) {
handleCommandChannelListFinished() {
this.connection.client.channelTree.events.fire_async("notify_channel_list_received");
if(this.batch_update_finished_timeout) {
clearTimeout(this.batch_update_finished_timeout);
this.batch_update_finished_timeout = 0;
@ -384,7 +384,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
log.info(LogCategory.NETWORKING, tr("Got %d channel deletions"), json.length);
for(let index = 0; index < json.length; index++) {
conversations.delete_conversation(parseInt(json[index]["cid"]));
conversations.destroyConversation(parseInt(json[index]["cid"]));
let channel = tree.findChannel(json[index]["cid"]);
if(!channel) {
log.error(LogCategory.NETWORKING, tr("Invalid channel onDelete (Unknown channel)"));
@ -400,7 +400,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
log.info(LogCategory.NETWORKING, tr("Got %d channel hides"), json.length);
for(let index = 0; index < json.length; index++) {
conversations.delete_conversation(parseInt(json[index]["cid"]));
conversations.destroyConversation(parseInt(json[index]["cid"]));
let channel = tree.findChannel(json[index]["cid"]);
if(!channel) {
log.error(LogCategory.NETWORKING, tr("Invalid channel on hide (Unknown channel)"));
@ -516,7 +516,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
this.connection_handler.update_voice_status();
this.connection_handler.side_bar.info_frame().update_channel_talk();
const conversations = this.connection.client.side_bar.channel_conversations();
conversations.set_current_channel(client.currentChannel().channelId);
conversations.setSelectedConversation(client.currentChannel().channelId);
}
}
}
@ -654,18 +654,6 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
const side_bar = this.connection_handler.side_bar;
side_bar.info_frame().update_channel_talk();
const conversation_to = side_bar.channel_conversations().conversation(channel_to.channelId, false);
if(conversation_to)
conversation_to.update_private_state();
if(channel_from) {
const conversation_from = side_bar.channel_conversations().conversation(channel_from.channelId, false);
if(conversation_from)
conversation_from.update_private_state();
}
side_bar.channel_conversations().update_chat_box();
}
const own_channel = this.connection.client.getClient().currentChannel();
@ -821,7 +809,6 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
const invoker = this.connection_handler.channelTree.findClient(parseInt(json["invokerid"]));
const own_channel_id = this.connection.client.getClient().currentChannel().channelId;
const channel_id = typeof(json["cid"]) !== "undefined" ? parseInt(json["cid"]) : own_channel_id;
const channel = this.connection_handler.channelTree.findChannel(channel_id) || this.connection_handler.getClient().currentChannel();
if(json["invokerid"] == this.connection.client.getClientId())
this.connection_handler.sound.play(Sound.MESSAGE_SEND, {default_volume: .5});
@ -829,18 +816,20 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
this.connection_handler.sound.play(Sound.MESSAGE_RECEIVED, {default_volume: .5});
}
if(!(invoker instanceof LocalClientEntry))
this.connection_handler.channelTree.findChannel(channel_id)?.setUnread(true);
const conversations = this.connection_handler.side_bar.channel_conversations();
const conversation = conversations.conversation(channel_id);
conversation.register_new_message({
conversations.findOrCreateConversation(channel_id).handleIncomingMessage({
sender_database_id: invoker ? invoker.properties.client_database_id : 0,
sender_name: json["invokername"],
sender_unique_id: json["invokeruid"],
timestamp: typeof(json["timestamp"]) === "undefined" ? Date.now() : parseInt(json["timestamp"]),
message: json["msg"]
});
if(conversation.is_unread() && channel)
channel.setUnread(true);
message: json["msg"],
unique_id: Date.now() + " - " + Math.random()
}, !(invoker instanceof LocalClientEntry));
} else if(mode == 3) {
this.connection_handler.log.log(server_log.Type.GLOBAL_MESSAGE, {
message: json["msg"],
@ -853,16 +842,20 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
const invoker = this.connection_handler.channelTree.findClient(parseInt(json["invokerid"]));
const conversations = this.connection_handler.side_bar.channel_conversations();
const conversation = conversations.conversation(0);
conversation.register_new_message({
if(!(invoker instanceof LocalClientEntry))
this.connection_handler.channelTree.server.setUnread(true);
conversations.findOrCreateConversation(0).handleIncomingMessage({
sender_database_id: invoker ? invoker.properties.client_database_id : 0,
sender_name: json["invokername"],
sender_unique_id: json["invokeruid"],
timestamp: typeof(json["timestamp"]) === "undefined" ? Date.now() : parseInt(json["timestamp"]),
message: json["msg"]
});
this.connection_handler.channelTree.server.setUnread(conversation.is_unread());
message: json["msg"],
unique_id: Date.now() + " - " + Math.random()
}, !(invoker instanceof LocalClientEntry));
}
}
@ -1048,43 +1041,21 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
}
}
handleNotifyConversationHistory(json: any[]) {
const conversations = this.connection.client.side_bar.channel_conversations();
const conversation = conversations.conversation(parseInt(json[0]["cid"]));
if(!conversation) {
log.warn(LogCategory.NETWORKING, tr("Received conversation history for invalid or unknown conversation (%o)"), json[0]["cid"]);
return;
}
for(const entry of json) {
conversation.register_new_message({
message: entry["msg"],
sender_unique_id: entry["sender_unique_id"],
sender_name: entry["sender_name"],
timestamp: parseInt(entry["timestamp"]),
sender_database_id: parseInt(entry["sender_database_id"])
}, false);
}
/* now update the boxes */
/* No update needed because the command which triggers this notify should update the chat box on success
conversation.fix_scroll(true);
conversation.handle.update_chat_box();
*/
}
/*
handleNotifyConversationMessageDelete(json: any[]) {
let conversation: Conversation;
const conversations = this.connection.client.side_bar.channel_conversations();
for(const entry of json) {
if(typeof(entry["cid"]) !== "undefined")
conversation = conversations.conversation(parseInt(entry["cid"]), false);
if(!conversation)
continue;
conversation.delete_messages(parseInt(entry["timestamp_begin"]), parseInt(entry["timestamp_end"]), parseInt(entry["cldbid"]), parseInt(entry["limit"]));
}
}
*/
handleNotifyMusicStatusUpdate(json: any[]) {
json = json[0];

View File

@ -1,34 +1,78 @@
import * as log from "tc-shared/log";
import {LogCategory} from "tc-shared/log";
import {ClientEntry} from "tc-shared/ui/client";
import * as hex from "tc-shared/crypto/hex";
import {image_type, ImageCache, ImageType, media_image_type} from "tc-shared/file/ImageCache";
import {image_type, ImageCache, media_image_type} from "tc-shared/file/ImageCache";
import {FileManager} from "tc-shared/file/FileManager";
import {
FileDownloadTransfer,
FileTransferState,
ResponseTransferTarget, TransferProvider,
ResponseTransferTarget,
TransferProvider,
TransferTargetType
} from "tc-shared/file/Transfer";
import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration";
import {tra} from "tc-shared/i18n/localize";
import {server_connections} from "tc-shared/ui/frames/connection_handlers";
import {icon_cache_loader} from "tc-shared/file/Icons";
import {Registry} from "tc-shared/events";
import {ClientEntry} from "tc-shared/ui/client";
export class Avatar {
client_avatar_id: string; /* the base64 uid thing from a-m */
avatar_id: string; /* client_flag_avatar */
url: string;
type: ImageType;
/* FIXME: Retry avatar download after some time! */
const DefaultAvatarImage = "img/style/avatar.png";
export type AvatarState = "unset" | "loading" | "errored" | "loaded";
interface AvatarEvents {
avatar_changed: {},
avatar_state_changed: { oldState: AvatarState, newState: AvatarState }
}
export class ClientAvatar {
readonly events: Registry<AvatarEvents>;
readonly clientAvatarId: string; /* the base64 unique id thing from a-m */
currentAvatarHash: string | "unknown"; /* the client avatars flag */
state: AvatarState = "loading";
/* only set when state is unset, loaded or errored */
avatarUrl?: string;
loadError?: string;
loadingTimestamp: number = 0;
constructor(client_avatar_id: string) {
this.clientAvatarId = client_avatar_id;
this.events = new Registry<AvatarEvents>();
}
setState(state: AvatarState) {
if(state === this.state)
return;
const oldState = this.state;
this.state = state;
this.events.fire("avatar_state_changed", { newState: state, oldState: oldState });
}
async awaitLoaded() {
if(this.state !== "loading")
return;
await new Promise(resolve => this.events.on("avatar_state_changed", event => event.newState !== "loading" && resolve()));
}
destroyUrl() {
URL.revokeObjectURL(this.avatarUrl);
this.avatarUrl = DefaultAvatarImage;
}
}
export class AvatarManager {
handle: FileManager;
private static cache: ImageCache;
private _cached_avatars: {[response_avatar_id:number]:Avatar} = {};
private _loading_promises: {[response_avatar_id:number]:Promise<any>} = {};
private cachedAvatars: {[avatarId: string]: ClientAvatar} = {};
constructor(handle: FileManager) {
this.handle = handle;
@ -37,48 +81,8 @@ export class AvatarManager {
}
destroy() {
this._cached_avatars = undefined;
this._loading_promises = undefined;
}
private async _response_url(response: Response, type: ImageType) : Promise<string> {
if(!response.headers.has('X-media-bytes'))
throw "missing media bytes";
const media = media_image_type(type);
const blob = await response.blob();
if(blob.type !== "image/" + media)
return URL.createObjectURL(blob.slice(0, blob.size, "image/" + media));
else
return URL.createObjectURL(blob);
}
async resolved_cached?(client_avatar_id: string, avatar_version?: string) : Promise<Avatar> {
let cachedAvatar: Avatar = this._cached_avatars[avatar_version];
if(cachedAvatar) {
if(typeof(avatar_version) !== "string" || cachedAvatar.avatar_id == avatar_version)
return cachedAvatar;
delete this._cached_avatars[avatar_version];
}
if(!AvatarManager.cache.setupped())
await AvatarManager.cache.setup();
const response = await AvatarManager.cache.resolve_cached('avatar_' + client_avatar_id); //TODO age!
if(!response)
return undefined;
let response_avatar_version = response.headers.has("X-avatar-version") ? response.headers.get("X-avatar-version") : undefined;
if(typeof(avatar_version) === "string" && response_avatar_version != avatar_version)
return undefined;
const type = image_type(response.headers.get('X-media-bytes'));
return this._cached_avatars[client_avatar_id] = {
client_avatar_id: client_avatar_id,
avatar_id: avatar_version || response_avatar_version,
url: await this._response_url(response, type),
type: type
};
Object.values(this.cachedAvatars).forEach(e => e.destroyUrl());
this.cachedAvatars = {};
}
create_avatar_download(client_avatar_id: string) : FileDownloadTransfer {
@ -91,9 +95,48 @@ export class AvatarManager {
});
}
private async _load_avatar(client_avatar_id: string, avatar_version: string) {
try {
let transfer = this.create_avatar_download(client_avatar_id);
private async executeAvatarLoad0(avatar: ClientAvatar) {
if(avatar.currentAvatarHash === "") {
avatar.destroyUrl();
avatar.setState("unset");
return;
}
const initialAvatarHash = avatar.currentAvatarHash;
let avatarResponse: Response;
/* try to lookup our cache for the avatar */
cache_lookup: {
if(!AvatarManager.cache.setupped()) {
await AvatarManager.cache.setup();
}
const response = await AvatarManager.cache.resolve_cached('avatar_' + avatar.clientAvatarId); //TODO age!
if(!response) {
break cache_lookup;
}
let cachedAvatarHash = response.headers.has("X-avatar-version") ? response.headers.get("X-avatar-version") : undefined;
if(avatar.currentAvatarHash !== "unknown") {
if(cachedAvatarHash === undefined) {
log.debug(LogCategory.FILE_TRANSFER, tr("Invalidating cached avatar for %s (Version miss match. Cached: unset, Current: %s)"), avatar.clientAvatarId, avatar.currentAvatarHash);
await AvatarManager.cache.delete('avatar_' + avatar.clientAvatarId);
break cache_lookup;
} else if(cachedAvatarHash !== avatar.currentAvatarHash) {
log.debug(LogCategory.FILE_TRANSFER, tr("Invalidating cached avatar for %s (Version miss match. Cached: %s, Current: %s)"), avatar.clientAvatarId, cachedAvatarHash, avatar.currentAvatarHash);
await AvatarManager.cache.delete('avatar_' + avatar.clientAvatarId);
break cache_lookup;
}
} else if(cachedAvatarHash) {
avatar.currentAvatarHash = cachedAvatarHash;
}
avatarResponse = response;
}
/* load the avatar from the server */
if(!avatarResponse) {
let transfer = this.create_avatar_download(avatar.clientAvatarId);
try {
await transfer.awaitFinished();
@ -102,27 +145,32 @@ export class AvatarManager {
throw tr("download canceled");
} else if(transfer.transferState() === FileTransferState.ERRORED) {
throw transfer.currentError();
} else if(transfer.transferState() === FileTransferState.FINISHED) {
} else {
throw tr("Unknown transfer finished state");
} else if(transfer.transferState() !== FileTransferState.FINISHED) {
throw tr("unknown transfer finished state");
}
} catch(error) {
if(typeof error === "object" && 'error' in error && error.error === "initialize") {
const commandResult = error.commandResult;
if(commandResult instanceof CommandResult) {
if(commandResult.id === ErrorID.FILE_NOT_FOUND)
throw tr("Avatar could not be found");
else if(commandResult.id === ErrorID.PERMISSION_ERROR)
throw tr("No permissions to download avatar");
else
if(commandResult.id === ErrorID.FILE_NOT_FOUND) {
if(avatar.currentAvatarHash !== initialAvatarHash)
return;
avatar.destroyUrl();
avatar.setState("unset");
return;
} else if(commandResult.id === ErrorID.PERMISSION_ERROR) {
throw tr("No permissions to download the avatar");
} else {
throw commandResult.message + (commandResult.extra_message ? " (" + commandResult.extra_message + ")" : "");
}
}
}
log.error(LogCategory.CLIENT, tr("Could not request download for avatar %s: %o"), client_avatar_id, error);
log.error(LogCategory.CLIENT, tr("Could not request download for avatar %s: %o"), avatar.clientAvatarId, error);
if(error === transfer.currentError())
throw transfer.currentErrorMessage();
throw typeof error === "string" ? error : tr("Avatar download failed");
}
@ -130,123 +178,196 @@ export class AvatarManager {
if(transfer.target.type !== TransferTargetType.RESPONSE)
throw "unsupported transfer target";
const response = transfer.target as ResponseTransferTarget;
if(!response.hasResponse())
throw tr("Transfer has no response");
const transferResponse = transfer.target as ResponseTransferTarget;
if(!transferResponse.hasResponse()) {
throw tr("Avatar transfer has no response");
}
const type = image_type(response.getResponse().headers.get('X-media-bytes'));
const headers = transferResponse.getResponse().headers;
if(!headers.has("X-media-bytes")) {
throw tr("Avatar response missing media bytes");
}
const type = image_type(headers.get('X-media-bytes'));
const media = media_image_type(type);
await AvatarManager.cache.put_cache('avatar_' + client_avatar_id, response.getResponse().clone(), "image/" + media, {
"X-avatar-version": avatar_version
});
const url = await this._response_url(response.getResponse().clone(), type);
if(avatar.currentAvatarHash !== initialAvatarHash)
return;
return this._cached_avatars[client_avatar_id] = {
client_avatar_id: client_avatar_id,
avatar_id: avatar_version,
url: url,
type: type
await AvatarManager.cache.put_cache('avatar_' + avatar.clientAvatarId, transferResponse.getResponse().clone(), "image/" + media, {
"X-avatar-version": avatar.currentAvatarHash
});
avatarResponse = transferResponse.getResponse();
}
if(!avatarResponse) {
throw tr("Missing avatar response");
}
/* get an url from the response */
{
if(!avatarResponse.headers.has('X-media-bytes'))
throw "missing media bytes";
const type = image_type(avatarResponse.headers.get('X-media-bytes'));
const media = media_image_type(type);
const blob = await avatarResponse.blob();
/* ensure we're still up to date */
if(avatar.currentAvatarHash !== initialAvatarHash)
return;
avatar.destroyUrl();
if(blob.type !== "image/" + media) {
avatar.avatarUrl = URL.createObjectURL(blob.slice(0, blob.size, "image/" + media));
} else {
avatar.avatarUrl = URL.createObjectURL(blob);
}
avatar.setState("loaded");
}
}
private executeAvatarLoad(avatar: ClientAvatar) {
const avatar_hash = avatar.currentAvatarHash;
avatar.setState("loading");
avatar.loadingTimestamp = Date.now();
this.executeAvatarLoad0(avatar).catch(error => {
if(avatar.currentAvatarHash !== avatar_hash)
return;
if(typeof error === "string") {
avatar.loadError = error;
} else if(error instanceof Error) {
avatar.loadError = error.message;
} else {
log.error(LogCategory.FILE_TRANSFER, tr("Failed to load avatar %s (hash: %s): %o"), avatar.clientAvatarId, avatar_hash, error);
avatar.loadError = tr("lookup the console");
}
avatar.destroyUrl(); /* if there were any image previously */
avatar.setState("errored");
});
}
update_cache(clientAvatarId: string, clientAvatarHash: string) {
AvatarManager.cache.setup().then(() => {
const deletePromise = AvatarManager.cache.delete("avatar_" + clientAvatarId).catch(error => {
log.warn(LogCategory.FILE_TRANSFER, tr("Failed to delete avatar %s: %o"), clientAvatarId, error);
});
const cached = this.cachedAvatars[clientAvatarId];
if(!cached || cached.currentAvatarHash === clientAvatarHash) return;
log.info(LogCategory.GENERAL, tr("Deleting cached avatar for client %s. Cached version: %s; New version: %s"), cached.currentAvatarHash, clientAvatarHash);
deletePromise.then(() => {
cached.currentAvatarHash = clientAvatarHash;
cached.events.fire("avatar_changed");
this.executeAvatarLoad(cached);
});
});
}
resolveAvatar(clientAvatarId: string, avatarHash?: string, cacheOnly?: boolean) {
let avatar = this.cachedAvatars[clientAvatarId];
if(!avatar) {
if(cacheOnly)
return undefined;
avatar = new ClientAvatar(clientAvatarId);
this.cachedAvatars[clientAvatarId] = avatar;
} else if(typeof avatarHash !== "string" || avatar.currentAvatarHash === avatarHash) {
return avatar;
}
avatar.currentAvatarHash = typeof avatarHash === "string" ? avatarHash : "unknown";
this.executeAvatarLoad(avatar);
return avatar;
}
resolveClientAvatar(client: { id?: number, database_id?: number, clientUniqueId: string }) {
let clientHandle: ClientEntry;
if(typeof client.id === "number") {
clientHandle = this.handle.connectionHandler.channelTree.findClient(client.id);
if(clientHandle?.properties.client_unique_identifier !== client.clientUniqueId)
clientHandle = undefined;
}
if(!clientHandle && typeof client.database_id === "number") {
clientHandle = this.handle.connectionHandler.channelTree.find_client_by_dbid(client.database_id);
if(clientHandle?.properties.client_unique_identifier !== client.clientUniqueId)
clientHandle = undefined;
}
return this.resolveAvatar(uniqueId2AvatarId(client.clientUniqueId), clientHandle?.properties.client_flag_avatar);
}
private generate_default_image() : JQuery {
return $.spawn("img").attr("src", "img/style/avatar.png").css({width: '100%', height: '100%'});
}
generate_chat_tag(client: { id?: number; database_id?: number; }, client_unique_id: string, callback_loaded?: (successfully: boolean, error?: any) => any) : JQuery {
let client_handle;
if(typeof(client.id) == "number") {
client_handle = this.handle.connectionHandler.channelTree.findClient(client.id);
}
if(!client_handle && typeof(client.id) == "number") {
client_handle = this.handle.connectionHandler.channelTree.find_client_by_dbid(client.database_id);
}
if(client_handle && client_handle.clientUid() !== client_unique_id) {
client_handle = undefined;
}
const container = $.spawn("div").addClass("avatar");
if(client_handle && !client_handle.properties.client_flag_avatar)
return container.append(this.generate_default_image());
const clientAvatarId = client_handle ? client_handle.avatarId() : uniqueId2AvatarId(client_unique_id);
if(clientAvatarId) {
const avatar = this.resolveAvatar(clientAvatarId, client_handle?.properties.client_flag_avatar);
const updateJQueryTag = () => {
const image = $.spawn("img").attr("src", avatar.avatarUrl).css({width: '100%', height: '100%'});
container.append(image);
};
} finally {
this._loading_promises[client_avatar_id] = undefined;
}
if(avatar.state !== "loading") {
/* Test if we're may able to load the client avatar sync without a loading screen */
updateJQueryTag();
return container;
}
/* loads an avatar by the avatar id and optional with the avatar version */
load_avatar(client_avatar_id: string, avatar_version: string) : Promise<Avatar> {
return this._loading_promises[client_avatar_id] || (this._loading_promises[client_avatar_id] = this._load_avatar(client_avatar_id, avatar_version));
}
const image_loading = $.spawn("img").attr("src", "img/loading_image.svg").css({width: '100%', height: '100%'});
generate_client_tag(client: ClientEntry) : JQuery {
return this.generate_tag(client.avatarId(), client.properties.client_flag_avatar);
}
update_cache(client_avatar_id: string, avatar_id: string) {
const _cached: Avatar = this._cached_avatars[client_avatar_id];
if(_cached) {
if(_cached.avatar_id === avatar_id)
return; /* cache is up2date */
log.info(LogCategory.GENERAL, tr("Deleting cached avatar for client %s. Cached version: %s; New version: %s"), client_avatar_id, _cached.avatar_id, avatar_id);
delete this._cached_avatars[client_avatar_id];
AvatarManager.cache.delete("avatar_" + client_avatar_id).catch(error => {
log.error(LogCategory.GENERAL, tr("Failed to delete cached avatar for client %o: %o"), client_avatar_id, error);
});
/* lets actually load the avatar */
avatar.awaitLoaded().then(updateJQueryTag);
image_loading.appendTo(container);
} else {
this.resolved_cached(client_avatar_id).then(avatar => {
if(avatar && avatar.avatar_id !== avatar_id) {
/* this time we ensured that its cached */
this.update_cache(client_avatar_id, avatar_id);
this.generate_default_image().appendTo(container);
}
}).catch(error => {
log.error(LogCategory.GENERAL, tr("Failed to delete cached avatar for client %o (cache lookup failed): %o"), client_avatar_id, error);
return container;
}
flush_cache() {
this.destroy();
}
}
(window as any).flush_avatar_cache = async () => {
server_connections.all_connections().forEach(e => {
e.fileManager.avatars.flush_cache();
});
}
}
};
generate_tag(client_avatar_id: string, avatar_id?: string, options?: {
callback_image?: (tag: JQuery<HTMLImageElement>) => any,
callback_avatar?: (avatar: Avatar) => any
}) : JQuery {
options = options || {};
let avatar_container = $.spawn("div");
let avatar_image = $.spawn("img").attr("alt", tr("Client avatar"));
let cached_avatar: Avatar = this._cached_avatars[client_avatar_id];
if(avatar_id === "") {
avatar_container.append(this.generate_default_image());
} else if(cached_avatar && cached_avatar.avatar_id == avatar_id) {
avatar_image.attr("src", cached_avatar.url);
avatar_container.append(avatar_image);
if(options.callback_image)
options.callback_image(avatar_image);
if(options.callback_avatar)
options.callback_avatar(cached_avatar);
} else {
let loader_image = $.spawn("img");
loader_image.attr("src", "img/loading_image.svg").css("width", "75%");
avatar_container.append(loader_image);
(async () => {
let avatar: Avatar;
try {
avatar = await this.resolved_cached(client_avatar_id, avatar_id);
} catch(error) {
log.error(LogCategory.CLIENT, error);
}
if(!avatar)
avatar = await this.load_avatar(client_avatar_id, avatar_id);
if(!avatar)
throw "failed to load avatar";
if(options.callback_avatar)
options.callback_avatar(avatar);
avatar_image.attr("src", avatar.url);
avatar_image.css("opacity", 0);
avatar_container.append(avatar_image);
loader_image.animate({opacity: 0}, 50, () => {
loader_image.remove();
avatar_image.animate({opacity: 1}, 150, () => {
if(options.callback_image)
options.callback_image(avatar_image);
});
});
})().catch(reason => {
log.error(LogCategory.CLIENT, tr("Could not load avatar for id %s. Reason: %s"), client_avatar_id, reason);
//TODO Broken image
loader_image.addClass("icon client-warning").attr("tag", tr("Could not load avatar ") + client_avatar_id);
})
}
return avatar_container;
}
unique_id_2_avatar_id(unique_id: string) {
export function uniqueId2AvatarId(unique_id: string) {
function str2ab(str) {
let buf = new ArrayBuffer(str.length); // 2 bytes for each char
let bufView = new Uint8Array(buf);
@ -277,87 +398,3 @@ export class AvatarManager {
return undefined;
}
}
private generate_default_image() : JQuery {
return $.spawn("img").attr("src", "img/style/avatar.png").css({width: '100%', height: '100%'});
}
generate_chat_tag(client: { id?: number; database_id?: number; }, client_unique_id: string, callback_loaded?: (successfully: boolean, error?: any) => any) : JQuery {
let client_handle;
if(typeof(client.id) == "number")
client_handle = this.handle.connectionHandler.channelTree.findClient(client.id);
if(!client_handle && typeof(client.id) == "number") {
client_handle = this.handle.connectionHandler.channelTree.find_client_by_dbid(client.database_id);
}
if(client_handle && client_handle.clientUid() !== client_unique_id)
client_handle = undefined;
const container = $.spawn("div").addClass("avatar");
if(client_handle && !client_handle.properties.client_flag_avatar)
return container.append(this.generate_default_image());
const avatar_id = client_handle ? client_handle.avatarId() : this.unique_id_2_avatar_id(client_unique_id);
if(avatar_id) {
if(this._cached_avatars[avatar_id]) { /* Test if we're may able to load the client avatar sync without a loading screen */
const cache: Avatar = this._cached_avatars[avatar_id];
log.debug(LogCategory.GENERAL, tr("Using cached avatar. ID: %o | Version: %o (Cached: %o)"), avatar_id, client_handle ? client_handle.properties.client_flag_avatar : undefined, cache.avatar_id);
if(!client_handle || client_handle.properties.client_flag_avatar == cache.avatar_id) {
const image = $.spawn("img").attr("src", cache.url).css({width: '100%', height: '100%'});
return container.append(image);
}
}
const image_loading = $.spawn("img").attr("src", "img/loading_image.svg").css({width: '100%', height: '100%'});
/* lets actually load the avatar */
(async () => {
let avatar: Avatar;
let loaded_image = this.generate_default_image();
log.debug(LogCategory.GENERAL, tr("Resolving avatar. ID: %o | Version: %o"), avatar_id, client_handle ? client_handle.properties.client_flag_avatar : undefined);
try {
//TODO: Cache if avatar load failed and try again in some minutes/may just even consider using the default avatar 'till restart
try {
avatar = await this.resolved_cached(avatar_id, client_handle ? client_handle.properties.client_flag_avatar : undefined);
} catch(error) {
log.error(LogCategory.GENERAL, tr("Failed to use cached avatar: %o"), error);
}
if(!avatar)
avatar = await this.load_avatar(avatar_id, client_handle ? client_handle.properties.client_flag_avatar : undefined);
if(!avatar)
throw "no avatar present!";
loaded_image = $.spawn("img").attr("src", avatar.url).css({width: '100%', height: '100%'});
} catch(error) {
throw error;
} finally {
container.children().remove();
container.append(loaded_image);
}
})().then(() => callback_loaded && callback_loaded(true)).catch(error => {
log.warn(LogCategory.CLIENT, tr("Failed to load chat avatar for client %s. Error: %o"), client_unique_id, error);
callback_loaded && callback_loaded(false, error);
});
image_loading.appendTo(container);
} else {
this.generate_default_image().appendTo(container);
}
return container;
}
flush_cache() {
this._cached_avatars = undefined;
this._loading_promises = undefined;
}
}
(window as any).flush_avatar_cache = async () => {
server_connections.all_connections().forEach(e => {
e.fileManager.avatars.flush_cache();
});
};

View File

@ -86,6 +86,9 @@ export class ImageCache {
if(!window.caches)
throw "Missing caches!";
if(this._cache_category)
return;
this._cache_category = await caches.open(this.cache_name);
}

View File

@ -1,4 +1,3 @@
//Used by CertAccept popup
import {settings} from "tc-shared/settings";
import * as loader from "tc-loader";
@ -41,7 +40,7 @@ let category_mapping = new Map<number, string>([
[LogCategory.NETWORKING, "Network "],
[LogCategory.VOICE, "Voice "],
[LogCategory.AUDIO, "Audio "],
[LogCategory.CHANNEL, "Chat "],
[LogCategory.CHAT, "Chat "],
[LogCategory.I18N, "I18N "],
[LogCategory.IDENTITIES, "Identities "],
[LogCategory.IPC, "IPC "],

View File

@ -1,5 +1,3 @@
//Used by CertAccept popup
import {createErrorModal} from "tc-shared/ui/elements/Modal";
import {LogCategory} from "tc-shared/log";
import * as loader from "tc-loader";
@ -395,6 +393,12 @@ export class Settings extends StaticSettings {
}
};
static readonly FN_CHANNEL_CHAT_READ: (id: number) => SettingsKey<number> = id => {
return {
key: 'channel_chat_read_' + id
}
};
static readonly KEYS = (() => {
const result = [];

View File

@ -355,7 +355,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
if(!singleSelect) return;
if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) {
this.channelTree.client.side_bar.channel_conversations().set_current_channel(this.channelId);
this.channelTree.client.side_bar.channel_conversations().setSelectedConversation(this.channelId);
this.channelTree.client.side_bar.show_channel_conversations();
}
}
@ -419,7 +419,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
icon_class: "client-channel_switch",
name: bold(tr("Join text channel")),
callback: () => {
this.channelTree.client.side_bar.channel_conversations().set_current_channel(this.getChannelId());
this.channelTree.client.side_bar.channel_conversations().setSelectedConversation(this.getChannelId());
this.channelTree.client.side_bar.show_channel_conversations();
},
visible: !settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)
@ -595,8 +595,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
this.channelTree.moveChannel(this, order, this.parent);
} else if(key === "channel_icon_id") {
this.properties.channel_icon_id = variable.value as any >>> 0; /* unsigned 32 bit number! */
}
else if(key == "channel_description") {
} else if(key == "channel_description") {
this._cached_channel_description = undefined;
if(this._cached_channel_description_promise_resolve)
this._cached_channel_description_promise_resolve(value);
@ -604,12 +603,6 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
this._cached_channel_description_promise_resolve = undefined;
this._cached_channel_description_promise_reject = undefined;
}
if(key == "channel_flag_conversation_private") {
const conversations = this.channelTree.client.side_bar.channel_conversations();
const conversation = conversations.conversation(this.channelId, false);
if(conversation)
conversation.set_flag_private(this.properties.channel_flag_conversation_private);
}
}
/* devel-block(log-channel-property-updates) */
group.end();

View File

@ -8,7 +8,7 @@ import {formatMessage} from "tc-shared/ui/frames/chat";
import {PrivateConverations} from "tc-shared/ui/frames/side/private_conversations";
import {ClientInfo} from "tc-shared/ui/frames/side/client_info";
import {MusicInfo} from "tc-shared/ui/frames/side/music_info";
import {ConversationManager} from "tc-shared/ui/frames/side/conversations";
import {ConversationManager} from "tc-shared/ui/frames/side/ConversationManager";
declare function setInterval(handler: TimerHandler, timeout?: number, ...arguments: any[]): number;
declare function setTimeout(handler: TimerHandler, timeout?: number, ...arguments: any[]): number;
@ -145,7 +145,7 @@ export class InfoFrame {
update_channel_text() {
const channel_tree = this.handle.handle.connected ? this.handle.handle.channelTree : undefined;
const current_channel_id = channel_tree ? this.handle.channel_conversations().current_channel() : 0;
const current_channel_id = channel_tree ? this.handle.channel_conversations().selectedConversation() : 0;
const channel = channel_tree ? channel_tree.findChannel(current_channel_id) : undefined;
this._channel_text = channel;
@ -292,7 +292,7 @@ export class Frame {
this._content_type = FrameContent.NONE;
this._info_frame = new InfoFrame(this);
this._conversations = new PrivateConverations(this);
this._channel_conversations = new ConversationManager(this);
this._channel_conversations = new ConversationManager(handle);
this._client_info = new ClientInfo(this);
this._music_info = new MusicInfo(this);
@ -322,8 +322,8 @@ export class Frame {
this._music_info && this._music_info.destroy();
this._music_info = undefined;
this._channel_conversations && this._channel_conversations.destroy();
this._channel_conversations = undefined;
//this._channel_conversations && this._channel_conversations.destroy();
//this._channel_conversations = undefined;
this._container_info && this._container_info.remove();
this._container_info = undefined;
@ -378,8 +378,9 @@ export class Frame {
this._clear();
this._content_type = FrameContent.CHANNEL_CHAT;
this._container_chat.append(this._channel_conversations.html_tag());
this._channel_conversations.on_show();
this._container_chat.append(this._channel_conversations.htmlTag);
this._channel_conversations.handlePanelShow();
this._info_frame.set_mode(InfoFrameMode.CHANNEL_CHAT);
}

View File

@ -0,0 +1,210 @@
@import "../../../../css/static/mixin";
@import "../../../../css/static/properties";
.container {
display: flex;
flex-direction: column;
justify-content: flex-start;
flex-shrink: 0;
flex-grow: 0;
.chatbox {
min-height: 2em;
display: flex;
flex-direction: row;
justify-content: stretch;
padding-left: .25em;
padding-right: .25em;
}
}
.containerEmojis {
display: flex;
flex-direction: column;
justify-content: flex-end;
margin-right: 5px;
width: 2em;
position: relative;
box-sizing: content-box;
.button {
padding: 2px;
cursor: pointer;
display: flex;
flex-direction: row;
justify-content: space-around;
border-radius: .25em;
&:hover {
background-color: #454545;
}
@include transition(background-color $button_hover_animation_time ease-in-out);
width: 1.5em;
height: 1.5em;
margin-bottom: .2em;
align-self: center;
> img {
height: 100%;
width: 100%;
}
}
.picker {
position: absolute;
bottom: 100%;
align-self: center;
span, button {
cursor: pointer;
}
}
:global(.lsx-emojipicker-appender) {
position: relative;
width: calc(1.5em - 4px);
height: calc(1.5em - 4px);
.lsx-emojipicker-container {
&:after {
right: calc(0.75em + 2px) !important
}
.lsx-emojipicker-tabs {
height: 38px;
display: flex;
flex-direction: row;
justify-content: flex-start;
li {
height: 36px;
width: 36px;
display: flex;
flex-direction: column;
justify-content: space-around;
img {
margin: 0;
}
}
}
}
}
}
.containerInput {
display: flex;
flex-direction: column;
justify-content: stretch;
min-height: 1.5em;
align-self: center;
box-sizing: content-box;
width: 100%;
background-color: #464646;
border: 2px solid #353535; /* background color (like no border) */
overflow: hidden;
border-radius: 5px;
.textarea {
@include user-select(text);
flex-shrink: 1;
flex-grow: 0;
width: 100%;
resize: none;
min-height: 1.5em;
max-height: 6em;
overflow: auto;
background-color: transparent;
padding-left: 5px;
padding-right: 5px;
margin: 0;
border: none;
outline: none;
color: #a9a9a9;
@include chat-scrollbar-vertical();
&:empty::before {
content: attr(placeholder);
color: #5e5e5e;
}
&:empty:focus::before {
content: "";
}
}
&.disabled {
.textarea {
background-color: #3d3d3d;
border-width: 0;
&:empty::before {
color: #4d4d4d;
}
}
pointer-events: none;
}
@include placeholder(textarea) {
color: #363535;
font-style: oblique;
}
&:hover {
border-color: #474747;
}
&:focus-within {
border-color: #585858;
}
@include transition(border-color $button_hover_animation_time ease-in-out);
}
.containerHelp {
flex-shrink: 0;
flex-grow: 0;
min-height: unset;
height: initial;
color: #555555;
font-size: .8em;
text-align: right;
margin: -3px 2px 2px 2.5em;
@include text-dotdotdot();
@include transition($button_hover_animation_time ease-in-out);
max-height: 2em; /* for a smooth transition */
&.hidden {
max-height: 0;
}
}

View File

@ -0,0 +1,299 @@
import * as React from "react";
import {useEffect, useRef, useState} from "react";
import {Registry} from "tc-shared/events";
import '!style-loader!css-loader!emoji-mart/css/emoji-mart.css'
import { Picker } from 'emoji-mart'
const cssStyle = require("./ChatBox.scss");
interface ChatBoxEvents {
action_set_enabled: { enabled: boolean },
action_request_focus: {},
action_submit_message: {
message: string
},
action_insert_text: {
text: string
},
notify_typing: {}
}
const EmojiButton = (props: { events: Registry<ChatBoxEvents> }) => {
const [ shown, setShown ] = useState(false);
const [ enabled, setEnabled ] = useState(false);
const refContainer = useRef();
useEffect(() => {
if(!shown)
return;
const clickListener = (event: MouseEvent) => {
let target = event.target as HTMLElement;
while(target && target !== refContainer.current)
target = target.parentElement;
if(target === refContainer.current && target)
return;
setShown(false);
};
document.addEventListener("click", clickListener);
return () => document.removeEventListener("click", clickListener);
});
props.events.reactUse("action_set_enabled", event => setEnabled(event.enabled));
return (
<div className={cssStyle.containerEmojis} ref={refContainer}>
<div className={cssStyle.button} onClick={() => enabled && setShown(true)}>
<img alt={""} src={"img/smiley-smile.svg"} />
</div>
<div className={cssStyle.picker} style={{ display: shown ? undefined : "none" }}>
<Picker
set={"twitter"}
theme={"light"}
showPreview={true}
title={""}
showSkinTones={true}
useButton={false}
native={false}
onSelect={(emoji: any) => {
if(enabled) {
props.events.fire("action_insert_text", { text: emoji.native });
}
}}
/>
</div>
</div>
);
};
const pasteTextTransformElement = document.createElement("div");
const nodeToText = (element: Node) => {
if(element instanceof Text) {
return element.textContent;
} else if(element instanceof HTMLElement) {
if(element instanceof HTMLImageElement) {
return element.alt || element.title;
} else if(element instanceof HTMLBRElement) {
return '\n';
}
if(element.children.length > 0)
return [...element.childNodes].map(nodeToText).join("");
return typeof(element.innerText) === "string" ? element.innerText : "";
} else {
return "";
}
};
const htmlEscape = (message: string) => {
pasteTextTransformElement.innerText = message;
message = pasteTextTransformElement.innerHTML;
return message.replace(/ /g, '&nbsp;');
};
const TextInput = (props: { events: Registry<ChatBoxEvents>, enabled?: boolean, placeholder?: string }) => {
const [ enabled, setEnabled ] = useState(!!props.enabled);
const [ historyIndex, setHistoryIndex ] = useState(-1);
const history = useRef([]);
const refInput = useRef<HTMLInputElement>();
const typingTimeout = useRef(undefined);
const triggerTyping = () => {
if(typeof typingTimeout.current === "number")
return;
props.events.fire("notify_typing");
};
const setHistory = index => {
setHistoryIndex(index);
refInput.current.innerText = history.current[index] || "";
const range = document.createRange();
range.selectNodeContents(refInput.current);
range.collapse(false);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
};
const pasteHandler = (event: React.ClipboardEvent) => {
triggerTyping();
event.preventDefault();
const clipboard = event.clipboardData || (window as any).clipboardData;
if(!clipboard) return;
const raw_text = clipboard.getData('text/plain');
const selection = window.getSelection();
if (!selection.rangeCount)
return false;
let htmlXML = clipboard.getData('text/html');
if(!htmlXML) {
pasteTextTransformElement.textContent = raw_text;
htmlXML = pasteTextTransformElement.innerHTML;
}
const parser = new DOMParser();
const nodes = parser.parseFromString(htmlXML, "text/html");
let data = nodeToText(nodes.body);
/* fix prefix & suffix new lines */
{
let prefix_length = 0, suffix_length = 0;
{
for (let i = 0; i < raw_text.length; i++)
if (raw_text.charAt(i) === '\n')
prefix_length++;
else if (raw_text.charAt(i) !== '\r')
break;
for (let i = raw_text.length - 1; i >= 0; i++)
if (raw_text.charAt(i) === '\n')
suffix_length++;
else if (raw_text.charAt(i) !== '\r')
break;
}
data = data.replace(/^[\n\r]+|[\n\r]+$/g, '');
data = "\n".repeat(prefix_length) + data + "\n".repeat(suffix_length);
}
event.preventDefault();
selection.deleteFromDocument();
document.execCommand('insertHTML', false, htmlEscape(data));
};
const keyDownHandler = (event: React.KeyboardEvent) => {
triggerTyping();
const inputEmpty = refInput.current.innerText.trim().length === 0;
if(event.key === "Enter" && !event.shiftKey) {
if(inputEmpty)
return;
const text = refInput.current.innerText;
props.events.fire("action_submit_message", { message: text });
history.current.push(text);
while(history.current.length > 10)
history.current.pop_front();
refInput.current.innerText = "";
setHistoryIndex(-1);
event.preventDefault();
} else if(event.key === "ArrowUp") {
const inputOriginal = history.current[historyIndex] === refInput.current.innerText;
if(inputEmpty && (historyIndex === -1 || !inputOriginal)) {
setHistory(history.current.length - 1);
event.preventDefault();
} else if(historyIndex > 0 && inputOriginal) {
setHistory(historyIndex - 1);
event.preventDefault();
}
} else if(event.key === "ArrowDown") {
if(history.current[historyIndex] === refInput.current.innerText) {
if(historyIndex < history.current.length - 1) {
setHistory(historyIndex + 1);
} else {
setHistory(-1);
}
event.preventDefault();
}
}
};
props.events.reactUse("action_request_focus", () => refInput.current?.focus());
props.events.reactUse("notify_typing", () => {
if(typeof typingTimeout.current === "number")
return;
typingTimeout.current = setTimeout(() => typingTimeout.current = undefined, 1000);
});
props.events.reactUse("action_insert_text", event => refInput.current.innerHTML = refInput.current.innerHTML + event.text);
props.events.reactUse("action_set_enabled", event => {
setEnabled(event.enabled);
if(!event.enabled) {
const text = refInput.current.innerText;
if(text.trim().length !== 0)
history.current.push(text);
refInput.current.innerText = "";
}
});
return (
<div className={cssStyle.containerInput + " " + (!enabled ? cssStyle.disabled : "")}>
<div
ref={refInput}
className={cssStyle.textarea}
contentEditable={enabled}
placeholder={enabled ? props.placeholder : tr("You can not write here.")}
onPaste={pasteHandler}
onKeyDown={keyDownHandler}
defaultValue={historyIndex < 0 ? undefined : history[historyIndex]}
/>
</div>
);
};
export interface ChatBoxProperties {
onSubmit?: (text: string) => void;
onType?: () => void;
}
export interface ChatBoxState {
enabled: boolean;
}
export class ChatBox extends React.Component<ChatBoxProperties, ChatBoxState> {
readonly events = new Registry<ChatBoxEvents>();
private callbackSubmit = event => this.props.onSubmit(event.message);
private callbackType = event => this.props.onType && this.props.onType();
constructor(props) {
super(props);
this.state = { enabled: false };
this.events.enable_debug("chat-box");
}
componentDidMount(): void {
this.events.on("action_submit_message", this.callbackSubmit);
this.events.on("notify_typing", this.callbackType);
}
componentWillUnmount(): void {
this.events.off("action_submit_message", this.callbackSubmit);
this.events.off("notify_typing", this.callbackType);
}
render() {
return <div className={cssStyle.container}>
<div className={cssStyle.chatbox}>
<EmojiButton events={this.events} />
<TextInput events={this.events} placeholder={tr("Type your message here...")} />
</div>
<div className={cssStyle.containerHelp}>*italic*, **bold**, ~~strikethrough~~, `code`, and more...</div>
</div>
}
componentDidUpdate(prevProps: Readonly<ChatBoxProperties>, prevState: Readonly<ChatBoxState>, snapshot?: any): void {
if(prevState.enabled !== this.state.enabled)
this.events.fire_async("action_set_enabled", { enabled: this.state.enabled });
}
}

View File

@ -0,0 +1,559 @@
import * as React from "react";
import {ConversationPanel} from "tc-shared/ui/frames/side/Conversations";
import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler";
import {EventHandler, Registry} from "tc-shared/events";
import * as log from "tc-shared/log";
import {LogCategory} from "tc-shared/log";
import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration";
import {ServerCommand} from "tc-shared/connection/ConnectionBase";
import {Settings} from "tc-shared/settings";
import ReactDOM = require("react-dom");
import {traj} from "tc-shared/i18n/localize";
import {createErrorModal} from "tc-shared/ui/elements/Modal";
export type ChatEvent = { timestamp: number; uniqueId: string; } & (ChatEventMessage | ChatEventMessageSendFailed);
export interface ChatMessage {
timestamp: number;
message: string;
sender_name: string;
sender_unique_id: string;
sender_database_id: number;
}
export interface ChatEventMessage {
type: "message";
message: ChatMessage;
}
export interface ChatEventMessageSendFailed {
type: "message-failed";
error: "permission" | "error";
failedPermission?: string;
errorMessage?: string;
}
export interface ConversationUIEvents {
action_select_conversation: { chatId: number },
action_clear_unread_flag: { chatId: number },
action_delete_message: { chatId: number, uniqueId: string },
action_send_message: { text: string, chatId: number },
query_conversation_state: { chatId: number }, /* will cause a notify_conversation_state */
notify_conversation_state: {
id: number,
mode: "normal" | "no-permissions" | "error" | "loading" | "private",
failedPermission?: string,
errorMessage?: string;
unreadTimestamp: number | undefined,
events: ChatEvent[],
haveOlderMessages: boolean,
conversationPrivate: boolean
},
notify_panel_show: {},
notify_local_client_channel: {
channelId: number
},
notify_chat_event: {
conversation: number,
triggerUnread: boolean,
event: ChatEvent
},
notify_chat_message_delete: {
conversation: number,
criteria: { begin: number, end: number, cldbid: number, limit: number }
},
notify_server_state: {
state: "disconnected" | "connected",
crossChannelChatSupport?: boolean
},
notify_unread_timestamp_changed: {
conversation: number,
timestamp: number | undefined
}
notify_channel_private_state_changed: {
id: number,
private: boolean
}
notify_destroy: {}
}
export interface ConversationHistoryResponse {
status: "success" | "error" | "no-permission" | "private";
messages?: ChatMessage[];
moreMessages?: boolean;
errorMessage?: string;
failedPermission?: string;
}
export type ConversationMode = "normal" | "loading" | "no-permissions" | "error" | "unloaded";
export class Conversation {
private readonly handle: ConversationManager;
private readonly events: Registry<ConversationUIEvents>;
public readonly conversationId: number;
private mode: ConversationMode = "unloaded";
private failedPermission: string;
private errorMessage: string;
public presentMessages: ({ uniqueId: string } & ChatMessage)[] = [];
public presentEvents: Exclude<ChatEvent, ChatEventMessage>[] = []; /* everything excluding chat messages */
private unreadTimestamp: number | undefined = undefined;
private lastReadMessage: number = 0;
private conversationPrivate: boolean = false;
private conversationVolatile: boolean = false;
private queryingHistory = false;
private pendingHistoryQueries: (() => Promise<any>)[] = [];
public historyQueryResponse: ChatMessage[] = [];
constructor(handle: ConversationManager, events: Registry<ConversationUIEvents>, id: number) {
this.handle = handle;
this.conversationId = id;
this.events = events;
this.lastReadMessage = handle.connection.settings.server(Settings.FN_CHANNEL_CHAT_READ(id), Date.now());
}
destroy() { }
currentMode() : ConversationMode { return this.mode; }
queryHistory(criteria: { begin?: number, end?: number, limit?: number }) : Promise<ConversationHistoryResponse> {
return new Promise<ConversationHistoryResponse>(resolve => {
this.pendingHistoryQueries.push(() => {
this.historyQueryResponse = [];
return this.handle.connection.serverConnection.send_command("conversationhistory", {
cid: this.conversationId,
timestamp_begin: criteria.begin,
message_count: criteria.limit,
timestamp_end: criteria.end
}, { flagset: [ "merge" ], process_result: false }).then(() => {
resolve({ status: "success", messages: this.historyQueryResponse, moreMessages: false });
}).catch(error => {
let errorMessage;
if(error instanceof CommandResult) {
if(error.id === ErrorID.CONVERSATION_MORE_DATA || error.id === ErrorID.EMPTY_RESULT) {
resolve({ status: "success", messages: this.historyQueryResponse, moreMessages: error.id === ErrorID.CONVERSATION_MORE_DATA });
return;
} else if(error.id === ErrorID.PERMISSION_ERROR) {
resolve({
status: "no-permission",
failedPermission: this.handle.connection.permissions.resolveInfo(parseInt(error.json["failed_permid"]))?.name || tr("unknwon")
});
return;
} else if(error.id === ErrorID.CONVERSATION_IS_PRIVATE) {
resolve({
status: "private"
});
return;
} else {
errorMessage = error.formattedMessage();
}
} else {
log.error(LogCategory.CHAT, tr("Failed to fetch conversation history. %o"), error);
errorMessage = tr("lookup the console");
}
resolve({
status: "error",
errorMessage: errorMessage
});
});
});
this.executeHistoryQuery();
});
}
queryCurrentMessages() {
this.presentMessages = [];
this.mode = "loading";
this.reportStateToUI();
this.queryHistory({ end: 1, limit: 50 }).then(history => {
this.conversationPrivate = false;
this.conversationVolatile = false;
this.failedPermission = undefined;
this.errorMessage = undefined;
this.presentMessages = history.messages?.map(e => Object.assign({ uniqueId: "m-" + e.timestamp }, e)) || [];
switch (history.status) {
case "error":
this.mode = "error";
this.errorMessage = history.errorMessage;
break;
case "no-permission":
this.mode = "no-permissions";
this.failedPermission = history.failedPermission;
break;
case "private":
this.mode = "normal";
break;
case "success":
this.mode = "normal";
break;
}
/* only update the UI if needed */
if(this.handle.selectedConversation() === this.conversationId)
this.reportStateToUI();
});
}
private executeHistoryQuery() {
if(this.queryingHistory)
return;
this.queryingHistory = true;
try {
const promise = this.pendingHistoryQueries.pop_front()();
promise
.catch(error => log.error(LogCategory.CLIENT, tr("Conversation history query task threw an error; this should never happen: %o"), error))
.then(() => this.executeHistoryQuery());
} catch (e) {
this.queryingHistory = false;
throw e;
}
}
public updateIndexFromServer(info: any) {
if('error_id' in info) {
/* FIXME: Parse error, may be flag private or similar */
return;
}
const timestamp = parseInt(info["timestamp"]);
if(isNaN(timestamp))
return;
if(timestamp > this.lastReadMessage) {
this.setUnreadTimestamp(this.lastReadMessage);
/* TODO: Move the whole last read part to the channel entry itself? */
this.handle.connection.channelTree.findChannel(this.conversationId)?.setUnread(true);
}
}
public handleIncomingMessage(message: ChatMessage, triggerUnread: boolean) {
let index = 0;
while(index < this.presentMessages.length && this.presentMessages[index].timestamp <= message.timestamp)
index++;
console.log("Insert at: %d", index);
this.presentMessages.splice(index, 0, Object.assign({ uniqueId: "m-" + message.timestamp }, message));
if(triggerUnread && !this.unreadTimestamp)
this.unreadTimestamp = message.timestamp;
const uMessage = this.presentMessages[index];
this.events.fire("notify_chat_event", {
conversation: this.conversationId,
triggerUnread: triggerUnread,
event: {
type: "message",
timestamp: message.timestamp,
message: message,
uniqueId: uMessage.uniqueId
}
});
}
public handleDeleteMessages(criteria: { begin: number, end: number, cldbid: number, limit: number }) {
let limit = { current: criteria.limit };
this.presentMessages = this.presentMessages.filter(message => {
if(message.sender_database_id !== criteria.cldbid)
return true;
if(criteria.end != 0 && message.timestamp > criteria.end)
return true;
if(criteria.begin != 0 && message.timestamp < criteria.begin)
return true;
return --limit.current < 0;
});
this.events.fire("notify_chat_message_delete", { conversation: this.conversationId, criteria: criteria });
}
public sendMessage(message: string) {
this.handle.connection.serverConnection.send_command("sendtextmessage", {
targetmode: this.conversationId == 0 ? 3 : 2,
cid: this.conversationId,
msg: message
}, { process_result: false }).catch(error => {
this.presentEvents.push({
type: "message-failed",
uniqueId: "msf-" + Date.now(),
timestamp: Date.now(),
error: "error",
errorMessage: tr("Unknown error TODO!") /* TODO! */
});
this.events.fire_async("notify_chat_event", {
conversation: this.conversationId,
triggerUnread: false,
event: this.presentEvents.last()
});
});
}
public deleteMessage(messageUniqueId: string) {
const message = this.presentMessages.find(e => e.uniqueId === messageUniqueId);
if(!message) {
log.warn(LogCategory.CHAT, tr("Tried to delete an unknown message (id: %s)"), messageUniqueId);
return;
}
this.handle.connection.serverConnection.send_command("conversationmessagedelete", {
cid: this.conversationId,
timestamp_begin: message.timestamp - 1,
timestamp_end: message.timestamp + 1,
limit: 1,
cldbid: message.sender_database_id
}, { process_result: false }).catch(error => {
log.error(LogCategory.CHAT, tr("Failed to delete conversation message for conversation %d: %o"), this.conversationId, error);
if(error instanceof CommandResult)
error = error.extra_message || error.message;
createErrorModal(tr("Failed to delete message"), traj("Failed to delete conversation message{:br:}Error: {}", error)).open();
});
}
public reportStateToUI() {
this.events.fire_async("notify_conversation_state", {
id: this.conversationId,
mode: this.mode === "unloaded" ? "loading" : this.mode,
unreadTimestamp: this.unreadTimestamp,
haveOlderMessages: false,
failedPermission: this.failedPermission,
conversationPrivate: this.conversationPrivate,
events: [...this.presentEvents, ...this.presentMessages.map(e => {
return {
timestamp: e.timestamp,
uniqueId: "m-" + e.timestamp,
type: "message",
message: e
} as ChatEvent;
})]
});
}
public setUnreadTimestamp(timestamp: number | undefined) {
if(this.unreadTimestamp === timestamp)
return;
this.unreadTimestamp = timestamp;
this.handle.connection.settings.changeServer(Settings.FN_CHANNEL_CHAT_READ(this.conversationId), typeof timestamp === "number" ? timestamp : Date.now());
this.events.fire_async("notify_unread_timestamp_changed", { conversation: this.conversationId, timestamp: timestamp });
}
}
export class ConversationManager {
readonly connection: ConnectionHandler;
readonly htmlTag: HTMLDivElement;
private readonly uiEvents: Registry<ConversationUIEvents>;
private conversations: {[key: number]: Conversation} = {};
private selectedConversation_: number;
constructor(connection: ConnectionHandler) {
this.connection = connection;
this.uiEvents = new Registry<ConversationUIEvents>();
this.htmlTag = document.createElement("div");
this.htmlTag.style.display = "flex";
this.htmlTag.style.flexDirection = "column";
this.htmlTag.style.justifyContent = "stretch";
this.htmlTag.style.height = "100%";
ReactDOM.render(React.createElement(ConversationPanel, { events: this.uiEvents, handler: this.connection }), this.htmlTag);
this.uiEvents.on("action_select_conversation", event => this.selectedConversation_ = event.chatId);
this.uiEvents.on("notify_destroy", connection.events().on("notify_connection_state_changed", event => {
if(ConnectionState.socketConnected(event.old_state) !== ConnectionState.socketConnected(event.new_state)) {
this.conversations = {};
this.setSelectedConversation(-1);
}
this.uiEvents.fire("notify_server_state", { crossChannelChatSupport: false, state: connection.connected ? "connected" : "disconnected" });
}));
this.uiEvents.register_handler(this);
this.uiEvents.on("notify_destroy", connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationhistory", this.handleConversationHistory.bind(this)));
this.uiEvents.on("notify_destroy", connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationindex", this.handleConversationIndex.bind(this)));
this.uiEvents.on("notify_destroy", connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationmessagedelete", this.handleConversationMessageDelete.bind(this)));
this.uiEvents.on("notify_destroy", this.connection.channelTree.events.on("notify_channel_list_received", () => {
this.queryUnreadFlags();
}));
this.uiEvents.on("notify_destroy", this.connection.channelTree.events.on("notify_channel_updated", event => {
/* TODO private flag! */
}));
}
destroy() {
this.uiEvents.unregister_handler(this);
this.uiEvents.fire("notify_destroy");
this.uiEvents.destroy();
}
selectedConversation() {
return this.selectedConversation_;
}
setSelectedConversation(id: number) {
this.findOrCreateConversation(id);
this.uiEvents.fire("action_select_conversation", { chatId: id });
}
findConversation(id: number) : Conversation {
for(const conversation of Object.values(this.conversations))
if(conversation.conversationId === id)
return conversation;
return undefined;
}
findOrCreateConversation(id: number) {
let conversation = this.findConversation(id);
if(!conversation) {
conversation = new Conversation(this, this.uiEvents, id);
this.conversations[id] = conversation;
}
return conversation;
}
destroyConversation(id: number) {
delete this.conversations[id];
if(id === this.selectedConversation_) {
this.uiEvents.fire("action_select_conversation", { chatId: -1 });
this.selectedConversation_ = -1;
}
}
queryUnreadFlags() {
/* FIXME: Test for old TeaSpeak servers which don't support this */
const commandData = this.connection.channelTree.channels.map(e => { return { cid: e.channelId, cpw: e.cached_password() }});
this.connection.serverConnection.send_command("conversationfetch", commandData).catch(error => {
log.warn(LogCategory.CHAT, tr("Failed to query conversation indexes: %o"), error);
});
}
handlePanelShow() {
this.uiEvents.fire("notify_panel_show");
}
private handleConversationHistory(command: ServerCommand) {
const conversation = this.findConversation(parseInt(command.arguments[0]["cid"]));
if(!conversation) {
log.warn(LogCategory.NETWORKING, tr("Received conversation history for an unknown conversation: %o"), command.arguments[0]["cid"]);
return;
}
for(const entry of command.arguments) {
conversation.historyQueryResponse.push({
timestamp: parseInt(entry["timestamp"]),
sender_database_id: parseInt(entry["sender_database_id"]),
sender_unique_id: entry["sender_unique_id"],
sender_name: entry["sender_name"],
message: entry["msg"]
});
}
}
private handleConversationIndex(command: ServerCommand) {
for(const entry of command.arguments) {
const conversation = this.findOrCreateConversation(parseInt(entry["cid"]));
conversation.updateIndexFromServer(entry);
}
}
private handleConversationMessageDelete(command: ServerCommand) {
const data = command.arguments[0];
const conversation = this.findConversation(parseInt(data["cid"]));
if(!conversation)
return;
conversation.handleDeleteMessages({
limit: parseInt(data["limit"]),
begin: parseInt(data["timestamp_begin"]),
end: parseInt(data["timestamp_end"]),
cldbid: parseInt(data["cldbid"])
})
}
@EventHandler<ConversationUIEvents>("query_conversation_state")
private handleQueryConversationState(event: ConversationUIEvents["query_conversation_state"]) {
const conversation = this.findConversation(event.chatId);
if(!conversation) {
this.uiEvents.fire_async("notify_conversation_state", {
mode: "error",
errorMessage: tr("Unknown conversation"),
id: event.chatId,
events: [],
conversationPrivate: false,
haveOlderMessages: false,
unreadTimestamp: undefined
});
return;
}
if(conversation.currentMode() === "unloaded")
conversation.queryCurrentMessages();
else
conversation.reportStateToUI();
}
@EventHandler<ConversationUIEvents>("action_clear_unread_flag")
private handleClearUnreadFlag(event: ConversationUIEvents["action_clear_unread_flag"]) {
this.connection.channelTree.findChannel(event.chatId)?.setUnread(false);
this.findConversation(event.chatId)?.setUnreadTimestamp(undefined);
}
@EventHandler<ConversationUIEvents>("action_send_message")
private handleSendMessage(event: ConversationUIEvents["action_send_message"]) {
const conversation = this.findConversation(event.chatId);
if(!conversation) {
log.error(LogCategory.CLIENT, tr("Tried to send a chat message to an unknown conversation with id %d"), event.chatId);
return;
}
conversation.sendMessage(event.text);
}
@EventHandler<ConversationUIEvents>("action_delete_message")
private handleMessageDelete(event: ConversationUIEvents["action_delete_message"]) {
const conversation = this.findConversation(event.chatId);
if(!conversation) {
log.error(LogCategory.CLIENT, tr("Tried to delete a chat message from an unknown conversation with id %d"), event.chatId);
return;
}
conversation.deleteMessage(event.uniqueId);
}
}

View File

@ -0,0 +1,231 @@
@import "../../../../css/static/mixin";
@import "../../../../css/static/properties";
$color_client_normal: #cccccc;
$client_info_avatar_size: 10em;
$bot_thumbnail_width: 16em;
$bot_thumbnail_height: 9em;
.panel {
display: flex;
flex-direction: column;
justify-content: stretch;
height: 100%;
width: 100%;
position: relative;
.containerMessages {
flex-grow: 1;
flex-shrink: 1;
display: flex;
flex-direction: column;
justify-content: stretch;
min-height: 2em;
position: relative;
.containerScrollNewMessage {
flex-grow: 0;
flex-shrink: 0;
position: absolute;
bottom: 0;
right: 0;
left: 0;
display: block;
text-align: center;
width: 100%;
color: #8b8b8b;
background: #353535; /* if we dont support gradients */
background: linear-gradient(rgba(53, 53, 53, 0) 10%, #353535 70%);
pointer-events: none;
opacity: 0;
@include transition(opacity .25s ease-in-out);
&.shown {
cursor: pointer;
pointer-events: all;
opacity: 1;
@include transition(opacity .25s ease-in-out);
}
}
}
}
.messages {
flex-grow: 1;
flex-shrink: 1;
display: block;
overflow-y: auto;
overflow-x: hidden;
@include chat-scrollbar();
position: relative;
min-height: 2em;
margin-bottom: .5em;
margin-right: .5em;
.containerMessage {
flex-shrink: 0;
flex-grow: 0;
display: flex;
flex-direction: row;
justify-content: stretch;
.avatar {
flex-grow: 0;
flex-shrink: 0;
position: relative;
display: inline-block;
margin: 1em 1em .5em .5em;
.imageContainer {
overflow: hidden;
width: 2.5em;
height: 2.5em;
border-radius: 50%;
}
}
.message {
flex-grow: 0;
flex-shrink: 1;
min-width: 2em;
position: relative;
display: inline-flex;
flex-direction: column;
justify-content: flex-start;
@include user-select(text);
background: #303030;
border-radius: 6px 6px 6px 6px;
margin-top: .5em;
padding: .5em;
.info {
display: block;
white-space : nowrap;
overflow : hidden;
text-overflow: ellipsis;
.sender, .sender :global(.htmltag-client) {
display: inline;
font-weight: bold;
color: $color_client_normal;
}
.timestamp {
display: inline;
margin-left: .5em;
font-size: 0.66em;
color: #5d5b5b;
}
.delete {
width: 1.1em;
cursor: pointer;
display: inline-block;
align-self: auto;
opacity: .25;
> img {
vertical-align: text-top;
}
&:hover {
opacity: 1;
}
@include transform(opacity $button_hover_animation_time ease-in-out);
}
}
.text {
color: #b5b5b5;
line-height: 1.1em;
word-wrap: break-word;
:global(.htmltag-client), :global(.htmltag-channel) {
display: inline;
font-weight: bold;
color: $color_client_normal;
}
}
&:before {
position: absolute;
content: ' ';
width: 1em;
height: 1em;
margin-left: calc(-.5em - 1em);
border-top: .5em solid transparent;
border-right: .75em solid #303030;
border-bottom: .5em solid transparent;
top: 1.25em;
}
}
}
.containerTimestamp {
color: #565353;
text-align: center;
}
.overlay {
flex-grow: 0;
flex-shrink: 0;
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
text-align: center;
width: 100%;
color: #5a5a5a;
background: #353535;
display: flex;
flex-direction: column;
justify-content: center;
}
.containerUnread {
text-align: center;
color: #bc1515;
}
}

View File

@ -0,0 +1,498 @@
import * as React from "react";
import {EventHandler, ReactEventHandler, Registry} from "tc-shared/events";
import {
ConversationUIEvents,
ChatMessage,
ChatEvent
} from "tc-shared/ui/frames/side/ConversationManager";
import {ChatBox} from "tc-shared/ui/frames/side/ChatBox";
import {generate_client} from "tc-shared/ui/htmltags";
import {useEffect, useRef, useState} from "react";
import {bbcode_chat} from "tc-shared/ui/frames/chat";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {AvatarRenderer} from "tc-shared/ui/react-elements/Avatar";
import {format} from "tc-shared/ui/frames/side/chat_helper";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
import {XBBCodeRenderer} from "../../../../../vendor/xbbcode/src/react";
const cssStyle = require("./Conversations.scss");
const CMTextRenderer = (props: { text: string }) => {
const refElement = useRef<HTMLSpanElement>();
const elements: HTMLElement[] = [];
bbcode_chat(props.text).forEach(e => elements.push(...e));
useEffect(() => {
if(elements.length === 0)
return;
refElement.current.replaceWith(...elements);
return () => {
/* just so react is happy again */
elements[0].replaceWith(refElement.current);
elements.forEach(e => e.remove());
};
});
return <XBBCodeRenderer>{props.text}</XBBCodeRenderer>
};
const TimestampRenderer = (props: { timestamp: number }) => {
const time = format.date.format_chat_time(new Date(props.timestamp));
const [ revision, setRevision ] = useState(0);
useEffect(() => {
if(!time.next_update)
return;
const id = setTimeout(() => setRevision(revision + 1), time.next_update);
return () => clearTimeout(id);
});
return <>{time.result}</>;
};
const ChatEventMessageRenderer = (props: { message: ChatMessage, callbackDelete?: () => void, events: Registry<ConversationUIEvents>, handler: ConnectionHandler }) => {
let deleteButton;
if(props.callbackDelete) {
deleteButton = (
<div className={cssStyle.delete} onClick={props.callbackDelete} >
<img src="img/icon_conversation_message_delete.svg" alt="X" />
</div>
);
}
return (
<div className={cssStyle.containerMessage}>
<div className={cssStyle.avatar}>
<div className={cssStyle.imageContainer}>
<AvatarRenderer avatar={props.handler.fileManager.avatars.resolveClientAvatar({ clientUniqueId: props.message.sender_unique_id, database_id: props.message.sender_database_id })} />
</div>
</div>
<div className={cssStyle.message}>
<div className={cssStyle.info}>
{deleteButton}
<a className={cssStyle.sender} dangerouslySetInnerHTML={{ __html: generate_client({
client_database_id: props.message.sender_database_id,
client_id: -1,
client_name: props.message.sender_name,
client_unique_id: props.message.sender_unique_id,
add_braces: false
})}} />
<a className={cssStyle.timestamp}><TimestampRenderer timestamp={props.message.timestamp} /></a>
<br /> { /* Only for copy purposes */ }
</div>
<div className={cssStyle.text}>
<CMTextRenderer text={props.message.message} />
<br style={{ content: " " }} /> { /* Only for copy purposes */ }
</div>
</div>
</div>
);
};
const TimestampEntry = (props: { timestamp: Date, refDiv: React.Ref<HTMLDivElement> }) => {
const diff = format.date.date_format(props.timestamp, new Date());
let formatted;
let update: boolean;
if(diff == format.date.ColloquialFormat.YESTERDAY) {
formatted = <Translatable key={"yesterday"}>Yesterday</Translatable>;
update = true;
} else if(diff == format.date.ColloquialFormat.TODAY) {
formatted = <Translatable key={"today"}>Today</Translatable>;
update = true;
} else if(diff == format.date.ColloquialFormat.GENERAL) {
formatted = <>{format.date.format_date_general(props.timestamp, false)}</>;
update = false;
}
const [ revision, setRevision ] = useState(0);
useEffect(() => {
if(!update)
return;
const nextHour = new Date();
nextHour.setUTCMilliseconds(0);
nextHour.setUTCMinutes(0);
nextHour.setUTCHours(nextHour.getUTCHours() + 1);
const id = setTimeout(() => {
setRevision(revision + 1);
}, nextHour.getTime() - Date.now() + 10);
return () => clearTimeout(id);
});
return (
<div className={cssStyle.containerTimestamp} ref={props.refDiv}>
{formatted}
</div>
);
};
const UnreadEntry = (props: { refDiv: React.Ref<HTMLDivElement> }) => (
<div key={"unread"} ref={props.refDiv} className={cssStyle.containerUnread}>
<Translatable>Unread messages</Translatable>
</div>
);
interface ConversationMessagesProperties {
events: Registry<ConversationUIEvents>;
handler: ConnectionHandler;
}
interface ConversationMessagesState {
mode: "normal" | "loading" | "error" | "private" | "no-permission" | "not-supported" | "unselected";
scrollOffset: number | "bottom";
errorMessage?: string;
failedPermission?: string;
}
@ReactEventHandler<ConversationMessages>(e => e.props.events)
class ConversationMessages extends React.Component<ConversationMessagesProperties, ConversationMessagesState> {
private readonly refMessages = React.createRef<HTMLDivElement>();
private readonly refUnread = React.createRef<HTMLDivElement>();
private readonly refTimestamp = React.createRef<HTMLDivElement>();
private readonly refScrollToNewMessages = React.createRef<HTMLDivElement>();
private conversationId: number = -1;
private chatEvents: ChatEvent[] = [];
private viewElementIndex = 0;
private viewEntries: React.ReactElement[] = [];
private unreadTimestamp: undefined | number;
private scrollIgnoreTimestamp: number = 0;
private currentHistoryFrame = {
begin: undefined,
end: undefined
};
constructor(props) {
super(props);
this.state = {
scrollOffset: "bottom",
mode: "unselected",
}
}
private scrollToBottom() {
requestAnimationFrame(() => {
if(this.state.scrollOffset !== "bottom")
return;
if(!this.refMessages.current)
return;
this.scrollIgnoreTimestamp = Date.now();
this.refMessages.current.scrollTop = this.refMessages.current.scrollHeight;
});
}
private scrollToNewMessage() {
requestAnimationFrame(() => {
if(!this.refUnread.current)
return;
this.scrollIgnoreTimestamp = Date.now();
this.refMessages.current.scrollTop = this.refUnread.current.offsetTop - this.refTimestamp.current.clientHeight;
});
}
private scrollToNewMessagesShown() {
const newMessageOffset = this.refUnread.current?.offsetTop;
return this.state.scrollOffset !== "bottom" && this.refMessages.current?.clientHeight + this.state.scrollOffset < newMessageOffset;
}
render() {
let contents = [];
switch (this.state.mode) {
case "error":
contents.push(<div key={"ol-error"} className={cssStyle.overlay}><a>{this.state.errorMessage ? this.state.errorMessage : tr("An unknown error happened.")}</a></div>);
break;
case "unselected":
contents.push(<div key={"ol-unselected"} className={cssStyle.overlay}><a><Translatable>No conversation selected</Translatable></a></div>);
break;
case "loading":
contents.push(<div key={"ol-loading"} className={cssStyle.overlay}><a><Translatable>Loading</Translatable> <LoadingDots maxDots={3}/></a></div>);
break;
case "private":
contents.push(<div key={"ol-private"} className={cssStyle.overlay}><a>
<Translatable>This conversation is private.</Translatable><br />
<Translatable>Join the channel to participate.</Translatable></a>
</div>);
break;
case "no-permission":
contents.push(<div key={"ol-permission"} className={cssStyle.overlay}><a>
<Translatable>You don't have permissions to participate in this conversation!</Translatable><br />
<Translatable>{this.state.failedPermission}</Translatable></a>
</div>);
break;
case "not-supported":
contents.push(<div key={"ol-support"} className={cssStyle.overlay}><a>
<Translatable>The target server does not support the cross channel chat system.</Translatable><br />
<Translatable>Join the channel if you want to write.</Translatable></a>
</div>);
break;
case "normal":
if(this.viewEntries.length === 0) {
contents.push(<div key={"ol-empty"} className={cssStyle.overlay}><a>
<Translatable>There have been no messages yet.</Translatable><br />
<Translatable>Be the first who talks in here!</Translatable></a>
</div>);
} else {
contents = this.viewEntries;
}
break;
}
return (
<div className={cssStyle.containerMessages}>
<div
className={cssStyle.messages} ref={this.refMessages}
onClick={() => this.state.mode === "normal" && this.props.events.fire("action_clear_unread_flag", { chatId: this.conversationId })}
onScroll={() => {
if(this.scrollIgnoreTimestamp > Date.now())
return;
const top = this.refMessages.current.scrollTop;
const total = this.refMessages.current.scrollHeight - this.refMessages.current.clientHeight;
const shouldFollow = top + 200 > total;
this.setState({ scrollOffset: shouldFollow ? "bottom" : top });
}}
>
{contents}
</div>
<div
ref={this.refScrollToNewMessages}
className={cssStyle.containerScrollNewMessage + " " + (this.scrollToNewMessagesShown() ? cssStyle.shown : "")}
onClick={() => this.setState({ scrollOffset: "bottom" }, () => this.scrollToNewMessage())}
>
<Translatable>Scroll to new messages</Translatable>
</div>
</div>
);
}
componentDidMount(): void {
this.scrollToBottom();
}
componentDidUpdate(prevProps: Readonly<ConversationMessagesProperties>, prevState: Readonly<ConversationMessagesState>, snapshot?: any): void {
requestAnimationFrame(() => {
this.refScrollToNewMessages.current.classList.toggle(cssStyle.shown, this.scrollToNewMessagesShown());
});
}
/* builds the view from the messages */
private buildView() {
this.viewEntries = [];
let timeMarker = new Date(0);
let unreadSet = false, timestampRefSet = false;
for(let event of this.chatEvents) {
const mdate = new Date(event.timestamp);
if(mdate.getFullYear() !== timeMarker.getFullYear() || mdate.getMonth() !== timeMarker.getMonth() || mdate.getDate() !== timeMarker.getDate()) {
timeMarker = new Date(mdate.getFullYear(), mdate.getMonth(), mdate.getDate(), 1);
this.viewEntries.push(<TimestampEntry key={"t" + this.viewElementIndex++} timestamp={timeMarker} refDiv={timestampRefSet ? undefined : this.refTimestamp} />);
timestampRefSet = true;
}
if(event.timestamp >= this.unreadTimestamp && !unreadSet) {
this.viewEntries.push(<UnreadEntry refDiv={this.refUnread} key={"u" + this.viewElementIndex++} />);
unreadSet = true;
}
switch (event.type) {
case "message":
this.viewEntries.push(<ChatEventMessageRenderer
key={event.uniqueId}
message={event.message}
events={this.props.events}
callbackDelete={() => this.props.events.fire("action_delete_message", { chatId: this.conversationId, uniqueId: event.uniqueId })}
handler={this.props.handler} />);
break;
case "message-failed":
/* TODO! */
break;
}
}
}
@EventHandler<ConversationUIEvents>("notify_server_state")
private handleNotifyServerState(event: ConversationUIEvents["notify_server_state"]) {
if(event.state === "connected")
return;
this.setState({ mode: "unselected" });
}
@EventHandler<ConversationUIEvents>("action_select_conversation")
private handleSelectConversation(event: ConversationUIEvents["action_select_conversation"]) {
if(this.conversationId === event.chatId)
return;
this.conversationId = event.chatId;
this.chatEvents = [];
this.currentHistoryFrame = { begin: undefined, end: undefined };
if(this.conversationId < 0) {
this.setState({ mode: "unselected" });
} else {
this.props.events.fire("query_conversation_state", {
chatId: this.conversationId
});
this.setState({ mode: "loading" });
}
}
@EventHandler<ConversationUIEvents>("notify_conversation_state")
private handleConversationStateUpdate(event: ConversationUIEvents["notify_conversation_state"]) {
if(event.id !== this.conversationId)
return;
if(event.mode === "no-permissions") {
this.setState({
mode: "no-permission",
failedPermission: event.failedPermission
});
} else if(event.mode === "loading") {
this.setState({
mode: "loading"
});
} else if(event.mode === "normal") {
this.unreadTimestamp = event.unreadTimestamp;
this.chatEvents = event.events;
this.buildView();
this.setState({
mode: "normal",
scrollOffset: "bottom"
}, () => this.scrollToBottom());
} else {
this.setState({
mode: "error",
errorMessage: event.errorMessage || tr("Unknown error/Invalid state")
});
}
}
@EventHandler<ConversationUIEvents>("notify_chat_event")
private handleMessageReceived(event: ConversationUIEvents["notify_chat_event"]) {
if(event.conversation !== this.conversationId)
return;
this.chatEvents.push(event.event);
if(typeof this.unreadTimestamp === "undefined" && event.triggerUnread)
this.unreadTimestamp = event.event.timestamp;
this.buildView();
this.forceUpdate(() => this.scrollToBottom());
}
@EventHandler<ConversationUIEvents>("notify_chat_message_delete")
private handleMessageDeleted(event: ConversationUIEvents["notify_chat_message_delete"]) {
if(event.conversation !== this.conversationId)
return;
let limit = { current: event.criteria.limit };
this.chatEvents = this.chatEvents.filter(mEvent => {
if(mEvent.type !== "message")
return;
const message = mEvent.message;
if(message.sender_database_id !== event.criteria.cldbid)
return true;
if(event.criteria.end != 0 && message.timestamp > event.criteria.end)
return true;
if(event.criteria.begin != 0 && message.timestamp < event.criteria.begin)
return true;
return --limit.current < 0;
});
this.buildView();
this.forceUpdate(() => this.scrollToBottom());
}
@EventHandler<ConversationUIEvents>("action_clear_unread_flag")
private handleMessageUnread(event: ConversationUIEvents["action_clear_unread_flag"]) {
if (event.chatId !== this.conversationId || this.unreadTimestamp === undefined)
return;
this.unreadTimestamp = undefined;
this.buildView();
this.forceUpdate();
};
@EventHandler<ConversationUIEvents>("notify_panel_show")
private handlePanelShow() {
if(this.refUnread.current) {
this.scrollToNewMessage();
} else if(this.state.scrollOffset === "bottom") {
this.scrollToBottom();
} else {
requestAnimationFrame(() => {
if(this.state.scrollOffset === "bottom")
return;
this.scrollIgnoreTimestamp = Date.now() + 250;
this.refMessages.current.scrollTop = this.state.scrollOffset;
});
}
}
}
export const ConversationPanel = (props: { events: Registry<ConversationUIEvents>, handler: ConnectionHandler }) => {
const currentChat = useRef({ id: -1 });
const chatEnabled = useRef(false);
const refChatBox = useRef<ChatBox>();
let connected = false;
const updateChatBox = () => {
refChatBox.current.setState({ enabled: connected && currentChat.current.id >= 0 && chatEnabled.current });
};
props.events.reactUse("notify_server_state", event => { connected = event.state === "connected"; updateChatBox(); });
props.events.reactUse("action_select_conversation", event => {
currentChat.current.id = event.chatId;
updateChatBox();
});
props.events.reactUse("notify_conversation_state", event => {
chatEnabled.current = event.mode === "normal" || event.mode === "private";
updateChatBox();
});
useEffect(() => {
return refChatBox.current.events.on("notify_typing", () => props.events.fire("action_clear_unread_flag", { chatId: currentChat.current.id }));
});
return <div className={cssStyle.panel}>
<ConversationMessages events={props.events} handler={props.handler} />
<ChatBox
ref={refChatBox}
onSubmit={text => props.events.fire("action_send_message", { chatId: currentChat.current.id, text: text }) }
/>
</div>
};

View File

@ -2,7 +2,8 @@ import * as log from "tc-shared/log";
import {LogCategory} from "tc-shared/log";
import {Settings, settings} from "tc-shared/settings";
declare const xbbcode;
const escapeBBCode = (text: string) => text.replace(/([\[\]])/g, "\\$1");
export namespace helpers {
//https://regex101.com/r/YQbfcX/2
//static readonly URL_REGEX = /^(?<hostname>([a-zA-Z0-9-]+\.)+[a-zA-Z0-9-]{2,63})(?:\/(?<path>(?:[^\s?]+)?)(?:\?(?<query>\S+))?)?$/gm;
@ -142,8 +143,8 @@ test
```
*/
"code": (renderer: Renderer, token: RemarkToken) => "[i-code]" + xbbcode.escape(token.content) + "[/i-code]",
"fence": (renderer: Renderer, token: RemarkToken) => "[code" + (token.params ? ("=" + token.params) : "") + "]" + xbbcode.escape(token.content) + "[/code]",
"code": (renderer: Renderer, token: RemarkToken) => "[i-code]" + escapeBBCode(token.content) + "[/i-code]",
"fence": (renderer: Renderer, token: RemarkToken) => "[code" + (token.params ? ("=" + token.params) : "") + "]" + escapeBBCode(token.content) + "[/code]",
"heading_open": (renderer: Renderer, token: RemarkToken) => "[size=" + (9 - Math.min(4, token.hLevel)) + "]",
"heading_close": (renderer: Renderer, token: RemarkToken) => "[/size][hr]",
@ -151,8 +152,8 @@ test
"hr": () => "[hr]",
//> Experience real-time editing with Remarkable!
//blockquote_open,
//blockquote_close
"blockquote_open": () => "[quote]",
"blockquote_close": () => "[/quote]"
};
private _options;
@ -205,7 +206,7 @@ test
maybe_escape_bb(text: string) {
if(this._options.escape_bb)
return xbbcode.escape(text);
return escapeBBCode(text);
return text;
}
}
@ -249,7 +250,7 @@ test
});
if(escape_bb)
message = xbbcode.escape(message);
message = escapeBBCode(message);
return process_url ? process_urls(message) : message;
}

View File

@ -68,6 +68,7 @@ export function spawnAvatarList(client: ConnectionHandler) {
tag_avatar_id.val(avatar_id);
tag_image_bytes.val(size);
/* FIXME!
container_avatar.empty().append(client.fileManager.avatars.generate_tag(avatar_id, undefined, {
callback_image: image => {
tag_image_width.val(image[0].naturalWidth + 'px');
@ -89,6 +90,7 @@ export function spawnAvatarList(client: ConnectionHandler) {
};
}
}));
*/
callback_delete = () => {
spawnYesNo(tr("Are you sure?"), tr("Do you really want to delete this avatar?"), result => {

View File

@ -0,0 +1,35 @@
import * as React from "react";
import {ClientAvatar} from "tc-shared/file/Avatars";
import {useState} from "react";
const ImageStyle = { height: "100%", width: "100%" };
export const AvatarRenderer = (props: { avatar: ClientAvatar, className?: string }) => {
let [ revision, setRevision ] = useState(0);
let image;
switch (props.avatar.state) {
case "unset":
image = <img key={"default"} title={tr("default avatar")} alt={tr("default avatar")} src={props.avatar.avatarUrl} style={ImageStyle} />;
break;
case "loaded":
image = <img key={"user-" + props.avatar.currentAvatarHash} alt={tr("user avatar")} title={tr("user avatar")} src={props.avatar.avatarUrl} style={ImageStyle} />;
break;
case "errored":
image = <img key={"error"} alt={tr("error")} title={tr("avatar failed to load:\n") + props.avatar.loadError} src={props.avatar.avatarUrl} style={ImageStyle} />;
break;
case "loading":
image = <img key={"loading"} alt={tr("loading")} title={tr("loading avatar")} src={"img/loading_image.svg"} style={ImageStyle} />;
break;
}
props.avatar.events.reactUse("avatar_state_changed", () => setRevision(revision + 1));
return (
<div className={props.className} style={{ overflow: "hidden" }}>
{image}
</div>
)
};

View File

@ -181,7 +181,7 @@ export class ServerEntry extends ChannelTreeEntry<ServerEvents> {
if(!singleSelect) return;
if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) {
this.channelTree.client.side_bar.channel_conversations().set_current_channel(0);
this.channelTree.client.side_bar.channel_conversations().setSelectedConversation(0);
this.channelTree.client.side_bar.show_channel_conversations();
}
}
@ -208,7 +208,7 @@ export class ServerEntry extends ChannelTreeEntry<ServerEvents> {
icon_class: "client-channel_switch",
name: tr("Join server text channel"),
callback: () => {
this.channelTree.client.side_bar.channel_conversations().set_current_channel(0);
this.channelTree.client.side_bar.channel_conversations().setSelectedConversation(0);
this.channelTree.client.side_bar.show_channel_conversations();
},
visible: !settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)

View File

@ -52,7 +52,9 @@ export interface ChannelTreeEvents {
channel: ChannelEntry,
channelProperties: ChannelProperties,
updatedProperties: ChannelProperties
}
},
notify_channel_list_received: {}
}
export class ChannelTreeEntrySelect {

View File

@ -14,7 +14,9 @@
"tc-shared/*": ["shared/js/*"],
"tc-backend/web/*": ["web/js/*"], /* specific web part */
"tc-backend/*": ["shared/backend.d/*"],
"tc-loader": ["loader/exports/loader.d.ts"]
"tc-loader": ["loader/exports/loader.d.ts"],
"vendor/xbbcode/*": ["vendor/xbbcode/src/*"]
}
},
"exclude": [

2
vendor/xbbcode vendored

@ -1 +1 @@
Subproject commit 4fe112d7ab4e2a9a94afc8cbeecc6e890723e61d
Subproject commit b1559dacda7bbb1072830e0e910bdd1bcb12c59f

View File

@ -167,7 +167,7 @@ export const config = async (target: "web" | "client") => { return {
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js', ".scss"],
extensions: ['.tsx', '.ts', '.js', ".scss", ".css"],
alias: { },
},
externals: [