Working un reactifying the conversations
parent
4365e03dbe
commit
f3f8d1cae9
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 $?
|
|
@ -1,5 +1,4 @@
|
|||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg preserveAspectRatio="xMinYMid" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.41421;">
|
||||
<g transform="matrix(0.933313,-4.93038e-32,-4.93038e-32,0.933313,2.134,2.13353)">
|
||||
<path d="M16.272,21.06L16.3,50.447C16.3,52.687 18.113,54.501 20.354,54.501L43.648,54.501C45.888,54.501 47.702,52.689 47.702,50.448L47.728,21.06" style="fill:none;stroke-width:2px;stroke:rgb(211,84,68);"/>
|
||||
|
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.4 KiB |
|
@ -65,7 +65,7 @@ export enum ConnectionState {
|
|||
}
|
||||
|
||||
export namespace ConnectionState {
|
||||
export function socket_connected(state: ConnectionState) {
|
||||
export function socketConnected(state: ConnectionState) {
|
||||
switch (state) {
|
||||
case ConnectionState.CONNECTED:
|
||||
case ConnectionState.AUTHENTICATING:
|
||||
|
@ -75,6 +75,10 @@ export namespace ConnectionState {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function fullyConnected(state: ConnectionState) {
|
||||
return state === ConnectionState.CONNECTED;
|
||||
}
|
||||
}
|
||||
|
||||
export enum ViewReasonId {
|
||||
|
@ -173,18 +177,17 @@ export class ConnectionHandler {
|
|||
|
||||
this.settings = new ServerSettings();
|
||||
|
||||
this.log = new ServerLog(this);
|
||||
this.channelTree = new ChannelTree(this);
|
||||
this.side_bar = new Frame(this);
|
||||
this.sound = new SoundManager(this);
|
||||
this.hostbanner = new Hostbanner(this);
|
||||
|
||||
this.serverConnection = connection.spawn_server_connection(this);
|
||||
this.serverConnection.onconnectionstatechanged = this.on_connection_state_changed.bind(this);
|
||||
|
||||
this.channelTree = new ChannelTree(this);
|
||||
this.fileManager = new FileManager(this);
|
||||
this.permissions = new PermissionManager(this);
|
||||
this.side_bar.channel_conversations().initialize_needed_listener();
|
||||
|
||||
this.log = new ServerLog(this);
|
||||
this.side_bar = new Frame(this);
|
||||
this.sound = new SoundManager(this);
|
||||
this.hostbanner = new Hostbanner(this);
|
||||
|
||||
this.groups = new GroupManager(this);
|
||||
this._local_client = new LocalClientEntry(this);
|
||||
|
@ -641,7 +644,6 @@ export class ConnectionHandler {
|
|||
this.serverConnection.disconnect();
|
||||
|
||||
this.side_bar.private_conversations().clear_client_ids();
|
||||
this.side_bar.channel_conversations().set_current_channel(0);
|
||||
this.hostbanner.update();
|
||||
|
||||
if(auto_reconnect) {
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -1,34 +1,78 @@
|
|||
import * as log from "tc-shared/log";
|
||||
import {LogCategory} from "tc-shared/log";
|
||||
import {ClientEntry} from "tc-shared/ui/client";
|
||||
import * as hex from "tc-shared/crypto/hex";
|
||||
import {image_type, ImageCache, ImageType, media_image_type} from "tc-shared/file/ImageCache";
|
||||
import {image_type, ImageCache, media_image_type} from "tc-shared/file/ImageCache";
|
||||
import {FileManager} from "tc-shared/file/FileManager";
|
||||
import {
|
||||
FileDownloadTransfer,
|
||||
FileTransferState,
|
||||
ResponseTransferTarget, TransferProvider,
|
||||
ResponseTransferTarget,
|
||||
TransferProvider,
|
||||
TransferTargetType
|
||||
} from "tc-shared/file/Transfer";
|
||||
import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration";
|
||||
import {tra} from "tc-shared/i18n/localize";
|
||||
import {server_connections} from "tc-shared/ui/frames/connection_handlers";
|
||||
import {icon_cache_loader} from "tc-shared/file/Icons";
|
||||
import {Registry} from "tc-shared/events";
|
||||
import {ClientEntry} from "tc-shared/ui/client";
|
||||
|
||||
export class Avatar {
|
||||
client_avatar_id: string; /* the base64 uid thing from a-m */
|
||||
avatar_id: string; /* client_flag_avatar */
|
||||
url: string;
|
||||
type: ImageType;
|
||||
/* FIXME: Retry avatar download after some time! */
|
||||
|
||||
const DefaultAvatarImage = "img/style/avatar.png";
|
||||
|
||||
export type AvatarState = "unset" | "loading" | "errored" | "loaded";
|
||||
|
||||
interface AvatarEvents {
|
||||
avatar_changed: {},
|
||||
avatar_state_changed: { oldState: AvatarState, newState: AvatarState }
|
||||
}
|
||||
|
||||
export class ClientAvatar {
|
||||
readonly events: Registry<AvatarEvents>;
|
||||
readonly clientAvatarId: string; /* the base64 unique id thing from a-m */
|
||||
|
||||
currentAvatarHash: string | "unknown"; /* the client avatars flag */
|
||||
state: AvatarState = "loading";
|
||||
|
||||
/* only set when state is unset, loaded or errored */
|
||||
avatarUrl?: string;
|
||||
loadError?: string;
|
||||
|
||||
loadingTimestamp: number = 0;
|
||||
|
||||
constructor(client_avatar_id: string) {
|
||||
this.clientAvatarId = client_avatar_id;
|
||||
this.events = new Registry<AvatarEvents>();
|
||||
}
|
||||
|
||||
setState(state: AvatarState) {
|
||||
if(state === this.state)
|
||||
return;
|
||||
|
||||
const oldState = this.state;
|
||||
this.state = state;
|
||||
this.events.fire("avatar_state_changed", { newState: state, oldState: oldState });
|
||||
}
|
||||
|
||||
async awaitLoaded() {
|
||||
if(this.state !== "loading")
|
||||
return;
|
||||
|
||||
await new Promise(resolve => this.events.on("avatar_state_changed", event => event.newState !== "loading" && resolve()));
|
||||
}
|
||||
|
||||
destroyUrl() {
|
||||
URL.revokeObjectURL(this.avatarUrl);
|
||||
this.avatarUrl = DefaultAvatarImage;
|
||||
}
|
||||
}
|
||||
|
||||
export class AvatarManager {
|
||||
handle: FileManager;
|
||||
|
||||
private static cache: ImageCache;
|
||||
private _cached_avatars: {[response_avatar_id:number]:Avatar} = {};
|
||||
private _loading_promises: {[response_avatar_id:number]:Promise<any>} = {};
|
||||
|
||||
|
||||
private cachedAvatars: {[avatarId: string]: ClientAvatar} = {};
|
||||
constructor(handle: FileManager) {
|
||||
this.handle = handle;
|
||||
|
||||
|
@ -37,48 +81,8 @@ export class AvatarManager {
|
|||
}
|
||||
|
||||
destroy() {
|
||||
this._cached_avatars = undefined;
|
||||
this._loading_promises = undefined;
|
||||
}
|
||||
|
||||
private async _response_url(response: Response, type: ImageType) : Promise<string> {
|
||||
if(!response.headers.has('X-media-bytes'))
|
||||
throw "missing media bytes";
|
||||
|
||||
const media = media_image_type(type);
|
||||
const blob = await response.blob();
|
||||
if(blob.type !== "image/" + media)
|
||||
return URL.createObjectURL(blob.slice(0, blob.size, "image/" + media));
|
||||
else
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
async resolved_cached?(client_avatar_id: string, avatar_version?: string) : Promise<Avatar> {
|
||||
let cachedAvatar: Avatar = this._cached_avatars[avatar_version];
|
||||
if(cachedAvatar) {
|
||||
if(typeof(avatar_version) !== "string" || cachedAvatar.avatar_id == avatar_version)
|
||||
return cachedAvatar;
|
||||
delete this._cached_avatars[avatar_version];
|
||||
}
|
||||
|
||||
if(!AvatarManager.cache.setupped())
|
||||
await AvatarManager.cache.setup();
|
||||
|
||||
const response = await AvatarManager.cache.resolve_cached('avatar_' + client_avatar_id); //TODO age!
|
||||
if(!response)
|
||||
return undefined;
|
||||
|
||||
let response_avatar_version = response.headers.has("X-avatar-version") ? response.headers.get("X-avatar-version") : undefined;
|
||||
if(typeof(avatar_version) === "string" && response_avatar_version != avatar_version)
|
||||
return undefined;
|
||||
|
||||
const type = image_type(response.headers.get('X-media-bytes'));
|
||||
return this._cached_avatars[client_avatar_id] = {
|
||||
client_avatar_id: client_avatar_id,
|
||||
avatar_id: avatar_version || response_avatar_version,
|
||||
url: await this._response_url(response, type),
|
||||
type: type
|
||||
};
|
||||
Object.values(this.cachedAvatars).forEach(e => e.destroyUrl());
|
||||
this.cachedAvatars = {};
|
||||
}
|
||||
|
||||
create_avatar_download(client_avatar_id: string) : FileDownloadTransfer {
|
||||
|
@ -91,9 +95,48 @@ export class AvatarManager {
|
|||
});
|
||||
}
|
||||
|
||||
private async _load_avatar(client_avatar_id: string, avatar_version: string) {
|
||||
try {
|
||||
let transfer = this.create_avatar_download(client_avatar_id);
|
||||
private async executeAvatarLoad0(avatar: ClientAvatar) {
|
||||
if(avatar.currentAvatarHash === "") {
|
||||
avatar.destroyUrl();
|
||||
avatar.setState("unset");
|
||||
return;
|
||||
}
|
||||
|
||||
const initialAvatarHash = avatar.currentAvatarHash;
|
||||
let avatarResponse: Response;
|
||||
|
||||
/* try to lookup our cache for the avatar */
|
||||
cache_lookup: {
|
||||
if(!AvatarManager.cache.setupped()) {
|
||||
await AvatarManager.cache.setup();
|
||||
}
|
||||
|
||||
const response = await AvatarManager.cache.resolve_cached('avatar_' + avatar.clientAvatarId); //TODO age!
|
||||
if(!response) {
|
||||
break cache_lookup;
|
||||
}
|
||||
|
||||
let cachedAvatarHash = response.headers.has("X-avatar-version") ? response.headers.get("X-avatar-version") : undefined;
|
||||
if(avatar.currentAvatarHash !== "unknown") {
|
||||
if(cachedAvatarHash === undefined) {
|
||||
log.debug(LogCategory.FILE_TRANSFER, tr("Invalidating cached avatar for %s (Version miss match. Cached: unset, Current: %s)"), avatar.clientAvatarId, avatar.currentAvatarHash);
|
||||
await AvatarManager.cache.delete('avatar_' + avatar.clientAvatarId);
|
||||
break cache_lookup;
|
||||
} else if(cachedAvatarHash !== avatar.currentAvatarHash) {
|
||||
log.debug(LogCategory.FILE_TRANSFER, tr("Invalidating cached avatar for %s (Version miss match. Cached: %s, Current: %s)"), avatar.clientAvatarId, cachedAvatarHash, avatar.currentAvatarHash);
|
||||
await AvatarManager.cache.delete('avatar_' + avatar.clientAvatarId);
|
||||
break cache_lookup;
|
||||
}
|
||||
} else if(cachedAvatarHash) {
|
||||
avatar.currentAvatarHash = cachedAvatarHash;
|
||||
}
|
||||
|
||||
avatarResponse = response;
|
||||
}
|
||||
|
||||
/* load the avatar from the server */
|
||||
if(!avatarResponse) {
|
||||
let transfer = this.create_avatar_download(avatar.clientAvatarId);
|
||||
|
||||
try {
|
||||
await transfer.awaitFinished();
|
||||
|
@ -102,27 +145,32 @@ export class AvatarManager {
|
|||
throw tr("download canceled");
|
||||
} else if(transfer.transferState() === FileTransferState.ERRORED) {
|
||||
throw transfer.currentError();
|
||||
} else if(transfer.transferState() === FileTransferState.FINISHED) {
|
||||
|
||||
} else {
|
||||
throw tr("Unknown transfer finished state");
|
||||
} else if(transfer.transferState() !== FileTransferState.FINISHED) {
|
||||
throw tr("unknown transfer finished state");
|
||||
}
|
||||
} catch(error) {
|
||||
if(typeof error === "object" && 'error' in error && error.error === "initialize") {
|
||||
const commandResult = error.commandResult;
|
||||
if(commandResult instanceof CommandResult) {
|
||||
if(commandResult.id === ErrorID.FILE_NOT_FOUND)
|
||||
throw tr("Avatar could not be found");
|
||||
else if(commandResult.id === ErrorID.PERMISSION_ERROR)
|
||||
throw tr("No permissions to download avatar");
|
||||
else
|
||||
if(commandResult.id === ErrorID.FILE_NOT_FOUND) {
|
||||
if(avatar.currentAvatarHash !== initialAvatarHash)
|
||||
return;
|
||||
|
||||
avatar.destroyUrl();
|
||||
avatar.setState("unset");
|
||||
return;
|
||||
} else if(commandResult.id === ErrorID.PERMISSION_ERROR) {
|
||||
throw tr("No permissions to download the avatar");
|
||||
} else {
|
||||
throw commandResult.message + (commandResult.extra_message ? " (" + commandResult.extra_message + ")" : "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.error(LogCategory.CLIENT, tr("Could not request download for avatar %s: %o"), client_avatar_id, error);
|
||||
log.error(LogCategory.CLIENT, tr("Could not request download for avatar %s: %o"), avatar.clientAvatarId, error);
|
||||
if(error === transfer.currentError())
|
||||
throw transfer.currentErrorMessage();
|
||||
|
||||
throw typeof error === "string" ? error : tr("Avatar download failed");
|
||||
}
|
||||
|
||||
|
@ -130,123 +178,196 @@ export class AvatarManager {
|
|||
if(transfer.target.type !== TransferTargetType.RESPONSE)
|
||||
throw "unsupported transfer target";
|
||||
|
||||
const response = transfer.target as ResponseTransferTarget;
|
||||
if(!response.hasResponse())
|
||||
throw tr("Transfer has no response");
|
||||
const transferResponse = transfer.target as ResponseTransferTarget;
|
||||
if(!transferResponse.hasResponse()) {
|
||||
throw tr("Avatar transfer has no response");
|
||||
}
|
||||
|
||||
const type = image_type(response.getResponse().headers.get('X-media-bytes'));
|
||||
const headers = transferResponse.getResponse().headers;
|
||||
if(!headers.has("X-media-bytes")) {
|
||||
throw tr("Avatar response missing media bytes");
|
||||
}
|
||||
|
||||
const type = image_type(headers.get('X-media-bytes'));
|
||||
const media = media_image_type(type);
|
||||
|
||||
await AvatarManager.cache.put_cache('avatar_' + client_avatar_id, response.getResponse().clone(), "image/" + media, {
|
||||
"X-avatar-version": avatar_version
|
||||
});
|
||||
const url = await this._response_url(response.getResponse().clone(), type);
|
||||
if(avatar.currentAvatarHash !== initialAvatarHash)
|
||||
return;
|
||||
|
||||
return this._cached_avatars[client_avatar_id] = {
|
||||
client_avatar_id: client_avatar_id,
|
||||
avatar_id: avatar_version,
|
||||
url: url,
|
||||
type: type
|
||||
await AvatarManager.cache.put_cache('avatar_' + avatar.clientAvatarId, transferResponse.getResponse().clone(), "image/" + media, {
|
||||
"X-avatar-version": avatar.currentAvatarHash
|
||||
});
|
||||
|
||||
avatarResponse = transferResponse.getResponse();
|
||||
}
|
||||
|
||||
if(!avatarResponse) {
|
||||
throw tr("Missing avatar response");
|
||||
}
|
||||
|
||||
/* get an url from the response */
|
||||
{
|
||||
if(!avatarResponse.headers.has('X-media-bytes'))
|
||||
throw "missing media bytes";
|
||||
|
||||
const type = image_type(avatarResponse.headers.get('X-media-bytes'));
|
||||
const media = media_image_type(type);
|
||||
|
||||
const blob = await avatarResponse.blob();
|
||||
|
||||
/* ensure we're still up to date */
|
||||
if(avatar.currentAvatarHash !== initialAvatarHash)
|
||||
return;
|
||||
|
||||
avatar.destroyUrl();
|
||||
if(blob.type !== "image/" + media) {
|
||||
avatar.avatarUrl = URL.createObjectURL(blob.slice(0, blob.size, "image/" + media));
|
||||
} else {
|
||||
avatar.avatarUrl = URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
avatar.setState("loaded");
|
||||
}
|
||||
}
|
||||
|
||||
private executeAvatarLoad(avatar: ClientAvatar) {
|
||||
const avatar_hash = avatar.currentAvatarHash;
|
||||
|
||||
avatar.setState("loading");
|
||||
avatar.loadingTimestamp = Date.now();
|
||||
this.executeAvatarLoad0(avatar).catch(error => {
|
||||
if(avatar.currentAvatarHash !== avatar_hash)
|
||||
return;
|
||||
|
||||
if(typeof error === "string") {
|
||||
avatar.loadError = error;
|
||||
} else if(error instanceof Error) {
|
||||
avatar.loadError = error.message;
|
||||
} else {
|
||||
log.error(LogCategory.FILE_TRANSFER, tr("Failed to load avatar %s (hash: %s): %o"), avatar.clientAvatarId, avatar_hash, error);
|
||||
avatar.loadError = tr("lookup the console");
|
||||
}
|
||||
|
||||
avatar.destroyUrl(); /* if there were any image previously */
|
||||
avatar.setState("errored");
|
||||
});
|
||||
}
|
||||
|
||||
update_cache(clientAvatarId: string, clientAvatarHash: string) {
|
||||
AvatarManager.cache.setup().then(() => {
|
||||
const deletePromise = AvatarManager.cache.delete("avatar_" + clientAvatarId).catch(error => {
|
||||
log.warn(LogCategory.FILE_TRANSFER, tr("Failed to delete avatar %s: %o"), clientAvatarId, error);
|
||||
});
|
||||
|
||||
const cached = this.cachedAvatars[clientAvatarId];
|
||||
if(!cached || cached.currentAvatarHash === clientAvatarHash) return;
|
||||
|
||||
log.info(LogCategory.GENERAL, tr("Deleting cached avatar for client %s. Cached version: %s; New version: %s"), cached.currentAvatarHash, clientAvatarHash);
|
||||
deletePromise.then(() => {
|
||||
cached.currentAvatarHash = clientAvatarHash;
|
||||
cached.events.fire("avatar_changed");
|
||||
this.executeAvatarLoad(cached);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
resolveAvatar(clientAvatarId: string, avatarHash?: string, cacheOnly?: boolean) {
|
||||
let avatar = this.cachedAvatars[clientAvatarId];
|
||||
if(!avatar) {
|
||||
if(cacheOnly)
|
||||
return undefined;
|
||||
|
||||
avatar = new ClientAvatar(clientAvatarId);
|
||||
this.cachedAvatars[clientAvatarId] = avatar;
|
||||
} else if(typeof avatarHash !== "string" || avatar.currentAvatarHash === avatarHash) {
|
||||
return avatar;
|
||||
}
|
||||
|
||||
avatar.currentAvatarHash = typeof avatarHash === "string" ? avatarHash : "unknown";
|
||||
this.executeAvatarLoad(avatar);
|
||||
|
||||
return avatar;
|
||||
}
|
||||
|
||||
resolveClientAvatar(client: { id?: number, database_id?: number, clientUniqueId: string }) {
|
||||
let clientHandle: ClientEntry;
|
||||
if(typeof client.id === "number") {
|
||||
clientHandle = this.handle.connectionHandler.channelTree.findClient(client.id);
|
||||
if(clientHandle?.properties.client_unique_identifier !== client.clientUniqueId)
|
||||
clientHandle = undefined;
|
||||
}
|
||||
|
||||
if(!clientHandle && typeof client.database_id === "number") {
|
||||
clientHandle = this.handle.connectionHandler.channelTree.find_client_by_dbid(client.database_id);
|
||||
if(clientHandle?.properties.client_unique_identifier !== client.clientUniqueId)
|
||||
clientHandle = undefined;
|
||||
}
|
||||
|
||||
return this.resolveAvatar(uniqueId2AvatarId(client.clientUniqueId), clientHandle?.properties.client_flag_avatar);
|
||||
}
|
||||
|
||||
private generate_default_image() : JQuery {
|
||||
return $.spawn("img").attr("src", "img/style/avatar.png").css({width: '100%', height: '100%'});
|
||||
}
|
||||
|
||||
generate_chat_tag(client: { id?: number; database_id?: number; }, client_unique_id: string, callback_loaded?: (successfully: boolean, error?: any) => any) : JQuery {
|
||||
let client_handle;
|
||||
if(typeof(client.id) == "number") {
|
||||
client_handle = this.handle.connectionHandler.channelTree.findClient(client.id);
|
||||
}
|
||||
|
||||
if(!client_handle && typeof(client.id) == "number") {
|
||||
client_handle = this.handle.connectionHandler.channelTree.find_client_by_dbid(client.database_id);
|
||||
}
|
||||
|
||||
if(client_handle && client_handle.clientUid() !== client_unique_id) {
|
||||
client_handle = undefined;
|
||||
}
|
||||
|
||||
const container = $.spawn("div").addClass("avatar");
|
||||
if(client_handle && !client_handle.properties.client_flag_avatar)
|
||||
return container.append(this.generate_default_image());
|
||||
|
||||
|
||||
const clientAvatarId = client_handle ? client_handle.avatarId() : uniqueId2AvatarId(client_unique_id);
|
||||
if(clientAvatarId) {
|
||||
const avatar = this.resolveAvatar(clientAvatarId, client_handle?.properties.client_flag_avatar);
|
||||
|
||||
|
||||
const updateJQueryTag = () => {
|
||||
const image = $.spawn("img").attr("src", avatar.avatarUrl).css({width: '100%', height: '100%'});
|
||||
container.append(image);
|
||||
};
|
||||
} finally {
|
||||
this._loading_promises[client_avatar_id] = undefined;
|
||||
}
|
||||
|
||||
if(avatar.state !== "loading") {
|
||||
/* Test if we're may able to load the client avatar sync without a loading screen */
|
||||
updateJQueryTag();
|
||||
return container;
|
||||
}
|
||||
|
||||
/* loads an avatar by the avatar id and optional with the avatar version */
|
||||
load_avatar(client_avatar_id: string, avatar_version: string) : Promise<Avatar> {
|
||||
return this._loading_promises[client_avatar_id] || (this._loading_promises[client_avatar_id] = this._load_avatar(client_avatar_id, avatar_version));
|
||||
}
|
||||
const image_loading = $.spawn("img").attr("src", "img/loading_image.svg").css({width: '100%', height: '100%'});
|
||||
|
||||
generate_client_tag(client: ClientEntry) : JQuery {
|
||||
return this.generate_tag(client.avatarId(), client.properties.client_flag_avatar);
|
||||
}
|
||||
|
||||
update_cache(client_avatar_id: string, avatar_id: string) {
|
||||
const _cached: Avatar = this._cached_avatars[client_avatar_id];
|
||||
if(_cached) {
|
||||
if(_cached.avatar_id === avatar_id)
|
||||
return; /* cache is up2date */
|
||||
|
||||
log.info(LogCategory.GENERAL, tr("Deleting cached avatar for client %s. Cached version: %s; New version: %s"), client_avatar_id, _cached.avatar_id, avatar_id);
|
||||
delete this._cached_avatars[client_avatar_id];
|
||||
AvatarManager.cache.delete("avatar_" + client_avatar_id).catch(error => {
|
||||
log.error(LogCategory.GENERAL, tr("Failed to delete cached avatar for client %o: %o"), client_avatar_id, error);
|
||||
});
|
||||
/* lets actually load the avatar */
|
||||
avatar.awaitLoaded().then(updateJQueryTag);
|
||||
image_loading.appendTo(container);
|
||||
} else {
|
||||
this.resolved_cached(client_avatar_id).then(avatar => {
|
||||
if(avatar && avatar.avatar_id !== avatar_id) {
|
||||
/* this time we ensured that its cached */
|
||||
this.update_cache(client_avatar_id, avatar_id);
|
||||
this.generate_default_image().appendTo(container);
|
||||
}
|
||||
}).catch(error => {
|
||||
log.error(LogCategory.GENERAL, tr("Failed to delete cached avatar for client %o (cache lookup failed): %o"), client_avatar_id, error);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
flush_cache() {
|
||||
this.destroy();
|
||||
}
|
||||
}
|
||||
(window as any).flush_avatar_cache = async () => {
|
||||
server_connections.all_connections().forEach(e => {
|
||||
e.fileManager.avatars.flush_cache();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
generate_tag(client_avatar_id: string, avatar_id?: string, options?: {
|
||||
callback_image?: (tag: JQuery<HTMLImageElement>) => any,
|
||||
callback_avatar?: (avatar: Avatar) => any
|
||||
}) : JQuery {
|
||||
options = options || {};
|
||||
|
||||
let avatar_container = $.spawn("div");
|
||||
let avatar_image = $.spawn("img").attr("alt", tr("Client avatar"));
|
||||
|
||||
let cached_avatar: Avatar = this._cached_avatars[client_avatar_id];
|
||||
if(avatar_id === "") {
|
||||
avatar_container.append(this.generate_default_image());
|
||||
} else if(cached_avatar && cached_avatar.avatar_id == avatar_id) {
|
||||
avatar_image.attr("src", cached_avatar.url);
|
||||
avatar_container.append(avatar_image);
|
||||
if(options.callback_image)
|
||||
options.callback_image(avatar_image);
|
||||
if(options.callback_avatar)
|
||||
options.callback_avatar(cached_avatar);
|
||||
} else {
|
||||
let loader_image = $.spawn("img");
|
||||
loader_image.attr("src", "img/loading_image.svg").css("width", "75%");
|
||||
avatar_container.append(loader_image);
|
||||
|
||||
(async () => {
|
||||
let avatar: Avatar;
|
||||
try {
|
||||
avatar = await this.resolved_cached(client_avatar_id, avatar_id);
|
||||
} catch(error) {
|
||||
log.error(LogCategory.CLIENT, error);
|
||||
}
|
||||
|
||||
if(!avatar)
|
||||
avatar = await this.load_avatar(client_avatar_id, avatar_id);
|
||||
|
||||
if(!avatar)
|
||||
throw "failed to load avatar";
|
||||
|
||||
if(options.callback_avatar)
|
||||
options.callback_avatar(avatar);
|
||||
|
||||
avatar_image.attr("src", avatar.url);
|
||||
avatar_image.css("opacity", 0);
|
||||
avatar_container.append(avatar_image);
|
||||
loader_image.animate({opacity: 0}, 50, () => {
|
||||
loader_image.remove();
|
||||
avatar_image.animate({opacity: 1}, 150, () => {
|
||||
if(options.callback_image)
|
||||
options.callback_image(avatar_image);
|
||||
});
|
||||
});
|
||||
})().catch(reason => {
|
||||
log.error(LogCategory.CLIENT, tr("Could not load avatar for id %s. Reason: %s"), client_avatar_id, reason);
|
||||
//TODO Broken image
|
||||
loader_image.addClass("icon client-warning").attr("tag", tr("Could not load avatar ") + client_avatar_id);
|
||||
})
|
||||
}
|
||||
|
||||
return avatar_container;
|
||||
}
|
||||
|
||||
unique_id_2_avatar_id(unique_id: string) {
|
||||
export function uniqueId2AvatarId(unique_id: string) {
|
||||
function str2ab(str) {
|
||||
let buf = new ArrayBuffer(str.length); // 2 bytes for each char
|
||||
let bufView = new Uint8Array(buf);
|
||||
|
@ -277,87 +398,3 @@ export class AvatarManager {
|
|||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private generate_default_image() : JQuery {
|
||||
return $.spawn("img").attr("src", "img/style/avatar.png").css({width: '100%', height: '100%'});
|
||||
}
|
||||
|
||||
generate_chat_tag(client: { id?: number; database_id?: number; }, client_unique_id: string, callback_loaded?: (successfully: boolean, error?: any) => any) : JQuery {
|
||||
let client_handle;
|
||||
if(typeof(client.id) == "number")
|
||||
client_handle = this.handle.connectionHandler.channelTree.findClient(client.id);
|
||||
if(!client_handle && typeof(client.id) == "number") {
|
||||
client_handle = this.handle.connectionHandler.channelTree.find_client_by_dbid(client.database_id);
|
||||
}
|
||||
|
||||
if(client_handle && client_handle.clientUid() !== client_unique_id)
|
||||
client_handle = undefined;
|
||||
|
||||
const container = $.spawn("div").addClass("avatar");
|
||||
if(client_handle && !client_handle.properties.client_flag_avatar)
|
||||
return container.append(this.generate_default_image());
|
||||
|
||||
|
||||
const avatar_id = client_handle ? client_handle.avatarId() : this.unique_id_2_avatar_id(client_unique_id);
|
||||
if(avatar_id) {
|
||||
if(this._cached_avatars[avatar_id]) { /* Test if we're may able to load the client avatar sync without a loading screen */
|
||||
const cache: Avatar = this._cached_avatars[avatar_id];
|
||||
log.debug(LogCategory.GENERAL, tr("Using cached avatar. ID: %o | Version: %o (Cached: %o)"), avatar_id, client_handle ? client_handle.properties.client_flag_avatar : undefined, cache.avatar_id);
|
||||
if(!client_handle || client_handle.properties.client_flag_avatar == cache.avatar_id) {
|
||||
const image = $.spawn("img").attr("src", cache.url).css({width: '100%', height: '100%'});
|
||||
return container.append(image);
|
||||
}
|
||||
}
|
||||
|
||||
const image_loading = $.spawn("img").attr("src", "img/loading_image.svg").css({width: '100%', height: '100%'});
|
||||
|
||||
/* lets actually load the avatar */
|
||||
(async () => {
|
||||
let avatar: Avatar;
|
||||
let loaded_image = this.generate_default_image();
|
||||
|
||||
log.debug(LogCategory.GENERAL, tr("Resolving avatar. ID: %o | Version: %o"), avatar_id, client_handle ? client_handle.properties.client_flag_avatar : undefined);
|
||||
try {
|
||||
//TODO: Cache if avatar load failed and try again in some minutes/may just even consider using the default avatar 'till restart
|
||||
try {
|
||||
avatar = await this.resolved_cached(avatar_id, client_handle ? client_handle.properties.client_flag_avatar : undefined);
|
||||
} catch(error) {
|
||||
log.error(LogCategory.GENERAL, tr("Failed to use cached avatar: %o"), error);
|
||||
}
|
||||
|
||||
if(!avatar)
|
||||
avatar = await this.load_avatar(avatar_id, client_handle ? client_handle.properties.client_flag_avatar : undefined);
|
||||
|
||||
if(!avatar)
|
||||
throw "no avatar present!";
|
||||
|
||||
loaded_image = $.spawn("img").attr("src", avatar.url).css({width: '100%', height: '100%'});
|
||||
} catch(error) {
|
||||
throw error;
|
||||
} finally {
|
||||
container.children().remove();
|
||||
container.append(loaded_image);
|
||||
}
|
||||
})().then(() => callback_loaded && callback_loaded(true)).catch(error => {
|
||||
log.warn(LogCategory.CLIENT, tr("Failed to load chat avatar for client %s. Error: %o"), client_unique_id, error);
|
||||
callback_loaded && callback_loaded(false, error);
|
||||
});
|
||||
|
||||
image_loading.appendTo(container);
|
||||
} else {
|
||||
this.generate_default_image().appendTo(container);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
flush_cache() {
|
||||
this._cached_avatars = undefined;
|
||||
this._loading_promises = undefined;
|
||||
}
|
||||
}
|
||||
(window as any).flush_avatar_cache = async () => {
|
||||
server_connections.all_connections().forEach(e => {
|
||||
e.fileManager.avatars.flush_cache();
|
||||
});
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
//Used by CertAccept popup
|
||||
import {settings} from "tc-shared/settings";
|
||||
import * as loader from "tc-loader";
|
||||
|
||||
|
@ -41,7 +40,7 @@ let category_mapping = new Map<number, string>([
|
|||
[LogCategory.NETWORKING, "Network "],
|
||||
[LogCategory.VOICE, "Voice "],
|
||||
[LogCategory.AUDIO, "Audio "],
|
||||
[LogCategory.CHANNEL, "Chat "],
|
||||
[LogCategory.CHAT, "Chat "],
|
||||
[LogCategory.I18N, "I18N "],
|
||||
[LogCategory.IDENTITIES, "Identities "],
|
||||
[LogCategory.IPC, "IPC "],
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
//Used by CertAccept popup
|
||||
|
||||
import {createErrorModal} from "tc-shared/ui/elements/Modal";
|
||||
import {LogCategory} from "tc-shared/log";
|
||||
import * as loader from "tc-loader";
|
||||
|
@ -395,6 +393,12 @@ export class Settings extends StaticSettings {
|
|||
}
|
||||
};
|
||||
|
||||
static readonly FN_CHANNEL_CHAT_READ: (id: number) => SettingsKey<number> = id => {
|
||||
return {
|
||||
key: 'channel_chat_read_' + id
|
||||
}
|
||||
};
|
||||
|
||||
static readonly KEYS = (() => {
|
||||
const result = [];
|
||||
|
||||
|
|
|
@ -355,7 +355,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
|
|||
if(!singleSelect) return;
|
||||
|
||||
if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) {
|
||||
this.channelTree.client.side_bar.channel_conversations().set_current_channel(this.channelId);
|
||||
this.channelTree.client.side_bar.channel_conversations().setSelectedConversation(this.channelId);
|
||||
this.channelTree.client.side_bar.show_channel_conversations();
|
||||
}
|
||||
}
|
||||
|
@ -419,7 +419,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
|
|||
icon_class: "client-channel_switch",
|
||||
name: bold(tr("Join text channel")),
|
||||
callback: () => {
|
||||
this.channelTree.client.side_bar.channel_conversations().set_current_channel(this.getChannelId());
|
||||
this.channelTree.client.side_bar.channel_conversations().setSelectedConversation(this.getChannelId());
|
||||
this.channelTree.client.side_bar.show_channel_conversations();
|
||||
},
|
||||
visible: !settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)
|
||||
|
@ -595,8 +595,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
|
|||
this.channelTree.moveChannel(this, order, this.parent);
|
||||
} else if(key === "channel_icon_id") {
|
||||
this.properties.channel_icon_id = variable.value as any >>> 0; /* unsigned 32 bit number! */
|
||||
}
|
||||
else if(key == "channel_description") {
|
||||
} else if(key == "channel_description") {
|
||||
this._cached_channel_description = undefined;
|
||||
if(this._cached_channel_description_promise_resolve)
|
||||
this._cached_channel_description_promise_resolve(value);
|
||||
|
@ -604,12 +603,6 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
|
|||
this._cached_channel_description_promise_resolve = undefined;
|
||||
this._cached_channel_description_promise_reject = undefined;
|
||||
}
|
||||
if(key == "channel_flag_conversation_private") {
|
||||
const conversations = this.channelTree.client.side_bar.channel_conversations();
|
||||
const conversation = conversations.conversation(this.channelId, false);
|
||||
if(conversation)
|
||||
conversation.set_flag_private(this.properties.channel_flag_conversation_private);
|
||||
}
|
||||
}
|
||||
/* devel-block(log-channel-property-updates) */
|
||||
group.end();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,299 @@
|
|||
import * as React from "react";
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import {Registry} from "tc-shared/events";
|
||||
|
||||
import '!style-loader!css-loader!emoji-mart/css/emoji-mart.css'
|
||||
import { Picker } from 'emoji-mart'
|
||||
|
||||
const cssStyle = require("./ChatBox.scss");
|
||||
|
||||
interface ChatBoxEvents {
|
||||
action_set_enabled: { enabled: boolean },
|
||||
|
||||
action_request_focus: {},
|
||||
action_submit_message: {
|
||||
message: string
|
||||
},
|
||||
action_insert_text: {
|
||||
text: string
|
||||
},
|
||||
|
||||
notify_typing: {}
|
||||
}
|
||||
|
||||
const EmojiButton = (props: { events: Registry<ChatBoxEvents> }) => {
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const [ enabled, setEnabled ] = useState(false);
|
||||
|
||||
const refContainer = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
if(!shown)
|
||||
return;
|
||||
|
||||
const clickListener = (event: MouseEvent) => {
|
||||
let target = event.target as HTMLElement;
|
||||
while(target && target !== refContainer.current)
|
||||
target = target.parentElement;
|
||||
|
||||
if(target === refContainer.current && target)
|
||||
return;
|
||||
|
||||
setShown(false);
|
||||
};
|
||||
|
||||
document.addEventListener("click", clickListener);
|
||||
return () => document.removeEventListener("click", clickListener);
|
||||
});
|
||||
|
||||
props.events.reactUse("action_set_enabled", event => setEnabled(event.enabled));
|
||||
|
||||
return (
|
||||
<div className={cssStyle.containerEmojis} ref={refContainer}>
|
||||
<div className={cssStyle.button} onClick={() => enabled && setShown(true)}>
|
||||
<img alt={""} src={"img/smiley-smile.svg"} />
|
||||
</div>
|
||||
<div className={cssStyle.picker} style={{ display: shown ? undefined : "none" }}>
|
||||
<Picker
|
||||
set={"twitter"}
|
||||
theme={"light"}
|
||||
showPreview={true}
|
||||
title={""}
|
||||
showSkinTones={true}
|
||||
useButton={false}
|
||||
native={false}
|
||||
|
||||
onSelect={(emoji: any) => {
|
||||
if(enabled) {
|
||||
props.events.fire("action_insert_text", { text: emoji.native });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const pasteTextTransformElement = document.createElement("div");
|
||||
|
||||
const nodeToText = (element: Node) => {
|
||||
if(element instanceof Text) {
|
||||
return element.textContent;
|
||||
} else if(element instanceof HTMLElement) {
|
||||
if(element instanceof HTMLImageElement) {
|
||||
return element.alt || element.title;
|
||||
} else if(element instanceof HTMLBRElement) {
|
||||
return '\n';
|
||||
}
|
||||
|
||||
if(element.children.length > 0)
|
||||
return [...element.childNodes].map(nodeToText).join("");
|
||||
|
||||
return typeof(element.innerText) === "string" ? element.innerText : "";
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const htmlEscape = (message: string) => {
|
||||
pasteTextTransformElement.innerText = message;
|
||||
message = pasteTextTransformElement.innerHTML;
|
||||
return message.replace(/ /g, ' ');
|
||||
};
|
||||
|
||||
const TextInput = (props: { events: Registry<ChatBoxEvents>, enabled?: boolean, placeholder?: string }) => {
|
||||
const [ enabled, setEnabled ] = useState(!!props.enabled);
|
||||
const [ historyIndex, setHistoryIndex ] = useState(-1);
|
||||
const history = useRef([]);
|
||||
const refInput = useRef<HTMLInputElement>();
|
||||
const typingTimeout = useRef(undefined);
|
||||
|
||||
const triggerTyping = () => {
|
||||
if(typeof typingTimeout.current === "number")
|
||||
return;
|
||||
|
||||
props.events.fire("notify_typing");
|
||||
};
|
||||
|
||||
const setHistory = index => {
|
||||
setHistoryIndex(index);
|
||||
refInput.current.innerText = history.current[index] || "";
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(refInput.current);
|
||||
range.collapse(false);
|
||||
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
};
|
||||
|
||||
const pasteHandler = (event: React.ClipboardEvent) => {
|
||||
triggerTyping();
|
||||
event.preventDefault();
|
||||
|
||||
const clipboard = event.clipboardData || (window as any).clipboardData;
|
||||
if(!clipboard) return;
|
||||
|
||||
const raw_text = clipboard.getData('text/plain');
|
||||
const selection = window.getSelection();
|
||||
if (!selection.rangeCount)
|
||||
return false;
|
||||
|
||||
let htmlXML = clipboard.getData('text/html');
|
||||
if(!htmlXML) {
|
||||
pasteTextTransformElement.textContent = raw_text;
|
||||
htmlXML = pasteTextTransformElement.innerHTML;
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const nodes = parser.parseFromString(htmlXML, "text/html");
|
||||
|
||||
let data = nodeToText(nodes.body);
|
||||
|
||||
/* fix prefix & suffix new lines */
|
||||
{
|
||||
let prefix_length = 0, suffix_length = 0;
|
||||
{
|
||||
for (let i = 0; i < raw_text.length; i++)
|
||||
if (raw_text.charAt(i) === '\n')
|
||||
prefix_length++;
|
||||
else if (raw_text.charAt(i) !== '\r')
|
||||
break;
|
||||
|
||||
for (let i = raw_text.length - 1; i >= 0; i++)
|
||||
if (raw_text.charAt(i) === '\n')
|
||||
suffix_length++;
|
||||
else if (raw_text.charAt(i) !== '\r')
|
||||
break;
|
||||
}
|
||||
|
||||
data = data.replace(/^[\n\r]+|[\n\r]+$/g, '');
|
||||
data = "\n".repeat(prefix_length) + data + "\n".repeat(suffix_length);
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
selection.deleteFromDocument();
|
||||
document.execCommand('insertHTML', false, htmlEscape(data));
|
||||
};
|
||||
|
||||
const keyDownHandler = (event: React.KeyboardEvent) => {
|
||||
triggerTyping();
|
||||
|
||||
const inputEmpty = refInput.current.innerText.trim().length === 0;
|
||||
if(event.key === "Enter" && !event.shiftKey) {
|
||||
if(inputEmpty)
|
||||
return;
|
||||
|
||||
const text = refInput.current.innerText;
|
||||
props.events.fire("action_submit_message", { message: text });
|
||||
history.current.push(text);
|
||||
while(history.current.length > 10)
|
||||
history.current.pop_front();
|
||||
|
||||
refInput.current.innerText = "";
|
||||
setHistoryIndex(-1);
|
||||
event.preventDefault();
|
||||
} else if(event.key === "ArrowUp") {
|
||||
const inputOriginal = history.current[historyIndex] === refInput.current.innerText;
|
||||
if(inputEmpty && (historyIndex === -1 || !inputOriginal)) {
|
||||
setHistory(history.current.length - 1);
|
||||
event.preventDefault();
|
||||
} else if(historyIndex > 0 && inputOriginal) {
|
||||
setHistory(historyIndex - 1);
|
||||
event.preventDefault();
|
||||
}
|
||||
} else if(event.key === "ArrowDown") {
|
||||
if(history.current[historyIndex] === refInput.current.innerText) {
|
||||
if(historyIndex < history.current.length - 1) {
|
||||
setHistory(historyIndex + 1);
|
||||
} else {
|
||||
setHistory(-1);
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
props.events.reactUse("action_request_focus", () => refInput.current?.focus());
|
||||
props.events.reactUse("notify_typing", () => {
|
||||
if(typeof typingTimeout.current === "number")
|
||||
return;
|
||||
|
||||
typingTimeout.current = setTimeout(() => typingTimeout.current = undefined, 1000);
|
||||
});
|
||||
props.events.reactUse("action_insert_text", event => refInput.current.innerHTML = refInput.current.innerHTML + event.text);
|
||||
props.events.reactUse("action_set_enabled", event => {
|
||||
setEnabled(event.enabled);
|
||||
if(!event.enabled) {
|
||||
const text = refInput.current.innerText;
|
||||
if(text.trim().length !== 0)
|
||||
history.current.push(text);
|
||||
refInput.current.innerText = "";
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cssStyle.containerInput + " " + (!enabled ? cssStyle.disabled : "")}>
|
||||
<div
|
||||
ref={refInput}
|
||||
className={cssStyle.textarea}
|
||||
contentEditable={enabled}
|
||||
|
||||
placeholder={enabled ? props.placeholder : tr("You can not write here.")}
|
||||
|
||||
onPaste={pasteHandler}
|
||||
onKeyDown={keyDownHandler}
|
||||
|
||||
defaultValue={historyIndex < 0 ? undefined : history[historyIndex]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface ChatBoxProperties {
|
||||
onSubmit?: (text: string) => void;
|
||||
onType?: () => void;
|
||||
}
|
||||
|
||||
export interface ChatBoxState {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export class ChatBox extends React.Component<ChatBoxProperties, ChatBoxState> {
|
||||
readonly events = new Registry<ChatBoxEvents>();
|
||||
private callbackSubmit = event => this.props.onSubmit(event.message);
|
||||
private callbackType = event => this.props.onType && this.props.onType();
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = { enabled: false };
|
||||
this.events.enable_debug("chat-box");
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
this.events.on("action_submit_message", this.callbackSubmit);
|
||||
this.events.on("notify_typing", this.callbackType);
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
this.events.off("action_submit_message", this.callbackSubmit);
|
||||
this.events.off("notify_typing", this.callbackType);
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div className={cssStyle.container}>
|
||||
<div className={cssStyle.chatbox}>
|
||||
<EmojiButton events={this.events} />
|
||||
<TextInput events={this.events} placeholder={tr("Type your message here...")} />
|
||||
</div>
|
||||
<div className={cssStyle.containerHelp}>*italic*, **bold**, ~~strikethrough~~, `code`, and more...</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Readonly<ChatBoxProperties>, prevState: Readonly<ChatBoxState>, snapshot?: any): void {
|
||||
if(prevState.enabled !== this.state.enabled)
|
||||
this.events.fire_async("action_set_enabled", { enabled: this.state.enabled });
|
||||
}
|
||||
}
|
|
@ -0,0 +1,559 @@
|
|||
import * as React from "react";
|
||||
import {ConversationPanel} from "tc-shared/ui/frames/side/Conversations";
|
||||
import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler";
|
||||
import {EventHandler, Registry} from "tc-shared/events";
|
||||
import * as log from "tc-shared/log";
|
||||
import {LogCategory} from "tc-shared/log";
|
||||
import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration";
|
||||
import {ServerCommand} from "tc-shared/connection/ConnectionBase";
|
||||
import {Settings} from "tc-shared/settings";
|
||||
import ReactDOM = require("react-dom");
|
||||
import {traj} from "tc-shared/i18n/localize";
|
||||
import {createErrorModal} from "tc-shared/ui/elements/Modal";
|
||||
|
||||
export type ChatEvent = { timestamp: number; uniqueId: string; } & (ChatEventMessage | ChatEventMessageSendFailed);
|
||||
|
||||
export interface ChatMessage {
|
||||
timestamp: number;
|
||||
message: string;
|
||||
|
||||
sender_name: string;
|
||||
sender_unique_id: string;
|
||||
sender_database_id: number;
|
||||
}
|
||||
|
||||
export interface ChatEventMessage {
|
||||
type: "message";
|
||||
message: ChatMessage;
|
||||
}
|
||||
|
||||
export interface ChatEventMessageSendFailed {
|
||||
type: "message-failed";
|
||||
|
||||
error: "permission" | "error";
|
||||
failedPermission?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface ConversationUIEvents {
|
||||
action_select_conversation: { chatId: number },
|
||||
action_clear_unread_flag: { chatId: number },
|
||||
action_delete_message: { chatId: number, uniqueId: string },
|
||||
action_send_message: { text: string, chatId: number },
|
||||
|
||||
query_conversation_state: { chatId: number }, /* will cause a notify_conversation_state */
|
||||
notify_conversation_state: {
|
||||
id: number,
|
||||
|
||||
mode: "normal" | "no-permissions" | "error" | "loading" | "private",
|
||||
failedPermission?: string,
|
||||
errorMessage?: string;
|
||||
|
||||
unreadTimestamp: number | undefined,
|
||||
events: ChatEvent[],
|
||||
|
||||
haveOlderMessages: boolean,
|
||||
conversationPrivate: boolean
|
||||
},
|
||||
|
||||
notify_panel_show: {},
|
||||
notify_local_client_channel: {
|
||||
channelId: number
|
||||
},
|
||||
notify_chat_event: {
|
||||
conversation: number,
|
||||
triggerUnread: boolean,
|
||||
event: ChatEvent
|
||||
},
|
||||
notify_chat_message_delete: {
|
||||
conversation: number,
|
||||
criteria: { begin: number, end: number, cldbid: number, limit: number }
|
||||
},
|
||||
notify_server_state: {
|
||||
state: "disconnected" | "connected",
|
||||
crossChannelChatSupport?: boolean
|
||||
},
|
||||
notify_unread_timestamp_changed: {
|
||||
conversation: number,
|
||||
timestamp: number | undefined
|
||||
}
|
||||
notify_channel_private_state_changed: {
|
||||
id: number,
|
||||
private: boolean
|
||||
}
|
||||
|
||||
notify_destroy: {}
|
||||
}
|
||||
|
||||
export interface ConversationHistoryResponse {
|
||||
status: "success" | "error" | "no-permission" | "private";
|
||||
|
||||
messages?: ChatMessage[];
|
||||
moreMessages?: boolean;
|
||||
|
||||
errorMessage?: string;
|
||||
failedPermission?: string;
|
||||
}
|
||||
|
||||
export type ConversationMode = "normal" | "loading" | "no-permissions" | "error" | "unloaded";
|
||||
export class Conversation {
|
||||
private readonly handle: ConversationManager;
|
||||
private readonly events: Registry<ConversationUIEvents>;
|
||||
public readonly conversationId: number;
|
||||
|
||||
private mode: ConversationMode = "unloaded";
|
||||
private failedPermission: string;
|
||||
private errorMessage: string;
|
||||
|
||||
public presentMessages: ({ uniqueId: string } & ChatMessage)[] = [];
|
||||
public presentEvents: Exclude<ChatEvent, ChatEventMessage>[] = []; /* everything excluding chat messages */
|
||||
|
||||
private unreadTimestamp: number | undefined = undefined;
|
||||
private lastReadMessage: number = 0;
|
||||
|
||||
private conversationPrivate: boolean = false;
|
||||
private conversationVolatile: boolean = false;
|
||||
|
||||
private queryingHistory = false;
|
||||
private pendingHistoryQueries: (() => Promise<any>)[] = [];
|
||||
public historyQueryResponse: ChatMessage[] = [];
|
||||
|
||||
constructor(handle: ConversationManager, events: Registry<ConversationUIEvents>, id: number) {
|
||||
this.handle = handle;
|
||||
this.conversationId = id;
|
||||
this.events = events;
|
||||
|
||||
this.lastReadMessage = handle.connection.settings.server(Settings.FN_CHANNEL_CHAT_READ(id), Date.now());
|
||||
}
|
||||
|
||||
destroy() { }
|
||||
|
||||
currentMode() : ConversationMode { return this.mode; }
|
||||
|
||||
queryHistory(criteria: { begin?: number, end?: number, limit?: number }) : Promise<ConversationHistoryResponse> {
|
||||
return new Promise<ConversationHistoryResponse>(resolve => {
|
||||
this.pendingHistoryQueries.push(() => {
|
||||
this.historyQueryResponse = [];
|
||||
|
||||
return this.handle.connection.serverConnection.send_command("conversationhistory", {
|
||||
cid: this.conversationId,
|
||||
timestamp_begin: criteria.begin,
|
||||
message_count: criteria.limit,
|
||||
timestamp_end: criteria.end
|
||||
}, { flagset: [ "merge" ], process_result: false }).then(() => {
|
||||
resolve({ status: "success", messages: this.historyQueryResponse, moreMessages: false });
|
||||
}).catch(error => {
|
||||
let errorMessage;
|
||||
if(error instanceof CommandResult) {
|
||||
if(error.id === ErrorID.CONVERSATION_MORE_DATA || error.id === ErrorID.EMPTY_RESULT) {
|
||||
resolve({ status: "success", messages: this.historyQueryResponse, moreMessages: error.id === ErrorID.CONVERSATION_MORE_DATA });
|
||||
return;
|
||||
} else if(error.id === ErrorID.PERMISSION_ERROR) {
|
||||
resolve({
|
||||
status: "no-permission",
|
||||
failedPermission: this.handle.connection.permissions.resolveInfo(parseInt(error.json["failed_permid"]))?.name || tr("unknwon")
|
||||
});
|
||||
return;
|
||||
} else if(error.id === ErrorID.CONVERSATION_IS_PRIVATE) {
|
||||
resolve({
|
||||
status: "private"
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
errorMessage = error.formattedMessage();
|
||||
}
|
||||
} else {
|
||||
log.error(LogCategory.CHAT, tr("Failed to fetch conversation history. %o"), error);
|
||||
errorMessage = tr("lookup the console");
|
||||
}
|
||||
resolve({
|
||||
status: "error",
|
||||
errorMessage: errorMessage
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
this.executeHistoryQuery();
|
||||
});
|
||||
}
|
||||
|
||||
queryCurrentMessages() {
|
||||
this.presentMessages = [];
|
||||
this.mode = "loading";
|
||||
|
||||
this.reportStateToUI();
|
||||
this.queryHistory({ end: 1, limit: 50 }).then(history => {
|
||||
this.conversationPrivate = false;
|
||||
this.conversationVolatile = false;
|
||||
this.failedPermission = undefined;
|
||||
this.errorMessage = undefined;
|
||||
this.presentMessages = history.messages?.map(e => Object.assign({ uniqueId: "m-" + e.timestamp }, e)) || [];
|
||||
|
||||
switch (history.status) {
|
||||
case "error":
|
||||
this.mode = "error";
|
||||
this.errorMessage = history.errorMessage;
|
||||
break;
|
||||
|
||||
case "no-permission":
|
||||
this.mode = "no-permissions";
|
||||
this.failedPermission = history.failedPermission;
|
||||
break;
|
||||
|
||||
case "private":
|
||||
this.mode = "normal";
|
||||
break;
|
||||
|
||||
case "success":
|
||||
this.mode = "normal";
|
||||
break;
|
||||
}
|
||||
|
||||
/* only update the UI if needed */
|
||||
if(this.handle.selectedConversation() === this.conversationId)
|
||||
this.reportStateToUI();
|
||||
});
|
||||
}
|
||||
|
||||
private executeHistoryQuery() {
|
||||
if(this.queryingHistory)
|
||||
return;
|
||||
|
||||
this.queryingHistory = true;
|
||||
try {
|
||||
const promise = this.pendingHistoryQueries.pop_front()();
|
||||
promise
|
||||
.catch(error => log.error(LogCategory.CLIENT, tr("Conversation history query task threw an error; this should never happen: %o"), error))
|
||||
.then(() => this.executeHistoryQuery());
|
||||
} catch (e) {
|
||||
this.queryingHistory = false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public updateIndexFromServer(info: any) {
|
||||
if('error_id' in info) {
|
||||
/* FIXME: Parse error, may be flag private or similar */
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = parseInt(info["timestamp"]);
|
||||
if(isNaN(timestamp))
|
||||
return;
|
||||
|
||||
if(timestamp > this.lastReadMessage) {
|
||||
this.setUnreadTimestamp(this.lastReadMessage);
|
||||
|
||||
/* TODO: Move the whole last read part to the channel entry itself? */
|
||||
this.handle.connection.channelTree.findChannel(this.conversationId)?.setUnread(true);
|
||||
}
|
||||
}
|
||||
|
||||
public handleIncomingMessage(message: ChatMessage, triggerUnread: boolean) {
|
||||
let index = 0;
|
||||
while(index < this.presentMessages.length && this.presentMessages[index].timestamp <= message.timestamp)
|
||||
index++;
|
||||
|
||||
console.log("Insert at: %d", index);
|
||||
this.presentMessages.splice(index, 0, Object.assign({ uniqueId: "m-" + message.timestamp }, message));
|
||||
if(triggerUnread && !this.unreadTimestamp)
|
||||
this.unreadTimestamp = message.timestamp;
|
||||
|
||||
const uMessage = this.presentMessages[index];
|
||||
this.events.fire("notify_chat_event", {
|
||||
conversation: this.conversationId,
|
||||
triggerUnread: triggerUnread,
|
||||
event: {
|
||||
type: "message",
|
||||
timestamp: message.timestamp,
|
||||
message: message,
|
||||
uniqueId: uMessage.uniqueId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public handleDeleteMessages(criteria: { begin: number, end: number, cldbid: number, limit: number }) {
|
||||
let limit = { current: criteria.limit };
|
||||
|
||||
this.presentMessages = this.presentMessages.filter(message => {
|
||||
if(message.sender_database_id !== criteria.cldbid)
|
||||
return true;
|
||||
|
||||
if(criteria.end != 0 && message.timestamp > criteria.end)
|
||||
return true;
|
||||
|
||||
if(criteria.begin != 0 && message.timestamp < criteria.begin)
|
||||
return true;
|
||||
|
||||
return --limit.current < 0;
|
||||
});
|
||||
|
||||
this.events.fire("notify_chat_message_delete", { conversation: this.conversationId, criteria: criteria });
|
||||
}
|
||||
|
||||
public sendMessage(message: string) {
|
||||
this.handle.connection.serverConnection.send_command("sendtextmessage", {
|
||||
targetmode: this.conversationId == 0 ? 3 : 2,
|
||||
cid: this.conversationId,
|
||||
msg: message
|
||||
}, { process_result: false }).catch(error => {
|
||||
this.presentEvents.push({
|
||||
type: "message-failed",
|
||||
uniqueId: "msf-" + Date.now(),
|
||||
timestamp: Date.now(),
|
||||
error: "error",
|
||||
errorMessage: tr("Unknown error TODO!") /* TODO! */
|
||||
});
|
||||
|
||||
this.events.fire_async("notify_chat_event", {
|
||||
conversation: this.conversationId,
|
||||
triggerUnread: false,
|
||||
event: this.presentEvents.last()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public deleteMessage(messageUniqueId: string) {
|
||||
const message = this.presentMessages.find(e => e.uniqueId === messageUniqueId);
|
||||
if(!message) {
|
||||
log.warn(LogCategory.CHAT, tr("Tried to delete an unknown message (id: %s)"), messageUniqueId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.handle.connection.serverConnection.send_command("conversationmessagedelete", {
|
||||
cid: this.conversationId,
|
||||
timestamp_begin: message.timestamp - 1,
|
||||
timestamp_end: message.timestamp + 1,
|
||||
limit: 1,
|
||||
cldbid: message.sender_database_id
|
||||
}, { process_result: false }).catch(error => {
|
||||
log.error(LogCategory.CHAT, tr("Failed to delete conversation message for conversation %d: %o"), this.conversationId, error);
|
||||
if(error instanceof CommandResult)
|
||||
error = error.extra_message || error.message;
|
||||
|
||||
createErrorModal(tr("Failed to delete message"), traj("Failed to delete conversation message{:br:}Error: {}", error)).open();
|
||||
});
|
||||
}
|
||||
|
||||
public reportStateToUI() {
|
||||
this.events.fire_async("notify_conversation_state", {
|
||||
id: this.conversationId,
|
||||
mode: this.mode === "unloaded" ? "loading" : this.mode,
|
||||
unreadTimestamp: this.unreadTimestamp,
|
||||
haveOlderMessages: false,
|
||||
failedPermission: this.failedPermission,
|
||||
conversationPrivate: this.conversationPrivate,
|
||||
|
||||
events: [...this.presentEvents, ...this.presentMessages.map(e => {
|
||||
return {
|
||||
timestamp: e.timestamp,
|
||||
uniqueId: "m-" + e.timestamp,
|
||||
type: "message",
|
||||
message: e
|
||||
} as ChatEvent;
|
||||
})]
|
||||
});
|
||||
}
|
||||
|
||||
public setUnreadTimestamp(timestamp: number | undefined) {
|
||||
if(this.unreadTimestamp === timestamp)
|
||||
return;
|
||||
|
||||
this.unreadTimestamp = timestamp;
|
||||
this.handle.connection.settings.changeServer(Settings.FN_CHANNEL_CHAT_READ(this.conversationId), typeof timestamp === "number" ? timestamp : Date.now());
|
||||
this.events.fire_async("notify_unread_timestamp_changed", { conversation: this.conversationId, timestamp: timestamp });
|
||||
}
|
||||
}
|
||||
|
||||
export class ConversationManager {
|
||||
readonly connection: ConnectionHandler;
|
||||
readonly htmlTag: HTMLDivElement;
|
||||
|
||||
private readonly uiEvents: Registry<ConversationUIEvents>;
|
||||
|
||||
private conversations: {[key: number]: Conversation} = {};
|
||||
private selectedConversation_: number;
|
||||
|
||||
constructor(connection: ConnectionHandler) {
|
||||
this.connection = connection;
|
||||
this.uiEvents = new Registry<ConversationUIEvents>();
|
||||
|
||||
this.htmlTag = document.createElement("div");
|
||||
this.htmlTag.style.display = "flex";
|
||||
this.htmlTag.style.flexDirection = "column";
|
||||
this.htmlTag.style.justifyContent = "stretch";
|
||||
this.htmlTag.style.height = "100%";
|
||||
|
||||
ReactDOM.render(React.createElement(ConversationPanel, { events: this.uiEvents, handler: this.connection }), this.htmlTag);
|
||||
|
||||
this.uiEvents.on("action_select_conversation", event => this.selectedConversation_ = event.chatId);
|
||||
this.uiEvents.on("notify_destroy", connection.events().on("notify_connection_state_changed", event => {
|
||||
if(ConnectionState.socketConnected(event.old_state) !== ConnectionState.socketConnected(event.new_state)) {
|
||||
this.conversations = {};
|
||||
this.setSelectedConversation(-1);
|
||||
}
|
||||
this.uiEvents.fire("notify_server_state", { crossChannelChatSupport: false, state: connection.connected ? "connected" : "disconnected" });
|
||||
}));
|
||||
|
||||
this.uiEvents.register_handler(this);
|
||||
this.uiEvents.on("notify_destroy", connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationhistory", this.handleConversationHistory.bind(this)));
|
||||
this.uiEvents.on("notify_destroy", connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationindex", this.handleConversationIndex.bind(this)));
|
||||
this.uiEvents.on("notify_destroy", connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationmessagedelete", this.handleConversationMessageDelete.bind(this)));
|
||||
|
||||
this.uiEvents.on("notify_destroy", this.connection.channelTree.events.on("notify_channel_list_received", () => {
|
||||
this.queryUnreadFlags();
|
||||
}));
|
||||
|
||||
this.uiEvents.on("notify_destroy", this.connection.channelTree.events.on("notify_channel_updated", event => {
|
||||
/* TODO private flag! */
|
||||
}));
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.uiEvents.unregister_handler(this);
|
||||
this.uiEvents.fire("notify_destroy");
|
||||
this.uiEvents.destroy();
|
||||
}
|
||||
|
||||
selectedConversation() {
|
||||
return this.selectedConversation_;
|
||||
}
|
||||
|
||||
setSelectedConversation(id: number) {
|
||||
this.findOrCreateConversation(id);
|
||||
|
||||
this.uiEvents.fire("action_select_conversation", { chatId: id });
|
||||
}
|
||||
|
||||
findConversation(id: number) : Conversation {
|
||||
for(const conversation of Object.values(this.conversations))
|
||||
if(conversation.conversationId === id)
|
||||
return conversation;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
findOrCreateConversation(id: number) {
|
||||
let conversation = this.findConversation(id);
|
||||
if(!conversation) {
|
||||
conversation = new Conversation(this, this.uiEvents, id);
|
||||
this.conversations[id] = conversation;
|
||||
}
|
||||
|
||||
return conversation;
|
||||
}
|
||||
|
||||
destroyConversation(id: number) {
|
||||
delete this.conversations[id];
|
||||
|
||||
if(id === this.selectedConversation_) {
|
||||
this.uiEvents.fire("action_select_conversation", { chatId: -1 });
|
||||
this.selectedConversation_ = -1;
|
||||
}
|
||||
}
|
||||
|
||||
queryUnreadFlags() {
|
||||
/* FIXME: Test for old TeaSpeak servers which don't support this */
|
||||
const commandData = this.connection.channelTree.channels.map(e => { return { cid: e.channelId, cpw: e.cached_password() }});
|
||||
this.connection.serverConnection.send_command("conversationfetch", commandData).catch(error => {
|
||||
log.warn(LogCategory.CHAT, tr("Failed to query conversation indexes: %o"), error);
|
||||
});
|
||||
}
|
||||
|
||||
handlePanelShow() {
|
||||
this.uiEvents.fire("notify_panel_show");
|
||||
}
|
||||
|
||||
private handleConversationHistory(command: ServerCommand) {
|
||||
const conversation = this.findConversation(parseInt(command.arguments[0]["cid"]));
|
||||
if(!conversation) {
|
||||
log.warn(LogCategory.NETWORKING, tr("Received conversation history for an unknown conversation: %o"), command.arguments[0]["cid"]);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
for(const entry of command.arguments) {
|
||||
conversation.historyQueryResponse.push({
|
||||
timestamp: parseInt(entry["timestamp"]),
|
||||
|
||||
sender_database_id: parseInt(entry["sender_database_id"]),
|
||||
sender_unique_id: entry["sender_unique_id"],
|
||||
sender_name: entry["sender_name"],
|
||||
|
||||
message: entry["msg"]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleConversationIndex(command: ServerCommand) {
|
||||
for(const entry of command.arguments) {
|
||||
const conversation = this.findOrCreateConversation(parseInt(entry["cid"]));
|
||||
conversation.updateIndexFromServer(entry);
|
||||
}
|
||||
}
|
||||
|
||||
private handleConversationMessageDelete(command: ServerCommand) {
|
||||
const data = command.arguments[0];
|
||||
const conversation = this.findConversation(parseInt(data["cid"]));
|
||||
if(!conversation)
|
||||
return;
|
||||
|
||||
conversation.handleDeleteMessages({
|
||||
limit: parseInt(data["limit"]),
|
||||
begin: parseInt(data["timestamp_begin"]),
|
||||
end: parseInt(data["timestamp_end"]),
|
||||
cldbid: parseInt(data["cldbid"])
|
||||
})
|
||||
}
|
||||
|
||||
@EventHandler<ConversationUIEvents>("query_conversation_state")
|
||||
private handleQueryConversationState(event: ConversationUIEvents["query_conversation_state"]) {
|
||||
const conversation = this.findConversation(event.chatId);
|
||||
if(!conversation) {
|
||||
this.uiEvents.fire_async("notify_conversation_state", {
|
||||
mode: "error",
|
||||
errorMessage: tr("Unknown conversation"),
|
||||
|
||||
id: event.chatId,
|
||||
|
||||
events: [],
|
||||
conversationPrivate: false,
|
||||
haveOlderMessages: false,
|
||||
unreadTimestamp: undefined
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if(conversation.currentMode() === "unloaded")
|
||||
conversation.queryCurrentMessages();
|
||||
else
|
||||
conversation.reportStateToUI();
|
||||
}
|
||||
|
||||
@EventHandler<ConversationUIEvents>("action_clear_unread_flag")
|
||||
private handleClearUnreadFlag(event: ConversationUIEvents["action_clear_unread_flag"]) {
|
||||
this.connection.channelTree.findChannel(event.chatId)?.setUnread(false);
|
||||
this.findConversation(event.chatId)?.setUnreadTimestamp(undefined);
|
||||
}
|
||||
|
||||
@EventHandler<ConversationUIEvents>("action_send_message")
|
||||
private handleSendMessage(event: ConversationUIEvents["action_send_message"]) {
|
||||
const conversation = this.findConversation(event.chatId);
|
||||
if(!conversation) {
|
||||
log.error(LogCategory.CLIENT, tr("Tried to send a chat message to an unknown conversation with id %d"), event.chatId);
|
||||
return;
|
||||
}
|
||||
|
||||
conversation.sendMessage(event.text);
|
||||
}
|
||||
|
||||
@EventHandler<ConversationUIEvents>("action_delete_message")
|
||||
private handleMessageDelete(event: ConversationUIEvents["action_delete_message"]) {
|
||||
const conversation = this.findConversation(event.chatId);
|
||||
if(!conversation) {
|
||||
log.error(LogCategory.CLIENT, tr("Tried to delete a chat message from an unknown conversation with id %d"), event.chatId);
|
||||
return;
|
||||
}
|
||||
|
||||
conversation.deleteMessage(event.uniqueId);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,498 @@
|
|||
import * as React from "react";
|
||||
import {EventHandler, ReactEventHandler, Registry} from "tc-shared/events";
|
||||
import {
|
||||
ConversationUIEvents,
|
||||
ChatMessage,
|
||||
ChatEvent
|
||||
} from "tc-shared/ui/frames/side/ConversationManager";
|
||||
import {ChatBox} from "tc-shared/ui/frames/side/ChatBox";
|
||||
import {generate_client} from "tc-shared/ui/htmltags";
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import {bbcode_chat} from "tc-shared/ui/frames/chat";
|
||||
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
|
||||
import {AvatarRenderer} from "tc-shared/ui/react-elements/Avatar";
|
||||
import {format} from "tc-shared/ui/frames/side/chat_helper";
|
||||
import {Translatable} from "tc-shared/ui/react-elements/i18n";
|
||||
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
|
||||
import {XBBCodeRenderer} from "../../../../../vendor/xbbcode/src/react";
|
||||
|
||||
const cssStyle = require("./Conversations.scss");
|
||||
|
||||
const CMTextRenderer = (props: { text: string }) => {
|
||||
const refElement = useRef<HTMLSpanElement>();
|
||||
const elements: HTMLElement[] = [];
|
||||
bbcode_chat(props.text).forEach(e => elements.push(...e));
|
||||
|
||||
useEffect(() => {
|
||||
if(elements.length === 0)
|
||||
return;
|
||||
|
||||
refElement.current.replaceWith(...elements);
|
||||
return () => {
|
||||
/* just so react is happy again */
|
||||
elements[0].replaceWith(refElement.current);
|
||||
elements.forEach(e => e.remove());
|
||||
};
|
||||
});
|
||||
|
||||
return <XBBCodeRenderer>{props.text}</XBBCodeRenderer>
|
||||
};
|
||||
|
||||
const TimestampRenderer = (props: { timestamp: number }) => {
|
||||
const time = format.date.format_chat_time(new Date(props.timestamp));
|
||||
const [ revision, setRevision ] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if(!time.next_update)
|
||||
return;
|
||||
|
||||
const id = setTimeout(() => setRevision(revision + 1), time.next_update);
|
||||
return () => clearTimeout(id);
|
||||
});
|
||||
|
||||
return <>{time.result}</>;
|
||||
};
|
||||
|
||||
const ChatEventMessageRenderer = (props: { message: ChatMessage, callbackDelete?: () => void, events: Registry<ConversationUIEvents>, handler: ConnectionHandler }) => {
|
||||
let deleteButton;
|
||||
|
||||
if(props.callbackDelete) {
|
||||
deleteButton = (
|
||||
<div className={cssStyle.delete} onClick={props.callbackDelete} >
|
||||
<img src="img/icon_conversation_message_delete.svg" alt="X" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cssStyle.containerMessage}>
|
||||
<div className={cssStyle.avatar}>
|
||||
<div className={cssStyle.imageContainer}>
|
||||
<AvatarRenderer avatar={props.handler.fileManager.avatars.resolveClientAvatar({ clientUniqueId: props.message.sender_unique_id, database_id: props.message.sender_database_id })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={cssStyle.message}>
|
||||
<div className={cssStyle.info}>
|
||||
{deleteButton}
|
||||
<a className={cssStyle.sender} dangerouslySetInnerHTML={{ __html: generate_client({
|
||||
client_database_id: props.message.sender_database_id,
|
||||
client_id: -1,
|
||||
client_name: props.message.sender_name,
|
||||
client_unique_id: props.message.sender_unique_id,
|
||||
add_braces: false
|
||||
})}} />
|
||||
<a className={cssStyle.timestamp}><TimestampRenderer timestamp={props.message.timestamp} /></a>
|
||||
<br /> { /* Only for copy purposes */ }
|
||||
</div>
|
||||
<div className={cssStyle.text}>
|
||||
<CMTextRenderer text={props.message.message} />
|
||||
<br style={{ content: " " }} /> { /* Only for copy purposes */ }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TimestampEntry = (props: { timestamp: Date, refDiv: React.Ref<HTMLDivElement> }) => {
|
||||
const diff = format.date.date_format(props.timestamp, new Date());
|
||||
let formatted;
|
||||
let update: boolean;
|
||||
|
||||
if(diff == format.date.ColloquialFormat.YESTERDAY) {
|
||||
formatted = <Translatable key={"yesterday"}>Yesterday</Translatable>;
|
||||
update = true;
|
||||
} else if(diff == format.date.ColloquialFormat.TODAY) {
|
||||
formatted = <Translatable key={"today"}>Today</Translatable>;
|
||||
update = true;
|
||||
} else if(diff == format.date.ColloquialFormat.GENERAL) {
|
||||
formatted = <>{format.date.format_date_general(props.timestamp, false)}</>;
|
||||
update = false;
|
||||
}
|
||||
|
||||
const [ revision, setRevision ] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if(!update)
|
||||
return;
|
||||
|
||||
const nextHour = new Date();
|
||||
nextHour.setUTCMilliseconds(0);
|
||||
nextHour.setUTCMinutes(0);
|
||||
nextHour.setUTCHours(nextHour.getUTCHours() + 1);
|
||||
|
||||
const id = setTimeout(() => {
|
||||
setRevision(revision + 1);
|
||||
}, nextHour.getTime() - Date.now() + 10);
|
||||
return () => clearTimeout(id);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cssStyle.containerTimestamp} ref={props.refDiv}>
|
||||
{formatted}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UnreadEntry = (props: { refDiv: React.Ref<HTMLDivElement> }) => (
|
||||
<div key={"unread"} ref={props.refDiv} className={cssStyle.containerUnread}>
|
||||
<Translatable>Unread messages</Translatable>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface ConversationMessagesProperties {
|
||||
events: Registry<ConversationUIEvents>;
|
||||
handler: ConnectionHandler;
|
||||
}
|
||||
|
||||
interface ConversationMessagesState {
|
||||
mode: "normal" | "loading" | "error" | "private" | "no-permission" | "not-supported" | "unselected";
|
||||
|
||||
scrollOffset: number | "bottom";
|
||||
|
||||
errorMessage?: string;
|
||||
failedPermission?: string;
|
||||
}
|
||||
|
||||
@ReactEventHandler<ConversationMessages>(e => e.props.events)
|
||||
class ConversationMessages extends React.Component<ConversationMessagesProperties, ConversationMessagesState> {
|
||||
private readonly refMessages = React.createRef<HTMLDivElement>();
|
||||
private readonly refUnread = React.createRef<HTMLDivElement>();
|
||||
private readonly refTimestamp = React.createRef<HTMLDivElement>();
|
||||
private readonly refScrollToNewMessages = React.createRef<HTMLDivElement>();
|
||||
|
||||
private conversationId: number = -1;
|
||||
private chatEvents: ChatEvent[] = [];
|
||||
|
||||
private viewElementIndex = 0;
|
||||
private viewEntries: React.ReactElement[] = [];
|
||||
|
||||
private unreadTimestamp: undefined | number;
|
||||
private scrollIgnoreTimestamp: number = 0;
|
||||
|
||||
private currentHistoryFrame = {
|
||||
begin: undefined,
|
||||
end: undefined
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
scrollOffset: "bottom",
|
||||
mode: "unselected",
|
||||
}
|
||||
}
|
||||
|
||||
private scrollToBottom() {
|
||||
requestAnimationFrame(() => {
|
||||
if(this.state.scrollOffset !== "bottom")
|
||||
return;
|
||||
|
||||
if(!this.refMessages.current)
|
||||
return;
|
||||
|
||||
this.scrollIgnoreTimestamp = Date.now();
|
||||
this.refMessages.current.scrollTop = this.refMessages.current.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
private scrollToNewMessage() {
|
||||
requestAnimationFrame(() => {
|
||||
if(!this.refUnread.current)
|
||||
return;
|
||||
|
||||
this.scrollIgnoreTimestamp = Date.now();
|
||||
this.refMessages.current.scrollTop = this.refUnread.current.offsetTop - this.refTimestamp.current.clientHeight;
|
||||
});
|
||||
}
|
||||
|
||||
private scrollToNewMessagesShown() {
|
||||
const newMessageOffset = this.refUnread.current?.offsetTop;
|
||||
return this.state.scrollOffset !== "bottom" && this.refMessages.current?.clientHeight + this.state.scrollOffset < newMessageOffset;
|
||||
}
|
||||
|
||||
render() {
|
||||
let contents = [];
|
||||
|
||||
switch (this.state.mode) {
|
||||
case "error":
|
||||
contents.push(<div key={"ol-error"} className={cssStyle.overlay}><a>{this.state.errorMessage ? this.state.errorMessage : tr("An unknown error happened.")}</a></div>);
|
||||
break;
|
||||
|
||||
case "unselected":
|
||||
contents.push(<div key={"ol-unselected"} className={cssStyle.overlay}><a><Translatable>No conversation selected</Translatable></a></div>);
|
||||
break;
|
||||
|
||||
case "loading":
|
||||
contents.push(<div key={"ol-loading"} className={cssStyle.overlay}><a><Translatable>Loading</Translatable> <LoadingDots maxDots={3}/></a></div>);
|
||||
break;
|
||||
|
||||
case "private":
|
||||
contents.push(<div key={"ol-private"} className={cssStyle.overlay}><a>
|
||||
<Translatable>This conversation is private.</Translatable><br />
|
||||
<Translatable>Join the channel to participate.</Translatable></a>
|
||||
</div>);
|
||||
break;
|
||||
|
||||
case "no-permission":
|
||||
contents.push(<div key={"ol-permission"} className={cssStyle.overlay}><a>
|
||||
<Translatable>You don't have permissions to participate in this conversation!</Translatable><br />
|
||||
<Translatable>{this.state.failedPermission}</Translatable></a>
|
||||
</div>);
|
||||
break;
|
||||
|
||||
case "not-supported":
|
||||
contents.push(<div key={"ol-support"} className={cssStyle.overlay}><a>
|
||||
<Translatable>The target server does not support the cross channel chat system.</Translatable><br />
|
||||
<Translatable>Join the channel if you want to write.</Translatable></a>
|
||||
</div>);
|
||||
break;
|
||||
|
||||
case "normal":
|
||||
if(this.viewEntries.length === 0) {
|
||||
contents.push(<div key={"ol-empty"} className={cssStyle.overlay}><a>
|
||||
<Translatable>There have been no messages yet.</Translatable><br />
|
||||
<Translatable>Be the first who talks in here!</Translatable></a>
|
||||
</div>);
|
||||
} else {
|
||||
contents = this.viewEntries;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cssStyle.containerMessages}>
|
||||
<div
|
||||
className={cssStyle.messages} ref={this.refMessages}
|
||||
onClick={() => this.state.mode === "normal" && this.props.events.fire("action_clear_unread_flag", { chatId: this.conversationId })}
|
||||
onScroll={() => {
|
||||
if(this.scrollIgnoreTimestamp > Date.now())
|
||||
return;
|
||||
|
||||
const top = this.refMessages.current.scrollTop;
|
||||
const total = this.refMessages.current.scrollHeight - this.refMessages.current.clientHeight;
|
||||
const shouldFollow = top + 200 > total;
|
||||
|
||||
this.setState({ scrollOffset: shouldFollow ? "bottom" : top });
|
||||
}}
|
||||
>
|
||||
{contents}
|
||||
</div>
|
||||
<div
|
||||
ref={this.refScrollToNewMessages}
|
||||
className={cssStyle.containerScrollNewMessage + " " + (this.scrollToNewMessagesShown() ? cssStyle.shown : "")}
|
||||
onClick={() => this.setState({ scrollOffset: "bottom" }, () => this.scrollToNewMessage())}
|
||||
>
|
||||
<Translatable>Scroll to new messages</Translatable>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Readonly<ConversationMessagesProperties>, prevState: Readonly<ConversationMessagesState>, snapshot?: any): void {
|
||||
requestAnimationFrame(() => {
|
||||
this.refScrollToNewMessages.current.classList.toggle(cssStyle.shown, this.scrollToNewMessagesShown());
|
||||
});
|
||||
}
|
||||
|
||||
/* builds the view from the messages */
|
||||
private buildView() {
|
||||
this.viewEntries = [];
|
||||
|
||||
let timeMarker = new Date(0);
|
||||
let unreadSet = false, timestampRefSet = false;
|
||||
|
||||
for(let event of this.chatEvents) {
|
||||
const mdate = new Date(event.timestamp);
|
||||
if(mdate.getFullYear() !== timeMarker.getFullYear() || mdate.getMonth() !== timeMarker.getMonth() || mdate.getDate() !== timeMarker.getDate()) {
|
||||
timeMarker = new Date(mdate.getFullYear(), mdate.getMonth(), mdate.getDate(), 1);
|
||||
this.viewEntries.push(<TimestampEntry key={"t" + this.viewElementIndex++} timestamp={timeMarker} refDiv={timestampRefSet ? undefined : this.refTimestamp} />);
|
||||
timestampRefSet = true;
|
||||
}
|
||||
|
||||
if(event.timestamp >= this.unreadTimestamp && !unreadSet) {
|
||||
this.viewEntries.push(<UnreadEntry refDiv={this.refUnread} key={"u" + this.viewElementIndex++} />);
|
||||
unreadSet = true;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case "message":
|
||||
this.viewEntries.push(<ChatEventMessageRenderer
|
||||
key={event.uniqueId}
|
||||
message={event.message}
|
||||
events={this.props.events}
|
||||
callbackDelete={() => this.props.events.fire("action_delete_message", { chatId: this.conversationId, uniqueId: event.uniqueId })}
|
||||
handler={this.props.handler} />);
|
||||
break;
|
||||
|
||||
case "message-failed":
|
||||
/* TODO! */
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler<ConversationUIEvents>("notify_server_state")
|
||||
private handleNotifyServerState(event: ConversationUIEvents["notify_server_state"]) {
|
||||
if(event.state === "connected")
|
||||
return;
|
||||
|
||||
this.setState({ mode: "unselected" });
|
||||
}
|
||||
|
||||
@EventHandler<ConversationUIEvents>("action_select_conversation")
|
||||
private handleSelectConversation(event: ConversationUIEvents["action_select_conversation"]) {
|
||||
if(this.conversationId === event.chatId)
|
||||
return;
|
||||
|
||||
this.conversationId = event.chatId;
|
||||
this.chatEvents = [];
|
||||
this.currentHistoryFrame = { begin: undefined, end: undefined };
|
||||
|
||||
if(this.conversationId < 0) {
|
||||
this.setState({ mode: "unselected" });
|
||||
} else {
|
||||
this.props.events.fire("query_conversation_state", {
|
||||
chatId: this.conversationId
|
||||
});
|
||||
|
||||
this.setState({ mode: "loading" });
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler<ConversationUIEvents>("notify_conversation_state")
|
||||
private handleConversationStateUpdate(event: ConversationUIEvents["notify_conversation_state"]) {
|
||||
if(event.id !== this.conversationId)
|
||||
return;
|
||||
|
||||
if(event.mode === "no-permissions") {
|
||||
this.setState({
|
||||
mode: "no-permission",
|
||||
failedPermission: event.failedPermission
|
||||
});
|
||||
} else if(event.mode === "loading") {
|
||||
this.setState({
|
||||
mode: "loading"
|
||||
});
|
||||
} else if(event.mode === "normal") {
|
||||
this.unreadTimestamp = event.unreadTimestamp;
|
||||
this.chatEvents = event.events;
|
||||
this.buildView();
|
||||
|
||||
this.setState({
|
||||
mode: "normal",
|
||||
scrollOffset: "bottom"
|
||||
}, () => this.scrollToBottom());
|
||||
} else {
|
||||
this.setState({
|
||||
mode: "error",
|
||||
errorMessage: event.errorMessage || tr("Unknown error/Invalid state")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler<ConversationUIEvents>("notify_chat_event")
|
||||
private handleMessageReceived(event: ConversationUIEvents["notify_chat_event"]) {
|
||||
if(event.conversation !== this.conversationId)
|
||||
return;
|
||||
|
||||
this.chatEvents.push(event.event);
|
||||
if(typeof this.unreadTimestamp === "undefined" && event.triggerUnread)
|
||||
this.unreadTimestamp = event.event.timestamp;
|
||||
|
||||
this.buildView();
|
||||
this.forceUpdate(() => this.scrollToBottom());
|
||||
}
|
||||
|
||||
@EventHandler<ConversationUIEvents>("notify_chat_message_delete")
|
||||
private handleMessageDeleted(event: ConversationUIEvents["notify_chat_message_delete"]) {
|
||||
if(event.conversation !== this.conversationId)
|
||||
return;
|
||||
|
||||
let limit = { current: event.criteria.limit };
|
||||
this.chatEvents = this.chatEvents.filter(mEvent => {
|
||||
if(mEvent.type !== "message")
|
||||
return;
|
||||
|
||||
const message = mEvent.message;
|
||||
if(message.sender_database_id !== event.criteria.cldbid)
|
||||
return true;
|
||||
|
||||
if(event.criteria.end != 0 && message.timestamp > event.criteria.end)
|
||||
return true;
|
||||
|
||||
if(event.criteria.begin != 0 && message.timestamp < event.criteria.begin)
|
||||
return true;
|
||||
|
||||
return --limit.current < 0;
|
||||
});
|
||||
|
||||
this.buildView();
|
||||
this.forceUpdate(() => this.scrollToBottom());
|
||||
}
|
||||
|
||||
@EventHandler<ConversationUIEvents>("action_clear_unread_flag")
|
||||
private handleMessageUnread(event: ConversationUIEvents["action_clear_unread_flag"]) {
|
||||
if (event.chatId !== this.conversationId || this.unreadTimestamp === undefined)
|
||||
return;
|
||||
|
||||
this.unreadTimestamp = undefined;
|
||||
this.buildView();
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
@EventHandler<ConversationUIEvents>("notify_panel_show")
|
||||
private handlePanelShow() {
|
||||
if(this.refUnread.current) {
|
||||
this.scrollToNewMessage();
|
||||
} else if(this.state.scrollOffset === "bottom") {
|
||||
this.scrollToBottom();
|
||||
} else {
|
||||
requestAnimationFrame(() => {
|
||||
if(this.state.scrollOffset === "bottom")
|
||||
return;
|
||||
|
||||
this.scrollIgnoreTimestamp = Date.now() + 250;
|
||||
this.refMessages.current.scrollTop = this.state.scrollOffset;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ConversationPanel = (props: { events: Registry<ConversationUIEvents>, handler: ConnectionHandler }) => {
|
||||
const currentChat = useRef({ id: -1 });
|
||||
const chatEnabled = useRef(false);
|
||||
|
||||
const refChatBox = useRef<ChatBox>();
|
||||
let connected = false;
|
||||
|
||||
const updateChatBox = () => {
|
||||
refChatBox.current.setState({ enabled: connected && currentChat.current.id >= 0 && chatEnabled.current });
|
||||
};
|
||||
|
||||
props.events.reactUse("notify_server_state", event => { connected = event.state === "connected"; updateChatBox(); });
|
||||
props.events.reactUse("action_select_conversation", event => {
|
||||
currentChat.current.id = event.chatId;
|
||||
updateChatBox();
|
||||
});
|
||||
props.events.reactUse("notify_conversation_state", event => {
|
||||
chatEnabled.current = event.mode === "normal" || event.mode === "private";
|
||||
updateChatBox();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return refChatBox.current.events.on("notify_typing", () => props.events.fire("action_clear_unread_flag", { chatId: currentChat.current.id }));
|
||||
});
|
||||
|
||||
return <div className={cssStyle.panel}>
|
||||
<ConversationMessages events={props.events} handler={props.handler} />
|
||||
<ChatBox
|
||||
ref={refChatBox}
|
||||
onSubmit={text => props.events.fire("action_send_message", { chatId: currentChat.current.id, text: text }) }
|
||||
/>
|
||||
</div>
|
||||
};
|
|
@ -2,7 +2,8 @@ import * as log from "tc-shared/log";
|
|||
import {LogCategory} from "tc-shared/log";
|
||||
import {Settings, settings} from "tc-shared/settings";
|
||||
|
||||
declare const xbbcode;
|
||||
const escapeBBCode = (text: string) => text.replace(/([\[\]])/g, "\\$1");
|
||||
|
||||
export namespace helpers {
|
||||
//https://regex101.com/r/YQbfcX/2
|
||||
//static readonly URL_REGEX = /^(?<hostname>([a-zA-Z0-9-]+\.)+[a-zA-Z0-9-]{2,63})(?:\/(?<path>(?:[^\s?]+)?)(?:\?(?<query>\S+))?)?$/gm;
|
||||
|
@ -142,8 +143,8 @@ test
|
|||
```
|
||||
*/
|
||||
|
||||
"code": (renderer: Renderer, token: RemarkToken) => "[i-code]" + xbbcode.escape(token.content) + "[/i-code]",
|
||||
"fence": (renderer: Renderer, token: RemarkToken) => "[code" + (token.params ? ("=" + token.params) : "") + "]" + xbbcode.escape(token.content) + "[/code]",
|
||||
"code": (renderer: Renderer, token: RemarkToken) => "[i-code]" + escapeBBCode(token.content) + "[/i-code]",
|
||||
"fence": (renderer: Renderer, token: RemarkToken) => "[code" + (token.params ? ("=" + token.params) : "") + "]" + escapeBBCode(token.content) + "[/code]",
|
||||
|
||||
"heading_open": (renderer: Renderer, token: RemarkToken) => "[size=" + (9 - Math.min(4, token.hLevel)) + "]",
|
||||
"heading_close": (renderer: Renderer, token: RemarkToken) => "[/size][hr]",
|
||||
|
@ -151,8 +152,8 @@ test
|
|||
"hr": () => "[hr]",
|
||||
|
||||
//> Experience real-time editing with Remarkable!
|
||||
//blockquote_open,
|
||||
//blockquote_close
|
||||
"blockquote_open": () => "[quote]",
|
||||
"blockquote_close": () => "[/quote]"
|
||||
};
|
||||
|
||||
private _options;
|
||||
|
@ -205,7 +206,7 @@ test
|
|||
|
||||
maybe_escape_bb(text: string) {
|
||||
if(this._options.escape_bb)
|
||||
return xbbcode.escape(text);
|
||||
return escapeBBCode(text);
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
@ -249,7 +250,7 @@ test
|
|||
});
|
||||
|
||||
if(escape_bb)
|
||||
message = xbbcode.escape(message);
|
||||
message = escapeBBCode(message);
|
||||
return process_url ? process_urls(message) : message;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
import * as React from "react";
|
||||
import {ClientAvatar} from "tc-shared/file/Avatars";
|
||||
import {useState} from "react";
|
||||
|
||||
const ImageStyle = { height: "100%", width: "100%" };
|
||||
export const AvatarRenderer = (props: { avatar: ClientAvatar, className?: string }) => {
|
||||
let [ revision, setRevision ] = useState(0);
|
||||
|
||||
let image;
|
||||
switch (props.avatar.state) {
|
||||
case "unset":
|
||||
image = <img key={"default"} title={tr("default avatar")} alt={tr("default avatar")} src={props.avatar.avatarUrl} style={ImageStyle} />;
|
||||
break;
|
||||
|
||||
case "loaded":
|
||||
image = <img key={"user-" + props.avatar.currentAvatarHash} alt={tr("user avatar")} title={tr("user avatar")} src={props.avatar.avatarUrl} style={ImageStyle} />;
|
||||
break;
|
||||
|
||||
case "errored":
|
||||
image = <img key={"error"} alt={tr("error")} title={tr("avatar failed to load:\n") + props.avatar.loadError} src={props.avatar.avatarUrl} style={ImageStyle} />;
|
||||
break;
|
||||
|
||||
case "loading":
|
||||
image = <img key={"loading"} alt={tr("loading")} title={tr("loading avatar")} src={"img/loading_image.svg"} style={ImageStyle} />;
|
||||
break;
|
||||
}
|
||||
|
||||
props.avatar.events.reactUse("avatar_state_changed", () => setRevision(revision + 1));
|
||||
|
||||
return (
|
||||
<div className={props.className} style={{ overflow: "hidden" }}>
|
||||
{image}
|
||||
</div>
|
||||
)
|
||||
};
|
|
@ -181,7 +181,7 @@ export class ServerEntry extends ChannelTreeEntry<ServerEvents> {
|
|||
if(!singleSelect) return;
|
||||
|
||||
if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) {
|
||||
this.channelTree.client.side_bar.channel_conversations().set_current_channel(0);
|
||||
this.channelTree.client.side_bar.channel_conversations().setSelectedConversation(0);
|
||||
this.channelTree.client.side_bar.show_channel_conversations();
|
||||
}
|
||||
}
|
||||
|
@ -208,7 +208,7 @@ export class ServerEntry extends ChannelTreeEntry<ServerEvents> {
|
|||
icon_class: "client-channel_switch",
|
||||
name: tr("Join server text channel"),
|
||||
callback: () => {
|
||||
this.channelTree.client.side_bar.channel_conversations().set_current_channel(0);
|
||||
this.channelTree.client.side_bar.channel_conversations().setSelectedConversation(0);
|
||||
this.channelTree.client.side_bar.show_channel_conversations();
|
||||
},
|
||||
visible: !settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)
|
||||
|
|
|
@ -52,7 +52,9 @@ export interface ChannelTreeEvents {
|
|||
channel: ChannelEntry,
|
||||
channelProperties: ChannelProperties,
|
||||
updatedProperties: ChannelProperties
|
||||
}
|
||||
},
|
||||
|
||||
notify_channel_list_received: {}
|
||||
}
|
||||
|
||||
export class ChannelTreeEntrySelect {
|
||||
|
|
|
@ -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": [
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit 4fe112d7ab4e2a9a94afc8cbeecc6e890723e61d
|
||||
Subproject commit b1559dacda7bbb1072830e0e910bdd1bcb12c59f
|
|
@ -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: [
|
||||
|
|
Loading…
Reference in New Issue