Added the basics for popoutable windows and a CSS style editor
parent
6332b0fc0e
commit
e377a77e32
|
@ -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
|
||||
|
|
|
@ -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,11 +93,15 @@ export function abort() {
|
|||
}
|
||||
|
||||
export function finalize() {
|
||||
if(getUrlParameter("loader-abort") === "1") {
|
||||
abort();
|
||||
} else {
|
||||
finalizing = true;
|
||||
|
||||
if(loaderStageContainer)
|
||||
loaderStageContainer.innerText = "app loaded successfully (" + (Date.now() - initializeTimestamp) + "ms)";
|
||||
}
|
||||
}
|
||||
|
||||
const StageNames = {};
|
||||
export function updateState(state: Stage, tasks: string[]) {
|
||||
|
|
|
@ -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 {};
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
}
|
|
@ -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,7 +245,8 @@ loader.register_task(loader.Stage.SETUP, {
|
|||
priority: 100
|
||||
});
|
||||
|
||||
export function run() {
|
||||
export default class implements ApplicationLoader {
|
||||
execute() {
|
||||
/* TeaClient */
|
||||
if(node_require) {
|
||||
if(__build.target !== "client") {
|
||||
|
@ -315,8 +275,6 @@ export function run() {
|
|||
window.native_client = false;
|
||||
}
|
||||
|
||||
if(!loader.running()) {
|
||||
/* we know that we want to load the app */
|
||||
loader.execute_managed();
|
||||
}
|
||||
}
|
|
@ -1,8 +1,9 @@
|
|||
import "./shared";
|
||||
import * as loader from "../loader/loader";
|
||||
import {Stage} from "../loader/loader";
|
||||
import {ApplicationLoader, Stage} from "../loader/loader";
|
||||
|
||||
export function run() {
|
||||
export default class implements ApplicationLoader {
|
||||
execute() {
|
||||
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
|
||||
name: "doing nothing",
|
||||
priority: 1,
|
||||
|
@ -25,3 +26,4 @@ export function run() {
|
|||
|
||||
loader.execute_managed();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
|
@ -1,6 +1,13 @@
|
|||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
*, :before, :after {
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@import "loader";
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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";
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
});
|
|
@ -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);
|
||||
*/
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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 }
|
||||
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 ClientAvatar {
|
||||
readonly events: Registry<AvatarEvents>;
|
||||
readonly clientAvatarId: string; /* the base64 unique id thing from a-m */
|
||||
|
||||
currentAvatarHash: string | "unknown"; /* the client avatars flag */
|
||||
state: AvatarState = "loading";
|
||||
|
||||
/* only set when state is unset, loaded or errored */
|
||||
avatarUrl?: string;
|
||||
loadError?: string;
|
||||
|
||||
loadingTimestamp: number = 0;
|
||||
|
||||
constructor(client_avatar_id: string) {
|
||||
this.clientAvatarId = client_avatar_id;
|
||||
this.events = new Registry<AvatarEvents>();
|
||||
}
|
||||
|
||||
setState(state: AvatarState) {
|
||||
if(state === this.state)
|
||||
return;
|
||||
|
||||
const oldState = this.state;
|
||||
this.state = state;
|
||||
this.events.fire("avatar_state_changed", { newState: state, oldState: oldState });
|
||||
}
|
||||
|
||||
async awaitLoaded() {
|
||||
if(this.state !== "loading")
|
||||
return;
|
||||
|
||||
await new Promise(resolve => this.events.on("avatar_state_changed", event => event.newState !== "loading" && resolve()));
|
||||
}
|
||||
|
||||
destroyUrl() {
|
||||
URL.revokeObjectURL(this.avatarUrl);
|
||||
this.avatarUrl = DefaultAvatarImage;
|
||||
}
|
||||
}
|
||||
|
||||
export class AvatarManager {
|
||||
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));
|
||||
avatar.setLoaded({ url: URL.createObjectURL(blob.slice(0, blob.size, "image/" + media)) });
|
||||
} else {
|
||||
avatar.avatarUrl = URL.createObjectURL(blob);
|
||||
avatar.setLoaded({ url: URL.createObjectURL(blob) });
|
||||
}
|
||||
|
||||
avatar.setState("loaded");
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
return result;
|
||||
} catch (e) { //invalid base 64 (like music bot etc)
|
||||
return undefined;
|
||||
|
||||
private handleHandlerCreated(handler: ConnectionHandler) {
|
||||
this.ipcChannel.sendMessage("notify-handler-created", { handler: handler.handlerId });
|
||||
}
|
||||
|
||||
private handleHandlerDestroyed(handler: ConnectionHandler) {
|
||||
this.ipcChannel.sendMessage("notify-handler-destroyed", { handler: handler.handlerId });
|
||||
const subscriptions = this.subscribedAvatars[handler.handlerId] || [];
|
||||
delete this.subscribedAvatars[handler.handlerId];
|
||||
|
||||
subscriptions.forEach(e => e.unregisterCallback());
|
||||
}
|
||||
|
||||
private handleIpcMessage(remoteId: string, broadcast: boolean, message: ChannelMessage) {
|
||||
if(broadcast)
|
||||
return;
|
||||
|
||||
if(message.type === "query-handlers") {
|
||||
this.ipcChannel.sendMessage("notify-handlers", {
|
||||
handlers: server_connections.all_connections().map(e => e.handlerId)
|
||||
}, remoteId);
|
||||
return;
|
||||
} else if(message.type === "load-avatar") {
|
||||
const sendResponse = properties => {
|
||||
this.ipcChannel.sendMessage("load-avatar-result", {
|
||||
avatarId: message.data.avatarId,
|
||||
handlerId: message.data.handlerId,
|
||||
...properties
|
||||
}, remoteId);
|
||||
};
|
||||
|
||||
const avatarId = message.data.avatarId;
|
||||
const handlerId = message.data.handlerId;
|
||||
const manager = this.getManager(handlerId);
|
||||
if(!manager) {
|
||||
sendResponse({ success: false, message: tr("Invalid handler") });
|
||||
return;
|
||||
}
|
||||
|
||||
let avatar: ClientAvatar;
|
||||
if(message.data.keyType === "client") {
|
||||
avatar = manager.resolveClientAvatar({
|
||||
id: message.data.clientId,
|
||||
clientUniqueId: message.data.clientUniqueId,
|
||||
database_id: message.data.clientDatabaseId
|
||||
});
|
||||
} else {
|
||||
avatar = manager.resolveAvatar(message.data.clientAvatarId, message.data.avatarVersion);
|
||||
}
|
||||
|
||||
const subscribedAvatars = this.subscribedAvatars[handlerId] || (this.subscribedAvatars[handlerId] = []);
|
||||
const oldSubscribedAvatarIndex = subscribedAvatars.findIndex(e => e.remoteAvatarId === avatarId);
|
||||
if(oldSubscribedAvatarIndex !== -1) {
|
||||
const [ subscription ] = subscribedAvatars.splice(oldSubscribedAvatarIndex, 1);
|
||||
subscription.unregisterCallback();
|
||||
}
|
||||
subscribedAvatars.push({
|
||||
avatar: avatar,
|
||||
remoteAvatarId: avatarId,
|
||||
unregisterCallback: avatar.events.onAll(event => {
|
||||
this.ipcChannel.sendMessage("avatar-event", { handlerId: handlerId, avatarId: avatarId, event: event }, remoteId);
|
||||
})
|
||||
});
|
||||
|
||||
sendResponse({ success: true, state: avatar.getState(), stateData: avatar.getStateData(), hash: avatar.getAvatarHash() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loader.register_task(Stage.LOADED, {
|
||||
name: "Avatar init",
|
||||
function: async () => {
|
||||
setGlobalAvatarManagerFactory(new LocalAvatarManagerFactory());
|
||||
},
|
||||
priority: 5
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
});
|
|
@ -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";
|
||||
}
|
|
@ -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;
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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."));
|
||||
|
||||
|
|
|
@ -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()
|
||||
})
|
|
@ -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;
|
||||
}
|
|
@ -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");
|
||||
|
|
|
@ -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";
|
|
@ -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();
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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...")} />
|
||||
|
|
|
@ -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,
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,17 +45,19 @@ 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,
|
||||
|
@ -64,6 +65,10 @@ const ChatEventMessageRenderer = React.memo((props: {
|
|||
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 }) }
|
||||
|
|
|
@ -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;
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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>
|
||||
);
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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 });
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
});
|
|
@ -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 }
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
<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;
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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" }}>
|
||||
|
|
|
@ -5,7 +5,7 @@ html:root {
|
|||
--checkbox-checkmark: #46c0ec;
|
||||
|
||||
--checkbox-background: #272626;
|
||||
--checkbox-disabled-background: #46c0ec;
|
||||
--checkbox-disabled-background: #1a1a1e;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
@ -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,6 +73,7 @@ html:root {
|
|||
background-image: unset!important;
|
||||
}
|
||||
|
||||
&.editable {
|
||||
&:focus, &:focus-within {
|
||||
background-color: var(--boxed-input-field-focus-background);
|
||||
border-color: var(--boxed-input-field-focus-border);
|
||||
|
@ -86,6 +87,7 @@ html:root {
|
|||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input, select, .inputBox {
|
||||
flex-grow: 1;
|
||||
|
@ -97,7 +99,7 @@ html:root {
|
|||
outline: none;
|
||||
margin: 0;
|
||||
|
||||
//color: #b3b3b3;
|
||||
color: inherit;
|
||||
min-width: 2em;
|
||||
|
||||
&.editable {
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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>;
|
||||
};
|
|
@ -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 }> {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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")
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
|
@ -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);
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
export interface VideoViewerEvents {
|
||||
"notify_show": {},
|
||||
|
||||
"notify_value": { value: number },
|
||||
"notify_data_url": { url: string }
|
||||
}
|
|
@ -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");
|
|
@ -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
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
Loading…
Reference in New Issue