Added the basics for popoutable windows and a CSS style editor

canary
WolverinDEV 2020-07-20 19:08:13 +02:00
parent 6332b0fc0e
commit e377a77e32
78 changed files with 3962 additions and 1283 deletions

View File

@ -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

View File

@ -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 = {};

View File

@ -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 {};

View File

@ -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 {

View File

@ -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) {

64
loader/app/maifest.ts Normal file
View File

@ -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<TeaManifest> {
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));
});
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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());
}

View File

@ -1,6 +1,13 @@
body {
padding: 0;
margin: 0;
background: #1e1e1e;
}
*, :before, :after {
box-sizing: border-box;
outline: none;
}
@import "loader";

303
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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;

View File

@ -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;

View File

@ -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: (<ProcessQuery>message.data).query_id,
request_timestamp: (<ProcessQuery>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<ProcessQueryResponse[]> {
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<void> {
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<boolean>;
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<boolean>) : Promise<void> {
return new Promise<void>((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<any>;
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<any>} = {};
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<R>(method: (...args: any[]) => Promise<R> | 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";
}

View File

@ -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<ConnectionEvents>;
channelTree: ChannelTree;
@ -172,8 +175,9 @@ export class ConnectionHandler {
log: ServerLog;
constructor() {
this.handlerId = guid();
this.event_registry = new Registry<ConnectionEvents>();
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();

28
shared/js/devel_main.ts Normal file
View File

@ -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();
}
});

View File

@ -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, "singletone-instan
as<T extends keyof SingletonEvents>() : SingletonEvents[T] { return; }
}
export interface EventReceiver<Events> {
fire<T extends keyof Events>(event_type: T, data?: Events[T], overrideTypeKey?: boolean);
fire_async<T extends keyof Events>(event_type: T, data?: Events[T], callback?: () => void);
}
const event_annotation_key = guid();
export class Registry<Events> {
private readonly registry_uuid;
export class Registry<Events> implements EventReceiver<Events> {
private readonly registryUuid;
private handler: {[key: string]: ((event) => void)[]} = {};
private connections: {[key: string]:Registry<string>[]} = {};
private event_handler_objects: {
private connections: {[key: string]: EventReceiver<Events>[]} = {};
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<T extends keyof Events>(event: T, handler: (event?: Events[T] & Event<Events, T>) => void) : () => void;
on(events: (keyof Events)[], handler: (event?: Event<Events, keyof Events>) => void) : () => void;
@ -50,7 +54,7 @@ export class Registry<Events> {
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<Events> {
return () => this.off(events, handler);
}
onAll(handler: (event?: Event<Events>) => 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<T extends keyof Events>(event: T, handler: (event?: Events[T] & Event<Events, T>) => void) : () => void;
one(events: (keyof Events)[], handler: (event?: Event<Events, keyof Events>) => void) : () => void;
@ -70,7 +82,7 @@ export class Registry<Events> {
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<Events> {
}
}
offAll(handler: (event?: Event<Events>) => void) {
(this.handler[null as any] || []).remove(handler);
}
/* special helper methods for react components */
reactUse<T extends keyof Events>(event: T, handler: (event?: Events[T] & Event<Events, T>) => void, condition?: boolean) {
@ -108,23 +124,28 @@ export class Registry<Events> {
});
}
connect<EOther, T extends keyof Events & keyof EOther>(events: T | T[], target: Registry<EOther>) {
connectAll<EOther, T extends keyof Events & keyof EOther>(target: EventReceiver<Events>) {
(this.connections[null as any] || (this.connections[null as any] = [])).push(target as any);
}
connect<EOther, T extends keyof Events & keyof EOther>(events: T | T[], target: EventReceiver<Events>) {
for(const event of Array.isArray(events) ? events : [events])
(this.connections[event as string] || (this.connections[event as string] = [])).push(target as any);
}
disconnect<EOther, T extends keyof Events & keyof EOther>(events: T | T[], target: Registry<EOther>) {
disconnect<EOther, T extends keyof Events & keyof EOther>(events: T | T[], target: EventReceiver<Events>) {
for(const event of Array.isArray(events) ? events : [events])
(this.connections[event as string] || []).remove(target as any);
}
disconnect_all<EOther>(target: Registry<EOther>) {
disconnectAll<EOther>(target: EventReceiver<Events>) {
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<T extends keyof Events>(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<Events> {
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<Events> {
destroy() {
this.handler = {};
this.connections = {};
this.event_handler_objects = [];
this.eventHandlerObjects = [];
}
register_handler(handler: any, parentClasses?: boolean) {
@ -213,17 +243,17 @@ export class Registry<Events> {
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<ObjectClass = React.Component<any, any>, 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 },
}
}
@ -722,13 +711,3 @@ export namespace modal {
}
}
}
//Some test code
/*
const eclient = new Registry<ClientEvents>();
const emusic = new Registry<sidebar.music>();
eclient.on("property_update", event => { event.as<"playlist_song_loaded">(); });
eclient.connect("playlist_song_loaded", emusic);
eclient.connect("playlist_song_loaded", emusic);
*/

164
shared/js/file/Avatars.ts Normal file
View File

@ -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<AvatarEvents>;
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<AvatarEvents>();
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<T extends AvatarState>(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<T extends AvatarState>(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<strLen; i++) {
bufView[i] = str.charCodeAt(i);
}
return buf;
}
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;
}
}

View File

@ -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,

View File

@ -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<AvatarEvents>;
readonly clientAvatarId: string; /* the base64 unique id thing from a-m */
currentAvatarHash: string | "unknown"; /* the client avatars flag */
state: AvatarState = "loading";
/* only set when state is unset, loaded or errored */
avatarUrl?: string;
loadError?: string;
loadingTimestamp: number = 0;
constructor(client_avatar_id: string) {
this.clientAvatarId = client_avatar_id;
this.events = new Registry<AvatarEvents>();
}
setState(state: AvatarState) {
if(state === this.state)
return;
const oldState = this.state;
this.state = state;
this.events.fire("avatar_state_changed", { newState: state, oldState: oldState });
}
async awaitLoaded() {
if(this.state !== "loading")
return;
await new Promise(resolve => this.events.on("avatar_state_changed", event => event.newState !== "loading" && resolve()));
}
destroyUrl() {
URL.revokeObjectURL(this.avatarUrl);
this.avatarUrl = DefaultAvatarImage;
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<strLen; i++) {
bufView[i] = str.charCodeAt(i);
}
return buf;
/* FIXME: unsubscribe if the other client isn't alive any mnore */
class LocalAvatarManagerFactory extends AbstractAvatarManagerFactory {
private ipcChannel: IPCChannel;
private subscribedAvatars: {[key: string]: { avatar: ClientAvatar, remoteAvatarId: string, unregisterCallback: () => 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));
getManager(handlerId: string): AbstractAvatarManager {
return server_connections.findConnection(handlerId)?.fileManager.avatars;
}
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);
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() });
}
return result;
} catch (e) { //invalid base 64 (like music bot etc)
return undefined;
}
}
loader.register_task(Stage.LOADED, {
name: "Avatar init",
function: async () => {
setGlobalAvatarManagerFactory(new LocalAvatarManagerFactory());
},
priority: 5
});

View File

@ -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);
}
});

287
shared/js/ipc/BrowserIPC.ts Normal file
View File

@ -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: (<ProcessQuery>message.data).query_id,
request_timestamp: (<ProcessQuery>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<ProcessQueryResponse[]> {
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<void> {
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";
}

View File

@ -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<boolean>;
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<boolean>) : Promise<void> {
return new Promise<void>((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;
})
}
}

View File

@ -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<any>;
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<any>} = {};
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<R>(method: (...args: any[]) => Promise<R> | 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();
}

View File

@ -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."));

View File

@ -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: <users download directory> */
};
static readonly KEY_IPC_REMOTE_ADDRESS: SettingsKey<string> = {
key: "ipc-address",
valueType: "string"
};
static readonly FN_LOG_ENABLED: (category: string) => SettingsKey<boolean> = category => {
return {
key: "log." + category.toLowerCase() + ".enabled",
@ -709,3 +715,9 @@ export class ServerSettings extends SettingsBase {
}
export let settings: Settings = null;
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
priority: 1000,
name: "Settings initialize",
function: async () => Settings.initialize()
})

View File

@ -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;
}

View File

@ -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");

View File

@ -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);
import "./emoji";
import "./highlight";

View File

@ -787,7 +787,7 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
}
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();

View File

@ -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());

View File

@ -26,7 +26,7 @@ export class ConnectionManager {
constructor(tag: JQuery) {
this.event_registry = new Registry<ConnectionManagerEvents>();
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;
}

View File

@ -406,7 +406,7 @@ export class ControlBar extends React.Component<ControlBarProperties, {}> {
super(props);
this.event_registry = new Registry<InternalControlBarEvents>();
this.event_registry.enable_debug("control-bar");
this.event_registry.enableDebug("control-bar");
initialize(this.event_registry);
}

View File

@ -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;

View File

@ -260,6 +260,7 @@ const TextInput = (props: { events: Registry<ChatBoxEvents>, enabled?: boolean,
};
export interface ChatBoxProperties {
className?: string;
onSubmit?: (text: string) => void;
onType?: () => void;
}
@ -298,7 +299,7 @@ export class ChatBox extends React.Component<ChatBoxProperties, ChatBoxState> {
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<ChatBoxProperties, ChatBoxState> {
}
render() {
return <div className={cssStyle.container}>
return <div className={cssStyle.container + " " + this.props.className}>
<div className={cssStyle.chatbox}>
<EmojiButton events={this.events} />
<TextInput events={this.events} placeholder={tr("Type your message here...")} />

View File

@ -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,

View File

@ -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<ConversationUIEvent
ReactDOM.render(React.createElement(ConversationPanel, {
events: this.uiEvents,
handler: this.connection,
handlerId: this.connection.handlerId,
noFirstMessageOverlay: false,
messagesDeletable: true
}), this.htmlTag);
/*
spawnExternalModal("conversation", this.uiEvents, {
handlerId: this.connection.handlerId,
noFirstMessageOverlay: false,
messagesDeletable: true
}).open().then(() => {
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<ConversationUIEvent
conversation.deleteMessage(event.uniqueId);
}
@EventHandler<ConversationUIEvents>("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<ConversationUIEvents>("notify_selected_chat")
private handleNotifySelectedChat(event: ConversationUIEvents["notify_selected_chat"]) {
this.selectedConversation_ = parseInt(event.chatId);

View File

@ -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);
}
}
}

View File

@ -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<ConversationUIEvents>,
handler: ConnectionHandler,
handlerId: string,
refHTMLElement?: Ref<HTMLDivElement>
}) => {
@ -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 (
<div className={cssStyle.containerMessage} ref={props.refHTMLElement}>
<div className={cssStyle.avatar}>
<AvatarRenderer
className={cssStyle.imageContainer}
alt={""}
avatar={props.handler.fileManager.avatars.resolveClientAvatar({ clientUniqueId: props.message.sender_unique_id, database_id: props.message.sender_database_id })} />
avatar={avatar} />
</div>
<div className={cssStyle.message}>
<div className={cssStyle.info}>
{deleteButton}
<a className={cssStyle.sender} dangerouslySetInnerHTML={{ __html: generate_client({
client_database_id: props.message.sender_database_id,
client_id: -1,
client_name: props.message.sender_name,
client_unique_id: props.message.sender_unique_id,
add_braces: false
})}} />
{/*
<a className={cssStyle.sender} dangerouslySetInnerHTML={{ __html: generate_client({
client_database_id: props.message.sender_database_id,
client_id: -1,
client_name: props.message.sender_name,
client_unique_id: props.message.sender_unique_id,
add_braces: false
})}} />
*/}
<a className={cssStyle.sender}>
<div className={"htmltag-client"}>{props.message.sender_name}</div>
</a>
<span> </span> { /* Only for copy purposes */}
<a className={cssStyle.timestamp}>
<TimestampRenderer timestamp={props.message.timestamp} />
@ -322,7 +327,7 @@ const PartnerTypingIndicator = (props: { events: Registry<ConversationUIEvents>,
interface ConversationMessagesProperties {
events: Registry<ConversationUIEvents>;
handler: ConnectionHandler;
handlerId: string;
noFirstMessageOverlay?: boolean
messagesDeletable?: boolean;
@ -555,6 +560,7 @@ class ConversationMessages extends React.PureComponent<ConversationMessagesPrope
}
componentDidMount(): void {
this.props.events.fire("query_selected_chat");
this.scrollToBottom();
}
@ -604,7 +610,7 @@ class ConversationMessages extends React.PureComponent<ConversationMessagesPrope
message={event.message}
events={this.props.events}
callbackDelete={this.props.messagesDeletable ? () => 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<ConversationMessagesPrope
}
}
export const ConversationPanel = React.memo((props: { events: Registry<ConversationUIEvents>, handler: ConnectionHandler, messagesDeletable: boolean, noFirstMessageOverlay: boolean }) => {
export const ConversationPanel = React.memo((props: { events: Registry<ConversationUIEvents>, 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<Conversat
});
return <div className={cssStyle.panel}>
<ConversationMessages events={props.events} handler={props.handler} messagesDeletable={props.messagesDeletable} noFirstMessageOverlay={props.noFirstMessageOverlay} />
<ConversationMessages events={props.events} handlerId={props.handlerId} messagesDeletable={props.messagesDeletable} noFirstMessageOverlay={props.noFirstMessageOverlay} />
<ChatBox
ref={refChatBox}
onSubmit={text => props.events.fire("action_send_message", { chatId: currentChat.current.id, text: text }) }

View File

@ -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<ConversationUIEvents>;
private readonly userData: any;
constructor(events: Registry<ConversationUIEvents>, userData: any) {
super();
this.userData = userData;
this.events = events;
}
renderBody() {
return <ConversationPanel
handlerId={this.userData.handlerId}
events={this.events}
messagesDeletable={this.userData.messagesDeletable}
noFirstMessageOverlay={this.userData.noFirstMessageOverlay} />;
}
title() {
return "Conversations";
}
}
export = PopoutConversationUI;

View File

@ -330,7 +330,7 @@ export class PrivateConversationManager extends AbstractChatManager<PrivateConve
this.htmlTag.style.height = "100%";
this.uiEvents.register_handler(this, true);
this.uiEvents.enable_debug("private-conversations");
this.uiEvents.enableDebug("private-conversations");
ReactDOM.render(React.createElement(PrivateConversationsPanel, { events: this.uiEvents, handler: this.connection }), this.htmlTag);

View File

@ -1,6 +1,22 @@
@import "../../../../css/static/mixin";
@import "../../../../css/static/properties";
html:root {
--chat-private-no-chats: #5a5a5a;
--chat-private-border: #313132;
--chat-private-unread-background: #a81414;
--chat-private-unread-shadow: rgba(0, 0, 0, 0.20);
--chat-private-name: #ccc;
--chat-private-timestamp: #555353;
--chat-private-close-background: #5a5a5a;
--chat-private-hovered-background: #393939;
--chat-private-selected-background: #2c2c2c;
}
.divider {
width: 2px!important;
min-width: 2px!important;
@ -39,7 +55,7 @@
> 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);
}

View File

@ -201,6 +201,6 @@ const OpenConversationsPanel = React.memo((props: { events: Registry<PrivateConv
export const PrivateConversationsPanel = (props: { events: Registry<PrivateConversationUIEvents>, handler: ConnectionHandler }) => (
<ContextDivider id={"seperator-conversation-list-messages"} direction={"horizontal"} defaultValue={25} separatorClassName={cssStyle.divider}>
<OpenConversationsPanel events={props.events} connection={props.handler} />
<ConversationPanel events={props.events as any} handler={props.handler} noFirstMessageOverlay={true} messagesDeletable={false} />
<ConversationPanel events={props.events as any} handlerId={props.handler.handlerId} noFirstMessageOverlay={true} messagesDeletable={false} />
</ContextDivider>
);

View File

@ -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<events.sidebar.music>;
readonly events: Registry<MusicSidebarEvents>;
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<events.sidebar.music>();
this.events = new Registry<MusicSidebarEvents>();
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);

View File

@ -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);

View File

@ -15,7 +15,7 @@ import * as htmltags from "tc-shared/ui/htmltags";
export function openMusicManage(client: ConnectionHandler, bot: MusicClientEntry) {
const ev_registry = new Registry<modal.music_manage>();
ev_registry.enable_debug("music-manage");
ev_registry.enableDebug("music-manage");
//dummy_controller(ev_registry);
permission_controller(ev_registry, bot, client);

View File

@ -34,7 +34,7 @@ export function openModalNewcomer() : Modal {
});
const event_registry = new Registry<emodal.newcomer>();
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<emodal.newco
function initializeStepIdentity(tag: JQuery, event_registry: Registry<emodal.newcomer>) {
const profile_events = new Registry<emodal.settings.profiles>();
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 });

View File

@ -436,7 +436,7 @@ function settings_general_chat(container: JQuery, modal: Modal) {
function settings_audio_microphone(container: JQuery, modal: Modal) {
const registry = new Registry<events.modal.settings.microphone>();
registry.enable_debug("settings-microphone");
registry.enableDebug("settings-microphone");
modal_settings.initialize_audio_microphone_controller(registry);
modal_settings.initialize_audio_microphone_view(container, registry);

View File

@ -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<CssEditorEvents>();
cssVariableEditorController(events);
const modal = spawnExternalModal("css-editor", events, {});
modal.open();
}
function cssVariableEditorController(events: Registry<CssEditorEvents>) {
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();
}
});

View File

@ -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 }
}

View File

@ -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;
}
}
}

View File

@ -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<CssEditorEvents>, 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 (
<div
className={cssStyle.variable + " " + (selected ? cssStyle.selected : "")}
onClick={() => {
if(selected)
return;
props.events.fire("action_select_entry", { variable: props.variable })
}}
>
<div className={cssStyle.preview}>
<div
className={cssStyle.color}
style={{ backgroundColor: props.variable.defaultValue }}
/>
</div>
<div className={cssStyle.preview}>
<div
className={cssStyle.color}
style={{ backgroundColor: override ? overrideColor : undefined }}
/>
</div>
<a>{props.variable.name}</a>
</div>
);
});
const CssVariableListBodyRenderer = (props: { events: Registry<CssEditorEvents> }) => {
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 = (
<div className={cssStyle.overlay} key={"loading"}>
<a>
<Translatable>Loading</Translatable>&nbsp;
<LoadingDots />
</a>
</div>
);
} else {
content = [];
for(const variable of variables) {
if(filter && variable.name.toLowerCase().indexOf(filter) === -1)
continue;
content.push(<CssVariableRenderer
key={"variable-" + variable.name}
events={props.events}
variable={variable}
selected={selectedVariable === variable.name}
/>);
}
if(content.length === 0) {
content.push(
<div className={cssStyle.overlay} key={"no-match"}>
<a><Translatable>No variable matched your filter</Translatable></a>
</div>
);
}
}
props.events.reactUse("action_set_filter", event => setFilter(event.filter?.toLowerCase()));
props.events.reactUse("notify_css_variables", event => setVariables(event.variables));
return (
<div className={cssStyle.body} onKeyPress={event => {
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}
</div>
);
};
const CssVariableListSearchRenderer = (props: { events: Registry<CssEditorEvents> }) => {
const [ isLoading, setLoading ] = useState(true);
props.events.reactUse("notify_css_variables", () => setLoading(false));
props.events.reactUse("query_css_variables", () => setLoading(true));
return (
<div className={cssStyle.search}>
<FlatInputField
label={<Translatable>Filter variables</Translatable>}
labelType={"floating"}
className={cssStyle.input}
onInput={text => props.events.fire("action_set_filter", { filter: text })}
disabled={isLoading}
/>
</div>
);
};
const CssVariableListRenderer = (props: { events: Registry<CssEditorEvents> }) => (
<div className={cssStyle.containerList}>
<div className={cssStyle.header}>
<a><Translatable>CSS Variable list</Translatable></a>
</div>
<div className={cssStyle.list} onKeyPress={event => console.error(event.key)}>
<CssVariableListBodyRenderer events={props.events} />
<CssVariableListSearchRenderer events={props.events} />
</div>
</div>
);
const SelectedVariableInfo = (props: { events: Registry<CssEditorEvents> }) => {
const [ selectedVariable, setSelectedVariable ] = useState<CssVariable>(undefined);
props.events.reactUse("action_select_entry", event => setSelectedVariable(event.variable));
return (<>
<div className={cssStyle.detail}>
<div className={cssStyle.title}>
<Translatable>Name</Translatable>
</div>
<div className={cssStyle.value}>
<BoxedInputField
editable={false}
value={selectedVariable ? selectedVariable.name : "-"}
/>
</div>
</div>
<div className={cssStyle.detail}>
<div className={cssStyle.title}>
<Translatable>Default Value</Translatable>
</div>
<div className={cssStyle.value}>
<BoxedInputField
editable={false}
value={selectedVariable ? selectedVariable.defaultValue : "-"}
/>
</div>
</div>
</>);
};
const OverrideVariableInfo = (props: { events: Registry<CssEditorEvents> }) => {
const [ selectedVariable, setSelectedVariable ] = useState<CssVariable>(undefined);
const [ overwriteValue, setOverwriteValue ] = useState<string>(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 (<>
<div className={cssStyle.detail}>
<div className={cssStyle.title}>
<Translatable>Override Value</Translatable>
<Checkbox
value={overwriteEnabled}
disabled={!selectedVariable}
onChange={value => {
props.events.fire("action_override_toggle", {
variableName: selectedVariable.name,
value: typeof selectedVariable.customValue === "string" ? selectedVariable.customValue : selectedVariable.defaultValue,
enabled: value
});
}}
/>
</div>
<div className={cssStyle.value + " " + cssStyle.color}>
<BoxedInputField
className={cssStyle.input}
disabled={!overwriteEnabled}
value={overwriteValue || " "}
onInput={text => {
selectedVariable.customValue = text;
props.events.fire("action_change_override_value", { value: text, variableName: selectedVariable.name });
}}
/>
<CssVariableColorPicker events={props.events} selectedVariable={selectedVariable} />
</div>
</div>
</>);
};
const CssVariableColorPicker = (props: { events: Registry<CssEditorEvents>, selectedVariable: CssVariable }) => {
const [ overwriteValue, setOverwriteValue ] = useState<string>(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 (
<label className={cssStyle.colorButton} >
<input
disabled={!overwriteEnabled}
type={"color"}
value={overwriteValue}
onChange={event => {
currentInput = event.target.value;
if(inputTimeout)
return;
inputTimeout = setTimeout(() => {
inputTimeout = undefined;
props.events.fire("action_change_override_value", { value: currentInput, variableName: props.selectedVariable.name });
}, 150);
}}
/>
<a className="rainbow-letter" style={{ borderBottomColor: overwriteValue }}>C</a>
</label>
)
};
const ControlButtons = (props: { events: Registry<CssEditorEvents> }) => {
return (
<div className={cssStyle.buttons}>
<Button
color={"blue"}
type={"normal"}
className={cssStyle.button}
onClick={() => props.events.fire("action_randomize")}
><Translatable>Randomize</Translatable></Button>
<Button
color={"red"}
type={"normal"}
className={cssStyle.button + " " + cssStyle.buttonReset}
onClick={() => props.events.fire("action_reset")}
><Translatable>Reset</Translatable></Button>
<Button
color={"blue"}
type={"normal"}
className={cssStyle.button}
onClick={() => props.events.fire("action_export")}
><Translatable>Export</Translatable></Button>
<Button
color={"green"}
type={"normal"}
className={cssStyle.button}
onClick={() => requestFileAsText().then(content => {
props.events.fire("action_import", { config: content })
})}
><Translatable>Import</Translatable></Button>
</div>
)
};
const CssVariableEditor = (props: { events: Registry<CssEditorEvents> }) => {
return (
<div className={cssStyle.containerEdit}>
<div className={cssStyle.header}>
<a><Translatable>Variable details</Translatable></a>
</div>
<SelectedVariableInfo events={props.events} />
<OverrideVariableInfo events={props.events} />
<ControlButtons events={props.events} />
</div>
)
};
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<string> => {
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<CssEditorEvents>;
private readonly userData: CssEditorUserData;
constructor(events: Registry<CssEditorEvents>, 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 (
<div className={cssStyle.container}>
<CssVariableListRenderer events={this.events} />
<CssVariableEditor events={this.events} />
</div>
);
}
title() {
return "CSS Variable editor";
}
}
export = PopoutConversationUI;

View File

@ -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);

View File

@ -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);

View File

@ -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 = <img
key={"default"}
title={tr("default avatar")}
alt={typeof props.alt === "string" ? props.alt : tr("default avatar")}
src={props.avatar.avatarUrl}
src={props.avatar.getAvatarUrl()}
style={ImageStyle}
onClick={event => {
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 = <img
key={"user-" + props.avatar.currentAvatarHash}
key={"user-" + props.avatar.getAvatarHash()}
alt={typeof props.alt === "string" ? props.alt : tr("user avatar")}
title={tr("user avatar")}
src={props.avatar.avatarUrl}
src={props.avatar.getAvatarUrl()}
style={ImageStyle}
onClick={event => {
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 = <img key={"error"} alt={typeof props.alt === "string" ? props.alt : tr("error")} title={tr("avatar failed to load:\n") + props.avatar.loadError} src={props.avatar.avatarUrl} style={ImageStyle} />;
image = <img key={"error"} alt={typeof props.alt === "string" ? props.alt : tr("error")} title={tr("avatar failed to load:\n") + props.avatar.getLoadError()} src={props.avatar.getAvatarUrl()} style={ImageStyle} />;
break;
case "loading":
image = <img key={"loading"} alt={typeof props.alt === "string" ? props.alt : tr("loading")} title={tr("loading avatar")} src={"img/loading_image.svg"} style={ImageStyle} />;
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 (
<div className={props.className} style={{ overflow: "hidden" }}>

View File

@ -5,7 +5,7 @@ html:root {
--checkbox-checkmark: #46c0ec;
--checkbox-background: #272626;
--checkbox-disabled-background: #46c0ec;
--checkbox-disabled-background: #1a1a1e;
}
.checkbox {

View File

@ -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<CheckboxProperties, CheckboxState>
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 (
<label className={cssStyle.labelCheckbox + " " + disabledClass}>
<div className={cssStyle.checkbox + " " + disabledClass}>
<input type={"checkbox"} checked={checked} disabled={disabled} onChange={() => this.onStateChange()} />
<input type={"checkbox"} checked={checked} disabled={disabled} onChange={event => this.onStateChange(event)} />
<div className={cssStyle.mark} />
</div>
{this.props.label ? <a>{this.props.label}</a> : undefined}
@ -42,9 +41,9 @@ export class Checkbox extends React.Component<CheckboxProperties, CheckboxState>
);
}
private onStateChange() {
private onStateChange(event: React.ChangeEvent) {
if(this.props.onChange)
this.props.onChange(!this.state.checked);
this.props.onChange((event.target as HTMLInputElement).checked);
this.setState({ checked: !this.state.checked });
}

View File

@ -9,9 +9,9 @@ html:root {
--boxed-input-field-disabled-background: #1a1819;
--boxed-input-field-focus-border: #111112;
--boxed-input-field-focus-background: #121213;
--boxed-input-field-focus-text: #b3b3b3;
--boxed-input-field-focus-border: #284262;
--boxed-input-field-focus-background: #131b22;
--boxed-input-field-focus-text: #e1e2e3;
--boxed-input-field-invalid-border: #721c1c;
--boxed-input-field-invalid-background: #180d0d;
@ -73,17 +73,19 @@ html:root {
background-image: unset!important;
}
&:focus, &:focus-within {
background-color: var(--boxed-input-field-focus-background);
border-color: var(--boxed-input-field-focus-border);
&.editable {
&:focus, &:focus-within {
background-color: var(--boxed-input-field-focus-background);
border-color: var(--boxed-input-field-focus-border);
color: var(--boxed-input-field-focus-text);
color: var(--boxed-input-field-focus-text);
.prefix {
width: 0;
padding-left: 0;
padding-right: 0;
opacity: 0;
.prefix {
width: 0;
padding-left: 0;
padding-right: 0;
opacity: 0;
}
}
}
@ -97,7 +99,7 @@ html:root {
outline: none;
margin: 0;
//color: #b3b3b3;
color: inherit;
min-width: 2em;
&.editable {

View File

@ -10,6 +10,7 @@ export interface BoxedInputFieldProperties {
disabled?: boolean;
editable?: boolean;
value?: string;
defaultValue?: string;
rightIcon?: () => ReactElement;
@ -52,9 +53,10 @@ export class BoxedInputField extends React.Component<BoxedInputFieldProperties,
draggable={false}
className={
cssStyle.containerBoxed + " " +
cssStyle["size-" + (this.props.size || "normal")] +
cssStyle["size-" + (this.props.size || "normal")] + " " +
(this.state.disabled || this.props.disabled ? cssStyle.disabled : "") + " " +
(this.state.isInvalid || this.props.isInvalid ? cssStyle.isInvalid : "") + " " +
(typeof this.props.editable !== "boolean" || this.props.editable ? cssStyle.editable : "") + " " +
(this.props.leftIcon ? "" : cssStyle.noLeftIcon) + " " +
(this.props.rightIcon ? "" : cssStyle.noRightIcon) + " " +
this.props.className
@ -69,10 +71,10 @@ export class BoxedInputField extends React.Component<BoxedInputFieldProperties,
<span key={"custom-input"} className={cssStyle.inputBox + " " + (this.props.editable ? cssStyle.editable : "")} onClick={this.props.onFocus}>{this.props.inputBox()}</span> :
<input key={"input"}
ref={this.refInput}
value={this.state.value}
value={this.props.value || this.state.value}
defaultValue={this.state.defaultValue || this.props.defaultValue}
placeholder={this.props.placeholder}
readOnly={typeof this.props.editable === "boolean" ? this.props.editable : false}
readOnly={typeof this.props.editable === "boolean" ? !this.props.editable : false}
disabled={this.state.disabled || this.props.disabled}
onInput={this.props.onInput && (event => this.props.onInput(event.currentTarget.value))}
onKeyDown={e => this.onKeyDown(e)}

View File

@ -1,7 +1,7 @@
import {useEffect, useState} from "react";
import * as React from "react";
export const LoadingDots = (props: { maxDots?: number, speed?: number }) => {
export const LoadingDots = (props: { maxDots?: number, speed?: number, textOnly?: boolean }) => {
let { maxDots, speed } = props;
if(!maxDots || maxDots < 1)
maxDots = 3;
@ -16,5 +16,8 @@ export const LoadingDots = (props: { maxDots?: number, speed?: number }) => {
let result = ".";
for(let index = 0; index < dots % maxDots; index++)
result += ".";
if(props.textOnly)
return <>{result}</>;
return <div style={{ width: (maxDots / 3) + "em", display: "inline-block", textAlign: "left" }}>{result}</div>;
};

View File

@ -101,26 +101,30 @@ export class ModalController<InstanceType extends Modal = Modal> {
}
}
export abstract class Modal {
private __modal_controller: ModalController;
public constructor() {}
export abstract class AbstractModal {
protected constructor() {}
type() : ModalType { return "none"; }
abstract renderBody() : ReactElement;
abstract title() : string | React.ReactElement<Translatable>;
protected onInitialize() {}
protected onDestroy() {}
protected onClose() {}
protected onOpen() {}
}
export abstract class Modal extends AbstractModal {
private __modal_controller: ModalController;
type() : ModalType { return "none"; }
/**
* Will only return a modal controller when the modal has not been destroyed
*/
modalController() : ModalController | undefined {
return this.__modal_controller;
}
protected onInitialize() {}
protected onDestroy() {}
protected onClose() {}
protected onOpen() {}
}
class ModalImpl extends React.PureComponent<{ controller: ModalController }, { show: boolean }> {

View File

@ -1,6 +1,28 @@
@import "../../../css/static/properties";
@import "../../../css/static/mixin";
html:root {
--switch-background: #1c1c1c;
--switch-thumb-background: #3d3a3a;
--switch-thumb-shadow: rgba(0, 0, 0, 0.27);
--switch-dot-background: #a5a5a5;
--switch-dot-shadow: rgba(165, 165, 165, 0.4);
--switch-dot-checked-background: #46c0ec;
--switch-dot-checked-shadow: #46c0ec;
--switch-thumb-disabled-background: #252424;
--switch-thumb-disabled-shadow: #2f2d2d;
--switch-dot-disabled-background: #808080;
--switch-dot-disabled-shadow: rgba(102, 102, 102, 0.4);
--switch-dot-checked-disabled-background: #138db9;
--switch-dot-checked-disabled-shadow: #138db9;
}
/* general switch look */
.switch {
$ball_outer_width: 1.5em; /* 1.5? */
@ -47,7 +69,7 @@
right: -$slider_border_size;
bottom: -$slider_border_size;
background-color: #1c1c1c;
background-color: var(--switch-background);
border: $slider_border_size solid #262628;
border-radius: 5px;
@ -62,12 +84,12 @@
left: - $ball_outer_width / 2;
bottom: -($ball_outer_width - $slider_height) / 2;
background-color: #3d3a3a;
background-color: var(--switch-thumb-background);
@include transition(.4s);
border-radius: 50%;
box-shadow: 0 0 .2em 1px rgba(0, 0, 0, 0.27);
box-shadow: 0 0 .2em 1px var(--switch-thumb-shadow);
}
.dot {
@ -79,8 +101,8 @@
left: -($ball_inner_width / 2);
bottom: $slider_height / 2 - $ball_inner_width / 2;
background-color: #a5a5a5;
box-shadow: 0 0 1em 1px rgba(165, 165, 165, 0.4);
background-color: var(--switch-dot-background);
box-shadow: 0 0 1em 1px var(--switch-dot-shadow);
border-radius: 50%;
@include transition(.4s);
@ -99,8 +121,8 @@
.dot {
@include transform(translateX($slider_width));
background-color: #46c0ec;
box-shadow: 0 0 1em 1px #46c0ec;
background-color: var(--switch-dot-checked-background);
box-shadow: 0 0 1em 1px var(--switch-dot-checked-shadow);
}
}
}
@ -117,23 +139,23 @@
&.disabled {
.dot {
background-color: #808080;
box-shadow: 0 0 1em 1px rgba(102, 102, 102, 0.4);
background-color: var(--switch-dot-disabled-background);
box-shadow: 0 0 1em 1px var(--switch-dot-disabled-shadow);
}
input:checked + .slider {
.dot {
background-color: #138db9;
box-shadow: 0 0 1em 1px #138db9;
background-color: var(--switch-dot-checked-disabled-background);
box-shadow: 0 0 1em 1px var(--switch-dot-checked-disabled-shadow);
}
}
.slider {
background-color: #252424;
background-color: var(--switch-thumb-disabled-background);
}
.slider:before {
background-color: #2f2d2d;
background-color: var(--switch-thumb-disabled-shadow);
}
}
}

View File

@ -2,10 +2,12 @@
html:root {
--tooltip-background-color: #232222;
--tooltip-color: #999;
}
.container {
background-color: var(--tooltip-background-color);
color: var(--tooltip-color);
position: fixed;
z-index: 1000000;

View File

@ -0,0 +1,156 @@
import * as ipc from "tc-shared/ipc/BrowserIPC";
import {ChannelMessage, IPCChannel} from "tc-shared/ipc/BrowserIPC";
import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo";
import {Registry} from "tc-shared/events";
import {
EventControllerBase,
Popout2ControllerMessages,
PopoutIPCMessage
} from "tc-shared/ui/react-elements/external-modal/IPCMessage";
export class ExternalModalController extends EventControllerBase<"controller"> {
readonly modal: string;
readonly userData: any;
private currentWindow: Window;
private callbackWindowInitialized: (error?: string) => void;
private documentQuitListener: () => void;
constructor(modal: string, localEventRegistry: Registry<any>, userData: any) {
super(localEventRegistry);
this.modal = modal;
this.userData = userData;
this.ipcChannel = ipc.getInstance().createChannel();
this.ipcChannel.messageHandler = this.handleIPCMessage.bind(this);
this.documentQuitListener = () => this.currentWindow?.close();
}
private trySpawnWindow() {
const parameters = {
"loader-target": "manifest",
"chunk": "modal-external",
"modal-target": this.modal,
"ipc-channel": this.ipcChannel.channelId,
"ipc-address": ipc.getInstance().getLocalAddress(),
"disableGlobalContextMenu": __build.mode === "debug" ? 1 : 0,
"loader-abort": __build.mode === "debug" ? 1 : 0,
};
const features = {
/*
status: "no",
location: "no",
toolbar: "no",
menubar: "no",
width: 600,
height: 400
*/
};
let baseUrl = location.origin + location.pathname + "?";
return window.open(
baseUrl + Object.keys(parameters).map(e => e + "=" + encodeURIComponent(parameters[e])).join("&"),
"External Modal",
Object.keys(features).map(e => e + "=" + features[e]).join(",")
);
}
async open() {
this.currentWindow = this.trySpawnWindow();
if(!this.currentWindow) {
await new Promise((resolve, reject) => {
spawnYesNo(tr("Would you like to open the popup?"), tra("Would you like to open popup {}?", this.modal), callback => {
if(!callback) {
reject("user aborted");
return;
}
this.currentWindow = this.trySpawnWindow();
if(this.currentWindow) {
reject("Failed to spawn window");
} else {
resolve();
}
}).close_listener.push(() => reject("user aborted"));
})
}
if(!this.currentWindow) {
/* some shitty popup blocker or whatever */
throw "failed to create window";
}
this.currentWindow.onclose = () => {
/* TODO: General handle */
window.removeEventListener("beforeunload", this.documentQuitListener);
};
window.addEventListener("beforeunload", this.documentQuitListener);
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.callbackWindowInitialized = undefined;
reject("window haven't called back");
}, 5000);
this.callbackWindowInitialized = error => {
this.callbackWindowInitialized = undefined;
clearTimeout(timeout);
error ? reject(error) : resolve();
};
});
}
protected handleIPCMessage(remoteId: string, broadcast: boolean, message: ChannelMessage) {
if(broadcast)
return;
if(this.ipcRemoteId !== remoteId) {
this.ipcRemoteId = remoteId;
} else if(this.ipcRemoteId !== remoteId) {
console.warn("Remote window got a new id. Maybe reload?");
this.ipcRemoteId = remoteId;
}
super.handleIPCMessage(remoteId, broadcast, message);
}
protected handleTypedIPCMessage<T extends Popout2ControllerMessages>(type: T, payload: PopoutIPCMessage[T]) {
super.handleTypedIPCMessage(type, payload);
switch (type) {
case "hello-popout": {
const tpayload = payload as PopoutIPCMessage["hello-popout"];
console.log("Received Hello World from popup with version %s (expected %s).", tpayload.version, __build.version);
if(tpayload.version !== __build.version) {
this.sendIPCMessage("hello-controller", { accepted: false, message: tr("version miss match") });
if(this.callbackWindowInitialized) {
this.callbackWindowInitialized(tr("version miss match"));
this.callbackWindowInitialized = undefined;
}
return;
}
if(this.callbackWindowInitialized) {
this.callbackWindowInitialized();
this.callbackWindowInitialized = undefined;
}
this.sendIPCMessage("hello-controller", { accepted: true, userData: this.userData });
break;
}
case "fire-event":
case "fire-event-callback":
/* already handled by out base class */
break;
default:
console.warn("Received unknown message type from popup window: %s", type);
return;
}
}
}

View File

@ -0,0 +1,113 @@
import {ChannelMessage, IPCChannel} from "tc-shared/ipc/BrowserIPC";
import {EventReceiver, Registry} from "tc-shared/events";
export interface PopoutIPCMessage {
"hello-popout": { version: string },
"hello-controller": { accepted: boolean, message?: string, userData?: any },
"fire-event": {
type: string;
payload: any;
callbackId: string;
},
"fire-event-callback": {
callbackId: string
}
}
export type Controller2PopoutMessages = "hello-controller" | "fire-event" | "fire-event-callback";
export type Popout2ControllerMessages = "hello-popout" | "fire-event" | "fire-event-callback";
interface SendIPCMessage {
"controller": Controller2PopoutMessages;
"popout": Popout2ControllerMessages;
}
interface ReceivedIPCMessage {
"controller": Popout2ControllerMessages;
"popout": Controller2PopoutMessages;
}
let callbackIdIndex = 0;
export abstract class EventControllerBase<Type extends "controller" | "popout"> {
protected ipcChannel: IPCChannel;
protected ipcRemoteId: string;
protected readonly localEventRegistry: Registry<string>;
private readonly localEventReceiver: EventReceiver<string>;
private omitEventType: string = undefined;
private omitEventData: any;
private eventFiredListeners: {[key: string]:{ callback: () => void, timeout: number }} = {};
protected constructor(localEventRegistry: Registry<string>) {
this.localEventRegistry = localEventRegistry;
let refThis = this;
this.localEventReceiver = new class implements EventReceiver<{}> {
fire<T extends keyof {}>(eventType: T, data?: any[T], overrideTypeKey?: boolean) {
if(refThis.omitEventType === eventType && refThis.omitEventData === data) {
refThis.omitEventType = undefined;
return;
}
refThis.sendIPCMessage("fire-event", { type: eventType, payload: data, callbackId: undefined });
}
fire_async<T extends keyof {}>(eventType: T, data?: any[T], callback?: () => void) {
const callbackId = callback ? (++callbackIdIndex) + "-ev-cb" : undefined;
refThis.sendIPCMessage("fire-event", { type: eventType, payload: data, callbackId: callbackId });
if(callbackId) {
const timeout = setTimeout(() => {
delete refThis.eventFiredListeners[callbackId];
callback();
}, 2500);
refThis.eventFiredListeners[callbackId] = {
callback: callback,
timeout: timeout
}
}
}
};
this.localEventRegistry.connectAll(this.localEventReceiver as any);
}
protected handleIPCMessage(remoteId: string, broadcast: boolean, message: ChannelMessage) {
if(this.ipcRemoteId !== remoteId) {
console.warn("Received message from unknown end: %s. Expected: %s", remoteId, this.ipcRemoteId);
return;
}
this.handleTypedIPCMessage(message.type as any, message.data);
}
protected sendIPCMessage<T extends SendIPCMessage[Type]>(type: T, payload: PopoutIPCMessage[T]) {
this.ipcChannel.sendMessage(type, payload, this.ipcRemoteId);
}
protected handleTypedIPCMessage<T extends ReceivedIPCMessage[Type]>(type: T, payload: PopoutIPCMessage[T]) {
switch (type) {
case "fire-event": {
const tpayload = payload as PopoutIPCMessage["fire-event"];
this.omitEventData = tpayload.payload;
this.omitEventType = tpayload.type;
this.localEventRegistry.fire(tpayload.type as any, tpayload.payload);
if(tpayload.callbackId)
this.sendIPCMessage("fire-event-callback", { callbackId: tpayload.callbackId });
break;
}
case "fire-event-callback": {
const tpayload = payload as PopoutIPCMessage["fire-event-callback"];
const callback = this.eventFiredListeners[tpayload.callbackId];
delete this.eventFiredListeners[tpayload.callbackId];
if(callback) {
clearTimeout(callback.timeout);
callback.callback();
}
break;
}
}
}
}

View File

@ -0,0 +1,90 @@
import {ChannelMessage, getInstance as getIPCInstance, IPCChannel} from "tc-shared/ipc/BrowserIPC";
import {Settings, SettingsKey} from "tc-shared/settings";
import {
Controller2PopoutMessages, EventControllerBase,
PopoutIPCMessage
} from "tc-shared/ui/react-elements/external-modal/IPCMessage";
import {Registry} from "tc-shared/events";
const kSettingIPCChannel: SettingsKey<string> = {
key: "ipc-channel",
valueType: "string"
};
let controller: PopoutController;
export function getPopoutController() {
if(!controller)
controller = new PopoutController();
return controller;
}
class PopoutController extends EventControllerBase<"popout"> {
private userData: any;
private callbackControllerHello: (accepted: boolean | string) => void;
constructor() {
super(new Registry<string>());
this.ipcRemoteId = Settings.instance.static(Settings.KEY_IPC_REMOTE_ADDRESS, "invalid");
this.ipcChannel = getIPCInstance().createChannel(this.ipcRemoteId, Settings.instance.static(kSettingIPCChannel, "invalid"));
this.ipcChannel.messageHandler = this.handleIPCMessage.bind(this);
}
getEventRegistry() {
return this.localEventRegistry;
}
async initialize() {
this.sendIPCMessage("hello-popout", { version: __build.version });
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.callbackControllerHello = undefined;
reject("controller haven't called back");
}, 5000);
this.callbackControllerHello = result => {
this.callbackControllerHello = undefined;
clearTimeout(timeout);
if(typeof result === "string") {
reject(result);
} else if(!result) {
reject();
} else {
resolve();
}
};
});
}
protected handleTypedIPCMessage<T extends Controller2PopoutMessages>(type: T, payload: PopoutIPCMessage[T]) {
super.handleTypedIPCMessage(type, payload);
switch (type) {
case "hello-controller": {
const tpayload = payload as PopoutIPCMessage["hello-controller"];
console.log("Received Hello World from controller. Window instance accpected: %o", tpayload.accepted);
if(!this.callbackControllerHello)
return;
this.userData = tpayload.userData;
this.callbackControllerHello(tpayload.accepted ? true : tpayload.message || false);
break;
}
case "fire-event-callback":
case "fire-event":
/* handled by out base class */
break;
default:
console.warn("Received unknown message type from controller: %s", type);
return;
}
}
getUserData() {
return this.userData;
}
}

View File

@ -0,0 +1,81 @@
import * as log from "tc-shared/log";
import {LogCategory} from "tc-shared/log";
import * as loader from "tc-loader";
import * as ipc from "../../../ipc/BrowserIPC";
import * as i18n from "../../../i18n/localize";
import "tc-shared/file/RemoteAvatars";
import "tc-shared/proto";
import {Stage} from "tc-loader";
import {AbstractModal} from "tc-shared/ui/react-elements/Modal";
import {Settings, SettingsKey} from "tc-shared/settings";
import {getPopoutController} from "./PopoutController";
import {findPopoutHandler} from "tc-shared/ui/react-elements/external-modal/PopoutRegistry";
import {bodyRenderer, titleRenderer} from "tc-shared/ui/react-elements/external-modal/PopoutRenderer";
import {Registry} from "tc-shared/events";
log.info(LogCategory.GENERAL, "Hello World");
console.error("External modal said hello!");
let modalInstance: AbstractModal;
let modalClass: new <T>(events: Registry<T>, userData: any) => AbstractModal;
const kSettingModalTarget: SettingsKey<string> = {
key: "modal-target",
valueType: "string"
};
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
name: "setup",
priority: 110,
function: async () => {
await i18n.initialize();
ipc.setup();
}
});
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
name: "main app connect",
priority: 100,
function: async () => {
const ppController = getPopoutController();
await ppController.initialize();
}
});
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
name: "modal class loader",
priority: 10,
function: async () => {
const modalTarget = Settings.instance.static(kSettingModalTarget, "unknown");
console.error("Loading modal class %s", modalTarget);
try {
const handler = findPopoutHandler(modalTarget);
if(!handler) {
loader.critical_error("Missing popout handler", "Handler " + modalTarget + " is missing.");
throw "missing handler";
}
modalClass = await handler.loadClass();
} catch(error) {
loader.critical_error("Failed to load modal", "Lookup the console for more detail");
console.error("Failed to load modal %s: %o", modalTarget, error);
}
}
});
loader.register_task(Stage.LOADED, {
name: "main app connect",
priority: 100,
function: async () => {
try {
modalInstance = new modalClass(getPopoutController().getEventRegistry(), getPopoutController().getUserData());
titleRenderer.setInstance(modalInstance);
bodyRenderer.setInstance(modalInstance);
} catch(error) {
loader.critical_error("Failed to invoker modal", "Lookup the console for more detail");
console.error("Failed to load modal: %o", error);
}
}
});

View File

@ -0,0 +1,33 @@
import {AbstractModal} from "tc-shared/ui/react-elements/Modal";
export interface PopoutHandler {
name: string;
loadClass: <T extends AbstractModal>() => Promise<any>;
}
const registeredHandler: {[key: string]: PopoutHandler} = {};
export function findPopoutHandler(name: string) {
return registeredHandler[name];
}
function registerHandler(handler: PopoutHandler) {
registeredHandler[handler.name] = handler;
}
registerHandler({
name: "video-viewer",
loadClass: async () => await import("tc-shared/video-viewer/Renderer")
});
registerHandler({
name: "conversation",
loadClass: async () => await import("tc-shared/ui/frames/side/PopoutConversationUI")
});
registerHandler({
name: "css-editor",
loadClass: async () => await import("tc-shared/ui/modal/css-editor/Renderer")
});

View File

@ -0,0 +1,31 @@
/* FIXME: Remove this wired import */
:global {
@import "../../../../css/static/general";
@import "../../../../css/static/modal";
}
html, body {
margin: 0;
padding: 0;
height: 100vh;
width: 100vw;
}
.container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
justify-content: stretch;
max-height: 100vh;
max-width: 100vw;
overflow: hidden;
}

View File

@ -0,0 +1,51 @@
import * as React from "react";
import * as ReactDOM from "react-dom";
import {AbstractModal} from "tc-shared/ui/react-elements/Modal";
const cssStyle = require("./PopoutRenderer.scss");
class TitleRenderer {
private readonly htmlContainer: HTMLElement;
private modalInstance: AbstractModal;
constructor() {
const titleElements = document.getElementsByTagName("title");
if(titleElements.length === 0) {
this.htmlContainer = document.createElement("title");
document.head.appendChild(this.htmlContainer);
} else {
this.htmlContainer = titleElements[0];
}
}
setInstance(instance: AbstractModal) {
if(this.modalInstance)
ReactDOM.unmountComponentAtNode(this.htmlContainer);
this.modalInstance = instance;
if(this.modalInstance)
ReactDOM.render(<>{this.modalInstance.title()}</>, this.htmlContainer);
}
}
export const titleRenderer = new TitleRenderer();
class BodyRenderer {
private readonly htmlContainer: HTMLElement;
private modalInstance: AbstractModal;
constructor() {
this.htmlContainer = document.createElement("div");
this.htmlContainer.classList.add(cssStyle.container);
document.body.appendChild(this.htmlContainer);
}
setInstance(instance: AbstractModal) {
if(this.modalInstance)
ReactDOM.unmountComponentAtNode(this.htmlContainer);
this.modalInstance = instance;
if(this.modalInstance)
ReactDOM.render(<>{this.modalInstance.renderBody()}</>, this.htmlContainer);
}
}
export const bodyRenderer = new BodyRenderer();

View File

@ -0,0 +1,7 @@
import {Registry} from "tc-shared/events";
import {ExternalModalController} from "tc-shared/ui/react-elements/external-modal/Controller";
export function spawnExternalModal<EventClass>(modal: string, events: Registry<EventClass>, userData: any) : ExternalModalController {
return new ExternalModalController(modal, events as any, userData);
}

View File

@ -228,7 +228,7 @@ export class ChannelTree {
constructor(client) {
this.events = new Registry<ChannelTreeEvents>();
this.events.enable_debug("channel-tree");
this.events.enableDebug("channel-tree");
this.client = client;
this.view = React.createRef();

View File

@ -0,0 +1,36 @@
import {spawnExternalModal} from "tc-shared/ui/react-elements/external-modal";
import {Registry} from "tc-shared/events";
import {VideoViewerEvents} from "./Definitions";
import {Modal, spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import * as React from "react";
import {useState} from "react";
const NumberRenderer = (props: { events: Registry<VideoViewerEvents> }) => {
const [ value, setValue ] = useState("unset");
props.events.reactUse("notify_value", event => setValue(event.value + ""));
return <>{value}</>;
};
export function spawnVideoPopout() {
const registry = new Registry<VideoViewerEvents>();
const modalController = spawnExternalModal("video-viewer", registry, {});
modalController.open().then(() => {
const url = URL.createObjectURL(new Blob(["Hello World"], { type: "plain/text" }));
registry.fire("notify_data_url", { url: url });
});
spawnReactModal(class extends Modal {
constructor() {
super();
}
title() {
return "Hello World";
}
renderBody() {
return <h1>Hello World: <NumberRenderer events={registry} /></h1>;
}
}).show();
}

View File

@ -0,0 +1,6 @@
export interface VideoViewerEvents {
"notify_show": {},
"notify_value": { value: number },
"notify_data_url": { url: string }
}

View File

@ -0,0 +1,39 @@
import {AbstractModal} from "tc-shared/ui/react-elements/Modal";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import * as React from "react";
import {Registry} from "tc-shared/events";
import {VideoViewerEvents} from "./Definitions";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
import {Slider} from "tc-shared/ui/react-elements/Slider";
class ModalVideoPopout extends AbstractModal {
readonly events: Registry<VideoViewerEvents>;
constructor(registry: Registry<VideoViewerEvents>, userData: any) {
super();
this.events = registry;
this.events.on("notify_show", () => {
console.log("Showed!");
});
this.events.on("notify_data_url", async event => {
console.log(event.url);
console.log(await (await fetch(event.url)).text());
});
}
title(): string | React.ReactElement<Translatable> {
return <>Hello World <LoadingDots textOnly={true} /></>;
}
renderBody(): React.ReactElement {
return <div style={{ padding: "10em" }}>
<Slider value={100} minValue={0} maxValue={100} stepSize={1} onInput={value => this.events.fire("notify_value", { value: value })} />
</div>;
}
}
export = ModalVideoPopout;
console.error("Hello World from video popout");

View File

@ -4,6 +4,7 @@ import trtransformer from "./tools/trgen/ts_transformer";
import {exec} from "child_process";
import * as util from "util";
import LoaderIndexGenerator = require("./loader/IndexGenerator");
import {Configuration} from "webpack";
const path = require('path');
const webpack = require("webpack");
@ -56,7 +57,9 @@ const isLoaderFile = (file: string) => {
export const config = async (target: "web" | "client") => { return {
entry: {
"loader": "./loader/app/index.ts"
"loader": "./loader/app/index.ts",
"modal-external": "./shared/js/ui/react-elements/external-modal/PopoutEntrypoint.ts",
"devel-main": "./shared/js/devel_main.ts"
},
devtool: isDevelopment ? "inline-source-map" : undefined,
@ -143,7 +146,8 @@ export const config = async (target: "web" | "client") => { return {
target_file: path.join(__dirname, "dist", "translations.json")
})]
};
}
},
transpileOnly: isDevelopment
}
},
{