diff --git a/ChangeLog.md b/ChangeLog.md index ea0c03b0..52dd7b9f 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -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 diff --git a/package-lock.json b/package-lock.json index dbe8dfc8..3f045969 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 510ab91f..0fc30d0e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/travis/deploy_dev_server.sh b/scripts/travis/deploy_dev_server.sh index 88625b2d..5453dde2 100644 --- a/scripts/travis/deploy_dev_server.sh +++ b/scripts/travis/deploy_dev_server.sh @@ -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 $? \ No newline at end of file diff --git a/shared/img/icon_conversation_message_delete.svg b/shared/img/icon_conversation_message_delete.svg index 5a334ed5..672f93f3 100644 --- a/shared/img/icon_conversation_message_delete.svg +++ b/shared/img/icon_conversation_message_delete.svg @@ -1,5 +1,4 @@ - diff --git a/shared/js/ConnectionHandler.ts b/shared/js/ConnectionHandler.ts index 6d938033..1033d44e 100644 --- a/shared/js/ConnectionHandler.ts +++ b/shared/js/ConnectionHandler.ts @@ -59,13 +59,13 @@ export enum ConnectionState { UNCONNECTED, /* no connection is currenting running */ CONNECTING, /* we try to establish a connection to the target server */ INITIALISING, /* we're setting up the connection encryption */ - AUTHENTICATING, /* we're authenticating ourself so we get a unique ID */ + AUTHENTICATING, /* we're authenticating our self so we get a unique ID */ CONNECTED, /* we're connected to the server. Server init has been done, may not everything is initialized */ DISCONNECTING/* we're curently disconnecting from the server and awaiting disconnect acknowledge */ } 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) { diff --git a/shared/js/MessageFormatter.ts b/shared/js/MessageFormatter.ts index c1191289..f7ea405f 100644 --- a/shared/js/MessageFormatter.ts +++ b/shared/js/MessageFormatter.ts @@ -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({ diff --git a/shared/js/connection/AbstractCommandHandler.ts b/shared/js/connection/AbstractCommandHandler.ts index ad723bc4..9f88b17f 100644 --- a/shared/js/connection/AbstractCommandHandler.ts +++ b/shared/js/connection/AbstractCommandHandler.ts @@ -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) diff --git a/shared/js/connection/CommandHandler.ts b/shared/js/connection/CommandHandler.ts index 417055d4..411f529f 100644 --- a/shared/js/connection/CommandHandler.ts +++ b/shared/js/connection/CommandHandler.ts @@ -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]; diff --git a/shared/js/file/Avatars.tsx b/shared/js/file/Avatars.tsx index 8cbee4cb..e4f551c5 100644 --- a/shared/js/file/Avatars.tsx +++ b/shared/js/file/Avatars.tsx @@ -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; + 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(); + } + + 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} = {}; + + 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 { - 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 { - 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,152 +178,132 @@ 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 + if(avatar.currentAvatarHash !== initialAvatarHash) + return; + + await AvatarManager.cache.put_cache('avatar_' + avatar.clientAvatarId, transferResponse.getResponse().clone(), "image/" + media, { + "X-avatar-version": avatar.currentAvatarHash }); - const url = await this._response_url(response.getResponse().clone(), type); - return this._cached_avatars[client_avatar_id] = { - client_avatar_id: client_avatar_id, - avatar_id: avatar_version, - url: url, - type: type - }; - } finally { - this._loading_promises[client_avatar_id] = undefined; - } - } - - /* loads an avatar by the avatar id and optional with the avatar version */ - load_avatar(client_avatar_id: string, avatar_version: string) : Promise { - return this._loading_promises[client_avatar_id] || (this._loading_promises[client_avatar_id] = this._load_avatar(client_avatar_id, avatar_version)); - } - - 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); - }); - } 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); - } - }).catch(error => { - log.error(LogCategory.GENERAL, tr("Failed to delete cached avatar for client %o (cache lookup failed): %o"), client_avatar_id, error); - }); - } - } - - generate_tag(client_avatar_id: string, avatar_id?: string, options?: { - callback_image?: (tag: JQuery) => 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); - }) + avatarResponse = transferResponse.getResponse(); } - return avatar_container; - } + if(!avatarResponse) { + throw tr("Missing avatar response"); + } - unique_id_2_avatar_id(unique_id: string) { - function str2ab(str) { - let buf = new ArrayBuffer(str.length); // 2 bytes for each char - let bufView = new Uint8Array(buf); - for (let i=0, strLen = str.length; i= '0' && c <= '9') - offset = c.charCodeAt(0) - '0'.charCodeAt(0); - else if(c >= 'A' && c <= 'F') - offset = c.charCodeAt(0) - 'A'.charCodeAt(0) + 0x0A; - else if(c >= 'a' && c <= 'f') - offset = c.charCodeAt(0) - 'a'.charCodeAt(0) + 0x0A; - result += String.fromCharCode('a'.charCodeAt(0) + offset); + 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"); } - return result; - } catch (e) { //invalid base 64 (like music bot etc) - return undefined; + + 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 { @@ -284,65 +312,43 @@ export class AvatarManager { 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") + 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) + 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 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); + }; + + if(avatar.state !== "loading") { + /* Test if we're may able to load the client avatar sync without a loading screen */ + updateJQueryTag(); + return container; } 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); - }); - + avatar.awaitLoaded().then(updateJQueryTag); image_loading.appendTo(container); } else { this.generate_default_image().appendTo(container); @@ -352,12 +358,43 @@ export class AvatarManager { } flush_cache() { - this._cached_avatars = undefined; - this._loading_promises = undefined; + this.destroy(); } } (window as any).flush_avatar_cache = async () => { server_connections.all_connections().forEach(e => { e.fileManager.avatars.flush_cache(); }); -}; \ No newline at end of file +}; + +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); + for (let i=0, strLen = str.length; i= '0' && c <= '9') + offset = c.charCodeAt(0) - '0'.charCodeAt(0); + else if(c >= 'A' && c <= 'F') + offset = c.charCodeAt(0) - 'A'.charCodeAt(0) + 0x0A; + else if(c >= 'a' && c <= 'f') + offset = c.charCodeAt(0) - 'a'.charCodeAt(0) + 0x0A; + result += String.fromCharCode('a'.charCodeAt(0) + offset); + } + return result; + } catch (e) { //invalid base 64 (like music bot etc) + return undefined; + } +} \ No newline at end of file diff --git a/shared/js/file/ImageCache.ts b/shared/js/file/ImageCache.ts index f8cdfdfe..43608894 100644 --- a/shared/js/file/ImageCache.ts +++ b/shared/js/file/ImageCache.ts @@ -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); } diff --git a/shared/js/log.ts b/shared/js/log.ts index e254027a..363d599d 100644 --- a/shared/js/log.ts +++ b/shared/js/log.ts @@ -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([ [LogCategory.NETWORKING, "Network "], [LogCategory.VOICE, "Voice "], [LogCategory.AUDIO, "Audio "], - [LogCategory.CHANNEL, "Chat "], + [LogCategory.CHAT, "Chat "], [LogCategory.I18N, "I18N "], [LogCategory.IDENTITIES, "Identities "], [LogCategory.IPC, "IPC "], diff --git a/shared/js/settings.ts b/shared/js/settings.ts index dff34e14..990003e5 100644 --- a/shared/js/settings.ts +++ b/shared/js/settings.ts @@ -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 = id => { + return { + key: 'channel_chat_read_' + id + } + }; + static readonly KEYS = (() => { const result = []; diff --git a/shared/js/ui/channel.ts b/shared/js/ui/channel.ts index bc8a825b..e4d29a63 100644 --- a/shared/js/ui/channel.ts +++ b/shared/js/ui/channel.ts @@ -355,7 +355,7 @@ export class ChannelEntry extends ChannelTreeEntry { 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 { 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 { 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 { 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(); diff --git a/shared/js/ui/frames/chat_frame.ts b/shared/js/ui/frames/chat_frame.ts index 7ba3fc39..d00a249d 100644 --- a/shared/js/ui/frames/chat_frame.ts +++ b/shared/js/ui/frames/chat_frame.ts @@ -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); } diff --git a/shared/js/ui/frames/side/ChatBox.scss b/shared/js/ui/frames/side/ChatBox.scss new file mode 100644 index 00000000..0571d406 --- /dev/null +++ b/shared/js/ui/frames/side/ChatBox.scss @@ -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; + } +} \ No newline at end of file diff --git a/shared/js/ui/frames/side/ChatBox.tsx b/shared/js/ui/frames/side/ChatBox.tsx new file mode 100644 index 00000000..3f051398 --- /dev/null +++ b/shared/js/ui/frames/side/ChatBox.tsx @@ -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 }) => { + 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 ( +
+
enabled && setShown(true)}> + {""} +
+
+ { + if(enabled) { + props.events.fire("action_insert_text", { text: emoji.native }); + } + }} + /> +
+
+ ); +}; + +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, ' '); +}; + +const TextInput = (props: { events: Registry, enabled?: boolean, placeholder?: string }) => { + const [ enabled, setEnabled ] = useState(!!props.enabled); + const [ historyIndex, setHistoryIndex ] = useState(-1); + const history = useRef([]); + const refInput = useRef(); + 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 ( +
+
+
+ ); +}; + +export interface ChatBoxProperties { + onSubmit?: (text: string) => void; + onType?: () => void; +} + +export interface ChatBoxState { + enabled: boolean; +} + +export class ChatBox extends React.Component { + readonly events = new Registry(); + 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
+
+ + +
+
*italic*, **bold**, ~~strikethrough~~, `code`, and more...
+
+ } + + componentDidUpdate(prevProps: Readonly, prevState: Readonly, snapshot?: any): void { + if(prevState.enabled !== this.state.enabled) + this.events.fire_async("action_set_enabled", { enabled: this.state.enabled }); + } +} \ No newline at end of file diff --git a/shared/js/ui/frames/side/ConversationManager.ts b/shared/js/ui/frames/side/ConversationManager.ts new file mode 100644 index 00000000..47c1346e --- /dev/null +++ b/shared/js/ui/frames/side/ConversationManager.ts @@ -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; + public readonly conversationId: number; + + private mode: ConversationMode = "unloaded"; + private failedPermission: string; + private errorMessage: string; + + public presentMessages: ({ uniqueId: string } & ChatMessage)[] = []; + public presentEvents: Exclude[] = []; /* 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)[] = []; + public historyQueryResponse: ChatMessage[] = []; + + constructor(handle: ConversationManager, events: Registry, 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 { + return new Promise(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; + + private conversations: {[key: number]: Conversation} = {}; + private selectedConversation_: number; + + constructor(connection: ConnectionHandler) { + this.connection = connection; + this.uiEvents = new Registry(); + + 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("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("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("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("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); + } +} \ No newline at end of file diff --git a/shared/js/ui/frames/side/Conversations.scss b/shared/js/ui/frames/side/Conversations.scss new file mode 100644 index 00000000..db0c1a7e --- /dev/null +++ b/shared/js/ui/frames/side/Conversations.scss @@ -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; + } +} \ No newline at end of file diff --git a/shared/js/ui/frames/side/Conversations.tsx b/shared/js/ui/frames/side/Conversations.tsx new file mode 100644 index 00000000..de7642ac --- /dev/null +++ b/shared/js/ui/frames/side/Conversations.tsx @@ -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(); + 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 {props.text} +}; + +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, handler: ConnectionHandler }) => { + let deleteButton; + + if(props.callbackDelete) { + deleteButton = ( +
+ X +
+ ); + } + + return ( +
+
+
+ +
+
+
+
+ {deleteButton} + + +
{ /* Only for copy purposes */ } +
+
+ +
{ /* Only for copy purposes */ } +
+
+
+ ); +}; + +const TimestampEntry = (props: { timestamp: Date, refDiv: React.Ref }) => { + const diff = format.date.date_format(props.timestamp, new Date()); + let formatted; + let update: boolean; + + if(diff == format.date.ColloquialFormat.YESTERDAY) { + formatted = Yesterday; + update = true; + } else if(diff == format.date.ColloquialFormat.TODAY) { + formatted = Today; + 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 ( +
+ {formatted} +
+ ); +}; + +const UnreadEntry = (props: { refDiv: React.Ref }) => ( +
+ Unread messages +
+); + +interface ConversationMessagesProperties { + events: Registry; + handler: ConnectionHandler; +} + +interface ConversationMessagesState { + mode: "normal" | "loading" | "error" | "private" | "no-permission" | "not-supported" | "unselected"; + + scrollOffset: number | "bottom"; + + errorMessage?: string; + failedPermission?: string; +} + +@ReactEventHandler(e => e.props.events) +class ConversationMessages extends React.Component { + private readonly refMessages = React.createRef(); + private readonly refUnread = React.createRef(); + private readonly refTimestamp = React.createRef(); + private readonly refScrollToNewMessages = React.createRef(); + + 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(); + break; + + case "unselected": + contents.push(); + break; + + case "loading": + contents.push(); + break; + + case "private": + contents.push(); + break; + + case "no-permission": + contents.push(); + break; + + case "not-supported": + contents.push(); + break; + + case "normal": + if(this.viewEntries.length === 0) { + contents.push(); + } else { + contents = this.viewEntries; + } + break; + } + + return ( +
+
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} +
+
this.setState({ scrollOffset: "bottom" }, () => this.scrollToNewMessage())} + > + Scroll to new messages +
+
+ ); + } + + componentDidMount(): void { + this.scrollToBottom(); + } + + componentDidUpdate(prevProps: Readonly, prevState: Readonly, 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(); + timestampRefSet = true; + } + + if(event.timestamp >= this.unreadTimestamp && !unreadSet) { + this.viewEntries.push(); + unreadSet = true; + } + + switch (event.type) { + case "message": + this.viewEntries.push( this.props.events.fire("action_delete_message", { chatId: this.conversationId, uniqueId: event.uniqueId })} + handler={this.props.handler} />); + break; + + case "message-failed": + /* TODO! */ + break; + } + } + } + + @EventHandler("notify_server_state") + private handleNotifyServerState(event: ConversationUIEvents["notify_server_state"]) { + if(event.state === "connected") + return; + + this.setState({ mode: "unselected" }); + } + + @EventHandler("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("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("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("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("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("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, handler: ConnectionHandler }) => { + const currentChat = useRef({ id: -1 }); + const chatEnabled = useRef(false); + + const refChatBox = useRef(); + 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
+ + props.events.fire("action_send_message", { chatId: currentChat.current.id, text: text }) } + /> +
+}; diff --git a/shared/js/ui/frames/side/chat_helper.ts b/shared/js/ui/frames/side/chat_helper.ts index 1522741c..c359422b 100644 --- a/shared/js/ui/frames/side/chat_helper.ts +++ b/shared/js/ui/frames/side/chat_helper.ts @@ -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 = /^(?([a-zA-Z0-9-]+\.)+[a-zA-Z0-9-]{2,63})(?:\/(?(?:[^\s?]+)?)(?:\?(?\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; } diff --git a/shared/js/ui/frames/side/conversations.ts b/shared/js/ui/frames/side/conversations_old.ts similarity index 100% rename from shared/js/ui/frames/side/conversations.ts rename to shared/js/ui/frames/side/conversations_old.ts diff --git a/shared/js/ui/modal/ModalAvatarList.ts b/shared/js/ui/modal/ModalAvatarList.ts index 7320808e..78d9116c 100644 --- a/shared/js/ui/modal/ModalAvatarList.ts +++ b/shared/js/ui/modal/ModalAvatarList.ts @@ -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 => { diff --git a/shared/js/ui/react-elements/Avatar.tsx b/shared/js/ui/react-elements/Avatar.tsx new file mode 100644 index 00000000..2722b0b1 --- /dev/null +++ b/shared/js/ui/react-elements/Avatar.tsx @@ -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 = {tr("default; + break; + + case "loaded": + image = {tr("user; + break; + + case "errored": + image = {tr("error")}; + break; + + case "loading": + image = {tr("loading")}; + break; + } + + props.avatar.events.reactUse("avatar_state_changed", () => setRevision(revision + 1)); + + return ( +
+ {image} +
+ ) +}; \ No newline at end of file diff --git a/shared/js/ui/server.ts b/shared/js/ui/server.ts index 743feaae..af1e1aeb 100644 --- a/shared/js/ui/server.ts +++ b/shared/js/ui/server.ts @@ -181,7 +181,7 @@ export class ServerEntry extends ChannelTreeEntry { 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 { 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) diff --git a/shared/js/ui/view.tsx b/shared/js/ui/view.tsx index d3b19295..b04996c4 100644 --- a/shared/js/ui/view.tsx +++ b/shared/js/ui/view.tsx @@ -52,7 +52,9 @@ export interface ChannelTreeEvents { channel: ChannelEntry, channelProperties: ChannelProperties, updatedProperties: ChannelProperties - } + }, + + notify_channel_list_received: {} } export class ChannelTreeEntrySelect { diff --git a/tsconfig.json b/tsconfig.json index 35f10b14..92bc85ca 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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": [ diff --git a/vendor/xbbcode b/vendor/xbbcode index 4fe112d7..b1559dac 160000 --- a/vendor/xbbcode +++ b/vendor/xbbcode @@ -1 +1 @@ -Subproject commit 4fe112d7ab4e2a9a94afc8cbeecc6e890723e61d +Subproject commit b1559dacda7bbb1072830e0e910bdd1bcb12c59f diff --git a/webpack.config.ts b/webpack.config.ts index e6029a7b..9112edea 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -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: [