From 7b261d6f15a98f40a1e6a8a16a12c8da58098950 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 18 Apr 2020 19:37:30 +0200 Subject: [PATCH 01/22] Using React for the client channel and a lot of performance updates related to the channel tree --- ChangeLog.md | 16 +- file.ts | 4 +- loader/app/index.ts | 4 +- package-lock.json | 15 + package.json | 4 +- shared/css/static/context_menu.scss | 5 +- shared/css/static/frame-chat.scss | 2 + shared/js/ConnectionHandler.ts | 1 - shared/js/{FileManager.ts => FileManager.tsx} | 491 +++++--- shared/js/bookmarks.ts | 1 + shared/js/connection/CommandHandler.ts | 77 +- .../connection/ServerConnectionDeclaration.ts | 1 + shared/js/events.ts | 82 +- shared/js/permission/GroupManager.ts | 92 +- shared/js/permission/PermissionManager.ts | 3 +- shared/js/proto.ts | 2 +- shared/js/settings.ts | 3 +- shared/js/stats.ts | 17 + shared/js/ui/TreeEntry.ts | 39 + shared/js/ui/channel.ts | 605 +++------- shared/js/ui/client.ts | 436 ++----- shared/js/ui/frames/MenuBar.ts | 9 +- shared/js/ui/frames/control-bar/button.tsx | 8 +- shared/js/ui/frames/control-bar/dropdown.tsx | 7 +- shared/js/ui/frames/control-bar/index.tsx | 40 +- shared/js/ui/frames/side/conversations.ts | 11 +- shared/js/ui/frames/side/music_info.ts | 10 +- .../ui/frames/side/private_conversations.ts | 2 +- shared/js/ui/modal/ModalBookmarks.ts | 4 +- shared/js/ui/modal/ModalConnect.ts | 8 +- .../modal/permission/HTMLPermissionEditor.ts | 4 +- .../modal/permission/ModalPermissionEdit.ts | 10 +- shared/js/ui/modal/settings/Keymap.tsx | 28 +- shared/js/ui/react-elements/Button.tsx | 2 +- shared/js/ui/react-elements/Icon.tsx | 58 +- .../ui/react-elements/ReactComponentBase.ts | 133 ++- shared/js/ui/server.ts | 152 ++- shared/js/ui/tree/Channel.scss | 99 ++ shared/js/ui/tree/Channel.tsx | 286 +++++ shared/js/ui/tree/Client.scss | 104 ++ shared/js/ui/tree/Client.tsx | 432 +++++++ shared/js/ui/tree/Server.scss | 34 + shared/js/ui/tree/Server.tsx | 126 ++ shared/js/ui/tree/TreeEntry.tsx | 26 + shared/js/ui/tree/View.scss | 116 ++ shared/js/ui/tree/View.tsx | 231 ++++ shared/js/ui/view.ts | 940 --------------- shared/js/ui/view.tsx | 1047 +++++++++++++++++ tools/dtsgen/declare_fixup.ts | 4 +- web/js/codec/CodecWrapperWorker.ts | 11 +- web/js/workers/codec/CodecWorker.ts | 6 + webpack.config.ts | 11 +- webpack/DevelBlocks.ts | 24 + 53 files changed, 3698 insertions(+), 2185 deletions(-) rename shared/js/{FileManager.ts => FileManager.tsx} (74%) create mode 100644 shared/js/ui/TreeEntry.ts create mode 100644 shared/js/ui/tree/Channel.scss create mode 100644 shared/js/ui/tree/Channel.tsx create mode 100644 shared/js/ui/tree/Client.scss create mode 100644 shared/js/ui/tree/Client.tsx create mode 100644 shared/js/ui/tree/Server.scss create mode 100644 shared/js/ui/tree/Server.tsx create mode 100644 shared/js/ui/tree/TreeEntry.tsx create mode 100644 shared/js/ui/tree/View.scss create mode 100644 shared/js/ui/tree/View.tsx delete mode 100644 shared/js/ui/view.ts create mode 100644 shared/js/ui/view.tsx create mode 100644 webpack/DevelBlocks.ts diff --git a/ChangeLog.md b/ChangeLog.md index 7756d564..ea0ffa45 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,12 +1,22 @@ # Changelog: -* **11.03.20** +* **18.04.20** + - Recoded the channel tree using React + - Heavily improved channel tree performance on large servers (fluent scroll & updates) + - Automatically scroll to channel tree selection + - Fixed client speak indicator + - Fixed the message unread indicator only shows up after the second message (as well increase visibility) + - Fixed the invalid initialisation of codec workers + - Improved context menu subcontainer selection + - Fixed client channel permission tab within the permission editor (previously you've been kick from the server) + +* **11.04.20** - Only show the host message when its not empty -* **10.03.20** +* **10.04.20** - Improved key code displaying - Added a keymap system (Hotkeys) -* **09.03.20** +* **09.04.20** - Using React for the client control bar - Saving last away state and message - Saving last query show state diff --git a/file.ts b/file.ts index 58ec51ec..180761df 100644 --- a/file.ts +++ b/file.ts @@ -676,7 +676,7 @@ namespace server { handle_api_request(request, response, url); return; } else if(url.pathname === "/") { - url.pathname = "/index.php"; + url.pathname = "/index.html"; } serve_file(url.pathname, url.query, response); } @@ -688,7 +688,7 @@ namespace watcher { return cp.spawn(process.env.comspec, ["/C", cmd, ...args], { stdio: "pipe", cwd: __dirname, - env: process.env + env: Object.assign({ NODE_ENV: "development" }, process.env) }); else return cp.spawn(cmd, args, { diff --git a/loader/app/index.ts b/loader/app/index.ts index cfcb8bb5..0f9091a9 100644 --- a/loader/app/index.ts +++ b/loader/app/index.ts @@ -4,4 +4,6 @@ window["loader"] = loader_base; /* let the loader register himself at the window first */ setTimeout(loader.run, 0); -export {}; \ No newline at end of file +export {}; + +//window.__REACT_DEVTOOLS_GLOBAL_HOOK__.inject = function () {}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6118d580..61eb4d30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -242,6 +242,16 @@ "@types/sizzle": "*" } }, + "@types/loader-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/loader-utils/-/loader-utils-1.1.3.tgz", + "integrity": "sha512-euKGFr2oCB3ASBwG39CYJMR3N9T0nanVqXdiH7Zu/Nqddt6SmFRxytq/i2w9LQYNQekEtGBz+pE3qG6fQTNvRg==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/webpack": "*" + } + }, "@types/lodash": { "version": "4.14.149", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.149.tgz", @@ -8421,6 +8431,11 @@ "integrity": "sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=", "dev": true }, + "resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" + }, "resolve": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.15.1.tgz", diff --git a/package.json b/package.json index 582c7fc7..5cb9224f 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,10 @@ "csso": "csso", "tsc": "tsc", "start": "npm run compile-project-base && node file.js ndevelop", - "build-web": "webpack --config webpack-web.config.js", "develop-web": "npm run compile-project-base && node file.js develop web", "build-client": "webpack --config webpack-client.config.js", "develop-client": "npm run compile-project-base && node file.js develop client", - "webpack-web": "webpack --config webpack-web.config.js", "webpack-client": "webpack --config webpack-client.config.js", "generate-i18n-gtranslate": "node shared/generate_i18n_gtranslate.js" @@ -33,6 +31,7 @@ "@types/fs-extra": "^8.0.1", "@types/html-minifier": "^3.5.3", "@types/jquery": "^3.3.34", + "@types/loader-utils": "^1.1.3", "@types/lodash": "^4.14.149", "@types/moment": "^2.13.0", "@types/node": "^12.7.2", @@ -84,6 +83,7 @@ "moment": "^2.24.0", "react": "^16.13.1", "react-dom": "^16.13.1", + "resize-observer-polyfill": "^1.5.1", "webrtc-adapter": "^7.5.1" } } diff --git a/shared/css/static/context_menu.scss b/shared/css/static/context_menu.scss index 09a90458..7101d343 100644 --- a/shared/css/static/context_menu.scss +++ b/shared/css/static/context_menu.scss @@ -70,7 +70,8 @@ } .sub-container { - padding-right: 3px; + margin-right: -3px; + padding-right: 24px; position: relative; &:hover { @@ -85,7 +86,7 @@ left: 100%; top: -4px; position: absolute; - margin-left: 3px; + margin-left: 0; } } } diff --git a/shared/css/static/frame-chat.scss b/shared/css/static/frame-chat.scss index 4079787c..7c0bc66d 100644 --- a/shared/css/static/frame-chat.scss +++ b/shared/css/static/frame-chat.scss @@ -1155,6 +1155,8 @@ $bot_thumbnail_height: 9em; overflow-x: hidden; overflow-y: auto; + @include chat-scrollbar-vertical(); + display: flex; flex-direction: row; justify-content: stretch; diff --git a/shared/js/ConnectionHandler.ts b/shared/js/ConnectionHandler.ts index e75e2b87..d4a2666a 100644 --- a/shared/js/ConnectionHandler.ts +++ b/shared/js/ConnectionHandler.ts @@ -807,7 +807,6 @@ export class ConnectionHandler { } resize_elements() { - this.channelTree.handle_resized(); this.invoke_resized_on_activate = false; } diff --git a/shared/js/FileManager.ts b/shared/js/FileManager.tsx similarity index 74% rename from shared/js/FileManager.ts rename to shared/js/FileManager.tsx index 3c9897d7..6dada06d 100644 --- a/shared/js/FileManager.ts +++ b/shared/js/FileManager.tsx @@ -1,12 +1,14 @@ import * as log from "tc-shared/log"; -import * as hex from "tc-shared/crypto/hex"; import {LogCategory} from "tc-shared/log"; +import * as hex from "tc-shared/crypto/hex"; import {ChannelEntry} from "tc-shared/ui/channel"; import {ConnectionHandler} from "tc-shared/ConnectionHandler"; import {ServerCommand} from "tc-shared/connection/ConnectionBase"; -import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration"; +import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration"; import {ClientEntry} from "tc-shared/ui/client"; import {AbstractCommandHandler} from "tc-shared/connection/AbstractCommandHandler"; +import {Registry} from "tc-shared/events"; +import {format_time} from "tc-shared/ui/frames/chat"; export class FileEntry { name: string; @@ -130,7 +132,7 @@ export class RequestFileUpload implements UploadTransfer { throw "invalid size"; form_data.append("file", new Blob([data], { type: "application/octet-stream" })); } else { - const buffer = data; + const buffer = data as BufferSource; if(buffer.byteLength != this.transfer_key.total_size) throw "invalid size"; @@ -416,11 +418,6 @@ export class FileManager extends AbstractCommandHandler { } } -export class Icon { - id: number; - url: string; -} - export enum ImageType { UNKNOWN, BITMAP, @@ -552,34 +549,228 @@ export class CacheManager { } } -export class IconManager { - private static cache: CacheManager = new CacheManager("icons"); +const icon_cache: CacheManager = new CacheManager("icons"); +export interface IconManagerEvents { + notify_icon_state_changed: { + icon_id: number, + server_unique_id: string, + icon: LocalIcon + }, +} + +//TODO: Invalidate icon after certain time if loading has failed and try to redownload (only if an icon loader has been set!) +type IconLoader = (icon?: LocalIcon) => Promise; +export class LocalIcon { + readonly icon_id: number; + readonly server_unique_id: string; + readonly status_change_callbacks: ((icon?: LocalIcon) => void)[] = []; + + status: "loading" | "loaded" | "empty" | "error" | "destroyed"; + + loaded_url?: string; + error_message?: string; + + private callback_icon_loader: IconLoader; + + constructor(id: number, server: string, loader_or_response: Response | IconLoader | undefined) { + this.icon_id = id; + this.server_unique_id = server; + + if(id >= 0 && id <= 1000) { + /* Internal TeaSpeak icons. These must be handled differently! */ + this.status = "loaded"; + } else { + this.status = "loading"; + if(loader_or_response instanceof Response) { + this.set_image(loader_or_response).catch(error => { + log.error(LogCategory.GENERAL, tr("Icon set image method threw an unexpected error: %o"), error); + this.status = "error"; + this.error_message = "unexpected parse error"; + this.triggerStatusChange(); + }); + } else { + this.callback_icon_loader = loader_or_response; + this.load().catch(error => { + log.error(LogCategory.GENERAL, tr("Icon load method threw an unexpected error: %o"), error); + this.status = "error"; + this.error_message = "unexpected load error"; + this.triggerStatusChange(); + }).then(() => { + this.callback_icon_loader = undefined; /* release resources captured by possible closures */ + }); + } + } + } + + private triggerStatusChange() { + for(const lister of this.status_change_callbacks.slice(0)) + lister(this); + } + + /* called within the CachedIconManager */ + protected destroy() { + if(typeof this.loaded_url === "string" && URL.revokeObjectURL) + URL.revokeObjectURL(this.loaded_url); + + this.status = "destroyed"; + this.loaded_url = undefined; + this.error_message = undefined; + + this.triggerStatusChange(); + this.status_change_callbacks.splice(0, this.status_change_callbacks.length); + } + + private async load() { + if(!icon_cache.setupped()) + await icon_cache.setup(); + + let response = await icon_cache.resolve_cached("icon_" + this.server_unique_id + "_" + this.icon_id); //TODO age! + if(!response) { + if(typeof this.callback_icon_loader !== "function") { + this.status = "empty"; + this.triggerStatusChange(); + return; + } + + try { + response = await this.callback_icon_loader(this); + } catch (error) { + log.warn(LogCategory.GENERAL, tr("Failed to download icon %d: %o"), this.icon_id, error); + await this.set_error(typeof error === "string" ? error : tr("Failed to load icon")); + return; + } + try { + await this.set_image(response); + } catch (error) { + log.error(LogCategory.GENERAL, tr("Failed to update icon image for icon %d: %o"), this.icon_id, error); + await this.set_error(typeof error === "string" ? error : tr("Failed to update icon from downloaded file")); + return; + } + return; + } + + this.loaded_url = await response_to_url(response); + this.status = "loaded"; + this.triggerStatusChange(); + } + + async set_image(response: Response) { + if(this.icon_id >= 0 && this.icon_id <= 1000) throw "Could not set image for internal icon"; + + const type = image_type(response.headers.get('X-media-bytes')); + if(type === ImageType.UNKNOWN) throw "unknown image type"; + + const media = media_image_type(type); + await icon_cache.put_cache("icon_" + this.server_unique_id + "_" + this.icon_id, response.clone(), "image/" + media); + + this.loaded_url = await response_to_url(response); + this.status = "loaded"; + this.triggerStatusChange(); + } + + set_error(error: string) { + if(this.status === "loaded" || this.status === "destroyed") return; + if(this.status === "error" && this.error_message === error) return; + this.status = "error"; + this.error_message = error; + this.triggerStatusChange(); + } + + async await_loading() { + await new Promise(resolve => { + if(this.status !== "loading") { + resolve(); + return; + } + const callback = () => { + if(this.status === "loading") return; + + this.status_change_callbacks.remove(callback); + resolve(); + }; + this.status_change_callbacks.push(callback); + }) + } +} + +async function response_to_url(response: Response) { + if(!response.headers.has('X-media-bytes')) + throw "missing media bytes"; + + const type = image_type(response.headers.get('X-media-bytes')); + const media = media_image_type(type); + + const blob = await response.blob(); + if(blob.type !== "image/" + media) + return URL.createObjectURL(blob.slice(0, blob.size, "image/" + media)); + else + return URL.createObjectURL(blob) +} + +class CachedIconManager { + private loaded_icons: {[id: string]:LocalIcon} = {}; + + async clear_cache() { + await icon_cache.reset(); + this.clear_memory_cache(); + } + + clear_memory_cache() { + for(const icon_id of Object.keys(this.loaded_icons)) + this.loaded_icons[icon_id]["destroy"](); + this.loaded_icons = {}; + } + + load_icon(id: number, server_unique_id: string, fallback_load?: IconLoader) : LocalIcon { + const cache_id = server_unique_id + "_" + (id >>> 0); + if(this.loaded_icons[cache_id]) return this.loaded_icons[cache_id]; + + return (this.loaded_icons[cache_id] = new LocalIcon(id >>> 0, server_unique_id, fallback_load)); + } + + async put_icon(id: number, server_unique_id: string, icon: Response) { + const cache_id = server_unique_id + "_" + (id >>> 0); + if(this.loaded_icons[cache_id]) + await this.loaded_icons[cache_id].set_image(icon); + else { + const licon = this.loaded_icons[cache_id] = new LocalIcon(id >>> 0, server_unique_id, icon); + await new Promise((resolve, reject) => { + const cb = () => { + licon.status_change_callbacks.remove(cb); + if(licon.status === "loaded") + resolve(); + else + reject(licon.status === "error" ? licon.error_message || tr("Unknown error") : tr("Invalid status")); + }; + + licon.status_change_callbacks.push(cb); + }) + } + } +} +export const icon_cache_loader = new CachedIconManager(); +window.addEventListener("beforeunload", () => { + icon_cache_loader.clear_memory_cache(); +}); + +type IconManagerLoadingData = { + result: "success" | "error" | "unset"; + next_retry?: number; + error?: string; +} +export class IconManager { handle: FileManager; - private _id_urls: {[id:number]:string} = {}; - private _loading_promises: {[id:number]:Promise} = {}; + readonly events: Registry; + private loading_timestamps: {[key: number]: IconManagerLoadingData} = {}; constructor(handle: FileManager) { this.handle = handle; + this.events = new Registry(); } destroy() { - if(URL.revokeObjectURL) { - for(const id of Object.keys(this._id_urls)) - URL.revokeObjectURL(this._id_urls[id]); - } - this._id_urls = undefined; - this._loading_promises = undefined; - } - - async clear_cache() { - await IconManager.cache.reset(); - if(URL.revokeObjectURL) { - for(const id of Object.keys(this._id_urls)) - URL.revokeObjectURL(this._id_urls[id]); - } - this._id_urls = {}; - this._loading_promises = {}; + this.loading_timestamps = {}; } async delete_icon(id: number) : Promise { @@ -599,83 +790,31 @@ export class IconManager { return this.handle.download_file("", "/icon_" + id); } - private static async _response_url(response: Response) { - if(!response.headers.has('X-media-bytes')) - throw "missing media bytes"; - - const type = image_type(response.headers.get('X-media-bytes')); - const media = media_image_type(type); - - const blob = await response.blob(); - if(blob.type !== "image/" + media) - return URL.createObjectURL(blob.slice(0, blob.size, "image/" + media)); - else - return URL.createObjectURL(blob) - } - - async resolved_cached?(id: number) : Promise { - if(this._id_urls[id]) - return { - id: id, - url: this._id_urls[id] - }; - - if(!IconManager.cache.setupped()) - await IconManager.cache.setup(); - - const response = await IconManager.cache.resolve_cached('icon_' + id); //TODO age! - if(response) { - const url = await IconManager._response_url(response); - if(this._id_urls[id]) - URL.revokeObjectURL(this._id_urls[id]); - return { - id: id, - url: url - }; - } - return undefined; - } - - private static _static_id_url: {[icon: number]:string} = {}; - private static _static_cached_promise: {[icon: number]:Promise} = {}; - static load_cached_icon(id: number, ignore_age?: boolean) : Promise | Icon { - if(this._static_id_url[id]) { - return { - id: id, - url: this._static_id_url[id] - }; - } - - if(this._static_cached_promise[id]) - return this._static_cached_promise[id]; - - return (this._static_cached_promise[id] = (async () => { - if(!this.cache.setupped()) - await this.cache.setup(); - - const response = await this.cache.resolve_cached('icon_' + id); //TODO age! - if(response) { - const url = await this._response_url(response); - if(this._static_id_url[id]) - URL.revokeObjectURL(this._static_id_url[id]); - this._static_id_url[id] = url; - - return { - id: id, - url: url - }; + private async server_icon_loader(icon: LocalIcon) : Promise { + const loading_data: IconManagerLoadingData = this.loading_timestamps[icon.icon_id] || (this.loading_timestamps[icon.icon_id] = { result: "unset" }); + if(loading_data.result === "error") { + if(!loading_data.next_retry || loading_data.next_retry > Date.now()) { + log.debug(LogCategory.GENERAL, tr("Don't retry icon download from server. We'll try again in %s"), + !loading_data.next_retry ? tr("never") : format_time(loading_data.next_retry - Date.now(), tr("1 second"))); + throw loading_data.error; } - })()); - } + } - private async _load_icon(id: number) : Promise { try { let download_key: DownloadKey; try { - download_key = await this.create_icon_download(id); + download_key = await this.create_icon_download(icon.icon_id); } catch(error) { - log.error(LogCategory.CLIENT, tr("Could not request download for icon %d: %o"), id, error); - throw "Failed to request icon"; + if(error instanceof CommandResult) { + if(error.id === ErrorID.FILE_NOT_FOUND) + throw tr("Icon could not be found"); + else if(error.id === ErrorID.PERMISSION_ERROR) + throw tr("No permissions to download icon"); + else + throw error.extra_message || error.message; + } + log.error(LogCategory.CLIENT, tr("Could not request download for icon %d: %o"), icon.icon_id, error); + throw typeof error === "string" ? error : tr("Failed to initialize icon download"); } const downloader = spawn_download_transfer(download_key); @@ -683,58 +822,21 @@ export class IconManager { try { response = await downloader.request_file(); } catch(error) { - log.error(LogCategory.CLIENT, tr("Could not download icon %d: %o"), id, error); + log.error(LogCategory.CLIENT, tr("Could not download icon %d: %o"), icon.icon_id, error); throw "failed to download icon"; } - const type = image_type(response.headers.get('X-media-bytes')); - const media = media_image_type(type); - - await IconManager.cache.put_cache('icon_' + id, response.clone(), "image/" + media); - const url = await IconManager._response_url(response.clone()); - if(this._id_urls[id]) - URL.revokeObjectURL(this._id_urls[id]); - this._id_urls[id] = url; - - this._loading_promises[id] = undefined; - return { - id: id, - url: url - }; - } catch(error) { - setTimeout(() => { - this._loading_promises[id] = undefined; - }, 1000 * 60); /* try again in 60 seconds */ + loading_data.result = "success"; + return response; + } catch (error) { + loading_data.result = "error"; + loading_data.error = error as string; + loading_data.next_retry = Date.now() + 300 * 1000; throw error; } } - download_icon(id: number) : Promise { - return this._loading_promises[id] || (this._loading_promises[id] = this._load_icon(id)); - } - - async resolve_icon(id: number) : Promise { - id = id >>> 0; - try { - const result = await this.resolved_cached(id); - if(result) - return result; - throw ""; - } catch(error) { } - - try { - const result = await this.download_icon(id); - if(result) - return result; - throw "load result is empty"; - } catch(error) { - log.error(LogCategory.CLIENT, tr("Icon download failed of icon %d: %o"), id, error); - } - - throw "icon not found"; - } - - static generate_tag(icon: Promise | Icon | undefined, options?: { + static generate_tag(icon: LocalIcon | undefined, options?: { animate?: boolean }) : JQuery { options = options || {}; @@ -743,42 +845,50 @@ export class IconManager { let icon_load_image = $.spawn("div").addClass("icon_loading"); const icon_image = $.spawn("img").attr("width", 16).attr("height", 16).attr("alt", ""); - const _apply = (icon) => { - let id = icon ? (icon.id >>> 0) : 0; - if (!icon || id == 0) { - icon_load_image.remove(); - icon_load_image = undefined; - return; - } else if (id < 1000) { - icon_load_image.remove(); - icon_load_image = undefined; - icon_container.removeClass("icon_empty").addClass("icon_em client-group_" + id); - return; - } - - icon_image.attr("src", icon.url); - icon_container.append(icon_image).removeClass("icon_empty"); - - if (typeof (options.animate) !== "boolean" || options.animate) { - icon_image.css("opacity", 0); - - icon_load_image.animate({opacity: 0}, 50, function () { - icon_load_image.remove(); - icon_image.animate({opacity: 1}, 150); - }); - } else { - icon_load_image.remove(); - icon_load_image = undefined; - } - }; - if(icon instanceof Promise) { - icon.then(_apply).catch(error => { - log.error(LogCategory.CLIENT, tr("Could not load icon. Reason: %s"), error); - icon_load_image.removeClass("icon_loading").addClass("icon client-warning").attr("tag", "Could not load icon"); - }); + if (icon.icon_id == 0) { + icon_load_image = undefined; + } else if (icon.icon_id < 1000) { + icon_load_image = undefined; + icon_container.removeClass("icon_empty").addClass("icon_em client-group_" + icon.icon_id); } else { - _apply(icon as Icon); + const loading_done = sync => {//TODO: Show error? + if(icon.status === "empty") { + icon_load_image.remove(); + icon_load_image = undefined; + } else if(icon.status === "error") { + //TODO: Error icon? + icon_load_image.remove(); + icon_load_image = undefined; + } else { + icon_image.attr("src", icon.loaded_url); + icon_container.append(icon_image).removeClass("icon_empty"); + + if (!sync && (typeof (options.animate) !== "boolean" || options.animate)) { + icon_image.css("opacity", 0); + + icon_load_image.animate({opacity: 0}, 50, function () { + icon_load_image.remove(); + icon_image.animate({opacity: 1}, 150); + }); + } else { + icon_load_image.remove(); + icon_load_image = undefined; + } + } + }; + + if(icon.status !== "loading") + loading_done(true); + else { + const cb = () => { + if(icon.status === "loading") return; + + icon.status_change_callbacks.remove(cb); + loading_done(false); + }; + icon.status_change_callbacks.push(cb); + } } if(icon_load_image) @@ -790,19 +900,20 @@ export class IconManager { animate?: boolean }) : JQuery { options = options || {}; + return IconManager.generate_tag(this.load_icon(id), options); + } - id = id >>> 0; - if(id == 0 || !id) - return IconManager.generate_tag({id: id, url: ""}, options); - else if(id < 1000) - return IconManager.generate_tag({id: id, url: ""}, options); - - - if(this._id_urls[id]) { - return IconManager.generate_tag({id: id, url: this._id_urls[id]}, options); - } else { - return IconManager.generate_tag(this.resolve_icon(id), options); + load_icon(id: number) : LocalIcon { + const server_uid = this.handle.handle.channelTree.server.properties.virtualserver_unique_identifier; + let icon = icon_cache_loader.load_icon(id, server_uid, this.server_icon_loader.bind(this)); + if(icon.status !== "loading" && icon.status !== "loaded") { + this.server_icon_loader(icon).then(response => { + return icon.set_image(response); + }).catch(error => { + console.warn("Failed to update broken cached icon from server: %o", error); + }) } + return icon; } } @@ -818,7 +929,7 @@ export class AvatarManager { private static cache: CacheManager; private _cached_avatars: {[response_avatar_id:number]:Avatar} = {}; - private _loading_promises: {[response_avatar_id:number]:Promise} = {}; + private _loading_promises: {[response_avatar_id:number]:Promise} = {}; constructor(handle: FileManager) { this.handle = handle; diff --git a/shared/js/bookmarks.ts b/shared/js/bookmarks.ts index 9dee583a..8fcf344a 100644 --- a/shared/js/bookmarks.ts +++ b/shared/js/bookmarks.ts @@ -68,6 +68,7 @@ export interface Bookmark { connect_profile: string; last_icon_id?: number; + last_icon_server_id?: string; } export interface DirectoryBookmark { diff --git a/shared/js/connection/CommandHandler.ts b/shared/js/connection/CommandHandler.ts index a05c2924..9c3d384c 100644 --- a/shared/js/connection/CommandHandler.ts +++ b/shared/js/connection/CommandHandler.ts @@ -21,6 +21,7 @@ import {spawnPoke} from "tc-shared/ui/modal/ModalPoke"; import {PrivateConversationState} from "tc-shared/ui/frames/side/private_conversations"; import {Conversation} from "tc-shared/ui/frames/side/conversations"; import {AbstractCommandHandler, AbstractCommandHandlerBoss} from "tc-shared/connection/AbstractCommandHandler"; +import {batch_updates, BatchUpdateType, flush_batched_updates} from "tc-shared/ui/react-elements/ReactComponentBase"; export class ServerConnectionCommandBoss extends AbstractCommandHandlerBoss { constructor(connection: AbstractServerConnection) { @@ -137,7 +138,13 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { handle_command(command: ServerCommand) : boolean { if(this[command.command]) { - this[command.command](command.arguments); + /* batch all updates the command applies to the channel tree */ + batch_updates(BatchUpdateType.CHANNEL_TREE); + try { + this[command.command](command.arguments); + } finally { + flush_batched_updates(BatchUpdateType.CHANNEL_TREE); + } return true; } @@ -297,24 +304,25 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { private createChannelFromJson(json, ignoreOrder: boolean = false) { let tree = this.connection.client.channelTree; - let channel = new ChannelEntry(parseInt(json["cid"]), json["channel_name"], tree.findChannel(json["cpid"])); - tree.insertChannel(channel); + let channel = new ChannelEntry(parseInt(json["cid"]), json["channel_name"]); + let parent, previous; if(json["channel_order"] !== "0") { - let prev = tree.findChannel(json["channel_order"]); - if(!prev && json["channel_order"] != 0) { + previous = tree.findChannel(json["channel_order"]); + if(!previous && json["channel_order"] != 0) { if(!ignoreOrder) { log.error(LogCategory.NETWORKING, tr("Invalid channel order id!")); return; } } - - let parent = tree.findChannel(json["cpid"]); - if(!parent && json["cpid"] != 0) { - log.error(LogCategory.NETWORKING, tr("Invalid channel parent")); - return; - } - tree.moveChannel(channel, prev, parent); //TODO test if channel exists! } + + parent = tree.findChannel(json["cpid"]); + if(!parent && json["cpid"] != 0) { + log.error(LogCategory.NETWORKING, tr("Invalid channel parent")); + return; + } + + tree.insertChannel(channel, previous, parent); if(ignoreOrder) { for(let ch of tree.channels) { if(ch.properties.channel_order == channel.channelId) { @@ -340,16 +348,30 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { channel.updateVariables(...updates); } + private batch_update_finished_timeout; handleCommandChannelList(json) { - this.connection.client.channelTree.hide_channel_tree(); /* dont perform channel inserts on the dom to prevent style recalculations */ - log.debug(LogCategory.NETWORKING, tr("Got %d new channels"), json.length); + if(this.batch_update_finished_timeout) { + clearTimeout(this.batch_update_finished_timeout); + this.batch_update_finished_timeout = 0; + /* batch update is still active */ + } else { + batch_updates(BatchUpdateType.CHANNEL_TREE); + } + for(let index = 0; index < json.length; index++) this.createChannelFromJson(json[index], true); + + this.batch_update_finished_timeout = setTimeout(() => { + }, 500); } handleCommandChannelListFinished(json) { - this.connection.client.channelTree.show_channel_tree(); + if(this.batch_update_finished_timeout) { + clearTimeout(this.batch_update_finished_timeout); + this.batch_update_finished_timeout = 0; + flush_batched_updates(BatchUpdateType.CHANNEL_TREE); + } } handleCommandChannelCreate(json) { @@ -795,7 +817,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { this.connection_handler.sound.play(Sound.MESSAGE_RECEIVED, {default_volume: .5}); const client = this.connection_handler.channelTree.findClient(parseInt(json["invokerid"])); if(client) /* the client itself might be invisible */ - client.flag_text_unread = conversation.is_unread(); + client.setUnread(conversation.is_unread()); } else { this.connection_handler.sound.play(Sound.MESSAGE_SEND, {default_volume: .5}); } @@ -822,7 +844,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { message: json["msg"] }); if(conversation.is_unread() && channel) - channel.flag_text_unread = true; + channel.setUnread(true); } else if(mode == 3) { this.connection_handler.log.log(server_log.Type.GLOBAL_MESSAGE, { message: json["msg"], @@ -844,7 +866,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { timestamp: typeof(json["timestamp"]) === "undefined" ? Date.now() : parseInt(json["timestamp"]), message: json["msg"] }); - this.connection_handler.channelTree.server.flag_text_unread = conversation.is_unread(); + this.connection_handler.channelTree.server.setUnread(conversation.is_unread()); } } @@ -1000,14 +1022,19 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { } handleNotifyChannelSubscribed(json) { - for(const entry of json) { - const channel = this.connection.client.channelTree.findChannel(entry["cid"]); - if(!channel) { - console.warn(tr("Received channel subscribed for not visible channel (cid: %d)"), entry['cid']); - continue; - } + batch_updates(BatchUpdateType.CHANNEL_TREE); + try { + for(const entry of json) { + const channel = this.connection.client.channelTree.findChannel(parseInt(entry["cid"])); + if(!channel) { + console.warn(tr("Received channel subscribed for not visible channel (cid: %d)"), entry['cid']); + continue; + } - channel.flag_subscribed = true; + channel.flag_subscribed = true; + } + } finally { + flush_batched_updates(BatchUpdateType.CHANNEL_TREE); } } diff --git a/shared/js/connection/ServerConnectionDeclaration.ts b/shared/js/connection/ServerConnectionDeclaration.ts index 362e9163..bde4cc03 100644 --- a/shared/js/connection/ServerConnectionDeclaration.ts +++ b/shared/js/connection/ServerConnectionDeclaration.ts @@ -9,6 +9,7 @@ export enum ErrorID { PLAYLIST_IS_IN_USE = 0x2103, FILE_ALREADY_EXISTS = 2050, + FILE_NOT_FOUND = 2051, CLIENT_INVALID_ID = 0x0200, diff --git a/shared/js/events.ts b/shared/js/events.ts index a7abe1b3..e004a400 100644 --- a/shared/js/events.ts +++ b/shared/js/events.ts @@ -1,4 +1,4 @@ -import {MusicClientEntry, SongInfo} from "tc-shared/ui/client"; +import {ClientEvents, MusicClientEntry, SongInfo} from "tc-shared/ui/client"; import {PlaylistSong} from "tc-shared/connection/ServerConnectionDeclaration"; import {guid} from "tc-shared/crypto/uid"; import * as React from "react"; @@ -74,8 +74,8 @@ export class Registry { } } - off(handler: (event?: Event) => void); - off(event: T, handler: (event?: Event) => void); + off(handler: (event?) => void); + off(event: T, handler: (event?: Events[T] & Event) => void); off(event: (keyof Events)[], handler: (event?: Event) => void); off(handler_or_events, handler?) { if(typeof handler_or_events === "function") { @@ -107,36 +107,49 @@ export class Registry { this.connections[event].remove(target as any); } - fire(event_type: T, data?: Events[T]) { + fire(event_type: T, data?: Events[T], overrideTypeKey?: boolean) { if(this.debug_prefix) console.log("[%s] Trigger event: %s", this.debug_prefix, event_type); - if(typeof data === "object" && 'type' in data) throw tr("The keyword 'type' is reserved for the event type and should not be passed as argument"); + if(typeof data === "object" && 'type' in data && !overrideTypeKey) { + if((data as any).type !== event_type) { + debugger; + throw tr("The keyword 'type' is reserved for the event type and should not be passed as argument"); + } + } const event = Object.assign(typeof data === "undefined" ? SingletonEvent.instance : data, { type: event_type, as: function () { return this; } }); + this.fire_event(event_type as string, event); + } + + private fire_event(type: string, data: any) { let invoke_count = 0; - for(const handler of (this.handler[event_type as string] || [])) { - handler(event); + for(const handler of (this.handler[type]?.slice(0) || [])) { + handler(data); invoke_count++; const reg_data = handler[this.registry_uuid]; if(typeof reg_data === "object" && reg_data.singleshot) - this.handler[event_type as string].remove(handler); + this.handler[type].remove(handler); } - for(const evhandler of (this.connections[event_type as string] || [])) { - evhandler.fire(event_type as any, event as any); + for(const evhandler of (this.connections[type]?.slice(0) || [])) { + evhandler.fire_event(type, data); invoke_count++; } if(invoke_count === 0) { - console.warn(tr("Event handler (%s) triggered event %s which has no consumers."), this.debug_prefix, event_type); + console.warn(tr("Event handler (%s) triggered event %s which has no consumers."), this.debug_prefix, type); } } - fire_async(event_type: T, data?: Events[T]) { - setTimeout(() => this.fire(event_type, data)); + fire_async(event_type: T, data?: Events[T], callback?: () => void) { + setTimeout(() => { + this.fire(event_type, data); + if(typeof callback === "function") + callback(); + }); } destroy() { @@ -227,31 +240,6 @@ export function ReactEventHandler, Event } } -export namespace channel_tree { - export interface client { - "enter_view": {}, - "left_view": {}, - - "property_update": { - properties: string[] - }, - - "music_status_update": { - player_buffered_index: number, - player_replay_index: number - }, - "music_song_change": { - "song": SongInfo - }, - - /* TODO: Move this out of the music bots interface? */ - "playlist_song_add": { song: PlaylistSong }, - "playlist_song_remove": { song_id: number }, - "playlist_song_reorder": { song_id: number, previous_song_id: number }, - "playlist_song_loaded": { song_id: number, success: boolean, error_msg?: string, metadata?: string }, - } -} - export namespace sidebar { export interface music { "open": {}, /* triggers when frame should be shown */ @@ -289,13 +277,13 @@ export namespace sidebar { "reorder_begin": { song_id: number; entry: JQuery }, "reorder_end": { song_id: number; canceled: boolean; entry: JQuery; previous_entry?: number }, - "player_time_update": channel_tree.client["music_status_update"], - "player_song_change": channel_tree.client["music_song_change"], + "player_time_update": ClientEvents["music_status_update"], + "player_song_change": ClientEvents["music_song_change"], - "playlist_song_add": channel_tree.client["playlist_song_add"] & { insert_effect?: boolean }, - "playlist_song_remove": channel_tree.client["playlist_song_remove"], - "playlist_song_reorder": channel_tree.client["playlist_song_reorder"], - "playlist_song_loaded": channel_tree.client["playlist_song_loaded"] & { html_entry?: JQuery }, + "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 }, } } @@ -700,9 +688,11 @@ export namespace modal { } //Some test code -const eclient = new Registry(); +/* +const eclient = new Registry(); const emusic = new Registry(); eclient.on("property_update", event => { event.as<"playlist_song_loaded">(); }); eclient.connect("playlist_song_loaded", emusic); -eclient.connect("playlist_song_loaded", emusic); \ No newline at end of file +eclient.connect("playlist_song_loaded", emusic); + */ \ No newline at end of file diff --git a/shared/js/permission/GroupManager.ts b/shared/js/permission/GroupManager.ts index 60004fc0..8475a908 100644 --- a/shared/js/permission/GroupManager.ts +++ b/shared/js/permission/GroupManager.ts @@ -6,6 +6,7 @@ import {ServerCommand} from "tc-shared/connection/ConnectionBase"; import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration"; import {ConnectionHandler} from "tc-shared/ConnectionHandler"; import {AbstractCommandHandler} from "tc-shared/connection/AbstractCommandHandler"; +import {Registry} from "tc-shared/events"; export enum GroupType { QUERY, @@ -31,10 +32,21 @@ export class GroupPermissionRequest { promise: LaterPromise; } -export class Group { - properties: GroupProperties = new GroupProperties(); +export interface GroupEvents { + notify_deleted: {}, + notify_properties_updated: { + updated_properties: {[Key in keyof GroupProperties]: GroupProperties[Key]}; + group_properties: GroupProperties + } +} + +export class Group { readonly handle: GroupManager; + + readonly events: Registry; + readonly properties: GroupProperties = new GroupProperties(); + readonly id: number; readonly target: GroupTarget; readonly type: GroupType; @@ -46,6 +58,8 @@ export class Group { constructor(handle: GroupManager, id: number, target: GroupTarget, type: GroupType, name: string) { + this.events = new Registry(); + this.handle = handle; this.id = id; this.target = target; @@ -53,20 +67,21 @@ export class Group { this.name = name; } - updateProperty(key, value) { - if(!JSON.map_field_to(this.properties, value, key)) - return; /* no updates */ + updateProperties(properties: {key: string, value: string}[]) { + let updates = {}; - if(key == "iconid") { - this.properties.iconid = (new Uint32Array([this.properties.iconid]))[0]; - this.handle.handle.channelTree.clientsByGroup(this).forEach(client => { - client.updateGroupIcon(this); - }); - } else if(key == "sortid") - this.handle.handle.channelTree.clientsByGroup(this).forEach(client => { - client.update_group_icon_order(); - }); + for(const { key, value } of properties) { + if(!JSON.map_field_to(this.properties, value, key)) + continue; /* no updates */ + if(key === "iconid") + this.properties.iconid = this.properties.iconid >>> 0; + updates[key] = this.properties[key]; + } + this.events.fire("notify_properties_updated", { + group_properties: this.properties, + updated_properties: updates as any + }); } } @@ -150,44 +165,45 @@ export class GroupManager extends AbstractCommandHandler { return; } - if(target == GroupTarget.SERVER) - this.serverGroups = []; - else - this.channelGroups = []; + let group_list = target == GroupTarget.SERVER ? this.serverGroups : this.channelGroups; + const deleted_groups = group_list.slice(0); - for(let groupData of json) { + for(const group_data of json) { let type : GroupType; - switch (Number.parseInt(groupData["type"])) { + switch (parseInt(group_data["type"])) { case 0: type = GroupType.TEMPLATE; break; case 1: type = GroupType.NORMAL; break; case 2: type = GroupType.QUERY; break; default: - log.error(LogCategory.CLIENT, tr("Invalid group type: %o for group %s"), groupData["type"],groupData["name"]); + log.error(LogCategory.CLIENT, tr("Invalid group type: %o for group %s"), group_data["type"],group_data["name"]); continue; } - let group = new Group(this,parseInt(target == GroupTarget.SERVER ? groupData["sgid"] : groupData["cgid"]), target, type, groupData["name"]); - for(let key of Object.keys(groupData)) { - if(key == "sgid") continue; - if(key == "cgid") continue; - if(key == "type") continue; - if(key == "name") continue; + const group_id = parseInt(target == GroupTarget.SERVER ? group_data["sgid"] : group_data["cgid"]); + let group_index = deleted_groups.findIndex(e => e.id === group_id); + let group: Group; + if(group_index === -1) { + group = new Group(this, group_id, target, type, group_data["name"]); + group_list.push(group); + } else + group = deleted_groups.splice(group_index, 1)[0]; - group.updateProperty(key, groupData[key]); - } + const property_blacklist = [ + "sgid", "cgid", "type", "name", - group.requiredMemberRemovePower = parseInt(groupData["n_member_removep"]); - group.requiredMemberAddPower = parseInt(groupData["n_member_addp"]); - group.requiredModifyPower = parseInt(groupData["n_modifyp"]); + "n_member_removep", "n_member_addp", "n_modifyp" + ]; + group.updateProperties(Object.keys(group_data).filter(e => property_blacklist.findIndex(a => a === e) === -1).map(e => { return { key: e, value: group_data[e] } })); - if(target == GroupTarget.SERVER) - this.serverGroups.push(group); - else - this.channelGroups.push(group); + group.requiredMemberRemovePower = parseInt(group_data["n_member_removep"]); + group.requiredMemberAddPower = parseInt(group_data["n_member_addp"]); + group.requiredModifyPower = parseInt(group_data["n_modifyp"]); } - for(const client of this.handle.channelTree.clients) - client.update_displayed_client_groups(); + for(const deleted of deleted_groups) { + group_list.remove(deleted); + deleted.events.fire("notify_deleted"); + } } request_permissions(group: Group) : Promise { //database_empty_result diff --git a/shared/js/permission/PermissionManager.ts b/shared/js/permission/PermissionManager.ts index 657fa282..9a8aa60e 100644 --- a/shared/js/permission/PermissionManager.ts +++ b/shared/js/permission/PermissionManager.ts @@ -492,7 +492,8 @@ export class PermissionManager extends AbstractCommandHandler { requestClientChannelPermissions(client_id: number, channel_id: number) : Promise { const keys: PermissionRequestKeys = { - client_id: client_id + client_id: client_id, + channel_id: channel_id }; return this.execute_permission_request("requests_client_channel_permissions", keys, this.execute_client_channel_permission_request.bind(this)); } diff --git a/shared/js/proto.ts b/shared/js/proto.ts index 130c5279..4621ff41 100644 --- a/shared/js/proto.ts +++ b/shared/js/proto.ts @@ -69,7 +69,7 @@ declare global { readonly webkitAudioContext: typeof AudioContext; readonly AudioContext: typeof OfflineAudioContext; readonly OfflineAudioContext: typeof OfflineAudioContext; - readonly webkitOfflineAudioContext: typeof webkitOfflineAudioContext; + readonly webkitOfflineAudioContext: typeof OfflineAudioContext; readonly RTCPeerConnection: typeof RTCPeerConnection; readonly Pointer_stringify: any; readonly jsrender: any; diff --git a/shared/js/settings.ts b/shared/js/settings.ts index 56ce9e3d..894d9914 100644 --- a/shared/js/settings.ts +++ b/shared/js/settings.ts @@ -156,7 +156,8 @@ export class Settings extends StaticSettings { static readonly KEY_DISABLE_CONTEXT_MENU: SettingsKey = { key: 'disableContextMenu', - description: 'Disable the context menu for the channel tree which allows to debug the DOM easier' + description: 'Disable the context menu for the channel tree which allows to debug the DOM easier', + default_value: false }; static readonly KEY_DISABLE_GLOBAL_CONTEXT_MENU: SettingsKey = { diff --git a/shared/js/stats.ts b/shared/js/stats.ts index f18df9b7..f06bb29e 100644 --- a/shared/js/stats.ts +++ b/shared/js/stats.ts @@ -241,4 +241,21 @@ namespace connection { handler["notifyinitialized"] = handle_notify_initialized; handler["notifyusercount"] = handle_notify_user_count; } +} + + + +{ + var X; /* just declare the identifier so we'll not getting a reference error */ + const A = () => { + console.log("Variable X: %o", X); + }; + + { + class X { + + } + A(); + } + A(); } \ No newline at end of file diff --git a/shared/js/ui/TreeEntry.ts b/shared/js/ui/TreeEntry.ts new file mode 100644 index 00000000..a5bfb160 --- /dev/null +++ b/shared/js/ui/TreeEntry.ts @@ -0,0 +1,39 @@ +import {Registry} from "tc-shared/events"; + +export interface ChannelTreeEntryEvents { + notify_select_state_change: { selected: boolean }, + notify_unread_state_change: { unread: boolean } +} + +export class ChannelTreeEntry { + readonly events: Registry; + + protected selected_: boolean = false; + protected unread_: boolean = false; + + /* called from the channel tree */ + protected onSelect(singleSelect: boolean) { + if(this.selected_ === true) return; + this.selected_ = true; + + this.events.fire("notify_select_state_change", { selected: true }); + } + + /* called from the channel tree */ + protected onUnselect() { + if(this.selected_ === false) return; + this.selected_ = false; + + this.events.fire("notify_select_state_change", { selected: false }); + } + + isSelected() { return this.selected_; } + + setUnread(flag: boolean) { + if(this.unread_ === flag) return; + this.unread_ = flag; + + this.events.fire("notify_unread_state_change", { unread: flag }); + } + isUnread() { return this.unread_; } +} \ No newline at end of file diff --git a/shared/js/ui/channel.ts b/shared/js/ui/channel.ts index a4ebd318..ac2f6247 100644 --- a/shared/js/ui/channel.ts +++ b/shared/js/ui/channel.ts @@ -1,5 +1,5 @@ import {ChannelTree} from "tc-shared/ui/view"; -import {ClientEntry} from "tc-shared/ui/client"; +import {ClientEntry, ClientEvents} from "tc-shared/ui/client"; import * as log from "tc-shared/log"; import {LogCategory, LogType} from "tc-shared/log"; import {PermissionType} from "tc-shared/permission/PermissionType"; @@ -15,6 +15,11 @@ import {openChannelInfo} from "tc-shared/ui/modal/ModalChannelInfo"; import {createChannelModal} from "tc-shared/ui/modal/ModalCreateChannel"; import {formatMessage} from "tc-shared/ui/frames/chat"; +import * as React from "react"; +import {Registry} from "tc-shared/events"; +import {ChannelTreeEntry, ChannelTreeEntryEvents} from "tc-shared/ui/TreeEntry"; +import { ChannelEntryView as ChannelEntryView } from "./tree/Channel"; + export enum ChannelType { PERMANENT, SEMI_PERMANENT, @@ -69,7 +74,79 @@ export class ChannelProperties { channel_conversation_history_length: number = -1; } -export class ChannelEntry { +export interface ChannelEvents extends ChannelTreeEntryEvents { + notify_properties_updated: { + updated_properties: {[Key in keyof ChannelProperties]: ChannelProperties[Key]}; + channel_properties: ChannelProperties + }, + + notify_cached_password_updated: { + reason: "channel-password-changed" | "password-miss-match" | "password-entered"; + new_hash?: string; + }, + + notify_subscribe_state_changed: { + channel_subscribed: boolean + }, + + notify_children_changed: {}, + notify_clients_changed: {}, /* will also be fired when clients haven been reordered */ +} + +export class ParsedChannelName { + readonly original_name: string; + alignment: "center" | "right" | "left" | "normal"; + repetitive: boolean; + text: string; /* does not contain any alignment codes */ + + constructor(name: string, has_parent_channel: boolean) { + this.original_name = name; + this.parse(has_parent_channel); + } + + private parse(has_parent_channel: boolean) { + this.alignment = "normal"; + + parse_type: + if(!has_parent_channel && this.original_name.charAt(0) == '[') { + let end = this.original_name.indexOf(']'); + if(end === -1) break parse_type; + + let options = this.original_name.substr(1, end - 1); + if(options.indexOf("spacer") === -1) break parse_type; + options = options.substr(0, options.indexOf("spacer")); + + if(options.length == 0) + options = "l"; + else if(options.length > 1) + options = options[0]; + + switch (options) { + case "r": + this.alignment = "right"; + break; + case "l": + this.alignment = "center"; + break; + case "c": + this.alignment = "center"; + break; + case "*": + this.alignment = "center"; + this.repetitive = true; + break; + default: + break parse_type; + } + + this.text = this.original_name.substr(end + 1); + } + if(!this.text && this.alignment === "normal") + this.text = this.original_name; + } +} + +export class ChannelEntry extends ChannelTreeEntry { channelTree: ChannelTree; channelId: number; parent?: ChannelEntry; @@ -78,15 +155,14 @@ export class ChannelEntry { channel_previous?: ChannelEntry; channel_next?: ChannelEntry; - private _channel_name_alignment: string = undefined; - private _channel_name_formatted: string = undefined; + readonly events: Registry; + readonly view: React.Ref; + + parsed_channel_name: ParsedChannelName; + private _family_index: number = 0; //HTML DOM elements - private _tag_root: JQuery; /* container for the channel, client and children tag */ - private _tag_siblings: JQuery; /* container for all sub channels */ - private _tag_clients: JQuery; /* container for all clients */ - private _tag_channel: JQuery; /* container for the channel info itself */ private _destroyed = false; private _cachedPassword: string; @@ -98,26 +174,33 @@ export class ChannelEntry { private _flag_subscribed: boolean; private _subscribe_mode: ChannelSubscribeMode; - constructor(channelId, channelName, parent = null) { + private client_list: ClientEntry[] = []; /* this list is sorted correctly! */ + private readonly client_property_listener; + + constructor(channelId, channelName) { + super(); + + this.events = new Registry(); + this.view = React.createRef(); + this.properties = new ChannelProperties(); this.channelId = channelId; this.properties.channel_name = channelName; - this.parent = parent; this.channelTree = null; - this.initializeTag(); - this.__updateChannelName(); + this.parsed_channel_name = new ParsedChannelName("undefined", false); + + this.client_property_listener = (event: ClientEvents["notify_properties_updated"]) => { + if(typeof event.updated_properties.client_nickname !== "undefined" || typeof event.updated_properties.client_talk_power !== "undefined") + this.reorderClientList(true); + } } destroy() { this._destroyed = true; - if(this._tag_root) { - this._tag_root.remove(); /* removes also all other tags */ - this._tag_root = undefined; - } - this._tag_siblings = undefined; - this._tag_channel = undefined; - this._tag_clients = undefined; + + this.client_list.forEach(e => this.unregisterClient(e, true)); + this.client_list = []; this._cached_channel_description_promise = undefined; this._cached_channel_description_promise_resolve = undefined; @@ -134,7 +217,7 @@ export class ChannelEntry { } formattedChannelName() { - return this._channel_name_formatted || this.properties.channel_name; + return this.parsed_channel_name.text; } getChannelDescription() : Promise { @@ -151,11 +234,57 @@ export class ChannelEntry { }); } + registerClient(client: ClientEntry) { + client.events.on("notify_properties_updated", this.client_property_listener); + this.client_list.push(client); + this.reorderClientList(false); + + this.events.fire("notify_clients_changed"); + } + + unregisterClient(client: ClientEntry, no_event?: boolean) { + client.events.off("notify_properties_updated", this.client_property_listener); + if(!this.client_list.remove(client)) + log.warn(LogCategory.CHANNEL, tr("Unregistered unknown client from channel %s"), this.channelName()); + + if(!no_event) + this.events.fire("notify_clients_changed"); + } + + private reorderClientList(fire_event: boolean) { + const original_list = this.client_list.slice(0); + + this.client_list.sort((a, b) => { + if(a.properties.client_talk_power < b.properties.client_talk_power) + return 1; + if(a.properties.client_talk_power > b.properties.client_talk_power) + return -1; + + if(a.properties.client_nickname > b.properties.client_nickname) + return 1; + if(a.properties.client_nickname < b.properties.client_nickname) + return -1; + + return 0; + }); + + if(fire_event) { + /* only fire if really something has changed ;) */ + for(let index = 0; index < this.client_list.length; index++) { + if(this.client_list[index] !== original_list[index]) { + this.events.fire("notify_clients_changed"); + break; + } + } + } + } + parent_channel() { return this.parent; } hasParent(){ return this.parent != null; } getChannelId(){ return this.channelId; } children(deep = false) : ChannelEntry[] { + //TODO: Speed this up by caching the children! const result: ChannelEntry[] = []; if(this.channelTree == null) return []; @@ -178,53 +307,17 @@ export class ChannelEntry { } clients(deep = false) : ClientEntry[] { - const result: ClientEntry[] = []; - if(this.channelTree == null) return []; + const result: ClientEntry[] = this.client_list.slice(0); + if(!deep) return result; - const self = this; - this.channelTree.clients.forEach(function (entry) { - let current = entry.currentChannel(); - if(deep) { - while(current) { - if(current == self) { - result.push(entry); - break; - } - current = current.parent_channel(); - } - } else - if(current == self) - result.push(entry); - }); - return result; + return this.children(true).map(e => e.clients(false)).reduce((prev, cur) => { + prev.push(...cur); + return cur; + }, result); } clients_ordered() : ClientEntry[] { - const clients = this.clients(false); - - clients.sort((a, b) => { - if(a.properties.client_talk_power < b.properties.client_talk_power) - return 1; - if(a.properties.client_talk_power > b.properties.client_talk_power) - return -1; - - if(a.properties.client_nickname > b.properties.client_nickname) - return 1; - if(a.properties.client_nickname < b.properties.client_nickname) - return -1; - - return 0; - }); - return clients; - } - - update_family_index(enforce?: boolean) { - const current_index = this._family_index; - const new_index = this.calculate_family_index(true); - if(current_index == new_index && !enforce) return; - - this._tag_channel.css("z-index", this._family_index); - this._tag_channel.css("padding-left", ((this._family_index + 1) * 16 + 10) + "px"); + return this.client_list; } calculate_family_index(enforce_recalculate: boolean = false) : number { @@ -242,235 +335,13 @@ export class ChannelEntry { return this._family_index; } - private initializeTag() { - const tag_channel = $.spawn("div").addClass("tree-entry channel"); + protected onSelect(singleSelect: boolean) { + super.onSelect(singleSelect); + if(!singleSelect) return; - { - const container_entry = $.spawn("div").addClass("container-channel"); - - container_entry.attr("channel-id", this.channelId); - container_entry.addClass(this._channel_name_alignment); - - /* unread marker */ - { - container_entry.append( - $.spawn("div") - .addClass("marker-text-unread hidden") - .attr("conversation", this.channelId) - ); - } - - /* channel icon (type) */ - { - container_entry.append( - $.spawn("div") - .addClass("show-channel-normal-only channel-type icon client-channel_green_subscribed") - ); - } - - /* channel name */ - { - container_entry.append( - $.spawn("div") - .addClass("container-channel-name") - .append( - $.spawn("a") - .addClass("channel-name") - .text(this.channelName()) - ) - ) - } - - /* all icons (last element) */ - { - //Icons - let container_icons = $.spawn("span").addClass("icons"); - - //Default icon (5) - container_icons.append( - $.spawn("div") - .addClass("show-channel-normal-only icon_entry icon_default icon client-channel_default") - .attr("title", tr("Default channel")) - ); - - //Password icon (4) - container_icons.append( - $.spawn("div") - .addClass("show-channel-normal-only icon_entry icon_password icon client-register") - .attr("title", tr("The channel is password protected")) - ); - - //Music icon (3) - container_icons.append( - $.spawn("div") - .addClass("show-channel-normal-only icon_entry icon_music icon client-music") - .attr("title", tr("Music quality")) - ); - - //Channel moderated (2) - container_icons.append( - $.spawn("div") - .addClass("show-channel-normal-only icon_entry icon_moderated icon client-moderated") - .attr("title", tr("Channel is moderated")) - ); - - //Channel Icon (1) - container_icons.append( - $.spawn("div") - .addClass("show-channel-normal-only icon_entry channel_icon") - .attr("title", tr("Channel icon")) - ); - - //Default no sound (0) - let container = $.spawn("div") - .css("position", "relative") - .addClass("icon_no_sound"); - - let noSound = $.spawn("div") - .addClass("icon_entry icon client-conflict-icon") - .attr("title", "You don't support the channel codec"); - - let bg = $.spawn("div") - .width(10) - .height(14) - .css("background", "red") - .css("position", "absolute") - .css("top", "1px") - .css("left", "3px") - .css("z-index", "-1"); - bg.appendTo(container); - noSound.appendTo(container); - container_icons.append(container); - - container_icons.appendTo(container_entry); - } - - tag_channel.append(this._tag_channel = container_entry); - this.update_family_index(true); - } - { - const container_client = $.spawn("div").addClass("container-clients"); - - - tag_channel.append(this._tag_clients = container_client); - } - { - const container_children = $.spawn("div").addClass("container-children"); - - - tag_channel.append(this._tag_siblings = container_children); - } - - /* - setInterval(() => { - let color = (Math.random() * 10000000).toString(16).substr(0, 6); - tag_channel.css("background", "#" + color); - }, 150); - */ - - this._tag_root = tag_channel; - } - - rootTag() : JQuery { - return this._tag_root; - } - - channelTag() : JQuery { - return this._tag_channel; - } - - siblingTag() : JQuery { - return this._tag_siblings; - } - clientTag() : JQuery{ - return this._tag_clients; - } - - private _reorder_timer: number; - reorderClients(sync?: boolean) { - if(this._reorder_timer) { - if(!sync) return; - clearTimeout(this._reorder_timer); - this._reorder_timer = undefined; - } else if(!sync) { - this._reorder_timer = setTimeout(() => { - this._reorder_timer = undefined; - this.reorderClients(true); - }, 5) as any; - return; - } - - let clients = this.clients(); - - if(clients.length > 1) { - clients.sort((a, b) => { - if(a.properties.client_talk_power < b.properties.client_talk_power) - return 1; - if(a.properties.client_talk_power > b.properties.client_talk_power) - return -1; - - if(a.properties.client_nickname > b.properties.client_nickname) - return 1; - if(a.properties.client_nickname < b.properties.client_nickname) - return -1; - - return 0; - }); - clients.reverse(); - - for(let index = 0; index + 1 < clients.length; index++) - clients[index].tag.before(clients[index + 1].tag); - - log.debug(LogCategory.CHANNEL, tr("Reordered channel clients: %d"), clients.length); - for(let client of clients) { - log.debug(LogCategory.CHANNEL, "- %i %s", client.properties.client_talk_power, client.properties.client_nickname); - } - } - } - - initializeListener() { - const tag_channel = this.channelTag(); - tag_channel.on('click', () => this.channelTree.onSelect(this)); - tag_channel.on('dblclick', () => { - if($.isArray(this.channelTree.currently_selected)) { //Multiselect - return; - } - this.joinChannel() - }); - - let last_touch: number = 0; - let touch_start: number = 0; - tag_channel.on('touchend', event => { - /* if over 250ms then its not a click its more a drag */ - if(Date.now() - touch_start > 250) { - touch_start = 0; - return; - } - if(Date.now() - last_touch > 750) { - last_touch = Date.now(); - return; - } - last_touch = Date.now(); - /* double touch */ - tag_channel.trigger('dblclick'); - }); - tag_channel.on('touchstart', event => { - touch_start = Date.now(); - }); - - if(!settings.static(Settings.KEY_DISABLE_CONTEXT_MENU, false)) { - this.channelTag().on("contextmenu", (event) => { - event.preventDefault(); - if($.isArray(this.channelTree.currently_selected)) { //Multiselect - (this.channelTree.currently_selected_context_callback || ((_) => null))(event); - return; - } - - this.channelTree.onSelect(this, true); - this.showContextMenu(event.pageX, event.pageY, () => { - this.channelTree.onSelect(undefined, true); - }); - }); + if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) { + this.channelTree.client.side_bar.channel_conversations().set_current_channel(this.channelId); + this.channelTree.client.side_bar.show_channel_conversations(); } } @@ -653,76 +524,8 @@ export class ChannelEntry { ); } - handle_frame_resized() { - if(this._channel_name_formatted === "align-repetitive") - this.__updateChannelName(); - } - - private static NAME_ALIGNMENTS: string[] = ["align-left", "align-center", "align-right", "align-repetitive"]; - private __updateChannelName() { - this._channel_name_formatted = undefined; - - parse_type: - if(this.parent_channel() == null && this.properties.channel_name.charAt(0) == '[') { - let end = this.properties.channel_name.indexOf(']'); - if(end == -1) break parse_type; - - let options = this.properties.channel_name.substr(1, end - 1); - if(options.indexOf("spacer") == -1) break parse_type; - options = options.substr(0, options.indexOf("spacer")); - - if(options.length == 0) - options = "l"; - else if(options.length > 1) - options = options[0]; - - switch (options) { - case "r": - this._channel_name_alignment = "align-right"; - break; - case "l": - this._channel_name_alignment = "align-left"; - break; - case "c": - this._channel_name_alignment = "align-center"; - break; - case "*": - this._channel_name_alignment = "align-repetitive"; - break; - default: - this._channel_name_alignment = undefined; - break parse_type; - } - - this._channel_name_formatted = this.properties.channel_name.substr(end + 1) || ""; - } - - this._tag_channel.find(".show-channel-normal-only").toggleClass("channel-normal", this._channel_name_formatted === undefined); - - const tag_container_name = this._tag_channel.find(".container-channel-name"); - tag_container_name.removeClass(ChannelEntry.NAME_ALIGNMENTS.join(" ")); - - const tag_name = tag_container_name.find(".channel-name"); - let text = this._channel_name_formatted === undefined ? this.properties.channel_name : this._channel_name_formatted; - - if(this._channel_name_formatted !== undefined) { - tag_container_name.addClass(this._channel_name_alignment); - - if(this._channel_name_alignment == "align-repetitive" && text.length > 0) { - while(text.length < 1024 * 8) - text += text; - } - } - - tag_name.text(text); - } - - recalculate_repetitive_name() { - if(this._channel_name_alignment == "align-repetitive") - this.__updateChannelName(); - } - updateVariables(...variables: {key: string, value: string}[]) { + /* devel-block(log-channel-property-updates) */ let group = log.group(log.LogType.DEBUG, LogCategory.CHANNEL_PROPERTIES, tr("Update properties (%i) of %s (%i)"), variables.length, this.channelName(), this.getChannelId()); { @@ -735,6 +538,7 @@ export class ChannelEntry { }); log.table(LogType.DEBUG, LogCategory.PERMISSIONS, "Clannel update properties", entries); } + /* devel-block-end */ let info_update = false; for(let variable of variables) { @@ -743,36 +547,14 @@ export class ChannelEntry { JSON.map_field_to(this.properties, value, variable.key); if(key == "channel_name") { - this.__updateChannelName(); + this.parsed_channel_name = new ParsedChannelName(value, this.hasParent()); info_update = true; } else if(key == "channel_order") { let order = this.channelTree.findChannel(this.properties.channel_order); this.channelTree.moveChannel(this, order, this.parent); - } else if(key == "channel_icon_id") { - /* For more detail lookup client::updateVariables and client_icon_id! - * ATTENTION: This is required! - */ - this.properties.channel_icon_id = variable.value as any >>> 0; - - let tag = this.channelTag().find(".icons .channel_icon"); - (this.properties.channel_icon_id > 0 ? $.fn.show : $.fn.hide).apply(tag); - if(this.properties.channel_icon_id > 0) { - tag.children().detach(); - this.channelTree.client.fileManager.icons.generateTag(this.properties.channel_icon_id).appendTo(tag); - } - info_update = true; - } else if(key == "channel_codec") { - (this.properties.channel_codec == 5 || this.properties.channel_codec == 3 ? $.fn.show : $.fn.hide).apply(this.channelTag().find(".icons .icon_music")); - this.channelTag().find(".icons .icon_no_sound").toggle(!( - this.channelTree.client.serverConnection.support_voice() && - this.channelTree.client.serverConnection.voice_connection().decoding_supported(this.properties.channel_codec) - )); - } else if(key == "channel_flag_default") { - (this.properties.channel_flag_default ? $.fn.show : $.fn.hide).apply(this.channelTag().find(".icons .icon_default")); - } else if(key == "channel_flag_password") - (this.properties.channel_flag_password ? $.fn.show : $.fn.hide).apply(this.channelTag().find(".icons .icon_password")); - else if(key == "channel_needed_talk_power") - (this.properties.channel_needed_talk_power > 0 ? $.fn.show : $.fn.hide).apply(this.channelTag().find(".icons .icon_moderated")); + } else if(key === "channel_icon_id") { + this.properties.channel_icon_id = variable.value as any >>> 0; /* unsigned 32 bit number! */ + } else if(key == "channel_description") { this._cached_channel_description = undefined; if(this._cached_channel_description_promise_resolve) @@ -781,10 +563,6 @@ export class ChannelEntry { this._cached_channel_description_promise_resolve = undefined; this._cached_channel_description_promise_reject = undefined; } - if(key == "channel_maxclients" || key == "channel_maxfamilyclients" || key == "channel_flag_private" || key == "channel_flag_password") { - this.updateChannelTypeIcon(); - info_update = true; - } if(key == "channel_flag_conversation_private") { const conversations = this.channelTree.client.side_bar.channel_conversations(); const conversation = conversations.conversation(this.channelId, false); @@ -792,7 +570,15 @@ export class ChannelEntry { conversation.set_flag_private(this.properties.channel_flag_conversation_private); } } + /* devel-block(log-channel-property-updates) */ group.end(); + /* devel-block-end */ + { + let properties = {}; + for(const property of variables) + properties[property.key] = this.properties[property.key]; + this.events.fire("notify_properties_updated", { updated_properties: properties as any, channel_properties: this.properties }); + } if(info_update) { const _client = this.channelTree.client.getClient(); @@ -802,28 +588,6 @@ export class ChannelEntry { } } - updateChannelTypeIcon() { - let tag = this.channelTag().find(".channel-type"); - tag.removeAttr('class'); - tag.addClass("show-channel-normal-only channel-type icon"); - - if(this._channel_name_formatted === undefined) - tag.addClass("channel-normal"); - - let type; - if(this.properties.channel_flag_password == true && !this._cachedPassword) - type = "yellow"; - else if( - (!this.properties.channel_flag_maxclients_unlimited && this.clients().length >= this.properties.channel_maxclients) || - (!this.properties.channel_flag_maxfamilyclients_unlimited && this.properties.channel_maxfamilyclients >= 0 && this.clients(true).length >= this.properties.channel_maxfamilyclients) - ) - type = "red"; - else - type = "green"; - - tag.addClass("client-channel_" + type + (this._flag_subscribed ? "_subscribed" : "")); - } - generate_bbcode() { return "[url=channel://" + this.channelId + "/" + encodeURIComponent(this.properties.channel_name) + "]" + this.formattedChannelName() + "[/url]"; } @@ -847,11 +611,12 @@ export class ChannelEntry { !this._cachedPassword && !this.channelTree.client.permissions.neededPermission(PermissionType.B_CHANNEL_JOIN_IGNORE_PASSWORD).granted(1)) { createInputModal(tr("Channel password"), tr("Channel password:"), () => true, text => { - if(typeof(text) == typeof(true)) return; - hashPassword(text as string).then(result => { + if(typeof(text) !== "string") return; + + hashPassword(text).then(result => { this._cachedPassword = result; + this.events.fire("notify_cached_password_updated", { reason: "password-entered", new_hash: result }); this.joinChannel(); - this.updateChannelTypeIcon(); }); }).open(); } else if(this.channelTree.client.getClient().currentChannel() != this) @@ -861,7 +626,7 @@ export class ChannelEntry { if(error instanceof CommandResult) { if(error.id == 781) { //Invalid password this._cachedPassword = undefined; - this.updateChannelTypeIcon(); + this.events.fire("notify_cached_password_updated", { reason: "password-miss-match" }); } } }); @@ -890,7 +655,7 @@ export class ChannelEntry { if(inherited_subscription_mode) { this.subscribe_mode = ChannelSubscribeMode.INHERITED; - unsubscribe = this.flag_subscribed && !this.channelTree.client.client_status.channel_subscribe_all; + unsubscribe = this.flag_subscribed && !this.channelTree.client.isSubscribeToAllChannels(); } else { this.subscribe_mode = ChannelSubscribeMode.UNSUBSCRIBED; unsubscribe = this.flag_subscribed; @@ -918,7 +683,7 @@ export class ChannelEntry { return; this._flag_subscribed = flag; - this.updateChannelTypeIcon(); + this.events.fire("notify_subscribe_state_changed", { channel_subscribed: flag }); } get subscribe_mode() : ChannelSubscribeMode { @@ -933,10 +698,6 @@ export class ChannelEntry { this.channelTree.client.settings.changeServer(Settings.FN_SERVER_CHANNEL_SUBSCRIBE_MODE(this.channelId), mode); } - set flag_text_unread(flag: boolean) { - this._tag_channel.find(".marker-text-unread").toggleClass("hidden", !flag); - } - log_data() : server_log.base.Channel { return { channel_name: this.channelName(), diff --git a/shared/js/ui/client.ts b/shared/js/ui/client.ts index d6a73852..2802e4eb 100644 --- a/shared/js/ui/client.ts +++ b/shared/js/ui/client.ts @@ -1,17 +1,16 @@ import * as contextmenu from "tc-shared/ui/elements/ContextMenu"; -import {channel_tree, Registry} from "tc-shared/events"; +import {Registry} from "tc-shared/events"; import {ChannelTree} from "tc-shared/ui/view"; import * as log from "tc-shared/log"; import {LogCategory, LogType} from "tc-shared/log"; import {Settings, settings} from "tc-shared/settings"; -import {KeyCode, SpecialKey} from "tc-shared/PPTListener"; import {Sound} from "tc-shared/sound/Sounds"; import {Group, GroupManager, GroupTarget, GroupType} from "tc-shared/permission/GroupManager"; import PermissionType from "tc-shared/permission/PermissionType"; import {createErrorModal, createInputModal} from "tc-shared/ui/elements/Modal"; import * as htmltags from "tc-shared/ui/htmltags"; import * as server_log from "tc-shared/ui/frames/server_log"; -import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration"; +import {CommandResult, PlaylistSong} from "tc-shared/connection/ServerConnectionDeclaration"; import {ChannelEntry} from "tc-shared/ui/channel"; import {ConnectionHandler, ViewReasonId} from "tc-shared/ConnectionHandler"; import {voice} from "tc-shared/connection/ConnectionBase"; @@ -25,8 +24,10 @@ import {spawnChangeLatency} from "tc-shared/ui/modal/ModalChangeLatency"; import {spawnPlaylistEdit} from "tc-shared/ui/modal/ModalPlaylistEdit"; import {formatMessage} from "tc-shared/ui/frames/chat"; import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo"; -import * as ppt from "tc-backend/ppt"; import * as hex from "tc-shared/crypto/hex"; +import { ClientEntry as ClientEntryView } from "./tree/Client"; +import * as React from "react"; +import {ChannelTreeEntry, ChannelTreeEntryEvents} from "tc-shared/ui/TreeEntry"; export enum ClientType { CLIENT_VOICE, @@ -135,12 +136,39 @@ export class ClientConnectionInfo { connection_client_port: number = -1; } -export class ClientEntry { - readonly events: Registry; +export interface ClientEvents extends ChannelTreeEntryEvents { + "notify_enter_view": {}, + "notify_left_view": {}, + + notify_properties_updated: { + updated_properties: {[Key in keyof ClientProperties]: ClientProperties[Key]}; + client_properties: ClientProperties + }, + notify_mute_state_change: { muted: boolean } + notify_speak_state_change: { speaking: boolean } + + "music_status_update": { + player_buffered_index: number, + player_replay_index: number + }, + "music_song_change": { + "song": SongInfo + }, + + /* TODO: Move this out of the music bots interface? */ + "playlist_song_add": { song: PlaylistSong }, + "playlist_song_remove": { song_id: number }, + "playlist_song_reorder": { song_id: number, previous_song_id: number }, + "playlist_song_loaded": { song_id: number, success: boolean, error_msg?: string, metadata?: string }, + +} + +export class ClientEntry extends ChannelTreeEntry { + readonly events: Registry; + readonly view: React.RefObject = React.createRef(); protected _clientId: number; protected _channel: ChannelEntry; - protected _tag: JQuery; protected _properties: ClientProperties; protected lastVariableUpdate: number = 0; @@ -162,7 +190,8 @@ export class ClientEntry { channelTree: ChannelTree; constructor(clientId: number, clientName, properties: ClientProperties = new ClientProperties()) { - this.events = new Registry(); + super(); + this.events = new Registry(); this._properties = properties; this._properties.client_nickname = clientName; @@ -172,10 +201,6 @@ export class ClientEntry { } destroy() { - if(this._tag) { - this._tag.remove(); - this._tag = undefined; - } if(this._audio_handle) { log.warn(LogCategory.AUDIO, tr("Destroying client with an active audio handle. This could cause memory leaks!")); try { @@ -240,7 +265,7 @@ export class ClientEntry { clientId(){ return this._clientId; } is_muted() { return !!this._audio_muted; } - set_muted(flag: boolean, update_icon: boolean, force?: boolean) { + set_muted(flag: boolean, force: boolean) { if(this._audio_muted === flag && !force) return; @@ -264,13 +289,11 @@ export class ClientEntry { } } - if(update_icon) - this.updateClientSpeakIcon(); - + this.events.fire("notify_mute_state_change", { muted: flag }); for(const client of this.channelTree.clients) { - if(client === this || client.properties.client_unique_identifier != this.properties.client_unique_identifier) + if(client === this || client.properties.client_unique_identifier !== this.properties.client_unique_identifier) continue; - client.set_muted(flag, true); + client.set_muted(flag, false); } } @@ -278,34 +301,8 @@ export class ClientEntry { if(this._listener_initialized) return; this._listener_initialized = true; - this.tag.on('mouseup', event => { - if(!this.channelTree.client_mover.is_active()) { - this.channelTree.onSelect(this); - } - }); - - if(!(this instanceof LocalClientEntry) && !(this instanceof MusicClientEntry)) - this.tag.dblclick(event => { - if($.isArray(this.channelTree.currently_selected)) { //Multiselect - return; - } - this.open_text_chat(); - }); - - if(!settings.static(Settings.KEY_DISABLE_CONTEXT_MENU, false)) { - this.tag.on("contextmenu", (event) => { - event.preventDefault(); - if($.isArray(this.channelTree.currently_selected)) { //Multiselect - (this.channelTree.currently_selected_context_callback || ((_) => null))(event); - return; - } - - this.channelTree.onSelect(this, true); - this.showContextMenu(event.pageX, event.pageY, () => {}); - return false; - }); - } - + //FIXME: TODO! + /* this.tag.on('mousedown', event => { if(event.which != 1) return; //Only the left button @@ -340,6 +337,19 @@ export class ClientEntry { this.channelTree.onSelect(); }, event); }); + */ + } + + protected onSelect(singleSelect: boolean) { + super.onSelect(singleSelect); + if(!singleSelect) return; + + if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CLIENT)) { + if(this instanceof MusicClientEntry) + this.channelTree.client.side_bar.show_music_player(this); + else + this.channelTree.client.side_bar.show_client_info(this); + } } protected contextmenu_info() : contextmenu.MenuEntry[] { @@ -674,85 +684,18 @@ export class ClientEntry { icon_class: "client-input_muted_local", name: tr("Mute client"), visible: !this._audio_muted, - callback: () => this.set_muted(true, true) + callback: () => this.set_muted(true, false) }, { type: contextmenu.MenuEntryType.ENTRY, icon_class: "client-input_muted_local", name: tr("Unmute client"), visible: this._audio_muted, - callback: () => this.set_muted(false, true) + callback: () => this.set_muted(false, false) }, contextmenu.Entry.CLOSE(() => trigger_close && on_close ? on_close() : {}) ); } - get tag() : JQuery { - if(this._tag) return this._tag; - - let container_client = $.spawn("div") - .addClass("tree-entry client") - .attr("client-id", this.clientId()); - - - /* unread marker */ - { - container_client.append( - $.spawn("div") - .addClass("marker-text-unread hidden") - .attr("private-conversation", this._clientId) - ); - } - container_client.append( - $.spawn("div") - .addClass("icon_client_state") - .attr("title", "Client state") - ); - - container_client.append( - $.spawn("div") - .addClass("group-prefix") - .attr("title", "Server groups prefixes") - .hide() - ); - container_client.append( - $.spawn("div") - .addClass("client-name") - .text(this.clientNickName()) - ); - container_client.append( - $.spawn("div") - .addClass("group-suffix") - .attr("title", "Server groups suffix") - .hide() - ); - container_client.append( - $.spawn("div") - .addClass("client-away-message") - .text(this.clientNickName()) - ); - - let container_icons = $.spawn("div").addClass("container-icons"); - - container_icons.append( - $.spawn("div") - .addClass("icon icon_talk_power client-input_muted") - .hide() - ); - container_icons.append( - $.spawn("div") - .addClass("container-icons-group") - ); - container_icons.append( - $.spawn("div") - .addClass("container-icon-client") - ); - container_client.append(container_icons); - - this._tag = container_client; - this.initializeListener(); - return this._tag; - } - static bbcodeTag(id: number, name: string, uid: string) : string { return "[url=client://" + id + "/" + uid + "~" + encodeURIComponent(name) + "]" + name + "[/url]"; } @@ -777,80 +720,18 @@ export class ClientEntry { set speaking(flag) { if(flag === this._speaking) return; this._speaking = flag; - this.updateClientSpeakIcon(); + this.events.fire("notify_speak_state_change", { speaking: flag }); } - updateClientStatusIcons() { - let talk_power = this.properties.client_talk_power >= this._channel.properties.channel_needed_talk_power; - if(talk_power) - this.tag.find(".icon_talk_power").hide(); - else - this.tag.find(".icon_talk_power").show(); - } - - updateClientSpeakIcon() { - let icon: string = ""; - let clicon: string = ""; - - if(this.properties.client_type_exact == ClientType.CLIENT_QUERY) { - icon = "client-server_query"; - console.log("Server query!"); - } else { - if (this.properties.client_away) { - icon = "client-away"; - } else if (this._audio_muted && !(this instanceof LocalClientEntry)) { - icon = "client-input_muted_local"; - } else if(!this.properties.client_output_hardware) { - icon = "client-hardware_output_muted"; - } else if(this.properties.client_output_muted) { - icon = "client-output_muted"; - } else if(!this.properties.client_input_hardware) { - icon = "client-hardware_input_muted"; - } else if(this.properties.client_input_muted) { - icon = "client-input_muted"; - } else { - if(this._speaking) { - if(this.properties.client_is_channel_commander) - clicon = "client_cc_talk"; - else - clicon = "client_talk"; - } else { - if(this.properties.client_is_channel_commander) - clicon = "client_cc_idle"; - else - clicon = "client_idle"; - } - } - } - - - if(clicon.length > 0) - this.tag.find(".icon_client_state").attr('class', 'icon_client_state clicon ' + clicon); - else if(icon.length > 0) - this.tag.find(".icon_client_state").attr('class', 'icon_client_state icon ' + icon); - else - this.tag.find(".icon_client_state").attr('class', 'icon_client_state icon_empty'); - } - - updateAwayMessage() { - let tag = this.tag.find(".client-away-message"); - if(this.properties.client_away == true && this.properties.client_away_message){ - tag.text("[" + this.properties.client_away_message + "]"); - tag.show(); - } else { - tag.hide(); - } - } + isSpeaking() { return this._speaking; } updateVariables(...variables: {key: string, value: string}[]) { - let group = log.group(log.LogType.DEBUG, LogCategory.CLIENT, tr("Update properties (%i) of %s (%i)"), variables.length, this.clientNickName(), this.clientId()); - let update_icon_status = false; - let update_icon_speech = false; - let update_away = false; let reorder_channel = false; let update_avatar = false; + /* devel-block(log-client-property-updates) */ + let group = log.group(log.LogType.DEBUG, LogCategory.CLIENT, tr("Update properties (%i) of %s (%i)"), variables.length, this.clientNickName(), this.clientId()); { const entries = []; for(const variable of variables) @@ -861,6 +742,7 @@ export class ClientEntry { }); log.table(LogType.DEBUG, LogCategory.PERMISSIONS, "Client update properties", entries); } + /* devel-block-end */ for(const variable of variables) { const old_value = this._properties[variable.key]; @@ -878,8 +760,6 @@ export class ClientEntry { } } - this.tag.find(".client-name").text(variable.value); - const chat = this.channelTree.client.side_bar; const conversation = chat.private_conversations().find_conversation({ name: this.clientNickName(), @@ -893,32 +773,19 @@ export class ClientEntry { conversation.set_client_name(variable.value); reorder_channel = true; } - if( - variable.key == "client_away" || - variable.key == "client_input_hardware" || - variable.key == "client_output_hardware" || - variable.key == "client_output_muted" || - variable.key == "client_input_muted" || - variable.key == "client_is_channel_commander"){ - update_icon_speech = true; - } - if(variable.key == "client_away_message" || variable.key == "client_away") { - update_away = true; - } if(variable.key == "client_unique_identifier") { this._audio_volume = parseFloat(this.channelTree.client.settings.server("volume_client_" + this.clientUid(), "1")); const mute_status = this.channelTree.client.settings.server("mute_client_" + this.clientUid(), false); - this.set_muted(mute_status, false, mute_status); /* force only needed when we want to mute the client */ + this.set_muted(mute_status, mute_status); /* force only needed when we want to mute the client */ if(this._audio_handle) this._audio_handle.set_volume(this._audio_muted ? 0 : this._audio_volume); - update_icon_speech = true; log.debug(LogCategory.CLIENT, tr("Loaded client (%s) server specific properties. Volume: %o Muted: %o."), this.clientUid(), this._audio_volume, this._audio_muted); } if(variable.key == "client_talk_power") { reorder_channel = true; - update_icon_status = true; + //update_icon_status = true; DONE } if(variable.key == "client_icon_id") { /* yeah we like javascript. Due to JS wiered integer behaviour parsing for example fails for 18446744073409829863. @@ -926,24 +793,12 @@ export class ClientEntry { * In opposite "18446744073409829863" >>> 0 evaluates to 3995244544, which is the icon id :) */ this.properties.client_icon_id = variable.value as any >>> 0; - this.updateClientIcon(); } - if(variable.key =="client_channel_group_id" || variable.key == "client_servergroups") - this.update_displayed_client_groups(); else if(variable.key == "client_flag_avatar") update_avatar = true; } /* process updates after variables have been set */ - if(this._channel && reorder_channel) - this._channel.reorderClients(); - if(update_icon_speech) - this.updateClientSpeakIcon(); - if(update_icon_status) - this.updateClientStatusIcons(); - if(update_away) - this.updateAwayMessage(); - const side_bar = this.channelTree.client.side_bar; { const client_info = side_bar.client_info(); @@ -959,44 +814,15 @@ export class ClientEntry { conversation.update_avatar(); } + /* devel-block(log-client-property-updates) */ group.end(); - this.events.fire("property_update", { - properties: variables.map(e => e.key) - }); - } + /* devel-block-end */ - update_displayed_client_groups() { - this.tag.find(".container-icons-group").children().remove(); - - for(let id of this.assignedServerGroupIds()) - this.updateGroupIcon(this.channelTree.client.groups.serverGroup(id)); - this.update_group_icon_order(); - this.updateGroupIcon(this.channelTree.client.groups.channelGroup(this.properties.client_channel_group_id)); - - let prefix_groups: string[] = []; - let suffix_groups: string[] = []; - for(const group_id of this.assignedServerGroupIds()) { - const group = this.channelTree.client.groups.serverGroup(group_id); - if(!group) continue; - - if(group.properties.namemode == 1) - prefix_groups.push(group.name); - else if(group.properties.namemode == 2) - suffix_groups.push(group.name); - } - - const tag_group_prefix = this.tag.find(".group-prefix"); - const tag_group_suffix = this.tag.find(".group-suffix"); - if(prefix_groups.length > 0) { - tag_group_prefix.text("[" + prefix_groups.join("][") + "]").show(); - } else { - tag_group_prefix.hide() - } - - if(suffix_groups.length > 0) { - tag_group_suffix.text("[" + suffix_groups.join("][") + "]").show(); - } else { - tag_group_suffix.hide() + { + let properties = {}; + for(const property of variables) + properties[property.key] = this.properties[property.key]; + this.events.fire("notify_properties_updated", { updated_properties: properties as any, client_properties: this.properties }); } } @@ -1013,35 +839,6 @@ export class ClientEntry { })); } - updateClientIcon() { - this.tag.find(".container-icon-client").children().remove(); - if(this.properties.client_icon_id > 0) { - this.channelTree.client.fileManager.icons.generateTag(this.properties.client_icon_id).attr("title", "Client icon") - .appendTo(this.tag.find(".container-icon-client")); - } - } - - updateGroupIcon(group: Group) { - if(!group) return; - - const container = this.tag.find(".container-icons-group"); - container.find(".icon_group_" + group.id).remove(); - - if (group.properties.iconid > 0) { - container.append( - $.spawn("div").attr('group-power', group.properties.sortid) - .addClass("container-group-icon icon_group_" + group.id) - .append(this.channelTree.client.fileManager.icons.generateTag(group.properties.iconid)).attr("title", group.name) - ); - } - } - - update_group_icon_order() { - const container = this.tag.find(".container-icons-group"); - - container.append(...[...container.children()].sort((a, b) => parseInt(a.getAttribute("group-power")) - parseInt(b.getAttribute("group-power")))); - } - assignedServerGroupIds() : number[] { let result = []; for(let id of this.properties.client_servergroups.split(",")){ @@ -1101,13 +898,6 @@ export class ClientEntry { } } - update_family_index() { - if(!this._channel) return; - const index = this._channel.calculate_family_index(); - - this.tag.css('padding-left', (5 + (index + 2) * 16) + "px"); - } - log_data() : server_log.base.Client { return { client_unique_id: this.properties.client_unique_identifier, @@ -1143,10 +933,6 @@ export class ClientEntry { this._info_connection_promise_resolve = undefined; this._info_connection_promise_reject = undefined; } - - set flag_text_unread(flag: boolean) { - this._tag.find(".marker-text-unread").toggleClass("hidden", !flag); - } } export class LocalClientEntry extends ClientEntry { @@ -1195,64 +981,42 @@ export class LocalClientEntry extends ClientEntry { } initializeListener(): void { - if(this._listener_initialized) - this.tag.off(); - this._listener_initialized = false; /* could there be a better system */ super.initializeListener(); - this.tag.find(".client-name").addClass("client-name-own"); + } - this.tag.on('dblclick', () => { - if(Array.isArray(this.channelTree.currently_selected)) { //Multiselect - return; - } - this.openRename(); + renameSelf(new_name: string) : Promise { + const old_name = this.properties.client_nickname; + this.updateVariables({ key: "client_nickname", value: new_name }); /* change it locally */ + return this.handle.serverConnection.command_helper.updateClient("client_nickname", new_name).then((e) => { + settings.changeGlobal(Settings.KEY_CONNECT_USERNAME, new_name); + this.channelTree.client.log.log(server_log.Type.CLIENT_NICKNAME_CHANGED, { + client: this.log_data(), + old_name: old_name, + new_name: new_name, + own_client: true + }); + return true; + }).catch((e: CommandResult) => { + this.updateVariables({ key: "client_nickname", value: old_name }); /* change it back */ + this.channelTree.client.log.log(server_log.Type.CLIENT_NICKNAME_CHANGE_FAILED, { + reason: e.extra_message + }); + return false; }); } openRename() : void { - this.channelTree.client_mover.enabled = false; - - const elm = this.tag.find(".client-name"); - elm.attr("contenteditable", "true"); - elm.removeClass("client-name-own"); - elm.css("background-color", "white"); - elm.focus(); - this.renaming = true; - - elm.on('keypress', event => { - if(event.keyCode == KeyCode.KEY_RETURN) { - $(event.target).trigger("focusout"); - return false; + const view = this.channelTree.view.current; + if(!view) return; //TODO: Fallback input modal + view.scrollEntryInView(this, () => { + const own_view = this.view.current; + if(!own_view) { + return; //TODO: Fallback input modal } - }); - elm.on('focusout', event => { - this.channelTree.client_mover.enabled = true; - - if(!this.renaming) return; - this.renaming = false; - - elm.css("background-color", ""); - elm.removeAttr("contenteditable"); - elm.addClass("client-name-own"); - let text = elm.text().toString(); - if(this.clientNickName() == text) return; - - elm.text(this.clientNickName()); - const old_name = this.clientNickName(); - this.handle.serverConnection.command_helper.updateClient("client_nickname", text).then((e) => { - settings.changeGlobal(Settings.KEY_CONNECT_USERNAME, text); - this.channelTree.client.log.log(server_log.Type.CLIENT_NICKNAME_CHANGED, { - client: this.log_data(), - old_name: old_name, - new_name: text, - own_client: true - }); - }).catch((e: CommandResult) => { - this.channelTree.client.log.log(server_log.Type.CLIENT_NICKNAME_CHANGE_FAILED, { - reason: e.extra_message - }); - this.openRename(); + own_view.setState({ + rename: true, + renameInitialName: this.properties.client_nickname }); }); } diff --git a/shared/js/ui/frames/MenuBar.ts b/shared/js/ui/frames/MenuBar.ts index bc6b15a2..dee7a447 100644 --- a/shared/js/ui/frames/MenuBar.ts +++ b/shared/js/ui/frames/MenuBar.ts @@ -1,4 +1,4 @@ -import {Icon, IconManager} from "tc-shared/FileManager"; +import {icon_cache_loader, IconManager, LocalIcon} from "tc-shared/FileManager"; import {spawnBookmarkModal} from "tc-shared/ui/modal/ModalBookmarks"; import { add_server_to_bookmarks, @@ -33,7 +33,7 @@ export interface MenuItem { delete_item(item: MenuItem | HRItem); items() : (MenuItem | HRItem)[]; - icon(klass?: string | Promise | Icon) : string; + icon(klass?: string | LocalIcon) : string; //FIXME: Native client must work as well! label(value?: string) : string; visible(value?: boolean) : boolean; disabled(value?: boolean) : boolean; @@ -178,7 +178,7 @@ namespace html { return this; } - icon(klass?: string | Promise | Icon): string { + icon(klass?: string | LocalIcon): string { this._label_icon_tag.children().remove(); if(typeof(klass) === "string") $.spawn("div").addClass("icon_em " + klass).appendTo(this._label_icon_tag); @@ -288,7 +288,8 @@ export function rebuild_bookmarks() { } else { const bookmark = entry as Bookmark; const item = root.append_item(bookmark.display_name); - item.icon(IconManager.load_cached_icon(bookmark.last_icon_id || 0)); + + item.icon(icon_cache_loader.load_icon(bookmark.last_icon_id, bookmark.last_icon_server_id)); item.click(() => boorkmak_connect(bookmark)); } }; diff --git a/shared/js/ui/frames/control-bar/button.tsx b/shared/js/ui/frames/control-bar/button.tsx index 3a85f432..21878ce2 100644 --- a/shared/js/ui/frames/control-bar/button.tsx +++ b/shared/js/ui/frames/control-bar/button.tsx @@ -27,7 +27,7 @@ export interface ButtonProperties { } export class Button extends ReactComponentBase { - protected default_state(): ButtonState { + protected defaultState(): ButtonState { return { switched: false, dropdownShowed: false, @@ -66,13 +66,13 @@ export class Button extends ReactComponentBase { } private onMouseEnter() { - this.updateState({ + this.setState({ dropdownShowed: true }); } private onMouseLeave() { - this.updateState({ + this.setState({ dropdownShowed: false }); } @@ -81,6 +81,6 @@ export class Button extends ReactComponentBase { const new_state = !(this.state.switched || this.props.switched); const result = this.props.onToggle?.call(undefined, new_state); if(this.props.autoSwitch) - this.updateState({ switched: typeof result === "boolean" ? result : new_state }); + this.setState({ switched: typeof result === "boolean" ? result : new_state }); } } \ No newline at end of file diff --git a/shared/js/ui/frames/control-bar/dropdown.tsx b/shared/js/ui/frames/control-bar/dropdown.tsx index 0ec541b2..d93c6703 100644 --- a/shared/js/ui/frames/control-bar/dropdown.tsx +++ b/shared/js/ui/frames/control-bar/dropdown.tsx @@ -1,10 +1,11 @@ import * as React from "react"; import {ReactComponentBase} from "tc-shared/ui/react-elements/ReactComponentBase"; import {IconRenderer} from "tc-shared/ui/react-elements/Icon"; +import {LocalIcon} from "tc-shared/FileManager"; const cssStyle = require("./button.scss"); export interface DropdownEntryProperties { - icon?: string | JQuery; + icon?: string | LocalIcon; text: JSX.Element | string; onClick?: (event) => void; @@ -12,7 +13,7 @@ export interface DropdownEntryProperties { } export class DropdownEntry extends ReactComponentBase { - protected default_state() { return {}; } + protected defaultState() { return {}; } render() { if(this.props.children) { @@ -41,7 +42,7 @@ export interface DropdownContainerProperties { } export interface DropdownContainerState { } export class DropdownContainer extends ReactComponentBase { - protected default_state() { + protected defaultState() { return { }; } diff --git a/shared/js/ui/frames/control-bar/index.tsx b/shared/js/ui/frames/control-bar/index.tsx index 10824f28..528c45d9 100644 --- a/shared/js/ui/frames/control-bar/index.tsx +++ b/shared/js/ui/frames/control-bar/index.tsx @@ -16,7 +16,7 @@ import { DirectoryBookmark, find_bookmark } from "tc-shared/bookmarks"; -import {IconManager} from "tc-shared/FileManager"; +import {icon_cache_loader, IconManager} from "tc-shared/FileManager"; import * as contextmenu from "tc-shared/ui/elements/ContextMenu"; import {createInputModal} from "tc-shared/ui/elements/Modal"; import {default_recorder} from "tc-shared/voice/RecorderProfile"; @@ -32,7 +32,7 @@ export interface ConnectionState { @ReactEventHandler(obj => obj.props.event_registry) class ConnectButton extends ReactComponentBase<{ multiSession: boolean; event_registry: Registry }, ConnectionState> { - protected default_state(): ConnectionState { + protected defaultState(): ConnectionState { return { connected: false, connectedAnywhere: false @@ -84,7 +84,7 @@ class ConnectButton extends ReactComponentBase<{ multiSession: boolean; event_re @EventHandler("update_connect_state") private handleStateUpdate(state: ConnectionState) { - this.updateState(state); + this.setState(state); } } @@ -96,7 +96,7 @@ class BookmarkButton extends ReactComponentBase<{ event_registry: Registry @@ -146,7 +146,7 @@ class BookmarkButton extends ReactComponentBase<{ event_registry: Registry boorkmak_connect(bookmark, true), visible: !settings.static_global(Settings.KEY_DISABLE_MULTI_SESSION) }, contextmenu.Entry.CLOSE(() => { - this.button_ref.current?.updateState({ dropdownForceShow: false }); + this.button_ref.current?.setState({ dropdownForceShow: false }); })); } @@ -177,7 +177,7 @@ export interface AwayState { @ReactEventHandler(obj => obj.props.event_registry) class AwayButton extends ReactComponentBase<{ event_registry: Registry }, AwayState> { - protected default_state(): AwayState { + protected defaultState(): AwayState { return { away: false, awayAnywhere: false, @@ -226,7 +226,7 @@ class AwayButton extends ReactComponentBase<{ event_registry: Registry("update_away_state") private handleStateUpdate(state: AwayState) { - this.updateState(state); + this.setState(state); } } @@ -236,7 +236,7 @@ export interface ChannelSubscribeState { @ReactEventHandler(obj => obj.props.event_registry) class ChannelSubscribeButton extends ReactComponentBase<{ event_registry: Registry }, ChannelSubscribeState> { - protected default_state(): ChannelSubscribeState { + protected defaultState(): ChannelSubscribeState { return { subscribeEnabled: false }; } @@ -247,7 +247,7 @@ class ChannelSubscribeButton extends ReactComponentBase<{ event_registry: Regist @EventHandler("update_subscribe_state") private handleStateUpdate(state: ChannelSubscribeState) { - this.updateState(state); + this.setState(state); } } @@ -258,7 +258,7 @@ export interface MicrophoneState { @ReactEventHandler(obj => obj.props.event_registry) class MicrophoneButton extends ReactComponentBase<{ event_registry: Registry }, MicrophoneState> { - protected default_state(): MicrophoneState { + protected defaultState(): MicrophoneState { return { enabled: false, muted: false @@ -278,7 +278,7 @@ class MicrophoneButton extends ReactComponentBase<{ event_registry: Registry("update_microphone_state") private handleStateUpdate(state: MicrophoneState) { - this.updateState(state); + this.setState(state); } } @@ -288,7 +288,7 @@ export interface SpeakerState { @ReactEventHandler(obj => obj.props.event_registry) class SpeakerButton extends ReactComponentBase<{ event_registry: Registry }, SpeakerState> { - protected default_state(): SpeakerState { + protected defaultState(): SpeakerState { return { muted: false }; @@ -304,7 +304,7 @@ class SpeakerButton extends ReactComponentBase<{ event_registry: Registry("update_speaker_state") private handleStateUpdate(state: SpeakerState) { - this.updateState(state); + this.setState(state); } } @@ -314,7 +314,7 @@ export interface QueryState { @ReactEventHandler(obj => obj.props.event_registry) class QueryButton extends ReactComponentBase<{ event_registry: Registry }, QueryState> { - protected default_state() { + protected defaultState() { return { queryShown: false }; @@ -340,7 +340,7 @@ class QueryButton extends ReactComponentBase<{ event_registry: Registry("update_query_state") private handleStateUpdate(state: QueryState) { - this.updateState(state); + this.setState(state); } } @@ -352,7 +352,7 @@ export interface HostButtonState { @ReactEventHandler(obj => obj.props.event_registry) class HostButton extends ReactComponentBase<{ event_registry: Registry }, HostButtonState> { - protected default_state() { + protected defaultState() { return { url: undefined, target_url: undefined @@ -382,7 +382,7 @@ class HostButton extends ReactComponentBase<{ event_registry: Registry("update_host_button") private handleStateUpdate(state: HostButtonState) { - this.updateState(state); + this.setState(state); } } @@ -446,6 +446,7 @@ export class ControlBar extends React.Component { const events = target.events(); events.off("notify_state_updated", this.connection_handler_callbacks.notify_state_updated); events.off("notify_connection_state_changed", this.connection_handler_callbacks.notify_connection_state_changed); + //FIXME: Add the host button here! } private registerConnectionHandlerEvents(target: ConnectionHandler) { @@ -455,7 +456,6 @@ export class ControlBar extends React.Component { } componentDidMount(): void { - console.error(server_connections.events()); server_connections.events().on("notify_active_handler_changed", this.connection_manager_callbacks.active_handler_changed); this.event_registry.fire("set_connection_handler", { handler: server_connections.active_connection() }); } diff --git a/shared/js/ui/frames/side/conversations.ts b/shared/js/ui/frames/side/conversations.ts index b2a95fda..58c27cab 100644 --- a/shared/js/ui/frames/side/conversations.ts +++ b/shared/js/ui/frames/side/conversations.ts @@ -140,8 +140,12 @@ export class Conversation { this._first_unread_message = undefined; const ctree = this.handle.handle.handle.channelTree; - if(ctree && ctree.tag_tree()) - ctree.tag_tree().find(".marker-text-unread[conversation='" + this.channel_id + "']").addClass("hidden"); + if(ctree && ctree.tag_tree()) { + if(this.channel_id === 0) + ctree.server.setUnread(false); + else + ctree.findChannel(this.channel_id).setUnread(false); + } } this._first_unread_message_pointer.html_element.detach(); } @@ -276,6 +280,9 @@ export class Conversation { return; /* we already have that message */ } } + if(this._last_messages.length === 0) + _new_message = true; + if(!spliced && this._last_messages.length < this._view_max_messages) { this._last_messages.push(message); } diff --git a/shared/js/ui/frames/side/music_info.ts b/shared/js/ui/frames/side/music_info.ts index 01f6d3a8..f417f9aa 100644 --- a/shared/js/ui/frames/side/music_info.ts +++ b/shared/js/ui/frames/side/music_info.ts @@ -1,6 +1,6 @@ import {Frame, FrameContent} from "tc-shared/ui/frames/chat_frame"; import * as events from "tc-shared/events"; -import {MusicClientEntry, SongInfo} from "tc-shared/ui/client"; +import {ClientEvents, MusicClientEntry, SongInfo} from "tc-shared/ui/client"; import {voice} from "tc-shared/connection/ConnectionBase"; import PlayerState = voice.PlayerState; import {LogCategory} from "tc-shared/log"; @@ -328,9 +328,9 @@ export class MusicInfo { }); /* bot property listener */ - const callback_property = event => this.events.fire("bot_property_update", { properties: event.properties }); - const callback_time_update = event => this.events.fire("player_time_update", event); - const callback_song_change = event => this.events.fire("player_song_change", event); + const callback_property = (event: ClientEvents["notify_properties_updated"]) => this.events.fire("bot_property_update", { properties: Object.keys(event.updated_properties) }); + const callback_time_update = (event: ClientEvents["music_status_update"]) => this.events.fire("player_time_update", event, true); + const callback_song_change = (event: ClientEvents["music_song_change"]) => this.events.fire("player_song_change", event, true); this.events.on("bot_change", event => { if(event.old) { event.old.events.off(callback_property); @@ -339,7 +339,7 @@ export class MusicInfo { event.old.events.disconnect_all(this.events); } if(event.new) { - event.new.events.on("property_update", callback_property); + event.new.events.on("notify_properties_updated", callback_property); event.new.events.on("music_status_update", callback_time_update); event.new.events.on("music_song_change", callback_song_change); diff --git a/shared/js/ui/frames/side/private_conversations.ts b/shared/js/ui/frames/side/private_conversations.ts index a441ffec..8ab94587 100644 --- a/shared/js/ui/frames/side/private_conversations.ts +++ b/shared/js/ui/frames/side/private_conversations.ts @@ -543,7 +543,7 @@ export class PrivateConveration { } else { const ctree = this.handle.handle.handle.channelTree; if(ctree && ctree.tag_tree() && this.client_id) - ctree.tag_tree().find(".marker-text-unread[private-conversation='" + this.client_id + "']").addClass("hidden"); + ctree.findClient(this.client_id)?.setUnread(false); if(this._spacer_unread_message) { this._destroy_view_entry(this._spacer_unread_message.tag_unread); diff --git a/shared/js/ui/modal/ModalBookmarks.ts b/shared/js/ui/modal/ModalBookmarks.ts index fa36810d..434694f6 100644 --- a/shared/js/ui/modal/ModalBookmarks.ts +++ b/shared/js/ui/modal/ModalBookmarks.ts @@ -8,7 +8,7 @@ import { save_bookmark } from "tc-shared/bookmarks"; import {connection_log, Regex} from "tc-shared/ui/modal/ModalConnect"; -import {IconManager} from "tc-shared/FileManager"; +import {icon_cache_loader, IconManager} from "tc-shared/FileManager"; import {profiles} from "tc-shared/profiles/ConnectionProfile"; import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo"; import {Settings, settings} from "tc-shared/settings"; @@ -147,7 +147,7 @@ export function spawnBookmarkModal() { const bookmark = entry as Bookmark; container.append( bookmark.last_icon_id ? - IconManager.generate_tag(IconManager.load_cached_icon(bookmark.last_icon_id || 0), {animate: false}) : + IconManager.generate_tag(icon_cache_loader.load_icon(bookmark.last_icon_id, bookmark.last_icon_server_id), {animate: false}) : $.spawn("div").addClass("icon-container icon_em") ); } else { diff --git a/shared/js/ui/modal/ModalConnect.ts b/shared/js/ui/modal/ModalConnect.ts index c149d016..ec941542 100644 --- a/shared/js/ui/modal/ModalConnect.ts +++ b/shared/js/ui/modal/ModalConnect.ts @@ -5,7 +5,7 @@ import * as loader from "tc-loader"; import {createModal} from "tc-shared/ui/elements/Modal"; import {ConnectionProfile, default_profile, find_profile, profiles} from "tc-shared/profiles/ConnectionProfile"; import {KeyCode} from "tc-shared/PPTListener"; -import {IconManager} from "tc-shared/FileManager"; +import {icon_cache_loader, IconManager} from "tc-shared/FileManager"; import * as i18nc from "tc-shared/i18n/country"; import {spawnSettingsModal} from "tc-shared/ui/modal/ModalSettings"; import {server_connections} from "tc-shared/ui/frames/connection_handlers"; @@ -15,7 +15,10 @@ export namespace connection_log { //TODO: Save password data export type ConnectionData = { name: string; + icon_id: number; + server_unique_id: string; + country: string; clients_online: number; clients_total: number; @@ -45,6 +48,7 @@ export namespace connection_log { country: 'unknown', name: 'Unknown', icon_id: 0, + server_unique_id: "unknown", total_connection: 0, flag_password: false, @@ -289,7 +293,7 @@ export function spawnConnectModal(options: { }) ).append( $.spawn("div").addClass("column name").append([ - IconManager.generate_tag(IconManager.load_cached_icon(entry.icon_id)), + IconManager.generate_tag(icon_cache_loader.load_icon(entry.icon_id, entry.server_unique_id)), $.spawn("a").text(entry.name) ]) ).append( diff --git a/shared/js/ui/modal/permission/HTMLPermissionEditor.ts b/shared/js/ui/modal/permission/HTMLPermissionEditor.ts index 262a5e7d..34a56cc7 100644 --- a/shared/js/ui/modal/permission/HTMLPermissionEditor.ts +++ b/shared/js/ui/modal/permission/HTMLPermissionEditor.ts @@ -10,7 +10,7 @@ import * as contextmenu from "tc-shared/ui/elements/ContextMenu"; import {createInfoModal} from "tc-shared/ui/elements/Modal"; import {copy_to_clipboard} from "tc-shared/utils/helpers"; import PermissionType from "tc-shared/permission/PermissionType"; -import {IconManager} from "tc-shared/FileManager"; +import {icon_cache_loader, IconManager} from "tc-shared/FileManager"; import {LogCategory} from "tc-shared/log"; import * as log from "tc-shared/log"; import { @@ -712,7 +712,7 @@ export class HTMLPermissionEditor extends AbstractPermissionEditor { let resolve: Promise>; if(icon_id >= 0 && icon_id <= 1000) - resolve = Promise.resolve(IconManager.generate_tag({id: icon_id, url: ""})); + resolve = Promise.resolve(IconManager.generate_tag(icon_cache_loader.load_icon(icon_id, "general"))); else resolve = this.icon_resolver(permission ? permission.get_value() : 0).then(e => $(e)); diff --git a/shared/js/ui/modal/permission/ModalPermissionEdit.ts b/shared/js/ui/modal/permission/ModalPermissionEdit.ts index 538f28de..a2c6546f 100644 --- a/shared/js/ui/modal/permission/ModalPermissionEdit.ts +++ b/shared/js/ui/modal/permission/ModalPermissionEdit.ts @@ -64,18 +64,18 @@ export function spawnPermissionEdit(connection: Conne const permission_editor: AbstractPermissionEditor = (() => { const editor = new HTMLPermissionEditor(); editor.initialize(connection.permissions.groupedPermissions()); - editor.icon_resolver = id => connection.fileManager.icons.resolve_icon(id).then(async icon => { - if(!icon) - return undefined; + editor.icon_resolver = async id => { + const icon = connection.fileManager.icons.load_icon(id); + await icon.await_loading(); const tag = document.createElement("img"); await new Promise((resolve, reject) => { tag.onerror = reject; tag.onload = resolve; - tag.src = icon.url; + tag.src = icon.loaded_url || "nope"; }); return tag; - }); + }; editor.icon_selector = current_icon => new Promise(resolve => { spawnIconSelect(connection, id => resolve(new Int32Array([id])[0]), current_icon); }); diff --git a/shared/js/ui/modal/settings/Keymap.tsx b/shared/js/ui/modal/settings/Keymap.tsx index c65ae536..c44f0aed 100644 --- a/shared/js/ui/modal/settings/Keymap.tsx +++ b/shared/js/ui/modal/settings/Keymap.tsx @@ -61,7 +61,7 @@ interface KeyActionEntryProperties { @ReactEventHandler(e => e.props.eventRegistry) class KeyActionEntry extends ReactComponentBase { - protected default_state() : KeyActionEntryState { + protected defaultState() : KeyActionEntryState { return { assignedKey: undefined, selected: false, @@ -133,7 +133,7 @@ class KeyActionEntry extends ReactComponentBase("set_selected_action") private handleSelectedChange(event: KeyMapEvents["set_selected_action"]) { - this.updateState({ + this.setState({ selected: this.props.action === event.action }); } @@ -143,7 +143,7 @@ class KeyActionEntry extends ReactComponentBase("set_keymap_result") @@ -177,12 +177,12 @@ class KeyActionEntry extends ReactComponentBase { - protected default_state(): { collapsed: boolean } { + protected defaultState(): { collapsed: boolean } { return { collapsed: false } } @@ -213,7 +213,7 @@ class KeyActionGroup extends ReactComponentBase { - protected default_state(): {} { + protected defaultState(): {} { return {}; } @@ -251,7 +251,7 @@ interface ButtonBarState { @ReactEventHandler(e => e.props.event_registry) class ButtonBar extends ReactComponentBase<{ event_registry: Registry }, ButtonBarState> { - protected default_state(): ButtonBarState { + protected defaultState(): ButtonBarState { return { active_action: undefined, loading: true, @@ -271,7 +271,7 @@ class ButtonBar extends ReactComponentBase<{ event_registry: Registry("set_selected_action") private handleSetSelectedAction(event: KeyMapEvents["set_selected_action"]) { - this.updateState({ + this.setState({ active_action: event.action, loading: true }, () => { @@ -281,7 +281,7 @@ class ButtonBar extends ReactComponentBase<{ event_registry: Registry("query_keymap_result") private handleQueryKeymapResult(event: KeyMapEvents["query_keymap_result"]) { - this.updateState({ + this.setState({ loading: false, has_key: event.status === "success" && !!event.key }); diff --git a/shared/js/ui/react-elements/Button.tsx b/shared/js/ui/react-elements/Button.tsx index a6ccae44..bc09085b 100644 --- a/shared/js/ui/react-elements/Button.tsx +++ b/shared/js/ui/react-elements/Button.tsx @@ -16,7 +16,7 @@ export interface ButtonState { } export class Button extends ReactComponentBase { - protected default_state(): ButtonState { + protected defaultState(): ButtonState { return { disabled: undefined }; diff --git a/shared/js/ui/react-elements/Icon.tsx b/shared/js/ui/react-elements/Icon.tsx index 0e09e627..2e6c0068 100644 --- a/shared/js/ui/react-elements/Icon.tsx +++ b/shared/js/ui/react-elements/Icon.tsx @@ -1,35 +1,63 @@ import * as React from "react"; +import {LocalIcon} from "tc-shared/FileManager"; export interface IconProperties { - icon: string | JQuery; + icon: string | LocalIcon; + title?: string; } export class IconRenderer extends React.Component { - private readonly icon_ref: React.RefObject; + render() { + if(!this.props.icon) + return
; + else if(typeof this.props.icon === "string") + return
; + else if(this.props.icon instanceof LocalIcon) + return ; + else throw "JQuery icons are not longer supported"; + } +} + +export interface LoadedIconRenderer { + icon: LocalIcon; + title?: string; +} + +export class LocalIconRenderer extends React.Component { + private readonly callback_state_update; constructor(props) { super(props); - if(typeof this.props.icon === "object") - this.icon_ref = React.createRef(); + this.callback_state_update = () => { + const icon = this.props.icon; + if(icon.status !== "destroyed") + this.forceUpdate(); + }; } render() { - if(!this.props.icon) - return
; - else if(typeof this.props.icon === "string") - return
; - - - return
; + const icon = this.props.icon; + if(icon.status === "loaded") { + if(icon.icon_id >= 0 && icon.icon_id <= 1000) { + if(icon.icon_id === 0) + return
; + return
; + } + return
{this.props.title
; + } else if(icon.status === "loading") + return
; + else if(icon.status === "error") + return
; + else if(icon.status === "empty" || icon.status === "destroyed") + return
; } componentDidMount(): void { - if(this.icon_ref) - $(this.icon_ref.current).replaceWith(this.props.icon); + this.props.icon.status_change_callbacks.push(this.callback_state_update); } + componentWillUnmount(): void { - if(this.icon_ref) - $(this.icon_ref.current).empty(); + this.props.icon.status_change_callbacks.remove(this.callback_state_update); } } \ No newline at end of file diff --git a/shared/js/ui/react-elements/ReactComponentBase.ts b/shared/js/ui/react-elements/ReactComponentBase.ts index 48cdab1e..4f89538e 100644 --- a/shared/js/ui/react-elements/ReactComponentBase.ts +++ b/shared/js/ui/react-elements/ReactComponentBase.ts @@ -1,22 +1,100 @@ import * as React from "react"; +import * as ReactDOM from "react-dom"; + +export enum BatchUpdateType { + UNSET = -1, + GENERAL = 0, + CHANNEL_TREE = 1 +} + +interface UpdateBatch { + enabled: boolean; + enable_count: number; + + update: { + c: any; + s: any; + b: () => void; + }[]; + + force: { + c: any; + b: () => void; + }[]; +} + +const generate_batch = () => { return { enabled: false, enable_count: 0, update: [], force: [] }}; +let update_batches: {[key: number]:UpdateBatch} = { + 0: generate_batch(), + 1: generate_batch() +}; + +export function BatchUpdateAssignment(type: BatchUpdateType) { + return function (constructor: Function) { + if(!ReactComponentBase.prototype.isPrototypeOf(constructor.prototype)) + throw "Class/object isn't an instance of ReactComponentBase"; + + const didMount = constructor.prototype.componentDidMount; + constructor.prototype.componentDidMount = function() { + if(typeof this.update_batch === "undefined") + this.update_batch = update_batches[type]; + + if(typeof didMount === "function") + didMount.call(this, arguments); + }; + } +} export abstract class ReactComponentBase extends React.Component { + private update_batch: UpdateBatch; + + private batch_component_id: number; + private batch_component_force_id: number; + constructor(props: Properties) { super(props); + this.batch_component_id = -1; + this.batch_component_force_id = -1; - this.state = this.default_state(); + this.state = this.defaultState(); this.initialize(); } protected initialize() { } - protected abstract default_state() : State; + protected defaultState() : State { return {} as State; } - updateState(updates: {[key in keyof State]?: State[key]}, callback?: () => void) { - if(Object.keys(updates).findIndex(e => updates[e] !== this.state[e]) === -1) { - if(callback) setTimeout(callback, 0); - return; /* no state has been changed */ + setState( + state: ((prevState: Readonly, props: Readonly) => (Pick | State | null)) | (Pick | State | null), + callback?: () => void + ): void { + if(typeof this.update_batch !== "undefined" && this.update_batch.enabled) { + const obj = { + c: this, + s: Object.assign(this.update_batch.update[this.batch_component_id]?.s || {}, state), + b: callback + }; + if(this.batch_component_id === -1) + this.batch_component_id = this.update_batch.update.push(obj) - 1; + else + this.update_batch.update[this.batch_component_id] = obj; + } else { + super.setState(state, callback); + } + } + + forceUpdate(callback?: () => void): void { + if(typeof this.update_batch !== "undefined" && this.update_batch.enabled) { + const obj = { + c: this, + b: callback + }; + if(this.batch_component_force_id === -1) + this.batch_component_force_id = this.update_batch.force.push(obj) - 1; + else + this.update_batch.force[this.batch_component_force_id] = obj; + } else { + super.forceUpdate(callback); } - this.setState(Object.assign(this.state, updates), callback); } protected classList(...classes: (string | undefined)[]) { @@ -29,4 +107,45 @@ export abstract class ReactComponentBase extends React.Compon return Array.isArray(this.props.children) ? this.props.children.length > 0 : true; } +} + +export function batch_updates(type: BatchUpdateType) { + const batch = update_batches[type]; + if(typeof batch === "undefined") throw "unknown batch type"; + + batch.enabled = true; + batch.enable_count++; +} + +export function flush_batched_updates(type: BatchUpdateType, force?: boolean) { + const batch = update_batches[type]; + if(typeof batch === "undefined") throw "unknown batch type"; + if(--batch.enable_count > 0 && !force) return; + if(batch.enable_count < 0) throw "flush_batched_updates called more than batch_updates!"; + + const updates = batch.update; + const forces = batch.force; + + batch.update = []; + batch.force = []; + batch.enabled = batch.enable_count > 0; + + ReactDOM.unstable_batchedUpdates(() => { + { + let index = updates.length; + while(index--) { /* fastest way to iterate */ + const update = updates[index]; + update.c.batch_component_id = -1; + update.c.setState(update.s, update.b); + } + } + { + let index = forces.length; + while(index--) { /* fastest way to iterate */ + const update = forces[index]; + update.c.batch_component_force_id = -1; + update.c.forceUpdate(update.b); + } + } + }); } \ No newline at end of file diff --git a/shared/js/ui/server.ts b/shared/js/ui/server.ts index aef16da4..5aa867f0 100644 --- a/shared/js/ui/server.ts +++ b/shared/js/ui/server.ts @@ -14,6 +14,10 @@ import {server_connections} from "tc-shared/ui/frames/connection_handlers"; import {connection_log} from "tc-shared/ui/modal/ModalConnect"; import * as top_menu from "./frames/MenuBar"; import {control_bar_instance} from "tc-shared/ui/frames/control-bar"; +import { ServerEntry as ServerEntryView } from "./tree/Server"; +import * as React from "react"; +import {Registry} from "tc-shared/events"; +import {ChannelTreeEntry, ChannelTreeEntryEvents} from "tc-shared/ui/TreeEntry"; export class ServerProperties { virtualserver_host: string = ""; @@ -122,11 +126,21 @@ export interface ServerAddress { port: number; } -export class ServerEntry { +export interface ServerEvents extends ChannelTreeEntryEvents { + notify_properties_updated: { + updated_properties: {[Key in keyof ServerProperties]: ServerProperties[Key]}; + server_properties: ServerProperties + } +} + +export class ServerEntry extends ChannelTreeEntry { remote_address: ServerAddress; channelTree: ChannelTree; properties: ServerProperties; + readonly events: Registry; + readonly view: React.Ref; + private info_request_promise: Promise = undefined; private info_request_promise_resolve: any = undefined; private info_request_promise_reject: any = undefined; @@ -138,56 +152,22 @@ export class ServerEntry { lastInfoRequest: number = 0; nextInfoRequest: number = 0; - private _htmlTag: JQuery; private _destroyed = false; constructor(tree, name, address: ServerAddress) { + super(); + + this.events = new Registry(); + this.view = React.createRef(); + this.properties = new ServerProperties(); this.channelTree = tree; - this.remote_address = Object.assign({}, address); /* close the address because it might get changed due to the DNS resolve */ + this.remote_address = Object.assign({}, address); /* copy the address because it might get changed due to the DNS resolve */ this.properties.virtualserver_name = name; } - get htmlTag() { - if(this._destroyed) throw "destoryed"; - if(this._htmlTag) return this._htmlTag; - - let tag = $.spawn("div").addClass("tree-entry server"); - - /* unread marker */ - { - tag.append( - $.spawn("div") - .addClass("marker-text-unread hidden") - .attr("conversation", 0) - ); - } - - tag.append( - $.spawn("div") - .addClass("server_type icon client-server_green") - ); - - tag.append( - $.spawn("div") - .addClass("name") - .text(this.properties.virtualserver_name) - ); - - tag.append( - $.spawn("div") - .addClass("icon_property icon_empty") - ); - - return this._htmlTag = tag; - } - destroy() { this._destroyed = true; - if(this._htmlTag) { - this._htmlTag.remove(); - this._htmlTag = undefined; - } this.info_request_promise = undefined; this.info_request_promise_resolve = undefined; this.info_request_promise_reject = undefined; @@ -196,33 +176,22 @@ export class ServerEntry { this.remote_address = undefined; } - initializeListener(){ - this._htmlTag.on('click' ,() => { - this.channelTree.onSelect(this); - this.updateProperties(); /* just prepare to show some server info */ - }); + protected onSelect(singleSelect: boolean) { + super.onSelect(singleSelect); + if(!singleSelect) return; - if(!settings.static(Settings.KEY_DISABLE_CONTEXT_MENU, false)) { - this.htmlTag.on("contextmenu", (event) => { - event.preventDefault(); - if($.isArray(this.channelTree.currently_selected)) { //Multiselect - (this.channelTree.currently_selected_context_callback || ((_) => null))(event); - return; - } - - this.channelTree.onSelect(this, true); - this.spawnContextMenu(event.pageX, event.pageY, () => { this.channelTree.onSelect(undefined, true); }); - }); + if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) { + this.channelTree.client.side_bar.channel_conversations().set_current_channel(0); + this.channelTree.client.side_bar.show_channel_conversations(); } } - spawnContextMenu(x: number, y: number, on_close: () => void = () => {}) { - let trigger_close = true; - contextmenu.spawn_context_menu(x, y, { + contextMenuItems() : contextmenu.MenuEntry[] { + return [ + { type: contextmenu.MenuEntryType.ENTRY, name: tr("Show server info"), callback: () => { - trigger_close = false; openServerInfo(this); }, icon_class: "client-about" @@ -276,8 +245,13 @@ export class ServerEntry { name: tr("View avatars"), visible: false, //TODO: Enable again as soon the new design is finished callback: () => spawnAvatarList(this.channelTree.client) - }, - contextmenu.Entry.CLOSE(() => trigger_close ? on_close() : {}) + } + ]; + } + + spawnContextMenu(x: number, y: number, on_close: () => void = () => {}) { + contextmenu.spawn_context_menu(x, y, ...this.contextMenuItems(), + contextmenu.Entry.CLOSE(on_close) ); } @@ -295,12 +269,11 @@ export class ServerEntry { log.table(LogType.DEBUG, LogCategory.PERMISSIONS, "Server update properties", entries); } - let update_bannner = false, update_button = false; + let update_bannner = false, update_button = false, update_bookmarks = false; for(let variable of variables) { JSON.map_field_to(this.properties, variable.value, variable.key); if(variable.key == "virtualserver_name") { - this.htmlTag.find(".name").text(variable.value); this.channelTree.client.tag_connection_handler.find(".server-name").text(variable.value); server_connections.update_ui(); } else if(variable.key == "virtualserver_icon_id") { @@ -308,30 +281,38 @@ export class ServerEntry { * ATTENTION: This is required! */ this.properties.virtualserver_icon_id = variable.value as any >>> 0; - - const bmarks = bookmarks.bookmarks_flat() - .filter(e => e.server_properties.server_address === this.remote_address.host && e.server_properties.server_port == this.remote_address.port) - .filter(e => e.last_icon_id !== this.properties.virtualserver_icon_id); - if(bmarks.length > 0) { - bmarks.forEach(e => { - e.last_icon_id = this.properties.virtualserver_icon_id; - }); - bookmarks.save_bookmark(); - top_menu.rebuild_bookmarks(); - - control_bar_instance()?.events().fire("update_state", { state: "bookmarks" }); - } - - if(this.channelTree.client.fileManager && this.channelTree.client.fileManager.icons) - this.htmlTag.find(".icon_property").replaceWith(this.channelTree.client.fileManager.icons.generateTag(this.properties.virtualserver_icon_id).addClass("icon_property")); + update_bookmarks = true; } else if(variable.key.indexOf('hostbanner') != -1) { update_bannner = true; } else if(variable.key.indexOf('hostbutton') != -1) { update_button = true; } } + { + let properties = {}; + for(const property of variables) + properties[property.key] = this.properties[property.key]; + this.events.fire("notify_properties_updated", { updated_properties: properties as any, server_properties: this.properties }); + } + if(update_bookmarks) { + const bmarks = bookmarks.bookmarks_flat() + .filter(e => e.server_properties.server_address === this.remote_address.host && e.server_properties.server_port == this.remote_address.port) + .filter(e => e.last_icon_id !== this.properties.virtualserver_icon_id || e.last_icon_server_id !== this.properties.virtualserver_unique_identifier); + if(bmarks.length > 0) { + bmarks.forEach(e => { + e.last_icon_id = this.properties.virtualserver_icon_id; + e.last_icon_server_id = this.properties.virtualserver_unique_identifier; + }); + bookmarks.save_bookmark(); + top_menu.rebuild_bookmarks(); + + control_bar_instance()?.events().fire("update_state", { state: "bookmarks" }); + } + } + if(update_bannner) this.channelTree.client.hostbanner.update(); + if(update_button) control_bar_instance()?.events().fire("server_updated", { handler: this.channelTree.client, category: "hostbanner" }); @@ -353,6 +334,7 @@ export class ServerEntry { flag_password: this.properties.virtualserver_flag_password, name: this.properties.virtualserver_name, icon_id: this.properties.virtualserver_icon_id, + server_unique_id: this.properties.virtualserver_unique_identifier, password_hash: undefined /* we've here no clue */ }); @@ -413,7 +395,11 @@ export class ServerEntry { return this.properties.virtualserver_uptime + (new Date().getTime() - this.lastInfoRequest) / 1000; } - set flag_text_unread(flag: boolean) { - this._htmlTag.find(".marker-text-unread").toggleClass("hidden", !flag); + reset() { + this.properties = new ServerProperties(); + this._info_connection_promise = undefined; + this._info_connection_promise_reject = undefined; + this._info_connection_promise_resolve = undefined; + this._info_connection_promise_timestamp = undefined; } } \ No newline at end of file diff --git a/shared/js/ui/tree/Channel.scss b/shared/js/ui/tree/Channel.scss new file mode 100644 index 00000000..0f0c6190 --- /dev/null +++ b/shared/js/ui/tree/Channel.scss @@ -0,0 +1,99 @@ +.channelEntry { + position: relative; + + display: flex; + flex-direction: row; + justify-content: stretch; + + width: 100%; + min-height: 16px; + + align-items: center; + cursor: pointer; + + .channelType { + flex-grow: 0; + flex-shrink: 0; + + margin-right: 2px; + } + + .containerChannelName { + display: flex; + flex-direction: row; + + flex-grow: 1; + flex-shrink: 1; + + justify-content: left; + + max-width: 100%; /* important for the repetitive channel name! */ + overflow-x: hidden; + height: 16px; + + &.align-right { + justify-content: right; + } + + &.align-center, &.align-repetitive { + justify-content: center; + } + + .channelName { + align-self: center; + color: var(--channel-tree-entry-color); + + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &.align-repetitive { + .channelName { + text-overflow: clip; + } + } + } + + .icons { + display: flex; + flex-direction: row; + + padding-right: 5px; + + flex-grow: 0; + flex-shrink: 0; + } + + &.moveSelected { + border-bottom: 1px solid black; + } + + .showChannelNormalOnly { + display: none; + + &.channelNormal { + display: block; + } + } + + .icon_no_sound { + z-index: 0; + + display: flex; + position: relative; + + .background { + height: 14px; + width: 10px; + + background: red; + position: absolute; + + top: 1px; + left: 3px; + z-index: -1; + } + } +} \ No newline at end of file diff --git a/shared/js/ui/tree/Channel.tsx b/shared/js/ui/tree/Channel.tsx new file mode 100644 index 00000000..7ed79488 --- /dev/null +++ b/shared/js/ui/tree/Channel.tsx @@ -0,0 +1,286 @@ +import { + BatchUpdateAssignment, + BatchUpdateType, + ReactComponentBase +} from "tc-shared/ui/react-elements/ReactComponentBase"; +import * as React from "react"; +import {ChannelEntry as ChannelEntryController, ChannelEvents, ChannelProperties} from "../channel"; +import {LocalIconRenderer} from "tc-shared/ui/react-elements/Icon"; +import {EventHandler, ReactEventHandler} from "tc-shared/events"; +import {Settings, settings} from "tc-shared/settings"; +import {TreeEntry, UnreadMarker} from "tc-shared/ui/tree/TreeEntry"; + +const channelStyle = require("./Channel.scss"); +const viewStyle = require("./View.scss"); + +interface ChannelEntryIconsProperties { + channel: ChannelEntryController; +} + +interface ChannelEntryIconsState { + icons_shown: boolean; + + is_default: boolean; + is_password_protected: boolean; + is_music_quality: boolean; + is_moderated: boolean; + is_codec_supported: boolean; + + custom_icon_id: number; +} + +@ReactEventHandler(e => e.props.channel.events) +@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) +class ChannelEntryIcons extends ReactComponentBase { + private static readonly SimpleIcon = (props: { iconClass: string, title: string }) => { + return
+ }; + + protected defaultState(): ChannelEntryIconsState { + const properties = this.props.channel.properties; + const server_connection = this.props.channel.channelTree.client.serverConnection; + + return { + icons_shown: this.props.channel.parsed_channel_name.alignment === "normal", + custom_icon_id: properties.channel_icon_id, + is_music_quality: properties.channel_codec === 3 || properties.channel_codec === 5, + is_codec_supported: server_connection.support_voice() && server_connection.voice_connection().decoding_supported(properties.channel_codec), + is_default: properties.channel_flag_default, + is_password_protected: properties.channel_flag_password, + is_moderated: properties.channel_needed_talk_power !== 0 + } + } + + render() { + let icons = []; + + if(!this.state.icons_shown) + return null; + + if(this.state.is_default) + icons.push(); + + if(this.state.is_password_protected) + icons.push(); //TODO: "client-register" is really the right icon? + + if(this.state.is_music_quality) + icons.push(); + + if(this.state.is_moderated) + icons.push(); + + if(this.state.custom_icon_id) + icons.push(); + + if(!this.state.is_codec_supported) { + icons.push(
+
+
+
); + } + + return + {icons} + + } + + @EventHandler("notify_properties_updated") + private handlePropertiesUpdate(event: ChannelEvents["notify_properties_updated"]) { + if(typeof event.updated_properties.channel_icon_id !== "undefined") + this.setState({ custom_icon_id: event.updated_properties.channel_icon_id }); + + if(typeof event.updated_properties.channel_codec !== "undefined" || typeof event.updated_properties.channel_codec_quality !== "undefined") { + const codec = event.channel_properties.channel_codec; + this.setState({ is_music_quality: codec === 3 || codec === 5 }); + } + + if(typeof event.updated_properties.channel_codec !== "undefined") { + const server_connection = this.props.channel.channelTree.client.serverConnection; + this.setState({ is_codec_supported: server_connection.support_voice() && server_connection.voice_connection().decoding_supported(event.channel_properties.channel_codec) }); + } + + if(typeof event.updated_properties.channel_flag_default !== "undefined") + this.setState({ is_default: event.updated_properties.channel_flag_default }); + + if(typeof event.updated_properties.channel_flag_password !== "undefined") + this.setState({ is_password_protected: event.updated_properties.channel_flag_password }); + + if(typeof event.updated_properties.channel_needed_talk_power !== "undefined") + this.setState({ is_moderated: event.channel_properties.channel_needed_talk_power !== 0 }); + + if(typeof event.updated_properties.channel_name !== "undefined") + this.setState({ icons_shown: this.props.channel.parsed_channel_name.alignment === "normal" }); + } +} + +interface ChannelEntryIconProperties { + channel: ChannelEntryController; +} + +@ReactEventHandler(e => e.props.channel.events) +@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) +class ChannelEntryIcon extends ReactComponentBase { + private static readonly IconUpdateKeys: (keyof ChannelProperties)[] = [ + "channel_name", + "channel_flag_password", + + "channel_maxclients", + "channel_flag_maxclients_unlimited", + + "channel_maxfamilyclients", + "channel_flag_maxfamilyclients_inherited", + "channel_flag_maxfamilyclients_unlimited", + ]; + + render() { + if(this.props.channel.formattedChannelName() !== this.props.channel.channelName()) + return null; + + const channel_properties = this.props.channel.properties; + + let type; + if(channel_properties.channel_flag_password === true && !this.props.channel.cached_password()) + type = "yellow"; + else if(!channel_properties.channel_flag_maxclients_unlimited && this.props.channel.clients().length >= channel_properties.channel_maxclients) + type = "red"; + else if(!channel_properties.channel_flag_maxfamilyclients_unlimited && channel_properties.channel_maxfamilyclients >= 0 && this.props.channel.clients(true).length >= channel_properties.channel_maxfamilyclients) + type = "red"; + else + type = "green"; + + return
; + } + + @EventHandler("notify_properties_updated") + private handlePropertiesUpdate(event: ChannelEvents["notify_properties_updated"]) { + for(const key of ChannelEntryIcon.IconUpdateKeys) { + if(key in event.updated_properties) { + this.forceUpdate(); + return; + } + } + } + + /* A client change may cause the channel to show another flag */ + @EventHandler("notify_clients_changed") + private handleClientsUpdated() { + this.forceUpdate(); + } + + @EventHandler("notify_cached_password_updated") + private handleCachedPasswordUpdate() { + this.forceUpdate(); + } + + @EventHandler("notify_subscribe_state_changed") + private handleSubscribeModeChanges() { + this.forceUpdate(); + } +} + +interface ChannelEntryNameProperties { + channel: ChannelEntryController; +} + +@ReactEventHandler(e => e.props.channel.events) +@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) +class ChannelEntryName extends ReactComponentBase { + + render() { + const name = this.props.channel.parsed_channel_name; + let class_name: string; + let text: string; + if(name.repetitive) { + class_name = "align-repetitive"; + text = name.text; + if(text.length) { + while(text.length < 8000) + text += text; + } + } else { + text = name.text; + class_name = "align-" + name.alignment; + } + + return
+ {text} +
; + } + + @EventHandler("notify_properties_updated") + private handlePropertiesUpdate(event: ChannelEvents["notify_properties_updated"]) { + if(typeof event.updated_properties.channel_name !== "undefined") + this.forceUpdate(); + } +} + +interface ChannelEntryViewProperties { + channel: ChannelEntryController; + depth: number; + offset: number; +} + +@ReactEventHandler(e => e.props.channel.events) +@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) +export class ChannelEntryView extends TreeEntry { + shouldComponentUpdate(nextProps: Readonly, nextState: Readonly<{}>, nextContext: any): boolean { + if(nextProps.offset !== this.props.offset) + return true; + if(nextProps.depth !== this.props.depth) + return true; + + return nextProps.channel !== this.props.channel; + } + + render() { + return
this.onMouseDown(e as any)} + onDoubleClick={() => this.onDoubleClick()} + onContextMenu={e => this.onContextMenu(e as any)} + > + + + + +
; + } + + private onMouseDown(event: MouseEvent) { + if(event.button !== 0) return; /* only left mouse clicks */ + + const channel = this.props.channel; + channel.channelTree.events.fire("action_select_entries", { + entries: [ channel ], + mode: "auto" + }); + } + + private onDoubleClick() { + const channel = this.props.channel; + if(channel.channelTree.selection.is_multi_select()) return; + + channel.joinChannel(); + } + + private onContextMenu(event: MouseEvent) { + if(settings.static(Settings.KEY_DISABLE_CONTEXT_MENU)) + return; + + event.preventDefault(); + const channel = this.props.channel; + if(channel.channelTree.selection.is_multi_select() && channel.isSelected()) + return; + + channel.channelTree.events.fire("action_select_entries", { + entries: [ channel ], + mode: "exclusive" + }); + channel.showContextMenu(event.pageX, event.pageY); + } + + @EventHandler("notify_select_state_change") + private handleSelectStateChange() { + this.forceUpdate(); + } +} \ No newline at end of file diff --git a/shared/js/ui/tree/Client.scss b/shared/js/ui/tree/Client.scss new file mode 100644 index 00000000..e06f9186 --- /dev/null +++ b/shared/js/ui/tree/Client.scss @@ -0,0 +1,104 @@ +@import "../../../css/static/mixin"; + +.clientEntry { + cursor: pointer; + + display: flex; + flex-direction: row; + + align-items: center; + + > div { + margin-right: 2px; + } + + .clientName { + line-height: 16px; + min-width: 2em; + + flex-grow: 0; + flex-shrink: 1; + + padding-right: .25em; + color: var(--channel-tree-entry-color); + + &:not(.edit) { + @include text-dotdotdot(); + } + + &.clientNameOwn { + font-weight: bold; + } + + &.edit { + width: 100%; + font-weight: normal; + + color: black; + background-color: white; + + overflow-y: hidden; + overflow-x: hidden; + } + } + + .clientAwayMessage { + color: var(--channel-tree-entry-color); + } + + .containerIcons { + margin-right: 0; /* override from previous thing */ + height: 100%; + + position: absolute; + right: 0; + padding-right: 5px; + padding-left: 4px; + + display: flex; + flex-direction: row; + + align-items: center; + + .containerIconsGroup { + display: flex; + flex-direction: row; + + .containerHroupIcon { + display: flex; + flex-direction: column; + justify-content: center; + } + } + } + + &.selected { + &:focus-within { + .containerIcons { + background-color: var(--channel-tree-entry-selected); /* overpaint the name change box */ + + padding-left: 5px; + z-index: 1001; /* show before client name */ + + height: 18px; + } + } + + .clientName { + &:focus { + position: absolute; + color: black; + + padding-top: 1px; + padding-bottom: 1px; + + z-index: 1000; + + margin-right: -10px; + margin-left: 18px; + + width: 100%; + } + } + } + } \ No newline at end of file diff --git a/shared/js/ui/tree/Client.tsx b/shared/js/ui/tree/Client.tsx new file mode 100644 index 00000000..26ea5590 --- /dev/null +++ b/shared/js/ui/tree/Client.tsx @@ -0,0 +1,432 @@ +import { + BatchUpdateAssignment, + BatchUpdateType, + ReactComponentBase +} from "tc-shared/ui/react-elements/ReactComponentBase"; +import * as React from "react"; +import { + ClientEntry as ClientEntryController, + ClientEvents, + ClientProperties, + ClientType, + LocalClientEntry, MusicClientEntry +} from "../client"; +import {EventHandler, ReactEventHandler} from "tc-shared/events"; +import {Group, GroupEvents} from "tc-shared/permission/GroupManager"; +import {Settings, settings} from "tc-shared/settings"; +import {TreeEntry, UnreadMarker} from "tc-shared/ui/tree/TreeEntry"; +import {LocalIconRenderer} from "tc-shared/ui/react-elements/Icon"; +import * as DOMPurify from "dompurify"; + +const clientStyle = require("./Client.scss"); +const viewStyle = require("./View.scss"); + +interface ClientIconProperties { + client: ClientEntryController; +} + +@ReactEventHandler(e => e.props.client.events) +@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) +class ClientSpeakIcon extends ReactComponentBase { + private static readonly IconUpdateKeys: (keyof ClientProperties)[] = [ + "client_away", + "client_input_hardware", + "client_output_hardware", + "client_output_muted", + "client_input_muted", + "client_is_channel_commander", + "client_talk_power" + ]; + + render() { + let icon: string = ""; + let clicon: string = ""; + + const client = this.props.client; + const properties = client.properties; + + if(properties.client_type_exact == ClientType.CLIENT_QUERY) { + icon = "client-server_query"; + } else { + if (properties.client_away) { + icon = "client-away"; + } else if (!client.get_audio_handle() && !(this instanceof LocalClientEntry)) { + icon = "client-input_muted_local"; + } else if(!properties.client_output_hardware) { + icon = "client-hardware_output_muted"; + } else if(properties.client_output_muted) { + icon = "client-output_muted"; + } else if(!properties.client_input_hardware) { + icon = "client-hardware_input_muted"; + } else if(properties.client_input_muted) { + icon = "client-input_muted"; + } else { + if(client.isSpeaking()) { + if(properties.client_is_channel_commander) + clicon = "client_cc_talk"; + else + clicon = "client_talk"; + } else { + if(properties.client_is_channel_commander) + clicon = "client_cc_idle"; + else + clicon = "client_idle"; + } + } + } + + if(clicon.length > 0) + return
; + else if(icon.length > 0) + return
; + else + return null; + } + + @EventHandler("notify_properties_updated") + private handlePropertiesUpdated(event: ClientEvents["notify_properties_updated"]) { + for(const key of ClientSpeakIcon.IconUpdateKeys) + if(key in event.updated_properties) { + this.forceUpdate(); + return; + } + } + + @EventHandler("notify_mute_state_change") + private handleMuteStateChange() { + this.forceUpdate(); + } + + @EventHandler("notify_speak_state_change") + private handleSpeakStateChange() { + this.forceUpdate(); + } +} + +interface ClientServerGroupIconsProperties { + client: ClientEntryController; +} + +@ReactEventHandler(e => e.props.client.events) +@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) +class ClientServerGroupIcons extends ReactComponentBase { + private subscribed_groups: Group[] = []; + private group_updated_callback; + + protected initialize() { + this.group_updated_callback = (event: GroupEvents["notify_properties_updated"]) => { + if(typeof event.updated_properties.iconid !== "undefined" || typeof event.updated_properties.sortid !== "undefined") + this.forceUpdate(); + }; + } + + private unsubscribeGroupEvents() { + this.subscribed_groups.forEach(e => e.events.off("notify_properties_updated", this.group_updated_callback)); + this.subscribed_groups = []; + } + + componentWillUnmount(): void { + this.unsubscribeGroupEvents(); + } + + render() { + this.unsubscribeGroupEvents(); + + const groups = this.props.client.assignedServerGroupIds() + .map(e => this.props.client.channelTree.client.groups.serverGroup(e)).filter(e => !!e); + if(groups.length === 0) return null; + + groups.forEach(e => { + e.events.on("notify_properties_updated", this.group_updated_callback); + this.subscribed_groups.push(e); + }); + + const group_icons = groups.filter(e => e?.properties.iconid) + .sort((a, b) => a.properties.sortid - b.properties.sortid); + if(group_icons.length === 0) return null; + return [ + group_icons.map(e => { + return ; + }) + ]; + } + + @EventHandler("notify_properties_updated") + private handlePropertiesUpdated(event: ClientEvents["notify_properties_updated"]) { + if(typeof event.updated_properties.client_servergroups) + this.forceUpdate(); + } +} + +interface ClientChannelGroupIconProperties { + client: ClientEntryController; +} + +@ReactEventHandler(e => e.props.client.events) +@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) +class ClientChannelGroupIcon extends ReactComponentBase { + private subscribed_group: Group | undefined; + private group_updated_callback; + + protected initialize() { + this.group_updated_callback = (event: GroupEvents["notify_properties_updated"]) => { + if(typeof event.updated_properties.iconid !== "undefined" || typeof event.updated_properties.sortid !== "undefined") + this.forceUpdate(); + }; + } + + private unsubscribeGroupEvent() { + this.subscribed_group?.events.off("notify_properties_updated", this.group_updated_callback); + } + + componentWillUnmount(): void { + this.unsubscribeGroupEvent(); + } + + render() { + this.unsubscribeGroupEvent(); + + const cgid = this.props.client.assignedChannelGroup(); + if(cgid === 0) return null; + + const channel_group = this.props.client.channelTree.client.groups.channelGroup(cgid); + if(!channel_group) return null; + + channel_group.events.on("notify_properties_updated", this.group_updated_callback); + this.subscribed_group = channel_group; + + if(channel_group.properties.iconid === 0) return null; + return ; + } + + @EventHandler("notify_properties_updated") + private handlePropertiesUpdated(event: ClientEvents["notify_properties_updated"]) { + if(typeof event.updated_properties.client_servergroups) + this.forceUpdate(); + } +} + +interface ClientIconsProperties { + client: ClientEntryController; +} + +@ReactEventHandler(e => e.props.client.events) +@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) +class ClientIcons extends ReactComponentBase { + render() { + const icons = []; + const talk_power = this.props.client.properties.client_talk_power; + const needed_talk_power = this.props.client.currentChannel()?.properties.channel_needed_talk_power || 0; + if(talk_power !== -1 && needed_talk_power !== 0 && needed_talk_power > talk_power) + icons.push(
); + + icons.push(); + icons.push(); + if(this.props.client.properties.client_icon_id !== 0) + icons.push(); + + return ( +
+ {icons} +
+ ) + } + + @EventHandler("notify_properties_updated") + private handlePropertiesUpdated(event: ClientEvents["notify_properties_updated"]) { + if(typeof event.updated_properties.client_channel_group_id !== "undefined" || typeof event.updated_properties.client_talk_power !== "undefined" || typeof event.updated_properties.client_icon_id !== "undefined") + this.forceUpdate(); + } +} + +interface ClientNameProperties { + client: ClientEntryController; +} + +interface ClientNameState { + group_prefix: string; + group_suffix: string; + + away_message: string; +} + +/* group prefix & suffix, away message */ +@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) +class ClientName extends ReactComponentBase { + protected defaultState(): ClientNameState { + return { + group_prefix: "", + away_message: "", + group_suffix: "" + } + } + + render() { + return
+ {this.state.group_prefix + this.props.client.clientNickName() + this.state.group_suffix + this.state.away_message} +
+ } + + @EventHandler("notify_properties_updated") + private handlePropertiesChanged(event: ClientEvents["notify_properties_updated"]) { + if(typeof event.updated_properties.client_away !== "undefined" || typeof event.updated_properties.client_away_message !== "undefined") { + this.setState({ + away_message: event.client_properties.client_away_message && " [" + event.client_properties.client_away_message + "]" + }); + } + if(typeof event.updated_properties.client_servergroups !== "undefined" || typeof event.updated_properties.client_channel_group_id !== "undefined") { + let prefix_groups: string[] = []; + let suffix_groups: string[] = []; + for(const group_id of this.props.client.assignedServerGroupIds()) { + const group = this.props.client.channelTree.client.groups.serverGroup(group_id); + if(!group) continue; + + if(group.properties.namemode == 1) + prefix_groups.push(group.name); + else if(group.properties.namemode == 2) + suffix_groups.push(group.name); + } + + const channel_group = this.props.client.channelTree.client.groups.channelGroup(this.props.client.assignedChannelGroup()); + if(channel_group) { + if(channel_group.properties.namemode == 1) + prefix_groups.push(channel_group.name); + else if(channel_group.properties.namemode == 2) + suffix_groups.splice(0, 0, channel_group.name); + } + + this.setState({ + group_suffix: suffix_groups.map(e => "[" + e + "]").join(""), + group_prefix: prefix_groups.map(e => "[" + e + "]").join("") + }) + } + } +} + +interface ClientNameEditProps { + editFinished: (new_name?: string) => void; + initialName: string; +} + +class ClientNameEdit extends ReactComponentBase { + private readonly ref_div: React.RefObject = React.createRef(); + + componentDidMount(): void { + this.ref_div.current.focus(); + } + + render() { + return
this.onBlur()} + onKeyPress={e => this.onKeyPress(e as any)} + /> + } + + private onBlur() { + this.props.editFinished(this.ref_div.current.textContent); + } + + private onKeyPress(event: KeyboardEvent) { + if(event.key === "Enter") { + event.preventDefault(); + this.onBlur(); + } + } +} + +export interface ClientEntryProperties { + client: ClientEntryController; + depth: number; + offset: number; +} + +export interface ClientEntryState { + rename: boolean; + renameInitialName?: string; +} + +@ReactEventHandler(e => e.props.client.events) +@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) +export class ClientEntry extends TreeEntry { + shouldComponentUpdate(nextProps: Readonly, nextState: Readonly, nextContext: any): boolean { + return nextState.rename !== this.state.rename || + nextProps.offset !== this.props.offset || + nextProps.client !== this.props.client || + nextProps.depth !== this.props.depth; + } + + render() { + return ( +
this.onDoubleClick()} + onMouseDown={e => this.onMouseDown(e as any)} + onContextMenu={e => this.onContextMenu(e as any)} + > + + + {this.state.rename ? + this.onEditFinished(name)} initialName={this.state.renameInitialName || this.props.client.properties.client_nickname} /> : + [, ] } +
+ ) + } + + private onDoubleClick() { + const client = this.props.client; + if(client.channelTree.selection.is_multi_select()) return; + + if(this.props.client instanceof LocalClientEntry) { + this.props.client.openRename(); + } else if(this.props.client instanceof MusicClientEntry) { + /* no action defined yet */ + } else { + this.props.client.open_text_chat(); + } + } + + private onEditFinished(new_name?: string) { + if(!(this.props.client instanceof LocalClientEntry)) + throw "Only local clients could be renamed"; + + if(new_name && new_name !== this.state.renameInitialName) { + const client = this.props.client; + client.renameSelf(new_name).then(result => { + if(!result) + this.setState({ rename: true, renameInitialName: new_name }); //TODO: Keep last name? + }); + } + this.setState({ rename: false }); + } + + private onMouseDown(event: MouseEvent) { + if(event.button !== 0) return; /* only left mouse clicks */ + + const tree = this.props.client.channelTree; + tree.events.fire("action_select_entries", { entries: [this.props.client], mode: "auto" }); + } + + private onContextMenu(event: MouseEvent) { + if(settings.static(Settings.KEY_DISABLE_CONTEXT_MENU)) + return; + + event.preventDefault(); + const client = this.props.client; + if(client.channelTree.selection.is_multi_select() && client.isSelected()) return; + + client.channelTree.events.fire("action_select_entries", { + entries: [ client ], + mode: "exclusive" + }); + client.showContextMenu(event.pageX, event.pageY); + } + + @EventHandler("notify_select_state_change") + private handleSelectChangeState() { + this.forceUpdate(); + } +} \ No newline at end of file diff --git a/shared/js/ui/tree/Server.scss b/shared/js/ui/tree/Server.scss new file mode 100644 index 00000000..667be765 --- /dev/null +++ b/shared/js/ui/tree/Server.scss @@ -0,0 +1,34 @@ +.serverEntry { + display: flex; + flex-direction: row; + justify-content: stretch; + + position: relative; + + cursor: pointer; + margin-left: 2px; + margin-right: 5px; + + .server_type { + flex-grow: 0; + flex-shrink: 0; + + margin-right: 2px; + margin-left: 2px; + + z-index: 1; + } + + .name { + flex-grow: 1; + flex-shrink: 1; + + align-self: center; + color: var(--channel-tree-entry-color); + + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} \ No newline at end of file diff --git a/shared/js/ui/tree/Server.tsx b/shared/js/ui/tree/Server.tsx new file mode 100644 index 00000000..556e6236 --- /dev/null +++ b/shared/js/ui/tree/Server.tsx @@ -0,0 +1,126 @@ +import { + BatchUpdateAssignment, + BatchUpdateType, + ReactComponentBase +} from "tc-shared/ui/react-elements/ReactComponentBase"; +import {ServerEntry as ServerEntryController, ServerEvents} from "../server"; +import * as React from "react"; +import {LocalIconRenderer} from "tc-shared/ui/react-elements/Icon"; +import {EventHandler, ReactEventHandler} from "tc-shared/events"; +import {Settings, settings} from "tc-shared/settings"; +import {TreeEntry, UnreadMarker} from "tc-shared/ui/tree/TreeEntry"; +import {ConnectionEvents, ConnectionState} from "tc-shared/ConnectionHandler"; + +const serverStyle = require("./Server.scss"); +const viewStyle = require("./View.scss"); + + +export interface ServerEntryProperties { + server: ServerEntryController; + offset: number; +} + +export interface ServerEntryState { + connection_state: "connected" | "connecting" | "disconnected"; +} + +@ReactEventHandler(e => e.props.server.events) +@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) +export class ServerEntry extends TreeEntry { + private handle_connection_state_change; + + protected defaultState(): ServerEntryState { + return { connection_state: "disconnected" }; + } + + protected initialize() { + this.handle_connection_state_change = (event: ConnectionEvents["notify_connection_state_changed"]) => { + switch (event.new_state) { + case ConnectionState.AUTHENTICATING: + case ConnectionState.CONNECTING: + case ConnectionState.INITIALISING: + this.setState({ connection_state: "connecting" }); + break; + + case ConnectionState.CONNECTED: + this.setState({ connection_state: "connected" }); + break; + + case ConnectionState.DISCONNECTING: + case ConnectionState.UNCONNECTED: + this.setState({ connection_state: "disconnected" }); + break; + } + } + } + + shouldComponentUpdate(nextProps: Readonly, nextState: Readonly, nextContext: any): boolean { + return this.state.connection_state !== nextState.connection_state || + this.props.offset !== nextProps.offset || + this.props.server !== nextProps.server; + } + + componentDidMount(): void { + this.props.server.channelTree.client.events().on("notify_connection_state_changed", this.handle_connection_state_change); + } + + componentWillUnmount(): void { + this.props.server.channelTree.client.events().off("notify_connection_state_changed", this.handle_connection_state_change); + } + + render() { + let name = this.props.server.properties.virtualserver_name; + if(this.state.connection_state === "disconnected") + name = tr("Not connected to any server"); + else if(this.state.connection_state === "connecting") + name = tr("Connecting to ") + this.props.server.remote_address.host + (this.props.server.remote_address.port !== 9987 ? ":" + this.props.server.remote_address.host : ""); + + return
this.onMouseDown(e as any)} + onContextMenu={e => this.onContextMenu(e as any)} + > + +
+
{name}
+ +
+ } + + private onMouseDown(event: MouseEvent) { + if(event.button !== 0) return; /* only left mouse clicks */ + + this.props.server.channelTree.events.fire("action_select_entries", { + entries: [ this.props.server ], + mode: "auto" + }); + } + + private onContextMenu(event: MouseEvent) { + if(settings.static(Settings.KEY_DISABLE_CONTEXT_MENU)) + return; + + event.preventDefault(); + const server = this.props.server; + if(server.channelTree.selection.is_multi_select() && server.isSelected()) + return; + + server.channelTree.events.fire("action_select_entries", { + entries: [ server ], + mode: "exclusive" + }); + server.spawnContextMenu(event.pageX, event.pageY); + } + + @EventHandler("notify_properties_updated") + private handlePropertiesUpdated(event: ServerEvents["notify_properties_updated"]) { + if(typeof event.updated_properties.virtualserver_name !== "undefined" || typeof event.updated_properties.virtualserver_icon_id !== "undefined") { + this.forceUpdate(); + } + } + + @EventHandler("notify_select_state_change") + private handleServerSelectStateChange() { + this.forceUpdate(); + } +} \ No newline at end of file diff --git a/shared/js/ui/tree/TreeEntry.tsx b/shared/js/ui/tree/TreeEntry.tsx new file mode 100644 index 00000000..efa596e1 --- /dev/null +++ b/shared/js/ui/tree/TreeEntry.tsx @@ -0,0 +1,26 @@ +import {ReactComponentBase} from "tc-shared/ui/react-elements/ReactComponentBase"; +import {ChannelTreeEntry, ChannelTreeEntryEvents} from "tc-shared/ui/TreeEntry"; +import * as React from "react"; +import {EventHandler, ReactEventHandler} from "tc-shared/events"; + +const viewStyle = require("./View.scss"); + +export interface UnreadMarkerProperties { + entry: ChannelTreeEntry; +} + +@ReactEventHandler(e => e.props.entry.events) +export class UnreadMarker extends ReactComponentBase { + render() { + if(!this.props.entry.isUnread()) + return null; + return
; + } + + @EventHandler("notify_unread_state_change") + private handleUnreadStateChange() { + this.forceUpdate(); + } +} + +export class TreeEntry extends ReactComponentBase { } \ No newline at end of file diff --git a/shared/js/ui/tree/View.scss b/shared/js/ui/tree/View.scss new file mode 100644 index 00000000..7d2bd573 --- /dev/null +++ b/shared/js/ui/tree/View.scss @@ -0,0 +1,116 @@ +@import "../../../css/static/properties"; +@import "../../../css/static/mixin"; + +html:root { + --channel-tree-entry-selected: #2d2d2d; + --channel-tree-entry-hovered: #393939; + --channel-tree-entry-color: #828282; + + --channel-tree-entry-marker-unread: rgba(168, 20, 20, 0.5); +} + +@if 0 { + /* the channel tree */ + .channel-tree { + + .tree-entry { + &.client { + + } + } + } +} + +.channelTree { + @include user-select(none); + width: 100%; + + min-width: 10em; + min-height: 5em; + + display: flex; + flex-direction: column; + + flex-shrink: 0; + flex-grow: 1; + + * { + font-family: sans-serif; + font-size: 12px; + white-space: pre; + line-height: 1; + } + + + .treeEntry { + position: absolute; + left: 0; + right: 0; + + display: flex; + flex-direction: row; + justify-content: stretch; + + height: 18px; + padding-top: 1px; + padding-bottom: 1px; + + flex-grow: 0; + flex-shrink: 0; + + &:hover { + background-color: var(--channel-tree-entry-hovered); + } + + &.selected { + background-color: var(--channel-tree-entry-selected); + } + + + .markerUnread { + position: absolute; + left: 0; + top: 0; + bottom: 0; + + width: 1px; + background-color: var(--channel-tree-entry-marker-unread); + + opacity: 1; + + &:before { + content: ''; + position: absolute; + + left: 0; + top: 0; + bottom: 0; + + width: 24px; + + background: linear-gradient(to right, var(--channel-tree-entry-marker-unread) 0%, rgba(0, 0, 0, 0) 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */ + } + + &.hidden { + opacity: 0; + } + + @include transition(opacity $button_hover_animation_time); + } + } +} + +.channelTreeContainer { + @include chat-scrollbar-vertical(); + + scroll-behavior: smooth; + + position: relative; + height: 100%; + + flex-grow: 1; + flex-shrink: 1; + + overflow: hidden; + overflow-y: auto; +} \ No newline at end of file diff --git a/shared/js/ui/tree/View.tsx b/shared/js/ui/tree/View.tsx new file mode 100644 index 00000000..aa7dcfd3 --- /dev/null +++ b/shared/js/ui/tree/View.tsx @@ -0,0 +1,231 @@ +import { + BatchUpdateAssignment, + BatchUpdateType, + ReactComponentBase +} from "tc-shared/ui/react-elements/ReactComponentBase"; +import {ChannelTree, ChannelTreeEvents} from "tc-shared/ui/view"; +import ResizeObserver from 'resize-observer-polyfill'; + +import * as React from "react"; + +import {EventHandler, ReactEventHandler} from "tc-shared/events"; + +import {ChannelEntryView as ChannelEntryView} from "./Channel"; +import {ServerEntry as ServerEntryView} from "./Server"; +import {ClientEntry as ClientEntryView} from "./Client"; + +import {ChannelEntry} from "tc-shared/ui/channel"; +import {ServerEntry} from "tc-shared/ui/server"; +import {ClientEntry, LocalClientEntry} from "tc-shared/ui/client"; + +const viewStyle = require("./View.scss"); + + +export interface ChannelTreeViewProperties { + tree: ChannelTree; +} + +export interface ChannelTreeViewState { + element_scroll_offset?: number; /* in px */ + scroll_offset: number; /* in px */ + view_height: number; /* in px */ +} + +type TreeEntry = ChannelEntry | ServerEntry | ClientEntry; +type FlatTreeEntry = { + rendered: any; + entry: TreeEntry; +} + +//TODO: Only register listeners when channel is in view ;) +@ReactEventHandler(e => e.props.tree.events) +@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) +export class ChannelTreeView extends ReactComponentBase { + private static readonly EntryHeight = 18; + + private readonly ref_container = React.createRef(); + private resize_observer: ResizeObserver; + + private flat_tree: FlatTreeEntry[] = []; + private listener_client_change; + private listener_channel_change; + private update_timeout; + + private in_view_callbacks: { + index: number, + callback: () => void, + timeout + }[] = []; + + protected defaultState(): ChannelTreeViewState { + return { + scroll_offset: 0, + view_height: 0, + }; + } + + componentDidMount(): void { + this.resize_observer = new ResizeObserver(entries => { + if(entries.length !== 1) { + if(entries.length === 0) + console.warn("Channel resize observer fired resize event with no entries!"); + else + console.warn("Channel resize observer fired resize event with more than one entry which should not be possible (%d)!", entries.length); + return; + } + const bounds = entries[0].contentRect; + if(this.state.view_height !== bounds.height) { + console.log("Handling height update and change tree height to %d from %d", bounds.height, this.state.view_height); + this.setState({ + view_height: bounds.height + }); + } + }); + this.resize_observer.observe(this.ref_container.current); + } + + componentWillUnmount(): void { + this.resize_observer.disconnect(); + this.resize_observer = undefined; + } + + protected initialize() { + (window as any).do_tree_update = () => this.handleTreeUpdate(); + this.listener_client_change = () => this.handleTreeUpdate(); + this.listener_channel_change = () => this.handleTreeUpdate(); + } + + private handleTreeUpdate() { + clearTimeout(this.update_timeout); + this.update_timeout = setTimeout(() => { + this.rebuild_tree(); + this.forceUpdate(); + }, 50); + } + + private visibleEntries() { + let view_entry_count = Math.ceil(this.state.view_height / ChannelTreeView.EntryHeight); + const view_entry_begin = Math.floor(this.state.scroll_offset / ChannelTreeView.EntryHeight); + const view_entry_end = Math.min(this.flat_tree.length, view_entry_begin + view_entry_count); + + return { + begin: view_entry_begin, + end: view_entry_end + } + } + + render() { + const entry_prerender_count = 5; + const entry_postrender_count = 5; + + const elements = []; + const renderedRange = this.visibleEntries(); + const view_entry_begin = Math.max(0, renderedRange.begin - entry_prerender_count); + const view_entry_end = Math.min(this.flat_tree.length, renderedRange.end + entry_postrender_count); + + for (let index = view_entry_begin; index < view_entry_end; index++) + elements.push(this.flat_tree[index].rendered); + + for(const callback of this.in_view_callbacks.slice(0)) { + if(callback.index >= renderedRange.begin && callback.index <= renderedRange.end) { + clearTimeout(callback.timeout); + callback.callback(); + this.in_view_callbacks.remove(callback); + } + } + + return ( +
this.onScroll()} ref={this.ref_container} > +
+ {elements} +
+
+ ) + } + + private build_top_offset: number; + private build_sub_tree(entry: ChannelEntry, depth: number) { + entry.events.on("notify_clients_changed", this.listener_client_change); + entry.events.on("notify_children_changed", this.listener_channel_change); + + this.flat_tree.push({ + entry: entry, + rendered: + }); + this.flat_tree.push(...entry.clients(false).map(e => { + return { + entry: e, + rendered: + }; + })); + for (const channel of entry.children(false)) + this.build_sub_tree(channel, depth + 1); + } + + private rebuild_tree() { + const tree = this.props.tree; + { + let index = this.flat_tree.length; + while(index--) { + const entry = this.flat_tree[index].entry; + if(entry instanceof ChannelEntry) { + entry.events.off("notify_clients_changed", this.listener_client_change); + entry.events.off("notify_children_changed", this.listener_channel_change); + } + } + } + this.build_top_offset = -ChannelTreeView.EntryHeight; /* because of the += */ + this.flat_tree = [{ + entry: tree.server, + rendered: + }]; + + for (const channel of tree.rootChannel()) + this.build_sub_tree(channel, 1); + } + + @EventHandler("notify_root_channel_changed") + private handleRootChannelChanged() { + this.handleTreeUpdate(); + } + + private onScroll() { + this.setState({ + scroll_offset: this.ref_container.current.scrollTop + }); + } + + scrollEntryInView(entry: TreeEntry, callback?: () => void) { + const index = this.flat_tree.findIndex(e => e.entry === entry); + if(index === -1) { + if(callback) callback(); + console.warn("Failed to scroll tree entry in view because its not registered within the view. Entry: %o", entry); + return; + } + + let new_index; + const currentRange = this.visibleEntries(); + if(index >= currentRange.end - 1) { + new_index = index - (currentRange.end - currentRange.begin) + 2; + } else if(index < currentRange.begin) { + new_index = index; + } else { + if(callback) callback(); + return; + } + + this.ref_container.current.scrollTop = new_index * ChannelTreeView.EntryHeight; + + if(callback) { + let cb = { + index: index, + callback: callback, + timeout: setTimeout(() => { + this.in_view_callbacks.remove(cb); + callback(); + }, (Math.abs(new_index - currentRange.begin) / (currentRange.end - currentRange.begin)) * 1500) + }; + this.in_view_callbacks.push(cb); + } + } +} \ No newline at end of file diff --git a/shared/js/ui/view.ts b/shared/js/ui/view.ts deleted file mode 100644 index e237ba3f..00000000 --- a/shared/js/ui/view.ts +++ /dev/null @@ -1,940 +0,0 @@ -import * as contextmenu from "tc-shared/ui/elements/ContextMenu"; -import * as log from "tc-shared/log"; -import {Settings, settings} from "tc-shared/settings"; -import {PermissionType} from "tc-shared/permission/PermissionType"; -import {LogCategory} from "tc-shared/log"; -import {KeyCode, SpecialKey} from "tc-shared/PPTListener"; -import {createInputModal} from "tc-shared/ui/elements/Modal"; -import {Sound} from "tc-shared/sound/Sounds"; -import {Group} from "tc-shared/permission/GroupManager"; -import * as server_log from "tc-shared/ui/frames/server_log"; -import {ServerAddress, ServerEntry} from "tc-shared/ui/server"; -import {ClientMover} from "tc-shared/ui/client_move"; -import {ChannelEntry, ChannelSubscribeMode} from "tc-shared/ui/channel"; -import {ClientEntry, ClientType, LocalClientEntry, MusicClientEntry} from "tc-shared/ui/client"; -import {ConnectionHandler, ViewReasonId} from "tc-shared/ConnectionHandler"; -import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo"; -import {formatMessage} from "tc-shared/ui/frames/chat"; -import {spawnBanClient} from "tc-shared/ui/modal/ModalBanClient"; -import {createChannelModal} from "tc-shared/ui/modal/ModalCreateChannel"; -import * as ppt from "tc-backend/ppt"; - -export class ChannelTree { - client: ConnectionHandler; - server: ServerEntry; - - channels: ChannelEntry[] = []; - clients: ClientEntry[] = []; - - currently_selected: ClientEntry | ServerEntry | ChannelEntry | (ClientEntry | ServerEntry)[] = undefined; - currently_selected_context_callback: (event) => any = undefined; - readonly client_mover: ClientMover; - - private _tag_container: JQuery; - private _tag_entries: JQuery; - - private _tree_detached: boolean = false; - private _show_queries: boolean; - private channel_last?: ChannelEntry; - private channel_first?: ChannelEntry; - - private _focused = false; - private _listener_document_click; - private _listener_document_key; - - private _scroll_bar/*: SimpleBar*/; - - constructor(client) { - this.client = client; - - this._tag_container = $.spawn("div").addClass("channel-tree-container"); - this._tag_entries = $.spawn("div").addClass("channel-tree"); - //if('SimpleBar' in window) /* for MSEdge, and may consider Firefox? */ - // this._scroll_bar = new SimpleBar(this._tag_container[0]); - - this.client_mover = new ClientMover(this); - this.reset(); - - if(!settings.static(Settings.KEY_DISABLE_CONTEXT_MENU, false)) { - this._tag_container.on("contextmenu", (event) => { - if(event.isDefaultPrevented()) return; - - for(const element of document.elementsFromPoint(event.pageX, event.pageY)) - if(element.classList.contains("channelLine") || element.classList.contains("client")) - return; - - event.preventDefault(); - if($.isArray(this.currently_selected)) { //Multiselect - (this.currently_selected_context_callback || ((_) => null))(event); - } else { - this.onSelect(undefined); - this.showContextMenu(event.pageX, event.pageY); - } - }); - } - - this._tag_container.on('resize', this.handle_resized.bind(this)); - this._listener_document_key = event => this.handle_key_press(event); - this._listener_document_click = event => { - this._focused = false; - let element = event.target as HTMLElement; - while(element) { - if(element === this._tag_container[0]) { - this._focused = true; - break; - } - element = element.parentNode as HTMLElement; - } - }; - document.addEventListener('click', this._listener_document_click); - document.addEventListener('keydown', this._listener_document_key); - } - - tag_tree() : JQuery { - return this._tag_container; - } - - destroy() { - this._listener_document_click && document.removeEventListener('click', this._listener_document_click); - this._listener_document_click = undefined; - - this._listener_document_key && document.removeEventListener('keydown', this._listener_document_key); - this._listener_document_key = undefined; - - if(this.server) { - this.server.destroy(); - this.server = undefined; - } - this.reset(); /* cleanup channel and clients */ - - this.channel_first = undefined; - this.channel_last = undefined; - - this._tag_container.remove(); - this.currently_selected = undefined; - this.currently_selected_context_callback = undefined; - } - - hide_channel_tree() { - this._tag_entries.detach(); - this._tree_detached = true; - } - - show_channel_tree() { - this._tree_detached = false; - if(this._scroll_bar) - this._tag_entries.appendTo(this._scroll_bar.getContentElement()); - else - this._tag_entries.appendTo(this._tag_container); - - this.channels.forEach(e => { - e.recalculate_repetitive_name(); - e.reorderClients(); - }); - this._scroll_bar?.recalculate(); - } - - showContextMenu(x: number, y: number, on_close: () => void = undefined) { - let channelCreate = - this.client.permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_TEMPORARY).granted(1) || - this.client.permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_SEMI_PERMANENT).granted(1) || - this.client.permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_PERMANENT).granted(1); - - contextmenu.spawn_context_menu(x, y, - { - type: contextmenu.MenuEntryType.ENTRY, - icon_class: "client-channel_create", - name: tr("Create channel"), - invalidPermission: !channelCreate, - callback: () => this.spawnCreateChannel() - }, - contextmenu.Entry.CLOSE(on_close) - ); - } - - initialiseHead(serverName: string, address: ServerAddress) { - if(this.server) { - this.server.destroy(); - this.server = undefined; - } - this.server = new ServerEntry(this, serverName, address); - this.server.htmlTag.appendTo(this._tag_entries); - this.server.initializeListener(); - } - - private __deleteAnimation(element: ChannelEntry | ClientEntry) { - let tag = element instanceof ChannelEntry ? element.rootTag() : element.tag; - tag.fadeOut("slow", () => { - tag.detach(); - element.destroy(); - }); - } - - rootChannel() : ChannelEntry[] { - return this.channels.filter(e => e.parent == undefined); - } - - deleteChannel(channel: ChannelEntry) { - const _this = this; - for(let index = 0; index < this.channels.length; index++) { - let entry = this.channels[index]; - let currentEntry = this.channels[index]; - while(currentEntry != undefined && currentEntry != null) { - if(currentEntry == channel) { - _this.channels.remove(entry); - _this.__deleteAnimation(entry); - entry.channelTree = null; - index--; - break; - } else currentEntry = currentEntry.parent_channel(); - } - } - - this.channels.remove(channel); - this.__deleteAnimation(channel); - channel.channelTree = null; - - if(channel.channel_previous) - channel.channel_previous.channel_next = channel.channel_next; - - if(channel.channel_next) - channel.channel_next.channel_previous = channel.channel_previous; - - if(channel == this.channel_first) - this.channel_first = channel.channel_next; - - if(channel == this.channel_last) - this.channel_last = channel.channel_previous; - - if(this._scroll_bar) this._scroll_bar.recalculate(); - } - - insertChannel(channel: ChannelEntry) { - channel.channelTree = this; - this.channels.push(channel); - - let elm = undefined; - let tag = this._tag_entries; - - let previous_channel = null; - if(channel.hasParent()) { - let parent = channel.parent_channel(); - let siblings = parent.children(); - if(siblings.length == 0) { - elm = parent.rootTag(); - previous_channel = null; - } else { - previous_channel = siblings.last(); - elm = previous_channel.tag; - } - tag = parent.siblingTag(); - } else { - previous_channel = this.channel_last; - - if(!this.channel_last) - this.channel_last = channel; - - if(!this.channel_first) - this.channel_first = channel; - } - - channel.channel_previous = previous_channel; - channel.channel_next = undefined; - - if(previous_channel) { - channel.channel_next = previous_channel.channel_next; - previous_channel.channel_next = channel; - - if(channel.channel_next) - channel.channel_next.channel_previous = channel; - } - - let entry = channel.rootTag(); - if(!this._tree_detached) - entry.css({display: "none"}).fadeIn("slow"); - entry.appendTo(tag); - - if(elm != undefined) - elm.after(entry); - - if(channel.channel_previous == channel) /* shall never happen */ - channel.channel_previous = undefined; - if(channel.channel_next == channel) /* shall never happen */ - channel.channel_next = undefined; - - channel.initializeListener(); - channel.update_family_index(); - if(this._scroll_bar) this._scroll_bar.recalculate(); - } - - findChannel(channelId: number) : ChannelEntry | undefined { - for(let index = 0; index < this.channels.length; index++) - if(this.channels[index].getChannelId() == channelId) return this.channels[index]; - return undefined; - } - - find_channel_by_name(name: string, parent?: ChannelEntry, force_parent: boolean = true) : ChannelEntry | undefined { - for(let index = 0; index < this.channels.length; index++) - if(this.channels[index].channelName() == name && (!force_parent || parent == this.channels[index].parent)) - return this.channels[index]; - return undefined; - } - - moveChannel(channel: ChannelEntry, channel_previous: ChannelEntry, parent: ChannelEntry) { - if(channel_previous != null && channel_previous.parent != parent) { - console.error(tr("Invalid channel move (different parents! (%o|%o)"), channel_previous.parent, parent); - return; - } - - if(channel.channel_next) - channel.channel_next.channel_previous = channel.channel_previous; - - if(channel.channel_previous) - channel.channel_previous.channel_next = channel.channel_next; - - if(channel == this.channel_last) - this.channel_last = channel.channel_previous; - - if(channel == this.channel_first) - this.channel_first = channel.channel_next; - - - channel.channel_next = undefined; - channel.channel_previous = channel_previous; - channel.parent = parent; - - if(channel_previous) { - if(channel_previous == this.channel_last) - this.channel_last = channel; - - channel.channel_next = channel_previous.channel_next; - channel_previous.channel_next = channel; - channel_previous.rootTag().after(channel.rootTag()); - - if(channel.channel_next) - channel.channel_next.channel_previous = channel; - } else { - if(parent) { - let children = parent.children(); - if(children.length <= 1) { //Self should be already in there - let left = channel.rootTag(); - left.appendTo(parent.siblingTag()); - - channel.channel_next = undefined; - } else { - channel.channel_previous = undefined; - channel.rootTag().prependTo(parent.siblingTag()); - - channel.channel_next = children[1]; /* children 0 shall be the channel itself */ - channel.channel_next.channel_previous = channel; - } - } else { - this._tag_entries.find(".server").after(channel.rootTag()); - - channel.channel_next = this.channel_first; - if(this.channel_first) - this.channel_first.channel_previous = channel; - - this.channel_first = channel; - } - } - - channel.update_family_index(); - channel.children(true).forEach(e => e.update_family_index()); - channel.clients(true).forEach(e => e.update_family_index()); - - if(channel.channel_previous == channel) { /* shall never happen */ - channel.channel_previous = undefined; - debugger; - } - if(channel.channel_next == channel) { /* shall never happen */ - channel.channel_next = undefined; - debugger; - } - } - - deleteClient(client: ClientEntry, animate_tag?: boolean) { - const old_channel = client.currentChannel(); - this.clients.remove(client); - if(typeof(animate_tag) !== "boolean" || animate_tag) - this.__deleteAnimation(client); - else - client.tag.detach(); - client.onDelete(); - - if(old_channel) { - this.client.side_bar.info_frame().update_channel_client_count(old_channel); - } - - const voice_connection = this.client.serverConnection.voice_connection(); - if(client.get_audio_handle()) { - if(!voice_connection) { - log.warn(LogCategory.VOICE, tr("Deleting client with a voice handle, but we haven't a voice connection!")); - } else { - voice_connection.unregister_client(client.get_audio_handle()); - } - } - client.set_audio_handle(undefined); - if(this._scroll_bar) this._scroll_bar.recalculate(); - } - - registerClient(client: ClientEntry) { - this.clients.push(client); - client.channelTree = this; - - const voice_connection = this.client.serverConnection.voice_connection(); - if(voice_connection) - client.set_audio_handle(voice_connection.register_client(client.clientId())); - } - - unregisterClient(client: ClientEntry) { - if(!this.clients.remove(client)) - return; - } - - private _update_timer: number; - private _reorder_channels = new Set(); - - insertClient(client: ClientEntry, channel: ChannelEntry) : ClientEntry { - let newClient = this.findClient(client.clientId()); - if(newClient) - client = newClient; //Got new client :) - else { - this.registerClient(client); - } - - client["_channel"] = channel; - let tag = client.tag; - - if(!this._show_queries && client.properties.client_type == ClientType.CLIENT_QUERY) - client.tag.hide(); - else if(!this._tree_detached) - tag.css("display", "none").fadeIn("slow"); - - tag.appendTo(channel.clientTag()); - channel.reorderClients(); - - /* schedule a reorder for this channel. */ - this._reorder_channels.add(client.currentChannel()); - if(!this._update_timer) { - this._update_timer = setTimeout(() => { - this._update_timer = undefined; - for(const channel of this._reorder_channels) { - channel.updateChannelTypeIcon(); - this.client.side_bar.info_frame().update_channel_client_count(channel); - } - this._reorder_channels.clear(); - }, 5) as any; - } - - client.update_family_index(); /* why the hell is this here?! */ - if(this._scroll_bar) this._scroll_bar.recalculate(); - return client; - } - - moveClient(client: ClientEntry, channel: ChannelEntry) { - let oldChannel = client.currentChannel(); - client["_channel"] = channel; - - let tag = client.tag; - tag.detach(); - tag.appendTo(client.currentChannel().clientTag()); - if(oldChannel) { - oldChannel.updateChannelTypeIcon(); - this.client.side_bar.info_frame().update_channel_client_count(oldChannel); - } - if(channel) { - channel.reorderClients(); - channel.updateChannelTypeIcon(); - this.client.side_bar.info_frame().update_channel_client_count(channel); - } - client.updateClientStatusIcons(); - client.update_family_index(); - } - - findClient?(clientId: number) : ClientEntry { - for(let index = 0; index < this.clients.length; index++) { - if(this.clients[index].clientId() == clientId) - return this.clients[index]; - } - return undefined; - } - - find_client_by_dbid?(client_dbid: number) : ClientEntry { - for(let index = 0; index < this.clients.length; index++) { - if(this.clients[index].properties.client_database_id == client_dbid) - return this.clients[index]; - } - return undefined; - } - - find_client_by_unique_id?(unique_id: string) : ClientEntry { - for(let index = 0; index < this.clients.length; index++) { - if(this.clients[index].properties.client_unique_identifier == unique_id) - return this.clients[index]; - } - return undefined; - } - - private static same_selected_type(a, b) { - if(a instanceof ChannelEntry) - return b instanceof ChannelEntry; - if(a instanceof ClientEntry) - return b instanceof ClientEntry; - if(a instanceof ServerEntry) - return b instanceof ServerEntry; - return a == b; - } - - onSelect(entry?: ChannelEntry | ClientEntry | ServerEntry, enforce_single?: boolean, flag_shift?: boolean) { - if(this.currently_selected && (ppt.key_pressed(SpecialKey.SHIFT) || flag_shift) && entry instanceof ClientEntry) { //Currently we're only supporting client multiselects :D - if(!entry) return; //Nowhere - - if($.isArray(this.currently_selected)) { - if(!ChannelTree.same_selected_type(this.currently_selected[0], entry)) return; //Not the same type - } else if(ChannelTree.same_selected_type(this.currently_selected, entry)) { - this.currently_selected = [this.currently_selected] as any; - } - if(entry instanceof ChannelEntry) - this.currently_selected_context_callback = this.callback_multiselect_channel.bind(this); - if(entry instanceof ClientEntry) - this.currently_selected_context_callback = this.callback_multiselect_client.bind(this); - } else - this.currently_selected = undefined; - - if(!$.isArray(this.currently_selected) || enforce_single) { - this.currently_selected = entry; - this._tag_entries.find(".selected").each(function (idx, e) { - $(e).removeClass("selected"); - }); - } else { - for(const e of this.currently_selected) - if(e == entry) { - this.currently_selected.remove(e); - if(entry instanceof ChannelEntry) - (entry as ChannelEntry).channelTag().removeClass("selected"); - else if(entry instanceof ClientEntry) - (entry as ClientEntry).tag.removeClass("selected"); - else if(entry instanceof ServerEntry) - (entry as ServerEntry).htmlTag.removeClass("selected"); - if(this.currently_selected.length == 1) - this.currently_selected = this.currently_selected[0]; - else if(this.currently_selected.length == 0) - this.currently_selected = undefined; - //Already selected - return; - } - this.currently_selected.push(entry as any); - } - - if(entry instanceof ChannelEntry) - (entry as ChannelEntry).channelTag().addClass("selected"); - else if(entry instanceof ClientEntry) - (entry as ClientEntry).tag.addClass("selected"); - else if(entry instanceof ServerEntry) - (entry as ServerEntry).htmlTag.addClass("selected"); - - if(!$.isArray(this.currently_selected)) { - if(this.currently_selected instanceof ClientEntry && settings.static_global(Settings.KEY_SWITCH_INSTANT_CLIENT)) { - if(this.currently_selected instanceof MusicClientEntry) - this.client.side_bar.show_music_player(this.currently_selected as MusicClientEntry); - else - this.client.side_bar.show_client_info(this.currently_selected); - } else if(this.currently_selected instanceof ChannelEntry && settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) { - this.client.side_bar.channel_conversations().set_current_channel(this.currently_selected.channelId); - this.client.side_bar.show_channel_conversations(); - } else if(this.currently_selected instanceof ServerEntry && settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) { - this.client.side_bar.channel_conversations().set_current_channel(0); - this.client.side_bar.show_channel_conversations(); - } - } - } - - private callback_multiselect_channel(event) { - console.log(tr("Multiselect channel")); - } - private callback_multiselect_client(event) { - console.log(tr("Multiselect client")); - const clients = this.currently_selected as ClientEntry[]; - const music_only = clients.map(e => e instanceof MusicClientEntry ? 0 : 1).reduce((a, b) => a + b, 0) == 0; - const music_entry = clients.map(e => e instanceof MusicClientEntry ? 1 : 0).reduce((a, b) => a + b, 0) > 0; - const local_client = clients.map(e => e instanceof LocalClientEntry ? 1 : 0).reduce((a, b) => a + b, 0) > 0; - let entries: contextmenu.MenuEntry[] = []; - if (!music_entry && !local_client) { //Music bots or local client cant be poked - entries.push({ - type: contextmenu.MenuEntryType.ENTRY, - icon_class: "client-poke", - name: tr("Poke clients"), - callback: () => { - createInputModal(tr("Poke clients"), tr("Poke message:
"), text => true, result => { - if (typeof(result) === "string") { - for (const client of this.currently_selected as ClientEntry[]) - this.client.serverConnection.send_command("clientpoke", { - clid: client.clientId(), - msg: result - }); - - } - }, {width: 400, maxLength: 512}).open(); - } - }); - } - entries.push({ - type: contextmenu.MenuEntryType.ENTRY, - icon_class: "client-move_client_to_own_channel", - name: tr("Move clients to your channel"), - callback: () => { - const target = this.client.getClient().currentChannel().getChannelId(); - for(const client of clients) - this.client.serverConnection.send_command("clientmove", { - clid: client.clientId(), - cid: target - }); - } - }); - if (!local_client) {//local client cant be kicked and/or banned or kicked - entries.push(contextmenu.Entry.HR()); - entries.push({ - type: contextmenu.MenuEntryType.ENTRY, - icon_class: "client-kick_channel", - name: tr("Kick clients from channel"), - callback: () => { - createInputModal(tr("Kick clients from channel"), tr("Kick reason:
"), text => true, result => { - if (result) { - for (const client of clients) - this.client.serverConnection.send_command("clientkick", { - clid: client.clientId(), - reasonid: ViewReasonId.VREASON_CHANNEL_KICK, - reasonmsg: result - }); - - } - }, {width: 400, maxLength: 255}).open(); - } - }); - - if (!music_entry) { //Music bots cant be banned or kicked - entries.push({ - type: contextmenu.MenuEntryType.ENTRY, - icon_class: "client-kick_server", - name: tr("Kick clients fom server"), - callback: () => { - createInputModal(tr("Kick clients from server"), tr("Kick reason:
"), text => true, result => { - if (result) { - for (const client of clients) - this.client.serverConnection.send_command("clientkick", { - clid: client.clientId(), - reasonid: ViewReasonId.VREASON_SERVER_KICK, - reasonmsg: result - }); - - } - }, {width: 400, maxLength: 255}).open(); - } - }, { - type: contextmenu.MenuEntryType.ENTRY, - icon_class: "client-ban_client", - name: tr("Ban clients"), - invalidPermission: !this.client.permissions.neededPermission(PermissionType.I_CLIENT_BAN_MAX_BANTIME).granted(1), - callback: () => { - spawnBanClient(this.client, (clients).map(entry => { - return { - name: entry.clientNickName(), - unique_id: entry.properties.client_unique_identifier - } - }), (data) => { - for (const client of clients) - this.client.serverConnection.send_command("banclient", { - uid: client.properties.client_unique_identifier, - banreason: data.reason, - time: data.length - }, { - flagset: [data.no_ip ? "no-ip" : "", data.no_hwid ? "no-hardware-id" : "", data.no_name ? "no-nickname" : ""] - }).then(() => { - this.client.sound.play(Sound.USER_BANNED); - }); - }); - } - }); - } - if(music_only) { - entries.push(contextmenu.Entry.HR()); - entries.push({ - name: tr("Delete bots"), - icon_class: "client-delete", - disabled: false, - callback: () => { - const param_string = clients.map((_, index) => "{" + index + "}").join(', '); - const param_values = clients.map(client => client.createChatTag(true)); - const tag = $.spawn("div").append(...formatMessage(tr("Do you really want to delete ") + param_string, ...param_values)); - const tag_container = $.spawn("div").append(tag); - spawnYesNo(tr("Are you sure?"), tag_container, result => { - if(result) { - for(const client of clients) - this.client.serverConnection.send_command("musicbotdelete", { - botid: client.properties.client_database_id - }); - } - }); - }, - type: contextmenu.MenuEntryType.ENTRY - }); - } - } - contextmenu.spawn_context_menu(event.pageX, event.pageY, ...entries); - } - - clientsByGroup(group: Group) : ClientEntry[] { - let result = []; - - for(let client of this.clients) { - if(client.groupAssigned(group)) - result.push(client); - } - - return result; - } - - clientsByChannel(channel: ChannelEntry) : ClientEntry[] { - let result = []; - - for(let client of this.clients) { - if(client.currentChannel() == channel) - result.push(client); - } - - return result; - } - - reset(){ - const voice_connection = this.client.serverConnection ? this.client.serverConnection.voice_connection() : undefined; - for(const client of this.clients) { - if(client.get_audio_handle() && voice_connection) { - voice_connection.unregister_client(client.get_audio_handle()); - client.set_audio_handle(undefined); - } - client.destroy(); - } - this.clients = []; - - for(const channel of this.channels) - channel.destroy(); - this.channels = []; - - this._tag_entries.children().detach(); //Dont remove listeners - - this.channel_first = undefined; - this.channel_last = undefined; - } - - spawnCreateChannel(parent?: ChannelEntry) { - createChannelModal(this.client, undefined, parent, this.client.permissions, (properties?, permissions?) => { - if(!properties) return; - properties["cpid"] = parent ? parent.channelId : 0; - log.debug(LogCategory.CHANNEL, tr("Creating a new channel.\nProperties: %o\nPermissions: %o"), properties); - this.client.serverConnection.send_command("channelcreate", properties).then(() => { - let channel = this.find_channel_by_name(properties.channel_name, parent, true); - if(!channel) { - log.error(LogCategory.CHANNEL, tr("Failed to resolve channel after creation. Could not apply permissions!")); - return; - } - if(permissions && permissions.length > 0) { - let perms = []; - for(let perm of permissions) { - perms.push({ - permvalue: perm.value, - permnegated: false, - permskip: false, - permid: perm.type.id - }); - } - - perms[0]["cid"] = channel.channelId; - return this.client.serverConnection.send_command("channeladdperm", perms, { - flagset: ["continueonerror"] - }).then(() => new Promise(resolve => { resolve(channel); })); - } - - return new Promise(resolve => { resolve(channel); }) - }).then(channel => { - this.client.log.log(server_log.Type.CHANNEL_CREATE, { - channel: channel.log_data(), - creator: this.client.getClient().log_data(), - own_action: true - }); - this.client.sound.play(Sound.CHANNEL_CREATED); - }); - }); - } - - handle_resized() { - for(let channel of this.channels) - channel.handle_frame_resized(); - } - - private select_next_channel(channel: ChannelEntry, select_client: boolean) { - if(select_client) { - const clients = channel.clients_ordered(); - if(clients.length > 0) { - this.onSelect(clients[0], true); - return; - } - } - - const children = channel.children(); - if(children.length > 0) { - this.onSelect(children[0], true); - return; - } - - const next = channel.channel_next; - if(next) { - this.onSelect(next, true); - return; - } - - let parent = channel.parent_channel(); - while(parent) { - const p_next = parent.channel_next; - if(p_next) { - this.onSelect(p_next, true); - return; - } - - parent = parent.parent_channel(); - } - } - - handle_key_press(event: KeyboardEvent) { - //console.log("Keydown: %o | %o | %o", this._focused, this.currently_selected, Array.isArray(this.currently_selected)); - if(!this._focused || !this.currently_selected || Array.isArray(this.currently_selected)) return; - - if(event.keyCode == KeyCode.KEY_UP) { - event.preventDefault(); - if(this.currently_selected instanceof ChannelEntry) { - let previous = this.currently_selected.channel_previous; - - if(previous) { - while(true) { - const siblings = previous.children(); - if(siblings.length == 0) break; - previous = siblings.last(); - } - const clients = previous.clients_ordered(); - if(clients.length > 0) { - this.onSelect(clients.last(), true); - return; - } else { - this.onSelect(previous, true); - return; - } - } else if(this.currently_selected.hasParent()) { - const channel = this.currently_selected.parent_channel(); - const clients = channel.clients_ordered(); - if(clients.length > 0) { - this.onSelect(clients.last(), true); - return; - } else { - this.onSelect(channel, true); - return; - } - } else - this.onSelect(this.server, true); - } else if(this.currently_selected instanceof ClientEntry) { - const channel = this.currently_selected.currentChannel(); - const clients = channel.clients_ordered(); - const index = clients.indexOf(this.currently_selected); - if(index > 0) { - this.onSelect(clients[index - 1], true); - return; - } - - this.onSelect(channel, true); - return; - } - - } else if(event.keyCode == KeyCode.KEY_DOWN) { - event.preventDefault(); - if(this.currently_selected instanceof ChannelEntry) { - this.select_next_channel(this.currently_selected, true); - } else if(this.currently_selected instanceof ClientEntry){ - const channel = this.currently_selected.currentChannel(); - const clients = channel.clients_ordered(); - const index = clients.indexOf(this.currently_selected); - if(index + 1 < clients.length) { - this.onSelect(clients[index + 1], true); - return; - } - - this.select_next_channel(channel, false); - } else if(this.currently_selected instanceof ServerEntry) - this.onSelect(this.channel_first, true); - } else if(event.keyCode == KeyCode.KEY_RETURN) { - if(this.currently_selected instanceof ChannelEntry) { - this.currently_selected.joinChannel(); - } - } - } - - toggle_server_queries(flag: boolean) { - if(this._show_queries == flag) return; - this._show_queries = flag; - - const channels: ChannelEntry[] = [] - for(const client of this.clients) - if(client.properties.client_type == ClientType.CLIENT_QUERY) { - if(this._show_queries) - client.tag.show(); - else - client.tag.hide(); - if(channels.indexOf(client.currentChannel()) == -1) - channels.push(client.currentChannel()); - } - } - - get_first_channel?() : ChannelEntry { - return this.channel_first; - } - - unsubscribe_all_channels(subscribe_specified?: boolean) { - if(!this.client.serverConnection || !this.client.serverConnection.connected()) - return; - - this.client.serverConnection.send_command('channelunsubscribeall').then(() => { - const channels: number[] = []; - for(const channel of this.channels) { - if(channel.subscribe_mode == ChannelSubscribeMode.SUBSCRIBED) - channels.push(channel.getChannelId()); - } - - if(channels.length > 0) { - this.client.serverConnection.send_command('channelsubscribe', channels.map(e => { return {cid: e}; })).catch(error => { - console.warn(tr("Failed to subscribe to specific channels (%o)"), channels); - }); - } - }).catch(error => { - console.warn(tr("Failed to unsubscribe to all channels! (%o)"), error); - }); - } - - subscribe_all_channels() { - if(!this.client.serverConnection || !this.client.serverConnection.connected()) - return; - - this.client.serverConnection.send_command('channelsubscribeall').then(() => { - const channels: number[] = []; - for(const channel of this.channels) { - if(channel.subscribe_mode == ChannelSubscribeMode.UNSUBSCRIBED) - channels.push(channel.getChannelId()); - } - - if(channels.length > 0) { - this.client.serverConnection.send_command('channelunsubscribe', channels.map(e => { return {cid: e}; })).catch(error => { - console.warn(tr("Failed to unsubscribe to specific channels (%o)"), channels); - }); - } - }).catch(error => { - console.warn(tr("Failed to subscribe to all channels! (%o)"), error); - }); - } -} \ No newline at end of file diff --git a/shared/js/ui/view.tsx b/shared/js/ui/view.tsx new file mode 100644 index 00000000..44d3881c --- /dev/null +++ b/shared/js/ui/view.tsx @@ -0,0 +1,1047 @@ +import * as contextmenu from "tc-shared/ui/elements/ContextMenu"; +import {MenuEntryType} from "tc-shared/ui/elements/ContextMenu"; +import * as log from "tc-shared/log"; +import {LogCategory} from "tc-shared/log"; +import {Settings, settings} from "tc-shared/settings"; +import {PermissionType} from "tc-shared/permission/PermissionType"; +import {KeyCode, SpecialKey} from "tc-shared/PPTListener"; +import {Sound} from "tc-shared/sound/Sounds"; +import {Group} from "tc-shared/permission/GroupManager"; +import * as server_log from "tc-shared/ui/frames/server_log"; +import {ServerAddress, ServerEntry} from "tc-shared/ui/server"; +import {ClientMover} from "tc-shared/ui/client_move"; +import {ChannelEntry, ChannelSubscribeMode} from "tc-shared/ui/channel"; +import {ClientEntry, LocalClientEntry, MusicClientEntry} from "tc-shared/ui/client"; +import {ConnectionHandler, ViewReasonId} from "tc-shared/ConnectionHandler"; +import {createChannelModal} from "tc-shared/ui/modal/ModalCreateChannel"; +import {Registry} from "tc-shared/events"; +import {ChannelTreeView} from "tc-shared/ui/tree/View"; +import * as ReactDOM from "react-dom"; +import * as React from "react"; +import * as ppt from "tc-backend/ppt"; + +import {batch_updates, BatchUpdateType, flush_batched_updates} from "tc-shared/ui/react-elements/ReactComponentBase"; +import {ChannelTreeEntry} from "tc-shared/ui/TreeEntry"; +import {createInputModal} from "tc-shared/ui/elements/Modal"; +import {spawnBanClient} from "tc-shared/ui/modal/ModalBanClient"; +import {formatMessage} from "tc-shared/ui/frames/chat"; +import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo"; +import {tra} from "tc-shared/i18n/localize"; + +export interface ChannelTreeEvents { + action_select_entries: { + entries: ChannelTreeEntry[], + /** + * auto := Select/unselect/add/remove depending on the selected state & shift key state + * exclusive := Only selected these entries + * append := Append these entries to the current selection + * remove := Remove these entries from the current selection + */ + mode: "auto" | "exclusive" | "append" | "remove"; + }, + + notify_selection_changed: {}, + notify_root_channel_changed: {} +} + +export class ChannelTreeEntrySelect { + readonly handle: ChannelTree; + selected_entries: ChannelTreeEntry[] = []; + + private readonly handler_select_entries; + + constructor(handle: ChannelTree) { + this.handle = handle; + + this.handler_select_entries = e => { + batch_updates(BatchUpdateType.CHANNEL_TREE); + try { + this.handleSelectEntries(e) + } finally { + flush_batched_updates(BatchUpdateType.CHANNEL_TREE); + } + }; + + this.handle.events.on("action_select_entries", this.handler_select_entries); + } + + reset() { + this.selected_entries.splice(0, this.selected_entries.length); + } + + destroy() { + this.handle.events.off("action_select_entries", this.handler_select_entries); + this.selected_entries.splice(0, this.selected_entries.length); + } + + is_multi_select() { + return this.selected_entries.length > 1; + } + + is_anything_selected() { + return this.selected_entries.length > 0; + } + + clear_selection() { + this.handleSelectEntries({ + entries: [], + mode: "exclusive" + }); + } + + private handleSelectEntries(event: ChannelTreeEvents["action_select_entries"]) { + if(event.mode === "exclusive") { + let deleted_entries = this.selected_entries; + let new_entries = []; + + this.selected_entries = []; + for(const new_entry of event.entries) { + if(!deleted_entries.remove(new_entry)) + new_entries.push(new_entry); + this.selected_entries.push(new_entry); + } + + for(const deleted of deleted_entries) + deleted["onUnselect"](); + + for(const new_entry of new_entries) + new_entry["onSelect"](!this.is_multi_select()); + + if(deleted_entries.length !== 0 || new_entries.length !== 0) + this.handle.events.fire("notify_selection_changed"); + } else if(event.mode === "append") { + let new_entries = []; + for(const entry of event.entries) { + if(this.selected_entries.findIndex(e => e === entry) !== -1) + continue; + + this.selected_entries.push(entry); + new_entries.push(entry); + } + + for(const new_entry of new_entries) + new_entry["onSelect"](!this.is_multi_select()); + + if(new_entries.length !== 0) + this.handle.events.fire("notify_selection_changed"); + } else if(event.mode === "remove") { + let deleted_entries = []; + for(const entry of event.entries) { + if(this.selected_entries.remove(entry)) + deleted_entries.push(entry); + } + + for(const deleted of deleted_entries) + deleted["onUnselect"](); + + if(deleted_entries.length !== 0) + this.handle.events.fire("notify_selection_changed"); + } else if(event.mode === "auto") { + let deleted_entries = []; + let new_entries = []; + + if(ppt.key_pressed(SpecialKey.SHIFT)) { + for(const entry of event.entries) { + const index = this.selected_entries.findIndex(e => e === entry); + if(index === -1) { + this.selected_entries.push(entry); + new_entries.push(entry); + } else { + this.selected_entries.splice(index, 1); + deleted_entries.push(entry); + } + } + } else { + deleted_entries = this.selected_entries.splice(0, this.selected_entries.length); + if(event.entries.length !== 0) { + const entry = event.entries[event.entries.length - 1]; + this.selected_entries.push(entry); + if(!deleted_entries.remove(entry)) + new_entries.push(entry); /* entry wans't selected yet */ + } + } + + for(const deleted of deleted_entries) + deleted["onUnselect"](); + + for(const new_entry of new_entries) + new_entry["onSelect"](!this.is_multi_select()); + + if(deleted_entries.length !== 0 || new_entries.length !== 0) + this.handle.events.fire("notify_selection_changed"); + } else { + console.warn("Received entry select event with unknown mode: %s", event.mode); + } + + if(this.selected_entries.length === 1) + this.handle.view.current?.scrollEntryInView(this.selected_entries[0] as any); + } +} + +export class ChannelTree { + readonly events: Registry; + + client: ConnectionHandler; + server: ServerEntry; + + channels: ChannelEntry[] = []; + clients: ClientEntry[] = []; + + readonly client_mover: ClientMover; + + readonly view: React.RefObject; + readonly selection: ChannelTreeEntrySelect; + + private readonly _tag_container: JQuery; + + private _show_queries: boolean; + private channel_last?: ChannelEntry; + private channel_first?: ChannelEntry; + + private _tag_container_focused = false; + private _listener_document_click; + private _listener_document_key; + + constructor(client) { + this.events = new Registry(); + this.client = client; + this.view = React.createRef(); + + this.server = new ServerEntry(this, "undefined", undefined); + this.selection = new ChannelTreeEntrySelect(this); + + this._tag_container = $.spawn("div").addClass("channel-tree-container"); + ReactDOM.render(, this._tag_container[0]); + + this.client_mover = new ClientMover(this); + this.reset(); + + if(!settings.static(Settings.KEY_DISABLE_CONTEXT_MENU, false)) { + this._tag_container.on("contextmenu", (event) => { + if(event.isDefaultPrevented()) return; + + for(const element of document.elementsFromPoint(event.pageX, event.pageY)) + if(element.classList.contains("channelLine") || element.classList.contains("client")) + return; + + event.preventDefault(); + if(this.selection.is_multi_select()) + this.open_multiselect_context_menu(this.selection.selected_entries, event.pageX, event.pageY); + else { + this.selection.clear_selection(); + this.showContextMenu(event.pageX, event.pageY); + } + }); + } + + this._listener_document_key = event => this.handle_key_press(event); + this._listener_document_click = event => { + this._tag_container_focused = false; + let element = event.target as HTMLElement; + while(element) { + if(element === this._tag_container[0]) { + this._tag_container_focused = true; + break; + } + element = element.parentNode as HTMLElement; + } + }; + document.addEventListener('click', this._listener_document_click); + document.addEventListener('keydown', this._listener_document_key); + } + + tag_tree() : JQuery { + return this._tag_container; + } + + destroy() { + ReactDOM.unmountComponentAtNode(this._tag_container[0]); + + this._listener_document_click && document.removeEventListener('click', this._listener_document_click); + this._listener_document_click = undefined; + + this._listener_document_key && document.removeEventListener('keydown', this._listener_document_key); + this._listener_document_key = undefined; + + if(this.server) { + this.server.destroy(); + this.server = undefined; + } + this.reset(); /* cleanup channel and clients */ + + this.channel_first = undefined; + this.channel_last = undefined; + + this._tag_container.remove(); + this.selection.destroy(); + this.events.destroy(); + } + + showContextMenu(x: number, y: number, on_close: () => void = undefined) { + let channelCreate = + this.client.permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_TEMPORARY).granted(1) || + this.client.permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_SEMI_PERMANENT).granted(1) || + this.client.permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_PERMANENT).granted(1); + + contextmenu.spawn_context_menu(x, y, + { + type: contextmenu.MenuEntryType.ENTRY, + icon_class: "client-channel_create", + name: tr("Create channel"), + invalidPermission: !channelCreate, + callback: () => this.spawnCreateChannel() + }, + contextmenu.Entry.CLOSE(on_close) + ); + } + + initialiseHead(serverName: string, address: ServerAddress) { + this.server.reset(); + this.server.remote_address = Object.assign({}, address); + this.server.properties.virtualserver_name = serverName; + this.events.fire("notify_root_channel_changed"); + } + + rootChannel() : ChannelEntry[] { + const result = []; + let first = this.channel_first; + while(first) { + result.push(first); + first = first.channel_next; + } + return result; + } + + deleteChannel(channel: ChannelEntry) { + channel.channelTree = null; + + batch_updates(BatchUpdateType.CHANNEL_TREE); + try { + if(!this.channels.remove(channel)) + log.warn(LogCategory.CHANNEL, tr("Deleting an unknown channel!")); + channel.children(false).forEach(e => this.deleteChannel(e)); + + if(channel.clients(false).length !== 0) { + log.warn(LogCategory.CHANNEL, tr("Deleting a non empty channel! This could cause some errors.")); + for(const client of channel.clients(false)) + this.deleteClient(client, false); + } + + const is_root_tree = !!channel.parent; + this.unregisterChannelFromTree(channel); + if(is_root_tree) this.events.fire("notify_root_channel_changed"); + } finally { + flush_batched_updates(BatchUpdateType.CHANNEL_TREE); + } + } + + insertChannel(channel: ChannelEntry, previous: ChannelEntry, parent: ChannelEntry) { + channel.channelTree = this; + this.channels.push(channel); + + this.moveChannel(channel, previous, parent); + } + + findChannel(channelId: number) : ChannelEntry | undefined { + if(typeof channelId === "string") /* legacy fix */ + channelId = parseInt(channelId); + + for(let index = 0; index < this.channels.length; index++) + if(this.channels[index].channelId === channelId) return this.channels[index]; + return undefined; + } + + find_channel_by_name(name: string, parent?: ChannelEntry, force_parent: boolean = true) : ChannelEntry | undefined { + for(let index = 0; index < this.channels.length; index++) + if(this.channels[index].channelName() == name && (!force_parent || parent == this.channels[index].parent)) + return this.channels[index]; + return undefined; + } + + private unregisterChannelFromTree(channel: ChannelEntry) { + //TODO: Parent/Child reference? + if(channel.parent) + channel.parent.events.fire("notify_children_changed"); + + if(channel.channel_previous) + channel.channel_previous.channel_next = channel.channel_next; + + if(channel.channel_next) + channel.channel_next.channel_previous = channel.channel_previous; + + if(channel === this.channel_last) + this.channel_last = channel.channel_previous; + + if(channel === this.channel_first) + this.channel_first = channel.channel_next; + + channel.channel_next = undefined; + channel.channel_previous = undefined; + channel.parent = undefined; + } + + moveChannel(channel: ChannelEntry, channel_previous: ChannelEntry, parent: ChannelEntry) { + if(channel_previous != null && channel_previous.parent != parent) { + console.error(tr("Invalid channel move (different parents! (%o|%o)"), channel_previous.parent, parent); + return; + } + + let root_tree_updated = !channel.parent; + this.unregisterChannelFromTree(channel); + channel.channel_previous = channel_previous; + channel.channel_next = undefined; + channel.parent = parent; + + if(channel_previous) { + if(channel_previous == this.channel_last) + this.channel_last = channel; + + channel.channel_next = channel_previous.channel_next; + channel_previous.channel_next = channel; + + if(channel.channel_next) + channel.channel_next.channel_previous = channel; + + if(!channel.parent_channel()) + root_tree_updated = true; + else + channel.parent.events.fire("notify_children_changed"); + } else { + if(parent) { + let children = parent.children(); + if(children.length <= 1) { //Self should be already in there + channel.channel_next = undefined; + } else { + channel.channel_previous = undefined; + + channel.channel_next = children[1]; /* children 0 shall be the channel itself */ + channel.channel_next.channel_previous = channel; + } + parent.events.fire("notify_children_changed"); + } else { + console.error("No previous & paretn!"); + channel.channel_next = this.channel_first; + if(this.channel_first) + this.channel_first.channel_previous = channel; + + this.channel_first = channel; + this.channel_last = this.channel_last || channel; + root_tree_updated = true; + } + } + + //channel.update_family_index(); + //channel.children(true).forEach(e => e.update_family_index()); + //channel.clients(true).forEach(e => e.update_family_index()); + + if(channel.channel_previous == channel) { /* shall never happen */ + channel.channel_previous = undefined; + debugger; + } + if(channel.channel_next == channel) { /* shall never happen */ + channel.channel_next = undefined; + debugger; + } + + if(root_tree_updated) + this.events.fire("notify_root_channel_changed"); + } + + deleteClient(client: ClientEntry, animate_tag?: boolean) { + const old_channel = client.currentChannel(); + old_channel?.unregisterClient(client); + this.clients.remove(client); + + if(old_channel) { + this.client.side_bar.info_frame().update_channel_client_count(old_channel); + } + + + //FIXME: Trigger the notify_clients_changed event! + const voice_connection = this.client.serverConnection.voice_connection(); + if(client.get_audio_handle()) { + if(!voice_connection) { + log.warn(LogCategory.VOICE, tr("Deleting client with a voice handle, but we haven't a voice connection!")); + } else { + voice_connection.unregister_client(client.get_audio_handle()); + } + } + client.set_audio_handle(undefined); + client.destroy(); + } + + registerClient(client: ClientEntry) { + this.clients.push(client); + client.channelTree = this; + + const voice_connection = this.client.serverConnection.voice_connection(); + if(voice_connection) + client.set_audio_handle(voice_connection.register_client(client.clientId())); + } + + unregisterClient(client: ClientEntry) { + if(!this.clients.remove(client)) + return; + } + + insertClient(client: ClientEntry, channel: ChannelEntry) : ClientEntry { + batch_updates(BatchUpdateType.CHANNEL_TREE); + try { + let newClient = this.findClient(client.clientId()); + if(newClient) + client = newClient; //Got new client :) + else { + this.registerClient(client); + } + + client.currentChannel()?.unregisterClient(client); + client["_channel"] = channel; + channel.registerClient(client); + + return client; + } finally { + flush_batched_updates(BatchUpdateType.CHANNEL_TREE); + } + } + + moveClient(client: ClientEntry, channel: ChannelEntry) { + batch_updates(BatchUpdateType.CHANNEL_TREE); + try { + let oldChannel = client.currentChannel(); + oldChannel?.unregisterClient(client); + client["_channel"] = channel; + channel?.registerClient(client); + + if(oldChannel) { + this.client.side_bar.info_frame().update_channel_client_count(oldChannel); + } + if(channel) { + this.client.side_bar.info_frame().update_channel_client_count(channel); + } + client.speaking = false; + } finally { + flush_batched_updates(BatchUpdateType.CHANNEL_TREE); + } + } + + findClient?(clientId: number) : ClientEntry { + for(let index = 0; index < this.clients.length; index++) { + if(this.clients[index].clientId() == clientId) + return this.clients[index]; + } + return undefined; + } + + find_client_by_dbid?(client_dbid: number) : ClientEntry { + for(let index = 0; index < this.clients.length; index++) { + if(this.clients[index].properties.client_database_id == client_dbid) + return this.clients[index]; + } + return undefined; + } + + find_client_by_unique_id?(unique_id: string) : ClientEntry { + for(let index = 0; index < this.clients.length; index++) { + if(this.clients[index].properties.client_unique_identifier == unique_id) + return this.clients[index]; + } + return undefined; + } + + private open_multiselect_context_menu(entries: ChannelTreeEntry[], x: number, y: number) { + const clients = entries.filter(e => e instanceof ClientEntry) as ClientEntry[]; + const channels = entries.filter(e => e instanceof ChannelEntry) as ChannelEntry[]; + const server = entries.find(e => e instanceof ServerEntry) as ServerEntry; + + let client_menu: contextmenu.MenuEntry[]; + let channel_menu: contextmenu.MenuEntry[]; + let server_menu: contextmenu.MenuEntry[]; + + if(clients.length > 0) { + client_menu = []; + + const music_only = clients.map(e => e instanceof MusicClientEntry ? 0 : 1).reduce((a, b) => a + b, 0) == 0; + const music_entry = clients.map(e => e instanceof MusicClientEntry ? 1 : 0).reduce((a, b) => a + b, 0) > 0; + const local_client = clients.map(e => e instanceof LocalClientEntry ? 1 : 0).reduce((a, b) => a + b, 0) > 0; + + if (!music_entry && !local_client) { //Music bots or local client cant be poked + client_menu.push({ + type: contextmenu.MenuEntryType.ENTRY, + icon_class: "client-poke", + name: tr("Poke clients"), + callback: () => { + createInputModal(tr("Poke clients"), tr("Poke message:
"), text => true, result => { + if (typeof(result) === "string") { + for (const client of clients) + this.client.serverConnection.send_command("clientpoke", { + clid: client.clientId(), + msg: result + }); + + this.selection.clear_selection(); + } + }, {width: 400, maxLength: 512}).open(); + } + }); + } + client_menu.push({ + type: contextmenu.MenuEntryType.ENTRY, + icon_class: "client-move_client_to_own_channel", + name: tr("Move clients to your channel"), + callback: () => { + const target = this.client.getClient().currentChannel().getChannelId(); + for(const client of clients) + this.client.serverConnection.send_command("clientmove", { + clid: client.clientId(), + cid: target + }); + this.selection.clear_selection(); + } + }); + if (!local_client) {//local client cant be kicked and/or banned or kicked + client_menu.push(contextmenu.Entry.HR()); + client_menu.push({ + type: contextmenu.MenuEntryType.ENTRY, + icon_class: "client-kick_channel", + name: tr("Kick clients from channel"), + callback: () => { + createInputModal(tr("Kick clients from channel"), tr("Kick reason:
"), text => true, result => { + if (result) { + for (const client of clients) + this.client.serverConnection.send_command("clientkick", { + clid: client.clientId(), + reasonid: ViewReasonId.VREASON_CHANNEL_KICK, + reasonmsg: result + }); + } + }, {width: 400, maxLength: 255}).open(); + this.selection.clear_selection(); + } + }); + + if (!music_entry) { //Music bots cant be banned or kicked + client_menu.push({ + type: contextmenu.MenuEntryType.ENTRY, + icon_class: "client-kick_server", + name: tr("Kick clients fom server"), + callback: () => { + this.selection.clear_selection(); + createInputModal(tr("Kick clients from server"), tr("Kick reason:
"), text => true, result => { + if (result) { + for (const client of clients) + this.client.serverConnection.send_command("clientkick", { + clid: client.clientId(), + reasonid: ViewReasonId.VREASON_SERVER_KICK, + reasonmsg: result + }); + } + }, {width: 400, maxLength: 255}).open(); + } + }, { + type: contextmenu.MenuEntryType.ENTRY, + icon_class: "client-ban_client", + name: tr("Ban clients"), + invalidPermission: !this.client.permissions.neededPermission(PermissionType.I_CLIENT_BAN_MAX_BANTIME).granted(1), + callback: () => { + this.selection.clear_selection(); + spawnBanClient(this.client, (clients).map(entry => { + return { + name: entry.clientNickName(), + unique_id: entry.properties.client_unique_identifier + } + }), (data) => { + for (const client of clients) + this.client.serverConnection.send_command("banclient", { + uid: client.properties.client_unique_identifier, + banreason: data.reason, + time: data.length + }, { + flagset: [data.no_ip ? "no-ip" : "", data.no_hwid ? "no-hardware-id" : "", data.no_name ? "no-nickname" : ""] + }).then(() => { + this.client.sound.play(Sound.USER_BANNED); + }); + }); + } + }); + } + if(music_only) { + client_menu.push(contextmenu.Entry.HR()); + client_menu.push({ + name: tr("Delete bots"), + icon_class: "client-delete", + disabled: false, + callback: () => { + const param_string = clients.map((_, index) => "{" + index + "}").join(', '); + const param_values = clients.map(client => client.createChatTag(true)); + const tag = $.spawn("div").append(...formatMessage(tr("Do you really want to delete ") + param_string, ...param_values)); + const tag_container = $.spawn("div").append(tag); + spawnYesNo(tr("Are you sure?"), tag_container, result => { + if(result) { + for(const client of clients) + this.client.serverConnection.send_command("musicbotdelete", { + botid: client.properties.client_database_id + }); + this.selection.clear_selection(); + } + }); + }, + type: contextmenu.MenuEntryType.ENTRY + }); + } + } + } + if(channels.length > 0) { + channel_menu = []; + + //TODO: Subscribe mode settings + channel_menu.push({ + type: MenuEntryType.ENTRY, + name: tr("Delete all channels"), + icon_class: "client-delete", + callback: () => { + spawnYesNo(tr("Are you sure?"), tra("Do you really want to delete {0} channels?", channels.length), result => { + if(typeof result === "boolean" && result) { + for(const channel of channels) + this.client.serverConnection.send_command("channeldelete", { cid: channel.channelId }); + this.selection.clear_selection(); + } + }); + } + }); + } + if(server) + server_menu = server.contextMenuItems(); + + const menus = [ + { + text: tr("Apply to all clients"), + menu: client_menu, + icon: "client-user-account" + }, + { + text: tr("Apply to all channels"), + menu: channel_menu, + icon: "client-channel_green" + }, + { + text: tr("Server actions"), + menu: server_menu, + icon: "client-server_green" + } + ].filter(e => !!e.menu); + if(menus.length === 1) { + contextmenu.spawn_context_menu(x, y, ...menus[0].menu); + } else { + contextmenu.spawn_context_menu(x, y, ...menus.map(e => { + return { + icon_class: e.icon, + name: e.text, + type: MenuEntryType.SUB_MENU, + sub_menu: e.menu + } as contextmenu.MenuEntry + })); + } + } + + clientsByGroup(group: Group) : ClientEntry[] { + let result = []; + + for(let client of this.clients) { + if(client.groupAssigned(group)) + result.push(client); + } + + return result; + } + + clientsByChannel(channel: ChannelEntry) : ClientEntry[] { + let result = []; + + for(let client of this.clients) { + if(client.currentChannel() == channel) + result.push(client); + } + + return result; + } + + reset() { + batch_updates(BatchUpdateType.CHANNEL_TREE); + + try { + this.selection.reset(); + + const voice_connection = this.client.serverConnection ? this.client.serverConnection.voice_connection() : undefined; + for(const client of this.clients) { + if(client.get_audio_handle() && voice_connection) { + voice_connection.unregister_client(client.get_audio_handle()); + client.set_audio_handle(undefined); + } + client.destroy(); + } + this.clients = []; + + for(const channel of this.channels) + channel.destroy(); + + this.channels = []; + this.channel_last = undefined; + this.channel_first = undefined; + } finally { + try { + this.events.fire_async("notify_root_channel_changed", undefined, () => flush_batched_updates(BatchUpdateType.CHANNEL_TREE)); + } catch (e) { + flush_batched_updates(BatchUpdateType.CHANNEL_TREE); + throw e; + } + } + } + + spawnCreateChannel(parent?: ChannelEntry) { + createChannelModal(this.client, undefined, parent, this.client.permissions, (properties?, permissions?) => { + if(!properties) return; + properties["cpid"] = parent ? parent.channelId : 0; + log.debug(LogCategory.CHANNEL, tr("Creating a new channel.\nProperties: %o\nPermissions: %o"), properties); + this.client.serverConnection.send_command("channelcreate", properties).then(() => { + let channel = this.find_channel_by_name(properties.channel_name, parent, true); + if(!channel) { + log.error(LogCategory.CHANNEL, tr("Failed to resolve channel after creation. Could not apply permissions!")); + return; + } + if(permissions && permissions.length > 0) { + let perms = []; + for(let perm of permissions) { + perms.push({ + permvalue: perm.value, + permnegated: false, + permskip: false, + permid: perm.type.id + }); + } + + perms[0]["cid"] = channel.channelId; + return this.client.serverConnection.send_command("channeladdperm", perms, { + flagset: ["continueonerror"] + }).then(() => new Promise(resolve => { resolve(channel); })); + } + + return new Promise(resolve => { resolve(channel); }) + }).then(channel => { + this.client.log.log(server_log.Type.CHANNEL_CREATE, { + channel: channel.log_data(), + creator: this.client.getClient().log_data(), + own_action: true + }); + this.client.sound.play(Sound.CHANNEL_CREATED); + }); + }); + } + + private select_next_channel(channel: ChannelEntry, select_client: boolean) { + if(select_client) { + const clients = channel.clients_ordered(); + if(clients.length > 0) { + this.events.fire("action_select_entries", { + mode: "exclusive", + entries: [ clients[0] ] + }); + return; + } + } + + const children = channel.children(); + if(children.length > 0) { + this.events.fire("action_select_entries", { + mode: "exclusive", + entries: [ children[0] ] + }); + return; + } + + const next = channel.channel_next; + if(next) { + this.events.fire("action_select_entries", { + mode: "exclusive", + entries: [ next ] + }); + return; + } + + let parent = channel.parent_channel(); + while(parent) { + const p_next = parent.channel_next; + if(p_next) { + this.events.fire("action_select_entries", { + mode: "exclusive", + entries: [ p_next ] + }); + return; + } + + parent = parent.parent_channel(); + } + } + + handle_key_press(event: KeyboardEvent) { + if(!this._tag_container_focused || !this.selection.is_anything_selected() || this.selection.is_multi_select()) return; + + const selected = this.selection.selected_entries[0]; + if(event.keyCode == KeyCode.KEY_UP) { + event.preventDefault(); + if(selected instanceof ChannelEntry) { + let previous = selected.channel_previous; + + if(previous) { + while(true) { + const siblings = previous.children(); + if(siblings.length == 0) break; + previous = siblings.last(); + } + const clients = previous.clients_ordered(); + if(clients.length > 0) { + this.events.fire("action_select_entries", { + mode: "exclusive", + entries: [ clients.last() ] + }); + return; + } else { + this.events.fire("action_select_entries", { + mode: "exclusive", + entries: [ previous ] + }); + return; + } + } else if(selected.hasParent()) { + const channel = selected.parent_channel(); + const clients = channel.clients_ordered(); + if(clients.length > 0) { + this.events.fire("action_select_entries", { + mode: "exclusive", + entries: [ clients.last() ] + }); + return; + } else { + this.events.fire("action_select_entries", { + mode: "exclusive", + entries: [ channel ] + }); + return; + } + } else { + this.events.fire("action_select_entries", { + mode: "exclusive", + entries: [ this.server ] + }); + } + } else if(selected instanceof ClientEntry) { + const channel = selected.currentChannel(); + const clients = channel.clients_ordered(); + const index = clients.indexOf(selected); + if(index > 0) { + this.events.fire("action_select_entries", { + mode: "exclusive", + entries: [ clients[index - 1] ] + }); + return; + } + this.events.fire("action_select_entries", { + mode: "exclusive", + entries: [ channel ] + }); + return; + } + + } else if(event.keyCode == KeyCode.KEY_DOWN) { + event.preventDefault(); + if(selected instanceof ChannelEntry) { + this.select_next_channel(selected, true); + } else if(selected instanceof ClientEntry){ + const channel = selected.currentChannel(); + const clients = channel.clients_ordered(); + const index = clients.indexOf(selected); + if(index + 1 < clients.length) { + this.events.fire("action_select_entries", { + mode: "exclusive", + entries: [ clients[index + 1] ] + }); + return; + } + + this.select_next_channel(channel, false); + } else if(selected instanceof ServerEntry) + this.events.fire("action_select_entries", { + mode: "exclusive", + entries: [ this.channel_first ] + }); + } else if(event.keyCode == KeyCode.KEY_RETURN) { + if(selected instanceof ChannelEntry) { + selected.joinChannel(); + } + } + } + + toggle_server_queries(flag: boolean) { + if(this._show_queries == flag) return; + this._show_queries = flag; + + //TODO: FIXME! + /* + const channels: ChannelEntry[] = [] + for(const client of this.clients) + if(client.properties.client_type == ClientType.CLIENT_QUERY) { + if(this._show_queries) + client.tag.show(); + else + client.tag.hide(); + if(channels.indexOf(client.currentChannel()) == -1) + channels.push(client.currentChannel()); + } + */ + } + + get_first_channel?() : ChannelEntry { + return this.channel_first; + } + + unsubscribe_all_channels(subscribe_specified?: boolean) { + if(!this.client.serverConnection || !this.client.serverConnection.connected()) + return; + + this.client.serverConnection.send_command('channelunsubscribeall').then(() => { + const channels: number[] = []; + for(const channel of this.channels) { + if(channel.subscribe_mode == ChannelSubscribeMode.SUBSCRIBED) + channels.push(channel.getChannelId()); + } + + if(channels.length > 0) { + this.client.serverConnection.send_command('channelsubscribe', channels.map(e => { return {cid: e}; })).catch(error => { + console.warn(tr("Failed to subscribe to specific channels (%o)"), channels); + }); + } + }).catch(error => { + console.warn(tr("Failed to unsubscribe to all channels! (%o)"), error); + }); + } + + subscribe_all_channels() { + if(!this.client.serverConnection || !this.client.serverConnection.connected()) + return; + + this.client.serverConnection.send_command('channelsubscribeall').then(() => { + const channels: number[] = []; + for(const channel of this.channels) { + if(channel.subscribe_mode == ChannelSubscribeMode.UNSUBSCRIBED) + channels.push(channel.getChannelId()); + } + + if(channels.length > 0) { + this.client.serverConnection.send_command('channelunsubscribe', channels.map(e => { return {cid: e}; })).catch(error => { + console.warn(tr("Failed to unsubscribe to specific channels (%o)"), channels); + }); + } + }).catch(error => { + console.warn(tr("Failed to subscribe to all channels! (%o)"), error); + }); + } +} \ No newline at end of file diff --git a/tools/dtsgen/declare_fixup.ts b/tools/dtsgen/declare_fixup.ts index b39eea28..cc9b0f12 100644 --- a/tools/dtsgen/declare_fixup.ts +++ b/tools/dtsgen/declare_fixup.ts @@ -12,4 +12,6 @@ export function fix_declare_global(nodes: ts.Node[]) : ts.Node[] { if(has_export) return nodes; return []; -} \ No newline at end of file +} + +SyntaxKind.PlusEqualsToken \ No newline at end of file diff --git a/web/js/codec/CodecWrapperWorker.ts b/web/js/codec/CodecWrapperWorker.ts index 7350bbdb..91526a66 100644 --- a/web/js/codec/CodecWrapperWorker.ts +++ b/web/js/codec/CodecWrapperWorker.ts @@ -54,19 +54,22 @@ export class CodecWrapperWorker extends BasicCodec { async initialise() : Promise { if(this._initialized) return; + if(this._initialize_promise) + return await this._initialize_promise; this._initialize_promise = this.spawn_worker().then(() => this.execute("initialise", { type: this.type, channelCount: this.channelCount, })).then(result => { - if(result.success) + if(result.success) { + this._initialized = true; return Promise.resolve(true); + } log.error(LogCategory.VOICE, tr("Failed to initialize codec %s: %s"), CodecType[this.type], result.error); return Promise.reject(result.error); }); - this._initialized = true; await this._initialize_promise; } @@ -81,6 +84,8 @@ export class CodecWrapperWorker extends BasicCodec { } async decode(data: Uint8Array): Promise { + if(!this.initialized()) throw "codec not initialized/initialize failed"; + const result = await this.execute("decodeSamples", { data: data, length: data.length }); if(result.timings.downstream > 5 || result.timings.upstream > 5 || result.timings.handle > 5) log.warn(LogCategory.VOICE, tr("Worker message stock time: {downstream: %dms, handle: %dms, upstream: %dms}"), result.timings.downstream, result.timings.handle, result.timings.upstream); @@ -102,6 +107,8 @@ export class CodecWrapperWorker extends BasicCodec { } async encode(data: AudioBuffer) : Promise { + if(!this.initialized()) throw "codec not initialized/initialize failed"; + let buffer = new Float32Array(this.channelCount * data.length); for (let offset = 0; offset < data.length; offset++) { for (let channel = 0; channel < this.channelCount; channel++) diff --git a/web/js/workers/codec/CodecWorker.ts b/web/js/workers/codec/CodecWorker.ts index 9ef301b4..e27ddad3 100644 --- a/web/js/workers/codec/CodecWorker.ts +++ b/web/js/workers/codec/CodecWorker.ts @@ -64,6 +64,9 @@ async function handle_message(command: string, data: any) : Promise { return { { loader: 'css-loader', options: { - modules: true, + modules: { + mode: "local", + localIdentName: '[path][name]__[local]--[hash:base64:5]', //FIXME: Debug mode only! + }, sourceMap: isDevelopment } }, @@ -141,6 +144,12 @@ export const config = async (target: "web" | "client") => { return { }; } } + }, + { + loader: "./webpack/DevelBlocks.js", + options: { + enabled: true + } } ] }, diff --git a/webpack/DevelBlocks.ts b/webpack/DevelBlocks.ts new file mode 100644 index 00000000..a2e0a957 --- /dev/null +++ b/webpack/DevelBlocks.ts @@ -0,0 +1,24 @@ +import {RawSourceMap} from "source-map"; +import * as webpack from "webpack"; +import * as loaderUtils from "loader-utils"; + +import LoaderContext = webpack.loader.LoaderContext; + +export default function loader(this: LoaderContext, source: string | Buffer, sourceMap?: RawSourceMap): string | Buffer | void | undefined { + this.cacheable(); + + const options = loaderUtils.getOptions(this); + if(!options.enabled) { + this.callback(null, source); + return; + } + + const start_regex = "devel-block\\((?\\S+)\\)"; + const end_regex = "devel-block-end"; + + const pattern = new RegExp("[\\t ]*\\/\\* ?" + start_regex + " ?\\*\\/[\\s\\S]*?\\/\\* ?" + end_regex + " ?\\*\\/[\\t ]*\\n?", "g"); + source = (source as string).replace(pattern, (value, type) => { + return "/* snipped block \"" + type + "\" */"; + }); + this.callback(null, source); +} \ No newline at end of file From 7daa7d293307e44e02e32e5bd4f55944e7da28b4 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 18 Apr 2020 20:03:04 +0200 Subject: [PATCH 02/22] Improve channel tree children method speed & Creating directories if needed --- shared/js/ui/channel.ts | 25 ++++++++----------------- shared/js/ui/view.tsx | 11 +++++++---- webpack/ManifestPlugin.ts | 4 +++- 3 files changed, 18 insertions(+), 22 deletions(-) diff --git a/shared/js/ui/channel.ts b/shared/js/ui/channel.ts index ac2f6247..e8186e9a 100644 --- a/shared/js/ui/channel.ts +++ b/shared/js/ui/channel.ts @@ -154,6 +154,7 @@ export class ChannelEntry extends ChannelTreeEntry { channel_previous?: ChannelEntry; channel_next?: ChannelEntry; + child_channel_head?: ChannelEntry; readonly events: Registry; readonly view: React.Ref; @@ -284,25 +285,15 @@ export class ChannelEntry extends ChannelTreeEntry { getChannelId(){ return this.channelId; } children(deep = false) : ChannelEntry[] { - //TODO: Speed this up by caching the children! const result: ChannelEntry[] = []; - if(this.channelTree == null) return []; + let head = this.child_channel_head; + while(head) { + result.push(head); + head = head.channel_next; + } - const self = this; - this.channelTree.channels.forEach(function (entry) { - let current = entry; - if(deep) { - while(current) { - if(current.parent_channel() == self) { - result.push(entry); - break; - } - current = current.parent_channel(); - } - } else - if(current.parent_channel() == self) - result.push(entry); - }); + if(deep) + return result.map(e => e.children(true)).reduce((prv, now) => { prv.push(...now); return prv; }, []); return result; } diff --git a/shared/js/ui/view.tsx b/shared/js/ui/view.tsx index 44d3881c..4ca75b19 100644 --- a/shared/js/ui/view.tsx +++ b/shared/js/ui/view.tsx @@ -359,9 +359,11 @@ export class ChannelTree { } private unregisterChannelFromTree(channel: ChannelEntry) { - //TODO: Parent/Child reference? - if(channel.parent) + if(channel.parent) { + if(channel.parent.child_channel_head === channel) + channel.parent.child_channel_head = channel.channel_next; channel.parent.events.fire("notify_children_changed"); + } if(channel.channel_previous) channel.channel_previous.channel_next = channel.channel_next; @@ -409,12 +411,13 @@ export class ChannelTree { } else { if(parent) { let children = parent.children(); - if(children.length <= 1) { //Self should be already in there + parent.child_channel_head = channel; + if(children.length === 0) { //Self should be already in there channel.channel_next = undefined; } else { channel.channel_previous = undefined; - channel.channel_next = children[1]; /* children 0 shall be the channel itself */ + channel.channel_next = children[0]; channel.channel_next.channel_previous = channel; } parent.events.fire("notify_children_changed"); diff --git a/webpack/ManifestPlugin.ts b/webpack/ManifestPlugin.ts index d0e3d0a3..42d57b3e 100644 --- a/webpack/ManifestPlugin.ts +++ b/webpack/ManifestPlugin.ts @@ -1,5 +1,5 @@ import * as webpack from "webpack"; -import * as fs from "fs"; +import * as fs from "fs-extra"; import * as path from "path"; interface Options { @@ -79,6 +79,8 @@ class ManifestGenerator { }); compiler.hooks.done.tap(this.constructor.name, () => { + const file = this.options.file || "manifest.json"; + fs.mkdirpSync(path.dirname(file)); fs.writeFileSync(this.options.file || "manifest.json", JSON.stringify(this.manifest_content)); }); } From c0d17e83483ff9ffb6639315b48a56a9e8d03906 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 18 Apr 2020 20:07:16 +0200 Subject: [PATCH 03/22] Added develblocks to the TSBase script --- tsbaseconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsbaseconfig.json b/tsbaseconfig.json index 167763c2..2d9d3f12 100644 --- a/tsbaseconfig.json +++ b/tsbaseconfig.json @@ -16,6 +16,7 @@ "webpack/ManifestPlugin.ts", "webpack/EJSGenerator.ts", "webpack/WatLoader.ts", + "webpack/DevelBlocks.ts", "file.ts" ], From df8136acb1cc2da87435320d0457a434081327e5 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 18 Apr 2020 20:25:58 +0200 Subject: [PATCH 04/22] Fixed some small stuff --- shared/js/events.ts | 4 ++-- shared/js/ui/channel.ts | 2 +- shared/js/ui/view.tsx | 12 +++++++----- web/js/connection/ServerConnection.ts | 4 ++++ web/js/voice/VoiceHandler.ts | 5 +---- webpack/DevelBlocks.ts | 3 +++ 6 files changed, 18 insertions(+), 12 deletions(-) diff --git a/shared/js/events.ts b/shared/js/events.ts index e004a400..cd094a32 100644 --- a/shared/js/events.ts +++ b/shared/js/events.ts @@ -31,7 +31,7 @@ export class Registry { handlers: {[key: string]: ((event) => void)[]} }[] = []; private debug_prefix = undefined; - private warn_unhandled_events = true; + private warn_unhandled_events = false; constructor() { this.registry_uuid = "evreg_data_" + guid(); @@ -139,7 +139,7 @@ export class Registry { evhandler.fire_event(type, data); invoke_count++; } - if(invoke_count === 0) { + 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); } } diff --git a/shared/js/ui/channel.ts b/shared/js/ui/channel.ts index e8186e9a..22e10dfa 100644 --- a/shared/js/ui/channel.ts +++ b/shared/js/ui/channel.ts @@ -441,7 +441,7 @@ export class ChannelEntry extends ChannelTreeEntry { name: tr("Edit channel"), invalidPermission: !channelModify, callback: () => { - createChannelModal(this.channelTree.client, this, undefined, this.channelTree.client.permissions, (changes?, permissions?) => { + createChannelModal(this.channelTree.client, this, this.parent, this.channelTree.client.permissions, (changes?, permissions?) => { if(changes) { changes["cid"] = this.channelId; this.channelTree.client.serverConnection.send_command("channeledit", changes); diff --git a/shared/js/ui/view.tsx b/shared/js/ui/view.tsx index 4ca75b19..e7cd48e5 100644 --- a/shared/js/ui/view.tsx +++ b/shared/js/ui/view.tsx @@ -358,11 +358,15 @@ export class ChannelTree { return undefined; } - private unregisterChannelFromTree(channel: ChannelEntry) { + private unregisterChannelFromTree(channel: ChannelEntry, new_parent?: ChannelEntry) { if(channel.parent) { if(channel.parent.child_channel_head === channel) channel.parent.child_channel_head = channel.channel_next; - channel.parent.events.fire("notify_children_changed"); + + /* We need only trigger this once. + If the new parent is equal to the old one with applying the "new" parent this event will get triggered */ + if(new_parent !== channel.parent) + channel.parent.events.fire("notify_children_changed"); } if(channel.channel_previous) @@ -389,7 +393,7 @@ export class ChannelTree { } let root_tree_updated = !channel.parent; - this.unregisterChannelFromTree(channel); + this.unregisterChannelFromTree(channel, parent); channel.channel_previous = channel_previous; channel.channel_next = undefined; channel.parent = parent; @@ -415,8 +419,6 @@ export class ChannelTree { if(children.length === 0) { //Self should be already in there channel.channel_next = undefined; } else { - channel.channel_previous = undefined; - channel.channel_next = children[0]; channel.channel_next.channel_previous = channel; } diff --git a/web/js/connection/ServerConnection.ts b/web/js/connection/ServerConnection.ts index 7eb3ba2c..ccd2898b 100644 --- a/web/js/connection/ServerConnection.ts +++ b/web/js/connection/ServerConnection.ts @@ -345,9 +345,11 @@ export class ServerConnection extends AbstractServerConnection { return; } if(json["type"] === "command") { + /* devel-block(log-networking-commands) */ let group = log.group(log.LogType.DEBUG, LogCategory.NETWORKING, tr("Handling command '%s'"), json["command"]); group.log(tr("Handling command '%s'"), json["command"]); group.group(log.LogType.TRACE, tr("Json:")).collapsed(true).log("%o", json).end(); + /* devel-block-end */ this._command_boss.invoke_handle({ command: json["command"], @@ -361,7 +363,9 @@ export class ServerConnection extends AbstractServerConnection { if(this._voice_connection) this._voice_connection.start_rtc_session(); /* FIXME: Move it to a handler boss and not here! */ } + /* devel-block(log-networking-commands) */ group.end(); + /* devel-block-end */ } else if(json["type"] === "WebRTC") { if(this._voice_connection) this._voice_connection.handleControlPacket(json); diff --git a/web/js/voice/VoiceHandler.ts b/web/js/voice/VoiceHandler.ts index c4077273..69b136a7 100644 --- a/web/js/voice/VoiceHandler.ts +++ b/web/js/voice/VoiceHandler.ts @@ -77,10 +77,7 @@ export namespace codec { this.entries[index].instance.initialise().then((flag) => { //TODO test success flag this.ownCodec(clientId, callback_encoded, false).then(resolve).catch(reject); - }).catch(error => { - log.error(LogCategory.VOICE, tr("Could not initialize codec!\nError: %o"), error); - reject(typeof(error) === 'string' ? error : tr("Could not initialize codec!")); - }); + }).catch(reject); } return; } else if(this.entries[index].owner == 0) { diff --git a/webpack/DevelBlocks.ts b/webpack/DevelBlocks.ts index a2e0a957..06431988 100644 --- a/webpack/DevelBlocks.ts +++ b/webpack/DevelBlocks.ts @@ -18,6 +18,9 @@ export default function loader(this: LoaderContext, source: string | Buffer, sou const pattern = new RegExp("[\\t ]*\\/\\* ?" + start_regex + " ?\\*\\/[\\s\\S]*?\\/\\* ?" + end_regex + " ?\\*\\/[\\t ]*\\n?", "g"); source = (source as string).replace(pattern, (value, type) => { + if(type === "log-networking-commands") + return value; + return "/* snipped block \"" + type + "\" */"; }); this.callback(null, source); From b46a7f59f585c8b3922e12d87a8fcecb48e80837 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 18 Apr 2020 21:26:44 +0200 Subject: [PATCH 05/22] Some more channel tree features --- ChangeLog.md | 1 + shared/js/ui/channel.ts | 39 +++++++++++++++++++++++++++++++++- shared/js/ui/server.ts | 18 +++++++++++++++- shared/js/ui/tree/Channel.scss | 14 ++++++++++++ shared/js/ui/tree/Channel.tsx | 16 +++++++++++++- shared/js/ui/tree/Client.tsx | 2 +- shared/js/ui/tree/Server.scss | 3 +-- shared/js/ui/tree/View.tsx | 6 ++++++ shared/js/ui/view.tsx | 32 ++++++++++++++++++++++++++++ 9 files changed, 125 insertions(+), 6 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index ea0ffa45..fdc86021 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -8,6 +8,7 @@ - Fixed the invalid initialisation of codec workers - Improved context menu subcontainer selection - Fixed client channel permission tab within the permission editor (previously you've been kick from the server) + - Added the ability to collapse/expend the channel tree * **11.04.20** - Only show the host message when its not empty diff --git a/shared/js/ui/channel.ts b/shared/js/ui/channel.ts index 22e10dfa..401c7aaf 100644 --- a/shared/js/ui/channel.ts +++ b/shared/js/ui/channel.ts @@ -19,6 +19,7 @@ import * as React from "react"; import {Registry} from "tc-shared/events"; import {ChannelTreeEntry, ChannelTreeEntryEvents} from "tc-shared/ui/TreeEntry"; import { ChannelEntryView as ChannelEntryView } from "./tree/Channel"; +import {MenuEntryType} from "tc-shared/ui/elements/ContextMenu"; export enum ChannelType { PERMANENT, @@ -88,6 +89,9 @@ export interface ChannelEvents extends ChannelTreeEntryEvents { notify_subscribe_state_changed: { channel_subscribed: boolean }, + notify_collapsed_state_changed: { + collapsed: boolean + }, notify_children_changed: {}, notify_clients_changed: {}, /* will also be fired when clients haven been reordered */ @@ -157,7 +161,7 @@ export class ChannelEntry extends ChannelTreeEntry { child_channel_head?: ChannelEntry; readonly events: Registry; - readonly view: React.Ref; + readonly view: React.RefObject; parsed_channel_name: ParsedChannelName; @@ -172,6 +176,7 @@ export class ChannelEntry extends ChannelTreeEntry { private _cached_channel_description_promise_resolve: any = undefined; private _cached_channel_description_promise_reject: any = undefined; + private _flag_collapsed: boolean; //TODO: Load from config! private _flag_subscribed: boolean; private _subscribe_mode: ChannelSubscribeMode; @@ -378,6 +383,7 @@ export class ChannelEntry extends ChannelTreeEntry { let trigger_close = true; + const collapse_expendable = !!this.child_channel_head || this.client_list.length > 0; const bold = text => contextmenu.get_provider().html_format_enabled() ? "" + text + "" : text; contextmenu.spawn_context_menu(x, y, { type: contextmenu.MenuEntryType.ENTRY, @@ -497,6 +503,25 @@ export class ChannelEntry extends ChannelTreeEntry { }); } }, + { + type: MenuEntryType.HR, + name: "", + visible: collapse_expendable + }, + { + type: contextmenu.MenuEntryType.ENTRY, + icon_class: "client-channel_collapse_all", + name: tr("Collapse sub channels"), + visible: collapse_expendable, + callback: () => this.channelTree.collapse_channels(this) + }, + { + type: contextmenu.MenuEntryType.ENTRY, + icon_class: "client-channel_expand_all", + name: tr("Expend sub channels"), + visible: collapse_expendable, + callback: () => this.channelTree.expand_channels(this) + }, contextmenu.Entry.HR(), { type: contextmenu.MenuEntryType.ENTRY, @@ -665,6 +690,18 @@ export class ChannelEntry extends ChannelTreeEntry { } } + get collapsed() : boolean { + return this._flag_collapsed; + } + + set collapsed(flag: boolean) { + if(this._flag_collapsed === flag) + return; + this._flag_collapsed = flag; + this.events.fire("notify_collapsed_state_changed", { collapsed: flag }); + this.view.current?.forceUpdate(); + } + get flag_subscribed() : boolean { return this._flag_subscribed; } diff --git a/shared/js/ui/server.ts b/shared/js/ui/server.ts index 5aa867f0..743feaae 100644 --- a/shared/js/ui/server.ts +++ b/shared/js/ui/server.ts @@ -245,7 +245,23 @@ export class ServerEntry extends ChannelTreeEntry { name: tr("View avatars"), visible: false, //TODO: Enable again as soon the new design is finished callback: () => spawnAvatarList(this.channelTree.client) - } + }, + { + type: contextmenu.MenuEntryType.HR, + name: '' + }, + { + type: contextmenu.MenuEntryType.ENTRY, + icon_class: "client-channel_collapse_all", + name: tr("Collapse all channels"), + callback: () => this.channelTree.collapse_channels() + }, + { + type: contextmenu.MenuEntryType.ENTRY, + icon_class: "client-channel_expand_all", + name: tr("Expend all channels"), + callback: () => this.channelTree.expand_channels() + }, ]; } diff --git a/shared/js/ui/tree/Channel.scss b/shared/js/ui/tree/Channel.scss index 0f0c6190..26b8e890 100644 --- a/shared/js/ui/tree/Channel.scss +++ b/shared/js/ui/tree/Channel.scss @@ -11,6 +11,20 @@ align-items: center; cursor: pointer; + .containerArrow { + width: 16px; + margin-left: -16px; + text-align: center; + + &.down { + align-self: normal; + } + + :global .arrow { + border-color: hsla(220, 5%, 30%, 1); + } + } + .channelType { flex-grow: 0; flex-shrink: 0; diff --git a/shared/js/ui/tree/Channel.tsx b/shared/js/ui/tree/Channel.tsx index 7ed79488..a90f1aab 100644 --- a/shared/js/ui/tree/Channel.tsx +++ b/shared/js/ui/tree/Channel.tsx @@ -220,6 +220,14 @@ interface ChannelEntryViewProperties { offset: number; } + +const ChannelCollapsedIndicator = (props: { collapsed: boolean, onToggle: () => void }) => { + return
{ + event.preventDefault(); + props.onToggle(); + }} />
+}; + @ReactEventHandler(e => e.props.channel.events) @BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE) export class ChannelEntryView extends TreeEntry { @@ -233,19 +241,25 @@ export class ChannelEntryView extends TreeEntry } render() { + const collapsed_indicator = this.props.channel.child_channel_head || this.props.channel.clients(false).length > 0; return
this.onMouseDown(e as any)} onDoubleClick={() => this.onDoubleClick()} onContextMenu={e => this.onContextMenu(e as any)} > + {collapsed_indicator && this.onCollapsedToggle()} collapsed={this.props.channel.collapsed} />}
; } + private onCollapsedToggle() { + this.props.channel.collapsed = !this.props.channel.collapsed; + } + private onMouseDown(event: MouseEvent) { if(event.button !== 0) return; /* only left mouse clicks */ diff --git a/shared/js/ui/tree/Client.tsx b/shared/js/ui/tree/Client.tsx index 26ea5590..6db96df6 100644 --- a/shared/js/ui/tree/Client.tsx +++ b/shared/js/ui/tree/Client.tsx @@ -362,7 +362,7 @@ export class ClientEntry extends TreeEntry this.onDoubleClick()} onMouseDown={e => this.onMouseDown(e as any)} onContextMenu={e => this.onContextMenu(e as any)} diff --git a/shared/js/ui/tree/Server.scss b/shared/js/ui/tree/Server.scss index 667be765..256a639f 100644 --- a/shared/js/ui/tree/Server.scss +++ b/shared/js/ui/tree/Server.scss @@ -6,7 +6,7 @@ position: relative; cursor: pointer; - margin-left: 2px; + padding-left: 2px; margin-right: 5px; .server_type { @@ -14,7 +14,6 @@ flex-shrink: 0; margin-right: 2px; - margin-left: 2px; z-index: 1; } diff --git a/shared/js/ui/tree/View.tsx b/shared/js/ui/tree/View.tsx index aa7dcfd3..00cea627 100644 --- a/shared/js/ui/tree/View.tsx +++ b/shared/js/ui/tree/View.tsx @@ -49,6 +49,7 @@ export class ChannelTreeView extends ReactComponentBase this.handleTreeUpdate(); this.listener_client_change = () => this.handleTreeUpdate(); this.listener_channel_change = () => this.handleTreeUpdate(); + this.listener_state_collapsed = () => this.handleTreeUpdate(); } private handleTreeUpdate() { @@ -147,11 +149,14 @@ export class ChannelTreeView extends ReactComponentBase }); + + if(entry.collapsed) return; this.flat_tree.push(...entry.clients(false).map(e => { return { entry: e, @@ -171,6 +176,7 @@ export class ChannelTreeView extends ReactComponentBase this.spawnCreateChannel() }, + { + type: contextmenu.MenuEntryType.ENTRY, + icon_class: "client-channel_collapse_all", + name: tr("Collapse all channels"), + callback: () => this.collapse_channels() + }, + { + type: contextmenu.MenuEntryType.ENTRY, + icon_class: "client-channel_expand_all", + name: tr("Expend all channels"), + callback: () => this.expand_channels() + }, contextmenu.Entry.CLOSE(on_close) ); } @@ -1049,4 +1061,24 @@ export class ChannelTree { console.warn(tr("Failed to subscribe to all channels! (%o)"), error); }); } + + expand_channels(root?: ChannelEntry) { + if(typeof root === "undefined") + this.rootChannel().forEach(e => this.expand_channels(e)); + else { + root.collapsed = false; + for(const child of root.children(false)) + this.expand_channels(child); + } + } + + collapse_channels(root?: ChannelEntry) { + if(typeof root === "undefined") + this.rootChannel().forEach(e => this.collapse_channels(e)); + else { + root.collapsed = true; + for(const child of root.children(false)) + this.collapse_channels(child); + } + } } \ No newline at end of file From ee994641efa450294ddcfbd3b979a8a79c9a0955 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 18 Apr 2020 21:31:13 +0200 Subject: [PATCH 06/22] Saving last channel collapsed state --- shared/js/settings.ts | 7 +++++++ shared/js/ui/channel.ts | 7 +++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/shared/js/settings.ts b/shared/js/settings.ts index 894d9914..03a5474f 100644 --- a/shared/js/settings.ts +++ b/shared/js/settings.ts @@ -366,6 +366,13 @@ export class Settings extends StaticSettings { } }; + static readonly FN_SERVER_CHANNEL_COLLAPSED: (channel_id: number) => SettingsKey = channel => { + return { + key: 'channel_collapsed_' + channel, + default_value: false + } + }; + static readonly FN_PROFILE_RECORD: (name: string) => SettingsKey = name => { return { key: 'profile_record' + name diff --git a/shared/js/ui/channel.ts b/shared/js/ui/channel.ts index 401c7aaf..1bfc493b 100644 --- a/shared/js/ui/channel.ts +++ b/shared/js/ui/channel.ts @@ -176,7 +176,7 @@ export class ChannelEntry extends ChannelTreeEntry { private _cached_channel_description_promise_resolve: any = undefined; private _cached_channel_description_promise_reject: any = undefined; - private _flag_collapsed: boolean; //TODO: Load from config! + private _flag_collapsed: boolean; private _flag_subscribed: boolean; private _subscribe_mode: ChannelSubscribeMode; @@ -199,7 +199,7 @@ export class ChannelEntry extends ChannelTreeEntry { this.client_property_listener = (event: ClientEvents["notify_properties_updated"]) => { if(typeof event.updated_properties.client_nickname !== "undefined" || typeof event.updated_properties.client_talk_power !== "undefined") this.reorderClientList(true); - } + }; } destroy() { @@ -691,6 +691,8 @@ export class ChannelEntry extends ChannelTreeEntry { } get collapsed() : boolean { + if(typeof this._flag_collapsed === "undefined") + this._flag_collapsed = this.channelTree.client.settings.server(Settings.FN_SERVER_CHANNEL_COLLAPSED(this.channelId)); return this._flag_collapsed; } @@ -700,6 +702,7 @@ export class ChannelEntry extends ChannelTreeEntry { this._flag_collapsed = flag; this.events.fire("notify_collapsed_state_changed", { collapsed: flag }); this.view.current?.forceUpdate(); + this.channelTree.client.settings.changeServer(Settings.FN_SERVER_CHANNEL_COLLAPSED(this.channelId), flag); } get flag_subscribed() : boolean { From 14d2c863a3cad137a37c006a4457c413611d16b1 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 18 Apr 2020 21:38:12 +0200 Subject: [PATCH 07/22] Do not lookup "localhost" anymore --- shared/js/ConnectionHandler.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/shared/js/ConnectionHandler.ts b/shared/js/ConnectionHandler.ts index d4a2666a..d68e8930 100644 --- a/shared/js/ConnectionHandler.ts +++ b/shared/js/ConnectionHandler.ts @@ -32,6 +32,7 @@ import * as dns from "tc-backend/dns"; import * as top_menu from "tc-shared/ui/frames/MenuBar"; import {EventHandler, Registry} from "tc-shared/events"; import {ServerLog} from "tc-shared/ui/frames/server_log"; +import {server} from "websocket"; export enum DisconnectReason { HANDLER_DESTROYED, @@ -279,7 +280,9 @@ export class ConnectionHandler { } const original_address = {host: server_address.host, port: server_address.port}; - if(dns.supported() && !server_address.host.match(Regex.IP_V4) && !server_address.host.match(Regex.IP_V6)) { + if(server_address.host === "localhost") { + server_address.host = "127.0.0.1"; + } else if(dns.supported() && !server_address.host.match(Regex.IP_V4) && !server_address.host.match(Regex.IP_V6)) { const id = ++this._connect_initialize_id; this.log.log(server_log.Type.CONNECTION_HOSTNAME_RESOLVE, {}); try { From 4045de5ef37271f7168fb3d6a098d9d233b86453 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 18 Apr 2020 22:20:08 +0200 Subject: [PATCH 08/22] Kicked out PHP from the file list --- file.ts | 133 +++----------------------------------------------------- 1 file changed, 5 insertions(+), 128 deletions(-) diff --git a/file.ts b/file.ts index 180761df..59cc6ccb 100644 --- a/file.ts +++ b/file.ts @@ -30,9 +30,9 @@ type ProjectResource = { } const APP_FILE_LIST_SHARED_SOURCE: ProjectResource[] = [ - { /* shared html and php files */ + { /* shared html files */ "type": "html", - "search-pattern": /^.*([a-zA-Z]+)\.(html|php|json)$/, + "search-pattern": /^.*([a-zA-Z]+)\.(html|json)$/, "build-target": "dev|rel", "path": "./", @@ -191,7 +191,7 @@ const APP_FILE_LIST_WEB_SOURCE: ProjectResource[] = [ { /* web html files */ "web-only": true, "type": "html", - "search-pattern": /.*\.(php|html)/, + "search-pattern": /.*\.(html)/, "build-target": "dev|rel", "path": "./", @@ -208,56 +208,11 @@ const APP_FILE_LIST_WEB_SOURCE: ProjectResource[] = [ } ]; -//TODO: This isn't needed anymore -const APP_FILE_LIST_WEB_TEASPEAK: ProjectResource[] = [ - /* special web.teaspeak.de only auth files */ - { /* login page and api */ - "web-only": true, - "type": "html", - "search-pattern": /[a-zA-Z_0-9]+\.(php|html)$/, - "build-target": "dev|rel", - - "path": "./", - "local-path": "./auth/", - "req-parm": ["-xf"] - }, - { /* javascript */ - "web-only": true, - "type": "js", - "search-pattern": /.*\.js$/, - "build-target": "dev|rel", - - "path": "js/", - "local-path": "./auth/js/", - "req-parm": ["-xf"] - }, - { /* web css files */ - "web-only": true, - "type": "css", - "search-pattern": /.*\.css$/, - "build-target": "dev|rel", - - "path": "css/", - "local-path": "./auth/css/", - "req-parm": ["-xf"] - }, - { /* certificates */ - "web-only": true, - "type": "pem", - "search-pattern": /.*\.pem$/, - "build-target": "dev|rel", - - "path": "certs/", - "local-path": "./auth/certs/", - "req-parm": ["-xf"] - } -]; - //FIXME: This isn't working right now const CERTACCEPT_FILE_LIST: ProjectResource[] = [ { /* html files */ "type": "html", - "search-pattern": /^([a-zA-Z]+)\.(html|php|json)$/, + "search-pattern": /^([a-zA-Z]+)\.(html|json)$/, "build-target": "dev|rel", "path": "./popup/certaccept/", @@ -355,7 +310,6 @@ const WEB_APP_FILE_LIST = [ ...APP_FILE_LIST_SHARED_SOURCE, ...APP_FILE_LIST_SHARED_VENDORS, ...APP_FILE_LIST_WEB_SOURCE, - ...APP_FILE_LIST_WEB_TEASPEAK, ...CERTACCEPT_FILE_LIST, ]; @@ -501,8 +455,6 @@ namespace server { import SearchOptions = generator.SearchOptions; export type Options = { port: number; - php: string; - search_options: SearchOptions; } @@ -510,7 +462,6 @@ namespace server { let files: ProjectResource[] = []; let server: http.Server; - let php: string; let options: Options; const use_https = false; @@ -518,21 +469,6 @@ namespace server { options = options_; files = _files; - try { - const info = await exec(options.php + " --version"); - if(info.stderr) - throw info.stderr; - - if(!info.stdout.startsWith("PHP 7.")) - throw "invalid php interpreter version (Require at least 7)"; - - console.debug("Found PHP interpreter:\n%s", info.stdout); - php = options.php; - } catch(error) { - console.error("failed to validate php interpreter: %o", error); - throw "invalid php interpreter"; - } - if(process.env["ssl_enabled"] || use_https) { //openssl req -nodes -new -x509 -keyout files_key.pem -out files_cert.pem const key_file = process.env["ssl_key"] || path.join(__dirname, "files_key.pem"); @@ -566,40 +502,6 @@ namespace server { } } - function serve_php(file: string, query: any, response: http.ServerResponse) { - if(!fs.existsSync("tmp")) - fs.mkdirSync("tmp"); - let tmp_script_name = path.join("tmp", Math.random().toFixed(32).substr(2)); - let script = " $value) $_GET[$key] = $value;\n"; - script += "chdir(urldecode(\"" + encodeURIComponent(path.dirname(file)) + "\"));"; - script += "?>"; - fs.writeFileSync(tmp_script_name, script, {flag: 'w'}); - exec(php + " -d auto_prepend_file=" + tmp_script_name + " " + file).then(result => { - if(result.stderr && !result.stdout) { - response.writeHead(500); - response.write("Encountered error while interpreting PHP script:\n"); - response.write(result.stderr); - response.end(); - return; - } - - response.writeHead(200, "success", { - "Content-Type": "text/html; charset=utf-8" - }); - response.write(result.stdout); - response.end(); - }).catch(error => { - response.writeHead(500); - response.write("Received an exception while interpreting PHP script:\n"); - response.write(error.toString()); - response.end(); - }).then(() => fs.unlink(tmp_script_name)).catch(error => { - console.error("[SERVER] Failed to delete tmp PHP prepend file: %o", error); - }); - } - async function serve_file(pathname: string, query: any, response: http.ServerResponse) { const file = await generator.search_http_file(files, pathname, options.search_options); if(!file) { @@ -612,10 +514,6 @@ namespace server { let type = mt.lookup(path.extname(file)) || "text/html"; console.log("[SERVER] Serving file %s", file, type); - if(path.extname(file) === ".php") { - serve_php(file, query, response); - return; - } const fis = fs.createReadStream(file); response.writeHead(200, "success", { @@ -634,19 +532,12 @@ namespace server { response.writeHead(200, { "info-version": 1 }); response.write("type\thash\tpath\tname\n"); for(const file of await generator.search_files(files, options.search_options)) - if(file.name.endsWith(".php")) - response.write(file.type + "\t" + file.hash + "\t" + path.dirname(file.target_path) + "\t" + path.basename(file.name, ".php") + ".html" + "\n"); - else - response.write(file.type + "\t" + file.hash + "\t" + path.dirname(file.target_path) + "\t" + file.name + "\n"); + response.write(file.type + "\t" + file.hash + "\t" + path.dirname(file.target_path) + "\t" + file.name + "\n"); response.end(); return; } else if(url.query["type"] === "file") { let p = path.join(url.query["path"] as string, url.query["name"] as string).replace(/\\/g, "/"); if(!p.startsWith("/")) p = "/" + p; - if(p.endsWith(".html")) { - const np = await generator.search_http_file(files, p.substr(0, p.length - 5) + ".php", options.search_options); - if(np) p = p.substr(0, p.length - 5) + ".php"; - } serve_file(p, url.query, response); return; } @@ -835,19 +726,9 @@ namespace watcher { } } } - -function php_exe() : string { - if(process.env["PHP_EXE"]) - return process.env["PHP_EXE"]; - if(os.platform() === "win32") - return "php.exe"; - return "php"; -} - async function main_serve(target: "client" | "web", mode: "rel" | "dev", port: number) { await server.launch(target === "client" ? CLIENT_APP_FILE_LIST : WEB_APP_FILE_LIST, { port: port, - php: php_exe(), search_options: { source_path: __dirname, parameter: [], @@ -882,7 +763,6 @@ async function main_develop(node: boolean, target: "client" | "web", port: numbe try { await server.launch(target === "client" ? CLIENT_APP_FILE_LIST : WEB_APP_FILE_LIST, { port: port, - php: php_exe(), search_options: { source_path: __dirname, parameter: [], @@ -1125,9 +1005,6 @@ async function main(args: string[]) { console.log(" node files.js list | List all project files"); console.log(" node files.js develop [port] | Start a developer session. All typescript an SASS files will generated automatically"); console.log(" | You could access your current build via http://localhost:8081"); - console.log(""); - console.log("Influential environment variables:"); - console.log(" PHP_EXE | Path to the PHP CLI interpreter"); } /* proxy log for better format */ From 01f0bff142d0f9f08dc0acdc9edf805a3593d7f5 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 18 Apr 2020 22:20:19 +0200 Subject: [PATCH 09/22] Removed obsolete files --- auth/.gitignore | 11 -- auth/auth.php | 250 --------------------------------------------- auth/css/auth.scss | 80 --------------- auth/js/auth.ts | 76 -------------- auth/login.php | 37 ------- 5 files changed, 454 deletions(-) delete mode 100644 auth/.gitignore delete mode 100644 auth/auth.php delete mode 100644 auth/css/auth.scss delete mode 100644 auth/js/auth.ts delete mode 100644 auth/login.php diff --git a/auth/.gitignore b/auth/.gitignore deleted file mode 100644 index df00405d..00000000 --- a/auth/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -#Nope :) -certs/ - -#A local link just for browsing the files -xf/ - -css/**/*.css -css/**/*.css.map - -js/**/*.js -js/**/*.js.map \ No newline at end of file diff --git a/auth/auth.php b/auth/auth.php deleted file mode 100644 index 1de297bf..00000000 --- a/auth/auth.php +++ /dev/null @@ -1,250 +0,0 @@ -service('XF:User\Login', $username, ""); - if (!$loginService->isLoginLimited()) { - $error = ""; - $user = $loginService->validate($password, $error); - if ($user) { - $response["success"] = true; - $allowed = false; - foreach ($allowedXFGroups as $id) { - foreach ($user->secondary_group_ids as $assigned) - if ($assigned == $id) { - $allowed = true; - break; - } - $allowed |= $user->user_group_id == $id; - if ($allowed) break; - } - if ($allowed) { - $response["allowed"] = true; - - try { - /** @var $session XF\Session\Session */ - $session = $app->session(); - if (!$session->exists()) { - $session->expunge(); - if (!$session->start(remoteAddress())) { - $response["success"] = false; - $response["msg"] = "could not create session"; - goto _return; - } - } - $session->changeUser($user); - $session->save(); - $response["sessionName"] = $session->getCookieName(); - $response["sessionId"] = $session->getSessionId(); - $response["user_name"] = $user->username; - } catch (Exception $error) { - $response["success"] = false; - $response["msg"] = $error->getMessage(); - } - goto _return; - } else { - $response["allowed"] = false; - } - } else { - $response["msg"] = $error; - } - } else { - $response["msg"] = "Too many login's!"; - } - - _return: - return $response; - } - - function logged_in() { - return test_session() == 0; - } - - function logout() - { - $app = getXF(); - if(!$app) return false; - - $session = $app->session(); - $session->expunge(); - - return true; - } - - /** - * @param null $sessionId - * @return int 0 = Success | 1 = Invalid coocie | 2 = invalid session - */ - function test_session($sessionId = null) { - $app = getXF(); - if(!$app) return -1; - - if(!isset($sessionId)) { - if (!isset($_COOKIE[$app->session()->getCookieName()])) - return 1; - $sessionId = $_COOKIE[$app->session()->getCookieName()]; - } - $app->session()->expunge(); - if (!$app->session()->start(remoteAddress(), $sessionId) || !$app->session()->exists()) - return 2; - return 0; - } - - function redirectOnInvalidSession() { - $app = getXF(); - if(!$app) return; - - $status = test_session(); - if ($status != 0) { - $type = "undefined"; - switch ($status) { - case 1: - $type = "nocookie"; - break; - case 2: - $type = "expired"; - break; - default: - $type = "unknown"; - break; - } - header('Location: ' . authPath() . 'login.php?error=' . $type); - setcookie($app->session()->getCookieName(), "", 1); - die(); - } - } - - function setup_forum_auth() { - getXF(); /* Initialize XF */ - } - - if(!defined("_AUTH_API_ONLY")) { - $app = getXF(); - if(!$app) { - die("failed to start app"); - } - - if (isset($_GET["type"])) { - error_log("Got authX request!"); - if ($_GET["type"] == "login") { - die(json_encode(checkLogin($_POST["user"], $_POST["pass"]))); - } else if ($_GET["type"] == "logout") { - logout(); - global $localhost; - if($localhost) - header("Location: login.php"); - else - header("Location: https://web.teaspeak.de/"); - - $session = $app->session(); - setcookie($session->getCookieName(), '', time() - 3600, '/'); - setcookie("session", '', time() - 3600, '/'); - setcookie("user_data", '', time() - 3600, '/'); - setcookie("user_sign", '', time() - 3600, '/'); - } else die("unknown type!"); - } else if(isset($_POST["action"])) { - error_log("Got auth post request!"); - if($_POST["action"] === "login") { - die(json_encode(checkLogin($_POST["user"], $_POST["pass"]))); - } else if ($_POST["action"] === "logout") { - logout(); - die(json_encode([ - "success" => true - ])); - } else if($_POST["action"] === "validate") { - $app = getXF(); - if(test_session($_POST["token"]) === 0) - die(json_encode([ - "success" => true, - "token" => $app->session()->getSessionId() - ])); - else - die(json_encode([ - "success" => false - ])); - } else - die(json_encode([ - "success" => false, - "msg" => "Invalid action" - ])); - } - } \ No newline at end of file diff --git a/auth/css/auth.scss b/auth/css/auth.scss deleted file mode 100644 index 07cd8efb..00000000 --- a/auth/css/auth.scss +++ /dev/null @@ -1,80 +0,0 @@ -body{ - padding:0; - margin:0; -} -.inner { - position: absolute; -} -.inner-container{ - width:400px; - height:400px; - position:absolute; - top:calc(50vh - 200px); - left:calc(50vw - 200px); - overflow:hidden; -} -.box{ - position:absolute; - height:100%; - width:100%; - font-family:Helvetica; - color:#fff; - background:rgba(0,0,0,0.13); - padding:30px 0px; - text-align: center; -} -.box h1{ - text-align:center; - margin:30px 0; - font-size:30px; -} -.box input{ - display:block; - width:300px; - margin:20px auto; - padding:15px; - background:rgba(0,0,0,0.2); - color:#fff; - border:0; -} -.box input:focus,.box input:active,.box button:focus,.box button:active{ - outline:none; -} -.box button { - background:#742ECC; - border:0; - color:#fff; - padding:10px; - font-size:20px; - width:330px; - margin:20px auto; - display:block; - cursor:pointer; -} -.box button:disabled { - background:rgba(0,0,0,0.2); -} -.box button:active{ - background:#27ae60; -} -.box p{ - font-size:14px; - text-align:center; -} -.box p span{ - cursor:pointer; - color:#666; -} - -.box .error { - color: darkred; - display: none; -} - -#login { - display: block; -} -#success { - margin-top: 50px; - display: none; -} \ No newline at end of file diff --git a/auth/js/auth.ts b/auth/js/auth.ts deleted file mode 100644 index 431bcad1..00000000 --- a/auth/js/auth.ts +++ /dev/null @@ -1,76 +0,0 @@ -const btn_login = $("#btn_login"); - -btn_login.on('click', () => { - btn_login - .prop("disabled", true) - .empty() - .append($(document.createElement("i")).addClass("fa fa-circle-o-notch fa-spin")); - submitLogin($("#user").val() as string, $("#pass").val() as string); -}); - -function submitLogin(user: string, pass: string) { - $.ajax({ - url: "auth.php?type=login", - type: "POST", - cache: false, - data: { - user: user, - pass: pass - }, - success: (result: string) => { - setTimeout(() => { - let data; - try { - data = JSON.parse(result); - } catch (e) { - loginFailed("Invalid response: " + result); - return; - } - if (data["success"] == false) { - loginFailed(data["msg"]); - return; - } - if (data["allowed"] == false) { - loginFailed("You're not allowed for the closed beta!"); - return; - } - $("#login").hide(500); - $("#success").show(500); - - document.cookie = data["sessionName"] + "=" + data["sessionId"] + ";path=/"; - document.cookie = data["cookie_name_data"] + "=" + data["user_data"] + ";path=/"; - document.cookie = data["cookie_name_sign"] + "=" + data["user_sign"] + ";path=/"; - console.log(result); - - setTimeout(() => { - window.location.href = btn_login.attr("target"); - }, 1000 + Math.random() % 1500); - }, 500 + Math.random() % 500); - }, - error: function (xhr,status,error) { - loginFailed("Invalid request (" + status + ") => " + error); - } - }); -} - -function loginFailed(err: string = "") { - btn_login - .prop("disabled", false) - .empty() - .append($(document.createElement("a")).text("Login")); - - let errTag = $(".box .error"); - if(err !== "") { - errTag.text(err).show(500); - } else errTag.hide(500); -} - -// - -$("#user").on('keydown', event => { - if(event.key == "Enter") $("#pass").focus(); -}); - -$("#pass").on('keydown', event => { - if(event.key == "Enter") $("#btn_login").trigger("click"); -}); \ No newline at end of file diff --git a/auth/login.php b/auth/login.php deleted file mode 100644 index 631108d3..00000000 --- a/auth/login.php +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - -
-
-

Login

-
- some error code - - - -

Create a account on forum.teaspeak.de

-
- -
-
- - - \ No newline at end of file From e641cef629d7d46a2ae744bb6c015d48884062d4 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 18 Apr 2020 22:30:02 +0200 Subject: [PATCH 10/22] Updated the development setup description --- ChangeLog.md | 1 + ...p_windows.md => setup_devel_environment.md | 59 ++++++++++--------- 2 files changed, 32 insertions(+), 28 deletions(-) rename setup_windows.md => setup_devel_environment.md (60%) diff --git a/ChangeLog.md b/ChangeLog.md index fdc86021..d3a3a4fc 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -9,6 +9,7 @@ - Improved context menu subcontainer selection - Fixed client channel permission tab within the permission editor (previously you've been kick from the server) - Added the ability to collapse/expend the channel tree + - Removed PHP dependencies from the project. PHP isn't needed anymore * **11.04.20** - Only show the host message when its not empty diff --git a/setup_windows.md b/setup_devel_environment.md similarity index 60% rename from setup_windows.md rename to setup_devel_environment.md index 5edf44b2..30bce10a 100644 --- a/setup_windows.md +++ b/setup_devel_environment.md @@ -2,38 +2,42 @@ ## 1.0 Requirements The following tools or applications are required to develop the web client: - [1.1 IDE](#11-ide) -- [1.2 PHP](#12-php) -- [1.3 NodeJS](#13-nodejs) -- [1.3.2 NPM](#132-npm) -- [1.4 Git bash](#14-git-bash) +- [1.2 NodeJS](#12-nodejs) +- [1.2.2 NPM](#122-npm) +- [1.3 Git bash](#13-git-bash) +- [1.4 Docker](#14-docker) ### 1.1 IDE It does not matter which IDE you use, you could even use a command line text editor for developing. -### 1.2 PHP -For having a test environment you require an installation of PHP 5 or grater. -You could just download PHP from [here](https://windows.php.net/download#php-7.4). -Note: -`php.exe` must be accessible via the command line. -This means you'll have to add the `bin` folder to your `PATH` variable. - -### 1.3 NodeJS +### 1.2 NodeJS For building and serving you require `nodejs` grater than 8. Nodejs is easily downloadable from [here](). Ensure you've added `node.exe` to the environment path! -### 1.3.2 NPM +### 1.2.2 NPM Normally NPM already comes with the NodeJS installer. So you don't really have to worry about it. NPM min 6.X is required to develop this project. With NPM you could easily download all required dependencies by just typing `npm install`. IMPORTANT: NPM must be available within the PATH environment variable! -### 1.4 Git bash +### 1.3 Git bash For using the `.sh` build scripts you require Git Bash. A minimum of 4.2 is recommend, but in general every modern version should work. +### 1.4 Docker +For building the native scripts you need docker. +Just install docker from [docker.com](https://docs.docker.com). +Attention: If you're having Windows make sure you're running linux containers! + +In order to setup the later required containers, just execute these commands: +Make sure you're within the web source directory! If not replace the `$(pwd)` the the path the web client is located at +```shell script +docker run -dit --name emscripten -v "$(pwd)":"/src/" trzeci/emscripten:sdk-incoming-64bit bash +``` + ## 2.0 Project initialization ### 2.1 Cloning the WebClient @@ -50,26 +54,18 @@ git submodule update --init ### 2.2 Setting up native scripts TeaWeb uses the Opus audio codec. Because almost no browser supports it out of the box we've to implement our own de/encoder. For this we're using `emscripten` to compile the codec. -Because this is a quite time consuming task we already offer prebuild javascript and wasm files. -So we just need to download them. Just execute the `download_compiled_files.sh` shell script within the `asm` folder. +In order to build the required javascript and wasm files just executing this in your git bash: ```shell script -./asm/download_compiled_files.sh +docker exec -it emscripten bash -c 'web/native-codec/build.sh' ``` - + ### 2.3 Initializing NPM To download all required packages simply type: ```shell script npm install ``` -### 2.4 Initial client compilation -Before you could start ahead with developing you've to compile everything. -Just execute the `web_build.sh` script: -```shell script -./scripts/web_build.sh development -``` - -### 2.5 Starting the development environment +### 2.4 You're ready to go and start developing To start the development environment which automatically compiles all your changed scripts and style sheets you simply have to execute: ```shell script @@ -78,5 +74,12 @@ npm start web This will also spin up a temporary web server where you could test out your newest changes. The server will by default listen on `http://localhost:8081` -### 2.6 You're ready -Now you're ready to start ahead and implement your own great ideas. +### 2.5 Using your UI within the TeaClient +An explanation how this works will come later. Stay tuned! + +### 2.6 Generate a release package +In order to build your own TeaWeb-Package just execute the `scripts/build.sh` script. +```shell script +./scripts/build.sh web rel +``` +You could also create a debug packaged just by replacing `rel` with `dev`. From 91284f7b1dff3228f502833c842a40a972f3ac00 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 18 Apr 2020 22:35:23 +0200 Subject: [PATCH 11/22] Added a check within the travis script if `emcc` is available locally or if we have a need to execute it within the docker --- scripts/travis.sh | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/scripts/travis.sh b/scripts/travis.sh index 559b55cc..ede2166c 100644 --- a/scripts/travis.sh +++ b/scripts/travis.sh @@ -148,11 +148,21 @@ if [[ -e "$LOG_FILE" ]]; then fi chmod +x ./web/native-codec/build.sh -execute \ - "Building native codes" \ - "Failed to build native opus codec" \ - "docker exec -it emscripten bash -c 'web/native-codec/build.sh'" +if hash emcmake 2>/dev/null; then + hash cmake 2>/dev/null || { echo "Missing cmake. Please install cmake before retrying. (apt-get install cmake)"; exit 1; } + hash make 2>/dev/null || { echo "Missing make. Please install build-essential before retrying. (apt-get install build-essential)"; exit 1; } + echo "Found installation of emcmake locally. Don't use docker in order to build the native parts." + execute \ + "Building native codes" \ + "Failed to build native opus codec" \ + "./web/native-codec/build.sh" +else + execute \ + "Building native codes" \ + "Failed to build native opus codec" \ + "docker exec -it emscripten bash -c 'web/native-codec/build.sh'" +fi echo "---------- Web client ----------" function move_target_file() { From 644d9a88169de1b4d556171512908cd4df5cf6a7 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 18 Apr 2020 22:38:10 +0200 Subject: [PATCH 12/22] Updated the package generation command line --- setup_devel_environment.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup_devel_environment.md b/setup_devel_environment.md index 30bce10a..256bd173 100644 --- a/setup_devel_environment.md +++ b/setup_devel_environment.md @@ -80,6 +80,7 @@ An explanation how this works will come later. Stay tuned! ### 2.6 Generate a release package In order to build your own TeaWeb-Package just execute the `scripts/build.sh` script. ```shell script -./scripts/build.sh web rel +./scripts/build.sh web rel # Build the web client +./scripts/web_package.sh rel # Bundle the webclient into one .zip archive ``` You could also create a debug packaged just by replacing `rel` with `dev`. From fbec274c072e80e6dc49d58cee65cea2b0edb097 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 18 Apr 2020 22:45:18 +0200 Subject: [PATCH 13/22] Using hashed CSS names for production --- webpack.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webpack.config.ts b/webpack.config.ts index 7762509c..c5df4484 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -113,7 +113,7 @@ export const config = async (target: "web" | "client") => { return { options: { modules: { mode: "local", - localIdentName: '[path][name]__[local]--[hash:base64:5]', //FIXME: Debug mode only! + localIdentName: isDevelopment ? "[path][name]__[local]--[hash:base64:5]" : "[hash:base64]", }, sourceMap: isDevelopment } From 37bd67af85cd413003f48666746234d4968f89f0 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sun, 19 Apr 2020 17:44:59 +0200 Subject: [PATCH 14/22] Some minor changed related to the WebRTC bridge --- web/js/voice/VoiceHandler.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/web/js/voice/VoiceHandler.ts b/web/js/voice/VoiceHandler.ts index 69b136a7..afb57620 100644 --- a/web/js/voice/VoiceHandler.ts +++ b/web/js/voice/VoiceHandler.ts @@ -398,6 +398,8 @@ export class VoiceConnection extends AbstractVoiceConnection { sdpConstraints.offerToReceiveVideo = false; sdpConstraints.voiceActivityDetection = true; + this.rtcPeerConnection.onicegatheringstatechange = () => console.log("ICE gathering state changed to %s", this.rtcPeerConnection.iceGatheringState); + this.rtcPeerConnection.oniceconnectionstatechange = () => console.log("ICE connection state changed to %s", this.rtcPeerConnection.iceConnectionState); this.rtcPeerConnection.onicecandidate = this.on_local_ice_candidate.bind(this); if(this.local_audio_stream) { //May a typecheck? this.rtcPeerConnection.addStream(this.local_audio_stream.stream); @@ -442,6 +444,13 @@ export class VoiceConnection extends AbstractVoiceConnection { log.info(LogCategory.VOICE, tr("Failed to add remote cached ice candidate %s: %o"), msg, error); }); } + console.log("Applying finish notification"); + this.rtcPeerConnection.addIceCandidate(new RTCIceCandidate({ candidate: "", sdpMLineIndex: 0, sdpMid: "0" })).catch(error => { + log.info(LogCategory.VOICE, tr("Failed to add signal a ICE candidate finish: %o"), error); + }); + this.rtcPeerConnection.addIceCandidate(new RTCIceCandidate({ candidate: "", sdpMLineIndex: 1, sdpMid: "1" })).catch(error => { + log.info(LogCategory.VOICE, tr("Failed to add signal a ICE candidate finish: %o"), error); + }); this._ice_cache = []; }).catch(error => { log.info(LogCategory.VOICE, tr("Failed to apply remote description: %o"), error); //FIXME error handling! @@ -456,6 +465,16 @@ export class VoiceConnection extends AbstractVoiceConnection { log.info(LogCategory.VOICE, tr("Cache remote ice! (%o)"), json["msg"]); this._ice_cache.push(json["msg"]); } + } else if(json["request"] === "ice_finish") { + if(!this._ice_use_cache) { + log.info(LogCategory.VOICE, tr("Remote signalled ice candidate finished: %o"), json["msg"]); + this.rtcPeerConnection.addIceCandidate(new RTCIceCandidate(json["msg"])).catch(error => { + log.info(LogCategory.VOICE, tr("Failed to add signal a ICE candidate finish: %o"), error); + }); + } else { + log.info(LogCategory.VOICE, tr("Cache remote ice finish.")); + this._ice_cache.push(json["msg"]); + } } else if(json["request"] == "status") { if(json["state"] == "failed") { const chandler = this.connection.client; @@ -469,13 +488,15 @@ export class VoiceConnection extends AbstractVoiceConnection { } //TODO handle fail specially when its not allowed to reconnect } + } else { + log.warn(LogCategory.NETWORKING, tr("Received unknown web client control packet: %s"), json["request"]); } } private on_local_ice_candidate(event: RTCPeerConnectionIceEvent) { if (event) { - //if(event.candidate && event.candidate.protocol !== "udp") - // return; + if(event.candidate && event.candidate.protocol !== "udp") + return; log.info(LogCategory.VOICE, tr("Gathered local ice candidate %o."), event.candidate); if(event.candidate) { From 0abd6c317831ab4aba29b814bdf37ea031ef6125 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sun, 19 Apr 2020 23:21:37 +0200 Subject: [PATCH 15/22] Using TCP only, because TeaSpeak does not really support UDP yet (missing packet resend for SCTP) --- web/js/voice/VoiceHandler.ts | 63 ++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/web/js/voice/VoiceHandler.ts b/web/js/voice/VoiceHandler.ts index afb57620..0eef79c4 100644 --- a/web/js/voice/VoiceHandler.ts +++ b/web/js/voice/VoiceHandler.ts @@ -385,8 +385,9 @@ export class VoiceConnection extends AbstractVoiceConnection { let config: RTCConfiguration = {}; config.iceServers = []; config.iceServers.push({ urls: 'stun:stun.l.google.com:19302' }); + //config.iceServers.push({ urls: "stun:stun.teaspeak.de:3478" }); this.rtcPeerConnection = new RTCPeerConnection(config); - const dataChannelConfig = { ordered: true, maxRetransmits: 0 }; + const dataChannelConfig = { ordered: false, maxRetransmits: 0 }; this.dataChannel = this.rtcPeerConnection.createDataChannel('main', dataChannelConfig); this.dataChannel.onmessage = this.on_data_channel_message.bind(this); @@ -430,8 +431,26 @@ export class VoiceConnection extends AbstractVoiceConnection { this.connection.client.update_voice_status(undefined); } + private registerRemoteICECandidate(candidate: RTCIceCandidate) { + if(candidate.candidate === "") { + console.log("Adding end candidate"); + this.rtcPeerConnection.addIceCandidate(null).catch(error => { + log.info(LogCategory.VOICE, tr("Failed to add remote cached ice candidate finish: %o"), error); + }); + return; + } + + const pcandidate = new RTCIceCandidate(candidate); + if(pcandidate.protocol !== "tcp") return; /* UDP does not work currently */ + + log.info(LogCategory.VOICE, tr("Add remote ice! (%o)"), pcandidate); + this.rtcPeerConnection.addIceCandidate(pcandidate).catch(error => { + log.info(LogCategory.VOICE, tr("Failed to add remote cached ice candidate %o: %o"), candidate, error); + }); + } + private _ice_use_cache: boolean = true; - private _ice_cache: any[] = []; + private _ice_cache: RTCIceCandidate[] = []; handleControlPacket(json) { if(json["request"] === "answer") { const session_description = new RTCSessionDescription(json["msg"]); @@ -439,41 +458,20 @@ export class VoiceConnection extends AbstractVoiceConnection { this.rtcPeerConnection.setRemoteDescription(session_description).then(() => { log.info(LogCategory.VOICE, tr("Answer applied successfully. Applying ICE candidates (%d)."), this._ice_cache.length); this._ice_use_cache = false; - for(let msg of this._ice_cache) { - this.rtcPeerConnection.addIceCandidate(new RTCIceCandidate(msg)).catch(error => { - log.info(LogCategory.VOICE, tr("Failed to add remote cached ice candidate %s: %o"), msg, error); - }); - } - console.log("Applying finish notification"); - this.rtcPeerConnection.addIceCandidate(new RTCIceCandidate({ candidate: "", sdpMLineIndex: 0, sdpMid: "0" })).catch(error => { - log.info(LogCategory.VOICE, tr("Failed to add signal a ICE candidate finish: %o"), error); - }); - this.rtcPeerConnection.addIceCandidate(new RTCIceCandidate({ candidate: "", sdpMLineIndex: 1, sdpMid: "1" })).catch(error => { - log.info(LogCategory.VOICE, tr("Failed to add signal a ICE candidate finish: %o"), error); - }); + + for(let candidate of this._ice_cache) + this.registerRemoteICECandidate(candidate); this._ice_cache = []; }).catch(error => { log.info(LogCategory.VOICE, tr("Failed to apply remote description: %o"), error); //FIXME error handling! }); - } else if(json["request"] === "ice") { + } else if(json["request"] === "ice" || json["request"] === "ice_finish") { + const candidate = new RTCIceCandidate(json["msg"]); if(!this._ice_use_cache) { - log.info(LogCategory.VOICE, tr("Add remote ice! (%o)"), json["msg"]); - this.rtcPeerConnection.addIceCandidate(new RTCIceCandidate(json["msg"])).catch(error => { - log.info(LogCategory.VOICE, tr("Failed to add remote ice candidate %s: %o"), json["msg"], error); - }); + this.registerRemoteICECandidate(candidate); } else { log.info(LogCategory.VOICE, tr("Cache remote ice! (%o)"), json["msg"]); - this._ice_cache.push(json["msg"]); - } - } else if(json["request"] === "ice_finish") { - if(!this._ice_use_cache) { - log.info(LogCategory.VOICE, tr("Remote signalled ice candidate finished: %o"), json["msg"]); - this.rtcPeerConnection.addIceCandidate(new RTCIceCandidate(json["msg"])).catch(error => { - log.info(LogCategory.VOICE, tr("Failed to add signal a ICE candidate finish: %o"), error); - }); - } else { - log.info(LogCategory.VOICE, tr("Cache remote ice finish.")); - this._ice_cache.push(json["msg"]); + this._ice_cache.push(candidate); } } else if(json["request"] == "status") { if(json["state"] == "failed") { @@ -495,17 +493,18 @@ export class VoiceConnection extends AbstractVoiceConnection { private on_local_ice_candidate(event: RTCPeerConnectionIceEvent) { if (event) { - if(event.candidate && event.candidate.protocol !== "udp") + if(event.candidate && event.candidate.protocol !== "tcp") return; - log.info(LogCategory.VOICE, tr("Gathered local ice candidate %o."), event.candidate); if(event.candidate) { + log.info(LogCategory.VOICE, tr("Gathered local ice candidate for stream %d: %s"), event.candidate.sdpMLineIndex, event.candidate.candidate); this.connection.sendData(JSON.stringify({ type: 'WebRTC', request: "ice", msg: event.candidate, })); } else { + log.info(LogCategory.VOICE, tr("Local ICE candidate gathering finish.")); this.connection.sendData(JSON.stringify({ type: 'WebRTC', request: "ice_finish" From 7871d7c1897bfc47318f14e14341c0365b0d8d0b Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Tue, 21 Apr 2020 15:18:16 +0200 Subject: [PATCH 16/22] A lot of updates --- ChangeLog.md | 4 + loader/app/targets/app.ts | 4 - shared/css/static/main-layout.scss | 16 -- shared/css/static/modal-permissions.scss | 1 + shared/js/connection/CommandHelper.ts | 4 +- shared/js/settings.ts | 5 +- shared/js/ui/client_move.ts | 167 --------------------- shared/js/ui/frames/control-bar/index.scss | 1 + shared/js/ui/frames/side/music_info.ts | 2 +- shared/js/ui/tree/Channel.tsx | 6 +- shared/js/ui/tree/Client.tsx | 7 +- shared/js/ui/tree/Server.tsx | 5 +- shared/js/ui/tree/TreeEntryMove.scss | 22 +++ shared/js/ui/tree/TreeEntryMove.tsx | 95 ++++++++++++ shared/js/ui/tree/View.scss | 8 +- shared/js/ui/tree/View.tsx | 86 ++++++++++- shared/js/ui/view.tsx | 83 +++++++--- 17 files changed, 293 insertions(+), 223 deletions(-) delete mode 100644 shared/js/ui/client_move.ts create mode 100644 shared/js/ui/tree/TreeEntryMove.scss create mode 100644 shared/js/ui/tree/TreeEntryMove.tsx diff --git a/ChangeLog.md b/ChangeLog.md index d3a3a4fc..37d1abb7 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,4 +1,8 @@ # Changelog: +* **21.04.20** + - Clicking on the music bot does not longer results in the insufficient permission sound when the client has no permissions + - Fixed permission editor overflow + * **18.04.20** - Recoded the channel tree using React - Heavily improved channel tree performance on large servers (fluent scroll & updates) diff --git a/loader/app/targets/app.ts b/loader/app/targets/app.ts index 6a1d810d..932c9b34 100644 --- a/loader/app/targets/app.ts +++ b/loader/app/targets/app.ts @@ -345,10 +345,6 @@ loader.register_task(loader.Stage.SETUP, { const container = document.createElement("div"); container.setAttribute('id', "mouse-move"); - const inner_container = document.createElement("div"); - inner_container.classList.add("container"); - container.append(inner_container); - body.append(container); } /* tooltip container */ diff --git a/shared/css/static/main-layout.scss b/shared/css/static/main-layout.scss index 3367846f..26448d41 100644 --- a/shared/css/static/main-layout.scss +++ b/shared/css/static/main-layout.scss @@ -257,22 +257,6 @@ $animation_seperator_length: .1s; } } -#mouse-move { - display: none; - position: absolute; - z-index: 10000; - - .container { - position: relative; - display: block; - - border: 2px solid gray; - -webkit-border-radius: 2px; - -moz-border-radius: 2px; - border-radius: 2px; - } -} - html, body { overflow: hidden; } diff --git a/shared/css/static/modal-permissions.scss b/shared/css/static/modal-permissions.scss index 6d8d5ff6..43336af4 100644 --- a/shared/css/static/modal-permissions.scss +++ b/shared/css/static/modal-permissions.scss @@ -166,6 +166,7 @@ width: 25%; min-width: 10em; min-height: 10em; + overflow: hidden; background-color: #222226; diff --git a/shared/js/connection/CommandHelper.ts b/shared/js/connection/CommandHelper.ts index 869a4afa..45571b7d 100644 --- a/shared/js/connection/CommandHelper.ts +++ b/shared/js/connection/CommandHelper.ts @@ -262,7 +262,7 @@ export class CommandHelper extends AbstractCommandHandler { }); } - request_playlist_songs(playlist_id: number) : Promise { + request_playlist_songs(playlist_id: number, process_result?: boolean) : Promise { let bulked_response = false; let bulk_index = 0; @@ -314,7 +314,7 @@ export class CommandHelper extends AbstractCommandHandler { }; this.handler_boss.register_single_handler(single_handler); - this.connection.send_command("playlistsonglist", {playlist_id: playlist_id}).catch(error => { + this.connection.send_command("playlistsonglist", {playlist_id: playlist_id}, { process_result: process_result }).catch(error => { this.handler_boss.remove_single_handler(single_handler); if(error instanceof CommandResult) { if(error.id == ErrorID.EMPTY_RESULT) { diff --git a/shared/js/settings.ts b/shared/js/settings.ts index 03a5474f..faaaf7be 100644 --- a/shared/js/settings.ts +++ b/shared/js/settings.ts @@ -493,14 +493,15 @@ export class ServerSettings extends SettingsBase { server?(key: string | SettingsKey, _default?: T) : T { if(this._destroyed) throw "destroyed"; - return StaticSettings.resolveKey(Settings.keyify(key), _default, key => this.cacheServer[key]); + const kkey = Settings.keyify(key); + return StaticSettings.resolveKey(kkey, typeof _default === "undefined" ? kkey.default_value : _default, key => this.cacheServer[key]); } changeServer(key: string | SettingsKey, value?: T) { if(this._destroyed) throw "destroyed"; key = Settings.keyify(key); - if(this.cacheServer[key.key] == value) return; + if(this.cacheServer[key.key] === value) return; this._server_settings_updated = true; this.cacheServer[key.key] = StaticSettings.transformOtS(value); diff --git a/shared/js/ui/client_move.ts b/shared/js/ui/client_move.ts deleted file mode 100644 index da548c80..00000000 --- a/shared/js/ui/client_move.ts +++ /dev/null @@ -1,167 +0,0 @@ -import {ChannelTree} from "tc-shared/ui/view"; -import * as log from "tc-shared/log"; -import {LogCategory} from "tc-shared/log"; -import {ClientEntry} from "tc-shared/ui/client"; -import {ChannelEntry} from "tc-shared/ui/channel"; - -export class ClientMover { - static readonly listener_root = $(document); - static readonly move_element = $("#mouse-move"); - readonly channel_tree: ChannelTree; - - selected_client: ClientEntry | ClientEntry[]; - - hovered_channel: HTMLDivElement; - callback: (channel?: ChannelEntry) => any; - - enabled: boolean = true; - - private _bound_finish; - private _bound_move; - private _active: boolean = false; - - private origin_point: {x: number, y: number} = undefined; - - constructor(tree: ChannelTree) { - this.channel_tree = tree; - } - - is_active() { return this._active; } - - private hover_text() { - if($.isArray(this.selected_client)) { - return this.selected_client.filter(client => !!client).map(client => client.clientNickName()).join(", "); - } else if(this.selected_client) { - return (this.selected_client).clientNickName(); - } else - return ""; - } - - private bbcode_text() { - if($.isArray(this.selected_client)) { - return this.selected_client.filter(client => !!client).map(client => client.create_bbcode()).join(", "); - } else if(this.selected_client) { - return (this.selected_client).create_bbcode(); - } else - return ""; - } - - activate(client: ClientEntry | ClientEntry[], callback: (channel?: ChannelEntry) => any, event: any) { - this.finish_listener(undefined); - - if(!this.enabled) - return false; - - this.selected_client = client; - this.callback = callback; - log.debug(LogCategory.GENERAL, tr("Starting mouse move")); - - ClientMover.listener_root.on('mouseup', this._bound_finish = this.finish_listener.bind(this)).on('mousemove', this._bound_move = this.move_listener.bind(this)); - - { - const content = ClientMover.move_element.find(".container"); - content.empty(); - content.append($.spawn("a").text(this.hover_text())); - } - this.move_listener(event); - } - - private move_listener(event) { - if(!this.enabled) - return; - - //console.log("Mouse move: " + event.pageX + " - " + event.pageY); - if(!event.pageX || !event.pageY) return; - if(!this.origin_point) - this.origin_point = {x: event.pageX, y: event.pageY}; - - ClientMover.move_element.css({ - "top": (event.pageY - 1) + "px", - "left": (event.pageX + 10) + "px" - }); - - if(!this._active) { - const d_x = this.origin_point.x - event.pageX; - const d_y = this.origin_point.y - event.pageY; - this._active = Math.sqrt(d_x * d_x + d_y * d_y) > 5 * 5; - - if(this._active) { - if($.isArray(this.selected_client)) { - this.channel_tree.onSelect(this.selected_client[0], true); - for(const client of this.selected_client.slice(1)) - this.channel_tree.onSelect(client, false, true); - } else { - this.channel_tree.onSelect(this.selected_client, true); - } - - ClientMover.move_element.show(); - } - } - - const elements = document.elementsFromPoint(event.pageX, event.pageY); - while(elements.length > 0) { - if(elements[0].classList.contains("container-channel")) break; - elements.pop_front(); - } - - if(this.hovered_channel) { - this.hovered_channel.classList.remove("move-selected"); - this.hovered_channel = undefined; - } - if(elements.length > 0) { - elements[0].classList.add("move-selected"); - this.hovered_channel = elements[0] as HTMLDivElement; - } - } - - private finish_listener(event) { - ClientMover.move_element.hide(); - log.debug(LogCategory.GENERAL, tr("Finishing mouse move")); - - const channel_id = this.hovered_channel ? parseInt(this.hovered_channel.getAttribute("channel-id")) : 0; - ClientMover.listener_root.unbind('mouseleave', this._bound_finish); - ClientMover.listener_root.unbind('mouseup', this._bound_finish); - ClientMover.listener_root.unbind('mousemove', this._bound_move); - if(this.hovered_channel) { - this.hovered_channel.classList.remove("move-selected"); - this.hovered_channel = undefined; - } - - this.origin_point = undefined; - if(!this._active) { - this.selected_client = undefined; - this.callback = undefined; - return; - } - - this._active = false; - if(this.callback) { - if(!channel_id) - this.callback(undefined); - else { - this.callback(this.channel_tree.findChannel(channel_id)); - } - this.callback = undefined; - } - - /* test for the chat box */ - { - const elements = document.elementsFromPoint(event.pageX, event.pageY); - console.error(elements); - while(elements.length > 0) { - if(elements[0].classList.contains("client-chat-box-field")) break; - elements.pop_front(); - } - - if(elements.length > 0) { - const element = $(elements[0]); - element.val((element.val() || "") + this.bbcode_text()); - } - } - } - - deactivate() { - this.callback = undefined; - this.finish_listener(undefined); - } -} \ No newline at end of file diff --git a/shared/js/ui/frames/control-bar/index.scss b/shared/js/ui/frames/control-bar/index.scss index 78da44ec..ddbb6808 100644 --- a/shared/js/ui/frames/control-bar/index.scss +++ b/shared/js/ui/frames/control-bar/index.scss @@ -16,6 +16,7 @@ html:root { height: 100%; align-items: center; background: var(--menu-bar-background); + border-radius: 5px; /* tmp fix for ultra small devices */ overflow-y: visible; diff --git a/shared/js/ui/frames/side/music_info.ts b/shared/js/ui/frames/side/music_info.ts index f417f9aa..9246a9a6 100644 --- a/shared/js/ui/frames/side/music_info.ts +++ b/shared/js/ui/frames/side/music_info.ts @@ -736,7 +736,7 @@ export class MusicInfo { this._current_bot.updateClientVariables(true).catch(error => { log.warn(LogCategory.CLIENT, tr("Failed to update music bot variables: %o"), error); }).then(() => { - this.handle.handle.serverConnection.command_helper.request_playlist_songs(this._current_bot.properties.client_playlist_id).then(songs => { + this.handle.handle.serverConnection.command_helper.request_playlist_songs(this._current_bot.properties.client_playlist_id, false).then(songs => { this.playlist_subscribe(false); /* we're allowed to see the playlist */ if(!songs) { this._container_playlist.find(".overlay-empty").removeClass("hidden"); diff --git a/shared/js/ui/tree/Channel.tsx b/shared/js/ui/tree/Channel.tsx index a90f1aab..4591a174 100644 --- a/shared/js/ui/tree/Channel.tsx +++ b/shared/js/ui/tree/Channel.tsx @@ -244,7 +244,7 @@ export class ChannelEntryView extends TreeEntry const collapsed_indicator = this.props.channel.child_channel_head || this.props.channel.clients(false).length > 0; return
this.onMouseDown(e as any)} + onMouseUp={e => this.onMouseUp(e as any)} onDoubleClick={() => this.onDoubleClick()} onContextMenu={e => this.onContextMenu(e as any)} > @@ -260,10 +260,12 @@ export class ChannelEntryView extends TreeEntry this.props.channel.collapsed = !this.props.channel.collapsed; } - private onMouseDown(event: MouseEvent) { + private onMouseUp(event: MouseEvent) { if(event.button !== 0) return; /* only left mouse clicks */ const channel = this.props.channel; + if(channel.channelTree.isClientMoveActive()) return; + channel.channelTree.events.fire("action_select_entries", { entries: [ channel ], mode: "auto" diff --git a/shared/js/ui/tree/Client.tsx b/shared/js/ui/tree/Client.tsx index 6db96df6..9d886ac9 100644 --- a/shared/js/ui/tree/Client.tsx +++ b/shared/js/ui/tree/Client.tsx @@ -364,7 +364,7 @@ export class ClientEntry extends TreeEntry this.onDoubleClick()} - onMouseDown={e => this.onMouseDown(e as any)} + onMouseUp={e => this.onMouseUp(e as any)} onContextMenu={e => this.onContextMenu(e as any)} > @@ -403,10 +403,11 @@ export class ClientEntry extends TreeEntry this.onMouseDown(e as any)} + onMouseUp={e => this.onMouseUp(e as any)} onContextMenu={e => this.onContextMenu(e as any)} > @@ -87,8 +87,9 @@ export class ServerEntry extends TreeEntry } - private onMouseDown(event: MouseEvent) { + private onMouseUp(event: MouseEvent) { if(event.button !== 0) return; /* only left mouse clicks */ + if(this.props.server.channelTree.isClientMoveActive()) return; this.props.server.channelTree.events.fire("action_select_entries", { entries: [ this.props.server ], diff --git a/shared/js/ui/tree/TreeEntryMove.scss b/shared/js/ui/tree/TreeEntryMove.scss new file mode 100644 index 00000000..5f2bdb3a --- /dev/null +++ b/shared/js/ui/tree/TreeEntryMove.scss @@ -0,0 +1,22 @@ +html:root { + --channel-tree-move-color: hsla(220, 5%, 2%, 1); + --channel-tree-move-background: hsla(0, 0%, 25%, 1); + --channel-tree-move-border: hsla(220, 4%, 40%, 1); +} + +.moveContainer { + position: absolute; + display: block; + + border: 2px solid var(--channel-tree-move-border); + background-color: var(--channel-tree-move-background); + + z-index: 10000; + margin-left: 5px; + + padding-left: .25em; + padding-right: .25em; + + border-radius: 2px; + color: var(--channel-tree-move-color); +} \ No newline at end of file diff --git a/shared/js/ui/tree/TreeEntryMove.tsx b/shared/js/ui/tree/TreeEntryMove.tsx new file mode 100644 index 00000000..5035a8c3 --- /dev/null +++ b/shared/js/ui/tree/TreeEntryMove.tsx @@ -0,0 +1,95 @@ +import {ReactComponentBase} from "tc-shared/ui/react-elements/ReactComponentBase"; +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import {ChannelTreeView} from "tc-shared/ui/tree/View"; +const moveStyle = require("./TreeEntryMove.scss"); + +export interface TreeEntryMoveProps { + onMoveEnd: (point: { x: number, y: number }) => void; +} + +export interface TreeEntryMoveState { + tree_view: ChannelTreeView; + + begin: { x: number, y: number }; + description: string; +} + +export class TreeEntryMove extends ReactComponentBase { + private readonly domContainer; + private readonly document_mouse_out_listener; + private readonly document_mouse_listener; + private readonly ref_container: React.RefObject; + + private current: { x: number, y: number }; + + constructor(props) { + super(props); + + this.ref_container = React.createRef(); + this.domContainer = document.getElementById("mouse-move"); + this.document_mouse_out_listener = (e: MouseEvent) => { + if(e.type === "mouseup") { + if(e.button !== 0) return; + + this.props.onMoveEnd({ x: e.pageX, y: e.pageY }); + } + + this.disableEntryMove(); + }; + + this.document_mouse_listener = (e: MouseEvent) => { + this.current = { x: e.pageX, y: e.pageY }; + const container = this.ref_container.current; + if(!container) return; + + container.style.top = e.pageY + "px"; + container.style.left = e.pageX + "px"; + }; + } + + enableEntryMove(view: ChannelTreeView, description: string, begin: { x: number, y: null }, current: { x: number, y: null }, callback_enabled?: () => void) { + this.setState({ + tree_view: view, + begin: begin, + description: description + }, callback_enabled); + + this.current = current; + document.addEventListener("mousemove", this.document_mouse_listener); + document.addEventListener("mouseleave", this.document_mouse_out_listener); + document.addEventListener("mouseup", this.document_mouse_out_listener); + } + + private disableEntryMove() { + this.setState({ + tree_view: null + }); + document.removeEventListener("mousemove", this.document_mouse_listener); + document.removeEventListener("mouseleave", this.document_mouse_out_listener); + document.removeEventListener("mouseup", this.document_mouse_out_listener); + } + + protected defaultState(): TreeEntryMoveState { + return { + tree_view: null, + begin: { x: 0, y: 0}, + description: "" + } + } + + isActive() { return !!this.state.tree_view; } + + render() { + if(!this.state.tree_view) + return null; + + return ReactDOM.createPortal(this.renderPortal(), this.domContainer); + } + + private renderPortal() { + return
+ {this.state.description} +
; + } +} \ No newline at end of file diff --git a/shared/js/ui/tree/View.scss b/shared/js/ui/tree/View.scss index 7d2bd573..c0139b05 100644 --- a/shared/js/ui/tree/View.scss +++ b/shared/js/ui/tree/View.scss @@ -2,6 +2,7 @@ @import "../../../css/static/mixin"; html:root { + --channel-tree-entry-move: #313235; --channel-tree-entry-selected: #2d2d2d; --channel-tree-entry-hovered: #393939; --channel-tree-entry-color: #828282; @@ -41,7 +42,6 @@ html:root { line-height: 1; } - .treeEntry { position: absolute; left: 0; @@ -98,6 +98,12 @@ html:root { @include transition(opacity $button_hover_animation_time); } } + + &.move { + .treeEntry.selected { + background-color: var(--channel-tree-entry-move); + } + } } .channelTreeContainer { diff --git a/shared/js/ui/tree/View.tsx b/shared/js/ui/tree/View.tsx index 00cea627..56929896 100644 --- a/shared/js/ui/tree/View.tsx +++ b/shared/js/ui/tree/View.tsx @@ -16,13 +16,15 @@ import {ClientEntry as ClientEntryView} from "./Client"; import {ChannelEntry} from "tc-shared/ui/channel"; import {ServerEntry} from "tc-shared/ui/server"; -import {ClientEntry, LocalClientEntry} from "tc-shared/ui/client"; +import {ClientEntry, ClientType} from "tc-shared/ui/client"; const viewStyle = require("./View.scss"); export interface ChannelTreeViewProperties { tree: ChannelTree; + onMoveStart: (start: { x: number, y: number }, current: { x: number, y: number }) => void; + moveThreshold?: number; } export interface ChannelTreeViewState { @@ -52,6 +54,9 @@ export class ChannelTreeView extends ReactComponentBase void, @@ -95,6 +100,26 @@ export class ChannelTreeView extends ReactComponentBase this.handleTreeUpdate(); this.listener_channel_change = () => this.handleTreeUpdate(); this.listener_state_collapsed = () => this.handleTreeUpdate(); + + this.document_mouse_listener = (e: MouseEvent) => { + if(e.type !== "mouseleave" && e.button !== 0) + return; + + this.mouse_move.down = false; + this.mouse_move.fired = false; + + this.removeDocumentMouseListener(); + } + } + + private registerDocumentMouseListener() { + document.addEventListener("mouseleave", this.document_mouse_listener); + document.addEventListener("mouseup", this.document_mouse_listener); + } + + private removeDocumentMouseListener() { + document.removeEventListener("mouseleave", this.document_mouse_listener); + document.removeEventListener("mouseup", this.document_mouse_listener); } private handleTreeUpdate() { @@ -137,8 +162,8 @@ export class ChannelTreeView extends ReactComponentBase this.onScroll()} ref={this.ref_container} > -
+
this.onScroll()} ref={this.ref_container} onMouseDown={e => this.onMouseDown(e as any)} onMouseMove={e => this.onMouseMove(e as any)} > +
{elements}
@@ -157,7 +182,10 @@ export class ChannelTreeView extends ReactComponentBase { + let clients = entry.clients(false); + if(!this.props.tree.areServerQueriesShown()) + clients = clients.filter(e => e.properties.client_type_exact !== ClientType.CLIENT_QUERY); + this.flat_tree.push(...clients.map(e => { return { entry: e, rendered: @@ -195,12 +223,44 @@ export class ChannelTreeView extends ReactComponentBase("notify_query_view_state_changed") + private handleQueryViewStateChange() { + this.handleTreeUpdate(); + } + + @EventHandler("notify_entry_move_begin") + private handleEntryMoveBegin() { + this.handleTreeUpdate(); + } + + @EventHandler("notify_entry_move_end") + private handleEntryMoveEnd() { + this.handleTreeUpdate(); + } + private onScroll() { this.setState({ scroll_offset: this.ref_container.current.scrollTop }); } + private onMouseDown(e: MouseEvent) { + if(e.button !== 0) return; /* left button only */ + + this.mouse_move.down = true; + this.mouse_move.x = e.pageX; + this.mouse_move.y = e.pageY; + this.registerDocumentMouseListener(); + } + + private onMouseMove(e: MouseEvent) { + if(!this.mouse_move.down || this.mouse_move.fired) return; + if(Math.abs((this.mouse_move.x - e.pageX) * (this.mouse_move.y - e.pageY)) > (this.props.moveThreshold || 9)) { + this.mouse_move.fired = true; + this.props.onMoveStart({x: this.mouse_move.x, y: this.mouse_move.y}, {x: e.pageX, y: e.pageY}); + } + } + scrollEntryInView(entry: TreeEntry, callback?: () => void) { const index = this.flat_tree.findIndex(e => e.entry === entry); if(index === -1) { @@ -234,4 +294,22 @@ export class ChannelTreeView extends ReactComponentBase container.clientWidth) + return undefined; + + const total_offset = container.scrollTop + pageY; + return this.flat_tree[Math.floor(total_offset / ChannelTreeView.EntryHeight)].entry; + } } \ No newline at end of file diff --git a/shared/js/ui/view.tsx b/shared/js/ui/view.tsx index 22b187ac..15d8cc82 100644 --- a/shared/js/ui/view.tsx +++ b/shared/js/ui/view.tsx @@ -9,7 +9,6 @@ import {Sound} from "tc-shared/sound/Sounds"; import {Group} from "tc-shared/permission/GroupManager"; import * as server_log from "tc-shared/ui/frames/server_log"; import {ServerAddress, ServerEntry} from "tc-shared/ui/server"; -import {ClientMover} from "tc-shared/ui/client_move"; import {ChannelEntry, ChannelSubscribeMode} from "tc-shared/ui/channel"; import {ClientEntry, LocalClientEntry, MusicClientEntry} from "tc-shared/ui/client"; import {ConnectionHandler, ViewReasonId} from "tc-shared/ConnectionHandler"; @@ -27,6 +26,7 @@ import {spawnBanClient} from "tc-shared/ui/modal/ModalBanClient"; import {formatMessage} from "tc-shared/ui/frames/chat"; import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo"; import {tra} from "tc-shared/i18n/localize"; +import {TreeEntryMove} from "tc-shared/ui/tree/TreeEntryMove"; export interface ChannelTreeEvents { action_select_entries: { @@ -41,7 +41,11 @@ export interface ChannelTreeEvents { }, notify_selection_changed: {}, - notify_root_channel_changed: {} + notify_root_channel_changed: {}, + notify_query_view_state_changed: { queries_shown: boolean }, + + notify_entry_move_begin: {}, + notify_entry_move_end: {} } export class ChannelTreeEntrySelect { @@ -187,9 +191,8 @@ export class ChannelTree { channels: ChannelEntry[] = []; clients: ClientEntry[] = []; - readonly client_mover: ClientMover; - readonly view: React.RefObject; + readonly view_move: React.RefObject; readonly selection: ChannelTreeEntrySelect; private readonly _tag_container: JQuery; @@ -206,14 +209,17 @@ export class ChannelTree { this.events = new Registry(); this.client = client; this.view = React.createRef(); + this.view_move = React.createRef(); this.server = new ServerEntry(this, "undefined", undefined); this.selection = new ChannelTreeEntrySelect(this); this._tag_container = $.spawn("div").addClass("channel-tree-container"); - ReactDOM.render(, this._tag_container[0]); + ReactDOM.render([ + this.onChannelEntryMove(a, b)} tree={this} ref={this.view} />, + this.onMoveEnd(point.x, point.y)} ref={this.view_move} /> + ], this._tag_container[0]); - this.client_mover = new ClientMover(this); this.reset(); if(!settings.static(Settings.KEY_DISABLE_CONTEXT_MENU, false)) { @@ -291,6 +297,7 @@ export class ChannelTree { invalidPermission: !channelCreate, callback: () => this.spawnCreateChannel() }, + contextmenu.Entry.HR(), { type: contextmenu.MenuEntryType.ENTRY, icon_class: "client-channel_collapse_all", @@ -1001,20 +1008,9 @@ export class ChannelTree { if(this._show_queries == flag) return; this._show_queries = flag; - //TODO: FIXME! - /* - const channels: ChannelEntry[] = [] - for(const client of this.clients) - if(client.properties.client_type == ClientType.CLIENT_QUERY) { - if(this._show_queries) - client.tag.show(); - else - client.tag.hide(); - if(channels.indexOf(client.currentChannel()) == -1) - channels.push(client.currentChannel()); - } - */ + this.events.fire("notify_query_view_state_changed", { queries_shown: flag }); } + areServerQueriesShown() { return this._show_queries; } get_first_channel?() : ChannelEntry { return this.channel_first; @@ -1081,4 +1077,53 @@ export class ChannelTree { this.collapse_channels(child); } } + + private onChannelEntryMove(start, current) { + const move = this.view_move.current; + if(!move) return; + + const target = this.view.current.getEntryFromPoint(start.x, start.y); + if(target && this.selection.selected_entries.findIndex(e => e === target) === -1) + this.events.fire("action_select_entries", { mode: "auto", entries: [ target ]}); + + const selection = this.selection.selected_entries; + if(selection.length === 0 || selection.filter(e => !(e instanceof ClientEntry)).length > 0) + return; + + move.enableEntryMove(this.view.current, selection.map(e => e as ClientEntry).map(e => e.clientNickName()).join(","), start, current, () => { + this.events.fire("notify_entry_move_begin"); + }); + } + + private onMoveEnd(x: number, y: number) { + batch_updates(BatchUpdateType.CHANNEL_TREE); + try { + this.events.fire("notify_entry_move_end"); + + const selection = this.selection.selected_entries.filter(e => e instanceof ClientEntry) as ClientEntry[]; + if(selection.length === 0) return; + this.selection.clear_selection(); + + const target = this.view.current.getEntryFromPoint(x, y); + let target_channel: ChannelEntry; + if(target instanceof ClientEntry) + target_channel = target.currentChannel(); + else if(target instanceof ChannelEntry) + target_channel = target; + if(!target_channel) return; + + selection.filter(e => e.currentChannel() !== target_channel).forEach(e => { + this.client.serverConnection.send_command("clientmove", { + clid: e.clientId(), + cid: target_channel.channelId + }); + }); + } finally { + flush_batched_updates(BatchUpdateType.CHANNEL_TREE); + } + } + + isClientMoveActive() { + return !!this.view_move.current?.isActive(); + } } \ No newline at end of file From 2019cfe54929fb80adbde3005df085ba4891f5f0 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Tue, 21 Apr 2020 15:50:11 +0200 Subject: [PATCH 17/22] Fixed bookmark savings --- ChangeLog.md | 1 + shared/js/ui/modal/ModalBookmarks.ts | 21 ++++++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index 37d1abb7..8a653c29 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -2,6 +2,7 @@ * **21.04.20** - Clicking on the music bot does not longer results in the insufficient permission sound when the client has no permissions - Fixed permission editor overflow + - Fixed the bookmark edit window (bookmarks have failed to save) * **18.04.20** - Recoded the channel tree using React diff --git a/shared/js/ui/modal/ModalBookmarks.ts b/shared/js/ui/modal/ModalBookmarks.ts index 434694f6..27fba3a9 100644 --- a/shared/js/ui/modal/ModalBookmarks.ts +++ b/shared/js/ui/modal/ModalBookmarks.ts @@ -2,7 +2,10 @@ import {createInputModal, createModal, Modal} from "tc-shared/ui/elements/Modal" import { Bookmark, bookmarks, - BookmarkType, boorkmak_connect, create_bookmark, create_bookmark_directory, + BookmarkType, + boorkmak_connect, + create_bookmark, + create_bookmark_directory, delete_bookmark, DirectoryBookmark, save_bookmark @@ -12,8 +15,8 @@ import {icon_cache_loader, IconManager} from "tc-shared/FileManager"; import {profiles} from "tc-shared/profiles/ConnectionProfile"; import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo"; import {Settings, settings} from "tc-shared/settings"; -import {LogCategory} from "tc-shared/log"; import * as log from "tc-shared/log"; +import {LogCategory} from "tc-shared/log"; import * as i18nc from "tc-shared/i18n/country"; import {formatMessage} from "tc-shared/ui/frames/chat"; import * as top_menu from "../frames/MenuBar"; @@ -107,8 +110,11 @@ export function spawnBookmarkModal() { input_server_address.val(address); let profile = input_connect_profile.find("option[value='" + entry.connect_profile + "']"); - if(profile.length == 0) + console.error("%o - %s", profile, entry.connect_profile); + if(profile.length == 0) { + log.warn(LogCategory.GENERAL, tr("Failed to find bookmark profile %s. Displaying default one."), entry.connect_profile); profile = input_connect_profile.find("option[value=default]"); + } profile.prop("selected", true); input_server_password.val(entry.server_properties.server_password_hash || entry.server_properties.server_password ? "WolverinDEV" : ""); @@ -287,6 +293,7 @@ export function spawnBookmarkModal() { if(event.type === "change" && valid) { selected_bookmark.display_name = name; label_bookmark_name.text(name); + save_bookmark(selected_bookmark); } }); @@ -306,17 +313,25 @@ export function spawnBookmarkModal() { entry.server_properties.server_address = address; entry.server_properties.server_port = 9987; } + save_bookmark(selected_bookmark); label_server_address.text(entry.server_properties.server_address + (entry.server_properties.server_port == 9987 ? "" : (" " + entry.server_properties.server_port))); update_connect_info(); } }); + input_server_password.on("change keydown", event => { + const password = input_server_password.val() as string; + (selected_bookmark as Bookmark).server_properties.server_password = password; + save_bookmark(selected_bookmark); + }); + input_connect_profile.on('change', event => { const id = input_connect_profile.val() as string; const profile = profiles().find(e => e.id === id); if(profile) { (selected_bookmark as Bookmark).connect_profile = id; + save_bookmark(selected_bookmark); } else { log.warn(LogCategory.GENERAL, tr("Failed to change connect profile for profile %s to %s"), selected_bookmark.unique_id, id); } From f18957e75e021a25c56738031a7ee2a2d8725b64 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Tue, 21 Apr 2020 16:17:21 +0200 Subject: [PATCH 18/22] Fixed a minor channel tree select bug. --- shared/js/ConnectionHandler.ts | 2 +- shared/js/connection/ConnectionBase.ts | 2 + shared/js/events.ts | 7 +- shared/js/main.tsx | 3 +- shared/js/ui/react-elements/Icon.tsx | 10 +-- .../ui/react-elements/ReactComponentBase.ts | 1 + shared/js/ui/tree/View.tsx | 11 +++ shared/js/ui/view.tsx | 70 +++++++++---------- 8 files changed, 59 insertions(+), 47 deletions(-) diff --git a/shared/js/ConnectionHandler.ts b/shared/js/ConnectionHandler.ts index d68e8930..8613bd2b 100644 --- a/shared/js/ConnectionHandler.ts +++ b/shared/js/ConnectionHandler.ts @@ -32,7 +32,6 @@ import * as dns from "tc-backend/dns"; import * as top_menu from "tc-shared/ui/frames/MenuBar"; import {EventHandler, Registry} from "tc-shared/events"; import {ServerLog} from "tc-shared/ui/frames/server_log"; -import {server} from "websocket"; export enum DisconnectReason { HANDLER_DESTROYED, @@ -639,6 +638,7 @@ export class ConnectionHandler { this.serverConnection.disconnect(); this.side_bar.private_conversations().clear_client_ids(); + this.side_bar.channel_conversations().set_current_channel(0); this.hostbanner.update(); if(auto_reconnect) { diff --git a/shared/js/connection/ConnectionBase.ts b/shared/js/connection/ConnectionBase.ts index b9e6a159..c18b4044 100644 --- a/shared/js/connection/ConnectionBase.ts +++ b/shared/js/connection/ConnectionBase.ts @@ -50,6 +50,8 @@ export abstract class AbstractServerConnection { //FIXME: Remove this this is currently only some kind of hack updateConnectionState(state: ConnectionState) { + if(state === this.connection_state_) return; + const old_state = this.connection_state_; this.connection_state_ = state; if(this.onconnectionstatechanged) diff --git a/shared/js/events.ts b/shared/js/events.ts index cd094a32..8efb7c89 100644 --- a/shared/js/events.ts +++ b/shared/js/events.ts @@ -1,5 +1,4 @@ import {ClientEvents, MusicClientEntry, SongInfo} from "tc-shared/ui/client"; -import {PlaylistSong} from "tc-shared/connection/ServerConnectionDeclaration"; import {guid} from "tc-shared/crypto/uid"; import * as React from "react"; @@ -232,7 +231,11 @@ export function ReactEventHandler, Event constructor.prototype.componentWillUnmount = function () { const registry = registry_callback(this); if(!registry) throw "Event registry returned for an event object is invalid"; - registry.unregister_handler(this); + try { + registry.unregister_handler(this); + } catch (error) { + console.warn("Failed to unregister event handler: %o", error); + } if(typeof willUnmount === "function") willUnmount.call(this, arguments); diff --git a/shared/js/main.tsx b/shared/js/main.tsx index 30895b87..11046d1e 100644 --- a/shared/js/main.tsx +++ b/shared/js/main.tsx @@ -29,8 +29,7 @@ import * as React from "react"; import * as ReactDOM from "react-dom"; import * as cbar from "./ui/frames/control-bar"; import * as global_ev_handler from "./events/ClientGlobalControlHandler"; -import {ClientGlobalControlEvents, global_client_actions} from "tc-shared/events/GlobalEvents"; -import {spawnSettingsModal} from "tc-shared/ui/modal/ModalSettings"; +import {global_client_actions} from "tc-shared/events/GlobalEvents"; /* required import for init */ require("./proto").initialize(); diff --git a/shared/js/ui/react-elements/Icon.tsx b/shared/js/ui/react-elements/Icon.tsx index 2e6c0068..7e360cfd 100644 --- a/shared/js/ui/react-elements/Icon.tsx +++ b/shared/js/ui/react-elements/Icon.tsx @@ -38,7 +38,9 @@ export class LocalIconRenderer extends React.Component { render() { const icon = this.props.icon; - if(icon.status === "loaded") { + if(!icon || icon.status === "empty" || icon.status === "destroyed") + return
; + else if(icon.status === "loaded") { if(icon.icon_id >= 0 && icon.icon_id <= 1000) { if(icon.icon_id === 0) return
; @@ -49,15 +51,13 @@ export class LocalIconRenderer extends React.Component { return
; else if(icon.status === "error") return
; - else if(icon.status === "empty" || icon.status === "destroyed") - return
; } componentDidMount(): void { - this.props.icon.status_change_callbacks.push(this.callback_state_update); + this.props.icon?.status_change_callbacks.push(this.callback_state_update); } componentWillUnmount(): void { - this.props.icon.status_change_callbacks.remove(this.callback_state_update); + this.props.icon?.status_change_callbacks.remove(this.callback_state_update); } } \ No newline at end of file diff --git a/shared/js/ui/react-elements/ReactComponentBase.ts b/shared/js/ui/react-elements/ReactComponentBase.ts index 4f89538e..636b79e1 100644 --- a/shared/js/ui/react-elements/ReactComponentBase.ts +++ b/shared/js/ui/react-elements/ReactComponentBase.ts @@ -28,6 +28,7 @@ let update_batches: {[key: number]:UpdateBatch} = { 0: generate_batch(), 1: generate_batch() }; +(window as any).update_batches = update_batches; export function BatchUpdateAssignment(type: BatchUpdateType) { return function (constructor: Function) { diff --git a/shared/js/ui/tree/View.tsx b/shared/js/ui/tree/View.tsx index 56929896..2e902f52 100644 --- a/shared/js/ui/tree/View.tsx +++ b/shared/js/ui/tree/View.tsx @@ -31,6 +31,8 @@ export interface ChannelTreeViewState { element_scroll_offset?: number; /* in px */ scroll_offset: number; /* in px */ view_height: number; /* in px */ + + tree_version: number; } type TreeEntry = ChannelEntry | ServerEntry | ClientEntry; @@ -67,6 +69,7 @@ export class ChannelTreeView extends ReactComponentBase("notify_tree_reset") + private handleTreeReset() { + this.rebuild_tree(); + this.setState({ + tree_version: this.state.tree_version + 1 + }); + } + private onScroll() { this.setState({ scroll_offset: this.ref_container.current.scrollTop diff --git a/shared/js/ui/view.tsx b/shared/js/ui/view.tsx index 15d8cc82..639116ff 100644 --- a/shared/js/ui/view.tsx +++ b/shared/js/ui/view.tsx @@ -42,6 +42,7 @@ export interface ChannelTreeEvents { notify_selection_changed: {}, notify_root_channel_changed: {}, + notify_tree_reset: {}, notify_query_view_state_changed: { queries_shown: boolean }, notify_entry_move_begin: {}, @@ -283,37 +284,6 @@ export class ChannelTree { this.events.destroy(); } - showContextMenu(x: number, y: number, on_close: () => void = undefined) { - let channelCreate = - this.client.permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_TEMPORARY).granted(1) || - this.client.permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_SEMI_PERMANENT).granted(1) || - this.client.permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_PERMANENT).granted(1); - - contextmenu.spawn_context_menu(x, y, - { - type: contextmenu.MenuEntryType.ENTRY, - icon_class: "client-channel_create", - name: tr("Create channel"), - invalidPermission: !channelCreate, - callback: () => this.spawnCreateChannel() - }, - contextmenu.Entry.HR(), - { - type: contextmenu.MenuEntryType.ENTRY, - icon_class: "client-channel_collapse_all", - name: tr("Collapse all channels"), - callback: () => this.collapse_channels() - }, - { - type: contextmenu.MenuEntryType.ENTRY, - icon_class: "client-channel_expand_all", - name: tr("Expend all channels"), - callback: () => this.expand_channels() - }, - contextmenu.Entry.CLOSE(on_close) - ); - } - initialiseHead(serverName: string, address: ServerAddress) { this.server.reset(); this.server.remote_address = Object.assign({}, address); @@ -572,6 +542,36 @@ export class ChannelTree { return undefined; } + showContextMenu(x: number, y: number, on_close: () => void = undefined) { + let channelCreate = + this.client.permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_TEMPORARY).granted(1) || + this.client.permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_SEMI_PERMANENT).granted(1) || + this.client.permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_PERMANENT).granted(1); + + contextmenu.spawn_context_menu(x, y, + { + type: contextmenu.MenuEntryType.ENTRY, + icon_class: "client-channel_create", + name: tr("Create channel"), + invalidPermission: !channelCreate, + callback: () => this.spawnCreateChannel() + }, + contextmenu.Entry.HR(), + { + type: contextmenu.MenuEntryType.ENTRY, + icon_class: "client-channel_collapse_all", + name: tr("Collapse all channels"), + callback: () => this.collapse_channels() + }, + { + type: contextmenu.MenuEntryType.ENTRY, + icon_class: "client-channel_expand_all", + name: tr("Expend all channels"), + callback: () => this.expand_channels() + }, + contextmenu.Entry.CLOSE(on_close) + ); + } private open_multiselect_context_menu(entries: ChannelTreeEntry[], x: number, y: number) { const clients = entries.filter(e => e instanceof ClientEntry) as ClientEntry[]; const channels = entries.filter(e => e instanceof ChannelEntry) as ChannelEntry[]; @@ -811,13 +811,9 @@ export class ChannelTree { this.channels = []; this.channel_last = undefined; this.channel_first = undefined; + this.events.fire("notify_tree_reset"); } finally { - try { - this.events.fire_async("notify_root_channel_changed", undefined, () => flush_batched_updates(BatchUpdateType.CHANNEL_TREE)); - } catch (e) { - flush_batched_updates(BatchUpdateType.CHANNEL_TREE); - throw e; - } + flush_batched_updates(BatchUpdateType.CHANNEL_TREE); } } From 566728226c0d5960b7c6bf47797557df68286971 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Tue, 21 Apr 2020 16:23:27 +0200 Subject: [PATCH 19/22] Removed all old css files --- shared/css/generate_packed.sh | 1 - shared/css/static/channel-tree.scss | 311 ---------------------------- shared/css/static/control_bar.scss | 287 ------------------------- 3 files changed, 599 deletions(-) delete mode 100644 shared/css/static/control_bar.scss diff --git a/shared/css/generate_packed.sh b/shared/css/generate_packed.sh index 4e2db23f..35e31b98 100644 --- a/shared/css/generate_packed.sh +++ b/shared/css/generate_packed.sh @@ -12,7 +12,6 @@ files=( "css/static/channel-tree.css" "css/static/connection_handlers.css" "css/static/context_menu.css" - "css/static/control_bar.css" "css/static/frame-chat.css" "css/static/server-log.css" "css/static/scroll.css" diff --git a/shared/css/static/channel-tree.scss b/shared/css/static/channel-tree.scss index b466548a..5b8ce460 100644 --- a/shared/css/static/channel-tree.scss +++ b/shared/css/static/channel-tree.scss @@ -1,314 +1,3 @@ -@import "properties"; -@import "mixin"; - -.channel-tree-container { - height: 100%; - - flex-grow: 1; - flex-shrink: 1; - - overflow: hidden; - overflow-y: auto; - - @include chat-scrollbar-vertical(); -} - -/* the channel tree */ -.channel-tree { - @include user-select(none); - width: 100%; - - display: -ms-flex; - display: flex; - - flex-direction: column; - - * { - font-family: sans-serif; - font-size: 12px; - white-space: pre; - line-height: 1; - } - - .tree-entry { - display: flex; - flex-direction: row; - justify-content: stretch; - - /* margin-left: 16px; */ - min-height: 16px; - - flex-grow: 0; - flex-shrink: 0; - - &.server, > .container-channel, &.client { - padding-left: 5px; - padding-right: 5px; - - &:hover { - background-color: $channel_tree_entry_hovered; - } - - &.selected { - background-color: $channel_tree_entry_selected; - .channel-name { - color: whitesmoke; - } - } - } - - &.server { - display: flex; - flex-direction: row; - justify-content: stretch; - - position: relative; - - cursor: pointer; - margin-left: 0; - - .server_type { - flex-grow: 0; - flex-shrink: 0; - - margin-right: 2px; - } - - .name { - flex-grow: 1; - flex-shrink: 1; - - align-self: center; - color: $channel_tree_entry_text_color; - - min-width: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .icon_property { - flex-grow: 0; - flex-shrink: 0; - } - } - - &.channel { - display: flex; - flex-direction: column; - - .container-channel { - position: relative; - - display: flex; - flex-direction: row; - justify-content: stretch; - - width: 100%; - min-height: 16px; - - align-items: center; - cursor: pointer; - - .channel-type { - flex-grow: 0; - flex-shrink: 0; - - margin-right: 2px; - } - - .container-channel-name { - display: flex; - flex-direction: row; - - flex-grow: 1; - flex-shrink: 1; - - justify-content: left; - - max-width: 100%; /* important for the repetitive channel name! */ - overflow-x: hidden; - height: 16px; - - &.align-right { - justify-content: right; - } - - &.align-center, &.align-repetitive { - justify-content: center; - } - - .channel-name { - align-self: center; - color: $channel_tree_entry_text_color; - - min-width: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - &.align-repetitive { - .channel-name { - text-overflow: clip; - } - } - } - - .icons { - display: flex; - flex-direction: row; - - flex-grow: 0; - flex-shrink: 0; - } - - &.move-selected { - border-bottom: 1px solid black; - } - - .show-channel-normal-only { - display: none; - - &.channel-normal { - display: block; - } - } - - .icon_no_sound { - display: flex; - } - } - - .container-clients { - display: flex; - flex-direction: column; - } - } - - &.client { - cursor: pointer; - - position: relative; - - display: flex; - flex-direction: row; - - align-items: center; - - > div { - margin-right: 2px; - } - - .client-name { - line-height: 16px; - - flex-grow: 0; - flex-shrink: 1; - - padding-right: .25em; - color: $channel_tree_entry_text_color; - - &.client-name-own { - font-weight: bold; - } - } - - .client-away-message { - color: $channel_tree_entry_text_color; - } - - .container-icons { - margin-right: 0; /* override from previous thing */ - - position: absolute; - right: 0; - padding-right: 5px; - - display: flex; - flex-direction: row; - - align-items: center; - - .container-icons-group { - display: flex; - flex-direction: row; - - .container-group-icon { - display: flex; - flex-direction: column; - justify-content: center; - } - } - } - - &.selected { - &:focus-within { - .container-icons { - background-color: $channel_tree_entry_selected; - padding-left: 5px; - z-index: 1001; /* show before client name */ - - height: 18px; - } - } - - .client-name { - &:focus { - position: absolute; - color: black; - - padding-top: 1px; - padding-bottom: 1px; - - z-index: 1000; - - margin-right: -10px; - margin-left: 18px; - - width: 100%; - } - } - } - } - - &.channel .container-channel, &.client, &.server { - .marker-text-unread { - position: absolute; - left: 0; - top: 0; - bottom: 0; - - width: 1px; - @include background-color(#a814147F); - - opacity: 1; - - &:before { - content: ''; - position: absolute; - - left: 0; - top: 0; - bottom: 0; - - width: 24px; - - background: -moz-linear-gradient(left, rgba(168,20,20,.18) 0%, rgba(168,20,20,0) 100%); /* FF3.6-15 */ - background: -webkit-linear-gradient(left, rgba(168,20,20,.18) 0%,rgba(168,20,20,0) 100%); /* Chrome10-25,Safari5.1-6 */ - background: linear-gradient(to right, rgba(168,20,20,.18) 0%,rgba(168,20,20,0) 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */ - } - - &.hidden { - opacity: 0; - } - - @include transition(opacity $button_hover_animation_time); - } - } - } -} - /* all icons related to basic_icons */ .clicon { width:16px; diff --git a/shared/css/static/control_bar.scss b/shared/css/static/control_bar.scss deleted file mode 100644 index 14a3f36a..00000000 --- a/shared/css/static/control_bar.scss +++ /dev/null @@ -1,287 +0,0 @@ -@import "properties"; -@import "mixin"; - -$border_color_activated: rgba(255, 255, 255, .75); - -/* max height is 2em */ -.control_bar { - display: flex; - flex-direction: row; - - @include user-select(none); - - height: 100%; - align-items: center; - - /* tmp fix for ultra small devices */ - overflow-y: visible; - - .divider { - border-left:2px solid #393838; - height: calc(100% - 3px); - margin: 3px; - } - - /* border etc */ - .button, .dropdown-arrow { - text-align: center; - - border: .05em solid rgba(0, 0, 0, 0); - border-radius: $border_radius_small; - - background-color: #454545; - - &:hover { - background-color: #393c43; - border-color: #4a4c55; - /*box-shadow: 0 12px 16px 0 rgba(0,0,0,0.24), 0 17px 50px 0 rgba(0,0,0,0.19);*/ - } - - &.activated { - background-color: #2f3841; - border-color: #005fa1; - - &:hover { - background-color: #263340; - border-color: #005fa1; - } - - &.button-red { - background-color: #412f2f; - border-color: #a10000; - - &:hover { - background-color: #402626; - border-color: #a10000; - } - } - } - - @include transition(background-color $button_hover_animation_time ease-in-out, border-color $button_hover_animation_time ease-in-out); - - > .icon_x24 { - vertical-align: middle; - } - } - - .button { - cursor: pointer; - align-items: center; - - margin-right: 5px; - margin-left: 5px; - - &:not(.icon_x24) { - min-width: 2em; - max-width: 2em; - height: 2em; - } - - .icon_em { - vertical-align: text-top; - } - - &.button-hostbutton { - img { - min-width: 1.5em; - max-width: 1.5em; - - height: 1.5em; - width: 1.5em; - } - - overflow: hidden; - padding: .25em; - } - } - - .button-dropdown { - height: 100%; - position: relative; - - .buttons { - height: 2em; - - align-items: center; - - display: flex; - flex-direction: row; - - .dropdown-arrow { - height: 2em; - - display: inline-flex; - justify-content: space-around; - width: 1.5em; - cursor: pointer; - - border-radius: 0 $border_radius_small $border_radius_small 0; - align-items: center; - border-left: 0; - } - - .button { - margin-right: 0; - } - - &:hover { - .button, .dropdown-arrow { - background-color: #393c43; - border-color: #4a4c55; - } - - .button { - border-right-color: transparent; - - border-bottom-right-radius: 0; - border-top-right-radius: 0; - } - } - } - - - .dropdown { - display: none; - position: absolute; - margin-left: 5px; - - color: #c4c5c5; - - background-color: #2d3032; - align-items: center; - border: .05em solid #2c2525; - border-radius: 0 $border_radius_middle $border_radius_middle $border_radius_middle; - - width: 230px; - - z-index: 1000; - /*box-shadow: 0 12px 16px 0 rgba(0,0,0,0.24), 0 17px 50px 0 rgba(0,0,0,0.19);*/ - - &.right { - right: 0; - } - - .icon, .icon-container, .icon_em { - vertical-align: middle; - margin-right: 5px; - } - - & > div { - display: block; - cursor: pointer; - padding: 1px 2px 1px 4px; - - &:hover { - background-color: #252729; - } - } - - & > div:first-of-type { - border-radius: .1em .1em 0 0; - } - - & > div:last-of-type { - border-radius: 0 0 .1em .1em; - } - - &.display_left { - margin-left: -179px; - border-radius: $border_radius_middle 0 $border_radius_middle $border_radius_middle; - } - } - - &:hover.dropdownDisplayed, &.force-show { - .dropdown { - display: block; - } - - .button, .dropdown-arrow { - background-color: #393c43; - border-color: #4a4c55; - - border-bottom-right-radius: 0; - border-bottom-left-radius: 0; - } - - .button { - border-right-color: transparent; - - border-bottom-right-radius: 0; - border-top-right-radius: 0; - } - } - - - hr { - margin-top: 5px; - margin-bottom: 5px; - } - } - - .bookmark-dropdown { - hr:last-child { - display: none; - } - - .hidden { - display: none!important; - } - - .disabled { - - } - - .bookmark, .directory { - display: flex!important; - flex-direction: row; - - align-items: center; - justify-content: stretch; - - .name { - flex-grow: 1; - flex-shrink: 1; - } - - .icon, .arrow { - flex-grow: 0; - flex-shrink: 0; - } - - .arrow { - margin-right: 5px; - } - } - - .directory { - &:hover { - > .sub-container, > .sub-container .sub-menu { - display: block; - } - } - - &:not(:hover) { - .sub-container { - display: none; - } - } - - .sub-container { - padding-right: 3px; - position: relative; - } - - .sub-menu { - display: none; - left: 100%; - top: -13px; - position: absolute; - margin-left: 3px; - } - } - } - - .icon_em { - font-size: 1.5em; - } -} \ No newline at end of file From ebfa7d357264b549200623f8ee2059d3b200b233 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Tue, 21 Apr 2020 16:25:17 +0200 Subject: [PATCH 20/22] Removed deleted style file from loader style list --- loader/app/targets/app.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/loader/app/targets/app.ts b/loader/app/targets/app.ts index 932c9b34..4df3e0ff 100644 --- a/loader/app/targets/app.ts +++ b/loader/app/targets/app.ts @@ -191,7 +191,6 @@ const loader_style = { "css/static/overlay-image-preview.css", "css/static/music/info_plate.css", "css/static/frame/SelectInfo.css", - "css/static/control_bar.css", "css/static/context_menu.css", "css/static/frame-chat.css", "css/static/connection_handlers.css", From 4dc176cc1f8e2328b0fa03c7397acb2219c68378 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Tue, 21 Apr 2020 16:34:54 +0200 Subject: [PATCH 21/22] Fixed too much deleted CSS style --- shared/css/static/main-layout.scss | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/shared/css/static/main-layout.scss b/shared/css/static/main-layout.scss index 26448d41..d98cd429 100644 --- a/shared/css/static/main-layout.scss +++ b/shared/css/static/main-layout.scss @@ -74,6 +74,14 @@ $animation_length: .5s; flex-grow: 1; flex-shrink: 1; + + .channel-tree-container { + height: 100%; + flex-grow: 1; + flex-shrink: 1; + overflow: hidden; + overflow-y: auto; + } } } From 5b83bd9b51b9e33b8a74f02615a3a1994ecd13cc Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Tue, 21 Apr 2020 17:11:06 +0200 Subject: [PATCH 22/22] Some minor fixes for the native client --- shared/js/ConnectionHandler.ts | 2 ++ shared/js/ui/channel.ts | 2 +- shared/js/ui/tree/Channel.tsx | 8 ++++---- shared/js/ui/tree/Client.tsx | 12 ++++++------ shared/js/ui/tree/Server.scss | 2 +- shared/js/ui/tree/Server.tsx | 8 ++++---- shared/js/ui/tree/View.tsx | 19 ++++++++++++------- shared/js/ui/view.tsx | 15 ++++++--------- 8 files changed, 36 insertions(+), 32 deletions(-) diff --git a/shared/js/ConnectionHandler.ts b/shared/js/ConnectionHandler.ts index 8613bd2b..ad385448 100644 --- a/shared/js/ConnectionHandler.ts +++ b/shared/js/ConnectionHandler.ts @@ -660,6 +660,8 @@ export class ConnectionHandler { this.startConnection(server_address.host + ":" + server_address.port, profile, false, Object.assign(this.reconnect_properties(profile), {auto_reconnect_attempt: true})); }, 5000); } + + this.serverConnection.updateConnectionState(ConnectionState.UNCONNECTED); /* Fix for the native client... */ } cancel_reconnect(log_event: boolean) { diff --git a/shared/js/ui/channel.ts b/shared/js/ui/channel.ts index 1bfc493b..85a42f89 100644 --- a/shared/js/ui/channel.ts +++ b/shared/js/ui/channel.ts @@ -536,7 +536,7 @@ export class ChannelEntry extends ChannelTreeEntry { invalidPermission: !channelCreate, callback: () => this.channelTree.spawnCreateChannel() }, - contextmenu.Entry.CLOSE(() => trigger_close ? on_close() : {}) + contextmenu.Entry.CLOSE(() => trigger_close && on_close ? on_close() : {}) ); } diff --git a/shared/js/ui/tree/Channel.tsx b/shared/js/ui/tree/Channel.tsx index 4591a174..daa7fa61 100644 --- a/shared/js/ui/tree/Channel.tsx +++ b/shared/js/ui/tree/Channel.tsx @@ -244,9 +244,9 @@ export class ChannelEntryView extends TreeEntry const collapsed_indicator = this.props.channel.child_channel_head || this.props.channel.clients(false).length > 0; return
this.onMouseUp(e as any)} + onMouseUp={e => this.onMouseUp(e)} onDoubleClick={() => this.onDoubleClick()} - onContextMenu={e => this.onContextMenu(e as any)} + onContextMenu={e => this.onContextMenu(e)} > {collapsed_indicator && this.onCollapsedToggle()} collapsed={this.props.channel.collapsed} />} @@ -260,7 +260,7 @@ export class ChannelEntryView extends TreeEntry this.props.channel.collapsed = !this.props.channel.collapsed; } - private onMouseUp(event: MouseEvent) { + private onMouseUp(event: React.MouseEvent) { if(event.button !== 0) return; /* only left mouse clicks */ const channel = this.props.channel; @@ -279,7 +279,7 @@ export class ChannelEntryView extends TreeEntry channel.joinChannel(); } - private onContextMenu(event: MouseEvent) { + private onContextMenu(event: React.MouseEvent) { if(settings.static(Settings.KEY_DISABLE_CONTEXT_MENU)) return; diff --git a/shared/js/ui/tree/Client.tsx b/shared/js/ui/tree/Client.tsx index 9d886ac9..b3f12183 100644 --- a/shared/js/ui/tree/Client.tsx +++ b/shared/js/ui/tree/Client.tsx @@ -322,7 +322,7 @@ class ClientNameEdit extends ReactComponentBase { ref={this.ref_div} dangerouslySetInnerHTML={{__html: DOMPurify.sanitize(this.props.initialName)}} onBlur={e => this.onBlur()} - onKeyPress={e => this.onKeyPress(e as any)} + onKeyPress={e => this.onKeyPress(e)} /> } @@ -330,7 +330,7 @@ class ClientNameEdit extends ReactComponentBase { this.props.editFinished(this.ref_div.current.textContent); } - private onKeyPress(event: KeyboardEvent) { + private onKeyPress(event: React.KeyboardEvent) { if(event.key === "Enter") { event.preventDefault(); this.onBlur(); @@ -364,8 +364,8 @@ export class ClientEntry extends TreeEntry this.onDoubleClick()} - onMouseUp={e => this.onMouseUp(e as any)} - onContextMenu={e => this.onContextMenu(e as any)} + onMouseUp={e => this.onMouseUp(e)} + onContextMenu={e => this.onContextMenu(e)} > @@ -403,7 +403,7 @@ export class ClientEntry extends TreeEntry this.onMouseUp(e as any)} - onContextMenu={e => this.onContextMenu(e as any)} + onMouseUp={e => this.onMouseUp(e)} + onContextMenu={e => this.onContextMenu(e)} >
@@ -87,7 +87,7 @@ export class ServerEntry extends TreeEntry } - private onMouseUp(event: MouseEvent) { + private onMouseUp(event: React.MouseEvent) { if(event.button !== 0) return; /* only left mouse clicks */ if(this.props.server.channelTree.isClientMoveActive()) return; @@ -97,7 +97,7 @@ export class ServerEntry extends TreeEntry this.onScroll()} ref={this.ref_container} onMouseDown={e => this.onMouseDown(e as any)} onMouseMove={e => this.onMouseMove(e as any)} > +
this.onScroll()} + ref={this.ref_container} + onMouseDown={e => this.onMouseDown(e)} + onMouseMove={e => this.onMouseMove(e)} >
{elements}
@@ -181,7 +186,7 @@ export class ChannelTreeView extends ReactComponentBase + rendered: }); if(entry.collapsed) return; @@ -191,7 +196,7 @@ export class ChannelTreeView extends ReactComponentBase { return { entry: e, - rendered: + rendered: }; })); for (const channel of entry.children(false)) @@ -214,7 +219,7 @@ export class ChannelTreeView extends ReactComponentBase + rendered: }]; for (const channel of tree.rootChannel()) @@ -255,7 +260,7 @@ export class ChannelTreeView extends ReactComponentBase (this.props.moveThreshold || 9)) { this.mouse_move.fired = true; @@ -321,6 +326,6 @@ export class ChannelTreeView extends ReactComponentBase { - if(event.isDefaultPrevented()) return; - - for(const element of document.elementsFromPoint(event.pageX, event.pageY)) - if(element.classList.contains("channelLine") || element.classList.contains("client")) - return; - event.preventDefault(); - if(this.selection.is_multi_select()) - this.open_multiselect_context_menu(this.selection.selected_entries, event.pageX, event.pageY); - else { + + const entry = this.view.current?.getEntryFromPoint(event.pageX, event.pageY); + if(entry) { + if(this.selection.is_multi_select()) + this.open_multiselect_context_menu(this.selection.selected_entries, event.pageX, event.pageY); + } else { this.selection.clear_selection(); this.showContextMenu(event.pageX, event.pageY); }