From e377a77e32069aa7a1d98040c344117b9a56f4d0 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Mon, 20 Jul 2020 19:08:13 +0200 Subject: [PATCH] Added the basics for popoutable windows and a CSS style editor --- ChangeLog.md | 6 + loader/app/animation.ts | 11 +- loader/app/index.ts | 18 +- loader/app/loader/loader.ts | 6 +- loader/app/loader/utils.ts | 8 + loader/app/maifest.ts | 64 ++ loader/app/targets/app.ts | 100 +-- loader/app/targets/empty.ts | 40 +- loader/app/targets/maifest-target.ts | 87 +++ loader/app/targets/shared.ts | 2 +- loader/css/index.scss | 7 + package-lock.json | 303 +++++++- package.json | 1 + shared/css/static/frame-chat.scss | 59 +- shared/css/static/general.scss | 43 +- shared/js/BrowserIPC.ts | 725 ------------------ shared/js/ConnectionHandler.ts | 12 +- shared/js/devel_main.ts | 28 + shared/js/events.ts | 155 ++-- shared/js/file/Avatars.ts | 164 ++++ shared/js/file/FileManager.tsx | 2 +- .../js/file/{Avatars.tsx => LocalAvatars.ts} | 304 ++++---- shared/js/file/RemoteAvatars.ts | 227 ++++++ shared/js/ipc/BrowserIPC.ts | 287 +++++++ shared/js/ipc/ConnectHandler.ts | 230 ++++++ shared/js/ipc/MethodProxy.ts | 216 ++++++ shared/js/main.tsx | 17 +- shared/js/settings.ts | 16 +- shared/js/text/bbcode/highlight.scss | 36 + shared/js/text/bbcode/highlight.tsx | 4 +- shared/js/text/bbcode/renderer.ts | 8 +- shared/js/ui/client.ts | 2 +- shared/js/ui/frames/MenuBar.ts | 6 + shared/js/ui/frames/connection_handlers.ts | 6 +- shared/js/ui/frames/control-bar/index.tsx | 2 +- shared/js/ui/frames/side/ChatBox.scss | 37 +- shared/js/ui/frames/side/ChatBox.tsx | 5 +- .../ui/frames/side/ConversationDefinitions.ts | 3 + .../js/ui/frames/side/ConversationManager.ts | 17 +- shared/js/ui/frames/side/ConversationUI.scss | 99 ++- shared/js/ui/frames/side/ConversationUI.tsx | 36 +- .../ui/frames/side/PopoutConversationUI.tsx | 31 + .../frames/side/PrivateConversationManager.ts | 2 +- .../ui/frames/side/PrivateConversationUI.scss | 38 +- .../ui/frames/side/PrivateConversationUI.tsx | 2 +- shared/js/ui/frames/side/music_info.ts | 55 +- shared/js/ui/modal/ModalGroupCreate.tsx | 2 +- shared/js/ui/modal/ModalMusicManage.ts | 2 +- shared/js/ui/modal/ModalNewcomer.ts | 4 +- shared/js/ui/modal/ModalSettings.tsx | 2 +- shared/js/ui/modal/css-editor/Controller.ts | 187 +++++ shared/js/ui/modal/css-editor/Definitions.ts | 29 + shared/js/ui/modal/css-editor/Renderer.scss | 259 +++++++ shared/js/ui/modal/css-editor/Renderer.tsx | 421 ++++++++++ .../permission/ModalPermissionEditor.tsx | 4 +- .../ui/modal/transfer/ModalFileTransfer.tsx | 4 +- shared/js/ui/react-elements/Avatar.tsx | 19 +- shared/js/ui/react-elements/Checkbox.scss | 2 +- shared/js/ui/react-elements/Checkbox.tsx | 15 +- shared/js/ui/react-elements/InputField.scss | 28 +- shared/js/ui/react-elements/InputField.tsx | 8 +- shared/js/ui/react-elements/LoadingDots.tsx | 5 +- shared/js/ui/react-elements/Modal.tsx | 24 +- shared/js/ui/react-elements/Switch.scss | 48 +- shared/js/ui/react-elements/Tooltip.scss | 2 + .../external-modal/Controller.ts | 156 ++++ .../external-modal/IPCMessage.ts | 113 +++ .../external-modal/PopoutController.ts | 90 +++ .../external-modal/PopoutEntrypoint.ts | 81 ++ .../external-modal/PopoutRegistry.ts | 33 + .../external-modal/PopoutRenderer.scss | 31 + .../external-modal/PopoutRenderer.tsx | 51 ++ .../ui/react-elements/external-modal/index.ts | 7 + shared/js/ui/view.tsx | 2 +- shared/js/video-viewer/Controller.tsx | 36 + shared/js/video-viewer/Definitions.ts | 6 + shared/js/video-viewer/Renderer.tsx | 39 + webpack.config.ts | 8 +- 78 files changed, 3962 insertions(+), 1283 deletions(-) create mode 100644 loader/app/maifest.ts create mode 100644 loader/app/targets/maifest-target.ts delete mode 100644 shared/js/BrowserIPC.ts create mode 100644 shared/js/devel_main.ts create mode 100644 shared/js/file/Avatars.ts rename shared/js/file/{Avatars.tsx => LocalAvatars.ts} (57%) create mode 100644 shared/js/file/RemoteAvatars.ts create mode 100644 shared/js/ipc/BrowserIPC.ts create mode 100644 shared/js/ipc/ConnectHandler.ts create mode 100644 shared/js/ipc/MethodProxy.ts create mode 100644 shared/js/text/bbcode/highlight.scss create mode 100644 shared/js/ui/frames/side/PopoutConversationUI.tsx create mode 100644 shared/js/ui/modal/css-editor/Controller.ts create mode 100644 shared/js/ui/modal/css-editor/Definitions.ts create mode 100644 shared/js/ui/modal/css-editor/Renderer.scss create mode 100644 shared/js/ui/modal/css-editor/Renderer.tsx create mode 100644 shared/js/ui/react-elements/external-modal/Controller.ts create mode 100644 shared/js/ui/react-elements/external-modal/IPCMessage.ts create mode 100644 shared/js/ui/react-elements/external-modal/PopoutController.ts create mode 100644 shared/js/ui/react-elements/external-modal/PopoutEntrypoint.ts create mode 100644 shared/js/ui/react-elements/external-modal/PopoutRegistry.ts create mode 100644 shared/js/ui/react-elements/external-modal/PopoutRenderer.scss create mode 100644 shared/js/ui/react-elements/external-modal/PopoutRenderer.tsx create mode 100644 shared/js/ui/react-elements/external-modal/index.ts create mode 100644 shared/js/video-viewer/Controller.tsx create mode 100644 shared/js/video-viewer/Definitions.ts create mode 100644 shared/js/video-viewer/Renderer.tsx diff --git a/ChangeLog.md b/ChangeLog.md index 186f1b09..213f605e 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,4 +1,10 @@ # Changelog: +* **20.07.20** + - Some general project cleanup + - Heavily improved the IPC internal API + - Added a basic API for popout able modals + - Added a CSS variable editor + * **18.07.20** - Rewrote the channel conversation UI and manager - Several bug fixes like the scrollbar diff --git a/loader/app/animation.ts b/loader/app/animation.ts index 6ada9777..dc23a339 100644 --- a/loader/app/animation.ts +++ b/loader/app/animation.ts @@ -1,5 +1,6 @@ import * as loader from "./loader/loader"; import {Stage} from "./loader/loader"; +import {getUrlParameter} from "./loader/utils"; let overlay: HTMLDivElement; let setupContainer: HTMLDivElement; @@ -92,10 +93,14 @@ export function abort() { } export function finalize() { - finalizing = true; + if(getUrlParameter("loader-abort") === "1") { + abort(); + } else { + finalizing = true; - if(loaderStageContainer) - loaderStageContainer.innerText = "app loaded successfully (" + (Date.now() - initializeTimestamp) + "ms)"; + if(loaderStageContainer) + loaderStageContainer.innerText = "app loaded successfully (" + (Date.now() - initializeTimestamp) + "ms)"; + } } const StageNames = {}; diff --git a/loader/app/index.ts b/loader/app/index.ts index 8276a1f4..710adf41 100644 --- a/loader/app/index.ts +++ b/loader/app/index.ts @@ -2,14 +2,24 @@ import "core-js/stable"; import "./polifill"; import * as loader from "./loader/loader"; +import {ApplicationLoader} from "./loader/loader"; +import {getUrlParameter} from "./loader/utils"; + window["loader"] = loader; /* let the loader register himself at the window first */ -import * as AppLoader from "./targets/app"; -setTimeout(AppLoader.run, 0); +const target = getUrlParameter("loader-target") || "app"; +console.error("Loading app with loader \"%s\"", target); -import * as EmptyLoader from "./targets/empty"; -//setTimeout(EmptyLoader.run, 0); +let appLoader: ApplicationLoader; +if(target === "empty") { + appLoader = new (require("./targets/empty").default); +} else if(target === "manifest") { + appLoader = new (require("./targets/maifest-target").default); +} else { + appLoader = new (require("./targets/app").default); +} +setTimeout(() => appLoader.execute(), 0); export {}; diff --git a/loader/app/loader/loader.ts b/loader/app/loader/loader.ts index 4e08529d..54904c68 100644 --- a/loader/app/loader/loader.ts +++ b/loader/app/loader/loader.ts @@ -14,7 +14,11 @@ declare global { } const tr: typeof window.tr; - const tra: typeof window.tr; + const tra: typeof window.tra; +} + +export interface ApplicationLoader { + execute(); } export interface Config { diff --git a/loader/app/loader/utils.ts b/loader/app/loader/utils.ts index 8dfc717d..8d395e58 100644 --- a/loader/app/loader/utils.ts +++ b/loader/app/loader/utils.ts @@ -1,6 +1,14 @@ import {SourcePath} from "./loader"; import {Options} from "./script_loader"; +export const getUrlParameter = key => { + const match = location.search.match(new RegExp("(.*[?&]|^)" + key + "=([^&]+)($|&.*)")); + if(!match) + return undefined; + + return match[2]; +}; + export class LoadSyntaxError { readonly source: any; constructor(source: any) { diff --git a/loader/app/maifest.ts b/loader/app/maifest.ts new file mode 100644 index 00000000..d89cf301 --- /dev/null +++ b/loader/app/maifest.ts @@ -0,0 +1,64 @@ +import * as loader from "./loader/loader"; +import {config} from "./loader/loader"; +import {script_name} from "./loader/utils"; + +export interface TeaManifest { + version: number; + + chunks: { + [key: string]: { + files: { + hash: string, + file: string + }[], + modules: { + id: string, + context: string, + resource: string + }[] + } + }; +} + +let manifest: TeaManifest; +export async function loadManifest() : Promise { + if(manifest) { + return manifest; + } + + try { + const response = await fetch(config.baseUrl + "js/manifest.json"); + if(!response.ok) throw response.status + " " + response.statusText; + + manifest = await response.json(); + } catch(error) { + console.error("Failed to load javascript manifest: %o", error); + loader.critical_error("Failed to load manifest.json", error); + throw "failed to load manifest.json"; + } + if(manifest.version !== 2) + throw "invalid manifest version"; + + return manifest; +} + +export async function loadManifestTarget(chunkName: string, taskId: number) { + if(typeof manifest.chunks[chunkName] !== "object") { + loader.critical_error("Missing entry chunk in manifest.json", "Chunk " + chunkName + " is missing."); + throw "missing entry chunk"; + } + loader.module_mapping().push({ + application: chunkName, + modules: manifest.chunks[chunkName].modules + }); + + await loader.scripts.load_multiple(manifest.chunks[chunkName].files.map(e => "js/" + e.file), { + cache_tag: undefined, + max_parallel_requests: -1 + }, (script, state) => { + if(state !== "loading") + return; + + loader.setCurrentTaskName(taskId, script_name(script, false)); + }); +} \ No newline at end of file diff --git a/loader/app/targets/app.ts b/loader/app/targets/app.ts index 77783b91..085b42df 100644 --- a/loader/app/targets/app.ts +++ b/loader/app/targets/app.ts @@ -1,8 +1,8 @@ import "./shared"; import * as loader from "../loader/loader"; -import {config, SourcePath} from "../loader/loader"; +import {ApplicationLoader, config, SourcePath} from "../loader/loader"; import {script_name} from "../loader/utils"; -import { detect as detectBrowser } from "detect-browser"; +import {loadManifest, loadManifestTarget} from "../maifest"; declare global { interface Window { @@ -31,22 +31,6 @@ export function ui_version() { return _ui_version; } -interface Manifest { - version: number; - - chunks: {[key: string]: { - files: { - hash: string, - file: string - }[], - modules: { - id: string, - context: string, - resource: string - }[] - }}; -} - const LoaderTaskCallback = taskId => (script: SourcePath, state) => { if(state !== "loading") return; @@ -62,33 +46,8 @@ const loader_javascript = { } loader.setCurrentTaskName(taskId, "manifest"); - let manifest: Manifest; - try { - const response = await fetch(config.baseUrl + "js/manifest.json"); - if(!response.ok) throw response.status + " " + response.statusText; - - manifest = await response.json(); - } catch(error) { - console.error("Failed to load javascript manifest: %o", error); - loader.critical_error("Failed to load manifest.json", error); - throw "failed to load manifest.json"; - } - if(manifest.version !== 2) - throw "invalid manifest version"; - - const chunk_name = __build.entry_chunk_name; - if(typeof manifest.chunks[chunk_name] !== "object") { - loader.critical_error("Missing entry chunk in manifest.json", "Chunk " + chunk_name + " is missing."); - throw "missing entry chunk"; - } - loader.module_mapping().push({ - application: chunk_name, - modules: manifest.chunks[chunk_name].modules - }); - await loader.scripts.load_multiple(manifest.chunks[chunk_name].files.map(e => "js/" + e.file), { - cache_tag: undefined, - max_parallel_requests: -1 - }, LoaderTaskCallback(taskId)); + await loadManifest(); + await loadManifestTarget(__build.entry_chunk_name, taskId); } }; @@ -286,37 +245,36 @@ loader.register_task(loader.Stage.SETUP, { priority: 100 }); -export function run() { - /* TeaClient */ - if(node_require) { - if(__build.target !== "client") { - loader.critical_error("App seems not to be compiled for the client.", "This app has been compiled for " + __build.target); - return; - } - window.native_client = true; +export default class implements ApplicationLoader { + execute() { + /* TeaClient */ + if(node_require) { + if(__build.target !== "client") { + loader.critical_error("App seems not to be compiled for the client.", "This app has been compiled for " + __build.target); + return; + } + window.native_client = true; - const path = node_require("path"); - const remote = node_require('electron').remote; + const path = node_require("path"); + const remote = node_require('electron').remote; - const render_entry = path.join(remote.app.getAppPath(), "/modules/", "renderer"); - const render = node_require(render_entry); + const render_entry = path.join(remote.app.getAppPath(), "/modules/", "renderer"); + const render = node_require(render_entry); - loader.register_task(loader.Stage.INITIALIZING, { - name: "teaclient initialize", - function: render.initialize, - priority: 40 - }); - } else { - if(__build.target !== "web") { - loader.critical_error("App seems not to be compiled for the web.", "This app has been compiled for " + __build.target); - return; + loader.register_task(loader.Stage.INITIALIZING, { + name: "teaclient initialize", + function: render.initialize, + priority: 40 + }); + } else { + if(__build.target !== "web") { + loader.critical_error("App seems not to be compiled for the web.", "This app has been compiled for " + __build.target); + return; + } + + window.native_client = false; } - window.native_client = false; - } - - if(!loader.running()) { - /* we know that we want to load the app */ loader.execute_managed(); } } \ No newline at end of file diff --git a/loader/app/targets/empty.ts b/loader/app/targets/empty.ts index 45172a62..7ca4a1e0 100644 --- a/loader/app/targets/empty.ts +++ b/loader/app/targets/empty.ts @@ -1,27 +1,29 @@ import "./shared"; import * as loader from "../loader/loader"; -import {Stage} from "../loader/loader"; +import {ApplicationLoader, Stage} from "../loader/loader"; -export function run() { - loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { - name: "doing nothing", - priority: 1, - function: async taskId => { - console.log("Doing nothing"); +export default class implements ApplicationLoader { + execute() { + loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { + name: "doing nothing", + priority: 1, + function: async taskId => { + console.log("Doing nothing"); - for(let index of [1, 2, 3]) { - await new Promise(resolve => { - const callback = () => { - document.removeEventListener("click", resolve); - resolve(); - }; + for(let index of [1, 2, 3]) { + await new Promise(resolve => { + const callback = () => { + document.removeEventListener("click", resolve); + resolve(); + }; - document.addEventListener("click", callback); - }); - loader.setCurrentTaskName(taskId, "try again (" + index + ")"); + document.addEventListener("click", callback); + }); + loader.setCurrentTaskName(taskId, "try again (" + index + ")"); + } } - } - }); + }); - loader.execute_managed(); + loader.execute_managed(); + } } \ No newline at end of file diff --git a/loader/app/targets/maifest-target.ts b/loader/app/targets/maifest-target.ts new file mode 100644 index 00000000..506bdf20 --- /dev/null +++ b/loader/app/targets/maifest-target.ts @@ -0,0 +1,87 @@ +import "./shared"; +import * as loader from "../loader/loader"; +import {ApplicationLoader, Stage} from "../loader/loader"; +import {loadManifest, loadManifestTarget} from "../maifest"; +import {getUrlParameter} from "../loader/utils"; + +export default class implements ApplicationLoader { + execute() { + loader.register_task(Stage.SETUP, { + function: async taskId => { + /* sadly still a need in general :/ */ + await loader.scripts.load_multiple(["vendor/jquery/jquery.min.js"], { }); + + await loadManifest(); + + const entryChunk = getUrlParameter("chunk"); + if(!entryChunk) { + loader.critical_error("Missing entry chunk parameter"); + throw "Missing entry chunk parameter"; + } + + await loadManifestTarget(entryChunk, taskId); + }, + name: "Manifest loader", + priority: 100 + }); + + /* required sadly */ + + loader.register_task(loader.Stage.SETUP, { + name: "page setup", + function: async () => { + const body = document.body; + /* top menu */ + { + const container = document.createElement("div"); + container.setAttribute('id', "top-menu-bar"); + body.append(container); + } + /* template containers */ + { + const container = document.createElement("div"); + container.setAttribute('id', "templates"); + body.append(container); + } + /* sounds container */ + { + const container = document.createElement("div"); + container.setAttribute('id', "sounds"); + body.append(container); + } + /* mouse move container */ + { + const container = document.createElement("div"); + container.setAttribute('id', "mouse-move"); + + body.append(container); + } + /* tooltip container */ + { + const container = document.createElement("div"); + container.setAttribute('id', "global-tooltip"); + + container.append(document.createElement("a")); + + body.append(container); + } + }, + priority: 10 + }); + + loader.register_task(loader.Stage.TEMPLATES, { + name: "templates", + function: async () => { + await loader.templates.load_multiple([ + "templates.html" + ], { + cache_tag: "?22", + max_parallel_requests: -1 + }); + }, + priority: 10 + }); + + loader.execute_managed(); + } +} \ No newline at end of file diff --git a/loader/app/targets/shared.ts b/loader/app/targets/shared.ts index cb5f1ddb..834a934a 100644 --- a/loader/app/targets/shared.ts +++ b/loader/app/targets/shared.ts @@ -32,7 +32,7 @@ if(__build.target === "web") { }); } -/* directly disable all context menus */ //disableGlobalContextMenu +/* directly disable all context menus */ if(!location.search.match(/(.*[?&]|^)disableGlobalContextMenu=1($|&.*)/)) { document.addEventListener("contextmenu", event => event.preventDefault()); } \ No newline at end of file diff --git a/loader/css/index.scss b/loader/css/index.scss index 20a076b6..d95da3c1 100644 --- a/loader/css/index.scss +++ b/loader/css/index.scss @@ -1,6 +1,13 @@ body { padding: 0; margin: 0; + + background: #1e1e1e; +} + +*, :before, :after { + box-sizing: border-box; + outline: none; } @import "loader"; diff --git a/package-lock.json b/package-lock.json index 0a958476..492053a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1141,6 +1141,11 @@ "protobufjs": "^6.8.6" } }, + "@icons/material": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz", + "integrity": "sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==" + }, "@nodelib/fs.scandir": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz", @@ -1448,6 +1453,16 @@ "csstype": "^2.2.0" } }, + "@types/react-color": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.4.tgz", + "integrity": "sha512-EswbYJDF1kkrx93/YU+BbBtb46CCtDMvTiGmcOa/c5PETnwTiSWoseJ1oSWeRl/4rUXkhME9bVURvvPg0W5YQw==", + "dev": true, + "requires": { + "@types/react": "*", + "@types/reactcss": "*" + } + }, "@types/react-dom": { "version": "16.9.5", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.9.5.tgz", @@ -1457,6 +1472,15 @@ "@types/react": "*" } }, + "@types/reactcss": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.3.tgz", + "integrity": "sha512-d2gQQ0IL6hXLnoRfVYZukQNWHuVsE75DzFTLPUuyyEhJS8G2VvlE+qfQQ91SJjaMqlURRCNIsX7Jcsw6cEuJlA==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/relateurl": { "version": "0.2.28", "resolved": "https://registry.npmjs.org/@types/relateurl/-/relateurl-0.2.28.tgz", @@ -1776,6 +1800,14 @@ "integrity": "sha512-wdlPY2tm/9XBr7QkKlq0WQVgiuGTX6YWPyRyBviSoScBuLfTVQhvwg6wJ369GJ/1nPfTLMfnrFIfjqVg6d+jQQ==", "dev": true }, + "add-dom-event-listener": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/add-dom-event-listener/-/add-dom-event-listener-1.1.0.tgz", + "integrity": "sha512-WCxx1ixHT0GQU9hb0KI/mhgRQhnU+U3GvwY6ZvVjYq8rsihIGoaIOUbY0yMPBxLH5MDtr0kz3fisWGNcbWW7Jw==", + "requires": { + "object-assign": "4.x" + } + }, "agent-base": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.0.tgz", @@ -2132,6 +2164,11 @@ "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", "dev": true }, + "asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=" + }, "asn1": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", @@ -2328,6 +2365,27 @@ "object.assign": "^4.1.0" } }, + "babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", + "requires": { + "core-js": "^2.4.0", + "regenerator-runtime": "^0.11.0" + }, + "dependencies": { + "core-js": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz", + "integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==" + }, + "regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==" + } + } + }, "bach": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/bach/-/bach-1.2.0.tgz", @@ -3133,6 +3191,11 @@ } } }, + "classnames": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", + "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==" + }, "clean-css": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.3.tgz", @@ -3312,12 +3375,25 @@ "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", "dev": true }, + "component-classes": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/component-classes/-/component-classes-1.2.6.tgz", + "integrity": "sha1-xkI5TDYYpNiwuJGe/Mu9kw5c1pE=", + "requires": { + "component-indexof": "0.0.3" + } + }, "component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", "dev": true }, + "component-indexof": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/component-indexof/-/component-indexof-0.0.3.tgz", + "integrity": "sha1-EdCRMSI5648yyPJa6csAL/6NPCQ=" + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3511,6 +3587,16 @@ "sha.js": "^2.4.8" } }, + "create-react-class": { + "version": "15.6.3", + "resolved": "https://registry.npmjs.org/create-react-class/-/create-react-class-15.6.3.tgz", + "integrity": "sha512-M+/3Q6E6DLO6Yx3OwrWjwHBnvfXXYA7W+dFjt/ZDBemHO1DDZhsalX/NUtnTYclN6GfnBDRh4qRHjcDHmlJBJg==", + "requires": { + "fbjs": "^0.8.9", + "loose-envify": "^1.3.1", + "object-assign": "^4.1.1" + } + }, "cross-spawn": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-3.0.1.tgz", @@ -3546,6 +3632,15 @@ "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", "dev": true }, + "css-animation": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/css-animation/-/css-animation-1.6.1.tgz", + "integrity": "sha512-/48+/BaEaHRY6kNQ2OIPzKf9A6g8WjZYjhiNDNuIVbsm5tXCGIAsHDjB4Xu1C4vXJtUWZo26O68OQkDpNBaPog==", + "requires": { + "babel-runtime": "6.x", + "component-classes": "^1.2.5" + } + }, "css-loader": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-3.6.0.tgz", @@ -3956,6 +4051,11 @@ } } }, + "dom-align": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.0.tgz", + "integrity": "sha512-YkoezQuhp3SLFGdOlr5xkqZ640iXrnHAwVYcDg8ZKRUtO7mSzSC2BA5V0VuyAwPSJA4CLIc6EDDJh4bEsD2+zA==" + }, "dom-converter": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", @@ -4142,6 +4242,24 @@ "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=", "dev": true }, + "encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "requires": { + "iconv-lite": "^0.6.2" + }, + "dependencies": { + "iconv-lite": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.2.tgz", + "integrity": "sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + } + } + }, "end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -4735,6 +4853,27 @@ "reusify": "^1.0.4" } }, + "fbjs": { + "version": "0.8.17", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.17.tgz", + "integrity": "sha1-xNWY6taUkRJlPWWIsBpc3Nn5D90=", + "requires": { + "core-js": "^1.0.0", + "isomorphic-fetch": "^2.1.1", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^0.7.18" + }, + "dependencies": { + "core-js": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz", + "integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY=" + } + } + }, "figgy-pudding": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz", @@ -7487,8 +7626,7 @@ "is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", - "dev": true + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" }, "is-stream-ended": { "version": "0.1.4", @@ -7561,6 +7699,26 @@ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", "dev": true }, + "isomorphic-fetch": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz", + "integrity": "sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk=", + "requires": { + "node-fetch": "^1.0.1", + "whatwg-fetch": ">=0.10.0" + }, + "dependencies": { + "node-fetch": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz", + "integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==", + "requires": { + "encoding": "^0.1.11", + "is-stream": "^1.0.1" + } + } + } + }, "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -7897,8 +8055,7 @@ "lodash": { "version": "4.17.19", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", - "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==", - "dev": true + "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==" }, "lodash.at": { "version": "4.6.0", @@ -8422,6 +8579,11 @@ } } }, + "material-colors": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz", + "integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==" + }, "md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -9555,8 +9717,7 @@ "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", - "dev": true + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" }, "picomatch": { "version": "2.2.1", @@ -9764,6 +9925,14 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, + "promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "requires": { + "asap": "~2.0.3" + } + }, "promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -9921,6 +10090,14 @@ "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", "dev": true }, + "raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "requires": { + "performance-now": "^2.1.0" + } + }, "randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -9980,6 +10157,68 @@ "strip-json-comments": "~2.0.1" } }, + "rc-align": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/rc-align/-/rc-align-2.4.5.tgz", + "integrity": "sha512-nv9wYUYdfyfK+qskThf4BQUSIadeI/dCsfaMZfNEoxm9HwOIioQ+LyqmMK6jWHAZQgOzMLaqawhuBXlF63vgjw==", + "requires": { + "babel-runtime": "^6.26.0", + "dom-align": "^1.7.0", + "prop-types": "^15.5.8", + "rc-util": "^4.0.4" + } + }, + "rc-animate": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/rc-animate/-/rc-animate-2.11.1.tgz", + "integrity": "sha512-1NyuCGFJG/0Y+9RKh5y/i/AalUCA51opyyS/jO2seELpgymZm2u9QV3xwODwEuzkmeQ1BDPxMLmYLcTJedPlkQ==", + "requires": { + "babel-runtime": "6.x", + "classnames": "^2.2.6", + "css-animation": "^1.3.2", + "prop-types": "15.x", + "raf": "^3.4.0", + "rc-util": "^4.15.3", + "react-lifecycles-compat": "^3.0.4" + } + }, + "rc-color-picker": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/rc-color-picker/-/rc-color-picker-1.2.6.tgz", + "integrity": "sha512-AaC9Pg7qCHSy5M4eVbqDIaNb2FC4SEw82GOHB2C4R/+vF2FVa/r5XA+Igg5+zLPmAvBLhz9tL4MAfkRA8yWNJw==", + "requires": { + "classnames": "^2.2.5", + "prop-types": "^15.5.8", + "rc-trigger": "1.x", + "rc-util": "^4.0.2", + "tinycolor2": "^1.4.1" + } + }, + "rc-trigger": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-1.11.5.tgz", + "integrity": "sha512-MBuUPw1nFzA4K7jQOwb7uvFaZFjXGd00EofUYiZ+l/fgKVq8wnLC0lkv36kwqM7vfKyftRo2sh7cWVpdPuNnnw==", + "requires": { + "babel-runtime": "6.x", + "create-react-class": "15.x", + "prop-types": "15.x", + "rc-align": "2.x", + "rc-animate": "2.x", + "rc-util": "4.x" + } + }, + "rc-util": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-4.21.1.tgz", + "integrity": "sha512-Z+vlkSQVc1l8O2UjR3WQ+XdWlhj5q9BMQNLk2iOBch75CqPfrJyGtcWMcnhRlNuDu0Ndtt4kLVO8JI8BrABobg==", + "requires": { + "add-dom-event-listener": "^1.1.0", + "prop-types": "^15.5.10", + "react-is": "^16.12.0", + "react-lifecycles-compat": "^3.0.4", + "shallowequal": "^1.1.0" + } + }, "react": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react/-/react-16.13.1.tgz", @@ -9990,6 +10229,19 @@ "prop-types": "^15.6.2" } }, + "react-color": { + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.18.1.tgz", + "integrity": "sha512-X5XpyJS6ncplZs74ak0JJoqPi+33Nzpv5RYWWxn17bslih+X7OlgmfpmGC1fNvdkK7/SGWYf1JJdn7D2n5gSuQ==", + "requires": { + "@icons/material": "^0.2.4", + "lodash": "^4.17.11", + "material-colors": "^1.2.1", + "prop-types": "^15.5.10", + "reactcss": "^1.2.0", + "tinycolor2": "^1.4.1" + } + }, "react-dom": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.13.1.tgz", @@ -10006,6 +10258,19 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" + }, + "reactcss": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz", + "integrity": "sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==", + "requires": { + "lodash": "^4.0.1" + } + }, "read-pkg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", @@ -10802,8 +11067,7 @@ "safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "sass": { "version": "1.22.10", @@ -11265,8 +11529,7 @@ "setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=", - "dev": true + "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=" }, "setprototypeof": { "version": "1.1.1", @@ -11311,6 +11574,11 @@ } } }, + "shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" + }, "shebang-command": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", @@ -11988,6 +12256,11 @@ "setimmediate": "^1.0.4" } }, + "tinycolor2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.1.tgz", + "integrity": "sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g=" + }, "to-absolute-glob": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz", @@ -12521,6 +12794,11 @@ "integrity": "sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w==", "dev": true }, + "ua-parser-js": { + "version": "0.7.21", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.21.tgz", + "integrity": "sha512-+O8/qh/Qj8CgC6eYBVBykMrNtp5Gebn4dlGD/kKXVkJNDwyrAwSIqwz8CDf+tsAIWVycKcku6gIXJ0qwx/ZXaQ==" + }, "uglify-js": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.8.1.tgz", @@ -14225,6 +14503,11 @@ "sdp": "^2.12.0" } }, + "whatwg-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.2.0.tgz", + "integrity": "sha512-SdGPoQMMnzVYThUbSrEvqTlkvC1Ux27NehaJ/GUHBfNrh5Mjg+1/uRyFMwVnxO2MrikMWvWAqUGgQOfVU4hT7w==" + }, "which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", diff --git a/package.json b/package.json index 89eaa53c..942baacf 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@types/lodash": "^4.14.149", "@types/moment": "^2.13.0", "@types/node": "^12.7.2", + "@types/react-color": "^3.0.4", "@types/react-dom": "^16.9.5", "@types/remarkable": "^1.7.4", "@types/sha256": "^0.2.0", diff --git a/shared/css/static/frame-chat.scss b/shared/css/static/frame-chat.scss index 19f9c1d9..bfd40d4f 100644 --- a/shared/css/static/frame-chat.scss +++ b/shared/css/static/frame-chat.scss @@ -7,6 +7,27 @@ $client_info_avatar_size: 10em; $bot_thumbnail_width: 16em; $bot_thumbnail_height: 9em; +html:root { + --side-info-background: #2e2e2e; + --side-info-shadow: rgba(0, 0, 0, 0.25); + --side-info-title: #8b8b8b; + + --side-info-indicator: #dab8b4; + --side-info-indicator-border: #6a0e0e; + --side-info-indicator-background: #ca3e22; + + --side-info-value-background: #373737; + --side-info-value: #5a5a5a; + + --side-info-ping-very-good: #3f7538; + --side-info-ping-good: #365632; + --side-info-ping-medium: #777f2c; + --side-info-ping-poor: #7f5122; + --side-info-ping-very-poor: #7f2222; + + --side-info-bot-add-song: #3f7538; +} + .container-chat-frame { flex-grow: 1; flex-shrink: 1; @@ -29,13 +50,13 @@ $bot_thumbnail_height: 9em; flex-direction: column; justify-content: space-evenly; - background-color: #2e2e2e; + background-color: var(--side-info-background); border-top-left-radius: 5px; border-top-right-radius: 5px; - -moz-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.25); - -webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.25); - box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.25); + -moz-box-shadow: inset 0 0 5px var(--side-info-shadow); + -webkit-box-shadow: inset 0 0 5px var(--side-info-shadow); + box-shadow: inset 0 0 5px var(--side-info-shadow); .lane { padding-right: 10px; @@ -90,22 +111,22 @@ $bot_thumbnail_height: 9em; .title { display: block; - color: #8b8b8b; + color: var(--side-info-title); .container-indicator { display: inline-flex; flex-direction: column; justify-content: space-around; - background: #ca3e22; - border: 1px solid #6a0e0e; + background: var(--side-info-indicator-background); + border: 1px solid var(--side-info-indicator-border); border-radius: 4px; text-align: center; vertical-align: text-top; - color: #dab8b4; + color: var(--side-info-indicator); font-size: .66em; height: 1.3em; @@ -117,8 +138,8 @@ $bot_thumbnail_height: 9em; } .value { - color: #5a5a5a; - background-color: #373737; + color: var(--side-info-value); + background-color: var(--side-info-value-background); display: inline-block; @@ -138,19 +159,19 @@ $bot_thumbnail_height: 9em; &.value-ping { //very-good good medium poor very-poor &.very-good { - color: #3f7538; + color: var(--side-info-ping-very-good); } &.good { - color: #365632; + color: var(--side-info-ping-good); } &.medium { - color: #777f2c; + color: var(--side-info-ping-medium); } &.poor { - color: #7f5122; + color: var(--side-info-ping-poor); } &.very-poor { - color: #7f2222; + color: var(--side-info-ping-very-poor); } } @@ -159,21 +180,21 @@ $bot_thumbnail_height: 9em; } &.bot-add-song { - color: #3f7538; + color: var(--side-info-bot-add-song); } } .small-value { display: inline-block; - color: #5a5a5a; + color: var(--side-info-value); font-size: .66em; vertical-align: top; margin-top: -.2em; } .button { - color: #5a5a5a; - background-color: #373737; + color: var(--side-info-value); + background-color: var(--side-info-value-background); display: inline-block; diff --git a/shared/css/static/general.scss b/shared/css/static/general.scss index 167a6071..4e1fcabb 100644 --- a/shared/css/static/general.scss +++ b/shared/css/static/general.scss @@ -71,11 +71,6 @@ html:root { --text: #999; } -*, :before, :after { - box-sizing: border-box; - outline: none; -} - html { font-family: sans-serif; line-height: 1.15; @@ -85,6 +80,7 @@ html { -webkit-tap-highlight-color: transparent; background-color: gray; } + body { height: 100vh; width: 100vw; @@ -156,43 +152,6 @@ a[href] { } } -/* code hightliting */ -.tag-hljs-inline-code, .tag-hljs-code { - display: block; - margin: 3px; - - font-size: 80%; - border-radius: .2em; - font-family: Monaco, Menlo, Consolas, "Roboto Mono", "Andale Mono", "Ubuntu Mono", monospace; - box-decoration-break: clone; - - &.tag-hljs-inline-code { - display: inline-block; - - > .hljs { - padding: 0 .25em!important; - } - - white-space: pre-wrap; - margin: 0 0 -0.1em; - vertical-align: bottom; - } - &.tag-hljs-code { - word-wrap: normal; - } - - code { - @include chat-scrollbar-horizontal(); - } -} - -/* fix tailing new line after code blocks */ -.message > { - .tag-hljs-code + br { - display: none; - } -} - /* tooltip */ #global-tooltip { color: #999999; diff --git a/shared/js/BrowserIPC.ts b/shared/js/BrowserIPC.ts deleted file mode 100644 index dab908ab..00000000 --- a/shared/js/BrowserIPC.ts +++ /dev/null @@ -1,725 +0,0 @@ -import * as log from "tc-shared/log"; -import {LogCategory} from "tc-shared/log"; - -export interface BroadcastMessage { - timestamp: number; - receiver: string; - sender: string; - - type: string; - data: any; -} - -function uuidv4() { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - const r = Math.random() * 16 | 0; - const v = c == 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); -} - -interface ProcessQuery { - timestamp: number - query_id: string; -} - -export interface ChannelMessage { - channel_id: string; - type: string; - data: any; -} - -export interface ProcessQueryResponse { - request_timestamp: number - request_query_id: string; - - device_id: string; - protocol: number; -} - -export interface CertificateAcceptCallback { - request_id: string; -} -export interface CertificateAcceptSucceeded { } - -export abstract class BasicIPCHandler { - protected static readonly BROADCAST_UNIQUE_ID = "00000000-0000-4000-0000-000000000000"; - protected static readonly PROTOCOL_VERSION = 1; - - protected _channels: Channel[] = []; - protected unique_id; - - protected constructor() { } - - setup() { - this.unique_id = uuidv4(); /* lets get an unique identifier */ - } - - get_local_address() { return this.unique_id; } - - abstract send_message(type: string, data: any, target?: string); - - protected handle_message(message: BroadcastMessage) { - //log.trace(LogCategory.IPC, tr("Received message %o"), message); - - if(message.receiver === BasicIPCHandler.BROADCAST_UNIQUE_ID) { - if(message.type == "process-query") { - log.debug(LogCategory.IPC, tr("Received a device query from %s."), message.sender); - this.send_message("process-query-response", { - request_query_id: (message.data).query_id, - request_timestamp: (message.data).timestamp, - - device_id: this.unique_id, - protocol: BasicIPCHandler.PROTOCOL_VERSION - } as ProcessQueryResponse, message.sender); - return; - } - } else if(message.receiver === this.unique_id) { - if(message.type == "process-query-response") { - const response: ProcessQueryResponse = message.data; - if(this._query_results[response.request_query_id]) - this._query_results[response.request_query_id].push(response); - else { - log.warn(LogCategory.IPC, tr("Received a query response for an unknown request.")); - } - return; - } - else if(message.type == "certificate-accept-callback") { - const data: CertificateAcceptCallback = message.data; - if(!this._cert_accept_callbacks[data.request_id]) { - log.warn(LogCategory.IPC, tr("Received certificate accept callback for an unknown request ID.")); - return; - } - this._cert_accept_callbacks[data.request_id](); - delete this._cert_accept_callbacks[data.request_id]; - - this.send_message("certificate-accept-succeeded", { - - } as CertificateAcceptSucceeded, message.sender); - return; - } - else if(message.type == "certificate-accept-succeeded") { - if(!this._cert_accept_succeeded[message.sender]) { - log.warn(LogCategory.IPC, tr("Received certificate accept succeeded, but haven't a callback.")); - return; - } - this._cert_accept_succeeded[message.sender](); - return; - } - } - if(message.type === "channel") { - const data: ChannelMessage = message.data; - - let channel_invoked = false; - for(const channel of this._channels) - if(channel.channel_id === data.channel_id && (typeof(channel.target_id) === "undefined" || channel.target_id === message.sender)) { - if(channel.message_handler) - channel.message_handler(message.sender, message.receiver === BasicIPCHandler.BROADCAST_UNIQUE_ID, data); - channel_invoked = true; - } - if(!channel_invoked) { - console.warn(tr("Received channel message for unknown channel (%s)"), data.channel_id); - } - } - } - - create_channel(target_id?: string, channel_id?: string) { - let channel: Channel = { - target_id: target_id, - channel_id: channel_id || uuidv4(), - message_handler: undefined, - send_message: (type: string, data: any, target?: string) => { - if(typeof target !== "undefined") { - if(typeof channel.target_id === "string" && target != channel.target_id) - throw "target id does not match channel target"; - } - - this.send_message("channel", { - type: type, - data: data, - channel_id: channel.channel_id - } as ChannelMessage, target || channel.target_id || BasicIPCHandler.BROADCAST_UNIQUE_ID); - } - }; - - this._channels.push(channel); - return channel; - } - - channels() : Channel[] { return this._channels; } - - delete_channel(channel: Channel) { - this._channels = this._channels.filter(e => e !== channel); - } - - private _query_results: {[key: string]:ProcessQueryResponse[]} = {}; - async query_processes(timeout?: number) : Promise { - const query_id = uuidv4(); - this._query_results[query_id] = []; - - this.send_message("process-query", { - query_id: query_id, - timestamp: Date.now() - } as ProcessQuery); - - await new Promise(resolve => setTimeout(resolve, timeout || 250)); - const result = this._query_results[query_id]; - delete this._query_results[query_id]; - return result; - } - - private _cert_accept_callbacks: {[key: string]:(() => any)} = {}; - register_certificate_accept_callback(callback: () => any) : string { - const id = uuidv4(); - this._cert_accept_callbacks[id] = callback; - return this.unique_id + ":" + id; - } - - private _cert_accept_succeeded: {[sender: string]:(() => any)} = {}; - post_certificate_accpected(id: string, timeout?: number) : Promise { - return new Promise((resolve, reject) => { - const data = id.split(":"); - const timeout_id = setTimeout(() => { - delete this._cert_accept_succeeded[data[0]]; - clearTimeout(timeout_id); - reject("timeout"); - }, timeout || 250); - this._cert_accept_succeeded[data[0]] = () => { - delete this._cert_accept_succeeded[data[0]]; - clearTimeout(timeout_id); - resolve(); - }; - this.send_message("certificate-accept-callback", { - request_id: data[1] - } as CertificateAcceptCallback, data[0]); - }) - } -} - -export interface Channel { - readonly channel_id: string; - target_id?: string; - - message_handler: (remote_id: string, broadcast: boolean, message: ChannelMessage) => any; - send_message(type: string, message: any, target?: string); -} - -class BroadcastChannelIPC extends BasicIPCHandler { - private static readonly CHANNEL_NAME = "TeaSpeak-Web"; - - private channel: BroadcastChannel; - - constructor() { - super(); - } - - setup() { - super.setup(); - - this.channel = new BroadcastChannel(BroadcastChannelIPC.CHANNEL_NAME); - this.channel.onmessage = this.on_message.bind(this); - this.channel.onmessageerror = this.on_error.bind(this); - } - - private on_message(event: MessageEvent) { - if(typeof(event.data) !== "string") { - log.warn(LogCategory.IPC, tr("Received message with an invalid type (%s): %o"), typeof(event.data), event.data); - return; - } - - let message: BroadcastMessage; - try { - message = JSON.parse(event.data); - } catch(error) { - log.error(LogCategory.IPC, tr("Received an invalid encoded message: %o"), event.data); - return; - } - super.handle_message(message); - } - - private on_error(event: MessageEvent) { - log.warn(LogCategory.IPC, tr("Received error: %o"), event); - } - - send_message(type: string, data: any, target?: string) { - const message: BroadcastMessage = {} as any; - - message.sender = this.unique_id; - message.receiver = target ? target : BasicIPCHandler.BROADCAST_UNIQUE_ID; - message.timestamp = Date.now(); - message.type = type; - message.data = data; - - this.channel.postMessage(JSON.stringify(message)); - } -} - -export namespace connect { - export type ConnectRequestData = { - address: string; - - profile?: string; - username?: string; - password?: { - value: string; - hashed: boolean; - }; - } - - export interface ConnectOffer { - request_id: string; - data: ConnectRequestData; - } - - export interface ConnectOfferAnswer { - request_id: string; - accepted: boolean; - } - - export interface ConnectExecute { - request_id: string; - } - - export interface ConnectExecuted { - request_id: string; - succeeded: boolean; - message?: string; - } - - /* The connect process: - * 1. Broadcast an offer - * 2. Wait 50ms for all offer responses or until the first one respond with "ok" - * 3. Select (if possible) on accepted offer and execute the connect - */ - export class ConnectHandler { - private static readonly CHANNEL_NAME = "connect"; - - readonly ipc_handler: BasicIPCHandler; - private ipc_channel: Channel; - - public callback_available: (data: ConnectRequestData) => boolean = () => false; - public callback_execute: (data: ConnectRequestData) => boolean | string = () => false; - - - private _pending_connect_offers: { - id: string; - data: ConnectRequestData; - timeout: number; - - remote_handler: string; - }[] = []; - - private _pending_connects_requests: { - id: string; - - data: ConnectRequestData; - timeout: number; - - callback_success: () => any; - callback_failed: (message: string) => any; - callback_avail: () => Promise; - - remote_handler?: string; - }[] = []; - - constructor(ipc_handler: BasicIPCHandler) { - this.ipc_handler = ipc_handler; - } - - public setup() { - this.ipc_channel = this.ipc_handler.create_channel(undefined, ConnectHandler.CHANNEL_NAME); - this.ipc_channel.message_handler = this.on_message.bind(this); - } - - private on_message(sender: string, broadcast: boolean, message: ChannelMessage) { - if(broadcast) { - if(message.type == "offer") { - const data = message.data as ConnectOffer; - - const response = { - accepted: this.callback_available(data.data), - request_id: data.request_id - } as ConnectOfferAnswer; - - if(response.accepted) { - log.debug(LogCategory.IPC, tr("Received new connect offer from %s: %s"), sender, data.request_id); - - const ld = { - remote_handler: sender, - data: data.data, - id: data.request_id, - timeout: 0 - }; - this._pending_connect_offers.push(ld); - ld.timeout = setTimeout(() => { - log.debug(LogCategory.IPC, tr("Dropping connect request %s, because we never received an execute."), ld.id); - this._pending_connect_offers.remove(ld); - }, 120 * 1000) as any; - } - this.ipc_channel.send_message("offer-answer", response, sender); - } - } else { - if(message.type == "offer-answer") { - const data = message.data as ConnectOfferAnswer; - const request = this._pending_connects_requests.find(e => e.id === data.request_id); - if(!request) { - log.warn(LogCategory.IPC, tr("Received connect offer answer with unknown request id (%s)."), data.request_id); - return; - } - if(!data.accepted) { - log.debug(LogCategory.IPC, tr("Client %s rejected the connect offer (%s)."), sender, request.id); - return; - } - if(request.remote_handler) { - log.debug(LogCategory.IPC, tr("Client %s accepted the connect offer (%s), but offer has already been accepted."), sender, request.id); - return; - } - - log.debug(LogCategory.IPC, tr("Client %s accepted the connect offer (%s). Request local acceptance."), sender, request.id); - request.remote_handler = sender; - clearTimeout(request.timeout); - - request.callback_avail().then(flag => { - if(!flag) { - request.callback_failed("local avail rejected"); - return; - } - - log.debug(LogCategory.IPC, tr("Executing connect with client %s"), request.remote_handler); - this.ipc_channel.send_message("execute", { - request_id: request.id - } as ConnectExecute, request.remote_handler); - request.timeout = setTimeout(() => { - request.callback_failed("connect execute timeout"); - }, 1000) as any; - }).catch(error => { - log.error(LogCategory.IPC, tr("Local avail callback caused an error: %o"), error); - request.callback_failed(tr("local avail callback caused an error")); - }); - - } - else if(message.type == "executed") { - const data = message.data as ConnectExecuted; - const request = this._pending_connects_requests.find(e => e.id === data.request_id); - if(!request) { - log.warn(LogCategory.IPC, tr("Received connect executed with unknown request id (%s)."), data.request_id); - return; - } - - if(request.remote_handler != sender) { - log.warn(LogCategory.IPC, tr("Received connect executed for request %s, but from wrong client: %s (expected %s)"), data.request_id, sender, request.remote_handler); - return; - } - - log.debug(LogCategory.IPC, tr("Received connect executed response from client %s for request %s. Succeeded: %o (%s)"), sender, data.request_id, data.succeeded, data.message); - clearTimeout(request.timeout); - if(data.succeeded) - request.callback_success(); - else - request.callback_failed(data.message); - } - else if(message.type == "execute") { - const data = message.data as ConnectExecute; - const request = this._pending_connect_offers.find(e => e.id === data.request_id); - if(!request) { - log.warn(LogCategory.IPC, tr("Received connect execute with unknown request id (%s)."), data.request_id); - return; - } - - if(request.remote_handler != sender) { - log.warn(LogCategory.IPC, tr("Received connect execute for request %s, but from wrong client: %s (expected %s)"), data.request_id, sender, request.remote_handler); - return; - } - clearTimeout(request.timeout); - this._pending_connect_offers.remove(request); - - log.debug(LogCategory.IPC, tr("Executing connect for %s"), data.request_id); - const cr = this.callback_execute(request.data); - - const response = { - request_id: data.request_id, - - succeeded: typeof(cr) !== "string" && cr, - message: typeof(cr) === "string" ? cr : "", - } as ConnectExecuted; - this.ipc_channel.send_message("executed", response, request.remote_handler); - } - } - } - - post_connect_request(data: ConnectRequestData, callback_avail: () => Promise) : Promise { - return new Promise((resolve, reject) => { - const pd = { - data: data, - id: uuidv4(), - timeout: 0, - - callback_success: () => { - this._pending_connects_requests.remove(pd); - clearTimeout(pd.timeout); - resolve(); - }, - - callback_failed: error => { - this._pending_connects_requests.remove(pd); - clearTimeout(pd.timeout); - reject(error); - }, - - callback_avail: callback_avail, - }; - this._pending_connects_requests.push(pd); - - this.ipc_channel.send_message("offer", { - request_id: pd.id, - data: pd.data - } as ConnectOffer); - pd.timeout = setTimeout(() => { - pd.callback_failed("received no response to offer"); - }, 50) as any; - }) - } - } -} - -export namespace mproxy { - export interface MethodProxyInvokeData { - method_name: string; - arguments: any[]; - promise_id: string; - } - export interface MethodProxyResultData { - promise_id: string; - result: any; - success: boolean; - } - export interface MethodProxyCallback { - promise: Promise; - promise_id: string; - - resolve: (object: any) => any; - reject: (object: any) => any; - } - - export type MethodProxyConnectParameters = { - channel_id: string; - client_id: string; - } - export abstract class MethodProxy { - readonly ipc_handler: BasicIPCHandler; - private _ipc_channel: Channel; - private _ipc_parameters: MethodProxyConnectParameters; - - private readonly _local: boolean; - private readonly _slave: boolean; - - private _connected: boolean; - private _proxied_methods: {[key: string]:() => Promise} = {}; - private _proxied_callbacks: {[key: string]:MethodProxyCallback} = {}; - - protected constructor(ipc_handler: BasicIPCHandler, connect_params?: MethodProxyConnectParameters) { - this.ipc_handler = ipc_handler; - this._ipc_parameters = connect_params; - this._connected = false; - this._slave = typeof(connect_params) !== "undefined"; - this._local = typeof(connect_params) !== "undefined" && connect_params.channel_id === "local" && connect_params.client_id === "local"; - } - - protected setup() { - if(this._local) { - this._connected = true; - this.on_connected(); - } else { - if(this._slave) - this._ipc_channel = this.ipc_handler.create_channel(this._ipc_parameters.client_id, this._ipc_parameters.channel_id); - else - this._ipc_channel = this.ipc_handler.create_channel(); - - this._ipc_channel.message_handler = this._handle_message.bind(this); - if(this._slave) - this._ipc_channel.send_message("initialize", {}); - } - } - - protected finalize() { - if(!this._local) { - if(this._connected) - this._ipc_channel.send_message("finalize", {}); - - this.ipc_handler.delete_channel(this._ipc_channel); - this._ipc_channel = undefined; - } - for(const promise of Object.values(this._proxied_callbacks)) - promise.reject("disconnected"); - this._proxied_callbacks = {}; - - this._connected = false; - this.on_disconnected(); - } - - protected register_method(method: (...args: any[]) => Promise | string) { - let method_name: string; - if(typeof method === "function") { - log.debug(LogCategory.IPC, tr("Registering method proxy for %s"), method.name); - method_name = method.name; - } else { - log.debug(LogCategory.IPC, tr("Registering method proxy for %s"), method); - method_name = method; - } - - if(!this[method_name]) - throw "method is missing in current object"; - - this._proxied_methods[method_name] = this[method_name]; - if(!this._local) { - this[method_name] = (...args: any[]) => { - if(!this._connected) - return Promise.reject("not connected"); - - const proxy_callback = { - promise_id: uuidv4() - } as MethodProxyCallback; - this._proxied_callbacks[proxy_callback.promise_id] = proxy_callback; - proxy_callback.promise = new Promise((resolve, reject) => { - proxy_callback.resolve = resolve; - proxy_callback.reject = reject; - }); - - this._ipc_channel.send_message("invoke", { - promise_id: proxy_callback.promise_id, - arguments: [...args], - method_name: method_name - } as MethodProxyInvokeData); - return proxy_callback.promise; - } - } - } - - private _handle_message(remote_id: string, boradcast: boolean, message: ChannelMessage) { - if(message.type === "finalize") { - this._handle_finalize(); - } else if(message.type === "initialize") { - this._handle_remote_callback(remote_id); - } else if(message.type === "invoke") { - this._handle_invoke(message.data); - } else if(message.type === "result") { - this._handle_result(message.data); - } - } - - private _handle_finalize() { - this.on_disconnected(); - this.finalize(); - this._connected = false; - } - - private _handle_remote_callback(remote_id: string) { - if(!this._ipc_channel.target_id) { - if(this._slave) - throw "initialize wrong state!"; - - this._ipc_channel.target_id = remote_id; /* now we're able to send messages */ - this.on_connected(); - this._ipc_channel.send_message("initialize", true); - } else { - if(!this._slave) - throw "initialize wrong state!"; - - this.on_connected(); - } - this._connected = true; - } - - private _send_result(promise_id: string, success: boolean, message: any) { - this._ipc_channel.send_message("result", { - promise_id: promise_id, - result: message, - success: success - } as MethodProxyResultData); - } - - private _handle_invoke(data: MethodProxyInvokeData) { - if(this._proxied_methods[data.method_name]) - throw "we could not invoke a local proxied method!"; - - if(!this[data.method_name]) { - this._send_result(data.promise_id, false, "missing method"); - return; - } - - try { - log.info(LogCategory.IPC, tr("Invoking method %s with arguments: %o"), data.method_name, data.arguments); - - const promise = this[data.method_name](...data.arguments); - promise.then(result => { - log.info(LogCategory.IPC, tr("Result: %o"), result); - this._send_result(data.promise_id, true, result); - }).catch(error => { - this._send_result(data.promise_id, false, error); - }); - } catch(error) { - this._send_result(data.promise_id, false, error); - return; - } - } - - private _handle_result(data: MethodProxyResultData) { - if(!this._proxied_callbacks[data.promise_id]) { - console.warn(tr("Received proxy method result for unknown promise")); - return; - } - const callback = this._proxied_callbacks[data.promise_id]; - delete this._proxied_callbacks[data.promise_id]; - - if(data.success) - callback.resolve(data.result); - else - callback.reject(data.result); - } - - generate_connect_parameters() : MethodProxyConnectParameters { - if(this._slave) - throw "only masters can generate connect parameters!"; - if(!this._ipc_channel) - throw "please call setup() before"; - - return { - channel_id: this._ipc_channel.channel_id, - client_id: this.ipc_handler.get_local_address() - }; - } - - is_slave() { return this._local || this._slave; } /* the popout modal */ - is_master() { return this._local || !this._slave; } /* the host (teaweb application) */ - - protected abstract on_connected(); - protected abstract on_disconnected(); - } -} - -let handler: BasicIPCHandler; -let connect_handler: connect.ConnectHandler; - -export function setup() { - if(!supported()) - return; - - handler = new BroadcastChannelIPC(); - handler.setup(); - - connect_handler = new connect.ConnectHandler(handler); - connect_handler.setup(); -} - -export function get_handler() { - return handler; -} - -export function get_connect_handler() { - return connect_handler; -} - -export function supported() { - /* ios does not support this */ - return typeof(window.BroadcastChannel) !== "undefined"; -} \ No newline at end of file diff --git a/shared/js/ConnectionHandler.ts b/shared/js/ConnectionHandler.ts index 6eaf7cef..b48ddbe6 100644 --- a/shared/js/ConnectionHandler.ts +++ b/shared/js/ConnectionHandler.ts @@ -18,7 +18,7 @@ import * as htmltags from "./ui/htmltags"; import {ChannelEntry} from "tc-shared/ui/channel"; import {InputStartResult, InputState} from "tc-shared/voice/RecorderBase"; import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration"; -import * as bipc from "./BrowserIPC"; +import * as bipc from "./ipc/BrowserIPC"; import {RecorderProfile} from "tc-shared/voice/RecorderProfile"; import {Frame} from "tc-shared/ui/frames/chat_frame"; import {Hostbanner} from "tc-shared/ui/frames/hostbanner"; @@ -34,6 +34,7 @@ import {FileManager} from "tc-shared/file/FileManager"; import {FileTransferState, TransferProvider} from "tc-shared/file/Transfer"; import {traj} from "tc-shared/i18n/localize"; import {md5} from "tc-shared/crypto/md5"; +import {guid} from "tc-shared/crypto/uid"; export enum DisconnectReason { HANDLER_DESTROYED, @@ -126,6 +127,8 @@ export interface ConnectParameters { declare const native_client; export class ConnectionHandler { + readonly handlerId: string; + private readonly event_registry: Registry; channelTree: ChannelTree; @@ -172,8 +175,9 @@ export class ConnectionHandler { log: ServerLog; constructor() { + this.handlerId = guid(); this.event_registry = new Registry(); - this.event_registry.enable_debug("connection-handler"); + this.event_registry.enableDebug("connection-handler"); this.settings = new ServerSettings(); @@ -435,7 +439,7 @@ export class ConnectionHandler { if(popup) popup.close(); - properties["certificate_callback"] = bipc.get_handler().register_certificate_accept_callback(() => { + properties["certificate_callback"] = bipc.getInstance().register_certificate_accept_callback(() => { log.info(LogCategory.GENERAL, tr("Received notification that the certificate has been accepted! Attempting reconnect!")); if(this._certificate_modal) this._certificate_modal.close(); @@ -448,7 +452,7 @@ export class ConnectionHandler { }); const url = build_url(document.location.origin + pathname + "/popup/certaccept/", "", properties); - const features_string = [...Object.keys(features)].map(e => e + "=" + features[e]).reduce((a, b) => a + "," + b); + const features_string = Object.keys(features).map(e => e + "=" + features[e]).join(","); popup = window.open(url, "TeaWeb certificate accept", features_string); try { popup.focus(); diff --git a/shared/js/devel_main.ts b/shared/js/devel_main.ts new file mode 100644 index 00000000..5b63b758 --- /dev/null +++ b/shared/js/devel_main.ts @@ -0,0 +1,28 @@ +import * as loader from "tc-loader"; +import {Stage} from "tc-loader"; + +import * as ipc from "./ipc/BrowserIPC"; +import * as i18n from "./i18n/localize"; + +import "./proto"; + +import {spawnVideoPopout} from "tc-shared/video-viewer/Controller"; + +console.error("Hello World from devel main"); +loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { + name: "setup", + priority: 10, + function: async () => { + await i18n.initialize(); + ipc.setup(); + } +}); + +loader.register_task(Stage.LOADED, { + name: "invoke", + priority: 10, + function: async () => { + console.error("Spawning video popup"); + spawnVideoPopout(); + } +}); \ No newline at end of file diff --git a/shared/js/events.ts b/shared/js/events.ts index cce20f6e..978fe79e 100644 --- a/shared/js/events.ts +++ b/shared/js/events.ts @@ -1,4 +1,3 @@ -import {ClientEvents, MusicClientEntry, SongInfo} from "tc-shared/ui/client"; import {guid} from "tc-shared/crypto/uid"; import * as React from "react"; import {useEffect} from "react"; @@ -20,29 +19,34 @@ export class SingletonEvent implements Event() : SingletonEvents[T] { return; } } +export interface EventReceiver { + fire(event_type: T, data?: Events[T], overrideTypeKey?: boolean); + fire_async(event_type: T, data?: Events[T], callback?: () => void); +} + const event_annotation_key = guid(); -export class Registry { - private readonly registry_uuid; +export class Registry implements EventReceiver { + private readonly registryUuid; private handler: {[key: string]: ((event) => void)[]} = {}; - private connections: {[key: string]:Registry[]} = {}; - private event_handler_objects: { + private connections: {[key: string]: EventReceiver[]} = {}; + private eventHandlerObjects: { object: any, handlers: {[key: string]: ((event) => void)[]} }[] = []; - private debug_prefix = undefined; - private warn_unhandled_events = false; + private debugPrefix = undefined; + private warnUnhandledEvents = false; constructor() { - this.registry_uuid = "evreg_data_" + guid(); + this.registryUuid = "evreg_data_" + guid(); } - enable_debug(prefix: string) { this.debug_prefix = prefix || "---"; } - disable_debug() { this.debug_prefix = undefined; } + enableDebug(prefix: string) { this.debugPrefix = prefix || "---"; } + disableDebug() { this.debugPrefix = undefined; } - enable_warn_unhandled_events() { this.warn_unhandled_events = true; } - disable_warn_unhandled_events() { this.warn_unhandled_events = false; } + enable_warn_unhandled_events() { this.warnUnhandledEvents = true; } + disable_warn_unhandled_events() { this.warnUnhandledEvents = false; } on(event: T, handler: (event?: Events[T] & Event) => void) : () => void; on(events: (keyof Events)[], handler: (event?: Event) => void) : () => void; @@ -50,7 +54,7 @@ export class Registry { if(!Array.isArray(events)) events = [events]; - handler[this.registry_uuid] = { + handler[this.registryUuid] = { singleshot: false }; for(const event of events) { @@ -60,6 +64,14 @@ export class Registry { return () => this.off(events, handler); } + onAll(handler: (event?: Event) => void) : () => void { + handler[this.registryUuid] = { + singleshot: false + }; + (this.handler[null as any] || (this.handler[null as any] = [])).push(handler); + return () => this.offAll(handler); + } + /* one */ one(event: T, handler: (event?: Events[T] & Event) => void) : () => void; one(events: (keyof Events)[], handler: (event?: Event) => void) : () => void; @@ -70,7 +82,7 @@ export class Registry { for(const event of events) { const handlers = this.handler[event] || (this.handler[event] = []); - handler[this.registry_uuid] = { singleshot: true }; + handler[this.registryUuid] = { singleshot: true }; handlers.push(handler); } return () => this.off(events, handler); @@ -94,6 +106,10 @@ export class Registry { } } + offAll(handler: (event?: Event) => void) { + (this.handler[null as any] || []).remove(handler); + } + /* special helper methods for react components */ reactUse(event: T, handler: (event?: Events[T] & Event) => void, condition?: boolean) { @@ -108,23 +124,28 @@ export class Registry { }); } - connect(events: T | T[], target: Registry) { + connectAll(target: EventReceiver) { + (this.connections[null as any] || (this.connections[null as any] = [])).push(target as any); + } + + connect(events: T | T[], target: EventReceiver) { for(const event of Array.isArray(events) ? events : [events]) (this.connections[event as string] || (this.connections[event as string] = [])).push(target as any); } - disconnect(events: T | T[], target: Registry) { + disconnect(events: T | T[], target: EventReceiver) { for(const event of Array.isArray(events) ? events : [events]) (this.connections[event as string] || []).remove(target as any); } - disconnect_all(target: Registry) { + disconnectAll(target: EventReceiver) { + this.connections[null as any]?.remove(target); for(const event of Object.keys(this.connections)) - this.connections[event].remove(target as any); + this.connections[event].remove(target); } fire(event_type: T, data?: Events[T], overrideTypeKey?: boolean) { - if(this.debug_prefix) console.log("[%s] Trigger event: %s", this.debug_prefix, event_type); + if(this.debugPrefix) console.log("[%s] Trigger event: %s", this.debugPrefix, event_type); if(typeof data === "object" && 'type' in data && !overrideTypeKey) { if((data as any).type !== event_type) { @@ -137,26 +158,35 @@ export class Registry { as: function () { return this; } }); - this.fire_event(event_type as string, event); + this.fire_event(event_type, event); } - private fire_event(type: string, data: any) { - let invoke_count = 0; - for(const handler of (this.handler[type]?.slice(0) || [])) { + private fire_event(type: keyof Events, data: any) { + let invokeCount = 0; + + const typedHandler = this.handler[type as string] || []; + const generalHandler = this.handler[null as string] || []; + for(const handler of [...generalHandler, ...typedHandler]) { handler(data); - invoke_count++; + invokeCount++; - const reg_data = handler[this.registry_uuid]; - if(typeof reg_data === "object" && reg_data.singleshot) - this.handler[type].remove(handler); + const regData = handler[this.registryUuid]; + if(typeof regData === "object" && regData.singleshot) + this.handler[type as string].remove(handler); /* FIXME: General single shot? */ } - for(const evhandler of (this.connections[type]?.slice(0) || [])) { - evhandler.fire_event(type, data); - invoke_count++; + const typedConnections = this.connections[type as string] || []; + const generalConnections = this.connections[null as string] || []; + for(const evhandler of [...generalConnections, ...typedConnections]) { + if('fire_event' in evhandler) + /* evhandler is an event registry as well. We don't have to check for any inappropriate keys */ + (evhandler as any).fire_event(type, data); + else + evhandler.fire(type, data); + invokeCount++; } - if(this.warn_unhandled_events && invoke_count === 0) { - console.warn(tr("Event handler (%s) triggered event %s which has no consumers."), this.debug_prefix, type); + if(this.warnUnhandledEvents && invokeCount === 0) { + console.warn(tr("Event handler (%s) triggered event %s which has no consumers."), this.debugPrefix, type); } } @@ -172,7 +202,7 @@ export class Registry { destroy() { this.handler = {}; this.connections = {}; - this.event_handler_objects = []; + this.eventHandlerObjects = []; } register_handler(handler: any, parentClasses?: boolean) { @@ -213,17 +243,17 @@ export class Registry { return; } - this.event_handler_objects.push({ + this.eventHandlerObjects.push({ handlers: registered_events, object: handler }); } unregister_handler(handler: any) { - const data = this.event_handler_objects.find(e => e.object === handler); + const data = this.eventHandlerObjects.find(e => e.object === handler); if(!data) return; - this.event_handler_objects.remove(data); + this.eventHandlerObjects.remove(data); for(const key of Object.keys(data.handlers)) { for(const evhandler of data.handlers[key]) @@ -278,48 +308,7 @@ export function ReactEventHandler, Event export namespace sidebar { export interface music { - "open": {}, /* triggers when frame should be shown */ - "close": {}, /* triggers when frame will be closed */ - "bot_change": { - old: MusicClientEntry | undefined, - new: MusicClientEntry | undefined - }, - "bot_property_update": { - properties: string[] - }, - - "action_play": {}, - "action_pause": {}, - "action_song_set": { song_id: number }, - "action_forward": {}, - "action_rewind": {}, - "action_forward_ms": { - units: number; - }, - "action_rewind_ms": { - units: number; - }, - "action_song_add": {}, - "action_song_delete": { song_id: number }, - "action_playlist_reload": {}, - - "playtime_move_begin": {}, - "playtime_move_end": { - canceled: boolean, - target_time?: number - }, - - "reorder_begin": { song_id: number; entry: JQuery }, - "reorder_end": { song_id: number; canceled: boolean; entry: JQuery; previous_entry?: number }, - - "player_time_update": ClientEvents["music_status_update"], - "player_song_change": ClientEvents["music_song_change"], - - "playlist_song_add": ClientEvents["playlist_song_add"] & { insert_effect?: boolean }, - "playlist_song_remove": ClientEvents["playlist_song_remove"], - "playlist_song_reorder": ClientEvents["playlist_song_reorder"], - "playlist_song_loaded": ClientEvents["playlist_song_loaded"] & { html_entry?: JQuery }, } } @@ -721,14 +710,4 @@ export namespace modal { "deinitialize": {} } } -} - -//Some test code -/* -const eclient = new Registry(); -const emusic = new Registry(); - -eclient.on("property_update", event => { event.as<"playlist_song_loaded">(); }); -eclient.connect("playlist_song_loaded", emusic); -eclient.connect("playlist_song_loaded", emusic); - */ \ No newline at end of file +} \ No newline at end of file diff --git a/shared/js/file/Avatars.ts b/shared/js/file/Avatars.ts new file mode 100644 index 00000000..2fb03210 --- /dev/null +++ b/shared/js/file/Avatars.ts @@ -0,0 +1,164 @@ +import {Registry} from "tc-shared/events"; +import * as hex from "tc-shared/crypto/hex"; + +export const kIPCAvatarChannel = "avatars"; +export const kDefaultAvatarImage = "img/style/avatar.png"; +export type AvatarState = "unset" | "loading" | "errored" | "loaded"; + +export interface AvatarStateData { + "unset": {}, + "loading": {}, + "errored": { message: string }, + "loaded": { url: string } +} + +interface AvatarEvents { + avatar_changed: { + newAvatarHash: string + }, + avatar_state_changed: { + oldState: AvatarState, + newState: AvatarState, + newStateData: AvatarStateData[keyof AvatarStateData] + } +} + +export abstract class ClientAvatar { + readonly events: Registry; + readonly clientAvatarId: string; /* the base64 unique id thing from a-m */ + + private currentAvatarHash: string | "unknown"; /* the client avatars flag */ + private state: AvatarState = "loading"; + + private stateData: AvatarStateData[AvatarState] = {}; + + loadingTimestamp: number = 0; + + constructor(clientAvatarId: string) { + this.clientAvatarId = clientAvatarId; + this.events = new Registry(); + + this.events.on("avatar_state_changed", event => { this.state = event.newState; this.stateData = event.newStateData; }); + this.events.on("avatar_changed", event => this.currentAvatarHash = event.newAvatarHash); + } + + destroy() { + this.setState("unset", {}); + this.events.destroy(); + } + + protected setState(state: T, data: AvatarStateData[T], force?: boolean) { + if(this.state === state && !force) + return; + + this.destroyStateData(this.state, this.stateData); + this.events.fire("avatar_state_changed", { newState: state, oldState: this.state, newStateData: data }); + } + + public getTypedStateData(state: T) : AvatarStateData[T] { + if(this.state !== state) + throw "invalid avatar state"; + + return this.stateData as any; + } + + public setUnset() { + this.setState("unset", {}); + } + + public setLoading() { + this.setState("loading", {}); + } + + public setLoaded(data: AvatarStateData["loaded"]) { + this.setState("loaded", data); + } + + public setErrored(data: AvatarStateData["errored"]) { + this.setState("errored", data); + } + + async awaitLoaded() { + if(this.state !== "loading") + return; + + await new Promise(resolve => this.events.on("avatar_state_changed", event => event.newState !== "loading" && resolve())); + } + + getState() : AvatarState { + return this.state; + } + + getStateData() : AvatarStateData[AvatarState] { + return this.stateData; + } + + getAvatarHash() : string | "unknown" { + return this.currentAvatarHash; + } + + getAvatarUrl() { + if(this.state === "loaded") + return this.getTypedStateData("loaded").url || kDefaultAvatarImage; + return kDefaultAvatarImage; + } + + getLoadError() { + return this.getTypedStateData("errored").message; + } + + protected abstract destroyStateData(state: AvatarState, data: AvatarStateData[AvatarState]); +} + +export abstract class AbstractAvatarManager { + abstract resolveAvatar(clientAvatarId: string, avatarHash?: string) : ClientAvatar; + abstract resolveClientAvatar(client: { id?: number, database_id?: number, clientUniqueId: string }); +} + +export abstract class AbstractAvatarManagerFactory { + abstract hasManager(handlerId: string) : boolean; + abstract getManager(handlerId: string) : AbstractAvatarManager; +} + +let globalAvatarManagerFactory: AbstractAvatarManagerFactory; +export function setGlobalAvatarManagerFactory(factory: AbstractAvatarManagerFactory) { + if(globalAvatarManagerFactory) + throw "global avatar manager factory has already been set"; + globalAvatarManagerFactory = factory; +} + +export function getGlobalAvatarManagerFactory() { + return globalAvatarManagerFactory; +} + +export function uniqueId2AvatarId(unique_id: string) { + function str2ab(str) { + let buf = new ArrayBuffer(str.length); // 2 bytes for each char + let bufView = new Uint8Array(buf); + for (let i=0, strLen = str.length; i= '0' && c <= '9') + offset = c.charCodeAt(0) - '0'.charCodeAt(0); + else if(c >= 'A' && c <= 'F') + offset = c.charCodeAt(0) - 'A'.charCodeAt(0) + 0x0A; + else if(c >= 'a' && c <= 'f') + offset = c.charCodeAt(0) - 'a'.charCodeAt(0) + 0x0A; + result += String.fromCharCode('a'.charCodeAt(0) + offset); + } + return result; + } catch (e) { //invalid base 64 (like music bot etc) + return undefined; + } +} \ No newline at end of file diff --git a/shared/js/file/FileManager.tsx b/shared/js/file/FileManager.tsx index d1469db4..650cb8e0 100644 --- a/shared/js/file/FileManager.tsx +++ b/shared/js/file/FileManager.tsx @@ -5,7 +5,7 @@ import {ServerCommand} from "tc-shared/connection/ConnectionBase"; import {CommandResult, ErrorCode, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration"; import {AbstractCommandHandler} from "tc-shared/connection/AbstractCommandHandler"; import {IconManager} from "tc-shared/file/Icons"; -import {AvatarManager} from "tc-shared/file/Avatars"; +import {AvatarManager} from "tc-shared/file/LocalAvatars"; import { CancelReason, FileDownloadTransfer, diff --git a/shared/js/file/Avatars.tsx b/shared/js/file/LocalAvatars.ts similarity index 57% rename from shared/js/file/Avatars.tsx rename to shared/js/file/LocalAvatars.ts index 02a69d7d..9442cab6 100644 --- a/shared/js/file/Avatars.tsx +++ b/shared/js/file/LocalAvatars.ts @@ -1,6 +1,9 @@ import * as log from "tc-shared/log"; import {LogCategory} from "tc-shared/log"; -import * as hex from "tc-shared/crypto/hex"; +import * as ipc from "../ipc/BrowserIPC"; +import {ChannelMessage} from "../ipc/BrowserIPC"; +import * as loader from "tc-loader"; +import {Stage} from "tc-loader"; import {image_type, ImageCache, media_image_type} from "tc-shared/file/ImageCache"; import {FileManager} from "tc-shared/file/FileManager"; import { @@ -12,69 +15,40 @@ import { } from "tc-shared/file/Transfer"; import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration"; import {server_connections} from "tc-shared/ui/frames/connection_handlers"; -import {Registry} from "tc-shared/events"; import {ClientEntry} from "tc-shared/ui/client"; import {tr} from "tc-shared/i18n/localize"; +import { + AbstractAvatarManager, + AbstractAvatarManagerFactory, + AvatarState, + AvatarStateData, + ClientAvatar, + kIPCAvatarChannel, + setGlobalAvatarManagerFactory, + uniqueId2AvatarId +} from "tc-shared/file/Avatars"; +import {IPCChannel} from "tc-shared/ipc/BrowserIPC"; +import {ConnectionHandler} from "tc-shared/ConnectionHandler"; /* FIXME: Retry avatar download after some time! */ -const DefaultAvatarImage = "img/style/avatar.png"; - -export type AvatarState = "unset" | "loading" | "errored" | "loaded"; - -interface AvatarEvents { - avatar_changed: {}, - avatar_state_changed: { oldState: AvatarState, newState: AvatarState } -} - -export class ClientAvatar { - readonly events: Registry; - readonly clientAvatarId: string; /* the base64 unique id thing from a-m */ - - currentAvatarHash: string | "unknown"; /* the client avatars flag */ - state: AvatarState = "loading"; - - /* only set when state is unset, loaded or errored */ - avatarUrl?: string; - loadError?: string; - - loadingTimestamp: number = 0; - - constructor(client_avatar_id: string) { - this.clientAvatarId = client_avatar_id; - this.events = new Registry(); - } - - setState(state: AvatarState) { - if(state === this.state) - return; - - const oldState = this.state; - this.state = state; - this.events.fire("avatar_state_changed", { newState: state, oldState: oldState }); - } - - async awaitLoaded() { - if(this.state !== "loading") - return; - - await new Promise(resolve => this.events.on("avatar_state_changed", event => event.newState !== "loading" && resolve())); - } - - destroyUrl() { - URL.revokeObjectURL(this.avatarUrl); - this.avatarUrl = DefaultAvatarImage; +class LocalClientAvatar extends ClientAvatar { + protected destroyStateData(state: AvatarState, data: AvatarStateData[AvatarState]) { + if(state === "loaded") { + const tdata = data as AvatarStateData["loaded"]; + URL.revokeObjectURL(tdata.url); + } } } -export class AvatarManager { +export class AvatarManager extends AbstractAvatarManager { handle: FileManager; - private static cache: ImageCache; - private cachedAvatars: {[avatarId: string]: ClientAvatar} = {}; + private cachedAvatars: {[avatarId: string]: LocalClientAvatar} = {}; constructor(handle: FileManager) { + super(); this.handle = handle; if(!AvatarManager.cache) @@ -82,7 +56,7 @@ export class AvatarManager { } destroy() { - Object.values(this.cachedAvatars).forEach(e => e.destroyUrl()); + Object.values(this.cachedAvatars).forEach(e => e.destroy()); this.cachedAvatars = {}; } @@ -96,14 +70,13 @@ export class AvatarManager { }); } - private async executeAvatarLoad0(avatar: ClientAvatar) { - if(avatar.currentAvatarHash === "") { - avatar.destroyUrl(); - avatar.setState("unset"); + private async executeAvatarLoad0(avatar: LocalClientAvatar) { + if(avatar.getAvatarHash() === "") { + avatar.setUnset(); return; } - const initialAvatarHash = avatar.currentAvatarHash; + let initialAvatarHash = avatar.getAvatarHash(); let avatarResponse: Response; /* try to lookup our cache for the avatar */ @@ -118,18 +91,19 @@ export class AvatarManager { } let cachedAvatarHash = response.headers.has("X-avatar-version") ? response.headers.get("X-avatar-version") : undefined; - if(avatar.currentAvatarHash !== "unknown") { + if(avatar.getAvatarHash() !== "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); + log.debug(LogCategory.FILE_TRANSFER, tr("Invalidating cached avatar for %s (Version miss match. Cached: unset, Current: %s)"), avatar.clientAvatarId, avatar.getAvatarHash()); 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); + } else if(cachedAvatarHash !== avatar.getAvatarHash()) { + log.debug(LogCategory.FILE_TRANSFER, tr("Invalidating cached avatar for %s (Version miss match. Cached: %s, Current: %s)"), avatar.clientAvatarId, cachedAvatarHash, avatar.getAvatarHash()); await AvatarManager.cache.delete('avatar_' + avatar.clientAvatarId); break cache_lookup; } } else if(cachedAvatarHash) { - avatar.currentAvatarHash = cachedAvatarHash; + avatar.events.fire("avatar_changed", { newAvatarHash: cachedAvatarHash }); + initialAvatarHash = cachedAvatarHash; } avatarResponse = response; @@ -154,11 +128,12 @@ export class AvatarManager { const commandResult = error.commandResult; if(commandResult instanceof CommandResult) { if(commandResult.id === ErrorID.FILE_NOT_FOUND) { - if(avatar.currentAvatarHash !== initialAvatarHash) + if(avatar.getAvatarHash() !== initialAvatarHash) { + log.debug(LogCategory.GENERAL, tr("Ignoring avatar not found since the avatar itself got updated. Out version: %s, current version: %s"), initialAvatarHash, avatar.getAvatarHash()); return; + } - avatar.destroyUrl(); - avatar.setState("unset"); + avatar.setUnset(); return; } else if(commandResult.id === ErrorID.PERMISSION_ERROR) { throw tr("No permissions to download the avatar"); @@ -192,11 +167,13 @@ export class AvatarManager { const type = image_type(headers.get('X-media-bytes')); const media = media_image_type(type); - if(avatar.currentAvatarHash !== initialAvatarHash) + if(avatar.getAvatarHash() !== initialAvatarHash) { + log.debug(LogCategory.GENERAL, tr("Ignoring avatar not found since the avatar itself got updated. Out version: %s, current version: %s"), initialAvatarHash, avatar.getAvatarHash()); return; + } await AvatarManager.cache.put_cache('avatar_' + avatar.clientAvatarId, transferResponse.getResponse().clone(), "image/" + media, { - "X-avatar-version": avatar.currentAvatarHash + "X-avatar-version": avatar.getAvatarHash() }); avatarResponse = transferResponse.getResponse(); @@ -217,51 +194,49 @@ export class AvatarManager { const blob = await avatarResponse.blob(); /* ensure we're still up to date */ - if(avatar.currentAvatarHash !== initialAvatarHash) + if(avatar.getAvatarHash() !== initialAvatarHash) { + log.debug(LogCategory.GENERAL, tr("Ignoring avatar not found since the avatar itself got updated. Out version: %s, current version: %s"), initialAvatarHash, avatar.getAvatarHash()); 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"); + if(blob.type !== "image/" + media) { + avatar.setLoaded({ url: URL.createObjectURL(blob.slice(0, blob.size, "image/" + media)) }); + } else { + avatar.setLoaded({ url: URL.createObjectURL(blob) }); + } } } - private executeAvatarLoad(avatar: ClientAvatar) { - const avatar_hash = avatar.currentAvatarHash; + private executeAvatarLoad(avatar: LocalClientAvatar) { + const avatarHash = avatar.getAvatarHash(); - avatar.setState("loading"); + avatar.setLoading(); avatar.loadingTimestamp = Date.now(); this.executeAvatarLoad0(avatar).catch(error => { - if(avatar.currentAvatarHash !== avatar_hash) + if(avatar.getAvatarHash() !== avatarHash) { + log.debug(LogCategory.GENERAL, tr("Ignoring avatar not found since the avatar itself got updated. Out version: %s, current version: %s"), avatarHash, avatar.getAvatarHash()); 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"); + if(typeof error === "string") { + avatar.setErrored({ message: error }); + } else if(error instanceof Error) { + avatar.setErrored({ message: error.message }); + } else { + log.error(LogCategory.FILE_TRANSFER, tr("Failed to load avatar %s (hash: %s): %o"), avatar.clientAvatarId, avatarHash, error); + avatar.setErrored({ message: tr("lookup the console") }); + } }); } - update_cache(clientAvatarId: string, clientAvatarHash: string) { + updateCache(clientAvatarId: string, clientAvatarHash: string) { AvatarManager.cache.setup().then(async () => { const cached = this.cachedAvatars[clientAvatarId]; if(cached) { - if(cached.currentAvatarHash === clientAvatarHash) + if(cached.getAvatarHash() === clientAvatarHash) return; - log.info(LogCategory.GENERAL, tr("Deleting cached avatar for client %s. Cached version: %s; New version: %s"), cached.currentAvatarHash, clientAvatarHash); + log.info(LogCategory.GENERAL, tr("Deleting cached avatar for client %s. Cached version: %s; New version: %s"), cached.getAvatarHash(), clientAvatarHash); } const response = await AvatarManager.cache.resolve_cached('avatar_' + clientAvatarId); @@ -275,26 +250,25 @@ export class AvatarManager { } if(cached) { - cached.currentAvatarHash = clientAvatarHash; - cached.events.fire("avatar_changed"); + cached.events.fire("avatar_changed", { newAvatarHash: clientAvatarHash }); this.executeAvatarLoad(cached); } }); } - resolveAvatar(clientAvatarId: string, avatarHash?: string, cacheOnly?: boolean) { + resolveAvatar(clientAvatarId: string, avatarHash?: string, cacheOnly?: boolean) : ClientAvatar { let avatar = this.cachedAvatars[clientAvatarId]; if(!avatar) { if(cacheOnly) return undefined; - avatar = new ClientAvatar(clientAvatarId); + avatar = new LocalClientAvatar(clientAvatarId); this.cachedAvatars[clientAvatarId] = avatar; - } else if(typeof avatarHash !== "string" || avatar.currentAvatarHash === avatarHash) { + } else if(typeof avatarHash !== "string" || avatar.getAvatarHash() === avatarHash) { return avatar; } - avatar.currentAvatarHash = typeof avatarHash === "string" ? avatarHash : "unknown"; + avatar.events.fire("avatar_changed", { newAvatarHash: typeof avatarHash === "string" ? avatarHash : "unknown" }); this.executeAvatarLoad(avatar); return avatar; @@ -317,7 +291,7 @@ export class AvatarManager { return this.resolveAvatar(uniqueId2AvatarId(client.clientUniqueId), clientHandle?.properties.client_flag_avatar); } - private generate_default_image() : JQuery { + private static generate_default_image() : JQuery { return $.spawn("img").attr("src", "img/style/avatar.png").css({width: '100%', height: '100%'}); } @@ -337,7 +311,7 @@ export class AvatarManager { const container = $.spawn("div").addClass("avatar"); if(client_handle && !client_handle.properties.client_flag_avatar) - return container.append(this.generate_default_image()); + return container.append(AvatarManager.generate_default_image()); const clientAvatarId = client_handle ? client_handle.avatarId() : uniqueId2AvatarId(client_unique_id); @@ -346,11 +320,11 @@ export class AvatarManager { const updateJQueryTag = () => { - const image = $.spawn("img").attr("src", avatar.avatarUrl).css({width: '100%', height: '100%'}); + const image = $.spawn("img").attr("src", avatar.getAvatarUrl()).css({width: '100%', height: '100%'}); container.append(image); }; - if(avatar.state !== "loading") { + if(avatar.getState() !== "loading") { /* Test if we're may able to load the client avatar sync without a loading screen */ updateJQueryTag(); return container; @@ -362,7 +336,7 @@ export class AvatarManager { avatar.awaitLoaded().then(updateJQueryTag); image_loading.appendTo(container); } else { - this.generate_default_image().appendTo(container); + AvatarManager.generate_default_image().appendTo(container); } return container; @@ -378,34 +352,102 @@ export class AvatarManager { }); }; -export function uniqueId2AvatarId(unique_id: string) { - function str2ab(str) { - let buf = new ArrayBuffer(str.length); // 2 bytes for each char - let bufView = new Uint8Array(buf); - for (let i=0, strLen = str.length; i void }[]} = {}; + + constructor() { + super(); + + this.ipcChannel = ipc.getInstance().createChannel(undefined, kIPCAvatarChannel); + this.ipcChannel.messageHandler = this.handleIpcMessage.bind(this); + + server_connections.events().on("notify_handler_created", event => this.handleHandlerCreated(event.handler)); + server_connections.events().on("notify_handler_deleted", event => this.handleHandlerDestroyed(event.handler)); } - try { - let raw = atob(unique_id); - let input = hex.encode(str2ab(raw)); - - let result: string = ""; - for(let index = 0; index < input.length; index++) { - let c = input.charAt(index); - let offset: number = 0; - if(c >= '0' && c <= '9') - offset = c.charCodeAt(0) - '0'.charCodeAt(0); - else if(c >= 'A' && c <= 'F') - offset = c.charCodeAt(0) - 'A'.charCodeAt(0) + 0x0A; - else if(c >= 'a' && c <= 'f') - offset = c.charCodeAt(0) - 'a'.charCodeAt(0) + 0x0A; - result += String.fromCharCode('a'.charCodeAt(0) + offset); - } - return result; - } catch (e) { //invalid base 64 (like music bot etc) - return undefined; + getManager(handlerId: string): AbstractAvatarManager { + return server_connections.findConnection(handlerId)?.fileManager.avatars; } -} \ No newline at end of file + + hasManager(handlerId: string): boolean { + return this.getManager(handlerId) !== undefined; + } + + private handleHandlerCreated(handler: ConnectionHandler) { + this.ipcChannel.sendMessage("notify-handler-created", { handler: handler.handlerId }); + } + + private handleHandlerDestroyed(handler: ConnectionHandler) { + this.ipcChannel.sendMessage("notify-handler-destroyed", { handler: handler.handlerId }); + const subscriptions = this.subscribedAvatars[handler.handlerId] || []; + delete this.subscribedAvatars[handler.handlerId]; + + subscriptions.forEach(e => e.unregisterCallback()); + } + + private handleIpcMessage(remoteId: string, broadcast: boolean, message: ChannelMessage) { + if(broadcast) + return; + + if(message.type === "query-handlers") { + this.ipcChannel.sendMessage("notify-handlers", { + handlers: server_connections.all_connections().map(e => e.handlerId) + }, remoteId); + return; + } else if(message.type === "load-avatar") { + const sendResponse = properties => { + this.ipcChannel.sendMessage("load-avatar-result", { + avatarId: message.data.avatarId, + handlerId: message.data.handlerId, + ...properties + }, remoteId); + }; + + const avatarId = message.data.avatarId; + const handlerId = message.data.handlerId; + const manager = this.getManager(handlerId); + if(!manager) { + sendResponse({ success: false, message: tr("Invalid handler") }); + return; + } + + let avatar: ClientAvatar; + if(message.data.keyType === "client") { + avatar = manager.resolveClientAvatar({ + id: message.data.clientId, + clientUniqueId: message.data.clientUniqueId, + database_id: message.data.clientDatabaseId + }); + } else { + avatar = manager.resolveAvatar(message.data.clientAvatarId, message.data.avatarVersion); + } + + const subscribedAvatars = this.subscribedAvatars[handlerId] || (this.subscribedAvatars[handlerId] = []); + const oldSubscribedAvatarIndex = subscribedAvatars.findIndex(e => e.remoteAvatarId === avatarId); + if(oldSubscribedAvatarIndex !== -1) { + const [ subscription ] = subscribedAvatars.splice(oldSubscribedAvatarIndex, 1); + subscription.unregisterCallback(); + } + subscribedAvatars.push({ + avatar: avatar, + remoteAvatarId: avatarId, + unregisterCallback: avatar.events.onAll(event => { + this.ipcChannel.sendMessage("avatar-event", { handlerId: handlerId, avatarId: avatarId, event: event }, remoteId); + }) + }); + + sendResponse({ success: true, state: avatar.getState(), stateData: avatar.getStateData(), hash: avatar.getAvatarHash() }); + } + } +} + +loader.register_task(Stage.LOADED, { + name: "Avatar init", + function: async () => { + setGlobalAvatarManagerFactory(new LocalAvatarManagerFactory()); + }, + priority: 5 +}); \ No newline at end of file diff --git a/shared/js/file/RemoteAvatars.ts b/shared/js/file/RemoteAvatars.ts new file mode 100644 index 00000000..489ab309 --- /dev/null +++ b/shared/js/file/RemoteAvatars.ts @@ -0,0 +1,227 @@ +import * as ipc from "../ipc/BrowserIPC"; +import * as loader from "tc-loader"; +import {Stage} from "tc-loader"; + +import { + AbstractAvatarManager, + AbstractAvatarManagerFactory, AvatarState, AvatarStateData, ClientAvatar, + kIPCAvatarChannel, + setGlobalAvatarManagerFactory, uniqueId2AvatarId +} from "tc-shared/file/Avatars"; +import {IPCChannel} from "tc-shared/ipc/BrowserIPC"; +import {Settings} from "tc-shared/settings"; +import {ChannelMessage} from "../ipc/BrowserIPC"; +import {guid} from "tc-shared/crypto/uid"; + +function isEquivalent(a, b) { + // Create arrays of property names + const aProps = Object.getOwnPropertyNames(a); + const bProps = Object.getOwnPropertyNames(b); + + // If number of properties is different, + // objects are not equivalent + if (aProps.length != bProps.length) { + return false; + } + + for (let i = 0; i < aProps.length; i++) { + const propName = aProps[i]; + + // If values of same property are not equal, + // objects are not equivalent + if (a[propName] !== b[propName]) { + return false; + } + } + + // If we made it this far, objects + // are considered equivalent + return true; +} + +class RemoteAvatar extends ClientAvatar { + readonly avatarId: string; + readonly type: "avatar" | "client-avatar"; + + constructor(clientAvatarId: string, type: "avatar" | "client-avatar") { + super(clientAvatarId); + + this.avatarId = guid(); + this.type = type; + } + + protected destroyStateData(state: AvatarState, data: AvatarStateData[AvatarState]) {} + + public updateStateFromRemote(state: AvatarState, data: AvatarStateData[AvatarState]) { + if(this.getState() === state && isEquivalent(this.getStateData(), data)) + return; + + this.setState(state, data, true); + } +} + +class RemoteAvatarManager extends AbstractAvatarManager { + readonly handlerId: string; + readonly ipcChannel: IPCChannel; + private knownAvatars: RemoteAvatar[] = []; + + constructor(handlerId: string, ipcChannel: IPCChannel) { + super(); + + this.ipcChannel = ipcChannel; + this.handlerId = handlerId; + } + + destroy() { + this.knownAvatars.forEach(e => e.destroy()); + } + + resolveAvatar(clientAvatarId: string, avatarHash?: string): ClientAvatar { + const sendRequest = (avatar: RemoteAvatar) => this.ipcChannel.sendMessage("load-avatar", { + avatarId: avatar.avatarId, + handlerId: this.handlerId, + + keyType: "avatar", + clientAvatarId: avatar.clientAvatarId, + avatarVersion: avatarHash + }); + + const cachedAvatar = this.knownAvatars.find(e => e.type === "avatar" && e.avatarId === clientAvatarId); + if(cachedAvatar) { + if(cachedAvatar.getAvatarHash() !== avatarHash) + sendRequest(cachedAvatar); /* update */ + return cachedAvatar; + } + + let avatar = new RemoteAvatar(clientAvatarId, "avatar"); + avatar.setLoading(); + this.knownAvatars.push(avatar); + sendRequest(avatar); + return avatar; + } + + resolveClientAvatar(client: { id?: number; database_id?: number; clientUniqueId: string }) { + const sendRequest = (avatar: RemoteAvatar) => this.ipcChannel.sendMessage("load-avatar", { + avatarId: avatar.avatarId, + handlerId: this.handlerId, + + keyType: "client", + clientId: client.id, + clientUniqueId: client.clientUniqueId, + clientDatabaseId: client.database_id + }); + + const clientAvatarId = uniqueId2AvatarId(client.clientUniqueId); + const cachedAvatar = this.knownAvatars.find(e => e.type === "client-avatar" && e.clientAvatarId === clientAvatarId); + if(cachedAvatar) { + //sendRequest(cachedAvatar); /* just update in case */ + return cachedAvatar; + } + + let avatar = new RemoteAvatar(clientAvatarId, "client-avatar"); + avatar.setLoading(); + this.knownAvatars.push(avatar); + sendRequest(avatar); + + return avatar; + } + + handleAvatarLoadCallback(data: any) { + const avatar = this.knownAvatars.find(e => e.avatarId === data.avatarId); + if(!avatar) return; + + if(!(data.success === true)) { + avatar.setErrored({ message: data.message }); + return; + } + + if(avatar.getAvatarHash() !== data.hash) + avatar.events.fire("avatar_changed", { newAvatarHash: data.hash }); + + avatar.updateStateFromRemote(data.state, data.stateData); + } + + handleAvatarEvent(data: any) { + const avatar = this.knownAvatars.find(e => e.avatarId === data.avatarId); + if(!avatar) return; + + avatar.events.fire(data.event.type, data.event, true); + } +} + +class RemoteAvatarManagerFactory extends AbstractAvatarManagerFactory { + private readonly ipcChannel: IPCChannel; + private manager: {[key: string]: RemoteAvatarManager} = {}; + + private callbackHandlerQueried: () => void; + + constructor() { + super(); + + this.ipcChannel = ipc.getInstance().createChannel(Settings.instance.static(Settings.KEY_IPC_REMOTE_ADDRESS, "invalid"), kIPCAvatarChannel); + this.ipcChannel.messageHandler = this.handleIpcMessage.bind(this); + } + + async initialize() { + this.ipcChannel.sendMessage("query-handlers", {}); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.callbackHandlerQueried = undefined; + reject(tr("handler query timeout")); + }, 5000); + + this.callbackHandlerQueried = () => { + clearTimeout(timeout); + resolve(); + } + }); + } + + getManager(handlerId: string): AbstractAvatarManager { + return this.manager[handlerId]; + } + + hasManager(handlerId: string): boolean { + return typeof this.manager[handlerId] !== "undefined"; + } + + private handleIpcMessage(remoteId: string, broadcast: boolean, message: ChannelMessage) { + if(broadcast) { + if(message.type === "notify-handler-destroyed") { + const manager = this.manager[message.data.handler]; + delete this.manager[message.data.handler]; + manager?.destroy(); + } else if(message.type === "notify-handler-created") { + this.manager[message.data.handler] = new RemoteAvatarManager(message.data.handler, this.ipcChannel); + } + } else { + if(message.type === "notify-handlers") { + Object.values(this.manager).forEach(e => e.destroy()); + this.manager = {}; + + for(const handlerId of message.data.handlers) + this.manager[handlerId] = new RemoteAvatarManager(handlerId, this.ipcChannel); + + if(this.callbackHandlerQueried) + this.callbackHandlerQueried(); + } else if(message.type === "load-avatar-result") { + const manager = this.manager[message.data.handlerId]; + manager?.handleAvatarLoadCallback(message.data); + } else if(message.type === "avatar-event") { + const manager = this.manager[message.data.handlerId]; + manager?.handleAvatarEvent(message.data); + } + } + } +} + +loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { + priority: 10, + name: "IPC avatar init", + function: async () => { + let factory = new RemoteAvatarManagerFactory(); + await factory.initialize(); + setGlobalAvatarManagerFactory(factory); + } +}); \ No newline at end of file diff --git a/shared/js/ipc/BrowserIPC.ts b/shared/js/ipc/BrowserIPC.ts new file mode 100644 index 00000000..41b87909 --- /dev/null +++ b/shared/js/ipc/BrowserIPC.ts @@ -0,0 +1,287 @@ +import * as log from "tc-shared/log"; +import {LogCategory} from "tc-shared/log"; +import {ConnectHandler} from "tc-shared/ipc/ConnectHandler"; + +export interface BroadcastMessage { + timestamp: number; + receiver: string; + sender: string; + + type: string; + data: any; +} + +function uuidv4() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +} + +interface ProcessQuery { + timestamp: number + query_id: string; +} + +export interface ChannelMessage { + channel_id: string; + type: string; + data: any; +} + +export interface ProcessQueryResponse { + request_timestamp: number + request_query_id: string; + + device_id: string; + protocol: number; +} + +export interface CertificateAcceptCallback { + request_id: string; +} +export interface CertificateAcceptSucceeded { } + +export abstract class BasicIPCHandler { + protected static readonly BROADCAST_UNIQUE_ID = "00000000-0000-4000-0000-000000000000"; + protected static readonly PROTOCOL_VERSION = 1; + + protected _channels: IPCChannel[] = []; + protected unique_id; + + protected constructor() { } + + setup() { + this.unique_id = uuidv4(); /* lets get an unique identifier */ + } + + getLocalAddress() { return this.unique_id; } + + abstract sendMessage(type: string, data: any, target?: string); + + protected handleMessage(message: BroadcastMessage) { + //log.trace(LogCategory.IPC, tr("Received message %o"), message); + + if(message.receiver === BasicIPCHandler.BROADCAST_UNIQUE_ID) { + if(message.type == "process-query") { + log.debug(LogCategory.IPC, tr("Received a device query from %s."), message.sender); + this.sendMessage("process-query-response", { + request_query_id: (message.data).query_id, + request_timestamp: (message.data).timestamp, + + device_id: this.unique_id, + protocol: BasicIPCHandler.PROTOCOL_VERSION + } as ProcessQueryResponse, message.sender); + return; + } + } else if(message.receiver === this.unique_id) { + if(message.type == "process-query-response") { + const response: ProcessQueryResponse = message.data; + if(this._query_results[response.request_query_id]) + this._query_results[response.request_query_id].push(response); + else { + log.warn(LogCategory.IPC, tr("Received a query response for an unknown request.")); + } + return; + } + else if(message.type == "certificate-accept-callback") { + const data: CertificateAcceptCallback = message.data; + if(!this._cert_accept_callbacks[data.request_id]) { + log.warn(LogCategory.IPC, tr("Received certificate accept callback for an unknown request ID.")); + return; + } + this._cert_accept_callbacks[data.request_id](); + delete this._cert_accept_callbacks[data.request_id]; + + this.sendMessage("certificate-accept-succeeded", { + + } as CertificateAcceptSucceeded, message.sender); + return; + } + else if(message.type == "certificate-accept-succeeded") { + if(!this._cert_accept_succeeded[message.sender]) { + log.warn(LogCategory.IPC, tr("Received certificate accept succeeded, but haven't a callback.")); + return; + } + this._cert_accept_succeeded[message.sender](); + return; + } + } + if(message.type === "channel") { + const data: ChannelMessage = message.data; + + let channel_invoked = false; + for(const channel of this._channels) + if(channel.channelId === data.channel_id && (typeof(channel.targetClientId) === "undefined" || channel.targetClientId === message.sender)) { + if(channel.messageHandler) + channel.messageHandler(message.sender, message.receiver === BasicIPCHandler.BROADCAST_UNIQUE_ID, data); + channel_invoked = true; + } + if(!channel_invoked) { + debugger; + console.warn(tr("Received channel message for unknown channel (%s)"), data.channel_id); + } + } + } + + createChannel(targetId?: string, channelId?: string) : IPCChannel { + let channel: IPCChannel = { + targetClientId: targetId, + channelId: channelId || uuidv4(), + messageHandler: undefined, + sendMessage: (type: string, data: any, target?: string) => { + if(typeof target !== "undefined") { + if(typeof channel.targetClientId === "string" && target != channel.targetClientId) + throw "target id does not match channel target"; + } + + this.sendMessage("channel", { + type: type, + data: data, + channel_id: channel.channelId + } as ChannelMessage, target || channel.targetClientId || BasicIPCHandler.BROADCAST_UNIQUE_ID); + } + }; + + this._channels.push(channel); + return channel; + } + + channels() : IPCChannel[] { return this._channels; } + + deleteChannel(channel: IPCChannel) { + this._channels = this._channels.filter(e => e !== channel); + } + + private _query_results: {[key: string]:ProcessQueryResponse[]} = {}; + async queryProcesses(timeout?: number) : Promise { + const query_id = uuidv4(); + this._query_results[query_id] = []; + + this.sendMessage("process-query", { + query_id: query_id, + timestamp: Date.now() + } as ProcessQuery); + + await new Promise(resolve => setTimeout(resolve, timeout || 250)); + const result = this._query_results[query_id]; + delete this._query_results[query_id]; + return result; + } + + private _cert_accept_callbacks: {[key: string]:(() => any)} = {}; + register_certificate_accept_callback(callback: () => any) : string { + const id = uuidv4(); + this._cert_accept_callbacks[id] = callback; + return this.unique_id + ":" + id; + } + + private _cert_accept_succeeded: {[sender: string]:(() => any)} = {}; + post_certificate_accpected(id: string, timeout?: number) : Promise { + return new Promise((resolve, reject) => { + const data = id.split(":"); + const timeout_id = setTimeout(() => { + delete this._cert_accept_succeeded[data[0]]; + clearTimeout(timeout_id); + reject("timeout"); + }, timeout || 250); + this._cert_accept_succeeded[data[0]] = () => { + delete this._cert_accept_succeeded[data[0]]; + clearTimeout(timeout_id); + resolve(); + }; + this.sendMessage("certificate-accept-callback", { + request_id: data[1] + } as CertificateAcceptCallback, data[0]); + }) + } +} + +export interface IPCChannel { + readonly channelId: string; + targetClientId?: string; + + messageHandler: (remoteId: string, broadcast: boolean, message: ChannelMessage) => void; + sendMessage(type: string, message: any, target?: string); +} + +class BroadcastChannelIPC extends BasicIPCHandler { + private static readonly CHANNEL_NAME = "TeaSpeak-Web"; + + private channel: BroadcastChannel; + + constructor() { + super(); + } + + setup() { + super.setup(); + + this.channel = new BroadcastChannel(BroadcastChannelIPC.CHANNEL_NAME); + this.channel.onmessage = this.onMessage.bind(this); + this.channel.onmessageerror = this.onError.bind(this); + } + + private onMessage(event: MessageEvent) { + if(typeof(event.data) !== "string") { + log.warn(LogCategory.IPC, tr("Received message with an invalid type (%s): %o"), typeof(event.data), event.data); + return; + } + + let message: BroadcastMessage; + try { + message = JSON.parse(event.data); + } catch(error) { + log.error(LogCategory.IPC, tr("Received an invalid encoded message: %o"), event.data); + return; + } + super.handleMessage(message); + } + + private onError(event: MessageEvent) { + log.warn(LogCategory.IPC, tr("Received error: %o"), event); + } + + sendMessage(type: string, data: any, target?: string) { + const message: BroadcastMessage = {} as any; + + message.sender = this.unique_id; + message.receiver = target ? target : BasicIPCHandler.BROADCAST_UNIQUE_ID; + message.timestamp = Date.now(); + message.type = type; + message.data = data; + + this.channel.postMessage(JSON.stringify(message)); + } +} + +let handler: BasicIPCHandler; +let connect_handler: ConnectHandler; + +export function setup() { + if(!supported()) + return; + + if(handler) + throw "bipc already started"; + + handler = new BroadcastChannelIPC(); + handler.setup(); + + connect_handler = new ConnectHandler(handler); + connect_handler.setup(); +} + +export function getInstance() { + return handler; +} + +export function getInstanceConnectHandler() { + return connect_handler; +} + +export function supported() { + /* ios does not support this */ + return typeof(window.BroadcastChannel) !== "undefined"; +} \ No newline at end of file diff --git a/shared/js/ipc/ConnectHandler.ts b/shared/js/ipc/ConnectHandler.ts new file mode 100644 index 00000000..a8dc0ea0 --- /dev/null +++ b/shared/js/ipc/ConnectHandler.ts @@ -0,0 +1,230 @@ +import * as log from "tc-shared/log"; +import {LogCategory} from "tc-shared/log"; +import {BasicIPCHandler, IPCChannel, ChannelMessage} from "tc-shared/ipc/BrowserIPC"; +import {guid} from "tc-shared/crypto/uid"; + +export type ConnectRequestData = { + address: string; + + profile?: string; + username?: string; + password?: { + value: string; + hashed: boolean; + }; +} + +export interface ConnectOffer { + request_id: string; + data: ConnectRequestData; +} + +export interface ConnectOfferAnswer { + request_id: string; + accepted: boolean; +} + +export interface ConnectExecute { + request_id: string; +} + +export interface ConnectExecuted { + request_id: string; + succeeded: boolean; + message?: string; +} + +/* The connect process: + * 1. Broadcast an offer + * 2. Wait 50ms for all offer responses or until the first one respond with "ok" + * 3. Select (if possible) on accepted offer and execute the connect + */ +export class ConnectHandler { + private static readonly CHANNEL_NAME = "connect"; + + readonly ipc_handler: BasicIPCHandler; + private ipc_channel: IPCChannel; + + public callback_available: (data: ConnectRequestData) => boolean = () => false; + public callback_execute: (data: ConnectRequestData) => boolean | string = () => false; + + + private _pending_connect_offers: { + id: string; + data: ConnectRequestData; + timeout: number; + + remote_handler: string; + }[] = []; + + private _pending_connects_requests: { + id: string; + + data: ConnectRequestData; + timeout: number; + + callback_success: () => any; + callback_failed: (message: string) => any; + callback_avail: () => Promise; + + remote_handler?: string; + }[] = []; + + constructor(ipc_handler: BasicIPCHandler) { + this.ipc_handler = ipc_handler; + } + + public setup() { + this.ipc_channel = this.ipc_handler.createChannel(undefined, ConnectHandler.CHANNEL_NAME); + this.ipc_channel.messageHandler = this.onMessage.bind(this); + } + + private onMessage(sender: string, broadcast: boolean, message: ChannelMessage) { + if(broadcast) { + if(message.type == "offer") { + const data = message.data as ConnectOffer; + + const response = { + accepted: this.callback_available(data.data), + request_id: data.request_id + } as ConnectOfferAnswer; + + if(response.accepted) { + log.debug(LogCategory.IPC, tr("Received new connect offer from %s: %s"), sender, data.request_id); + + const ld = { + remote_handler: sender, + data: data.data, + id: data.request_id, + timeout: 0 + }; + this._pending_connect_offers.push(ld); + ld.timeout = setTimeout(() => { + log.debug(LogCategory.IPC, tr("Dropping connect request %s, because we never received an execute."), ld.id); + this._pending_connect_offers.remove(ld); + }, 120 * 1000) as any; + } + this.ipc_channel.sendMessage("offer-answer", response, sender); + } + } else { + if(message.type == "offer-answer") { + const data = message.data as ConnectOfferAnswer; + const request = this._pending_connects_requests.find(e => e.id === data.request_id); + if(!request) { + log.warn(LogCategory.IPC, tr("Received connect offer answer with unknown request id (%s)."), data.request_id); + return; + } + if(!data.accepted) { + log.debug(LogCategory.IPC, tr("Client %s rejected the connect offer (%s)."), sender, request.id); + return; + } + if(request.remote_handler) { + log.debug(LogCategory.IPC, tr("Client %s accepted the connect offer (%s), but offer has already been accepted."), sender, request.id); + return; + } + + log.debug(LogCategory.IPC, tr("Client %s accepted the connect offer (%s). Request local acceptance."), sender, request.id); + request.remote_handler = sender; + clearTimeout(request.timeout); + + request.callback_avail().then(flag => { + if(!flag) { + request.callback_failed("local avail rejected"); + return; + } + + log.debug(LogCategory.IPC, tr("Executing connect with client %s"), request.remote_handler); + this.ipc_channel.sendMessage("execute", { + request_id: request.id + } as ConnectExecute, request.remote_handler); + request.timeout = setTimeout(() => { + request.callback_failed("connect execute timeout"); + }, 1000) as any; + }).catch(error => { + log.error(LogCategory.IPC, tr("Local avail callback caused an error: %o"), error); + request.callback_failed(tr("local avail callback caused an error")); + }); + + } + else if(message.type == "executed") { + const data = message.data as ConnectExecuted; + const request = this._pending_connects_requests.find(e => e.id === data.request_id); + if(!request) { + log.warn(LogCategory.IPC, tr("Received connect executed with unknown request id (%s)."), data.request_id); + return; + } + + if(request.remote_handler != sender) { + log.warn(LogCategory.IPC, tr("Received connect executed for request %s, but from wrong client: %s (expected %s)"), data.request_id, sender, request.remote_handler); + return; + } + + log.debug(LogCategory.IPC, tr("Received connect executed response from client %s for request %s. Succeeded: %o (%s)"), sender, data.request_id, data.succeeded, data.message); + clearTimeout(request.timeout); + if(data.succeeded) + request.callback_success(); + else + request.callback_failed(data.message); + } + else if(message.type == "execute") { + const data = message.data as ConnectExecute; + const request = this._pending_connect_offers.find(e => e.id === data.request_id); + if(!request) { + log.warn(LogCategory.IPC, tr("Received connect execute with unknown request id (%s)."), data.request_id); + return; + } + + if(request.remote_handler != sender) { + log.warn(LogCategory.IPC, tr("Received connect execute for request %s, but from wrong client: %s (expected %s)"), data.request_id, sender, request.remote_handler); + return; + } + clearTimeout(request.timeout); + this._pending_connect_offers.remove(request); + + log.debug(LogCategory.IPC, tr("Executing connect for %s"), data.request_id); + const cr = this.callback_execute(request.data); + + const response = { + request_id: data.request_id, + + succeeded: typeof(cr) !== "string" && cr, + message: typeof(cr) === "string" ? cr : "", + } as ConnectExecuted; + this.ipc_channel.sendMessage("executed", response, request.remote_handler); + } + } + } + + post_connect_request(data: ConnectRequestData, callback_avail: () => Promise) : Promise { + return new Promise((resolve, reject) => { + const pd = { + data: data, + id: guid(), + timeout: 0, + + callback_success: () => { + this._pending_connects_requests.remove(pd); + clearTimeout(pd.timeout); + resolve(); + }, + + callback_failed: error => { + this._pending_connects_requests.remove(pd); + clearTimeout(pd.timeout); + reject(error); + }, + + callback_avail: callback_avail, + }; + this._pending_connects_requests.push(pd); + + this.ipc_channel.sendMessage("offer", { + request_id: pd.id, + data: pd.data + } as ConnectOffer); + pd.timeout = setTimeout(() => { + pd.callback_failed("received no response to offer"); + }, 50) as any; + }) + } +} \ No newline at end of file diff --git a/shared/js/ipc/MethodProxy.ts b/shared/js/ipc/MethodProxy.ts new file mode 100644 index 00000000..cd803921 --- /dev/null +++ b/shared/js/ipc/MethodProxy.ts @@ -0,0 +1,216 @@ +import * as log from "tc-shared/log"; +import {LogCategory} from "tc-shared/log"; +import {BasicIPCHandler, IPCChannel, ChannelMessage} from "tc-shared/ipc/BrowserIPC"; + +export interface MethodProxyInvokeData { + method_name: string; + arguments: any[]; + promise_id: string; +} +export interface MethodProxyResultData { + promise_id: string; + result: any; + success: boolean; +} +export interface MethodProxyCallback { + promise: Promise; + promise_id: string; + + resolve: (object: any) => any; + reject: (object: any) => any; +} + +export type MethodProxyConnectParameters = { + channel_id: string; + client_id: string; +} +export abstract class MethodProxy { + readonly ipc_handler: BasicIPCHandler; + private _ipc_channel: IPCChannel; + private _ipc_parameters: MethodProxyConnectParameters; + + private readonly _local: boolean; + private readonly _slave: boolean; + + private _connected: boolean; + private _proxied_methods: {[key: string]:() => Promise} = {}; + private _proxied_callbacks: {[key: string]:MethodProxyCallback} = {}; + + protected constructor(ipc_handler: BasicIPCHandler, connect_params?: MethodProxyConnectParameters) { + this.ipc_handler = ipc_handler; + this._ipc_parameters = connect_params; + this._connected = false; + this._slave = typeof(connect_params) !== "undefined"; + this._local = typeof(connect_params) !== "undefined" && connect_params.channel_id === "local" && connect_params.client_id === "local"; + } + + protected setup() { + if(this._local) { + this._connected = true; + this.on_connected(); + } else { + if(this._slave) + this._ipc_channel = this.ipc_handler.createChannel(this._ipc_parameters.client_id, this._ipc_parameters.channel_id); + else + this._ipc_channel = this.ipc_handler.createChannel(); + + this._ipc_channel.messageHandler = this._handle_message.bind(this); + if(this._slave) + this._ipc_channel.sendMessage("initialize", {}); + } + } + + protected finalize() { + if(!this._local) { + if(this._connected) + this._ipc_channel.sendMessage("finalize", {}); + + this.ipc_handler.deleteChannel(this._ipc_channel); + this._ipc_channel = undefined; + } + for(const promise of Object.values(this._proxied_callbacks)) + promise.reject("disconnected"); + this._proxied_callbacks = {}; + + this._connected = false; + this.on_disconnected(); + } + + protected register_method(method: (...args: any[]) => Promise | string) { + let method_name: string; + if(typeof method === "function") { + log.debug(LogCategory.IPC, tr("Registering method proxy for %s"), method.name); + method_name = method.name; + } else { + log.debug(LogCategory.IPC, tr("Registering method proxy for %s"), method); + method_name = method; + } + + if(!this[method_name]) + throw "method is missing in current object"; + + this._proxied_methods[method_name] = this[method_name]; + if(!this._local) { + this[method_name] = (...args: any[]) => { + if(!this._connected) + return Promise.reject("not connected"); + + const proxy_callback = { + promise_id: uuidv4() + } as MethodProxyCallback; + this._proxied_callbacks[proxy_callback.promise_id] = proxy_callback; + proxy_callback.promise = new Promise((resolve, reject) => { + proxy_callback.resolve = resolve; + proxy_callback.reject = reject; + }); + + this._ipc_channel.sendMessage("invoke", { + promise_id: proxy_callback.promise_id, + arguments: [...args], + method_name: method_name + } as MethodProxyInvokeData); + return proxy_callback.promise; + } + } + } + + private _handle_message(remote_id: string, boradcast: boolean, message: ChannelMessage) { + if(message.type === "finalize") { + this._handle_finalize(); + } else if(message.type === "initialize") { + this._handle_remote_callback(remote_id); + } else if(message.type === "invoke") { + this._handle_invoke(message.data); + } else if(message.type === "result") { + this._handle_result(message.data); + } + } + + private _handle_finalize() { + this.on_disconnected(); + this.finalize(); + this._connected = false; + } + + private _handle_remote_callback(remote_id: string) { + if(!this._ipc_channel.targetClientId) { + if(this._slave) + throw "initialize wrong state!"; + + this._ipc_channel.targetClientId = remote_id; /* now we're able to send messages */ + this.on_connected(); + this._ipc_channel.sendMessage("initialize", true); + } else { + if(!this._slave) + throw "initialize wrong state!"; + + this.on_connected(); + } + this._connected = true; + } + + private _send_result(promise_id: string, success: boolean, message: any) { + this._ipc_channel.sendMessage("result", { + promise_id: promise_id, + result: message, + success: success + } as MethodProxyResultData); + } + + private _handle_invoke(data: MethodProxyInvokeData) { + if(this._proxied_methods[data.method_name]) + throw "we could not invoke a local proxied method!"; + + if(!this[data.method_name]) { + this._send_result(data.promise_id, false, "missing method"); + return; + } + + try { + log.info(LogCategory.IPC, tr("Invoking method %s with arguments: %o"), data.method_name, data.arguments); + + const promise = this[data.method_name](...data.arguments); + promise.then(result => { + log.info(LogCategory.IPC, tr("Result: %o"), result); + this._send_result(data.promise_id, true, result); + }).catch(error => { + this._send_result(data.promise_id, false, error); + }); + } catch(error) { + this._send_result(data.promise_id, false, error); + return; + } + } + + private _handle_result(data: MethodProxyResultData) { + if(!this._proxied_callbacks[data.promise_id]) { + console.warn(tr("Received proxy method result for unknown promise")); + return; + } + const callback = this._proxied_callbacks[data.promise_id]; + delete this._proxied_callbacks[data.promise_id]; + + if(data.success) + callback.resolve(data.result); + else + callback.reject(data.result); + } + + generate_connect_parameters() : MethodProxyConnectParameters { + if(this._slave) + throw "only masters can generate connect parameters!"; + if(!this._ipc_channel) + throw "please call setup() before"; + + return { + channel_id: this._ipc_channel.channelId, + client_id: this.ipc_handler.getLocalAddress() + }; + } + + is_slave() { return this._local || this._slave; } /* the popout modal */ + is_master() { return this._local || !this._slave; } /* the host (teaweb application) */ + + protected abstract on_connected(); + protected abstract on_disconnected(); +} \ No newline at end of file diff --git a/shared/js/main.tsx b/shared/js/main.tsx index 14fe02f0..c5222406 100644 --- a/shared/js/main.tsx +++ b/shared/js/main.tsx @@ -4,7 +4,7 @@ import {settings, Settings} from "tc-shared/settings"; import * as profiles from "tc-shared/profiles/ConnectionProfile"; import * as log from "tc-shared/log"; import {LogCategory} from "tc-shared/log"; -import * as bipc from "./BrowserIPC"; +import * as bipc from "./ipc/BrowserIPC"; import * as sound from "./sound/Sounds"; import * as i18n from "./i18n/localize"; import {tra} from "./i18n/localize"; @@ -39,7 +39,10 @@ import ContextMenuEvent = JQuery.ContextMenuEvent; import "./proto"; import "./ui/elements/ContextDivider"; import "./ui/elements/Tab"; -import "./connection/CommandHandler"; /* else it might not get bundled because only the backends are accessing it */ +import "./connection/CommandHandler"; +import {ConnectRequestData} from "tc-shared/ipc/ConnectHandler"; +import {spawnVideoPopout} from "tc-shared/video-viewer/Controller"; +import {spawnModalCssVariableEditor} from "tc-shared/ui/modal/css-editor/Controller"; /* else it might not get bundled because only the backends are accessing it */ declare global { interface Window { @@ -130,8 +133,6 @@ function setup_jsrender() : boolean { } async function initialize() { - Settings.initialize(); - try { await i18n.initialize(); } catch(error) { @@ -251,7 +252,7 @@ interface Window { } */ -export function handle_connect_request(properties: bipc.connect.ConnectRequestData, connection: ConnectionHandler) { +export function handle_connect_request(properties: ConnectRequestData, connection: ConnectionHandler) { const profile_uuid = properties.profile || (profiles.default_profile() || {id: 'default'}).id; const profile = profiles.find_profile(profile_uuid) || profiles.default_profile(); const username = properties.username || profile.connect_username(); @@ -497,7 +498,7 @@ function main() { modal.close_listener.push(() => settings.changeGlobal(Settings.KEY_USER_IS_NEW, false)); } - (window as any).spawnFileTransferModal = spawnFileTransferModal; + (window as any).spawnVideoPopout = spawnModalCssVariableEditor; } const task_teaweb_starter: loader.Task = { @@ -527,7 +528,7 @@ const task_connect_handler: loader.Task = { name: "Connect handler", function: async () => { const address = settings.static(Settings.KEY_CONNECT_ADDRESS, ""); - const chandler = bipc.get_connect_handler(); + const chandler = bipc.getInstanceConnectHandler(); if(settings.static(Settings.KEY_FLAG_CONNECT_DEFAULT, false) && address) { const connect_data = { address: address, @@ -599,7 +600,7 @@ const task_certificate_callback: loader.Task = { log.info(LogCategory.IPC, tr("Using this instance as certificate callback. ID: %s"), certificate_accept); try { try { - await bipc.get_handler().post_certificate_accpected(certificate_accept); + await bipc.getInstance().post_certificate_accpected(certificate_accept); } catch(e) {} //FIXME remove! log.info(LogCategory.IPC, tr("Other instance has acknowledged out work. Closing this window.")); diff --git a/shared/js/settings.ts b/shared/js/settings.ts index 771a1c87..cf370329 100644 --- a/shared/js/settings.ts +++ b/shared/js/settings.ts @@ -1,6 +1,7 @@ +import * as log from "tc-shared/log"; import {LogCategory} from "tc-shared/log"; import * as loader from "tc-loader"; -import * as log from "tc-shared/log"; +import {Stage} from "tc-loader"; import {Registry} from "tc-shared/events"; type ConfigValueTypes = boolean | number | string; @@ -456,6 +457,11 @@ export class Settings extends StaticSettings { /* defaultValue: */ }; + static readonly KEY_IPC_REMOTE_ADDRESS: SettingsKey = { + key: "ipc-address", + valueType: "string" + }; + static readonly FN_LOG_ENABLED: (category: string) => SettingsKey = category => { return { key: "log." + category.toLowerCase() + ".enabled", @@ -708,4 +714,10 @@ export class ServerSettings extends SettingsBase { } } -export let settings: Settings = null; \ No newline at end of file +export let settings: Settings = null; + +loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { + priority: 1000, + name: "Settings initialize", + function: async () => Settings.initialize() +}) \ No newline at end of file diff --git a/shared/js/text/bbcode/highlight.scss b/shared/js/text/bbcode/highlight.scss new file mode 100644 index 00000000..bf1bcb4f --- /dev/null +++ b/shared/js/text/bbcode/highlight.scss @@ -0,0 +1,36 @@ +@import "../../../css/static/mixin"; + +/* code hightliting */ +.inlineCode, .code { + display: block; + margin: 3px; + + font-size: 80%; + border-radius: .2em; + font-family: Monaco, Menlo, Consolas, "Roboto Mono", "Andale Mono", "Ubuntu Mono", monospace; + box-decoration-break: clone; + + &.inlineCode { + display: inline-block; + + > .hljs { + padding: 0 .25em!important; + } + + white-space: pre-wrap; + margin: 0 0 -0.1em; + vertical-align: bottom; + } + &.code { + word-wrap: normal; + } + + code { + @include chat-scrollbar-horizontal(); + } +} + +/* fix tailing new line after code blocks */ +.code + br { + display: none; +} \ No newline at end of file diff --git a/shared/js/text/bbcode/highlight.tsx b/shared/js/text/bbcode/highlight.tsx index 96135985..60469127 100644 --- a/shared/js/text/bbcode/highlight.tsx +++ b/shared/js/text/bbcode/highlight.tsx @@ -68,6 +68,8 @@ registerLanguage("x86asm", import("highlight.js/lib/languages/x86asm")); registerLanguage("xml", import("highlight.js/lib/languages/xml")); registerLanguage("yaml", import("highlight.js/lib/languages/yaml")); +const cssStyle = require("./highlight.scss"); + interface HighlightResult { relevance : number value : string @@ -91,7 +93,7 @@ loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, { } render(element: TagElement): React.ReactNode { - const klass = element.tagNormalized != 'code' ? "tag-hljs-inline-code" : "tag-hljs-code"; + const klass = element.tagNormalized != 'code' ? cssStyle.inlineCode : cssStyle.code; const language = (element.options || "").replace("\"", "'").toLowerCase(); let lines = rendererText.renderContent(element).join("").split("\n"); diff --git a/shared/js/text/bbcode/renderer.ts b/shared/js/text/bbcode/renderer.ts index b001d971..8e492a4a 100644 --- a/shared/js/text/bbcode/renderer.ts +++ b/shared/js/text/bbcode/renderer.ts @@ -2,9 +2,9 @@ import TextRenderer from "vendor/xbbcode/renderer/text"; import ReactRenderer from "vendor/xbbcode/renderer/react"; import HTMLRenderer from "vendor/xbbcode/renderer/html"; -import "./emoji"; -import "./highlight"; - export const rendererText = new TextRenderer(); export const rendererReact = new ReactRenderer(); -export const rendererHTML = new HTMLRenderer(rendererReact); \ No newline at end of file +export const rendererHTML = new HTMLRenderer(rendererReact); + +import "./emoji"; +import "./highlight"; \ No newline at end of file diff --git a/shared/js/ui/client.ts b/shared/js/ui/client.ts index f5868b17..7482f1a5 100644 --- a/shared/js/ui/client.ts +++ b/shared/js/ui/client.ts @@ -787,7 +787,7 @@ export class ClientEntry extends ChannelTreeEntry { } if(update_avatar) - this.channelTree.client.fileManager.avatars.update_cache(this.avatarId(), this.properties.client_flag_avatar); + this.channelTree.client.fileManager.avatars.updateCache(this.avatarId(), this.properties.client_flag_avatar); /* devel-block(log-client-property-updates) */ group.end(); diff --git a/shared/js/ui/frames/MenuBar.ts b/shared/js/ui/frames/MenuBar.ts index 2cb2f4a4..6798b4ff 100644 --- a/shared/js/ui/frames/MenuBar.ts +++ b/shared/js/ui/frames/MenuBar.ts @@ -24,6 +24,7 @@ import {formatMessage} from "tc-shared/ui/frames/chat"; import {control_bar_instance} from "tc-shared/ui/frames/control-bar"; import {icon_cache_loader, IconManager, LocalIcon} from "tc-shared/file/Icons"; import {spawnPermissionEditorModal} from "tc-shared/ui/modal/permission/ModalPermissionEditor"; +import {spawnModalCssVariableEditor} from "tc-shared/ui/modal/css-editor/Controller"; export interface HRItem { } @@ -521,6 +522,11 @@ export function initialize() { _state_updater["tools.qc"] = { item: item, conditions: [condition_connected]}; menu.append_hr(); + if(__build.target === "web") { + item = menu.append_item(tr("Modify CSS variables")); + item.click(() => spawnModalCssVariableEditor()); + } + item = menu.append_item(tr("Settings")); item.icon("client-settings"); item.click(() => spawnSettingsModal()); diff --git a/shared/js/ui/frames/connection_handlers.ts b/shared/js/ui/frames/connection_handlers.ts index 9d6525c0..58c944e4 100644 --- a/shared/js/ui/frames/connection_handlers.ts +++ b/shared/js/ui/frames/connection_handlers.ts @@ -26,7 +26,7 @@ export class ConnectionManager { constructor(tag: JQuery) { this.event_registry = new Registry(); - this.event_registry.enable_debug("connection-manager"); + this.event_registry.enableDebug("connection-manager"); this._tag = tag; @@ -129,6 +129,10 @@ export class ConnectionManager { top_menu.update_state(); //FIXME: Top menu should listen to our events! } + findConnection(handlerId: string) : ConnectionHandler | undefined { + return this.connection_handlers.find(e => e.handlerId === handlerId); + } + active_connection() : ConnectionHandler | undefined { return this.active_handler; } diff --git a/shared/js/ui/frames/control-bar/index.tsx b/shared/js/ui/frames/control-bar/index.tsx index 7ed45e19..e8501fc7 100644 --- a/shared/js/ui/frames/control-bar/index.tsx +++ b/shared/js/ui/frames/control-bar/index.tsx @@ -406,7 +406,7 @@ export class ControlBar extends React.Component { super(props); this.event_registry = new Registry(); - this.event_registry.enable_debug("control-bar"); + this.event_registry.enableDebug("control-bar"); initialize(this.event_registry); } diff --git a/shared/js/ui/frames/side/ChatBox.scss b/shared/js/ui/frames/side/ChatBox.scss index acbd8b65..ec2c9f58 100644 --- a/shared/js/ui/frames/side/ChatBox.scss +++ b/shared/js/ui/frames/side/ChatBox.scss @@ -1,6 +1,21 @@ @import "../../../../css/static/mixin"; @import "../../../../css/static/properties"; +html:root { + --chatbox-emoji-hover-background: #454545; + --chatbox-input-background: #464646; + --chatbox-input-background-disabled: #3d3d3d; + + --chatbox-input-border: #353535; + --chatbox-input-border-hover: #474747; + --chatbox-input-border-focus: #585858; + + --chatbox-input: #a9a9a9; + --chatbox-input-planceholder: #363535; + --chatbox-input-disabled: #4d4d4d; + --chatbox-input-help: #555555; +} + .container { @include user-select(none); @@ -47,7 +62,7 @@ border-radius: .25em; &:hover { - background-color: #454545; + background-color: var(--chatbox-emoji-hover-background); } @include transition(background-color $button_hover_animation_time ease-in-out); @@ -118,8 +133,8 @@ box-sizing: content-box; width: 100%; - background-color: #464646; - border: 2px solid #353535; /* background color (like no border) */ + background-color: var(--chatbox-input-background); + border: 2px solid var(--chatbox-input-border); /* background color (like no border) */ overflow: hidden; border-radius: 5px; @@ -147,13 +162,13 @@ border: none; outline: none; - color: #a9a9a9; + color: var(--chatbox-input); @include chat-scrollbar-vertical(); &:empty::before { content: attr(placeholder); - color: #5e5e5e; + color: var(--chatbox-input-planceholder); } &:empty:focus::before { @@ -163,11 +178,11 @@ &.disabled { .textarea { - background-color: #3d3d3d; + background-color: var(--chatbox-input-background-disabled); border-width: 0; &:empty::before { - color: #4d4d4d; + color: var(--chatbox-input-disabled); } } @@ -175,16 +190,16 @@ } @include placeholder(textarea) { - color: #363535; + color: var(--chatbox-input-planceholder); font-style: oblique; } &:hover { - border-color: #474747; + border-color: var(--chatbox-input-border-hover); } &:focus-within { - border-color: #585858; + border-color: var(--chatbox-input-border-focus); } @include transition(border-color $button_hover_animation_time ease-in-out); @@ -197,7 +212,7 @@ min-height: unset; height: initial; - color: #555555; + color: var(--chatbox-input-help); font-size: .8em; text-align: right; margin: -3px 2px 2px 2.5em; diff --git a/shared/js/ui/frames/side/ChatBox.tsx b/shared/js/ui/frames/side/ChatBox.tsx index 36a77157..8790df61 100644 --- a/shared/js/ui/frames/side/ChatBox.tsx +++ b/shared/js/ui/frames/side/ChatBox.tsx @@ -260,6 +260,7 @@ const TextInput = (props: { events: Registry, enabled?: boolean, }; export interface ChatBoxProperties { + className?: string; onSubmit?: (text: string) => void; onType?: () => void; } @@ -298,7 +299,7 @@ export class ChatBox extends React.Component { super(props); this.state = { enabled: false }; - this.events.enable_debug("chat-box"); + this.events.enableDebug("chat-box"); } componentDidMount(): void { @@ -312,7 +313,7 @@ export class ChatBox extends React.Component { } render() { - return
+ return
diff --git a/shared/js/ui/frames/side/ConversationDefinitions.ts b/shared/js/ui/frames/side/ConversationDefinitions.ts index 33c5a6fa..5028098d 100644 --- a/shared/js/ui/frames/side/ConversationDefinitions.ts +++ b/shared/js/ui/frames/side/ConversationDefinitions.ts @@ -109,6 +109,9 @@ export interface ConversationUIEvents { action_jump_to_present: { chatId: string }, action_focus_chat: {}, + query_selected_chat: {}, + /* will cause a notify_selected_chat */ + query_conversation_state: { chatId: string }, /* will cause a notify_conversation_state */ notify_conversation_state: { chatId: string } & ChatStateData, diff --git a/shared/js/ui/frames/side/ConversationManager.ts b/shared/js/ui/frames/side/ConversationManager.ts index 145288b2..e5d52932 100644 --- a/shared/js/ui/frames/side/ConversationManager.ts +++ b/shared/js/ui/frames/side/ConversationManager.ts @@ -19,6 +19,7 @@ import { } from "tc-shared/ui/frames/side/ConversationDefinitions"; import {ConversationPanel} from "tc-shared/ui/frames/side/ConversationUI"; import {preprocessChatMessageForSend} from "tc-shared/text/chat"; +import {spawnExternalModal} from "tc-shared/ui/react-elements/external-modal"; const kMaxChatFrameMessageSize = 50; /* max 100 messages, since the server does not support more than 100 messages queried at once */ @@ -676,10 +677,19 @@ export class ConversationManager extends AbstractChatManager { + console.error("Opened"); + }); + */ this.uiEvents.on("action_select_chat", event => this.selectedConversation_ = parseInt(event.chatId)); this.uiEvents.on("notify_destroy", connection.events().on("notify_connection_state_changed", event => { @@ -827,6 +837,11 @@ export class ConversationManager extends AbstractChatManager("query_selected_chat") + private handleQuerySelectedChat(event: ConversationUIEvents["query_selected_chat"]) { + this.uiEvents.fire_async("notify_selected_chat", { chatId: isNaN(this.selectedConversation_) ? "unselected" : this.selectedConversation_ + ""}) + } + @EventHandler("notify_selected_chat") private handleNotifySelectedChat(event: ConversationUIEvents["notify_selected_chat"]) { this.selectedConversation_ = parseInt(event.chatId); diff --git a/shared/js/ui/frames/side/ConversationUI.scss b/shared/js/ui/frames/side/ConversationUI.scss index 5bda83bf..a81a7d97 100644 --- a/shared/js/ui/frames/side/ConversationUI.scss +++ b/shared/js/ui/frames/side/ConversationUI.scss @@ -6,6 +6,43 @@ $client_info_avatar_size: 10em; $bot_thumbnail_width: 16em; $bot_thumbnail_height: 9em; +html:root { + --chat-background: #353535; + + --chat-event-new-message: #8b8b8b; + + --chat-loader-background: #252525; + --chat-loader: #565353; + + --chat-loader-background-hover: #232326; + --chat-loader-hover: #5b5757; + + --chat-partner-typing: #4d4d4d; + + --chat-message-background: #303030; + --chat-message-timestamp: #5d5b5b; + --chat-message: #b5b5b5; + + --chat-message-table-border: #1e2025; + --chat-message-table-row-background: #303036; + --chat-message-table-row-even-background: #25252a; + + --chat-message-quote-border: #737373; + --chat-message-quote: #737373; + + --chat-timestamp: #565353; + --chat-overlay: #5a5a5a; + --chat-unread: #bc1515; + + --chat-container-switch: #535353; + + --chat-event-general: #524e4e; + --chat-event-message-send-failed: #ac5353; + --chat-event-close: #adad1f; + --chat-event-disconnect: #a82424; + --chat-event-reconnect: #1B7E1B; +} + .panel { display: flex; flex-direction: column; @@ -15,6 +52,7 @@ $bot_thumbnail_height: 9em; width: 100%; min-width: 250px; + background: var(--chat-background); position: relative; @@ -44,10 +82,10 @@ $bot_thumbnail_height: 9em; text-align: center; width: 100%; - color: #8b8b8b; + color: var(--chat-event-new-message); - background: #353535; /* if we dont support gradients */ - background: linear-gradient(rgba(53, 53, 53, 0) 10%, #353535 70%); + background: var(--chat-background); /* if we dont support gradients */ + background: linear-gradient(rgba(53, 53, 53, 0) 10%, var(--chat-background) 70%); pointer-events: none; opacity: 0; @@ -67,7 +105,8 @@ $bot_thumbnail_height: 9em; display: flex; flex-direction: row; - background: linear-gradient(rgba(53, 53, 53, 0) 10%, #353535 70%); + background: var(--chat-background); /* if we dont support gradients */ + background: linear-gradient(rgba(53, 53, 53, 0) 10%, var(--chat-background) 70%); .inner { flex-grow: 1; @@ -78,8 +117,8 @@ $bot_thumbnail_height: 9em; text-align: center; - background: #252525; - color: #565353; + background: var(--chat-loader-background); + color: var(--chat-loader); margin-left: 4.5em; margin-right: 2em; @@ -92,8 +131,8 @@ $bot_thumbnail_height: 9em; @include transition(background-color ease-in-out $button_hover_animation_time); &:hover { - background-color: #232326; - color: #5b5757; + background-color: var(--chat-loader-background-hover); + color: var(--chat-loader-hover); } } @@ -117,7 +156,7 @@ $bot_thumbnail_height: 9em; padding-left: .6em; line-height: 1; - color: #4d4d4d; + color: var(--chat-partner-typing); opacity: 1; @include transition(.25s ease-in-out); @@ -189,7 +228,7 @@ $bot_thumbnail_height: 9em; @include user-select(text); - background: #303030; + background: var(--chat-message-background); border-radius: 6px 6px 6px 6px; margin-top: .5em; @@ -213,7 +252,7 @@ $bot_thumbnail_height: 9em; display: inline; font-size: 0.66em; - color: #5d5b5b; + color: var(--chat-message-timestamp); } .delete { @@ -238,7 +277,7 @@ $bot_thumbnail_height: 9em; } .text { - color: #b5b5b5; + color: var(--chat-message); line-height: 1.1em; word-wrap: break-word; @@ -261,15 +300,15 @@ $bot_thumbnail_height: 9em; table { th, td { - border-color: #1e2025; + border-color: var(--chat-message-table-border); } tr { - background-color: #303036; + background-color: var(--chat-message-table-row-background); } tr:nth-child(2n) { - background-color: #25252a; + background-color: var(--chat-message-table-row-even-background); } } @@ -304,9 +343,9 @@ $bot_thumbnail_height: 9em; } :global(.xbbcode-tag-quote) { - border-color: #737373; + border-color: var(--chat-message-quote-border); padding-left: .5em; - color: #737373; + color: var(--chat-message-quote); } } @@ -320,7 +359,7 @@ $bot_thumbnail_height: 9em; margin-left: calc(-.5em - 1em); border-top: .5em solid transparent; - border-right: .75em solid #303030; + border-right: .75em solid var(--chat-message-background); border-bottom: .5em solid transparent; top: 1.25em; @@ -331,7 +370,7 @@ $bot_thumbnail_height: 9em; .containerTimestamp { margin-left: 2.5em; - color: #565353; + color: var(--chat-timestamp); text-align: center; } @@ -349,8 +388,8 @@ $bot_thumbnail_height: 9em; text-align: center; width: 100%; - color: #5a5a5a; - background: #353535; + color: var(--chat-overlay); + background: var(--chat-background); display: flex; flex-direction: column; @@ -362,7 +401,7 @@ $bot_thumbnail_height: 9em; margin-right: .5em; text-align: center; - color: #bc1515; + color: var(--chat-unread); } .jumpToPresentPlaceholder { @@ -379,10 +418,10 @@ $bot_thumbnail_height: 9em; flex-direction: row; justify-content: center; - color: #535353; + color: var(--chat-container-switch); a { - background: #353535; + background: var(--chat-background); z-index: 1; padding-left: 1em; @@ -398,7 +437,7 @@ $bot_thumbnail_height: 9em; right: 0; height: .1em; - background-color: #535353; + background-color: var(--chat-container-switch); } } @@ -408,27 +447,27 @@ $bot_thumbnail_height: 9em; justify-content: center; text-align: center; - color: #524e4e; + color: var(--chat-event-general); a { display: inline-block; } &.containerMessageSendFailed { - color: #ac5353; + color: var(--chat-event-message-send-failed); margin-bottom: .5em; } &.actionClose { - color: #adad1f; + color: var(--chat-event-close); } &.actionDisconnect { - color: #a82424; + color: var(--chat-event-disconnect); } &.actionReconnect { - color: hsl(120, 65%, 30%); + color: var(--chat-event-reconnect); } } } \ No newline at end of file diff --git a/shared/js/ui/frames/side/ConversationUI.tsx b/shared/js/ui/frames/side/ConversationUI.tsx index 0d7f2a2e..c82df245 100644 --- a/shared/js/ui/frames/side/ConversationUI.tsx +++ b/shared/js/ui/frames/side/ConversationUI.tsx @@ -1,9 +1,7 @@ import * as React from "react"; import {EventHandler, ReactEventHandler, Registry} from "tc-shared/events"; import {ChatBox} from "tc-shared/ui/frames/side/ChatBox"; -import {generate_client} from "tc-shared/ui/htmltags"; import {Ref, useEffect, useRef, useState} from "react"; -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"; @@ -23,6 +21,7 @@ import { } from "tc-shared/ui/frames/side/ConversationDefinitions"; import {TimestampRenderer} from "tc-shared/ui/react-elements/TimestampRenderer"; import {BBCodeRenderer} from "tc-shared/text/bbcode"; +import {getGlobalAvatarManagerFactory} from "tc-shared/file/Avatars"; const cssStyle = require("./ConversationUI.scss"); @@ -32,7 +31,7 @@ const ChatEventMessageRenderer = React.memo((props: { message: ChatMessage, callbackDelete?: () => void, events: Registry, - handler: ConnectionHandler, + handlerId: string, refHTMLElement?: Ref }) => { @@ -46,24 +45,30 @@ const ChatEventMessageRenderer = React.memo((props: { ); } + const avatar = getGlobalAvatarManagerFactory().getManager(props.handlerId)?.resolveClientAvatar({ clientUniqueId: props.message.sender_unique_id, database_id: props.message.sender_database_id }); return (
+ avatar={avatar} />
{deleteButton} - + {/* + + */} + +
{props.message.sender_name}
+
{ /* Only for copy purposes */} @@ -322,7 +327,7 @@ const PartnerTypingIndicator = (props: { events: Registry, interface ConversationMessagesProperties { events: Registry; - handler: ConnectionHandler; + handlerId: string; noFirstMessageOverlay?: boolean messagesDeletable?: boolean; @@ -555,6 +560,7 @@ class ConversationMessages extends React.PureComponent this.props.events.fire("action_delete_message", { chatId: this.currentChatId, uniqueId: event.uniqueId }) : undefined} - handler={this.props.handler} + handlerId={this.props.handlerId} refHTMLElement={reference} />); break; @@ -850,7 +856,7 @@ class ConversationMessages extends React.PureComponent, handler: ConnectionHandler, messagesDeletable: boolean, noFirstMessageOverlay: boolean }) => { +export const ConversationPanel = React.memo((props: { events: Registry, handlerId: string, messagesDeletable: boolean, noFirstMessageOverlay: boolean }) => { const currentChat = useRef({ id: "unselected" }); const chatEnabled = useRef(false); @@ -882,7 +888,7 @@ export const ConversationPanel = React.memo((props: { events: Registry - + props.events.fire("action_send_message", { chatId: currentChat.current.id, text: text }) } diff --git a/shared/js/ui/frames/side/PopoutConversationUI.tsx b/shared/js/ui/frames/side/PopoutConversationUI.tsx new file mode 100644 index 00000000..3c242167 --- /dev/null +++ b/shared/js/ui/frames/side/PopoutConversationUI.tsx @@ -0,0 +1,31 @@ +import {AbstractModal} from "tc-shared/ui/react-elements/Modal"; +import {Registry} from "tc-shared/events"; +import {ConversationUIEvents} from "tc-shared/ui/frames/side/ConversationDefinitions"; +import {ConversationPanel} from "tc-shared/ui/frames/side/ConversationUI"; +import * as React from "react"; + +class PopoutConversationUI extends AbstractModal { + private readonly events: Registry; + private readonly userData: any; + + constructor(events: Registry, userData: any) { + super(); + + this.userData = userData; + this.events = events; + } + + renderBody() { + return ; + } + + title() { + return "Conversations"; + } +} + +export = PopoutConversationUI; \ No newline at end of file diff --git a/shared/js/ui/frames/side/PrivateConversationManager.ts b/shared/js/ui/frames/side/PrivateConversationManager.ts index e082c7f3..fc7cb2ac 100644 --- a/shared/js/ui/frames/side/PrivateConversationManager.ts +++ b/shared/js/ui/frames/side/PrivateConversationManager.ts @@ -330,7 +330,7 @@ export class PrivateConversationManager extends AbstractChatManager div { display: inline-block; - color: #5a5a5a; + color: var(--chat-private-no-chats); } } } @@ -52,7 +68,7 @@ justify-content: stretch; cursor: pointer; - border-bottom: 1px solid #313132; + border-bottom: 1px solid var(--chat-private-border); .containerAvatar { flex-grow: 0; @@ -78,15 +94,15 @@ position: absolute; top: 0; right: 0; - background-color: #a81414; + background-color: var(--chat-private-unread-background); width: 7px; height: 7px; border-radius: 50%; - -webkit-box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.20); - -moz-box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.20); - box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.20); + -webkit-box-shadow: 0 0 1px 1px var(--chat-private-unread-shadow); + -moz-box-shadow: 0 0 1px 1px var(--chat-private-unread-shadow); + box-shadow: 0 0 1px 1px var(--chat-private-unread-shadow); } } @@ -112,14 +128,14 @@ } .name { - color: #ccc; + color: var(--chat-private-name); font-weight: bold; margin-bottom: -.4em; } .timestamp { - color: #555353; + color: var(--chat-private-timestamp); display: inline-block; font-size: .66em; @@ -150,7 +166,7 @@ content: ' '; height: .5em; width: .05em; - background-color: #5a5a5a; + background-color: var(--chat-private-close-background); } &:before { @@ -163,11 +179,11 @@ } &:hover { - background-color: #393939; + background-color: var(--chat-private-hovered-background); } &.selected { - background-color: #2c2c2c; + background-color: var(--chat-private-selected-background); } @include transition(background-color $button_hover_animation_time ease-in-out); } \ No newline at end of file diff --git a/shared/js/ui/frames/side/PrivateConversationUI.tsx b/shared/js/ui/frames/side/PrivateConversationUI.tsx index 6abdba70..8518583d 100644 --- a/shared/js/ui/frames/side/PrivateConversationUI.tsx +++ b/shared/js/ui/frames/side/PrivateConversationUI.tsx @@ -201,6 +201,6 @@ const OpenConversationsPanel = React.memo((props: { events: Registry, handler: ConnectionHandler }) => ( - + ); \ No newline at end of file diff --git a/shared/js/ui/frames/side/music_info.ts b/shared/js/ui/frames/side/music_info.ts index 25aa09ed..5a793f59 100644 --- a/shared/js/ui/frames/side/music_info.ts +++ b/shared/js/ui/frames/side/music_info.ts @@ -1,5 +1,4 @@ import {Frame, FrameContent} from "tc-shared/ui/frames/chat_frame"; -import * as events from "tc-shared/events"; import {ClientEvents, MusicClientEntry, SongInfo} from "tc-shared/ui/client"; import {voice} from "tc-shared/connection/ConnectionBase"; import PlayerState = voice.PlayerState; @@ -8,6 +7,52 @@ import {CommandResult, ErrorID, PlaylistSong} from "tc-shared/connection/ServerC import {createErrorModal, createInputModal} from "tc-shared/ui/elements/Modal"; import * as log from "tc-shared/log"; import * as image_preview from "../image_preview"; +import {Registry} from "tc-shared/events"; + +export interface MusicSidebarEvents { + "open": {}, /* triggers when frame should be shown */ + "close": {}, /* triggers when frame will be closed */ + + "bot_change": { + old: MusicClientEntry | undefined, + new: MusicClientEntry | undefined + }, + "bot_property_update": { + properties: string[] + }, + + "action_play": {}, + "action_pause": {}, + "action_song_set": { song_id: number }, + "action_forward": {}, + "action_rewind": {}, + "action_forward_ms": { + units: number; + }, + "action_rewind_ms": { + units: number; + }, + "action_song_add": {}, + "action_song_delete": { song_id: number }, + "action_playlist_reload": {}, + + "playtime_move_begin": {}, + "playtime_move_end": { + canceled: boolean, + target_time?: number + }, + + "reorder_begin": { song_id: number; entry: JQuery }, + "reorder_end": { song_id: number; canceled: boolean; entry: JQuery; previous_entry?: number }, + + "player_time_update": ClientEvents["music_status_update"], + "player_song_change": ClientEvents["music_song_change"], + + "playlist_song_add": ClientEvents["playlist_song_add"] & { insert_effect?: boolean }, + "playlist_song_remove": ClientEvents["playlist_song_remove"], + "playlist_song_reorder": ClientEvents["playlist_song_reorder"], + "playlist_song_loaded": ClientEvents["playlist_song_loaded"] & { html_entry?: JQuery }, +} interface LoadedSongData { description: string; @@ -21,7 +66,7 @@ interface LoadedSongData { } export class MusicInfo { - readonly events: events.Registry; + readonly events: Registry; readonly handle: Frame; private _html_tag: JQuery; @@ -47,10 +92,10 @@ export class MusicInfo { previous_frame_content: FrameContent; constructor(handle: Frame) { - this.events = new events.Registry(); + this.events = new Registry(); this.handle = handle; - this.events.enable_debug("music-info"); + this.events.enableDebug("music-info"); this.initialize_listener(); this._build_html_tag(); @@ -333,7 +378,7 @@ export class MusicInfo { event.old.events.off(callback_property); event.old.events.off(callback_time_update); event.old.events.off(callback_song_change); - event.old.events.disconnect_all(this.events); + event.old.events.disconnectAll(this.events); } if(event.new) { event.new.events.on("notify_properties_updated", callback_property); diff --git a/shared/js/ui/modal/ModalGroupCreate.tsx b/shared/js/ui/modal/ModalGroupCreate.tsx index 18204abd..d4f1c6a7 100644 --- a/shared/js/ui/modal/ModalGroupCreate.tsx +++ b/shared/js/ui/modal/ModalGroupCreate.tsx @@ -240,7 +240,7 @@ class ModalGroupCreate extends Modal { constructor(connection: ConnectionHandler, target: "server" | "channel", defaultSourceGroup: number) { super(); - this.events.enable_debug("group-create"); + this.events.enableDebug("group-create"); this.defaultSourceGroup = defaultSourceGroup; this.target = target; initializeGroupCreateController(connection, this.events, this.target); diff --git a/shared/js/ui/modal/ModalMusicManage.ts b/shared/js/ui/modal/ModalMusicManage.ts index 3cf4ed23..54b2c992 100644 --- a/shared/js/ui/modal/ModalMusicManage.ts +++ b/shared/js/ui/modal/ModalMusicManage.ts @@ -15,7 +15,7 @@ import * as htmltags from "tc-shared/ui/htmltags"; export function openMusicManage(client: ConnectionHandler, bot: MusicClientEntry) { const ev_registry = new Registry(); - ev_registry.enable_debug("music-manage"); + ev_registry.enableDebug("music-manage"); //dummy_controller(ev_registry); permission_controller(ev_registry, bot, client); diff --git a/shared/js/ui/modal/ModalNewcomer.ts b/shared/js/ui/modal/ModalNewcomer.ts index 0c21875e..4ab4eb06 100644 --- a/shared/js/ui/modal/ModalNewcomer.ts +++ b/shared/js/ui/modal/ModalNewcomer.ts @@ -34,7 +34,7 @@ export function openModalNewcomer() : Modal { }); const event_registry = new Registry(); - event_registry.enable_debug("newcomer"); + event_registry.enableDebug("newcomer"); modal.htmlTag.find(".modal-body").addClass("modal-newcomer"); @@ -126,7 +126,7 @@ function initializeStepFinish(tag: JQuery, event_registry: Registry) { const profile_events = new Registry(); - profile_events.enable_debug("settings-identity"); + profile_events.enableDebug("settings-identity"); modal_settings.initialize_identity_profiles_controller(profile_events); modal_settings.initialize_identity_profiles_view(tag, profile_events, { forum_setuppable: false }); diff --git a/shared/js/ui/modal/ModalSettings.tsx b/shared/js/ui/modal/ModalSettings.tsx index 233158bd..220c4326 100644 --- a/shared/js/ui/modal/ModalSettings.tsx +++ b/shared/js/ui/modal/ModalSettings.tsx @@ -436,7 +436,7 @@ function settings_general_chat(container: JQuery, modal: Modal) { function settings_audio_microphone(container: JQuery, modal: Modal) { const registry = new Registry(); - registry.enable_debug("settings-microphone"); + registry.enableDebug("settings-microphone"); modal_settings.initialize_audio_microphone_controller(registry); modal_settings.initialize_audio_microphone_view(container, registry); diff --git a/shared/js/ui/modal/css-editor/Controller.ts b/shared/js/ui/modal/css-editor/Controller.ts new file mode 100644 index 00000000..5a869d1e --- /dev/null +++ b/shared/js/ui/modal/css-editor/Controller.ts @@ -0,0 +1,187 @@ +import * as loader from "tc-loader"; +import {Stage} from "tc-loader"; +import {CssEditorEvents, CssVariable} from "tc-shared/ui/modal/css-editor/Definitions"; +import {spawnExternalModal} from "tc-shared/ui/react-elements/external-modal"; +import {Registry} from "tc-shared/events"; + +interface CustomVariable { + name: string; + value: string; + enabled: boolean; +} + +class CssVariableManager { + private customVariables: {[key: string]: CustomVariable} = {}; + private htmlTag: HTMLStyleElement; + + constructor() { + + } + + initialize() { + this.htmlTag = document.createElement("style"); + document.body.appendChild(this.htmlTag); + } + + getAllCssVariables() : CssVariable[] { + let variables: {[key: string]: CssVariable} = {}; + + const ownStyleSheets = Array.from(document.styleSheets) + .filter(sheet => sheet.href === null || sheet.href.startsWith(window.location.origin)) as CSSStyleSheet[]; + for(const sheet of ownStyleSheets) { + for(const rule of sheet.cssRules) { + if(!(rule instanceof CSSStyleRule)) + continue; + + if(rule.selectorText !== "html:root" && rule.selectorText !== ":root") + continue; + + for(const entry of rule.style) { + if(!entry.startsWith("--")) + continue; + + if(variables[entry]) + continue; + + const customVariable = this.customVariables[entry]; + variables[entry] = { + name: entry, + defaultValue: rule.style.getPropertyValue(entry).trim(), + customValue: customVariable?.value, + overwriteValue: !!customVariable?.enabled + }; + } + } + } + + return Object.values(variables); + } + + setVariable(name: string, value: string) { + const customVariable = this.customVariables[name] || (this.customVariables[name] = { name: name, value: undefined, enabled: false }); + customVariable.enabled = true; + customVariable.value = value; + this.updateCustomVariables(); + } + + toggleCustomVariable(name: string, flag: boolean, value?: string) { + let customVariable = this.customVariables[name]; + if(!customVariable) { + if(!flag) + return; + + customVariable = this.customVariables[name] = { name: name, value: value, enabled: true }; + } + + customVariable.enabled = flag; + if(flag && typeof value === "string") + customVariable.value = value; + this.updateCustomVariables(); + } + + exportConfig() { + return JSON.stringify({ + version: 1, + variables: this.customVariables + }); + } + + importConfig(config: string) { + const data = JSON.parse(config); + if(data.version !== 1) + throw "unsupported config version"; + + this.customVariables = data.variables; + this.updateCustomVariables(); + } + + reset() { + this.customVariables = {}; + this.updateCustomVariables(); + } + + randomize() { + this.customVariables = {}; + this.getAllCssVariables().forEach(e => { + this.customVariables[e.name] = { + enabled: true, + value: "#" + Math.floor(Math.random() * 0xFFFFFF).toString(16), + name: e.name + } + }); + this.updateCustomVariables(); + } + + private updateCustomVariables() { + let text = "html:root {\n"; + for(const variable of Object.values(this.customVariables)) + text += " " + variable.name + ": " + variable.value + ";\n"; + text += "}"; + this.htmlTag.textContent = text; + } +} +let cssVariableManager: CssVariableManager; + +export function spawnModalCssVariableEditor() { + /* FIXME: Disable for the native client! */ + const events = new Registry(); + cssVariableEditorController(events); + + const modal = spawnExternalModal("css-editor", events, {}); + modal.open(); +} + +function cssVariableEditorController(events: Registry) { + events.on("query_css_variables", () => { + events.fire_async("notify_css_variables", { + variables: cssVariableManager.getAllCssVariables() + }) + }); + + events.on("action_override_toggle", event => { + cssVariableManager.toggleCustomVariable(event.variableName, event.enabled, event.value); + }); + + events.on("action_change_override_value", event => { + cssVariableManager.setVariable(event.variableName, event.value); + }); + + events.on("action_export", () => { + events.fire_async("notify_export_result", { + config: cssVariableManager.exportConfig() + }); + }); + + events.on("action_import", event => { + try { + cssVariableManager.importConfig(event.config); + events.fire_async("notify_import_result", { success: true }); + events.fire_async("action_select_entry", { variable: undefined }); + events.fire_async("query_css_variables"); + } catch (error) { + console.warn("Failed to import CSS variable values: %o", error); + events.fire_async("notify_import_result", { success: false }); + } + }); + + events.on("action_reset", () => { + cssVariableManager.reset(); + events.fire_async("action_select_entry", { variable: undefined }); + events.fire_async("query_css_variables"); + }); + + events.on("action_randomize", () => { + cssVariableManager.randomize(); + events.fire_async("action_select_entry", { variable: undefined }); + events.fire_async("query_css_variables"); + }); +} + +loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { + priority: 10, + name: "CSS Variable setup", + function: async () => { + cssVariableManager = new CssVariableManager(); + cssVariableManager.initialize(); + } +}); \ No newline at end of file diff --git a/shared/js/ui/modal/css-editor/Definitions.ts b/shared/js/ui/modal/css-editor/Definitions.ts new file mode 100644 index 00000000..5fff4ac4 --- /dev/null +++ b/shared/js/ui/modal/css-editor/Definitions.ts @@ -0,0 +1,29 @@ +export interface CssVariable { + name: string; + defaultValue: string; + overwriteValue: boolean; + + customValue?: string; +} + +export interface CssEditorUserData { + +} + +export interface CssEditorEvents { + action_set_filter: { filter: string | undefined }, + action_select_entry: { variable: CssVariable }, + action_override_toggle: { variableName: string, enabled: boolean, value?: string } + action_change_override_value: { variableName: string, value: string }, + action_reset: { }, + action_randomize: {}, + + action_export: {}, + action_import: { config: string } + + query_css_variables: {}, + notify_css_variables: { variables: CssVariable[] } + + notify_export_result: { config: string }, + notify_import_result: { success: boolean } +} \ No newline at end of file diff --git a/shared/js/ui/modal/css-editor/Renderer.scss b/shared/js/ui/modal/css-editor/Renderer.scss new file mode 100644 index 00000000..add224c3 --- /dev/null +++ b/shared/js/ui/modal/css-editor/Renderer.scss @@ -0,0 +1,259 @@ +@import "../../../../css/static/mixin"; +@import "../../../../css/static/properties"; + +.container { + padding: 1em; + + background: #19191b; + + display: flex; + flex-direction: row; + justify-content: stretch; + + flex-grow: 1; + flex-shrink: 1; + + min-height: 10em; + min-width: 20em; + + .containerList, .containerEdit { + width: 50%; + + display: flex; + flex-direction: column; + justify-content: stretch; + + .header { + a { + font-weight: 700; + color: #e0e0e0; + + flex-grow: 1; + flex-shrink: 1; + + font-size: 1.05em; + min-width: 5em; + + line-height: normal; + + @include text-dotdotdot(); + } + } + } +} + +.containerList { + .list { + flex-grow: 1; + flex-shrink: 1; + + min-height: 8em; + + display: flex; + flex-direction: column; + justify-content: stretch; + + border-radius: .2em; + border: 1px solid #1f2122; + background-color: #28292b; + + .search { + flex-shrink: 0; + flex-grow: 0; + + padding: 0 .5em; + border-top: 1px solid #1f2122; + + display: flex; + flex-direction: row; + justify-content: stretch; + + .input { + flex-grow: 1; + flex-shrink: 1; + + min-width: 5em; + + margin-left: 1em; + margin-right: 1em; + } + } + + .body { + flex-shrink: 1; + flex-grow: 1; + + min-height: 5em; + position: relative; + + overflow-x: hidden; + overflow-y: auto; + + @include chat-scrollbar-vertical(); + + .overlay { + position: absolute; + + top: 0; + bottom: 0; + left: 0; + right: 0; + + display: flex; + flex-direction: column; + justify-content: center; + + text-align: center; + font-size: 2em; + + color: #4d4d4d; + background-color: #28292b; + } + + .variable { + display: flex; + flex-direction: row; + justify-content: flex-start; + + position: relative; + flex-shrink: 1; + + min-width: 4em; + padding-left: .5em; + padding-right: .5em; + + cursor: pointer; + + @include text-dotdotdot(); + color: #999; + + .preview { + align-self: center; + margin-right: .5em; + + height: 1em; + width: 1em; + + border-right: .1em; + background-color: white; + + .color { + height: 100%; + width: 100%; + + background-color: #28292b; /* default value if the value is not a color or broken */ + } + } + + &:hover { + background-color: #2c2d2f; + + .preview .color { + background-color: #2c2d2f; + } + } + + &.selected { + background-color: #1a1a1b; + + .preview .color { + background-color: #1a1a1b; + } + } + } + } + } +} + +.containerEdit { + margin-left: 2em; + + .detail { + flex-shrink: 0; + display: flex; + flex-direction: column; + justify-content: flex-start; + margin-bottom: 1em; + + .title, .value { + @include text-dotdotdot(); + } + + .title { + display: flex; + flex-direction: row; + justify-content: space-between; + + text-transform: uppercase; + color: #557edc; + } + + .value { + color: #999; + + &.color { + display: flex; + flex-direction: row; + justify-content: stretch; + + .colorButton { + flex-grow: 0; + flex-shrink: 0; + + margin-left: .5em; + } + + .input { + flex-grow: 1; + flex-shrink: 1; + + min-width: 5em; + } + } + } + } + + .colorButton { + cursor: pointer; + + padding: .5em; + border-radius: .2em; + border: 1px solid #111112; + background-color: #121213; + + height: 2em; + width: 2em; + + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; + align-self: center; + + input { + position: absolute; + + opacity: 0; + pointer-events: none; + } + } + + .buttons { + display: flex; + flex-direction: row; + + margin-top: auto; + + .button { + margin-left: 1em; + + &:first-of-type { + margin-left: 0; + } + } + + .buttonReset { + margin-right: auto; + } + } +} \ No newline at end of file diff --git a/shared/js/ui/modal/css-editor/Renderer.tsx b/shared/js/ui/modal/css-editor/Renderer.tsx new file mode 100644 index 00000000..cd84b3c3 --- /dev/null +++ b/shared/js/ui/modal/css-editor/Renderer.tsx @@ -0,0 +1,421 @@ +import * as React from "react"; +import {CssEditorEvents, CssEditorUserData, CssVariable} from "tc-shared/ui/modal/css-editor/Definitions"; +import {Registry} from "tc-shared/events"; +import {AbstractModal} from "tc-shared/ui/react-elements/Modal"; +import {Translatable} from "tc-shared/ui/react-elements/i18n"; +import {BoxedInputField, FlatInputField} from "tc-shared/ui/react-elements/InputField"; +import {useState} from "react"; +import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; +import {Checkbox} from "tc-shared/ui/react-elements/Checkbox"; +import 'rc-color-picker/assets/index.css'; +import {Button} from "tc-shared/ui/react-elements/Button"; +import {createErrorModal, createInfoModal} from "tc-shared/ui/elements/Modal"; + +const cssStyle = require("./Renderer.scss"); + +const CssVariableRenderer = React.memo((props: { events: Registry, variable: CssVariable, selected: boolean }) => { + const [ selected, setSelected ] = useState(props.selected); + const [ override, setOverride ] = useState(props.variable.overwriteValue); + const [ overrideColor, setOverrideColor ] = useState(props.variable.customValue); + + props.events.reactUse("action_select_entry", event => setSelected(event.variable === props.variable)); + props.events.reactUse("action_override_toggle", event => { + if(event.variableName !== props.variable.name) + return; + + setOverride(event.enabled); + if(event.enabled) + setOverrideColor(event.value); + }); + + props.events.reactUse("action_change_override_value", event => { + if(event.variableName !== props.variable.name) + return; + + setOverrideColor(event.value); + }); + + return ( +
{ + if(selected) + return; + + props.events.fire("action_select_entry", { variable: props.variable }) + }} + > +
+
+
+
+ ); +}); + +const CssVariableListBodyRenderer = (props: { events: Registry }) => { + const [ variables, setVariables ] = useState<"loading" | CssVariable[]>(() => { + props.events.fire_async("query_css_variables"); + return "loading"; + }); + + const [ filter, setFilter ] = useState(undefined); + const [ selectedVariable, setSelectedVariable ] = useState(undefined); + + props.events.reactUse("action_select_entry", event => setSelectedVariable(event.variable)); + props.events.reactUse("query_css_variables", () => setVariables("loading")); + + let content; + if(variables === "loading") { + content = ( + + ); + } else { + content = []; + for(const variable of variables) { + if(filter && variable.name.toLowerCase().indexOf(filter) === -1) + continue; + + content.push(); + } + + if(content.length === 0) { + content.push( + + ); + } + } + + props.events.reactUse("action_set_filter", event => setFilter(event.filter?.toLowerCase())); + props.events.reactUse("notify_css_variables", event => setVariables(event.variables)); + + return ( +
{ + if(variables === "loading") + return; + + /* TODO: This isn't working since the div isn't focused properly yet */ + let offset = 0; + if(event.key === "ArrowDown") { + offset = 1; + } else if(event.key === "ArrowUp") { + offset = -1; + } + + if(offset !== 0) { + const selectIndex = variables.findIndex(e => e === selectedVariable); + if(selectIndex === -1) + return; + + const variable = variables[selectIndex + offset]; + if(!variable) + return; + + props.events.fire("action_select_entry", { variable: variable }); + } + }} tabIndex={0}> + {content} +
+ ); +}; + +const CssVariableListSearchRenderer = (props: { events: Registry }) => { + const [ isLoading, setLoading ] = useState(true); + + props.events.reactUse("notify_css_variables", () => setLoading(false)); + props.events.reactUse("query_css_variables", () => setLoading(true)); + + return ( +
+ Filter variables} + labelType={"floating"} + className={cssStyle.input} + onInput={text => props.events.fire("action_set_filter", { filter: text })} + disabled={isLoading} + /> +
+ ); +}; + +const CssVariableListRenderer = (props: { events: Registry }) => ( +
+ +
console.error(event.key)}> + + +
+
+); + +const SelectedVariableInfo = (props: { events: Registry }) => { + const [ selectedVariable, setSelectedVariable ] = useState(undefined); + props.events.reactUse("action_select_entry", event => setSelectedVariable(event.variable)); + + return (<> +
+
+ Name +
+
+ +
+
+
+
+ Default Value +
+
+ +
+
+ ); +}; + +const OverrideVariableInfo = (props: { events: Registry }) => { + const [ selectedVariable, setSelectedVariable ] = useState(undefined); + const [ overwriteValue, setOverwriteValue ] = useState(undefined); + const [ overwriteEnabled, setOverwriteEnabled ] = useState(false); + + props.events.reactUse("action_select_entry", event => { + setSelectedVariable(event.variable); + setOverwriteEnabled(event.variable?.overwriteValue); + setOverwriteValue(event.variable?.customValue); + }); + + props.events.reactUse("action_override_toggle", event => { + if(event.variableName !== selectedVariable?.name) + return; + + selectedVariable.overwriteValue = event.enabled; + setOverwriteEnabled(event.enabled); + if(event.enabled) + setOverwriteValue(event.value); + }); + + props.events.reactUse("action_change_override_value", event => { + if(event.variableName !== selectedVariable?.name) + return; + + setOverwriteValue(event.value); + }); + + return (<> +
+
+ Override Value + { + props.events.fire("action_override_toggle", { + variableName: selectedVariable.name, + value: typeof selectedVariable.customValue === "string" ? selectedVariable.customValue : selectedVariable.defaultValue, + enabled: value + }); + }} + /> +
+
+ { + selectedVariable.customValue = text; + props.events.fire("action_change_override_value", { value: text, variableName: selectedVariable.name }); + }} + /> + +
+
+ ); +}; + +const CssVariableColorPicker = (props: { events: Registry, selectedVariable: CssVariable }) => { + const [ overwriteValue, setOverwriteValue ] = useState(undefined); + const [ overwriteEnabled, setOverwriteEnabled ] = useState(false); + + props.events.reactUse("action_override_toggle", event => { + if(event.variableName !== props.selectedVariable?.name) + return; + + props.selectedVariable.overwriteValue = event.enabled; + setOverwriteEnabled(event.enabled); + if(event.enabled) + setOverwriteValue(event.value); + }); + + props.events.reactUse("action_change_override_value", event => { + if(event.variableName !== props.selectedVariable?.name || 'cpInvoker' in event) + return; + + setOverwriteValue(event.value); + }); + + let currentInput: string; + let inputTimeout: number; + return ( + + ) +}; + +const ControlButtons = (props: { events: Registry }) => { + return ( +
+ + + + +
+ ) +}; + +const CssVariableEditor = (props: { events: Registry }) => { + return ( +
+ + + + +
+ ) +}; +const downloadTextAsFile = (text, name) => { + const element = document.createElement("a"); + element.text = "download"; + element.href = "data:test/plain;charset=utf-8," + encodeURIComponent(text); + element.download = name; + element.style.display = "none"; + + document.body.appendChild(element); + element.click(); + element.remove(); +}; + +const requestFileAsText = async (): Promise => { + const element = document.createElement("input"); + element.style.display = "none"; + element.type = "file"; + + document.body.appendChild(element); + element.click(); + await new Promise(resolve => { + element.onchange = resolve; + }); + + if(element.files.length !== 1) + return undefined; + const file = element.files[0]; + element.remove(); + + return await file.text(); +}; + +class PopoutConversationUI extends AbstractModal { + private readonly events: Registry; + private readonly userData: CssEditorUserData; + + constructor(events: Registry, userData: CssEditorUserData) { + super(); + + this.userData = userData; + this.events = events; + + this.events.on("notify_export_result", event => { + createInfoModal(tr("Config exported successfully"), tr("The config has been exported successfully.")).open(); + downloadTextAsFile(event.config, "teaweb-style.json"); + }); + this.events.on("notify_import_result", event => { + if(event.success) + createInfoModal(tr("Config imported successfully"), tr("The config has been imported successfully.")).open(); + else + createErrorModal(tr("Config imported failed"), tr("The config import has been failed.")).open(); + }) + } + + renderBody() { + return ( +
+ + +
+ ); + } + + title() { + return "CSS Variable editor"; + } +} + +export = PopoutConversationUI; \ No newline at end of file diff --git a/shared/js/ui/modal/permission/ModalPermissionEditor.tsx b/shared/js/ui/modal/permission/ModalPermissionEditor.tsx index d8a817cf..1d214ba1 100644 --- a/shared/js/ui/modal/permission/ModalPermissionEditor.tsx +++ b/shared/js/ui/modal/permission/ModalPermissionEditor.tsx @@ -281,8 +281,8 @@ class PermissionEditorModal extends Modal { this.defaultTab = defaultTab; this.defaultTabValues = defaultTabValues || {}; - this.modalEvents.enable_debug("modal-permissions"); - this.editorEvents.enable_debug("permissions-editor"); + this.modalEvents.enableDebug("modal-permissions"); + this.editorEvents.enableDebug("permissions-editor"); this.connection = connection; initializePermissionModalResultHandlers(this.modalEvents); diff --git a/shared/js/ui/modal/transfer/ModalFileTransfer.tsx b/shared/js/ui/modal/transfer/ModalFileTransfer.tsx index 5c3d4b68..75bd33c1 100644 --- a/shared/js/ui/modal/transfer/ModalFileTransfer.tsx +++ b/shared/js/ui/modal/transfer/ModalFileTransfer.tsx @@ -190,8 +190,8 @@ class FileTransferModal extends Modal { this.defaultChannelId = defaultChannelId; - this.remoteBrowseEvents.enable_debug("remote-file-browser"); - this.transferInfoEvents.enable_debug("transfer-info"); + this.remoteBrowseEvents.enableDebug("remote-file-browser"); + this.transferInfoEvents.enableDebug("transfer-info"); initializeRemoteFileBrowserController(server_connections.active_connection(), this.remoteBrowseEvents); initializeTransferInfoController(server_connections.active_connection(), this.transferInfoEvents); diff --git a/shared/js/ui/react-elements/Avatar.tsx b/shared/js/ui/react-elements/Avatar.tsx index 3dececff..8ca115b3 100644 --- a/shared/js/ui/react-elements/Avatar.tsx +++ b/shared/js/ui/react-elements/Avatar.tsx @@ -8,51 +8,54 @@ export const AvatarRenderer = React.memo((props: { avatar: ClientAvatar, classNa let [ revision, setRevision ] = useState(0); let image; - switch (props.avatar.state) { + switch (props.avatar?.getState()) { case "unset": image = {typeof { if(event.isDefaultPrevented()) return; event.preventDefault(); - image_preview.preview_image(props.avatar.avatarUrl, undefined); + image_preview.preview_image(props.avatar.getAvatarUrl(), undefined); }} />; break; case "loaded": image = {typeof { if(event.isDefaultPrevented()) return; event.preventDefault(); - image_preview.preview_image(props.avatar.avatarUrl, undefined); + image_preview.preview_image(props.avatar.getAvatarUrl(), undefined); }} />; break; case "errored": - image = {typeof; + image = {typeof; break; case "loading": image = {typeof; break; + + case undefined: + break; } - props.avatar.events.reactUse("avatar_state_changed", () => setRevision(revision + 1)); + props.avatar?.events.reactUse("avatar_state_changed", () => setRevision(revision + 1)); return (
diff --git a/shared/js/ui/react-elements/Checkbox.scss b/shared/js/ui/react-elements/Checkbox.scss index f791f5e4..79ce643e 100644 --- a/shared/js/ui/react-elements/Checkbox.scss +++ b/shared/js/ui/react-elements/Checkbox.scss @@ -5,7 +5,7 @@ html:root { --checkbox-checkmark: #46c0ec; --checkbox-background: #272626; - --checkbox-disabled-background: #46c0ec; + --checkbox-disabled-background: #1a1a1e; } .checkbox { diff --git a/shared/js/ui/react-elements/Checkbox.tsx b/shared/js/ui/react-elements/Checkbox.tsx index 4a3bdbb3..3eb1d538 100644 --- a/shared/js/ui/react-elements/Checkbox.tsx +++ b/shared/js/ui/react-elements/Checkbox.tsx @@ -6,6 +6,8 @@ export interface CheckboxProperties { label?: ReactElement | string; disabled?: boolean; onChange?: (value: boolean) => void; + + value?: boolean; initialValue?: boolean; children?: never; @@ -20,21 +22,18 @@ export class Checkbox extends React.Component constructor(props) { super(props); - this.state = { - checked: this.props.initialValue, - disabled: this.props.disabled - }; + this.state = { }; } render() { const disabled = typeof this.state.disabled === "boolean" ? this.state.disabled : this.props.disabled; - const checked = typeof this.state.checked === "boolean" ? this.state.checked : this.props.initialValue; + const checked = typeof this.props.value === "boolean" ? this.props.value : typeof this.state.checked === "boolean" ? this.state.checked : this.props.initialValue; const disabledClass = disabled ? cssStyle.disabled : ""; return (