Using React for the client channel and a lot of performance updates related to the channel tree

canary
WolverinDEV 2020-04-18 19:37:30 +02:00
parent 58dc5da34f
commit 7b261d6f15
53 changed files with 3698 additions and 2185 deletions

View File

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

View File

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

View File

@ -4,4 +4,6 @@ window["loader"] = loader_base;
/* let the loader register himself at the window first */
setTimeout(loader.run, 0);
export {};
export {};
//window.__REACT_DEVTOOLS_GLOBAL_HOOK__.inject = function () {};

15
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

@ -807,7 +807,6 @@ export class ConnectionHandler {
}
resize_elements() {
this.channelTree.handle_resized();
this.invoke_resized_on_activate = false;
}

View File

@ -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 = <BufferSource>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<Response>;
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<Icon>} = {};
readonly events: Registry<IconManagerEvents>;
private loading_timestamps: {[key: number]: IconManagerLoadingData} = {};
constructor(handle: FileManager) {
this.handle = handle;
this.events = new Registry<IconManagerEvents>();
}
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<void> {
@ -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<Icon> {
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<Icon>} = {};
static load_cached_icon(id: number, ignore_age?: boolean) : Promise<Icon> | 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<Response> {
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<Icon> {
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<Icon> {
return this._loading_promises[id] || (this._loading_promises[id] = this._load_icon(id));
}
async resolve_icon(id: number) : Promise<Icon> {
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> | Icon | undefined, options?: {
static generate_tag(icon: LocalIcon | undefined, options?: {
animate?: boolean
}) : JQuery<HTMLDivElement> {
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<HTMLDivElement> {
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<Icon>} = {};
private _loading_promises: {[response_avatar_id:number]:Promise<any>} = {};
constructor(handle: FileManager) {
this.handle = handle;

View File

@ -68,6 +68,7 @@ export interface Bookmark {
connect_profile: string;
last_icon_id?: number;
last_icon_server_id?: string;
}
export interface DirectoryBookmark {

View File

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

View File

@ -9,6 +9,7 @@ export enum ErrorID {
PLAYLIST_IS_IN_USE = 0x2103,
FILE_ALREADY_EXISTS = 2050,
FILE_NOT_FOUND = 2051,
CLIENT_INVALID_ID = 0x0200,

View File

@ -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<Events> {
}
}
off<T extends keyof Events>(handler: (event?: Event<Events, T>) => void);
off<T extends keyof Events>(event: T, handler: (event?: Event<Events, T>) => void);
off<T extends keyof Events>(handler: (event?) => void);
off<T extends keyof Events>(event: T, handler: (event?: Events[T] & Event<Events, T>) => void);
off(event: (keyof Events)[], handler: (event?: Event<Events, keyof Events>) => void);
off(handler_or_events, handler?) {
if(typeof handler_or_events === "function") {
@ -107,36 +107,49 @@ export class Registry<Events> {
this.connections[event].remove(target as any);
}
fire<T extends keyof Events>(event_type: T, data?: Events[T]) {
fire<T extends keyof Events>(event_type: T, data?: Events[T], overrideTypeKey?: boolean) {
if(this.debug_prefix) console.log("[%s] Trigger event: %s", this.debug_prefix, event_type);
if(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<T extends keyof Events>(event_type: T, data?: Events[T]) {
setTimeout(() => this.fire(event_type, data));
fire_async<T extends keyof Events>(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<ObjectClass = React.Component<any, any>, 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<channel_tree.client>();
/*
const eclient = new Registry<ClientEvents>();
const emusic = new Registry<sidebar.music>();
eclient.on("property_update", event => { event.as<"playlist_song_loaded">(); });
eclient.connect("playlist_song_loaded", emusic);
eclient.connect("playlist_song_loaded", emusic);
eclient.connect("playlist_song_loaded", emusic);
*/

View File

@ -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<PermissionValue[]>;
}
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<GroupEvents>;
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<GroupEvents>();
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<PermissionValue[]> { //database_empty_result

View File

@ -492,7 +492,8 @@ export class PermissionManager extends AbstractCommandHandler {
requestClientChannelPermissions(client_id: number, channel_id: number) : Promise<PermissionValue[]> {
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));
}

View File

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

View File

@ -156,7 +156,8 @@ export class Settings extends StaticSettings {
static readonly KEY_DISABLE_CONTEXT_MENU: SettingsKey<boolean> = {
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<boolean> = {

View File

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

39
shared/js/ui/TreeEntry.ts Normal file
View File

@ -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<Events extends ChannelTreeEntryEvents> {
readonly events: Registry<Events>;
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_; }
}

View File

@ -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<ChannelEvents> {
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<ChannelEvents>;
readonly view: React.Ref<ChannelEntryView>;
parsed_channel_name: ParsedChannelName;
private _family_index: number = 0;
//HTML DOM elements
private _tag_root: JQuery<HTMLElement>; /* container for the channel, client and children tag */
private _tag_siblings: JQuery<HTMLElement>; /* container for all sub channels */
private _tag_clients: JQuery<HTMLElement>; /* container for all clients */
private _tag_channel: JQuery<HTMLElement>; /* 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<ChannelEvents>();
this.view = React.createRef<ChannelEntryView>();
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<string> {
@ -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<HTMLElement> {
return this._tag_root;
}
channelTag() : JQuery<HTMLElement> {
return this._tag_channel;
}
siblingTag() : JQuery<HTMLElement> {
return this._tag_siblings;
}
clientTag() : JQuery<HTMLElement>{
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(),

View File

@ -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<channel_tree.client>;
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<ClientEvents> {
readonly events: Registry<ClientEvents>;
readonly view: React.RefObject<ClientEntryView> = React.createRef<ClientEntryView>();
protected _clientId: number;
protected _channel: ChannelEntry;
protected _tag: JQuery<HTMLElement>;
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<channel_tree.client>();
super();
this.events = new Registry<ClientEvents>();
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<HTMLElement> {
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<boolean> {
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
});
});
}

View File

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

View File

@ -27,7 +27,7 @@ export interface ButtonProperties {
}
export class Button extends ReactComponentBase<ButtonProperties, ButtonState> {
protected default_state(): ButtonState {
protected defaultState(): ButtonState {
return {
switched: false,
dropdownShowed: false,
@ -66,13 +66,13 @@ export class Button extends ReactComponentBase<ButtonProperties, ButtonState> {
}
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<ButtonProperties, ButtonState> {
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 });
}
}

View File

@ -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<HTMLDivElement>;
icon?: string | LocalIcon;
text: JSX.Element | string;
onClick?: (event) => void;
@ -12,7 +13,7 @@ export interface DropdownEntryProperties {
}
export class DropdownEntry extends ReactComponentBase<DropdownEntryProperties, {}> {
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<DropdownContainerProperties, DropdownContainerState> {
protected default_state() {
protected defaultState() {
return { };
}

View File

@ -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<InternalControlBarEvents> }, 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<InternalControlBarEvents>("update_connect_state")
private handleStateUpdate(state: ConnectionState) {
this.updateState(state);
this.setState(state);
}
}
@ -96,7 +96,7 @@ class BookmarkButton extends ReactComponentBase<{ event_registry: Registry<Inter
this.button_ref = React.createRef();
}
protected default_state() {
protected defaultState() {
return {};
}
@ -118,7 +118,7 @@ class BookmarkButton extends ReactComponentBase<{ event_registry: Registry<Inter
private renderBookmark(bookmark: Bookmark) {
return (
<DropdownEntry key={bookmark.unique_id}
icon={IconManager.generate_tag(IconManager.load_cached_icon(bookmark.last_icon_id || 0), {animate: false})}
icon={icon_cache_loader.load_icon(bookmark.last_icon_id, bookmark.last_icon_server_id)}
text={bookmark.display_name}
onClick={BookmarkButton.onBookmarkClick.bind(undefined, bookmark.unique_id)}
onContextMenu={this.onBookmarkContextMenu.bind(this, bookmark.unique_id)}/>
@ -146,7 +146,7 @@ class BookmarkButton extends ReactComponentBase<{ event_registry: Registry<Inter
const bookmark = find_bookmark(bookmark_id) as Bookmark;
if(!bookmark) return;
this.button_ref.current?.updateState({ dropdownForceShow: true });
this.button_ref.current?.setState({ dropdownForceShow: true });
contextmenu.spawn_context_menu(event.pageX, event.pageY, {
type: contextmenu.MenuEntryType.ENTRY,
name: tr("Connect"),
@ -159,7 +159,7 @@ class BookmarkButton extends ReactComponentBase<{ event_registry: Registry<Inter
callback: () => 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<InternalControlBarEvents> }, AwayState> {
protected default_state(): AwayState {
protected defaultState(): AwayState {
return {
away: false,
awayAnywhere: false,
@ -226,7 +226,7 @@ class AwayButton extends ReactComponentBase<{ event_registry: Registry<InternalC
@EventHandler<InternalControlBarEvents>("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<InternalControlBarEvents> }, ChannelSubscribeState> {
protected default_state(): ChannelSubscribeState {
protected defaultState(): ChannelSubscribeState {
return { subscribeEnabled: false };
}
@ -247,7 +247,7 @@ class ChannelSubscribeButton extends ReactComponentBase<{ event_registry: Regist
@EventHandler<InternalControlBarEvents>("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<InternalControlBarEvents> }, MicrophoneState> {
protected default_state(): MicrophoneState {
protected defaultState(): MicrophoneState {
return {
enabled: false,
muted: false
@ -278,7 +278,7 @@ class MicrophoneButton extends ReactComponentBase<{ event_registry: Registry<Int
@EventHandler<InternalControlBarEvents>("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<InternalControlBarEvents> }, SpeakerState> {
protected default_state(): SpeakerState {
protected defaultState(): SpeakerState {
return {
muted: false
};
@ -304,7 +304,7 @@ class SpeakerButton extends ReactComponentBase<{ event_registry: Registry<Intern
@EventHandler<InternalControlBarEvents>("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<InternalControlBarEvents> }, QueryState> {
protected default_state() {
protected defaultState() {
return {
queryShown: false
};
@ -340,7 +340,7 @@ class QueryButton extends ReactComponentBase<{ event_registry: Registry<Internal
@EventHandler<InternalControlBarEvents>("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<InternalControlBarEvents> }, HostButtonState> {
protected default_state() {
protected defaultState() {
return {
url: undefined,
target_url: undefined
@ -382,7 +382,7 @@ class HostButton extends ReactComponentBase<{ event_registry: Registry<InternalC
@EventHandler<InternalControlBarEvents>("update_host_button")
private handleStateUpdate(state: HostButtonState) {
this.updateState(state);
this.setState(state);
}
}
@ -446,6 +446,7 @@ export class ControlBar extends React.Component<ControlBarProperties, {}> {
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<ControlBarProperties, {}> {
}
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() });
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -64,18 +64,18 @@ export function spawnPermissionEdit<T extends keyof OptionMap>(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<number>(resolve => {
spawnIconSelect(connection, id => resolve(new Int32Array([id])[0]), current_icon);
});

View File

@ -61,7 +61,7 @@ interface KeyActionEntryProperties {
@ReactEventHandler(e => e.props.eventRegistry)
class KeyActionEntry extends ReactComponentBase<KeyActionEntryProperties, KeyActionEntryState> {
protected default_state() : KeyActionEntryState {
protected defaultState() : KeyActionEntryState {
return {
assignedKey: undefined,
selected: false,
@ -133,7 +133,7 @@ class KeyActionEntry extends ReactComponentBase<KeyActionEntryProperties, KeyAct
@EventHandler<KeyMapEvents>("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<KeyActionEntryProperties, KeyAct
if(event.action !== this.props.action) return;
if(event.query_type !== "general") return;
this.updateState({
this.setState({
state: "loading"
});
}
@ -153,12 +153,12 @@ class KeyActionEntry extends ReactComponentBase<KeyActionEntryProperties, KeyAct
if(event.action !== this.props.action) return;
if(event.status === "success") {
this.updateState({
this.setState({
state: "loaded",
assignedKey: event.key
});
} else {
this.updateState({
this.setState({
state: "error",
error: event.status === "timeout" ? tr("query timeout") : event.error
});
@ -169,7 +169,7 @@ class KeyActionEntry extends ReactComponentBase<KeyActionEntryProperties, KeyAct
private handleSetKeymap(event: KeyMapEvents["set_keymap"]) {
if(event.action !== this.props.action) return;
this.updateState({ state: "applying" });
this.setState({ state: "applying" });
}
@EventHandler<KeyMapEvents>("set_keymap_result")
@ -177,12 +177,12 @@ class KeyActionEntry extends ReactComponentBase<KeyActionEntryProperties, KeyAct
if(event.action !== this.props.action) return;
if(event.status === "success") {
this.updateState({
this.setState({
state: "loaded",
assignedKey: event.key
});
} else {
this.updateState({ state: "loaded" });
this.setState({ state: "loaded" });
createErrorModal(tr("Failed to change key"), tra("Failed to change key for action \"{}\":{:br:}{}", this.props.action, event.status === "timeout" ? tr("timeout") : event.error));
}
}
@ -195,7 +195,7 @@ interface KeyActionGroupProperties {
}
class KeyActionGroup extends ReactComponentBase<KeyActionGroupProperties, { collapsed: boolean }> {
protected default_state(): { collapsed: boolean } {
protected defaultState(): { collapsed: boolean } {
return { collapsed: false }
}
@ -213,7 +213,7 @@ class KeyActionGroup extends ReactComponentBase<KeyActionGroupProperties, { coll
}
private toggleCollapsed() {
this.updateState({
this.setState({
collapsed: !this.state.collapsed
});
}
@ -224,7 +224,7 @@ interface KeyActionListProperties {
}
class KeyActionList extends ReactComponentBase<KeyActionListProperties, {}> {
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<KeyMapEvents> }, 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<KeyMapEven
@EventHandler<KeyMapEvents>("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<KeyMapEven
@EventHandler<KeyMapEvents>("query_keymap_result")
private handleQueryKeymapResult(event: KeyMapEvents["query_keymap_result"]) {
this.updateState({
this.setState({
loading: false,
has_key: event.status === "success" && !!event.key
});

View File

@ -16,7 +16,7 @@ export interface ButtonState {
}
export class Button extends ReactComponentBase<ButtonProperties, ButtonState> {
protected default_state(): ButtonState {
protected defaultState(): ButtonState {
return {
disabled: undefined
};

View File

@ -1,35 +1,63 @@
import * as React from "react";
import {LocalIcon} from "tc-shared/FileManager";
export interface IconProperties {
icon: string | JQuery<HTMLDivElement>;
icon: string | LocalIcon;
title?: string;
}
export class IconRenderer extends React.Component<IconProperties, {}> {
private readonly icon_ref: React.RefObject<HTMLDivElement>;
render() {
if(!this.props.icon)
return <div className={"icon-container icon-empty"} title={this.props.title} />;
else if(typeof this.props.icon === "string")
return <div className={"icon " + this.props.icon} title={this.props.title} />;
else if(this.props.icon instanceof LocalIcon)
return <LocalIconRenderer icon={this.props.icon} title={this.props.title} />;
else throw "JQuery icons are not longer supported";
}
}
export interface LoadedIconRenderer {
icon: LocalIcon;
title?: string;
}
export class LocalIconRenderer extends React.Component<LoadedIconRenderer, {}> {
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 <div className={"icon-container icon-empty"} />;
else if(typeof this.props.icon === "string")
return <div className={"icon " + this.props.icon} />;
return <div ref={this.icon_ref} />;
const icon = this.props.icon;
if(icon.status === "loaded") {
if(icon.icon_id >= 0 && icon.icon_id <= 1000) {
if(icon.icon_id === 0)
return <div className={"icon-container icon-empty"} title={this.props.title} />;
return <div className={"icon_em client-group_" + icon.icon_id} />;
}
return <div key={"icon"} className={"icon-container"}><img src={icon.loaded_url} alt={this.props.title || ("icon " + icon.icon_id)} /></div>;
} else if(icon.status === "loading")
return <div key={"loading"} className={"icon-container"} title={this.props.title}><div className={"icon_loading"} /></div>;
else if(icon.status === "error")
return <div key={"error"} className={"icon client-warning"} title={icon.error_message || tr("Failed to load icon")} />;
else if(icon.status === "empty" || icon.status === "destroyed")
return <div className={"icon-container icon-empty"} title={this.props.title} />;
}
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);
}
}

View File

@ -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<Properties, State> extends React.Component<Properties, State> {
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<K extends keyof State>(
state: ((prevState: Readonly<State>, props: Readonly<Properties>) => (Pick<State, K> | State | null)) | (Pick<State, K> | 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<Properties, State> 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);
}
}
});
}

View File

@ -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<ServerEvents> {
remote_address: ServerAddress;
channelTree: ChannelTree;
properties: ServerProperties;
readonly events: Registry<ServerEvents>;
readonly view: React.Ref<ServerEntryView>;
private info_request_promise: Promise<void> = 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<HTMLElement>;
private _destroyed = false;
constructor(tree, name, address: ServerAddress) {
super();
this.events = new Registry<ServerEvents>();
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;
}
}

View File

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

View File

@ -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<ChannelEntryIcons>(e => e.props.channel.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
class ChannelEntryIcons extends ReactComponentBase<ChannelEntryIconsProperties, ChannelEntryIconsState> {
private static readonly SimpleIcon = (props: { iconClass: string, title: string }) => {
return <div className={"icon " + props.iconClass} title={props.title} />
};
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(<ChannelEntryIcons.SimpleIcon key={"icon-default"} iconClass={"client-channel_default"} title={tr("Default channel")} />);
if(this.state.is_password_protected)
icons.push(<ChannelEntryIcons.SimpleIcon key={"icon-password"} iconClass={"client-register"} title={tr("The channel is password protected")} />); //TODO: "client-register" is really the right icon?
if(this.state.is_music_quality)
icons.push(<ChannelEntryIcons.SimpleIcon key={"icon-music"} iconClass={"client-music"} title={tr("Music quality")} />);
if(this.state.is_moderated)
icons.push(<ChannelEntryIcons.SimpleIcon key={"icon-moderated"} iconClass={"client-moderated"} title={tr("Channel is moderated")} />);
if(this.state.custom_icon_id)
icons.push(<LocalIconRenderer key={"icon-custom"} icon={this.props.channel.channelTree.client.fileManager.icons.load_icon(this.state.custom_icon_id)} title={tr("Client icon")} />);
if(!this.state.is_codec_supported) {
icons.push(<div key={"icon-unsupported"} className={channelStyle.icon_no_sound}>
<div className={"icon_entry icon client-conflict-icon"} title={tr("You don't support the channel codec")} />
<div className={channelStyle.background} />
</div>);
}
return <span className={channelStyle.icons}>
{icons}
</span>
}
@EventHandler<ChannelEvents>("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<ChannelEntryIcon>(e => e.props.channel.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
class ChannelEntryIcon extends ReactComponentBase<ChannelEntryIconProperties, {}> {
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 <div className={"icon client-channel_" + type + (this.props.channel.flag_subscribed ? "_subscribed" : "") + " " + channelStyle.channelType} />;
}
@EventHandler<ChannelEvents>("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<ChannelEvents>("notify_clients_changed")
private handleClientsUpdated() {
this.forceUpdate();
}
@EventHandler<ChannelEvents>("notify_cached_password_updated")
private handleCachedPasswordUpdate() {
this.forceUpdate();
}
@EventHandler<ChannelEvents>("notify_subscribe_state_changed")
private handleSubscribeModeChanges() {
this.forceUpdate();
}
}
interface ChannelEntryNameProperties {
channel: ChannelEntryController;
}
@ReactEventHandler<ChannelEntryName>(e => e.props.channel.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
class ChannelEntryName extends ReactComponentBase<ChannelEntryNameProperties, {}> {
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 <div className={this.classList(channelStyle.containerChannelName, channelStyle[class_name])}>
<a className={channelStyle.channelName}>{text}</a>
</div>;
}
@EventHandler<ChannelEvents>("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<ChannelEntryView>(e => e.props.channel.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
export class ChannelEntryView extends TreeEntry<ChannelEntryViewProperties, {}> {
shouldComponentUpdate(nextProps: Readonly<ChannelEntryViewProperties>, 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 <div className={this.classList(viewStyle.treeEntry, channelStyle.channelEntry, this.props.channel.isSelected() && viewStyle.selected)}
style={{ paddingLeft: this.props.depth * 16, top: this.props.offset }}
onMouseDown={e => this.onMouseDown(e as any)}
onDoubleClick={() => this.onDoubleClick()}
onContextMenu={e => this.onContextMenu(e as any)}
>
<UnreadMarker entry={this.props.channel} />
<ChannelEntryIcon channel={this.props.channel} />
<ChannelEntryName channel={this.props.channel} />
<ChannelEntryIcons channel={this.props.channel} />
</div>;
}
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<ChannelEvents>("notify_select_state_change")
private handleSelectStateChange() {
this.forceUpdate();
}
}

View File

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

View File

@ -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<ClientSpeakIcon>(e => e.props.client.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
class ClientSpeakIcon extends ReactComponentBase<ClientIconProperties, {}> {
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 <div className={"clicon " + clicon} />;
else if(icon.length > 0)
return <div className={"icon " + icon} />;
else
return null;
}
@EventHandler<ClientEvents>("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<ClientEvents>("notify_mute_state_change")
private handleMuteStateChange() {
this.forceUpdate();
}
@EventHandler<ClientEvents>("notify_speak_state_change")
private handleSpeakStateChange() {
this.forceUpdate();
}
}
interface ClientServerGroupIconsProperties {
client: ClientEntryController;
}
@ReactEventHandler<ClientServerGroupIcons>(e => e.props.client.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
class ClientServerGroupIcons extends ReactComponentBase<ClientServerGroupIconsProperties, {}> {
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 <LocalIconRenderer key={"group-icon-" + e.id} icon={this.props.client.channelTree.client.fileManager.icons.load_icon(e.properties.iconid)} />;
})
];
}
@EventHandler<ClientEvents>("notify_properties_updated")
private handlePropertiesUpdated(event: ClientEvents["notify_properties_updated"]) {
if(typeof event.updated_properties.client_servergroups)
this.forceUpdate();
}
}
interface ClientChannelGroupIconProperties {
client: ClientEntryController;
}
@ReactEventHandler<ClientChannelGroupIcon>(e => e.props.client.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
class ClientChannelGroupIcon extends ReactComponentBase<ClientChannelGroupIconProperties, {}> {
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 <LocalIconRenderer key={"cg-icon"} icon={this.props.client.channelTree.client.fileManager.icons.load_icon(channel_group.properties.iconid)} />;
}
@EventHandler<ClientEvents>("notify_properties_updated")
private handlePropertiesUpdated(event: ClientEvents["notify_properties_updated"]) {
if(typeof event.updated_properties.client_servergroups)
this.forceUpdate();
}
}
interface ClientIconsProperties {
client: ClientEntryController;
}
@ReactEventHandler<ClientIcons>(e => e.props.client.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
class ClientIcons extends ReactComponentBase<ClientIconsProperties, {}> {
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(<div key={"muted"} className={"icon icon_talk_power client-input_muted"} />);
icons.push(<ClientServerGroupIcons key={"sg-icons"} client={this.props.client} />);
icons.push(<ClientChannelGroupIcon key={"channel-icons"} client={this.props.client} />);
if(this.props.client.properties.client_icon_id !== 0)
icons.push(<LocalIconRenderer key={"client-icon"} icon={this.props.client.channelTree.client.fileManager.icons.load_icon(this.props.client.properties.client_icon_id)} />);
return (
<div className={clientStyle.containerIcons}>
{icons}
</div>
)
}
@EventHandler<ClientEvents>("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<ClientNameProperties, ClientNameState> {
protected defaultState(): ClientNameState {
return {
group_prefix: "",
away_message: "",
group_suffix: ""
}
}
render() {
return <div className={this.classList(clientStyle.clientName, this.props.client instanceof LocalClientEntry && clientStyle.clientNameOwn)}>
{this.state.group_prefix + this.props.client.clientNickName() + this.state.group_suffix + this.state.away_message}
</div>
}
@EventHandler<ClientEvents>("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<ClientNameEditProps, {}> {
private readonly ref_div: React.RefObject<HTMLDivElement> = React.createRef();
componentDidMount(): void {
this.ref_div.current.focus();
}
render() {
return <div
className={this.classList(clientStyle.clientName, clientStyle.edit)}
contentEditable={true}
ref={this.ref_div}
dangerouslySetInnerHTML={{__html: DOMPurify.sanitize(this.props.initialName)}}
onBlur={e => 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<ClientEntry>(e => e.props.client.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
export class ClientEntry extends TreeEntry<ClientEntryProperties, ClientEntryState> {
shouldComponentUpdate(nextProps: Readonly<ClientEntryProperties>, nextState: Readonly<ClientEntryState>, 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 (
<div className={this.classList(clientStyle.clientEntry, viewStyle.treeEntry, this.props.client.isSelected() && viewStyle.selected)}
style={{ paddingLeft: (this.props.depth * 16) + "px", top: this.props.offset }}
onDoubleClick={() => this.onDoubleClick()}
onMouseDown={e => this.onMouseDown(e as any)}
onContextMenu={e => this.onContextMenu(e as any)}
>
<UnreadMarker entry={this.props.client} />
<ClientSpeakIcon client={this.props.client} />
{this.state.rename ?
<ClientNameEdit key={"rename"} editFinished={name => this.onEditFinished(name)} initialName={this.state.renameInitialName || this.props.client.properties.client_nickname} /> :
[<ClientName key={"name"} client={this.props.client} />, <ClientIcons key={"icons"} client={this.props.client} />] }
</div>
)
}
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<ClientEvents>("notify_select_state_change")
private handleSelectChangeState() {
this.forceUpdate();
}
}

View File

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

View File

@ -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<ServerEntry>(e => e.props.server.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
export class ServerEntry extends TreeEntry<ServerEntryProperties, ServerEntryState> {
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<ServerEntryProperties>, nextState: Readonly<ServerEntryState>, 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 <div className={this.classList(serverStyle.serverEntry, viewStyle.treeEntry, this.props.server.isSelected() && viewStyle.selected )}
style={{ top: this.props.offset }}
onMouseDown={e => this.onMouseDown(e as any)}
onContextMenu={e => this.onContextMenu(e as any)}
>
<UnreadMarker entry={this.props.server} />
<div className={"icon client-server_green " + serverStyle.server_type} />
<div className={this.classList(serverStyle.name)}>{name}</div>
<LocalIconRenderer icon={this.props.server.channelTree.client.fileManager?.icons.load_icon(this.props.server.properties.virtualserver_icon_id)} />
</div>
}
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<ServerEvents>("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<ServerEvents>("notify_select_state_change")
private handleServerSelectStateChange() {
this.forceUpdate();
}
}

View File

@ -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<any>;
}
@ReactEventHandler<UnreadMarker>(e => e.props.entry.events)
export class UnreadMarker extends ReactComponentBase<UnreadMarkerProperties, {}> {
render() {
if(!this.props.entry.isUnread())
return null;
return <div className={viewStyle.markerUnread} />;
}
@EventHandler<ChannelTreeEntryEvents>("notify_unread_state_change")
private handleUnreadStateChange() {
this.forceUpdate();
}
}
export class TreeEntry<Props, State> extends ReactComponentBase<Props, State> { }

116
shared/js/ui/tree/View.scss Normal file
View File

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

231
shared/js/ui/tree/View.tsx Normal file
View File

@ -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<ChannelTreeView>(e => e.props.tree.events)
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
export class ChannelTreeView extends ReactComponentBase<ChannelTreeViewProperties, ChannelTreeViewState> {
private static readonly EntryHeight = 18;
private readonly ref_container = React.createRef<HTMLDivElement>();
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 (
<div className={viewStyle.channelTreeContainer} onScroll={() => this.onScroll()} ref={this.ref_container} >
<div className={viewStyle.channelTree} style={{height: (this.flat_tree.length * ChannelTreeView.EntryHeight) + "px"}}>
{elements}
</div>
</div>
)
}
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: <ChannelEntryView key={"channel-" + entry.channelId} channel={entry} offset={this.build_top_offset += ChannelTreeView.EntryHeight} depth={depth} ref={entry.view} />
});
this.flat_tree.push(...entry.clients(false).map(e => {
return {
entry: e,
rendered: <ClientEntryView key={"client-" + e.clientId()} client={e} offset={this.build_top_offset += ChannelTreeView.EntryHeight} depth={depth + 1} ref={e.view} />
};
}));
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: <ServerEntryView key={"server"} server={tree.server} offset={this.build_top_offset += ChannelTreeView.EntryHeight} ref={tree.server.view} />
}];
for (const channel of tree.rootChannel())
this.build_sub_tree(channel, 1);
}
@EventHandler<ChannelTreeEvents>("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);
}
}
}

View File

@ -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<ChannelEntry>();
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:<br>"), 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:<br>"), 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:<br>"), 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<ChannelEntry>(resolve => { resolve(channel); }));
}
return new Promise<ChannelEntry>(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);
});
}
}

1047
shared/js/ui/view.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -12,4 +12,6 @@ export function fix_declare_global(nodes: ts.Node[]) : ts.Node[] {
if(has_export) return nodes;
return [];
}
}
SyntaxKind.PlusEqualsToken

View File

@ -54,19 +54,22 @@ export class CodecWrapperWorker extends BasicCodec {
async initialise() : Promise<Boolean> {
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<AudioBuffer> {
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<Uint8Array> {
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++)

View File

@ -64,6 +64,9 @@ async function handle_message(command: string, data: any) : Promise<string | obj
return {};
case "encodeSamples":
if(!codec_instance)
return "codec not initialized/initialize failed";
let encodeArray = new Float32Array(data.length);
for(let index = 0; index < encodeArray.length; index++)
encodeArray[index] = data.data[index];
@ -74,6 +77,9 @@ async function handle_message(command: string, data: any) : Promise<string | obj
else
return { data: encodeResult, length: encodeResult.length };
case "decodeSamples":
if(!codec_instance)
return "codec not initialized/initialize failed";
let decodeArray = new Uint8Array(data.length);
for(let index = 0; index < decodeArray.length; index++)
decodeArray[index] = data.data[index];

View File

@ -111,7 +111,10 @@ export const config = async (target: "web" | "client") => { 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
}
}
]
},

24
webpack/DevelBlocks.ts Normal file
View File

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