From c144af0879bb8fb001884ca1d204b73dbc053520 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Tue, 8 Dec 2020 14:42:41 +0100 Subject: [PATCH 01/37] Fixed image cache initialisation --- shared/js/file/ImageCache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/js/file/ImageCache.ts b/shared/js/file/ImageCache.ts index 8541a26d..86b64cc3 100644 --- a/shared/js/file/ImageCache.ts +++ b/shared/js/file/ImageCache.ts @@ -79,7 +79,7 @@ export class ImageCache { public static async load(cacheName: string) : Promise { const cache = new ImageCache(cacheName); - + await cache.initialize(); return cache; } From a05b795859afac17681c4139328e737a956ec060 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Wed, 9 Dec 2020 13:36:56 +0100 Subject: [PATCH 02/37] Some minor chat related bugfixing and separated the chat controller form the chat modal --- ChangeLog.md | 8 +- shared/css/static/frame-chat.scss | 208 --------- shared/js/ConnectionHandler.ts | 39 +- shared/js/connection/CommandHandler.ts | 49 +-- shared/js/connection/ConnectionBase.ts | 13 +- shared/js/connection/PluginCmdHandler.ts | 6 +- shared/js/connection/ServerFeatures.ts | 4 +- shared/js/conversations/AbstractConversion.ts | 383 +++++++++++++++++ .../ChannelConversationManager.ts} | 304 ++++++-------- .../PrivateConversationHistory.ts | 44 +- .../PrivateConversationManager.ts | 268 ++++-------- shared/js/file/LocalIcons.ts | 2 +- shared/js/permission/GroupManager.ts | 2 +- shared/js/tree/Channel.ts | 14 +- shared/js/tree/ChannelTree.tsx | 24 +- shared/js/tree/Client.ts | 13 +- shared/js/tree/Server.ts | 4 +- shared/js/ui/frames/chat_frame.ts | 355 ++-------------- shared/js/ui/frames/control-bar/Controller.ts | 2 +- .../side/AbstractConversationController.ts | 311 ++++++++++++++ ...scss => AbstractConversationRenderer.scss} | 0 ...I.tsx => AbstractConversationRenderer.tsx} | 50 +-- .../js/ui/frames/side/AbstractConversion.ts | 395 ------------------ .../side/ChannelConversationController.ts | 91 ++++ .../js/ui/frames/side/ClientInfoController.ts | 9 +- ...lientInfo.scss => ClientInfoRenderer.scss} | 0 .../js/ui/frames/side/ClientInfoRenderer.tsx | 2 +- .../ui/frames/side/ConversationDefinitions.ts | 9 +- shared/js/ui/frames/side/HeaderController.ts | 269 ++++++++++++ shared/js/ui/frames/side/HeaderDefinitions.ts | 65 +++ shared/js/ui/frames/side/HeaderRenderer.scss | 186 +++++++++ shared/js/ui/frames/side/HeaderRenderer.tsx | 281 +++++++++++++ ...nUI.tsx => PopoutConversationRenderer.tsx} | 6 +- .../side/PrivateConversationController.ts | 162 +++++++ .../side/PrivateConversationDefinitions.ts | 8 +- ....scss => PrivateConversationRenderer.scss} | 0 ...UI.tsx => PrivateConversationRenderer.tsx} | 21 +- shared/js/ui/frames/side/music_info.ts | 3 +- .../side => react-elements}/ChatBox.scss | 4 +- .../side => react-elements}/ChatBox.tsx | 0 .../external-modal/PopoutRegistry.ts | 2 +- shared/js/ui/react-elements/i18n/index.tsx | 8 +- shared/js/ui/tree/Controller.tsx | 2 +- web/app/connection/ServerConnection.ts | 8 +- 44 files changed, 2178 insertions(+), 1456 deletions(-) create mode 100644 shared/js/conversations/AbstractConversion.ts rename shared/js/{ui/frames/side/ConversationManager.ts => conversations/ChannelConversationManager.ts} (56%) rename shared/js/{ui/frames/side => conversations}/PrivateConversationHistory.ts (83%) rename shared/js/{ui/frames/side => conversations}/PrivateConversationManager.ts (55%) create mode 100644 shared/js/ui/frames/side/AbstractConversationController.ts rename shared/js/ui/frames/side/{ConversationUI.scss => AbstractConversationRenderer.scss} (100%) rename shared/js/ui/frames/side/{ConversationUI.tsx => AbstractConversationRenderer.tsx} (96%) delete mode 100644 shared/js/ui/frames/side/AbstractConversion.ts create mode 100644 shared/js/ui/frames/side/ChannelConversationController.ts rename shared/js/ui/frames/side/{ClientInfo.scss => ClientInfoRenderer.scss} (100%) create mode 100644 shared/js/ui/frames/side/HeaderController.ts create mode 100644 shared/js/ui/frames/side/HeaderDefinitions.ts create mode 100644 shared/js/ui/frames/side/HeaderRenderer.scss create mode 100644 shared/js/ui/frames/side/HeaderRenderer.tsx rename shared/js/ui/frames/side/{PopoutConversationUI.tsx => PopoutConversationRenderer.tsx} (84%) create mode 100644 shared/js/ui/frames/side/PrivateConversationController.ts rename shared/js/ui/frames/side/{PrivateConversationUI.scss => PrivateConversationRenderer.scss} (100%) rename shared/js/ui/frames/side/{PrivateConversationUI.tsx => PrivateConversationRenderer.tsx} (94%) rename shared/js/ui/{frames/side => react-elements}/ChatBox.scss (98%) rename shared/js/ui/{frames/side => react-elements}/ChatBox.tsx (100%) diff --git a/ChangeLog.md b/ChangeLog.md index 5b276570..0a6b3256 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,8 +1,14 @@ # Changelog: +* **09.12.20** + - Fixed the private messages unread indicator + - Properly updating the private message unread count + * **08.12.20** - Fixed the permission editor not resolving unique ids - Fixed client database info resolve - + - Improved the side header bar + All values are not updating accordingly to the connection state + * **07.12.20** - Fixed the Markdown to BBCode transpiler falsely emitting empty lines - Fixed invalid BBCode escaping diff --git a/shared/css/static/frame-chat.scss b/shared/css/static/frame-chat.scss index 0706f3d0..b75be2fe 100644 --- a/shared/css/static/frame-chat.scss +++ b/shared/css/static/frame-chat.scss @@ -38,214 +38,6 @@ html:root { min-height: 200px; - .container-info { - user-select: none; - - flex-grow: 0; - flex-shrink: 0; - - height: 9em; - - display: flex; - flex-direction: column; - justify-content: space-evenly; - - background-color: var(--side-info-background); - border-top-left-radius: 5px; - border-top-right-radius: 5px; - - -moz-box-shadow: inset 0 0 5px var(--side-info-shadow); - -webkit-box-shadow: inset 0 0 5px var(--side-info-shadow); - box-shadow: inset 0 0 5px var(--side-info-shadow); - - .lane { - padding-right: 10px; - padding-left: 10px; - - display: flex; - flex-direction: row; - justify-content: stretch; - - height: 3.25em; - - .block, .button { - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - } - - .block { - flex-shrink: 1; - flex-grow: 1; - - min-width: 0; - - &.right { - text-align: right; - - &.mode-client_info { - max-width: calc(50% - #{$client_info_avatar_size / 2}); - margin-left: calc(#{$client_info_avatar_size / 2}); - } - } - - &.left { - margin-right: .5em; - text-align: left; - padding-right: 10px; - - &.mode-client_info { - max-width: calc(50% - #{$client_info_avatar_size / 2}); - margin-right: calc(#{$client_info_avatar_size} / 2); - } - } - - .title, .value, .small-value { - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - - min-width: 0; - max-width: 100%; - } - - .title { - display: block; - color: var(--side-info-title); - - .container-indicator { - display: inline-flex; - flex-direction: column; - justify-content: space-around; - - background: var(--side-info-indicator-background); - border: 1px solid var(--side-info-indicator-border); - border-radius: 4px; - - text-align: center; - - vertical-align: text-top; - - color: var(--side-info-indicator); - - font-size: .66em; - height: 1.3em; - min-width: .9em; - - padding-right: 2px; - padding-left: 2px; - } - } - - .value { - color: var(--side-info-value); - background-color: var(--side-info-value-background); - - display: inline-block; - - border-radius: .18em; - padding-right: .31em; - padding-left: .31em; - - > div { - display: inline-block; - } - - .icon-container, .icon { - vertical-align: middle; - margin-right: .25em; - } - - &.value-ping { - //very-good good medium poor very-poor - &.very-good { - color: var(--side-info-ping-very-good); - } - &.good { - color: var(--side-info-ping-good); - } - &.medium { - color: var(--side-info-ping-medium); - } - &.poor { - color: var(--side-info-ping-poor); - } - &.very-poor { - color: var(--side-info-ping-very-poor); - } - } - - &.chat-counter { - cursor: pointer; - } - - &.bot-add-song { - color: var(--side-info-bot-add-song); - } - } - - .small-value { - display: inline-block; - color: var(--side-info-value); - font-size: .66em; - vertical-align: top; - margin-top: -.2em; - } - - .button { - color: var(--side-info-value); - background-color: var(--side-info-value-background); - - display: inline-block; - - &:not(.value) { - border-radius: .18em; - padding-right: .31em; - padding-left: .31em; - - margin-top: 1.5em; /* because we've no title */ - } - - cursor: pointer; - - &:hover { - background-color: #4e4e4e; /* TODO: Evaluate color */ - } - @include transition(background-color $button_hover_animation_time ease-in-out); - } - } - - &:not(.mode-channel_chat) { - .mode-channel_chat { display: none; } - } - - &:not(.mode-private_chat) { - .mode-private_chat { display: none; } - } - - &:not(.mode-client_info) { - .mode-client_info { display: none; } - } - - &:not(.mode-music_bot) { - .mode-music_bot { display: none; } - } - - &.mode-music_bot { - .mode-music_bot { - &.right { - margin-left: 8.5em; - } - &.left { - margin-right: 8.5em; - } - - width: 60em; /* same width so flex-shrik applies equaly */ - } - } - } - } - .container-chat { width: 100%; diff --git a/shared/js/ConnectionHandler.ts b/shared/js/ConnectionHandler.ts index d593b13b..7f646b00 100644 --- a/shared/js/ConnectionHandler.ts +++ b/shared/js/ConnectionHandler.ts @@ -38,6 +38,9 @@ import {LocalClientEntry} from "./tree/Client"; import {ServerAddress} from "./tree/Server"; import {ChannelVideoFrame} from "tc-shared/ui/frames/video/Controller"; import {global_client_actions} from "tc-shared/events/GlobalEvents"; +import {ChannelConversationManager} from "./conversations/ChannelConversationManager"; +import {PrivateConversationManager} from "tc-shared/conversations/PrivateConversationManager"; +import {ChannelConversationController} from "./ui/frames/side/ChannelConversationController"; export enum InputHardwareState { MISSING, @@ -154,6 +157,9 @@ export class ConnectionHandler { serverFeatures: ServerFeatures; + private channelConversations: ChannelConversationManager; + private privateConversations: PrivateConversationManager; + private _clientId: number = 0; private localClient: LocalClientEntry; @@ -205,6 +211,9 @@ export class ConnectionHandler { this.fileManager = new FileManager(this); this.permissions = new PermissionManager(this); + this.privateConversations = new PrivateConversationManager(this); + this.channelConversations = new ChannelConversationManager(this); + this.pluginCmdRegistry = new PluginCmdRegistry(this); this.video_frame = new ChannelVideoFrame(this); @@ -217,9 +226,9 @@ export class ConnectionHandler { this.localClient.channelTree = this.channelTree; this.event_registry.register_handler(this); - this.events().fire("notify_handler_initialized"); - this.pluginCmdRegistry.registerHandler(new W2GPluginCmdHandler()); + + this.events().fire("notify_handler_initialized"); } initialize_client_state(source?: ConnectionHandler) { @@ -341,6 +350,14 @@ export class ConnectionHandler { getClient() : LocalClientEntry { return this.localClient; } getClientId() { return this._clientId; } + getPrivateConversations() : PrivateConversationManager { + return this.privateConversations; + } + + getChannelConversations() : ChannelConversationManager { + return this.channelConversations; + } + initializeLocalClient(clientId: number, acceptedName: string) { this._clientId = clientId; this.localClient["_clientId"] = clientId; @@ -354,8 +371,8 @@ export class ConnectionHandler { @EventHandler("notify_connection_state_changed") private handleConnectionStateChanged(event: ConnectionEvents["notify_connection_state_changed"]) { - this.connection_state = event.new_state; - if(event.new_state === ConnectionState.CONNECTED) { + this.connection_state = event.newState; + if(event.newState === ConnectionState.CONNECTED) { log.info(LogCategory.CLIENT, tr("Client connected")); this.log.log(EventType.CONNECTION_CONNECTED, { serverAddress: { @@ -679,8 +696,8 @@ export class ConnectionHandler { private on_connection_state_changed(old_state: ConnectionState, new_state: ConnectionState) { console.log("From %s to %s", ConnectionState[old_state], ConnectionState[new_state]); this.event_registry.fire("notify_connection_state_changed", { - old_state: old_state, - new_state: new_state + oldState: old_state, + newState: new_state }); } @@ -1016,6 +1033,12 @@ export class ConnectionHandler { } this.localClient = undefined; + this.privateConversations?.destroy(); + this.privateConversations = undefined; + + this.channelConversations?.destroy(); + this.channelConversations = undefined; + this.channelTree?.destroy(); this.channelTree = undefined; @@ -1194,8 +1217,8 @@ export interface ConnectionEvents { } notify_connection_state_changed: { - old_state: ConnectionState, - new_state: ConnectionState + oldState: ConnectionState, + newState: ConnectionState }, /* the handler has become visible/invisible for the client */ diff --git a/shared/js/connection/CommandHandler.ts b/shared/js/connection/CommandHandler.ts index 7e6d9f3e..06c8ec7e 100644 --- a/shared/js/connection/CommandHandler.ts +++ b/shared/js/connection/CommandHandler.ts @@ -17,7 +17,7 @@ import {formatMessage} from "../ui/frames/chat"; import {spawnPoke} from "../ui/modal/ModalPoke"; import {AbstractCommandHandler, AbstractCommandHandlerBoss} from "../connection/AbstractCommandHandler"; import {batch_updates, BatchUpdateType, flush_batched_updates} from "../ui/react-elements/ReactComponentBase"; -import {OutOfViewClient} from "../ui/frames/side/PrivateConversationManager"; +import {OutOfViewClient} from "../ui/frames/side/PrivateConversationController"; import {renderBBCodeAsJQuery} from "../text/bbcode"; import {tr} from "../i18n/localize"; import {EventClient, EventType} from "../ui/frames/log/Definitions"; @@ -74,9 +74,6 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { this["notifychannelsubscribed"] = this.handleNotifyChannelSubscribed; this["notifychannelunsubscribed"] = this.handleNotifyChannelUnsubscribed; - //this["notifyconversationhistory"] = this.handleNotifyConversationHistory; - //this["notifyconversationmessagedelete"] = this.handleNotifyConversationMessageDelete; - this["notifymusicstatusupdate"] = this.handleNotifyMusicStatusUpdate; this["notifymusicplayersongchange"] = this.handleMusicPlayerSongChange; @@ -413,7 +410,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { handleCommandChannelDelete(json) { let tree = this.connection.client.channelTree; - const conversations = this.connection.client.side_bar.channel_conversations(); + const conversations = this.connection.client.getChannelConversations(); let playSound = false; @@ -448,7 +445,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { handleCommandChannelHide(json) { let tree = this.connection.client.channelTree; - const conversations = this.connection.client.side_bar.channel_conversations(); + const conversations = this.connection.client.getChannelConversations(); log.info(LogCategory.NETWORKING, tr("Got %d channel hides"), json.length); for(let index = 0; index < json.length; index++) { @@ -556,9 +553,8 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { if(client instanceof LocalClientEntry) { client.initializeListener(); this.connection_handler.update_voice_status(); - this.connection_handler.side_bar.info_frame().update_channel_talk(); - const conversations = this.connection.client.side_bar.channel_conversations(); - conversations.setSelectedConversation(client.currentChannel().channelId); + const conversations = this.connection.client.getChannelConversations(); + conversations.setSelectedConversation(conversations.findOrCreateConversation(client.currentChannel().channelId)); } } } @@ -586,7 +582,6 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { } else { this.connection.client.handleDisconnect(DisconnectReason.UNKNOWN, entry); } - this.connection_handler.side_bar.info_frame().update_channel_talk(); return; } @@ -673,9 +668,6 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { for(const entry of client.channelTree.clientsByChannel(channelFrom)) { entry.getVoiceClient()?.abortReplay(); } - - const side_bar = this.connection_handler.side_bar; - side_bar.info_frame().update_channel_talk(); } else { client.speaking = false; } @@ -810,8 +802,8 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { uniqueId: targetIsOwn ? json["invokeruid"] : undefined } as OutOfViewClient; - const conversation_manager = this.connection_handler.side_bar.private_conversations(); - const conversation = conversation_manager.findOrCreateConversation(chatPartner); + const conversationManager = this.connection_handler.getPrivateConversations(); + const conversation = conversationManager.findOrCreateConversation(chatPartner); conversation.handleIncomingMessage(chatPartner, !targetIsOwn, { sender_database_id: targetClientEntry ? targetClientEntry.properties.client_database_id : 0, @@ -842,7 +834,6 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { } }); } - this.connection_handler.side_bar.info_frame().update_chat_counter(); } else if(mode == 2) { const invoker = this.connection_handler.channelTree.findClient(parseInt(json["invokerid"])); const own_channel_id = this.connection.client.getClient().currentChannel().channelId; @@ -854,7 +845,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { this.connection_handler.sound.play(Sound.MESSAGE_RECEIVED, {default_volume: .5}); } - const conversations = this.connection_handler.side_bar.channel_conversations(); + const conversations = this.connection_handler.getChannelConversations(); conversations.findOrCreateConversation(channel_id).handleIncomingMessage({ sender_database_id: invoker ? invoker.properties.client_database_id : 0, sender_name: json["invokername"], @@ -865,7 +856,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { }, invoker instanceof LocalClientEntry); } else if(mode == 3) { const invoker = this.connection_handler.channelTree.findClient(parseInt(json["invokerid"])); - const conversations = this.connection_handler.side_bar.channel_conversations(); + const conversations = this.connection_handler.getChannelConversations(); this.connection_handler.log.log(EventType.GLOBAL_MESSAGE, { isOwnMessage: invoker instanceof LocalClientEntry, @@ -891,7 +882,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { notifyClientChatComposing(json) { json = json[0]; - const conversation_manager = this.connection_handler.side_bar.private_conversations(); + const conversation_manager = this.connection_handler.getPrivateConversations(); const conversation = conversation_manager.findConversation(json["cluid"]); conversation?.handleRemoteComposing(parseInt(json["clid"])); } @@ -899,8 +890,8 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { handleNotifyClientChatClosed(json) { json = json[0]; //Only one bulk - const conversation_manager = this.connection_handler.side_bar.private_conversations(); - const conversation = conversation_manager.findConversation(json["cluid"]); + const conversationManager = this.connection_handler.getPrivateConversations(); + const conversation = conversationManager.findConversation(json["cluid"]); if(!conversation) { log.warn(LogCategory.GENERAL, tr("Received chat close for client, but we haven't a chat open.")); return; @@ -1054,22 +1045,6 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { } } - /* - handleNotifyConversationMessageDelete(json: any[]) { - let conversation: Conversation; - const conversations = this.connection.client.side_bar.channel_conversations(); - for(const entry of json) { - if(typeof(entry["cid"]) !== "undefined") - conversation = conversations.conversation(parseInt(entry["cid"]), false); - - if(!conversation) - continue; - - conversation.delete_messages(parseInt(entry["timestamp_begin"]), parseInt(entry["timestamp_end"]), parseInt(entry["cldbid"]), parseInt(entry["limit"])); - } - } - */ - handleNotifyMusicStatusUpdate(json: any[]) { json = json[0]; diff --git a/shared/js/connection/ConnectionBase.ts b/shared/js/connection/ConnectionBase.ts index f24a1437..c4b5cce8 100644 --- a/shared/js/connection/ConnectionBase.ts +++ b/shared/js/connection/ConnectionBase.ts @@ -20,10 +20,18 @@ export const CommandOptionDefaults: CommandOptions = { timeout: 10_000 }; +export type ConnectionPing = { + javascript: number | undefined, + native: number +}; + export interface ServerConnectionEvents { notify_connection_state_changed: { oldState: ConnectionState, newState: ConnectionState + }, + notify_ping_updated: { + newPing: ConnectionPing } } @@ -73,10 +81,7 @@ export abstract class AbstractServerConnection { getConnectionState() { return this.connectionState; } - abstract ping() : { - native: number, - javascript?: number - }; + abstract ping() : ConnectionPing; } export class ServerCommand { diff --git a/shared/js/connection/PluginCmdHandler.ts b/shared/js/connection/PluginCmdHandler.ts index df5193f8..ec753605 100644 --- a/shared/js/connection/PluginCmdHandler.ts +++ b/shared/js/connection/PluginCmdHandler.ts @@ -91,8 +91,9 @@ export class PluginCmdRegistry { } registerHandler(handler: PluginCmdHandler) { - if(this.handlerMap[handler.getChannel()] !== undefined) + if(this.handlerMap[handler.getChannel()] !== undefined) { throw tra("A handler for channel {} already exists", handler.getChannel()); + } this.handlerMap[handler.getChannel()] = handler; handler["currentServerConnection"] = this.connection.serverConnection; @@ -100,8 +101,9 @@ export class PluginCmdRegistry { } unregisterHandler(handler: PluginCmdHandler) { - if(this.handlerMap[handler.getChannel()] !== handler) + if(this.handlerMap[handler.getChannel()] !== handler) { return; + } handler["currentServerConnection"] = undefined; handler.handleHandlerUnregistered(); diff --git a/shared/js/connection/ServerFeatures.ts b/shared/js/connection/ServerFeatures.ts index 051cefb3..e0784f16 100644 --- a/shared/js/connection/ServerFeatures.ts +++ b/shared/js/connection/ServerFeatures.ts @@ -68,7 +68,7 @@ export class ServerFeatures { }); this.connection.events().on("notify_connection_state_changed", this.stateChangeListener = event => { - if(event.new_state === ConnectionState.CONNECTED) { + if(event.newState === ConnectionState.CONNECTED) { this.connection.getServerConnection().send_command("listfeaturesupport").catch(error => { this.disableAllFeatures(); if(error instanceof CommandResult) { @@ -84,7 +84,7 @@ export class ServerFeatures { this.featureAwaitCallback(true); } }); - } else if(event.new_state === ConnectionState.DISCONNECTING || event.new_state === ConnectionState.UNCONNECTED) { + } else if(event.newState === ConnectionState.DISCONNECTING || event.newState === ConnectionState.UNCONNECTED) { this.disableAllFeatures(); this.featureAwait = undefined; this.featureAwaitCallback = undefined; diff --git a/shared/js/conversations/AbstractConversion.ts b/shared/js/conversations/AbstractConversion.ts new file mode 100644 index 00000000..db08886e --- /dev/null +++ b/shared/js/conversations/AbstractConversion.ts @@ -0,0 +1,383 @@ +import { + ChatEvent, + ChatEventMessage, + ChatMessage, + ChatState, ConversationHistoryResponse +} from "tc-shared/ui/frames/side/ConversationDefinitions"; +import {Registry} from "tc-shared/events"; +import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler"; +import {preprocessChatMessageForSend} from "tc-shared/text/chat"; +import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration"; +import {ErrorCode} from "tc-shared/connection/ErrorCode"; +import {LogCategory, logWarn} from "tc-shared/log"; +import {ChannelConversation} from "tc-shared/conversations/ChannelConversationManager"; + +export const kMaxChatFrameMessageSize = 50; /* max 100 messages, since the server does not support more than 100 messages queried at once */ + +export interface AbstractChatEvents { + notify_chat_event: { + triggerUnread: boolean, + event: ChatEvent + }, + notify_unread_timestamp_changed: { + timestamp: number + }, + notify_unread_state_changed: { + unread: boolean + }, + notify_send_toggle: { + enabled: boolean + }, + notify_state_changed: { + oldSTate: ChatState, + newState: ChatState + }, + notify_history_state_changed: { + hasHistory: boolean + } +} + +export abstract class AbstractChat { + readonly events: Registry; + + protected readonly connection: ConnectionHandler; + protected readonly chatId: string; + + protected presentMessages: ChatEvent[] = []; + protected presentEvents: Exclude[] = []; /* everything excluding chat messages */ + + private mode: ChatState = "unloaded"; + protected failedPermission: string; + protected errorMessage: string; + + protected conversationPrivate: boolean = false; + protected crossChannelChatSupported: boolean = true; + + protected unreadTimestamp: number; + protected unreadState: boolean = false; + + protected messageSendEnabled: boolean = true; + + private history = false; + + protected constructor(connection: ConnectionHandler, chatId: string) { + this.events = new Registry(); + this.connection = connection; + this.chatId = chatId; + this.unreadTimestamp = Date.now(); + } + + destroy() { + this.events.destroy(); + } + + public getCurrentMode() : ChatState { return this.mode; }; + + protected setCurrentMode(mode: ChatState) { + if(this.mode === mode) { + return; + } + + const oldState = this.mode; + this.mode = mode; + this.events.fire("notify_state_changed", { oldSTate: oldState, newState: mode }); + } + + public registerChatEvent(event: ChatEvent, triggerUnread: boolean) { + if(event.type === "message") { + let index = 0; + while(index < this.presentMessages.length && this.presentMessages[index].timestamp <= event.timestamp) { + index++; + } + + this.presentMessages.splice(index, 0, event); + + const deleteMessageCount = Math.max(0, this.presentMessages.length - kMaxChatFrameMessageSize); + this.presentMessages.splice(0, deleteMessageCount); + if(deleteMessageCount > 0) { + this.setHistory(true); + } + index -= deleteMessageCount; + + if(event.isOwnMessage) { + this.setUnreadTimestamp(Date.now()); + } else if(!this.isUnread() && triggerUnread) { + this.setUnreadTimestamp(event.message.timestamp - 1); + } else { + /* mark the last message as read */ + this.setUnreadTimestamp(event.message.timestamp); + } + + this.events.fire("notify_chat_event", { + triggerUnread: triggerUnread, + event: event + }); + } else { + this.presentEvents.push(event); + this.presentEvents.sort((a, b) => a.timestamp - b.timestamp); + /* TODO: Cutoff too old events! */ + + if(!this.isUnread() && triggerUnread) { + this.setUnreadTimestamp(event.timestamp - 1); + } else { + /* mark the last message as read */ + this.setUnreadTimestamp(event.timestamp); + } + + this.events.fire("notify_chat_event", { + triggerUnread: triggerUnread, + event: event + }); + } + } + + protected registerIncomingMessage(message: ChatMessage, isOwnMessage: boolean, uniqueId: string) { + this.registerChatEvent({ + type: "message", + isOwnMessage: isOwnMessage, + uniqueId: uniqueId, + timestamp: message.timestamp, + message: message + }, !isOwnMessage); + } + + protected doSendMessage(message: string, targetMode: number, target: number) : Promise { + let msg = preprocessChatMessageForSend(message); + return this.connection.serverConnection.send_command("sendtextmessage", { + targetmode: targetMode, + cid: target, + target: target, + msg: msg + }, { process_result: false }).then(async () => true).catch(error => { + if(error instanceof CommandResult) { + if(error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) { + this.registerChatEvent({ + type: "message-failed", + uniqueId: "msf-" + this.chatId + "-" + Date.now(), + timestamp: Date.now(), + error: "permission", + failedPermission: this.connection.permissions.resolveInfo(parseInt(error.json["failed_permid"]))?.name || tr("unknown") + }, false); + } else { + this.registerChatEvent({ + type: "message-failed", + uniqueId: "msf-" + this.chatId + "-" + Date.now(), + timestamp: Date.now(), + error: "error", + errorMessage: error.formattedMessage() + }, false); + } + } else if(typeof error === "string") { + this.registerChatEvent({ + type: "message-failed", + uniqueId: "msf-" + this.chatId + "-" + Date.now(), + timestamp: Date.now(), + error: "error", + errorMessage: error + }, false); + } else { + logWarn(LogCategory.CHAT, tr("Failed to send channel chat message to %s: %o"), this.chatId, error); + this.registerChatEvent({ + type: "message-failed", + uniqueId: "msf-" + this.chatId + "-" + Date.now(), + timestamp: Date.now(), + error: "error", + errorMessage: tr("lookup the console") + }, false); + } + return false; + }); + } + + public getChatId() : string { + return this.chatId; + } + + public isUnread() : boolean { + return this.unreadState; + } + + public isPrivate() : boolean { + return this.conversationPrivate; + } + + public isSendEnabled() : boolean { + return this.messageSendEnabled; + } + + public getUnreadTimestamp() : number | undefined { + return this.unreadTimestamp; + } + + public getPresentMessages() : ChatEvent[] { + return this.presentMessages; + } + + public getPresentEvents() : ChatEvent[] { + return this.presentEvents; + } + + public getErrorMessage() : string | undefined { + return this.errorMessage; + } + + public getFailedPermission() : string | undefined { + return this.failedPermission; + } + + public setUnreadTimestamp(timestamp: number) { + if(this.unreadTimestamp !== timestamp) { + this.unreadTimestamp = timestamp; + this.events.fire("notify_unread_timestamp_changed", { timestamp: timestamp }); + } + + /* do update the unread state anyways since setUnreadTimestamp will be called when new messages arrive */ + this.updateUnreadState(); + } + + protected updateUnreadState() { + const newState = this.unreadTimestamp < this.lastEvent()?.timestamp; + if(this.unreadState !== newState) { + this.unreadState = newState; + this.events.fire("notify_unread_state_changed", { unread: newState }); + } + } + + public hasHistory() : boolean { + return this.history; + } + + protected setHistory(hasHistory: boolean) { + if(this.history === hasHistory) { + return; + } + + this.history = hasHistory; + this.events.fire("notify_history_state_changed", { hasHistory: hasHistory }); + } + + protected lastEvent() : ChatEvent | undefined { + if(this.presentMessages.length === 0) { + return this.presentEvents.last(); + } else if(this.presentEvents.length === 0 || this.presentMessages.last().timestamp > this.presentEvents.last().timestamp) { + return this.presentMessages.last(); + } else { + return this.presentEvents.last(); + } + } + + protected sendMessageSendingEnabled(enabled: boolean) { + if(this.messageSendEnabled === enabled) { + return; + } + + this.messageSendEnabled = enabled; + this.events.fire("notify_send_toggle", { enabled: enabled }); + } + + public abstract canClientAccessChat() : boolean; + public abstract queryHistory(criteria: { begin?: number, end?: number, limit?: number }) : Promise; + public abstract queryCurrentMessages(); + public abstract sendMessage(text: string); +} + +export interface AbstractChatManagerEvents { + notify_selected_changed: { + oldConversation: ConversationType, + newConversation: ConversationType + }, + notify_conversation_destroyed: { + conversation: ConversationType + }, + notify_conversation_created: { + conversation: ConversationType + }, + notify_unread_count_changed: { + unreadConversations: number + } +} + +export abstract class AbstractChatManager, ConversationType extends AbstractChat, ConversationEvents extends AbstractChatEvents> { + readonly events: Registry; + readonly connection: ConnectionHandler; + protected readonly listenerConnection: (() => void)[]; + private readonly listenerUnreadTimestamp: () => void; + + private conversations: {[key: string]: ConversationType} = {}; + private selectedConversation: ConversationType; + + private currentUnreadCount: number; + + protected constructor(connection: ConnectionHandler) { + this.events = new Registry(); + this.listenerConnection = []; + this.currentUnreadCount = 0; + + this.listenerUnreadTimestamp = () => { + let count = this.getConversations().filter(conversation => conversation.isUnread()).length; + if(count === this.currentUnreadCount) { return; } + + this.currentUnreadCount = count; + this.events.fire("notify_unread_count_changed", { unreadConversations: count }); + } + } + + destroy() { + this.events.destroy(); + + this.listenerConnection.forEach(callback => callback()); + this.listenerConnection.splice(0, this.listenerConnection.length); + } + + getConversations() : ConversationType[] { + return Object.values(this.conversations); + } + + getUnreadCount() : number { + return this.currentUnreadCount; + } + + getSelectedConversation() : ConversationType { + return this.selectedConversation; + } + + setSelectedConversation(conversation: ConversationType | undefined) { + if(this.selectedConversation === conversation) { + return; + } + + const oldConversation = this.selectedConversation; + this.selectedConversation = conversation; + + this.events.fire("notify_selected_changed", { + oldConversation: oldConversation, + newConversation: conversation + }); + } + + findConversationById(id: string) : ConversationType { + return this.conversations[id]; + } + + protected registerConversation(conversation: ConversationType) { + conversation.events.on("notify_unread_state_changed", this.listenerUnreadTimestamp); + this.conversations[conversation.getChatId()] = conversation; + this.events.fire("notify_conversation_created", { conversation: conversation }); + this.listenerUnreadTimestamp(); + } + + protected unregisterConversation(conversation: ConversationType) { + conversation = this.conversations[conversation.getChatId()]; + if(!conversation) { return; } + + if(conversation === this.selectedConversation) { + this.setSelectedConversation(undefined); + } + + conversation.events.off("notify_unread_state_changed", this.listenerUnreadTimestamp); + delete this.conversations[conversation.getChatId()]; + this.events.fire("notify_conversation_destroyed", { conversation: conversation }); + + this.listenerUnreadTimestamp(); + } +} \ No newline at end of file diff --git a/shared/js/ui/frames/side/ConversationManager.ts b/shared/js/conversations/ChannelConversationManager.ts similarity index 56% rename from shared/js/ui/frames/side/ConversationManager.ts rename to shared/js/conversations/ChannelConversationManager.ts index 406d6820..604272ef 100644 --- a/shared/js/ui/frames/side/ConversationManager.ts +++ b/shared/js/conversations/ChannelConversationManager.ts @@ -1,44 +1,58 @@ -import * as React from "react"; -import {ConnectionHandler, ConnectionState} from "../../../ConnectionHandler"; -import {EventHandler, Registry} from "../../../events"; -import * as log from "../../../log"; -import {LogCategory} from "../../../log"; -import {CommandResult} from "../../../connection/ServerConnectionDeclaration"; -import {ServerCommand} from "../../../connection/ConnectionBase"; -import {Settings} from "../../../settings"; -import {traj, tr} from "../../../i18n/localize"; -import {createErrorModal} from "../../../ui/elements/Modal"; -import ReactDOM = require("react-dom"); import { - ChatMessage, ConversationHistoryResponse, - ConversationUIEvents -} from "../../../ui/frames/side/ConversationDefinitions"; -import {ConversationPanel} from "../../../ui/frames/side/ConversationUI"; -import {AbstractChat, AbstractChatManager, kMaxChatFrameMessageSize} from "./AbstractConversion"; -import {ErrorCode} from "../../../connection/ErrorCode"; + AbstractChat, + AbstractChatEvents, + AbstractChatManager, + AbstractChatManagerEvents, + kMaxChatFrameMessageSize +} from "./AbstractConversion"; +import {ChatMessage, ConversationHistoryResponse} from "tc-shared/ui/frames/side/ConversationDefinitions"; +import {Settings} from "tc-shared/settings"; +import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration"; +import {ErrorCode} from "tc-shared/connection/ErrorCode"; +import {LogCategory, logError, logWarn} from "tc-shared/log"; +import {createErrorModal} from "tc-shared/ui/elements/Modal"; +import {traj} from "tc-shared/i18n/localize"; +import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler"; import {LocalClientEntry} from "tc-shared/tree/Client"; +import {ServerCommand} from "tc-shared/connection/ConnectionBase"; + +export interface ChannelConversationEvents extends AbstractChatEvents { + notify_messages_deleted: { messages: string[] }, + notify_messages_loaded: {} +} const kSuccessQueryThrottle = 5 * 1000; const kErrorQueryThrottle = 30 * 1000; -export class Conversation extends AbstractChat { - private readonly handle: ConversationManager; +export class ChannelConversation extends AbstractChat { + private readonly handle: ChannelConversationManager; public readonly conversationId: number; private conversationVolatile: boolean = false; + private preventUnreadUpdate = false; private executingHistoryQueries = false; private pendingHistoryQueries: (() => Promise)[] = []; public historyQueryResponse: ChatMessage[] = []; - constructor(handle: ConversationManager, events: Registry, id: number) { - super(handle.connection, id.toString(), events); + constructor(handle: ChannelConversationManager, id: number) { + super(handle.connection, id.toString()); this.handle = handle; this.conversationId = id; - this.lastReadMessage = handle.connection.settings.server(Settings.FN_CHANNEL_CHAT_READ(id), Date.now()); + this.preventUnreadUpdate = true; + const dateNow = Date.now(); + const unreadTimestamp = handle.connection.settings.server(Settings.FN_CHANNEL_CHAT_READ(id), Date.now()); + this.setUnreadTimestamp(unreadTimestamp); + this.preventUnreadUpdate = false; + + this.events.on("notify_unread_state_changed", event => { + this.handle.connection.channelTree.findChannel(this.conversationId)?.setUnread(event.unread); + }); } - destroy() { } + destroy() { + super.destroy(); + } queryHistory(criteria: { begin?: number, end?: number, limit?: number }) : Promise { return new Promise(resolve => { @@ -108,7 +122,7 @@ export class Conversation extends AbstractChat { errorMessage = error.formattedMessage(); } } else { - log.error(LogCategory.CHAT, tr("Failed to fetch conversation history. %o"), error); + logError(LogCategory.CHAT, tr("Failed to fetch conversation history. %o"), error); errorMessage = tr("lookup the console"); } resolve({ @@ -125,56 +139,54 @@ export class Conversation extends AbstractChat { queryCurrentMessages() { this.presentMessages = []; - this.mode = "loading"; + this.setCurrentMode("loading"); - this.reportStateToUI(); this.queryHistory({ end: 1, limit: kMaxChatFrameMessageSize }).then(history => { this.conversationPrivate = false; this.conversationVolatile = false; this.failedPermission = undefined; this.errorMessage = undefined; - this.hasHistory = !!history.moreEvents; + this.setHistory(!!history.moreEvents); this.presentMessages = history.events?.map(e => Object.assign({ uniqueId: "m-" + this.conversationId + "-" + e.timestamp }, e)) || []; switch (history.status) { case "error": - this.mode = "normal"; - this.presentEvents.push({ + this.setCurrentMode("normal"); + this.registerChatEvent({ type: "query-failed", timestamp: Date.now(), uniqueId: "qf-" + this.conversationId + "-" + Date.now() + "-" + Math.random(), message: history.errorMessage - }); + }, false); break; case "no-permission": - this.mode = "no-permissions"; + this.setCurrentMode("no-permissions"); this.failedPermission = history.failedPermission; break; case "private": this.conversationPrivate = true; - this.mode = "normal"; + this.setCurrentMode("normal"); break; case "success": - this.mode = "normal"; + this.setCurrentMode("normal"); break; case "unsupported": this.crossChannelChatSupported = false; this.conversationPrivate = true; - this.mode = "normal"; + this.setCurrentMode("normal"); break; } - /* only update the UI if needed */ - if(this.handle.selectedConversation() === this.conversationId) - this.reportStateToUI(); + this.events.fire("notify_messages_loaded"); }); } - protected canClientAccessChat() { + /* TODO: Query this state and if changed notify state */ + public canClientAccessChat() { return this.conversationId === 0 || this.handle.connection.getClient().currentChannel()?.channelId === this.conversationId; } @@ -186,7 +198,7 @@ export class Conversation extends AbstractChat { try { const promise = this.pendingHistoryQueries.pop_front()(); promise - .catch(error => log.error(LogCategory.CLIENT, tr("Conversation history query task threw an error; this should never happen: %o"), error)) + .catch(error => logError(LogCategory.CLIENT, tr("Conversation history query task threw an error; this should never happen: %o"), error)) .then(() => { this.executingHistoryQueries = false; this.executeHistoryQuery(); }); } catch (e) { this.executingHistoryQueries = false; @@ -205,8 +217,12 @@ export class Conversation extends AbstractChat { return; } - if(timestamp > this.lastReadMessage) { - this.setUnreadTimestamp(this.lastReadMessage); + if(this.unreadTimestamp < timestamp) { + this.registerChatEvent({ + type: "unread-trigger", + timestamp: timestamp, + uniqueId: "unread-trigger-" + Date.now() + " - " + timestamp + }, true); } } @@ -217,29 +233,36 @@ export class Conversation extends AbstractChat { public handleDeleteMessages(criteria: { begin: number, end: number, cldbid: number, limit: number }) { let limit = { current: criteria.limit }; - this.presentMessages = this.presentMessages.filter(message => { - if(message.type !== "message") - return; + const deletedMessages = this.presentMessages.filter(message => { + if(message.type !== "message") { + return false; + } - if(message.message.sender_database_id !== criteria.cldbid) - return true; + if(message.message.sender_database_id !== criteria.cldbid) { + return false; + } - if(criteria.end != 0 && message.timestamp > criteria.end) - return true; + if(criteria.end != 0 && message.timestamp > criteria.end) { + return false; + } - if(criteria.begin != 0 && message.timestamp < criteria.begin) - return true; + if(criteria.begin != 0 && message.timestamp < criteria.begin) { + return false; + } - return --limit.current < 0; + /* if the limit is zero it means all messages */ + return --limit.current >= 0; }); - this.events.fire("notify_chat_message_delete", { chatId: this.conversationId.toString(), criteria: criteria }); + this.presentMessages = this.presentMessages.filter(message => deletedMessages.indexOf(message) === -1); + this.events.fire("notify_messages_deleted", { messages: deletedMessages.map(message => message.uniqueId) }); + this.updateUnreadState(); } public deleteMessage(messageUniqueId: string) { const message = this.presentMessages.find(e => e.uniqueId === messageUniqueId); if(!message) { - log.warn(LogCategory.CHAT, tr("Tried to delete an unknown message (id: %s)"), messageUniqueId); + logWarn(LogCategory.CHAT, tr("Tried to delete an unknown message (id: %s)"), messageUniqueId); return; } @@ -253,32 +276,34 @@ export class Conversation extends AbstractChat { limit: 1, cldbid: message.message.sender_database_id }, { process_result: false }).catch(error => { - log.error(LogCategory.CHAT, tr("Failed to delete conversation message for conversation %d: %o"), this.conversationId, error); - if(error instanceof CommandResult) + logError(LogCategory.CHAT, tr("Failed to delete conversation message for conversation %d: %o"), this.conversationId, error); + if(error instanceof CommandResult) { error = error.extra_message || error.message; + } createErrorModal(tr("Failed to delete message"), traj("Failed to delete conversation message{:br:}Error: {}", error)).open(); }); } - setUnreadTimestamp(timestamp: number | undefined) { + setUnreadTimestamp(timestamp: number) { super.setUnreadTimestamp(timestamp); - /* we've to update the last read timestamp regardless of if we're having actual unread stuff */ - this.handle.connection.settings.changeServer(Settings.FN_CHANNEL_CHAT_READ(this.conversationId), typeof timestamp === "number" ? timestamp : Date.now()); - this.handle.connection.channelTree.findChannel(this.conversationId)?.setUnread(timestamp !== undefined); + if(this.preventUnreadUpdate) { + return; + } + + this.handle.connection.settings.changeServer(Settings.FN_CHANNEL_CHAT_READ(this.conversationId), timestamp); } public localClientSwitchedChannel(type: "join" | "leave") { - this.presentEvents.push({ + this.registerChatEvent({ type: "local-user-switch", uniqueId: "us-" + this.conversationId + "-" + Date.now() + "-" + Math.random(), timestamp: Date.now(), mode: type - }); + }, false); - if(this.conversationId === this.handle.selectedConversation()) - this.reportStateToUI(); + /* TODO: Update can access state! */ } sendMessage(text: string) { @@ -286,138 +311,84 @@ export class Conversation extends AbstractChat { } } -export class ConversationManager extends AbstractChatManager { - readonly connection: ConnectionHandler; - readonly htmlTag: HTMLDivElement; +export interface ChannelConversationManagerEvents extends AbstractChatManagerEvents { } - private conversations: {[key: number]: Conversation} = {}; - private selectedConversation_: number; +export class ChannelConversationManager extends AbstractChatManager { + readonly connection: ConnectionHandler; constructor(connection: ConnectionHandler) { - super(); + super(connection); this.connection = connection; - this.htmlTag = document.createElement("div"); - this.htmlTag.style.display = "flex"; - this.htmlTag.style.flexDirection = "column"; - this.htmlTag.style.justifyContent = "stretch"; - this.htmlTag.style.height = "100%"; - - ReactDOM.render(React.createElement(ConversationPanel, { - events: this.uiEvents, - handlerId: this.connection.handlerId, - noFirstMessageOverlay: false, - messagesDeletable: true - }), this.htmlTag); - /* - spawnExternalModal("conversation", this.uiEvents, { - handlerId: this.connection.handlerId, - noFirstMessageOverlay: false, - messagesDeletable: true - }).open().then(() => { - console.error("Opened"); - }); - */ - - this.uiEvents.on("action_select_chat", event => this.selectedConversation_ = parseInt(event.chatId)); - this.uiEvents.on("notify_destroy", connection.events().on("notify_connection_state_changed", event => { - if(ConnectionState.socketConnected(event.old_state) !== ConnectionState.socketConnected(event.new_state)) { - this.conversations = {}; - this.setSelectedConversation(-1); - } - })); - this.uiEvents.on("notify_destroy", connection.events().on("notify_visibility_changed", event => { - if(!event.visible) - return; - - this.handlePanelShow(); - })); - - connection.events().one("notify_handler_initialized", () => this.uiEvents.on("notify_destroy", connection.channelTree.events.on("notify_client_moved", event => { + connection.events().one("notify_handler_initialized", () => this.listenerConnection.push(connection.channelTree.events.on("notify_client_moved", event => { if(event.client instanceof LocalClientEntry) { event.oldChannel && this.findOrCreateConversation(event.oldChannel.channelId).localClientSwitchedChannel("leave"); this.findOrCreateConversation(event.newChannel.channelId).localClientSwitchedChannel("join"); } }))); - this.uiEvents.register_handler(this, true); - this.uiEvents.on("notify_destroy", connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationhistory", this.handleConversationHistory.bind(this))); - this.uiEvents.on("notify_destroy", connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationindex", this.handleConversationIndex.bind(this))); - this.uiEvents.on("notify_destroy", connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationmessagedelete", this.handleConversationMessageDelete.bind(this))); + this.listenerConnection.push(connection.events().on("notify_connection_state_changed", event => { + if(ConnectionState.socketConnected(event.oldState) !== ConnectionState.socketConnected(event.newState)) { + this.setSelectedConversation(undefined); + this.getConversations().forEach(conversation => { + this.unregisterConversation(conversation); + conversation.destroy(); + }); + } + })); - this.uiEvents.on("notify_destroy", this.connection.channelTree.events.on("notify_channel_list_received", () => { + this.listenerConnection.push(connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationhistory", this.handleConversationHistory.bind(this))); + this.listenerConnection.push(connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationindex", this.handleConversationIndex.bind(this))); + this.listenerConnection.push(connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationmessagedelete", this.handleConversationMessageDelete.bind(this))); + + this.listenerConnection.push(this.connection.channelTree.events.on("notify_channel_list_received", () => { this.queryUnreadFlags(); })); - this.uiEvents.on("notify_destroy", this.connection.channelTree.events.on("notify_channel_updated", _event => { + this.listenerConnection.push(this.connection.channelTree.events.on("notify_channel_updated", () => { /* TODO private flag! */ })); } destroy() { - ReactDOM.unmountComponentAtNode(this.htmlTag); - this.htmlTag.remove(); - - this.uiEvents.unregister_handler(this); - this.uiEvents.fire("notify_destroy"); - this.uiEvents.destroy(); + super.destroy(); } - selectedConversation() { - return this.selectedConversation_; + findConversation(channelId: number) : ChannelConversation { + return this.findConversationById(channelId.toString()); } - setSelectedConversation(id: number) { - if(id >= 0) - this.findOrCreateConversation(id); - - this.uiEvents.fire("notify_selected_chat", { chatId: id.toString() }); - } - - @EventHandler("action_select_chat") - private handleActionSelectChat(event: ConversationUIEvents["action_select_chat"]) { - this.setSelectedConversation(parseInt(event.chatId)); - } - - findConversation(id: number) : Conversation { - for(const conversation of Object.values(this.conversations)) - if(conversation.conversationId === id) - return conversation; - return undefined; - } - - protected findChat(id: string): AbstractChat { - return this.findConversation(parseInt(id)); - } - - findOrCreateConversation(id: number) { - let conversation = this.findConversation(id); + findOrCreateConversation(channelId: number) { + let conversation = this.findConversation(channelId); if(!conversation) { - conversation = new Conversation(this, this.uiEvents, id); - this.conversations[id] = conversation; + conversation = new ChannelConversation(this, channelId); + this.registerConversation(conversation); } return conversation; } destroyConversation(id: number) { - delete this.conversations[id]; + const conversation = this.findConversation(id); + if(!conversation) { + return; + } - if(id === this.selectedConversation_) - this.uiEvents.fire("action_select_chat", { chatId: "unselected" }); + this.unregisterConversation(conversation); + conversation.destroy(); } queryUnreadFlags() { const commandData = this.connection.channelTree.channels.map(e => { return { cid: e.channelId, cpw: e.cached_password() }}); this.connection.serverConnection.send_command("conversationfetch", commandData).catch(error => { - log.warn(LogCategory.CHAT, tr("Failed to query conversation indexes: %o"), error); + logWarn(LogCategory.CHAT, tr("Failed to query conversation indexes: %o"), error); }); } private handleConversationHistory(command: ServerCommand) { const conversation = this.findConversation(parseInt(command.arguments[0]["cid"])); if(!conversation) { - log.warn(LogCategory.NETWORKING, tr("Received conversation history for an unknown conversation: %o"), command.arguments[0]["cid"]); + logWarn(LogCategory.NETWORKING, tr("Received conversation history for an unknown conversation: %o"), command.arguments[0]["cid"]); return; } @@ -444,8 +415,9 @@ export class ConversationManager extends AbstractChatManager("action_delete_message") - private handleMessageDelete(event: ConversationUIEvents["action_delete_message"]) { - const conversation = this.findConversation(parseInt(event.chatId)); - if(!conversation) { - log.error(LogCategory.CLIENT, tr("Tried to delete a chat message from an unknown conversation with id %s"), event.chatId); - return; - } - - conversation.deleteMessage(event.uniqueId); - } - - @EventHandler("query_selected_chat") - private handleQuerySelectedChat() { - this.uiEvents.fire_react("notify_selected_chat", { chatId: isNaN(this.selectedConversation_) ? "unselected" : this.selectedConversation_ + ""}) - } - - @EventHandler("notify_selected_chat") - private handleNotifySelectedChat(event: ConversationUIEvents["notify_selected_chat"]) { - this.selectedConversation_ = parseInt(event.chatId); - } - - @EventHandler("action_clear_unread_flag") - protected handleClearUnreadFlag1(event: ConversationUIEvents["action_clear_unread_flag"]) { - this.connection.channelTree.findChannel(parseInt(event.chatId))?.setUnread(false); - } } \ No newline at end of file diff --git a/shared/js/ui/frames/side/PrivateConversationHistory.ts b/shared/js/conversations/PrivateConversationHistory.ts similarity index 83% rename from shared/js/ui/frames/side/PrivateConversationHistory.ts rename to shared/js/conversations/PrivateConversationHistory.ts index 4054f148..9081abfb 100644 --- a/shared/js/ui/frames/side/PrivateConversationHistory.ts +++ b/shared/js/conversations/PrivateConversationHistory.ts @@ -1,9 +1,8 @@ import * as loader from "tc-loader"; import {Stage} from "tc-loader"; -import * as log from "../../../log"; -import {LogCategory} from "../../../log"; -import {ChatEvent} from "../../../ui/frames/side/ConversationDefinitions"; import { tr } from "tc-shared/i18n/localize"; +import {LogCategory, logDebug, logError, logInfo, logWarn} from "tc-shared/log"; +import {ChatEvent} from "tc-shared/ui/frames/side/ConversationDefinitions"; const clientUniqueId2StoreName = uniqueId => "conversation-" + uniqueId; @@ -65,17 +64,18 @@ function fireDatabaseStateChanged() { try { databaseStateChangedCallbacks.pop()(); } catch (error) { - log.error(LogCategory.CHAT, tr("Database ready callback throw an unexpected exception: %o"), error); + logError(LogCategory.CHAT, tr("Database ready callback throw an unexpected exception: %o"), error); } } } let cacheImportUniqueKeyId = 0; async function importChatsFromCacheStorage(database: IDBDatabase) { - if(!(await caches.has("chat_history"))) + if(!(await caches.has("chat_history"))) { return; + } - log.info(LogCategory.CHAT, tr("Importing old chats saved via cache storage. This may take some moments.")); + logInfo(LogCategory.CHAT, tr("Importing old chats saved via cache storage. This may take some moments.")); let chatEvents = {}; const cache = await caches.open("chat_history"); @@ -83,7 +83,7 @@ async function importChatsFromCacheStorage(database: IDBDatabase) { for(const chat of await cache.keys()) { try { if(!chat.url.startsWith("https://_local_cache/cache_request_")) { - log.warn(LogCategory.CHAT, tr("Skipping importing chat %s because URL does not match."), chat.url); + logWarn(LogCategory.CHAT, tr("Skipping importing chat %s because URL does not match."), chat.url); continue; } @@ -91,8 +91,9 @@ async function importChatsFromCacheStorage(database: IDBDatabase) { const events: ChatEvent[] = chatEvents[clientUniqueId] || (chatEvents[clientUniqueId] = []); const data = await (await cache.match(chat)).json(); - if(!Array.isArray(data)) + if(!Array.isArray(data)) { throw tr("array expected"); + } for(const event of data) { events.push({ @@ -110,18 +111,20 @@ async function importChatsFromCacheStorage(database: IDBDatabase) { }); } } catch (error) { - log.warn(LogCategory.CHAT, tr("Skipping importing chat %s because of an error: %o"), chat?.url, error); + logWarn(LogCategory.CHAT, tr("Skipping importing chat %s because of an error: %o"), chat?.url, error); } } const clientUniqueIds = Object.keys(chatEvents); - if(clientUniqueIds.length === 0) + if(clientUniqueIds.length === 0) { return; + } - log.info(LogCategory.CHAT, tr("Found %d old chats. Importing."), clientUniqueIds.length); + logInfo(LogCategory.CHAT, tr("Found %d old chats. Importing."), clientUniqueIds.length); await requestDatabaseUpdate(database => { - for(const uniqueId of clientUniqueIds) + for(const uniqueId of clientUniqueIds) { doInitializeUser(uniqueId, database); + } }); await requestDatabase(); @@ -134,7 +137,8 @@ async function importChatsFromCacheStorage(database: IDBDatabase) { }); await new Promise(resolve => store.transaction.oncomplete = resolve); } - log.info(LogCategory.CHAT, tr("All old chats have been imported. Deleting old data.")); + + logInfo(LogCategory.CHAT, tr("All old chats have been imported. Deleting old data.")); await caches.delete("chat_history"); } @@ -153,7 +157,7 @@ async function doOpenDatabase(forceUpgrade: boolean) { if(event.oldVersion === 0) { /* database newly created */ importChatsFromCacheStorage(openRequest.result).catch(error => { - log.warn(LogCategory.CHAT, tr("Failed to import old chats from cache storage: %o"), error); + logWarn(LogCategory.CHAT, tr("Failed to import old chats from cache storage: %o"), error); }); } upgradePerformed = true; @@ -161,7 +165,7 @@ async function doOpenDatabase(forceUpgrade: boolean) { try { databaseUpdateRequests.pop()(openRequest.result); } catch (error) { - log.error(LogCategory.CHAT, tr("Database update callback throw an unexpected exception: %o"), error); + logError(LogCategory.CHAT, tr("Database update callback throw an unexpected exception: %o"), error); } } }; @@ -181,14 +185,14 @@ async function doOpenDatabase(forceUpgrade: boolean) { localStorage.setItem("indexeddb-private-conversations-version", database.version.toString()); if(!upgradePerformed && forceUpgrade) { - log.warn(LogCategory.CHAT, tr("Opened private conversations database, with an update, but update didn't happened. Trying again.")); + logWarn(LogCategory.CHAT, tr("Opened private conversations database, with an update, but update didn't happened. Trying again.")); database.close(); await new Promise(resolve => database.onclose = resolve); continue; } database.onversionchange = () => { - log.debug(LogCategory.CHAT, tr("Received external database version change. Closing database.")); + logDebug(LogCategory.CHAT, tr("Received external database version change. Closing database.")); databaseMode = "closed"; executeClose(); }; @@ -233,10 +237,10 @@ loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { try { await doOpenDatabase(false); - log.debug(LogCategory.CHAT, tr("Successfully initialized private conversation history database")); + logDebug(LogCategory.CHAT, tr("Successfully initialized private conversation history database")); } catch (error) { - log.error(LogCategory.CHAT, tr("Failed to initialize private conversation history database: %o"), error); - log.error(LogCategory.CHAT, tr("Do not saving the private conversation chat.")); + logError(LogCategory.CHAT, tr("Failed to initialize private conversation history database: %o"), error); + logError(LogCategory.CHAT, tr("Do not saving the private conversation chat.")); } } }); diff --git a/shared/js/ui/frames/side/PrivateConversationManager.ts b/shared/js/conversations/PrivateConversationManager.ts similarity index 55% rename from shared/js/ui/frames/side/PrivateConversationManager.ts rename to shared/js/conversations/PrivateConversationManager.ts index e6f62714..7dce6e37 100644 --- a/shared/js/ui/frames/side/PrivateConversationManager.ts +++ b/shared/js/conversations/PrivateConversationManager.ts @@ -1,25 +1,15 @@ -import {ClientEntry} from "../../../tree/Client"; -import {ConnectionHandler, ConnectionState} from "../../../ConnectionHandler"; -import {EventHandler, Registry} from "../../../events"; import { - PrivateConversationInfo, - PrivateConversationUIEvents -} from "../../../ui/frames/side/PrivateConversationDefinitions"; -import * as ReactDOM from "react-dom"; -import * as React from "react"; -import {PrivateConversationsPanel} from "../../../ui/frames/side/PrivateConversationUI"; -import { - ChatEvent, - ChatMessage, - ConversationHistoryResponse, - ConversationUIEvents -} from "../../../ui/frames/side/ConversationDefinitions"; -import * as log from "../../../log"; -import {LogCategory} from "../../../log"; -import {queryConversationEvents, registerConversationEvent} from "../../../ui/frames/side/PrivateConversationHistory"; -import {AbstractChat, AbstractChatManager} from "../../../ui/frames/side/AbstractConversion"; + AbstractChat, + AbstractChatEvents, + AbstractChatManager, + AbstractChatManagerEvents +} from "tc-shared/conversations/AbstractConversion"; +import {ClientEntry} from "tc-shared/tree/Client"; +import {ChatEvent, ChatMessage, ConversationHistoryResponse} from "tc-shared/ui/frames/side/ConversationDefinitions"; import {ChannelTreeEvents} from "tc-shared/tree/ChannelTree"; -import { tr } from "tc-shared/i18n/localize"; +import {queryConversationEvents, registerConversationEvent} from "tc-shared/conversations/PrivateConversationHistory"; +import {LogCategory, logWarn} from "tc-shared/log"; +import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler"; export type OutOfViewClient = { nickname: string, @@ -29,16 +19,29 @@ export type OutOfViewClient = { let receivingEventUniqueIdIndex = 0; -export class PrivateConversation extends AbstractChat { +export interface PrivateConversationEvents extends AbstractChatEvents { + notify_partner_typing: {}, + notify_partner_changed: { + chatId: string, + clientId: number, + name: string + }, + notify_partner_name_changed: { + chatId: string, + name: string + } +} + +export class PrivateConversation extends AbstractChat { public readonly clientUniqueId: string; private activeClientListener: (() => void)[] | undefined = undefined; private activeClient: ClientEntry | OutOfViewClient | undefined = undefined; - private lastClientInfo: OutOfViewClient = undefined; + private lastClientInfo: OutOfViewClient; private conversationOpen: boolean = false; - constructor(manager: PrivateConversationManager, events: Registry, client: ClientEntry | OutOfViewClient) { - super(manager.connection, client instanceof ClientEntry ? client.clientUid() : client.uniqueId, events); + constructor(manager: PrivateConversationManager, client: ClientEntry | OutOfViewClient) { + super(manager.connection, client instanceof ClientEntry ? client.clientUid() : client.uniqueId); this.activeClient = client; if(client instanceof ClientEntry) { @@ -48,11 +51,10 @@ export class PrivateConversation extends AbstractChat this.unregisterClientEvents()); } destroy() { + super.destroy(); this.unregisterClientEvents(); } @@ -62,10 +64,15 @@ export class PrivateConversation extends AbstractChat succeeded && (this.conversationOpen = true)); - else if(this.activeClient !== undefined && this.activeClient.clientId > 0) + } else if(this.activeClient !== undefined && this.activeClient.clientId > 0) { this.doSendMessage(text, 1, this.activeClient.clientId).then(succeeded => succeeded && (this.conversationOpen = true)); - else { - this.presentEvents.push({ + } else { + this.registerChatEvent({ type: "message-failed", uniqueId: "msf-" + this.chatId + "-" + Date.now(), timestamp: Date.now(), error: "error", errorMessage: tr("target client is offline/invisible") - }); - this.events.fire_react("notify_chat_event", { - chatId: this.chatId, - triggerUnread: false, - event: this.presentEvents.last() - }); + }, false); } } sendChatClose() { - if(!this.conversationOpen) + if(!this.conversationOpen) { return; + } this.conversationOpen = false; if(this.lastClientInfo.clientId > 0 && this.connection.connected) { @@ -208,14 +201,16 @@ export class PrivateConversation extends AbstractChat { - if('client_nickname' in event.updated_properties) + if('client_nickname' in event.updated_properties) { this.updateClientInfo(); + } })); } private unregisterClientEvents() { - if(this.activeClientListener === undefined) + if(this.activeClientListener === undefined) { return; + } this.activeClientListener.forEach(e => e()); this.activeClientListener = undefined; @@ -253,18 +248,16 @@ export class PrivateConversation extends AbstractChat { this.presentEvents = result.events.filter(e => e.type !== "message") as any; this.presentMessages = result.events.filter(e => e.type === "message"); - this.hasHistory = result.hasMore; - this.mode = "normal"; + this.setHistory(!!result.hasMore); - this.reportStateToUI(); + this.setCurrentMode("normal"); }); } - protected registerChatEvent(event: ChatEvent, triggerUnread: boolean) { + public registerChatEvent(event: ChatEvent, triggerUnread: boolean) { super.registerChatEvent(event, triggerUnread); registerConversationEvent(this.clientUniqueId, event).catch(error => { - log.warn(LogCategory.CHAT, tr("Failed to register private conversation chat event for %s: %o"), this.clientUniqueId, error); + logWarn(LogCategory.CHAT, tr("Failed to register private conversation chat event for %s: %o"), this.clientUniqueId, error); }); } @@ -320,164 +311,65 @@ export class PrivateConversation extends AbstractChat { - public readonly htmlTag: HTMLDivElement; +export interface PrivateConversationManagerEvents extends AbstractChatManagerEvents { } + +export class PrivateConversationManager extends AbstractChatManager { public readonly connection: ConnectionHandler; - - private activeConversation: PrivateConversation | undefined = undefined; - private conversations: PrivateConversation[] = []; - private channelTreeInitialized = false; constructor(connection: ConnectionHandler) { - super(); + super(connection); this.connection = connection; - this.htmlTag = document.createElement("div"); - this.htmlTag.style.display = "flex"; - this.htmlTag.style.flexDirection = "row"; - this.htmlTag.style.justifyContent = "stretch"; - this.htmlTag.style.height = "100%"; - - this.uiEvents.register_handler(this, true); - this.uiEvents.enableDebug("private-conversations"); - - ReactDOM.render(React.createElement(PrivateConversationsPanel, { events: this.uiEvents, handler: this.connection }), this.htmlTag); - - this.uiEvents.on("notify_destroy", connection.events().on("notify_visibility_changed", event => { - if(!event.visible) - return; - - this.handlePanelShow(); - })); - - this.uiEvents.on("notify_destroy", connection.events().on("notify_connection_state_changed", event => { - if(ConnectionState.socketConnected(event.old_state) !== ConnectionState.socketConnected(event.new_state)) { - for(const chat of this.conversations) { - chat.handleLocalClientDisconnect(event.old_state === ConnectionState.CONNECTED); - } + this.listenerConnection.push(connection.events().on("notify_connection_state_changed", event => { + if(ConnectionState.socketConnected(event.oldState) !== ConnectionState.socketConnected(event.newState)) { + this.getConversations().forEach(conversation => { + conversation.handleLocalClientDisconnect(event.oldState === ConnectionState.CONNECTED); + }); this.channelTreeInitialized = false; } })); - this.uiEvents.on("notify_destroy", connection.channelTree.events.on("notify_client_enter_view", event => { + this.listenerConnection.push(connection.channelTree.events.on("notify_client_enter_view", event => { const conversation = this.findConversation(event.client); if(!conversation) return; conversation.handleClientEnteredView(event.client, this.channelTreeInitialized ? event.isServerJoin ? "server-join" : "appear" : "local-reconnect"); })); - this.uiEvents.on("notify_destroy", connection.channelTree.events.on("notify_channel_list_received", event => { + this.listenerConnection.push(connection.channelTree.events.on("notify_channel_list_received", _event => { this.channelTreeInitialized = true; })); } destroy() { - ReactDOM.unmountComponentAtNode(this.htmlTag); - this.htmlTag.remove(); + super.destroy(); - this.uiEvents.unregister_handler(this); - this.uiEvents.fire("notify_destroy"); - this.uiEvents.destroy(); + this.listenerConnection.forEach(callback => callback()); + this.listenerConnection.splice(0, this.listenerConnection.length); } findConversation(client: ClientEntry | string) { const uniqueId = client instanceof ClientEntry ? client.clientUid() : client; - return this.conversations.find(e => e.clientUniqueId === uniqueId); - } - - protected findChat(id: string): AbstractChat { - return this.findConversation(id); + return this.getConversations().find(e => e.clientUniqueId === uniqueId); } findOrCreateConversation(client: ClientEntry | OutOfViewClient) { let conversation = this.findConversation(client instanceof ClientEntry ? client : client.uniqueId); if(!conversation) { - this.conversations.push(conversation = new PrivateConversation(this, this.uiEvents, client)); - this.reportConversationList(); + conversation = new PrivateConversation(this, client); + this.registerConversation(conversation); } return conversation; } - setActiveConversation(conversation: PrivateConversation | undefined) { - if(conversation === this.activeConversation) - return; - - this.activeConversation = conversation; - /* fire this after all other events have been processed, maybe reportConversationList has been called before */ - this.uiEvents.fire_react("notify_selected_chat", { chatId: this.activeConversation ? this.activeConversation.clientUniqueId : "unselected" }); - } - - @EventHandler("action_select_chat") - private handleActionSelectChat(event: PrivateConversationUIEvents["action_select_chat"]) { - this.setActiveConversation(this.findConversation(event.chatId)); - } - - getActiveConversation() { - return this.activeConversation; - } - - getConversations() { - return this.conversations; - } - - focusInput() { - this.uiEvents.fire("action_focus_chat"); - } - closeConversation(...conversations: PrivateConversation[]) { for(const conversation of conversations) { conversation.sendChatClose(); - this.conversations.remove(conversation); + this.unregisterConversation(conversation); conversation.destroy(); - - if(this.activeConversation === conversation) - this.setActiveConversation(undefined); } - this.reportConversationList(); - } - - private reportConversationList() { - this.uiEvents.fire_react("notify_private_conversations", { - conversations: this.conversations.map(conversation => conversation.generateUIInfo()), - selected: this.activeConversation?.clientUniqueId || "unselected" - }); - } - - @EventHandler("query_private_conversations") - private handleQueryPrivateConversations() { - this.reportConversationList(); - } - - @EventHandler("action_close_chat") - private handleConversationClose(event: PrivateConversationUIEvents["action_close_chat"]) { - const conversation = this.findConversation(event.chatId); - if(!conversation) { - log.error(LogCategory.CLIENT, tr("Tried to close a not existing private conversation with id %s"), event.chatId); - return; - } - - this.closeConversation(conversation); - } - - @EventHandler("notify_partner_typing") - private handleNotifySelectChat(event: PrivateConversationUIEvents["notify_selected_chat"]) { - /* TODO, set active chat? */ - } - - @EventHandler("action_self_typing") - protected handleActionSelfTyping1(event: ConversationUIEvents["action_self_typing"]) { - if(!this.activeConversation) - return; - - const clientId = this.activeConversation.currentClientId(); - if(!clientId) - return; - - this.connection.serverConnection.send_command("clientchatcomposing", { clid: clientId }).catch(error => { - log.warn(LogCategory.CHAT, tr("Failed to send chat composing to server for chat %d: %o"), clientId, error); - }); } } \ No newline at end of file diff --git a/shared/js/file/LocalIcons.ts b/shared/js/file/LocalIcons.ts index 573d51d0..7157b4cf 100644 --- a/shared/js/file/LocalIcons.ts +++ b/shared/js/file/LocalIcons.ts @@ -99,7 +99,7 @@ class IconManager extends AbstractIconManager { return; } - if(event.new_state !== ConnectionState.CONNECTED) { + if(event.newState !== ConnectionState.CONNECTED) { return; } diff --git a/shared/js/permission/GroupManager.ts b/shared/js/permission/GroupManager.ts index 0f18858d..2a5ab246 100644 --- a/shared/js/permission/GroupManager.ts +++ b/shared/js/permission/GroupManager.ts @@ -164,7 +164,7 @@ export class GroupManager extends AbstractCommandHandler { this.connectionHandler = client; this.connectionStateListener = (event: ConnectionEvents["notify_connection_state_changed"]) => { - if(event.new_state === ConnectionState.DISCONNECTING || event.new_state === ConnectionState.UNCONNECTED || event.new_state === ConnectionState.CONNECTING) { + if(event.newState === ConnectionState.DISCONNECTING || event.newState === ConnectionState.UNCONNECTED || event.newState === ConnectionState.CONNECTING) { this.reset(); } }; diff --git a/shared/js/tree/Channel.ts b/shared/js/tree/Channel.ts index c87b7602..fc73209a 100644 --- a/shared/js/tree/Channel.ts +++ b/shared/js/tree/Channel.ts @@ -404,8 +404,9 @@ export class ChannelEntry extends ChannelTreeEntry { icon_class: "client-channel_switch", name: bold(tr("Join text channel")), callback: () => { - this.channelTree.client.side_bar.channel_conversations().setSelectedConversation(this.getChannelId()); - this.channelTree.client.side_bar.show_channel_conversations(); + const conversation = this.channelTree.client.getChannelConversations().findOrCreateConversation(this.getChannelId()); + this.channelTree.client.getChannelConversations().setSelectedConversation(conversation); + this.channelTree.client.side_bar.showChannelConversations(); }, visible: !settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT) }, { @@ -563,7 +564,6 @@ export class ChannelEntry extends ChannelTreeEntry { } /* devel-block-end */ - let info_update = false; for(let variable of variables) { let key = variable.key; let value = variable.value; @@ -571,7 +571,6 @@ export class ChannelEntry extends ChannelTreeEntry { if(key == "channel_name") { 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, true); @@ -599,13 +598,6 @@ export class ChannelEntry extends ChannelTreeEntry { 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(); - if(_client.currentChannel() === this) - this.channelTree.client.side_bar.info_frame().update_channel_talk(); - //TODO chat channel! - } } generate_bbcode() { diff --git a/shared/js/tree/ChannelTree.tsx b/shared/js/tree/ChannelTree.tsx index e0a87c94..bc83a999 100644 --- a/shared/js/tree/ChannelTree.tsx +++ b/shared/js/tree/ChannelTree.tsx @@ -166,22 +166,23 @@ export class ChannelTree { if(this.selectedEntry instanceof ClientEntry) { if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CLIENT)) { if(this.selectedEntry instanceof MusicClientEntry) { - this.client.side_bar.show_music_player(this.selectedEntry); + this.client.side_bar.showMusicPlayer(this.selectedEntry); } else { - this.client.side_bar.show_client_info(this.selectedEntry); + this.client.side_bar.showClientInfo(this.selectedEntry); } } } else if(this.selectedEntry instanceof ChannelEntry) { if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) { - this.client.side_bar.channel_conversations().setSelectedConversation(this.selectedEntry.channelId); - this.client.side_bar.show_channel_conversations(); + const conversation = this.client.getChannelConversations().findOrCreateConversation(this.selectedEntry.channelId); + this.client.getChannelConversations().setSelectedConversation(conversation); + this.client.side_bar.showChannelConversations(); } } else if(this.selectedEntry instanceof ServerEntry) { if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) { const sidebar = this.client.side_bar; - sidebar.channel_conversations().findOrCreateConversation(0); - sidebar.channel_conversations().setSelectedConversation(0); - sidebar.show_channel_conversations(); + const conversation = this.client.getChannelConversations().findOrCreateConversation(0); + this.client.getChannelConversations().setSelectedConversation(conversation); + sidebar.showChannelConversations(); } } } @@ -373,7 +374,6 @@ export class ChannelTree { if(oldChannel) { this.events.fire("notify_client_leave_view", { client: client, message: reason.message, reason: reason.reason, isServerLeave: reason.serverLeave, sourceChannel: oldChannel }); - this.client.side_bar.info_frame().update_channel_client_count(oldChannel); } else { logWarn(LogCategory.CHANNEL, tr("Deleting client %s from channel tree which hasn't a channel."), client.clientId()); } @@ -458,14 +458,6 @@ export class ChannelTree { client["_channel"] = targetChannel; targetChannel?.registerClient(client); - if(oldChannel) { - this.client.side_bar.info_frame().update_channel_client_count(oldChannel); - } - - if(targetChannel) { - this.client.side_bar.info_frame().update_channel_client_count(targetChannel); - } - this.events.fire("notify_client_moved", { oldChannel: oldChannel, newChannel: targetChannel, client: client }); } finally { flush_batched_updates(BatchUpdateType.CHANNEL_TREE); diff --git a/shared/js/tree/Client.ts b/shared/js/tree/Client.ts index 77fbc624..349e03a9 100644 --- a/shared/js/tree/Client.ts +++ b/shared/js/tree/Client.ts @@ -382,7 +382,7 @@ export class ClientEntry extends ChannelTreeEntry { type: contextmenu.MenuEntryType.ENTRY, name: this.properties.client_type_exact === ClientType.CLIENT_MUSIC ? tr("Show bot info") : tr("Show client info"), callback: () => { - this.channelTree.client.side_bar.show_client_info(this); + this.channelTree.client.side_bar.showClientInfo(this); }, icon_class: "client-about", visible: !settings.static_global(Settings.KEY_SWITCH_INSTANT_CLIENT) @@ -518,12 +518,13 @@ export class ClientEntry extends ChannelTreeEntry { } open_text_chat() { - const chat = this.channelTree.client.side_bar; - const conversation = chat.private_conversations().findOrCreateConversation(this); + const privateConversations = this.channelTree.client.getPrivateConversations(); + const sideBar = this.channelTree.client.side_bar; + const conversation = privateConversations.findOrCreateConversation(this); conversation.setActiveClientEntry(this); - chat.private_conversations().setActiveConversation(conversation); - chat.show_private_conversations(); - chat.private_conversations().focusInput(); + privateConversations.setSelectedConversation(conversation); + sideBar.showPrivateConversations(); + sideBar.private_conversations().focusInput(); } showContextMenu(x: number, y: number, on_close: () => void = undefined) { diff --git a/shared/js/tree/Server.ts b/shared/js/tree/Server.ts index 2186bef7..93bc62c4 100644 --- a/shared/js/tree/Server.ts +++ b/shared/js/tree/Server.ts @@ -192,8 +192,8 @@ export class ServerEntry extends ChannelTreeEntry { icon_class: "client-channel_switch", name: tr("Join server text channel"), callback: () => { - this.channelTree.client.side_bar.channel_conversations().setSelectedConversation(0); - this.channelTree.client.side_bar.show_channel_conversations(); + this.channelTree.client.getChannelConversations().setSelectedConversation(0); + this.channelTree.client.side_bar.showChannelConversations(); }, visible: !settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT) }, { diff --git a/shared/js/ui/frames/chat_frame.ts b/shared/js/ui/frames/chat_frame.ts index ed5e809c..055fc736 100644 --- a/shared/js/ui/frames/chat_frame.ts +++ b/shared/js/ui/frames/chat_frame.ts @@ -1,269 +1,10 @@ -/* the bar on the right with the chats (Channel & Client) */ -import {ClientEntry, MusicClientEntry} from "../../tree/Client"; +import {ClientEntry, LocalClientEntry, MusicClientEntry} from "../../tree/Client"; import {ConnectionHandler} from "../../ConnectionHandler"; -import {ChannelEntry} from "../../tree/Channel"; -import {ServerEntry} from "../../tree/Server"; -import {openMusicManage} from "../../ui/modal/ModalMusicManage"; -import {formatMessage} from "../../ui/frames/chat"; import {MusicInfo} from "../../ui/frames/side/music_info"; -import {ConversationManager} from "../../ui/frames/side/ConversationManager"; -import {PrivateConversationManager} from "../../ui/frames/side/PrivateConversationManager"; -import {generateIconJQueryTag, getIconManager} from "tc-shared/file/Icons"; -import { tr } from "tc-shared/i18n/localize"; +import {ChannelConversationController} from "./side/ChannelConversationController"; +import {PrivateConversationController} from "./side/PrivateConversationController"; import {ClientInfoController} from "tc-shared/ui/frames/side/ClientInfoController"; - -export enum InfoFrameMode { - NONE = "none", - CHANNEL_CHAT = "channel_chat", - PRIVATE_CHAT = "private_chat", - CLIENT_INFO = "client_info", - MUSIC_BOT = "music_bot" -} -export class InfoFrame { - private readonly handle: Frame; - private _html_tag: JQuery; - private _mode: InfoFrameMode; - - private _value_ping: JQuery; - private _ping_updater: number; - - private _channel_text: ChannelEntry; - private _channel_voice: ChannelEntry; - - private _button_conversation: HTMLElement; - - private _button_bot_manage: JQuery; - private _button_song_add: JQuery; - - constructor(handle: Frame) { - this.handle = handle; - this._build_html_tag(); - this.update_channel_talk(); - this.update_channel_text(); - this.set_mode(InfoFrameMode.CHANNEL_CHAT); - this._ping_updater = setInterval(() => this.update_ping(), 2000); - this.update_ping(); - } - - html_tag() : JQuery { return this._html_tag; } - destroy() { - clearInterval(this._ping_updater); - - this._html_tag && this._html_tag.remove(); - this._html_tag = undefined; - this._value_ping = undefined; - } - - private _build_html_tag() { - this._html_tag = $("#tmpl_frame_chat_info").renderTag(); - this._html_tag.find(".button-switch-chat-channel").on('click', () => this.handle.show_channel_conversations()); - this._value_ping = this._html_tag.find(".value-ping"); - this._html_tag.find(".chat-counter").on('click', event => this.handle.show_private_conversations()); - this._button_conversation = this._html_tag.find(".button.open-conversation").on('click', event => { - const selected_client = this.handle.getClientInfo().getClient(); - if(!selected_client) return; - - const conversation = selected_client ? this.handle.private_conversations().findOrCreateConversation(selected_client) : undefined; - if(!conversation) return; - - this.handle.private_conversations().setActiveConversation(conversation); - this.handle.show_private_conversations(); - })[0]; - - this._button_bot_manage = this._html_tag.find(".bot-manage").on('click', event => { - const bot = this.handle.music_info().current_bot(); - if(!bot) return; - - openMusicManage(this.handle.handle, bot); - }); - this._button_song_add = this._html_tag.find(".bot-add-song").on('click', event => { - this.handle.music_info().events.fire("action_song_add"); - }); - } - - update_ping() { - this._value_ping.removeClass("very-good good medium poor very-poor"); - const connection = this.handle.handle.serverConnection; - if(!this.handle.handle.connected || !connection) { - this._value_ping.text("Not connected"); - return; - } - - const ping = connection.ping(); - if(!ping || typeof(ping.native) !== "number") { - this._value_ping.text("Not available"); - return; - } - - let value; - if(typeof(ping.javascript) !== "undefined") { - value = ping.javascript; - this._value_ping.text(ping.javascript.toFixed(0) + "ms").attr('title', 'Native: ' + ping.native.toFixed(3) + "ms \nJavascript: " + ping.javascript.toFixed(3) + "ms"); - } else { - value = ping.native; - this._value_ping.text(ping.native.toFixed(0) + "ms").attr('title', "Ping: " + ping.native.toFixed(3) + "ms"); - } - - if(value <= 10) - this._value_ping.addClass("very-good"); - else if(value <= 30) - this._value_ping.addClass("good"); - else if(value <= 60) - this._value_ping.addClass("medium"); - else if(value <= 150) - this._value_ping.addClass("poor"); - else - this._value_ping.addClass("very-poor"); - } - - update_channel_talk() { - const client = this.handle.handle.getClient(); - const channel = client ? client.currentChannel() : undefined; - this._channel_voice = channel; - - const html_tag = this._html_tag.find(".value-voice-channel"); - const html_limit_tag = this._html_tag.find(".value-voice-limit"); - - html_limit_tag.text(""); - html_tag.children().remove(); - - if(channel) { - if(channel.properties.channel_icon_id != 0) { - const connection = channel.channelTree.client; - generateIconJQueryTag(getIconManager().resolveIcon(channel.properties.channel_icon_id, connection.getCurrentServerUniqueId(), connection.handlerId)).appendTo(html_tag); - } - $.spawn("div").text(channel.formattedChannelName()).appendTo(html_tag); - - this.update_channel_limit(channel, html_limit_tag); - } else { - $.spawn("div").text("Not connected").appendTo(html_tag); - } - } - - update_channel_text() { - const channel_tree = this.handle.handle.connected ? this.handle.handle.channelTree : undefined; - const current_channel_id = channel_tree ? this.handle.channel_conversations().selectedConversation() : 0; - const channel = channel_tree ? channel_tree.findChannel(current_channel_id) : undefined; - this._channel_text = channel; - - const tag_container = this._html_tag.find(".mode-channel_chat.channel"); - const html_tag_title = tag_container.find(".title"); - const html_tag = tag_container.find(".value-text-channel"); - const html_limit_tag = tag_container.find(".value-text-limit"); - - /* reset */ - html_tag_title.text(tr("You're chatting in Channel")); - html_limit_tag.text(""); - html_tag.children().detach(); - - /* initialize */ - if(channel) { - if(channel.properties.channel_icon_id != 0) { - const connection = channel.channelTree.client; - generateIconJQueryTag(getIconManager().resolveIcon(channel.properties.channel_icon_id, connection.getCurrentServerUniqueId(), connection.handlerId)).appendTo(html_tag); - } - $.spawn("div").text(channel.formattedChannelName()).appendTo(html_tag); - - this.update_channel_limit(channel, html_limit_tag); - } else if(channel_tree && current_channel_id > 0) { - html_tag.append(formatMessage(tr("Unknown channel id {}"), current_channel_id)); - } else if(channel_tree && current_channel_id == 0) { - const server = this.handle.handle.channelTree.server; - if(server.properties.virtualserver_icon_id != 0) { - const connection = server.channelTree.client; - generateIconJQueryTag(getIconManager().resolveIcon(server.properties.virtualserver_icon_id, connection.getCurrentServerUniqueId(), connection.handlerId)).appendTo(html_tag); - } - $.spawn("div").text(server.properties.virtualserver_name).appendTo(html_tag); - html_tag_title.text(tr("You're chatting in Server")); - - this.update_server_limit(server, html_limit_tag); - } else if(this.handle.handle.connected) { - $.spawn("div").text("No channel selected").appendTo(html_tag); - } else { - $.spawn("div").text("Not connected").appendTo(html_tag); - } - } - - update_channel_client_count(channel: ChannelEntry) { - if(channel === this._channel_text) { - this.update_channel_limit(channel, this._html_tag.find(".value-text-limit")); - } - - if(channel === this._channel_voice) { - this.update_channel_limit(channel, this._html_tag.find(".value-voice-limit")); - } - } - - private update_channel_limit(channel: ChannelEntry, tag: JQuery) { - let channel_limit = tr("Unlimited"); - if(!channel.properties.channel_flag_maxclients_unlimited) - channel_limit = "" + channel.properties.channel_maxclients; - else if(!channel.properties.channel_flag_maxfamilyclients_unlimited) { - if(channel.properties.channel_maxfamilyclients >= 0) - channel_limit = "" + channel.properties.channel_maxfamilyclients; - } - tag.text(channel.clients(false).length + " / " + channel_limit); - } - - private update_server_limit(server: ServerEntry, tag: JQuery) { - const fn = () => { - let text = server.properties.virtualserver_clientsonline + " / " + server.properties.virtualserver_maxclients; - if(server.properties.virtualserver_reserved_slots) - text += " (" + server.properties.virtualserver_reserved_slots + " " + tr("Reserved") + ")"; - tag.text(text); - }; - - server.updateProperties().then(fn).catch(error => tag.text(tr("Failed to update info"))); - fn(); - } - - update_chat_counter() { - const privateConversations = this.handle.private_conversations().getConversations(); - { - const count = privateConversations.filter(e => e.hasUnreadMessages()).length; - const count_container = this._html_tag.find(".container-indicator"); - const count_tag = count_container.find(".chat-unread-counter"); - count_container.toggle(count > 0); - count_tag.text(count); - } - { - const count_tag = this._html_tag.find(".chat-counter"); - if(privateConversations.length == 0) - count_tag.text(tr("No conversations")); - else if(privateConversations.length == 1) - count_tag.text(tr("One conversation")); - else - count_tag.text(privateConversations.length + " " + tr("conversations")); - } - } - - current_mode() : InfoFrameMode { - return this._mode; - } - - set_mode(mode: InfoFrameMode) { - for(const mode in InfoFrameMode) - this._html_tag.removeClass("mode-" + InfoFrameMode[mode]); - this._html_tag.addClass("mode-" + mode); - - if(mode === InfoFrameMode.CLIENT_INFO && this._button_conversation) { - //Will be called every time a client is shown - const selected_client = this.handle.getClientInfo().getClient(); - const conversation = selected_client ? this.handle.private_conversations().findConversation(selected_client) : undefined; - - const visibility = (selected_client && selected_client.clientId() !== this.handle.handle.getClientId()) ? "visible" : "hidden"; - if(this._button_conversation.style.visibility !== visibility) - this._button_conversation.style.visibility = visibility; - if(conversation) { - this._button_conversation.innerText = tr("Open conversation"); - } else { - this._button_conversation.innerText = tr("Start a conversation"); - } - } else if(mode === InfoFrameMode.MUSIC_BOT) { - //TODO? - } - } -} +import {SideHeader} from "tc-shared/ui/frames/side/HeaderController"; export enum FrameContent { NONE, @@ -275,44 +16,43 @@ export enum FrameContent { export class Frame { readonly handle: ConnectionHandler; - private infoFrame: InfoFrame; private htmlTag: JQuery; - private containerInfo: JQuery; private containerChannelChat: JQuery; private _content_type: FrameContent; + private header: SideHeader; private clientInfo: ClientInfoController; private musicInfo: MusicInfo; - private channelConversations: ConversationManager; - private privateConversations: PrivateConversationManager; + private channelConversations: ChannelConversationController; + private privateConversations: PrivateConversationController; constructor(handle: ConnectionHandler) { this.handle = handle; this._content_type = FrameContent.NONE; - this.infoFrame = new InfoFrame(this); - this.privateConversations = new PrivateConversationManager(handle); - this.channelConversations = new ConversationManager(handle); + this.privateConversations = new PrivateConversationController(handle); + this.channelConversations = new ChannelConversationController(handle); this.clientInfo = new ClientInfoController(handle); this.musicInfo = new MusicInfo(this); + this.header = new SideHeader(); - this._build_html_tag(); - this.show_channel_conversations(); - this.info_frame().update_chat_counter(); + this.handle.events().one("notify_handler_initialized", () => this.header.setConnectionHandler(handle)); + + this.createHtmlTag(); + this.showChannelConversations(); } html_tag() : JQuery { return this.htmlTag; } - info_frame() : InfoFrame { return this.infoFrame; } content_type() : FrameContent { return this._content_type; } destroy() { + this.header?.destroy(); + this.header = undefined; + this.htmlTag && this.htmlTag.remove(); this.htmlTag = undefined; - this.infoFrame && this.infoFrame.destroy(); - this.infoFrame = undefined; - this.clientInfo?.destroy(); this.clientInfo = undefined; @@ -325,30 +65,21 @@ export class Frame { this.channelConversations && this.channelConversations.destroy(); this.channelConversations = undefined; - this.containerInfo && this.containerInfo.remove(); - this.containerInfo = undefined; - this.containerChannelChat && this.containerChannelChat.remove(); this.containerChannelChat = undefined; } - private _build_html_tag() { + private createHtmlTag() { this.htmlTag = $("#tmpl_frame_chat").renderTag(); - this.containerInfo = this.htmlTag.find(".container-info"); + this.htmlTag.find(".container-info").replaceWith(this.header.getHtmlTag()); this.containerChannelChat = this.htmlTag.find(".container-chat"); - - this.infoFrame.html_tag().appendTo(this.containerInfo); } - private_conversations() : PrivateConversationManager { + private_conversations() : PrivateConversationController { return this.privateConversations; } - channel_conversations() : ConversationManager { - return this.channelConversations; - } - getClientInfo() : ClientInfoController { return this.clientInfo; } @@ -357,71 +88,73 @@ export class Frame { return this.musicInfo; } - private _clear() { + private clearSideBar() { this._content_type = FrameContent.NONE; this.containerChannelChat.children().detach(); } - show_private_conversations() { + showPrivateConversations() { if(this._content_type === FrameContent.PRIVATE_CHAT) return; - this._clear(); + this.header.setState({ state: "conversation", mode: "private" }); + + this.clearSideBar(); this._content_type = FrameContent.PRIVATE_CHAT; this.containerChannelChat.append(this.privateConversations.htmlTag); this.privateConversations.handlePanelShow(); - this.infoFrame.set_mode(InfoFrameMode.PRIVATE_CHAT); } - show_channel_conversations() { + showChannelConversations() { if(this._content_type === FrameContent.CHANNEL_CHAT) return; - this._clear(); + this.header.setState({ state: "conversation", mode: "channel" }); + + this.clearSideBar(); this._content_type = FrameContent.CHANNEL_CHAT; this.containerChannelChat.append(this.channelConversations.htmlTag); this.channelConversations.handlePanelShow(); - - this.infoFrame.set_mode(InfoFrameMode.CHANNEL_CHAT); } - show_client_info(client: ClientEntry) { + showClientInfo(client: ClientEntry) { this.clientInfo.setClient(client); - this.infoFrame.set_mode(InfoFrameMode.CLIENT_INFO); /* specially needs an update here to update the conversation button */ + this.header.setState({ state: "client", ownClient: client instanceof LocalClientEntry }); if(this._content_type === FrameContent.CLIENT_INFO) return; - this._clear(); + this.clearSideBar(); this._content_type = FrameContent.CLIENT_INFO; this.containerChannelChat.append(this.clientInfo.getHtmlTag()); } - show_music_player(client: MusicClientEntry) { + showMusicPlayer(client: MusicClientEntry) { this.musicInfo.set_current_bot(client); if(this._content_type === FrameContent.MUSIC_BOT) return; - this.infoFrame.set_mode(InfoFrameMode.MUSIC_BOT); + this.header.setState({ state: "music-bot" }); this.musicInfo.previous_frame_content = this._content_type; - this._clear(); + this.clearSideBar(); this._content_type = FrameContent.MUSIC_BOT; this.containerChannelChat.append(this.musicInfo.html_tag()); } set_content(type: FrameContent) { - if(this._content_type === type) + if(this._content_type === type) { return; + } - if(type === FrameContent.CHANNEL_CHAT) - this.show_channel_conversations(); - else if(type === FrameContent.PRIVATE_CHAT) - this.show_private_conversations(); - else { - this._clear(); + if(type === FrameContent.CHANNEL_CHAT) { + this.showChannelConversations(); + } else if(type === FrameContent.PRIVATE_CHAT) { + this.showPrivateConversations(); + } else { + this.header.setState({ state: "none" }); + this.clearSideBar(); this._content_type = FrameContent.NONE; - this.infoFrame.set_mode(InfoFrameMode.NONE); } } } \ No newline at end of file diff --git a/shared/js/ui/frames/control-bar/Controller.ts b/shared/js/ui/frames/control-bar/Controller.ts index bedc5c77..e34c3616 100644 --- a/shared/js/ui/frames/control-bar/Controller.ts +++ b/shared/js/ui/frames/control-bar/Controller.ts @@ -98,7 +98,7 @@ class InfoController { const events = this.handlerRegisteredEvents; events.push(handler.events().on("notify_connection_state_changed", event => { - if(event.old_state === ConnectionState.CONNECTED || event.new_state === ConnectionState.CONNECTED) { + if(event.oldState === ConnectionState.CONNECTED || event.newState === ConnectionState.CONNECTED) { this.sendHostButton(); this.sendVideoState("screen"); this.sendVideoState("camera"); diff --git a/shared/js/ui/frames/side/AbstractConversationController.ts b/shared/js/ui/frames/side/AbstractConversationController.ts new file mode 100644 index 00000000..5032befa --- /dev/null +++ b/shared/js/ui/frames/side/AbstractConversationController.ts @@ -0,0 +1,311 @@ +import { + ChatHistoryState, + ConversationUIEvents +} from "../../../ui/frames/side/ConversationDefinitions"; +import {EventHandler, Registry} from "../../../events"; +import * as log from "../../../log"; +import {LogCategory} from "../../../log"; +import {tra, tr} from "../../../i18n/localize"; +import { + AbstractChat, + AbstractChatEvents, + AbstractChatManager, + AbstractChatManagerEvents +} from "tc-shared/conversations/AbstractConversion"; + +export const kMaxChatFrameMessageSize = 50; /* max 100 messages, since the server does not support more than 100 messages queried at once */ + +export abstract class AbstractConversationController< + Events extends ConversationUIEvents, + Manager extends AbstractChatManager, + ManagerEvents extends AbstractChatManagerEvents, + ConversationType extends AbstractChat, + ConversationEvents extends AbstractChatEvents +> { + protected readonly uiEvents: Registry; + protected readonly conversationManager: Manager; + protected readonly listenerManager: (() => void)[]; + + private historyUiStates: {[id: string]: { + executingUIHistoryQuery: boolean, + historyErrorMessage: string | undefined, + historyRetryTimestamp: number + }} = {}; + + protected currentSelectedConversation: ConversationType; + protected currentSelectedListener: (() => void)[]; + + protected constructor(conversationManager: Manager) { + this.uiEvents = new Registry(); + this.currentSelectedListener = []; + this.conversationManager = conversationManager; + + this.listenerManager = []; + + this.listenerManager.push(this.conversationManager.events.on("notify_selected_changed", event => { + this.currentSelectedListener.forEach(callback => callback()); + this.currentSelectedListener = []; + + this.currentSelectedConversation = event.newConversation; + this.uiEvents.fire_react("notify_selected_chat", { chatId: event.newConversation ? event.newConversation.getChatId() : "unselected" }); + + const conversation = event.newConversation; + if(conversation) { + this.registerConversationEvents(conversation); + } + })); + + this.listenerManager.push(this.conversationManager.events.on("notify_conversation_destroyed", event => { + delete this.historyUiStates[event.conversation.getChatId()]; + })); + } + + destroy() { + this.listenerManager.forEach(callback => callback()); + this.listenerManager.splice(0, this.listenerManager.length); + + this.uiEvents.fire("notify_destroy"); + this.uiEvents.destroy(); + } + + protected registerConversationEvents(conversation: ConversationType) { + this.currentSelectedListener.push(conversation.events.on("notify_unread_timestamp_changed", event => + this.uiEvents.fire_react("notify_unread_timestamp_changed", { chatId: conversation.getChatId(), timestamp: event.timestamp }))); + + this.currentSelectedListener.push(conversation.events.on("notify_send_toggle", event => + this.uiEvents.fire_react("notify_send_enabled", { chatId: conversation.getChatId(), enabled: event.enabled }))); + + this.currentSelectedListener.push(conversation.events.on("notify_chat_event", event => { + this.uiEvents.fire_react("notify_chat_event", { chatId: conversation.getChatId(), event: event.event, triggerUnread: event.triggerUnread }); + })); + + this.currentSelectedListener.push(conversation.events.on("notify_state_changed", () => { + this.reportStateToUI(conversation); + })); + + this.currentSelectedListener.push(conversation.events.on("notify_history_state_changed", () => { + this.reportStateToUI(conversation); + })); + } + + handlePanelShow() { + this.uiEvents.fire_react("notify_panel_show"); + } + + protected reportStateToUI(conversation: AbstractChat) { + const crossChannelChatSupported = true; /* FIXME: Determine this form the server! */ + + let historyState: ChatHistoryState; + const localHistoryState = this.historyUiStates[conversation.getChatId()]; + if(!localHistoryState) { + historyState = conversation.hasHistory() ? "available" : "none"; + } else { + if(Date.now() < localHistoryState.historyRetryTimestamp && localHistoryState.historyErrorMessage) { + historyState = "error"; + } else if(localHistoryState.executingUIHistoryQuery) { + historyState = "loading"; + } else if(conversation.hasHistory()) { + historyState = "available"; + } else { + historyState = "none"; + } + } + + switch (conversation.getCurrentMode()) { + case "normal": + if(conversation.isPrivate() && !conversation.canClientAccessChat()) { + this.uiEvents.fire_react("notify_conversation_state", { + chatId: conversation.getChatId(), + state: "private", + crossChannelChatSupported: crossChannelChatSupported + }); + return; + } + + this.uiEvents.fire_react("notify_conversation_state", { + chatId: conversation.getChatId(), + state: "normal", + + historyState: historyState, + historyErrorMessage: localHistoryState?.historyErrorMessage, + historyRetryTimestamp: localHistoryState ? localHistoryState.historyRetryTimestamp : 0, + + chatFrameMaxMessageCount: kMaxChatFrameMessageSize, + unreadTimestamp: conversation.getUnreadTimestamp(), + + showUserSwitchEvents: conversation.isPrivate() || !crossChannelChatSupported, + sendEnabled: conversation.isSendEnabled(), + + events: [...conversation.getPresentEvents(), ...conversation.getPresentMessages()] + }); + break; + + case "loading": + case "unloaded": + this.uiEvents.fire_react("notify_conversation_state", { + chatId: conversation.getChatId(), + state: "loading" + }); + break; + + case "error": + this.uiEvents.fire_react("notify_conversation_state", { + chatId: conversation.getChatId(), + state: "error", + errorMessage: conversation.getErrorMessage() + }); + break; + + case "no-permissions": + this.uiEvents.fire_react("notify_conversation_state", { + chatId: conversation.getChatId(), + state: "no-permissions", + failedPermission: conversation.getFailedPermission() + }); + break; + + } + } + public uiQueryHistory(conversation: AbstractChat, timestamp: number, enforce?: boolean) { + const localHistoryState = this.historyUiStates[conversation.getChatId()] || (this.historyUiStates[conversation.getChatId()] = { + executingUIHistoryQuery: false, + historyErrorMessage: undefined, + historyRetryTimestamp: 0 + }); + + if(localHistoryState.executingUIHistoryQuery && !enforce) { + return; + } + + localHistoryState.executingUIHistoryQuery = true; + conversation.queryHistory({ end: 1, begin: timestamp, limit: kMaxChatFrameMessageSize }).then(result => { + localHistoryState.executingUIHistoryQuery = false; + localHistoryState.historyErrorMessage = undefined; + localHistoryState.historyRetryTimestamp = result.nextAllowedQuery; + + switch (result.status) { + case "success": + this.uiEvents.fire_react("notify_conversation_history", { + chatId: conversation.getChatId(), + state: "success", + + hasMoreMessages: result.moreEvents, + retryTimestamp: localHistoryState.historyRetryTimestamp, + + events: result.events + }); + break; + + case "private": + this.uiEvents.fire_react("notify_conversation_history", { + chatId: conversation.getChatId(), + state: "error", + errorMessage: localHistoryState.historyErrorMessage = tr("chat is private"), + retryTimestamp: localHistoryState.historyRetryTimestamp + }); + break; + + case "no-permission": + this.uiEvents.fire_react("notify_conversation_history", { + chatId: conversation.getChatId(), + state: "error", + errorMessage: localHistoryState.historyErrorMessage = tra("failed on {}", result.failedPermission || tr("unknown permission")), + retryTimestamp: localHistoryState.historyRetryTimestamp + }); + break; + + case "error": + this.uiEvents.fire_react("notify_conversation_history", { + chatId: conversation.getChatId(), + state: "error", + errorMessage: localHistoryState.historyErrorMessage = result.errorMessage, + retryTimestamp: localHistoryState.historyRetryTimestamp + }); + break; + } + }); + } + + protected getCurrentConversation() : ConversationType | undefined { + return this.currentSelectedConversation; + } + + @EventHandler("query_conversation_state") + protected handleQueryConversationState(event: ConversationUIEvents["query_conversation_state"]) { + const conversation = this.conversationManager.findConversationById(event.chatId); + if(!conversation) { + this.uiEvents.fire_react("notify_conversation_state", { + state: "error", + errorMessage: tr("Unknown conversation"), + + chatId: event.chatId + }); + return; + } + + if(conversation.getCurrentMode() === "unloaded") { + /* will switch the state to "loading" and already reports the state to the ui */ + conversation.queryCurrentMessages(); + } else { + this.reportStateToUI(conversation); + } + } + + @EventHandler("query_conversation_history") + protected handleQueryHistory(event: ConversationUIEvents["query_conversation_history"]) { + const conversation = this.conversationManager.findConversationById(event.chatId); + if(!conversation) { + this.uiEvents.fire_react("notify_conversation_history", { + state: "error", + errorMessage: tr("Unknown conversation"), + retryTimestamp: Date.now() + 10 * 1000, + + chatId: event.chatId + }); + + log.error(LogCategory.CLIENT, tr("Tried to query history for an unknown conversation with id %s"), event.chatId); + return; + } + + this.uiQueryHistory(conversation, event.timestamp); + } + + @EventHandler(["action_clear_unread_flag", "action_self_typing"]) + protected handleClearUnreadFlag(event: ConversationUIEvents["action_clear_unread_flag" | "action_self_typing"]) { + const conversation = this.conversationManager.findConversationById(event.chatId); + conversation?.setUnreadTimestamp(Date.now()); + } + + @EventHandler("action_send_message") + protected handleSendMessage(event: ConversationUIEvents["action_send_message"]) { + const conversation = this.conversationManager.findConversationById(event.chatId); + if(!conversation) { + log.error(LogCategory.CLIENT, tr("Tried to send a chat message to an unknown conversation with id %s"), event.chatId); + return; + } + + conversation.sendMessage(event.text); + } + + @EventHandler("action_jump_to_present") + protected handleJumpToPresent(event: ConversationUIEvents["action_jump_to_present"]) { + const conversation = this.conversationManager.findConversationById(event.chatId); + if(!conversation) { + log.error(LogCategory.CLIENT, tr("Tried to jump to present for an unknown conversation with id %s"), event.chatId); + return; + } + + this.reportStateToUI(conversation); + } + + @EventHandler("query_selected_chat") + private handleQuerySelectedChat() { + this.uiEvents.fire_react("notify_selected_chat", { chatId: this.currentSelectedConversation ? this.currentSelectedConversation.getChatId() : "unselected"}) + } + + @EventHandler("action_select_chat") + private handleActionSelectChat(event: ConversationUIEvents["action_select_chat"]) { + const conversation = this.conversationManager.findConversationById(event.chatId); + this.conversationManager.setSelectedConversation(conversation); + } +} \ No newline at end of file diff --git a/shared/js/ui/frames/side/ConversationUI.scss b/shared/js/ui/frames/side/AbstractConversationRenderer.scss similarity index 100% rename from shared/js/ui/frames/side/ConversationUI.scss rename to shared/js/ui/frames/side/AbstractConversationRenderer.scss diff --git a/shared/js/ui/frames/side/ConversationUI.tsx b/shared/js/ui/frames/side/AbstractConversationRenderer.tsx similarity index 96% rename from shared/js/ui/frames/side/ConversationUI.tsx rename to shared/js/ui/frames/side/AbstractConversationRenderer.tsx index 24854f91..4c2f9f5d 100644 --- a/shared/js/ui/frames/side/ConversationUI.tsx +++ b/shared/js/ui/frames/side/AbstractConversationRenderer.tsx @@ -1,6 +1,5 @@ import * as React from "react"; import {EventHandler, ReactEventHandler, Registry} from "tc-shared/events"; -import {ChatBox} from "tc-shared/ui/frames/side/ChatBox"; import {Ref, useEffect, useRef, useState} from "react"; import {AvatarRenderer} from "tc-shared/ui/react-elements/Avatar"; import {Translatable} from "tc-shared/ui/react-elements/i18n"; @@ -23,8 +22,9 @@ import {BBCodeRenderer} from "tc-shared/text/bbcode"; import {getGlobalAvatarManagerFactory} from "tc-shared/file/Avatars"; import {ColloquialFormat, date_format, format_date_general, formatDayTime} from "tc-shared/utils/DateUtils"; import {ClientTag} from "tc-shared/ui/tree/EntryTags"; +import {ChatBox} from "tc-shared/ui/react-elements/ChatBox"; -const cssStyle = require("./ConversationUI.scss"); +const cssStyle = require("./AbstractConversationRenderer.scss"); const ChatMessageTextRenderer = React.memo((props: { text: string }) => { if(typeof props.text !== "string") { debugger; } @@ -664,8 +664,9 @@ class ConversationMessages extends React.PureComponent("notify_selected_chat") private handleNotifySelectedChat(event: ConversationUIEvents["notify_selected_chat"]) { - if(this.currentChatId === event.chatId) + if(this.currentChatId === event.chatId) { return; + } this.currentChatId = event.chatId; this.chatEvents = []; @@ -758,40 +759,33 @@ class ConversationMessages extends React.PureComponent("notify_chat_message_delete") private handleMessageDeleted(event: ConversationUIEvents["notify_chat_message_delete"]) { - if(event.chatId !== this.currentChatId) + if(event.chatId !== this.currentChatId) { return; + } - let limit = { current: event.criteria.limit }; - this.chatEvents = this.chatEvents.filter(mEvent => { - if(mEvent.type !== "message") - return; - - const message = mEvent.message; - if(message.sender_database_id !== event.criteria.cldbid) - return true; - - if(event.criteria.end != 0 && message.timestamp > event.criteria.end) - return true; - - if(event.criteria.begin != 0 && message.timestamp < event.criteria.begin) - return true; - - return --limit.current < 0; - }); + this.chatEvents = this.chatEvents.filter(mEvent => event.messageIds.indexOf(mEvent.uniqueId) === -1); this.buildView(); this.forceUpdate(() => this.scrollToBottom()); } - @EventHandler("action_clear_unread_flag") - private handleMessageUnread(event: ConversationUIEvents["action_clear_unread_flag"]) { - if (event.chatId !== this.currentChatId || this.unreadTimestamp === undefined) + @EventHandler("notify_unread_timestamp_changed") + private handleUnreadTimestampChanged(event: ConversationUIEvents["notify_unread_timestamp_changed"]) { + if (event.chatId !== this.currentChatId) return; - this.unreadTimestamp = undefined; - this.buildView(); - this.forceUpdate(); - }; + const oldUnreadTimestamp = this.unreadTimestamp; + if(this.chatEvents.last()?.timestamp > event.timestamp) { + this.unreadTimestamp = event.timestamp; + } else { + this.unreadTimestamp = undefined; + } + + if(oldUnreadTimestamp !== this.unreadTimestamp) { + this.buildView(); + this.forceUpdate(); + } + } @EventHandler("notify_panel_show") private handlePanelShow() { diff --git a/shared/js/ui/frames/side/AbstractConversion.ts b/shared/js/ui/frames/side/AbstractConversion.ts deleted file mode 100644 index 8ce0280b..00000000 --- a/shared/js/ui/frames/side/AbstractConversion.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { - ChatEvent, - ChatEventMessage, ChatHistoryState, ChatMessage, - ChatState, ConversationHistoryResponse, - ConversationUIEvents -} from "../../../ui/frames/side/ConversationDefinitions"; -import {ConnectionHandler} from "../../../ConnectionHandler"; -import {EventHandler, Registry} from "../../../events"; -import {preprocessChatMessageForSend} from "../../../text/chat"; -import {CommandResult} from "../../../connection/ServerConnectionDeclaration"; -import * as log from "../../../log"; -import {LogCategory} from "../../../log"; -import {tra, tr} from "../../../i18n/localize"; -import {ErrorCode} from "../../../connection/ErrorCode"; - -export const kMaxChatFrameMessageSize = 50; /* max 100 messages, since the server does not support more than 100 messages queried at once */ - -export abstract class AbstractChat { - protected readonly connection: ConnectionHandler; - protected readonly chatId: string; - protected readonly events: Registry; - protected presentMessages: ChatEvent[] = []; - protected presentEvents: Exclude[] = []; /* everything excluding chat messages */ - - protected mode: ChatState = "unloaded"; - protected failedPermission: string; - protected errorMessage: string; - - protected conversationPrivate: boolean = false; - protected crossChannelChatSupported: boolean = true; - - protected unreadTimestamp: number | undefined = undefined; - protected lastReadMessage: number = 0; - - protected historyErrorMessage: string; - protected historyRetryTimestamp: number = 0; - protected executingUIHistoryQuery = false; - - protected messageSendEnabled: boolean = true; - - protected hasHistory = false; - - protected constructor(connection: ConnectionHandler, chatId: string, events: Registry) { - this.connection = connection; - this.events = events; - this.chatId = chatId; - } - - public currentMode() : ChatState { return this.mode; }; - - protected registerChatEvent(event: ChatEvent, triggerUnread: boolean) { - if(event.type === "message") { - let index = 0; - while(index < this.presentMessages.length && this.presentMessages[index].timestamp <= event.timestamp) - index++; - - this.presentMessages.splice(index, 0, event); - - const deleteMessageCount = Math.max(0, this.presentMessages.length - kMaxChatFrameMessageSize); - this.presentMessages.splice(0, deleteMessageCount); - if(deleteMessageCount > 0) - this.hasHistory = true; - index -= deleteMessageCount; - - if(event.isOwnMessage) { - this.setUnreadTimestamp(undefined); - } else if(!this.unreadTimestamp) { - this.setUnreadTimestamp(event.message.timestamp); - } - - /* let all other events run before */ - this.events.fire_react("notify_chat_event", { - chatId: this.chatId, - triggerUnread: triggerUnread, - event: event - }); - } else { - this.presentEvents.push(event); - this.presentEvents.sort((a, b) => a.timestamp - b.timestamp); - /* TODO: Cutoff too old events! */ - - this.events.fire("notify_chat_event", { - chatId: this.chatId, - triggerUnread: triggerUnread, - event: event - }); - } - } - - protected registerIncomingMessage(message: ChatMessage, isOwnMessage: boolean, uniqueId: string) { - this.registerChatEvent({ - type: "message", - isOwnMessage: isOwnMessage, - uniqueId: uniqueId, - timestamp: message.timestamp, - message: message - }, !isOwnMessage); - } - - public reportStateToUI() { - let historyState: ChatHistoryState; - if(Date.now() < this.historyRetryTimestamp && this.historyErrorMessage) { - historyState = "error"; - } else if(this.executingUIHistoryQuery) { - historyState = "loading"; - } else if(this.hasHistory) { - historyState = "available"; - } else { - historyState = "none"; - } - - switch (this.mode) { - case "normal": - if(this.conversationPrivate && !this.canClientAccessChat()) { - this.events.fire_react("notify_conversation_state", { - chatId: this.chatId, - state: "private", - crossChannelChatSupported: this.crossChannelChatSupported - }); - return; - } - - this.events.fire_react("notify_conversation_state", { - chatId: this.chatId, - state: "normal", - - historyState: historyState, - historyErrorMessage: this.historyErrorMessage, - historyRetryTimestamp: this.historyRetryTimestamp, - - chatFrameMaxMessageCount: kMaxChatFrameMessageSize, - unreadTimestamp: this.unreadTimestamp, - - showUserSwitchEvents: this.conversationPrivate || !this.crossChannelChatSupported, - sendEnabled: this.messageSendEnabled, - - events: [...this.presentEvents, ...this.presentMessages] - }); - break; - - case "loading": - case "unloaded": - this.events.fire_react("notify_conversation_state", { - chatId: this.chatId, - state: "loading" - }); - break; - - case "error": - this.events.fire_react("notify_conversation_state", { - chatId: this.chatId, - state: "error", - errorMessage: this.errorMessage - }); - break; - - case "no-permissions": - this.events.fire_react("notify_conversation_state", { - chatId: this.chatId, - state: "no-permissions", - failedPermission: this.failedPermission - }); - break; - - } - } - - protected doSendMessage(message: string, targetMode: number, target: number) : Promise { - let msg = preprocessChatMessageForSend(message); - return this.connection.serverConnection.send_command("sendtextmessage", { - targetmode: targetMode, - cid: target, - target: target, - msg: msg - }, { process_result: false }).then(async () => true).catch(error => { - if(error instanceof CommandResult) { - if(error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) { - this.registerChatEvent({ - type: "message-failed", - uniqueId: "msf-" + this.chatId + "-" + Date.now(), - timestamp: Date.now(), - error: "permission", - failedPermission: this.connection.permissions.resolveInfo(parseInt(error.json["failed_permid"]))?.name || tr("unknown") - }, false); - } else { - this.registerChatEvent({ - type: "message-failed", - uniqueId: "msf-" + this.chatId + "-" + Date.now(), - timestamp: Date.now(), - error: "error", - errorMessage: error.formattedMessage() - }, false); - } - } else if(typeof error === "string") { - this.registerChatEvent({ - type: "message-failed", - uniqueId: "msf-" + this.chatId + "-" + Date.now(), - timestamp: Date.now(), - error: "error", - errorMessage: error - }, false); - } else { - log.warn(LogCategory.CHAT, tr("Failed to send channel chat message to %s: %o"), this.chatId, error); - this.registerChatEvent({ - type: "message-failed", - uniqueId: "msf-" + this.chatId + "-" + Date.now(), - timestamp: Date.now(), - error: "error", - errorMessage: tr("lookup the console") - }, false); - } - return false; - }); - } - - public isUnread() { - return this.unreadTimestamp !== undefined; - } - - public setUnreadTimestamp(timestamp: number | undefined) { - if(timestamp === undefined) - this.lastReadMessage = Date.now(); - - if(this.unreadTimestamp === timestamp) - return; - - this.unreadTimestamp = timestamp; - this.events.fire_react("notify_unread_timestamp_changed", { chatId: this.chatId, timestamp: timestamp }); - } - - public jumpToPresent() { - this.reportStateToUI(); - } - - public uiQueryHistory(timestamp: number, enforce?: boolean) { - if(this.executingUIHistoryQuery && !enforce) - return; - - this.executingUIHistoryQuery = true; - this.queryHistory({ end: 1, begin: timestamp, limit: kMaxChatFrameMessageSize }).then(result => { - this.executingUIHistoryQuery = false; - this.historyErrorMessage = undefined; - this.historyRetryTimestamp = result.nextAllowedQuery; - - switch (result.status) { - case "success": - this.events.fire_react("notify_conversation_history", { - chatId: this.chatId, - state: "success", - - hasMoreMessages: result.moreEvents, - retryTimestamp: this.historyRetryTimestamp, - - events: result.events - }); - break; - - case "private": - this.events.fire_react("notify_conversation_history", { - chatId: this.chatId, - state: "error", - errorMessage: this.historyErrorMessage = tr("chat is private"), - retryTimestamp: this.historyRetryTimestamp - }); - break; - - case "no-permission": - this.events.fire_react("notify_conversation_history", { - chatId: this.chatId, - state: "error", - errorMessage: this.historyErrorMessage = tra("failed on {}", result.failedPermission || tr("unknown permission")), - retryTimestamp: this.historyRetryTimestamp - }); - break; - - case "error": - this.events.fire_react("notify_conversation_history", { - chatId: this.chatId, - state: "error", - errorMessage: this.historyErrorMessage = result.errorMessage, - retryTimestamp: this.historyRetryTimestamp - }); - break; - } - }); - } - - protected lastEvent() : ChatEvent | undefined { - if(this.presentMessages.length === 0) - return this.presentEvents.last(); - else if(this.presentEvents.length === 0 || this.presentMessages.last().timestamp > this.presentEvents.last().timestamp) - return this.presentMessages.last(); - else - return this.presentEvents.last(); - } - - protected sendMessageSendingEnabled(enabled: boolean) { - if(this.messageSendEnabled === enabled) - return; - - this.messageSendEnabled = enabled; - this.events.fire("notify_send_enabled", { chatId: this.chatId, enabled: enabled }); - } - - protected abstract canClientAccessChat() : boolean; - public abstract queryHistory(criteria: { begin?: number, end?: number, limit?: number }) : Promise; - public abstract queryCurrentMessages(); - public abstract sendMessage(text: string); -} - -export abstract class AbstractChatManager { - protected readonly uiEvents: Registry; - - protected constructor() { - this.uiEvents = new Registry(); - } - - handlePanelShow() { - this.uiEvents.fire("notify_panel_show"); - } - - protected abstract findChat(id: string) : AbstractChat; - - @EventHandler("query_conversation_state") - protected handleQueryConversationState(event: ConversationUIEvents["query_conversation_state"]) { - const conversation = this.findChat(event.chatId); - if(!conversation) { - this.uiEvents.fire_react("notify_conversation_state", { - state: "error", - errorMessage: tr("Unknown conversation"), - - chatId: event.chatId - }); - return; - } - - if(conversation.currentMode() === "unloaded") { - conversation.queryCurrentMessages(); - } else { - conversation.reportStateToUI(); - } - } - - @EventHandler("query_conversation_history") - protected handleQueryHistory(event: ConversationUIEvents["query_conversation_history"]) { - const conversation = this.findChat(event.chatId); - if(!conversation) { - this.uiEvents.fire_react("notify_conversation_history", { - state: "error", - errorMessage: tr("Unknown conversation"), - retryTimestamp: Date.now() + 10 * 1000, - - chatId: event.chatId - }); - - log.error(LogCategory.CLIENT, tr("Tried to query history for an unknown conversation with id %s"), event.chatId); - return; - } - - conversation.uiQueryHistory(event.timestamp); - } - - @EventHandler("action_clear_unread_flag") - protected handleClearUnreadFlag(event: ConversationUIEvents["action_clear_unread_flag"]) { - this.findChat(event.chatId)?.setUnreadTimestamp(undefined); - } - - @EventHandler("action_self_typing") - protected handleActionSelfTyping(event: ConversationUIEvents["action_self_typing"]) { - if(this.findChat(event.chatId)?.isUnread()) - this.uiEvents.fire("action_clear_unread_flag", { chatId: event.chatId }); - } - - @EventHandler("action_send_message") - protected handleSendMessage(event: ConversationUIEvents["action_send_message"]) { - const conversation = this.findChat(event.chatId); - if(!conversation) { - log.error(LogCategory.CLIENT, tr("Tried to send a chat message to an unknown conversation with id %s"), event.chatId); - return; - } - - conversation.sendMessage(event.text); - } - - @EventHandler("action_jump_to_present") - protected handleJumpToPresent(event: ConversationUIEvents["action_jump_to_present"]) { - const conversation = this.findChat(event.chatId); - if(!conversation) { - log.error(LogCategory.CLIENT, tr("Tried to jump to present for an unknown conversation with id %s"), event.chatId); - return; - } - - conversation.jumpToPresent(); - } -} \ No newline at end of file diff --git a/shared/js/ui/frames/side/ChannelConversationController.ts b/shared/js/ui/frames/side/ChannelConversationController.ts new file mode 100644 index 00000000..10d02062 --- /dev/null +++ b/shared/js/ui/frames/side/ChannelConversationController.ts @@ -0,0 +1,91 @@ +import * as React from "react"; +import {ConnectionHandler} from "../../../ConnectionHandler"; +import {EventHandler} from "../../../events"; +import * as log from "../../../log"; +import {LogCategory} from "../../../log"; +import {tr} from "../../../i18n/localize"; +import ReactDOM = require("react-dom"); +import { + ConversationUIEvents +} from "../../../ui/frames/side/ConversationDefinitions"; +import {ConversationPanel} from "./AbstractConversationRenderer"; +import {AbstractConversationController} from "./AbstractConversationController"; +import { + ChannelConversation, ChannelConversationEvents, + ChannelConversationManager, + ChannelConversationManagerEvents +} from "tc-shared/conversations/ChannelConversationManager"; + +export class ChannelConversationController extends AbstractConversationController< + ConversationUIEvents, + ChannelConversationManager, + ChannelConversationManagerEvents, + ChannelConversation, + ChannelConversationEvents +> { + readonly connection: ConnectionHandler; + readonly htmlTag: HTMLDivElement; + + constructor(connection: ConnectionHandler) { + super(connection.getChannelConversations() as any); + this.connection = connection; + + this.htmlTag = document.createElement("div"); + this.htmlTag.style.display = "flex"; + this.htmlTag.style.flexDirection = "column"; + this.htmlTag.style.justifyContent = "stretch"; + this.htmlTag.style.height = "100%"; + + ReactDOM.render(React.createElement(ConversationPanel, { + events: this.uiEvents, + handlerId: this.connection.handlerId, + noFirstMessageOverlay: false, + messagesDeletable: true + }), this.htmlTag); + /* + spawnExternalModal("conversation", this.uiEvents, { + handlerId: this.connection.handlerId, + noFirstMessageOverlay: false, + messagesDeletable: true + }).open().then(() => { + console.error("Opened"); + }); + */ + + this.uiEvents.on("notify_destroy", connection.events().on("notify_visibility_changed", event => { + if(!event.visible) { + return; + } + + this.handlePanelShow(); + })); + + this.uiEvents.register_handler(this, true); + } + + destroy() { + ReactDOM.unmountComponentAtNode(this.htmlTag); + this.htmlTag.remove(); + + this.uiEvents.unregister_handler(this); + super.destroy(); + } + + @EventHandler("action_delete_message") + private handleMessageDelete(event: ConversationUIEvents["action_delete_message"]) { + const conversation = this.conversationManager.findConversationById(event.chatId); + if(!conversation) { + log.error(LogCategory.CLIENT, tr("Tried to delete a chat message from an unknown conversation with id %s"), event.chatId); + return; + } + + conversation.deleteMessage(event.uniqueId); + } + + protected registerConversationEvents(conversation: ChannelConversation) { + super.registerConversationEvents(conversation); + this.currentSelectedListener.push(conversation.events.on("notify_messages_deleted", event => { + this.uiEvents.fire_react("notify_chat_message_delete", { messageIds: event.messages, chatId: conversation.getChatId() }); + })); + } +} \ No newline at end of file diff --git a/shared/js/ui/frames/side/ClientInfoController.ts b/shared/js/ui/frames/side/ClientInfoController.ts index 6bf510bb..8251ce79 100644 --- a/shared/js/ui/frames/side/ClientInfoController.ts +++ b/shared/js/ui/frames/side/ClientInfoController.ts @@ -95,7 +95,7 @@ export class ClientInfoController { })); this.listenerConnection.push(this.connection.events().on("notify_connection_state_changed", event => { - if(event.new_state !== ConnectionState.CONNECTED && this.currentClientStatus) { + if(event.newState !== ConnectionState.CONNECTED && this.currentClientStatus) { this.currentClient = undefined; this.currentClientStatus.leaveTimestamp = Date.now() / 1000; this.sendOnline(); @@ -196,7 +196,7 @@ export class ClientInfoController { microphoneMuted: client.properties.client_input_muted, microphoneDisabled: !client.properties.client_input_hardware, speakerMuted: client.properties.client_output_muted, - speakerDisabled: client.properties.client_output_hardware + speakerDisabled: !client.properties.client_output_hardware }; } @@ -250,6 +250,11 @@ export class ClientInfoController { } destroy() { + ReactDOM.unmountComponentAtNode(this.htmlContainer); + + this.listenerClient.forEach(callback => callback()); + this.listenerClient = []; + this.listenerConnection.forEach(callback => callback()); this.listenerConnection.splice(0, this.listenerConnection.length); } diff --git a/shared/js/ui/frames/side/ClientInfo.scss b/shared/js/ui/frames/side/ClientInfoRenderer.scss similarity index 100% rename from shared/js/ui/frames/side/ClientInfo.scss rename to shared/js/ui/frames/side/ClientInfoRenderer.scss diff --git a/shared/js/ui/frames/side/ClientInfoRenderer.tsx b/shared/js/ui/frames/side/ClientInfoRenderer.tsx index d938bb5e..26a3e4c6 100644 --- a/shared/js/ui/frames/side/ClientInfoRenderer.tsx +++ b/shared/js/ui/frames/side/ClientInfoRenderer.tsx @@ -23,7 +23,7 @@ import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons"; import {getIconManager} from "tc-shared/file/Icons"; import {RemoteIconRenderer} from "tc-shared/ui/react-elements/Icon"; -const cssStyle = require("./ClientInfo.scss"); +const cssStyle = require("./ClientInfoRenderer.scss"); const EventsContext = React.createContext>(undefined); const ClientContext = React.createContext(undefined); diff --git a/shared/js/ui/frames/side/ConversationDefinitions.ts b/shared/js/ui/frames/side/ConversationDefinitions.ts index 5028098d..253ff520 100644 --- a/shared/js/ui/frames/side/ConversationDefinitions.ts +++ b/shared/js/ui/frames/side/ConversationDefinitions.ts @@ -9,6 +9,7 @@ export interface ChatMessage { /* ---------- Chat events ---------- */ export type ChatEvent = { timestamp: number; uniqueId: string; } & ( + ChatEventUnreadTrigger | ChatEventMessage | ChatEventMessageSendFailed | ChatEventLocalUserSwitch | @@ -18,6 +19,10 @@ export type ChatEvent = { timestamp: number; uniqueId: string; } & ( ChatEventPartnerAction ); +export interface ChatEventUnreadTrigger { + type: "unread-trigger"; +} + export interface ChatEventMessageSendFailed { type: "message-failed"; @@ -136,11 +141,11 @@ export interface ConversationUIEvents { }, notify_chat_message_delete: { chatId: string, - criteria: { begin: number, end: number, cldbid: number, limit: number } + messageIds: string[] }, notify_unread_timestamp_changed: { chatId: string, - timestamp: number | undefined + timestamp: number, } notify_private_state_changed: { chatId: string, diff --git a/shared/js/ui/frames/side/HeaderController.ts b/shared/js/ui/frames/side/HeaderController.ts new file mode 100644 index 00000000..32e45879 --- /dev/null +++ b/shared/js/ui/frames/side/HeaderController.ts @@ -0,0 +1,269 @@ +import {ConnectionHandler} from "tc-shared/ConnectionHandler"; +import * as ReactDOM from "react-dom"; +import {SideHeaderRenderer} from "./HeaderRenderer"; +import * as React from "react"; +import {SideHeaderEvents, SideHeaderState} from "tc-shared/ui/frames/side/HeaderDefinitions"; +import * as _ from "lodash"; +import {Registry} from "tc-shared/events"; +import {ChannelEntry, ChannelProperties} from "tc-shared/tree/Channel"; +import {ClientEntry, LocalClientEntry} from "tc-shared/tree/Client"; +import {openMusicManage} from "tc-shared/ui/modal/ModalMusicManage"; + +const ChannelInfoUpdateProperties: (keyof ChannelProperties)[] = [ + "channel_name", + "channel_icon_id", + + "channel_flag_maxclients_unlimited", + "channel_maxclients", + + "channel_flag_maxfamilyclients_inherited", + "channel_flag_maxfamilyclients_unlimited", + "channel_maxfamilyclients" +]; + +/* TODO: Remove the ping interval handler. It's currently still there since the clients are not emiting the event yet */ +export class SideHeader { + private readonly htmlTag: HTMLDivElement; + private readonly uiEvents: Registry; + + private connection: ConnectionHandler; + + private listenerConnection: (() => void)[]; + private listenerVoiceChannel: (() => void)[]; + private listenerTextChannel: (() => void)[]; + + private currentState: SideHeaderState; + private currentVoiceChannel: ChannelEntry; + private currentTextChannel: ChannelEntry; + + private pingUpdateInterval: number; + + constructor() { + this.uiEvents = new Registry(); + this.listenerConnection = []; + this.listenerVoiceChannel = []; + this.listenerTextChannel = []; + + this.htmlTag = document.createElement("div"); + this.htmlTag.style.display = "flex"; + this.htmlTag.style.flexDirection = "column"; + this.htmlTag.style.flexShrink = "0"; + this.htmlTag.style.flexGrow = "0"; + + ReactDOM.render(React.createElement(SideHeaderRenderer, { events: this.uiEvents }), this.htmlTag); + this.initialize(); + } + + private initialize() { + this.uiEvents.on("action_open_conversation", () => { + const selectedClient = this.connection.side_bar.getClientInfo().getClient() + if(selectedClient) { + const conversations = this.connection.getPrivateConversations(); + conversations.setSelectedConversation(conversations.findOrCreateConversation(selectedClient)); + } + this.connection.side_bar.showPrivateConversations(); + }); + + this.uiEvents.on("action_switch_channel_chat", () => { + this.connection.side_bar.showChannelConversations(); + }); + + this.uiEvents.on("action_bot_manage", () => { + const bot = this.connection.side_bar.music_info().current_bot(); + if(!bot) return; + + openMusicManage(this.connection, bot); + }); + + this.uiEvents.on("action_bot_manage", () => this.connection.side_bar.music_info().events.fire("action_song_add")); + + this.uiEvents.on("query_current_channel_state", event => this.sendChannelState(event.mode)); + this.uiEvents.on("query_private_conversations", () => this.sendPrivateConversationInfo()); + this.uiEvents.on("query_ping", () => this.sendPing()); + } + + private initializeConnection() { + this.listenerConnection.push(this.connection.channelTree.events.on("notify_client_moved", event => { + if(event.client instanceof LocalClientEntry) { + this.updateVoiceChannel(); + } + })); + this.listenerConnection.push(this.connection.channelTree.events.on("notify_client_enter_view", event => { + if(event.client instanceof LocalClientEntry) { + this.updateVoiceChannel(); + } + })); + this.listenerConnection.push(this.connection.events().on("notify_connection_state_changed", () => { + this.updateVoiceChannel(); + this.updateTextChannel(); + this.sendPing(); + if(this.connection.connected) { + if(!this.pingUpdateInterval) { + this.pingUpdateInterval = setInterval(() => this.sendPing(), 2000); + } + } else if(this.pingUpdateInterval) { + clearInterval(this.pingUpdateInterval); + this.pingUpdateInterval = undefined; + } + })); + this.listenerConnection.push(this.connection.getChannelConversations().events.on("notify_selected_changed", () => this.updateTextChannel())); + this.listenerConnection.push(this.connection.serverConnection.events.on("notify_ping_updated", () => this.sendPing())); + this.listenerConnection.push(this.connection.getPrivateConversations().events.on("notify_unread_count_changed", () => this.sendPrivateConversationInfo())); + this.listenerConnection.push(this.connection.getPrivateConversations().events.on(["notify_conversation_destroyed", "notify_conversation_destroyed"], () => this.sendPrivateConversationInfo())); + } + + setConnectionHandler(connection: ConnectionHandler) { + if(this.connection === connection) { + return; + } + + this.listenerConnection.forEach(callback => callback()); + this.listenerConnection = []; + + this.connection = connection; + if(connection) { + this.initializeConnection(); + /* TODO: Update state! */ + } else { + this.setState({ state: "none" }); + } + } + + getConnectionHandler() : ConnectionHandler | undefined { + return this.connection; + } + + getHtmlTag() : HTMLDivElement { + return this.htmlTag; + } + + destroy() { + ReactDOM.unmountComponentAtNode(this.htmlTag); + + this.listenerConnection.forEach(callback => callback()); + this.listenerConnection = []; + + this.listenerTextChannel.forEach(callback => callback()); + this.listenerTextChannel = []; + + this.listenerVoiceChannel.forEach(callback => callback()); + this.listenerVoiceChannel = []; + + clearInterval(this.pingUpdateInterval); + this.pingUpdateInterval = undefined; + } + + setState(state: SideHeaderState) { + if(_.isEqual(this.currentState, state)) { + return; + } + + this.currentState = state; + this.uiEvents.fire_react("notify_header_state", { state: state }); + } + + private sendChannelState(mode: "voice" | "text") { + const channel = mode === "voice" ? this.currentVoiceChannel : this.currentTextChannel; + if(channel) { + let maxClients = -1; + if(!channel.properties.channel_flag_maxclients_unlimited) { + maxClients = channel.properties.channel_maxclients; + } + + this.uiEvents.fire_react("notify_current_channel_state", { + mode: mode, + state: { + state: "connected", + channelName: channel.parsed_channel_name.text, + channelIcon: { + handlerId: this.connection.handlerId, + serverUniqueId: this.connection.getCurrentServerUniqueId(), + iconId: channel.properties.channel_icon_id + }, + channelUserCount: channel.clients(false).length, + channelMaxUser: maxClients + } + }); + } else { + this.uiEvents.fire_react("notify_current_channel_state", { mode: mode, state: { state: "not-connected" }}); + } + } + + private updateVoiceChannel() { + let targetChannel = this.connection?.connected ? this.connection.getClient().currentChannel() : undefined; + if(this.currentVoiceChannel === targetChannel) { + return; + } + + this.listenerVoiceChannel.forEach(callback => callback()); + this.listenerVoiceChannel = []; + + this.currentVoiceChannel = targetChannel; + this.sendChannelState("voice"); + + if(targetChannel) { + this.listenerTextChannel.push(targetChannel.events.on("notify_properties_updated", event => { + for(const key of ChannelInfoUpdateProperties) { + if(key in event.updated_properties) { + this.sendChannelState("voice"); + return; + } + } + })); + } + } + + private updateTextChannel() { + let targetChannel: ChannelEntry; + let targetChannelId = this.connection?.connected ? parseInt(this.connection.getChannelConversations().getSelectedConversation()?.getChatId()) : -1; + if(!isNaN(targetChannelId) && targetChannelId >= 0) { + targetChannel = this.connection.channelTree.findChannel(targetChannelId); + } + + if(this.currentTextChannel === targetChannel) { + return; + } + + this.listenerTextChannel.forEach(callback => callback()); + this.listenerTextChannel = []; + + this.currentTextChannel = targetChannel; + this.sendChannelState("text"); + + + if(targetChannel) { + this.listenerTextChannel.push(targetChannel.events.on("notify_properties_updated", event => { + for(const key of ChannelInfoUpdateProperties) { + if(key in event.updated_properties) { + this.sendChannelState("text"); + return; + } + } + })); + } + } + + private sendPing() { + if(this.connection?.connected) { + const ping = this.connection.getServerConnection().ping(); + this.uiEvents.fire_react("notify_ping", { + ping: { + native: typeof ping.native !== "number" ? -1 : ping.native, + javaScript: ping.javascript + } + }); + } else { + this.uiEvents.fire_react("notify_ping", { ping: undefined }); + } + } + + private sendPrivateConversationInfo() { + const conversations = this.connection.getPrivateConversations(); + this.uiEvents.fire_react("notify_private_conversations", { + info: { + open: conversations.getConversations().length, + unread: conversations.getUnreadCount() + } + }); + } +} \ No newline at end of file diff --git a/shared/js/ui/frames/side/HeaderDefinitions.ts b/shared/js/ui/frames/side/HeaderDefinitions.ts new file mode 100644 index 00000000..638e1293 --- /dev/null +++ b/shared/js/ui/frames/side/HeaderDefinitions.ts @@ -0,0 +1,65 @@ +import {RemoteIconInfo} from "tc-shared/file/Icons"; + +export type SideHeaderState = SideHeaderStateNone | SideHeaderStateConversation | SideHeaderStateClient | SideHeaderStateMusicBot; +export type SideHeaderStateNone = { + state: "none" +}; + +export type SideHeaderStateConversation = { + state: "conversation", + mode: "channel" | "private" +}; + +export type SideHeaderStateClient = { + state: "client", + ownClient: boolean +} + +export type SideHeaderStateMusicBot = { + state: "music-bot" +} + +export type SideHeaderChannelState = { + state: "not-connected" +} | { + state: "connected", + channelName: string, + channelIcon: RemoteIconInfo, + channelUserCount: number, + channelMaxUser: number | -1 +}; + +export type SideHeaderPingInfo = { + native: number, + javaScript: number | undefined +}; + +export type PrivateConversationInfo = { + unread: number, + open: number +}; + +export interface SideHeaderEvents { + action_bot_manage: {}, + action_bot_add_song: {}, + action_switch_channel_chat: {}, + action_open_conversation: {}, + + query_current_channel_state: { mode: "voice" | "text" }, + query_ping: {}, + query_private_conversations: {}, + + notify_header_state: { + state: SideHeaderState + }, + notify_current_channel_state: { + mode: "voice" | "text", + state: SideHeaderChannelState + }, + notify_ping: { + ping: SideHeaderPingInfo | undefined + }, + notify_private_conversations: { + info: PrivateConversationInfo + } +} \ No newline at end of file diff --git a/shared/js/ui/frames/side/HeaderRenderer.scss b/shared/js/ui/frames/side/HeaderRenderer.scss new file mode 100644 index 00000000..1800b74c --- /dev/null +++ b/shared/js/ui/frames/side/HeaderRenderer.scss @@ -0,0 +1,186 @@ +@import "../../../../css/static/mixin"; +@import "../../../../css/static/properties"; + +@import "./ClientInfoRenderer"; + +.container { + user-select: none; + + flex-grow: 0; + flex-shrink: 0; + + height: 9em; + + display: flex; + flex-direction: column; + justify-content: space-evenly; + + background-color: var(--side-info-background); + border-top-left-radius: 5px; + border-top-right-radius: 5px; + + -moz-box-shadow: inset 0 0 5px var(--side-info-shadow); + -webkit-box-shadow: inset 0 0 5px var(--side-info-shadow); + box-shadow: inset 0 0 5px var(--side-info-shadow); + + .lane { + padding-right: 10px; + padding-left: 10px; + + display: flex; + flex-direction: row; + justify-content: stretch; + + height: 3.25em; + max-width: 100%; + + overflow: hidden; + + .block, .button { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + + .block { + flex-shrink: 1; + flex-grow: 1; + + min-width: 0; + + &.right { + text-align: right; + + &.mode-client_info { + max-width: calc(50% - #{$client_info_avatar_size / 2}); + margin-left: calc(#{$client_info_avatar_size / 2}); + } + } + + &.left { + margin-right: .5em; + text-align: left; + padding-right: 10px; + + &.mode-client_info { + max-width: calc(50% - #{$client_info_avatar_size / 2}); + margin-right: calc(#{$client_info_avatar_size} / 2); + } + } + + .title, .value, .smallValue { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + + min-width: 0; + max-width: 100%; + } + + .title { + display: block; + color: var(--side-info-title); + + .containerIndicator { + display: inline-flex; + flex-direction: column; + justify-content: space-around; + + background: var(--side-info-indicator-background); + border: 1px solid var(--side-info-indicator-border); + border-radius: 4px; + + text-align: center; + + vertical-align: text-top; + + color: var(--side-info-indicator); + + font-size: .66em; + height: 1.3em; + min-width: .9em; + + padding-right: 2px; + padding-left: 2px; + } + } + + .value { + color: var(--side-info-value); + background-color: var(--side-info-value-background); + + display: inline-block; + + border-radius: .18em; + padding-right: .31em; + padding-left: .31em; + + > div { + display: inline-block; + } + + .icon { + vertical-align: text-top; + margin-right: .25em; + } + + &.ping { + &.veryGood { + color: var(--side-info-ping-very-good); + } + &.good { + color: var(--side-info-ping-good); + } + &.medium { + color: var(--side-info-ping-medium); + } + &.poor { + color: var(--side-info-ping-poor); + } + &.veryPoor { + color: var(--side-info-ping-very-poor); + } + } + } + + .smallValue { + display: inline-block; + color: var(--side-info-value); + font-size: .66em; + vertical-align: top; + margin-top: -.2em; + margin-left: .25em; + } + + .button { + color: var(--side-info-value); + background-color: var(--side-info-value-background); + + display: inline-block; + cursor: pointer; + + &.botAddSong { + color: var(--side-info-bot-add-song); + } + + &:hover { + background-color: #4e4e4e; /* TODO: Evaluate color */ + } + + @include transition(background-color $button_hover_animation_time ease-in-out); + } + } + + &.musicBotInfo { + .right { + margin-left: 8.5em; + } + + .left { + margin-right: 8.5em; + } + + width: 60em; /* same width so flex-shrik applies equaly */ + } + } +} \ No newline at end of file diff --git a/shared/js/ui/frames/side/HeaderRenderer.tsx b/shared/js/ui/frames/side/HeaderRenderer.tsx new file mode 100644 index 00000000..f3a160a5 --- /dev/null +++ b/shared/js/ui/frames/side/HeaderRenderer.tsx @@ -0,0 +1,281 @@ +import * as React from "react"; +import {Registry} from "tc-shared/events"; +import { + SideHeaderEvents, + SideHeaderState, + SideHeaderChannelState, + SideHeaderPingInfo, PrivateConversationInfo +} from "tc-shared/ui/frames/side/HeaderDefinitions"; +import {Translatable, VariadicTranslatable} from "tc-shared/ui/react-elements/i18n"; +import {useContext, useState} from "react"; +import {RemoteIconRenderer} from "tc-shared/ui/react-elements/Icon"; +import {getIconManager} from "tc-shared/file/Icons"; + +const StateContext = React.createContext(undefined); +const EventsContext = React.createContext>(undefined); + + +const cssStyle = require("./HeaderRenderer.scss"); + +const Block = (props: { children: [React.ReactElement, React.ReactElement], target: "left" | "right" }) => ( +
+
{props.children[0]}
+ {props.children[1]} +
+) + +const ChannelStateRenderer = (props: { info: SideHeaderChannelState }) => { + if(props.info.state === "not-connected") { + return
Not connected
; + } else { + let limit; + if(props.info.channelMaxUser === -1) { + limit = Unlimited + } else { + limit = props.info.channelMaxUser; + } + + let icon; + if(props.info.channelIcon.iconId !== 0) { + const remoteIcon = getIconManager().resolveIcon(props.info.channelIcon.iconId, props.info.channelIcon.serverUniqueId, props.info.channelIcon.handlerId); + icon = ; + } + + return ( + +
{icon}{props.info.channelName}
+
{props.info.channelUserCount} / {limit}
+
+ ); + } +} + +const BlockChannelState = (props: { mode: "voice" | "text" }) => { + const events = useContext(EventsContext); + const [ info, setInfo ] = useState(() => { + events.fire("query_current_channel_state", { mode: props.mode }); + return { state: "not-connected" }; + }); + + events.reactUse("notify_current_channel_state", event => event.mode === props.mode && setInfo(event.state)); + + let title; + if(props.mode === "voice") { + title = You're talking in Channel; + } else { + title = You're chatting in Channel; + } + + return ( + + {title} + + + ); +} + +const BlockPing = () => { + const events = useContext(EventsContext); + const [ pingInfo, setPingInfo ] = useState(() => { + events.fire("query_ping"); + return undefined; + }); + + events.reactUse("notify_ping", event => setPingInfo(event.ping)); + + let value, title; + if(!pingInfo) { + value = ( +
+ Not connected +
+ ); + } else { + let pingClass; + if(pingInfo.native <= 30) { + pingClass = cssStyle.veryGood; + } else if(pingInfo.native <= 50) { + pingClass = cssStyle.good; + } else if(pingInfo.native <= 90) { + pingClass = cssStyle.medium; + } else if(pingInfo.native <= 200) { + pingClass = cssStyle.poor; + } else { + pingClass = cssStyle.veryPoor; + } + + if(pingInfo.javaScript === undefined) { + title = tr("Ping: " + pingInfo.native.toFixed(3) + "ms"); + } else { + title = tr("Native: " + pingInfo.native.toFixed(3) + "ms\nJavascript: " + pingInfo.javaScript.toFixed(3) + "ms"); + } + value =
{pingInfo.native.toFixed(0)}ms
; + } + + return ( + + Your Ping + {value} + + ); +}; + +const BlockPrivateChats = (props: { asButton: boolean }) => { + const events = useContext(EventsContext); + const [ info, setInfo ] = useState(() => { + events.fire("query_private_conversations"); + return { unread: 0, open: 0 }; + }); + + events.reactUse("notify_private_conversations", event => setInfo(event.info)); + + let body; + if(info.open === 0) { + body = No conversations; + } else if(info.open === 1) { + body = One conversation; + } else { + body = {info.open}; + } + + let title; + if(info.unread === 0) { + title = Private Chats; + } else { + title = ( + + Private Chats +
+ {info.unread} +
+
+ ) + } + + return ( + + {title} +
props.asButton && events.fire("action_open_conversation")}> + {body} +
+
+ ); +} + +const BlockButtonSwitchToChannelChat = () => { + const events = useContext(EventsContext); + return ( + + <>  +
events.fire("action_switch_channel_chat")}> + Switch to channel chat +
+
+ ) +} + +const BlockButtonOpenConversation = () => { + const events = useContext(EventsContext); + return ( + + <>  +
events.fire("action_open_conversation")}> + Open conversation +
+
+ ) +} + +const BlockButtonBotManage = () => { + const events = useContext(EventsContext); + return ( + + <>  +
events.fire("action_bot_manage")}> + Manage bot +
+
+ ) +} + +const BlockButtonBotSongAdd = () => { + const events = useContext(EventsContext); + return ( + + <>  +
events.fire("action_bot_add_song")}> + Add song +
+
+ ) +} + + +const BlockTopLeft = () => ; +const BlockTopRight = () => ; + +const BlockBottomLeft = () => { + const state = useContext(StateContext); + + switch (state.state) { + case "conversation": + if(state.mode === "private") { + return ; + } else { + return ; + } + + case "music-bot": + return ; + + case "none": + case "client": + default: + return null; + } +} + +const BlockBottomRight = () => { + const state = useContext(StateContext); + + switch (state.state) { + case "client": + if(state.ownClient) { + return null; + } else { + return ; + } + + case "conversation": + return ; + + case "music-bot": + return ; + + case "none": + default: + return null; + } +} + +export const SideHeaderRenderer = (props: { events: Registry }) => { + const [ state, setState ] = useState({ state: "none" }); + props.events.reactUse("notify_header_state", event => setState(event.state)); + + return ( + + +
+
+ + +
+
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/shared/js/ui/frames/side/PopoutConversationUI.tsx b/shared/js/ui/frames/side/PopoutConversationRenderer.tsx similarity index 84% rename from shared/js/ui/frames/side/PopoutConversationUI.tsx rename to shared/js/ui/frames/side/PopoutConversationRenderer.tsx index d793ef79..72d92f6a 100644 --- a/shared/js/ui/frames/side/PopoutConversationUI.tsx +++ b/shared/js/ui/frames/side/PopoutConversationRenderer.tsx @@ -1,10 +1,10 @@ import {Registry, RegistryMap} from "tc-shared/events"; import {ConversationUIEvents} from "tc-shared/ui/frames/side/ConversationDefinitions"; -import {ConversationPanel} from "tc-shared/ui/frames/side/ConversationUI"; +import {ConversationPanel} from "./AbstractConversationRenderer"; import * as React from "react"; import {AbstractModal} from "tc-shared/ui/react-elements/ModalDefinitions"; -class PopoutConversationUI extends AbstractModal { +class PopoutConversationRenderer extends AbstractModal { private readonly events: Registry; private readonly userData: any; @@ -28,4 +28,4 @@ class PopoutConversationUI extends AbstractModal { } } -export = PopoutConversationUI; \ No newline at end of file +export = PopoutConversationRenderer; \ No newline at end of file diff --git a/shared/js/ui/frames/side/PrivateConversationController.ts b/shared/js/ui/frames/side/PrivateConversationController.ts new file mode 100644 index 00000000..262bb8f1 --- /dev/null +++ b/shared/js/ui/frames/side/PrivateConversationController.ts @@ -0,0 +1,162 @@ +import {ConnectionHandler} from "../../../ConnectionHandler"; +import {EventHandler} from "../../../events"; +import { + PrivateConversationInfo, + PrivateConversationUIEvents +} from "../../../ui/frames/side/PrivateConversationDefinitions"; +import * as ReactDOM from "react-dom"; +import * as React from "react"; +import {PrivateConversationsPanel} from "./PrivateConversationRenderer"; +import { + ConversationUIEvents +} from "../../../ui/frames/side/ConversationDefinitions"; +import * as log from "../../../log"; +import {LogCategory} from "../../../log"; +import {AbstractConversationController} from "./AbstractConversationController"; +import { tr } from "tc-shared/i18n/localize"; +import { + PrivateConversation, + PrivateConversationEvents, + PrivateConversationManager, + PrivateConversationManagerEvents +} from "tc-shared/conversations/PrivateConversationManager"; + +export type OutOfViewClient = { + nickname: string, + clientId: number, + uniqueId: string +} + +function generateConversationUiInfo(conversation: PrivateConversation) : PrivateConversationInfo { + const lastMessage = conversation.getPresentMessages().last(); + const lastClientInfo = conversation.getLastClientInfo(); + return { + nickname: lastClientInfo.nickname, + uniqueId: lastClientInfo.uniqueId, + clientId: lastClientInfo.clientId, + chatId: conversation.clientUniqueId, + + lastMessage: lastMessage ? lastMessage.timestamp : 0, + unreadMessages: conversation.isUnread() + }; +} + +export class PrivateConversationController extends AbstractConversationController< + PrivateConversationUIEvents, + PrivateConversationManager, + PrivateConversationManagerEvents, + PrivateConversation, + PrivateConversationEvents +> { + public readonly htmlTag: HTMLDivElement; + public readonly connection: ConnectionHandler; + + private listenerConversation: {[key: string]:(() => void)[]}; + + constructor(connection: ConnectionHandler) { + super(connection.getPrivateConversations()); + this.connection = connection; + this.listenerConversation = {}; + + this.htmlTag = document.createElement("div"); + this.htmlTag.style.display = "flex"; + this.htmlTag.style.flexDirection = "row"; + this.htmlTag.style.justifyContent = "stretch"; + this.htmlTag.style.height = "100%"; + + this.uiEvents.register_handler(this, true); + this.uiEvents.enableDebug("private-conversations"); + + ReactDOM.render(React.createElement(PrivateConversationsPanel, { events: this.uiEvents, handler: this.connection }), this.htmlTag); + + this.uiEvents.on("notify_destroy", connection.events().on("notify_visibility_changed", event => { + if(!event.visible) + return; + + this.handlePanelShow(); + })); + + this.listenerManager.push(this.conversationManager.events.on("notify_conversation_created", event => { + const conversation = event.conversation; + const events = this.listenerConversation[conversation.getChatId()] = []; + events.push(conversation.events.on("notify_partner_changed", event => { + this.uiEvents.fire_react("notify_partner_changed", { chatId: conversation.getChatId(), clientId: event.clientId, name: event.name }); + })); + events.push(conversation.events.on("notify_partner_name_changed", event => { + this.uiEvents.fire_react("notify_partner_name_changed", { chatId: conversation.getChatId(), name: event.name }); + })); + events.push(conversation.events.on("notify_partner_typing", () => { + this.uiEvents.fire_react("notify_partner_typing", { chatId: conversation.getChatId() }); + })); + events.push(conversation.events.on("notify_unread_state_changed", event => { + this.uiEvents.fire_react("notify_unread_state_changed", { chatId: conversation.getChatId(), unread: event.unread }); + })); + + this.reportConversationList(); + })); + this.listenerManager.push(this.conversationManager.events.on("notify_conversation_destroyed", event => { + this.listenerConversation[event.conversation.getChatId()]?.forEach(callback => callback()); + delete this.listenerConversation[event.conversation.getChatId()]; + + this.reportConversationList(); + })); + this.listenerManager.push(this.conversationManager.events.on("notify_selected_changed", () => this.reportConversationList())); + } + + destroy() { + ReactDOM.unmountComponentAtNode(this.htmlTag); + this.htmlTag.remove(); + + this.uiEvents.unregister_handler(this); + super.destroy(); + } + + focusInput() { + this.uiEvents.fire_react("action_focus_chat"); + } + + private reportConversationList() { + this.uiEvents.fire_react("notify_private_conversations", { + conversations: this.conversationManager.getConversations().map(generateConversationUiInfo), + selected: this.conversationManager.getSelectedConversation()?.clientUniqueId || "unselected" + }); + } + + @EventHandler("query_private_conversations") + private handleQueryPrivateConversations() { + this.reportConversationList(); + } + + @EventHandler("action_close_chat") + private handleConversationClose(event: PrivateConversationUIEvents["action_close_chat"]) { + const conversation = this.conversationManager.findConversation(event.chatId); + if(!conversation) { + log.error(LogCategory.CLIENT, tr("Tried to close a not existing private conversation with id %s"), event.chatId); + return; + } + + this.conversationManager.closeConversation(conversation); + } + + @EventHandler("notify_partner_typing") + private handleNotifySelectChat(event: PrivateConversationUIEvents["notify_partner_typing"]) { + /* TODO, set active chat? MH 9/12/20: What?? */ + } + + @EventHandler("action_self_typing") + protected handleActionSelfTyping1(_event: ConversationUIEvents["action_self_typing"]) { + const conversation = this.getCurrentConversation(); + if(!conversation) { + return; + } + + const clientId = conversation.currentClientId(); + if(!clientId) { + return; + } + + this.connection.serverConnection.send_command("clientchatcomposing", { clid: clientId }).catch(error => { + log.warn(LogCategory.CHAT, tr("Failed to send chat composing to server for chat %d: %o"), clientId, error); + }); + } +} \ No newline at end of file diff --git a/shared/js/ui/frames/side/PrivateConversationDefinitions.ts b/shared/js/ui/frames/side/PrivateConversationDefinitions.ts index 8eb1d107..8c98cfc6 100644 --- a/shared/js/ui/frames/side/PrivateConversationDefinitions.ts +++ b/shared/js/ui/frames/side/PrivateConversationDefinitions.ts @@ -15,11 +15,11 @@ export interface PrivateConversationUIEvents extends ConversationUIEvents { action_close_chat: { chatId: string }, query_private_conversations: {}, + notify_private_conversations: { conversations: PrivateConversationInfo[], selected: string - } - + }, notify_partner_changed: { chatId: string, clientId: number, @@ -28,5 +28,9 @@ export interface PrivateConversationUIEvents extends ConversationUIEvents { notify_partner_name_changed: { chatId: string, name: string + }, + notify_unread_state_changed: { + chatId: string, + unread: boolean } } \ No newline at end of file diff --git a/shared/js/ui/frames/side/PrivateConversationUI.scss b/shared/js/ui/frames/side/PrivateConversationRenderer.scss similarity index 100% rename from shared/js/ui/frames/side/PrivateConversationUI.scss rename to shared/js/ui/frames/side/PrivateConversationRenderer.scss diff --git a/shared/js/ui/frames/side/PrivateConversationUI.tsx b/shared/js/ui/frames/side/PrivateConversationRenderer.tsx similarity index 94% rename from shared/js/ui/frames/side/PrivateConversationUI.tsx rename to shared/js/ui/frames/side/PrivateConversationRenderer.tsx index 2a5becce..77fffcaf 100644 --- a/shared/js/ui/frames/side/PrivateConversationUI.tsx +++ b/shared/js/ui/frames/side/PrivateConversationRenderer.tsx @@ -6,7 +6,7 @@ import { } from "tc-shared/ui/frames/side/PrivateConversationDefinitions"; import {ConnectionHandler} from "tc-shared/ConnectionHandler"; import {ContextDivider} from "tc-shared/ui/react-elements/ContextDivider"; -import {ConversationPanel} from "tc-shared/ui/frames/side/ConversationUI"; +import {ConversationPanel} from "./AbstractConversationRenderer"; import {useContext, useEffect, useState} from "react"; import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; import {AvatarRenderer} from "tc-shared/ui/react-elements/Avatar"; @@ -14,7 +14,7 @@ import {TimestampRenderer} from "tc-shared/ui/react-elements/TimestampRenderer"; import {Translatable} from "tc-shared/ui/react-elements/i18n"; import {getGlobalAvatarManagerFactory} from "tc-shared/file/Avatars"; -const cssStyle = require("./PrivateConversationUI.scss"); +const cssStyle = require("./PrivateConversationRenderer.scss"); const kTypingTimeout = 5000; const HandlerIdContext = React.createContext(undefined); @@ -119,22 +119,17 @@ const ConversationEntryUnreadMarker = React.memo((props: { chatId: string, initi const events = useContext(EventContext); const [ unread, setUnread ] = useState(props.initialUnread); - events.reactUse("notify_unread_timestamp_changed", event => { - if(event.chatId !== props.chatId) + events.reactUse("notify_unread_state_changed", event => { + if(event.chatId !== props.chatId) { return; + } - setUnread(event.timestamp !== undefined); + setUnread(event.unread); }); - events.reactUse("notify_chat_event", event => { - if(event.chatId !== props.chatId || !event.triggerUnread) - return; - - setUnread(true); - }); - - if(!unread) + if(!unread) { return null; + } return
; }); diff --git a/shared/js/ui/frames/side/music_info.ts b/shared/js/ui/frames/side/music_info.ts index 0b895cf4..60f1eaf7 100644 --- a/shared/js/ui/frames/side/music_info.ts +++ b/shared/js/ui/frames/side/music_info.ts @@ -141,8 +141,9 @@ export class MusicInfo { this._container_playlist = this._html_tag.find(".container-playlist"); this._html_tag.find(".button-close").on('click', () => { - if(this.previous_frame_content === FrameContent.CLIENT_INFO) + if(this.previous_frame_content === FrameContent.CLIENT_INFO) { this.previous_frame_content = FrameContent.NONE; + } this.handle.set_content(this.previous_frame_content); }); diff --git a/shared/js/ui/frames/side/ChatBox.scss b/shared/js/ui/react-elements/ChatBox.scss similarity index 98% rename from shared/js/ui/frames/side/ChatBox.scss rename to shared/js/ui/react-elements/ChatBox.scss index b8f788dc..a1ce7dc0 100644 --- a/shared/js/ui/frames/side/ChatBox.scss +++ b/shared/js/ui/react-elements/ChatBox.scss @@ -1,5 +1,5 @@ -@import "../../../../css/static/mixin"; -@import "../../../../css/static/properties"; +@import "../../../css/static/mixin.scss"; +@import "../../../css/static/properties.scss"; html:root { --chatbox-emoji-hover-background: #454545; diff --git a/shared/js/ui/frames/side/ChatBox.tsx b/shared/js/ui/react-elements/ChatBox.tsx similarity index 100% rename from shared/js/ui/frames/side/ChatBox.tsx rename to shared/js/ui/react-elements/ChatBox.tsx diff --git a/shared/js/ui/react-elements/external-modal/PopoutRegistry.ts b/shared/js/ui/react-elements/external-modal/PopoutRegistry.ts index 29f66093..71eed530 100644 --- a/shared/js/ui/react-elements/external-modal/PopoutRegistry.ts +++ b/shared/js/ui/react-elements/external-modal/PopoutRegistry.ts @@ -23,7 +23,7 @@ registerHandler({ registerHandler({ name: "conversation", - loadClass: async () => await import("tc-shared/ui/frames/side/PopoutConversationUI") + loadClass: async () => await import("../../frames/side/PopoutConversationRenderer") }); diff --git a/shared/js/ui/react-elements/i18n/index.tsx b/shared/js/ui/react-elements/i18n/index.tsx index bdbc0a66..27456d06 100644 --- a/shared/js/ui/react-elements/i18n/index.tsx +++ b/shared/js/ui/react-elements/i18n/index.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import {parseMessageWithArguments} from "tc-shared/ui/frames/chat"; -import {cloneElement, ReactNode} from "react"; +import {cloneElement} from "react"; let instances = []; export class Translatable extends React.Component<{ @@ -51,7 +51,7 @@ export class Translatable extends React.Component<{ } let renderBrElementIndex = 0; -export type VariadicTranslatableChild = React.ReactElement | string; +export type VariadicTranslatableChild = React.ReactElement | string | number; export const VariadicTranslatable = (props: { text: string, __cacheKey?: string, children?: VariadicTranslatableChild[] | VariadicTranslatableChild }) => { const args = Array.isArray(props.children) ? props.children : [props.children]; const argsUseCount = [...new Array(args.length)].map(() => 0); @@ -64,7 +64,7 @@ export const VariadicTranslatable = (props: { text: string, __cacheKey?: string, if(typeof e === "string") { return e.split("\n").reduce((result, element) => { if(result.length > 0) { - result.push(
); + result.push(
); } result.push(element); return result; @@ -73,7 +73,7 @@ export const VariadicTranslatable = (props: { text: string, __cacheKey?: string, let element = args[e]; if(argsUseCount[e]) { - if(typeof element === "string") { + if(typeof element === "string" || typeof element === "number") { /* do nothing */ } else { element = cloneElement(element); diff --git a/shared/js/ui/tree/Controller.tsx b/shared/js/ui/tree/Controller.tsx index e6627d8b..1b066168 100644 --- a/shared/js/ui/tree/Controller.tsx +++ b/shared/js/ui/tree/Controller.tsx @@ -134,7 +134,7 @@ class ChannelTreeController { } private handleConnectionStateChanged(event: ConnectionEvents["notify_connection_state_changed"]) { - if(event.new_state !== ConnectionState.CONNECTED) { + if(event.newState !== ConnectionState.CONNECTED) { this.channelTreeInitialized = false; this.sendChannelTreeEntries(); } diff --git a/web/app/connection/ServerConnection.ts b/web/app/connection/ServerConnection.ts index 1cc9972a..26440406 100644 --- a/web/app/connection/ServerConnection.ts +++ b/web/app/connection/ServerConnection.ts @@ -1,7 +1,7 @@ import { AbstractServerConnection, CommandOptionDefaults, - CommandOptions, + CommandOptions, ConnectionPing, ConnectionStateListener, ConnectionStatistics, } from "tc-shared/connection/ConnectionBase"; @@ -378,6 +378,7 @@ export class ServerConnection extends AbstractServerConnection { this.pingStatistics.lastResponseTimestamp = 'now' in performance ? performance.now() : Date.now(); this.pingStatistics.currentJsValue = this.pingStatistics.lastResponseTimestamp - this.pingStatistics.lastRequestTimestamp; this.pingStatistics.currentNativeValue = parseInt(json["ping_native"]) / 1000; /* we're getting it in microseconds and not milliseconds */ + this.events.fire("notify_ping_updated", { newPing: this.ping() }); //log.debug(LogCategory.NETWORKING, tr("Received new pong. Updating ping to: JS: %o Native: %o"), this._ping.value.toFixed(3), this._ping.value_native.toFixed(3)); } } else if(json["type"] === "WebRTC") { @@ -526,10 +527,11 @@ export class ServerConnection extends AbstractServerConnection { } } - ping(): { native: number; javascript?: number } { + ping(): ConnectionPing { return { javascript: this.pingStatistics.currentJsValue, - native: this.pingStatistics.currentNativeValue + /* if the native value is zero that means we don't have any */ + native: this.pingStatistics.currentNativeValue === 0 ? this.pingStatistics.currentJsValue : this.pingStatistics.currentNativeValue }; } From e6bbb883e48fa53e15e0d279428b83a9e8bef4cf Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Wed, 9 Dec 2020 14:22:22 +0100 Subject: [PATCH 03/37] Improved channel private conversation mode behaviour --- ChangeLog.md | 1 + shared/js/conversations/AbstractConversion.ts | 58 ++++++++++++++++--- .../ChannelConversationManager.ts | 55 ++++++++++++++++-- shared/js/tree/Channel.ts | 10 +++- .../side/AbstractConversationController.ts | 26 +++++++-- .../side/AbstractConversationRenderer.tsx | 38 +++++++++++- .../side/ChannelConversationController.ts | 29 ++++++++-- .../ui/frames/side/ConversationDefinitions.ts | 8 ++- shared/js/ui/frames/side/HeaderRenderer.tsx | 4 +- 9 files changed, 199 insertions(+), 30 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index 0a6b3256..c7ce7b2e 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -2,6 +2,7 @@ * **09.12.20** - Fixed the private messages unread indicator - Properly updating the private message unread count + - Improved channel conversation mode detection support * **08.12.20** - Fixed the permission editor not resolving unique ids diff --git a/shared/js/conversations/AbstractConversion.ts b/shared/js/conversations/AbstractConversion.ts index db08886e..26cc04ec 100644 --- a/shared/js/conversations/AbstractConversion.ts +++ b/shared/js/conversations/AbstractConversion.ts @@ -2,15 +2,17 @@ import { ChatEvent, ChatEventMessage, ChatMessage, - ChatState, ConversationHistoryResponse + ChatState, + ConversationHistoryResponse } from "tc-shared/ui/frames/side/ConversationDefinitions"; import {Registry} from "tc-shared/events"; -import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler"; +import {ConnectionHandler} from "tc-shared/ConnectionHandler"; import {preprocessChatMessageForSend} from "tc-shared/text/chat"; import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration"; import {ErrorCode} from "tc-shared/connection/ErrorCode"; import {LogCategory, logWarn} from "tc-shared/log"; -import {ChannelConversation} from "tc-shared/conversations/ChannelConversationManager"; +import {ChannelConversationMode} from "tc-shared/tree/Channel"; +import {guid} from "tc-shared/crypto/uid"; export const kMaxChatFrameMessageSize = 50; /* max 100 messages, since the server does not support more than 100 messages queried at once */ @@ -34,6 +36,12 @@ export interface AbstractChatEvents { }, notify_history_state_changed: { hasHistory: boolean + }, + notify_conversation_mode_changed: { + newMode: ChannelConversationMode + }, + notify_read_state_changed: { + readable: boolean } } @@ -50,13 +58,14 @@ export abstract class AbstractChat { protected failedPermission: string; protected errorMessage: string; - protected conversationPrivate: boolean = false; + private conversationMode: ChannelConversationMode; protected crossChannelChatSupported: boolean = true; protected unreadTimestamp: number; protected unreadState: boolean = false; protected messageSendEnabled: boolean = true; + private conversationReadable = true; private history = false; @@ -65,6 +74,7 @@ export abstract class AbstractChat { this.connection = connection; this.chatId = chatId; this.unreadTimestamp = Date.now(); + this.conversationMode = ChannelConversationMode.Public; } destroy() { @@ -103,7 +113,7 @@ export abstract class AbstractChat { this.setUnreadTimestamp(Date.now()); } else if(!this.isUnread() && triggerUnread) { this.setUnreadTimestamp(event.message.timestamp - 1); - } else { + } else if(!this.isUnread()) { /* mark the last message as read */ this.setUnreadTimestamp(event.message.timestamp); } @@ -119,7 +129,7 @@ export abstract class AbstractChat { if(!this.isUnread() && triggerUnread) { this.setUnreadTimestamp(event.timestamp - 1); - } else { + } else if(!this.isUnread()) { /* mark the last message as read */ this.setUnreadTimestamp(event.timestamp); } @@ -197,8 +207,41 @@ export abstract class AbstractChat { return this.unreadState; } + public getConversationMode() : ChannelConversationMode { + return this.conversationMode; + } + public isPrivate() : boolean { - return this.conversationPrivate; + return this.conversationMode === ChannelConversationMode.Private; + } + + protected setConversationMode(mode: ChannelConversationMode) { + if(this.conversationMode === mode) { + return; + } + + this.registerChatEvent({ + type: "mode-changed", + uniqueId: guid() + "-mode-change", + timestamp: Date.now(), + newMode: mode === ChannelConversationMode.Public ? "normal" : mode === ChannelConversationMode.Private ? "private" : "none" + }, true); + + this.conversationMode = mode; + this.events.fire("notify_conversation_mode_changed", { newMode: mode }); + } + + public isReadable() { + return this.conversationReadable; + } + + protected setReadable(flag: boolean) { + if(this.conversationReadable === flag) { + return; + } + + this.conversationReadable = flag; + this.events.fire("notify_read_state_changed", { readable: flag }); } public isSendEnabled() : boolean { @@ -275,7 +318,6 @@ export abstract class AbstractChat { this.events.fire("notify_send_toggle", { enabled: enabled }); } - public abstract canClientAccessChat() : boolean; public abstract queryHistory(criteria: { begin?: number, end?: number, limit?: number }) : Promise; public abstract queryCurrentMessages(); public abstract sendMessage(text: string); diff --git a/shared/js/conversations/ChannelConversationManager.ts b/shared/js/conversations/ChannelConversationManager.ts index 604272ef..0adb68a8 100644 --- a/shared/js/conversations/ChannelConversationManager.ts +++ b/shared/js/conversations/ChannelConversationManager.ts @@ -15,6 +15,7 @@ import {traj} from "tc-shared/i18n/localize"; import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler"; import {LocalClientEntry} from "tc-shared/tree/Client"; import {ServerCommand} from "tc-shared/connection/ConnectionBase"; +import {ChannelConversationMode} from "tc-shared/tree/Channel"; export interface ChannelConversationEvents extends AbstractChatEvents { notify_messages_deleted: { messages: string[] }, @@ -40,13 +41,12 @@ export class ChannelConversation extends AbstractChat this.conversationId = id; this.preventUnreadUpdate = true; - const dateNow = Date.now(); const unreadTimestamp = handle.connection.settings.server(Settings.FN_CHANNEL_CHAT_READ(id), Date.now()); this.setUnreadTimestamp(unreadTimestamp); this.preventUnreadUpdate = false; - this.events.on("notify_unread_state_changed", event => { - this.handle.connection.channelTree.findChannel(this.conversationId)?.setUnread(event.unread); + this.events.on(["notify_unread_state_changed", "notify_read_state_changed"], event => { + this.handle.connection.channelTree.findChannel(this.conversationId)?.setUnread(this.isReadable() && this.isUnread()); }); } @@ -142,7 +142,6 @@ export class ChannelConversation extends AbstractChat this.setCurrentMode("loading"); this.queryHistory({ end: 1, limit: kMaxChatFrameMessageSize }).then(history => { - this.conversationPrivate = false; this.conversationVolatile = false; this.failedPermission = undefined; this.errorMessage = undefined; @@ -166,17 +165,18 @@ export class ChannelConversation extends AbstractChat break; case "private": - this.conversationPrivate = true; + this.setConversationMode(ChannelConversationMode.Private); this.setCurrentMode("normal"); break; case "success": + this.setConversationMode(ChannelConversationMode.Public); this.setCurrentMode("normal"); break; case "unsupported": this.crossChannelChatSupported = false; - this.conversationPrivate = true; + this.setConversationMode(ChannelConversationMode.Private); this.setCurrentMode("normal"); break; } @@ -295,6 +295,10 @@ export class ChannelConversation extends AbstractChat this.handle.connection.settings.changeServer(Settings.FN_CHANNEL_CHAT_READ(this.conversationId), timestamp); } + public setConversationMode(mode: ChannelConversationMode) { + super.setConversationMode(mode); + } + public localClientSwitchedChannel(type: "join" | "leave") { this.registerChatEvent({ type: "local-user-switch", @@ -309,6 +313,14 @@ export class ChannelConversation extends AbstractChat sendMessage(text: string) { this.doSendMessage(text, this.conversationId ? 2 : 3, this.conversationId).then(() => {}); } + + updateAccessState() { + if(this.isPrivate()) { + this.setReadable(this.connection.getClient().currentChannel()?.getChannelId() === this.conversationId); + } else { + this.setReadable(true); + } + } } export interface ChannelConversationManagerEvents extends AbstractChatManagerEvents { } @@ -337,6 +349,37 @@ export class ChannelConversationManager extends AbstractChatManager { + const conversation = this.findConversation(event.channel.channelId); + if(!conversation) { + return; + } + + if("channel_conversation_mode" in event.updatedProperties) { + conversation.setConversationMode(event.channel.properties.channel_conversation_mode); + conversation.updateAccessState(); + } + })); + + this.listenerConnection.push(connection.channelTree.events.on("notify_client_moved", event => { + if(event.client instanceof LocalClientEntry) { + const fromConversation = this.findConversation(event.oldChannel.channelId); + const targetConversation = this.findConversation(event.newChannel.channelId); + + fromConversation?.updateAccessState(); + targetConversation?.updateAccessState(); + } + })); + + this.listenerConnection.push(connection.channelTree.events.on("notify_client_enter_view", event => { + if(event.client instanceof LocalClientEntry) { + const targetConversation = this.findConversation(event.targetChannel.channelId); + targetConversation?.updateAccessState(); + } + })); + + /* TODO: Permission listener for text send power! */ + this.listenerConnection.push(connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationhistory", this.handleConversationHistory.bind(this))); this.listenerConnection.push(connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationindex", this.handleConversationIndex.bind(this))); this.listenerConnection.push(connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationmessagedelete", this.handleConversationMessageDelete.bind(this))); diff --git a/shared/js/tree/Channel.ts b/shared/js/tree/Channel.ts index fc73209a..26ec80c4 100644 --- a/shared/js/tree/Channel.ts +++ b/shared/js/tree/Channel.ts @@ -42,6 +42,12 @@ export enum ChannelSubscribeMode { INHERITED } +export enum ChannelConversationMode { + Public = 0, + Private = 1, + None = 2 +} + export class ChannelProperties { channel_order: number = 0; channel_name: string = ""; @@ -73,7 +79,7 @@ export class ChannelProperties { //Only after request channel_description: string = ""; - channel_conversation_mode: number = 0; /* 0 := Private, the default */ + channel_conversation_mode: ChannelConversationMode = 0; channel_conversation_history_length: number = -1; } @@ -564,6 +570,8 @@ export class ChannelEntry extends ChannelTreeEntry { } /* devel-block-end */ + /* TODO: Validate values. Example: channel_conversation_mode */ + for(let variable of variables) { let key = variable.key; let value = variable.value; diff --git a/shared/js/ui/frames/side/AbstractConversationController.ts b/shared/js/ui/frames/side/AbstractConversationController.ts index 5032befa..80999ca4 100644 --- a/shared/js/ui/frames/side/AbstractConversationController.ts +++ b/shared/js/ui/frames/side/AbstractConversationController.ts @@ -35,6 +35,8 @@ export abstract class AbstractConversationController< protected currentSelectedConversation: ConversationType; protected currentSelectedListener: (() => void)[]; + protected crossChannelChatSupported = true; + protected constructor(conversationManager: Manager) { this.uiEvents = new Registry(); this.currentSelectedListener = []; @@ -86,6 +88,10 @@ export abstract class AbstractConversationController< this.currentSelectedListener.push(conversation.events.on("notify_history_state_changed", () => { this.reportStateToUI(conversation); })); + + this.currentSelectedListener.push(conversation.events.on("notify_read_state_changed", () => { + this.reportStateToUI(conversation); + })); } handlePanelShow() { @@ -93,8 +99,6 @@ export abstract class AbstractConversationController< } protected reportStateToUI(conversation: AbstractChat) { - const crossChannelChatSupported = true; /* FIXME: Determine this form the server! */ - let historyState: ChatHistoryState; const localHistoryState = this.historyUiStates[conversation.getChatId()]; if(!localHistoryState) { @@ -113,11 +117,11 @@ export abstract class AbstractConversationController< switch (conversation.getCurrentMode()) { case "normal": - if(conversation.isPrivate() && !conversation.canClientAccessChat()) { + if(conversation.isPrivate() && !conversation.isReadable()) { this.uiEvents.fire_react("notify_conversation_state", { chatId: conversation.getChatId(), state: "private", - crossChannelChatSupported: crossChannelChatSupported + crossChannelChatSupported: this.crossChannelChatSupported }); return; } @@ -133,7 +137,7 @@ export abstract class AbstractConversationController< chatFrameMaxMessageCount: kMaxChatFrameMessageSize, unreadTimestamp: conversation.getUnreadTimestamp(), - showUserSwitchEvents: conversation.isPrivate() || !crossChannelChatSupported, + showUserSwitchEvents: conversation.isPrivate() || !this.crossChannelChatSupported, sendEnabled: conversation.isSendEnabled(), events: [...conversation.getPresentEvents(), ...conversation.getPresentMessages()] @@ -230,6 +234,18 @@ export abstract class AbstractConversationController< return this.currentSelectedConversation; } + protected setCrossChannelChatSupport(flag: boolean) { + if(this.crossChannelChatSupported === flag) { + return; + } + + this.crossChannelChatSupported = flag; + const currentConversation = this.getCurrentConversation(); + if(currentConversation) { + this.reportStateToUI(this.getCurrentConversation()); + } + } + @EventHandler("query_conversation_state") protected handleQueryConversationState(event: ConversationUIEvents["query_conversation_state"]) { const conversation = this.conversationManager.findConversationById(event.chatId); diff --git a/shared/js/ui/frames/side/AbstractConversationRenderer.tsx b/shared/js/ui/frames/side/AbstractConversationRenderer.tsx index 4c2f9f5d..e4bd599e 100644 --- a/shared/js/ui/frames/side/AbstractConversationRenderer.tsx +++ b/shared/js/ui/frames/side/AbstractConversationRenderer.tsx @@ -15,7 +15,7 @@ import { ChatEventPartnerAction, ChatHistoryState, ChatMessage, - ConversationUIEvents + ConversationUIEvents, ChatEventModeChanged } from "tc-shared/ui/frames/side/ConversationDefinitions"; import {TimestampRenderer} from "tc-shared/ui/react-elements/TimestampRenderer"; import {BBCodeRenderer} from "tc-shared/text/bbcode"; @@ -277,6 +277,34 @@ const ChatEventPartnerActionRenderer = (props: { event: ChatEventPartnerAction, return null; }; +const ChatEventModeChangedRenderer = (props: { event: ChatEventModeChanged, refHTMLElement: Ref }) => { + switch (props.event.newMode) { + case "none": + return ( +
+ The conversation has been disabled +
+
+ ); + + case "private": + return ( +
+ The conversation has been made private +
+
+ ); + + case "normal": + return ( +
+ The conversation has been made public +
+
+ ); + } +} + const PartnerTypingIndicator = (props: { events: Registry, chatId: string, timeout?: number }) => { const kTypingTimeout = props.timeout || 5000; @@ -658,6 +686,14 @@ class ConversationMessages extends React.PureComponent); break; + + case "mode-changed": + this.viewEntries.push(); + break; } } } diff --git a/shared/js/ui/frames/side/ChannelConversationController.ts b/shared/js/ui/frames/side/ChannelConversationController.ts index 10d02062..11d3fd5f 100644 --- a/shared/js/ui/frames/side/ChannelConversationController.ts +++ b/shared/js/ui/frames/side/ChannelConversationController.ts @@ -1,20 +1,20 @@ import * as React from "react"; -import {ConnectionHandler} from "../../../ConnectionHandler"; +import {ConnectionHandler, ConnectionState} from "../../../ConnectionHandler"; import {EventHandler} from "../../../events"; import * as log from "../../../log"; import {LogCategory} from "../../../log"; import {tr} from "../../../i18n/localize"; -import ReactDOM = require("react-dom"); -import { - ConversationUIEvents -} from "../../../ui/frames/side/ConversationDefinitions"; +import {ConversationUIEvents} from "../../../ui/frames/side/ConversationDefinitions"; import {ConversationPanel} from "./AbstractConversationRenderer"; import {AbstractConversationController} from "./AbstractConversationController"; import { - ChannelConversation, ChannelConversationEvents, + ChannelConversation, + ChannelConversationEvents, ChannelConversationManager, ChannelConversationManagerEvents } from "tc-shared/conversations/ChannelConversationManager"; +import {ServerFeature} from "tc-shared/connection/ServerFeatures"; +import ReactDOM = require("react-dom"); export class ChannelConversationController extends AbstractConversationController< ConversationUIEvents, @@ -61,6 +61,18 @@ export class ChannelConversationController extends AbstractConversationControlle })); this.uiEvents.register_handler(this, true); + + this.listenerManager.push(connection.events().on("notify_connection_state_changed", event => { + if(event.newState === ConnectionState.CONNECTED) { + connection.serverFeatures.awaitFeatures().then(success => { + if(!success) { return; } + + this.setCrossChannelChatSupport(connection.serverFeatures.supportsFeature(ServerFeature.ADVANCED_CHANNEL_CHAT)); + }); + } else { + this.setCrossChannelChatSupport(true); + } + })); } destroy() { @@ -84,8 +96,13 @@ export class ChannelConversationController extends AbstractConversationControlle protected registerConversationEvents(conversation: ChannelConversation) { super.registerConversationEvents(conversation); + this.currentSelectedListener.push(conversation.events.on("notify_messages_deleted", event => { this.uiEvents.fire_react("notify_chat_message_delete", { messageIds: event.messages, chatId: conversation.getChatId() }); })); + + this.currentSelectedListener.push(conversation.events.on("notify_conversation_mode_changed", () => { + this.reportStateToUI(conversation); + })); } } \ No newline at end of file diff --git a/shared/js/ui/frames/side/ConversationDefinitions.ts b/shared/js/ui/frames/side/ConversationDefinitions.ts index 253ff520..e7dd6a3e 100644 --- a/shared/js/ui/frames/side/ConversationDefinitions.ts +++ b/shared/js/ui/frames/side/ConversationDefinitions.ts @@ -16,7 +16,8 @@ export type ChatEvent = { timestamp: number; uniqueId: string; } & ( ChatEventQueryFailed | ChatEventPartnerInstanceChanged | ChatEventLocalAction | - ChatEventPartnerAction + ChatEventPartnerAction | + ChatEventModeChanged ); export interface ChatEventUnreadTrigger { @@ -64,6 +65,11 @@ export interface ChatEventPartnerAction { action: "disconnect" | "close" | "reconnect"; } +export interface ChatEventModeChanged { + type: "mode-changed"; + newMode: "normal" | "private" | "none" +} + /* ---------- Chat States ---------- */ export type ChatState = "normal" | "loading" | "no-permissions" | "error" | "unloaded"; export type ChatHistoryState = "none" | "loading" | "available" | "error"; diff --git a/shared/js/ui/frames/side/HeaderRenderer.tsx b/shared/js/ui/frames/side/HeaderRenderer.tsx index f3a160a5..645129f3 100644 --- a/shared/js/ui/frames/side/HeaderRenderer.tsx +++ b/shared/js/ui/frames/side/HeaderRenderer.tsx @@ -105,9 +105,9 @@ const BlockPing = () => { } if(pingInfo.javaScript === undefined) { - title = tr("Ping: " + pingInfo.native.toFixed(3) + "ms"); + title = tra("Ping: {}ms", pingInfo.native.toFixed(3)); } else { - title = tr("Native: " + pingInfo.native.toFixed(3) + "ms\nJavascript: " + pingInfo.javaScript.toFixed(3) + "ms"); + title = tra("Native: {}ms\nJavascript: {}ms", pingInfo.native.toFixed(3), pingInfo.javaScript.toFixed(3)); } value =
{pingInfo.native.toFixed(0)}ms
; } From 9e90510b433a69afbec52c6fc55bf06d50c0f980 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Wed, 9 Dec 2020 14:32:14 +0100 Subject: [PATCH 04/37] Fixed HTML link copy pasting --- ChangeLog.md | 1 + .../ChannelConversationManager.ts | 4 +-- shared/js/ui/react-elements/ChatBox.tsx | 27 ++++++++++++++++++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index c7ce7b2e..e46cbd86 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -3,6 +3,7 @@ - Fixed the private messages unread indicator - Properly updating the private message unread count - Improved channel conversation mode detection support + - Added support for HTML encoded links (Example would be when copying from Edge the URL) * **08.12.20** - Fixed the permission editor not resolving unique ids diff --git a/shared/js/conversations/ChannelConversationManager.ts b/shared/js/conversations/ChannelConversationManager.ts index 0adb68a8..103d6b6a 100644 --- a/shared/js/conversations/ChannelConversationManager.ts +++ b/shared/js/conversations/ChannelConversationManager.ts @@ -363,8 +363,8 @@ export class ChannelConversationManager extends AbstractChatManager { if(event.client instanceof LocalClientEntry) { - const fromConversation = this.findConversation(event.oldChannel.channelId); - const targetConversation = this.findConversation(event.newChannel.channelId); + const fromConversation = this.findConversation(event.oldChannel?.channelId); + const targetConversation = this.findConversation(event.newChannel?.channelId); fromConversation?.updateAccessState(); targetConversation?.updateAccessState(); diff --git a/shared/js/ui/react-elements/ChatBox.tsx b/shared/js/ui/react-elements/ChatBox.tsx index 0c29798e..f2c5803c 100644 --- a/shared/js/ui/react-elements/ChatBox.tsx +++ b/shared/js/ui/react-elements/ChatBox.tsx @@ -89,6 +89,30 @@ const nodeToText = (element: Node) => { return element.alt || element.title; } else if(element instanceof HTMLBRElement) { return '\n'; + } else if(element instanceof HTMLAnchorElement) { + const content = [...element.childNodes].map(nodeToText).join(""); + + if(element.href) { + if(settings.static_global(Settings.KEY_CHAT_ENABLE_MARKDOWN)) { + if(content && element.title) { + return `[${content}](${element.href} "${element.title}")`; + } else if(content) { + return `[${content}](${element.href})`; + } else { + return `[${element.href}](${element.href})`; + } + } else if(settings.static_global(Settings.KEY_CHAT_ENABLE_BBCODE)) { + if(content) { + return `[url=${element.href}]${content}"[/url]`; + } else { + return `[url]${element.href}"[/url]`; + } + } else { + return element.href; + } + } else { + return content; + } } if(element.children.length > 0) { @@ -143,8 +167,9 @@ const TextInput = (props: { events: Registry, enabled?: boolean, const rawText = clipboard.getData('text/plain'); const selection = window.getSelection(); - if (!selection.rangeCount) + if (!selection.rangeCount) { return false; + } let htmlXML = clipboard.getData('text/html'); if(!htmlXML) { From b665d69a9f3e7171ec7353259e965767eef0dab5 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Wed, 9 Dec 2020 14:54:25 +0100 Subject: [PATCH 05/37] Some minor bugfixing and enabled context menus for every clickable client tag --- ChangeLog.md | 1 + .../ChannelConversationManager.ts | 2 +- shared/js/file/LocalAvatars.ts | 2 +- shared/js/file/LocalIcons.ts | 2 +- shared/js/file/RemoteAvatars.ts | 2 +- shared/js/file/RemoteIcons.ts | 2 +- shared/js/ipc/BrowserIPC.ts | 35 +++--- shared/js/text/markdown.ts | 5 +- shared/js/tree/ChannelTree.tsx | 2 + shared/js/tree/EntryTagsHandler.ts | 101 ++++++++++++++++++ .../external-modal/Controller.ts | 4 +- .../external-modal/PopoutController.ts | 2 +- shared/js/ui/tree/EntryTags.tsx | 66 +++++++++--- web/app/ExternalModalFactory.ts | 2 +- web/app/ui/context-menu/Ipc.ts | 2 +- 15 files changed, 187 insertions(+), 43 deletions(-) create mode 100644 shared/js/tree/EntryTagsHandler.ts diff --git a/ChangeLog.md b/ChangeLog.md index e46cbd86..9a39b3f2 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -4,6 +4,7 @@ - Properly updating the private message unread count - Improved channel conversation mode detection support - Added support for HTML encoded links (Example would be when copying from Edge the URL) + - Enabled context menus for all clickable client tags * **08.12.20** - Fixed the permission editor not resolving unique ids diff --git a/shared/js/conversations/ChannelConversationManager.ts b/shared/js/conversations/ChannelConversationManager.ts index 103d6b6a..1c2f08d2 100644 --- a/shared/js/conversations/ChannelConversationManager.ts +++ b/shared/js/conversations/ChannelConversationManager.ts @@ -398,7 +398,7 @@ export class ChannelConversationManager extends AbstractChatManager this.handleHandlerCreated(event.handler)); diff --git a/shared/js/file/LocalIcons.ts b/shared/js/file/LocalIcons.ts index 7157b4cf..35d95211 100644 --- a/shared/js/file/LocalIcons.ts +++ b/shared/js/file/LocalIcons.ts @@ -69,7 +69,7 @@ class IconManager extends AbstractIconManager { constructor() { super(); - this.ipcChannel = ipc.getInstance().createChannel(undefined, kIPCIconChannel); + this.ipcChannel = ipc.getIpcInstance().createChannel(undefined, kIPCIconChannel); this.ipcChannel.messageHandler = this.handleIpcMessage.bind(this); server_connections.events().on("notify_handler_created", event => { diff --git a/shared/js/file/RemoteAvatars.ts b/shared/js/file/RemoteAvatars.ts index e319735f..ba11bd3f 100644 --- a/shared/js/file/RemoteAvatars.ts +++ b/shared/js/file/RemoteAvatars.ts @@ -159,7 +159,7 @@ class RemoteAvatarManagerFactory extends AbstractAvatarManagerFactory { constructor() { super(); - this.ipcChannel = ipc.getInstance().createChannel(Settings.instance.static(Settings.KEY_IPC_REMOTE_ADDRESS, "invalid"), kIPCAvatarChannel); + this.ipcChannel = ipc.getIpcInstance().createChannel(Settings.instance.static(Settings.KEY_IPC_REMOTE_ADDRESS, "invalid"), kIPCAvatarChannel); this.ipcChannel.messageHandler = this.handleIpcMessage.bind(this); } diff --git a/shared/js/file/RemoteIcons.ts b/shared/js/file/RemoteIcons.ts index 2ba3eb0e..9f725bfe 100644 --- a/shared/js/file/RemoteIcons.ts +++ b/shared/js/file/RemoteIcons.ts @@ -33,7 +33,7 @@ class RemoteIconManager extends AbstractIconManager { constructor() { super(); - this.ipcChannel = ipc.getInstance().createChannel(Settings.instance.static(Settings.KEY_IPC_REMOTE_ADDRESS, "invalid"), kIPCIconChannel); + this.ipcChannel = ipc.getIpcInstance().createChannel(Settings.instance.static(Settings.KEY_IPC_REMOTE_ADDRESS, "invalid"), kIPCIconChannel); this.ipcChannel.messageHandler = this.handleIpcMessage.bind(this); } diff --git a/shared/js/ipc/BrowserIPC.ts b/shared/js/ipc/BrowserIPC.ts index cc192f6d..32c67148 100644 --- a/shared/js/ipc/BrowserIPC.ts +++ b/shared/js/ipc/BrowserIPC.ts @@ -49,16 +49,16 @@ export abstract class BasicIPCHandler { protected static readonly BROADCAST_UNIQUE_ID = "00000000-0000-4000-0000-000000000000"; protected static readonly PROTOCOL_VERSION = 1; - protected _channels: IPCChannel[] = []; - protected unique_id; + protected registeredChannels: IPCChannel[] = []; + protected localUniqueId: string; protected constructor() { } setup() { - this.unique_id = uuidv4(); /* lets get an unique identifier */ + this.localUniqueId = uuidv4(); } - getLocalAddress() { return this.unique_id; } + getLocalAddress() : string { return this.localUniqueId; } abstract sendMessage(type: string, data: any, target?: string); @@ -72,12 +72,12 @@ export abstract class BasicIPCHandler { request_query_id: (message.data).query_id, request_timestamp: (message.data).timestamp, - device_id: this.unique_id, + device_id: this.localUniqueId, protocol: BasicIPCHandler.PROTOCOL_VERSION } as ProcessQueryResponse, message.sender); return; } - } else if(message.receiver === this.unique_id) { + } else if(message.receiver === this.localUniqueId) { if(message.type == "process-query-response") { const response: ProcessQueryResponse = message.data; if(this._query_results[response.request_query_id]) @@ -114,7 +114,7 @@ export abstract class BasicIPCHandler { const data: ChannelMessage = message.data; let channel_invoked = false; - for(const channel of this._channels) { + for(const channel of this.registeredChannels) { if(channel.channelId === data.channel_id && (typeof(channel.targetClientId) === "undefined" || channel.targetClientId === message.sender)) { if(channel.messageHandler) channel.messageHandler(message.sender, message.receiver === BasicIPCHandler.BROADCAST_UNIQUE_ID, data); @@ -136,8 +136,9 @@ export abstract class BasicIPCHandler { messageHandler: undefined, sendMessage: (type: string, data: any, target?: string) => { if(typeof target !== "undefined") { - if(typeof channel.targetClientId === "string" && target != channel.targetClientId) + if(typeof channel.targetClientId === "string" && target != channel.targetClientId) { throw "target id does not match channel target"; + } } this.sendMessage("channel", { @@ -148,14 +149,14 @@ export abstract class BasicIPCHandler { } }; - this._channels.push(channel); + this.registeredChannels.push(channel); return channel; } - channels() : IPCChannel[] { return this._channels; } + channels() : IPCChannel[] { return this.registeredChannels; } deleteChannel(channel: IPCChannel) { - this._channels = this._channels.filter(e => e !== channel); + this.registeredChannels = this.registeredChannels.filter(e => e !== channel); } private _query_results: {[key: string]:ProcessQueryResponse[]} = {}; @@ -178,7 +179,7 @@ export abstract class BasicIPCHandler { register_certificate_accept_callback(callback: () => any) : string { const id = uuidv4(); this._cert_accept_callbacks[id] = callback; - return this.unique_id + ":" + id; + return this.localUniqueId + ":" + id; } private _cert_accept_succeeded: {[sender: string]:(() => any)} = {}; @@ -250,13 +251,17 @@ class BroadcastChannelIPC extends BasicIPCHandler { sendMessage(type: string, data: any, target?: string) { const message: BroadcastMessage = {} as any; - message.sender = this.unique_id; + message.sender = this.localUniqueId; message.receiver = target ? target : BasicIPCHandler.BROADCAST_UNIQUE_ID; message.timestamp = Date.now(); message.type = type; message.data = data; - this.channel.postMessage(JSON.stringify(message)); + if(message.receiver === this.localUniqueId) { + this.handleMessage(message); + } else { + this.channel.postMessage(JSON.stringify(message)); + } } } @@ -277,7 +282,7 @@ export function setup() { connect_handler.setup(); } -export function getInstance() { +export function getIpcInstance() { return handler; } diff --git a/shared/js/text/markdown.ts b/shared/js/text/markdown.ts index 4660c957..e75cc7f6 100644 --- a/shared/js/text/markdown.ts +++ b/shared/js/text/markdown.ts @@ -14,7 +14,6 @@ import { TextToken, Token } from "remarkable/lib"; -import {escapeBBCode} from "../text/bbcode"; import {tr} from "tc-shared/i18n/localize"; const { Remarkable } = require("remarkable"); @@ -29,7 +28,7 @@ export class MD2BBCodeRenderer { "hardbreak": () => "\n", "paragraph_open": () => "", - "paragraph_close": (_, token: ParagraphCloseToken) => token.tight ? "" : "[br]", + "paragraph_close": (_, token: ParagraphCloseToken) => token.tight ? "" : "\n", "strong_open": () => "[b]", "strong_close": () => "[/b]", @@ -119,7 +118,7 @@ export class MD2BBCodeRenderer { if(tokens[index].lines?.length) { while(this.currentLineCount < tokens[index].lines[0]) { this.currentLineCount += 1; - result += "[br]"; + result += "\n"; } } diff --git a/shared/js/tree/ChannelTree.tsx b/shared/js/tree/ChannelTree.tsx index bc83a999..67e8dd97 100644 --- a/shared/js/tree/ChannelTree.tsx +++ b/shared/js/tree/ChannelTree.tsx @@ -26,6 +26,8 @@ import {ChannelTreePopoutController} from "tc-shared/ui/tree/popout/Controller"; import {Settings, settings} from "tc-shared/settings"; import {ClientIcon} from "svg-sprites/client-icons"; +import "./EntryTagsHandler"; + export interface ChannelTreeEvents { /* general tree notified */ notify_tree_reset: {}, diff --git a/shared/js/tree/EntryTagsHandler.ts b/shared/js/tree/EntryTagsHandler.ts new file mode 100644 index 00000000..54f26673 --- /dev/null +++ b/shared/js/tree/EntryTagsHandler.ts @@ -0,0 +1,101 @@ +import * as loader from "tc-loader"; +import {Stage} from "tc-loader"; +import {getIpcInstance} from "tc-shared/ipc/BrowserIPC"; +import {LogCategory, logWarn} from "tc-shared/log"; +import {server_connections} from "tc-shared/ConnectionManager"; + +const kIpcChannel = "entry-tags"; + +function handleIpcMessage(type: string, payload: any) { + switch (type) { + case "contextmenu-client": { + const { + handlerId, + + clientUniqueId, + clientId, + clientDatabaseId, + + pageX, + pageY + } = payload; + + if(typeof pageX !== "number" || typeof pageY !== "number") { + logWarn(LogCategory.IPC, tr("Received client context menu action with an invalid page coordinated: %ox%o."), pageX, pageY); + return; + } + + if(typeof handlerId !== "string") { + logWarn(LogCategory.IPC, tr("Received client context menu action with an invalid handler id: %o."), handlerId); + return; + } + + if(typeof clientUniqueId !== "string") { + logWarn(LogCategory.IPC, tr("Received client context menu action with an invalid client unique id: %o."), clientUniqueId); + return; + } + + if(clientId !== undefined && typeof clientId !== "number") { + logWarn(LogCategory.IPC, tr("Received client context menu action with an invalid client id: %o."), clientId); + return; + } + + if(clientDatabaseId !== undefined && typeof clientDatabaseId !== "number") { + logWarn(LogCategory.IPC, tr("Received client context menu action with an invalid client database id: %o."), clientDatabaseId); + return; + } + + const handler = server_connections.findConnection(handlerId); + if(!handler) { return; } + + let clients = handler.channelTree.clients.filter(client => client.properties.client_unique_identifier === clientUniqueId); + if(clientId) { + clients = clients.filter(client => client.clientId() === clientId); + } + if(clientDatabaseId) { + clients = clients.filter(client => client.properties.client_database_id === clientDatabaseId); + } + + clients[0]?.showContextMenu(pageX, pageY); + break; + } + case "contextmenu-channel": { + const { + handlerId, + channelId, + + pageX, + pageY + } = payload; + + if(typeof pageX !== "number" || typeof pageY !== "number") { + logWarn(LogCategory.IPC, tr("Received channel context menu action with an invalid page coordinated: %ox%o."), pageX, pageY); + return; + } + + if(typeof handlerId !== "string") { + logWarn(LogCategory.IPC, tr("Received channel context menu action with an invalid handler id: %o."), handlerId); + return; + } + + if(typeof channelId !== "number") { + logWarn(LogCategory.IPC, tr("Received channel context menu action with an invalid channel id: %o."), channelId); + return; + } + + const handler = server_connections.findConnection(handlerId); + const channel = handler?.channelTree.findChannel(channelId); + channel?.showContextMenu(pageX, pageY); + break; + } + } +} + +loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { + name: "entry tags", + priority: 10, + function: async () => { + const channel = getIpcInstance().createChannel(undefined, kIpcChannel); + channel.messageHandler = (_remoteId, _broadcast, message) => handleIpcMessage(message.type, message.data); + } +}); \ No newline at end of file diff --git a/shared/js/ui/react-elements/external-modal/Controller.ts b/shared/js/ui/react-elements/external-modal/Controller.ts index 215556eb..2291a63b 100644 --- a/shared/js/ui/react-elements/external-modal/Controller.ts +++ b/shared/js/ui/react-elements/external-modal/Controller.ts @@ -29,7 +29,7 @@ export abstract class AbstractExternalModalController extends EventControllerBas this.modalType = modal; this.userData = userData; - this.ipcChannel = ipc.getInstance().createChannel(); + this.ipcChannel = ipc.getIpcInstance().createChannel(); this.ipcChannel.messageHandler = this.handleIPCMessage.bind(this); this.documentUnloadListener = () => this.destroy(); @@ -109,7 +109,7 @@ export abstract class AbstractExternalModalController extends EventControllerBas this.doDestroyWindow(); if(this.ipcChannel) { - ipc.getInstance().deleteChannel(this.ipcChannel); + ipc.getIpcInstance().deleteChannel(this.ipcChannel); } this.destroyIPC(); diff --git a/shared/js/ui/react-elements/external-modal/PopoutController.ts b/shared/js/ui/react-elements/external-modal/PopoutController.ts index ce393a99..12c6da48 100644 --- a/shared/js/ui/react-elements/external-modal/PopoutController.ts +++ b/shared/js/ui/react-elements/external-modal/PopoutController.ts @@ -1,4 +1,4 @@ -import {getInstance as getIPCInstance} from "../../../ipc/BrowserIPC"; +import {getIpcInstance as getIPCInstance} from "../../../ipc/BrowserIPC"; import {Settings, SettingsKey} from "../../../settings"; import { Controller2PopoutMessages, EventControllerBase, diff --git a/shared/js/ui/tree/EntryTags.tsx b/shared/js/ui/tree/EntryTags.tsx index bed7617c..4a39340a 100644 --- a/shared/js/ui/tree/EntryTags.tsx +++ b/shared/js/ui/tree/EntryTags.tsx @@ -1,23 +1,59 @@ import * as React from "react"; +import * as loader from "tc-loader"; +import {Stage} from "tc-loader"; +import {getIpcInstance, IPCChannel} from "tc-shared/ipc/BrowserIPC"; +import {Settings} from "tc-shared/settings"; +const kIpcChannel = "entry-tags"; const cssStyle = require("./EntryTags.scss"); -export const ClientTag = (props: { clientName: string, clientUniqueId: string, handlerId: string, clientId?: number, clientDatabaseId?: number, className?: string }) => { +let ipcChannel: IPCChannel; - return ( -
{ - event.preventDefault(); +export const ClientTag = (props: { clientName: string, clientUniqueId: string, handlerId: string, clientId?: number, clientDatabaseId?: number, className?: string }) => ( +
{ + event.preventDefault(); - /* TODO: Enable context menus */ - }} - > - {props.clientName} -
- ); -}; + ipcChannel.sendMessage("contextmenu-client", { + clientUniqueId: props.clientUniqueId, + handlerId: props.handlerId, + clientId: props.clientId, + clientDatabaseId: props.clientDatabaseId, -export const ChannelTag = (props: { channelName: string, channelId: number, handlerId: string, className?: string }) => { + pageX: event.pageX, + pageY: event.pageY + }); + }} + > + {props.clientName} +
+); - return
{props.channelName}
; -}; \ No newline at end of file +export const ChannelTag = (props: { channelName: string, channelId: number, handlerId: string, className?: string }) => ( +
{ + event.preventDefault(); + + ipcChannel.sendMessage("contextmenu-channel", { + handlerId: props.handlerId, + channelId: props.channelId, + + pageX: event.pageX, + pageY: event.pageY + }); + }} + > + {props.channelName} +
+); + + +loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { + name: "entry tags", + priority: 10, + function: async () => { + const ipc = getIpcInstance(); + ipcChannel = ipc.createChannel(Settings.instance.static(Settings.KEY_IPC_REMOTE_ADDRESS, ipc.getLocalAddress()), kIpcChannel); + } +}); \ No newline at end of file diff --git a/web/app/ExternalModalFactory.ts b/web/app/ExternalModalFactory.ts index 3ca3773c..e7e70593 100644 --- a/web/app/ExternalModalFactory.ts +++ b/web/app/ExternalModalFactory.ts @@ -87,7 +87,7 @@ export class ExternalModalController extends AbstractExternalModalController { "chunk": "modal-external", "modal-target": this.modalType, "ipc-channel": this.ipcChannel.channelId, - "ipc-address": ipc.getInstance().getLocalAddress(), + "ipc-address": ipc.getIpcInstance().getLocalAddress(), "disableGlobalContextMenu": __build.mode === "debug" ? 1 : 0, "loader-abort": __build.mode === "debug" ? 1 : 0, }; diff --git a/web/app/ui/context-menu/Ipc.ts b/web/app/ui/context-menu/Ipc.ts index dcad3e97..f35a0b0a 100644 --- a/web/app/ui/context-menu/Ipc.ts +++ b/web/app/ui/context-menu/Ipc.ts @@ -26,7 +26,7 @@ class IPCContextMenu implements ContextMenuFactory { private closeCallback: () => void; constructor() { - this.ipcChannel = ipc.getInstance().createChannel(undefined, kIPCContextMenuChannel); + this.ipcChannel = ipc.getIpcInstance().createChannel(undefined, kIPCContextMenuChannel); this.ipcChannel.messageHandler = this.handleIpcMessage.bind(this); /* if we're just created we're the focused window ;) */ From a3b9b1b11e7e0817643821ece0d618837ea62be9 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Wed, 9 Dec 2020 15:50:58 +0100 Subject: [PATCH 06/37] Allowing to drag client tags --- ChangeLog.md | 1 + shared/js/conversations/AbstractConversion.ts | 16 ++-- .../ChannelConversationManager.ts | 16 ++-- shared/js/ui/tree/Controller.tsx | 31 ++++++- shared/js/ui/tree/Definitions.ts | 27 +++++- shared/js/ui/tree/DragHelper.ts | 83 +++++-------------- shared/js/ui/tree/EntryTags.tsx | 31 +++++++ shared/js/ui/tree/RendererDataProvider.tsx | 69 +++++++++++++-- 8 files changed, 184 insertions(+), 90 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index 9a39b3f2..5df04a8f 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -5,6 +5,7 @@ - Improved channel conversation mode detection support - Added support for HTML encoded links (Example would be when copying from Edge the URL) - Enabled context menus for all clickable client tags + - Allowing to drag client tags * **08.12.20** - Fixed the permission editor not resolving unique ids diff --git a/shared/js/conversations/AbstractConversion.ts b/shared/js/conversations/AbstractConversion.ts index 26cc04ec..9960f4b5 100644 --- a/shared/js/conversations/AbstractConversion.ts +++ b/shared/js/conversations/AbstractConversion.ts @@ -215,17 +215,19 @@ export abstract class AbstractChat { return this.conversationMode === ChannelConversationMode.Private; } - protected setConversationMode(mode: ChannelConversationMode) { + protected setConversationMode(mode: ChannelConversationMode, logChange: boolean) { if(this.conversationMode === mode) { return; } - this.registerChatEvent({ - type: "mode-changed", - uniqueId: guid() + "-mode-change", - timestamp: Date.now(), - newMode: mode === ChannelConversationMode.Public ? "normal" : mode === ChannelConversationMode.Private ? "private" : "none" - }, true); + if(logChange) { + this.registerChatEvent({ + type: "mode-changed", + uniqueId: guid() + "-mode-change", + timestamp: Date.now(), + newMode: mode === ChannelConversationMode.Public ? "normal" : mode === ChannelConversationMode.Private ? "private" : "none" + }, true); + } this.conversationMode = mode; this.events.fire("notify_conversation_mode_changed", { newMode: mode }); diff --git a/shared/js/conversations/ChannelConversationManager.ts b/shared/js/conversations/ChannelConversationManager.ts index 1c2f08d2..41186133 100644 --- a/shared/js/conversations/ChannelConversationManager.ts +++ b/shared/js/conversations/ChannelConversationManager.ts @@ -165,18 +165,18 @@ export class ChannelConversation extends AbstractChat break; case "private": - this.setConversationMode(ChannelConversationMode.Private); + this.setConversationMode(ChannelConversationMode.Private, true); this.setCurrentMode("normal"); break; case "success": - this.setConversationMode(ChannelConversationMode.Public); + this.setConversationMode(ChannelConversationMode.Public, true); this.setCurrentMode("normal"); break; case "unsupported": this.crossChannelChatSupported = false; - this.setConversationMode(ChannelConversationMode.Private); + this.setConversationMode(ChannelConversationMode.Private, true); this.setCurrentMode("normal"); break; } @@ -295,8 +295,8 @@ export class ChannelConversation extends AbstractChat this.handle.connection.settings.changeServer(Settings.FN_CHANNEL_CHAT_READ(this.conversationId), timestamp); } - public setConversationMode(mode: ChannelConversationMode) { - super.setConversationMode(mode); + public setConversationMode(mode: ChannelConversationMode, logChange: boolean) { + super.setConversationMode(mode, logChange); } public localClientSwitchedChannel(type: "join" | "leave") { @@ -356,7 +356,7 @@ export class ChannelConversationManager extends AbstractChatManager { - const entry = channelTree.findEntryId(entryId); + if(entryId.type !== "client") { return; } + + let entry: ServerEntry | ChannelEntry | ClientEntry; + if("uniqueTreeId" in entryId) { + entry = channelTree.findEntryId(entryId.uniqueTreeId); + } else { + let clients = channelTree.clients.filter(client => client.properties.client_unique_identifier === entryId.clientUniqueId); + if(entryId.clientId) { + clients = clients.filter(client => client.clientId() === entryId.clientId); + } + + if(entryId.clientDatabaseId) { + clients = clients.filter(client => client.properties.client_database_id === entryId.clientDatabaseId); + } + + if(clients.length === 1) { + entry = clients[0]; + } + } + if(!entry || !(entry instanceof ClientEntry)) { logWarn(LogCategory.CHANNEL, tr("Received client move notify with an entry id which isn't a client. Entry id: %o"), entryId); return undefined; @@ -808,7 +827,15 @@ export function initializeChannelTreeController(events: Registry { - const entry = channelTree.findEntryId(entryId); + if(entryId.type !== "channel") { return; } + + let entry: ServerEntry | ChannelEntry | ClientEntry; + if("uniqueTreeId" in entryId) { + entry = channelTree.findEntryId(entryId.uniqueTreeId); + } else { + entry = channelTree.findChannel(entryId.channelId); + } + if(!entry || !(entry instanceof ChannelEntry)) { logWarn(LogCategory.CHANNEL, tr("Received channel move notify with a channel id which isn't a channel. Entry id: %o"), entryId); return undefined; diff --git a/shared/js/ui/tree/Definitions.ts b/shared/js/ui/tree/Definitions.ts index 5570f38a..14f48478 100644 --- a/shared/js/ui/tree/Definitions.ts +++ b/shared/js/ui/tree/Definitions.ts @@ -37,8 +37,8 @@ export interface ChannelTreeUIEvents { action_channel_open_file_browser: { treeEntryId: number }, action_client_double_click: { treeEntryId: number }, action_client_name_submit: { treeEntryId: number, name: string }, - action_move_clients: { targetTreeEntry: number, entries: number[] }, - action_move_channels: { targetTreeEntry: number, mode: "before" | "after" | "child", entries: number[] }, + action_move_clients: { targetTreeEntry: number, entries: ChannelTreeDragEntry[] }, + action_move_channels: { targetTreeEntry: number, mode: "before" | "after" | "child", entries: ChannelTreeDragEntry[] }, /* queries */ query_tree_entries: {}, @@ -82,11 +82,30 @@ export interface ChannelTreeUIEvents { notify_destroy: {} } +export type ChannelTreeDragEntry = { + type: "channel", + uniqueTreeId: number, +} | { + type: "channel", + channelId: number +} | { + type: "server" +} | { + type: "client", + + uniqueTreeId: number, +} | { + type: "client", + + clientUniqueId: string, + clientId?: number, + clientDatabaseId?: number +}; + export type ChannelTreeDragData = { version: 1, handlerId: string, type: string, - entryIds: number[], - entryTypes: ("server" | "channel" | "client")[] + entries: ChannelTreeDragEntry[], }; \ No newline at end of file diff --git a/shared/js/ui/tree/DragHelper.ts b/shared/js/ui/tree/DragHelper.ts index 86de226a..d606d09f 100644 --- a/shared/js/ui/tree/DragHelper.ts +++ b/shared/js/ui/tree/DragHelper.ts @@ -1,4 +1,3 @@ -import {RDPChannel, RDPChannelTree, RDPClient, RDPEntry, RDPServer} from "./RendererDataProvider"; import * as loader from "tc-loader"; import {Stage} from "tc-loader"; import { @@ -9,7 +8,7 @@ import { spriteWidth as kClientSpriteWidth, } from "svg-sprites/client-icons"; import {LogCategory, logDebug} from "tc-shared/log"; -import {ChannelTreeDragData} from "tc-shared/ui/tree/Definitions"; +import {ChannelTreeDragData, ChannelTreeDragEntry} from "tc-shared/ui/tree/Definitions"; let spriteImage: HTMLImageElement; @@ -29,7 +28,12 @@ function paintClientIcon(context: CanvasRenderingContext2D, icon: ClientIcon, of context.drawImage(spriteImage, sprite.xOffset, sprite.yOffset, sprite.width, sprite.height, offsetX, offsetY, width, height); } -export function generateDragElement(entries: RDPEntry[]) : HTMLElement { +export type DragImageEntryType = { + icon: ClientIcon, + name: string +} + +export function generateDragElement(entries: DragImageEntryType[]) : HTMLElement { const totalHeight = entries.length * 18 + 2; /* the two extra for "low" letters like "gyj" etc. */ const totalWidth = 250; @@ -37,44 +41,19 @@ export function generateDragElement(entries: RDPEntry[]) : HTMLElement { let offsetX = 20; const canvas = document.createElement("canvas"); + document.body.appendChild(canvas); canvas.height = totalHeight; canvas.width = totalWidth; /* TODO: With font size? */ + const ctx = canvas.getContext("2d"); + { - const ctx = canvas.getContext("2d"); ctx.textAlign = "left"; ctx.textBaseline = "bottom"; ctx.font = "700 16px Roboto, Helvetica, Arial, sans-serif"; for(const entry of entries) { - let name: string; - let icon: ClientIcon; - - if(entry instanceof RDPClient) { - name = entry.name.name; - icon = entry.status; - } else if(entry instanceof RDPChannel) { - name = entry.info?.name; - icon = entry.icon; - } else if(entry instanceof RDPServer) { - icon = ClientIcon.ServerGreen; - - switch (entry.state.state) { - case "connected": - name = entry.state.name; - break; - - case "disconnected": - name = tr("Not connected"); - break; - - case "connecting": - name = tr("Connecting"); - break; - } - } - /* ctx.strokeStyle = "red"; ctx.moveTo(offsetX, offsetY); @@ -83,8 +62,8 @@ export function generateDragElement(entries: RDPEntry[]) : HTMLElement { */ ctx.fillStyle = "black"; - paintClientIcon(ctx, icon, offsetX + 1, offsetY + 1, 16, 16); - ctx.fillText(name, offsetX + 20, offsetY + 19); + paintClientIcon(ctx, entry.icon, offsetX + 1, offsetY + 1, 16, 16); + ctx.fillText(entry.name, offsetX + 20, offsetY + 19); offsetY += 18; @@ -98,9 +77,9 @@ export function generateDragElement(entries: RDPEntry[]) : HTMLElement { } canvas.style.position = "absolute"; - canvas.style.left = "-100000000px"; - canvas.style.top = (Math.random() * 1000000).toFixed(0) + "px"; - document.body.appendChild(canvas); + canvas.style.zIndex = "100000"; + canvas.style.top = "0"; + canvas.style.left = -canvas.width + "px"; setTimeout(() => { canvas.remove(); @@ -113,43 +92,19 @@ const kDragDataType = "application/x-teaspeak-channel-move"; const kDragHandlerPrefix = "application/x-teaspeak-handler-"; const kDragTypePrefix = "application/x-teaspeak-type-"; -export function setupDragData(transfer: DataTransfer, tree: RDPChannelTree, entries: RDPEntry[], type: string) { +export function setupDragData(transfer: DataTransfer, handlerId: string, entries: ChannelTreeDragEntry[], type: string) { let data: ChannelTreeDragData = { version: 1, - handlerId: tree.handlerId, - entryIds: entries.map(e => e.entryId), - entryTypes: entries.map(entry => { - if(entry instanceof RDPServer) { - return "server"; - } else if(entry instanceof RDPClient) { - return "client"; - } else { - return "channel"; - } - }), + handlerId: handlerId, + entries: entries, type: type }; transfer.effectAllowed = "all" transfer.dropEffect = "move"; - transfer.setData(kDragHandlerPrefix + tree.handlerId, ""); + transfer.setData(kDragHandlerPrefix + handlerId, ""); transfer.setData(kDragTypePrefix + type, ""); transfer.setData(kDragDataType, JSON.stringify(data)); - - { - let texts = []; - for(const entry of entries) { - if(entry instanceof RDPClient) { - texts.push(entry.name?.name); - } else if(entry instanceof RDPChannel) { - texts.push(entry.info?.name); - } else if(entry instanceof RDPServer) { - texts.push(entry.state.state === "connected" ? entry.state.name : undefined); - } - } - transfer.setData("text/plain", texts.filter(e => !!e).join(", ")); - } - /* TODO: Other things as well! */ } export function parseDragData(transfer: DataTransfer) : ChannelTreeDragData | undefined { diff --git a/shared/js/ui/tree/EntryTags.tsx b/shared/js/ui/tree/EntryTags.tsx index 4a39340a..e88f145a 100644 --- a/shared/js/ui/tree/EntryTags.tsx +++ b/shared/js/ui/tree/EntryTags.tsx @@ -3,6 +3,8 @@ import * as loader from "tc-loader"; import {Stage} from "tc-loader"; import {getIpcInstance, IPCChannel} from "tc-shared/ipc/BrowserIPC"; import {Settings} from "tc-shared/settings"; +import {generateDragElement, setupDragData} from "tc-shared/ui/tree/DragHelper"; +import {ClientIcon} from "svg-sprites/client-icons"; const kIpcChannel = "entry-tags"; const cssStyle = require("./EntryTags.scss"); @@ -24,6 +26,22 @@ export const ClientTag = (props: { clientName: string, clientUniqueId: string, h pageY: event.pageY }); }} + draggable={true} + onDragStart={event => { + /* clients only => move */ + event.dataTransfer.effectAllowed = "move"; /* prohibit copying */ + event.dataTransfer.dropEffect = "move"; + event.dataTransfer.setDragImage(generateDragElement([{ icon: ClientIcon.PlayerOn, name: props.clientName }]), 0, 6); + setupDragData(event.dataTransfer, props.handlerId, [ + { + type: "client", + clientUniqueId: props.clientUniqueId, + clientId: props.clientId, + clientDatabaseId: props.clientDatabaseId + } + ], "client"); + event.dataTransfer.setData("text/plain", props.clientName); + }} > {props.clientName}
@@ -43,6 +61,19 @@ export const ChannelTag = (props: { channelName: string, channelId: number, hand pageY: event.pageY }); }} + draggable={true} + onDragStart={event => { + event.dataTransfer.effectAllowed = "all"; + event.dataTransfer.dropEffect = "move"; + event.dataTransfer.setDragImage(generateDragElement([{ icon: ClientIcon.ChannelGreen, name: props.channelName }]), 0, 6); + setupDragData(event.dataTransfer, props.handlerId, [ + { + type: "channel", + channelId: props.channelId + } + ], "channel"); + event.dataTransfer.setData("text/plain", props.channelName); + }} > {props.channelName}
diff --git a/shared/js/ui/tree/RendererDataProvider.tsx b/shared/js/ui/tree/RendererDataProvider.tsx index 9d7cec2e..9cab7eb5 100644 --- a/shared/js/ui/tree/RendererDataProvider.tsx +++ b/shared/js/ui/tree/RendererDataProvider.tsx @@ -1,7 +1,7 @@ import {EventHandler, Registry} from "tc-shared/events"; import { ChannelEntryInfo, - ChannelIcons, + ChannelIcons, ChannelTreeDragEntry, ChannelTreeUIEvents, ClientIcons, ClientNameInfo, @@ -22,7 +22,13 @@ import { RendererClient } from "tc-shared/ui/tree/RendererClient"; import {ServerRenderer} from "tc-shared/ui/tree/RendererServer"; -import {generateDragElement, getDragInfo, parseDragData, setupDragData} from "tc-shared/ui/tree/DragHelper"; +import { + DragImageEntryType, + generateDragElement, + getDragInfo, + parseDragData, + setupDragData +} from "tc-shared/ui/tree/DragHelper"; import {createErrorModal} from "tc-shared/ui/elements/Modal"; function isEquivalent(a, b) { @@ -64,6 +70,25 @@ function isEquivalent(a, b) { } } +function generateDragElementFromRdp(entries: RDPEntry[]) : HTMLElement { + return generateDragElement(entries.map(entry => { + if(entry instanceof RDPClient) { + return { name: entry.name?.name, icon: entry.status }; + } else if(entry instanceof RDPChannel) { + return { name: entry.info?.name, icon: entry.icon }; + } else if(entry instanceof RDPServer) { + switch (entry.state.state) { + case "connected": + return { name: entry.state.name, icon: ClientIcon.ServerGreen }; + case "disconnected": + return { name: tr("Not connected"), icon: ClientIcon.ServerGreen }; + case "connecting": + return { name: tr("Connecting"), icon: ClientIcon.ServerGreen }; + } + } + })); +} + /** * auto := Select/unselect/add/remove depending on the selected state & shift key state * exclusive := Only selected these entries @@ -476,8 +501,38 @@ export class RDPChannelTree { } event.dataTransfer.dropEffect = "move"; - event.dataTransfer.setDragImage(generateDragElement(entries), 0, 6); - setupDragData(event.dataTransfer, this, entries, dragType); + event.dataTransfer.setDragImage(generateDragElementFromRdp(entries), 0, 6); + setupDragData(event.dataTransfer, this.handlerId, entries.map(entry => { + if(entry instanceof RDPClient) { + return { + type: "client", + uniqueTreeId: entry.entryId + }; + } else if(entry instanceof RDPChannel) { + return { + type: "channel", + uniqueTreeId: entry.entryId + }; + } else if(entry instanceof RDPServer) { + return { + type: "server", + }; + } + }).filter(entry => !!entry), dragType); + + { + let texts = []; + for(const entry of entries) { + if(entry instanceof RDPClient) { + texts.push(entry.name?.name); + } else if(entry instanceof RDPChannel) { + texts.push(entry.info?.name); + } else if(entry instanceof RDPServer) { + texts.push(entry.state.state === "connected" ? entry.state.name : undefined); + } + } + event.dataTransfer.setData("text/plain", texts.filter(e => !!e).join(", ")); + } } @@ -565,7 +620,7 @@ export class RDPChannelTree { } this.events.fire("action_move_clients", { - entries: data.entryIds, + entries: data.entries, targetTreeEntry: target.entryId }); } else if(data.type === "channel") { @@ -577,14 +632,14 @@ export class RDPChannelTree { return; } - if(data.entryIds.indexOf(target.entryId) !== -1) { + if(data.entries.findIndex(entry => entry.type === "channel" && "uniqueTreeId" in entry && entry.uniqueTreeId === target.entryId) !== -1) { return; } this.events.fire("action_move_channels", { targetTreeEntry: target.entryId, mode: currentDragHint === "contain" ? "child" : currentDragHint === "top" ? "before" : "after", - entries: data.entryIds + entries: data.entries }); } } From 077a788a3fbea2b83c66b41a68bc43bfb0a09588 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Wed, 9 Dec 2020 16:02:38 +0100 Subject: [PATCH 07/37] Fixed a compiling error --- shared/js/tree/Server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/js/tree/Server.ts b/shared/js/tree/Server.ts index 93bc62c4..4154e891 100644 --- a/shared/js/tree/Server.ts +++ b/shared/js/tree/Server.ts @@ -192,7 +192,7 @@ export class ServerEntry extends ChannelTreeEntry { icon_class: "client-channel_switch", name: tr("Join server text channel"), callback: () => { - this.channelTree.client.getChannelConversations().setSelectedConversation(0); + this.channelTree.client.getChannelConversations().setSelectedConversation(this.channelTree.client.getChannelConversations().findOrCreateConversation(0)); this.channelTree.client.side_bar.showChannelConversations(); }, visible: !settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT) From c7a01ce79ac7596c5cd6d8148884fe8587efb8af Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Wed, 9 Dec 2020 16:14:51 +0100 Subject: [PATCH 08/37] Fixed the context menu within popout windows for the web client --- ChangeLog.md | 1 + web/app/index-external.ts | 4 ++++ webpack-web.config.ts | 3 ++- 3 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 web/app/index-external.ts diff --git a/ChangeLog.md b/ChangeLog.md index 5df04a8f..3d46bcbf 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -6,6 +6,7 @@ - Added support for HTML encoded links (Example would be when copying from Edge the URL) - Enabled context menus for all clickable client tags - Allowing to drag client tags + - Fixed the context menu within popout windows for the web client * **08.12.20** - Fixed the permission editor not resolving unique ids diff --git a/web/app/index-external.ts b/web/app/index-external.ts new file mode 100644 index 00000000..12a2f66b --- /dev/null +++ b/web/app/index-external.ts @@ -0,0 +1,4 @@ +/* This is the entry point file for the external modals */ + +import "./ui/context-menu"; +import "tc-shared/ui/react-elements/external-modal/PopoutEntrypoint" \ No newline at end of file diff --git a/webpack-web.config.ts b/webpack-web.config.ts index 253870de..637a1992 100644 --- a/webpack-web.config.ts +++ b/webpack-web.config.ts @@ -4,7 +4,8 @@ const WasmPackPlugin = require("@wasm-tool/wasm-pack-plugin"); export = () => config_base.config("web").then(config => { Object.assign(config.entry, { - "shared-app": "./web/app/index.ts" + "shared-app": "./web/app/index.ts", + "modal-external": "./web/app/index-external.ts" }); Object.assign(config.resolve.alias, { From 3d02669d20eba80bb2d8d9cdc1e72a6d516caddb Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Wed, 9 Dec 2020 18:45:11 +0100 Subject: [PATCH 09/37] Reworked the side bar algorithm. This should heavily improve the memory footprint especially on muti connection sessions --- shared/js/ConnectionManager.ts | 7 +- shared/js/settings.ts | 7 + shared/js/text/bbcode/highlight.tsx | 4 + shared/js/tree/Client.ts | 2 +- shared/js/ui/frames/SideBar.scss | 37 ++++ shared/js/ui/frames/SideBarDefinitions.ts | 38 ++++ shared/js/ui/frames/SideBarRenderer.tsx | 132 ++++++++++++++ shared/js/ui/frames/chat_frame.ts | 170 ++++++++++-------- .../side/AbstractConversationRenderer.tsx | 2 + .../js/ui/frames/side/ClientInfoController.ts | 75 ++++---- .../ui/frames/side/ClientInfoDefinitions.ts | 2 +- .../js/ui/frames/side/ClientInfoRenderer.tsx | 5 +- shared/js/ui/frames/side/HeaderController.ts | 76 ++++---- shared/js/ui/frames/side/HeaderDefinitions.ts | 10 +- shared/js/ui/frames/side/HeaderRenderer.tsx | 21 ++- .../side/PrivateConversationRenderer.tsx | 6 +- 16 files changed, 423 insertions(+), 171 deletions(-) create mode 100644 shared/js/ui/frames/SideBar.scss create mode 100644 shared/js/ui/frames/SideBarDefinitions.ts create mode 100644 shared/js/ui/frames/SideBarRenderer.tsx diff --git a/shared/js/ConnectionManager.ts b/shared/js/ConnectionManager.ts index 62a4afd4..9a41ebb9 100644 --- a/shared/js/ConnectionManager.ts +++ b/shared/js/ConnectionManager.ts @@ -32,8 +32,8 @@ export class ConnectionManager { private _container_log_server: JQuery; private _container_channel_tree: JQuery; private _container_hostbanner: JQuery; - private _container_chat: JQuery; private containerChannelVideo: ReplaceableContainer; + private containerSideBar: HTMLDivElement; private containerFooter: HTMLDivElement; constructor() { @@ -41,10 +41,10 @@ export class ConnectionManager { this.event_registry.enableDebug("connection-manager"); this.containerChannelVideo = new ReplaceableContainer(document.getElementById("channel-video") as HTMLDivElement); + this.containerSideBar = document.getElementById("chat") as HTMLDivElement; this._container_log_server = $("#server-log"); this._container_channel_tree = $("#channelTree"); this._container_hostbanner = $("#hostbanner"); - this._container_chat = $("#chat"); this.containerFooter = document.getElementById("container-footer") as HTMLDivElement; this.set_active_connection(undefined); @@ -112,7 +112,6 @@ export class ConnectionManager { private set_active_connection_(handler: ConnectionHandler) { this._container_channel_tree.children().detach(); - this._container_chat.children().detach(); this._container_log_server.children().detach(); this._container_hostbanner.children().detach(); this.containerChannelVideo.replaceWith(handler?.video_frame.getContainer()); @@ -120,8 +119,8 @@ export class ConnectionManager { if(handler) { this._container_hostbanner.append(handler.hostbanner.html_tag); this._container_channel_tree.append(handler.channelTree.tag_tree()); - this._container_chat.append(handler.side_bar.html_tag()); this._container_log_server.append(handler.log.getHTMLTag()); + handler.side_bar.renderInto(this.containerSideBar); } const old_handler = this.active_handler; this.active_handler = handler; diff --git a/shared/js/settings.ts b/shared/js/settings.ts index 238a94db..ce660b26 100644 --- a/shared/js/settings.ts +++ b/shared/js/settings.ts @@ -369,6 +369,13 @@ export class Settings extends StaticSettings { valueType: "boolean", }; + static readonly KEY_CHAT_HIGHLIGHT_CODE: ValuedSettingsKey = { + key: 'chat_highlight_code', + defaultValue: true, + description: 'Enables code highlighting within the chat (Client restart required)', + valueType: "boolean", + }; + static readonly KEY_CHAT_TAG_URLS: ValuedSettingsKey = { key: 'chat_tag_urls', defaultValue: true, diff --git a/shared/js/text/bbcode/highlight.tsx b/shared/js/text/bbcode/highlight.tsx index 60469127..c0df10ed 100644 --- a/shared/js/text/bbcode/highlight.tsx +++ b/shared/js/text/bbcode/highlight.tsx @@ -11,6 +11,7 @@ import {rendererReact, rendererText} from "tc-shared/text/bbcode/renderer"; import {MenuEntryType, spawn_context_menu} from "tc-shared/ui/elements/ContextMenu"; import '!style-loader!css-loader!highlight.js/styles/darcula.css'; +import {Settings, settings} from "tc-shared/settings"; const registerLanguage = (name, language: Promise) => { language.then(lan => hljs.registerLanguage(name, lan)).catch(error => { @@ -86,6 +87,9 @@ loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, { function: async () => { let reactId = 0; + if(!settings.static_global(Settings.KEY_CHAT_HIGHLIGHT_CODE)) { + return; + } /* override default parser */ rendererReact.registerCustomRenderer(new class extends ElementRenderer { tags(): string | string[] { diff --git a/shared/js/tree/Client.ts b/shared/js/tree/Client.ts index 349e03a9..9e2b6693 100644 --- a/shared/js/tree/Client.ts +++ b/shared/js/tree/Client.ts @@ -524,7 +524,7 @@ export class ClientEntry extends ChannelTreeEntry { conversation.setActiveClientEntry(this); privateConversations.setSelectedConversation(conversation); sideBar.showPrivateConversations(); - sideBar.private_conversations().focusInput(); + sideBar.privateConversationsController().focusInput(); } showContextMenu(x: number, y: number, on_close: () => void = undefined) { diff --git a/shared/js/ui/frames/SideBar.scss b/shared/js/ui/frames/SideBar.scss new file mode 100644 index 00000000..3ddd2a49 --- /dev/null +++ b/shared/js/ui/frames/SideBar.scss @@ -0,0 +1,37 @@ +.rendererContainer { + flex-grow: 1; + flex-shrink: 1; + display: flex; + flex-direction: column; + justify-content: stretch; + + min-height: 200px; + min-width: 200px; +} + +.container { + flex-grow: 1; + flex-shrink: 1; + + display: flex; + flex-direction: column; + justify-content: stretch; + + min-height: 200px; +} + +.frameContainer { + width: 100%; + + flex-grow: 1; + flex-shrink: 1; + + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; + + min-width: 350px; + min-height: 16em; + + display: flex; + flex-direction: column; +} \ No newline at end of file diff --git a/shared/js/ui/frames/SideBarDefinitions.ts b/shared/js/ui/frames/SideBarDefinitions.ts new file mode 100644 index 00000000..737c42ec --- /dev/null +++ b/shared/js/ui/frames/SideBarDefinitions.ts @@ -0,0 +1,38 @@ +import {Registry} from "tc-shared/events"; +import {PrivateConversationUIEvents} from "tc-shared/ui/frames/side/PrivateConversationDefinitions"; +import {ConversationUIEvents} from "tc-shared/ui/frames/side/ConversationDefinitions"; +import {ClientInfoEvents} from "tc-shared/ui/frames/side/ClientInfoDefinitions"; + +/* TODO: Somehow outsource the event registries to IPC? */ + +export type SideBarType = "none" | "channel-chat" | "private-chat" | "client-info" | "music-manage"; +export interface SideBarTypeData { + "none": {}, + "channel-chat": { + events: Registry, + handlerId: string + }, + "private-chat": { + events: Registry, + handlerId: string + }, + "client-info": { + events: Registry, + }, + "music-manage": { + + } +} + +export type SideBarNotifyContentData = { + content: T, + data: SideBarTypeData[T] +} + +export interface SideBarEvents { + query_content: {}, + query_content_data: { content: SideBarType }, + + notify_content: { content: SideBarType }, + notify_content_data: SideBarNotifyContentData +} \ No newline at end of file diff --git a/shared/js/ui/frames/SideBarRenderer.tsx b/shared/js/ui/frames/SideBarRenderer.tsx new file mode 100644 index 00000000..0b379cde --- /dev/null +++ b/shared/js/ui/frames/SideBarRenderer.tsx @@ -0,0 +1,132 @@ +import {SideHeaderEvents, SideHeaderState} from "tc-shared/ui/frames/side/HeaderDefinitions"; +import {Registry} from "tc-shared/events"; +import React = require("react"); +import {SideHeaderRenderer} from "tc-shared/ui/frames/side/HeaderRenderer"; +import {ConversationPanel} from "tc-shared/ui/frames/side/AbstractConversationRenderer"; +import {SideBarEvents, SideBarType, SideBarTypeData} from "tc-shared/ui/frames/SideBarDefinitions"; +import {useContext, useState} from "react"; +import {ClientInfoRenderer} from "tc-shared/ui/frames/side/ClientInfoRenderer"; +import {PrivateConversationsPanel} from "tc-shared/ui/frames/side/PrivateConversationRenderer"; + +const cssStyle = require("./SideBar.scss"); + +const EventContent = React.createContext>(undefined); + +function useContentData(type: T) : SideBarTypeData[T] { + const events = useContext(EventContent); + const [ contentData, setContentData ] = useState(() => { + events.fire("query_content_data", { content: type }); + return undefined; + }); + events.reactUse("notify_content_data", event => event.content === type && setContentData(event.data)); + + return contentData; +} + +const ContentRendererChannelConversation = () => { + const contentData = useContentData("channel-chat"); + if(!contentData) { return null; } + + return ( + + ); +}; + +const ContentRendererPrivateConversation = () => { + const contentData = useContentData("private-chat"); + if(!contentData) { return null; } + + return ( + + ); +}; + +const ContentRendererClientInfo = () => { + const contentData = useContentData("client-info"); + if(!contentData) { return null; } + + return ( + + ); +}; + +const SideBarFrame = (props: { type: SideBarType }) => { + switch (props.type) { + case "channel-chat": + return ; + + case "private-chat": + return ; + + case "client-info": + return ; + + case "music-manage": + /* TODO! */ + + case "none": + default: + return null; + } +} + +const SideBarHeader = (props: { type: SideBarType, eventsHeader: Registry }) => { + let headerState: SideHeaderState; + switch (props.type) { + case "none": + headerState = { state: "none" }; + break; + + case "channel-chat": + headerState = { state: "conversation", mode: "channel" }; + break; + + case "private-chat": + headerState = { state: "conversation", mode: "private" }; + break; + + case "client-info": + headerState = { state: "client" }; + break; + + case "music-manage": + headerState = { state: "music-bot" }; + break; + } + + return ; +} + +export const SideBarRenderer = (props: { + handlerId: string, + events: Registry, + eventsHeader: Registry +}) => { + const [ content, setContent ] = useState(() => { + props.events.fire("query_content"); + return "none"; + }); + props.events.reactUse("notify_content", event => setContent(event.content)); + + return ( + +
+ +
+ +
+
+
+ ) +}; \ No newline at end of file diff --git a/shared/js/ui/frames/chat_frame.ts b/shared/js/ui/frames/chat_frame.ts index 055fc736..ebbadd24 100644 --- a/shared/js/ui/frames/chat_frame.ts +++ b/shared/js/ui/frames/chat_frame.ts @@ -1,35 +1,40 @@ -import {ClientEntry, LocalClientEntry, MusicClientEntry} from "../../tree/Client"; +import {ClientEntry, MusicClientEntry} from "../../tree/Client"; import {ConnectionHandler} from "../../ConnectionHandler"; import {MusicInfo} from "../../ui/frames/side/music_info"; import {ChannelConversationController} from "./side/ChannelConversationController"; import {PrivateConversationController} from "./side/PrivateConversationController"; import {ClientInfoController} from "tc-shared/ui/frames/side/ClientInfoController"; import {SideHeader} from "tc-shared/ui/frames/side/HeaderController"; +import * as ReactDOM from "react-dom"; +import {SideBarRenderer} from "tc-shared/ui/frames/SideBarRenderer"; +import * as React from "react"; +import {SideBarEvents, SideBarType} from "tc-shared/ui/frames/SideBarDefinitions"; +import {Registry} from "tc-shared/events"; -export enum FrameContent { - NONE, - PRIVATE_CHAT, - CHANNEL_CHAT, - CLIENT_INFO, - MUSIC_BOT -} +const cssStyle = require("./SideBar.scss"); export class Frame { readonly handle: ConnectionHandler; - private htmlTag: JQuery; - private containerChannelChat: JQuery; - private _content_type: FrameContent; + private htmlTag: HTMLDivElement; + private currentType: SideBarType; + + private uiEvents: Registry; private header: SideHeader; - private clientInfo: ClientInfoController; + private musicInfo: MusicInfo; + private clientInfo: ClientInfoController; private channelConversations: ChannelConversationController; private privateConversations: PrivateConversationController; constructor(handle: ConnectionHandler) { this.handle = handle; - this._content_type = FrameContent.NONE; + this.currentType = "none"; + this.uiEvents = new Registry(); + this.uiEvents.on("query_content", () => this.uiEvents.fire_react("notify_content", { content: this.currentType })); + this.uiEvents.on("query_content_data", event => this.sendContentData(event.content)); + this.privateConversations = new PrivateConversationController(handle); this.channelConversations = new ChannelConversationController(handle); this.clientInfo = new ClientInfoController(handle); @@ -42,9 +47,7 @@ export class Frame { this.showChannelConversations(); } - html_tag() : JQuery { return this.htmlTag; } - - content_type() : FrameContent { return this._content_type; } + html_tag() : HTMLDivElement { return this.htmlTag; } destroy() { this.header?.destroy(); @@ -56,6 +59,12 @@ export class Frame { this.clientInfo?.destroy(); this.clientInfo = undefined; + this.privateConversations?.destroy(); + this.privateConversations = undefined; + + this.channelConversations?.destroy(); + this.channelConversations = undefined; + this.musicInfo && this.musicInfo.destroy(); this.musicInfo = undefined; @@ -64,19 +73,24 @@ export class Frame { this.channelConversations && this.channelConversations.destroy(); this.channelConversations = undefined; + } - this.containerChannelChat && this.containerChannelChat.remove(); - this.containerChannelChat = undefined; + renderInto(container: HTMLDivElement) { + ReactDOM.render(React.createElement(SideBarRenderer, { + key: this.handle.handlerId, + handlerId: this.handle.handlerId, + events: this.uiEvents, + eventsHeader: this.header["uiEvents"], + }), container); } private createHtmlTag() { - this.htmlTag = $("#tmpl_frame_chat").renderTag(); - this.htmlTag.find(".container-info").replaceWith(this.header.getHtmlTag()); - this.containerChannelChat = this.htmlTag.find(".container-chat"); + this.htmlTag = document.createElement("div"); + this.htmlTag.classList.add(cssStyle.container); } - private_conversations() : PrivateConversationController { + privateConversationsController() : PrivateConversationController { return this.privateConversations; } @@ -88,73 +102,81 @@ export class Frame { return this.musicInfo; } - private clearSideBar() { - this._content_type = FrameContent.NONE; - this.containerChannelChat.children().detach(); + private setCurrentContent(type: SideBarType) { + if(this.currentType === type) { + return; + } + + this.currentType = type; + this.uiEvents.fire_react("notify_content", { content: this.currentType }); + } + + private sendContentData(content: SideBarType) { + switch (content) { + case "none": + this.uiEvents.fire_react("notify_content_data", { + content: "none", + data: {} + }); + break; + + case "channel-chat": + this.uiEvents.fire_react("notify_content_data", { + content: "channel-chat", + data: { + events: this.channelConversations["uiEvents"], + handlerId: this.handle.handlerId + } + }); + break; + + case "private-chat": + this.uiEvents.fire_react("notify_content_data", { + content: "private-chat", + data: { + events: this.privateConversations["uiEvents"], + handlerId: this.handle.handlerId + } + }); + break; + + case "client-info": + this.uiEvents.fire_react("notify_content_data", { + content: "client-info", + data: { + events: this.clientInfo["uiEvents"], + } + }); + break; + + case "music-manage": + this.uiEvents.fire_react("notify_content_data", { + content: "music-manage", + data: { } + }); + break; + } } showPrivateConversations() { - if(this._content_type === FrameContent.PRIVATE_CHAT) - return; - - this.header.setState({ state: "conversation", mode: "private" }); - - this.clearSideBar(); - this._content_type = FrameContent.PRIVATE_CHAT; - this.containerChannelChat.append(this.privateConversations.htmlTag); - this.privateConversations.handlePanelShow(); + this.setCurrentContent("private-chat"); } showChannelConversations() { - if(this._content_type === FrameContent.CHANNEL_CHAT) - return; - - this.header.setState({ state: "conversation", mode: "channel" }); - - this.clearSideBar(); - this._content_type = FrameContent.CHANNEL_CHAT; - this.containerChannelChat.append(this.channelConversations.htmlTag); - this.channelConversations.handlePanelShow(); + this.setCurrentContent("channel-chat"); } showClientInfo(client: ClientEntry) { this.clientInfo.setClient(client); - this.header.setState({ state: "client", ownClient: client instanceof LocalClientEntry }); - - if(this._content_type === FrameContent.CLIENT_INFO) - return; - - this.clearSideBar(); - this._content_type = FrameContent.CLIENT_INFO; - this.containerChannelChat.append(this.clientInfo.getHtmlTag()); + this.setCurrentContent("client-info"); } showMusicPlayer(client: MusicClientEntry) { this.musicInfo.set_current_bot(client); - - if(this._content_type === FrameContent.MUSIC_BOT) - return; - - this.header.setState({ state: "music-bot" }); - this.musicInfo.previous_frame_content = this._content_type; - this.clearSideBar(); - this._content_type = FrameContent.MUSIC_BOT; - this.containerChannelChat.append(this.musicInfo.html_tag()); + this.setCurrentContent("music-manage"); } - set_content(type: FrameContent) { - if(this._content_type === type) { - return; - } - - if(type === FrameContent.CHANNEL_CHAT) { - this.showChannelConversations(); - } else if(type === FrameContent.PRIVATE_CHAT) { - this.showPrivateConversations(); - } else { - this.header.setState({ state: "none" }); - this.clearSideBar(); - this._content_type = FrameContent.NONE; - } + clearSideBar() { + this.setCurrentContent("none"); } } \ No newline at end of file diff --git a/shared/js/ui/frames/side/AbstractConversationRenderer.tsx b/shared/js/ui/frames/side/AbstractConversationRenderer.tsx index e4bd599e..3b517410 100644 --- a/shared/js/ui/frames/side/AbstractConversationRenderer.tsx +++ b/shared/js/ui/frames/side/AbstractConversationRenderer.tsx @@ -895,10 +895,12 @@ export const ConversationPanel = React.memo((props: { events: Registry { chatEnabled.current = event.state === "normal" && event.sendEnabled; updateChatBox(); }); + props.events.reactUse("notify_send_enabled", event => { if(event.chatId !== currentChat.current.id) return; diff --git a/shared/js/ui/frames/side/ClientInfoController.ts b/shared/js/ui/frames/side/ClientInfoController.ts index 8251ce79..3ab6ae46 100644 --- a/shared/js/ui/frames/side/ClientInfoController.ts +++ b/shared/js/ui/frames/side/ClientInfoController.ts @@ -3,20 +3,22 @@ import {ClientEntry, ClientType, LocalClientEntry} from "tc-shared/tree/Client"; import { ClientForumInfo, ClientGroupInfo, - ClientInfoEvents, + ClientInfoEvents, ClientInfoType, ClientStatusInfo, ClientVersionInfo } from "tc-shared/ui/frames/side/ClientInfoDefinitions"; -import * as ReactDOM from "react-dom"; -import {ClientInfoRenderer} from "tc-shared/ui/frames/side/ClientInfoRenderer"; import {Registry} from "tc-shared/events"; -import * as React from "react"; import * as i18nc from "../../../i18n/country"; import {openClientInfo} from "tc-shared/ui/modal/ModalClientInfo"; type CurrentClientInfo = { + type: ClientInfoType; name: string, + uniqueId: string, + databaseId: number, + clientId: number, + description: string, joinTimestamp: number, leaveTimestamp: number, @@ -29,12 +31,19 @@ type CurrentClientInfo = { version: ClientVersionInfo } +export interface ClientInfoControllerEvents { + notify_client_changed: { + newClient: ClientEntry | undefined + } +} + export class ClientInfoController { + readonly events: Registry; + private readonly connection: ConnectionHandler; private readonly listenerConnection: (() => void)[]; private readonly uiEvents: Registry; - private readonly htmlContainer: HTMLDivElement; private listenerClient: (() => void)[]; private currentClient: ClientEntry | undefined; @@ -42,6 +51,7 @@ export class ClientInfoController { constructor(connection: ConnectionHandler) { this.connection = connection; + this.events = new Registry(); this.uiEvents = new Registry(); this.uiEvents.enableDebug("client-info"); @@ -49,17 +59,6 @@ export class ClientInfoController { this.listenerClient = []; this.initialize(); - - this.htmlContainer = document.createElement("div"); - this.htmlContainer.style.display = "flex"; - this.htmlContainer.style.flexDirection = "column"; - this.htmlContainer.style.justifyContent = "strech"; - this.htmlContainer.style.height = "100%"; - ReactDOM.render(React.createElement(ClientInfoRenderer, { events: this.uiEvents }), this.htmlContainer); - } - - getHtmlTag() : HTMLDivElement { - return this.htmlContainer; } private initialize() { @@ -89,6 +88,7 @@ export class ClientInfoController { } this.currentClientStatus.leaveTimestamp = Date.now() / 1000; + this.currentClientStatus.clientId = 0; this.currentClient = undefined; this.unregisterClientEvents(); this.sendOnline(); @@ -102,6 +102,7 @@ export class ClientInfoController { } })) + this.uiEvents.on("query_client", () => this.sendClient()); this.uiEvents.on("query_client_name", () => this.sendClientName()); this.uiEvents.on("query_client_description", () => this.sendClientDescription()); this.uiEvents.on("query_channel_group", () => this.sendChannelGroup()); @@ -228,7 +229,12 @@ export class ClientInfoController { private initializeClientInfo(client: ClientEntry) { this.currentClientStatus = { + type: client instanceof LocalClientEntry ? "self" : client.properties.client_type === ClientType.CLIENT_QUERY ? "query" : "voice", name: client.properties.client_nickname, + databaseId: client.properties.client_database_id, + uniqueId: client.properties.client_unique_identifier, + clientId: client.clientId(), + description: client.properties.client_description, channelGroup: client.properties.client_channel_group_id, serverGroups: client.assignedServerGroupIds(), @@ -250,8 +256,6 @@ export class ClientInfoController { } destroy() { - ReactDOM.unmountComponentAtNode(this.htmlContainer); - this.listenerClient.forEach(callback => callback()); this.listenerClient = []; @@ -266,25 +270,14 @@ export class ClientInfoController { this.unregisterClientEvents(); this.currentClient = client; + this.currentClientStatus = undefined; if(this.currentClient) { this.currentClient.updateClientVariables().then(undefined); this.registerClientEvents(this.currentClient); this.initializeClientInfo(this.currentClient); - this.uiEvents.fire("notify_client", { - info: { - handlerId: this.connection.handlerId, - type: client instanceof LocalClientEntry ? "self" : client.properties.client_type === ClientType.CLIENT_QUERY ? "query" : "voice", - clientDatabaseId: client.properties.client_database_id, - clientId: client.clientId(), - clientUniqueId: client.properties.client_unique_identifier - } - }); - } else { - this.currentClientStatus = undefined; - this.uiEvents.fire("notify_client", { - info: undefined - }); } + this.sendClient(); + this.events.fire("notify_client_changed", { newClient: client }); } getClient() : ClientEntry | undefined { @@ -316,6 +309,24 @@ export class ClientInfoController { } } + private sendClient() { + if(this.currentClientStatus) { + this.uiEvents.fire_react("notify_client", { + info: { + handlerId: this.connection.handlerId, + type: this.currentClientStatus.type, + clientDatabaseId: this.currentClientStatus.databaseId, + clientId: this.currentClientStatus.clientId, + clientUniqueId: this.currentClientStatus.uniqueId + } + }); + } else { + this.uiEvents.fire_react("notify_client", { + info: undefined + }); + } + } + private sendChannelGroup() { if(typeof this.currentClientStatus === "undefined") { this.uiEvents.fire_react("notify_channel_group", { group: undefined }); diff --git a/shared/js/ui/frames/side/ClientInfoDefinitions.ts b/shared/js/ui/frames/side/ClientInfoDefinitions.ts index b31ef0df..1a0b31c8 100644 --- a/shared/js/ui/frames/side/ClientInfoDefinitions.ts +++ b/shared/js/ui/frames/side/ClientInfoDefinitions.ts @@ -61,6 +61,7 @@ export interface ClientInfoEvents { action_show_full_info: {}, action_edit_avatar: {}, + query_client: {}, query_channel_group: {}, query_server_groups: {}, query_client_name: {}, @@ -83,7 +84,6 @@ export interface ClientInfoEvents { notify_version: { version: ClientVersionInfo }, notify_forum: { forum: ClientForumInfo }, - /* reset all fields into "loading" state */ notify_client: { info: ClientInfoInfo | undefined } diff --git a/shared/js/ui/frames/side/ClientInfoRenderer.tsx b/shared/js/ui/frames/side/ClientInfoRenderer.tsx index 26a3e4c6..354fc110 100644 --- a/shared/js/ui/frames/side/ClientInfoRenderer.tsx +++ b/shared/js/ui/frames/side/ClientInfoRenderer.tsx @@ -439,7 +439,10 @@ const ServerGroupRenderer = () => { const ClientInfoProvider = () => { const events = useContext(EventsContext); - const [ client, setClient ] = useState({ type: "none", contextHash: guid() }); + const [ client, setClient ] = useState(() => { + events.fire("query_client"); + return { type: "none", contextHash: guid() }; + }); events.reactUse("notify_client", event => { if(event.info) { setClient({ diff --git a/shared/js/ui/frames/side/HeaderController.ts b/shared/js/ui/frames/side/HeaderController.ts index 32e45879..5b89dc8a 100644 --- a/shared/js/ui/frames/side/HeaderController.ts +++ b/shared/js/ui/frames/side/HeaderController.ts @@ -1,12 +1,8 @@ import {ConnectionHandler} from "tc-shared/ConnectionHandler"; -import * as ReactDOM from "react-dom"; -import {SideHeaderRenderer} from "./HeaderRenderer"; -import * as React from "react"; -import {SideHeaderEvents, SideHeaderState} from "tc-shared/ui/frames/side/HeaderDefinitions"; -import * as _ from "lodash"; +import {SideHeaderEvents} from "tc-shared/ui/frames/side/HeaderDefinitions"; import {Registry} from "tc-shared/events"; import {ChannelEntry, ChannelProperties} from "tc-shared/tree/Channel"; -import {ClientEntry, LocalClientEntry} from "tc-shared/tree/Client"; +import {LocalClientEntry} from "tc-shared/tree/Client"; import {openMusicManage} from "tc-shared/ui/modal/ModalMusicManage"; const ChannelInfoUpdateProperties: (keyof ChannelProperties)[] = [ @@ -21,9 +17,8 @@ const ChannelInfoUpdateProperties: (keyof ChannelProperties)[] = [ "channel_maxfamilyclients" ]; -/* TODO: Remove the ping interval handler. It's currently still there since the clients are not emiting the event yet */ +/* TODO: Remove the ping interval handler. It's currently still there since the clients are not emitting the event yet */ export class SideHeader { - private readonly htmlTag: HTMLDivElement; private readonly uiEvents: Registry; private connection: ConnectionHandler; @@ -32,7 +27,6 @@ export class SideHeader { private listenerVoiceChannel: (() => void)[]; private listenerTextChannel: (() => void)[]; - private currentState: SideHeaderState; private currentVoiceChannel: ChannelEntry; private currentTextChannel: ChannelEntry; @@ -44,13 +38,6 @@ export class SideHeader { this.listenerVoiceChannel = []; this.listenerTextChannel = []; - this.htmlTag = document.createElement("div"); - this.htmlTag.style.display = "flex"; - this.htmlTag.style.flexDirection = "column"; - this.htmlTag.style.flexShrink = "0"; - this.htmlTag.style.flexGrow = "0"; - - ReactDOM.render(React.createElement(SideHeaderRenderer, { events: this.uiEvents }), this.htmlTag); this.initialize(); } @@ -75,8 +62,9 @@ export class SideHeader { openMusicManage(this.connection, bot); }); - this.uiEvents.on("action_bot_manage", () => this.connection.side_bar.music_info().events.fire("action_song_add")); + this.uiEvents.on("action_bot_add_song", () => this.connection.side_bar.music_info().events.fire("action_song_add")); + this.uiEvents.on("query_client_info_own_client", () => this.sendClientInfoOwnClient()); this.uiEvents.on("query_current_channel_state", event => this.sendChannelState(event.mode)); this.uiEvents.on("query_private_conversations", () => this.sendPrivateConversationInfo()); this.uiEvents.on("query_ping", () => this.sendPing()); @@ -110,6 +98,7 @@ export class SideHeader { this.listenerConnection.push(this.connection.serverConnection.events.on("notify_ping_updated", () => this.sendPing())); this.listenerConnection.push(this.connection.getPrivateConversations().events.on("notify_unread_count_changed", () => this.sendPrivateConversationInfo())); this.listenerConnection.push(this.connection.getPrivateConversations().events.on(["notify_conversation_destroyed", "notify_conversation_destroyed"], () => this.sendPrivateConversationInfo())); + this.listenerConnection.push(this.connection.side_bar.getClientInfo().events.on("notify_client_changed", () => this.sendClientInfoOwnClient())); } setConnectionHandler(connection: ConnectionHandler) { @@ -123,23 +112,18 @@ export class SideHeader { this.connection = connection; if(connection) { this.initializeConnection(); - /* TODO: Update state! */ - } else { - this.setState({ state: "none" }); } + this.sendPing(); + this.sendPrivateConversationInfo(); + this.sendChannelState("voice"); + this.sendChannelState("text"); } getConnectionHandler() : ConnectionHandler | undefined { return this.connection; } - getHtmlTag() : HTMLDivElement { - return this.htmlTag; - } - destroy() { - ReactDOM.unmountComponentAtNode(this.htmlTag); - this.listenerConnection.forEach(callback => callback()); this.listenerConnection = []; @@ -153,15 +137,6 @@ export class SideHeader { this.pingUpdateInterval = undefined; } - setState(state: SideHeaderState) { - if(_.isEqual(this.currentState, state)) { - return; - } - - this.currentState = state; - this.uiEvents.fire_react("notify_header_state", { state: state }); - } - private sendChannelState(mode: "voice" | "text") { const channel = mode === "voice" ? this.currentVoiceChannel : this.currentTextChannel; if(channel) { @@ -258,12 +233,29 @@ export class SideHeader { } private sendPrivateConversationInfo() { - const conversations = this.connection.getPrivateConversations(); - this.uiEvents.fire_react("notify_private_conversations", { - info: { - open: conversations.getConversations().length, - unread: conversations.getUnreadCount() - } - }); + if(this.connection) { + const conversations = this.connection.getPrivateConversations(); + this.uiEvents.fire_react("notify_private_conversations", { + info: { + open: conversations.getConversations().length, + unread: conversations.getUnreadCount() + } + }); + } else { + this.uiEvents.fire_react("notify_private_conversations", { + info: { + open: 0, + unread: 0 + } + }); + } + } + + private sendClientInfoOwnClient() { + if(this.connection) { + this.uiEvents.fire_react("notify_client_info_own_client", { isOwnClient: this.connection.side_bar.getClientInfo().getClient() instanceof LocalClientEntry }); + } else { + this.uiEvents.fire_react("notify_client_info_own_client", { isOwnClient: false }); + } } } \ No newline at end of file diff --git a/shared/js/ui/frames/side/HeaderDefinitions.ts b/shared/js/ui/frames/side/HeaderDefinitions.ts index 638e1293..f7e98c3b 100644 --- a/shared/js/ui/frames/side/HeaderDefinitions.ts +++ b/shared/js/ui/frames/side/HeaderDefinitions.ts @@ -12,7 +12,6 @@ export type SideHeaderStateConversation = { export type SideHeaderStateClient = { state: "client", - ownClient: boolean } export type SideHeaderStateMusicBot = { @@ -46,12 +45,10 @@ export interface SideHeaderEvents { action_open_conversation: {}, query_current_channel_state: { mode: "voice" | "text" }, - query_ping: {}, query_private_conversations: {}, + query_client_info_own_client: {}, + query_ping: {}, - notify_header_state: { - state: SideHeaderState - }, notify_current_channel_state: { mode: "voice" | "text", state: SideHeaderChannelState @@ -61,5 +58,8 @@ export interface SideHeaderEvents { }, notify_private_conversations: { info: PrivateConversationInfo + }, + notify_client_info_own_client: { + isOwnClient: boolean } } \ No newline at end of file diff --git a/shared/js/ui/frames/side/HeaderRenderer.tsx b/shared/js/ui/frames/side/HeaderRenderer.tsx index 645129f3..21a87b2a 100644 --- a/shared/js/ui/frames/side/HeaderRenderer.tsx +++ b/shared/js/ui/frames/side/HeaderRenderer.tsx @@ -236,11 +236,19 @@ const BlockBottomLeft = () => { } const BlockBottomRight = () => { + const events = useContext(EventsContext); const state = useContext(StateContext); + const [ ownClient, setOwnClient ] = useState(() => { + events.fire("query_client_info_own_client"); + return false; + }); + + events.reactUse("notify_client_info_own_client", event => setOwnClient(event.isOwnClient)); + switch (state.state) { case "client": - if(state.ownClient) { + if(ownClient) { return null; } else { return ; @@ -258,19 +266,16 @@ const BlockBottomRight = () => { } } -export const SideHeaderRenderer = (props: { events: Registry }) => { - const [ state, setState ] = useState({ state: "none" }); - props.events.reactUse("notify_header_state", event => setState(event.state)); - +export const SideHeaderRenderer = React.memo((props: { events: Registry, state: SideHeaderState }) => { return ( - +
-
+
@@ -278,4 +283,4 @@ export const SideHeaderRenderer = (props: { events: Registry } ); -} \ No newline at end of file +}) \ No newline at end of file diff --git a/shared/js/ui/frames/side/PrivateConversationRenderer.tsx b/shared/js/ui/frames/side/PrivateConversationRenderer.tsx index 77fffcaf..ad013c0f 100644 --- a/shared/js/ui/frames/side/PrivateConversationRenderer.tsx +++ b/shared/js/ui/frames/side/PrivateConversationRenderer.tsx @@ -215,12 +215,12 @@ const OpenConversationsPanel = React.memo(() => { }); -export const PrivateConversationsPanel = (props: { events: Registry, handler: ConnectionHandler }) => ( - +export const PrivateConversationsPanel = (props: { events: Registry, handlerId: string }) => ( + - + From e78833e5340a2e6ef673e3b61875872156cc2b8b Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Wed, 9 Dec 2020 20:44:33 +0100 Subject: [PATCH 10/37] The sidebar now is completely disconnected from the connection handler itself and only renders what's needed. --- ChangeLog.md | 1 + shared/js/ConnectionHandler.ts | 27 +- shared/js/ConnectionManager.ts | 11 +- shared/js/SelectedClientInfo.ts | 248 ++++++++++++ shared/js/SideBarManager.ts | 58 +++ shared/js/conversations/AbstractConversion.ts | 16 +- .../ChannelConversationManager.ts | 6 +- .../PrivateConversationHistory.ts | 2 +- .../PrivateConversationManager.ts | 6 +- shared/js/tree/Channel.ts | 2 +- shared/js/tree/ChannelTree.tsx | 9 +- shared/js/tree/Client.ts | 9 +- shared/js/tree/Server.ts | 2 +- .../{chat_frame.ts => SideBarController.ts} | 151 +++---- shared/js/ui/frames/SideBarDefinitions.ts | 9 +- .../{SideBar.scss => SideBarRenderer.scss} | 0 shared/js/ui/frames/SideBarRenderer.tsx | 3 +- .../side/AbstractConversationController.ts | 129 +++--- ....ts => AbstractConversationDefinitions.ts} | 2 +- .../side/AbstractConversationRenderer.tsx | 48 +-- .../side/ChannelConversationController.ts | 80 ++-- .../side/ChannelConversationDefinitions.ts | 3 + .../js/ui/frames/side/ClientInfoController.ts | 375 ++++++------------ shared/js/ui/frames/side/HeaderController.ts | 22 +- .../side/PopoutConversationRenderer.tsx | 4 +- .../side/PrivateConversationController.ts | 88 ++-- .../side/PrivateConversationDefinitions.ts | 4 +- .../side/PrivateConversationRenderer.scss | 7 + .../side/PrivateConversationRenderer.tsx | 5 +- shared/js/ui/frames/side/music_info.ts | 6 +- .../js/ui/react-elements/ContextDivider.tsx | 10 +- 31 files changed, 777 insertions(+), 566 deletions(-) create mode 100644 shared/js/SelectedClientInfo.ts create mode 100644 shared/js/SideBarManager.ts rename shared/js/ui/frames/{chat_frame.ts => SideBarController.ts} (51%) rename shared/js/ui/frames/{SideBar.scss => SideBarRenderer.scss} (100%) rename shared/js/ui/frames/side/{ConversationDefinitions.ts => AbstractConversationDefinitions.ts} (98%) create mode 100644 shared/js/ui/frames/side/ChannelConversationDefinitions.ts diff --git a/ChangeLog.md b/ChangeLog.md index 3d46bcbf..19acf68e 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -7,6 +7,7 @@ - Enabled context menus for all clickable client tags - Allowing to drag client tags - Fixed the context menu within popout windows for the web client + - Reworked the whole sidebar (Hightly decreased memory footprint) * **08.12.20** - Fixed the permission editor not resolving unique ids diff --git a/shared/js/ConnectionHandler.ts b/shared/js/ConnectionHandler.ts index 7f646b00..6f0612d9 100644 --- a/shared/js/ConnectionHandler.ts +++ b/shared/js/ConnectionHandler.ts @@ -13,7 +13,6 @@ import * as htmltags from "./ui/htmltags"; import {FilterMode, InputState, MediaStreamRequestResult} from "./voice/RecorderBase"; import {CommandResult} from "./connection/ServerConnectionDeclaration"; import {defaultRecorder, RecorderProfile} from "./voice/RecorderProfile"; -import {Frame} from "./ui/frames/chat_frame"; import {Hostbanner} from "./ui/frames/hostbanner"; import {connection_log, Regex} from "./ui/modal/ModalConnect"; import {formatMessage} from "./ui/frames/chat"; @@ -40,7 +39,8 @@ import {ChannelVideoFrame} from "tc-shared/ui/frames/video/Controller"; import {global_client_actions} from "tc-shared/events/GlobalEvents"; import {ChannelConversationManager} from "./conversations/ChannelConversationManager"; import {PrivateConversationManager} from "tc-shared/conversations/PrivateConversationManager"; -import {ChannelConversationController} from "./ui/frames/side/ChannelConversationController"; +import {SelectedClientInfo} from "./SelectedClientInfo"; +import {SideBarManager} from "tc-shared/SideBarManager"; export enum InputHardwareState { MISSING, @@ -145,7 +145,6 @@ export class ConnectionHandler { permissions: PermissionManager; groups: GroupManager; - side_bar: Frame; video_frame: ChannelVideoFrame; settings: ServerSettings; @@ -157,9 +156,13 @@ export class ConnectionHandler { serverFeatures: ServerFeatures; + private sideBar: SideBarManager; + private channelConversations: ChannelConversationManager; private privateConversations: PrivateConversationManager; + private clientInfoManager: SelectedClientInfo; + private _clientId: number = 0; private localClient: LocalClientEntry; @@ -211,14 +214,15 @@ export class ConnectionHandler { this.fileManager = new FileManager(this); this.permissions = new PermissionManager(this); + this.sideBar = new SideBarManager(this); this.privateConversations = new PrivateConversationManager(this); this.channelConversations = new ChannelConversationManager(this); + this.clientInfoManager = new SelectedClientInfo(this); this.pluginCmdRegistry = new PluginCmdRegistry(this); this.video_frame = new ChannelVideoFrame(this); this.log = new ServerEventLog(this); - this.side_bar = new Frame(this); this.sound = new SoundManager(this); this.hostbanner = new Hostbanner(this); @@ -358,6 +362,14 @@ export class ConnectionHandler { return this.channelConversations; } + getSelectedClientInfo() : SelectedClientInfo { + return this.clientInfoManager; + } + + getSideBar() : SideBarManager { + return this.sideBar; + } + initializeLocalClient(clientId: number, acceptedName: string) { this._clientId = clientId; this.localClient["_clientId"] = clientId; @@ -1042,8 +1054,11 @@ export class ConnectionHandler { this.channelTree?.destroy(); this.channelTree = undefined; - this.side_bar?.destroy(); - this.side_bar = undefined; + this.sideBar?.destroy(); + this.sideBar = undefined; + + this.clientInfoManager?.destroy(); + this.clientInfoManager = undefined; this.log?.destroy(); this.log = undefined; diff --git a/shared/js/ConnectionManager.ts b/shared/js/ConnectionManager.ts index 9a41ebb9..71df28cf 100644 --- a/shared/js/ConnectionManager.ts +++ b/shared/js/ConnectionManager.ts @@ -5,6 +5,7 @@ import {Stage} from "tc-loader"; import {FooterRenderer} from "tc-shared/ui/frames/footer/Renderer"; import * as React from "react"; import * as ReactDOM from "react-dom"; +import {SideBarController} from "tc-shared/ui/frames/SideBarController"; export let server_connections: ConnectionManager; @@ -36,17 +37,21 @@ export class ConnectionManager { private containerSideBar: HTMLDivElement; private containerFooter: HTMLDivElement; + private sideBarController: SideBarController; + constructor() { this.event_registry = new Registry(); this.event_registry.enableDebug("connection-manager"); + this.sideBarController = new SideBarController(); + this.containerChannelVideo = new ReplaceableContainer(document.getElementById("channel-video") as HTMLDivElement); - this.containerSideBar = document.getElementById("chat") as HTMLDivElement; this._container_log_server = $("#server-log"); this._container_channel_tree = $("#channelTree"); this._container_hostbanner = $("#hostbanner"); this.containerFooter = document.getElementById("container-footer") as HTMLDivElement; + this.sideBarController.renderInto(document.getElementById("chat") as HTMLDivElement); this.set_active_connection(undefined); } @@ -111,6 +116,8 @@ export class ConnectionManager { } private set_active_connection_(handler: ConnectionHandler) { + this.sideBarController.setConnection(handler); + this._container_channel_tree.children().detach(); this._container_log_server.children().detach(); this._container_hostbanner.children().detach(); @@ -120,8 +127,8 @@ export class ConnectionManager { this._container_hostbanner.append(handler.hostbanner.html_tag); this._container_channel_tree.append(handler.channelTree.tag_tree()); this._container_log_server.append(handler.log.getHTMLTag()); - handler.side_bar.renderInto(this.containerSideBar); } + const old_handler = this.active_handler; this.active_handler = handler; this.event_registry.fire("notify_active_handler_changed", { diff --git a/shared/js/SelectedClientInfo.ts b/shared/js/SelectedClientInfo.ts new file mode 100644 index 00000000..b0462286 --- /dev/null +++ b/shared/js/SelectedClientInfo.ts @@ -0,0 +1,248 @@ +import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler"; +import { + ClientForumInfo, + ClientInfoType, + ClientStatusInfo, + ClientVersionInfo +} from "tc-shared/ui/frames/side/ClientInfoDefinitions"; +import {ClientEntry, ClientType, LocalClientEntry} from "tc-shared/tree/Client"; +import {Registry} from "tc-shared/events"; +import * as i18nc from "tc-shared/i18n/country"; + +export type CachedClientInfoCategory = "name" | "description" | "online-state" | "country" | "volume" | "status" | "forum-account" | "group-channel" | "groups-server" | "version"; + +export type CachedClientInfo = { + type: ClientInfoType; + name: string, + uniqueId: string, + databaseId: number, + clientId: number, + + description: string, + joinTimestamp: number, + leaveTimestamp: number, + country: { name: string, flag: string }, + volume: { volume: number, muted: boolean }, + status: ClientStatusInfo, + forumAccount: ClientForumInfo | undefined, + channelGroup: number, + serverGroups: number[], + version: ClientVersionInfo +} + +export interface ClientInfoManagerEvents { + notify_client_changed: { newClient: ClientEntry | undefined }, + notify_cache_changed: { category: CachedClientInfoCategory }, +} + +export class SelectedClientInfo { + readonly events: Registry; + + private readonly connection: ConnectionHandler; + private readonly listenerConnection: (() => void)[]; + + private listenerClient: (() => void)[]; + private currentClient: ClientEntry | undefined; + private currentClientStatus: CachedClientInfo | undefined; + + constructor(connection: ConnectionHandler) { + this.connection = connection; + this.events = new Registry(); + + this.listenerClient = []; + this.listenerConnection = []; + this.listenerConnection.push(connection.channelTree.events.on("notify_client_leave_view", event => { + if(event.client !== this.currentClient) { + return; + } + + this.currentClientStatus.leaveTimestamp = Date.now() / 1000; + this.currentClientStatus.clientId = 0; + this.currentClient = undefined; + this.unregisterClientEvents(); + this.events.fire("notify_cache_changed", { category: "online-state" }); + })); + + this.listenerConnection.push(connection.events().on("notify_connection_state_changed", event => { + if(event.newState !== ConnectionState.CONNECTED && this.currentClientStatus) { + this.currentClient = undefined; + this.currentClientStatus.leaveTimestamp = Date.now() / 1000; + this.events.fire("notify_cache_changed", { category: "online-state" }); + } + })); + } + + destroy() { + this.listenerConnection.forEach(callback => callback()); + this.listenerConnection.splice(0, this.listenerConnection.length); + + this.unregisterClientEvents(); + } + + getInfo() : CachedClientInfo { + return this.currentClientStatus; + } + + setClient(client: ClientEntry | undefined) { + if(this.currentClient === client) { + return; + } + + if(client.channelTree.client !== this.connection) { + throw tr("client does not belong to current connection handler"); + } + + this.unregisterClientEvents(); + this.currentClient = client; + this.currentClientStatus = undefined; + if(this.currentClient) { + this.currentClient.updateClientVariables().then(undefined); + this.registerClientEvents(this.currentClient); + this.initializeClientInfo(this.currentClient); + } + + this.events.fire("notify_client_changed", { newClient: client }); + } + + getClient() : ClientEntry | undefined { + return this.currentClient; + } + + private unregisterClientEvents() { + this.listenerClient.forEach(callback => callback()); + this.listenerClient = []; + } + + private registerClientEvents(client: ClientEntry) { + const events = this.listenerClient; + + events.push(client.events.on("notify_properties_updated", event => { + if('client_nickname' in event.updated_properties) { + this.currentClientStatus.name = event.client_properties.client_nickname; + this.events.fire("notify_cache_changed", { category: "name" }); + } + + if('client_description' in event.updated_properties) { + this.currentClientStatus.description = event.client_properties.client_description; + this.events.fire("notify_cache_changed", { category: "description" }); + } + + if('client_channel_group_id' in event.updated_properties) { + this.currentClientStatus.channelGroup = event.client_properties.client_channel_group_id; + this.events.fire("notify_cache_changed", { category: "group-channel" }); + } + + if('client_servergroups' in event.updated_properties) { + this.currentClientStatus.serverGroups = client.assignedServerGroupIds(); + this.events.fire("notify_cache_changed", { category: "groups-server" }); + } + + /* Can happen since that variable isn't in view on client appearance */ + if('client_lastconnected' in event.updated_properties) { + this.currentClientStatus.joinTimestamp = event.client_properties.client_lastconnected; + this.events.fire("notify_cache_changed", { category: "online-state" }); + } + + if('client_country' in event.updated_properties) { + this.updateCachedCountry(client); + this.events.fire("notify_cache_changed", { category: "country" }); + } + + for(const key of ["client_away", "client_away_message", "client_input_muted", "client_input_hardware", "client_output_muted", "client_output_hardware"]) { + if(key in event.updated_properties) { + this.updateCachedClientStatus(client); + this.events.fire("notify_cache_changed", { category: "status" }); + break; + } + } + + if('client_platform' in event.updated_properties || 'client_version' in event.updated_properties) { + this.currentClientStatus.version = { + platform: client.properties.client_platform, + version: client.properties.client_version + }; + this.events.fire("notify_cache_changed", { category: "version" }); + } + + if('client_teaforo_flags' in event.updated_properties || 'client_teaforo_name' in event.updated_properties || 'client_teaforo_id' in event.updated_properties) { + this.updateForumAccount(client); + this.events.fire("notify_cache_changed", { category: "forum-account" }); + } + })); + + events.push(client.events.on("notify_audio_level_changed", () => { + this.updateCachedVolume(client); + this.events.fire("notify_cache_changed", { category: "volume" }); + })); + + events.push(client.events.on("notify_mute_state_change", () => { + this.updateCachedVolume(client); + this.events.fire("notify_cache_changed", { category: "volume" }); + })); + } + + + private updateCachedClientStatus(client: ClientEntry) { + this.currentClientStatus.status = { + away: client.properties.client_away ? client.properties.client_away_message ? client.properties.client_away_message : true : false, + microphoneMuted: client.properties.client_input_muted, + microphoneDisabled: !client.properties.client_input_hardware, + speakerMuted: client.properties.client_output_muted, + speakerDisabled: !client.properties.client_output_hardware + }; + } + + private updateCachedCountry(client: ClientEntry) { + this.currentClientStatus.country = { + flag: client.properties.client_country, + name: i18nc.country_name(client.properties.client_country.toUpperCase()), + }; + } + + private updateCachedVolume(client: ClientEntry) { + this.currentClientStatus.volume = { + volume: client.getAudioVolume(), + muted: client.isMuted() + } + } + + private updateForumAccount(client: ClientEntry) { + if(client.properties.client_teaforo_id) { + this.currentClientStatus.forumAccount = { + flags: client.properties.client_teaforo_flags, + nickname: client.properties.client_teaforo_name, + userId: client.properties.client_teaforo_id + }; + } else { + this.currentClientStatus.forumAccount = undefined; + } + } + + private initializeClientInfo(client: ClientEntry) { + this.currentClientStatus = { + type: client instanceof LocalClientEntry ? "self" : client.properties.client_type === ClientType.CLIENT_QUERY ? "query" : "voice", + name: client.properties.client_nickname, + databaseId: client.properties.client_database_id, + uniqueId: client.properties.client_unique_identifier, + clientId: client.clientId(), + + description: client.properties.client_description, + channelGroup: client.properties.client_channel_group_id, + serverGroups: client.assignedServerGroupIds(), + country: undefined, + forumAccount: undefined, + joinTimestamp: client.properties.client_lastconnected, + leaveTimestamp: 0, + status: undefined, + volume: undefined, + version: { + platform: client.properties.client_platform, + version: client.properties.client_version + } + }; + this.updateCachedClientStatus(client); + this.updateCachedCountry(client); + this.updateCachedVolume(client); + this.updateForumAccount(client); + } +} \ No newline at end of file diff --git a/shared/js/SideBarManager.ts b/shared/js/SideBarManager.ts new file mode 100644 index 00000000..d79073d2 --- /dev/null +++ b/shared/js/SideBarManager.ts @@ -0,0 +1,58 @@ +import {SideBarType} from "tc-shared/ui/frames/SideBarDefinitions"; +import {Registry} from "tc-shared/events"; +import {ClientEntry, MusicClientEntry} from "tc-shared/tree/Client"; +import {ConnectionHandler} from "tc-shared/ConnectionHandler"; + +export interface SideBarManagerEvents { + notify_content_type_changed: { newContent: SideBarType } +} + +export class SideBarManager { + readonly events: Registry; + private readonly connection: ConnectionHandler; + private currentType: SideBarType; + + + constructor(connection: ConnectionHandler) { + this.events = new Registry(); + this.connection = connection; + this.currentType = "channel-chat"; + } + + destroy() {} + + getSideBarContent() : SideBarType { + return this.currentType; + } + + setSideBarContent(content: SideBarType) { + if(this.currentType === content) { + return; + } + + this.currentType = content; + this.events.fire("notify_content_type_changed", { newContent: content }); + } + + showPrivateConversations() { + this.setSideBarContent("private-chat"); + } + + showChannelConversations() { + this.setSideBarContent("channel-chat"); + } + + showClientInfo(client: ClientEntry) { + this.connection.getSelectedClientInfo().setClient(client); + this.setSideBarContent("client-info"); + } + + showMusicPlayer(_client: MusicClientEntry) { + /* FIXME: TODO! */ + this.setSideBarContent("music-manage"); + } + + clearSideBar() { + this.setSideBarContent("none"); + } +} \ No newline at end of file diff --git a/shared/js/conversations/AbstractConversion.ts b/shared/js/conversations/AbstractConversion.ts index 9960f4b5..7a5a2f2e 100644 --- a/shared/js/conversations/AbstractConversion.ts +++ b/shared/js/conversations/AbstractConversion.ts @@ -4,7 +4,7 @@ import { ChatMessage, ChatState, ConversationHistoryResponse -} from "tc-shared/ui/frames/side/ConversationDefinitions"; +} from "../ui/frames/side/AbstractConversationDefinitions"; import {Registry} from "tc-shared/events"; import {ConnectionHandler} from "tc-shared/ConnectionHandler"; import {preprocessChatMessageForSend} from "tc-shared/text/chat"; @@ -16,7 +16,7 @@ import {guid} from "tc-shared/crypto/uid"; export const kMaxChatFrameMessageSize = 50; /* max 100 messages, since the server does not support more than 100 messages queried at once */ -export interface AbstractChatEvents { +export interface AbstractConversationEvents { notify_chat_event: { triggerUnread: boolean, event: ChatEvent @@ -45,7 +45,7 @@ export interface AbstractChatEvents { } } -export abstract class AbstractChat { +export abstract class AbstractChat { readonly events: Registry; protected readonly connection: ConnectionHandler; @@ -341,7 +341,7 @@ export interface AbstractChatManagerEvents { } } -export abstract class AbstractChatManager, ConversationType extends AbstractChat, ConversationEvents extends AbstractChatEvents> { +export abstract class AbstractChatManager, ConversationType extends AbstractChat, ConversationEvents extends AbstractConversationEvents> { readonly events: Registry; readonly connection: ConnectionHandler; protected readonly listenerConnection: (() => void)[]; @@ -351,6 +351,12 @@ export abstract class AbstractChatManager(); @@ -418,6 +424,8 @@ export abstract class AbstractChatManager "conversation-" + uniqueId; diff --git a/shared/js/conversations/PrivateConversationManager.ts b/shared/js/conversations/PrivateConversationManager.ts index 7dce6e37..059cbff0 100644 --- a/shared/js/conversations/PrivateConversationManager.ts +++ b/shared/js/conversations/PrivateConversationManager.ts @@ -1,11 +1,11 @@ import { AbstractChat, - AbstractChatEvents, + AbstractConversationEvents, AbstractChatManager, AbstractChatManagerEvents } from "tc-shared/conversations/AbstractConversion"; import {ClientEntry} from "tc-shared/tree/Client"; -import {ChatEvent, ChatMessage, ConversationHistoryResponse} from "tc-shared/ui/frames/side/ConversationDefinitions"; +import {ChatEvent, ChatMessage, ConversationHistoryResponse} from "../ui/frames/side/AbstractConversationDefinitions"; import {ChannelTreeEvents} from "tc-shared/tree/ChannelTree"; import {queryConversationEvents, registerConversationEvent} from "tc-shared/conversations/PrivateConversationHistory"; import {LogCategory, logWarn} from "tc-shared/log"; @@ -19,7 +19,7 @@ export type OutOfViewClient = { let receivingEventUniqueIdIndex = 0; -export interface PrivateConversationEvents extends AbstractChatEvents { +export interface PrivateConversationEvents extends AbstractConversationEvents { notify_partner_typing: {}, notify_partner_changed: { chatId: string, diff --git a/shared/js/tree/Channel.ts b/shared/js/tree/Channel.ts index 26ec80c4..19371a97 100644 --- a/shared/js/tree/Channel.ts +++ b/shared/js/tree/Channel.ts @@ -412,7 +412,7 @@ export class ChannelEntry extends ChannelTreeEntry { callback: () => { const conversation = this.channelTree.client.getChannelConversations().findOrCreateConversation(this.getChannelId()); this.channelTree.client.getChannelConversations().setSelectedConversation(conversation); - this.channelTree.client.side_bar.showChannelConversations(); + this.channelTree.client.getSideBar().showChannelConversations(); }, visible: !settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT) }, { diff --git a/shared/js/tree/ChannelTree.tsx b/shared/js/tree/ChannelTree.tsx index 67e8dd97..a60f3c17 100644 --- a/shared/js/tree/ChannelTree.tsx +++ b/shared/js/tree/ChannelTree.tsx @@ -168,23 +168,22 @@ export class ChannelTree { if(this.selectedEntry instanceof ClientEntry) { if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CLIENT)) { if(this.selectedEntry instanceof MusicClientEntry) { - this.client.side_bar.showMusicPlayer(this.selectedEntry); + this.client.getSideBar().showMusicPlayer(this.selectedEntry); } else { - this.client.side_bar.showClientInfo(this.selectedEntry); + this.client.getSideBar().showClientInfo(this.selectedEntry); } } } else if(this.selectedEntry instanceof ChannelEntry) { if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) { const conversation = this.client.getChannelConversations().findOrCreateConversation(this.selectedEntry.channelId); this.client.getChannelConversations().setSelectedConversation(conversation); - this.client.side_bar.showChannelConversations(); + this.client.getSideBar().showChannelConversations(); } } else if(this.selectedEntry instanceof ServerEntry) { if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) { - const sidebar = this.client.side_bar; const conversation = this.client.getChannelConversations().findOrCreateConversation(0); this.client.getChannelConversations().setSelectedConversation(conversation); - sidebar.showChannelConversations(); + this.client.getSideBar().showChannelConversations() } } } diff --git a/shared/js/tree/Client.ts b/shared/js/tree/Client.ts index 9e2b6693..28f84c51 100644 --- a/shared/js/tree/Client.ts +++ b/shared/js/tree/Client.ts @@ -382,7 +382,7 @@ export class ClientEntry extends ChannelTreeEntry { type: contextmenu.MenuEntryType.ENTRY, name: this.properties.client_type_exact === ClientType.CLIENT_MUSIC ? tr("Show bot info") : tr("Show client info"), callback: () => { - this.channelTree.client.side_bar.showClientInfo(this); + this.channelTree.client.getSideBar().showClientInfo(this); }, icon_class: "client-about", visible: !settings.static_global(Settings.KEY_SWITCH_INSTANT_CLIENT) @@ -519,12 +519,13 @@ export class ClientEntry extends ChannelTreeEntry { open_text_chat() { const privateConversations = this.channelTree.client.getPrivateConversations(); - const sideBar = this.channelTree.client.side_bar; const conversation = privateConversations.findOrCreateConversation(this); conversation.setActiveClientEntry(this); privateConversations.setSelectedConversation(conversation); - sideBar.showPrivateConversations(); - sideBar.privateConversationsController().focusInput(); + + this.channelTree.client.getSideBar().showPrivateConversations(); + /* FIXME: Draw focus to the input box! */ + //sideBar.privateConversationsController().focusInput(); } showContextMenu(x: number, y: number, on_close: () => void = undefined) { diff --git a/shared/js/tree/Server.ts b/shared/js/tree/Server.ts index 4154e891..a0db1dea 100644 --- a/shared/js/tree/Server.ts +++ b/shared/js/tree/Server.ts @@ -193,7 +193,7 @@ export class ServerEntry extends ChannelTreeEntry { name: tr("Join server text channel"), callback: () => { this.channelTree.client.getChannelConversations().setSelectedConversation(this.channelTree.client.getChannelConversations().findOrCreateConversation(0)); - this.channelTree.client.side_bar.showChannelConversations(); + this.channelTree.client.getSideBar().showChannelConversations(); }, visible: !settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT) }, { diff --git a/shared/js/ui/frames/chat_frame.ts b/shared/js/ui/frames/SideBarController.ts similarity index 51% rename from shared/js/ui/frames/chat_frame.ts rename to shared/js/ui/frames/SideBarController.ts index ebbadd24..5b1b9072 100644 --- a/shared/js/ui/frames/chat_frame.ts +++ b/shared/js/ui/frames/SideBarController.ts @@ -1,61 +1,64 @@ -import {ClientEntry, MusicClientEntry} from "../../tree/Client"; import {ConnectionHandler} from "../../ConnectionHandler"; -import {MusicInfo} from "../../ui/frames/side/music_info"; import {ChannelConversationController} from "./side/ChannelConversationController"; import {PrivateConversationController} from "./side/PrivateConversationController"; import {ClientInfoController} from "tc-shared/ui/frames/side/ClientInfoController"; -import {SideHeader} from "tc-shared/ui/frames/side/HeaderController"; +import {SideHeaderController} from "tc-shared/ui/frames/side/HeaderController"; import * as ReactDOM from "react-dom"; import {SideBarRenderer} from "tc-shared/ui/frames/SideBarRenderer"; import * as React from "react"; import {SideBarEvents, SideBarType} from "tc-shared/ui/frames/SideBarDefinitions"; import {Registry} from "tc-shared/events"; +import {LogCategory, logWarn} from "tc-shared/log"; -const cssStyle = require("./SideBar.scss"); +export class SideBarController { + private readonly uiEvents: Registry; -export class Frame { - readonly handle: ConnectionHandler; - private htmlTag: HTMLDivElement; + private currentConnection: ConnectionHandler; + private listenerConnection: (() => void)[]; - private currentType: SideBarType; - - private uiEvents: Registry; - private header: SideHeader; - - private musicInfo: MusicInfo; + private header: SideHeaderController; private clientInfo: ClientInfoController; private channelConversations: ChannelConversationController; private privateConversations: PrivateConversationController; - constructor(handle: ConnectionHandler) { - this.handle = handle; + constructor() { + this.listenerConnection = []; - this.currentType = "none"; this.uiEvents = new Registry(); - this.uiEvents.on("query_content", () => this.uiEvents.fire_react("notify_content", { content: this.currentType })); + this.uiEvents.on("query_content", () => this.sendContent()); this.uiEvents.on("query_content_data", event => this.sendContentData(event.content)); - this.privateConversations = new PrivateConversationController(handle); - this.channelConversations = new ChannelConversationController(handle); - this.clientInfo = new ClientInfoController(handle); - this.musicInfo = new MusicInfo(this); - this.header = new SideHeader(); - - this.handle.events().one("notify_handler_initialized", () => this.header.setConnectionHandler(handle)); - - this.createHtmlTag(); - this.showChannelConversations(); + this.privateConversations = new PrivateConversationController(); + this.channelConversations = new ChannelConversationController(); + this.clientInfo = new ClientInfoController(); + this.header = new SideHeaderController(); } - html_tag() : HTMLDivElement { return this.htmlTag; } + setConnection(connection: ConnectionHandler) { + if(this.currentConnection === connection) { + return; + } + + this.listenerConnection.forEach(callback => callback()); + this.listenerConnection = []; + + this.currentConnection = connection; + this.header.setConnectionHandler(connection); + this.clientInfo.setConnectionHandler(connection); + this.channelConversations.setConnectionHandler(connection); + this.privateConversations.setConnectionHandler(connection); + + if(connection) { + this.listenerConnection.push(connection.getSideBar().events.on("notify_content_type_changed", () => this.sendContent())); + } + + this.sendContent(); + } destroy() { this.header?.destroy(); this.header = undefined; - this.htmlTag && this.htmlTag.remove(); - this.htmlTag = undefined; - this.clientInfo?.destroy(); this.clientInfo = undefined; @@ -64,51 +67,21 @@ export class Frame { this.channelConversations?.destroy(); this.channelConversations = undefined; - - this.musicInfo && this.musicInfo.destroy(); - this.musicInfo = undefined; - - this.privateConversations && this.privateConversations.destroy(); - this.privateConversations = undefined; - - this.channelConversations && this.channelConversations.destroy(); - this.channelConversations = undefined; } renderInto(container: HTMLDivElement) { ReactDOM.render(React.createElement(SideBarRenderer, { - key: this.handle.handlerId, - handlerId: this.handle.handlerId, events: this.uiEvents, eventsHeader: this.header["uiEvents"], }), container); } - private createHtmlTag() { - this.htmlTag = document.createElement("div"); - this.htmlTag.classList.add(cssStyle.container); - } - - - privateConversationsController() : PrivateConversationController { - return this.privateConversations; - } - - getClientInfo() : ClientInfoController { - return this.clientInfo; - } - - music_info() : MusicInfo { - return this.musicInfo; - } - - private setCurrentContent(type: SideBarType) { - if(this.currentType === type) { - return; + private sendContent() { + if(this.currentConnection) { + this.uiEvents.fire("notify_content", { content: this.currentConnection.getSideBar().getSideBarContent() }); + } else { + this.uiEvents.fire("notify_content", { content: "none" }); } - - this.currentType = type; - this.uiEvents.fire_react("notify_content", { content: this.currentType }); } private sendContentData(content: SideBarType) { @@ -121,26 +94,41 @@ export class Frame { break; case "channel-chat": + if(!this.currentConnection) { + logWarn(LogCategory.GENERAL, tr("Received channel chat content data request without an active connection.")); + return; + } + this.uiEvents.fire_react("notify_content_data", { content: "channel-chat", data: { events: this.channelConversations["uiEvents"], - handlerId: this.handle.handlerId + handlerId: this.currentConnection.handlerId } }); break; case "private-chat": + if(!this.currentConnection) { + logWarn(LogCategory.GENERAL, tr("Received private chat content data request without an active connection.")); + return; + } + this.uiEvents.fire_react("notify_content_data", { content: "private-chat", data: { events: this.privateConversations["uiEvents"], - handlerId: this.handle.handlerId + handlerId: this.currentConnection.handlerId } }); break; case "client-info": + if(!this.currentConnection) { + logWarn(LogCategory.GENERAL, tr("Received client info content data request without an active connection.")); + return; + } + this.uiEvents.fire_react("notify_content_data", { content: "client-info", data: { @@ -150,6 +138,11 @@ export class Frame { break; case "music-manage": + if(!this.currentConnection) { + logWarn(LogCategory.GENERAL, tr("Received music bot content data request without an active connection.")); + return; + } + this.uiEvents.fire_react("notify_content_data", { content: "music-manage", data: { } @@ -157,26 +150,4 @@ export class Frame { break; } } - - showPrivateConversations() { - this.setCurrentContent("private-chat"); - } - - showChannelConversations() { - this.setCurrentContent("channel-chat"); - } - - showClientInfo(client: ClientEntry) { - this.clientInfo.setClient(client); - this.setCurrentContent("client-info"); - } - - showMusicPlayer(client: MusicClientEntry) { - this.musicInfo.set_current_bot(client); - this.setCurrentContent("music-manage"); - } - - clearSideBar() { - this.setCurrentContent("none"); - } } \ No newline at end of file diff --git a/shared/js/ui/frames/SideBarDefinitions.ts b/shared/js/ui/frames/SideBarDefinitions.ts index 737c42ec..5fc58888 100644 --- a/shared/js/ui/frames/SideBarDefinitions.ts +++ b/shared/js/ui/frames/SideBarDefinitions.ts @@ -1,7 +1,8 @@ import {Registry} from "tc-shared/events"; import {PrivateConversationUIEvents} from "tc-shared/ui/frames/side/PrivateConversationDefinitions"; -import {ConversationUIEvents} from "tc-shared/ui/frames/side/ConversationDefinitions"; +import {AbstractConversationUiEvents} from "./side/AbstractConversationDefinitions"; import {ClientInfoEvents} from "tc-shared/ui/frames/side/ClientInfoDefinitions"; +import {SideHeaderEvents} from "tc-shared/ui/frames/side/HeaderDefinitions"; /* TODO: Somehow outsource the event registries to IPC? */ @@ -9,7 +10,7 @@ export type SideBarType = "none" | "channel-chat" | "private-chat" | "client-inf export interface SideBarTypeData { "none": {}, "channel-chat": { - events: Registry, + events: Registry, handlerId: string }, "private-chat": { @@ -32,7 +33,9 @@ export type SideBarNotifyContentData = { export interface SideBarEvents { query_content: {}, query_content_data: { content: SideBarType }, + query_header_data: {}, notify_content: { content: SideBarType }, - notify_content_data: SideBarNotifyContentData + notify_content_data: SideBarNotifyContentData, + notify_header_data: { events: Registry } } \ No newline at end of file diff --git a/shared/js/ui/frames/SideBar.scss b/shared/js/ui/frames/SideBarRenderer.scss similarity index 100% rename from shared/js/ui/frames/SideBar.scss rename to shared/js/ui/frames/SideBarRenderer.scss diff --git a/shared/js/ui/frames/SideBarRenderer.tsx b/shared/js/ui/frames/SideBarRenderer.tsx index 0b379cde..81ad17b3 100644 --- a/shared/js/ui/frames/SideBarRenderer.tsx +++ b/shared/js/ui/frames/SideBarRenderer.tsx @@ -8,7 +8,7 @@ import {useContext, useState} from "react"; import {ClientInfoRenderer} from "tc-shared/ui/frames/side/ClientInfoRenderer"; import {PrivateConversationsPanel} from "tc-shared/ui/frames/side/PrivateConversationRenderer"; -const cssStyle = require("./SideBar.scss"); +const cssStyle = require("./SideBarRenderer.scss"); const EventContent = React.createContext>(undefined); @@ -109,7 +109,6 @@ const SideBarHeader = (props: { type: SideBarType, eventsHeader: Registry, eventsHeader: Registry }) => { diff --git a/shared/js/ui/frames/side/AbstractConversationController.ts b/shared/js/ui/frames/side/AbstractConversationController.ts index 80999ca4..bc9ffdaa 100644 --- a/shared/js/ui/frames/side/AbstractConversationController.ts +++ b/shared/js/ui/frames/side/AbstractConversationController.ts @@ -1,14 +1,14 @@ import { ChatHistoryState, - ConversationUIEvents -} from "../../../ui/frames/side/ConversationDefinitions"; + AbstractConversationUiEvents +} from "./AbstractConversationDefinitions"; import {EventHandler, Registry} from "../../../events"; import * as log from "../../../log"; import {LogCategory} from "../../../log"; import {tra, tr} from "../../../i18n/localize"; import { AbstractChat, - AbstractChatEvents, + AbstractConversationEvents, AbstractChatManager, AbstractChatManagerEvents } from "tc-shared/conversations/AbstractConversion"; @@ -16,50 +16,25 @@ import { export const kMaxChatFrameMessageSize = 50; /* max 100 messages, since the server does not support more than 100 messages queried at once */ export abstract class AbstractConversationController< - Events extends ConversationUIEvents, + Events extends AbstractConversationUiEvents, Manager extends AbstractChatManager, ManagerEvents extends AbstractChatManagerEvents, ConversationType extends AbstractChat, - ConversationEvents extends AbstractChatEvents + ConversationEvents extends AbstractConversationEvents > { protected readonly uiEvents: Registry; - protected readonly conversationManager: Manager; - protected readonly listenerManager: (() => void)[]; - - private historyUiStates: {[id: string]: { - executingUIHistoryQuery: boolean, - historyErrorMessage: string | undefined, - historyRetryTimestamp: number - }} = {}; + protected conversationManager: Manager | undefined; + protected listenerManager: (() => void)[]; protected currentSelectedConversation: ConversationType; protected currentSelectedListener: (() => void)[]; protected crossChannelChatSupported = true; - protected constructor(conversationManager: Manager) { + protected constructor() { this.uiEvents = new Registry(); this.currentSelectedListener = []; - this.conversationManager = conversationManager; - this.listenerManager = []; - - this.listenerManager.push(this.conversationManager.events.on("notify_selected_changed", event => { - this.currentSelectedListener.forEach(callback => callback()); - this.currentSelectedListener = []; - - this.currentSelectedConversation = event.newConversation; - this.uiEvents.fire_react("notify_selected_chat", { chatId: event.newConversation ? event.newConversation.getChatId() : "unselected" }); - - const conversation = event.newConversation; - if(conversation) { - this.registerConversationEvents(conversation); - } - })); - - this.listenerManager.push(this.conversationManager.events.on("notify_conversation_destroyed", event => { - delete this.historyUiStates[event.conversation.getChatId()]; - })); } destroy() { @@ -70,6 +45,31 @@ export abstract class AbstractConversationController< this.uiEvents.destroy(); } + getUiEvents() : Registry { + return this.uiEvents; + } + + protected setConversationManager(manager: Manager | undefined) { + if(this.conversationManager === manager) { + return; + } + + this.listenerManager.forEach(callback => callback()); + this.listenerManager = []; + this.conversationManager = manager; + + if(manager) { + this.registerConversationManagerEvents(manager); + this.setCurrentlySelected(manager.getSelectedConversation()); + } else { + this.setCurrentlySelected(undefined); + } + } + + protected registerConversationManagerEvents(manager: Manager) { + this.listenerManager.push(manager.events.on("notify_selected_changed", event => this.setCurrentlySelected(event.newConversation))); + } + protected registerConversationEvents(conversation: ConversationType) { this.currentSelectedListener.push(conversation.events.on("notify_unread_timestamp_changed", event => this.uiEvents.fire_react("notify_unread_timestamp_changed", { chatId: conversation.getChatId(), timestamp: event.timestamp }))); @@ -94,13 +94,30 @@ export abstract class AbstractConversationController< })); } + protected setCurrentlySelected(conversation: ConversationType | undefined) { + if(this.currentSelectedConversation === conversation) { + return; + } + + this.currentSelectedListener.forEach(callback => callback()); + this.currentSelectedListener = []; + + this.currentSelectedConversation = conversation; + this.uiEvents.fire_react("notify_selected_chat", { chatId: conversation ? conversation.getChatId() : "unselected" }); + + if(conversation) { + this.registerConversationEvents(conversation); + } + } + + /* TODO: Is this even a thing? */ handlePanelShow() { this.uiEvents.fire_react("notify_panel_show"); } protected reportStateToUI(conversation: AbstractChat) { let historyState: ChatHistoryState; - const localHistoryState = this.historyUiStates[conversation.getChatId()]; + const localHistoryState = this.conversationManager.historyUiStates[conversation.getChatId()]; if(!localHistoryState) { historyState = conversation.hasHistory() ? "available" : "none"; } else { @@ -171,7 +188,7 @@ export abstract class AbstractConversationController< } } public uiQueryHistory(conversation: AbstractChat, timestamp: number, enforce?: boolean) { - const localHistoryState = this.historyUiStates[conversation.getChatId()] || (this.historyUiStates[conversation.getChatId()] = { + const localHistoryState = this.conversationManager.historyUiStates[conversation.getChatId()] || (this.conversationManager.historyUiStates[conversation.getChatId()] = { executingUIHistoryQuery: false, historyErrorMessage: undefined, historyRetryTimestamp: 0 @@ -242,13 +259,13 @@ export abstract class AbstractConversationController< this.crossChannelChatSupported = flag; const currentConversation = this.getCurrentConversation(); if(currentConversation) { - this.reportStateToUI(this.getCurrentConversation()); + this.reportStateToUI(currentConversation); } } - @EventHandler("query_conversation_state") - protected handleQueryConversationState(event: ConversationUIEvents["query_conversation_state"]) { - const conversation = this.conversationManager.findConversationById(event.chatId); + @EventHandler("query_conversation_state") + protected handleQueryConversationState(event: AbstractConversationUiEvents["query_conversation_state"]) { + const conversation = this.conversationManager?.findConversationById(event.chatId); if(!conversation) { this.uiEvents.fire_react("notify_conversation_state", { state: "error", @@ -267,9 +284,9 @@ export abstract class AbstractConversationController< } } - @EventHandler("query_conversation_history") - protected handleQueryHistory(event: ConversationUIEvents["query_conversation_history"]) { - const conversation = this.conversationManager.findConversationById(event.chatId); + @EventHandler("query_conversation_history") + protected handleQueryHistory(event: AbstractConversationUiEvents["query_conversation_history"]) { + const conversation = this.conversationManager?.findConversationById(event.chatId); if(!conversation) { this.uiEvents.fire_react("notify_conversation_history", { state: "error", @@ -286,15 +303,15 @@ export abstract class AbstractConversationController< this.uiQueryHistory(conversation, event.timestamp); } - @EventHandler(["action_clear_unread_flag", "action_self_typing"]) - protected handleClearUnreadFlag(event: ConversationUIEvents["action_clear_unread_flag" | "action_self_typing"]) { - const conversation = this.conversationManager.findConversationById(event.chatId); + @EventHandler(["action_clear_unread_flag", "action_self_typing"]) + protected handleClearUnreadFlag(event: AbstractConversationUiEvents["action_clear_unread_flag" | "action_self_typing"]) { + const conversation = this.conversationManager?.findConversationById(event.chatId); conversation?.setUnreadTimestamp(Date.now()); } - @EventHandler("action_send_message") - protected handleSendMessage(event: ConversationUIEvents["action_send_message"]) { - const conversation = this.conversationManager.findConversationById(event.chatId); + @EventHandler("action_send_message") + protected handleSendMessage(event: AbstractConversationUiEvents["action_send_message"]) { + const conversation = this.conversationManager?.findConversationById(event.chatId); if(!conversation) { log.error(LogCategory.CLIENT, tr("Tried to send a chat message to an unknown conversation with id %s"), event.chatId); return; @@ -303,9 +320,9 @@ export abstract class AbstractConversationController< conversation.sendMessage(event.text); } - @EventHandler("action_jump_to_present") - protected handleJumpToPresent(event: ConversationUIEvents["action_jump_to_present"]) { - const conversation = this.conversationManager.findConversationById(event.chatId); + @EventHandler("action_jump_to_present") + protected handleJumpToPresent(event: AbstractConversationUiEvents["action_jump_to_present"]) { + const conversation = this.conversationManager?.findConversationById(event.chatId); if(!conversation) { log.error(LogCategory.CLIENT, tr("Tried to jump to present for an unknown conversation with id %s"), event.chatId); return; @@ -314,14 +331,14 @@ export abstract class AbstractConversationController< this.reportStateToUI(conversation); } - @EventHandler("query_selected_chat") + @EventHandler("query_selected_chat") private handleQuerySelectedChat() { - this.uiEvents.fire_react("notify_selected_chat", { chatId: this.currentSelectedConversation ? this.currentSelectedConversation.getChatId() : "unselected"}) + this.uiEvents.fire_react("notify_selected_chat", { chatId: this.currentSelectedConversation ? this.currentSelectedConversation.getChatId() : "unselected"}); } - @EventHandler("action_select_chat") - private handleActionSelectChat(event: ConversationUIEvents["action_select_chat"]) { - const conversation = this.conversationManager.findConversationById(event.chatId); + @EventHandler("action_select_chat") + private handleActionSelectChat(event: AbstractConversationUiEvents["action_select_chat"]) { + const conversation = this.conversationManager?.findConversationById(event.chatId); this.conversationManager.setSelectedConversation(conversation); } } \ No newline at end of file diff --git a/shared/js/ui/frames/side/ConversationDefinitions.ts b/shared/js/ui/frames/side/AbstractConversationDefinitions.ts similarity index 98% rename from shared/js/ui/frames/side/ConversationDefinitions.ts rename to shared/js/ui/frames/side/AbstractConversationDefinitions.ts index e7dd6a3e..a396fca0 100644 --- a/shared/js/ui/frames/side/ConversationDefinitions.ts +++ b/shared/js/ui/frames/side/AbstractConversationDefinitions.ts @@ -111,7 +111,7 @@ export interface ChatStatePrivate { export type ChatStateData = ChatStateNormal | ChatStateNoPermissions | ChatStateError | ChatStateLoading | ChatStatePrivate; -export interface ConversationUIEvents { +export interface AbstractConversationUiEvents { action_select_chat: { chatId: "unselected" | string }, action_clear_unread_flag: { chatId: string }, action_self_typing: { chatId: string }, diff --git a/shared/js/ui/frames/side/AbstractConversationRenderer.tsx b/shared/js/ui/frames/side/AbstractConversationRenderer.tsx index 3b517410..79357c25 100644 --- a/shared/js/ui/frames/side/AbstractConversationRenderer.tsx +++ b/shared/js/ui/frames/side/AbstractConversationRenderer.tsx @@ -15,8 +15,8 @@ import { ChatEventPartnerAction, ChatHistoryState, ChatMessage, - ConversationUIEvents, ChatEventModeChanged -} from "tc-shared/ui/frames/side/ConversationDefinitions"; + AbstractConversationUiEvents, ChatEventModeChanged +} from "./AbstractConversationDefinitions"; import {TimestampRenderer} from "tc-shared/ui/react-elements/TimestampRenderer"; import {BBCodeRenderer} from "tc-shared/text/bbcode"; import {getGlobalAvatarManagerFactory} from "tc-shared/file/Avatars"; @@ -34,7 +34,7 @@ const ChatMessageTextRenderer = React.memo((props: { text: string }) => { const ChatEventMessageRenderer = React.memo((props: { message: ChatMessage, callbackDelete?: () => void, - events: Registry, + events: Registry, handlerId: string, refHTMLElement?: Ref @@ -126,7 +126,7 @@ const UnreadEntry = (props: { refDiv: React.Ref }) => (
); -const LoadOderMessages = (props: { events: Registry, chatId: string, state: ChatHistoryState | "error", errorMessage?: string, retryTimestamp?: number, timestamp: number | undefined }) => { +const LoadOderMessages = (props: { events: Registry, chatId: string, state: ChatHistoryState | "error", errorMessage?: string, retryTimestamp?: number, timestamp: number | undefined }) => { if(props.state === "none") return null; @@ -172,7 +172,7 @@ const LoadOderMessages = (props: { events: Registry, chatI ) }; -const JumpToPresent = (props: { events: Registry, chatId: string }) => ( +const JumpToPresent = (props: { events: Registry, chatId: string }) => (
props.events.fire("action_jump_to_present", { chatId: props.chatId })} @@ -305,7 +305,7 @@ const ChatEventModeChangedRenderer = (props: { event: ChatEventModeChanged, refH } } -const PartnerTypingIndicator = (props: { events: Registry, chatId: string, timeout?: number }) => { +const PartnerTypingIndicator = (props: { events: Registry, chatId: string, timeout?: number }) => { const kTypingTimeout = props.timeout || 5000; @@ -349,7 +349,7 @@ const PartnerTypingIndicator = (props: { events: Registry, }; interface ConversationMessagesProperties { - events: Registry; + events: Registry; handlerId: string; noFirstMessageOverlay?: boolean @@ -698,8 +698,8 @@ class ConversationMessages extends React.PureComponent("notify_selected_chat") - private handleNotifySelectedChat(event: ConversationUIEvents["notify_selected_chat"]) { + @EventHandler("notify_selected_chat") + private handleNotifySelectedChat(event: AbstractConversationUiEvents["notify_selected_chat"]) { if(this.currentChatId === event.chatId) { return; } @@ -718,8 +718,8 @@ class ConversationMessages extends React.PureComponent("notify_conversation_state") - private handleConversationStateUpdate(event: ConversationUIEvents["notify_conversation_state"]) { + @EventHandler("notify_conversation_state") + private handleConversationStateUpdate(event: AbstractConversationUiEvents["notify_conversation_state"]) { if(event.chatId !== this.currentChatId) return; @@ -771,8 +771,8 @@ class ConversationMessages extends React.PureComponent("notify_chat_event") - private handleChatEvent(event: ConversationUIEvents["notify_chat_event"]) { + @EventHandler("notify_chat_event") + private handleChatEvent(event: AbstractConversationUiEvents["notify_chat_event"]) { if(event.chatId !== this.currentChatId || this.state.isBrowsingHistory) return; @@ -793,8 +793,8 @@ class ConversationMessages extends React.PureComponent this.scrollToBottom()); } - @EventHandler("notify_chat_message_delete") - private handleMessageDeleted(event: ConversationUIEvents["notify_chat_message_delete"]) { + @EventHandler("notify_chat_message_delete") + private handleMessageDeleted(event: AbstractConversationUiEvents["notify_chat_message_delete"]) { if(event.chatId !== this.currentChatId) { return; } @@ -805,8 +805,8 @@ class ConversationMessages extends React.PureComponent this.scrollToBottom()); } - @EventHandler("notify_unread_timestamp_changed") - private handleUnreadTimestampChanged(event: ConversationUIEvents["notify_unread_timestamp_changed"]) { + @EventHandler("notify_unread_timestamp_changed") + private handleUnreadTimestampChanged(event: AbstractConversationUiEvents["notify_unread_timestamp_changed"]) { if (event.chatId !== this.currentChatId) return; @@ -823,13 +823,13 @@ class ConversationMessages extends React.PureComponent("notify_panel_show") + @EventHandler("notify_panel_show") private handlePanelShow() { this.fixScroll(); } - @EventHandler("query_conversation_history") - private handleQueryConversationHistory(event: ConversationUIEvents["query_conversation_history"]) { + @EventHandler("query_conversation_history") + private handleQueryConversationHistory(event: AbstractConversationUiEvents["query_conversation_history"]) { if (event.chatId !== this.currentChatId) return; @@ -838,8 +838,8 @@ class ConversationMessages extends React.PureComponent("notify_conversation_history") - private handleNotifyConversationHistory(event: ConversationUIEvents["notify_conversation_history"]) { + @EventHandler("notify_conversation_history") + private handleNotifyConversationHistory(event: AbstractConversationUiEvents["notify_conversation_history"]) { if (event.chatId !== this.currentChatId) return; @@ -881,7 +881,7 @@ class ConversationMessages extends React.PureComponent, handlerId: string, messagesDeletable: boolean, noFirstMessageOverlay: boolean }) => { +export const ConversationPanel = React.memo((props: { events: Registry, handlerId: string, messagesDeletable: boolean, noFirstMessageOverlay: boolean }) => { const currentChat = useRef({ id: "unselected" }); const chatEnabled = useRef(false); @@ -900,7 +900,7 @@ export const ConversationPanel = React.memo((props: { events: Registry { if(event.chatId !== currentChat.current.id) return; diff --git a/shared/js/ui/frames/side/ChannelConversationController.ts b/shared/js/ui/frames/side/ChannelConversationController.ts index 11d3fd5f..0ce07034 100644 --- a/shared/js/ui/frames/side/ChannelConversationController.ts +++ b/shared/js/ui/frames/side/ChannelConversationController.ts @@ -1,11 +1,9 @@ -import * as React from "react"; import {ConnectionHandler, ConnectionState} from "../../../ConnectionHandler"; import {EventHandler} from "../../../events"; import * as log from "../../../log"; import {LogCategory} from "../../../log"; import {tr} from "../../../i18n/localize"; -import {ConversationUIEvents} from "../../../ui/frames/side/ConversationDefinitions"; -import {ConversationPanel} from "./AbstractConversationRenderer"; +import {AbstractConversationUiEvents} from "./AbstractConversationDefinitions"; import {AbstractConversationController} from "./AbstractConversationController"; import { ChannelConversation, @@ -14,34 +12,22 @@ import { ChannelConversationManagerEvents } from "tc-shared/conversations/ChannelConversationManager"; import {ServerFeature} from "tc-shared/connection/ServerFeatures"; -import ReactDOM = require("react-dom"); +import {ChannelConversationUiEvents} from "tc-shared/ui/frames/side/ChannelConversationDefinitions"; export class ChannelConversationController extends AbstractConversationController< - ConversationUIEvents, + ChannelConversationUiEvents, ChannelConversationManager, ChannelConversationManagerEvents, ChannelConversation, ChannelConversationEvents > { - readonly connection: ConnectionHandler; - readonly htmlTag: HTMLDivElement; + private connection: ConnectionHandler; + private connectionListener: (() => void)[]; - constructor(connection: ConnectionHandler) { - super(connection.getChannelConversations() as any); - this.connection = connection; + constructor() { + super(); + this.connectionListener = []; - this.htmlTag = document.createElement("div"); - this.htmlTag.style.display = "flex"; - this.htmlTag.style.flexDirection = "column"; - this.htmlTag.style.justifyContent = "stretch"; - this.htmlTag.style.height = "100%"; - - ReactDOM.render(React.createElement(ConversationPanel, { - events: this.uiEvents, - handlerId: this.connection.handlerId, - noFirstMessageOverlay: false, - messagesDeletable: true - }), this.htmlTag); /* spawnExternalModal("conversation", this.uiEvents, { handlerId: this.connection.handlerId, @@ -52,7 +38,37 @@ export class ChannelConversationController extends AbstractConversationControlle }); */ - this.uiEvents.on("notify_destroy", connection.events().on("notify_visibility_changed", event => { + this.uiEvents.register_handler(this, true); + } + + destroy() { + this.connectionListener.forEach(callback => callback()); + this.connectionListener = []; + + this.uiEvents.unregister_handler(this); + super.destroy(); + } + + setConnectionHandler(connection: ConnectionHandler) { + if(this.connection === connection) { + return; + } + + this.connectionListener.forEach(callback => callback()); + this.connectionListener = []; + + this.connection = connection; + if(connection) { + this.initializeConnectionListener(connection); + /* FIXME: Update cross channel talk state! */ + this.setConversationManager(connection.getChannelConversations()); + } else { + this.setConversationManager(undefined); + } + } + + private initializeConnectionListener(connection: ConnectionHandler) { + this.connectionListener.push(connection.events().on("notify_visibility_changed", event => { if(!event.visible) { return; } @@ -60,9 +76,7 @@ export class ChannelConversationController extends AbstractConversationControlle this.handlePanelShow(); })); - this.uiEvents.register_handler(this, true); - - this.listenerManager.push(connection.events().on("notify_connection_state_changed", event => { + this.connectionListener.push(connection.events().on("notify_connection_state_changed", event => { if(event.newState === ConnectionState.CONNECTED) { connection.serverFeatures.awaitFeatures().then(success => { if(!success) { return; } @@ -75,17 +89,9 @@ export class ChannelConversationController extends AbstractConversationControlle })); } - destroy() { - ReactDOM.unmountComponentAtNode(this.htmlTag); - this.htmlTag.remove(); - - this.uiEvents.unregister_handler(this); - super.destroy(); - } - - @EventHandler("action_delete_message") - private handleMessageDelete(event: ConversationUIEvents["action_delete_message"]) { - const conversation = this.conversationManager.findConversationById(event.chatId); + @EventHandler("action_delete_message") + private handleMessageDelete(event: AbstractConversationUiEvents["action_delete_message"]) { + const conversation = this.conversationManager?.findConversationById(event.chatId); if(!conversation) { log.error(LogCategory.CLIENT, tr("Tried to delete a chat message from an unknown conversation with id %s"), event.chatId); return; diff --git a/shared/js/ui/frames/side/ChannelConversationDefinitions.ts b/shared/js/ui/frames/side/ChannelConversationDefinitions.ts new file mode 100644 index 00000000..7d7908e6 --- /dev/null +++ b/shared/js/ui/frames/side/ChannelConversationDefinitions.ts @@ -0,0 +1,3 @@ +import {AbstractConversationUiEvents} from "tc-shared/ui/frames/side/AbstractConversationDefinitions"; + +export interface ChannelConversationUiEvents extends AbstractConversationUiEvents {} \ No newline at end of file diff --git a/shared/js/ui/frames/side/ClientInfoController.ts b/shared/js/ui/frames/side/ClientInfoController.ts index 3ab6ae46..969b7418 100644 --- a/shared/js/ui/frames/side/ClientInfoController.ts +++ b/shared/js/ui/frames/side/ClientInfoController.ts @@ -1,107 +1,23 @@ -import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler"; -import {ClientEntry, ClientType, LocalClientEntry} from "tc-shared/tree/Client"; +import {ConnectionHandler} from "tc-shared/ConnectionHandler"; import { - ClientForumInfo, ClientGroupInfo, - ClientInfoEvents, ClientInfoType, - ClientStatusInfo, - ClientVersionInfo + ClientInfoEvents, } from "tc-shared/ui/frames/side/ClientInfoDefinitions"; import {Registry} from "tc-shared/events"; -import * as i18nc from "../../../i18n/country"; import {openClientInfo} from "tc-shared/ui/modal/ModalClientInfo"; -type CurrentClientInfo = { - type: ClientInfoType; - name: string, - uniqueId: string, - databaseId: number, - clientId: number, - - description: string, - joinTimestamp: number, - leaveTimestamp: number, - country: { name: string, flag: string }, - volume: { volume: number, muted: boolean }, - status: ClientStatusInfo, - forumAccount: ClientForumInfo | undefined, - channelGroup: number, - serverGroups: number[], - version: ClientVersionInfo -} - -export interface ClientInfoControllerEvents { - notify_client_changed: { - newClient: ClientEntry | undefined - } -} - export class ClientInfoController { - readonly events: Registry; - - private readonly connection: ConnectionHandler; - private readonly listenerConnection: (() => void)[]; - private readonly uiEvents: Registry; - private listenerClient: (() => void)[]; - private currentClient: ClientEntry | undefined; - private currentClientStatus: CurrentClientInfo | undefined; + private connection: ConnectionHandler; + private listenerConnection: (() => void)[]; - constructor(connection: ConnectionHandler) { - this.connection = connection; - this.events = new Registry(); + constructor() { this.uiEvents = new Registry(); this.uiEvents.enableDebug("client-info"); this.listenerConnection = []; - this.listenerClient = []; - - this.initialize(); - } - - private initialize() { - this.listenerConnection.push(this.connection.groups.events.on("notify_groups_updated", event => { - if(!this.currentClientStatus) { - return; - } - - for(const update of event.updates) { - if(update.group.id === this.currentClientStatus.channelGroup) { - this.sendChannelGroup(); - break; - } - } - - for(const update of event.updates) { - if(this.currentClientStatus.serverGroups.indexOf(update.group.id) !== -1) { - this.sendServerGroups(); - break; - } - } - })); - - this.listenerConnection.push(this.connection.channelTree.events.on("notify_client_leave_view", event => { - if(event.client !== this.currentClient) { - return; - } - - this.currentClientStatus.leaveTimestamp = Date.now() / 1000; - this.currentClientStatus.clientId = 0; - this.currentClient = undefined; - this.unregisterClientEvents(); - this.sendOnline(); - })); - - this.listenerConnection.push(this.connection.events().on("notify_connection_state_changed", event => { - if(event.newState !== ConnectionState.CONNECTED && this.currentClientStatus) { - this.currentClient = undefined; - this.currentClientStatus.leaveTimestamp = Date.now() / 1000; - this.sendOnline(); - } - })) - this.uiEvents.on("query_client", () => this.sendClient()); this.uiEvents.on("query_client_name", () => this.sendClientName()); this.uiEvents.on("query_client_description", () => this.sendClientDescription()); @@ -114,179 +30,105 @@ export class ClientInfoController { this.uiEvents.on("query_version", () => this.sendVersion()); this.uiEvents.on("query_forum", () => this.sendForum()); - this.uiEvents.on("action_edit_avatar", () => this.connection.update_avatar()); - this.uiEvents.on("action_show_full_info", () => this.currentClient && openClientInfo(this.currentClient)); + this.uiEvents.on("action_edit_avatar", () => this.connection?.update_avatar()); + this.uiEvents.on("action_show_full_info", () => { + const client = this.connection?.getSelectedClientInfo().getClient(); + if(client) { + openClientInfo(client); + } + }); } - private unregisterClientEvents() { - this.listenerClient.forEach(callback => callback()); - this.listenerClient = []; + destroy() { + this.listenerConnection.forEach(callback => callback()); + this.listenerConnection = []; } - private registerClientEvents(client: ClientEntry) { - const events = this.listenerClient; + setConnectionHandler(connection: ConnectionHandler) { + if(this.connection === connection) { + return; + } - events.push(client.events.on("notify_properties_updated", event => { - if('client_nickname' in event.updated_properties) { - this.currentClientStatus.name = event.client_properties.client_nickname; - this.sendClientName(); + this.listenerConnection.forEach(callback => callback()); + this.listenerConnection = []; + + this.connection = connection; + if(connection) { + this.initializeConnection(connection); + } + this.sendClient(); + } + + private initializeConnection(connection: ConnectionHandler) { + this.listenerConnection.push(connection.groups.events.on("notify_groups_updated", event => { + const info = this.connection?.getSelectedClientInfo().getInfo(); + if(!info) { + return; } - if('client_description' in event.updated_properties) { - this.currentClientStatus.description = event.client_properties.client_description; - this.sendClientDescription(); - } - - if('client_channel_group_id' in event.updated_properties) { - this.currentClientStatus.channelGroup = event.client_properties.client_channel_group_id; - this.sendChannelGroup(); - } - - if('client_servergroups' in event.updated_properties) { - this.currentClientStatus.serverGroups = client.assignedServerGroupIds(); - this.sendServerGroups(); - } - - /* Can happen since that variable isn't in view on client appearance */ - if('client_lastconnected' in event.updated_properties) { - this.currentClientStatus.joinTimestamp = event.client_properties.client_lastconnected; - this.sendOnline(); - } - - if('client_country' in event.updated_properties) { - this.updateCachedCountry(client); - this.sendCountry(); - } - - for(const key of ["client_away", "client_away_message", "client_input_muted", "client_input_hardware", "client_output_muted", "client_output_hardware"]) { - if(key in event.updated_properties) { - this.updateCachedClientStatus(client); - this.sendClientStatus(); + for(const update of event.updates) { + if(update.group.id === info.channelGroup) { + this.sendChannelGroup(); break; } } - if('client_platform' in event.updated_properties || 'client_version' in event.updated_properties) { - this.currentClientStatus.version = { - platform: client.properties.client_platform, - version: client.properties.client_version - }; - this.sendVersion(); - } - - if('client_teaforo_flags' in event.updated_properties || 'client_teaforo_name' in event.updated_properties || 'client_teaforo_id' in event.updated_properties) { - this.updateForumAccount(client); - this.sendForum(); + for(const update of event.updates) { + if(info.serverGroups.indexOf(update.group.id) !== -1) { + this.sendServerGroups(); + break; + } } })); - events.push(client.events.on("notify_audio_level_changed", () => { - this.updateCachedVolume(client); - this.sendVolume(); - })); + this.listenerConnection.push(connection.getSelectedClientInfo().events.on("notify_cache_changed", event => { + switch (event.category) { + case "name": + this.sendClientName(); + break; - events.push(client.events.on("notify_mute_state_change", () => { - this.updateCachedVolume(client); - this.sendVolume(); - })); - } + case "country": + this.sendCountry(); + break; - private updateCachedClientStatus(client: ClientEntry) { - this.currentClientStatus.status = { - away: client.properties.client_away ? client.properties.client_away_message ? client.properties.client_away_message : true : false, - microphoneMuted: client.properties.client_input_muted, - microphoneDisabled: !client.properties.client_input_hardware, - speakerMuted: client.properties.client_output_muted, - speakerDisabled: !client.properties.client_output_hardware - }; - } + case "description": + this.sendClientDescription(); + break; - private updateCachedCountry(client: ClientEntry) { - this.currentClientStatus.country = { - flag: client.properties.client_country, - name: i18nc.country_name(client.properties.client_country.toUpperCase()), - }; - } + case "forum-account": + this.sendForum(); + break; - private updateCachedVolume(client: ClientEntry) { - this.currentClientStatus.volume = { - volume: client.getAudioVolume(), - muted: client.isMuted() - } - } + case "group-channel": + this.sendChannelGroup(); + break; - private updateForumAccount(client: ClientEntry) { - if(client.properties.client_teaforo_id) { - this.currentClientStatus.forumAccount = { - flags: client.properties.client_teaforo_flags, - nickname: client.properties.client_teaforo_name, - userId: client.properties.client_teaforo_id - }; - } else { - this.currentClientStatus.forumAccount = undefined; - } - } + case "groups-server": + this.sendServerGroups(); + break; - private initializeClientInfo(client: ClientEntry) { - this.currentClientStatus = { - type: client instanceof LocalClientEntry ? "self" : client.properties.client_type === ClientType.CLIENT_QUERY ? "query" : "voice", - name: client.properties.client_nickname, - databaseId: client.properties.client_database_id, - uniqueId: client.properties.client_unique_identifier, - clientId: client.clientId(), + case "online-state": + this.sendOnline(); + break; - description: client.properties.client_description, - channelGroup: client.properties.client_channel_group_id, - serverGroups: client.assignedServerGroupIds(), - country: undefined, - forumAccount: undefined, - joinTimestamp: client.properties.client_lastconnected, - leaveTimestamp: 0, - status: undefined, - volume: undefined, - version: { - platform: client.properties.client_platform, - version: client.properties.client_version + case "status": + this.sendClientStatus(); + break; + + case "version": + this.sendVolume(); + break; + + case "volume": + this.sendVolume(); + break; } - }; - this.updateCachedClientStatus(client); - this.updateCachedCountry(client); - this.updateCachedVolume(client); - this.updateForumAccount(client); - } - - destroy() { - this.listenerClient.forEach(callback => callback()); - this.listenerClient = []; - - this.listenerConnection.forEach(callback => callback()); - this.listenerConnection.splice(0, this.listenerConnection.length); - } - - setClient(client: ClientEntry | undefined) { - if(this.currentClient === client) { - return; - } - - this.unregisterClientEvents(); - this.currentClient = client; - this.currentClientStatus = undefined; - if(this.currentClient) { - this.currentClient.updateClientVariables().then(undefined); - this.registerClientEvents(this.currentClient); - this.initializeClientInfo(this.currentClient); - } - this.sendClient(); - this.events.fire("notify_client_changed", { newClient: client }); - } - - getClient() : ClientEntry | undefined { - return this.currentClient; + })); } private generateGroupInfo(groupId: number, type: "channel" | "server") : ClientGroupInfo { - const uniqueServerId = this.connection.channelTree.server.properties.virtualserver_unique_identifier; - const group = type === "channel" ? this.connection.groups.findChannelGroup(groupId) : this.connection.groups.findServerGroup(groupId); + const uniqueServerId = this.connection?.channelTree.server.properties.virtualserver_unique_identifier; + const group = type === "channel" ? this.connection?.groups.findChannelGroup(groupId) : this.connection?.groups.findServerGroup(groupId); if(!group) { return { @@ -310,14 +152,15 @@ export class ClientInfoController { } private sendClient() { - if(this.currentClientStatus) { + const info = this.connection?.getSelectedClientInfo().getInfo(); + if(info) { this.uiEvents.fire_react("notify_client", { info: { handlerId: this.connection.handlerId, - type: this.currentClientStatus.type, - clientDatabaseId: this.currentClientStatus.databaseId, - clientId: this.currentClientStatus.clientId, - clientUniqueId: this.currentClientStatus.uniqueId + type: info.type, + clientDatabaseId: info.databaseId, + clientId: info.clientId, + clientUniqueId: info.uniqueId } }); } else { @@ -328,19 +171,21 @@ export class ClientInfoController { } private sendChannelGroup() { - if(typeof this.currentClientStatus === "undefined") { + const info = this.connection?.getSelectedClientInfo().getInfo(); + if(typeof info === "undefined") { this.uiEvents.fire_react("notify_channel_group", { group: undefined }); } else { - this.uiEvents.fire_react("notify_channel_group", { group: this.generateGroupInfo(this.currentClientStatus.channelGroup, "channel") }); + this.uiEvents.fire_react("notify_channel_group", { group: this.generateGroupInfo(info.channelGroup, "channel") }); } } private sendServerGroups() { - if(this.currentClientStatus === undefined) { + const info = this.connection?.getSelectedClientInfo().getInfo(); + if(info === undefined) { this.uiEvents.fire_react("notify_server_groups", { groups: [] }); } else { this.uiEvents.fire_react("notify_server_groups", { - groups: this.currentClientStatus.serverGroups.map(group => this.generateGroupInfo(group, "server")) + groups: info.serverGroups.map(group => this.generateGroupInfo(group, "server")) .sort((a, b) => { if (a.groupSortOrder < b.groupSortOrder) return 1; @@ -361,8 +206,9 @@ export class ClientInfoController { } private sendClientStatus() { + const info = this.connection?.getSelectedClientInfo().getInfo(); this.uiEvents.fire_react("notify_status", { - status: this.currentClientStatus?.status || { + status: info?.status || { away: false, speakerDisabled: false, speakerMuted: false, @@ -373,27 +219,31 @@ export class ClientInfoController { } private sendClientName() { - this.uiEvents.fire_react("notify_client_name", { name: this.currentClientStatus?.name }); + const info = this.connection?.getSelectedClientInfo().getInfo(); + this.uiEvents.fire_react("notify_client_name", { name: info?.name }); } private sendClientDescription() { - this.uiEvents.fire_react("notify_client_description", { description: this.currentClientStatus?.description }); + const info = this.connection?.getSelectedClientInfo().getInfo(); + this.uiEvents.fire_react("notify_client_description", { description: info?.description }); } private sendOnline() { + const info = this.connection?.getSelectedClientInfo().getInfo(); this.uiEvents.fire_react("notify_online", { status: { - leaveTimestamp: this.currentClientStatus ? this.currentClientStatus.leaveTimestamp : 0, - joinTimestamp: this.currentClientStatus ? this.currentClientStatus.joinTimestamp : 0 + leaveTimestamp: info ? info.leaveTimestamp : 0, + joinTimestamp: info ? info.joinTimestamp : 0 } }); } private sendCountry() { + const info = this.connection?.getSelectedClientInfo().getInfo(); this.uiEvents.fire_react("notify_country", { - country: this.currentClientStatus ? { - name: this.currentClientStatus.country.name, - flag: this.currentClientStatus.country.flag + country: info ? { + name: info.country.name, + flag: info.country.flag } : { name: tr("Unknown"), flag: "xx" @@ -402,10 +252,11 @@ export class ClientInfoController { } private sendVolume() { + const info = this.connection?.getSelectedClientInfo().getInfo(); this.uiEvents.fire_react("notify_volume", { - volume: this.currentClientStatus ? { - volume: this.currentClientStatus.volume.volume, - muted: this.currentClientStatus.volume.muted + volume: info ? { + volume: info.volume.volume, + muted: info.volume.muted } : { volume: -1, muted: false @@ -414,10 +265,11 @@ export class ClientInfoController { } private sendVersion() { + const info = this.connection?.getSelectedClientInfo().getInfo(); this.uiEvents.fire_react("notify_version", { - version: this.currentClientStatus ? { - platform: this.currentClientStatus.version.platform, - version: this.currentClientStatus.version.version + version: info ? { + platform: info.version.platform, + version: info.version.version } : { platform: tr("Unknown"), version: tr("Unknown") @@ -426,6 +278,7 @@ export class ClientInfoController { } private sendForum() { - this.uiEvents.fire_react("notify_forum", { forum: this.currentClientStatus?.forumAccount }) + const info = this.connection?.getSelectedClientInfo().getInfo(); + this.uiEvents.fire_react("notify_forum", { forum: info?.forumAccount }) } } \ No newline at end of file diff --git a/shared/js/ui/frames/side/HeaderController.ts b/shared/js/ui/frames/side/HeaderController.ts index 5b89dc8a..2f9571ef 100644 --- a/shared/js/ui/frames/side/HeaderController.ts +++ b/shared/js/ui/frames/side/HeaderController.ts @@ -18,7 +18,7 @@ const ChannelInfoUpdateProperties: (keyof ChannelProperties)[] = [ ]; /* TODO: Remove the ping interval handler. It's currently still there since the clients are not emitting the event yet */ -export class SideHeader { +export class SideHeaderController { private readonly uiEvents: Registry; private connection: ConnectionHandler; @@ -43,26 +43,32 @@ export class SideHeader { private initialize() { this.uiEvents.on("action_open_conversation", () => { - const selectedClient = this.connection.side_bar.getClientInfo().getClient() + const selectedClient = this.connection.getSelectedClientInfo().getClient() if(selectedClient) { const conversations = this.connection.getPrivateConversations(); conversations.setSelectedConversation(conversations.findOrCreateConversation(selectedClient)); } - this.connection.side_bar.showPrivateConversations(); + this.connection.getSideBar().showPrivateConversations(); }); this.uiEvents.on("action_switch_channel_chat", () => { - this.connection.side_bar.showChannelConversations(); + this.connection.getSideBar().showChannelConversations(); }); this.uiEvents.on("action_bot_manage", () => { - const bot = this.connection.side_bar.music_info().current_bot(); + /* FIXME: TODO! */ + /* + const bot = this.connection.getSideBar().music_info().current_bot(); if(!bot) return; openMusicManage(this.connection, bot); + */ }); - this.uiEvents.on("action_bot_add_song", () => this.connection.side_bar.music_info().events.fire("action_song_add")); + this.uiEvents.on("action_bot_add_song", () => { + /* FIXME: TODO! */ + //this.connection.side_bar.music_info().events.fire("action_song_add") + }); this.uiEvents.on("query_client_info_own_client", () => this.sendClientInfoOwnClient()); this.uiEvents.on("query_current_channel_state", event => this.sendChannelState(event.mode)); @@ -98,7 +104,7 @@ export class SideHeader { this.listenerConnection.push(this.connection.serverConnection.events.on("notify_ping_updated", () => this.sendPing())); this.listenerConnection.push(this.connection.getPrivateConversations().events.on("notify_unread_count_changed", () => this.sendPrivateConversationInfo())); this.listenerConnection.push(this.connection.getPrivateConversations().events.on(["notify_conversation_destroyed", "notify_conversation_destroyed"], () => this.sendPrivateConversationInfo())); - this.listenerConnection.push(this.connection.side_bar.getClientInfo().events.on("notify_client_changed", () => this.sendClientInfoOwnClient())); + this.listenerConnection.push(this.connection.getSelectedClientInfo().events.on("notify_client_changed", () => this.sendClientInfoOwnClient())); } setConnectionHandler(connection: ConnectionHandler) { @@ -253,7 +259,7 @@ export class SideHeader { private sendClientInfoOwnClient() { if(this.connection) { - this.uiEvents.fire_react("notify_client_info_own_client", { isOwnClient: this.connection.side_bar.getClientInfo().getClient() instanceof LocalClientEntry }); + this.uiEvents.fire_react("notify_client_info_own_client", { isOwnClient: this.connection.getSelectedClientInfo().getClient() instanceof LocalClientEntry }); } else { this.uiEvents.fire_react("notify_client_info_own_client", { isOwnClient: false }); } diff --git a/shared/js/ui/frames/side/PopoutConversationRenderer.tsx b/shared/js/ui/frames/side/PopoutConversationRenderer.tsx index 72d92f6a..79ff39d8 100644 --- a/shared/js/ui/frames/side/PopoutConversationRenderer.tsx +++ b/shared/js/ui/frames/side/PopoutConversationRenderer.tsx @@ -1,11 +1,11 @@ import {Registry, RegistryMap} from "tc-shared/events"; -import {ConversationUIEvents} from "tc-shared/ui/frames/side/ConversationDefinitions"; +import {AbstractConversationUiEvents} from "./AbstractConversationDefinitions"; import {ConversationPanel} from "./AbstractConversationRenderer"; import * as React from "react"; import {AbstractModal} from "tc-shared/ui/react-elements/ModalDefinitions"; class PopoutConversationRenderer extends AbstractModal { - private readonly events: Registry; + private readonly events: Registry; private readonly userData: any; constructor(registryMap: RegistryMap, userData: any) { diff --git a/shared/js/ui/frames/side/PrivateConversationController.ts b/shared/js/ui/frames/side/PrivateConversationController.ts index 262bb8f1..60f9708a 100644 --- a/shared/js/ui/frames/side/PrivateConversationController.ts +++ b/shared/js/ui/frames/side/PrivateConversationController.ts @@ -4,12 +4,9 @@ import { PrivateConversationInfo, PrivateConversationUIEvents } from "../../../ui/frames/side/PrivateConversationDefinitions"; -import * as ReactDOM from "react-dom"; -import * as React from "react"; -import {PrivateConversationsPanel} from "./PrivateConversationRenderer"; import { - ConversationUIEvents -} from "../../../ui/frames/side/ConversationDefinitions"; + AbstractConversationUiEvents +} from "./AbstractConversationDefinitions"; import * as log from "../../../log"; import {LogCategory} from "../../../log"; import {AbstractConversationController} from "./AbstractConversationController"; @@ -48,35 +45,57 @@ export class PrivateConversationController extends AbstractConversationControlle PrivateConversation, PrivateConversationEvents > { - public readonly htmlTag: HTMLDivElement; - public readonly connection: ConnectionHandler; + private connection: ConnectionHandler; + private connectionListener: (() => void)[]; private listenerConversation: {[key: string]:(() => void)[]}; - constructor(connection: ConnectionHandler) { - super(connection.getPrivateConversations()); - this.connection = connection; + constructor() { + super(); + this.connectionListener = []; this.listenerConversation = {}; - this.htmlTag = document.createElement("div"); - this.htmlTag.style.display = "flex"; - this.htmlTag.style.flexDirection = "row"; - this.htmlTag.style.justifyContent = "stretch"; - this.htmlTag.style.height = "100%"; - this.uiEvents.register_handler(this, true); this.uiEvents.enableDebug("private-conversations"); + } - ReactDOM.render(React.createElement(PrivateConversationsPanel, { events: this.uiEvents, handler: this.connection }), this.htmlTag); + destroy() { + /* listenerConversation will be cleaned up via the listenerManager callbacks */ - this.uiEvents.on("notify_destroy", connection.events().on("notify_visibility_changed", event => { + this.uiEvents.unregister_handler(this); + super.destroy(); + } + + setConnectionHandler(connection: ConnectionHandler) { + if(this.connection === connection) { + return; + } + + this.connectionListener.forEach(callback => callback()); + this.connectionListener = []; + + this.connection = connection; + if(connection) { + this.initializeConnectionListener(connection); + this.setConversationManager(connection.getPrivateConversations()); + } else { + this.setConversationManager(undefined); + } + } + + private initializeConnectionListener(connection: ConnectionHandler) { + this.connectionListener.push(connection.events().on("notify_visibility_changed", event => { if(!event.visible) return; this.handlePanelShow(); })); + } - this.listenerManager.push(this.conversationManager.events.on("notify_conversation_created", event => { + protected registerConversationManagerEvents(manager: PrivateConversationManager) { + super.registerConversationManagerEvents(manager); + + this.listenerManager.push(manager.events.on("notify_conversation_created", event => { const conversation = event.conversation; const events = this.listenerConversation[conversation.getChatId()] = []; events.push(conversation.events.on("notify_partner_changed", event => { @@ -94,21 +113,17 @@ export class PrivateConversationController extends AbstractConversationControlle this.reportConversationList(); })); - this.listenerManager.push(this.conversationManager.events.on("notify_conversation_destroyed", event => { + this.listenerManager.push(manager.events.on("notify_conversation_destroyed", event => { this.listenerConversation[event.conversation.getChatId()]?.forEach(callback => callback()); delete this.listenerConversation[event.conversation.getChatId()]; this.reportConversationList(); })); - this.listenerManager.push(this.conversationManager.events.on("notify_selected_changed", () => this.reportConversationList())); - } - - destroy() { - ReactDOM.unmountComponentAtNode(this.htmlTag); - this.htmlTag.remove(); - - this.uiEvents.unregister_handler(this); - super.destroy(); + this.listenerManager.push(manager.events.on("notify_selected_changed", () => this.reportConversationList())); + this.listenerManager.push(() => { + Object.values(this.listenerConversation).forEach(callbacks => callbacks.forEach(callback => callback())); + this.listenerConversation = {}; + }); } focusInput() { @@ -117,8 +132,8 @@ export class PrivateConversationController extends AbstractConversationControlle private reportConversationList() { this.uiEvents.fire_react("notify_private_conversations", { - conversations: this.conversationManager.getConversations().map(generateConversationUiInfo), - selected: this.conversationManager.getSelectedConversation()?.clientUniqueId || "unselected" + conversations: this.conversationManager ? this.conversationManager.getConversations().map(generateConversationUiInfo) : [], + selected: this.conversationManager?.getSelectedConversation()?.clientUniqueId || "unselected" }); } @@ -129,7 +144,7 @@ export class PrivateConversationController extends AbstractConversationControlle @EventHandler("action_close_chat") private handleConversationClose(event: PrivateConversationUIEvents["action_close_chat"]) { - const conversation = this.conversationManager.findConversation(event.chatId); + const conversation = this.conversationManager?.findConversation(event.chatId); if(!conversation) { log.error(LogCategory.CLIENT, tr("Tried to close a not existing private conversation with id %s"), event.chatId); return; @@ -138,13 +153,8 @@ export class PrivateConversationController extends AbstractConversationControlle this.conversationManager.closeConversation(conversation); } - @EventHandler("notify_partner_typing") - private handleNotifySelectChat(event: PrivateConversationUIEvents["notify_partner_typing"]) { - /* TODO, set active chat? MH 9/12/20: What?? */ - } - - @EventHandler("action_self_typing") - protected handleActionSelfTyping1(_event: ConversationUIEvents["action_self_typing"]) { + @EventHandler("action_self_typing") + protected handleActionSelfTyping1(_event: AbstractConversationUiEvents["action_self_typing"]) { const conversation = this.getCurrentConversation(); if(!conversation) { return; diff --git a/shared/js/ui/frames/side/PrivateConversationDefinitions.ts b/shared/js/ui/frames/side/PrivateConversationDefinitions.ts index 8c98cfc6..2e083379 100644 --- a/shared/js/ui/frames/side/PrivateConversationDefinitions.ts +++ b/shared/js/ui/frames/side/PrivateConversationDefinitions.ts @@ -1,4 +1,4 @@ -import {ConversationUIEvents} from "../../../ui/frames/side/ConversationDefinitions"; +import {AbstractConversationUiEvents} from "./AbstractConversationDefinitions"; export type PrivateConversationInfo = { nickname: string; @@ -11,7 +11,7 @@ export type PrivateConversationInfo = { unreadMessages: boolean; }; -export interface PrivateConversationUIEvents extends ConversationUIEvents { +export interface PrivateConversationUIEvents extends AbstractConversationUiEvents { action_close_chat: { chatId: string }, query_private_conversations: {}, diff --git a/shared/js/ui/frames/side/PrivateConversationRenderer.scss b/shared/js/ui/frames/side/PrivateConversationRenderer.scss index 9794bf7b..8f88b7c5 100644 --- a/shared/js/ui/frames/side/PrivateConversationRenderer.scss +++ b/shared/js/ui/frames/side/PrivateConversationRenderer.scss @@ -17,6 +17,13 @@ html:root { --chat-private-selected-background: #2c2c2c; } +.dividerContainer { + display: flex; + flex-direction: row; + justify-content: stretch; + height: 100%; +} + .divider { width: 2px!important; min-width: 2px!important; diff --git a/shared/js/ui/frames/side/PrivateConversationRenderer.tsx b/shared/js/ui/frames/side/PrivateConversationRenderer.tsx index ad013c0f..cc87a6f5 100644 --- a/shared/js/ui/frames/side/PrivateConversationRenderer.tsx +++ b/shared/js/ui/frames/side/PrivateConversationRenderer.tsx @@ -218,10 +218,11 @@ const OpenConversationsPanel = React.memo(() => { export const PrivateConversationsPanel = (props: { events: Registry, handlerId: string }) => ( - +
+ - +
); \ No newline at end of file diff --git a/shared/js/ui/frames/side/music_info.ts b/shared/js/ui/frames/side/music_info.ts index 60f1eaf7..1ef3f2fa 100644 --- a/shared/js/ui/frames/side/music_info.ts +++ b/shared/js/ui/frames/side/music_info.ts @@ -1,4 +1,4 @@ -import {Frame, FrameContent} from "../../../ui/frames/chat_frame"; +import {SideBarController, FrameContent} from "../SideBarController"; import {LogCategory} from "../../../log"; import {CommandResult, PlaylistSong} from "../../../connection/ServerConnectionDeclaration"; import {createErrorModal, createInputModal} from "../../../ui/elements/Modal"; @@ -67,7 +67,7 @@ interface LoadedSongData { export class MusicInfo { readonly events: Registry; - readonly handle: Frame; + readonly handle: SideBarController; private _html_tag: JQuery; private _container_playlist: JQuery; @@ -91,7 +91,7 @@ export class MusicInfo { previous_frame_content: FrameContent; - constructor(handle: Frame) { + constructor(handle: SideBarController) { this.events = new Registry(); this.handle = handle; diff --git a/shared/js/ui/react-elements/ContextDivider.tsx b/shared/js/ui/react-elements/ContextDivider.tsx index 4b94f28f..bbbfc206 100644 --- a/shared/js/ui/react-elements/ContextDivider.tsx +++ b/shared/js/ui/react-elements/ContextDivider.tsx @@ -11,7 +11,7 @@ export interface ContextDividerProperties { separatorClassName?: string; separatorActiveClassName?: string; - children: [React.ReactElement, React.ReactElement]; + children?: never; } export interface ContextDividerState { @@ -99,11 +99,9 @@ export class ContextDivider extends React.Component this.startMovement(e)} onTouchStart={e => this.startMovement(e)} />, - this.props.children[1] - ]; + return ( +
this.startMovement(e)} onTouchStart={e => this.startMovement(e)} /> + ) } componentDidMount(): void { From 23414b7f3174ee15421b964c8a0d971b87972a5c Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Thu, 10 Dec 2020 13:32:35 +0100 Subject: [PATCH 11/37] Video now has to be manually activated in order to watch it --- shared/js/connection/VideoConnection.ts | 8 +- shared/js/connection/rtc/Connection.ts | 6 + shared/js/connection/rtc/video/Connection.ts | 94 ++++++-- shared/js/connection/rtc/video/VideoClient.ts | 124 +++++++++-- shared/js/settings.ts | 21 ++ shared/js/ui/frames/video/Controller.ts | 102 ++++++--- shared/js/ui/frames/video/Definitions.ts | 8 +- shared/js/ui/frames/video/Renderer.scss | 67 ++++++ shared/js/ui/frames/video/Renderer.tsx | 205 +++++++++++++----- .../global-settings-editor/Renderer.scss | 2 +- 10 files changed, 521 insertions(+), 116 deletions(-) diff --git a/shared/js/connection/VideoConnection.ts b/shared/js/connection/VideoConnection.ts index 8baf5251..ac0d53fc 100644 --- a/shared/js/connection/VideoConnection.ts +++ b/shared/js/connection/VideoConnection.ts @@ -41,9 +41,12 @@ export enum VideoConnectionStatus { } export enum VideoBroadcastState { + Stopped, + Available, /* A stream is available but we've not joined it */ Initializing, Running, - Stopped, + /* We've a stream but the stream does not replays anything */ + Buffering, } export interface VideoClientEvents { @@ -56,6 +59,9 @@ export interface VideoClient { getVideoState(broadcastType: VideoBroadcastType) : VideoBroadcastState; getVideoStream(broadcastType: VideoBroadcastType) : MediaStream; + + joinBroadcast(broadcastType: VideoBroadcastType) : Promise; + leaveBroadcast(broadcastType: VideoBroadcastType); } export interface VideoConnection { diff --git a/shared/js/connection/rtc/Connection.ts b/shared/js/connection/rtc/Connection.ts index a9de1bba..d53312b5 100644 --- a/shared/js/connection/rtc/Connection.ts +++ b/shared/js/connection/rtc/Connection.ts @@ -291,6 +291,8 @@ class CommandHandler extends AbstractCommandHandler { } else { logWarn(LogCategory.WEBRTC, tr("Received unknown/invalid rtc track state: %d"), state); } + } else if(command.command === "notifybroadcastvideo") { + /* FIXME: TODO! */ } return false; } @@ -920,10 +922,14 @@ export class RTCConnection { private handleLocalIceCandidate(candidate: RTCIceCandidate | undefined) { if(candidate) { + console.error(candidate.candidate); if(candidate.address?.endsWith(".local")) { logTrace(LogCategory.WEBRTC, tr("Skipping local fqdn ICE candidate %s"), candidate.toJSON().candidate); return; } + if(candidate.protocol !== "tcp") { + return; + } this.localCandidateCount++; const json = candidate.toJSON(); diff --git a/shared/js/connection/rtc/video/Connection.ts b/shared/js/connection/rtc/video/Connection.ts index e2f78f7d..ad9ea6c6 100644 --- a/shared/js/connection/rtc/video/Connection.ts +++ b/shared/js/connection/rtc/video/Connection.ts @@ -27,9 +27,7 @@ type VideoBroadcast = { export class RtpVideoConnection implements VideoConnection { private readonly rtcConnection: RTCConnection; private readonly events: Registry; - private readonly listenerClientMoved; - private readonly listenerRtcStateChanged; - private readonly listenerConnectionStateChanged; + private readonly listener: (() => void)[]; private connectionState: VideoConnectionStatus; private broadcasts: {[T in VideoBroadcastType]: VideoBroadcast} = { @@ -43,10 +41,19 @@ export class RtpVideoConnection implements VideoConnection { this.events = new Registry(); this.setConnectionState(VideoConnectionStatus.Disconnected); - this.listenerClientMoved = this.rtcConnection.getConnection().command_handler_boss().register_explicit_handler("notifyclientmoved", event => { - const localClientId = this.rtcConnection.getConnection().client.getClientId(); + this.listener = []; + + /* We only have to listen for move events since if the client is leaving the broadcast will be terminated anyways */ + this.listener.push(this.rtcConnection.getConnection().command_handler_boss().register_explicit_handler("notifyclientmoved", event => { + const localClient = this.rtcConnection.getConnection().client.getClient(); for(const data of event.arguments) { - if(parseInt(data["clid"]) === localClientId) { + const clientId = parseInt(data["clid"]); + if(clientId === localClient.clientId()) { + Object.values(this.registeredClients).forEach(client => { + client.setBroadcastId("screen", undefined); + client.setBroadcastId("camera", undefined); + }); + if(settings.static_global(Settings.KEY_STOP_VIDEO_ON_SWITCH)) { this.stopBroadcasting("camera", true); this.stopBroadcasting("screen", true); @@ -55,18 +62,72 @@ export class RtpVideoConnection implements VideoConnection { this.restartBroadcast("screen"); this.restartBroadcast("camera"); } + } else if(parseInt("scid") === localClient.currentChannel().channelId) { + const broadcast = this.registeredClients[clientId]; + broadcast?.setBroadcastId("screen", undefined); + broadcast?.setBroadcastId("camera", undefined); } } - }); - this.listenerConnectionStateChanged = this.rtcConnection.getConnection().events.on("notify_connection_state_changed", event => { + })); + + this.listener.push(this.rtcConnection.getConnection().command_handler_boss().register_explicit_handler("notifybroadcastvideo", event => { + const assignedClients: { clientId: number, broadcastType: VideoBroadcastType }[] = []; + for(const data of event.arguments) { + if(!("bid" in data)) { + continue; + } + + const broadcastId = parseInt(data["bid"]); + const broadcastType = parseInt(data["bt"]); + const sourceClientId = parseInt(data["sclid"]); + + if(!this.registeredClients[sourceClientId]) { + logWarn(LogCategory.VIDEO, tr("Received video broadcast info about a not registered client (%d)"), sourceClientId); + /* TODO: Cache the value! */ + continue; + } + + let videoBroadcastType: VideoBroadcastType; + switch(broadcastType) { + case 0x00: + videoBroadcastType = "camera"; + break; + + case 0x01: + videoBroadcastType = "screen"; + break; + + default: + logWarn(LogCategory.VIDEO, tr("Received video broadcast info with an invalid video broadcast type: %d."), broadcastType); + continue; + } + + this.registeredClients[sourceClientId].setBroadcastId(videoBroadcastType, broadcastId); + assignedClients.push({ broadcastType: videoBroadcastType, clientId: sourceClientId }); + } + + const broadcastTypes: VideoBroadcastType[] = ["screen", "camera"]; + Object.values(this.registeredClients).forEach(client => { + for(const type of broadcastTypes) { + if(assignedClients.findIndex(assignment => assignment.broadcastType === type && assignment.clientId === client.getClientId()) !== -1) { + continue; + } + + client.setBroadcastId(type, undefined); + } + }); + })); + + this.listener.push(this.rtcConnection.getConnection().events.on("notify_connection_state_changed", event => { if(event.newState !== ConnectionState.CONNECTED) { this.stopBroadcasting("camera"); this.stopBroadcasting("screen"); } - }); + })); - this.listenerRtcStateChanged = this.rtcConnection.getEvents().on("notify_state_changed", event => this.handleRtcConnectionStateChanged(event)); - this.rtcConnection.getEvents().on("notify_video_assignment_changed", event => { + this.listener.push(this.rtcConnection.getEvents().on("notify_state_changed", event => this.handleRtcConnectionStateChanged(event))); + + this.listener.push(this.rtcConnection.getEvents().on("notify_video_assignment_changed", event => { if(event.info) { switch (event.info.media) { case 2: @@ -86,7 +147,7 @@ export class RtpVideoConnection implements VideoConnection { this.handleVideoAssignmentChanged("screen", event); this.handleVideoAssignmentChanged("camera", event); } - }); + })); } private setConnectionState(state: VideoConnectionStatus) { @@ -121,9 +182,10 @@ export class RtpVideoConnection implements VideoConnection { } destroy() { - this.listenerClientMoved(); - this.listenerRtcStateChanged(); - this.listenerConnectionStateChanged(); + this.listener.forEach(callback => callback()); + this.listener.splice(0, this.listener.length); + + this.events.destroy(); } getEvents(): Registry { @@ -229,7 +291,7 @@ export class RtpVideoConnection implements VideoConnection { throw tr("a video client with this id has already been registered"); } - return this.registeredClients[clientId] = new RtpVideoClient(clientId); + return this.registeredClients[clientId] = new RtpVideoClient(this.rtcConnection, clientId); } registeredVideoClients(): VideoClient[] { diff --git a/shared/js/connection/rtc/video/VideoClient.ts b/shared/js/connection/rtc/video/VideoClient.ts index 4c7bccd1..653beb1c 100644 --- a/shared/js/connection/rtc/video/VideoClient.ts +++ b/shared/js/connection/rtc/video/VideoClient.ts @@ -6,10 +6,12 @@ import { } from "tc-shared/connection/VideoConnection"; import {Registry} from "tc-shared/events"; import {RemoteRTPTrackState, RemoteRTPVideoTrack} from "../RemoteTrack"; -import {LogCategory, logWarn} from "tc-shared/log"; -import { tr } from "tc-shared/i18n/localize"; +import {LogCategory, logError, logWarn} from "tc-shared/log"; +import {tr} from "tc-shared/i18n/localize"; +import {RTCConnection} from "tc-shared/connection/rtc/Connection"; export class RtpVideoClient implements VideoClient { + private readonly handle: RTCConnection; private readonly clientId: number; private readonly events: Registry; @@ -28,7 +30,18 @@ export class RtpVideoClient implements VideoClient { screen: VideoBroadcastState.Stopped } - constructor(clientId: number) { + private broadcastIds: {[T in VideoBroadcastType]: number | undefined} = { + camera: undefined, + screen: undefined + }; + + private joinedStates: {[T in VideoBroadcastType]: boolean} = { + camera: false, + screen: false + } + + constructor(handle: RTCConnection, clientId: number) { + this.handle = handle; this.clientId = clientId; this.events = new Registry(); } @@ -49,6 +62,50 @@ export class RtpVideoClient implements VideoClient { return this.trackStates[broadcastType]; } + async joinBroadcast(broadcastType: VideoBroadcastType): Promise { + if(typeof this.broadcastIds[broadcastType] === "undefined") { + throw tr("broadcast isn't available"); + } + + this.joinedStates[broadcastType] = true; + this.setBroadcastState(broadcastType, VideoBroadcastState.Initializing); + await this.handle.getConnection().send_command("broadcastvideojoin", { + bid: this.broadcastIds[broadcastType], + bt: broadcastType === "camera" ? 0 : 1 + }).then(() => { + /* the broadcast state should switch automatically to running since we got an RTP stream now */ + if(this.trackStates[broadcastType] === VideoBroadcastState.Initializing) { + throw tr("failed to receive stream"); + } + }).catch(error => { + this.updateBroadcastState(broadcastType); + this.joinedStates[broadcastType] = false; + logError(LogCategory.VIDEO, tr("Failed to join video broadcast: %o"), error); + throw tr("failed to join broadcast"); + }); + } + + leaveBroadcast(broadcastType: VideoBroadcastType) { + this.joinedStates[broadcastType] = false; + this.setBroadcastState(broadcastType, typeof this.trackStates[broadcastType] === "number" ? VideoBroadcastState.Available : VideoBroadcastState.Stopped); + + const connection = this.handle.getConnection(); + if(!connection.connected()) { + return; + } + + if(typeof this.broadcastIds[broadcastType] === "undefined") { + return; + } + + this.handle.getConnection().send_command("broadcastvideoleave", { + bid: this.broadcastIds[broadcastType], + bt: broadcastType === "camera" ? 0 : 1 + }).catch(error => { + logWarn(LogCategory.VIDEO, tr("Failed to leave video broadcast: %o"), error); + }); + } + destroy() { this.setRtpTrack("camera", undefined); this.setRtpTrack("screen", undefined); @@ -66,8 +123,21 @@ export class RtpVideoClient implements VideoClient { this.currentTrack[type] = track; if(this.currentTrack[type]) { this.currentTrack[type].getEvents().on("notify_state_changed", this.listenerTrackStateChanged[type]); - this.handleTrackStateChanged(type, this.currentTrack[type].getState()); } + this.updateBroadcastState(type); + } + + setBroadcastId(type: VideoBroadcastType, id: number | undefined) { + if(this.broadcastIds[type] === id) { + return; + } + + this.broadcastIds[type] = id; + if(typeof id === "undefined") { + /* we've to join each video explicitly */ + this.joinedStates[type] = false; + } + this.updateBroadcastState(type); } private setBroadcastState(type: VideoBroadcastType, state: VideoBroadcastState) { @@ -80,21 +150,41 @@ export class RtpVideoClient implements VideoClient { this.events.fire("notify_broadcast_state_changed", { broadcastType: type, oldState: oldState, newState: state }); } - private handleTrackStateChanged(type: VideoBroadcastType, newState: RemoteRTPTrackState) { - switch (newState) { - case RemoteRTPTrackState.Bound: - case RemoteRTPTrackState.Unbound: - this.setBroadcastState(type, VideoBroadcastState.Stopped); - break; + private handleTrackStateChanged(type: VideoBroadcastType, _newState: RemoteRTPTrackState) { + this.updateBroadcastState(type); + } - case RemoteRTPTrackState.Started: - this.setBroadcastState(type, VideoBroadcastState.Running); - break; + private updateBroadcastState(type: VideoBroadcastType) { + if(!this.broadcastIds[type]) { + this.setBroadcastState(type, VideoBroadcastState.Stopped); + } else if(!this.joinedStates[type]) { + this.setBroadcastState(type, VideoBroadcastState.Available); + } else { + const rtpState = this.currentTrack[type]?.getState(); + switch (rtpState) { + case undefined: + /* We're initializing the broadcast */ + this.setBroadcastState(type, VideoBroadcastState.Initializing); + break; - case RemoteRTPTrackState.Destroyed: - logWarn(LogCategory.VIDEO, tr("Received new track state 'Destroyed' which should never happen.")); - this.setBroadcastState(type, VideoBroadcastState.Stopped); - break; + case RemoteRTPTrackState.Unbound: + logWarn(LogCategory.VIDEO, tr("Updated video broadcast state and the track state is 'Unbound' which should never happen.")); + this.setBroadcastState(type, VideoBroadcastState.Stopped); + break; + + case RemoteRTPTrackState.Destroyed: + logWarn(LogCategory.VIDEO, tr("Updated video broadcast state and the track state is 'Destroyed' which should never happen.")); + this.setBroadcastState(type, VideoBroadcastState.Stopped); + break; + + case RemoteRTPTrackState.Started: + this.setBroadcastState(type, VideoBroadcastState.Running); + break; + + case RemoteRTPTrackState.Bound: + this.setBroadcastState(type, VideoBroadcastState.Buffering); + break; + } } } } \ No newline at end of file diff --git a/shared/js/settings.ts b/shared/js/settings.ts index ce660b26..5672e82c 100644 --- a/shared/js/settings.ts +++ b/shared/js/settings.ts @@ -520,6 +520,27 @@ export class Settings extends StaticSettings { valueType: "boolean", }; + static readonly KEY_VIDEO_SHOW_ALL_CLIENTS: ValuedSettingsKey = { + key: 'video_show_all_clients', + defaultValue: false, + description: "Show all clients within the video frame, even if they're not broadcasting video", + valueType: "boolean", + }; + + static readonly KEY_VIDEO_FORCE_SHOW_OWN_VIDEO: ValuedSettingsKey = { + key: 'video_force_show_own_video', + defaultValue: true, + description: "Show own video preview even if you're not broadcasting any video", + valueType: "boolean", + }; + + static readonly KEY_VIDEO_AUTO_SUBSCRIBE_MODE: ValuedSettingsKey = { + key: 'video_auto_subscribe_mode', + defaultValue: 1, + description: "Auto subscribe to incoming videos.\n0 := Do not auto subscribe.\n1 := Auto subscribe to the first video.\n2 := Subscribe to all incoming videos.", + valueType: "number", + }; + static readonly FN_LOG_ENABLED: (category: string) => SettingsKey = category => { return { key: "log." + category.toLowerCase() + ".enabled", diff --git a/shared/js/ui/frames/video/Controller.ts b/shared/js/ui/frames/video/Controller.ts index 20b4a804..fb707e2e 100644 --- a/shared/js/ui/frames/video/Controller.ts +++ b/shared/js/ui/frames/video/Controller.ts @@ -6,8 +6,9 @@ import {Registry} from "tc-shared/events"; import {ChannelVideoEvents, kLocalVideoId} from "tc-shared/ui/frames/video/Definitions"; import {VideoBroadcastState, VideoBroadcastType, VideoConnection} from "tc-shared/connection/VideoConnection"; import {ClientEntry, ClientType, LocalClientEntry, MusicClientEntry} from "tc-shared/tree/Client"; -import {LogCategory, logWarn} from "tc-shared/log"; -import { tr } from "tc-shared/i18n/localize"; +import {LogCategory, logError, logWarn} from "tc-shared/log"; +import {tr} from "tc-shared/i18n/localize"; +import {Settings, settings} from "tc-shared/settings"; const cssStyle = require("./Renderer.scss"); @@ -15,6 +16,7 @@ let videoIdIndex = 0; interface ClientVideoController { destroy(); toggleMuteState(type: VideoBroadcastType, state: boolean); + dismissVideo(type: VideoBroadcastType); notifyVideoInfo(); notifyVideo(); @@ -30,13 +32,12 @@ class RemoteClientVideoController implements ClientVideoController { protected eventListener: (() => void)[]; protected eventListenerVideoClient: (() => void)[]; - protected mutedState: {[T in VideoBroadcastType]: boolean} = { + private currentBroadcastState: boolean; + private dismissed: {[T in VideoBroadcastType]: boolean} = { screen: false, camera: false }; - private currentBroadcastState: boolean; - constructor(client: ClientEntry, eventRegistry: Registry, videoId?: string) { this.client = client; this.events = eventRegistry; @@ -54,7 +55,10 @@ class RemoteClientVideoController implements ClientVideoController { this.events.fire_react("notify_video_info_status", { videoId: this.videoId, statusIcon: event.newIcon }); })); - events.push(client.events.on("notify_video_handle_changed", () => this.updateVideoClient())); + events.push(client.events.on("notify_video_handle_changed", () => { + Object.keys(this.dismissed).forEach(key => this.dismissed[key] = false); + this.updateVideoClient(); + })); this.updateVideoClient(); } @@ -65,7 +69,12 @@ class RemoteClientVideoController implements ClientVideoController { const videoClient = this.client.getVideoClient(); if(videoClient) { - events.push(videoClient.getEvents().on("notify_broadcast_state_changed", () => { + events.push(videoClient.getEvents().on("notify_broadcast_state_changed", event => { + console.error("Broadcast state changed: %o - %o - %o", event.broadcastType, VideoBroadcastState[event.oldState], VideoBroadcastState[event.newState]); + if(event.newState === VideoBroadcastState.Stopped || event.oldState === VideoBroadcastState.Stopped) { + /* we've a new broadcast which hasn't been dismissed yet */ + this.dismissed[event.broadcastType] = false; + } this.notifyVideo(); this.notifyMuteState(); })); @@ -85,12 +94,27 @@ class RemoteClientVideoController implements ClientVideoController { return videoClient && (videoClient.getVideoState("camera") !== VideoBroadcastState.Stopped || videoClient.getVideoState("screen") !== VideoBroadcastState.Stopped); } - toggleMuteState(type: VideoBroadcastType, state: boolean) { - if(this.mutedState[type] === state) { return; } + toggleMuteState(type: VideoBroadcastType, muted: boolean) { + if(muted) { + this.client.getVideoClient().leaveBroadcast(type); + } else { + /* we explicitly specified that we don't want to have that */ + this.dismissed[type] = true; - this.mutedState[type] = state; + this.client.getVideoClient().joinBroadcast(type).catch(error => { + logError(LogCategory.VIDEO, tr("Failed to join video broadcast: %o"), error); + /* TODO: Propagate error? */ + }); + } + } + + dismissVideo(type: VideoBroadcastType) { + if(this.dismissed[type] === true) { + return; + } + + this.dismissed[type] = true; this.notifyVideo(); - this.notifyMuteState(); } notifyVideoInfo() { @@ -113,21 +137,19 @@ class RemoteClientVideoController implements ClientVideoController { let cameraStream, desktopStream; const stateCamera = this.getBroadcastState("camera"); - if(stateCamera === VideoBroadcastState.Running) { + if(stateCamera === VideoBroadcastState.Available) { + cameraStream = "available"; + } else if(stateCamera === VideoBroadcastState.Running) { cameraStream = this.getBroadcastStream("camera") - if(cameraStream && this.mutedState["camera"]) { - cameraStream = "muted"; - } } else if(stateCamera === VideoBroadcastState.Initializing) { initializing = true; } const stateScreen = this.getBroadcastState("screen"); - if(stateScreen === VideoBroadcastState.Running) { + if(stateScreen === VideoBroadcastState.Available) { + desktopStream = "available"; + } else if(stateScreen === VideoBroadcastState.Running) { desktopStream = this.getBroadcastStream("screen"); - if(desktopStream && this.mutedState["screen"]) { - desktopStream = "muted"; - } } else if(stateScreen === VideoBroadcastState.Initializing) { initializing = true; } @@ -141,6 +163,8 @@ class RemoteClientVideoController implements ClientVideoController { desktopStream: desktopStream, cameraStream: cameraStream, + + dismissed: this.dismissed } }); } else if(initializing) { @@ -156,7 +180,9 @@ class RemoteClientVideoController implements ClientVideoController { status: "connected", cameraStream: undefined, - desktopStream: undefined + desktopStream: undefined, + + dismissed: this.dismissed } }); } @@ -179,8 +205,8 @@ class RemoteClientVideoController implements ClientVideoController { this.events.fire_react("notify_video_mute_status", { videoId: this.videoId, status: { - camera: this.getBroadcastStream("camera") ? this.mutedState["camera"] ? "muted" : "available" : "unset", - screen: this.getBroadcastStream("screen") ? this.mutedState["screen"] ? "muted" : "available" : "unset", + camera: this.getBroadcastState("camera") === VideoBroadcastState.Available ? "muted" : this.getBroadcastState("camera") === VideoBroadcastState.Stopped ? "unset" : "available", + screen: this.getBroadcastState("screen") === VideoBroadcastState.Available ? "muted" : this.getBroadcastState("screen") === VideoBroadcastState.Stopped ? "unset" : "available", } }); } @@ -305,6 +331,16 @@ class ChannelVideoController { controller.toggleMuteState(event.broadcastType, event.muted); }); + this.events.on("action_dismiss", event => { + const controller = this.findVideoById(event.videoId); + if(!controller) { + logWarn(LogCategory.VIDEO, tr("Tried to dismiss video for a non existing video id (%s)."), event.videoId); + return; + } + + controller.dismissVideo(event.broadcastType); + }); + this.events.on("query_expended", () => this.events.fire_react("notify_expended", { expended: this.expended })); this.events.on("query_videos", () => this.notifyVideoList()); this.events.on("query_spotlight", () => this.notifySpotlight()); @@ -398,6 +434,9 @@ class ChannelVideoController { this.notifyVideoList(); } })); + + events.push(settings.globalChangeListener(Settings.KEY_VIDEO_SHOW_ALL_CLIENTS, () => this.notifyVideoList())); + events.push(settings.globalChangeListener(Settings.KEY_VIDEO_FORCE_SHOW_OWN_VIDEO, () => this.notifyVideoList())); } setSpotlight(videoId: string | undefined) { @@ -486,10 +525,15 @@ class ChannelVideoController { private notifyVideoList() { const videoIds = []; - let videoCount = 0; + let videoStreamingCount = 0; if(this.localVideoController) { - videoIds.push(this.localVideoController.videoId); - if(this.localVideoController.isBroadcasting()) { videoCount++; } + const localBroadcasting = this.localVideoController.isBroadcasting(); + if(localBroadcasting || settings.static_global(Settings.KEY_VIDEO_FORCE_SHOW_OWN_VIDEO)) { + videoIds.push(this.localVideoController.videoId); + if(localBroadcasting) { + videoStreamingCount++; + } + } } const channel = this.connection.channelTree.findChannel(this.currentChannelId); @@ -503,15 +547,15 @@ class ChannelVideoController { const controller = this.clientVideos[client.clientId()]; if(controller.isBroadcasting()) { - videoCount++; - } else { - /* TODO: Filter if video is active */ + videoStreamingCount++; + } else if(!settings.static_global(Settings.KEY_VIDEO_SHOW_ALL_CLIENTS)) { + continue; } videoIds.push(controller.videoId); } } - this.updateVisibility(videoCount !== 0); + this.updateVisibility(videoStreamingCount !== 0); if(this.expended) { videoIds.remove(this.currentSpotlight); } diff --git a/shared/js/ui/frames/video/Definitions.ts b/shared/js/ui/frames/video/Definitions.ts index 636c8bcf..7904af21 100644 --- a/shared/js/ui/frames/video/Definitions.ts +++ b/shared/js/ui/frames/video/Definitions.ts @@ -4,14 +4,17 @@ import {VideoBroadcastType} from "tc-shared/connection/VideoConnection"; export const kLocalVideoId = "__local__video__"; export type ChannelVideoInfo = { clientName: string, clientUniqueId: string, clientId: number, statusIcon: ClientIcon }; +export type ChannelVideoStream = "available" | MediaStream | undefined; export type ChannelVideo ={ status: "initializing", } | { status: "connected", - cameraStream: "muted" | MediaStream | undefined, - desktopStream: "muted" | MediaStream | undefined + cameraStream: ChannelVideoStream, + desktopStream: ChannelVideoStream, + + dismissed: {[T in VideoBroadcastType]: boolean} } | { status: "error", message: string @@ -61,6 +64,7 @@ export interface ChannelVideoEvents { action_focus_spotlight: {}, action_set_fullscreen: { videoId: string | undefined }, action_toggle_mute: { videoId: string, broadcastType: VideoBroadcastType, muted: boolean }, + action_dismiss: { videoId: string, broadcastType: VideoBroadcastType }, query_expended: {}, query_videos: {}, diff --git a/shared/js/ui/frames/video/Renderer.scss b/shared/js/ui/frames/video/Renderer.scss index 11be9e97..e1961156 100644 --- a/shared/js/ui/frames/video/Renderer.scss +++ b/shared/js/ui/frames/video/Renderer.scss @@ -193,6 +193,11 @@ $small_height: 10em; .videoContainer .actionIcons { opacity: .5; } + + .videoSecondary { + max-width: 25% !important; + max-height: 25%!important; + } } .videoContainer { @@ -237,6 +242,9 @@ $small_height: 10em; max-height: 50%; border-bottom-left-radius: .2em; + + background: #2e2e2e; + box-shadow: inset 0 0 5px #00000040; } .text { @@ -254,6 +262,65 @@ $small_height: 10em; &.error { /* TODO! */ } + + .videoAvailable { + display: flex; + flex-direction: column; + justify-content: center; + + .button { + width: 5em; + align-self: center; + margin-top: .5em; + font-size: .8em; + margin-bottom: .5em; + } + + .buttons { + display: flex; + flex-direction: row; + justify-content: stretch; + + align-self: center; + + width: 8.5em; + } + + .button2 { + width: 8em; + min-width: 3em; + + flex-shrink: 1; + flex-grow: 0; + + align-self: center; + + background-color: #3d3d3d; + + border-radius: .18em; + + padding-right: .31em; + padding-left: .31em; + + transition: background-color 0.25s ease-in-out; + cursor: pointer; + + &:not(:first-child) { + margin-left: .5em; + } + + &:hover { + background-color: #4a4a4a; + } + } + } + + &.videoSecondary { + font-size: .75em; + + height: 100%; + width: 100%; + } } .info { diff --git a/shared/js/ui/frames/video/Renderer.tsx b/shared/js/ui/frames/video/Renderer.tsx index 9881cc43..57b70492 100644 --- a/shared/js/ui/frames/video/Renderer.tsx +++ b/shared/js/ui/frames/video/Renderer.tsx @@ -3,7 +3,13 @@ import {useCallback, useContext, useEffect, useRef, useState} from "react"; import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons"; import {ClientIcon} from "svg-sprites/client-icons"; import {Registry} from "tc-shared/events"; -import {ChannelVideo, ChannelVideoEvents, ChannelVideoInfo, kLocalVideoId} from "tc-shared/ui/frames/video/Definitions"; +import { + ChannelVideo, + ChannelVideoEvents, + ChannelVideoInfo, + ChannelVideoStream, + kLocalVideoId +} from "tc-shared/ui/frames/video/Definitions"; import {Translatable} from "tc-shared/ui/react-elements/i18n"; import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; import {ClientTag} from "tc-shared/ui/tree/EntryTags"; @@ -88,20 +94,43 @@ const VideoStreamReplay = React.memo((props: { stream: MediaStream | undefined, video.autoplay = true; video.muted = true; - video.play().then(undefined).catch(() => { - logWarn(LogCategory.VIDEO, tr("Failed to start video replay. Retrying in 500ms intervals.")); - refReplayTimeout.current = setInterval(() => { - video.play().then(() => { - clearInterval(refReplayTimeout.current); - refReplayTimeout.current = undefined; - }).catch(() => {}); + const executePlay = () => { + if(refReplayTimeout.current) { + return; + } + + video.play().then(undefined).catch(() => { + logWarn(LogCategory.VIDEO, tr("Failed to start video replay. Retrying in 500ms intervals.")); + refReplayTimeout.current = setInterval(() => { + video.play().then(() => { + clearInterval(refReplayTimeout.current); + refReplayTimeout.current = undefined; + }).catch(() => {}); + }); }); - }); + }; + + video.onpause = () => { + logWarn(LogCategory.VIDEO, tr("Video replay paused. Executing play again.")); + executePlay(); + } + + video.onended = () => { + logWarn(LogCategory.VIDEO, tr("Video replay ended. Executing play again.")); + executePlay(); + } + executePlay(); } else { video.style.opacity = "0"; } return () => { + const video = refVideo.current; + if(video) { + video.onpause = undefined; + video.onended = undefined; + } + clearInterval(refReplayTimeout.current); refReplayTimeout.current = undefined; } @@ -112,12 +141,45 @@ const VideoStreamReplay = React.memo((props: { stream: MediaStream | undefined, ) }); +const VideoAvailableRenderer = (props: { callbackEnable: () => void, callbackIgnore?: () => void, className?: string }) => ( +
+
+ Video available +
+
+ Watch +
+ {!props.callbackIgnore ? undefined : +
+ Ignore +
+ } +
+
+
+); + +const VideoStreamRenderer = (props: { stream: ChannelVideoStream, callbackEnable: () => void, callbackIgnore: () => void, videoTitle: string, className?: string }) => { + if(props.stream === "available") { + return ; + } else if(props.stream === undefined) { + return ( +
+
No Video
+
+ ); + } else { + return ; + } +} + const VideoPlayer = React.memo((props: { videoId: string }) => { const events = useContext(EventContext); const [ state, setState ] = useState<"loading" | ChannelVideo>(() => { events.fire("query_video", { videoId: props.videoId }); return "loading"; }); + events.reactUse("notify_video", event => { if(event.videoId === props.videoId) { setState(event.status); @@ -143,40 +205,83 @@ const VideoPlayer = React.memo((props: { videoId: string }) => {
); } else if(state.status === "connected") { - const desktopStream = state.desktopStream === "muted" ? undefined : state.desktopStream; - const cameraStream = state.cameraStream === "muted" ? undefined : state.cameraStream; + const streamElements = []; + const streamClasses = [cssStyle.videoPrimary, cssStyle.videoSecondary]; - if(desktopStream && cameraStream) { - return ( - - - - + if(state.desktopStream === "available" && (state.cameraStream === "available" || state.cameraStream === undefined) || + state.cameraStream === "available" && (state.desktopStream === "available" || state.desktopStream === undefined) + ) { + /* One or both streams are available. Showing just one box. */ + streamElements.push( + { + if(state.desktopStream === "available") { + events.fire("action_toggle_mute", { broadcastType: "screen", muted: false, videoId: props.videoId }) + } + + if(state.cameraStream === "available") { + events.fire("action_toggle_mute", { broadcastType: "camera", muted: false, videoId: props.videoId }) + } + }} + className={streamClasses.pop_front()} + /> ); } else { - const stream = desktopStream || cameraStream; - if(stream) { - return ( - - ); - } else if(state.desktopStream || state.cameraStream) { - return ( -
-
Video muted
-
- ); - } else { - return ( -
-
No Video
-
- ); + if(state.desktopStream) { + if(!state.dismissed["screen"] || state.desktopStream !== "available") { + streamElements.push( + events.fire("action_toggle_mute", { broadcastType: "screen", muted: false, videoId: props.videoId })} + callbackIgnore={() => events.fire("action_dismiss", { broadcastType: "screen", videoId: props.videoId })} + videoTitle={tr("Screen")} + className={streamClasses.pop_front()} + /> + ); + } + } + + if(state.cameraStream) { + if(!state.dismissed["camera"] || state.cameraStream !== "available") { + streamElements.push( + events.fire("action_toggle_mute", { broadcastType: "camera", muted: false, videoId: props.videoId })} + callbackIgnore={() => events.fire("action_dismiss", { broadcastType: "camera", videoId: props.videoId })} + videoTitle={tr("Camera")} + className={streamClasses.pop_front()} + /> + ); + } } } + + if(streamElements.length === 0){ + return ( +
+
+ {props.videoId === kLocalVideoId ? + You're not broadcasting video : + No Video + } +
+
+ ); + } + + return <>{streamElements}; } else if(state.status === "no-video") { return (
-
No Video
+
+ {props.videoId === kLocalVideoId ? + You're not broadcasting video : + No Video + } +
); } @@ -288,18 +393,11 @@ const VideoContainer = React.memo((props: { videoId: string, isSpotlight: boolea
-
{ - if(props.isSpotlight) { - events.fire("action_set_fullscreen", { videoId: isFullscreen ? undefined : props.videoId }); - } else { - events.fire("action_set_spotlight", { videoId: props.videoId, expend: true }); - events.fire("action_focus_spotlight", { }); - } - }} - title={props.isSpotlight ? tr("Toggle fullscreen") : tr("Toggle spotlight")} +
events.fire("action_toggle_mute", { videoId: props.videoId, broadcastType: "screen", muted: muteState.screen === "available" })} + title={muteState["screen"] === "muted" ? tr("Unmute screen video") : tr("Mute screen video")} > - +
events.fire("action_toggle_mute", { videoId: props.videoId, broadcastType: "camera", muted: muteState.camera === "available" })} @@ -307,11 +405,18 @@ const VideoContainer = React.memo((props: { videoId: string, isSpotlight: boolea >
-
events.fire("action_toggle_mute", { videoId: props.videoId, broadcastType: "screen", muted: muteState.screen === "available" })} - title={muteState["screen"] === "muted" ? tr("Unmute screen video") : tr("Mute screen video")} +
{ + if(props.isSpotlight) { + events.fire("action_set_fullscreen", { videoId: isFullscreen ? undefined : props.videoId }); + } else { + events.fire("action_set_spotlight", { videoId: props.videoId, expend: true }); + events.fire("action_focus_spotlight", { }); + } + }} + title={props.isSpotlight ? tr("Toggle fullscreen") : tr("Toggle spotlight")} > - +
diff --git a/shared/js/ui/modal/global-settings-editor/Renderer.scss b/shared/js/ui/modal/global-settings-editor/Renderer.scss index 96c869dd..ff509f74 100644 --- a/shared/js/ui/modal/global-settings-editor/Renderer.scss +++ b/shared/js/ui/modal/global-settings-editor/Renderer.scss @@ -184,7 +184,7 @@ .infoDescription { .value { white-space: pre-wrap!important; - height: 3.2em!important; + min-height: 3.2em !important; } } From f50c6c4f3e3b4a12635930e082a7f6641e2be6bc Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Fri, 11 Dec 2020 11:55:41 +0100 Subject: [PATCH 12/37] Fixed music bot compiler error --- shared/js/ui/frames/side/music_info.ts | 1812 ++++++++++++------------ 1 file changed, 906 insertions(+), 906 deletions(-) diff --git a/shared/js/ui/frames/side/music_info.ts b/shared/js/ui/frames/side/music_info.ts index 1ef3f2fa..f86a227b 100644 --- a/shared/js/ui/frames/side/music_info.ts +++ b/shared/js/ui/frames/side/music_info.ts @@ -1,906 +1,906 @@ -import {SideBarController, FrameContent} from "../SideBarController"; -import {LogCategory} from "../../../log"; -import {CommandResult, PlaylistSong} from "../../../connection/ServerConnectionDeclaration"; -import {createErrorModal, createInputModal} from "../../../ui/elements/Modal"; -import * as log from "../../../log"; -import * as image_preview from "../image_preview"; -import {Registry} from "../../../events"; -import {ErrorCode} from "../../../connection/ErrorCode"; -import {ClientEvents, MusicClientEntry, SongInfo} from "../../../tree/Client"; -import { tr } from "tc-shared/i18n/localize"; - -export interface MusicSidebarEvents { - "open": {}, /* triggers when frame should be shown */ - "close": {}, /* triggers when frame will be closed */ - - "bot_change": { - old: MusicClientEntry | undefined, - new: MusicClientEntry | undefined - }, - "bot_property_update": { - properties: string[] - }, - - "action_play": {}, - "action_pause": {}, - "action_song_set": { song_id: number }, - "action_forward": {}, - "action_rewind": {}, - "action_forward_ms": { - units: number; - }, - "action_rewind_ms": { - units: number; - }, - "action_song_add": {}, - "action_song_delete": { song_id: number }, - "action_playlist_reload": {}, - - "playtime_move_begin": {}, - "playtime_move_end": { - canceled: boolean, - target_time?: number - }, - - "reorder_begin": { song_id: number; entry: JQuery }, - "reorder_end": { song_id: number; canceled: boolean; entry: JQuery; previous_entry?: number }, - - "player_time_update": ClientEvents["music_status_update"], - "player_song_change": ClientEvents["music_song_change"], - - "playlist_song_add": ClientEvents["playlist_song_add"] & { insert_effect?: boolean }, - "playlist_song_remove": ClientEvents["playlist_song_remove"], - "playlist_song_reorder": ClientEvents["playlist_song_reorder"], - "playlist_song_loaded": ClientEvents["playlist_song_loaded"] & { html_entry?: JQuery }, -} - -interface LoadedSongData { - description: string; - title: string; - url: string; - - length: number; - thumbnail?: string; - - metadata: {[key: string]: string}; -} - -export class MusicInfo { - readonly events: Registry; - readonly handle: SideBarController; - - private _html_tag: JQuery; - private _container_playlist: JQuery; - - private currentMusicBot: MusicClientEntry | undefined; - private update_song_info: number = 0; /* timestamp when we force update the info */ - private time_select: { - active: boolean, - max_time: number, - current_select_time: number, - current_player_time: number - } = { active: false, current_select_time: 0, max_time: 0, current_player_time: 0}; - private song_reorder: { - active: boolean, - song_id: number, - previous_entry: number, - html: JQuery, - mouse?: {x: number, y: number}, - indicator: JQuery - } = { active: false, song_id: 0, previous_entry: 0, html: undefined, indicator: $.spawn("div").addClass("reorder-indicator") }; - - previous_frame_content: FrameContent; - - constructor(handle: SideBarController) { - this.events = new Registry(); - this.handle = handle; - - this.events.enableDebug("music-info"); - this.initialize_listener(); - this._build_html_tag(); - - this.set_current_bot(undefined, true); - } - - html_tag() : JQuery { - return this._html_tag; - } - - destroy() { - this.set_current_bot(undefined); - this.events.destroy(); - - this._html_tag && this._html_tag.remove(); - this._html_tag = undefined; - - this.currentMusicBot = undefined; - this.previous_frame_content = undefined; - } - - private format_time(value: number) { - if(value == 0) return "--:--:--"; - - value /= 1000; - - let hours = 0, minutes = 0; - while(value >= 60 * 60) { - hours++; - value -= 60 * 60; - } - - while(value >= 60) { - minutes++; - value -= 60; - } - - return ("0" + hours).substr(-2) + ":" + ("0" + minutes).substr(-2) + ":" + ("0" + value.toFixed(0)).substr(-2); - }; - - private _build_html_tag() { - this._html_tag = $("#tmpl_frame_chat_music_info").renderTag(); - this._container_playlist = this._html_tag.find(".container-playlist"); - - this._html_tag.find(".button-close").on('click', () => { - if(this.previous_frame_content === FrameContent.CLIENT_INFO) { - this.previous_frame_content = FrameContent.NONE; - } - - this.handle.set_content(this.previous_frame_content); - }); - - this._html_tag.find(".button-reload-playlist").on('click', () => this.events.fire("action_playlist_reload")); - this._html_tag.find(".button-song-add").on('click', () => this.events.fire("action_song_add")); - this._html_tag.find(".thumbnail").on('click', event => { - const image = this._html_tag.find(".thumbnail img"); - const url = image.attr("x-thumbnail-url"); - if(!url) return; - - image_preview.preview_image(decodeURIComponent(url), decodeURIComponent(url)); - }); - - { - const button_play = this._html_tag.find(".control-buttons .button-play"); - const button_pause = this._html_tag.find(".control-buttons .button-pause"); - - button_play.on('click', () => this.events.fire("action_play")); - button_pause.on('click', () => this.events.fire("action_pause")); - - this.events.on(["bot_change", "bot_property_update"], event => { - if(event.type === "bot_property_update" && event.as<"bot_property_update">().properties.indexOf("player_state") == -1) return; - - button_play.toggleClass("hidden", this.currentMusicBot === undefined || this.currentMusicBot.isCurrentlyPlaying()); - }); - - this.events.on(["bot_change", "bot_property_update"], event => { - if(event.type === "bot_property_update" && event.as<"bot_property_update">().properties.indexOf("player_state") == -1) return; - - button_pause.toggleClass("hidden", this.currentMusicBot !== undefined && !this.currentMusicBot.isCurrentlyPlaying()); - }); - - this._html_tag.find(".control-buttons .button-rewind").on('click', () => this.events.fire("action_rewind")); - this._html_tag.find(".control-buttons .button-forward").on('click', () => this.events.fire("action_forward")); - } - - /* timeline updaters */ - { - const container = this._html_tag.find(".container-timeline"); - - const timeline = container.find(".timeline"); - const indicator_playtime = container.find(".indicator-playtime"); - const indicator_buffered = container.find(".indicator-buffered"); - const thumb = container.find(".thumb"); - - const timestamp_current = container.find(".timestamps .current"); - const timestamp_max = container.find(".timestamps .max"); - - thumb.on('mousedown', event => event.button === 0 && this.events.fire("playtime_move_begin")); - - this.events.on(["bot_change", "player_song_change", "player_time_update", "playtime_move_end"], event => { - if(!this.currentMusicBot) { - this.time_select.max_time = 0; - indicator_buffered.each((_, e) => { e.style.width = "0%"; }); - indicator_playtime.each((_, e) => { e.style.width = "0%"; }); - thumb.each((_, e) => { e.style.marginLeft = "0%"; }); - - timestamp_current.text("--:--:--"); - timestamp_max.text("--:--:--"); - return; - } - if(event.type === "playtime_move_end" && !event.as<"playtime_move_end">().canceled) return; - - const update_info = Date.now() > this.update_song_info; - this.currentMusicBot.requestPlayerInfo(update_info ? 1000 : 60 * 1000).then(data => { - if(update_info) - this.display_song_info(data); - - let played, buffered; - if(event.type !== "player_time_update") { - played = data.player_replay_index; - buffered = data.player_buffered_index; - } else { - played = event.as<"player_time_update">().player_replay_index; - buffered = event.as<"player_time_update">().player_buffered_index; - } - - this.time_select.current_player_time = played; - this.time_select.max_time = data.player_max_index; - timestamp_max.text(data.player_max_index ? this.format_time(data.player_max_index) : "--:--:--"); - - if(this.time_select.active) - return; - - let wplayed, wbuffered; - if(data.player_max_index) { - wplayed = (played * 100 / data.player_max_index).toFixed(2) + "%"; - wbuffered = (buffered * 100 / data.player_max_index).toFixed(2) + "%"; - - timestamp_current.text(this.format_time(played)); - } else { - wplayed = "100%"; - wbuffered = "100%"; - - timestamp_current.text(this.format_time(played)); - } - - indicator_buffered.each((_, e) => { e.style.width = wbuffered; }); - indicator_playtime.each((_, e) => { e.style.width = wplayed; }); - thumb.each((_, e) => { e.style.marginLeft = wplayed; }); - }); - }); - - const move_callback = (event: MouseEvent) => { - const x_min = timeline.offset().left; - const x_max = x_min + timeline.width(); - - let current = event.pageX; - if(current < x_min) - current = x_min; - else if(current > x_max) - current = x_max; - - const percent = (current - x_min) / (x_max - x_min); - this.time_select.current_select_time = percent * this.time_select.max_time; - timestamp_current.text(this.format_time(this.time_select.current_select_time)); - - const w = (percent * 100).toFixed(2) + "%"; - indicator_playtime.each((_, e) => { e.style.width = w; }); - thumb.each((_, e) => { e.style.marginLeft = w; }); - }; - - const up_callback = (event: MouseEvent | FocusEvent) => { - if(event.type === "mouseup") - if((event as MouseEvent).button !== 0) return; - - this.events.fire("playtime_move_end", { - canceled: event.type !== "mouseup", - target_time: this.time_select.current_select_time - }); - }; - - this.events.on("playtime_move_begin", event => { - if(this.time_select.max_time <= 0) return; - - this.time_select.active = true; - indicator_buffered.each((_, e) => { e.style.width = "0"; }); - document.addEventListener("mousemove", move_callback); - document.addEventListener("mouseleave", up_callback); - document.addEventListener("blur", up_callback); - document.addEventListener("mouseup", up_callback); - document.body.style.userSelect = "none"; - }); - - this.events.on(["bot_change", "player_song_change", "playtime_move_end"], event => { - document.removeEventListener("mousemove", move_callback); - document.removeEventListener("mouseleave", up_callback); - document.removeEventListener("blur", up_callback); - document.removeEventListener("mouseup", up_callback); - document.body.style.userSelect = undefined; - this.time_select.active = false; - - if(event.type === "playtime_move_end") { - const data = event.as<"playtime_move_end">(); - if(data.canceled) return; - - const offset = data.target_time - this.time_select.current_player_time; - this.events.fire(offset > 0 ? "action_forward_ms" : "action_rewind_ms", {units: Math.abs(offset) }); - } - }); - } - - /* song info handlers */ - this.events.on(["bot_change", "player_song_change"], event => { - let song: SongInfo; - - /* update the player info so we dont get old data */ - if(this.currentMusicBot) { - this.update_song_info = 0; - this.currentMusicBot.requestPlayerInfo(1000).then(data => { - this.display_song_info(data); - }).catch(error => { - log.warn(LogCategory.CLIENT, tr("Failed to update current song for side bar: %o"), error); - }); - } - - if(event.type === "bot_change") { - song = undefined; - } else { - song = event.as<"player_song_change">().song; - } - this.display_song_info(song); - }); - } - - private display_song_info(song: SongInfo) { - if(song) { - if(!song.song_loaded) { - console.log("Awaiting a loaded song info."); - this.update_song_info = 0; - } else { - console.log("Song info loaded."); - this.update_song_info = Date.now() + 60 * 1000; - } - } - - if(!song) song = new SongInfo(); - - const container_thumbnail = this._html_tag.find(".player .container-thumbnail"); - const container_info = this._html_tag.find(".player .container-song-info"); - - container_thumbnail.find("img") - .attr("src", song.song_thumbnail || "img/music/no-thumbnail.png") - .attr("x-thumbnail-url", encodeURIComponent(song.song_thumbnail)) - .css("cursor", song.song_thumbnail ? "pointer" : null); - - if(song.song_id) - container_info.find(".song-name").text(song.song_title || song.song_url).attr("title", song.song_title || song.song_url); - else - container_info.find(".song-name").text(tr("No song selected")); - if(song.song_description) { - container_info.find(".song-description").removeClass("hidden").text(song.song_description).attr("title", song.song_description); - } else { - container_info.find(".song-description").addClass("hidden").text(tr("Song has no description")).attr("title", tr("Song has no description")); - } - } - - private initialize_listener() { - //Must come at first! - this.events.on("player_song_change", event => { - if(!this.currentMusicBot) return; - - this.currentMusicBot.requestPlayerInfo(0); /* enforce an info refresh */ - }); - - /* bot property listener */ - 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); - event.old.events.off(callback_time_update); - event.old.events.off(callback_song_change); - event.old.events.disconnectAll(this.events); - } - if(event.new) { - 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); - - // @ts-ignore - event.new.events.connect("playlist_song_add", this.events); - - // @ts-ignore - event.new.events.connect("playlist_song_remove", this.events); - - // @ts-ignore - event.new.events.connect("playlist_song_reorder", this.events); - - // @ts-ignore - event.new.events.connect("playlist_song_loaded", this.events); - } - }); - - /* basic player actions */ - { - const action_map = { - "action_play": 1, - "action_pause": 2, - "action_forward": 3, - "action_rewind": 4, - "action_forward_ms": 5, - "action_rewind_ms": 6 - }; - - this.events.on(Object.keys(action_map) as any, event => { - if(!this.currentMusicBot) return; - - const action_id = action_map[event.type]; - if(typeof action_id === "undefined") { - log.warn(LogCategory.GENERAL, tr("Invalid music bot action event detected: %s. This should not happen!"), event.type); - return; - } - const data = { - bot_id: this.currentMusicBot.properties.client_database_id, - action: action_id, - units: event.units - }; - this.handle.handle.serverConnection.send_command("musicbotplayeraction", data).catch(error => { - if(error instanceof CommandResult && error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) return; - - log.error(LogCategory.CLIENT, tr("Failed to perform action %s on bot: %o"), event.type, error); - //TODO: Better error dialog - createErrorModal(tr("Failed to perform action."), tr("Failed to perform action for music bot.")).open(); - }); - }); - } - - this.events.on("action_song_set", event => { - if(!this.currentMusicBot) return; - - const connection = this.handle.handle.serverConnection; - if(!connection || !connection.connected()) return; - - connection.send_command("playlistsongsetcurrent", { - playlist_id: this.currentMusicBot.properties.client_playlist_id, - song_id: event.song_id - }).catch(error => { - if(error instanceof CommandResult && error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) return; - - log.error(LogCategory.CLIENT, tr("Failed to set current song on bot: %o"), event.type, error); - //TODO: Better error dialog - createErrorModal(tr("Failed to set song."), tr("Failed to set current replaying song.")).open(); - }) - }); - - this.events.on("action_song_add", () => { - if(!this.currentMusicBot) return; - - createInputModal(tr("Enter song URL"), tr("Please enter the target song URL"), text => { - try { - new URL(text); - return true; - } catch(error) { - return false; - } - }, result => { - if(!result || !this.currentMusicBot) return; - - const connection = this.handle.handle.serverConnection; - connection.send_command("playlistsongadd", { - playlist_id: this.currentMusicBot.properties.client_playlist_id, - url: result - }).catch(error => { - if(error instanceof CommandResult && error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) return; - - log.error(LogCategory.CLIENT, tr("Failed to add song to bot playlist: %o"), error); - - //TODO: Better error description - createErrorModal(tr("Failed to insert song"), tr("Failed to append song to the playlist.")).open(); - }); - }).open(); - }); - - this.events.on("action_song_delete", event => { - if(!this.currentMusicBot) return; - - const connection = this.handle.handle.serverConnection; - if(!connection || !connection.connected()) return; - - connection.send_command("playlistsongremove", { - playlist_id: this.currentMusicBot.properties.client_playlist_id, - song_id: event.song_id - }).catch(error => { - if(error instanceof CommandResult && error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) return; - - log.error(LogCategory.CLIENT, tr("Failed to delete song from bot playlist: %o"), error); - - //TODO: Better error description - createErrorModal(tr("Failed to delete song"), tr("Failed to remove song from the playlist.")).open(); - }); - }); - - /* bot subscription */ - this.events.on("bot_change", () => { - const connection = this.handle.handle.serverConnection; - if(!connection || !connection.connected()) return; - - const bot_id = this.currentMusicBot ? this.currentMusicBot.properties.client_database_id : 0; - this.handle.handle.serverConnection.send_command("musicbotsetsubscription", { bot_id: bot_id }).catch(error => { - log.warn(LogCategory.CLIENT, tr("Failed to subscribe to displayed bot within the side bar: %o"), error); - }); - }); - - /* playlist stuff */ - this.events.on(["bot_change", "action_playlist_reload"], event => { - this.playlist_subscribe(true); - this.update_playlist(); - }); - - this.events.on("playlist_song_add", event => { - const animation = typeof event.insert_effect === "boolean" ? event.insert_effect : true; - const html_entry = this.build_playlist_entry(event.song, animation); - const playlist = this._container_playlist.find(".playlist"); - const previous = playlist.find(".entry[song-id=" + event.song.song_previous_song_id + "]"); - - if(previous.length) - html_entry.insertAfter(previous); - else - html_entry.appendTo(playlist); - if(event.song.song_loaded) - this.events.fire("playlist_song_loaded", { - html_entry: html_entry, - metadata: event.song.song_metadata, - success: true, - song_id: event.song.song_id - }); - if(animation) - setTimeout(() => html_entry.addClass("shown"), 50); - }); - - this.events.on("playlist_song_remove", event => { - const playlist = this._container_playlist.find(".playlist"); - const song = playlist.find(".entry[song-id=" + event.song_id + "]"); - song.addClass("deleted"); - setTimeout(() => song.remove(), 5000); /* to play some animations */ - }); - - this.events.on("playlist_song_reorder", event => { - const playlist = this._container_playlist.find(".playlist"); - const entry = playlist.find(".entry[song-id=" + event.song_id + "]"); - if(!entry) return; - - console.log(event); - const previous = playlist.find(".entry[song-id=" + event.previous_song_id + "]"); - if(previous.length) { - entry.insertAfter(previous); - } else { - entry.insertBefore(playlist.find(".entry")[0]); - } - }); - - this.events.on("playlist_song_loaded", event => { - const entry = event.html_entry || this._container_playlist.find(".playlist .entry[song-id=" + event.song_id + "]"); - - const thumbnail = entry.find(".container-thumbnail img"); - const name = entry.find(".name"); - const description = entry.find(".description"); - const length = entry.find(".length"); - - if(event.success) { - let meta: LoadedSongData; - try { - meta = JSON.parse(event.metadata); - } catch(error) { - log.warn(LogCategory.CLIENT, tr("Failed to decode song metadata")); - meta = { - description: "", - title: "", - metadata: {}, - length: 0, - url: entry.attr("song-url") - } - } - - if(!meta.title && meta.description) { - meta.title = meta.description.split("\n")[0]; - meta.description = meta.description.split("\n").slice(1).join("\n"); - } - meta.title = meta.title || meta.url; - - name.text(meta.title); - description.text(meta.description); - length.text(this.format_time(meta.length || 0)); - if(meta.thumbnail) { - thumbnail.attr("src", meta.thumbnail) - .attr("x-thumbnail-url", encodeURIComponent(meta.thumbnail)); - } - } else { - name.text(tr("failed to load ") + entry.attr("song-url")).attr("title", tr("failed to load ") + entry.attr("song-url")); - description.text(event.error_msg || tr("unknown error")).attr("title", event.error_msg || tr("unknown error")); - } - }); - - /* song reorder */ - { - const move_callback = (event: MouseEvent) => { - if(!this.song_reorder.html) return; - - this.song_reorder.html.each((_, e) => { - e.style.left = (event.pageX - this.song_reorder.mouse.x) + "px"; - e.style.top = (event.pageY - this.song_reorder.mouse.y) + "px"; - }); - - const entries = this._container_playlist.find(".playlist .entry"); - let before: HTMLElement; - for(const entry of entries) { - const off = $(entry).offset().top; - if(off > event.pageY) { - this.song_reorder.indicator.insertBefore(entry); - this.song_reorder.previous_entry = before ? parseInt(before.attributes.getNamedItem("song-id").value) : 0; - return; - } - - before = entry; - } - this.song_reorder.indicator.insertAfter(entries.last()); - this.song_reorder.previous_entry = before ? parseInt(before.attributes.getNamedItem("song-id").value) : 0; - }; - - const up_callback = (event: MouseEvent | FocusEvent) => { - if(event.type === "mouseup") - if((event as MouseEvent).button !== 0) return; - - this.events.fire("reorder_end", { - canceled: event.type !== "mouseup", - song_id: this.song_reorder.song_id, - entry: this.song_reorder.html, - previous_entry: this.song_reorder.previous_entry - }); - }; - - this.events.on("reorder_begin", event => { - this.song_reorder.song_id = event.song_id; - this.song_reorder.html = event.entry; - - const width = this.song_reorder.html.width() + "px"; - this.song_reorder.html.each((_, e) => { e.style.width = width; }); - this.song_reorder.active = true; - this.song_reorder.html.addClass("reordering"); - - document.addEventListener("mousemove", move_callback); - document.addEventListener("mouseleave", up_callback); - document.addEventListener("blur", up_callback); - document.addEventListener("mouseup", up_callback); - document.body.style.userSelect = "none"; - }); - - this.events.on(["bot_change", "playlist_song_remove", "reorder_end"], event => { - if(event.type === "playlist_song_remove" && event.as<"playlist_song_remove">().song_id !== this.song_reorder.song_id) return; - - document.removeEventListener("mousemove", move_callback); - document.removeEventListener("mouseleave", up_callback); - document.removeEventListener("blur", up_callback); - document.removeEventListener("mouseup", up_callback); - document.body.style.userSelect = undefined; - - this.song_reorder.active = false; - this.song_reorder.indicator.remove(); - if(this.song_reorder.html) { - this.song_reorder.html.each((_, e) => { - e.style.width = null; - e.style.left = null; - e.style.top = null; - }); - this.song_reorder.html.removeClass("reordering"); - } - - if(event.type === "reorder_end") { - const data = event.as<"reorder_end">(); - if(data.canceled) return; - - const connection = this.handle.handle.serverConnection; - if(!connection || !connection.connected()) return; - if(!this.currentMusicBot) return; - - connection.send_command("playlistsongreorder", { - playlist_id: this.currentMusicBot.properties.client_playlist_id, - song_id: data.song_id, - song_previous_song_id: data.previous_entry - }).catch(error => { - if(error instanceof CommandResult && error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) return; - - log.error(LogCategory.CLIENT, tr("Failed to add song to bot playlist: %o"), error); - - //TODO: Better error description - createErrorModal(tr("Failed to reorder song"), tr("Failed to reorder song within the playlist.")).open(); - }); - console.log("Reorder to %d", data.previous_entry); - } - }); - - this.events.on(["bot_change", "player_song_change"], event => { - if(!this.currentMusicBot) { - this._html_tag.find(".playlist .current-song").removeClass("current-song"); - return; - } - - this.currentMusicBot.requestPlayerInfo(1000).then(data => { - const song_id = data ? data.song_id : 0; - this._html_tag.find(".playlist .current-song").removeClass("current-song"); - this._html_tag.find(".playlist .entry[song-id=" + song_id + "]").addClass("current-song"); - }); - }); - } - } - - set_current_bot(client: MusicClientEntry | undefined, enforce?: boolean) { - if(client) client.updateClientVariables(); /* just to ensure */ - if(client === this.currentMusicBot && (typeof(enforce) === "undefined" || !enforce)) - return; - - const old = this.currentMusicBot; - this.currentMusicBot = client; - this.events.fire("bot_change", { - new: client, - old: old - }); - } - - current_bot() : MusicClientEntry | undefined { - return this.currentMusicBot; - } - - private sort_songs(data: PlaylistSong[]) { - const result = []; - - let appendable: PlaylistSong[] = []; - for(const song of data) { - if(song.song_id == 0 || data.findIndex(e => e.song_id === song.song_previous_song_id) == -1) - result.push(song); - else - appendable.push(song); - } - - let iters; - while (appendable.length) { - do { - iters = 0; - const left: PlaylistSong[] = []; - for(const song of appendable) { - const index = data.findIndex(e => e.song_id === song.song_previous_song_id); - if(index == -1) { - left.push(song); - continue; - } - - result.splice(index + 1, 0, song); - iters++; - } - appendable = left; - } while(iters > 0); - - if(appendable.length) - result.push(appendable.pop_front()); - } - - return result; - } - - /* playlist stuff */ - update_playlist() { - this.playlist_subscribe(true); - - this._container_playlist.find(".overlay").toggleClass("hidden", true); - const playlist = this._container_playlist.find(".playlist"); - playlist.empty(); - - if(!this.handle.handle.serverConnection || !this.handle.handle.serverConnection.connected() || !this.currentMusicBot) { - this._container_playlist.find(".overlay-empty").removeClass("hidden"); - return; - } - - const overlay_loading = this._container_playlist.find(".overlay-loading"); - overlay_loading.removeClass("hidden"); - - this.currentMusicBot.updateClientVariables(true).catch(error => { - log.warn(LogCategory.CLIENT, tr("Failed to update music bot variables: %o"), error); - }).then(() => { - this.handle.handle.serverConnection.command_helper.requestPlaylistSongs(this.currentMusicBot.properties.client_playlist_id, false).then(songs => { - this.playlist_subscribe(false); /* we're allowed to see the playlist */ - if(!songs) { - this._container_playlist.find(".overlay-empty").removeClass("hidden"); - return; - } - - for(const song of this.sort_songs(songs)) - this.events.fire("playlist_song_add", { song: song, insert_effect: false }); - }).catch(error => { - if(error instanceof CommandResult && error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) { - this._container_playlist.find(".overlay-no-permissions").removeClass("hidden"); - return; - } - log.error(LogCategory.CLIENT, tr("Failed to load bot playlist: %o"), error); - this._container_playlist.find(".overlay.overlay-error").removeClass("hidden"); - }).then(() => { - overlay_loading.addClass("hidden"); - }); - }); - } - - private _playlist_subscribed = false; - private playlist_subscribe(unsubscribe: boolean) { - if(!this.handle.handle.serverConnection) return; - - if(unsubscribe || !this.currentMusicBot) { - if(!this._playlist_subscribed) return; - this._playlist_subscribed = false; - - this.handle.handle.serverConnection.send_command("playlistsetsubscription", {playlist_id: 0}).catch(error => { - log.warn(LogCategory.CLIENT, tr("Failed to unsubscribe from last playlist: %o"), error); - }); - } else { - this.handle.handle.serverConnection.send_command("playlistsetsubscription", { - playlist_id: this.currentMusicBot.properties.client_playlist_id - }).then(() => this._playlist_subscribed = true).catch(error => { - log.warn(LogCategory.CLIENT, tr("Failed to subscribe to bots playlist: %o"), error); - }); - } - } - - private build_playlist_entry(data: PlaylistSong, insert_effect: boolean) : JQuery { - const tag = $("#tmpl_frame_music_playlist_entry").renderTag(); - tag.attr({ - "song-id": data.song_id, - "song-url": data.song_url - }); - - const thumbnail = tag.find(".container-thumbnail img"); - const name = tag.find(".name"); - const description = tag.find(".description"); - const length = tag.find(".length"); - - tag.find(".button-delete").on('click', () => this.events.fire("action_song_delete", { song_id: data.song_id })); - tag.find(".container-thumbnail").on('click', event => { - const target = tag.find(".container-thumbnail img"); - const url = target.attr("x-thumbnail-url"); - if(!url) return; - - image_preview.preview_image(decodeURIComponent(url), decodeURIComponent(url)); - }); - tag.on('dblclick', event => this.events.fire("action_song_set", { song_id: data.song_id })); - name.text(tr("loading...")); - description.text(data.song_url); - - tag.on('mousedown', event => { - if(event.button !== 0) return; - - this.song_reorder.mouse = { - x: event.pageX, - y: event.pageY - }; - - const baseOff = tag.offset(); - const off = { x: event.pageX - baseOff.left, y: event.pageY - baseOff.top }; - const move_listener = (event: MouseEvent) => { - const distance = Math.pow(event.pageX - this.song_reorder.mouse.x, 2) + Math.pow(event.pageY - this.song_reorder.mouse.y, 2); - if(distance < 50) return; - - document.removeEventListener("blur", up_listener); - document.removeEventListener("mouseup", up_listener); - document.removeEventListener("mousemove", move_listener); - - this.song_reorder.mouse = off; - this.events.fire("reorder_begin", { - entry: tag, - song_id: data.song_id - }); - }; - - const up_listener = event => { - if(event.type === "mouseup" && event.button !== 0) return; - - document.removeEventListener("blur", up_listener); - document.removeEventListener("mouseup", up_listener); - document.removeEventListener("mousemove", move_listener); - }; - - document.addEventListener("blur", up_listener); - document.addEventListener("mouseup", up_listener); - document.addEventListener("mousemove", move_listener); - }); - - if(this.currentMusicBot) { - this.currentMusicBot.requestPlayerInfo(60 * 1000).then(pdata => { - if(pdata.song_id === data.song_id) - tag.addClass("current-song"); - }); - } - - if(insert_effect) { - tag.removeClass("shown"); - tag.addClass("animation"); - } - return tag; - } -} \ No newline at end of file +// import {SideBarController, FrameContent} from "../SideBarController"; +// import {LogCategory} from "../../../log"; +// import {CommandResult, PlaylistSong} from "../../../connection/ServerConnectionDeclaration"; +// import {createErrorModal, createInputModal} from "../../../ui/elements/Modal"; +// import * as log from "../../../log"; +// import * as image_preview from "../image_preview"; +// import {Registry} from "../../../events"; +// import {ErrorCode} from "../../../connection/ErrorCode"; +// import {ClientEvents, MusicClientEntry, SongInfo} from "../../../tree/Client"; +// import { tr } from "tc-shared/i18n/localize"; +// +// export interface MusicSidebarEvents { +// "open": {}, /* triggers when frame should be shown */ +// "close": {}, /* triggers when frame will be closed */ +// +// "bot_change": { +// old: MusicClientEntry | undefined, +// new: MusicClientEntry | undefined +// }, +// "bot_property_update": { +// properties: string[] +// }, +// +// "action_play": {}, +// "action_pause": {}, +// "action_song_set": { song_id: number }, +// "action_forward": {}, +// "action_rewind": {}, +// "action_forward_ms": { +// units: number; +// }, +// "action_rewind_ms": { +// units: number; +// }, +// "action_song_add": {}, +// "action_song_delete": { song_id: number }, +// "action_playlist_reload": {}, +// +// "playtime_move_begin": {}, +// "playtime_move_end": { +// canceled: boolean, +// target_time?: number +// }, +// +// "reorder_begin": { song_id: number; entry: JQuery }, +// "reorder_end": { song_id: number; canceled: boolean; entry: JQuery; previous_entry?: number }, +// +// "player_time_update": ClientEvents["music_status_update"], +// "player_song_change": ClientEvents["music_song_change"], +// +// "playlist_song_add": ClientEvents["playlist_song_add"] & { insert_effect?: boolean }, +// "playlist_song_remove": ClientEvents["playlist_song_remove"], +// "playlist_song_reorder": ClientEvents["playlist_song_reorder"], +// "playlist_song_loaded": ClientEvents["playlist_song_loaded"] & { html_entry?: JQuery }, +// } +// +// interface LoadedSongData { +// description: string; +// title: string; +// url: string; +// +// length: number; +// thumbnail?: string; +// +// metadata: {[key: string]: string}; +// } +// +// export class MusicInfo { +// readonly events: Registry; +// readonly handle: SideBarController; +// +// private _html_tag: JQuery; +// private _container_playlist: JQuery; +// +// private currentMusicBot: MusicClientEntry | undefined; +// private update_song_info: number = 0; /* timestamp when we force update the info */ +// private time_select: { +// active: boolean, +// max_time: number, +// current_select_time: number, +// current_player_time: number +// } = { active: false, current_select_time: 0, max_time: 0, current_player_time: 0}; +// private song_reorder: { +// active: boolean, +// song_id: number, +// previous_entry: number, +// html: JQuery, +// mouse?: {x: number, y: number}, +// indicator: JQuery +// } = { active: false, song_id: 0, previous_entry: 0, html: undefined, indicator: $.spawn("div").addClass("reorder-indicator") }; +// +// previous_frame_content: FrameContent; +// +// constructor(handle: SideBarController) { +// this.events = new Registry(); +// this.handle = handle; +// +// this.events.enableDebug("music-info"); +// this.initialize_listener(); +// this._build_html_tag(); +// +// this.set_current_bot(undefined, true); +// } +// +// html_tag() : JQuery { +// return this._html_tag; +// } +// +// destroy() { +// this.set_current_bot(undefined); +// this.events.destroy(); +// +// this._html_tag && this._html_tag.remove(); +// this._html_tag = undefined; +// +// this.currentMusicBot = undefined; +// this.previous_frame_content = undefined; +// } +// +// private format_time(value: number) { +// if(value == 0) return "--:--:--"; +// +// value /= 1000; +// +// let hours = 0, minutes = 0; +// while(value >= 60 * 60) { +// hours++; +// value -= 60 * 60; +// } +// +// while(value >= 60) { +// minutes++; +// value -= 60; +// } +// +// return ("0" + hours).substr(-2) + ":" + ("0" + minutes).substr(-2) + ":" + ("0" + value.toFixed(0)).substr(-2); +// }; +// +// private _build_html_tag() { +// this._html_tag = $("#tmpl_frame_chat_music_info").renderTag(); +// this._container_playlist = this._html_tag.find(".container-playlist"); +// +// this._html_tag.find(".button-close").on('click', () => { +// if(this.previous_frame_content === FrameContent.CLIENT_INFO) { +// this.previous_frame_content = FrameContent.NONE; +// } +// +// this.handle.set_content(this.previous_frame_content); +// }); +// +// this._html_tag.find(".button-reload-playlist").on('click', () => this.events.fire("action_playlist_reload")); +// this._html_tag.find(".button-song-add").on('click', () => this.events.fire("action_song_add")); +// this._html_tag.find(".thumbnail").on('click', event => { +// const image = this._html_tag.find(".thumbnail img"); +// const url = image.attr("x-thumbnail-url"); +// if(!url) return; +// +// image_preview.preview_image(decodeURIComponent(url), decodeURIComponent(url)); +// }); +// +// { +// const button_play = this._html_tag.find(".control-buttons .button-play"); +// const button_pause = this._html_tag.find(".control-buttons .button-pause"); +// +// button_play.on('click', () => this.events.fire("action_play")); +// button_pause.on('click', () => this.events.fire("action_pause")); +// +// this.events.on(["bot_change", "bot_property_update"], event => { +// if(event.type === "bot_property_update" && event.as<"bot_property_update">().properties.indexOf("player_state") == -1) return; +// +// button_play.toggleClass("hidden", this.currentMusicBot === undefined || this.currentMusicBot.isCurrentlyPlaying()); +// }); +// +// this.events.on(["bot_change", "bot_property_update"], event => { +// if(event.type === "bot_property_update" && event.as<"bot_property_update">().properties.indexOf("player_state") == -1) return; +// +// button_pause.toggleClass("hidden", this.currentMusicBot !== undefined && !this.currentMusicBot.isCurrentlyPlaying()); +// }); +// +// this._html_tag.find(".control-buttons .button-rewind").on('click', () => this.events.fire("action_rewind")); +// this._html_tag.find(".control-buttons .button-forward").on('click', () => this.events.fire("action_forward")); +// } +// +// /* timeline updaters */ +// { +// const container = this._html_tag.find(".container-timeline"); +// +// const timeline = container.find(".timeline"); +// const indicator_playtime = container.find(".indicator-playtime"); +// const indicator_buffered = container.find(".indicator-buffered"); +// const thumb = container.find(".thumb"); +// +// const timestamp_current = container.find(".timestamps .current"); +// const timestamp_max = container.find(".timestamps .max"); +// +// thumb.on('mousedown', event => event.button === 0 && this.events.fire("playtime_move_begin")); +// +// this.events.on(["bot_change", "player_song_change", "player_time_update", "playtime_move_end"], event => { +// if(!this.currentMusicBot) { +// this.time_select.max_time = 0; +// indicator_buffered.each((_, e) => { e.style.width = "0%"; }); +// indicator_playtime.each((_, e) => { e.style.width = "0%"; }); +// thumb.each((_, e) => { e.style.marginLeft = "0%"; }); +// +// timestamp_current.text("--:--:--"); +// timestamp_max.text("--:--:--"); +// return; +// } +// if(event.type === "playtime_move_end" && !event.as<"playtime_move_end">().canceled) return; +// +// const update_info = Date.now() > this.update_song_info; +// this.currentMusicBot.requestPlayerInfo(update_info ? 1000 : 60 * 1000).then(data => { +// if(update_info) +// this.display_song_info(data); +// +// let played, buffered; +// if(event.type !== "player_time_update") { +// played = data.player_replay_index; +// buffered = data.player_buffered_index; +// } else { +// played = event.as<"player_time_update">().player_replay_index; +// buffered = event.as<"player_time_update">().player_buffered_index; +// } +// +// this.time_select.current_player_time = played; +// this.time_select.max_time = data.player_max_index; +// timestamp_max.text(data.player_max_index ? this.format_time(data.player_max_index) : "--:--:--"); +// +// if(this.time_select.active) +// return; +// +// let wplayed, wbuffered; +// if(data.player_max_index) { +// wplayed = (played * 100 / data.player_max_index).toFixed(2) + "%"; +// wbuffered = (buffered * 100 / data.player_max_index).toFixed(2) + "%"; +// +// timestamp_current.text(this.format_time(played)); +// } else { +// wplayed = "100%"; +// wbuffered = "100%"; +// +// timestamp_current.text(this.format_time(played)); +// } +// +// indicator_buffered.each((_, e) => { e.style.width = wbuffered; }); +// indicator_playtime.each((_, e) => { e.style.width = wplayed; }); +// thumb.each((_, e) => { e.style.marginLeft = wplayed; }); +// }); +// }); +// +// const move_callback = (event: MouseEvent) => { +// const x_min = timeline.offset().left; +// const x_max = x_min + timeline.width(); +// +// let current = event.pageX; +// if(current < x_min) +// current = x_min; +// else if(current > x_max) +// current = x_max; +// +// const percent = (current - x_min) / (x_max - x_min); +// this.time_select.current_select_time = percent * this.time_select.max_time; +// timestamp_current.text(this.format_time(this.time_select.current_select_time)); +// +// const w = (percent * 100).toFixed(2) + "%"; +// indicator_playtime.each((_, e) => { e.style.width = w; }); +// thumb.each((_, e) => { e.style.marginLeft = w; }); +// }; +// +// const up_callback = (event: MouseEvent | FocusEvent) => { +// if(event.type === "mouseup") +// if((event as MouseEvent).button !== 0) return; +// +// this.events.fire("playtime_move_end", { +// canceled: event.type !== "mouseup", +// target_time: this.time_select.current_select_time +// }); +// }; +// +// this.events.on("playtime_move_begin", event => { +// if(this.time_select.max_time <= 0) return; +// +// this.time_select.active = true; +// indicator_buffered.each((_, e) => { e.style.width = "0"; }); +// document.addEventListener("mousemove", move_callback); +// document.addEventListener("mouseleave", up_callback); +// document.addEventListener("blur", up_callback); +// document.addEventListener("mouseup", up_callback); +// document.body.style.userSelect = "none"; +// }); +// +// this.events.on(["bot_change", "player_song_change", "playtime_move_end"], event => { +// document.removeEventListener("mousemove", move_callback); +// document.removeEventListener("mouseleave", up_callback); +// document.removeEventListener("blur", up_callback); +// document.removeEventListener("mouseup", up_callback); +// document.body.style.userSelect = undefined; +// this.time_select.active = false; +// +// if(event.type === "playtime_move_end") { +// const data = event.as<"playtime_move_end">(); +// if(data.canceled) return; +// +// const offset = data.target_time - this.time_select.current_player_time; +// this.events.fire(offset > 0 ? "action_forward_ms" : "action_rewind_ms", {units: Math.abs(offset) }); +// } +// }); +// } +// +// /* song info handlers */ +// this.events.on(["bot_change", "player_song_change"], event => { +// let song: SongInfo; +// +// /* update the player info so we dont get old data */ +// if(this.currentMusicBot) { +// this.update_song_info = 0; +// this.currentMusicBot.requestPlayerInfo(1000).then(data => { +// this.display_song_info(data); +// }).catch(error => { +// log.warn(LogCategory.CLIENT, tr("Failed to update current song for side bar: %o"), error); +// }); +// } +// +// if(event.type === "bot_change") { +// song = undefined; +// } else { +// song = event.as<"player_song_change">().song; +// } +// this.display_song_info(song); +// }); +// } +// +// private display_song_info(song: SongInfo) { +// if(song) { +// if(!song.song_loaded) { +// console.log("Awaiting a loaded song info."); +// this.update_song_info = 0; +// } else { +// console.log("Song info loaded."); +// this.update_song_info = Date.now() + 60 * 1000; +// } +// } +// +// if(!song) song = new SongInfo(); +// +// const container_thumbnail = this._html_tag.find(".player .container-thumbnail"); +// const container_info = this._html_tag.find(".player .container-song-info"); +// +// container_thumbnail.find("img") +// .attr("src", song.song_thumbnail || "img/music/no-thumbnail.png") +// .attr("x-thumbnail-url", encodeURIComponent(song.song_thumbnail)) +// .css("cursor", song.song_thumbnail ? "pointer" : null); +// +// if(song.song_id) +// container_info.find(".song-name").text(song.song_title || song.song_url).attr("title", song.song_title || song.song_url); +// else +// container_info.find(".song-name").text(tr("No song selected")); +// if(song.song_description) { +// container_info.find(".song-description").removeClass("hidden").text(song.song_description).attr("title", song.song_description); +// } else { +// container_info.find(".song-description").addClass("hidden").text(tr("Song has no description")).attr("title", tr("Song has no description")); +// } +// } +// +// private initialize_listener() { +// //Must come at first! +// this.events.on("player_song_change", event => { +// if(!this.currentMusicBot) return; +// +// this.currentMusicBot.requestPlayerInfo(0); /* enforce an info refresh */ +// }); +// +// /* bot property listener */ +// 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); +// event.old.events.off(callback_time_update); +// event.old.events.off(callback_song_change); +// event.old.events.disconnectAll(this.events); +// } +// if(event.new) { +// 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); +// +// // @ts-ignore +// event.new.events.connect("playlist_song_add", this.events); +// +// // @ts-ignore +// event.new.events.connect("playlist_song_remove", this.events); +// +// // @ts-ignore +// event.new.events.connect("playlist_song_reorder", this.events); +// +// // @ts-ignore +// event.new.events.connect("playlist_song_loaded", this.events); +// } +// }); +// +// /* basic player actions */ +// { +// const action_map = { +// "action_play": 1, +// "action_pause": 2, +// "action_forward": 3, +// "action_rewind": 4, +// "action_forward_ms": 5, +// "action_rewind_ms": 6 +// }; +// +// this.events.on(Object.keys(action_map) as any, event => { +// if(!this.currentMusicBot) return; +// +// const action_id = action_map[event.type]; +// if(typeof action_id === "undefined") { +// log.warn(LogCategory.GENERAL, tr("Invalid music bot action event detected: %s. This should not happen!"), event.type); +// return; +// } +// const data = { +// bot_id: this.currentMusicBot.properties.client_database_id, +// action: action_id, +// units: event.units +// }; +// this.handle.handle.serverConnection.send_command("musicbotplayeraction", data).catch(error => { +// if(error instanceof CommandResult && error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) return; +// +// log.error(LogCategory.CLIENT, tr("Failed to perform action %s on bot: %o"), event.type, error); +// //TODO: Better error dialog +// createErrorModal(tr("Failed to perform action."), tr("Failed to perform action for music bot.")).open(); +// }); +// }); +// } +// +// this.events.on("action_song_set", event => { +// if(!this.currentMusicBot) return; +// +// const connection = this.handle.handle.serverConnection; +// if(!connection || !connection.connected()) return; +// +// connection.send_command("playlistsongsetcurrent", { +// playlist_id: this.currentMusicBot.properties.client_playlist_id, +// song_id: event.song_id +// }).catch(error => { +// if(error instanceof CommandResult && error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) return; +// +// log.error(LogCategory.CLIENT, tr("Failed to set current song on bot: %o"), event.type, error); +// //TODO: Better error dialog +// createErrorModal(tr("Failed to set song."), tr("Failed to set current replaying song.")).open(); +// }) +// }); +// +// this.events.on("action_song_add", () => { +// if(!this.currentMusicBot) return; +// +// createInputModal(tr("Enter song URL"), tr("Please enter the target song URL"), text => { +// try { +// new URL(text); +// return true; +// } catch(error) { +// return false; +// } +// }, result => { +// if(!result || !this.currentMusicBot) return; +// +// const connection = this.handle.handle.serverConnection; +// connection.send_command("playlistsongadd", { +// playlist_id: this.currentMusicBot.properties.client_playlist_id, +// url: result +// }).catch(error => { +// if(error instanceof CommandResult && error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) return; +// +// log.error(LogCategory.CLIENT, tr("Failed to add song to bot playlist: %o"), error); +// +// //TODO: Better error description +// createErrorModal(tr("Failed to insert song"), tr("Failed to append song to the playlist.")).open(); +// }); +// }).open(); +// }); +// +// this.events.on("action_song_delete", event => { +// if(!this.currentMusicBot) return; +// +// const connection = this.handle.handle.serverConnection; +// if(!connection || !connection.connected()) return; +// +// connection.send_command("playlistsongremove", { +// playlist_id: this.currentMusicBot.properties.client_playlist_id, +// song_id: event.song_id +// }).catch(error => { +// if(error instanceof CommandResult && error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) return; +// +// log.error(LogCategory.CLIENT, tr("Failed to delete song from bot playlist: %o"), error); +// +// //TODO: Better error description +// createErrorModal(tr("Failed to delete song"), tr("Failed to remove song from the playlist.")).open(); +// }); +// }); +// +// /* bot subscription */ +// this.events.on("bot_change", () => { +// const connection = this.handle.handle.serverConnection; +// if(!connection || !connection.connected()) return; +// +// const bot_id = this.currentMusicBot ? this.currentMusicBot.properties.client_database_id : 0; +// this.handle.handle.serverConnection.send_command("musicbotsetsubscription", { bot_id: bot_id }).catch(error => { +// log.warn(LogCategory.CLIENT, tr("Failed to subscribe to displayed bot within the side bar: %o"), error); +// }); +// }); +// +// /* playlist stuff */ +// this.events.on(["bot_change", "action_playlist_reload"], event => { +// this.playlist_subscribe(true); +// this.update_playlist(); +// }); +// +// this.events.on("playlist_song_add", event => { +// const animation = typeof event.insert_effect === "boolean" ? event.insert_effect : true; +// const html_entry = this.build_playlist_entry(event.song, animation); +// const playlist = this._container_playlist.find(".playlist"); +// const previous = playlist.find(".entry[song-id=" + event.song.song_previous_song_id + "]"); +// +// if(previous.length) +// html_entry.insertAfter(previous); +// else +// html_entry.appendTo(playlist); +// if(event.song.song_loaded) +// this.events.fire("playlist_song_loaded", { +// html_entry: html_entry, +// metadata: event.song.song_metadata, +// success: true, +// song_id: event.song.song_id +// }); +// if(animation) +// setTimeout(() => html_entry.addClass("shown"), 50); +// }); +// +// this.events.on("playlist_song_remove", event => { +// const playlist = this._container_playlist.find(".playlist"); +// const song = playlist.find(".entry[song-id=" + event.song_id + "]"); +// song.addClass("deleted"); +// setTimeout(() => song.remove(), 5000); /* to play some animations */ +// }); +// +// this.events.on("playlist_song_reorder", event => { +// const playlist = this._container_playlist.find(".playlist"); +// const entry = playlist.find(".entry[song-id=" + event.song_id + "]"); +// if(!entry) return; +// +// console.log(event); +// const previous = playlist.find(".entry[song-id=" + event.previous_song_id + "]"); +// if(previous.length) { +// entry.insertAfter(previous); +// } else { +// entry.insertBefore(playlist.find(".entry")[0]); +// } +// }); +// +// this.events.on("playlist_song_loaded", event => { +// const entry = event.html_entry || this._container_playlist.find(".playlist .entry[song-id=" + event.song_id + "]"); +// +// const thumbnail = entry.find(".container-thumbnail img"); +// const name = entry.find(".name"); +// const description = entry.find(".description"); +// const length = entry.find(".length"); +// +// if(event.success) { +// let meta: LoadedSongData; +// try { +// meta = JSON.parse(event.metadata); +// } catch(error) { +// log.warn(LogCategory.CLIENT, tr("Failed to decode song metadata")); +// meta = { +// description: "", +// title: "", +// metadata: {}, +// length: 0, +// url: entry.attr("song-url") +// } +// } +// +// if(!meta.title && meta.description) { +// meta.title = meta.description.split("\n")[0]; +// meta.description = meta.description.split("\n").slice(1).join("\n"); +// } +// meta.title = meta.title || meta.url; +// +// name.text(meta.title); +// description.text(meta.description); +// length.text(this.format_time(meta.length || 0)); +// if(meta.thumbnail) { +// thumbnail.attr("src", meta.thumbnail) +// .attr("x-thumbnail-url", encodeURIComponent(meta.thumbnail)); +// } +// } else { +// name.text(tr("failed to load ") + entry.attr("song-url")).attr("title", tr("failed to load ") + entry.attr("song-url")); +// description.text(event.error_msg || tr("unknown error")).attr("title", event.error_msg || tr("unknown error")); +// } +// }); +// +// /* song reorder */ +// { +// const move_callback = (event: MouseEvent) => { +// if(!this.song_reorder.html) return; +// +// this.song_reorder.html.each((_, e) => { +// e.style.left = (event.pageX - this.song_reorder.mouse.x) + "px"; +// e.style.top = (event.pageY - this.song_reorder.mouse.y) + "px"; +// }); +// +// const entries = this._container_playlist.find(".playlist .entry"); +// let before: HTMLElement; +// for(const entry of entries) { +// const off = $(entry).offset().top; +// if(off > event.pageY) { +// this.song_reorder.indicator.insertBefore(entry); +// this.song_reorder.previous_entry = before ? parseInt(before.attributes.getNamedItem("song-id").value) : 0; +// return; +// } +// +// before = entry; +// } +// this.song_reorder.indicator.insertAfter(entries.last()); +// this.song_reorder.previous_entry = before ? parseInt(before.attributes.getNamedItem("song-id").value) : 0; +// }; +// +// const up_callback = (event: MouseEvent | FocusEvent) => { +// if(event.type === "mouseup") +// if((event as MouseEvent).button !== 0) return; +// +// this.events.fire("reorder_end", { +// canceled: event.type !== "mouseup", +// song_id: this.song_reorder.song_id, +// entry: this.song_reorder.html, +// previous_entry: this.song_reorder.previous_entry +// }); +// }; +// +// this.events.on("reorder_begin", event => { +// this.song_reorder.song_id = event.song_id; +// this.song_reorder.html = event.entry; +// +// const width = this.song_reorder.html.width() + "px"; +// this.song_reorder.html.each((_, e) => { e.style.width = width; }); +// this.song_reorder.active = true; +// this.song_reorder.html.addClass("reordering"); +// +// document.addEventListener("mousemove", move_callback); +// document.addEventListener("mouseleave", up_callback); +// document.addEventListener("blur", up_callback); +// document.addEventListener("mouseup", up_callback); +// document.body.style.userSelect = "none"; +// }); +// +// this.events.on(["bot_change", "playlist_song_remove", "reorder_end"], event => { +// if(event.type === "playlist_song_remove" && event.as<"playlist_song_remove">().song_id !== this.song_reorder.song_id) return; +// +// document.removeEventListener("mousemove", move_callback); +// document.removeEventListener("mouseleave", up_callback); +// document.removeEventListener("blur", up_callback); +// document.removeEventListener("mouseup", up_callback); +// document.body.style.userSelect = undefined; +// +// this.song_reorder.active = false; +// this.song_reorder.indicator.remove(); +// if(this.song_reorder.html) { +// this.song_reorder.html.each((_, e) => { +// e.style.width = null; +// e.style.left = null; +// e.style.top = null; +// }); +// this.song_reorder.html.removeClass("reordering"); +// } +// +// if(event.type === "reorder_end") { +// const data = event.as<"reorder_end">(); +// if(data.canceled) return; +// +// const connection = this.handle.handle.serverConnection; +// if(!connection || !connection.connected()) return; +// if(!this.currentMusicBot) return; +// +// connection.send_command("playlistsongreorder", { +// playlist_id: this.currentMusicBot.properties.client_playlist_id, +// song_id: data.song_id, +// song_previous_song_id: data.previous_entry +// }).catch(error => { +// if(error instanceof CommandResult && error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) return; +// +// log.error(LogCategory.CLIENT, tr("Failed to add song to bot playlist: %o"), error); +// +// //TODO: Better error description +// createErrorModal(tr("Failed to reorder song"), tr("Failed to reorder song within the playlist.")).open(); +// }); +// console.log("Reorder to %d", data.previous_entry); +// } +// }); +// +// this.events.on(["bot_change", "player_song_change"], event => { +// if(!this.currentMusicBot) { +// this._html_tag.find(".playlist .current-song").removeClass("current-song"); +// return; +// } +// +// this.currentMusicBot.requestPlayerInfo(1000).then(data => { +// const song_id = data ? data.song_id : 0; +// this._html_tag.find(".playlist .current-song").removeClass("current-song"); +// this._html_tag.find(".playlist .entry[song-id=" + song_id + "]").addClass("current-song"); +// }); +// }); +// } +// } +// +// set_current_bot(client: MusicClientEntry | undefined, enforce?: boolean) { +// if(client) client.updateClientVariables(); /* just to ensure */ +// if(client === this.currentMusicBot && (typeof(enforce) === "undefined" || !enforce)) +// return; +// +// const old = this.currentMusicBot; +// this.currentMusicBot = client; +// this.events.fire("bot_change", { +// new: client, +// old: old +// }); +// } +// +// current_bot() : MusicClientEntry | undefined { +// return this.currentMusicBot; +// } +// +// private sort_songs(data: PlaylistSong[]) { +// const result = []; +// +// let appendable: PlaylistSong[] = []; +// for(const song of data) { +// if(song.song_id == 0 || data.findIndex(e => e.song_id === song.song_previous_song_id) == -1) +// result.push(song); +// else +// appendable.push(song); +// } +// +// let iters; +// while (appendable.length) { +// do { +// iters = 0; +// const left: PlaylistSong[] = []; +// for(const song of appendable) { +// const index = data.findIndex(e => e.song_id === song.song_previous_song_id); +// if(index == -1) { +// left.push(song); +// continue; +// } +// +// result.splice(index + 1, 0, song); +// iters++; +// } +// appendable = left; +// } while(iters > 0); +// +// if(appendable.length) +// result.push(appendable.pop_front()); +// } +// +// return result; +// } +// +// /* playlist stuff */ +// update_playlist() { +// this.playlist_subscribe(true); +// +// this._container_playlist.find(".overlay").toggleClass("hidden", true); +// const playlist = this._container_playlist.find(".playlist"); +// playlist.empty(); +// +// if(!this.handle.handle.serverConnection || !this.handle.handle.serverConnection.connected() || !this.currentMusicBot) { +// this._container_playlist.find(".overlay-empty").removeClass("hidden"); +// return; +// } +// +// const overlay_loading = this._container_playlist.find(".overlay-loading"); +// overlay_loading.removeClass("hidden"); +// +// this.currentMusicBot.updateClientVariables(true).catch(error => { +// log.warn(LogCategory.CLIENT, tr("Failed to update music bot variables: %o"), error); +// }).then(() => { +// this.handle.handle.serverConnection.command_helper.requestPlaylistSongs(this.currentMusicBot.properties.client_playlist_id, false).then(songs => { +// this.playlist_subscribe(false); /* we're allowed to see the playlist */ +// if(!songs) { +// this._container_playlist.find(".overlay-empty").removeClass("hidden"); +// return; +// } +// +// for(const song of this.sort_songs(songs)) +// this.events.fire("playlist_song_add", { song: song, insert_effect: false }); +// }).catch(error => { +// if(error instanceof CommandResult && error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) { +// this._container_playlist.find(".overlay-no-permissions").removeClass("hidden"); +// return; +// } +// log.error(LogCategory.CLIENT, tr("Failed to load bot playlist: %o"), error); +// this._container_playlist.find(".overlay.overlay-error").removeClass("hidden"); +// }).then(() => { +// overlay_loading.addClass("hidden"); +// }); +// }); +// } +// +// private _playlist_subscribed = false; +// private playlist_subscribe(unsubscribe: boolean) { +// if(!this.handle.handle.serverConnection) return; +// +// if(unsubscribe || !this.currentMusicBot) { +// if(!this._playlist_subscribed) return; +// this._playlist_subscribed = false; +// +// this.handle.handle.serverConnection.send_command("playlistsetsubscription", {playlist_id: 0}).catch(error => { +// log.warn(LogCategory.CLIENT, tr("Failed to unsubscribe from last playlist: %o"), error); +// }); +// } else { +// this.handle.handle.serverConnection.send_command("playlistsetsubscription", { +// playlist_id: this.currentMusicBot.properties.client_playlist_id +// }).then(() => this._playlist_subscribed = true).catch(error => { +// log.warn(LogCategory.CLIENT, tr("Failed to subscribe to bots playlist: %o"), error); +// }); +// } +// } +// +// private build_playlist_entry(data: PlaylistSong, insert_effect: boolean) : JQuery { +// const tag = $("#tmpl_frame_music_playlist_entry").renderTag(); +// tag.attr({ +// "song-id": data.song_id, +// "song-url": data.song_url +// }); +// +// const thumbnail = tag.find(".container-thumbnail img"); +// const name = tag.find(".name"); +// const description = tag.find(".description"); +// const length = tag.find(".length"); +// +// tag.find(".button-delete").on('click', () => this.events.fire("action_song_delete", { song_id: data.song_id })); +// tag.find(".container-thumbnail").on('click', event => { +// const target = tag.find(".container-thumbnail img"); +// const url = target.attr("x-thumbnail-url"); +// if(!url) return; +// +// image_preview.preview_image(decodeURIComponent(url), decodeURIComponent(url)); +// }); +// tag.on('dblclick', event => this.events.fire("action_song_set", { song_id: data.song_id })); +// name.text(tr("loading...")); +// description.text(data.song_url); +// +// tag.on('mousedown', event => { +// if(event.button !== 0) return; +// +// this.song_reorder.mouse = { +// x: event.pageX, +// y: event.pageY +// }; +// +// const baseOff = tag.offset(); +// const off = { x: event.pageX - baseOff.left, y: event.pageY - baseOff.top }; +// const move_listener = (event: MouseEvent) => { +// const distance = Math.pow(event.pageX - this.song_reorder.mouse.x, 2) + Math.pow(event.pageY - this.song_reorder.mouse.y, 2); +// if(distance < 50) return; +// +// document.removeEventListener("blur", up_listener); +// document.removeEventListener("mouseup", up_listener); +// document.removeEventListener("mousemove", move_listener); +// +// this.song_reorder.mouse = off; +// this.events.fire("reorder_begin", { +// entry: tag, +// song_id: data.song_id +// }); +// }; +// +// const up_listener = event => { +// if(event.type === "mouseup" && event.button !== 0) return; +// +// document.removeEventListener("blur", up_listener); +// document.removeEventListener("mouseup", up_listener); +// document.removeEventListener("mousemove", move_listener); +// }; +// +// document.addEventListener("blur", up_listener); +// document.addEventListener("mouseup", up_listener); +// document.addEventListener("mousemove", move_listener); +// }); +// +// if(this.currentMusicBot) { +// this.currentMusicBot.requestPlayerInfo(60 * 1000).then(pdata => { +// if(pdata.song_id === data.song_id) +// tag.addClass("current-song"); +// }); +// } +// +// if(insert_effect) { +// tag.removeClass("shown"); +// tag.addClass("animation"); +// } +// return tag; +// } +// } \ No newline at end of file From f3fb114115da36a91cbcd6e782ee59ceb2864b6a Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 12 Dec 2020 00:16:17 +0100 Subject: [PATCH 13/37] Some video related changes and bugfixes (requires nightly 18) --- ChangeLog.md | 5 + shared/js/connection/rtc/Connection.ts | 41 +- shared/js/connection/rtc/SdpUtils.ts | 31 +- shared/js/connection/rtc/video/Connection.ts | 6 +- .../js/events/ClientGlobalControlHandler.ts | 3 +- shared/js/events/GlobalEvents.ts | 8 +- shared/js/text/bbcode.tsx | 3 +- shared/js/text/bbcode/image.tsx | 15 +- shared/js/text/chat.ts | 21 + shared/js/tree/ChannelTree.tsx | 15 +- shared/js/ui/frames/control-bar/Button.scss | 21 +- shared/js/ui/frames/control-bar/Controller.ts | 35 +- .../js/ui/frames/control-bar/Definitions.ts | 6 +- shared/js/ui/frames/control-bar/DropDown.tsx | 10 +- shared/js/ui/frames/control-bar/Renderer.tsx | 81 ++- shared/js/ui/frames/video/Controller.ts | 45 +- .../permission/ModalPermissionEditor.tsx | 21 +- .../js/ui/modal/video-source/Controller.tsx | 471 ++++++++++-------- shared/js/ui/react-elements/Icon.tsx | 2 +- 19 files changed, 524 insertions(+), 316 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index 19acf68e..9435c704 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,4 +1,9 @@ # Changelog: +* **12.12.20** + - Improved screen sharing and camara selection + - Showing the echoed own stream from the server instead of the local one + - Fixed a few minor issues with the auto url tokenizer + * **09.12.20** - Fixed the private messages unread indicator - Properly updating the private message unread count diff --git a/shared/js/connection/rtc/Connection.ts b/shared/js/connection/rtc/Connection.ts index d53312b5..389719ce 100644 --- a/shared/js/connection/rtc/Connection.ts +++ b/shared/js/connection/rtc/Connection.ts @@ -11,6 +11,7 @@ import {SdpCompressor, SdpProcessor} from "./SdpUtils"; import {ErrorCode} from "tc-shared/connection/ErrorCode"; import {WhisperTarget} from "tc-shared/voice/VoiceWhisper"; import {globalAudioContext} from "tc-backend/audio/player"; +import * as sdpTransform from "sdp-transform"; const kSdpCompressionMode = 1; @@ -200,6 +201,7 @@ class CommandHandler extends AbstractCommandHandler { sdp: sdp, type: "answer" }).then(() => { + this.handle["cachedRemoteSessionDescription"] = sdp; this.handle["peerRemoteDescriptionReceived"] = true; this.handle.applyCachedRemoteIceCandidates(); }).catch(error => { @@ -207,6 +209,7 @@ class CommandHandler extends AbstractCommandHandler { this.handle["handleFatalError"](tr("Failed to set the remote description (answer)"), true); }); } else if(data.mode === "offer") { + this.handle["cachedRemoteSessionDescription"] = sdp; this.handle["peer"].setRemoteDescription({ sdp: sdp, type: "offer" @@ -498,6 +501,8 @@ export class RTCConnection { private peerRemoteDescriptionReceived: boolean; private cachedRemoteIceCandidates: { candidate: RTCIceCandidate, mediaLine: number }[]; + private cachedRemoteSessionDescription: string; + private currentTracks: {[T in RTCSourceTrackType]: MediaStreamTrack | undefined} = { "audio-whisper": undefined, "video-screen": undefined, @@ -592,6 +597,7 @@ export class RTCConnection { } this.peerRemoteDescriptionReceived = false; this.cachedRemoteIceCandidates = []; + this.cachedRemoteSessionDescription = undefined; clearTimeout(this.connectTimeout); Object.keys(this.currentTransceiver).forEach(key => this.currentTransceiver[key] = undefined); @@ -625,7 +631,7 @@ export class RTCConnection { } } - async setTrackSource(type: RTCSourceTrackType, source: MediaStreamTrack | null) { + async setTrackSource(type: RTCSourceTrackType, source: MediaStreamTrack | null) : Promise { switch (type) { case "audio": case "audio-whisper": @@ -642,8 +648,9 @@ export class RTCConnection { return; } - this.currentTracks[type] = source; + const oldTrack = this.currentTracks[type] = source; await this.updateTracks(); + return oldTrack; } /** @@ -791,13 +798,17 @@ export class RTCConnection { iceServers: [{ urls: ["stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"] }] }); + const kAddGenericTransceiver = false; + if(this.audioSupport) { this.currentTransceiver["audio"] = this.peer.addTransceiver("audio"); this.currentTransceiver["audio-whisper"] = this.peer.addTransceiver("audio"); /* add some other transceivers for later use */ - for(let i = 0; i < 8; i++) { - this.peer.addTransceiver("audio"); + for(let i = 0; i < 8 && kAddGenericTransceiver; i++) { + const transceiver = this.peer.addTransceiver("audio"); + /* we only want to received on that and don't share any bandwidth limits */ + transceiver.direction = "recvonly"; } } @@ -805,8 +816,10 @@ export class RTCConnection { this.currentTransceiver["video-screen"] = this.peer.addTransceiver("video"); /* add some other transceivers for later use */ - for(let i = 0; i < 4; i++) { - this.peer.addTransceiver("video"); + for(let i = 0; i < 4 && kAddGenericTransceiver; i++) { + const transceiver = this.peer.addTransceiver("video"); + /* we only want to received on that and don't share any bandwidth limits */ + transceiver.direction = "recvonly"; } this.peer.onicecandidate = event => this.handleLocalIceCandidate(event.candidate); @@ -855,6 +868,20 @@ export class RTCConnection { } await this.currentTransceiver[type].sender.replaceTrack(target); + if(target) { + console.error("Setting sendrecv from %o", this.currentTransceiver[type].direction, this.currentTransceiver[type].currentDirection); + this.currentTransceiver[type].direction = "sendrecv"; + } else if(type === "video" || type === "video-screen") { + /* + * We don't need to stop & start the audio transceivers every time we're toggling the stream state. + * This would be a much overall cost than just keeping it going. + * + * The video streams instead are not toggling that much and since they split up the bandwidth between them, + * we've to shut them down if they're no needed. This not only allows the one stream to take full advantage + * of the bandwidth it also reduces resource usage. + */ + //this.currentTransceiver[type].direction = "recvonly"; + } logTrace(LogCategory.WEBRTC, "Replaced track for %o (Fallback: %o)", type, target === fallback); } } @@ -1005,7 +1032,7 @@ export class RTCConnection { logTrace(LogCategory.WEBRTC, tr("Peer signalling state changed to %s"), this.peer.signalingState); } private handleNegotiationNeeded() { - logWarn(LogCategory.WEBRTC, tr("Local peer needs negotiation, but we don't support client sideded negotiation.")); + logWarn(LogCategory.WEBRTC, tr("Local peer needs negotiation, but that's not supported that.")); } private handlePeerConnectionStateChanged() { logTrace(LogCategory.WEBRTC, tr("Peer connection state changed to %s"), this.peer.connectionState); diff --git a/shared/js/connection/rtc/SdpUtils.ts b/shared/js/connection/rtc/SdpUtils.ts index e9bc3f15..6a429ebf 100644 --- a/shared/js/connection/rtc/SdpUtils.ts +++ b/shared/js/connection/rtc/SdpUtils.ts @@ -1,5 +1,4 @@ import * as sdpTransform from "sdp-transform"; -import {MediaDescription} from "sdp-transform"; import { tr } from "tc-shared/i18n/localize"; interface SdpCodec { @@ -49,9 +48,10 @@ export class SdpProcessor { rtcpFb: [ "nack", "nack pli", "ccm fir", "transport-cc" ], //42001f | Original: 42e01f fmtp: { - "level-asymmetry-allowed": 1, "packetization-mode": 1, "profile-level-id": "42e01f", "max-br": 25000, "max-fr": 30, - "x-google-max-bitrate": 22 * 1000, - "x-google-start-bitrate": 22 * 1000, + "level-asymmetry-allowed": 1, + "packetization-mode": 1, + "profile-level-id": "42e01f", + "max-fr": 30, } } ]; @@ -83,27 +83,10 @@ export class SdpProcessor { const sdp = sdpTransform.parse(sdpString); this.rtpRemoteChannelMapping = SdpProcessor.generateRtpSSrcMapping(sdp); - /* Fix for Firefox to acknowledge the max bandwidth */ - for(const media of sdp.media) { - if(media.type !== "video") { continue; } - if(media.bandwidth?.length > 0) { continue; } - - const config = media.fmtp.find(e => e.config.indexOf("x-google-start-bitrate") !== -1); - if(!config) { continue; } - - const bitrate = config.config.split(";").find(e => e.startsWith("x-google-start-bitrate="))?.substr(23); - if(!bitrate) { continue; } - - media.bandwidth = [{ - type: "AS", - limit: bitrate - }]; - } - return sdpTransform.write(sdp); } - processOutgoingSdp(sdpString: string, mode: "offer" | "answer") : string { + processOutgoingSdp(sdpString: string, _mode: "offer" | "answer") : string { const sdp = sdpTransform.parse(sdpString); /* apply the "root" fingerprint to each media, FF fix */ @@ -195,10 +178,6 @@ export class SdpProcessor { } media.payloads = media.rtp.map(e => e.payload).join(" "); - media.bandwidth = [{ - type: "AS", - limit: 12000 - }] } } } diff --git a/shared/js/connection/rtc/video/Connection.ts b/shared/js/connection/rtc/video/Connection.ts index ad9ea6c6..5d9453fd 100644 --- a/shared/js/connection/rtc/video/Connection.ts +++ b/shared/js/connection/rtc/video/Connection.ts @@ -222,10 +222,6 @@ export class RtpVideoConnection implements VideoConnection { } async startBroadcasting(type: VideoBroadcastType, source: VideoSource) : Promise { - if(this.broadcasts[type]) { - this.stopBroadcasting(type); - } - const videoTracks = source.getStream().getVideoTracks(); if(videoTracks.length === 0) { throw tr("missing video stream track"); @@ -237,7 +233,7 @@ export class RtpVideoConnection implements VideoConnection { failedReason: undefined, active: true }; - this.events.fire("notify_local_broadcast_state_changed", { oldState: VideoBroadcastState.Stopped, newState: VideoBroadcastState.Initializing, broadcastType: type }); + this.events.fire("notify_local_broadcast_state_changed", { oldState: this.broadcasts[type].state || VideoBroadcastState.Stopped, newState: VideoBroadcastState.Initializing, broadcastType: type }); try { await this.rtcConnection.setTrackSource(type === "camera" ? "video" : "video-screen", videoTracks[0]); diff --git a/shared/js/events/ClientGlobalControlHandler.ts b/shared/js/events/ClientGlobalControlHandler.ts index 432376a2..8a025667 100644 --- a/shared/js/events/ClientGlobalControlHandler.ts +++ b/shared/js/events/ClientGlobalControlHandler.ts @@ -192,7 +192,8 @@ export function initialize(event_registry: Registry) createErrorModal(tr("You're not connected"), tr("You're not connected to any server!")).open(); return; } - spawnVideoSourceSelectModal(event.broadcastType, true, !!event.quickSelect).then(async source => { + + spawnVideoSourceSelectModal(event.broadcastType, event.quickSelect ? "quick" : "default", event.defaultDevice).then(async source => { if(!source) { return; } try { diff --git a/shared/js/events/GlobalEvents.ts b/shared/js/events/GlobalEvents.ts index d8e6c5da..a72c0b62 100644 --- a/shared/js/events/GlobalEvents.ts +++ b/shared/js/events/GlobalEvents.ts @@ -29,7 +29,13 @@ export interface ClientGlobalControlEvents { videoUrl: string, handlerId: string }, - action_toggle_video_broadcasting: { connection: ConnectionHandler, enabled: boolean, broadcastType: VideoBroadcastType, quickSelect?: boolean } + action_toggle_video_broadcasting: { + connection: ConnectionHandler, + enabled: boolean, + broadcastType: VideoBroadcastType, + quickSelect?: boolean, + defaultDevice?: string + } /* some more specific window openings */ action_open_window_connect: { diff --git a/shared/js/text/bbcode.tsx b/shared/js/text/bbcode.tsx index 7d0501cd..30672e26 100644 --- a/shared/js/text/bbcode.tsx +++ b/shared/js/text/bbcode.tsx @@ -14,7 +14,7 @@ export const allowedBBCodes = [ "u", "underlined", "s", "strikethrough", "color", "bgcolor", - "url", + "url", "img", "code", "i-code", "icode", "sub", "sup", @@ -31,7 +31,6 @@ export const allowedBBCodes = [ "tr", "td", "th", "yt", "youtube", - "img", "quote" ]; diff --git a/shared/js/text/bbcode/image.tsx b/shared/js/text/bbcode/image.tsx index aca17136..8ab57cdd 100644 --- a/shared/js/text/bbcode/image.tsx +++ b/shared/js/text/bbcode/image.tsx @@ -7,11 +7,12 @@ import * as contextmenu from "tc-shared/ui/elements/ContextMenu"; import {copy_to_clipboard} from "tc-shared/utils/helpers"; import * as image_preview from "tc-shared/ui/frames/image_preview"; -const regexImage = /^(?:https?):(?:\/{1,3}|\\)[-a-zA-Z0-9:;,@#%&()~_?+=\/\\.]*$/g; +export const regexImage = /^(?:https?):(?:\/{1,3}|\\)[-a-zA-Z0-9:;,@#%&()~_?+=\/\\.]*$/g; function loadImageForElement(element: HTMLImageElement) { - if(!element.hasAttribute("x-image-url")) + if(!element.hasAttribute("x-image-url")) { return; + } const url = decodeURIComponent(element.getAttribute("x-image-url") || ""); element.removeAttribute("x-image-url"); @@ -65,13 +66,15 @@ loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, { let target; let content = rendererText.render(element); if (!element.options) { - target = content; - } else - target = element.options; + target = content?.trim(); + } else { + target = element.options?.trim(); + } regexImage.lastIndex = 0; - if (!regexImage.test(target)) + if (!regexImage.test(target)) { return {"[img]" + content + "[/img]"}; + } return (
diff --git a/shared/js/text/chat.ts b/shared/js/text/chat.ts index 2b115f11..62c77365 100644 --- a/shared/js/text/chat.ts +++ b/shared/js/text/chat.ts @@ -4,6 +4,8 @@ import {renderMarkdownAsBBCode} from "../text/markdown"; import {escapeBBCode} from "../text/bbcode"; import {parse as parseBBCode} from "vendor/xbbcode/parser"; import {TagElement} from "vendor/xbbcode/elements"; +import * as React from "react"; +import {regexImage} from "tc-shared/text/bbcode/image"; interface UrlKnifeUrl { value: { @@ -26,6 +28,7 @@ function bbcodeLinkUrls(message: string, ignore: { start: number, end: number }[ /* we want to go through the urls from the back to the front */ urls.sort((a, b) => b.index.start - a.index.start); + outerLoop: for(const url of urls) { if(ignore.findIndex(range => range.start <= url.index.start && range.end >= url.index.end) !== -1) { continue; @@ -36,6 +39,23 @@ function bbcodeLinkUrls(message: string, ignore: { start: number, end: number }[ const urlPath = message.substring(url.index.start, url.index.end); let bbcodeUrl; + regexImage.lastIndex = 0; + if (regexImage.test(urlPath)) { + for(const suffix of [ + ".jpeg", ".jpg", + ".png", ".gif", ".tiff", + ".bmp", + ".webp", + ".svg" + ]) { + if(urlPath.endsWith(suffix)) { + /* It's an image. Images will be rendered by the client automatically. */ + continue outerLoop; + } + } + } + + let colonIndex = urlPath.indexOf(":"); if(colonIndex === -1 || colonIndex + 2 < urlPath.length || urlPath[colonIndex + 1] !== "/" || urlPath[colonIndex + 2] !== "/") { bbcodeUrl = "[url=https://" + urlPath + "]" + urlPath + "[/url]"; @@ -89,6 +109,7 @@ export function preprocessChatMessageForSend(message: string) : string { break; } } + console.error("Message: %s; No Parse: %s", message, noParseRanges); message = bbcodeLinkUrls(message, noParseRanges); } diff --git a/shared/js/tree/ChannelTree.tsx b/shared/js/tree/ChannelTree.tsx index a60f3c17..caa8cb9d 100644 --- a/shared/js/tree/ChannelTree.tsx +++ b/shared/js/tree/ChannelTree.tsx @@ -394,21 +394,20 @@ export class ChannelTree { client.channelTree = this; } - /* for debug purposes, the server might send back the own audio/video stream */ - if(!isLocalClient || __build.mode === "debug") { + if(!isLocalClient) { const voiceConnection = this.client.serverConnection.getVoiceConnection(); try { client.setVoiceClient(voiceConnection.registerVoiceClient(client.clientId())); } catch (error) { logError(LogCategory.AUDIO, tr("Failed to register a voice client for %d: %o"), client.clientId(), error); } + } - const videoConnection = this.client.serverConnection.getVideoConnection(); - try { - client.setVideoClient(videoConnection.registerVideoClient(client.clientId())); - } catch (error) { - logError(LogCategory.VIDEO, tr("Failed to register a video client for %d: %o"), client.clientId(), error); - } + const videoConnection = this.client.serverConnection.getVideoConnection(); + try { + client.setVideoClient(videoConnection.registerVideoClient(client.clientId())); + } catch (error) { + logError(LogCategory.VIDEO, tr("Failed to register a video client for %d: %o"), client.clientId(), error); } } diff --git a/shared/js/ui/frames/control-bar/Button.scss b/shared/js/ui/frames/control-bar/Button.scss index fe0293bc..e144d979 100644 --- a/shared/js/ui/frames/control-bar/Button.scss +++ b/shared/js/ui/frames/control-bar/Button.scss @@ -167,19 +167,14 @@ html:root { right: 0; } - :global { - .icon, .icon-container, .icon_em { - vertical-align: middle; - margin-right: 5px; - } + .iconContainer { + margin-right: .25em; - .icon-empty, .icon_empty { - flex-shrink: 0; - flex-grow: 0; + display: flex; + flex-direction: column; - height: 16px; - width: 16px; - } + flex-shrink: 0; + flex-grow: 0; } .dropdownEntry { @@ -204,6 +199,10 @@ html:root { .icon, .arrow { flex-grow: 0; flex-shrink: 0; + + display: flex; + flex-direction: column; + justify-content: center; } .arrow { diff --git a/shared/js/ui/frames/control-bar/Controller.ts b/shared/js/ui/frames/control-bar/Controller.ts index e34c3616..d5d5dd94 100644 --- a/shared/js/ui/frames/control-bar/Controller.ts +++ b/shared/js/ui/frames/control-bar/Controller.ts @@ -3,7 +3,7 @@ import { Bookmark, ControlBarEvents, ControlBarMode, - HostButtonInfo, + HostButtonInfo, VideoDeviceInfo, VideoState } from "tc-shared/ui/frames/control-bar/Definitions"; import {server_connections} from "tc-shared/ConnectionManager"; @@ -24,6 +24,7 @@ import {LogCategory, logWarn} from "tc-shared/log"; import {createErrorModal, createInputModal} from "tc-shared/ui/elements/Modal"; import {VideoBroadcastState, VideoBroadcastType, VideoConnectionStatus} from "tc-shared/connection/VideoConnection"; import { tr } from "tc-shared/i18n/localize"; +import {getVideoDriver} from "tc-shared/video/VideoSource"; class InfoController { private readonly mode: ControlBarMode; @@ -61,7 +62,7 @@ class InfoController { this.sendVideoState("camera"); })); events.push(bookmarkEvents.on("notify_bookmarks_updated", () => this.sendBookmarks())); - + events.push(getVideoDriver().getEvents().on("notify_device_list_changed", () => this.sendCameraList())) if(this.mode === "main") { events.push(server_connections.events().on("notify_active_handler_changed", event => this.setConnectionHandler(event.newHandler))); } @@ -268,6 +269,26 @@ class InfoController { this.events.fire_react("notify_video_state", { state: state, broadcastType: type }); } + + public sendCameraList() { + let devices: VideoDeviceInfo[] = []; + const driver = getVideoDriver(); + driver.getDevices().then(result => { + if(result === false || result.length === 0) { + return; + } + + this.events.fire_react("notify_camera_list", { + devices: result.map(e => { + return { + name: e.name, + id: e.id + }; + }) + }); + }) + this.events.fire_react("notify_camera_list", { devices: devices }); + } } export function initializePopoutControlBarController(events: Registry, handler: ConnectionHandler) { @@ -294,6 +315,7 @@ export function initializeControlBarController(events: Registry infoHandler.sendSubscribeState()); events.on("query_host_button", () => infoHandler.sendHostButton()); events.on("query_video_state", event => infoHandler.sendVideoState(event.broadcastType)); + events.on("query_camera_list", () => infoHandler.sendCameraList()); events.on("action_connection_connect", event => global_client_actions.fire("action_open_window_connect", { newTab: event.newTab })); events.on("action_connection_disconnect", event => { @@ -378,7 +400,14 @@ export function initializeControlBarController(events: Registry { if(infoHandler.getCurrentHandler()) { - global_client_actions.fire("action_toggle_video_broadcasting", { connection: infoHandler.getCurrentHandler(), broadcastType: event.broadcastType, enabled: event.enable }); + /* TODO: Just update the stream and don't "rebroadcast" */ + global_client_actions.fire("action_toggle_video_broadcasting", { + connection: infoHandler.getCurrentHandler(), + broadcastType: event.broadcastType, + enabled: event.enable, + quickSelect: event.quickStart, + defaultDevice: event.deviceId + }); } else { createErrorModal(tr("Missing connection handler"), tr("Cannot start video broadcasting with a missing connection handler")).open(); } diff --git a/shared/js/ui/frames/control-bar/Definitions.ts b/shared/js/ui/frames/control-bar/Definitions.ts index b1b15e75..0562f462 100644 --- a/shared/js/ui/frames/control-bar/Definitions.ts +++ b/shared/js/ui/frames/control-bar/Definitions.ts @@ -8,6 +8,7 @@ export type AwayState = { locallyAway: boolean, globallyAway: "partial" | "full" export type MicrophoneState = "enabled" | "disabled" | "muted"; export type VideoState = "enabled" | "disabled" | "unavailable" | "unsupported" | "disconnected"; export type HostButtonInfo = { title?: string, target?: string, url: string }; +export type VideoDeviceInfo = { name: string, id: string }; export interface ControlBarEvents { action_connection_connect: { newTab: boolean }, @@ -21,7 +22,8 @@ export interface ControlBarEvents { action_toggle_subscribe: { subscribe: boolean }, action_toggle_query: { show: boolean }, action_query_manage: {}, - action_toggle_video: { broadcastType: VideoBroadcastType, enable: boolean } + action_toggle_video: { broadcastType: VideoBroadcastType, enable: boolean, quickStart?: boolean, deviceId?: string }, + action_manage_video: { broadcastType: VideoBroadcastType } query_mode: {}, query_connection_state: {}, @@ -33,6 +35,7 @@ export interface ControlBarEvents { query_query_state: {}, query_host_button: {}, query_video_state: { broadcastType: VideoBroadcastType }, + query_camera_list: {} notify_mode: { mode: ControlBarMode } notify_connection_state: { state: ConnectionState }, @@ -44,6 +47,7 @@ export interface ControlBarEvents { notify_query_state: { shown: boolean }, notify_host_button: { button: HostButtonInfo | undefined }, notify_video_state: { broadcastType: VideoBroadcastType, state: VideoState }, + notify_camera_list: { devices: VideoDeviceInfo[] } notify_destroy: {} } \ No newline at end of file diff --git a/shared/js/ui/frames/control-bar/DropDown.tsx b/shared/js/ui/frames/control-bar/DropDown.tsx index d8f763f1..28cbb98e 100644 --- a/shared/js/ui/frames/control-bar/DropDown.tsx +++ b/shared/js/ui/frames/control-bar/DropDown.tsx @@ -15,11 +15,11 @@ export interface DropdownEntryProperties { children?: React.ReactElement[] } -const LocalIconRenderer = (props: { icon?: string | RemoteIconInfo }) => { +const LocalIconRenderer = (props: { icon?: string | RemoteIconInfo, className?: string }) => { if(!props.icon || typeof props.icon === "string") { - return + return } else { - return ; + return ; } } @@ -30,7 +30,7 @@ export class DropdownEntry extends ReactComponentBase - + {this.props.text}
@@ -41,7 +41,7 @@ export class DropdownEntry extends ReactComponentBase - + {this.props.text}
); diff --git a/shared/js/ui/frames/control-bar/Renderer.tsx b/shared/js/ui/frames/control-bar/Renderer.tsx index 189ee7a7..e751c196 100644 --- a/shared/js/ui/frames/control-bar/Renderer.tsx +++ b/shared/js/ui/frames/control-bar/Renderer.tsx @@ -6,7 +6,7 @@ import { ControlBarEvents, ControlBarMode, HostButtonInfo, - MicrophoneState, + MicrophoneState, VideoDeviceInfo, VideoState } from "tc-shared/ui/frames/control-bar/Definitions"; import * as React from "react"; @@ -205,6 +205,32 @@ const AwayButton = () => { ); }; +const VideoDeviceList = React.memo(() => { + const events = useContext(Events); + const [ devices, setDevices ] = useState(() => { + events.fire("query_camera_list"); + return []; + }); + events.reactUse("notify_camera_list", event => setDevices(event.devices)); + + if(devices.length === 0) { + return null; + } + + return ( + <> +
+ {devices.map(device => ( + events.fire("action_toggle_video", {enable: true, broadcastType: "camera", deviceId: device.id, quickStart: true})} + /> + ))} + + ); +}); + const VideoButton = (props: { type: VideoBroadcastType }) => { const events = useContext(Events); @@ -221,34 +247,59 @@ const VideoButton = (props: { type: VideoBroadcastType }) => { let tooltip = props.type === "camera" ? tr("Video broadcasting not supported") : tr("Screen sharing not supported"); let modalTitle = props.type === "camera" ? tr("Video broadcasting unsupported") : tr("Screen sharing unsupported"); let modalBody = props.type === "camera" ? tr("Video broadcasting isn't supported by the target server.") : tr("Screen sharing isn't supported by the target server."); - return + ); } case "unavailable": { let tooltip = props.type === "camera" ? tr("Video broadcasting not available") : tr("Screen sharing not available"); let modalTitle = props.type === "camera" ? tr("Video broadcasting unavailable") : tr("Screen sharing unavailable"); let modalBody = props.type === "camera" ? tr("Video broadcasting isn't available right now.") : tr("Screen sharing isn't available right now."); - return + ); } case "disconnected": case "disabled": { let tooltip = props.type === "camera" ? tr("Start video broadcasting") : tr("Start screen sharing"); - return + ); } case "enabled": { let tooltip = props.type === "camera" ? tr("Stop video broadcasting") : tr("Stop screen sharing"); - return + ); } } } @@ -403,12 +454,12 @@ export const ControlBar2 = (props: { events: Registry, classNa items.push(); items.push(
); } - items.push(); items.push(); items.push(); items.push(); items.push(); items.push(
); + items.push(); items.push(); items.push(); items.push(
); diff --git a/shared/js/ui/frames/video/Controller.ts b/shared/js/ui/frames/video/Controller.ts index fb707e2e..4b272c61 100644 --- a/shared/js/ui/frames/video/Controller.ts +++ b/shared/js/ui/frames/video/Controller.ts @@ -4,7 +4,12 @@ import * as ReactDOM from "react-dom"; import {ChannelVideoRenderer} from "tc-shared/ui/frames/video/Renderer"; import {Registry} from "tc-shared/events"; import {ChannelVideoEvents, kLocalVideoId} from "tc-shared/ui/frames/video/Definitions"; -import {VideoBroadcastState, VideoBroadcastType, VideoConnection} from "tc-shared/connection/VideoConnection"; +import { + VideoBroadcastState, + VideoBroadcastType, + VideoClient, + VideoConnection +} from "tc-shared/connection/VideoConnection"; import {ClientEntry, ClientType, LocalClientEntry, MusicClientEntry} from "tc-shared/tree/Client"; import {LogCategory, logError, logWarn} from "tc-shared/log"; import {tr} from "tc-shared/i18n/localize"; @@ -69,6 +74,7 @@ class RemoteClientVideoController implements ClientVideoController { const videoClient = this.client.getVideoClient(); if(videoClient) { + this.initializeVideoClient(videoClient); events.push(videoClient.getEvents().on("notify_broadcast_state_changed", event => { console.error("Broadcast state changed: %o - %o - %o", event.broadcastType, VideoBroadcastState[event.oldState], VideoBroadcastState[event.newState]); if(event.newState === VideoBroadcastState.Stopped || event.oldState === VideoBroadcastState.Stopped) { @@ -81,6 +87,18 @@ class RemoteClientVideoController implements ClientVideoController { } } + protected initializeVideoClient(videoClient: VideoClient) { + this.eventListenerVideoClient.push(videoClient.getEvents().on("notify_broadcast_state_changed", event => { + console.error("Broadcast state changed: %o - %o - %o", event.broadcastType, VideoBroadcastState[event.oldState], VideoBroadcastState[event.newState]); + if(event.newState === VideoBroadcastState.Stopped || event.oldState === VideoBroadcastState.Stopped) { + /* we've a new broadcast which hasn't been dismissed yet */ + this.dismissed[event.broadcastType] = false; + } + this.notifyVideo(); + this.notifyMuteState(); + })); + } + destroy() { this.eventListenerVideoClient?.forEach(callback => callback()); this.eventListenerVideoClient = undefined; @@ -231,7 +249,20 @@ class LocalVideoController extends RemoteClientVideoController { super(client, eventRegistry, kLocalVideoId); const videoConnection = client.channelTree.client.serverConnection.getVideoConnection(); - this.eventListener.push(videoConnection.getEvents().on("notify_local_broadcast_state_changed", () => this.notifyVideo())); + this.eventListener.push(videoConnection.getEvents().on("notify_local_broadcast_state_changed", () => { + this.notifyVideo(); + })); + } + + protected initializeVideoClient(videoClient: VideoClient) { + super.initializeVideoClient(videoClient); + + this.eventListenerVideoClient.push(videoClient.getEvents().on("notify_broadcast_state_changed", event => { + if(event.newState === VideoBroadcastState.Available) { + /* we want to watch our own broadcast */ + videoClient.joinBroadcast(event.broadcastType).then(undefined); + } + })) } isBroadcasting() { @@ -239,14 +270,11 @@ class LocalVideoController extends RemoteClientVideoController { return videoConnection.isBroadcasting("camera") || videoConnection.isBroadcasting("screen"); } - async getStatistics(target: VideoBroadcastType) { - - } - protected isVideoActive(): boolean { return true; } + /* protected getBroadcastState(target: VideoBroadcastType): VideoBroadcastState { const videoConnection = this.client.channelTree.client.serverConnection.getVideoConnection(); return videoConnection.getBroadcastingState(target); @@ -256,6 +284,7 @@ class LocalVideoController extends RemoteClientVideoController { const videoConnection = this.client.channelTree.client.serverConnection.getVideoConnection(); return videoConnection.getBroadcastingSource(target)?.getStream(); } + */ } class ChannelVideoController { @@ -540,6 +569,10 @@ class ChannelVideoController { if(channel) { const clients = channel.channelClientsOrdered(); for(const client of clients) { + if(client instanceof LocalClientEntry) { + continue; + } + if(!this.clientVideos[client.clientId()]) { /* should not be possible (Is only possible for the local client) */ continue; diff --git a/shared/js/ui/modal/permission/ModalPermissionEditor.tsx b/shared/js/ui/modal/permission/ModalPermissionEditor.tsx index fe371044..7d4ce987 100644 --- a/shared/js/ui/modal/permission/ModalPermissionEditor.tsx +++ b/shared/js/ui/modal/permission/ModalPermissionEditor.tsx @@ -321,17 +321,16 @@ class PermissionEditorModal extends InternalModal { renderBody() { return (
- -
- - -
-
- - -
-
+
+ + +
+ +
+ + +
); } diff --git a/shared/js/ui/modal/video-source/Controller.tsx b/shared/js/ui/modal/video-source/Controller.tsx index 530b55e5..8dea6720 100644 --- a/shared/js/ui/modal/video-source/Controller.tsx +++ b/shared/js/ui/modal/video-source/Controller.tsx @@ -6,42 +6,48 @@ import {getVideoDriver, VideoPermissionStatus, VideoSource} from "tc-shared/vide import {LogCategory, logError} from "tc-shared/log"; import {VideoBroadcastType} from "tc-shared/connection/VideoConnection"; -type VideoSourceRef = { source: VideoSource }; +type SourceConstraints = { width?: number, height?: number, frameRate?: number }; /** * @param type The video type which should be prompted - * @param selectDefault If we're trying to select a source on default - * @param quickSelect If we want to quickly select a source and instantly use it. - * This option is only useable for screen sharing. + * @param selectMode + * @param defaultDeviceId */ -export async function spawnVideoSourceSelectModal(type: VideoBroadcastType, selectDefault: boolean, quickSelect: boolean) : Promise { - const refSource: VideoSourceRef = { - source: undefined - }; +export async function spawnVideoSourceSelectModal(type: VideoBroadcastType, selectMode: "quick" | "default" | "none", defaultDeviceId?: string) : Promise { + const controller = new VideoSourceController(type); - const events = new Registry(); - events.enableDebug("video-source-select"); - initializeController(events, refSource, type, quickSelect); + if(selectMode === "quick") { + /* We need the modal itself for the native client in order to present the window selector */ + if(type === "camera" || __build.target === "web") { + /* Try to get the default device. If we succeeded directly return that */ + if(await controller.selectSource(defaultDeviceId)) { + const source = controller.getCurrentSource()?.ref(); + controller.destroy(); + + return source; + } + } + } + + const modal = spawnReactModal(ModalVideoSource, controller.events, type); + controller.events.on(["action_start", "action_cancel"], () => modal.destroy()); - const modal = spawnReactModal(ModalVideoSource, events, type); - modal.events.on("destroy", () => { - events.fire("notify_destroy"); - events.destroy(); - }); - events.on(["action_start", "action_cancel"], () => { - modal.destroy(); - }); modal.show().then(() => { - if(type === "screen" && getVideoDriver().screenQueryAvailable()) { - events.fire_react("action_toggle_screen_capture_device_select", { shown: true }); - } else if(selectDefault) { - events.fire("action_select_source", { id: undefined }); + if(selectMode === "default" || selectMode === "quick") { + if(type === "screen" && getVideoDriver().screenQueryAvailable()) { + controller.events.fire_react("action_toggle_screen_capture_device_select", { shown: true }); + } else { + controller.selectSource(defaultDeviceId); + } } }); + let refSource: { source: VideoSource } = { source: undefined }; + controller.events.on("action_start", () => refSource.source = controller.getCurrentSource()?.ref()); + await new Promise(resolve => { - if(type === "screen" && quickSelect) { - events.on("notify_video_preview", event => { + if(type === "screen" && selectMode === "quick") { + controller.events.on("notify_video_preview", event => { if(event.status.status === "preview") { /* we've successfully selected something */ modal.destroy(); @@ -52,106 +58,287 @@ export async function spawnVideoSourceSelectModal(type: VideoBroadcastType, sele modal.events.one(["destroy", "close"], resolve); }); + controller.destroy(); return refSource.source; } -type SourceConstraints = { width?: number, height?: number, frameRate?: number }; +class VideoSourceController { + readonly events: Registry; + private readonly type: VideoBroadcastType; -function initializeController(events: Registry, currentSourceRef: VideoSourceRef, type: VideoBroadcastType, quickSelect: boolean) { - let currentSource: VideoSource | string; - let currentConstraints: SourceConstraints; + private currentSource: VideoSource | string; + private currentConstraints: SourceConstraints; /* preselected current source id */ - let currentSourceId: string; + private currentSourceId: string; + /* fallback current source name if "currentSource" is empty */ - let fallbackCurrentSourceName: string; + private fallbackCurrentSourceName: string; - const notifyStartButton = () => { - events.fire_react("notify_start_button", { enabled: typeof currentSource === "object" }) - }; + constructor(type: VideoBroadcastType) { + this.type = type; + this.events = new Registry(); + this.events.enableDebug("video-source-select"); - const notifyDeviceList = () => { + + this.events.on("query_source", () => this.notifyCurrentSource()); + this.events.on("query_device_list", () => this.notifyDeviceList()); + this.events.on("query_screen_capture_devices", () => this.notifyScreenCaptureDevices()); + this.events.on("query_video_preview", () => this.notifyVideoPreview()); + this.events.on("query_start_button", () => this.notifyStartButton()); + this.events.on("query_setting_dimension", () => this.notifySettingDimension()); + this.events.on("query_setting_framerate", () => this.notifySettingFramerate()); + + this.events.on("action_request_permissions", () => { + getVideoDriver().requestPermissions().then(result => { + if(typeof result === "object") { + this.currentSourceId = result.getId() + " --"; + this.fallbackCurrentSourceName = result.getName(); + this.notifyDeviceList(); + + this.setCurrentSource(result); + } else { + /* the device list will already be updated due to the notify_permissions_changed event */ + } + }); + }); + + this.events.on("action_select_source", event => { + const driver = getVideoDriver(); + + if(type === "camera") { + this.currentSourceId = event.id; + this.fallbackCurrentSourceName = tr("loading..."); + this.notifyDeviceList(); + + driver.createVideoSource(this.currentSourceId).then(stream => { + this.fallbackCurrentSourceName = stream.getName(); + this.setCurrentSource(stream); + }).catch(error => { + this.fallbackCurrentSourceName = "invalid device"; + if(typeof error === "string") { + this.setCurrentSource(error); + } else { + logError(LogCategory.GENERAL, tr("Failed to open video device %s: %o"), event.id, error); + this.setCurrentSource(tr("Failed to open video device (Lookup the console)")); + } + }); + } else if(driver.screenQueryAvailable() && typeof event.id === "undefined") { + this.events.fire_react("action_toggle_screen_capture_device_select", { shown: true }); + } else { + this.currentSourceId = undefined; + this.fallbackCurrentSourceName = tr("loading..."); + driver.createScreenSource(event.id, false).then(stream => { + this.setCurrentSource(stream); + this.fallbackCurrentSourceName = stream?.getName() || tr("No stream"); + }).catch(error => { + this.fallbackCurrentSourceName = "screen capture failed"; + if(typeof error === "string") { + this.setCurrentSource(error); + } else { + logError(LogCategory.GENERAL, tr("Failed to open screen capture device %s: %o"), event.id, error); + this.setCurrentSource(tr("Failed to open screen capture device (Lookup the console)")); + } + }); + } + }); + + this.events.on("action_cancel", () => { + this.setCurrentSource(undefined); + }); + + if(type === "camera") { + /* only the camara requires a device list */ + this.events.on("notify_destroy", getVideoDriver().getEvents().on("notify_permissions_changed", () => { + if(getVideoDriver().getPermissionStatus() !== VideoPermissionStatus.Granted) { + this.currentSourceId = undefined; + this.fallbackCurrentSourceName = undefined; + this.notifyDeviceList(); + + /* implicitly updates the start button */ + this.setCurrentSource(undefined); + } else { + this.notifyDeviceList(); + this.notifyVideoPreview(); + this.notifyStartButton(); + } + })); + } + + const applyConstraints = async () => { + if(typeof this.currentSource === "object") { + const videoTrack = this.currentSource.getStream().getVideoTracks()[0]; + if(!videoTrack) { return; } + + await videoTrack.applyConstraints(this.currentConstraints); + } + }; + + this.events.on("action_setting_dimension", event => { + this.currentConstraints.height = event.height; + this.currentConstraints.width = event.width; + applyConstraints().then(undefined); + }); + + this.events.on("action_setting_framerate", event => { + this.currentConstraints.frameRate = event.frameRate; + applyConstraints().then(undefined); + }); + } + + destroy() { + if(typeof this.currentSource === "object") { + this.currentSource.deref(); + this.currentSource = undefined; + } + + this.events.fire("notify_destroy"); + this.events.destroy(); + } + + setCurrentSource(source: VideoSource | string | undefined) { + if(typeof this.currentSource === "object") { + this.currentSource.deref(); + } + + this.currentConstraints = {}; + this.currentSource = source; + this.notifyVideoPreview(); + this.notifyStartButton(); + this.notifyCurrentSource(); + this.notifySettingDimension(); + this.notifySettingFramerate(); + } + + async selectSource(sourceId: string) : Promise { + const driver = getVideoDriver(); + + let streamPromise: Promise; + if(this.type === "camera") { + this.currentSourceId = sourceId; + this.fallbackCurrentSourceName = tr("loading..."); + this.notifyDeviceList(); + + streamPromise = driver.createVideoSource(this.currentSourceId); + } else if(driver.screenQueryAvailable() && typeof sourceId === "undefined") { + /* TODO: What the hack is this?! */ + this.events.fire_react("action_toggle_screen_capture_device_select", { shown: true }); + return; + } else { + this.currentSourceId = undefined; + this.fallbackCurrentSourceName = tr("loading..."); + streamPromise = driver.createScreenSource(sourceId, false); + } + + try { + const stream = await streamPromise; + this.setCurrentSource(stream); + this.fallbackCurrentSourceName = stream?.getName() || tr("No stream"); + + return !!stream; + } catch (error) { + this.fallbackCurrentSourceName = tr("failed to attach to device"); + if(typeof error === "string") { + this.setCurrentSource(error); + } else { + logError(LogCategory.GENERAL, tr("Failed to open capture device %s: %o"), sourceId, error); + this.setCurrentSource(tr("Failed to open capture device (Lookup the console)")); + } + + return false; + } + } + + getCurrentSource() : VideoSource | undefined { + return typeof this.currentSource === "object" ? this.currentSource : undefined; + } + + private notifyStartButton() { + this.events.fire_react("notify_start_button", { enabled: typeof this.currentSource === "object" }) + } + + private notifyDeviceList(){ const driver = getVideoDriver(); driver.getDevices().then(devices => { if(devices === false) { if(driver.getPermissionStatus() === VideoPermissionStatus.SystemDenied) { - events.fire_react("notify_device_list", { status: { status: "error", reason: "no-permissions" } }); + this.events.fire_react("notify_device_list", { status: { status: "error", reason: "no-permissions" } }); } else { - events.fire_react("notify_device_list", { status: { status: "error", reason: "request-permissions" } }); + this.events.fire_react("notify_device_list", { status: { status: "error", reason: "request-permissions" } }); } } else { - events.fire_react("notify_device_list", { + this.events.fire_react("notify_device_list", { status: { status: "success", devices: devices.map(e => { return { id: e.id, displayName: e.name }}), - selectedDeviceId: currentSourceId, - fallbackSelectedDeviceName: fallbackCurrentSourceName + selectedDeviceId: this.currentSourceId, + fallbackSelectedDeviceName: this.fallbackCurrentSourceName } }); } }); } - const notifyScreenCaptureDevices = () => { + private notifyScreenCaptureDevices(){ const driver = getVideoDriver(); driver.queryScreenCaptureDevices().then(devices => { - events.fire_react("notify_screen_capture_devices", { devices: { status: "success", devices: devices }}); + this.events.fire_react("notify_screen_capture_devices", { devices: { status: "success", devices: devices }}); }).catch(error => { if(typeof error !== "string") { logError(LogCategory.VIDEO, tr("Failed to query screen capture devices: %o"), error); error = tr("lookup the console"); } - events.fire_react("notify_screen_capture_devices", { devices: { status: "error", reason: error }}); + this.events.fire_react("notify_screen_capture_devices", { devices: { status: "error", reason: error }}); }) } - const notifyVideoPreview = () => { + private notifyVideoPreview(){ const driver = getVideoDriver(); switch (driver.getPermissionStatus()) { case VideoPermissionStatus.SystemDenied: - events.fire_react("notify_video_preview", { status: { status: "error", reason: "no-permissions" }}); + this.events.fire_react("notify_video_preview", { status: { status: "error", reason: "no-permissions" }}); break; case VideoPermissionStatus.UserDenied: - events.fire_react("notify_video_preview", { status: { status: "error", reason: "request-permissions" }}); + this.events.fire_react("notify_video_preview", { status: { status: "error", reason: "request-permissions" }}); break; case VideoPermissionStatus.Granted: - if(typeof currentSource === "string") { - events.fire_react("notify_video_preview", { status: { - status: "error", - reason: "custom", - message: currentSource - }}); - } else if(currentSource) { - events.fire_react("notify_video_preview", { status: { - status: "preview", - stream: currentSource.getStream() - }}); + if(typeof this.currentSource === "string") { + this.events.fire_react("notify_video_preview", { status: { + status: "error", + reason: "custom", + message: this.currentSource + }}); + } else if(this.currentSource) { + this.events.fire_react("notify_video_preview", { status: { + status: "preview", + stream: this.currentSource.getStream() + }}); } else { - events.fire_react("notify_video_preview", { status: { status: "none" }}); + this.events.fire_react("notify_video_preview", { status: { status: "none" }}); } break; } }; - const notifyCurrentSource = () => { - if(typeof currentSource === "object") { - events.fire_react("notify_source", { + private notifyCurrentSource(){ + if(typeof this.currentSource === "object") { + this.events.fire_react("notify_source", { state: { type: "selected", - deviceId: currentSource.getId(), - name: currentSource?.getName() || fallbackCurrentSourceName + deviceId: this.currentSource.getId(), + name: this.currentSource?.getName() || this.fallbackCurrentSourceName } }); - } else if(typeof currentSource === "string") { - events.fire_react("notify_source", { + } else if(typeof this.currentSource === "string") { + this.events.fire_react("notify_source", { state: { type: "errored", - error: currentSource + error: this.currentSource } }); } else { - events.fire_react("notify_source", { + this.events.fire_react("notify_source", { state: { type: "none" } @@ -159,13 +346,13 @@ function initializeController(events: Registry, currentS } } - const notifySettingDimension = () => { - if(typeof currentSource === "object") { - const videoTrack = currentSource.getStream().getVideoTracks()[0]; + private notifySettingDimension(){ + if(typeof this.currentSource === "object") { + const videoTrack = this.currentSource.getStream().getVideoTracks()[0]; const settings = videoTrack.getSettings(); const capabilities = "getCapabilities" in videoTrack ? videoTrack.getCapabilities() : undefined; - events.fire_react("notify_setting_dimension", { + this.events.fire_react("notify_setting_dimension", { setting: { minWidth: capabilities?.width ? capabilities.width.min : 1, maxWidth: capabilities?.width ? capabilities.width.max : settings.width, @@ -181,18 +368,18 @@ function initializeController(events: Registry, currentS } }); } else { - events.fire_react("notify_setting_dimension", { setting: undefined }); + this.events.fire_react("notify_setting_dimension", { setting: undefined }); } }; - const notifySettingFramerate = () => { - if(typeof currentSource === "object") { - const videoTrack = currentSource.getStream().getVideoTracks()[0]; + notifySettingFramerate() { + if(typeof this.currentSource === "object") { + const videoTrack = this.currentSource.getStream().getVideoTracks()[0]; const settings = videoTrack.getSettings(); const capabilities = "getCapabilities" in videoTrack ? videoTrack.getCapabilities() : undefined; const round = (value: number) => Math.round(value * 100) / 100; - events.fire_react("notify_settings_framerate", { + this.events.fire_react("notify_settings_framerate", { frameRate: { min: round(capabilities?.frameRate ? capabilities.frameRate.min : 1), max: round(capabilities?.frameRate ? capabilities.frameRate.max : settings.frameRate), @@ -200,137 +387,7 @@ function initializeController(events: Registry, currentS } }); } else { - events.fire_react("notify_settings_framerate", { frameRate: undefined }); + this.events.fire_react("notify_settings_framerate", { frameRate: undefined }); } }; - - const setCurrentSource = (source: VideoSource | string | undefined) => { - if(typeof currentSource === "object") { - currentSource.deref(); - } - - currentConstraints = {}; - currentSource = source; - notifyVideoPreview(); - notifyStartButton(); - notifyCurrentSource(); - notifySettingDimension(); - notifySettingFramerate(); - } - - events.on("query_source", () => notifyCurrentSource()); - events.on("query_device_list", () => notifyDeviceList()); - events.on("query_screen_capture_devices", () => notifyScreenCaptureDevices()); - events.on("query_video_preview", () => notifyVideoPreview()); - events.on("query_start_button", () => notifyStartButton()); - events.on("query_setting_dimension", () => notifySettingDimension()); - events.on("query_setting_framerate", () => notifySettingFramerate()); - - events.on("action_request_permissions", () => { - getVideoDriver().requestPermissions().then(result => { - if(typeof result === "object") { - currentSourceId = result.getId() + " --"; - fallbackCurrentSourceName = result.getName(); - notifyDeviceList(); - - setCurrentSource(result); - } else { - /* the device list will already be updated due to the notify_permissions_changed event */ - } - }); - }); - - events.on("action_select_source", event => { - const driver = getVideoDriver(); - - if(type === "camera") { - currentSourceId = event.id; - fallbackCurrentSourceName = tr("loading..."); - notifyDeviceList(); - - driver.createVideoSource(currentSourceId).then(stream => { - fallbackCurrentSourceName = stream.getName(); - setCurrentSource(stream); - }).catch(error => { - fallbackCurrentSourceName = "invalid device"; - if(typeof error === "string") { - setCurrentSource(error); - } else { - logError(LogCategory.GENERAL, tr("Failed to open video device %s: %o"), event.id, error); - setCurrentSource(tr("Failed to open video device (Lookup the console)")); - } - }); - } else if(driver.screenQueryAvailable() && typeof event.id === "undefined") { - events.fire_react("action_toggle_screen_capture_device_select", { shown: true }); - } else { - currentSourceId = undefined; - fallbackCurrentSourceName = tr("loading..."); - driver.createScreenSource(event.id, quickSelect).then(stream => { - setCurrentSource(stream); - fallbackCurrentSourceName = stream?.getName() || tr("No stream"); - }).catch(error => { - fallbackCurrentSourceName = "screen capture failed"; - if(typeof error === "string") { - setCurrentSource(error); - } else { - logError(LogCategory.GENERAL, tr("Failed to open screen capture device %s: %o"), event.id, error); - setCurrentSource(tr("Failed to open screen capture device (Lookup the console)")); - } - }); - } - }); - - events.on("action_cancel", () => { - currentSourceRef.source = undefined; - }); - - if(type === "camera") { - /* only the camara requires a device list */ - events.on("notify_destroy", getVideoDriver().getEvents().on("notify_permissions_changed", () => { - if(getVideoDriver().getPermissionStatus() !== VideoPermissionStatus.Granted) { - currentSourceId = undefined; - fallbackCurrentSourceName = undefined; - notifyDeviceList(); - - /* implicitly updates the start button */ - setCurrentSource(undefined); - } else { - notifyDeviceList(); - notifyVideoPreview(); - notifyStartButton(); - } - })); - } - - events.on("notify_destroy", () => { - if(typeof currentSource === "object") { - currentSource.deref(); - } - }); - - events.on("action_start", () => { - if(typeof currentSource === "object") { - currentSourceRef.source = currentSource.ref(); - } - }) - - const applyConstraints = async () => { - if(typeof currentSource === "object") { - const videoTrack = currentSource.getStream().getVideoTracks()[0]; - if(!videoTrack) { return; } - - await videoTrack.applyConstraints(currentConstraints); - } - }; - - events.on("action_setting_dimension", event => { - currentConstraints.height = event.height; - currentConstraints.width = event.width; - applyConstraints().then(undefined); - }); - - events.on("action_setting_framerate", event => { - currentConstraints.frameRate = event.frameRate; - applyConstraints().then(undefined); - }); } \ No newline at end of file diff --git a/shared/js/ui/react-elements/Icon.tsx b/shared/js/ui/react-elements/Icon.tsx index a7d4bac5..d41e72e2 100644 --- a/shared/js/ui/react-elements/Icon.tsx +++ b/shared/js/ui/react-elements/Icon.tsx @@ -12,7 +12,7 @@ export const IconRenderer = (props: { if(!props.icon) { return
; } else if(typeof props.icon === "string") { - return
; + return
; } else { throw "JQuery icons are not longer supported"; } From aada747f4e38a3efae216b6e6123f72af20685e7 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 12 Dec 2020 13:19:04 +0100 Subject: [PATCH 14/37] Improved the video rendering and selection --- shared/js/connection/VideoConnection.ts | 51 ++- shared/js/connection/rtc/Connection.ts | 7 +- shared/js/connection/rtc/SdpUtils.ts | 11 + shared/js/connection/rtc/video/Connection.ts | 359 ++++++++++++------ .../js/events/ClientGlobalControlHandler.ts | 25 +- shared/js/events/GlobalEvents.ts | 10 +- shared/js/ui/frames/control-bar/Controller.ts | 12 +- shared/js/ui/frames/control-bar/Renderer.tsx | 11 +- shared/js/ui/frames/video/Controller.ts | 129 ++++--- shared/js/ui/frames/video/Definitions.ts | 1 + shared/js/ui/frames/video/Renderer.scss | 6 + .../js/ui/modal/video-source/Controller.tsx | 9 +- shared/js/ui/modal/video-source/Renderer.scss | 4 +- web/app/connection/ServerConnection.ts | 1 + 14 files changed, 432 insertions(+), 204 deletions(-) diff --git a/shared/js/connection/VideoConnection.ts b/shared/js/connection/VideoConnection.ts index ac0d53fc..10a3dfa3 100644 --- a/shared/js/connection/VideoConnection.ts +++ b/shared/js/connection/VideoConnection.ts @@ -24,7 +24,6 @@ export type VideoBroadcastStatistics = { export interface VideoConnectionEvent { notify_status_changed: { oldState: VideoConnectionStatus, newState: VideoConnectionStatus }, - notify_local_broadcast_state_changed: { broadcastType: VideoBroadcastType, oldState: VideoBroadcastState, newState: VideoBroadcastState }, } export enum VideoConnectionStatus { @@ -64,6 +63,44 @@ export interface VideoClient { leaveBroadcast(broadcastType: VideoBroadcastType); } +export interface LocalVideoBroadcastEvents { + notify_state_changed: { oldState: LocalVideoBroadcastState, newState: LocalVideoBroadcastState }, +} + +export type LocalVideoBroadcastState = { + state: "stopped", +} | { + state: "initializing" +} | { + state: "failed", + reason: string +} | { + state: "broadcasting" +} + +export interface LocalVideoBroadcast { + getEvents() : Registry; + + getState() : LocalVideoBroadcastState; + getSource() : VideoSource | undefined; + getStatistics() : Promise; + + //getBandwidthLimit() : number | undefined; + //setBandwidthLimit(value: number); + + /** + * @param source The source of the broadcast (No ownership will be taken. The voice connection must ref the source by itself!) + */ + startBroadcasting(source: VideoSource) : Promise; + + /** + * @param source The source of the broadcast (No ownership will be taken. The voice connection must ref the source by itself!) + */ + changeSource(source: VideoSource) : Promise; + + stopBroadcasting(); +} + export interface VideoConnection { getEvents() : Registry; @@ -73,17 +110,7 @@ export interface VideoConnection { getConnectionStats() : Promise; - isBroadcasting(type: VideoBroadcastType); - getBroadcastingSource(type: VideoBroadcastType) : VideoSource | undefined; - getBroadcastingState(type: VideoBroadcastType) : VideoBroadcastState; - getBroadcastStatistics(type: VideoBroadcastType) : Promise; - - /** - * @param type - * @param source The source of the broadcast (No ownership will be taken. The voice connection must ref the source by itself!) - */ - startBroadcasting(type: VideoBroadcastType, source: VideoSource) : Promise; - stopBroadcasting(type: VideoBroadcastType); + getLocalBroadcast(channel: VideoBroadcastType) : LocalVideoBroadcast; registerVideoClient(clientId: number); registeredVideoClients() : VideoClient[]; diff --git a/shared/js/connection/rtc/Connection.ts b/shared/js/connection/rtc/Connection.ts index 389719ce..be808098 100644 --- a/shared/js/connection/rtc/Connection.ts +++ b/shared/js/connection/rtc/Connection.ts @@ -64,6 +64,7 @@ class RetryTimeCalculator { } calculateRetryTime() { + return 0; if(this.retryCount >= 5) { /* no more retries */ return 0; @@ -203,7 +204,7 @@ class CommandHandler extends AbstractCommandHandler { }).then(() => { this.handle["cachedRemoteSessionDescription"] = sdp; this.handle["peerRemoteDescriptionReceived"] = true; - this.handle.applyCachedRemoteIceCandidates(); + setTimeout(() => this.handle.applyCachedRemoteIceCandidates(), 50); }).catch(error => { logError(LogCategory.WEBRTC, tr("Failed to set the remote description: %o"), error); this.handle["handleFatalError"](tr("Failed to set the remote description (answer)"), true); @@ -954,9 +955,6 @@ export class RTCConnection { logTrace(LogCategory.WEBRTC, tr("Skipping local fqdn ICE candidate %s"), candidate.toJSON().candidate); return; } - if(candidate.protocol !== "tcp") { - return; - } this.localCandidateCount++; const json = candidate.toJSON(); @@ -1009,6 +1007,7 @@ export class RTCConnection { this.handleRemoteIceCandidate(candidate, mediaLine); } + this.handleRemoteIceCandidate(undefined, 0); this.cachedRemoteIceCandidates = []; } diff --git a/shared/js/connection/rtc/SdpUtils.ts b/shared/js/connection/rtc/SdpUtils.ts index 6a429ebf..99a2878e 100644 --- a/shared/js/connection/rtc/SdpUtils.ts +++ b/shared/js/connection/rtc/SdpUtils.ts @@ -81,11 +81,22 @@ export class SdpProcessor { sdpString = sdpString.replace(/profile-level-id=4325407/g, "profile-level-id=42e01f"); const sdp = sdpTransform.parse(sdpString); + //sdp.media.forEach(media => media.candidates = []); + //sdp.origin.address = "127.0.0.1"; this.rtpRemoteChannelMapping = SdpProcessor.generateRtpSSrcMapping(sdp); return sdpTransform.write(sdp); } + /* + getCandidates(sdpString: string) : string[] { + const sdp = sdpTransform.parse(sdpString); + sdp.media = [sdp.media[0]]; + sdpTransform.write(sdp).split("\r\n") + .filter(line => line.startsWith("a")) + } + */ + processOutgoingSdp(sdpString: string, _mode: "offer" | "answer") : string { const sdp = sdpTransform.parse(sdpString); diff --git a/shared/js/connection/rtc/video/Connection.ts b/shared/js/connection/rtc/video/Connection.ts index 5d9453fd..9e1315a0 100644 --- a/shared/js/connection/rtc/video/Connection.ts +++ b/shared/js/connection/rtc/video/Connection.ts @@ -1,5 +1,7 @@ import { - VideoBroadcastState, + LocalVideoBroadcast, + LocalVideoBroadcastEvents, + LocalVideoBroadcastState, VideoBroadcastStatistics, VideoBroadcastType, VideoClient, @@ -9,19 +11,236 @@ import { } from "tc-shared/connection/VideoConnection"; import {Registry} from "tc-shared/events"; import {VideoSource} from "tc-shared/video/VideoSource"; -import {RTCConnection, RTCConnectionEvents, RTPConnectionState} from "../Connection"; -import {LogCategory, logDebug, logError, logWarn} from "tc-shared/log"; +import {RTCBroadcastableTrackType, RTCConnection, RTCConnectionEvents, RTPConnectionState} from "../Connection"; +import {LogCategory, logError, logWarn} from "tc-shared/log"; import {Settings, settings} from "tc-shared/settings"; import {RtpVideoClient} from "./VideoClient"; import {tr} from "tc-shared/i18n/localize"; import {ConnectionState} from "tc-shared/ConnectionHandler"; import {ConnectionStatistics} from "tc-shared/connection/ConnectionBase"; +import * as _ from "lodash"; -type VideoBroadcast = { - readonly source: VideoSource; - state: VideoBroadcastState, - failedReason: string | undefined, - active: boolean +class LocalRtpVideoBroadcast implements LocalVideoBroadcast { + private readonly handle: RtpVideoConnection; + private readonly type: VideoBroadcastType; + private readonly events: Registry; + + private state: LocalVideoBroadcastState; + private currentSource: VideoSource; + private broadcastStartId: number; + + private localStartPromise: Promise; + + constructor(handle: RtpVideoConnection, type: VideoBroadcastType) { + this.handle = handle; + this.type = type; + this.broadcastStartId = 0; + + this.events = new Registry(); + this.state = { state: "stopped" }; + } + + destroy() { + this.events.destroy(); + } + + getEvents(): Registry { + return this.events; + } + + getSource(): VideoSource | undefined { + return this.currentSource; + } + + getState(): LocalVideoBroadcastState { + return this.state; + } + + private setState(newState: LocalVideoBroadcastState) { + if(_.isEqual(this.state, newState)) { + return; + } + + const oldState = this.state; + this.state = newState; + this.events.fire("notify_state_changed", { oldState: oldState, newState: newState }); + } + + getStatistics(): Promise { + return Promise.resolve(undefined); + } + + async changeSource(source: VideoSource): Promise { + const videoTracks = source.getStream().getVideoTracks(); + if(videoTracks.length === 0) { + throw tr("missing video stream track"); + } + + let sourceRef = source.ref(); + while(this.localStartPromise) { + await this.localStartPromise; + } + + if(this.state.state !== "broadcasting") { + sourceRef.deref(); + throw tr("not broadcasting anything"); + } + + const startId = ++this.broadcastStartId; + let rtcBroadcastType: RTCBroadcastableTrackType = this.type === "camera" ? "video" : "video-screen"; + try { + await this.handle.getRTCConnection().setTrackSource(rtcBroadcastType, videoTracks[0]); + } catch (error) { + if(this.broadcastStartId !== startId) { + /* broadcast start has been canceled */ + return; + } + + sourceRef.deref(); + logError(LogCategory.WEBRTC, tr("Failed to change video track for broadcast %s: %o"), this.type, error); + throw tr("failed to change video track"); + } + + this.setCurrentSource(sourceRef); + sourceRef.deref(); + } + + private setCurrentSource(source: VideoSource | undefined) { + if(this.currentSource) { + this.currentSource.deref(); + } + this.currentSource = source?.ref(); + } + + async startBroadcasting(source: VideoSource): Promise { + const sourceRef = source.ref(); + while(this.localStartPromise) { + await this.localStartPromise; + } + + const promise = this.doStartBroadcast(source); + this.localStartPromise = promise.catch(() => {}); + this.localStartPromise.then(() => this.localStartPromise = undefined); + try { + await promise; + } finally { + sourceRef.deref(); + } + } + + private async doStartBroadcast(source: VideoSource) { + const videoTracks = source.getStream().getVideoTracks(); + if(videoTracks.length === 0) { + throw tr("missing video stream track"); + } + const startId = ++this.broadcastStartId; + + this.setCurrentSource(source); + this.setState({ state: "initializing" }); + + if(this.broadcastStartId !== startId) { + /* broadcast start has been canceled */ + return; + } + + let rtcBroadcastType: RTCBroadcastableTrackType = this.type === "camera" ? "video" : "video-screen"; + + try { + await this.handle.getRTCConnection().setTrackSource(rtcBroadcastType, videoTracks[0]); + } catch (error) { + if(this.broadcastStartId !== startId) { + /* broadcast start has been canceled */ + return; + } + + this.stopBroadcasting(true, { state: "failed", reason: tr("Failed to set track source") }); + logError(LogCategory.WEBRTC, tr("Failed to setup video track for broadcast %s: %o"), this.type, error); + throw tr("failed to initialize video track"); + } + + if(this.broadcastStartId !== startId) { + /* broadcast start has been canceled */ + return; + } + + try { + await this.handle.getRTCConnection().startTrackBroadcast(rtcBroadcastType); + } catch (error) { + if(this.broadcastStartId !== startId) { + /* broadcast start has been canceled */ + return; + } + + this.stopBroadcasting(true, { state: "failed", reason: error }); + throw error; + } + + if(this.broadcastStartId !== startId) { + /* broadcast start has been canceled */ + return; + } + + this.setState({ state: "broadcasting" }); + } + + stopBroadcasting(skipRtcStop?: boolean, stopState?: LocalVideoBroadcastState) { + if(this.state.state === "stopped" && (!stopState || _.isEqual(stopState, this.state))) { + return; + } + + this.broadcastStartId++; + + (async () => { + while(this.localStartPromise) { + await this.localStartPromise; + } + + let rtcBroadcastType: RTCBroadcastableTrackType = this.type === "camera" ? "video" : "video-screen"; + if(!skipRtcStop && !(this.state.state === "failed" || this.state.state === "stopped")) { + this.handle.getRTCConnection().stopTrackBroadcast(rtcBroadcastType); + } + + this.setCurrentSource(undefined); + + try { + await this.handle.getRTCConnection().setTrackSource(rtcBroadcastType, null); + } catch (error) { + logWarn(LogCategory.VIDEO, tr("Failed to change the RTC video track to null: %o"), error); + } + this.setState(stopState || { state: "stopped" }); + })(); + } + + /** + * Restart the broadcast after a channel switch. + */ + restartBroadcast() { + (async () => { + while(this.localStartPromise) { + await this.localStartPromise; + } + + if(this.state.state !== "broadcasting") { + return; + } + + this.setState({ state: "initializing" }); + let rtcBroadcastType: RTCBroadcastableTrackType = this.type === "camera" ? "video" : "video-screen"; + const startId = ++this.broadcastStartId; + + try { + await this.handle.getRTCConnection().startTrackBroadcast(rtcBroadcastType); + } catch (error) { + if(this.broadcastStartId !== startId) { + /* broadcast start has been canceled */ + return; + } + + this.stopBroadcasting(true, { state: "failed", reason: error }); + throw error; + } + })(); + } } export class RtpVideoConnection implements VideoConnection { @@ -30,9 +249,9 @@ export class RtpVideoConnection implements VideoConnection { private readonly listener: (() => void)[]; private connectionState: VideoConnectionStatus; - private broadcasts: {[T in VideoBroadcastType]: VideoBroadcast} = { - camera: undefined, - screen: undefined + private broadcasts: {[T in VideoBroadcastType]: LocalRtpVideoBroadcast} = { + camera: new LocalRtpVideoBroadcast(this, "camera"), + screen: new LocalRtpVideoBroadcast(this, "screen") }; private registeredClients: {[key: number]: RtpVideoClient} = {}; @@ -55,12 +274,10 @@ export class RtpVideoConnection implements VideoConnection { }); if(settings.static_global(Settings.KEY_STOP_VIDEO_ON_SWITCH)) { - this.stopBroadcasting("camera", true); - this.stopBroadcasting("screen", true); + Object.values(this.broadcasts).forEach(broadcast => broadcast.stopBroadcasting()); } else { /* The server stops broadcasting by default, we've to reenable it */ - this.restartBroadcast("screen"); - this.restartBroadcast("camera"); + Object.values(this.broadcasts).forEach(broadcast => broadcast.restartBroadcast()); } } else if(parseInt("scid") === localClient.currentChannel().channelId) { const broadcast = this.registeredClients[clientId]; @@ -120,8 +337,7 @@ export class RtpVideoConnection implements VideoConnection { this.listener.push(this.rtcConnection.getConnection().events.on("notify_connection_state_changed", event => { if(event.newState !== ConnectionState.CONNECTED) { - this.stopBroadcasting("camera"); - this.stopBroadcasting("screen"); + Object.values(this.broadcasts).forEach(broadcast => broadcast.stopBroadcasting(true)); } })); @@ -157,30 +373,6 @@ export class RtpVideoConnection implements VideoConnection { this.events.fire("notify_status_changed", { oldState: oldState, newState: state }); } - private restartBroadcast(type: VideoBroadcastType) { - if(!this.broadcasts[type]?.active) { return; } - const broadcast = this.broadcasts[type]; - - if(broadcast.state !== VideoBroadcastState.Initializing) { - const oldState = broadcast.state; - broadcast.state = VideoBroadcastState.Initializing; - this.events.fire("notify_local_broadcast_state_changed", { oldState: oldState, newState: VideoBroadcastState.Initializing, broadcastType: type }); - } - - this.rtcConnection.startTrackBroadcast(type === "camera" ? "video" : "video-screen").then(() => { - if(!broadcast.active) { return; } - - const oldState = broadcast.state; - broadcast.state = VideoBroadcastState.Running; - this.events.fire("notify_local_broadcast_state_changed", { oldState: oldState, newState: VideoBroadcastState.Initializing, broadcastType: type }); - logDebug(LogCategory.VIDEO, tr("Successfully restarted video broadcast of type %s"), type); - }).catch(error => { - if(!broadcast.active) { return; } - logWarn(LogCategory.VIDEO, tr("Failed to restart video broadcast %s: %o"), type, error); - this.stopBroadcasting(type, true); - }); - } - destroy() { this.listener.forEach(callback => callback()); this.listener.splice(0, this.listener.length); @@ -188,6 +380,10 @@ export class RtpVideoConnection implements VideoConnection { this.events.destroy(); } + getRTCConnection() : RTCConnection { + return this.rtcConnection; + } + getEvents(): Registry { return this.events; } @@ -204,83 +400,6 @@ export class RtpVideoConnection implements VideoConnection { return this.rtcConnection.getFailReason(); } - getBroadcastingState(type: VideoBroadcastType): VideoBroadcastState { - return this.broadcasts[type] ? this.broadcasts[type].state : VideoBroadcastState.Stopped; - } - - async getBroadcastStatistics(type: VideoBroadcastType): Promise { - /* TODO! */ - return undefined; - } - - getBroadcastingSource(type: VideoBroadcastType): VideoSource | undefined { - return this.broadcasts[type]?.source; - } - - isBroadcasting(type: VideoBroadcastType) { - return typeof this.broadcasts[type] !== "undefined"; - } - - async startBroadcasting(type: VideoBroadcastType, source: VideoSource) : Promise { - const videoTracks = source.getStream().getVideoTracks(); - if(videoTracks.length === 0) { - throw tr("missing video stream track"); - } - - const broadcast = this.broadcasts[type] = { - source: source.ref(), - state: VideoBroadcastState.Initializing as VideoBroadcastState, - failedReason: undefined, - active: true - }; - this.events.fire("notify_local_broadcast_state_changed", { oldState: this.broadcasts[type].state || VideoBroadcastState.Stopped, newState: VideoBroadcastState.Initializing, broadcastType: type }); - - try { - await this.rtcConnection.setTrackSource(type === "camera" ? "video" : "video-screen", videoTracks[0]); - } catch (error) { - this.stopBroadcasting(type); - logError(LogCategory.WEBRTC, tr("Failed to setup video track for broadcast %s: %o"), type, error); - throw tr("failed to initialize video track"); - } - - if(!broadcast.active) { - return; - } - - try { - await this.rtcConnection.startTrackBroadcast(type === "camera" ? "video" : "video-screen"); - } catch (error) { - this.stopBroadcasting(type); - throw error; - } - - if(!broadcast.active) { - return; - } - - broadcast.state = VideoBroadcastState.Running; - this.events.fire("notify_local_broadcast_state_changed", { oldState: VideoBroadcastState.Initializing, newState: VideoBroadcastState.Running, broadcastType: type }); - } - - stopBroadcasting(type: VideoBroadcastType, skipRtcStop?: boolean) { - const broadcast = this.broadcasts[type]; - if(!broadcast) { - return; - } - - if(!skipRtcStop) { - this.rtcConnection.stopTrackBroadcast(type === "camera" ? "video" : "video-screen"); - } - - this.rtcConnection.setTrackSource(type === "camera" ? "video" : "video-screen", null).then(undefined); - const oldState = this.broadcasts[type].state; - this.broadcasts[type].active = false; - this.broadcasts[type] = undefined; - broadcast.source.deref(); - - this.events.fire("notify_local_broadcast_state_changed", { oldState: oldState, newState: VideoBroadcastState.Stopped, broadcastType: type }); - } - registerVideoClient(clientId: number) { if(typeof this.registeredClients[clientId] !== "undefined") { debugger; @@ -353,4 +472,8 @@ export class RtpVideoConnection implements VideoConnection { bytesSend: stats.videoBytesSent }; } + + getLocalBroadcast(channel: VideoBroadcastType): LocalVideoBroadcast { + return this.broadcasts[channel]; + } } \ No newline at end of file diff --git a/shared/js/events/ClientGlobalControlHandler.ts b/shared/js/events/ClientGlobalControlHandler.ts index 8a025667..8368ed96 100644 --- a/shared/js/events/ClientGlobalControlHandler.ts +++ b/shared/js/events/ClientGlobalControlHandler.ts @@ -197,8 +197,24 @@ export function initialize(event_registry: Registry) if(!source) { return; } try { - connection.getServerConnection().getVideoConnection().startBroadcasting(event.broadcastType, source) - .catch(error => { + const broadcast = connection.getServerConnection().getVideoConnection().getLocalBroadcast(event.broadcastType); + if(broadcast.getState().state === "initializing" || broadcast.getState().state === "broadcasting") { + console.error("Change source"); + broadcast.changeSource(source).catch(error => { + logError(LogCategory.VIDEO, tr("Failed to change broadcast source: %o"), event.broadcastType, error); + if(typeof error !== "string") { + error = tr("lookup the console for detail"); + } + + if(event.broadcastType === "camera") { + createErrorModal(tr("Failed to change video source"), tra("Failed to change video broadcasting source:\n{}", error)).open(); + } else { + createErrorModal(tr("Failed to change screen sharing source"), tra("Failed to change screen sharing source:\n{}", error)).open(); + } + }); + } else { + console.error("Start broadcast"); + broadcast.startBroadcasting(source).catch(error => { logError(LogCategory.VIDEO, tr("Failed to start %s broadcasting: %o"), event.broadcastType, error); if(typeof error !== "string") { error = tr("lookup the console for detail"); @@ -210,12 +226,15 @@ export function initialize(event_registry: Registry) createErrorModal(tr("Failed to start screen sharing"), tra("Failed to start screen sharing:\n{}", error)).open(); } }); + } } finally { source.deref(); } }); } else { - event.connection.getServerConnection().getVideoConnection().stopBroadcasting(event.broadcastType); + const connection = event.connection; + const broadcast = connection.getServerConnection().getVideoConnection().getLocalBroadcast(event.broadcastType); + broadcast.stopBroadcasting(); } }); } \ No newline at end of file diff --git a/shared/js/events/GlobalEvents.ts b/shared/js/events/GlobalEvents.ts index a72c0b62..98f43a5b 100644 --- a/shared/js/events/GlobalEvents.ts +++ b/shared/js/events/GlobalEvents.ts @@ -29,13 +29,19 @@ export interface ClientGlobalControlEvents { videoUrl: string, handlerId: string }, + /* Start/open a new video broadcast */ action_toggle_video_broadcasting: { connection: ConnectionHandler, - enabled: boolean, broadcastType: VideoBroadcastType, + enabled: boolean, quickSelect?: boolean, defaultDevice?: string - } + }, + /* Open the broadcast edit window */ + action_edit_video_broadcasting: { + connection: ConnectionHandler, + broadcastType: VideoBroadcastType, + }, /* some more specific window openings */ action_open_window_connect: { diff --git a/shared/js/ui/frames/control-bar/Controller.ts b/shared/js/ui/frames/control-bar/Controller.ts index d5d5dd94..f3f12515 100644 --- a/shared/js/ui/frames/control-bar/Controller.ts +++ b/shared/js/ui/frames/control-bar/Controller.ts @@ -22,9 +22,10 @@ import { } from "tc-shared/bookmarks"; import {LogCategory, logWarn} from "tc-shared/log"; import {createErrorModal, createInputModal} from "tc-shared/ui/elements/Modal"; -import {VideoBroadcastState, VideoBroadcastType, VideoConnectionStatus} from "tc-shared/connection/VideoConnection"; +import {VideoBroadcastType, VideoConnectionStatus} from "tc-shared/connection/VideoConnection"; import { tr } from "tc-shared/i18n/localize"; import {getVideoDriver} from "tc-shared/video/VideoSource"; +import {kLocalBroadcastChannels} from "tc-shared/ui/frames/video/Definitions"; class InfoController { private readonly mode: ControlBarMode; @@ -127,7 +128,11 @@ class InfoController { })); const videoConnection = handler.getServerConnection().getVideoConnection(); - events.push(videoConnection.getEvents().on(["notify_local_broadcast_state_changed", "notify_status_changed"], () => { + for(const channel of kLocalBroadcastChannels) { + const broadcast = videoConnection.getLocalBroadcast(channel); + events.push(broadcast.getEvents().on("notify_state_changed", () => this.sendVideoState(channel))); + } + events.push(videoConnection.getEvents().on("notify_status_changed", () => { this.sendVideoState("screen"); this.sendVideoState("camera"); })); @@ -253,7 +258,8 @@ class InfoController { if(this.currentHandler?.connected) { const videoConnection = this.currentHandler.getServerConnection().getVideoConnection(); if(videoConnection.getStatus() === VideoConnectionStatus.Connected) { - if(videoConnection.getBroadcastingState(type) === VideoBroadcastState.Running) { + const broadcast = videoConnection.getLocalBroadcast(type); + if(broadcast.getState().state === "broadcasting" || broadcast.getState().state === "initializing") { state = "enabled"; } else { state = "disabled"; diff --git a/shared/js/ui/frames/control-bar/Renderer.tsx b/shared/js/ui/frames/control-bar/Renderer.tsx index e751c196..f2efa595 100644 --- a/shared/js/ui/frames/control-bar/Renderer.tsx +++ b/shared/js/ui/frames/control-bar/Renderer.tsx @@ -247,7 +247,7 @@ const VideoButton = (props: { type: VideoBroadcastType }) => { let tooltip = props.type === "camera" ? tr("Video broadcasting not supported") : tr("Screen sharing not supported"); let modalTitle = props.type === "camera" ? tr("Video broadcasting unsupported") : tr("Screen sharing unsupported"); let modalBody = props.type === "camera" ? tr("Video broadcasting isn't supported by the target server.") : tr("Screen sharing isn't supported by the target server."); - let dropdownText = props.type === "camera" ? tr("Start screen sharing") : tr("Start video broadcasting"); + let dropdownText = props.type === "camera" ? tr("Start video broadcasting") : tr("Start screen sharing"); return ( ); diff --git a/shared/js/ui/frames/video/Controller.ts b/shared/js/ui/frames/video/Controller.ts index 4b272c61..d748754c 100644 --- a/shared/js/ui/frames/video/Controller.ts +++ b/shared/js/ui/frames/video/Controller.ts @@ -3,8 +3,9 @@ import * as React from "react"; import * as ReactDOM from "react-dom"; import {ChannelVideoRenderer} from "tc-shared/ui/frames/video/Renderer"; import {Registry} from "tc-shared/events"; -import {ChannelVideoEvents, kLocalVideoId} from "tc-shared/ui/frames/video/Definitions"; +import {ChannelVideo, ChannelVideoEvents, kLocalVideoId} from "tc-shared/ui/frames/video/Definitions"; import { + LocalVideoBroadcastState, VideoBroadcastState, VideoBroadcastType, VideoClient, @@ -14,6 +15,7 @@ import {ClientEntry, ClientType, LocalClientEntry, MusicClientEntry} from "tc-sh import {LogCategory, logError, logWarn} from "tc-shared/log"; import {tr} from "tc-shared/i18n/localize"; import {Settings, settings} from "tc-shared/settings"; +import * as _ from "lodash"; const cssStyle = require("./Renderer.scss"); @@ -24,7 +26,7 @@ interface ClientVideoController { dismissVideo(type: VideoBroadcastType); notifyVideoInfo(); - notifyVideo(); + notifyVideo(forceSend: boolean); notifyMuteState(); } @@ -43,6 +45,8 @@ class RemoteClientVideoController implements ClientVideoController { camera: false }; + private cachedVideoStatus: ChannelVideo; + constructor(client: ClientEntry, eventRegistry: Registry, videoId?: string) { this.client = client; this.events = eventRegistry; @@ -70,20 +74,11 @@ class RemoteClientVideoController implements ClientVideoController { private updateVideoClient() { this.eventListenerVideoClient?.forEach(callback => callback()); - const events = this.eventListenerVideoClient = []; + this.eventListenerVideoClient = []; const videoClient = this.client.getVideoClient(); if(videoClient) { this.initializeVideoClient(videoClient); - events.push(videoClient.getEvents().on("notify_broadcast_state_changed", event => { - console.error("Broadcast state changed: %o - %o - %o", event.broadcastType, VideoBroadcastState[event.oldState], VideoBroadcastState[event.newState]); - if(event.newState === VideoBroadcastState.Stopped || event.oldState === VideoBroadcastState.Stopped) { - /* we've a new broadcast which hasn't been dismissed yet */ - this.dismissed[event.broadcastType] = false; - } - this.notifyVideo(); - this.notifyMuteState(); - })); } } @@ -94,7 +89,7 @@ class RemoteClientVideoController implements ClientVideoController { /* we've a new broadcast which hasn't been dismissed yet */ this.dismissed[event.broadcastType] = false; } - this.notifyVideo(); + this.notifyVideo(false); this.notifyMuteState(); })); } @@ -132,7 +127,7 @@ class RemoteClientVideoController implements ClientVideoController { } this.dismissed[type] = true; - this.notifyVideo(); + this.notifyVideo(false); } notifyVideoInfo() { @@ -147,9 +142,10 @@ class RemoteClientVideoController implements ClientVideoController { }); } - notifyVideo() { + notifyVideo(forceSend: boolean) { let broadcasting = false; - if(this.isVideoActive()) { + let status: ChannelVideo; + if(this.hasVideoSupport()) { let initializing = false; let cameraStream, desktopStream; @@ -174,41 +170,34 @@ class RemoteClientVideoController implements ClientVideoController { if(cameraStream || desktopStream) { broadcasting = true; - this.events.fire_react("notify_video", { - videoId: this.videoId, - status: { - status: "connected", + status = { + status: "connected", - desktopStream: desktopStream, - cameraStream: cameraStream, + desktopStream: desktopStream, + cameraStream: cameraStream, - dismissed: this.dismissed - } - }); + dismissed: this.dismissed + }; } else if(initializing) { broadcasting = true; - this.events.fire_react("notify_video", { - videoId: this.videoId, - status: { status: "initializing" } - }); + status = { status: "initializing" }; } else { - this.events.fire_react("notify_video", { - videoId: this.videoId, - status: { - status: "connected", + status = { + status: "connected", - cameraStream: undefined, - desktopStream: undefined, + cameraStream: undefined, + desktopStream: undefined, - dismissed: this.dismissed - } - }); + dismissed: this.dismissed + }; } } else { - this.events.fire_react("notify_video", { - videoId: this.videoId, - status: { status: "no-video" } - }); + status = { status: "no-video" }; + } + + if(forceSend || !_.isEqual(this.cachedVideoStatus, status)) { + this.cachedVideoStatus = status; + this.events.fire_react("notify_video", { videoId: this.videoId, status: status }); } if(broadcasting !== this.currentBroadcastState) { @@ -229,7 +218,7 @@ class RemoteClientVideoController implements ClientVideoController { }); } - protected isVideoActive() : boolean { + protected hasVideoSupport() : boolean { return typeof this.client.getVideoClient() !== "undefined"; } @@ -244,14 +233,19 @@ class RemoteClientVideoController implements ClientVideoController { } } +const kLocalBroadcastChannels: VideoBroadcastType[] = ["screen", "camera"]; class LocalVideoController extends RemoteClientVideoController { constructor(client: ClientEntry, eventRegistry: Registry) { super(client, eventRegistry, kLocalVideoId); const videoConnection = client.channelTree.client.serverConnection.getVideoConnection(); - this.eventListener.push(videoConnection.getEvents().on("notify_local_broadcast_state_changed", () => { - this.notifyVideo(); - })); + + for(const broadcastType of kLocalBroadcastChannels) { + const broadcast = videoConnection.getLocalBroadcast(broadcastType); + this.eventListener.push(broadcast.getEvents().on("notify_state_changed", () => { + this.notifyVideo(false); + })); + } } protected initializeVideoClient(videoClient: VideoClient) { @@ -267,19 +261,52 @@ class LocalVideoController extends RemoteClientVideoController { isBroadcasting() { const videoConnection = this.client.channelTree.client.serverConnection.getVideoConnection(); - return videoConnection.isBroadcasting("camera") || videoConnection.isBroadcasting("screen"); + const isBroadcasting = (state: LocalVideoBroadcastState) => state.state === "initializing" || state.state === "broadcasting"; + + for(const broadcastType of kLocalBroadcastChannels) { + const broadcast = videoConnection.getLocalBroadcast(broadcastType); + if(isBroadcasting(broadcast.getState())) { + return true; + } + } + + /* the super should return false as well but just in case something went wrong we want to give the user the visual feedback */ + return super.isBroadcasting(); } - protected isVideoActive(): boolean { + protected hasVideoSupport(): boolean { return true; } - /* protected getBroadcastState(target: VideoBroadcastType): VideoBroadcastState { const videoConnection = this.client.channelTree.client.serverConnection.getVideoConnection(); - return videoConnection.getBroadcastingState(target); + const broadcast = videoConnection.getLocalBroadcast(target); + + const receivingState = super.getBroadcastState(target); + switch (broadcast.getState().state) { + case "stopped": + case "failed": + if(receivingState !== VideoBroadcastState.Stopped) { + /* this should never happen but just in case give the client a visual feedback */ + return receivingState; + } + return VideoBroadcastState.Stopped; + + case "initializing": + return VideoBroadcastState.Initializing; + + case "broadcasting": + const state = super.getBroadcastState(target); + if(state === VideoBroadcastState.Stopped) { + /* we should receive a stream in a few seconds */ + return VideoBroadcastState.Initializing; + } else { + return state; + } + } } + /* protected getBroadcastStream(target: VideoBroadcastType) : MediaStream | undefined { const videoConnection = this.client.channelTree.client.serverConnection.getVideoConnection(); return videoConnection.getBroadcastingSource(target)?.getStream(); @@ -391,7 +418,7 @@ class ChannelVideoController { return; } - controller.notifyVideo(); + controller.notifyVideo(true); }); this.events.on("query_video_mute_status", event => { diff --git a/shared/js/ui/frames/video/Definitions.ts b/shared/js/ui/frames/video/Definitions.ts index 7904af21..f6f457c7 100644 --- a/shared/js/ui/frames/video/Definitions.ts +++ b/shared/js/ui/frames/video/Definitions.ts @@ -2,6 +2,7 @@ import {ClientIcon} from "svg-sprites/client-icons"; import {VideoBroadcastType} from "tc-shared/connection/VideoConnection"; export const kLocalVideoId = "__local__video__"; +export const kLocalBroadcastChannels: VideoBroadcastType[] = ["screen", "camera"]; export type ChannelVideoInfo = { clientName: string, clientUniqueId: string, clientId: number, statusIcon: ClientIcon }; export type ChannelVideoStream = "available" | MediaStream | undefined; diff --git a/shared/js/ui/frames/video/Renderer.scss b/shared/js/ui/frames/video/Renderer.scss index e1961156..5621221b 100644 --- a/shared/js/ui/frames/video/Renderer.scss +++ b/shared/js/ui/frames/video/Renderer.scss @@ -419,4 +419,10 @@ $small_height: 10em; opacity: 1; } } +} + +/* Opera popout button fix (we've our own?) */ +html > div { + display: none; + pointer-events: none; } \ No newline at end of file diff --git a/shared/js/ui/modal/video-source/Controller.tsx b/shared/js/ui/modal/video-source/Controller.tsx index 8dea6720..0e4b6a05 100644 --- a/shared/js/ui/modal/video-source/Controller.tsx +++ b/shared/js/ui/modal/video-source/Controller.tsx @@ -16,6 +16,7 @@ type SourceConstraints = { width?: number, height?: number, frameRate?: number } export async function spawnVideoSourceSelectModal(type: VideoBroadcastType, selectMode: "quick" | "default" | "none", defaultDeviceId?: string) : Promise { const controller = new VideoSourceController(type); + let defaultSelectSource = selectMode === "default"; if(selectMode === "quick") { /* We need the modal itself for the native client in order to present the window selector */ if(type === "camera" || __build.target === "web") { @@ -26,6 +27,8 @@ export async function spawnVideoSourceSelectModal(type: VideoBroadcastType, sele return source; } + } else { + defaultSelectSource = true; } } @@ -33,7 +36,7 @@ export async function spawnVideoSourceSelectModal(type: VideoBroadcastType, sele controller.events.on(["action_start", "action_cancel"], () => modal.destroy()); modal.show().then(() => { - if(selectMode === "default" || selectMode === "quick") { + if(defaultSelectSource) { if(type === "screen" && getVideoDriver().screenQueryAvailable()) { controller.events.fire_react("action_toggle_screen_capture_device_select", { shown: true }); } else { @@ -46,8 +49,8 @@ export async function spawnVideoSourceSelectModal(type: VideoBroadcastType, sele controller.events.on("action_start", () => refSource.source = controller.getCurrentSource()?.ref()); await new Promise(resolve => { - if(type === "screen" && selectMode === "quick") { - controller.events.on("notify_video_preview", event => { + if(defaultSelectSource && selectMode === "quick") { + controller.events.one("notify_video_preview", event => { if(event.status.status === "preview") { /* we've successfully selected something */ modal.destroy(); diff --git a/shared/js/ui/modal/video-source/Renderer.scss b/shared/js/ui/modal/video-source/Renderer.scss index 9a7eb04d..c323934a 100644 --- a/shared/js/ui/modal/video-source/Renderer.scss +++ b/shared/js/ui/modal/video-source/Renderer.scss @@ -42,7 +42,7 @@ margin-left: 1em; width: 20em; - .body .title { + .sectionBody .title { display: flex; flex-direction: row; justify-content: space-between; @@ -245,7 +245,7 @@ .bpsInfo { margin-top: auto; - .body { + .sectionBody { font-size: .8em; } } diff --git a/web/app/connection/ServerConnection.ts b/web/app/connection/ServerConnection.ts index 26440406..6e87d03f 100644 --- a/web/app/connection/ServerConnection.ts +++ b/web/app/connection/ServerConnection.ts @@ -27,6 +27,7 @@ import {ServerFeature} from "tc-shared/connection/ServerFeatures"; import {RTCConnection} from "tc-shared/connection/rtc/Connection"; import {RtpVideoConnection} from "tc-shared/connection/rtc/video/Connection"; import { tr } from "tc-shared/i18n/localize"; +import {createErrorModal} from "tc-shared/ui/elements/Modal"; class ReturnListener { resolve: (value?: T | PromiseLike) => void; From 13803b0195cc6c111fcdc88d8e1ee0ac4abdc0d1 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 12 Dec 2020 14:07:51 +0100 Subject: [PATCH 15/37] Improved channel update performance --- shared/js/tree/Channel.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/shared/js/tree/Channel.ts b/shared/js/tree/Channel.ts index 19371a97..f6a4e78d 100644 --- a/shared/js/tree/Channel.ts +++ b/shared/js/tree/Channel.ts @@ -572,10 +572,13 @@ export class ChannelEntry extends ChannelTreeEntry { /* TODO: Validate values. Example: channel_conversation_mode */ - for(let variable of variables) { + for(const variable of variables) { let key = variable.key; let value = variable.value; - JSON.map_field_to(this.properties, value, variable.key); + if(!JSON.map_field_to(this.properties, value, variable.key)) { + /* no update */ + continue; + } if(key == "channel_name") { this.parsed_channel_name = new ParsedChannelName(value, this.hasParent()); From f37476dd0bf0b00868c14e8137be1af1154a378c Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 12 Dec 2020 14:18:50 +0100 Subject: [PATCH 16/37] Some minor changes, bugfixes and improvements --- shared/js/connection/CommandHandler.ts | 2 +- shared/js/connection/rtc/Connection.ts | 33 +++++++++++++++++------ shared/js/log.ts | 37 +++++++++++++++----------- shared/js/proto.ts | 26 +++++++++--------- shared/js/tree/Channel.ts | 6 ++--- shared/js/tree/ChannelTree.tsx | 12 ++++++--- 6 files changed, 74 insertions(+), 42 deletions(-) diff --git a/shared/js/connection/CommandHandler.ts b/shared/js/connection/CommandHandler.ts index 06c8ec7e..85613bdd 100644 --- a/shared/js/connection/CommandHandler.ts +++ b/shared/js/connection/CommandHandler.ts @@ -746,7 +746,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { return 0; } - tree.moveChannel(channel, prev, parent, true); + tree.moveChannel(channel, prev, parent, false); } handleNotifyChannelEdited(json) { diff --git a/shared/js/connection/rtc/Connection.ts b/shared/js/connection/rtc/Connection.ts index be808098..7314067a 100644 --- a/shared/js/connection/rtc/Connection.ts +++ b/shared/js/connection/rtc/Connection.ts @@ -1,7 +1,7 @@ import {AbstractServerConnection, ServerCommand, ServerConnectionEvents} from "tc-shared/connection/ConnectionBase"; import {ConnectionState} from "tc-shared/ConnectionHandler"; import * as log from "tc-shared/log"; -import {LogCategory, logDebug, logError, logTrace, logWarn} from "tc-shared/log"; +import {group, LogCategory, logDebug, logError, logGroupNative, logTrace, LogType, logWarn} from "tc-shared/log"; import {AbstractCommandHandler} from "tc-shared/connection/AbstractCommandHandler"; import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration"; import {tr, tra} from "tc-shared/i18n/localize"; @@ -11,7 +11,6 @@ import {SdpCompressor, SdpProcessor} from "./SdpUtils"; import {ErrorCode} from "tc-shared/connection/ErrorCode"; import {WhisperTarget} from "tc-shared/voice/VoiceWhisper"; import {globalAudioContext} from "tc-backend/audio/player"; -import * as sdpTransform from "sdp-transform"; const kSdpCompressionMode = 1; @@ -185,7 +184,10 @@ class CommandHandler extends AbstractCommandHandler { return; } if(RTCConnection.kEnableSdpTrace) { - logTrace(LogCategory.WEBRTC, tr("Received remote %s:\n%s"), data.mode, data.sdp); + const gr = logGroupNative(LogType.TRACE, LogCategory.WEBRTC, tra("Original remote SDP ({})", data.mode as string)); + gr.collapsed(true); + gr.log("%s", data.sdp); + gr.end(); } try { sdp = this.sdpProcessor.processIncomingSdp(sdp, data.mode); @@ -195,7 +197,10 @@ class CommandHandler extends AbstractCommandHandler { return; } if(RTCConnection.kEnableSdpTrace) { - logTrace(LogCategory.WEBRTC, tr("Patched remote %s:\n%s"), data.mode, sdp); + const gr = logGroupNative(LogType.TRACE, LogCategory.WEBRTC, tra("Patched remote SDP ({})", data.mode as string)); + gr.collapsed(true); + gr.log("%s", sdp); + gr.end(); } if(data.mode === "answer") { this.handle["peer"].setRemoteDescription({ @@ -217,7 +222,10 @@ class CommandHandler extends AbstractCommandHandler { }).then(() => this.handle["peer"].createAnswer()) .then(async answer => { if(RTCConnection.kEnableSdpTrace) { - logTrace(LogCategory.WEBRTC, tr("Generated local answer due to remote %s:\n%s"), data.mode, answer.sdp); + const gr = logGroupNative(LogType.TRACE, LogCategory.WEBRTC, tra("Original local SDP ({})", data.mode as string)); + gr.collapsed(true); + gr.log("%s", answer.sdp); + gr.end(); } answer.sdp = this.sdpProcessor.processOutgoingSdp(answer.sdp, "answer"); @@ -227,7 +235,10 @@ class CommandHandler extends AbstractCommandHandler { .then(answer => { answer.sdp = SdpCompressor.compressSdp(answer.sdp, kSdpCompressionMode); if(RTCConnection.kEnableSdpTrace) { - logTrace(LogCategory.WEBRTC, tr("Patched answer to remote %s:\n%s"), data.mode, answer.sdp); + const gr = logGroupNative(LogType.TRACE, LogCategory.WEBRTC, tra("Patched local SDP ({})", data.mode as string)); + gr.collapsed(true); + gr.log("%s", answer.sdp); + gr.end(); } return this.connection.send_command("rtcsessiondescribe", { @@ -898,11 +909,17 @@ export class RTCConnection { if(this.peer !== peer) { return; } if(RTCConnection.kEnableSdpTrace) { - logTrace(LogCategory.WEBRTC, tr("Generated initial local offer:\n%s"), offer.sdp); + const gr = logGroupNative(LogType.TRACE, LogCategory.WEBRTC, tra("Original initial local SDP (offer)")); + gr.collapsed(true); + gr.log("%s", offer.sdp); + gr.end(); } try { offer.sdp = this.sdpProcessor.processOutgoingSdp(offer.sdp, "offer"); - logTrace(LogCategory.WEBRTC, tr("Patched initial local offer:\n%s"), offer.sdp); + const gr = logGroupNative(LogType.TRACE, LogCategory.WEBRTC, tra("Patched initial local SDP (offer)")); + gr.collapsed(true); + gr.log("%s", offer.sdp); + gr.end(); } catch (error) { logError(LogCategory.WEBRTC, tr("Failed to preprocess outgoing initial offer: %o"), error); this.handleFatalError(tr("Failed to preprocess outgoing initial offer"), true); diff --git a/shared/js/log.ts b/shared/js/log.ts index c3fc19af..92e45e1a 100644 --- a/shared/js/log.ts +++ b/shared/js/log.ts @@ -33,7 +33,7 @@ export enum LogType { ERROR } -let category_mapping = new Map([ +let categoryMapping = new Map([ [LogCategory.CHANNEL, "Channel "], [LogCategory.CHANNEL_PROPERTIES, "Channel "], [LogCategory.CLIENT, "Client "], @@ -92,7 +92,7 @@ enum GroupMode { NATIVE, PREFIX } -const group_mode: GroupMode = GroupMode.PREFIX; +const defaultGroupMode: GroupMode = GroupMode.PREFIX; //Category Example: ?log.i18n.enabled=0 //Level Example A: ?log.level.trace.enabled=0 @@ -134,7 +134,7 @@ function logDirect(type: LogType, message: string, ...optionalParams: any[]) { export function log(type: LogType, category: LogCategory, message: string, ...optionalParams: any[]) { if(!enabled_mapping.get(category)) return; - optionalParams.unshift(category_mapping.get(category)); + optionalParams.unshift(categoryMapping.get(category)); message = "[%s] " + message; logDirect(type, message, ...optionalParams); } @@ -182,13 +182,20 @@ export function logError(category: LogCategory, message: string, ...optionalPara export function group(level: LogType, category: LogCategory, name: string, ...optionalParams: any[]) : Group { name = "[%s] " + name; - optionalParams.unshift(category_mapping.get(category)); + optionalParams.unshift(categoryMapping.get(category)); - return new Group(group_mode, level, category, name, optionalParams); + return new Group(defaultGroupMode, level, category, name, optionalParams); +} + +export function logGroupNative(level: LogType, category: LogCategory, name: string, ...optionalParams: any[]) : Group { + name = "[%s] " + name; + optionalParams.unshift(categoryMapping.get(category)); + + return new Group(GroupMode.NATIVE, level, category, name, optionalParams); } export function table(level: LogType, category: LogCategory, title: string, args: any) { - if(group_mode == GroupMode.NATIVE) { + if(defaultGroupMode == GroupMode.NATIVE) { console.groupCollapsed(title); console.table(args); console.groupEnd(); @@ -209,9 +216,9 @@ export class Group { private readonly name: string; private readonly optionalParams: any[][]; - private _collapsed: boolean = false; + private isCollapsed: boolean = false; private initialized = false; - private _log_prefix: string; + private logPrefix: string; constructor(mode: GroupMode, level: LogType, category: LogCategory, name: string, optionalParams: any[][], owner: Group = undefined) { this.level = level; @@ -227,7 +234,7 @@ export class Group { } collapsed(flag: boolean = true) : this { - this._collapsed = flag; + this.isCollapsed = flag; return this; } @@ -238,17 +245,17 @@ export class Group { if(!this.initialized) { if(this.mode == GroupMode.NATIVE) { - if(this._collapsed && console.groupCollapsed) { + if(this.isCollapsed && console.groupCollapsed) { console.groupCollapsed(this.name, ...this.optionalParams); } else { console.group(this.name, ...this.optionalParams); } } else { - this._log_prefix = " "; + this.logPrefix = " "; let parent = this.owner; while(parent) { if(parent.mode == GroupMode.PREFIX) { - this._log_prefix = this._log_prefix + parent._log_prefix; + this.logPrefix = this.logPrefix + parent.logPrefix; } else { break; } @@ -259,7 +266,7 @@ export class Group { if(this.mode == GroupMode.NATIVE) { logDirect(this.level, message, ...optionalParams); } else { - logDirect(this.level, "[%s] " + this._log_prefix + message, category_mapping.get(this.category), ...optionalParams); + logDirect(this.level, "[%s] " + this.logPrefix + message, categoryMapping.get(this.category), ...optionalParams); } return this; } @@ -272,11 +279,11 @@ export class Group { } get prefix() : string { - return this._log_prefix; + return this.logPrefix; } set prefix(prefix: string) { - this._log_prefix = prefix; + this.logPrefix = prefix; } } diff --git a/shared/js/proto.ts b/shared/js/proto.ts index 94889bd3..2973aaa4 100644 --- a/shared/js/proto.ts +++ b/shared/js/proto.ts @@ -131,22 +131,24 @@ if(!JSON.map_to) { if(!JSON.map_field_to) { JSON.map_field_to = function(object: T, value: any, field: string) : boolean { - let field_type = typeof(object[field]); - let new_object; - if(field_type == "string" || field_type == "object" || field_type == "undefined") - new_object = value; - else if(field_type == "number") - new_object = parseFloat(value); - else if(field_type == "boolean") - new_object = value == "1" || value == "true"; - else { - console.warn(tr("Invalid object type %s for entry %s"), field_type, field); + let fieldType = typeof object[field]; + let newValue; + if(fieldType == "string" || fieldType == "object" || fieldType == "undefined") { + newValue = value; + } else if(fieldType == "number") { + newValue = parseFloat(value); + } else if(fieldType == "boolean") { + newValue = typeof value === "boolean" && value || value === "1" || value === "true"; + } else { + console.warn(tr("Invalid object type %s for entry %s"), fieldType, field); return false; } - if(new_object === object[field as string]) return false; + if(newValue === object[field]) { + return false; + } - object[field as string] = new_object; + object[field] = newValue; return true; } } diff --git a/shared/js/tree/Channel.ts b/shared/js/tree/Channel.ts index f6a4e78d..2885d2b2 100644 --- a/shared/js/tree/Channel.ts +++ b/shared/js/tree/Channel.ts @@ -194,8 +194,7 @@ export class ChannelEntry extends ChannelTreeEntry { this.properties = new ChannelProperties(); this.channelId = channelId; this.properties.channel_name = channelName; - - this.parsed_channel_name = new ParsedChannelName("undefined", false); + this.parsed_channel_name = new ParsedChannelName(channelName, false); this.clientPropertyChangedListener = (event: ClientEvents["notify_properties_updated"]) => { if("client_nickname" in event.updated_properties || "client_talk_power" in event.updated_properties) { @@ -575,6 +574,7 @@ export class ChannelEntry extends ChannelTreeEntry { for(const variable of variables) { let key = variable.key; let value = variable.value; + if(!JSON.map_field_to(this.properties, value, variable.key)) { /* no update */ continue; @@ -584,7 +584,7 @@ export class ChannelEntry extends ChannelTreeEntry { this.parsed_channel_name = new ParsedChannelName(value, this.hasParent()); } else if(key == "channel_order") { let order = this.channelTree.findChannel(this.properties.channel_order); - this.channelTree.moveChannel(this, order, this.parent, true); + this.channelTree.moveChannel(this, order, this.parent, false); } 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") { diff --git a/shared/js/tree/ChannelTree.tsx b/shared/js/tree/ChannelTree.tsx index caa8cb9d..486c3b85 100644 --- a/shared/js/tree/ChannelTree.tsx +++ b/shared/js/tree/ChannelTree.tsx @@ -251,7 +251,7 @@ export class ChannelTree { handleChannelCreated(previous: ChannelEntry, parent: ChannelEntry, channelId: number, channelName: string) : ChannelEntry { const channel = new ChannelEntry(this, channelId, channelName); this.channels.push(channel); - this.moveChannel(channel, previous, parent, false); + this.moveChannel(channel, previous, parent, true); this.events.fire("notify_channel_created", { channel: channel }); return channel; } @@ -300,12 +300,16 @@ export class ChannelTree { channel.parent = undefined; } - moveChannel(channel: ChannelEntry, channelPrevious: ChannelEntry, parent: ChannelEntry, triggerMoveEvent: boolean) { + moveChannel(channel: ChannelEntry, channelPrevious: ChannelEntry, parent: ChannelEntry, isInsertMove: boolean) { if(channelPrevious != null && channelPrevious.parent != parent) { console.error(tr("Invalid channel move (different parents! (%o|%o)"), channelPrevious.parent, parent); return; } + if(!isInsertMove && channel.channel_previous === channelPrevious && channel.parent === parent) { + return; + } + const previousParent = channel.parent_channel(); const previousOrder = channel.channel_previous; @@ -355,13 +359,15 @@ export class ChannelTree { debugger; } - if(triggerMoveEvent) { + if(!isInsertMove) { this.events.fire("notify_channel_moved", { channel: channel, previousOrder: previousOrder, previousParent: previousParent }); } + + channel.properties.channel_order = previousOrder ? previousOrder.channelId : 0; } deleteClient(client: ClientEntry, reason: { reason: ViewReasonId, message?: string, serverLeave: boolean }) { From e442924b2f0bc7759fafff986b95d8a15a95f083 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 12 Dec 2020 22:43:47 +0100 Subject: [PATCH 17/37] Fixed icon renderer bug --- shared/generate_declarations.sh | 2 +- shared/js/connection/rtc/SdpUtils.ts | 11 ----------- shared/js/ui/modal/video-source/Controller.tsx | 13 +++++++++++-- shared/js/ui/react-elements/Icon.tsx | 2 +- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/shared/generate_declarations.sh b/shared/generate_declarations.sh index e3c2e28c..aba560ad 100644 --- a/shared/generate_declarations.sh +++ b/shared/generate_declarations.sh @@ -24,7 +24,7 @@ function generate_declaration() { #Generate the loader definitions first app_declaration="../declarations/shared-app/" -generate_declaration dtsconfig_app.json ${app_declaration} +generate_declaration tsconfig.declarations.json ${app_declaration} cp -r svg-sprites "../declarations/svg-sprites" exit 0 \ No newline at end of file diff --git a/shared/js/connection/rtc/SdpUtils.ts b/shared/js/connection/rtc/SdpUtils.ts index 99a2878e..6a429ebf 100644 --- a/shared/js/connection/rtc/SdpUtils.ts +++ b/shared/js/connection/rtc/SdpUtils.ts @@ -81,22 +81,11 @@ export class SdpProcessor { sdpString = sdpString.replace(/profile-level-id=4325407/g, "profile-level-id=42e01f"); const sdp = sdpTransform.parse(sdpString); - //sdp.media.forEach(media => media.candidates = []); - //sdp.origin.address = "127.0.0.1"; this.rtpRemoteChannelMapping = SdpProcessor.generateRtpSSrcMapping(sdp); return sdpTransform.write(sdp); } - /* - getCandidates(sdpString: string) : string[] { - const sdp = sdpTransform.parse(sdpString); - sdp.media = [sdp.media[0]]; - sdpTransform.write(sdp).split("\r\n") - .filter(line => line.startsWith("a")) - } - */ - processOutgoingSdp(sdpString: string, _mode: "offer" | "answer") : string { const sdp = sdpTransform.parse(sdpString); diff --git a/shared/js/ui/modal/video-source/Controller.tsx b/shared/js/ui/modal/video-source/Controller.tsx index 0e4b6a05..2c42a2bf 100644 --- a/shared/js/ui/modal/video-source/Controller.tsx +++ b/shared/js/ui/modal/video-source/Controller.tsx @@ -46,13 +46,22 @@ export async function spawnVideoSourceSelectModal(type: VideoBroadcastType, sele }); let refSource: { source: VideoSource } = { source: undefined }; - controller.events.on("action_start", () => refSource.source = controller.getCurrentSource()?.ref()); + controller.events.on("action_start", () => { + refSource.source?.deref(); + refSource.source = controller.getCurrentSource()?.ref(); + }); await new Promise(resolve => { if(defaultSelectSource && selectMode === "quick") { - controller.events.one("notify_video_preview", event => { + const callbackRemove = controller.events.on("notify_video_preview", event => { + if(event.status.status === "error") { + callbackRemove(); + } + if(event.status.status === "preview") { /* we've successfully selected something */ + refSource.source = controller.getCurrentSource()?.ref(); + modal.hide(); modal.destroy(); } }); diff --git a/shared/js/ui/react-elements/Icon.tsx b/shared/js/ui/react-elements/Icon.tsx index d41e72e2..28d975c0 100644 --- a/shared/js/ui/react-elements/Icon.tsx +++ b/shared/js/ui/react-elements/Icon.tsx @@ -37,7 +37,7 @@ export const RemoteIconRenderer = (props: { icon: RemoteIcon, className?: string return
; } return ( -
+
{props.title
); From ae57fbea6d92080f04a102afafdf18bd460cd22f Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 12 Dec 2020 22:54:19 +0100 Subject: [PATCH 18/37] Fixed client info --- shared/js/ui/frames/side/ClientInfoController.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/shared/js/ui/frames/side/ClientInfoController.ts b/shared/js/ui/frames/side/ClientInfoController.ts index 969b7418..b94a626b 100644 --- a/shared/js/ui/frames/side/ClientInfoController.ts +++ b/shared/js/ui/frames/side/ClientInfoController.ts @@ -81,6 +81,19 @@ export class ClientInfoController { } })); + this.listenerConnection.push(connection.getSelectedClientInfo().events.on("notify_client_changed", () => { + this.sendClientName(); + this.sendCountry(); + this.sendClientDescription(); + this.sendForum(); + this.sendChannelGroup(); + this.sendServerGroups(); + this.sendOnline(); + this.sendClientStatus(); + this.sendVolume(); + this.sendVersion(); + })); + this.listenerConnection.push(connection.getSelectedClientInfo().events.on("notify_cache_changed", event => { switch (event.category) { case "name": From 8949c7ae8a623633de81912cd475e902022f0b22 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 12 Dec 2020 22:58:52 +0100 Subject: [PATCH 19/37] Updated avatar as well wehn switching the client info --- shared/js/ui/frames/side/ClientInfoController.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/shared/js/ui/frames/side/ClientInfoController.ts b/shared/js/ui/frames/side/ClientInfoController.ts index b94a626b..b7049f56 100644 --- a/shared/js/ui/frames/side/ClientInfoController.ts +++ b/shared/js/ui/frames/side/ClientInfoController.ts @@ -82,6 +82,7 @@ export class ClientInfoController { })); this.listenerConnection.push(connection.getSelectedClientInfo().events.on("notify_client_changed", () => { + this.sendClient(); this.sendClientName(); this.sendCountry(); this.sendClientDescription(); From 7a02420c724cc7d8b67a99645071bc1336be243d Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 12 Dec 2020 23:13:06 +0100 Subject: [PATCH 20/37] Fixed private conversation mode --- shared/js/conversations/ChannelConversationManager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/shared/js/conversations/ChannelConversationManager.ts b/shared/js/conversations/ChannelConversationManager.ts index 9e94f623..d7cec4ce 100644 --- a/shared/js/conversations/ChannelConversationManager.ts +++ b/shared/js/conversations/ChannelConversationManager.ts @@ -297,6 +297,7 @@ export class ChannelConversation extends AbstractChat public setConversationMode(mode: ChannelConversationMode, logChange: boolean) { super.setConversationMode(mode, logChange); + this.updateAccessState(); } public localClientSwitchedChannel(type: "join" | "leave") { From 27888661d9aaa32542bbd0edc3eea29b266050fa Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sun, 13 Dec 2020 14:18:58 +0100 Subject: [PATCH 21/37] Directly connection when hitting enter on the address line --- shared/js/ui/modal/ModalConnect.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/shared/js/ui/modal/ModalConnect.ts b/shared/js/ui/modal/ModalConnect.ts index bce7cf18..046aea1a 100644 --- a/shared/js/ui/modal/ModalConnect.ts +++ b/shared/js/ui/modal/ModalConnect.ts @@ -193,12 +193,13 @@ export function spawnConnectModal(options: { }; input_address.val(defaultHost.enforce ? defaultHost.url : settings.static_global(Settings.KEY_CONNECT_ADDRESS, defaultHost.url)); - input_address - .on("keyup", () => updateFields(true)) - .on('keydown', event => { - if (event.keyCode == KeyCode.KEY_ENTER && !event.shiftKey) - button_connect.trigger('click'); - }); + input_address.on("keyup", () => updateFields(true)); + input_address.on("keypress", event => { + if (event.key === "Enter" && !event.shiftKey) { + button_connect.trigger('click'); + } + }); + button_manage.on('click', event => { const modal = spawnSettingsModal("identity-profiles"); modal.close_listener.push(() => { From 2b5bc274711917cb04a05ded91bf45ff11e82ca6 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sun, 13 Dec 2020 14:29:01 +0100 Subject: [PATCH 22/37] Some minor fixes/improvements --- ChangeLog.md | 3 +++ .../conversations/ChannelConversationManager.ts | 2 +- shared/js/ui/frames/side/ClientInfoController.ts | 15 +-------------- 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index 9435c704..dce6bd4c 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,4 +1,7 @@ # Changelog: +* **13.12.20** + - Directly connection when hitting enter on the address line + * **12.12.20** - Improved screen sharing and camara selection - Showing the echoed own stream from the server instead of the local one diff --git a/shared/js/conversations/ChannelConversationManager.ts b/shared/js/conversations/ChannelConversationManager.ts index d7cec4ce..ac364a63 100644 --- a/shared/js/conversations/ChannelConversationManager.ts +++ b/shared/js/conversations/ChannelConversationManager.ts @@ -176,7 +176,7 @@ export class ChannelConversation extends AbstractChat case "unsupported": this.crossChannelChatSupported = false; - this.setConversationMode(ChannelConversationMode.Private, true); + this.setConversationMode(ChannelConversationMode.Private, false); this.setCurrentMode("normal"); break; } diff --git a/shared/js/ui/frames/side/ClientInfoController.ts b/shared/js/ui/frames/side/ClientInfoController.ts index b7049f56..ab7c3ea4 100644 --- a/shared/js/ui/frames/side/ClientInfoController.ts +++ b/shared/js/ui/frames/side/ClientInfoController.ts @@ -81,20 +81,7 @@ export class ClientInfoController { } })); - this.listenerConnection.push(connection.getSelectedClientInfo().events.on("notify_client_changed", () => { - this.sendClient(); - this.sendClientName(); - this.sendCountry(); - this.sendClientDescription(); - this.sendForum(); - this.sendChannelGroup(); - this.sendServerGroups(); - this.sendOnline(); - this.sendClientStatus(); - this.sendVolume(); - this.sendVersion(); - })); - + this.listenerConnection.push(connection.getSelectedClientInfo().events.on("notify_client_changed", () => this.sendClient())); this.listenerConnection.push(connection.getSelectedClientInfo().events.on("notify_cache_changed", event => { switch (event.category) { case "name": From d18701b984fa15d59dce955083fa07c80ecbf610 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Wed, 16 Dec 2020 22:06:46 +0100 Subject: [PATCH 23/37] Fixed some minor bugs and made broadcast settings reeditable --- shared/js/connection/VideoConnection.ts | 43 ++- shared/js/connection/rtc/Connection.ts | 42 +-- shared/js/connection/rtc/video/Connection.ts | 137 +++++++--- shared/js/connection/rtc/video/VideoClient.ts | 2 +- .../js/events/ClientGlobalControlHandler.ts | 41 ++- shared/js/media/Video.ts | 37 ++- shared/js/settings.ts | 14 + shared/js/ui/frames/control-bar/Controller.ts | 10 + shared/js/ui/frames/control-bar/Renderer.tsx | 2 +- .../js/ui/modal/video-source/Controller.tsx | 256 +++++++++++++----- .../js/ui/modal/video-source/Definitions.ts | 1 + shared/js/ui/modal/video-source/Renderer.tsx | 18 +- shared/js/video/VideoSource.ts | 19 ++ 13 files changed, 489 insertions(+), 133 deletions(-) diff --git a/shared/js/connection/VideoConnection.ts b/shared/js/connection/VideoConnection.ts index 10a3dfa3..bcda07e1 100644 --- a/shared/js/connection/VideoConnection.ts +++ b/shared/js/connection/VideoConnection.ts @@ -1,6 +1,5 @@ import {VideoSource} from "tc-shared/video/VideoSource"; import {Registry} from "tc-shared/events"; -import {ConnectionStatus} from "tc-shared/ui/frames/footer/StatusDefinitions"; import {ConnectionStatistics} from "tc-shared/connection/ConnectionBase"; export type VideoBroadcastType = "camera" | "screen"; @@ -78,6 +77,39 @@ export type LocalVideoBroadcastState = { state: "broadcasting" } +export interface BroadcastConstraints { + /** + * Ideal and max video width + */ + width: number, + + /** + * Ideal and max video height + */ + height: number, + + /** + * Dynamically change the video quality related to bandwidth constraints. + */ + dynamicQuality: boolean, + + /** + * Max bandwidth which should be used (in bits/second) + */ + maxBandwidth: number, + + /** + * Maximal frame rate for the video. + * This might be ignored by some browsers. + */ + maxFrameRate: number, + + /** + * The maximal + */ + dynamicFrameRate: boolean +} + export interface LocalVideoBroadcast { getEvents() : Registry; @@ -90,13 +122,18 @@ export interface LocalVideoBroadcast { /** * @param source The source of the broadcast (No ownership will be taken. The voice connection must ref the source by itself!) + * @param constraints */ - startBroadcasting(source: VideoSource) : Promise; + startBroadcasting(source: VideoSource, constraints: BroadcastConstraints) : Promise; /** * @param source The source of the broadcast (No ownership will be taken. The voice connection must ref the source by itself!) + * @param constraints */ - changeSource(source: VideoSource) : Promise; + changeSource(source: VideoSource, constraints: BroadcastConstraints) : Promise; + + getConstraints() : BroadcastConstraints | undefined; + applyConstraints(constraints: BroadcastConstraints) : Promise; stopBroadcasting(); } diff --git a/shared/js/connection/rtc/Connection.ts b/shared/js/connection/rtc/Connection.ts index 7314067a..92c0cc09 100644 --- a/shared/js/connection/rtc/Connection.ts +++ b/shared/js/connection/rtc/Connection.ts @@ -222,7 +222,7 @@ class CommandHandler extends AbstractCommandHandler { }).then(() => this.handle["peer"].createAnswer()) .then(async answer => { if(RTCConnection.kEnableSdpTrace) { - const gr = logGroupNative(LogType.TRACE, LogCategory.WEBRTC, tra("Original local SDP ({})", data.mode as string)); + const gr = logGroupNative(LogType.TRACE, LogCategory.WEBRTC, tra("Original local SDP ({})", answer.type as string)); gr.collapsed(true); gr.log("%s", answer.sdp); gr.end(); @@ -235,7 +235,7 @@ class CommandHandler extends AbstractCommandHandler { .then(answer => { answer.sdp = SdpCompressor.compressSdp(answer.sdp, kSdpCompressionMode); if(RTCConnection.kEnableSdpTrace) { - const gr = logGroupNative(LogType.TRACE, LogCategory.WEBRTC, tra("Patched local SDP ({})", data.mode as string)); + const gr = logGroupNative(LogType.TRACE, LogCategory.WEBRTC, tra("Patched local SDP ({})", answer.type as string)); gr.collapsed(true); gr.log("%s", answer.sdp); gr.end(); @@ -810,7 +810,8 @@ export class RTCConnection { iceServers: [{ urls: ["stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"] }] }); - const kAddGenericTransceiver = false; + /* If set to false FF failed: FIXME! */ + const kAddGenericTransceiver = true; if(this.audioSupport) { this.currentTransceiver["audio"] = this.peer.addTransceiver("audio"); @@ -880,19 +881,23 @@ export class RTCConnection { } await this.currentTransceiver[type].sender.replaceTrack(target); - if(target) { - console.error("Setting sendrecv from %o", this.currentTransceiver[type].direction, this.currentTransceiver[type].currentDirection); - this.currentTransceiver[type].direction = "sendrecv"; - } else if(type === "video" || type === "video-screen") { - /* - * We don't need to stop & start the audio transceivers every time we're toggling the stream state. - * This would be a much overall cost than just keeping it going. - * - * The video streams instead are not toggling that much and since they split up the bandwidth between them, - * we've to shut them down if they're no needed. This not only allows the one stream to take full advantage - * of the bandwidth it also reduces resource usage. - */ - //this.currentTransceiver[type].direction = "recvonly"; + + /* Firefox has some crazy issues */ + if(window.detectedBrowser.name !== "firefox") { + if(target) { + console.error("Setting sendrecv from %o", this.currentTransceiver[type].direction, this.currentTransceiver[type].currentDirection); + this.currentTransceiver[type].direction = "sendrecv"; + } else if(type === "video" || type === "video-screen") { + /* + * We don't need to stop & start the audio transceivers every time we're toggling the stream state. + * This would be a much overall cost than just keeping it going. + * + * The video streams instead are not toggling that much and since they split up the bandwidth between them, + * we've to shut them down if they're no needed. This not only allows the one stream to take full advantage + * of the bandwidth it also reduces resource usage. + */ + //this.currentTransceiver[type].direction = "recvonly"; + } } logTrace(LogCategory.WEBRTC, "Replaced track for %o (Fallback: %o)", type, target === fallback); } @@ -1108,8 +1113,9 @@ export class RTCConnection { logWarn(LogCategory.WEBRTC, tr("Received remote audio track %d but audio has been disabled. Dropping track."), ssrc); return; } + const track = new InternalRemoteRTPAudioTrack(ssrc, event.transceiver); - logDebug(LogCategory.WEBRTC, tr("Received remote audio track on ssrc %d"), ssrc); + logDebug(LogCategory.WEBRTC, tr("Received remote audio track on ssrc %o"), ssrc); if(tempInfo?.info !== undefined) { track.handleAssignment(tempInfo.info); this.events.fire("notify_audio_assignment_changed", { @@ -1123,7 +1129,7 @@ export class RTCConnection { this.remoteAudioTracks[ssrc] = track; } else if(event.track.kind === "video") { const track = new InternalRemoteRTPVideoTrack(ssrc, event.transceiver); - logDebug(LogCategory.WEBRTC, tr("Received remote video track on ssrc %d"), ssrc); + logDebug(LogCategory.WEBRTC, tr("Received remote video track on ssrc %o"), ssrc); if(tempInfo?.info !== undefined) { track.handleAssignment(tempInfo.info); this.events.fire("notify_video_assignment_changed", { diff --git a/shared/js/connection/rtc/video/Connection.ts b/shared/js/connection/rtc/video/Connection.ts index 9e1315a0..7ee05c5c 100644 --- a/shared/js/connection/rtc/video/Connection.ts +++ b/shared/js/connection/rtc/video/Connection.ts @@ -1,4 +1,5 @@ import { + BroadcastConstraints, LocalVideoBroadcast, LocalVideoBroadcastEvents, LocalVideoBroadcastState, @@ -27,6 +28,7 @@ class LocalRtpVideoBroadcast implements LocalVideoBroadcast { private state: LocalVideoBroadcastState; private currentSource: VideoSource; + private currentConstrints: BroadcastConstraints; private broadcastStartId: number; private localStartPromise: Promise; @@ -70,55 +72,66 @@ class LocalRtpVideoBroadcast implements LocalVideoBroadcast { return Promise.resolve(undefined); } - async changeSource(source: VideoSource): Promise { - const videoTracks = source.getStream().getVideoTracks(); - if(videoTracks.length === 0) { - throw tr("missing video stream track"); - } - + async changeSource(source: VideoSource, constraints: BroadcastConstraints): Promise { let sourceRef = source.ref(); - while(this.localStartPromise) { - await this.localStartPromise; - } - - if(this.state.state !== "broadcasting") { - sourceRef.deref(); - throw tr("not broadcasting anything"); - } - - const startId = ++this.broadcastStartId; - let rtcBroadcastType: RTCBroadcastableTrackType = this.type === "camera" ? "video" : "video-screen"; try { - await this.handle.getRTCConnection().setTrackSource(rtcBroadcastType, videoTracks[0]); - } catch (error) { - if(this.broadcastStartId !== startId) { - /* broadcast start has been canceled */ - return; + if(this.currentSource !== source) { + console.error("Source changed"); + const videoTracks = source.getStream().getVideoTracks(); + if(videoTracks.length === 0) { + throw tr("missing video stream track"); + } + + while(this.localStartPromise) { + await this.localStartPromise; + } + + if(this.state.state !== "broadcasting") { + throw tr("not broadcasting anything"); + } + + /* Apply the constraints to the current source */ + await this.doApplyConstraints(constraints, source); + + const startId = ++this.broadcastStartId; + let rtcBroadcastType: RTCBroadcastableTrackType = this.type === "camera" ? "video" : "video-screen"; + try { + await this.handle.getRTCConnection().setTrackSource(rtcBroadcastType, videoTracks[0]); + } catch (error) { + if(this.broadcastStartId !== startId) { + /* broadcast start has been canceled */ + return; + } + + logError(LogCategory.WEBRTC, tr("Failed to change video track for broadcast %s: %o"), this.type, error); + throw tr("failed to change video track"); + } + + this.setCurrentSource(sourceRef); + } else if(!_.isEqual(this.currentConstrints, constraints)) { + console.error("Constraints changed"); + await this.applyConstraints(constraints); } - + } finally { sourceRef.deref(); - logError(LogCategory.WEBRTC, tr("Failed to change video track for broadcast %s: %o"), this.type, error); - throw tr("failed to change video track"); } - - this.setCurrentSource(sourceRef); - sourceRef.deref(); } private setCurrentSource(source: VideoSource | undefined) { if(this.currentSource) { this.currentSource.deref(); + this.currentConstrints = undefined; } this.currentSource = source?.ref(); } - async startBroadcasting(source: VideoSource): Promise { + async startBroadcasting(source: VideoSource, constraints: BroadcastConstraints): Promise { const sourceRef = source.ref(); while(this.localStartPromise) { await this.localStartPromise; } - const promise = this.doStartBroadcast(source); + const promise = this.doStartBroadcast(source, constraints); this.localStartPromise = promise.catch(() => {}); this.localStartPromise.then(() => this.localStartPromise = undefined); try { @@ -128,7 +141,7 @@ class LocalRtpVideoBroadcast implements LocalVideoBroadcast { } } - private async doStartBroadcast(source: VideoSource) { + private async doStartBroadcast(source: VideoSource, constraints: BroadcastConstraints) { const videoTracks = source.getStream().getVideoTracks(); if(videoTracks.length === 0) { throw tr("missing video stream track"); @@ -143,6 +156,23 @@ class LocalRtpVideoBroadcast implements LocalVideoBroadcast { return; } + try { + await this.applyConstraints(constraints); + } catch (error) { + if(this.broadcastStartId !== startId) { + /* broadcast start has been canceled */ + return; + } + + logError(LogCategory.WEBRTC, tr("Failed to apply video constraints for broadcast %s: %o"), this.type, error); + this.stopBroadcasting(true, { state: "failed", reason: tr("Failed to apply video constraints") }); + throw tr("Failed to apply video constraints"); + } + if(this.broadcastStartId !== startId) { + /* broadcast start has been canceled */ + return; + } + let rtcBroadcastType: RTCBroadcastableTrackType = this.type === "camera" ? "video" : "video-screen"; try { @@ -183,6 +213,47 @@ class LocalRtpVideoBroadcast implements LocalVideoBroadcast { this.setState({ state: "broadcasting" }); } + async applyConstraints(constraints: BroadcastConstraints): Promise { + await this.doApplyConstraints(constraints, this.currentSource); + } + + private async doApplyConstraints(constraints: BroadcastConstraints, source: VideoSource): Promise { + const capabilities = source.getCapabilities(); + const videoConstraints: MediaTrackConstraints = {}; + + if(constraints.dynamicQuality && capabilities) { + videoConstraints.width = { + min: capabilities.minWidth, + max: constraints.width, + ideal: constraints.width + }; + + videoConstraints.height = { + min: capabilities.minHeight, + max: constraints.height, + ideal: constraints.height + }; + } else { + videoConstraints.width = constraints.width; + videoConstraints.height = constraints.height; + } + + if(constraints.dynamicFrameRate && capabilities) { + videoConstraints.frameRate = { + min: capabilities.minFrameRate, + max: constraints.maxFrameRate, + ideal: constraints.maxFrameRate + }; + } else { + videoConstraints.frameRate = constraints.maxFrameRate; + } + + await source.getStream().getVideoTracks()[0]?.applyConstraints(constraints); + this.currentConstrints = constraints; + + /* TODO: Bandwidth update? */ + } + stopBroadcasting(skipRtcStop?: boolean, stopState?: LocalVideoBroadcastState) { if(this.state.state === "stopped" && (!stopState || _.isEqual(stopState, this.state))) { return; @@ -241,6 +312,10 @@ class LocalRtpVideoBroadcast implements LocalVideoBroadcast { } })(); } + + getConstraints(): BroadcastConstraints | undefined { + return this.currentConstrints; + } } export class RtpVideoConnection implements VideoConnection { diff --git a/shared/js/connection/rtc/video/VideoClient.ts b/shared/js/connection/rtc/video/VideoClient.ts index 653beb1c..10483629 100644 --- a/shared/js/connection/rtc/video/VideoClient.ts +++ b/shared/js/connection/rtc/video/VideoClient.ts @@ -78,8 +78,8 @@ export class RtpVideoClient implements VideoClient { throw tr("failed to receive stream"); } }).catch(error => { - this.updateBroadcastState(broadcastType); this.joinedStates[broadcastType] = false; + this.updateBroadcastState(broadcastType); logError(LogCategory.VIDEO, tr("Failed to join video broadcast: %o"), error); throw tr("failed to join broadcast"); }); diff --git a/shared/js/events/ClientGlobalControlHandler.ts b/shared/js/events/ClientGlobalControlHandler.ts index 8368ed96..00ba4ed6 100644 --- a/shared/js/events/ClientGlobalControlHandler.ts +++ b/shared/js/events/ClientGlobalControlHandler.ts @@ -17,8 +17,7 @@ import {spawnModalCssVariableEditor} from "tc-shared/ui/modal/css-editor/Control import {server_connections} from "tc-shared/ConnectionManager"; import {spawnAbout} from "tc-shared/ui/modal/ModalAbout"; import {spawnVideoSourceSelectModal} from "tc-shared/ui/modal/video-source/Controller"; -import {LogCategory, logError} from "tc-shared/log"; -import {getVideoDriver} from "tc-shared/video/VideoSource"; +import {LogCategory, logError, logWarn} from "tc-shared/log"; import {spawnEchoTestModal} from "tc-shared/ui/modal/echo-test/Controller"; /* @@ -193,14 +192,15 @@ export function initialize(event_registry: Registry) return; } - spawnVideoSourceSelectModal(event.broadcastType, event.quickSelect ? "quick" : "default", event.defaultDevice).then(async source => { + spawnVideoSourceSelectModal(event.broadcastType, event.quickSelect ? { mode: "select-quick", defaultDevice: event.defaultDevice } : { mode: "select-default", defaultDevice: event.defaultDevice }) + .then(async ({ source, constraints }) => { if(!source) { return; } try { const broadcast = connection.getServerConnection().getVideoConnection().getLocalBroadcast(event.broadcastType); if(broadcast.getState().state === "initializing" || broadcast.getState().state === "broadcasting") { console.error("Change source"); - broadcast.changeSource(source).catch(error => { + broadcast.changeSource(source, constraints).catch(error => { logError(LogCategory.VIDEO, tr("Failed to change broadcast source: %o"), event.broadcastType, error); if(typeof error !== "string") { error = tr("lookup the console for detail"); @@ -214,7 +214,7 @@ export function initialize(event_registry: Registry) }); } else { console.error("Start broadcast"); - broadcast.startBroadcasting(source).catch(error => { + broadcast.startBroadcasting(source, constraints).catch(error => { logError(LogCategory.VIDEO, tr("Failed to start %s broadcasting: %o"), event.broadcastType, error); if(typeof error !== "string") { error = tr("lookup the console for detail"); @@ -237,4 +237,35 @@ export function initialize(event_registry: Registry) broadcast.stopBroadcasting(); } }); + + event_registry.on("action_edit_video_broadcasting", event => { + const connection = event.connection; + if(!connection.connected) { + createErrorModal(tr("You're not connected"), tr("You're not connected to any server!")).open(); + return; + } + + const broadcast = connection.getServerConnection().getVideoConnection().getLocalBroadcast(event.broadcastType); + if(!broadcast || (broadcast.getState().state !== "broadcasting" && broadcast.getState().state !== "initializing")) { + createErrorModal(tr("You're not broadcasting"), tr("You're not broadcasting any video!")).open(); + return; + } + + spawnVideoSourceSelectModal(event.broadcastType, { mode: "edit", source: broadcast.getSource(), broadcastConstraints: Object.assign({}, broadcast.getConstraints()) }) + .then(async ({ source, constraints }) => { + if (!source) { + return; + } + + if(broadcast.getState().state !== "broadcasting" && broadcast.getState().state !== "initializing") { + createErrorModal(tr("Video broadcast has ended"), tr("The video broadcast has ended.\nUpdate failed.")).open(); + return; + } + + await broadcast.changeSource(source, constraints); + }).catch(error => { + logWarn(LogCategory.VIDEO, tr("Failed to edit video broadcast: %o"), error); + createErrorModal(tr("Broadcast update failed"), tr("We failed to update the current video broadcast settings.\nThe old settings will be used.")).open(); + }); + }); } \ No newline at end of file diff --git a/shared/js/media/Video.ts b/shared/js/media/Video.ts index cebc959f..68a1d98a 100644 --- a/shared/js/media/Video.ts +++ b/shared/js/media/Video.ts @@ -4,13 +4,13 @@ import { VideoDriver, VideoDriverEvents, VideoPermissionStatus, - VideoSource + VideoSource, VideoSourceCapabilities, VideoSourceInitialSettings } from "tc-shared/video/VideoSource"; import {Registry} from "tc-shared/events"; import {MediaStreamRequestResult} from "tc-shared/voice/RecorderBase"; import {LogCategory, logDebug, logError, logWarn} from "tc-shared/log"; import {queryMediaPermissions, requestMediaStream, stopMediaStream} from "tc-shared/media/Stream"; -import { tr } from "tc-shared/i18n/localize"; +import {tr} from "tc-shared/i18n/localize"; declare global { interface MediaDevices { @@ -225,7 +225,9 @@ export class WebVideoDriver implements VideoDriver { try { const source = await navigator.mediaDevices.getDisplayMedia({ audio: false, video: true }); const videoTrack = source.getVideoTracks()[0]; - if(!videoTrack) { throw tr("missing video track"); } + if(!videoTrack) { + throw tr("missing video track"); + } logDebug(LogCategory.VIDEO, tr("Display media received with settings: %o"), videoTrack.getSettings()); return new WebVideoSource(videoTrack.getSettings().deviceId, tr("Screen"), source); @@ -248,10 +250,19 @@ export class WebVideoSource implements VideoSource { private readonly stream: MediaStream; private referenceCount = 1; + private initialSettings: VideoSourceInitialSettings; + constructor(deviceId: string, displayName: string, stream: MediaStream) { this.deviceId = deviceId; this.displayName = displayName; this.stream = stream; + + const settings = stream.getVideoTracks()[0].getSettings(); + this.initialSettings = { + frameRate: settings.frameRate, + height: settings.height, + width: settings.width + }; } destroy() { @@ -270,6 +281,26 @@ export class WebVideoSource implements VideoSource { return this.stream; } + getInitialSettings(): VideoSourceInitialSettings { + return this.initialSettings; + } + + getCapabilities(): VideoSourceCapabilities { + const videoTrack = this.stream.getVideoTracks()[0]; + const capabilities = "getCapabilities" in videoTrack ? videoTrack.getCapabilities() : undefined; + + return { + minWidth: capabilities?.width?.min || 1, + maxWidth: capabilities?.width?.max || this.initialSettings.width, + + minHeight: capabilities?.height?.min || 1, + maxHeight: capabilities?.height?.max || this.initialSettings.height, + + minFrameRate: capabilities?.frameRate?.min || 1, + maxFrameRate: capabilities?.frameRate?.max || this.initialSettings.frameRate + }; + } + deref() { this.referenceCount -= 1; diff --git a/shared/js/settings.ts b/shared/js/settings.ts index 5672e82c..88ee2d44 100644 --- a/shared/js/settings.ts +++ b/shared/js/settings.ts @@ -541,6 +541,20 @@ export class Settings extends StaticSettings { valueType: "number", }; + static readonly KEY_VIDEO_DEFAULT_MAX_WIDTH: ValuedSettingsKey = { + key: 'video_default_max_width', + defaultValue: 1280, + description: "The default maximal width of the video being crated.", + valueType: "number", + }; + + static readonly KEY_VIDEO_DEFAULT_MAX_HEIGHT: ValuedSettingsKey = { + key: 'video_default_max_height', + defaultValue: 720, + description: "The default maximal height of the video being crated.", + valueType: "number", + }; + static readonly FN_LOG_ENABLED: (category: string) => SettingsKey = category => { return { key: "log." + category.toLowerCase() + ".enabled", diff --git a/shared/js/ui/frames/control-bar/Controller.ts b/shared/js/ui/frames/control-bar/Controller.ts index f3f12515..b9b0a20c 100644 --- a/shared/js/ui/frames/control-bar/Controller.ts +++ b/shared/js/ui/frames/control-bar/Controller.ts @@ -418,6 +418,16 @@ export function initializeControlBarController(events: Registry { + if(infoHandler.getCurrentHandler()) { + global_client_actions.fire("action_edit_video_broadcasting", { + connection: infoHandler.getCurrentHandler(), + broadcastType: event.broadcastType + }); + } else { + createErrorModal(tr("Missing connection handler"), tr("Cannot start video broadcasting with a missing connection handler")).open(); + } + }); return infoHandler; } \ No newline at end of file diff --git a/shared/js/ui/frames/control-bar/Renderer.tsx b/shared/js/ui/frames/control-bar/Renderer.tsx index f2efa595..40e75ed4 100644 --- a/shared/js/ui/frames/control-bar/Renderer.tsx +++ b/shared/js/ui/frames/control-bar/Renderer.tsx @@ -294,7 +294,7 @@ const VideoButton = (props: { type: VideoBroadcastType }) => { diff --git a/shared/js/ui/modal/video-source/Controller.tsx b/shared/js/ui/modal/video-source/Controller.tsx index 2c42a2bf..73cc920a 100644 --- a/shared/js/ui/modal/video-source/Controller.tsx +++ b/shared/js/ui/modal/video-source/Controller.tsx @@ -3,56 +3,74 @@ import {spawnReactModal} from "tc-shared/ui/react-elements/Modal"; import {ModalVideoSourceEvents} from "tc-shared/ui/modal/video-source/Definitions"; import {ModalVideoSource} from "tc-shared/ui/modal/video-source/Renderer"; import {getVideoDriver, VideoPermissionStatus, VideoSource} from "tc-shared/video/VideoSource"; -import {LogCategory, logError} from "tc-shared/log"; -import {VideoBroadcastType} from "tc-shared/connection/VideoConnection"; +import {LogCategory, logError, logWarn} from "tc-shared/log"; +import {BroadcastConstraints, VideoBroadcastType} from "tc-shared/connection/VideoConnection"; +import {Settings, settings} from "tc-shared/settings"; +import {tr} from "tc-shared/i18n/localize"; -type SourceConstraints = { width?: number, height?: number, frameRate?: number }; +export type VideoSourceModalAction = { + mode: "select-quick", + defaultDevice?: string +} | { + mode: "select-default", + defaultDevice?: string +} | { + mode: "new" +} | { + mode: "edit", + source: VideoSource, + broadcastConstraints: BroadcastConstraints +}; /** * @param type The video type which should be prompted - * @param selectMode - * @param defaultDeviceId + * @param mode */ -export async function spawnVideoSourceSelectModal(type: VideoBroadcastType, selectMode: "quick" | "default" | "none", defaultDeviceId?: string) : Promise { +export async function spawnVideoSourceSelectModal(type: VideoBroadcastType, mode: VideoSourceModalAction) : Promise<{ source: VideoSource | undefined, constraints: BroadcastConstraints | undefined }> { const controller = new VideoSourceController(type); - let defaultSelectSource = selectMode === "default"; - if(selectMode === "quick") { + let defaultSelectDevice: string | true; + if(mode.mode === "select-quick") { /* We need the modal itself for the native client in order to present the window selector */ if(type === "camera" || __build.target === "web") { /* Try to get the default device. If we succeeded directly return that */ - if(await controller.selectSource(defaultDeviceId)) { - const source = controller.getCurrentSource()?.ref(); + if(await controller.selectSource(mode.defaultDevice)) { + /* select succeeded */ + const resultSource = controller.getCurrentSource()?.ref(); + const resultConstraints = controller.getBroadcastConstraints(); controller.destroy(); - - return source; + return { + source: resultSource, + constraints: resultConstraints + }; + } else { + /* Select failed. We'll open the modal and show the error. */ } } else { - defaultSelectSource = true; + defaultSelectDevice = mode.defaultDevice || true; } + } else if(mode.mode === "select-default") { + defaultSelectDevice = mode.defaultDevice || true; + } else if(mode.mode === "edit") { + await controller.useSettings(mode.source, mode.broadcastConstraints); } - const modal = spawnReactModal(ModalVideoSource, controller.events, type); + const modal = spawnReactModal(ModalVideoSource, controller.events, type, mode.mode === "edit"); controller.events.on(["action_start", "action_cancel"], () => modal.destroy()); modal.show().then(() => { - if(defaultSelectSource) { + if(defaultSelectDevice) { if(type === "screen" && getVideoDriver().screenQueryAvailable()) { controller.events.fire_react("action_toggle_screen_capture_device_select", { shown: true }); } else { - controller.selectSource(defaultDeviceId); + controller.selectSource(defaultSelectDevice === true ? undefined : defaultSelectDevice); } } }); - let refSource: { source: VideoSource } = { source: undefined }; - controller.events.on("action_start", () => { - refSource.source?.deref(); - refSource.source = controller.getCurrentSource()?.ref(); - }); - await new Promise(resolve => { - if(defaultSelectSource && selectMode === "quick") { + if(mode.mode === "select-quick" && __build.target !== "web") { + /* We need the modal event for quick select */ const callbackRemove = controller.events.on("notify_video_preview", event => { if(event.status.status === "error") { callbackRemove(); @@ -60,8 +78,6 @@ export async function spawnVideoSourceSelectModal(type: VideoBroadcastType, sele if(event.status.status === "preview") { /* we've successfully selected something */ - refSource.source = controller.getCurrentSource()?.ref(); - modal.hide(); modal.destroy(); } }); @@ -70,8 +86,96 @@ export async function spawnVideoSourceSelectModal(type: VideoBroadcastType, sele modal.events.one(["destroy", "close"], resolve); }); + const resultSource = controller.getCurrentSource()?.ref(); + const resultConstraints = controller.getBroadcastConstraints(); controller.destroy(); - return refSource.source; + return { + source: resultSource, + constraints: resultConstraints + }; +} + +function updateBroadcastConstraintsFromSource(source: VideoSource, constraints: BroadcastConstraints) { + const videoTrack = source.getStream().getVideoTracks()[0]; + const trackSettings = videoTrack.getSettings(); + + constraints.width = trackSettings.width; + constraints.height = trackSettings.height; + constraints.maxFrameRate = trackSettings.frameRate; +} + +async function generateAndApplyDefaultConstraints(source: VideoSource) : Promise { + const videoTrack = source.getStream().getVideoTracks()[0]; + + let maxHeight = settings.static_global(Settings.KEY_VIDEO_DEFAULT_MAX_HEIGHT); + let maxWidth = settings.static_global(Settings.KEY_VIDEO_DEFAULT_MAX_WIDTH); + + const trackSettings = videoTrack.getSettings(); + const capabilities = source.getCapabilities(); + + maxHeight = Math.min(maxHeight, capabilities.maxHeight); + maxWidth = Math.min(maxWidth, capabilities.maxWidth); + + const broadcastConstraints: BroadcastConstraints = {} as any; + { + let ratio = 1; + + if(trackSettings.height > maxHeight) { + ratio = Math.min(maxHeight / trackSettings.height, ratio); + } + + if(trackSettings.width > maxWidth) { + ratio = Math.min(maxWidth / trackSettings.width, ratio); + } + + if(ratio !== 1) { + broadcastConstraints.width = Math.ceil(ratio * trackSettings.width); + broadcastConstraints.height = Math.ceil(ratio * trackSettings.height); + } else { + broadcastConstraints.width = trackSettings.width; + broadcastConstraints.height = trackSettings.height; + } + } + + broadcastConstraints.dynamicQuality = true; + broadcastConstraints.dynamicFrameRate = true; + broadcastConstraints.maxBandwidth = 10_000_000; + + try { + await applyBroadcastConstraints(source, broadcastConstraints); + } catch (error) { + logWarn(LogCategory.VIDEO, tr("Failed to apply initial default broadcast constraints: %o"), error); + } + + updateBroadcastConstraintsFromSource(source, broadcastConstraints); + + return broadcastConstraints; +} + +/* May throws an overconstraint error */ +async function applyBroadcastConstraints(source: VideoSource, constraints: BroadcastConstraints) { + const videoTrack = source.getStream().getVideoTracks()[0]; + if(!videoTrack) { return; } + + await videoTrack.applyConstraints({ + frameRate: constraints.dynamicFrameRate ? { + min: 1, + max: constraints.maxFrameRate, + ideal: constraints.maxFrameRate + } : constraints.maxFrameRate, + + width: constraints.dynamicQuality ? { + min: 1, + max: constraints.width, + ideal: constraints.width + } : constraints.width, + + height: constraints.dynamicQuality ? { + min: 1, + max: constraints.height, + ideal: constraints.height + } : constraints.height + }); } class VideoSourceController { @@ -79,7 +183,7 @@ class VideoSourceController { private readonly type: VideoBroadcastType; private currentSource: VideoSource | string; - private currentConstraints: SourceConstraints; + private currentConstraints: BroadcastConstraints; /* preselected current source id */ private currentSourceId: string; @@ -177,24 +281,13 @@ class VideoSourceController { })); } - const applyConstraints = async () => { - if(typeof this.currentSource === "object") { - const videoTrack = this.currentSource.getStream().getVideoTracks()[0]; - if(!videoTrack) { return; } - - await videoTrack.applyConstraints(this.currentConstraints); - } - }; - this.events.on("action_setting_dimension", event => { this.currentConstraints.height = event.height; this.currentConstraints.width = event.width; - applyConstraints().then(undefined); }); this.events.on("action_setting_framerate", event => { - this.currentConstraints.frameRate = event.frameRate; - applyConstraints().then(undefined); + this.currentConstraints.maxFrameRate = event.frameRate; }); } @@ -208,12 +301,27 @@ class VideoSourceController { this.events.destroy(); } - setCurrentSource(source: VideoSource | string | undefined) { + async setCurrentSource(source: VideoSource | string | undefined) { if(typeof this.currentSource === "object") { this.currentSource.deref(); } - this.currentConstraints = {}; + if(typeof source === "object") { + if(this.currentConstraints) { + try { + /* TODO: Automatically scale down resolution if new one isn't capable of supplying our current resolution */ + await applyBroadcastConstraints(source, this.currentConstraints); + } catch (error) { + logWarn(LogCategory.VIDEO, tr("Failed to apply broadcast constraints to new source: %o"), error); + this.currentConstraints = undefined; + } + } + + if(!this.currentConstraints) { + this.currentConstraints = await generateAndApplyDefaultConstraints(source); + } + } + this.currentSource = source; this.notifyVideoPreview(); this.notifyStartButton(); @@ -222,6 +330,20 @@ class VideoSourceController { this.notifySettingFramerate(); } + async useSettings(source: VideoSource, constraints: BroadcastConstraints) { + if(typeof this.currentSource === "object") { + this.currentSource.deref(); + } + + this.currentSource = source.ref(); + this.currentConstraints = constraints; + this.notifyVideoPreview(); + this.notifyStartButton(); + this.notifyCurrentSource(); + this.notifySettingDimension(); + this.notifySettingFramerate(); + } + async selectSource(sourceId: string) : Promise { const driver = getVideoDriver(); @@ -244,17 +366,17 @@ class VideoSourceController { try { const stream = await streamPromise; - this.setCurrentSource(stream); + await this.setCurrentSource(stream); this.fallbackCurrentSourceName = stream?.getName() || tr("No stream"); return !!stream; } catch (error) { this.fallbackCurrentSourceName = tr("failed to attach to device"); if(typeof error === "string") { - this.setCurrentSource(error); + await this.setCurrentSource(error); } else { logError(LogCategory.GENERAL, tr("Failed to open capture device %s: %o"), sourceId, error); - this.setCurrentSource(tr("Failed to open capture device (Lookup the console)")); + await this.setCurrentSource(tr("Failed to open capture device (Lookup the console)")); } return false; @@ -265,6 +387,10 @@ class VideoSourceController { return typeof this.currentSource === "object" ? this.currentSource : undefined; } + getBroadcastConstraints() : BroadcastConstraints { + return this.currentConstraints; + } + private notifyStartButton() { this.events.fire_react("notify_start_button", { enabled: typeof this.currentSource === "object" }) } @@ -291,7 +417,7 @@ class VideoSourceController { }); } - private notifyScreenCaptureDevices(){ + private notifyScreenCaptureDevices() { const driver = getVideoDriver(); driver.queryScreenCaptureDevices().then(devices => { this.events.fire_react("notify_screen_capture_devices", { devices: { status: "success", devices: devices }}); @@ -305,7 +431,7 @@ class VideoSourceController { }) } - private notifyVideoPreview(){ + private notifyVideoPreview() { const driver = getVideoDriver(); switch (driver.getPermissionStatus()) { case VideoPermissionStatus.SystemDenied: @@ -333,7 +459,7 @@ class VideoSourceController { } }; - private notifyCurrentSource(){ + private notifyCurrentSource() { if(typeof this.currentSource === "object") { this.events.fire_react("notify_source", { state: { @@ -358,25 +484,25 @@ class VideoSourceController { } } - private notifySettingDimension(){ + private notifySettingDimension() { if(typeof this.currentSource === "object") { - const videoTrack = this.currentSource.getStream().getVideoTracks()[0]; - const settings = videoTrack.getSettings(); - const capabilities = "getCapabilities" in videoTrack ? videoTrack.getCapabilities() : undefined; + const initialSettings = this.currentSource.getInitialSettings(); + const capabilities = this.currentSource.getCapabilities(); + const constraints = this.currentConstraints; this.events.fire_react("notify_setting_dimension", { setting: { - minWidth: capabilities?.width ? capabilities.width.min : 1, - maxWidth: capabilities?.width ? capabilities.width.max : settings.width, + minWidth: capabilities.minWidth, + maxWidth: capabilities.maxWidth, - minHeight: capabilities?.height ? capabilities.height.min : 1, - maxHeight: capabilities?.height ? capabilities.height.max : settings.height, + minHeight: capabilities.minHeight, + maxHeight: capabilities.maxHeight, - originalWidth: settings.width, - originalHeight: settings.height, + originalWidth: initialSettings.width, + originalHeight: initialSettings.height, - currentWidth: settings.width, - currentHeight: settings.height + currentWidth: constraints.width, + currentHeight: constraints.height } }); } else { @@ -386,16 +512,16 @@ class VideoSourceController { notifySettingFramerate() { if(typeof this.currentSource === "object") { - const videoTrack = this.currentSource.getStream().getVideoTracks()[0]; - const settings = videoTrack.getSettings(); - const capabilities = "getCapabilities" in videoTrack ? videoTrack.getCapabilities() : undefined; + const initialSettings = this.currentSource.getInitialSettings(); + const capabilities = this.currentSource.getCapabilities(); const round = (value: number) => Math.round(value * 100) / 100; this.events.fire_react("notify_settings_framerate", { frameRate: { - min: round(capabilities?.frameRate ? capabilities.frameRate.min : 1), - max: round(capabilities?.frameRate ? capabilities.frameRate.max : settings.frameRate), - original: round(settings.frameRate) + min: round(capabilities.minFrameRate), + max: round(capabilities.maxFrameRate), + original: round(initialSettings.frameRate), + current: round(this.currentConstraints.maxFrameRate) } }); } else { diff --git a/shared/js/ui/modal/video-source/Definitions.ts b/shared/js/ui/modal/video-source/Definitions.ts index 0252a68a..2b8b7bd1 100644 --- a/shared/js/ui/modal/video-source/Definitions.ts +++ b/shared/js/ui/modal/video-source/Definitions.ts @@ -48,6 +48,7 @@ export type SettingFrameRate = { min: number, max: number, original: number, + current: number }; export interface ModalVideoSourceEvents { diff --git a/shared/js/ui/modal/video-source/Renderer.tsx b/shared/js/ui/modal/video-source/Renderer.tsx index b4d442e7..518c1d36 100644 --- a/shared/js/ui/modal/video-source/Renderer.tsx +++ b/shared/js/ui/modal/video-source/Renderer.tsx @@ -233,7 +233,7 @@ const VideoPreview = () => { ); } -const ButtonStart = () => { +const ButtonStart = (props: { editMode: boolean }) => { const events = useContext(ModalEvents); const [ enabled, setEnabled ] = useState(() => { events.fire("query_start_button"); @@ -248,7 +248,7 @@ const ButtonStart = () => { disabled={!enabled} onClick={() => enabled && events.fire("action_start")} > - Start + {props.editMode ? Apply Changed : Start} ); } @@ -317,7 +317,7 @@ const SettingDimension = () => { setHeight(event.setting.currentHeight); refSliderWidth.current?.setState({ value: event.setting.currentWidth }); refSliderHeight.current?.setState({ value: event.setting.currentHeight }); - setSelectValue("original"); + setSelectValue("current"); } else { setSettings(undefined); setSelectValue("no-source"); @@ -419,6 +419,7 @@ const SettingDimension = () => { )} + @@ -486,7 +487,7 @@ const SettingFramerate = () => { setFrameRate(event.frameRate); setCurrentRate(event.frameRate ? event.frameRate.original : 1); if(event.frameRate) { - setSelectedValue(event.frameRate.original.toString()); + setSelectedValue(event.frameRate.current.toString()); } else { setSelectedValue("no-source"); } @@ -497,6 +498,9 @@ const SettingFramerate = () => { if(Object.keys(FrameRates).findIndex(key => FrameRates[key] === frameRate.original) === -1) { FrameRates[frameRate.original.toString()] = frameRate.original; } + if(Object.keys(FrameRates).findIndex(key => FrameRates[key] === frameRate.current) === -1) { + FrameRates[frameRate.current.toString()] = frameRate.current; + } } return ( @@ -758,12 +762,14 @@ const ScreenCaptureDeviceSelect = React.memo(() => { export class ModalVideoSource extends InternalModal { protected readonly events: Registry; private readonly sourceType: VideoBroadcastType; + private readonly editMode: boolean; - constructor(events: Registry, type: VideoBroadcastType) { + constructor(events: Registry, type: VideoBroadcastType, editMode: boolean) { super(); this.sourceType = type; this.events = events; + this.editMode = editMode; } renderBody(): React.ReactElement { @@ -793,7 +799,7 @@ export class ModalVideoSource extends InternalModal { - +
diff --git a/shared/js/video/VideoSource.ts b/shared/js/video/VideoSource.ts index a7d3be18..1f0e738d 100644 --- a/shared/js/video/VideoSource.ts +++ b/shared/js/video/VideoSource.ts @@ -1,11 +1,30 @@ import {Registry} from "tc-shared/events"; import { tr } from "tc-shared/i18n/localize"; +export interface VideoSourceCapabilities { + minWidth: number, + maxWidth: number, + + minHeight: number, + maxHeight: number, + + minFrameRate: number, + maxFrameRate: number +} + +export interface VideoSourceInitialSettings { + width: number, + height: number, + frameRate: number +} + export interface VideoSource { getId() : string; getName() : string; getStream() : MediaStream; + getCapabilities() : VideoSourceCapabilities; + getInitialSettings() : VideoSourceInitialSettings; /** Add a new reference to this stream */ ref() : this; From fdae0c77e8d7961d657f6a227cc58abf690b9fc1 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Wed, 16 Dec 2020 23:11:57 +0100 Subject: [PATCH 24/37] Improved video control handling --- shared/js/ui/frames/video/Controller.ts | 111 ++++----- shared/js/ui/frames/video/Definitions.ts | 44 ++-- shared/js/ui/frames/video/Renderer.tsx | 304 ++++++++++++----------- 3 files changed, 236 insertions(+), 223 deletions(-) diff --git a/shared/js/ui/frames/video/Controller.ts b/shared/js/ui/frames/video/Controller.ts index d748754c..751ad4da 100644 --- a/shared/js/ui/frames/video/Controller.ts +++ b/shared/js/ui/frames/video/Controller.ts @@ -3,7 +3,12 @@ import * as React from "react"; import * as ReactDOM from "react-dom"; import {ChannelVideoRenderer} from "tc-shared/ui/frames/video/Renderer"; import {Registry} from "tc-shared/events"; -import {ChannelVideo, ChannelVideoEvents, kLocalVideoId} from "tc-shared/ui/frames/video/Definitions"; +import { + ChannelVideoEvents, + ChannelVideoStreamState, + kLocalVideoId, + VideoStreamState +} from "tc-shared/ui/frames/video/Definitions"; import { LocalVideoBroadcastState, VideoBroadcastState, @@ -27,7 +32,7 @@ interface ClientVideoController { notifyVideoInfo(); notifyVideo(forceSend: boolean); - notifyMuteState(); + notifyVideoStream(type: VideoBroadcastType); } class RemoteClientVideoController implements ClientVideoController { @@ -45,7 +50,8 @@ class RemoteClientVideoController implements ClientVideoController { camera: false }; - private cachedVideoStatus: ChannelVideo; + private cachedCameraState: ChannelVideoStreamState; + private cachedScreenState: ChannelVideoStreamState; constructor(client: ClientEntry, eventRegistry: Registry, videoId?: string) { this.client = client; @@ -90,7 +96,7 @@ class RemoteClientVideoController implements ClientVideoController { this.dismissed[event.broadcastType] = false; } this.notifyVideo(false); - this.notifyMuteState(); + this.notifyVideoStream(event.broadcastType); })); } @@ -119,6 +125,8 @@ class RemoteClientVideoController implements ClientVideoController { /* TODO: Propagate error? */ }); } + + this.notifyVideo(false); } dismissVideo(type: VideoBroadcastType) { @@ -143,61 +151,36 @@ class RemoteClientVideoController implements ClientVideoController { } notifyVideo(forceSend: boolean) { + let cameraState: ChannelVideoStreamState = "none"; + let screenState: ChannelVideoStreamState = "none"; + let broadcasting = false; - let status: ChannelVideo; if(this.hasVideoSupport()) { - let initializing = false; - - let cameraStream, desktopStream; - const stateCamera = this.getBroadcastState("camera"); if(stateCamera === VideoBroadcastState.Available) { - cameraStream = "available"; - } else if(stateCamera === VideoBroadcastState.Running) { - cameraStream = this.getBroadcastStream("camera") - } else if(stateCamera === VideoBroadcastState.Initializing) { - initializing = true; + cameraState = this.dismissed["camera"] ? "ignored" : "available"; + } else if(stateCamera === VideoBroadcastState.Running || stateCamera === VideoBroadcastState.Initializing) { + cameraState = "streaming"; } const stateScreen = this.getBroadcastState("screen"); if(stateScreen === VideoBroadcastState.Available) { - desktopStream = "available"; - } else if(stateScreen === VideoBroadcastState.Running) { - desktopStream = this.getBroadcastStream("screen"); - } else if(stateScreen === VideoBroadcastState.Initializing) { - initializing = true; + screenState = this.dismissed["screen"] ? "ignored" : "available"; + } else if(stateScreen === VideoBroadcastState.Running || stateScreen === VideoBroadcastState.Initializing) { + screenState = "streaming"; } - if(cameraStream || desktopStream) { - broadcasting = true; - status = { - status: "connected", - - desktopStream: desktopStream, - cameraStream: cameraStream, - - dismissed: this.dismissed - }; - } else if(initializing) { - broadcasting = true; - status = { status: "initializing" }; - } else { - status = { - status: "connected", - - cameraStream: undefined, - desktopStream: undefined, - - dismissed: this.dismissed - }; - } - } else { - status = { status: "no-video" }; + broadcasting = cameraState !== "none" || screenState !== "none"; } - if(forceSend || !_.isEqual(this.cachedVideoStatus, status)) { - this.cachedVideoStatus = status; - this.events.fire_react("notify_video", { videoId: this.videoId, status: status }); + if(forceSend || !_.isEqual(this.cachedCameraState, cameraState) || !_.isEqual(this.cachedScreenState, screenState)) { + this.cachedCameraState = cameraState; + this.cachedScreenState = screenState; + this.events.fire_react("notify_video", { + videoId: this.videoId, + cameraStream: cameraState, + screenStream: screenState + }); } if(broadcasting !== this.currentBroadcastState) { @@ -208,13 +191,29 @@ class RemoteClientVideoController implements ClientVideoController { } } - notifyMuteState() { - this.events.fire_react("notify_video_mute_status", { - videoId: this.videoId, - status: { - camera: this.getBroadcastState("camera") === VideoBroadcastState.Available ? "muted" : this.getBroadcastState("camera") === VideoBroadcastState.Stopped ? "unset" : "available", - screen: this.getBroadcastState("screen") === VideoBroadcastState.Available ? "muted" : this.getBroadcastState("screen") === VideoBroadcastState.Stopped ? "unset" : "available", + notifyVideoStream(type: VideoBroadcastType) { + let state: VideoStreamState; + + const streamState = this.getBroadcastState(type); + if(streamState === VideoBroadcastState.Stopped) { + state = { state: "disconnected" }; + } else if(streamState === VideoBroadcastState.Initializing) { + state = { state: "connecting" }; + } else if(streamState === VideoBroadcastState.Available) { + state = { state: "available" }; + } else if(streamState === VideoBroadcastState.Buffering || streamState === VideoBroadcastState.Running) { + const stream = this.getBroadcastStream(type); + if(!stream) { + state = { state: "failed", reason: tr("Missing video stream") }; + } else { + state = { state: "connected", stream: stream }; } + } + + this.events.fire_react("notify_video_stream", { + videoId: this.videoId, + broadcastType: type, + state: state }); } @@ -421,14 +420,14 @@ class ChannelVideoController { controller.notifyVideo(true); }); - this.events.on("query_video_mute_status", event => { + this.events.on("query_video_stream", event => { const controller = this.findVideoById(event.videoId); if(!controller) { - logWarn(LogCategory.VIDEO, tr("Tried to query mute state for a non existing video id (%s)."), event.videoId); + logWarn(LogCategory.VIDEO, tr("Tried to query video stream for a non existing video id (%s)."), event.videoId); return; } - controller.notifyMuteState(); + controller.notifyVideoStream(event.broadcastType); }); const channelTree = this.connection.channelTree; diff --git a/shared/js/ui/frames/video/Definitions.ts b/shared/js/ui/frames/video/Definitions.ts index f6f457c7..8cae9879 100644 --- a/shared/js/ui/frames/video/Definitions.ts +++ b/shared/js/ui/frames/video/Definitions.ts @@ -5,23 +5,7 @@ export const kLocalVideoId = "__local__video__"; export const kLocalBroadcastChannels: VideoBroadcastType[] = ["screen", "camera"]; export type ChannelVideoInfo = { clientName: string, clientUniqueId: string, clientId: number, statusIcon: ClientIcon }; -export type ChannelVideoStream = "available" | MediaStream | undefined; - -export type ChannelVideo ={ - status: "initializing", -} | { - status: "connected", - - cameraStream: ChannelVideoStream, - desktopStream: ChannelVideoStream, - - dismissed: {[T in VideoBroadcastType]: boolean} -} | { - status: "error", - message: string -} | { - status: "no-video" -}; +export type ChannelVideoStreamState = "available" | "streaming" | "ignored" | "muted" | "none"; export type VideoStatistics = { type: "sender", @@ -51,6 +35,21 @@ export type VideoStatistics = { codec: { name: string, payloadType: number } }; +export type VideoStreamState = { + state: "disconnected" +} | { + state: "available" +} | { + state: "connecting" +} | { + /* like join failed or whatever */ + state: "failed", + reason?: string +} | { + state: "connected", + stream: MediaStream +}; + /** * "muted": The video has been muted locally * "unset": The video will be normally played @@ -73,7 +72,7 @@ export interface ChannelVideoEvents { query_video_info: { videoId: string }, query_video_statistics: { videoId: string, broadcastType: VideoBroadcastType }, query_spotlight: {}, - query_video_mute_status: { videoId: string } + query_video_stream: { videoId: string, broadcastType: VideoBroadcastType }, notify_expended: { expended: boolean }, notify_videos: { @@ -81,7 +80,9 @@ export interface ChannelVideoEvents { }, notify_video: { videoId: string, - status: ChannelVideo + + cameraStream: ChannelVideoStreamState, + screenStream: ChannelVideoStreamState, }, notify_video_info: { videoId: string, @@ -103,8 +104,9 @@ export interface ChannelVideoEvents { broadcastType: VideoBroadcastType, statistics: VideoStatistics }, - notify_video_mute_status: { + notify_video_stream: { videoId: string, - status: {[T in VideoBroadcastType] : "muted" | "available" | "unset"} + broadcastType: VideoBroadcastType, + state: VideoStreamState } } \ No newline at end of file diff --git a/shared/js/ui/frames/video/Renderer.tsx b/shared/js/ui/frames/video/Renderer.tsx index 57b70492..f9408a16 100644 --- a/shared/js/ui/frames/video/Renderer.tsx +++ b/shared/js/ui/frames/video/Renderer.tsx @@ -4,11 +4,10 @@ import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons"; import {ClientIcon} from "svg-sprites/client-icons"; import {Registry} from "tc-shared/events"; import { - ChannelVideo, ChannelVideoEvents, ChannelVideoInfo, - ChannelVideoStream, - kLocalVideoId + ChannelVideoStreamState, + kLocalVideoId, VideoStreamState } from "tc-shared/ui/frames/video/Definitions"; import {Translatable} from "tc-shared/ui/react-elements/i18n"; import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; @@ -159,123 +158,115 @@ const VideoAvailableRenderer = (props: { callbackEnable: () => void, callbackIg
); -const VideoStreamRenderer = (props: { stream: ChannelVideoStream, callbackEnable: () => void, callbackIgnore: () => void, videoTitle: string, className?: string }) => { - if(props.stream === "available") { - return ; - } else if(props.stream === undefined) { - return ( -
-
No Video
-
- ); - } else { - return ; +const VideoStreamRenderer = (props: { videoId: string, streamType: VideoBroadcastType, className?: string }) => { + const events = useContext(EventContext); + const [ state, setState ] = useState(() => { + events.fire("query_video_stream", { videoId: props.videoId, broadcastType: props.streamType }); + return { + state: "disconnected", + } + }); + events.reactUse("notify_video_stream", event => { + if(event.videoId === props.videoId && event.broadcastType === props.streamType) { + setState(event.state); + } + }); + + switch (state.state) { + case "disconnected": + return ( +
+
No video stream
+
+ ); + + case "connecting": + return ( +
+
connecting
+
+ ); + + case "connected": + return ; + + case "failed": + return ( +
+
Stream replay failed
+
+ ); + + case "available": + return ( +
+
Video available
+
+ ); } } -const VideoPlayer = React.memo((props: { videoId: string }) => { +const VideoPlayer = React.memo((props: { videoId: string, cameraState: ChannelVideoStreamState, screenState: ChannelVideoStreamState }) => { const events = useContext(EventContext); - const [ state, setState ] = useState<"loading" | ChannelVideo>(() => { - events.fire("query_video", { videoId: props.videoId }); - return "loading"; - }); - events.reactUse("notify_video", event => { - if(event.videoId === props.videoId) { - setState(event.status); - } - }); + const streamElements = []; + const streamClasses = [cssStyle.videoPrimary, cssStyle.videoSecondary]; - if(state === "loading") { - return ( -
-
loading
-
- ); - } else if(state.status === "initializing") { - return ( -
-
connecting
-
- ); - } else if(state.status === "error") { - return ( -
-
{state.message}
-
- ); - } else if(state.status === "connected") { - const streamElements = []; - const streamClasses = [cssStyle.videoPrimary, cssStyle.videoSecondary]; + if(props.cameraState === "none" && props.screenState === "none") { + /* No video available. Will be handled bellow */ + } else if(props.cameraState !== "streaming" && props.screenState !== "streaming") { + /* We're not streaming any video nor we don't have any video. Show general show video button. */ + streamElements.push( + { + if(props.screenState !== "streaming" && props.screenState !== "none") { + events.fire("action_toggle_mute", { broadcastType: "screen", muted: false, videoId: props.videoId }) + } - if(state.desktopStream === "available" && (state.cameraStream === "available" || state.cameraStream === undefined) || - state.cameraStream === "available" && (state.desktopStream === "available" || state.desktopStream === undefined) - ) { - /* One or both streams are available. Showing just one box. */ + if(props.cameraState !== "streaming" && props.cameraState !== "none") { + events.fire("action_toggle_mute", { broadcastType: "camera", muted: false, videoId: props.videoId }) + } + }} + className={streamClasses.pop_front()} + /> + ); + } else { + if(props.screenState === "available") { streamElements.push( { - if(state.desktopStream === "available") { - events.fire("action_toggle_mute", { broadcastType: "screen", muted: false, videoId: props.videoId }) - } - - if(state.cameraStream === "available") { - events.fire("action_toggle_mute", { broadcastType: "camera", muted: false, videoId: props.videoId }) - } - }} + key={"video-available-screen"} + callbackEnable={() => events.fire("action_toggle_mute", { broadcastType: "screen", muted: false, videoId: props.videoId })} + callbackIgnore={() => events.fire("action_dismiss", { broadcastType: "screen", videoId: props.videoId })} className={streamClasses.pop_front()} /> ); - } else { - if(state.desktopStream) { - if(!state.dismissed["screen"] || state.desktopStream !== "available") { - streamElements.push( - events.fire("action_toggle_mute", { broadcastType: "screen", muted: false, videoId: props.videoId })} - callbackIgnore={() => events.fire("action_dismiss", { broadcastType: "screen", videoId: props.videoId })} - videoTitle={tr("Screen")} - className={streamClasses.pop_front()} - /> - ); - } - } - - if(state.cameraStream) { - if(!state.dismissed["camera"] || state.cameraStream !== "available") { - streamElements.push( - events.fire("action_toggle_mute", { broadcastType: "camera", muted: false, videoId: props.videoId })} - callbackIgnore={() => events.fire("action_dismiss", { broadcastType: "camera", videoId: props.videoId })} - videoTitle={tr("Camera")} - className={streamClasses.pop_front()} - /> - ); - } - } - } - - if(streamElements.length === 0){ - return ( -
-
- {props.videoId === kLocalVideoId ? - You're not broadcasting video : - No Video - } -
-
+ } else if(props.screenState === "streaming") { + streamElements.push( + ); } - return <>{streamElements}; - } else if(state.status === "no-video") { + + if(props.cameraState === "available") { + streamElements.push( + events.fire("action_toggle_mute", { broadcastType: "camera", muted: false, videoId: props.videoId })} + callbackIgnore={() => events.fire("action_dismiss", { broadcastType: "camera", videoId: props.videoId })} + className={streamClasses.pop_front()} + /> + ); + } else if(props.cameraState === "streaming") { + streamElements.push( + + ); + } + } + + if(streamElements.length === 0){ return ( -
+
{props.videoId === kLocalVideoId ? You're not broadcasting video : @@ -286,7 +277,53 @@ const VideoPlayer = React.memo((props: { videoId: string }) => { ); } - return null; + return <>{streamElements}; +}); + +const VideoControlButtons = React.memo((props: { + videoId: string, + cameraState: ChannelVideoStreamState, + screenState: ChannelVideoStreamState, + isSpotlight: boolean, + fullscreenMode: "none" | "unavailable" | "set" +}) => { + const events = useContext(EventContext); + + const screenShown = props.screenState !== "none" && props.videoId !== kLocalVideoId; + const cameraShown = props.cameraState !== "none" && props.videoId !== kLocalVideoId; + + const screenDisabled = props.screenState === "ignored" || props.screenState === "muted" || props.screenState === "available"; + const cameraDisabled = props.cameraState === "ignored" || props.cameraState === "muted" || props.cameraState === "available"; + + return ( +
+
events.fire("action_toggle_mute", { videoId: props.videoId, broadcastType: "screen", muted: !screenDisabled })} + title={props.screenState === "muted" ? tr("Unmute screen video") : tr("Mute screen video")} + > + +
+
events.fire("action_toggle_mute", { videoId: props.videoId, broadcastType: "camera", muted: !cameraDisabled })} + title={props.cameraState === "muted" ? tr("Unmute camera video") : tr("Mute camera video")} + > + +
+
{ + if(props.isSpotlight) { + events.fire("action_set_fullscreen", { videoId: props.fullscreenMode === "set" ? undefined : props.videoId }); + } else { + events.fire("action_set_spotlight", { videoId: props.videoId, expend: true }); + events.fire("action_focus_spotlight", { }); + } + }} + title={props.isSpotlight ? tr("Toggle fullscreen") : tr("Toggle spotlight")} + > + +
+
+ ); }); const VideoContainer = React.memo((props: { videoId: string, isSpotlight: boolean }) => { @@ -295,14 +332,17 @@ const VideoContainer = React.memo((props: { videoId: string, isSpotlight: boolea const fullscreenCapable = "requestFullscreen" in HTMLElement.prototype; const [ isFullscreen, setFullscreen ] = useState(false); - const [ muteState, setMuteState ] = useState<{[T in VideoBroadcastType]: "muted" | "available" | "unset"}>(() => { - events.fire("query_video_mute_status", { videoId: props.videoId }); - return { camera: "unset", screen: "unset" }; + + const [ cameraState, setCameraState ] = useState("none"); + const [ screenState, setScreenState ] = useState(() => { + events.fire("query_video", { videoId: props.videoId }); + return "none"; }); - events.reactUse("notify_video_mute_status", event => { + events.reactUse("notify_video", event => { if(event.videoId === props.videoId) { - setMuteState(event.status); + setCameraState(event.cameraStream); + setScreenState(event.screenStream); } }); @@ -342,14 +382,6 @@ const VideoContainer = React.memo((props: { videoId: string, isSpotlight: boolea } }); - const toggleClass = (type: VideoBroadcastType) => { - if(props.videoId === kLocalVideoId || muteState[type] === "unset") { - return cssStyle.hidden; - } - - return muteState[type] === "muted" ? cssStyle.disabled : ""; - } - return (
- + -
-
events.fire("action_toggle_mute", { videoId: props.videoId, broadcastType: "screen", muted: muteState.screen === "available" })} - title={muteState["screen"] === "muted" ? tr("Unmute screen video") : tr("Mute screen video")} - > - -
-
events.fire("action_toggle_mute", { videoId: props.videoId, broadcastType: "camera", muted: muteState.camera === "available" })} - title={muteState["camera"] === "muted" ? tr("Unmute camera video") : tr("Mute camera video")} - > - -
-
{ - if(props.isSpotlight) { - events.fire("action_set_fullscreen", { videoId: isFullscreen ? undefined : props.videoId }); - } else { - events.fire("action_set_spotlight", { videoId: props.videoId, expend: true }); - events.fire("action_focus_spotlight", { }); - } - }} - title={props.isSpotlight ? tr("Toggle fullscreen") : tr("Toggle spotlight")} - > - -
-
+
); }); From eb94db07730a6dfa894e176f551365b40da60fa3 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Wed, 16 Dec 2020 23:49:36 +0100 Subject: [PATCH 25/37] Properly outlining the permission why a video subscription isn't possible --- shared/js/permission/PermissionManager.ts | 6 +- shared/js/permission/PermissionType.ts | 707 +++++++++++----------- shared/js/ui/frames/video/Controller.ts | 91 ++- shared/js/ui/frames/video/Definitions.ts | 14 +- shared/js/ui/frames/video/Renderer.tsx | 140 +++-- 5 files changed, 572 insertions(+), 386 deletions(-) diff --git a/shared/js/permission/PermissionManager.ts b/shared/js/permission/PermissionManager.ts index cc7640dd..fa946f64 100644 --- a/shared/js/permission/PermissionManager.ts +++ b/shared/js/permission/PermissionManager.ts @@ -154,7 +154,7 @@ export class PermissionManager extends AbstractCommandHandler { permissionGroups: PermissionGroup[] = []; neededPermissions: NeededPermissionValue[] = []; - needed_permission_change_listener: {[permission: string]:(() => any)[]} = {}; + needed_permission_change_listener: {[permission: string]:((value?: PermissionValue) => void)[]} = {}; requests_channel_permissions: PermissionRequest[] = []; requests_client_permissions: PermissionRequest[] = []; @@ -415,10 +415,12 @@ export class PermissionManager extends AbstractCommandHandler { this.events.fire("client_permissions_changed"); } - register_needed_permission(key: PermissionType, listener: () => any) { + register_needed_permission(key: PermissionType, listener: () => any) : () => void { const array = this.needed_permission_change_listener[key] || []; array.push(listener); this.needed_permission_change_listener[key] = array; + + return () => this.needed_permission_change_listener[key]?.remove(listener); } unregister_needed_permission(key: PermissionType, listener: () => any) { diff --git a/shared/js/permission/PermissionType.ts b/shared/js/permission/PermissionType.ts index 99ae8d07..4529c182 100644 --- a/shared/js/permission/PermissionType.ts +++ b/shared/js/permission/PermissionType.ts @@ -1,350 +1,363 @@ export enum PermissionType { - B_SERVERINSTANCE_HELP_VIEW = "b_serverinstance_help_view", - B_SERVERINSTANCE_VERSION_VIEW = "b_serverinstance_version_view", - B_SERVERINSTANCE_INFO_VIEW = "b_serverinstance_info_view", - B_SERVERINSTANCE_VIRTUALSERVER_LIST = "b_serverinstance_virtualserver_list", - B_SERVERINSTANCE_BINDING_LIST = "b_serverinstance_binding_list", - B_SERVERINSTANCE_PERMISSION_LIST = "b_serverinstance_permission_list", - B_SERVERINSTANCE_PERMISSION_FIND = "b_serverinstance_permission_find", - B_VIRTUALSERVER_CREATE = "b_virtualserver_create", - B_VIRTUALSERVER_DELETE = "b_virtualserver_delete", - B_VIRTUALSERVER_START_ANY = "b_virtualserver_start_any", - B_VIRTUALSERVER_STOP_ANY = "b_virtualserver_stop_any", - B_VIRTUALSERVER_CHANGE_MACHINE_ID = "b_virtualserver_change_machine_id", - B_VIRTUALSERVER_CHANGE_TEMPLATE = "b_virtualserver_change_template", - B_SERVERQUERY_LOGIN = "b_serverquery_login", - B_SERVERINSTANCE_TEXTMESSAGE_SEND = "b_serverinstance_textmessage_send", - B_SERVERINSTANCE_LOG_VIEW = "b_serverinstance_log_view", - B_SERVERINSTANCE_LOG_ADD = "b_serverinstance_log_add", - B_SERVERINSTANCE_STOP = "b_serverinstance_stop", - B_SERVERINSTANCE_MODIFY_SETTINGS = "b_serverinstance_modify_settings", - B_SERVERINSTANCE_MODIFY_QUERYGROUP = "b_serverinstance_modify_querygroup", - B_SERVERINSTANCE_MODIFY_TEMPLATES = "b_serverinstance_modify_templates", - B_VIRTUALSERVER_SELECT = "b_virtualserver_select", - B_VIRTUALSERVER_SELECT_GODMODE = "b_virtualserver_select_godmode", - B_VIRTUALSERVER_INFO_VIEW = "b_virtualserver_info_view", - B_VIRTUALSERVER_CONNECTIONINFO_VIEW = "b_virtualserver_connectioninfo_view", - B_VIRTUALSERVER_CHANNEL_LIST = "b_virtualserver_channel_list", - B_VIRTUALSERVER_CHANNEL_SEARCH = "b_virtualserver_channel_search", - B_VIRTUALSERVER_CLIENT_LIST = "b_virtualserver_client_list", - B_VIRTUALSERVER_CLIENT_SEARCH = "b_virtualserver_client_search", - B_VIRTUALSERVER_CLIENT_DBLIST = "b_virtualserver_client_dblist", - B_VIRTUALSERVER_CLIENT_DBSEARCH = "b_virtualserver_client_dbsearch", - B_VIRTUALSERVER_CLIENT_DBINFO = "b_virtualserver_client_dbinfo", - B_VIRTUALSERVER_PERMISSION_FIND = "b_virtualserver_permission_find", - B_VIRTUALSERVER_CUSTOM_SEARCH = "b_virtualserver_custom_search", - B_VIRTUALSERVER_START = "b_virtualserver_start", - B_VIRTUALSERVER_STOP = "b_virtualserver_stop", - B_VIRTUALSERVER_TOKEN_LIST = "b_virtualserver_token_list", - B_VIRTUALSERVER_TOKEN_ADD = "b_virtualserver_token_add", - B_VIRTUALSERVER_TOKEN_USE = "b_virtualserver_token_use", - B_VIRTUALSERVER_TOKEN_DELETE = "b_virtualserver_token_delete", - B_VIRTUALSERVER_LOG_VIEW = "b_virtualserver_log_view", - B_VIRTUALSERVER_LOG_ADD = "b_virtualserver_log_add", - B_VIRTUALSERVER_JOIN_IGNORE_PASSWORD = "b_virtualserver_join_ignore_password", - B_VIRTUALSERVER_NOTIFY_REGISTER = "b_virtualserver_notify_register", - B_VIRTUALSERVER_NOTIFY_UNREGISTER = "b_virtualserver_notify_unregister", - B_VIRTUALSERVER_SNAPSHOT_CREATE = "b_virtualserver_snapshot_create", - B_VIRTUALSERVER_SNAPSHOT_DEPLOY = "b_virtualserver_snapshot_deploy", - B_VIRTUALSERVER_PERMISSION_RESET = "b_virtualserver_permission_reset", - B_VIRTUALSERVER_MODIFY_NAME = "b_virtualserver_modify_name", - B_VIRTUALSERVER_MODIFY_WELCOMEMESSAGE = "b_virtualserver_modify_welcomemessage", - B_VIRTUALSERVER_MODIFY_MAXCLIENTS = "b_virtualserver_modify_maxclients", - B_VIRTUALSERVER_MODIFY_RESERVED_SLOTS = "b_virtualserver_modify_reserved_slots", - B_VIRTUALSERVER_MODIFY_PASSWORD = "b_virtualserver_modify_password", - B_VIRTUALSERVER_MODIFY_DEFAULT_SERVERGROUP = "b_virtualserver_modify_default_servergroup", - B_VIRTUALSERVER_MODIFY_DEFAULT_MUSICGROUP = "b_virtualserver_modify_default_musicgroup", - B_VIRTUALSERVER_MODIFY_DEFAULT_CHANNELGROUP = "b_virtualserver_modify_default_channelgroup", - B_VIRTUALSERVER_MODIFY_DEFAULT_CHANNELADMINGROUP = "b_virtualserver_modify_default_channeladmingroup", - B_VIRTUALSERVER_MODIFY_CHANNEL_FORCED_SILENCE = "b_virtualserver_modify_channel_forced_silence", - B_VIRTUALSERVER_MODIFY_COMPLAIN = "b_virtualserver_modify_complain", - B_VIRTUALSERVER_MODIFY_ANTIFLOOD = "b_virtualserver_modify_antiflood", - B_VIRTUALSERVER_MODIFY_FT_SETTINGS = "b_virtualserver_modify_ft_settings", - B_VIRTUALSERVER_MODIFY_FT_QUOTAS = "b_virtualserver_modify_ft_quotas", - B_VIRTUALSERVER_MODIFY_HOSTMESSAGE = "b_virtualserver_modify_hostmessage", - B_VIRTUALSERVER_MODIFY_HOSTBANNER = "b_virtualserver_modify_hostbanner", - B_VIRTUALSERVER_MODIFY_HOSTBUTTON = "b_virtualserver_modify_hostbutton", - B_VIRTUALSERVER_MODIFY_PORT = "b_virtualserver_modify_port", - B_VIRTUALSERVER_MODIFY_HOST = "b_virtualserver_modify_host", - B_VIRTUALSERVER_MODIFY_DEFAULT_MESSAGES = "b_virtualserver_modify_default_messages", - B_VIRTUALSERVER_MODIFY_AUTOSTART = "b_virtualserver_modify_autostart", - B_VIRTUALSERVER_MODIFY_NEEDED_IDENTITY_SECURITY_LEVEL = "b_virtualserver_modify_needed_identity_security_level", - B_VIRTUALSERVER_MODIFY_PRIORITY_SPEAKER_DIMM_MODIFICATOR = "b_virtualserver_modify_priority_speaker_dimm_modificator", - B_VIRTUALSERVER_MODIFY_LOG_SETTINGS = "b_virtualserver_modify_log_settings", - B_VIRTUALSERVER_MODIFY_MIN_CLIENT_VERSION = "b_virtualserver_modify_min_client_version", - B_VIRTUALSERVER_MODIFY_ICON_ID = "b_virtualserver_modify_icon_id", - B_VIRTUALSERVER_MODIFY_WEBLIST = "b_virtualserver_modify_weblist", - B_VIRTUALSERVER_MODIFY_CODEC_ENCRYPTION_MODE = "b_virtualserver_modify_codec_encryption_mode", - B_VIRTUALSERVER_MODIFY_TEMPORARY_PASSWORDS = "b_virtualserver_modify_temporary_passwords", - B_VIRTUALSERVER_MODIFY_TEMPORARY_PASSWORDS_OWN = "b_virtualserver_modify_temporary_passwords_own", - B_VIRTUALSERVER_MODIFY_CHANNEL_TEMP_DELETE_DELAY_DEFAULT = "b_virtualserver_modify_channel_temp_delete_delay_default", - B_VIRTUALSERVER_MODIFY_MUSIC_BOT_LIMIT = "b_virtualserver_modify_music_bot_limit", - B_VIRTUALSERVER_MODIFY_COUNTRY_CODE = "b_virtualserver_modify_country_code", - I_CHANNEL_MIN_DEPTH = "i_channel_min_depth", - I_CHANNEL_MAX_DEPTH = "i_channel_max_depth", - B_CHANNEL_GROUP_INHERITANCE_END = "b_channel_group_inheritance_end", - I_CHANNEL_PERMISSION_MODIFY_POWER = "i_channel_permission_modify_power", - I_CHANNEL_NEEDED_PERMISSION_MODIFY_POWER = "i_channel_needed_permission_modify_power", - B_CHANNEL_INFO_VIEW = "b_channel_info_view", - B_CHANNEL_CREATE_CHILD = "b_channel_create_child", - B_CHANNEL_CREATE_PERMANENT = "b_channel_create_permanent", - B_CHANNEL_CREATE_SEMI_PERMANENT = "b_channel_create_semi_permanent", - B_CHANNEL_CREATE_TEMPORARY = "b_channel_create_temporary", - B_CHANNEL_CREATE_PRIVATE = "b_channel_create_private", - B_CHANNEL_CREATE_WITH_TOPIC = "b_channel_create_with_topic", - B_CHANNEL_CREATE_WITH_DESCRIPTION = "b_channel_create_with_description", - B_CHANNEL_CREATE_WITH_PASSWORD = "b_channel_create_with_password", - B_CHANNEL_CREATE_MODIFY_WITH_CODEC_SPEEX8 = "b_channel_create_modify_with_codec_speex8", - B_CHANNEL_CREATE_MODIFY_WITH_CODEC_SPEEX16 = "b_channel_create_modify_with_codec_speex16", - B_CHANNEL_CREATE_MODIFY_WITH_CODEC_SPEEX32 = "b_channel_create_modify_with_codec_speex32", - B_CHANNEL_CREATE_MODIFY_WITH_CODEC_CELTMONO48 = "b_channel_create_modify_with_codec_celtmono48", - B_CHANNEL_CREATE_MODIFY_WITH_CODEC_OPUSVOICE = "b_channel_create_modify_with_codec_opusvoice", - B_CHANNEL_CREATE_MODIFY_WITH_CODEC_OPUSMUSIC = "b_channel_create_modify_with_codec_opusmusic", - I_CHANNEL_CREATE_MODIFY_WITH_CODEC_MAXQUALITY = "i_channel_create_modify_with_codec_maxquality", - I_CHANNEL_CREATE_MODIFY_WITH_CODEC_LATENCY_FACTOR_MIN = "i_channel_create_modify_with_codec_latency_factor_min", - I_CHANNEL_CREATE_MODIFY_CONVERSATION_HISTORY_LENGTH = "i_channel_create_modify_conversation_history_length", - B_CHANNEL_CREATE_MODIFY_CONVERSATION_HISTORY_UNLIMITED = "b_channel_create_modify_conversation_history_unlimited", - B_CHANNEL_CREATE_MODIFY_CONVERSATION_PRIVATE = "b_channel_create_modify_conversation_private", - B_CHANNEL_CREATE_WITH_MAXCLIENTS = "b_channel_create_with_maxclients", - B_CHANNEL_CREATE_WITH_MAXFAMILYCLIENTS = "b_channel_create_with_maxfamilyclients", - B_CHANNEL_CREATE_WITH_SORTORDER = "b_channel_create_with_sortorder", - B_CHANNEL_CREATE_WITH_DEFAULT = "b_channel_create_with_default", - B_CHANNEL_CREATE_WITH_NEEDED_TALK_POWER = "b_channel_create_with_needed_talk_power", - B_CHANNEL_CREATE_MODIFY_WITH_FORCE_PASSWORD = "b_channel_create_modify_with_force_password", - I_CHANNEL_CREATE_MODIFY_WITH_TEMP_DELETE_DELAY = "i_channel_create_modify_with_temp_delete_delay", - B_CHANNEL_MODIFY_PARENT = "b_channel_modify_parent", - B_CHANNEL_MODIFY_MAKE_DEFAULT = "b_channel_modify_make_default", - B_CHANNEL_MODIFY_MAKE_PERMANENT = "b_channel_modify_make_permanent", - B_CHANNEL_MODIFY_MAKE_SEMI_PERMANENT = "b_channel_modify_make_semi_permanent", - B_CHANNEL_MODIFY_MAKE_TEMPORARY = "b_channel_modify_make_temporary", - B_CHANNEL_MODIFY_NAME = "b_channel_modify_name", - B_CHANNEL_MODIFY_TOPIC = "b_channel_modify_topic", - B_CHANNEL_MODIFY_DESCRIPTION = "b_channel_modify_description", - B_CHANNEL_MODIFY_PASSWORD = "b_channel_modify_password", - B_CHANNEL_MODIFY_CODEC = "b_channel_modify_codec", - B_CHANNEL_MODIFY_CODEC_QUALITY = "b_channel_modify_codec_quality", - B_CHANNEL_MODIFY_CODEC_LATENCY_FACTOR = "b_channel_modify_codec_latency_factor", - B_CHANNEL_MODIFY_MAXCLIENTS = "b_channel_modify_maxclients", - B_CHANNEL_MODIFY_MAXFAMILYCLIENTS = "b_channel_modify_maxfamilyclients", - B_CHANNEL_MODIFY_SORTORDER = "b_channel_modify_sortorder", - B_CHANNEL_MODIFY_NEEDED_TALK_POWER = "b_channel_modify_needed_talk_power", - I_CHANNEL_MODIFY_POWER = "i_channel_modify_power", - I_CHANNEL_NEEDED_MODIFY_POWER = "i_channel_needed_modify_power", - B_CHANNEL_MODIFY_MAKE_CODEC_ENCRYPTED = "b_channel_modify_make_codec_encrypted", - B_CHANNEL_MODIFY_TEMP_DELETE_DELAY = "b_channel_modify_temp_delete_delay", - B_CHANNEL_DELETE_PERMANENT = "b_channel_delete_permanent", - B_CHANNEL_DELETE_SEMI_PERMANENT = "b_channel_delete_semi_permanent", - B_CHANNEL_DELETE_TEMPORARY = "b_channel_delete_temporary", - B_CHANNEL_DELETE_FLAG_FORCE = "b_channel_delete_flag_force", - I_CHANNEL_DELETE_POWER = "i_channel_delete_power", - B_CHANNEL_CONVERSATION_MESSAGE_DELETE = "b_channel_conversation_message_delete", - I_CHANNEL_NEEDED_DELETE_POWER = "i_channel_needed_delete_power", - B_CHANNEL_JOIN_PERMANENT = "b_channel_join_permanent", - B_CHANNEL_JOIN_SEMI_PERMANENT = "b_channel_join_semi_permanent", - B_CHANNEL_JOIN_TEMPORARY = "b_channel_join_temporary", - B_CHANNEL_JOIN_IGNORE_PASSWORD = "b_channel_join_ignore_password", - B_CHANNEL_JOIN_IGNORE_MAXCLIENTS = "b_channel_join_ignore_maxclients", - B_CHANNEL_IGNORE_VIEW_POWER = "b_channel_ignore_view_power", - I_CHANNEL_JOIN_POWER = "i_channel_join_power", - I_CHANNEL_NEEDED_JOIN_POWER = "i_channel_needed_join_power", - B_CHANNEL_IGNORE_JOIN_POWER = "b_channel_ignore_join_power", - B_CHANNEL_IGNORE_DESCRIPTION_VIEW_POWER = "b_channel_ignore_description_view_power", - I_CHANNEL_VIEW_POWER = "i_channel_view_power", - I_CHANNEL_NEEDED_VIEW_POWER = "i_channel_needed_view_power", - I_CHANNEL_SUBSCRIBE_POWER = "i_channel_subscribe_power", - I_CHANNEL_NEEDED_SUBSCRIBE_POWER = "i_channel_needed_subscribe_power", - I_CHANNEL_DESCRIPTION_VIEW_POWER = "i_channel_description_view_power", - I_CHANNEL_NEEDED_DESCRIPTION_VIEW_POWER = "i_channel_needed_description_view_power", - I_ICON_ID = "i_icon_id", - I_MAX_ICON_FILESIZE = "i_max_icon_filesize", - I_MAX_PLAYLIST_SIZE = "i_max_playlist_size", - I_MAX_PLAYLISTS = "i_max_playlists", - B_ICON_MANAGE = "b_icon_manage", - B_GROUP_IS_PERMANENT = "b_group_is_permanent", - I_GROUP_AUTO_UPDATE_TYPE = "i_group_auto_update_type", - I_GROUP_AUTO_UPDATE_MAX_VALUE = "i_group_auto_update_max_value", - I_GROUP_SORT_ID = "i_group_sort_id", - I_GROUP_SHOW_NAME_IN_TREE = "i_group_show_name_in_tree", - B_VIRTUALSERVER_SERVERGROUP_CREATE = "b_virtualserver_servergroup_create", - B_VIRTUALSERVER_SERVERGROUP_LIST = "b_virtualserver_servergroup_list", - B_VIRTUALSERVER_SERVERGROUP_PERMISSION_LIST = "b_virtualserver_servergroup_permission_list", - B_VIRTUALSERVER_SERVERGROUP_CLIENT_LIST = "b_virtualserver_servergroup_client_list", - B_VIRTUALSERVER_CHANNELGROUP_CREATE = "b_virtualserver_channelgroup_create", - B_VIRTUALSERVER_CHANNELGROUP_LIST = "b_virtualserver_channelgroup_list", - B_VIRTUALSERVER_CHANNELGROUP_PERMISSION_LIST = "b_virtualserver_channelgroup_permission_list", - B_VIRTUALSERVER_CHANNELGROUP_CLIENT_LIST = "b_virtualserver_channelgroup_client_list", - B_VIRTUALSERVER_CLIENT_PERMISSION_LIST = "b_virtualserver_client_permission_list", - B_VIRTUALSERVER_CHANNEL_PERMISSION_LIST = "b_virtualserver_channel_permission_list", - B_VIRTUALSERVER_CHANNELCLIENT_PERMISSION_LIST = "b_virtualserver_channelclient_permission_list", - B_VIRTUALSERVER_PLAYLIST_PERMISSION_LIST = "b_virtualserver_playlist_permission_list", - I_SERVER_GROUP_MODIFY_POWER = "i_server_group_modify_power", - I_SERVER_GROUP_NEEDED_MODIFY_POWER = "i_server_group_needed_modify_power", - I_SERVER_GROUP_MEMBER_ADD_POWER = "i_server_group_member_add_power", - I_SERVER_GROUP_SELF_ADD_POWER = "i_server_group_self_add_power", - I_SERVER_GROUP_NEEDED_MEMBER_ADD_POWER = "i_server_group_needed_member_add_power", - I_SERVER_GROUP_MEMBER_REMOVE_POWER = "i_server_group_member_remove_power", - I_SERVER_GROUP_SELF_REMOVE_POWER = "i_server_group_self_remove_power", - I_SERVER_GROUP_NEEDED_MEMBER_REMOVE_POWER = "i_server_group_needed_member_remove_power", - I_CHANNEL_GROUP_MODIFY_POWER = "i_channel_group_modify_power", - I_CHANNEL_GROUP_NEEDED_MODIFY_POWER = "i_channel_group_needed_modify_power", - I_CHANNEL_GROUP_MEMBER_ADD_POWER = "i_channel_group_member_add_power", - I_CHANNEL_GROUP_SELF_ADD_POWER = "i_channel_group_self_add_power", - I_CHANNEL_GROUP_NEEDED_MEMBER_ADD_POWER = "i_channel_group_needed_member_add_power", - I_CHANNEL_GROUP_MEMBER_REMOVE_POWER = "i_channel_group_member_remove_power", - I_CHANNEL_GROUP_SELF_REMOVE_POWER = "i_channel_group_self_remove_power", - I_CHANNEL_GROUP_NEEDED_MEMBER_REMOVE_POWER = "i_channel_group_needed_member_remove_power", - I_GROUP_MEMBER_ADD_POWER = "i_group_member_add_power", - I_GROUP_NEEDED_MEMBER_ADD_POWER = "i_group_needed_member_add_power", - I_GROUP_MEMBER_REMOVE_POWER = "i_group_member_remove_power", - I_GROUP_NEEDED_MEMBER_REMOVE_POWER = "i_group_needed_member_remove_power", - I_GROUP_MODIFY_POWER = "i_group_modify_power", - I_GROUP_NEEDED_MODIFY_POWER = "i_group_needed_modify_power", - I_PERMISSION_MODIFY_POWER = "i_permission_modify_power", - B_PERMISSION_MODIFY_POWER_IGNORE = "b_permission_modify_power_ignore", - B_VIRTUALSERVER_SERVERGROUP_DELETE = "b_virtualserver_servergroup_delete", - B_VIRTUALSERVER_CHANNELGROUP_DELETE = "b_virtualserver_channelgroup_delete", - I_CLIENT_PERMISSION_MODIFY_POWER = "i_client_permission_modify_power", - I_CLIENT_NEEDED_PERMISSION_MODIFY_POWER = "i_client_needed_permission_modify_power", - I_CLIENT_MAX_CLONES_UID = "i_client_max_clones_uid", - I_CLIENT_MAX_CLONES_IP = "i_client_max_clones_ip", - I_CLIENT_MAX_CLONES_HWID = "i_client_max_clones_hwid", - I_CLIENT_MAX_IDLETIME = "i_client_max_idletime", - I_CLIENT_MAX_AVATAR_FILESIZE = "i_client_max_avatar_filesize", - I_CLIENT_MAX_CHANNEL_SUBSCRIPTIONS = "i_client_max_channel_subscriptions", - I_CLIENT_MAX_CHANNELS = "i_client_max_channels", - I_CLIENT_MAX_TEMPORARY_CHANNELS = "i_client_max_temporary_channels", - I_CLIENT_MAX_SEMI_CHANNELS = "i_client_max_semi_channels", - I_CLIENT_MAX_PERMANENT_CHANNELS = "i_client_max_permanent_channels", - B_CLIENT_USE_PRIORITY_SPEAKER = "b_client_use_priority_speaker", - B_CLIENT_SKIP_CHANNELGROUP_PERMISSIONS = "b_client_skip_channelgroup_permissions", - B_CLIENT_FORCE_PUSH_TO_TALK = "b_client_force_push_to_talk", - B_CLIENT_IGNORE_BANS = "b_client_ignore_bans", - B_CLIENT_IGNORE_VPN = "b_client_ignore_vpn", - B_CLIENT_IGNORE_ANTIFLOOD = "b_client_ignore_antiflood", - B_CLIENT_ENFORCE_VALID_HWID = "b_client_enforce_valid_hwid", - B_CLIENT_ALLOW_INVALID_PACKET = "b_client_allow_invalid_packet", - B_CLIENT_ALLOW_INVALID_BADGES = "b_client_allow_invalid_badges", - B_CLIENT_ISSUE_CLIENT_QUERY_COMMAND = "b_client_issue_client_query_command", - B_CLIENT_USE_RESERVED_SLOT = "b_client_use_reserved_slot", - B_CLIENT_USE_CHANNEL_COMMANDER = "b_client_use_channel_commander", - B_CLIENT_REQUEST_TALKER = "b_client_request_talker", - B_CLIENT_AVATAR_DELETE_OTHER = "b_client_avatar_delete_other", - B_CLIENT_IS_STICKY = "b_client_is_sticky", - B_CLIENT_IGNORE_STICKY = "b_client_ignore_sticky", - B_CLIENT_MUSIC_CREATE_PERMANENT = "b_client_music_create_permanent", - B_CLIENT_MUSIC_CREATE_SEMI_PERMANENT = "b_client_music_create_semi_permanent", - B_CLIENT_MUSIC_CREATE_TEMPORARY = "b_client_music_create_temporary", - B_CLIENT_MUSIC_MODIFY_PERMANENT = "b_client_music_modify_permanent", - B_CLIENT_MUSIC_MODIFY_SEMI_PERMANENT = "b_client_music_modify_semi_permanent", - B_CLIENT_MUSIC_MODIFY_TEMPORARY = "b_client_music_modify_temporary", - I_CLIENT_MUSIC_CREATE_MODIFY_MAX_VOLUME = "i_client_music_create_modify_max_volume", - I_CLIENT_MUSIC_LIMIT = "i_client_music_limit", - I_CLIENT_MUSIC_NEEDED_DELETE_POWER = "i_client_music_needed_delete_power", - I_CLIENT_MUSIC_DELETE_POWER = "i_client_music_delete_power", - I_CLIENT_MUSIC_PLAY_POWER = "i_client_music_play_power", - I_CLIENT_MUSIC_NEEDED_PLAY_POWER = "i_client_music_needed_play_power", - I_CLIENT_MUSIC_MODIFY_POWER = "i_client_music_modify_power", - I_CLIENT_MUSIC_NEEDED_MODIFY_POWER = "i_client_music_needed_modify_power", - I_CLIENT_MUSIC_RENAME_POWER = "i_client_music_rename_power", - I_CLIENT_MUSIC_NEEDED_RENAME_POWER = "i_client_music_needed_rename_power", - B_PLAYLIST_CREATE = "b_playlist_create", - I_PLAYLIST_VIEW_POWER = "i_playlist_view_power", - I_PLAYLIST_NEEDED_VIEW_POWER = "i_playlist_needed_view_power", - I_PLAYLIST_MODIFY_POWER = "i_playlist_modify_power", - I_PLAYLIST_NEEDED_MODIFY_POWER = "i_playlist_needed_modify_power", - I_PLAYLIST_PERMISSION_MODIFY_POWER = "i_playlist_permission_modify_power", - I_PLAYLIST_NEEDED_PERMISSION_MODIFY_POWER = "i_playlist_needed_permission_modify_power", - I_PLAYLIST_DELETE_POWER = "i_playlist_delete_power", - I_PLAYLIST_NEEDED_DELETE_POWER = "i_playlist_needed_delete_power", - I_PLAYLIST_SONG_ADD_POWER = "i_playlist_song_add_power", - I_PLAYLIST_SONG_NEEDED_ADD_POWER = "i_playlist_song_needed_add_power", - I_PLAYLIST_SONG_REMOVE_POWER = "i_playlist_song_remove_power", - I_PLAYLIST_SONG_NEEDED_REMOVE_POWER = "i_playlist_song_needed_remove_power", - B_CLIENT_INFO_VIEW = "b_client_info_view", - B_CLIENT_PERMISSIONOVERVIEW_VIEW = "b_client_permissionoverview_view", - B_CLIENT_PERMISSIONOVERVIEW_OWN = "b_client_permissionoverview_own", - B_CLIENT_REMOTEADDRESS_VIEW = "b_client_remoteaddress_view", - I_CLIENT_SERVERQUERY_VIEW_POWER = "i_client_serverquery_view_power", - I_CLIENT_NEEDED_SERVERQUERY_VIEW_POWER = "i_client_needed_serverquery_view_power", - B_CLIENT_CUSTOM_INFO_VIEW = "b_client_custom_info_view", - B_CLIENT_MUSIC_CHANNEL_LIST = "b_client_music_channel_list", - B_CLIENT_MUSIC_SERVER_LIST = "b_client_music_server_list", - I_CLIENT_MUSIC_INFO = "i_client_music_info", - I_CLIENT_MUSIC_NEEDED_INFO = "i_client_music_needed_info", - I_CLIENT_KICK_FROM_SERVER_POWER = "i_client_kick_from_server_power", - I_CLIENT_NEEDED_KICK_FROM_SERVER_POWER = "i_client_needed_kick_from_server_power", - I_CLIENT_KICK_FROM_CHANNEL_POWER = "i_client_kick_from_channel_power", - I_CLIENT_NEEDED_KICK_FROM_CHANNEL_POWER = "i_client_needed_kick_from_channel_power", - I_CLIENT_BAN_POWER = "i_client_ban_power", - I_CLIENT_NEEDED_BAN_POWER = "i_client_needed_ban_power", - I_CLIENT_MOVE_POWER = "i_client_move_power", - I_CLIENT_NEEDED_MOVE_POWER = "i_client_needed_move_power", - I_CLIENT_COMPLAIN_POWER = "i_client_complain_power", - I_CLIENT_NEEDED_COMPLAIN_POWER = "i_client_needed_complain_power", - B_CLIENT_COMPLAIN_LIST = "b_client_complain_list", - B_CLIENT_COMPLAIN_DELETE_OWN = "b_client_complain_delete_own", - B_CLIENT_COMPLAIN_DELETE = "b_client_complain_delete", - B_CLIENT_BAN_LIST = "b_client_ban_list", - B_CLIENT_BAN_LIST_GLOBAL = "b_client_ban_list_global", - B_CLIENT_BAN_TRIGGER_LIST = "b_client_ban_trigger_list", - B_CLIENT_BAN_CREATE = "b_client_ban_create", - B_CLIENT_BAN_CREATE_GLOBAL = "b_client_ban_create_global", - B_CLIENT_BAN_NAME = "b_client_ban_name", - B_CLIENT_BAN_IP = "b_client_ban_ip", - B_CLIENT_BAN_HWID = "b_client_ban_hwid", - B_CLIENT_BAN_EDIT = "b_client_ban_edit", - B_CLIENT_BAN_EDIT_GLOBAL = "b_client_ban_edit_global", - B_CLIENT_BAN_DELETE_OWN = "b_client_ban_delete_own", - B_CLIENT_BAN_DELETE = "b_client_ban_delete", - B_CLIENT_BAN_DELETE_OWN_GLOBAL = "b_client_ban_delete_own_global", - B_CLIENT_BAN_DELETE_GLOBAL = "b_client_ban_delete_global", - I_CLIENT_BAN_MAX_BANTIME = "i_client_ban_max_bantime", - I_CLIENT_PRIVATE_TEXTMESSAGE_POWER = "i_client_private_textmessage_power", - I_CLIENT_NEEDED_PRIVATE_TEXTMESSAGE_POWER = "i_client_needed_private_textmessage_power", - B_CLIENT_EVEN_TEXTMESSAGE_SEND = "b_client_even_textmessage_send", - B_CLIENT_SERVER_TEXTMESSAGE_SEND = "b_client_server_textmessage_send", - B_CLIENT_CHANNEL_TEXTMESSAGE_SEND = "b_client_channel_textmessage_send", - B_CLIENT_OFFLINE_TEXTMESSAGE_SEND = "b_client_offline_textmessage_send", - I_CLIENT_TALK_POWER = "i_client_talk_power", - I_CLIENT_NEEDED_TALK_POWER = "i_client_needed_talk_power", - I_CLIENT_POKE_POWER = "i_client_poke_power", - I_CLIENT_NEEDED_POKE_POWER = "i_client_needed_poke_power", - B_CLIENT_SET_FLAG_TALKER = "b_client_set_flag_talker", - I_CLIENT_WHISPER_POWER = "i_client_whisper_power", - I_CLIENT_NEEDED_WHISPER_POWER = "i_client_needed_whisper_power", - B_CLIENT_MODIFY_DESCRIPTION = "b_client_modify_description", - B_CLIENT_MODIFY_OWN_DESCRIPTION = "b_client_modify_own_description", - B_CLIENT_USE_BBCODE_ANY = "b_client_use_bbcode_any", - B_CLIENT_USE_BBCODE_URL = "b_client_use_bbcode_url", - B_CLIENT_USE_BBCODE_IMAGE = "b_client_use_bbcode_image", - B_CLIENT_MODIFY_DBPROPERTIES = "b_client_modify_dbproperties", - B_CLIENT_DELETE_DBPROPERTIES = "b_client_delete_dbproperties", - B_CLIENT_CREATE_MODIFY_SERVERQUERY_LOGIN = "b_client_create_modify_serverquery_login", - B_CLIENT_QUERY_CREATE = "b_client_query_create", - B_CLIENT_QUERY_LIST = "b_client_query_list", - B_CLIENT_QUERY_LIST_OWN = "b_client_query_list_own", - B_CLIENT_QUERY_RENAME = "b_client_query_rename", - B_CLIENT_QUERY_RENAME_OWN = "b_client_query_rename_own", - B_CLIENT_QUERY_CHANGE_PASSWORD = "b_client_query_change_password", - B_CLIENT_QUERY_CHANGE_OWN_PASSWORD = "b_client_query_change_own_password", - B_CLIENT_QUERY_CHANGE_PASSWORD_GLOBAL = "b_client_query_change_password_global", - B_CLIENT_QUERY_DELETE = "b_client_query_delete", - B_CLIENT_QUERY_DELETE_OWN = "b_client_query_delete_own", - B_FT_IGNORE_PASSWORD = "b_ft_ignore_password", - B_FT_TRANSFER_LIST = "b_ft_transfer_list", - I_FT_FILE_UPLOAD_POWER = "i_ft_file_upload_power", - I_FT_NEEDED_FILE_UPLOAD_POWER = "i_ft_needed_file_upload_power", - I_FT_FILE_DOWNLOAD_POWER = "i_ft_file_download_power", - I_FT_NEEDED_FILE_DOWNLOAD_POWER = "i_ft_needed_file_download_power", - I_FT_FILE_DELETE_POWER = "i_ft_file_delete_power", - I_FT_NEEDED_FILE_DELETE_POWER = "i_ft_needed_file_delete_power", - I_FT_FILE_RENAME_POWER = "i_ft_file_rename_power", - I_FT_NEEDED_FILE_RENAME_POWER = "i_ft_needed_file_rename_power", - I_FT_FILE_BROWSE_POWER = "i_ft_file_browse_power", - I_FT_NEEDED_FILE_BROWSE_POWER = "i_ft_needed_file_browse_power", - I_FT_DIRECTORY_CREATE_POWER = "i_ft_directory_create_power", - I_FT_NEEDED_DIRECTORY_CREATE_POWER = "i_ft_needed_directory_create_power", - I_FT_QUOTA_MB_DOWNLOAD_PER_CLIENT = "i_ft_quota_mb_download_per_client", - I_FT_QUOTA_MB_UPLOAD_PER_CLIENT = "i_ft_quota_mb_upload_per_client" + B_SERVERINSTANCE_HELP_VIEW = "b_serverinstance_help_view", /* Permission ID: 1 */ + B_SERVERINSTANCE_VERSION_VIEW = "b_serverinstance_version_view", /* Permission ID: 2 */ + B_SERVERINSTANCE_INFO_VIEW = "b_serverinstance_info_view", /* Permission ID: 3 */ + B_SERVERINSTANCE_VIRTUALSERVER_LIST = "b_serverinstance_virtualserver_list", /* Permission ID: 4 */ + B_SERVERINSTANCE_BINDING_LIST = "b_serverinstance_binding_list", /* Permission ID: 5 */ + B_SERVERINSTANCE_PERMISSION_LIST = "b_serverinstance_permission_list", /* Permission ID: 6 */ + B_SERVERINSTANCE_PERMISSION_FIND = "b_serverinstance_permission_find", /* Permission ID: 7 */ + B_VIRTUALSERVER_CREATE = "b_virtualserver_create", /* Permission ID: 8 */ + B_VIRTUALSERVER_DELETE = "b_virtualserver_delete", /* Permission ID: 9 */ + B_VIRTUALSERVER_START_ANY = "b_virtualserver_start_any", /* Permission ID: 10 */ + B_VIRTUALSERVER_STOP_ANY = "b_virtualserver_stop_any", /* Permission ID: 11 */ + B_VIRTUALSERVER_CHANGE_MACHINE_ID = "b_virtualserver_change_machine_id", /* Permission ID: 12 */ + B_VIRTUALSERVER_CHANGE_TEMPLATE = "b_virtualserver_change_template", /* Permission ID: 13 */ + B_SERVERQUERY_LOGIN = "b_serverquery_login", /* Permission ID: 14 */ + B_SERVERINSTANCE_TEXTMESSAGE_SEND = "b_serverinstance_textmessage_send", /* Permission ID: 15 */ + B_SERVERINSTANCE_LOG_VIEW = "b_serverinstance_log_view", /* Permission ID: 16 */ + B_SERVERINSTANCE_LOG_ADD = "b_serverinstance_log_add", /* Permission ID: 17 */ + B_SERVERINSTANCE_STOP = "b_serverinstance_stop", /* Permission ID: 18 */ + B_SERVERINSTANCE_MODIFY_SETTINGS = "b_serverinstance_modify_settings", /* Permission ID: 19 */ + B_SERVERINSTANCE_MODIFY_QUERYGROUP = "b_serverinstance_modify_querygroup", /* Permission ID: 20 */ + B_SERVERINSTANCE_MODIFY_TEMPLATES = "b_serverinstance_modify_templates", /* Permission ID: 21 */ + B_VIRTUALSERVER_SELECT = "b_virtualserver_select", /* Permission ID: 22 */ + B_VIRTUALSERVER_SELECT_GODMODE = "b_virtualserver_select_godmode", /* Permission ID: 23 */ + B_VIRTUALSERVER_INFO_VIEW = "b_virtualserver_info_view", /* Permission ID: 24 */ + B_VIRTUALSERVER_CONNECTIONINFO_VIEW = "b_virtualserver_connectioninfo_view", /* Permission ID: 25 */ + B_VIRTUALSERVER_CHANNEL_LIST = "b_virtualserver_channel_list", /* Permission ID: 26 */ + B_VIRTUALSERVER_CHANNEL_SEARCH = "b_virtualserver_channel_search", /* Permission ID: 27 */ + B_VIRTUALSERVER_CLIENT_LIST = "b_virtualserver_client_list", /* Permission ID: 28 */ + B_VIRTUALSERVER_CLIENT_SEARCH = "b_virtualserver_client_search", /* Permission ID: 29 */ + B_VIRTUALSERVER_CLIENT_DBLIST = "b_virtualserver_client_dblist", /* Permission ID: 30 */ + B_VIRTUALSERVER_CLIENT_DBSEARCH = "b_virtualserver_client_dbsearch", /* Permission ID: 31 */ + B_VIRTUALSERVER_CLIENT_DBINFO = "b_virtualserver_client_dbinfo", /* Permission ID: 32 */ + B_VIRTUALSERVER_PERMISSION_FIND = "b_virtualserver_permission_find", /* Permission ID: 33 */ + B_VIRTUALSERVER_CUSTOM_SEARCH = "b_virtualserver_custom_search", /* Permission ID: 34 */ + B_VIRTUALSERVER_START = "b_virtualserver_start", /* Permission ID: 35 */ + B_VIRTUALSERVER_STOP = "b_virtualserver_stop", /* Permission ID: 36 */ + B_VIRTUALSERVER_TOKEN_LIST = "b_virtualserver_token_list", /* Permission ID: 37 */ + B_VIRTUALSERVER_TOKEN_ADD = "b_virtualserver_token_add", /* Permission ID: 38 */ + B_VIRTUALSERVER_TOKEN_USE = "b_virtualserver_token_use", /* Permission ID: 39 */ + B_VIRTUALSERVER_TOKEN_DELETE = "b_virtualserver_token_delete", /* Permission ID: 40 */ + B_VIRTUALSERVER_LOG_VIEW = "b_virtualserver_log_view", /* Permission ID: 41 */ + B_VIRTUALSERVER_LOG_ADD = "b_virtualserver_log_add", /* Permission ID: 42 */ + B_VIRTUALSERVER_JOIN_IGNORE_PASSWORD = "b_virtualserver_join_ignore_password", /* Permission ID: 43 */ + B_VIRTUALSERVER_NOTIFY_REGISTER = "b_virtualserver_notify_register", /* Permission ID: 44 */ + B_VIRTUALSERVER_NOTIFY_UNREGISTER = "b_virtualserver_notify_unregister", /* Permission ID: 45 */ + B_VIRTUALSERVER_SNAPSHOT_CREATE = "b_virtualserver_snapshot_create", /* Permission ID: 46 */ + B_VIRTUALSERVER_SNAPSHOT_DEPLOY = "b_virtualserver_snapshot_deploy", /* Permission ID: 47 */ + B_VIRTUALSERVER_PERMISSION_RESET = "b_virtualserver_permission_reset", /* Permission ID: 48 */ + B_VIRTUALSERVER_MODIFY_NAME = "b_virtualserver_modify_name", /* Permission ID: 49 */ + B_VIRTUALSERVER_MODIFY_WELCOMEMESSAGE = "b_virtualserver_modify_welcomemessage", /* Permission ID: 50 */ + B_VIRTUALSERVER_MODIFY_MAXCHANNELS = "b_virtualserver_modify_maxchannels", /* Permission ID: 51 */ + B_VIRTUALSERVER_MODIFY_MAXCLIENTS = "b_virtualserver_modify_maxclients", /* Permission ID: 52 */ + B_VIRTUALSERVER_MODIFY_RESERVED_SLOTS = "b_virtualserver_modify_reserved_slots", /* Permission ID: 53 */ + B_VIRTUALSERVER_MODIFY_PASSWORD = "b_virtualserver_modify_password", /* Permission ID: 54 */ + B_VIRTUALSERVER_MODIFY_DEFAULT_SERVERGROUP = "b_virtualserver_modify_default_servergroup", /* Permission ID: 55 */ + B_VIRTUALSERVER_MODIFY_DEFAULT_MUSICGROUP = "b_virtualserver_modify_default_musicgroup", /* Permission ID: 56 */ + B_VIRTUALSERVER_MODIFY_DEFAULT_CHANNELGROUP = "b_virtualserver_modify_default_channelgroup", /* Permission ID: 57 */ + B_VIRTUALSERVER_MODIFY_DEFAULT_CHANNELADMINGROUP = "b_virtualserver_modify_default_channeladmingroup", /* Permission ID: 58 */ + B_VIRTUALSERVER_MODIFY_CHANNEL_FORCED_SILENCE = "b_virtualserver_modify_channel_forced_silence", /* Permission ID: 59 */ + B_VIRTUALSERVER_MODIFY_COMPLAIN = "b_virtualserver_modify_complain", /* Permission ID: 60 */ + B_VIRTUALSERVER_MODIFY_ANTIFLOOD = "b_virtualserver_modify_antiflood", /* Permission ID: 61 */ + B_VIRTUALSERVER_MODIFY_FT_SETTINGS = "b_virtualserver_modify_ft_settings", /* Permission ID: 62 */ + B_VIRTUALSERVER_MODIFY_FT_QUOTAS = "b_virtualserver_modify_ft_quotas", /* Permission ID: 63 */ + B_VIRTUALSERVER_MODIFY_HOSTMESSAGE = "b_virtualserver_modify_hostmessage", /* Permission ID: 64 */ + B_VIRTUALSERVER_MODIFY_HOSTBANNER = "b_virtualserver_modify_hostbanner", /* Permission ID: 65 */ + B_VIRTUALSERVER_MODIFY_HOSTBUTTON = "b_virtualserver_modify_hostbutton", /* Permission ID: 66 */ + B_VIRTUALSERVER_MODIFY_PORT = "b_virtualserver_modify_port", /* Permission ID: 67 */ + B_VIRTUALSERVER_MODIFY_HOST = "b_virtualserver_modify_host", /* Permission ID: 68 */ + B_VIRTUALSERVER_MODIFY_DEFAULT_MESSAGES = "b_virtualserver_modify_default_messages", /* Permission ID: 69 */ + B_VIRTUALSERVER_MODIFY_AUTOSTART = "b_virtualserver_modify_autostart", /* Permission ID: 70 */ + B_VIRTUALSERVER_MODIFY_NEEDED_IDENTITY_SECURITY_LEVEL = "b_virtualserver_modify_needed_identity_security_level", /* Permission ID: 71 */ + B_VIRTUALSERVER_MODIFY_PRIORITY_SPEAKER_DIMM_MODIFICATOR = "b_virtualserver_modify_priority_speaker_dimm_modificator", /* Permission ID: 72 */ + B_VIRTUALSERVER_MODIFY_LOG_SETTINGS = "b_virtualserver_modify_log_settings", /* Permission ID: 73 */ + B_VIRTUALSERVER_MODIFY_MIN_CLIENT_VERSION = "b_virtualserver_modify_min_client_version", /* Permission ID: 74 */ + B_VIRTUALSERVER_MODIFY_ICON_ID = "b_virtualserver_modify_icon_id", /* Permission ID: 75 */ + B_VIRTUALSERVER_MODIFY_WEBLIST = "b_virtualserver_modify_weblist", /* Permission ID: 76 */ + B_VIRTUALSERVER_MODIFY_COUNTRY_CODE = "b_virtualserver_modify_country_code", /* Permission ID: 77 */ + B_VIRTUALSERVER_MODIFY_CODEC_ENCRYPTION_MODE = "b_virtualserver_modify_codec_encryption_mode", /* Permission ID: 78 */ + B_VIRTUALSERVER_MODIFY_TEMPORARY_PASSWORDS = "b_virtualserver_modify_temporary_passwords", /* Permission ID: 79 */ + B_VIRTUALSERVER_MODIFY_TEMPORARY_PASSWORDS_OWN = "b_virtualserver_modify_temporary_passwords_own", /* Permission ID: 80 */ + B_VIRTUALSERVER_MODIFY_CHANNEL_TEMP_DELETE_DELAY_DEFAULT = "b_virtualserver_modify_channel_temp_delete_delay_default", /* Permission ID: 81 */ + B_VIRTUALSERVER_MODIFY_MUSIC_BOT_LIMIT = "b_virtualserver_modify_music_bot_limit", /* Permission ID: 82 */ + I_CHANNEL_MIN_DEPTH = "i_channel_min_depth", /* Permission ID: 83 */ + I_CHANNEL_MAX_DEPTH = "i_channel_max_depth", /* Permission ID: 84 */ + B_CHANNEL_GROUP_INHERITANCE_END = "b_channel_group_inheritance_end", /* Permission ID: 85 */ + I_CHANNEL_PERMISSION_MODIFY_POWER = "i_channel_permission_modify_power", /* Permission ID: 86 */ + I_CHANNEL_NEEDED_PERMISSION_MODIFY_POWER = "i_channel_needed_permission_modify_power", /* Permission ID: 87 */ + B_CHANNEL_INFO_VIEW = "b_channel_info_view", /* Permission ID: 88 */ + B_VIRTUALSERVER_CHANNEL_PERMISSION_LIST = "b_virtualserver_channel_permission_list", /* Permission ID: 89 */ + B_CHANNEL_CREATE_CHILD = "b_channel_create_child", /* Permission ID: 90 */ + B_CHANNEL_CREATE_PERMANENT = "b_channel_create_permanent", /* Permission ID: 91 */ + B_CHANNEL_CREATE_SEMI_PERMANENT = "b_channel_create_semi_permanent", /* Permission ID: 92 */ + B_CHANNEL_CREATE_TEMPORARY = "b_channel_create_temporary", /* Permission ID: 93 */ + B_CHANNEL_CREATE_WITH_TOPIC = "b_channel_create_with_topic", /* Permission ID: 94 */ + B_CHANNEL_CREATE_WITH_DESCRIPTION = "b_channel_create_with_description", /* Permission ID: 95 */ + B_CHANNEL_CREATE_WITH_PASSWORD = "b_channel_create_with_password", /* Permission ID: 96 */ + B_CHANNEL_CREATE_MODIFY_WITH_CODEC_OPUSVOICE = "b_channel_create_modify_with_codec_opusvoice", /* Permission ID: 97 */ + B_CHANNEL_CREATE_MODIFY_WITH_CODEC_OPUSMUSIC = "b_channel_create_modify_with_codec_opusmusic", /* Permission ID: 98 */ + I_CHANNEL_CREATE_MODIFY_WITH_CODEC_MAXQUALITY = "i_channel_create_modify_with_codec_maxquality", /* Permission ID: 99 */ + I_CHANNEL_CREATE_MODIFY_WITH_CODEC_LATENCY_FACTOR_MIN = "i_channel_create_modify_with_codec_latency_factor_min", /* Permission ID: 100 */ + B_CHANNEL_CREATE_WITH_MAXCLIENTS = "b_channel_create_with_maxclients", /* Permission ID: 101 */ + B_CHANNEL_CREATE_WITH_MAXFAMILYCLIENTS = "b_channel_create_with_maxfamilyclients", /* Permission ID: 102 */ + B_CHANNEL_CREATE_WITH_SORTORDER = "b_channel_create_with_sortorder", /* Permission ID: 103 */ + B_CHANNEL_CREATE_WITH_DEFAULT = "b_channel_create_with_default", /* Permission ID: 104 */ + B_CHANNEL_CREATE_WITH_NEEDED_TALK_POWER = "b_channel_create_with_needed_talk_power", /* Permission ID: 105 */ + B_CHANNEL_CREATE_MODIFY_WITH_FORCE_PASSWORD = "b_channel_create_modify_with_force_password", /* Permission ID: 106 */ + I_CHANNEL_CREATE_MODIFY_WITH_TEMP_DELETE_DELAY = "i_channel_create_modify_with_temp_delete_delay", /* Permission ID: 107 */ + I_CHANNEL_CREATE_MODIFY_CONVERSATION_HISTORY_LENGTH = "i_channel_create_modify_conversation_history_length", /* Permission ID: 108 */ + B_CHANNEL_CREATE_MODIFY_CONVERSATION_HISTORY_UNLIMITED = "b_channel_create_modify_conversation_history_unlimited", /* Permission ID: 109 */ + B_CHANNEL_CREATE_MODIFY_CONVERSATION_MODE_PRIVATE = "b_channel_create_modify_conversation_mode_private", /* Permission ID: 110 */ + B_CHANNEL_CREATE_MODIFY_CONVERSATION_MODE_PUBLIC = "b_channel_create_modify_conversation_mode_public", /* Permission ID: 111 */ + B_CHANNEL_CREATE_MODIFY_CONVERSATION_MODE_NONE = "b_channel_create_modify_conversation_mode_none", /* Permission ID: 112 */ + B_CHANNEL_CREATE_MODIFY_SIDEBAR_MODE = "b_channel_create_modify_sidebar_mode", /* Permission ID: 113 */ + B_CHANNEL_MODIFY_PARENT = "b_channel_modify_parent", /* Permission ID: 114 */ + B_CHANNEL_MODIFY_MAKE_DEFAULT = "b_channel_modify_make_default", /* Permission ID: 115 */ + B_CHANNEL_MODIFY_MAKE_PERMANENT = "b_channel_modify_make_permanent", /* Permission ID: 116 */ + B_CHANNEL_MODIFY_MAKE_SEMI_PERMANENT = "b_channel_modify_make_semi_permanent", /* Permission ID: 117 */ + B_CHANNEL_MODIFY_MAKE_TEMPORARY = "b_channel_modify_make_temporary", /* Permission ID: 118 */ + B_CHANNEL_MODIFY_NAME = "b_channel_modify_name", /* Permission ID: 119 */ + B_CHANNEL_MODIFY_TOPIC = "b_channel_modify_topic", /* Permission ID: 120 */ + B_CHANNEL_MODIFY_DESCRIPTION = "b_channel_modify_description", /* Permission ID: 121 */ + B_CHANNEL_MODIFY_PASSWORD = "b_channel_modify_password", /* Permission ID: 122 */ + B_CHANNEL_MODIFY_CODEC = "b_channel_modify_codec", /* Permission ID: 123 */ + B_CHANNEL_MODIFY_CODEC_QUALITY = "b_channel_modify_codec_quality", /* Permission ID: 124 */ + B_CHANNEL_MODIFY_CODEC_LATENCY_FACTOR = "b_channel_modify_codec_latency_factor", /* Permission ID: 125 */ + B_CHANNEL_MODIFY_MAXCLIENTS = "b_channel_modify_maxclients", /* Permission ID: 126 */ + B_CHANNEL_MODIFY_MAXFAMILYCLIENTS = "b_channel_modify_maxfamilyclients", /* Permission ID: 127 */ + B_CHANNEL_MODIFY_SORTORDER = "b_channel_modify_sortorder", /* Permission ID: 128 */ + B_CHANNEL_MODIFY_NEEDED_TALK_POWER = "b_channel_modify_needed_talk_power", /* Permission ID: 129 */ + I_CHANNEL_MODIFY_POWER = "i_channel_modify_power", /* Permission ID: 130 */ + I_CHANNEL_NEEDED_MODIFY_POWER = "i_channel_needed_modify_power", /* Permission ID: 131 */ + B_CHANNEL_MODIFY_MAKE_CODEC_ENCRYPTED = "b_channel_modify_make_codec_encrypted", /* Permission ID: 132 */ + B_CHANNEL_MODIFY_TEMP_DELETE_DELAY = "b_channel_modify_temp_delete_delay", /* Permission ID: 133 */ + B_CHANNEL_CONVERSATION_MESSAGE_DELETE = "b_channel_conversation_message_delete", /* Permission ID: 134 */ + B_CHANNEL_DELETE_PERMANENT = "b_channel_delete_permanent", /* Permission ID: 135 */ + B_CHANNEL_DELETE_SEMI_PERMANENT = "b_channel_delete_semi_permanent", /* Permission ID: 136 */ + B_CHANNEL_DELETE_TEMPORARY = "b_channel_delete_temporary", /* Permission ID: 137 */ + B_CHANNEL_DELETE_FLAG_FORCE = "b_channel_delete_flag_force", /* Permission ID: 138 */ + I_CHANNEL_DELETE_POWER = "i_channel_delete_power", /* Permission ID: 139 */ + I_CHANNEL_NEEDED_DELETE_POWER = "i_channel_needed_delete_power", /* Permission ID: 140 */ + B_CHANNEL_JOIN_PERMANENT = "b_channel_join_permanent", /* Permission ID: 141 */ + B_CHANNEL_JOIN_SEMI_PERMANENT = "b_channel_join_semi_permanent", /* Permission ID: 142 */ + B_CHANNEL_JOIN_TEMPORARY = "b_channel_join_temporary", /* Permission ID: 143 */ + B_CHANNEL_JOIN_IGNORE_PASSWORD = "b_channel_join_ignore_password", /* Permission ID: 144 */ + B_CHANNEL_JOIN_IGNORE_MAXCLIENTS = "b_channel_join_ignore_maxclients", /* Permission ID: 145 */ + I_CHANNEL_JOIN_POWER = "i_channel_join_power", /* Permission ID: 146 */ + I_CHANNEL_NEEDED_JOIN_POWER = "i_channel_needed_join_power", /* Permission ID: 147 */ + B_CHANNEL_IGNORE_JOIN_POWER = "b_channel_ignore_join_power", /* Permission ID: 148 */ + I_CHANNEL_VIEW_POWER = "i_channel_view_power", /* Permission ID: 149 */ + I_CHANNEL_NEEDED_VIEW_POWER = "i_channel_needed_view_power", /* Permission ID: 150 */ + B_CHANNEL_IGNORE_VIEW_POWER = "b_channel_ignore_view_power", /* Permission ID: 151 */ + I_CHANNEL_SUBSCRIBE_POWER = "i_channel_subscribe_power", /* Permission ID: 152 */ + I_CHANNEL_NEEDED_SUBSCRIBE_POWER = "i_channel_needed_subscribe_power", /* Permission ID: 153 */ + B_CHANNEL_IGNORE_SUBSCRIBE_POWER = "b_channel_ignore_subscribe_power", /* Permission ID: 154 */ + I_CHANNEL_DESCRIPTION_VIEW_POWER = "i_channel_description_view_power", /* Permission ID: 155 */ + I_CHANNEL_NEEDED_DESCRIPTION_VIEW_POWER = "i_channel_needed_description_view_power", /* Permission ID: 156 */ + B_CHANNEL_IGNORE_DESCRIPTION_VIEW_POWER = "b_channel_ignore_description_view_power", /* Permission ID: 157 */ + I_ICON_ID = "i_icon_id", /* Permission ID: 158 */ + I_MAX_ICON_FILESIZE = "i_max_icon_filesize", /* Permission ID: 159 */ + I_MAX_PLAYLIST_SIZE = "i_max_playlist_size", /* Permission ID: 160 */ + I_MAX_PLAYLISTS = "i_max_playlists", /* Permission ID: 161 */ + B_ICON_MANAGE = "b_icon_manage", /* Permission ID: 162 */ + B_GROUP_IS_PERMANENT = "b_group_is_permanent", /* Permission ID: 163 */ + I_GROUP_AUTO_UPDATE_TYPE = "i_group_auto_update_type", /* Permission ID: 164 */ + I_GROUP_AUTO_UPDATE_MAX_VALUE = "i_group_auto_update_max_value", /* Permission ID: 165 */ + I_GROUP_SORT_ID = "i_group_sort_id", /* Permission ID: 166 */ + I_GROUP_SHOW_NAME_IN_TREE = "i_group_show_name_in_tree", /* Permission ID: 167 */ + B_VIRTUALSERVER_SERVERGROUP_LIST = "b_virtualserver_servergroup_list", /* Permission ID: 168 */ + B_VIRTUALSERVER_SERVERGROUP_PERMISSION_LIST = "b_virtualserver_servergroup_permission_list", /* Permission ID: 169 */ + B_VIRTUALSERVER_SERVERGROUP_CLIENT_LIST = "b_virtualserver_servergroup_client_list", /* Permission ID: 170 */ + B_VIRTUALSERVER_CHANNELGROUP_LIST = "b_virtualserver_channelgroup_list", /* Permission ID: 171 */ + B_VIRTUALSERVER_CHANNELGROUP_PERMISSION_LIST = "b_virtualserver_channelgroup_permission_list", /* Permission ID: 172 */ + B_VIRTUALSERVER_CHANNELGROUP_CLIENT_LIST = "b_virtualserver_channelgroup_client_list", /* Permission ID: 173 */ + B_VIRTUALSERVER_SERVERGROUP_CREATE = "b_virtualserver_servergroup_create", /* Permission ID: 174 */ + B_VIRTUALSERVER_CHANNELGROUP_CREATE = "b_virtualserver_channelgroup_create", /* Permission ID: 175 */ + I_SERVER_GROUP_MODIFY_POWER = "i_server_group_modify_power", /* Permission ID: 176 */ + I_SERVER_GROUP_NEEDED_MODIFY_POWER = "i_server_group_needed_modify_power", /* Permission ID: 177 */ + I_SERVER_GROUP_MEMBER_ADD_POWER = "i_server_group_member_add_power", /* Permission ID: 178 */ + I_SERVER_GROUP_SELF_ADD_POWER = "i_server_group_self_add_power", /* Permission ID: 179 */ + I_SERVER_GROUP_NEEDED_MEMBER_ADD_POWER = "i_server_group_needed_member_add_power", /* Permission ID: 180 */ + I_SERVER_GROUP_MEMBER_REMOVE_POWER = "i_server_group_member_remove_power", /* Permission ID: 181 */ + I_SERVER_GROUP_SELF_REMOVE_POWER = "i_server_group_self_remove_power", /* Permission ID: 182 */ + I_SERVER_GROUP_NEEDED_MEMBER_REMOVE_POWER = "i_server_group_needed_member_remove_power", /* Permission ID: 183 */ + I_CHANNEL_GROUP_MODIFY_POWER = "i_channel_group_modify_power", /* Permission ID: 184 */ + I_CHANNEL_GROUP_NEEDED_MODIFY_POWER = "i_channel_group_needed_modify_power", /* Permission ID: 185 */ + I_CHANNEL_GROUP_MEMBER_ADD_POWER = "i_channel_group_member_add_power", /* Permission ID: 186 */ + I_CHANNEL_GROUP_SELF_ADD_POWER = "i_channel_group_self_add_power", /* Permission ID: 187 */ + I_CHANNEL_GROUP_NEEDED_MEMBER_ADD_POWER = "i_channel_group_needed_member_add_power", /* Permission ID: 188 */ + I_CHANNEL_GROUP_MEMBER_REMOVE_POWER = "i_channel_group_member_remove_power", /* Permission ID: 189 */ + I_CHANNEL_GROUP_SELF_REMOVE_POWER = "i_channel_group_self_remove_power", /* Permission ID: 190 */ + I_CHANNEL_GROUP_NEEDED_MEMBER_REMOVE_POWER = "i_channel_group_needed_member_remove_power", /* Permission ID: 191 */ + I_GROUP_MEMBER_ADD_POWER = "i_group_member_add_power", /* Permission ID: 192 */ + I_GROUP_NEEDED_MEMBER_ADD_POWER = "i_group_needed_member_add_power", /* Permission ID: 193 */ + I_GROUP_MEMBER_REMOVE_POWER = "i_group_member_remove_power", /* Permission ID: 194 */ + I_GROUP_NEEDED_MEMBER_REMOVE_POWER = "i_group_needed_member_remove_power", /* Permission ID: 195 */ + I_GROUP_MODIFY_POWER = "i_group_modify_power", /* Permission ID: 196 */ + I_GROUP_NEEDED_MODIFY_POWER = "i_group_needed_modify_power", /* Permission ID: 197 */ + I_PERMISSION_MODIFY_POWER = "i_permission_modify_power", /* Permission ID: 198 */ + B_PERMISSION_MODIFY_POWER_IGNORE = "b_permission_modify_power_ignore", /* Permission ID: 199 */ + B_VIRTUALSERVER_SERVERGROUP_DELETE = "b_virtualserver_servergroup_delete", /* Permission ID: 200 */ + B_VIRTUALSERVER_CHANNELGROUP_DELETE = "b_virtualserver_channelgroup_delete", /* Permission ID: 201 */ + I_CLIENT_PERMISSION_MODIFY_POWER = "i_client_permission_modify_power", /* Permission ID: 202 */ + I_CLIENT_NEEDED_PERMISSION_MODIFY_POWER = "i_client_needed_permission_modify_power", /* Permission ID: 203 */ + I_CLIENT_MAX_CLONES_UID = "i_client_max_clones_uid", /* Permission ID: 204 */ + I_CLIENT_MAX_CLONES_IP = "i_client_max_clones_ip", /* Permission ID: 205 */ + I_CLIENT_MAX_CLONES_HWID = "i_client_max_clones_hwid", /* Permission ID: 206 */ + I_CLIENT_MAX_IDLETIME = "i_client_max_idletime", /* Permission ID: 207 */ + I_CLIENT_MAX_AVATAR_FILESIZE = "i_client_max_avatar_filesize", /* Permission ID: 208 */ + I_CLIENT_MAX_CHANNEL_SUBSCRIPTIONS = "i_client_max_channel_subscriptions", /* Permission ID: 209 */ + I_CLIENT_MAX_CHANNELS = "i_client_max_channels", /* Permission ID: 210 */ + I_CLIENT_MAX_TEMPORARY_CHANNELS = "i_client_max_temporary_channels", /* Permission ID: 211 */ + I_CLIENT_MAX_SEMI_CHANNELS = "i_client_max_semi_channels", /* Permission ID: 212 */ + I_CLIENT_MAX_PERMANENT_CHANNELS = "i_client_max_permanent_channels", /* Permission ID: 213 */ + B_CLIENT_USE_PRIORITY_SPEAKER = "b_client_use_priority_speaker", /* Permission ID: 214 */ + B_CLIENT_IS_PRIORITY_SPEAKER = "b_client_is_priority_speaker", /* Permission ID: 215 */ + B_CLIENT_SKIP_CHANNELGROUP_PERMISSIONS = "b_client_skip_channelgroup_permissions", /* Permission ID: 216 */ + B_CLIENT_FORCE_PUSH_TO_TALK = "b_client_force_push_to_talk", /* Permission ID: 217 */ + B_CLIENT_IGNORE_BANS = "b_client_ignore_bans", /* Permission ID: 218 */ + B_CLIENT_IGNORE_VPN = "b_client_ignore_vpn", /* Permission ID: 219 */ + B_CLIENT_IGNORE_ANTIFLOOD = "b_client_ignore_antiflood", /* Permission ID: 220 */ + B_CLIENT_ENFORCE_VALID_HWID = "b_client_enforce_valid_hwid", /* Permission ID: 221 */ + B_CLIENT_ALLOW_INVALID_PACKET = "b_client_allow_invalid_packet", /* Permission ID: 222 */ + B_CLIENT_ALLOW_INVALID_BADGES = "b_client_allow_invalid_badges", /* Permission ID: 223 */ + B_CLIENT_ISSUE_CLIENT_QUERY_COMMAND = "b_client_issue_client_query_command", /* Permission ID: 224 */ + B_CLIENT_USE_RESERVED_SLOT = "b_client_use_reserved_slot", /* Permission ID: 225 */ + B_CLIENT_USE_CHANNEL_COMMANDER = "b_client_use_channel_commander", /* Permission ID: 226 */ + B_CLIENT_REQUEST_TALKER = "b_client_request_talker", /* Permission ID: 227 */ + B_CLIENT_AVATAR_DELETE_OTHER = "b_client_avatar_delete_other", /* Permission ID: 228 */ + B_CLIENT_IS_STICKY = "b_client_is_sticky", /* Permission ID: 229 */ + B_CLIENT_IGNORE_STICKY = "b_client_ignore_sticky", /* Permission ID: 230 */ + B_CLIENT_MUSIC_CREATE_PERMANENT = "b_client_music_create_permanent", /* Permission ID: 231 */ + B_CLIENT_MUSIC_CREATE_SEMI_PERMANENT = "b_client_music_create_semi_permanent", /* Permission ID: 232 */ + B_CLIENT_MUSIC_CREATE_TEMPORARY = "b_client_music_create_temporary", /* Permission ID: 233 */ + B_CLIENT_MUSIC_MODIFY_PERMANENT = "b_client_music_modify_permanent", /* Permission ID: 234 */ + B_CLIENT_MUSIC_MODIFY_SEMI_PERMANENT = "b_client_music_modify_semi_permanent", /* Permission ID: 235 */ + B_CLIENT_MUSIC_MODIFY_TEMPORARY = "b_client_music_modify_temporary", /* Permission ID: 236 */ + I_CLIENT_MUSIC_CREATE_MODIFY_MAX_VOLUME = "i_client_music_create_modify_max_volume", /* Permission ID: 237 */ + I_CLIENT_MUSIC_LIMIT = "i_client_music_limit", /* Permission ID: 238 */ + I_CLIENT_MUSIC_NEEDED_DELETE_POWER = "i_client_music_needed_delete_power", /* Permission ID: 239 */ + I_CLIENT_MUSIC_DELETE_POWER = "i_client_music_delete_power", /* Permission ID: 240 */ + I_CLIENT_MUSIC_PLAY_POWER = "i_client_music_play_power", /* Permission ID: 241 */ + I_CLIENT_MUSIC_NEEDED_PLAY_POWER = "i_client_music_needed_play_power", /* Permission ID: 242 */ + I_CLIENT_MUSIC_MODIFY_POWER = "i_client_music_modify_power", /* Permission ID: 243 */ + I_CLIENT_MUSIC_NEEDED_MODIFY_POWER = "i_client_music_needed_modify_power", /* Permission ID: 244 */ + I_CLIENT_MUSIC_RENAME_POWER = "i_client_music_rename_power", /* Permission ID: 245 */ + I_CLIENT_MUSIC_NEEDED_RENAME_POWER = "i_client_music_needed_rename_power", /* Permission ID: 246 */ + B_VIRTUALSERVER_PLAYLIST_PERMISSION_LIST = "b_virtualserver_playlist_permission_list", /* Permission ID: 247 */ + B_PLAYLIST_CREATE = "b_playlist_create", /* Permission ID: 248 */ + I_PLAYLIST_VIEW_POWER = "i_playlist_view_power", /* Permission ID: 249 */ + I_PLAYLIST_NEEDED_VIEW_POWER = "i_playlist_needed_view_power", /* Permission ID: 250 */ + I_PLAYLIST_MODIFY_POWER = "i_playlist_modify_power", /* Permission ID: 251 */ + I_PLAYLIST_NEEDED_MODIFY_POWER = "i_playlist_needed_modify_power", /* Permission ID: 252 */ + I_PLAYLIST_PERMISSION_MODIFY_POWER = "i_playlist_permission_modify_power", /* Permission ID: 253 */ + I_PLAYLIST_NEEDED_PERMISSION_MODIFY_POWER = "i_playlist_needed_permission_modify_power", /* Permission ID: 254 */ + I_PLAYLIST_DELETE_POWER = "i_playlist_delete_power", /* Permission ID: 255 */ + I_PLAYLIST_NEEDED_DELETE_POWER = "i_playlist_needed_delete_power", /* Permission ID: 256 */ + I_PLAYLIST_SONG_ADD_POWER = "i_playlist_song_add_power", /* Permission ID: 257 */ + I_PLAYLIST_SONG_NEEDED_ADD_POWER = "i_playlist_song_needed_add_power", /* Permission ID: 258 */ + I_PLAYLIST_SONG_REMOVE_POWER = "i_playlist_song_remove_power", /* Permission ID: 259 */ + I_PLAYLIST_SONG_NEEDED_REMOVE_POWER = "i_playlist_song_needed_remove_power", /* Permission ID: 260 */ + I_PLAYLIST_SONG_MOVE_POWER = "i_playlist_song_move_power", /* Permission ID: 261 */ + I_PLAYLIST_SONG_NEEDED_MOVE_POWER = "i_playlist_song_needed_move_power", /* Permission ID: 262 */ + B_CLIENT_INFO_VIEW = "b_client_info_view", /* Permission ID: 263 */ + B_CLIENT_PERMISSIONOVERVIEW_VIEW = "b_client_permissionoverview_view", /* Permission ID: 264 */ + B_CLIENT_PERMISSIONOVERVIEW_OWN = "b_client_permissionoverview_own", /* Permission ID: 265 */ + B_CLIENT_REMOTEADDRESS_VIEW = "b_client_remoteaddress_view", /* Permission ID: 266 */ + I_CLIENT_SERVERQUERY_VIEW_POWER = "i_client_serverquery_view_power", /* Permission ID: 267 */ + I_CLIENT_NEEDED_SERVERQUERY_VIEW_POWER = "i_client_needed_serverquery_view_power", /* Permission ID: 268 */ + B_CLIENT_CUSTOM_INFO_VIEW = "b_client_custom_info_view", /* Permission ID: 269 */ + B_CLIENT_MUSIC_CHANNEL_LIST = "b_client_music_channel_list", /* Permission ID: 270 */ + B_CLIENT_MUSIC_SERVER_LIST = "b_client_music_server_list", /* Permission ID: 271 */ + I_CLIENT_MUSIC_INFO = "i_client_music_info", /* Permission ID: 272 */ + I_CLIENT_MUSIC_NEEDED_INFO = "i_client_music_needed_info", /* Permission ID: 273 */ + B_VIRTUALSERVER_CHANNELCLIENT_PERMISSION_LIST = "b_virtualserver_channelclient_permission_list", /* Permission ID: 274 */ + B_VIRTUALSERVER_CLIENT_PERMISSION_LIST = "b_virtualserver_client_permission_list", /* Permission ID: 275 */ + I_CLIENT_KICK_FROM_SERVER_POWER = "i_client_kick_from_server_power", /* Permission ID: 276 */ + I_CLIENT_NEEDED_KICK_FROM_SERVER_POWER = "i_client_needed_kick_from_server_power", /* Permission ID: 277 */ + I_CLIENT_KICK_FROM_CHANNEL_POWER = "i_client_kick_from_channel_power", /* Permission ID: 278 */ + I_CLIENT_NEEDED_KICK_FROM_CHANNEL_POWER = "i_client_needed_kick_from_channel_power", /* Permission ID: 279 */ + I_CLIENT_BAN_POWER = "i_client_ban_power", /* Permission ID: 280 */ + I_CLIENT_NEEDED_BAN_POWER = "i_client_needed_ban_power", /* Permission ID: 281 */ + I_CLIENT_MOVE_POWER = "i_client_move_power", /* Permission ID: 282 */ + I_CLIENT_NEEDED_MOVE_POWER = "i_client_needed_move_power", /* Permission ID: 283 */ + I_CLIENT_COMPLAIN_POWER = "i_client_complain_power", /* Permission ID: 284 */ + I_CLIENT_NEEDED_COMPLAIN_POWER = "i_client_needed_complain_power", /* Permission ID: 285 */ + B_CLIENT_COMPLAIN_LIST = "b_client_complain_list", /* Permission ID: 286 */ + B_CLIENT_COMPLAIN_DELETE_OWN = "b_client_complain_delete_own", /* Permission ID: 287 */ + B_CLIENT_COMPLAIN_DELETE = "b_client_complain_delete", /* Permission ID: 288 */ + B_CLIENT_BAN_LIST = "b_client_ban_list", /* Permission ID: 289 */ + B_CLIENT_BAN_LIST_GLOBAL = "b_client_ban_list_global", /* Permission ID: 290 */ + B_CLIENT_BAN_TRIGGER_LIST = "b_client_ban_trigger_list", /* Permission ID: 291 */ + B_CLIENT_BAN_CREATE = "b_client_ban_create", /* Permission ID: 292 */ + B_CLIENT_BAN_CREATE_GLOBAL = "b_client_ban_create_global", /* Permission ID: 293 */ + B_CLIENT_BAN_NAME = "b_client_ban_name", /* Permission ID: 294 */ + B_CLIENT_BAN_IP = "b_client_ban_ip", /* Permission ID: 295 */ + B_CLIENT_BAN_HWID = "b_client_ban_hwid", /* Permission ID: 296 */ + B_CLIENT_BAN_EDIT = "b_client_ban_edit", /* Permission ID: 297 */ + B_CLIENT_BAN_EDIT_GLOBAL = "b_client_ban_edit_global", /* Permission ID: 298 */ + B_CLIENT_BAN_DELETE_OWN = "b_client_ban_delete_own", /* Permission ID: 299 */ + B_CLIENT_BAN_DELETE = "b_client_ban_delete", /* Permission ID: 300 */ + B_CLIENT_BAN_DELETE_OWN_GLOBAL = "b_client_ban_delete_own_global", /* Permission ID: 301 */ + B_CLIENT_BAN_DELETE_GLOBAL = "b_client_ban_delete_global", /* Permission ID: 302 */ + I_CLIENT_BAN_MAX_BANTIME = "i_client_ban_max_bantime", /* Permission ID: 303 */ + I_CLIENT_PRIVATE_TEXTMESSAGE_POWER = "i_client_private_textmessage_power", /* Permission ID: 304 */ + I_CLIENT_NEEDED_PRIVATE_TEXTMESSAGE_POWER = "i_client_needed_private_textmessage_power", /* Permission ID: 305 */ + B_CLIENT_EVEN_TEXTMESSAGE_SEND = "b_client_even_textmessage_send", /* Permission ID: 306 */ + B_CLIENT_SERVER_TEXTMESSAGE_SEND = "b_client_server_textmessage_send", /* Permission ID: 307 */ + B_CLIENT_CHANNEL_TEXTMESSAGE_SEND = "b_client_channel_textmessage_send", /* Permission ID: 308 */ + B_CLIENT_OFFLINE_TEXTMESSAGE_SEND = "b_client_offline_textmessage_send", /* Permission ID: 309 */ + I_CLIENT_TALK_POWER = "i_client_talk_power", /* Permission ID: 310 */ + I_CLIENT_NEEDED_TALK_POWER = "i_client_needed_talk_power", /* Permission ID: 311 */ + I_CLIENT_POKE_POWER = "i_client_poke_power", /* Permission ID: 312 */ + I_CLIENT_NEEDED_POKE_POWER = "i_client_needed_poke_power", /* Permission ID: 313 */ + I_CLIENT_POKE_MAX_CLIENTS = "i_client_poke_max_clients", /* Permission ID: 314 */ + B_CLIENT_SET_FLAG_TALKER = "b_client_set_flag_talker", /* Permission ID: 315 */ + I_CLIENT_WHISPER_POWER = "i_client_whisper_power", /* Permission ID: 316 */ + I_CLIENT_NEEDED_WHISPER_POWER = "i_client_needed_whisper_power", /* Permission ID: 317 */ + B_VIDEO_SCREEN = "b_video_screen", /* Permission ID: 318 */ + B_VIDEO_CAMERA = "b_video_camera", /* Permission ID: 319 */ + I_VIDEO_MAX_KBPS = "i_video_max_kbps", /* Permission ID: 320 */ + I_VIDEO_MAX_STREAMS = "i_video_max_streams", /* Permission ID: 321 */ + I_VIDEO_MAX_SCREEN_STREAMS = "i_video_max_screen_streams", /* Permission ID: 322 */ + I_VIDEO_MAX_CAMERA_STREAMS = "i_video_max_camera_streams", /* Permission ID: 323 */ + B_CLIENT_MODIFY_DESCRIPTION = "b_client_modify_description", /* Permission ID: 324 */ + B_CLIENT_MODIFY_OWN_DESCRIPTION = "b_client_modify_own_description", /* Permission ID: 325 */ + B_CLIENT_USE_BBCODE_ANY = "b_client_use_bbcode_any", /* Permission ID: 326 */ + B_CLIENT_USE_BBCODE_URL = "b_client_use_bbcode_url", /* Permission ID: 327 */ + B_CLIENT_USE_BBCODE_IMAGE = "b_client_use_bbcode_image", /* Permission ID: 328 */ + B_CLIENT_MODIFY_DBPROPERTIES = "b_client_modify_dbproperties", /* Permission ID: 329 */ + B_CLIENT_DELETE_DBPROPERTIES = "b_client_delete_dbproperties", /* Permission ID: 330 */ + B_CLIENT_CREATE_MODIFY_SERVERQUERY_LOGIN = "b_client_create_modify_serverquery_login", /* Permission ID: 331 */ + B_CLIENT_QUERY_CREATE = "b_client_query_create", /* Permission ID: 332 */ + B_CLIENT_QUERY_CREATE_OWN = "b_client_query_create_own", /* Permission ID: 333 */ + B_CLIENT_QUERY_LIST = "b_client_query_list", /* Permission ID: 334 */ + B_CLIENT_QUERY_LIST_OWN = "b_client_query_list_own", /* Permission ID: 335 */ + B_CLIENT_QUERY_RENAME = "b_client_query_rename", /* Permission ID: 336 */ + B_CLIENT_QUERY_RENAME_OWN = "b_client_query_rename_own", /* Permission ID: 337 */ + B_CLIENT_QUERY_CHANGE_PASSWORD = "b_client_query_change_password", /* Permission ID: 338 */ + B_CLIENT_QUERY_CHANGE_OWN_PASSWORD = "b_client_query_change_own_password", /* Permission ID: 339 */ + B_CLIENT_QUERY_CHANGE_PASSWORD_GLOBAL = "b_client_query_change_password_global", /* Permission ID: 340 */ + B_CLIENT_QUERY_DELETE = "b_client_query_delete", /* Permission ID: 341 */ + B_CLIENT_QUERY_DELETE_OWN = "b_client_query_delete_own", /* Permission ID: 342 */ + B_FT_IGNORE_PASSWORD = "b_ft_ignore_password", /* Permission ID: 343 */ + B_FT_TRANSFER_LIST = "b_ft_transfer_list", /* Permission ID: 344 */ + I_FT_FILE_UPLOAD_POWER = "i_ft_file_upload_power", /* Permission ID: 345 */ + I_FT_NEEDED_FILE_UPLOAD_POWER = "i_ft_needed_file_upload_power", /* Permission ID: 346 */ + I_FT_FILE_DOWNLOAD_POWER = "i_ft_file_download_power", /* Permission ID: 347 */ + I_FT_NEEDED_FILE_DOWNLOAD_POWER = "i_ft_needed_file_download_power", /* Permission ID: 348 */ + I_FT_FILE_DELETE_POWER = "i_ft_file_delete_power", /* Permission ID: 349 */ + I_FT_NEEDED_FILE_DELETE_POWER = "i_ft_needed_file_delete_power", /* Permission ID: 350 */ + I_FT_FILE_RENAME_POWER = "i_ft_file_rename_power", /* Permission ID: 351 */ + I_FT_NEEDED_FILE_RENAME_POWER = "i_ft_needed_file_rename_power", /* Permission ID: 352 */ + I_FT_FILE_BROWSE_POWER = "i_ft_file_browse_power", /* Permission ID: 353 */ + I_FT_NEEDED_FILE_BROWSE_POWER = "i_ft_needed_file_browse_power", /* Permission ID: 354 */ + I_FT_DIRECTORY_CREATE_POWER = "i_ft_directory_create_power", /* Permission ID: 355 */ + I_FT_NEEDED_DIRECTORY_CREATE_POWER = "i_ft_needed_directory_create_power", /* Permission ID: 356 */ + I_FT_QUOTA_MB_DOWNLOAD_PER_CLIENT = "i_ft_quota_mb_download_per_client", /* Permission ID: 357 */ + I_FT_QUOTA_MB_UPLOAD_PER_CLIENT = "i_ft_quota_mb_upload_per_client", /* Permission ID: 358 */ + I_FT_MAX_BANDWIDTH_DOWNLOAD = "i_ft_max_bandwidth_download", /* Permission ID: 359 */ + I_FT_MAX_BANDWIDTH_UPLOAD = "i_ft_max_bandwidth_upload", /* Permission ID: 360 */ } export default PermissionType; \ No newline at end of file diff --git a/shared/js/ui/frames/video/Controller.ts b/shared/js/ui/frames/video/Controller.ts index 751ad4da..e730319e 100644 --- a/shared/js/ui/frames/video/Controller.ts +++ b/shared/js/ui/frames/video/Controller.ts @@ -21,12 +21,14 @@ import {LogCategory, logError, logWarn} from "tc-shared/log"; import {tr} from "tc-shared/i18n/localize"; import {Settings, settings} from "tc-shared/settings"; import * as _ from "lodash"; +import PermissionType from "tc-shared/permission/PermissionType"; const cssStyle = require("./Renderer.scss"); let videoIdIndex = 0; interface ClientVideoController { destroy(); + isSubscribed(type: VideoBroadcastType); toggleMuteState(type: VideoBroadcastType, state: boolean); dismissVideo(type: VideoBroadcastType); @@ -39,12 +41,18 @@ class RemoteClientVideoController implements ClientVideoController { readonly videoId: string; readonly client: ClientEntry; callbackBroadcastStateChanged: (broadcasting: boolean) => void; + callbackSubscriptionStateChanged: () => void; protected readonly events: Registry; protected eventListener: (() => void)[]; protected eventListenerVideoClient: (() => void)[]; private currentBroadcastState: boolean; + private currentSubscriptionState: {[T in VideoBroadcastType]: boolean} = { + screen: false, + camera: false + }; + private dismissed: {[T in VideoBroadcastType]: boolean} = { screen: false, camera: false @@ -113,13 +121,34 @@ class RemoteClientVideoController implements ClientVideoController { return videoClient && (videoClient.getVideoState("camera") !== VideoBroadcastState.Stopped || videoClient.getVideoState("screen") !== VideoBroadcastState.Stopped); } + isSubscribed(type: VideoBroadcastType) { + const videoClient = this.client.getVideoClient(); + const videoState = videoClient?.getVideoState(type); + return typeof videoState !== "undefined" && videoState !== VideoBroadcastState.Stopped && videoState !== VideoBroadcastState.Available; + } + toggleMuteState(type: VideoBroadcastType, muted: boolean) { + const videoClient = this.client.getVideoClient(); + if(!videoClient) { + return; + } + + const videoState = videoClient.getVideoState(type); + if(muted) { + if(videoState ===VideoBroadcastState.Stopped || videoState === VideoBroadcastState.Available) { + return; + } + this.client.getVideoClient().leaveBroadcast(type); } else { /* we explicitly specified that we don't want to have that */ this.dismissed[type] = true; + if(videoState !== VideoBroadcastState.Available) { + return; + } + this.client.getVideoClient().joinBroadcast(type).catch(error => { logError(LogCategory.VIDEO, tr("Failed to join video broadcast: %o"), error); /* TODO: Propagate error? */ @@ -155,12 +184,16 @@ class RemoteClientVideoController implements ClientVideoController { let screenState: ChannelVideoStreamState = "none"; let broadcasting = false; + + let cameraSubscribed = false, screenSubscribed = false; + if(this.hasVideoSupport()) { const stateCamera = this.getBroadcastState("camera"); if(stateCamera === VideoBroadcastState.Available) { cameraState = this.dismissed["camera"] ? "ignored" : "available"; } else if(stateCamera === VideoBroadcastState.Running || stateCamera === VideoBroadcastState.Initializing) { cameraState = "streaming"; + cameraSubscribed = true; } const stateScreen = this.getBroadcastState("screen"); @@ -168,6 +201,7 @@ class RemoteClientVideoController implements ClientVideoController { screenState = this.dismissed["screen"] ? "ignored" : "available"; } else if(stateScreen === VideoBroadcastState.Running || stateScreen === VideoBroadcastState.Initializing) { screenState = "streaming"; + screenSubscribed = true; } broadcasting = cameraState !== "none" || screenState !== "none"; @@ -189,6 +223,17 @@ class RemoteClientVideoController implements ClientVideoController { this.callbackBroadcastStateChanged(broadcasting); } } + + if(this.currentSubscriptionState.camera !== cameraSubscribed || this.currentSubscriptionState.screen !== screenSubscribed) { + this.currentSubscriptionState = { + screen: screenSubscribed, + camera: cameraSubscribed + }; + + if(this.callbackSubscriptionStateChanged) { + this.callbackSubscriptionStateChanged(); + } + } } notifyVideoStream(type: VideoBroadcastType) { @@ -383,7 +428,12 @@ class ChannelVideoController { return; } - controller.toggleMuteState(event.broadcastType, event.muted); + if(event.broadcastType === undefined) { + controller.toggleMuteState("camera", event.muted); + controller.toggleMuteState("screen", event.muted); + } else { + controller.toggleMuteState(event.broadcastType, event.muted); + } }); this.events.on("action_dismiss", event => { @@ -399,6 +449,7 @@ class ChannelVideoController { this.events.on("query_expended", () => this.events.fire_react("notify_expended", { expended: this.expended })); this.events.on("query_videos", () => this.notifyVideoList()); this.events.on("query_spotlight", () => this.notifySpotlight()); + this.events.on("query_subscribe_info", () => this.notifySubscribeInfo()); this.events.on("query_video_info", event => { const controller = this.findVideoById(event.videoId); @@ -490,6 +541,11 @@ class ChannelVideoController { } })); + /* TODO: Unify update if all three changed? */ + events.push(this.connection.permissions.register_needed_permission(PermissionType.I_VIDEO_MAX_STREAMS, () => this.notifySubscribeInfo())); + events.push(this.connection.permissions.register_needed_permission(PermissionType.I_VIDEO_MAX_CAMERA_STREAMS, () => this.notifySubscribeInfo())); + events.push(this.connection.permissions.register_needed_permission(PermissionType.I_VIDEO_MAX_SCREEN_STREAMS, () => this.notifySubscribeInfo())); + events.push(settings.globalChangeListener(Settings.KEY_VIDEO_SHOW_ALL_CLIENTS, () => this.notifyVideoList())); events.push(settings.globalChangeListener(Settings.KEY_VIDEO_FORCE_SHOW_OWN_VIDEO, () => this.notifyVideoList())); } @@ -551,6 +607,7 @@ class ChannelVideoController { if(this.clientVideos[clientId]) { const video = this.clientVideos[clientId]; video.callbackBroadcastStateChanged = undefined; + video.callbackSubscriptionStateChanged = undefined; video.destroy(); delete this.clientVideos[clientId]; @@ -570,6 +627,7 @@ class ChannelVideoController { const controller = new RemoteClientVideoController(client, this.events); /* update our video list and the visibility */ controller.callbackBroadcastStateChanged = () => this.notifyVideoList(); + controller.callbackSubscriptionStateChanged = () => this.notifySubscribeInfo(); this.clientVideos[client.clientId()] = controller; } @@ -624,6 +682,37 @@ class ChannelVideoController { }); } + private notifySubscribeInfo() { + const permissionMaxStreams = this.connection.permissions.neededPermission(PermissionType.I_VIDEO_MAX_STREAMS); + const permissionMaxScreenStreams = this.connection.permissions.neededPermission(PermissionType.I_VIDEO_MAX_SCREEN_STREAMS); + const permissionMaxCameraStreams = this.connection.permissions.neededPermission(PermissionType.I_VIDEO_MAX_CAMERA_STREAMS); + + let subscriptionsCamera = 0, subscriptionsScreen = 0; + for(const client of Object.values(this.clientVideos)) { + if(client.isSubscribed("screen")) { + subscriptionsScreen++; + } + if(client.isSubscribed("camera")) { + subscriptionsCamera++; + } + } + + this.events.fire_react("notify_subscribe_info", { + info: { + totalSubscriptions: subscriptionsCamera + subscriptionsScreen, + maxSubscriptions: permissionMaxStreams.valueOr(undefined), + subscribeLimits: { + screen: permissionMaxScreenStreams.valueOr(undefined), + camera: permissionMaxCameraStreams.valueOr(undefined) + }, + subscribedStreams: { + camera: subscriptionsCamera, + screen: subscriptionsScreen, + } + } + }); + } + private updateVisibility(target: boolean) { if(this.currentlyVisible === target) { return; } diff --git a/shared/js/ui/frames/video/Definitions.ts b/shared/js/ui/frames/video/Definitions.ts index 8cae9879..37f0dd8a 100644 --- a/shared/js/ui/frames/video/Definitions.ts +++ b/shared/js/ui/frames/video/Definitions.ts @@ -50,6 +50,14 @@ export type VideoStreamState = { stream: MediaStream }; +export type VideoSubscribeInfo = { + totalSubscriptions: number, + subscribedStreams: {[T in VideoBroadcastType]: number}, + + subscribeLimits: {[T in VideoBroadcastType]?: number}, + maxSubscriptions: number | undefined +}; + /** * "muted": The video has been muted locally * "unset": The video will be normally played @@ -63,7 +71,7 @@ export interface ChannelVideoEvents { action_set_spotlight: { videoId: string | undefined, expend: boolean }, action_focus_spotlight: {}, action_set_fullscreen: { videoId: string | undefined }, - action_toggle_mute: { videoId: string, broadcastType: VideoBroadcastType, muted: boolean }, + action_toggle_mute: { videoId: string, broadcastType: VideoBroadcastType | undefined, muted: boolean }, action_dismiss: { videoId: string, broadcastType: VideoBroadcastType }, query_expended: {}, @@ -73,6 +81,7 @@ export interface ChannelVideoEvents { query_video_statistics: { videoId: string, broadcastType: VideoBroadcastType }, query_spotlight: {}, query_video_stream: { videoId: string, broadcastType: VideoBroadcastType }, + query_subscribe_info: {} notify_expended: { expended: boolean }, notify_videos: { @@ -108,5 +117,8 @@ export interface ChannelVideoEvents { videoId: string, broadcastType: VideoBroadcastType, state: VideoStreamState + }, + notify_subscribe_info: { + info: VideoSubscribeInfo } } \ No newline at end of file diff --git a/shared/js/ui/frames/video/Renderer.tsx b/shared/js/ui/frames/video/Renderer.tsx index f9408a16..dd0c94b7 100644 --- a/shared/js/ui/frames/video/Renderer.tsx +++ b/shared/js/ui/frames/video/Renderer.tsx @@ -7,7 +7,7 @@ import { ChannelVideoEvents, ChannelVideoInfo, ChannelVideoStreamState, - kLocalVideoId, VideoStreamState + kLocalVideoId, VideoStreamState, VideoSubscribeInfo } from "tc-shared/ui/frames/video/Definitions"; import {Translatable} from "tc-shared/ui/react-elements/i18n"; import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; @@ -17,6 +17,7 @@ import {LogCategory, logWarn} from "tc-shared/log"; import {spawnContextMenu} from "tc-shared/ui/ContextMenu"; import {VideoBroadcastType} from "tc-shared/connection/VideoConnection"; +const SubscribeContext = React.createContext(undefined); const EventContext = React.createContext>(undefined); const HandlerIdContext = React.createContext(undefined); @@ -140,23 +141,98 @@ const VideoStreamReplay = React.memo((props: { stream: MediaStream | undefined, ) }); -const VideoAvailableRenderer = (props: { callbackEnable: () => void, callbackIgnore?: () => void, className?: string }) => ( -
-
- Video available -
-
- Watch -
- {!props.callbackIgnore ? undefined : -
- Ignore +const VideoSubscribeContextProvider = (props: { children?: React.ReactElement | React.ReactElement[] }) => { + const events = useContext(EventContext); + + const [ subscribeInfo, setSubscribeInfo ] = useState(() => { + events.fire("query_subscribe_info"); + return { + totalSubscriptions: 0, + subscribedStreams: { + screen: 0, + camera: 0 + }, + subscribeLimits: {}, + maxSubscriptions: undefined + }; + }); + events.reactUse("notify_subscribe_info", event => setSubscribeInfo(event.info)); + + return ( + + {props.children} + + ); +} + +const canSubscribe = (subscribeInfo: VideoSubscribeInfo, target: VideoBroadcastType) : boolean => { + if(typeof subscribeInfo.maxSubscriptions === "number" && subscribeInfo.maxSubscriptions <= subscribeInfo.totalSubscriptions) { + return false; + } + + return typeof subscribeInfo.subscribeLimits[target] !== "number" || subscribeInfo.subscribeLimits[target] > subscribeInfo.subscribedStreams[target]; +}; + +const VideoGeneralAvailableRenderer = (props: { videoId: string, haveScreen: boolean, haveCamera: boolean, className?: string }) => { + const events = useContext(EventContext); + + const subscribeInfo = useContext(SubscribeContext); + if(props.haveCamera && canSubscribe(subscribeInfo, "camera") || props.haveScreen && canSubscribe(subscribeInfo, "screen")) { + return ( +
+
+ Video available +
+
events.fire("action_toggle_mute", { videoId: props.videoId, broadcastType: undefined, muted: false })}> + Watch +
- } +
-
-
-); + ); + } else { + return ( +
+
+ Stream subscribe limit reached + {/* TODO: Name the failed permission */} +
+
+ ); + } +}; + +const VideoStreamAvailableRenderer = (props: { videoId: string, mode: VideoBroadcastType , className?: string }) => { + const events = useContext(EventContext); + + const subscribeInfo = useContext(SubscribeContext); + if(canSubscribe(subscribeInfo, props.mode)) { + return ( +
+
+ Video available +
+
events.fire("action_toggle_mute", { videoId: props.videoId, broadcastType: props.mode, muted: false })}> + Watch +
+
events.fire("action_dismiss", { videoId: props.videoId, broadcastType: props.mode })}> + Ignore +
+
+
+
+ ); + } else { + return ( +
+
+ Stream subscribe limit reached + {/* TODO: Name the failed permission */} +
+
+ ); + } +}; const VideoStreamRenderer = (props: { videoId: string, streamType: VideoBroadcastType, className?: string }) => { const events = useContext(EventContext); @@ -207,8 +283,6 @@ const VideoStreamRenderer = (props: { videoId: string, streamType: VideoBroadcas } const VideoPlayer = React.memo((props: { videoId: string, cameraState: ChannelVideoStreamState, screenState: ChannelVideoStreamState }) => { - const events = useContext(EventContext); - const streamElements = []; const streamClasses = [cssStyle.videoPrimary, cssStyle.videoSecondary]; @@ -217,27 +291,21 @@ const VideoPlayer = React.memo((props: { videoId: string, cameraState: ChannelVi } else if(props.cameraState !== "streaming" && props.screenState !== "streaming") { /* We're not streaming any video nor we don't have any video. Show general show video button. */ streamElements.push( - { - if(props.screenState !== "streaming" && props.screenState !== "none") { - events.fire("action_toggle_mute", { broadcastType: "screen", muted: false, videoId: props.videoId }) - } - - if(props.cameraState !== "streaming" && props.cameraState !== "none") { - events.fire("action_toggle_mute", { broadcastType: "camera", muted: false, videoId: props.videoId }) - } - }} + videoId={props.videoId} + haveCamera={props.cameraState !== "none"} + haveScreen={props.screenState !== "none"} className={streamClasses.pop_front()} /> ); } else { if(props.screenState === "available") { streamElements.push( - events.fire("action_toggle_mute", { broadcastType: "screen", muted: false, videoId: props.videoId })} - callbackIgnore={() => events.fire("action_dismiss", { broadcastType: "screen", videoId: props.videoId })} + videoId={props.videoId} + mode={"screen"} className={streamClasses.pop_front()} /> ); @@ -250,10 +318,10 @@ const VideoPlayer = React.memo((props: { videoId: string, cameraState: ChannelVi if(props.cameraState === "available") { streamElements.push( - events.fire("action_toggle_mute", { broadcastType: "camera", muted: false, videoId: props.videoId })} - callbackIgnore={() => events.fire("action_dismiss", { broadcastType: "camera", videoId: props.videoId })} + videoId={props.videoId} + mode={"camera"} className={streamClasses.pop_front()} /> ); @@ -570,7 +638,9 @@ export const ChannelVideoRenderer = (props: { handlerId: string, events: Registr
- + + +
From 41f9facf3148ddbd74371872efd52d259f8a91a9 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Thu, 17 Dec 2020 11:55:53 +0100 Subject: [PATCH 26/37] Some minor updates --- shared/js/ConnectionHandler.ts | 19 +++++++++---------- shared/js/permission/PermissionManager.ts | 12 ++++++++++++ shared/js/ui/frames/video/Controller.ts | 6 +++--- shared/js/ui/modal/ModalCreateChannel.ts | 4 ---- 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/shared/js/ConnectionHandler.ts b/shared/js/ConnectionHandler.ts index 6f0612d9..9c1ee6f7 100644 --- a/shared/js/ConnectionHandler.ts +++ b/shared/js/ConnectionHandler.ts @@ -500,17 +500,14 @@ export class ConnectionHandler { case DisconnectReason.CONNECT_FAILURE: if(this._reconnect_attempt) { auto_reconnect = true; - this.log.log(EventType.CONNECTION_FAILED, { serverAddress: { - server_port: this.channelTree.server.remote_address.port, - server_hostname: this.channelTree.server.remote_address.host - } }); break; } - if(data) + if(data) { log.error(LogCategory.CLIENT, tr("Could not connect to remote host! Extra data: %o"), data); - else + } else { log.error(LogCategory.CLIENT, tr("Could not connect to remote host!"), data); + } if(__build.target === "client" || !dns.resolve_address_ipv4) { createErrorModal( @@ -542,10 +539,12 @@ export class ConnectionHandler { this._certificate_modal.open(); }); } - this.log.log(EventType.CONNECTION_FAILED, { serverAddress: { - server_hostname: this.serverConnection.remote_address().host, - server_port: this.serverConnection.remote_address().port - } }); + this.log.log(EventType.CONNECTION_FAILED, { + serverAddress: { + server_hostname: this.serverConnection.remote_address().host, + server_port: this.serverConnection.remote_address().port + } + }); this.sound.play(Sound.CONNECTION_REFUSED); break; case DisconnectReason.HANDSHAKE_FAILED: diff --git a/shared/js/permission/PermissionManager.ts b/shared/js/permission/PermissionManager.ts index fa946f64..505154a5 100644 --- a/shared/js/permission/PermissionManager.ts +++ b/shared/js/permission/PermissionManager.ts @@ -72,6 +72,18 @@ export class PermissionValue { valueOr(fallback: number) { return this.hasValue() ? this.value : fallback; } + + valueNormalOr(fallback: number) { + if(this.hasValue()) { + if(this.value === -1) { + return Number.MAX_SAFE_INTEGER; + } + + return this.value; + } else { + return fallback; + } + } } export class NeededPermissionValue extends PermissionValue { diff --git a/shared/js/ui/frames/video/Controller.ts b/shared/js/ui/frames/video/Controller.ts index e730319e..4e14de8b 100644 --- a/shared/js/ui/frames/video/Controller.ts +++ b/shared/js/ui/frames/video/Controller.ts @@ -700,10 +700,10 @@ class ChannelVideoController { this.events.fire_react("notify_subscribe_info", { info: { totalSubscriptions: subscriptionsCamera + subscriptionsScreen, - maxSubscriptions: permissionMaxStreams.valueOr(undefined), + maxSubscriptions: permissionMaxStreams.valueNormalOr(undefined), subscribeLimits: { - screen: permissionMaxScreenStreams.valueOr(undefined), - camera: permissionMaxCameraStreams.valueOr(undefined) + screen: permissionMaxScreenStreams.valueNormalOr(undefined), + camera: permissionMaxCameraStreams.valueNormalOr(undefined) }, subscribedStreams: { camera: subscriptionsCamera, diff --git a/shared/js/ui/modal/ModalCreateChannel.ts b/shared/js/ui/modal/ModalCreateChannel.ts index a0533455..cd19b86f 100644 --- a/shared/js/ui/modal/ModalCreateChannel.ts +++ b/shared/js/ui/modal/ModalCreateChannel.ts @@ -705,10 +705,6 @@ function applyAudioListener(connection: ConnectionHandler, properties: ChannelPr } let codecs = tag.find(".voice_codec option"); - codecs.eq(0).prop("disabled", !connection.permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_MODIFY_WITH_CODEC_SPEEX8).granted(1)); - codecs.eq(1).prop("disabled", !connection.permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_MODIFY_WITH_CODEC_SPEEX16).granted(1)); - codecs.eq(2).prop("disabled", !connection.permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_MODIFY_WITH_CODEC_SPEEX32).granted(1)); - codecs.eq(3).prop("disabled", !connection.permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_MODIFY_WITH_CODEC_CELTMONO48).granted(1)); codecs.eq(4).prop("disabled", !connection.permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_MODIFY_WITH_CODEC_OPUSVOICE).granted(1)); codecs.eq(5).prop("disabled", !connection.permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_MODIFY_WITH_CODEC_OPUSMUSIC).granted(1)); tag.find(".voice_codec").change(function (this: HTMLSelectElement) { From 001bececbea1e9ad3a29bcb6eb649a36bd32b63f Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Fri, 18 Dec 2020 12:23:00 +0100 Subject: [PATCH 27/37] Updating the channel client count on client switch --- shared/js/ui/frames/side/HeaderController.ts | 25 +++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/shared/js/ui/frames/side/HeaderController.ts b/shared/js/ui/frames/side/HeaderController.ts index 2f9571ef..085137e4 100644 --- a/shared/js/ui/frames/side/HeaderController.ts +++ b/shared/js/ui/frames/side/HeaderController.ts @@ -3,7 +3,6 @@ import {SideHeaderEvents} from "tc-shared/ui/frames/side/HeaderDefinitions"; import {Registry} from "tc-shared/events"; import {ChannelEntry, ChannelProperties} from "tc-shared/tree/Channel"; import {LocalClientEntry} from "tc-shared/tree/Client"; -import {openMusicManage} from "tc-shared/ui/modal/ModalMusicManage"; const ChannelInfoUpdateProperties: (keyof ChannelProperties)[] = [ "channel_name", @@ -80,11 +79,35 @@ export class SideHeaderController { this.listenerConnection.push(this.connection.channelTree.events.on("notify_client_moved", event => { if(event.client instanceof LocalClientEntry) { this.updateVoiceChannel(); + } else { + if(event.newChannel === this.currentVoiceChannel || event.oldChannel === this.currentVoiceChannel) { + this.sendChannelState("voice"); + } + + if(event.newChannel === this.currentTextChannel || event.oldChannel === this.currentTextChannel) { + this.sendChannelState("text"); + } } })); this.listenerConnection.push(this.connection.channelTree.events.on("notify_client_enter_view", event => { if(event.client instanceof LocalClientEntry) { this.updateVoiceChannel(); + } else { + if(event.targetChannel === this.currentVoiceChannel) { + this.sendChannelState("voice"); + } + if(event.targetChannel === this.currentTextChannel) { + this.sendChannelState("text"); + } + } + })); + this.listenerConnection.push(this.connection.channelTree.events.on("notify_client_leave_view", event => { + if(event.sourceChannel === this.currentVoiceChannel) { + this.sendChannelState("voice"); + } + + if(event.sourceChannel === this.currentTextChannel) { + this.sendChannelState("text"); } })); this.listenerConnection.push(this.connection.events().on("notify_connection_state_changed", () => { From 3412faf125ff4355a9c4f203f747bdcc3af1aa2b Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Fri, 18 Dec 2020 17:06:38 +0100 Subject: [PATCH 28/37] A lot of updates and added a new feature --- ChangeLog.md | 7 + shared/css/static/main-layout.scss | 2 + shared/js/ConnectionHandler.ts | 38 +- shared/js/ConnectionManager.ts | 18 +- shared/js/SideBarManager.ts | 6 +- shared/js/connection/CommandHandler.ts | 44 ++- shared/js/connection/ErrorCode.ts | 1 + shared/js/connectionlog/Definitions.ts | 345 +++++++++++++++++ .../log => connectionlog}/DispatcherFocus.ts | 8 +- .../DispatcherNotifications.ts | 70 ++-- shared/js/connectionlog/ServerEventLog.tsx | 61 +++ shared/js/conversations/AbstractConversion.ts | 30 +- .../ChannelConversationManager.ts | 14 +- shared/js/text/bbcode.tsx | 23 +- shared/js/text/bbcode/renderer.ts | 4 +- shared/js/text/bbcode/url.tsx | 35 +- shared/js/text/chat.ts | 1 - shared/js/tree/Channel.ts | 130 ++++--- shared/js/tree/ChannelTree.tsx | 28 +- shared/js/tree/Client.ts | 10 +- shared/js/tree/Server.ts | 2 +- shared/js/ui/frames/SideBarController.ts | 20 +- shared/js/ui/frames/SideBarDefinitions.ts | 9 +- shared/js/ui/frames/SideBarRenderer.tsx | 27 +- shared/js/ui/frames/log/Controller.ts | 49 +++ shared/js/ui/frames/log/Definitions.ts | 354 +----------------- shared/js/ui/frames/log/Renderer.scss | 2 +- shared/js/ui/frames/log/Renderer.tsx | 59 ++- .../{DispatcherLog.tsx => RendererEvent.tsx} | 116 +++--- shared/js/ui/frames/log/ServerEventLog.tsx | 86 ----- .../side/AbstractConversationController.ts | 24 +- .../side/AbstractConversationRenderer.scss | 3 + .../side/AbstractConversationRenderer.tsx | 6 +- .../js/ui/frames/side/ChannelBarController.ts | 194 ++++++++++ .../ui/frames/side/ChannelBarDefinitions.ts | 36 ++ .../js/ui/frames/side/ChannelBarRenderer.tsx | 89 +++++ .../side/ChannelConversationController.ts | 13 - .../side/ChannelDescriptionController.ts | 121 ++++++ .../side/ChannelDescriptionDefinitions.ts | 18 + .../side/ChannelDescriptionRenderer.scss | 51 +++ .../side/ChannelDescriptionRenderer.tsx | 86 +++++ shared/js/ui/frames/side/HeaderController.ts | 2 +- shared/js/ui/modal/settings/Notifications.tsx | 84 ++--- shared/js/ui/tree/Controller.tsx | 2 + web/app/connection/ServerConnection.ts | 4 +- web/app/legacy/voice/VoiceHandler.ts | 9 +- 46 files changed, 1568 insertions(+), 773 deletions(-) create mode 100644 shared/js/connectionlog/Definitions.ts rename shared/js/{ui/frames/log => connectionlog}/DispatcherFocus.ts (63%) rename shared/js/{ui/frames/log => connectionlog}/DispatcherNotifications.ts (89%) create mode 100644 shared/js/connectionlog/ServerEventLog.tsx create mode 100644 shared/js/ui/frames/log/Controller.ts rename shared/js/ui/frames/log/{DispatcherLog.tsx => RendererEvent.tsx} (86%) delete mode 100644 shared/js/ui/frames/log/ServerEventLog.tsx create mode 100644 shared/js/ui/frames/side/ChannelBarController.ts create mode 100644 shared/js/ui/frames/side/ChannelBarDefinitions.ts create mode 100644 shared/js/ui/frames/side/ChannelBarRenderer.tsx create mode 100644 shared/js/ui/frames/side/ChannelDescriptionController.ts create mode 100644 shared/js/ui/frames/side/ChannelDescriptionDefinitions.ts create mode 100644 shared/js/ui/frames/side/ChannelDescriptionRenderer.scss create mode 100644 shared/js/ui/frames/side/ChannelDescriptionRenderer.tsx diff --git a/ChangeLog.md b/ChangeLog.md index dce6bd4c..1c114acc 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,4 +1,11 @@ # Changelog: +* **18.12.20** + - Added the ability to send private messages to multiple clients + - Channel client count now updates within the side bar header + - The client now supports the new server channel sidebar mode (File transfer is in progress) + - Correctly parsing and render `client://` urls + - Updating the client talk status if the client has been moved + * **13.12.20** - Directly connection when hitting enter on the address line diff --git a/shared/css/static/main-layout.scss b/shared/css/static/main-layout.scss index 6d6dc432..d65b1a08 100644 --- a/shared/css/static/main-layout.scss +++ b/shared/css/static/main-layout.scss @@ -134,6 +134,8 @@ html:root { min-height: 0; width: 100%; + overflow: hidden; + border-radius: 5px 5px 0 0; padding-right: 5px; diff --git a/shared/js/ConnectionHandler.ts b/shared/js/ConnectionHandler.ts index 9c1ee6f7..b22297bf 100644 --- a/shared/js/ConnectionHandler.ts +++ b/shared/js/ConnectionHandler.ts @@ -24,8 +24,6 @@ import {FileTransferState, TransferProvider} from "./file/Transfer"; import {traj, tr} from "./i18n/localize"; import {md5} from "./crypto/md5"; import {guid} from "./crypto/uid"; -import {ServerEventLog} from "./ui/frames/log/ServerEventLog"; -import {EventType} from "./ui/frames/log/Definitions"; import {PluginCmdRegistry} from "./connection/PluginCmdHandler"; import {W2GPluginCmdHandler} from "./video-viewer/W2GPlugin"; import {VoiceConnectionStatus, WhisperSessionInitializeData} from "./connection/VoiceConnection"; @@ -41,6 +39,8 @@ import {ChannelConversationManager} from "./conversations/ChannelConversationMan import {PrivateConversationManager} from "tc-shared/conversations/PrivateConversationManager"; import {SelectedClientInfo} from "./SelectedClientInfo"; import {SideBarManager} from "tc-shared/SideBarManager"; +import {ServerEventLog} from "tc-shared/connectionlog/ServerEventLog"; +import {EventType} from "tc-shared/connectionlog/Definitions"; export enum InputHardwareState { MISSING, @@ -270,7 +270,7 @@ export class ConnectionHandler { } } log.info(LogCategory.CLIENT, tr("Start connection to %s:%d"), server_address.host, server_address.port); - this.log.log(EventType.CONNECTION_BEGIN, { + this.log.log("connection.begin", { address: { server_hostname: server_address.host, server_port: server_address.port @@ -303,7 +303,7 @@ export class ConnectionHandler { server_address.host = "127.0.0.1"; } else if(dns.supported() && !server_address.host.match(Regex.IP_V4) && !server_address.host.match(Regex.IP_V6)) { const id = ++this._connect_initialize_id; - this.log.log(EventType.CONNECTION_HOSTNAME_RESOLVE, {}); + this.log.log("connection.hostname.resolve", {}); try { const resolved = await dns.resolve_address(server_address, { timeout: 5000 }) || {} as any; if(id != this._connect_initialize_id) @@ -311,7 +311,7 @@ export class ConnectionHandler { server_address.host = typeof(resolved.target_ip) === "string" ? resolved.target_ip : server_address.host; server_address.port = typeof(resolved.target_port) === "number" ? resolved.target_port : server_address.port; - this.log.log(EventType.CONNECTION_HOSTNAME_RESOLVED, { + this.log.log("connection.hostname.resolved", { address: { server_port: server_address.port, server_hostname: server_address.host @@ -348,7 +348,7 @@ export class ConnectionHandler { log.warn(LogCategory.CLIENT, tr("Failed to successfully disconnect from server: {}"), error); } this.sound.play(Sound.CONNECTION_DISCONNECTED); - this.log.log(EventType.DISCONNECTED, {}); + this.log.log("disconnected", {}); } getClient() : LocalClientEntry { return this.localClient; } @@ -386,7 +386,7 @@ export class ConnectionHandler { this.connection_state = event.newState; if(event.newState === ConnectionState.CONNECTED) { log.info(LogCategory.CLIENT, tr("Client connected")); - this.log.log(EventType.CONNECTION_CONNECTED, { + this.log.log("connection.connected", { serverAddress: { server_port: this.channelTree.server.remote_address.port, server_hostname: this.channelTree.server.remote_address.host @@ -487,12 +487,12 @@ export class ConnectionHandler { case DisconnectReason.HANDLER_DESTROYED: if(data) { this.sound.play(Sound.CONNECTION_DISCONNECTED); - this.log.log(EventType.DISCONNECTED, {}); + this.log.log("disconnected", {}); } break; case DisconnectReason.DNS_FAILED: log.error(LogCategory.CLIENT, tr("Failed to resolve hostname: %o"), data); - this.log.log(EventType.CONNECTION_HOSTNAME_RESOLVE_ERROR, { + this.log.log("connection.hostname.resolve.error", { message: data as any }); this.sound.play(Sound.CONNECTION_REFUSED); @@ -539,7 +539,7 @@ export class ConnectionHandler { this._certificate_modal.open(); }); } - this.log.log(EventType.CONNECTION_FAILED, { + this.log.log("connection.failed", { serverAddress: { server_hostname: this.serverConnection.remote_address().host, server_port: this.serverConnection.remote_address().port @@ -594,7 +594,7 @@ export class ConnectionHandler { break; case DisconnectReason.SERVER_CLOSED: - this.log.log(EventType.SERVER_CLOSED, {message: data.reasonmsg}); + this.log.log("server.closed", {message: data.reasonmsg}); createErrorModal( tr("Server closed"), @@ -606,7 +606,7 @@ export class ConnectionHandler { auto_reconnect = true; break; case DisconnectReason.SERVER_REQUIRES_PASSWORD: - this.log.log(EventType.SERVER_REQUIRES_PASSWORD, {}); + this.log.log("server.requires.password", {}); createInputModal(tr("Server password"), tr("Enter server password:"), password => password.length != 0, password => { if(!(typeof password === "string")) return; @@ -647,7 +647,7 @@ export class ConnectionHandler { this.sound.play(Sound.CONNECTION_BANNED); break; case DisconnectReason.CLIENT_BANNED: - this.log.log(EventType.SERVER_BANNED, { + this.log.log("server.banned", { invoker: { client_name: data["invokername"], client_id: parseInt(data["invokerid"]), @@ -678,7 +678,7 @@ export class ConnectionHandler { log.info(LogCategory.NETWORKING, tr("Allowed to auto reconnect but cant reconnect because we dont have any information left...")); return; } - this.log.log(EventType.RECONNECT_SCHEDULED, {timeout: 5000}); + this.log.log("reconnect.scheduled", {timeout: 5000}); log.info(LogCategory.NETWORKING, tr("Allowed to auto reconnect. Reconnecting in 5000ms")); const server_address = this.serverConnection.remote_address(); @@ -686,7 +686,7 @@ export class ConnectionHandler { this._reconnect_timer = setTimeout(() => { this._reconnect_timer = undefined; - this.log.log(EventType.RECONNECT_EXECUTE, {}); + this.log.log("reconnect.execute", {}); log.info(LogCategory.NETWORKING, tr("Reconnecting...")); this.startConnection(server_address.host + ":" + server_address.port, profile, false, Object.assign(this.reconnect_properties(profile), {auto_reconnect_attempt: true})); @@ -698,7 +698,7 @@ export class ConnectionHandler { cancel_reconnect(log_event: boolean) { if(this._reconnect_timer) { - if(log_event) this.log.log(EventType.RECONNECT_CANCELED, {}); + if(log_event) this.log.log("reconnect.canceled", {}); clearTimeout(this._reconnect_timer); this._reconnect_timer = undefined; } @@ -791,7 +791,7 @@ export class ConnectionHandler { this.clientStatusSync = true; this.serverConnection.send_command("clientupdate", localClientUpdates).catch(error => { log.warn(LogCategory.GENERAL, tr("Failed to update client audio hardware properties. Error: %o"), error); - this.log.log(EventType.ERROR_CUSTOM, { message: tr("Failed to update audio hardware properties.") }); + this.log.log("error.custom", { message: tr("Failed to update audio hardware properties.") }); this.clientStatusSync = false; }); } @@ -837,7 +837,7 @@ export class ConnectionHandler { //client_output_hardware: this.client_status.sound_playback_supported }).catch(error => { log.warn(LogCategory.GENERAL, tr("Failed to sync handler state with server. Error: %o"), error); - this.log.log(EventType.ERROR_CUSTOM, {message: tr("Failed to sync handler state with server.")}); + this.log.log("error.custom", {message: tr("Failed to sync handler state with server.")}); }); } @@ -1154,7 +1154,7 @@ export class ConnectionHandler { client_away_message: typeof(this.client_status.away) === "string" ? this.client_status.away : "", }).catch(error => { log.warn(LogCategory.GENERAL, tr("Failed to update away status. Error: %o"), error); - this.log.log(EventType.ERROR_CUSTOM, {message: tr("Failed to update away status.")}); + this.log.log("error.custom", {message: tr("Failed to update away status.")}); }); this.event_registry.fire("notify_state_updated", { diff --git a/shared/js/ConnectionManager.ts b/shared/js/ConnectionManager.ts index 71df28cf..524fc7ec 100644 --- a/shared/js/ConnectionManager.ts +++ b/shared/js/ConnectionManager.ts @@ -6,6 +6,8 @@ import {FooterRenderer} from "tc-shared/ui/frames/footer/Renderer"; import * as React from "react"; import * as ReactDOM from "react-dom"; import {SideBarController} from "tc-shared/ui/frames/SideBarController"; +import {ServerEventLogController} from "tc-shared/ui/frames/log/Controller"; +import {ServerLogFrame} from "tc-shared/ui/frames/log/Renderer"; export let server_connections: ConnectionManager; @@ -30,33 +32,36 @@ export class ConnectionManager { private connection_handlers: ConnectionHandler[] = []; private active_handler: ConnectionHandler | undefined; - private _container_log_server: JQuery; private _container_channel_tree: JQuery; private _container_hostbanner: JQuery; private containerChannelVideo: ReplaceableContainer; private containerSideBar: HTMLDivElement; private containerFooter: HTMLDivElement; + private containerServerLog: HTMLDivElement; private sideBarController: SideBarController; + private serverLogController: ServerEventLogController; constructor() { this.event_registry = new Registry(); this.event_registry.enableDebug("connection-manager"); this.sideBarController = new SideBarController(); + this.serverLogController = new ServerEventLogController(); this.containerChannelVideo = new ReplaceableContainer(document.getElementById("channel-video") as HTMLDivElement); - this._container_log_server = $("#server-log"); + this.containerServerLog = document.getElementById("server-log") as HTMLDivElement; + this.containerFooter = document.getElementById("container-footer") as HTMLDivElement; this._container_channel_tree = $("#channelTree"); this._container_hostbanner = $("#hostbanner"); - this.containerFooter = document.getElementById("container-footer") as HTMLDivElement; this.sideBarController.renderInto(document.getElementById("chat") as HTMLDivElement); this.set_active_connection(undefined); } - initializeFooter() { + initializeReactComponents() { ReactDOM.render(React.createElement(FooterRenderer), this.containerFooter); + ReactDOM.render(React.createElement(ServerLogFrame, { events: this.serverLogController.events }), this.containerServerLog); } events() : Registry { @@ -117,16 +122,15 @@ export class ConnectionManager { private set_active_connection_(handler: ConnectionHandler) { this.sideBarController.setConnection(handler); + this.serverLogController.setConnectionHandler(handler); this._container_channel_tree.children().detach(); - this._container_log_server.children().detach(); this._container_hostbanner.children().detach(); this.containerChannelVideo.replaceWith(handler?.video_frame.getContainer()); if(handler) { this._container_hostbanner.append(handler.hostbanner.html_tag); this._container_channel_tree.append(handler.channelTree.tag_tree()); - this._container_log_server.append(handler.log.getHTMLTag()); } const old_handler = this.active_handler; @@ -184,7 +188,7 @@ loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { name: "server manager init", function: async () => { server_connections = new ConnectionManager(); - server_connections.initializeFooter(); + server_connections.initializeReactComponents(); }, priority: 80 }); diff --git a/shared/js/SideBarManager.ts b/shared/js/SideBarManager.ts index d79073d2..f0487177 100644 --- a/shared/js/SideBarManager.ts +++ b/shared/js/SideBarManager.ts @@ -16,7 +16,7 @@ export class SideBarManager { constructor(connection: ConnectionHandler) { this.events = new Registry(); this.connection = connection; - this.currentType = "channel-chat"; + this.currentType = "channel"; } destroy() {} @@ -38,8 +38,8 @@ export class SideBarManager { this.setSideBarContent("private-chat"); } - showChannelConversations() { - this.setSideBarContent("channel-chat"); + showChannel() { + this.setSideBarContent("channel"); } showClientInfo(client: ClientEntry) { diff --git a/shared/js/connection/CommandHandler.ts b/shared/js/connection/CommandHandler.ts index 85613bdd..1208fae2 100644 --- a/shared/js/connection/CommandHandler.ts +++ b/shared/js/connection/CommandHandler.ts @@ -1,5 +1,5 @@ import * as log from "../log"; -import {LogCategory, logError} from "../log"; +import {LogCategory, logError, logWarn} from "../log"; import {AbstractServerConnection, CommandOptions, ServerCommand} from "../connection/ConnectionBase"; import {Sound} from "../sound/Sounds"; import {CommandResult} from "../connection/ServerConnectionDeclaration"; @@ -20,10 +20,10 @@ import {batch_updates, BatchUpdateType, flush_batched_updates} from "../ui/react import {OutOfViewClient} from "../ui/frames/side/PrivateConversationController"; import {renderBBCodeAsJQuery} from "../text/bbcode"; import {tr} from "../i18n/localize"; -import {EventClient, EventType} from "../ui/frames/log/Definitions"; import {ErrorCode} from "../connection/ErrorCode"; import {server_connections} from "tc-shared/ConnectionManager"; import {ChannelEntry} from "tc-shared/tree/Channel"; +import {EventClient} from "tc-shared/connectionlog/Definitions"; export class ServerConnectionCommandBoss extends AbstractCommandHandlerBoss { constructor(connection: AbstractServerConnection) { @@ -56,6 +56,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { this["initserver"] = this.handleCommandServerInit; this["notifychannelmoved"] = this.handleNotifyChannelMoved; this["notifychanneledited"] = this.handleNotifyChannelEdited; + this["notifychanneldescriptionchanged"] = this.handleNotifyChannelDescriptionChanged; this["notifytextmessage"] = this.handleNotifyTextMessage; this["notifyclientchatcomposing"] = this.notifyClientChatComposing; this["notifyclientchatclosed"] = this.handleNotifyClientChatClosed; @@ -116,18 +117,18 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { if(res.id == ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) { //Permission error const permission = this.connection_handler.permissions.resolveInfo(res.json["failed_permid"] as number); res.message = tr("Insufficient client permissions. Failed on permission ") + (permission ? permission.name : "unknown"); - this.connection_handler.log.log(EventType.ERROR_PERMISSION, { + this.connection_handler.log.log("error.permission", { permission: this.connection_handler.permissions.resolveInfo(res.json["failed_permid"] as number) }); this.connection_handler.sound.play(Sound.ERROR_INSUFFICIENT_PERMISSIONS); } else if(res.id != ErrorCode.DATABASE_EMPTY_RESULT) { - this.connection_handler.log.log(EventType.ERROR_CUSTOM, { + this.connection_handler.log.log("error.custom", { message: res.extra_message.length == 0 ? res.message : res.extra_message }); } } } else if(typeof(ex) === "string") { - this.connection_handler.log.log(EventType.CONNECTION_COMMAND_ERROR, {error: ex}); + this.connection_handler.log.log("connection.command.error", {error: ex}); } else { log.error(LogCategory.NETWORKING, tr("Invalid promise result type: %s. Result: %o"), typeof (ex), ex); } @@ -204,7 +205,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { if(properties.virtualserver_hostmessage_mode == 1) { /* show in log */ if(properties.virtualserver_hostmessage) - this.connection_handler.log.log(EventType.SERVER_HOST_MESSAGE, { + this.connection_handler.log.log("server.host.message", { message: properties.virtualserver_hostmessage }); } else { @@ -219,7 +220,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { if(properties.virtualserver_hostmessage_mode == 3) { /* first let the client initialize his stuff */ setTimeout(() => { - this.connection_handler.log.log(EventType.SERVER_HOST_MESSAGE_DISCONNECT, { + this.connection_handler.log.log("server.host.message.disconnect", { message: properties.virtualserver_welcomemessage }); @@ -233,7 +234,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { /* welcome message */ if(properties.virtualserver_welcomemessage) { - this.connection_handler.log.log(EventType.SERVER_WELCOME_MESSAGE, { + this.connection_handler.log.log("server.welcome.message", { message: properties.virtualserver_welcomemessage }); } @@ -504,7 +505,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { if(this.connection_handler.areQueriesShown() || client.properties.client_type != ClientType.CLIENT_QUERY) { const own_channel = this.connection.client.getClient().currentChannel(); - this.connection_handler.log.log(channel == own_channel ? EventType.CLIENT_VIEW_ENTER_OWN_CHANNEL : EventType.CLIENT_VIEW_ENTER, { + this.connection_handler.log.log(channel == own_channel ? "client.view.enter.own.channel" : "client.view.enter", { channel_from: old_channel ? old_channel.log_data() : undefined, channel_to: channel ? channel.log_data() : undefined, client: client.log_data(), @@ -592,7 +593,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { let channel_to = tree.findChannel(targetChannelId); const is_own_channel = channel_from == own_channel; - this.connection_handler.log.log(is_own_channel ? EventType.CLIENT_VIEW_LEAVE_OWN_CHANNEL : EventType.CLIENT_VIEW_LEAVE, { + this.connection_handler.log.log(is_own_channel ? "client.view.leave.own.channel" : "client.view.leave", { channel_from: channel_from ? channel_from.log_data() : undefined, channel_to: channel_to ? channel_to.log_data() : undefined, client: client.log_data(), @@ -673,7 +674,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { } const own_channel = this.connection.client.getClient().currentChannel(); - const event = self ? EventType.CLIENT_VIEW_MOVE_OWN : (channelFrom == own_channel || channel_to == own_channel ? EventType.CLIENT_VIEW_MOVE_OWN_CHANNEL : EventType.CLIENT_VIEW_MOVE); + const event = self ? "client.view.move.own" : (channelFrom == own_channel || channel_to == own_channel ? "client.view.move.own.channel" : "client.view.move"); this.connection_handler.log.log(event, { channel_from: channelFrom ? { channel_id: channelFrom.channelId, @@ -779,6 +780,19 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { } } + handleNotifyChannelDescriptionChanged(json) { + json = json[0]; + + let tree = this.connection.client.channelTree; + let channel = tree.findChannel(parseInt(json["cid"])); + if(!channel) { + logWarn(LogCategory.NETWORKING, tr("Received channel description changed notify for invalid channel: %o"), json["cid"]); + return; + } + + channel.handleDescriptionChanged(); + } + handleNotifyTextMessage(json) { json = json[0]; //Only one bulk @@ -815,7 +829,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { }); if(targetIsOwn) { this.connection_handler.sound.play(Sound.MESSAGE_RECEIVED, {default_volume: .5}); - this.connection_handler.log.log(EventType.PRIVATE_MESSAGE_RECEIVED, { + this.connection_handler.log.log("private.message.received", { message: json["msg"], sender: { client_unique_id: json["invokeruid"], @@ -825,7 +839,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { }); } else { this.connection_handler.sound.play(Sound.MESSAGE_SEND, {default_volume: .5}); - this.connection_handler.log.log(EventType.PRIVATE_MESSAGE_SEND, { + this.connection_handler.log.log("private.message.send", { message: json["msg"], target: { client_unique_id: json["invokeruid"], @@ -858,7 +872,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { const invoker = this.connection_handler.channelTree.findClient(parseInt(json["invokerid"])); const conversations = this.connection_handler.getChannelConversations(); - this.connection_handler.log.log(EventType.GLOBAL_MESSAGE, { + this.connection_handler.log.log("global.message", { isOwnMessage: invoker instanceof LocalClientEntry, message: json["msg"], sender: { @@ -976,7 +990,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { unique_id: json["invokeruid"] }, json["msg"]); - this.connection_handler.log.log(EventType.CLIENT_POKE_RECEIVED, { + this.connection_handler.log.log("client.poke.received", { sender: this.loggable_invoker(json["invokeruid"], json["invokerid"], json["invokername"]), message: json["msg"] }); diff --git a/shared/js/connection/ErrorCode.ts b/shared/js/connection/ErrorCode.ts index 1b49c186..5ab02bcd 100644 --- a/shared/js/connection/ErrorCode.ts +++ b/shared/js/connection/ErrorCode.ts @@ -172,6 +172,7 @@ export enum ErrorCode { CUSTOM_ERROR = 0xFFFF, + /** @deprecated Use SERVER_INSUFFICIENT_PERMISSIONS */ PERMISSION_ERROR = ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS, EMPTY_RESULT = ErrorCode.DATABASE_EMPTY_RESULT } \ No newline at end of file diff --git a/shared/js/connectionlog/Definitions.ts b/shared/js/connectionlog/Definitions.ts new file mode 100644 index 00000000..e12cd726 --- /dev/null +++ b/shared/js/connectionlog/Definitions.ts @@ -0,0 +1,345 @@ +import * as React from "react"; +import {ServerEventLog} from "tc-shared/connectionlog/ServerEventLog"; +import {ViewReasonId} from "tc-shared/ConnectionHandler"; +import {PermissionInfo} from "tc-shared/permission/PermissionManager"; + +/* FIXME: Remove this! */ +export enum EventType { + CONNECTION_BEGIN = "connection.begin", + CONNECTION_HOSTNAME_RESOLVE = "connection.hostname.resolve", + CONNECTION_HOSTNAME_RESOLVE_ERROR = "connection.hostname.resolve.error", + CONNECTION_HOSTNAME_RESOLVED = "connection.hostname.resolved", + CONNECTION_LOGIN = "connection.login", + CONNECTION_CONNECTED = "connection.connected", + CONNECTION_FAILED = "connection.failed", + + DISCONNECTED = "disconnected", + + CONNECTION_VOICE_CONNECT = "connection.voice.connect", + CONNECTION_VOICE_CONNECT_FAILED = "connection.voice.connect.failed", + CONNECTION_VOICE_CONNECT_SUCCEEDED = "connection.voice.connect.succeeded", + CONNECTION_VOICE_DROPPED = "connection.voice.dropped", + + CONNECTION_COMMAND_ERROR = "connection.command.error", + + GLOBAL_MESSAGE = "global.message", + + SERVER_WELCOME_MESSAGE = "server.welcome.message", + SERVER_HOST_MESSAGE = "server.host.message", + SERVER_HOST_MESSAGE_DISCONNECT = "server.host.message.disconnect", + + SERVER_CLOSED = "server.closed", + SERVER_BANNED = "server.banned", + SERVER_REQUIRES_PASSWORD = "server.requires.password", + + CLIENT_VIEW_ENTER = "client.view.enter", + CLIENT_VIEW_LEAVE = "client.view.leave", + CLIENT_VIEW_MOVE = "client.view.move", + + CLIENT_VIEW_ENTER_OWN_CHANNEL = "client.view.enter.own.channel", + CLIENT_VIEW_LEAVE_OWN_CHANNEL = "client.view.leave.own.channel", + CLIENT_VIEW_MOVE_OWN_CHANNEL = "client.view.move.own.channel", + + CLIENT_VIEW_MOVE_OWN = "client.view.move.own", + + CLIENT_NICKNAME_CHANGED = "client.nickname.changed", + CLIENT_NICKNAME_CHANGED_OWN = "client.nickname.changed.own", + CLIENT_NICKNAME_CHANGE_FAILED = "client.nickname.change.failed", + + CLIENT_SERVER_GROUP_ADD = "client.server.group.add", + CLIENT_SERVER_GROUP_REMOVE = "client.server.group.remove", + CLIENT_CHANNEL_GROUP_CHANGE = "client.channel.group.change", + + PRIVATE_MESSAGE_RECEIVED = "private.message.received", + PRIVATE_MESSAGE_SEND = "private.message.send", + + CHANNEL_CREATE = "channel.create", + CHANNEL_DELETE = "channel.delete", + + ERROR_CUSTOM = "error.custom", + ERROR_PERMISSION = "error.permission", + + CLIENT_POKE_RECEIVED = "client.poke.received", + CLIENT_POKE_SEND = "client.poke.send", + + RECONNECT_SCHEDULED = "reconnect.scheduled", + RECONNECT_EXECUTE = "reconnect.execute", + RECONNECT_CANCELED = "reconnect.canceled", + + WEBRTC_FATAL_ERROR = "webrtc.fatal.error" +} + +export type EventClient = { + client_unique_id: string; + client_name: string; + client_id: number; +} +export type EventChannelData = { + channel_id: number; + channel_name: string; +} + +export type EventServerAddress = { + server_hostname: string; + server_port: number; +} + +export namespace event { + export type EventGlobalMessage = { + isOwnMessage: boolean; + sender: EventClient; + message: string; + } + export type EventConnectBegin = { + address: EventServerAddress; + client_nickname: string; + } + export type EventErrorCustom = { + message: string; + } + + export type EventReconnectScheduled = { + timeout: number; + } + + export type EventReconnectCanceled = { } + export type EventReconnectExecute = { } + + export type EventErrorPermission = { + permission: PermissionInfo; + } + + export type EventWelcomeMessage = { + message: string; + } + + export type EventHostMessageDisconnect = { + message: string; + } + + export type EventClientMove = { + channel_from?: EventChannelData; + channel_from_own: boolean; + + channel_to?: EventChannelData; + channel_to_own: boolean; + + client: EventClient; + client_own: boolean; + + invoker?: EventClient; + + message?: string; + reason: ViewReasonId; + } + + export type EventClientEnter = { + channel_from?: EventChannelData; + channel_to?: EventChannelData; + + client: EventClient; + invoker?: EventClient; + + message?: string; + + reason: ViewReasonId; + ban_time?: number; + } + + export type EventClientLeave = { + channel_from?: EventChannelData; + channel_to?: EventChannelData; + + client: EventClient; + invoker?: EventClient; + + message?: string; + + reason: ViewReasonId; + ban_time?: number; + } + + export type EventChannelCreate = { + creator: EventClient, + channel: EventChannelData, + ownAction: boolean + } + + export type EventChannelToggle = { + channel: EventChannelData + } + + export type EventChannelDelete = { + deleter: EventClient, + channel: EventChannelData, + ownAction: boolean + } + + export type EventConnectionConnected = { + serverName: string, + serverAddress: EventServerAddress, + own_client: EventClient; + } + export type EventConnectionFailed = { + serverAddress: EventServerAddress + } + export type EventConnectionLogin = {} + export type EventConnectionHostnameResolve = {}; + export type EventConnectionHostnameResolved = { + address: EventServerAddress; + } + export type EventConnectionHostnameResolveError = { + message: string; + } + + export type EventConnectionVoiceConnectFailed = { + reason: string; + reconnect_delay: number; /* if less or equal to 0 reconnect is prohibited */ + } + + export type EventConnectionVoiceConnectSucceeded = {} + + export type EventConnectionVoiceConnect = { + attemptCount: number + } + + export type EventConnectionVoiceDropped = {} + + export type EventConnectionCommandError = { + error: any; + } + + export type EventClientNicknameChanged = { + client: EventClient; + + old_name: string; + new_name: string; + } + + export type EventClientNicknameChangeFailed = { + reason: string; + } + + export type EventServerClosed = { + message: string; + } + + export type EventServerRequiresPassword = {} + + export type EventServerBanned = { + message: string; + time: number; + + invoker: EventClient; + } + + export type EventClientPokeReceived = { + sender: EventClient, + message: string + } + + export type EventClientPokeSend = { + target: EventClient, + message: string + } + + export type EventPrivateMessageSend = { + target: EventClient, + message: string + } + + export type EventPrivateMessageReceived = { + sender: EventClient, + message: string + } + + export type EventWebrtcFatalError = { + message: string, + retryTimeout: number | 0 + } +} + +export type LogMessage = { + type: EventType; + uniqueId: string; + timestamp: number; + data: any; +} + +export interface TypeInfo { + "connection.begin" : event.EventConnectBegin; + "global.message": event.EventGlobalMessage; + + "error.custom": event.EventErrorCustom; + "error.permission": event.EventErrorPermission; + + "connection.hostname.resolved": event.EventConnectionHostnameResolved; + "connection.hostname.resolve": event.EventConnectionHostnameResolve; + "connection.hostname.resolve.error": event.EventConnectionHostnameResolveError; + "connection.failed": event.EventConnectionFailed; + "connection.login": event.EventConnectionLogin; + "connection.connected": event.EventConnectionConnected; + "connection.voice.dropped": event.EventConnectionVoiceDropped; + "connection.voice.connect": event.EventConnectionVoiceConnect; + "connection.voice.connect.failed": event.EventConnectionVoiceConnectFailed; + "connection.voice.connect.succeeded": event.EventConnectionVoiceConnectSucceeded; + "connection.command.error": event.EventConnectionCommandError; + + "reconnect.scheduled": event.EventReconnectScheduled; + "reconnect.canceled": event.EventReconnectCanceled; + "reconnect.execute": event.EventReconnectExecute; + + "server.welcome.message": event.EventWelcomeMessage; + "server.host.message": event.EventWelcomeMessage; + "server.host.message.disconnect": event.EventHostMessageDisconnect; + + "server.closed": event.EventServerClosed; + "server.requires.password": event.EventServerRequiresPassword; + "server.banned": event.EventServerBanned; + + "client.view.enter": event.EventClientEnter; + "client.view.move": event.EventClientMove; + "client.view.leave": event.EventClientLeave; + + "client.view.enter.own.channel": event.EventClientEnter; + "client.view.move.own.channel": event.EventClientMove; + "client.view.leave.own.channel": event.EventClientLeave; + + "client.view.move.own": event.EventClientMove; + + "client.nickname.change.failed": event.EventClientNicknameChangeFailed, + "client.nickname.changed": event.EventClientNicknameChanged, + "client.nickname.changed.own": event.EventClientNicknameChanged, + + "channel.create": event.EventChannelCreate, + "channel.show": event.EventChannelToggle, + "channel.hide": event.EventChannelToggle, + "channel.delete": event.EventChannelDelete, + + "client.poke.received": event.EventClientPokeReceived, + "client.poke.send": event.EventClientPokeSend, + + "private.message.received": event.EventPrivateMessageReceived, + "private.message.send": event.EventPrivateMessageSend, + + "webrtc.fatal.error": event.EventWebrtcFatalError + + "disconnected": any; +} + +export interface EventDispatcher { + log(data: TypeInfo[EventType], logger: ServerEventLog) : React.ReactNode; + notify(data: TypeInfo[EventType], logger: ServerEventLog); + sound(data: TypeInfo[EventType], logger: ServerEventLog); +} + +export interface ServerLogUIEvents { + "query_log": {}, + "notify_log": { + log: LogMessage[] + }, + "notify_log_add": { + event: LogMessage + }, + "notify_show": {} +} \ No newline at end of file diff --git a/shared/js/ui/frames/log/DispatcherFocus.ts b/shared/js/connectionlog/DispatcherFocus.ts similarity index 63% rename from shared/js/ui/frames/log/DispatcherFocus.ts rename to shared/js/connectionlog/DispatcherFocus.ts index 35e90e06..afd72258 100644 --- a/shared/js/ui/frames/log/DispatcherFocus.ts +++ b/shared/js/connectionlog/DispatcherFocus.ts @@ -1,8 +1,8 @@ -import {EventType} from "../../../ui/frames/log/Definitions"; -import {Settings, settings} from "../../../settings"; +import {settings, Settings} from "tc-shared/settings"; +import {EventType, TypeInfo} from "tc-shared/connectionlog/Definitions"; -const focusDefaultStatus = {}; -focusDefaultStatus[EventType.CLIENT_POKE_RECEIVED] = true; +const focusDefaultStatus: {[T in keyof TypeInfo]?: boolean} = {}; +focusDefaultStatus["client.poke.received"] = true; export function requestWindowFocus() { if(__build.target === "web") { diff --git a/shared/js/ui/frames/log/DispatcherNotifications.ts b/shared/js/connectionlog/DispatcherNotifications.ts similarity index 89% rename from shared/js/ui/frames/log/DispatcherNotifications.ts rename to shared/js/connectionlog/DispatcherNotifications.ts index 6ad0bd20..b7ef40f8 100644 --- a/shared/js/ui/frames/log/DispatcherNotifications.ts +++ b/shared/js/connectionlog/DispatcherNotifications.ts @@ -1,29 +1,27 @@ import * as loader from "tc-loader"; import {Stage} from "tc-loader"; -import * as log from "../../../log"; -import {LogCategory} from "../../../log"; -import {EventClient, EventServerAddress, EventType, TypeInfo} from "../../../ui/frames/log/Definitions"; -import {renderBBCodeAsText} from "../../../text/bbcode"; -import {format_time} from "../../../ui/frames/chat"; -import {ViewReasonId} from "../../../ConnectionHandler"; -import {findLogDispatcher} from "../../../ui/frames/log/DispatcherLog"; -import {formatDate} from "../../../MessageFormatter"; -import {Settings, settings} from "../../../settings"; import {server_connections} from "tc-shared/ConnectionManager"; import {getIconManager} from "tc-shared/file/Icons"; import { tra, tr } from "tc-shared/i18n/localize"; +import {EventClient, EventServerAddress, EventType, TypeInfo} from "tc-shared/connectionlog/Definitions"; +import {Settings, settings} from "tc-shared/settings"; +import {format_time} from "tc-shared/ui/frames/chat"; +import {ViewReasonId} from "tc-shared/ConnectionHandler"; +import {formatDate} from "tc-shared/MessageFormatter"; +import {renderBBCodeAsText} from "tc-shared/text/bbcode"; +import {LogCategory, logInfo} from "tc-shared/log"; export type DispatcherLog = (data: TypeInfo[T], handlerId: string, eventType: T) => void; -const notificationDefaultStatus = {}; -notificationDefaultStatus[EventType.CLIENT_POKE_RECEIVED] = true; -notificationDefaultStatus[EventType.SERVER_BANNED] = true; -notificationDefaultStatus[EventType.SERVER_CLOSED] = true; -notificationDefaultStatus[EventType.SERVER_HOST_MESSAGE_DISCONNECT] = true; -notificationDefaultStatus[EventType.GLOBAL_MESSAGE] = true; -notificationDefaultStatus[EventType.CONNECTION_FAILED] = true; -notificationDefaultStatus[EventType.PRIVATE_MESSAGE_RECEIVED] = true; -notificationDefaultStatus[EventType.CONNECTION_VOICE_DROPPED] = true; +const notificationDefaultStatus: {[T in keyof TypeInfo]?: boolean} = {}; +notificationDefaultStatus["client.poke.received"] = true; +notificationDefaultStatus["server.banned"] = true; +notificationDefaultStatus["server.closed"] = true; +notificationDefaultStatus["server.host.message.disconnect"] = true; +notificationDefaultStatus["global.message"] = true; +notificationDefaultStatus["connection.failed"] = true; +notificationDefaultStatus["private.message.received"] = true; +notificationDefaultStatus["connection.voice.dropped"] = true; let windowFocused = false; @@ -210,7 +208,7 @@ registerDispatcher(EventType.SERVER_BANNED, (data, handlerId) => { spawnServerNotification(handlerId, { body: data.invoker.client_id > 0 ? tra("You've been banned from the server by {0} for {1}.{2}", data.invoker.client_name, time, reason) : - tra("You've been banned from the server for {0}.{1}", time, reason) + tra("You've been banned from the server for {0}.{1}", time, reason) }); }); @@ -320,7 +318,7 @@ registerDispatcher(EventType.CLIENT_VIEW_MOVE, (data, handlerId) => { }); }); -registerDispatcher(EventType.CLIENT_VIEW_MOVE_OWN_CHANNEL, findLogDispatcher(EventType.CLIENT_VIEW_MOVE)); +registerDispatcher(EventType.CLIENT_VIEW_MOVE_OWN_CHANNEL, findNotificationDispatcher(EventType.CLIENT_VIEW_MOVE)); registerDispatcher(EventType.CLIENT_VIEW_MOVE_OWN, (data, handlerId) => { let message; @@ -406,7 +404,7 @@ registerDispatcher(EventType.CLIENT_VIEW_LEAVE_OWN_CHANNEL, (data, handlerId) => break; default: - return findLogDispatcher(EventType.CLIENT_VIEW_LEAVE)(data, handlerId, EventType.CLIENT_VIEW_LEAVE); + return findNotificationDispatcher("client.view.leave")(data, handlerId, EventType.CLIENT_VIEW_LEAVE); } spawnClientNotification(handlerId, data.client, { @@ -482,19 +480,19 @@ registerDispatcher(EventType.PRIVATE_MESSAGE_RECEIVED, (data, handlerId) => { }); registerDispatcher(EventType.WEBRTC_FATAL_ERROR, (data, handlerId) => { - if(data.retryTimeout) { - let time = Math.ceil(data.retryTimeout / 1000); - let minutes = Math.floor(time / 60); - let seconds = time % 60; + if(data.retryTimeout) { + let time = Math.ceil(data.retryTimeout / 1000); + let minutes = Math.floor(time / 60); + let seconds = time % 60; - spawnServerNotification(handlerId, { - body: tra("WebRTC connection closed due to a fatal error:\n{}\nRetry scheduled in {}.", data.message, (minutes > 0 ? minutes + "m" : "") + seconds + "s") - }); - } else { - spawnServerNotification(handlerId, { - body: tra("WebRTC connection closed due to a fatal error:\n{}\nNo retry scheduled.", data.message) - }); - } + spawnServerNotification(handlerId, { + body: tra("WebRTC connection closed due to a fatal error:\n{}\nRetry scheduled in {}.", data.message, (minutes > 0 ? minutes + "m" : "") + seconds + "s") + }); + } else { + spawnServerNotification(handlerId, { + body: tra("WebRTC connection closed due to a fatal error:\n{}\nNo retry scheduled.", data.message) + }); + } }); /* snipped PRIVATE_MESSAGE_SEND */ @@ -509,14 +507,14 @@ loader.register_task(Stage.LOADED, { /* yeahr fuck safari */ const promise = Notification.requestPermission(result => { - log.info(LogCategory.GENERAL, tr("Notification permission request (callback) resulted in %s"), result); + logInfo(LogCategory.GENERAL, tr("Notification permission request (callback) resulted in %s"), result); }) if(typeof promise !== "undefined" && 'then' in promise) { promise.then(result => { - log.info(LogCategory.GENERAL, tr("Notification permission request resulted in %s"), result); + logInfo(LogCategory.GENERAL, tr("Notification permission request resulted in %s"), result); }).catch(error => { - log.warn(LogCategory.GENERAL, tr("Failed to execute notification permission request: %O"), error); + logInfo(LogCategory.GENERAL, tr("Failed to execute notification permission request: %O"), error); }); } }, diff --git a/shared/js/connectionlog/ServerEventLog.tsx b/shared/js/connectionlog/ServerEventLog.tsx new file mode 100644 index 00000000..bd32c2be --- /dev/null +++ b/shared/js/connectionlog/ServerEventLog.tsx @@ -0,0 +1,61 @@ +import {ConnectionHandler} from "tc-shared/ConnectionHandler"; +import * as React from "react"; +import {Registry} from "tc-shared/events"; +import {Settings, settings} from "tc-shared/settings"; +import {LogMessage, TypeInfo} from "tc-shared/connectionlog/Definitions"; +import {findNotificationDispatcher, isNotificationEnabled} from "tc-shared/connectionlog/DispatcherNotifications"; +import {isFocusRequestEnabled, requestWindowFocus} from "tc-shared/connectionlog/DispatcherFocus"; + +let uniqueLogEventId = 0; +export interface ServerEventLogEvents { + notify_log_add: { event: LogMessage } +} + +export class ServerEventLog { + readonly events: Registry; + private readonly connection: ConnectionHandler; + + private maxHistoryLength: number = 100; + private eventLog: LogMessage[] = []; + + constructor(connection: ConnectionHandler) { + this.connection = connection; + this.events = new Registry(); + } + + log(type: T, data: TypeInfo[T]) { + const event = { + data: data, + timestamp: Date.now(), + type: type as any, + uniqueId: "log-" + Date.now() + "-" + (++uniqueLogEventId) + }; + + if(settings.global(Settings.FN_EVENTS_LOG_ENABLED(type), true)) { + this.eventLog.push(event); + while(this.eventLog.length > this.maxHistoryLength) { + this.eventLog.pop_front(); + } + + this.events.fire("notify_log_add", { event: event }); + } + + if(isNotificationEnabled(type as any)) { + const notification = findNotificationDispatcher(type); + if(notification) notification(data, this.connection.handlerId, type); + } + + if(isFocusRequestEnabled(type as any)) { + requestWindowFocus(); + } + } + + getHistory() : LogMessage[] { + return this.eventLog; + } + + destroy() { + this.events.destroy(); + this.eventLog = undefined; + } +} \ No newline at end of file diff --git a/shared/js/conversations/AbstractConversion.ts b/shared/js/conversations/AbstractConversion.ts index 7a5a2f2e..45e01a8f 100644 --- a/shared/js/conversations/AbstractConversion.ts +++ b/shared/js/conversations/AbstractConversion.ts @@ -59,7 +59,6 @@ export abstract class AbstractChat { protected errorMessage: string; private conversationMode: ChannelConversationMode; - protected crossChannelChatSupported: boolean = true; protected unreadTimestamp: number; protected unreadState: boolean = false; @@ -338,6 +337,9 @@ export interface AbstractChatManagerEvents { }, notify_unread_count_changed: { unreadConversations: number + }, + notify_cross_conversation_support_changed: { + crossConversationSupported: boolean } } @@ -351,18 +353,23 @@ export abstract class AbstractChatManager(); this.listenerConnection = []; this.currentUnreadCount = 0; + this.crossConversationSupport = true; + this.listenerUnreadTimestamp = () => { let count = this.getConversations().filter(conversation => conversation.isUnread()).length; if(count === this.currentUnreadCount) { return; } @@ -387,6 +394,10 @@ export abstract class AbstractChatManager this.setUnreadTimestamp(unreadTimestamp); this.preventUnreadUpdate = false; - this.events.on(["notify_unread_state_changed", "notify_read_state_changed"], event => { + this.events.on(["notify_unread_state_changed", "notify_read_state_changed"], () => { this.handle.connection.channelTree.findChannel(this.conversationId)?.setUnread(this.isReadable() && this.isUnread()); }); } @@ -175,7 +176,6 @@ export class ChannelConversation extends AbstractChat break; case "unsupported": - this.crossChannelChatSupported = false; this.setConversationMode(ChannelConversationMode.Private, false); this.setCurrentMode("normal"); break; @@ -348,6 +348,16 @@ export class ChannelConversationManager extends AbstractChatManager { + if(!success) { return; } + + this.setCrossConversationSupport(connection.serverFeatures.supportsFeature(ServerFeature.ADVANCED_CHANNEL_CHAT)); + }); + } else { + this.setCrossConversationSupport(true); + } })); this.listenerConnection.push(connection.channelTree.events.on("notify_channel_updated", event => { diff --git a/shared/js/text/bbcode.tsx b/shared/js/text/bbcode.tsx index 30672e26..12a2c1bc 100644 --- a/shared/js/text/bbcode.tsx +++ b/shared/js/text/bbcode.tsx @@ -5,6 +5,7 @@ import {parse as parseBBCode} from "vendor/xbbcode/parser"; import {fixupJQueryUrlTags} from "tc-shared/text/bbcode/url"; import {fixupJQueryImageTags} from "tc-shared/text/bbcode/image"; import "./bbcode.scss"; +import {BBCodeHandlerContext} from "vendor/xbbcode/renderer/react"; export const escapeBBCode = (text: string) => text.replace(/(\[)/g, "\\$1"); @@ -80,11 +81,23 @@ function preprocessMessage(message: string, settings: BBCodeRenderOptions) : str return message; } -export const BBCodeRenderer = (props: { message: string, settings: BBCodeRenderOptions }) => ( - - {preprocessMessage(props.message, props.settings)} - -); +export const BBCodeRenderer = (props: { message: string, settings: BBCodeRenderOptions, handlerId?: string }) => { + if(props.handlerId) { + return ( + + + {preprocessMessage(props.message, props.settings)} + + + ); + } else { + return ( + + {preprocessMessage(props.message, props.settings)} + + ); + } +} export function renderBBCodeAsJQuery(message: string, settings: BBCodeRenderOptions) : JQuery[] { diff --git a/shared/js/text/bbcode/renderer.ts b/shared/js/text/bbcode/renderer.ts index cb913483..add7a512 100644 --- a/shared/js/text/bbcode/renderer.ts +++ b/shared/js/text/bbcode/renderer.ts @@ -8,4 +8,6 @@ export const rendererHTML = new HTMLRenderer(rendererReact); import "./emoji"; import "./highlight"; -import "./youtube"; \ No newline at end of file +import "./youtube"; +import "./url"; +import "./image"; \ No newline at end of file diff --git a/shared/js/text/bbcode/url.tsx b/shared/js/text/bbcode/url.tsx index ccf1c170..56aca68d 100644 --- a/shared/js/text/bbcode/url.tsx +++ b/shared/js/text/bbcode/url.tsx @@ -4,8 +4,10 @@ import * as loader from "tc-loader"; import {ElementRenderer} from "vendor/xbbcode/renderer/base"; import {TagElement} from "vendor/xbbcode/elements"; import * as React from "react"; -import ReactRenderer from "vendor/xbbcode/renderer/react"; +import ReactRenderer, {BBCodeHandlerContext} from "vendor/xbbcode/renderer/react"; import {rendererReact, rendererText} from "tc-shared/text/bbcode/renderer"; +import {ClientTag} from "tc-shared/ui/tree/EntryTags"; +import {useContext} from "react"; function spawnUrlContextMenu(pageX: number, pageY: number, target: string) { contextmenu.spawn_context_menu(pageX, pageY, { @@ -31,6 +33,8 @@ function spawnUrlContextMenu(pageX: number, pageY: number, target: string) { }); } +const ClientUrlRegex = /client:\/\/([0-9]+)\/([-A-Za-z0-9+/=]+)~/g; + loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, { name: "XBBCode code tag init", function: async () => { @@ -39,17 +43,36 @@ loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, { const regexUrl = /^(?:[a-zA-Z]{1,16}):(?:\/{1,3}|\\)[-a-zA-Z0-9:;,@#%&()~_?+=\/\\.]*$/g; rendererReact.registerCustomRenderer(new class extends ElementRenderer { render(element: TagElement, renderer: ReactRenderer): React.ReactNode { - let target; - if (!element.options) + let target: string; + if (!element.options) { target = rendererText.render(element); - else + } else { target = element.options; + } regexUrl.lastIndex = 0; - if (!regexUrl.test(target)) + if (!regexUrl.test(target)) { target = '#'; + } + + const handlerId = useContext(BBCodeHandlerContext); + + if(handlerId) { + /* TS3-Protocol for a client */ + if(target.match(ClientUrlRegex)) { + const clientData = target.match(ClientUrlRegex); + const clientDatabaseId = parseInt(clientData[1]); + const clientUniqueId = clientDatabaseId[2]; + + return 0 ? clientDatabaseId : undefined} + handlerId={handlerId} + />; + } + } - /* TODO: Implement client URLs */ return { event.preventDefault(); spawnUrlContextMenu(event.pageX, event.pageY, target); diff --git a/shared/js/text/chat.ts b/shared/js/text/chat.ts index 62c77365..09fa189c 100644 --- a/shared/js/text/chat.ts +++ b/shared/js/text/chat.ts @@ -4,7 +4,6 @@ import {renderMarkdownAsBBCode} from "../text/markdown"; import {escapeBBCode} from "../text/bbcode"; import {parse as parseBBCode} from "vendor/xbbcode/parser"; import {TagElement} from "vendor/xbbcode/elements"; -import * as React from "react"; import {regexImage} from "tc-shared/text/bbcode/image"; interface UrlKnifeUrl { diff --git a/shared/js/tree/Channel.ts b/shared/js/tree/Channel.ts index 2885d2b2..83483c0f 100644 --- a/shared/js/tree/Channel.ts +++ b/shared/js/tree/Channel.ts @@ -18,10 +18,10 @@ import {formatMessage} from "../ui/frames/chat"; import {Registry} from "../events"; import {ChannelTreeEntry, ChannelTreeEntryEvents} from "./ChannelTreeEntry"; import {spawnFileTransferModal} from "../ui/modal/transfer/ModalFileTransfer"; -import {EventChannelData} from "../ui/frames/log/Definitions"; import {ErrorCode} from "../connection/ErrorCode"; import {ClientIcon} from "svg-sprites/client-icons"; import { tr } from "tc-shared/i18n/localize"; +import {EventChannelData} from "tc-shared/connectionlog/Definitions"; export enum ChannelType { PERMANENT, @@ -45,7 +45,16 @@ export enum ChannelSubscribeMode { export enum ChannelConversationMode { Public = 0, Private = 1, - None = 2 + None = 2, +} + +export enum ChannelSidebarMode { + Conversation = 0, + Description = 1, + FileTransfer = 2, + + /* Only used within client side */ + Unknown = 0xFF } export class ChannelProperties { @@ -79,8 +88,10 @@ export class ChannelProperties { //Only after request channel_description: string = ""; - channel_conversation_mode: ChannelConversationMode = 0; + channel_conversation_mode: ChannelConversationMode = ChannelConversationMode.Public; channel_conversation_history_length: number = -1; + + channel_sidebar_mode: ChannelSidebarMode = ChannelSidebarMode.Unknown; } export interface ChannelEvents extends ChannelTreeEntryEvents { @@ -99,7 +110,8 @@ export interface ChannelEvents extends ChannelTreeEntryEvents { }, notify_collapsed_state_changed: { collapsed: boolean - } + }, + notify_description_changed: {} } export class ParsedChannelName { @@ -173,10 +185,9 @@ export class ChannelEntry extends ChannelTreeEntry { private _destroyed = false; private cachedPasswordHash: string; - private _cached_channel_description: string = undefined; - private _cached_channel_description_promise: Promise = undefined; - private _cached_channel_description_promise_resolve: any = undefined; - private _cached_channel_description_promise_reject: any = undefined; + private channelDescriptionCached: boolean; + private channelDescriptionCallback: ((success: boolean) => void)[]; + private channelDescriptionPromise: Promise; private collapsed: boolean; private subscribed: boolean; @@ -212,18 +223,20 @@ export class ChannelEntry extends ChannelTreeEntry { this.collapsed = this.channelTree.client.settings.server(Settings.FN_SERVER_CHANNEL_COLLAPSED(this.channelId)); this.subscriptionMode = this.channelTree.client.settings.server(Settings.FN_SERVER_CHANNEL_SUBSCRIBE_MODE(this.channelId)); + + this.channelDescriptionCached = false; + this.channelDescriptionCallback = []; } destroy() { this._destroyed = true; + this.channelDescriptionCallback.forEach(callback => callback(false)); + this.channelDescriptionCallback = []; + 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; - this._cached_channel_description_promise_reject = undefined; - this.channel_previous = undefined; this.parent = undefined; this.channel_next = undefined; @@ -248,18 +261,39 @@ export class ChannelEntry extends ChannelTreeEntry { return this.parsed_channel_name.text; } - getChannelDescription() : Promise { - if(this._cached_channel_description) return new Promise(resolve => resolve(this._cached_channel_description)); - if(this._cached_channel_description_promise) return this._cached_channel_description_promise; + async getChannelDescription() : Promise { + if(this.channelDescriptionPromise) { + return this.channelDescriptionPromise; + } - this.channelTree.client.serverConnection.send_command("channelgetdescription", {cid: this.channelId}).catch(error => { - this._cached_channel_description_promise_reject(error); - }); + const promise = this.doGetChannelDescription(); + this.channelDescriptionPromise = promise; + promise + .then(() => this.channelDescriptionPromise = undefined) + .catch(() => this.channelDescriptionPromise = undefined); + return promise; + } - return this._cached_channel_description_promise = new Promise((resolve, reject) => { - this._cached_channel_description_promise_resolve = resolve; - this._cached_channel_description_promise_reject = reject; - }); + private async doGetChannelDescription() { + if(!this.channelDescriptionCached) { + await this.channelTree.client.serverConnection.send_command("channelgetdescription", { + cid: this.channelId + }); + + if(!this.channelDescriptionCached) { + /* since the channel description is a low command it will not be processed in sync */ + await new Promise((resolve, reject) => { + this.channelDescriptionCallback.push(succeeded => { + if(succeeded) { + resolve(); + } else { + reject(tr("failed to receive description")); + } + }) + }); + } + } + return this.properties.channel_description; } registerClient(client: ClientEntry) { @@ -411,7 +445,7 @@ export class ChannelEntry extends ChannelTreeEntry { callback: () => { const conversation = this.channelTree.client.getChannelConversations().findOrCreateConversation(this.getChannelId()); this.channelTree.client.getChannelConversations().setSelectedConversation(conversation); - this.channelTree.client.getSideBar().showChannelConversations(); + this.channelTree.client.getSideBar().showChannel(); }, visible: !settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT) }, { @@ -575,29 +609,27 @@ export class ChannelEntry extends ChannelTreeEntry { let key = variable.key; let value = variable.value; - if(!JSON.map_field_to(this.properties, value, variable.key)) { - /* no update */ - continue; + const hasUpdate = JSON.map_field_to(this.properties, value, variable.key); + + if(key == "channel_description") { + this.channelDescriptionCached = true; + this.channelDescriptionCallback.forEach(callback => callback(true)); + this.channelDescriptionCallback = []; } - if(key == "channel_name") { - this.parsed_channel_name = new ParsedChannelName(value, this.hasParent()); - } else if(key == "channel_order") { - let order = this.channelTree.findChannel(this.properties.channel_order); - this.channelTree.moveChannel(this, order, this.parent, false); - } 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) - this._cached_channel_description_promise_resolve(value); - this._cached_channel_description_promise = undefined; - this._cached_channel_description_promise_resolve = undefined; - this._cached_channel_description_promise_reject = undefined; - } else if(key === "channel_flag_conversation_private") { - /* "fix" for older TeaSpeak server versions (pre. 1.4.22) */ - this.properties.channel_conversation_mode = value === "1" ? 0 : 1; - variables.push({ key: "channel_conversation_mode", value: this.properties.channel_conversation_mode + "" }); + if(hasUpdate) { + if(key == "channel_name") { + this.parsed_channel_name = new ParsedChannelName(value, this.hasParent()); + } else if(key == "channel_order") { + let order = this.channelTree.findChannel(this.properties.channel_order); + this.channelTree.moveChannel(this, order, this.parent, false); + } else if(key === "channel_icon_id") { + this.properties.channel_icon_id = variable.value as any >>> 0; /* unsigned 32 bit number! */ + } else if(key === "channel_flag_conversation_private") { + /* "fix" for older TeaSpeak server versions (pre. 1.4.22) */ + this.properties.channel_conversation_mode = value === "1" ? 0 : 1; + variables.push({ key: "channel_conversation_mode", value: this.properties.channel_conversation_mode + "" }); + } } } /* devel-block(log-channel-property-updates) */ @@ -791,4 +823,14 @@ export class ChannelEntry extends ChannelTreeEntry { return subscribed ? ClientIcon.ChannelGreenSubscribed : ClientIcon.ChannelGreen; } } + + handleDescriptionChanged() { + if(!this.channelDescriptionCached) { + return; + } + + this.channelDescriptionCached = false; + this.properties.channel_description = undefined; + this.events.fire("notify_description_changed"); + } } \ No newline at end of file diff --git a/shared/js/tree/ChannelTree.tsx b/shared/js/tree/ChannelTree.tsx index 486c3b85..e7fb9859 100644 --- a/shared/js/tree/ChannelTree.tsx +++ b/shared/js/tree/ChannelTree.tsx @@ -115,6 +115,12 @@ export class ChannelTree { this.tagContainer = $.spawn("div").addClass("channel-tree-container"); renderChannelTree(this, this.tagContainer[0], { popoutButton: true }); + this.events.on("notify_channel_list_received", () => { + if(!this.selectedEntry) { + this.setSelectedEntry(this.client.getClient().currentChannel()); + } + }); + this.reset(); } @@ -177,13 +183,13 @@ export class ChannelTree { if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) { const conversation = this.client.getChannelConversations().findOrCreateConversation(this.selectedEntry.channelId); this.client.getChannelConversations().setSelectedConversation(conversation); - this.client.getSideBar().showChannelConversations(); + this.client.getSideBar().showChannel(); } } else if(this.selectedEntry instanceof ServerEntry) { if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) { const conversation = this.client.getChannelConversations().findOrCreateConversation(0); this.client.getChannelConversations().setSelectedConversation(conversation); - this.client.getSideBar().showChannelConversations() + this.client.getSideBar().showChannel() } } } @@ -559,6 +565,24 @@ export class ChannelTree { }, {width: 400, maxLength: 512}).open(); } }); + client_menu.push({ + type: contextmenu.MenuEntryType.ENTRY, + icon_class: ClientIcon.ChangeNickname, + name: tr("Send private message"), + callback: () => { + createInputModal(tr("Send private message"), tr("Message:
"), text => !!text, result => { + if (typeof(result) === "string") { + for (const client of clients) { + this.client.serverConnection.send_command("sendtextmessage", { + target: client.clientId(), + msg: result, + targetmode: 1 + }); + } + } + }, {width: 400, maxLength: 1024 * 8}).open(); + } + }); } client_menu.push({ type: contextmenu.MenuEntryType.ENTRY, diff --git a/shared/js/tree/Client.ts b/shared/js/tree/Client.ts index 28f84c51..2e2d834f 100644 --- a/shared/js/tree/Client.ts +++ b/shared/js/tree/Client.ts @@ -22,7 +22,6 @@ import * as hex from "../crypto/hex"; import {ChannelTreeEntry, ChannelTreeEntryEvents} from "./ChannelTreeEntry"; import {spawnClientVolumeChange, spawnMusicBotVolumeChange} from "../ui/modal/ModalChangeVolumeNew"; import {spawnPermissionEditorModal} from "../ui/modal/permission/ModalPermissionEditor"; -import {EventClient, EventType} from "../ui/frames/log/Definitions"; import {W2GPluginCmdHandler} from "../video-viewer/W2GPlugin"; import {global_client_actions} from "../events/GlobalEvents"; import {ClientIcon} from "svg-sprites/client-icons"; @@ -31,6 +30,7 @@ import {VoicePlayerEvents, VoicePlayerState} from "../voice/VoicePlayer"; import {ChannelTreeUIEvents} from "tc-shared/ui/tree/Definitions"; import {VideoClient} from "tc-shared/connection/VideoConnection"; import { tr } from "tc-shared/i18n/localize"; +import {EventClient} from "tc-shared/connectionlog/Definitions"; export enum ClientType { CLIENT_VOICE, @@ -573,7 +573,7 @@ export class ClientEntry extends ChannelTreeEntry { clid: this.clientId(), msg: result }).then(() => { - this.channelTree.client.log.log(EventType.CLIENT_POKE_SEND, { + this.channelTree.client.log.log("client.poke.send", { target: this.log_data(), message: result }); @@ -771,7 +771,7 @@ export class ClientEntry extends ChannelTreeEntry { if(variable.key == "client_nickname") { if(variable.value !== old_value && typeof(old_value) === "string") { if(!(this instanceof LocalClientEntry)) { /* own changes will be logged somewhere else */ - this.channelTree.client.log.log(EventType.CLIENT_NICKNAME_CHANGED, { + this.channelTree.client.log.log("client.nickname.changed", { client: this.log_data(), new_name: variable.value, old_name: old_value @@ -996,7 +996,7 @@ export class LocalClientEntry extends ClientEntry { this.updateVariables({ key: "client_nickname", value: new_name }); /* change it locally */ return this.handle.serverConnection.send_command("clientupdate", { client_nickname: new_name }).then(() => { settings.changeGlobal(Settings.KEY_CONNECT_USERNAME, new_name); - this.channelTree.client.log.log(EventType.CLIENT_NICKNAME_CHANGED_OWN, { + this.channelTree.client.log.log("client.nickname.changed.own", { client: this.log_data(), old_name: old_name, new_name: new_name, @@ -1004,7 +1004,7 @@ export class LocalClientEntry extends ClientEntry { return true; }).catch((e: CommandResult) => { this.updateVariables({ key: "client_nickname", value: old_name }); /* change it back */ - this.channelTree.client.log.log(EventType.CLIENT_NICKNAME_CHANGE_FAILED, { + this.channelTree.client.log.log("client.nickname.change.failed", { reason: e.extra_message }); return false; diff --git a/shared/js/tree/Server.ts b/shared/js/tree/Server.ts index a0db1dea..6b606ad7 100644 --- a/shared/js/tree/Server.ts +++ b/shared/js/tree/Server.ts @@ -193,7 +193,7 @@ export class ServerEntry extends ChannelTreeEntry { name: tr("Join server text channel"), callback: () => { this.channelTree.client.getChannelConversations().setSelectedConversation(this.channelTree.client.getChannelConversations().findOrCreateConversation(0)); - this.channelTree.client.getSideBar().showChannelConversations(); + this.channelTree.client.getSideBar().showChannel(); }, visible: !settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT) }, { diff --git a/shared/js/ui/frames/SideBarController.ts b/shared/js/ui/frames/SideBarController.ts index 5b1b9072..b8382841 100644 --- a/shared/js/ui/frames/SideBarController.ts +++ b/shared/js/ui/frames/SideBarController.ts @@ -9,6 +9,7 @@ import * as React from "react"; import {SideBarEvents, SideBarType} from "tc-shared/ui/frames/SideBarDefinitions"; import {Registry} from "tc-shared/events"; import {LogCategory, logWarn} from "tc-shared/log"; +import {ChannelBarController} from "tc-shared/ui/frames/side/ChannelBarController"; export class SideBarController { private readonly uiEvents: Registry; @@ -18,8 +19,8 @@ export class SideBarController { private header: SideHeaderController; private clientInfo: ClientInfoController; - private channelConversations: ChannelConversationController; private privateConversations: PrivateConversationController; + private channelBar: ChannelBarController; constructor() { this.listenerConnection = []; @@ -28,8 +29,8 @@ export class SideBarController { this.uiEvents.on("query_content", () => this.sendContent()); this.uiEvents.on("query_content_data", event => this.sendContentData(event.content)); + this.channelBar = new ChannelBarController(); this.privateConversations = new PrivateConversationController(); - this.channelConversations = new ChannelConversationController(); this.clientInfo = new ClientInfoController(); this.header = new SideHeaderController(); } @@ -45,8 +46,8 @@ export class SideBarController { this.currentConnection = connection; this.header.setConnectionHandler(connection); this.clientInfo.setConnectionHandler(connection); - this.channelConversations.setConnectionHandler(connection); this.privateConversations.setConnectionHandler(connection); + this.channelBar.setConnectionHandler(connection); if(connection) { this.listenerConnection.push(connection.getSideBar().events.on("notify_content_type_changed", () => this.sendContent())); @@ -59,14 +60,14 @@ export class SideBarController { this.header?.destroy(); this.header = undefined; + this.channelBar?.destroy(); + this.channelBar = undefined; + this.clientInfo?.destroy(); this.clientInfo = undefined; this.privateConversations?.destroy(); this.privateConversations = undefined; - - this.channelConversations?.destroy(); - this.channelConversations = undefined; } renderInto(container: HTMLDivElement) { @@ -93,17 +94,16 @@ export class SideBarController { }); break; - case "channel-chat": + case "channel": if(!this.currentConnection) { logWarn(LogCategory.GENERAL, tr("Received channel chat content data request without an active connection.")); return; } this.uiEvents.fire_react("notify_content_data", { - content: "channel-chat", + content: "channel", data: { - events: this.channelConversations["uiEvents"], - handlerId: this.currentConnection.handlerId + events: this.channelBar.uiEvents, } }); break; diff --git a/shared/js/ui/frames/SideBarDefinitions.ts b/shared/js/ui/frames/SideBarDefinitions.ts index 5fc58888..d56e43c1 100644 --- a/shared/js/ui/frames/SideBarDefinitions.ts +++ b/shared/js/ui/frames/SideBarDefinitions.ts @@ -1,17 +1,16 @@ import {Registry} from "tc-shared/events"; import {PrivateConversationUIEvents} from "tc-shared/ui/frames/side/PrivateConversationDefinitions"; -import {AbstractConversationUiEvents} from "./side/AbstractConversationDefinitions"; import {ClientInfoEvents} from "tc-shared/ui/frames/side/ClientInfoDefinitions"; import {SideHeaderEvents} from "tc-shared/ui/frames/side/HeaderDefinitions"; +import {ChannelBarUiEvents} from "tc-shared/ui/frames/side/ChannelBarDefinitions"; /* TODO: Somehow outsource the event registries to IPC? */ -export type SideBarType = "none" | "channel-chat" | "private-chat" | "client-info" | "music-manage"; +export type SideBarType = "none" | "channel" | "private-chat" | "client-info" | "music-manage"; export interface SideBarTypeData { "none": {}, - "channel-chat": { - events: Registry, - handlerId: string + "channel": { + events: Registry }, "private-chat": { events: Registry, diff --git a/shared/js/ui/frames/SideBarRenderer.tsx b/shared/js/ui/frames/SideBarRenderer.tsx index 81ad17b3..4a655295 100644 --- a/shared/js/ui/frames/SideBarRenderer.tsx +++ b/shared/js/ui/frames/SideBarRenderer.tsx @@ -1,12 +1,13 @@ import {SideHeaderEvents, SideHeaderState} from "tc-shared/ui/frames/side/HeaderDefinitions"; import {Registry} from "tc-shared/events"; -import React = require("react"); import {SideHeaderRenderer} from "tc-shared/ui/frames/side/HeaderRenderer"; -import {ConversationPanel} from "tc-shared/ui/frames/side/AbstractConversationRenderer"; import {SideBarEvents, SideBarType, SideBarTypeData} from "tc-shared/ui/frames/SideBarDefinitions"; import {useContext, useState} from "react"; import {ClientInfoRenderer} from "tc-shared/ui/frames/side/ClientInfoRenderer"; import {PrivateConversationsPanel} from "tc-shared/ui/frames/side/PrivateConversationRenderer"; +import {ChannelBarRenderer} from "tc-shared/ui/frames/side/ChannelBarRenderer"; +import {LogCategory, logWarn} from "tc-shared/log"; +import React = require("react"); const cssStyle = require("./SideBarRenderer.scss"); @@ -23,17 +24,14 @@ function useContentData(type: T) : SideBarTypeData[T] { return contentData; } -const ContentRendererChannelConversation = () => { - const contentData = useContentData("channel-chat"); +const ContentRendererChannel = () => { + const contentData = useContentData("channel"); if(!contentData) { return null; } return ( - ); }; @@ -63,8 +61,8 @@ const ContentRendererClientInfo = () => { const SideBarFrame = (props: { type: SideBarType }) => { switch (props.type) { - case "channel-chat": - return ; + case "channel": + return ; case "private-chat": return ; @@ -88,7 +86,7 @@ const SideBarHeader = (props: { type: SideBarType, eventsHeader: Registry; diff --git a/shared/js/ui/frames/log/Controller.ts b/shared/js/ui/frames/log/Controller.ts new file mode 100644 index 00000000..9fb6a0c6 --- /dev/null +++ b/shared/js/ui/frames/log/Controller.ts @@ -0,0 +1,49 @@ +import {Registry} from "tc-shared/events"; +import {ServerEventLogUiEvents} from "tc-shared/ui/frames/log/Definitions"; +import {ConnectionHandler} from "tc-shared/ConnectionHandler"; + +export class ServerEventLogController { + readonly events: Registry; + + private currentConnection: ConnectionHandler; + private listenerConnection: (() => void)[]; + + constructor() { + this.events = new Registry(); + + this.events.on("query_handler_id", () => this.events.fire_react("notify_handler_id", { handlerId: this.currentConnection?.handlerId })); + this.events.on("query_log", () => this.sendLogs()); + } + + destroy() { + this.listenerConnection?.forEach(callback => callback()); + this.listenerConnection = []; + + this.events.destroy(); + } + + setConnectionHandler(handler: ConnectionHandler) { + if(this.currentConnection === handler) { + return; + } + + this.listenerConnection?.forEach(callback => callback()); + this.listenerConnection = []; + + this.currentConnection = handler; + + if(this.currentConnection) { + this.listenerConnection.push(this.currentConnection.log.events.on("notify_log_add", event => { + this.events.fire_react("notify_log_add", { event: event.event }); + })); + } + + this.events.fire_react("notify_handler_id", { handlerId: handler?.handlerId }); + } + + + private sendLogs() { + const logs = this.currentConnection?.log.getHistory() || []; + this.events.fire_react("notify_log", { events: logs }); + } +} \ No newline at end of file diff --git a/shared/js/ui/frames/log/Definitions.ts b/shared/js/ui/frames/log/Definitions.ts index 9d442d24..6bf96009 100644 --- a/shared/js/ui/frames/log/Definitions.ts +++ b/shared/js/ui/frames/log/Definitions.ts @@ -1,348 +1,16 @@ -import {PermissionInfo} from "../../../permission/PermissionManager"; -import {ViewReasonId} from "../../../ConnectionHandler"; -import * as React from "react"; -import {ServerEventLog} from "../../../ui/frames/log/ServerEventLog"; +import {LogMessage} from "tc-shared/connectionlog/Definitions"; -/* FIXME: Remove this! */ -export enum EventType { - CONNECTION_BEGIN = "connection.begin", - CONNECTION_HOSTNAME_RESOLVE = "connection.hostname.resolve", - CONNECTION_HOSTNAME_RESOLVE_ERROR = "connection.hostname.resolve.error", - CONNECTION_HOSTNAME_RESOLVED = "connection.hostname.resolved", - CONNECTION_LOGIN = "connection.login", - CONNECTION_CONNECTED = "connection.connected", - CONNECTION_FAILED = "connection.failed", +export interface ServerEventLogUiEvents { + query_handler_id: {}, + query_log: {}, - DISCONNECTED = "disconnected", - - CONNECTION_VOICE_CONNECT = "connection.voice.connect", - CONNECTION_VOICE_CONNECT_FAILED = "connection.voice.connect.failed", - CONNECTION_VOICE_CONNECT_SUCCEEDED = "connection.voice.connect.succeeded", - CONNECTION_VOICE_DROPPED = "connection.voice.dropped", - - CONNECTION_COMMAND_ERROR = "connection.command.error", - - GLOBAL_MESSAGE = "global.message", - - SERVER_WELCOME_MESSAGE = "server.welcome.message", - SERVER_HOST_MESSAGE = "server.host.message", - SERVER_HOST_MESSAGE_DISCONNECT = "server.host.message.disconnect", - - SERVER_CLOSED = "server.closed", - SERVER_BANNED = "server.banned", - SERVER_REQUIRES_PASSWORD = "server.requires.password", - - CLIENT_VIEW_ENTER = "client.view.enter", - CLIENT_VIEW_LEAVE = "client.view.leave", - CLIENT_VIEW_MOVE = "client.view.move", - - CLIENT_VIEW_ENTER_OWN_CHANNEL = "client.view.enter.own.channel", - CLIENT_VIEW_LEAVE_OWN_CHANNEL = "client.view.leave.own.channel", - CLIENT_VIEW_MOVE_OWN_CHANNEL = "client.view.move.own.channel", - - CLIENT_VIEW_MOVE_OWN = "client.view.move.own", - - CLIENT_NICKNAME_CHANGED = "client.nickname.changed", - CLIENT_NICKNAME_CHANGED_OWN = "client.nickname.changed.own", - CLIENT_NICKNAME_CHANGE_FAILED = "client.nickname.change.failed", - - CLIENT_SERVER_GROUP_ADD = "client.server.group.add", - CLIENT_SERVER_GROUP_REMOVE = "client.server.group.remove", - CLIENT_CHANNEL_GROUP_CHANGE = "client.channel.group.change", - - PRIVATE_MESSAGE_RECEIVED = "private.message.received", - PRIVATE_MESSAGE_SEND = "private.message.send", - - CHANNEL_CREATE = "channel.create", - CHANNEL_DELETE = "channel.delete", - - ERROR_CUSTOM = "error.custom", - ERROR_PERMISSION = "error.permission", - - CLIENT_POKE_RECEIVED = "client.poke.received", - CLIENT_POKE_SEND = "client.poke.send", - - RECONNECT_SCHEDULED = "reconnect.scheduled", - RECONNECT_EXECUTE = "reconnect.execute", - RECONNECT_CANCELED = "reconnect.canceled", - - WEBRTC_FATAL_ERROR = "webrtc.fatal.error" -} - -export type EventClient = { - client_unique_id: string; - client_name: string; - client_id: number; -} -export type EventChannelData = { - channel_id: number; - channel_name: string; -} -export type EventServerData = { - server_name: string; - server_unique_id: string; -} -export type EventServerAddress = { - server_hostname: string; - server_port: number; -} - -export namespace event { - export type EventGlobalMessage = { - isOwnMessage: boolean; - sender: EventClient; - message: string; - } - export type EventConnectBegin = { - address: EventServerAddress; - client_nickname: string; - } - export type EventErrorCustom = { - message: string; - } - - export type EventReconnectScheduled = { - timeout: number; - } - - export type EventReconnectCanceled = { } - export type EventReconnectExecute = { } - - export type EventErrorPermission = { - permission: PermissionInfo; - } - - export type EventWelcomeMessage = { - message: string; - } - - export type EventHostMessageDisconnect = { - message: string; - } - - export type EventClientMove = { - channel_from?: EventChannelData; - channel_from_own: boolean; - - channel_to?: EventChannelData; - channel_to_own: boolean; - - client: EventClient; - client_own: boolean; - - invoker?: EventClient; - - message?: string; - reason: ViewReasonId; - } - - export type EventClientEnter = { - channel_from?: EventChannelData; - channel_to?: EventChannelData; - - client: EventClient; - invoker?: EventClient; - - message?: string; - - reason: ViewReasonId; - ban_time?: number; - } - - export type EventClientLeave = { - channel_from?: EventChannelData; - channel_to?: EventChannelData; - - client: EventClient; - invoker?: EventClient; - - message?: string; - - reason: ViewReasonId; - ban_time?: number; - } - - export type EventChannelCreate = { - creator: EventClient, - channel: EventChannelData, - ownAction: boolean - } - - export type EventChannelToggle = { - channel: EventChannelData - } - - export type EventChannelDelete = { - deleter: EventClient, - channel: EventChannelData, - ownAction: boolean - } - - export type EventConnectionConnected = { - serverName: string, - serverAddress: EventServerAddress, - own_client: EventClient; - } - export type EventConnectionFailed = { - serverAddress: EventServerAddress - } - export type EventConnectionLogin = {} - export type EventConnectionHostnameResolve = {}; - export type EventConnectionHostnameResolved = { - address: EventServerAddress; - } - export type EventConnectionHostnameResolveError = { - message: string; - } - - export type EventConnectionVoiceConnectFailed = { - reason: string; - reconnect_delay: number; /* if less or equal to 0 reconnect is prohibited */ - } - - export type EventConnectionVoiceConnectSucceeded = {} - - export type EventConnectionVoiceConnect = { - attemptCount: number - } - - export type EventConnectionVoiceDropped = {} - - export type EventConnectionCommandError = { - error: any; - } - - export type EventClientNicknameChanged = { - client: EventClient; - - old_name: string; - new_name: string; - } - - export type EventClientNicknameChangeFailed = { - reason: string; - } - - export type EventServerClosed = { - message: string; - } - - export type EventServerRequiresPassword = {} - - export type EventServerBanned = { - message: string; - time: number; - - invoker: EventClient; - } - - export type EventClientPokeReceived = { - sender: EventClient, - message: string - } - - export type EventClientPokeSend = { - target: EventClient, - message: string - } - - export type EventPrivateMessageSend = { - target: EventClient, - message: string - } - - export type EventPrivateMessageReceived = { - sender: EventClient, - message: string - } - - export type EventWebrtcFatalError = { - message: string, - retryTimeout: number | 0 - } -} - -export type LogMessage = { - type: EventType; - uniqueId: string; - timestamp: number; - data: any; -} - -export interface TypeInfo { - "connection.begin" : event.EventConnectBegin; - "global.message": event.EventGlobalMessage; - - "error.custom": event.EventErrorCustom; - "error.permission": event.EventErrorPermission; - - "connection.hostname.resolved": event.EventConnectionHostnameResolved; - "connection.hostname.resolve": event.EventConnectionHostnameResolve; - "connection.hostname.resolve.error": event.EventConnectionHostnameResolveError; - "connection.failed": event.EventConnectionFailed; - "connection.login": event.EventConnectionLogin; - "connection.connected": event.EventConnectionConnected; - "connection.voice.dropped": event.EventConnectionVoiceDropped; - "connection.voice.connect": event.EventConnectionVoiceConnect; - "connection.voice.connect.failed": event.EventConnectionVoiceConnectFailed; - "connection.voice.connect.succeeded": event.EventConnectionVoiceConnectSucceeded; - "connection.command.error": event.EventConnectionCommandError; - - "reconnect.scheduled": event.EventReconnectScheduled; - "reconnect.canceled": event.EventReconnectCanceled; - "reconnect.execute": event.EventReconnectExecute; - - "server.welcome.message": event.EventWelcomeMessage; - "server.host.message": event.EventWelcomeMessage; - "server.host.message.disconnect": event.EventHostMessageDisconnect; - - "server.closed": event.EventServerClosed; - "server.requires.password": event.EventServerRequiresPassword; - "server.banned": event.EventServerBanned; - - "client.view.enter": event.EventClientEnter; - "client.view.move": event.EventClientMove; - "client.view.leave": event.EventClientLeave; - - "client.view.enter.own.channel": event.EventClientEnter; - "client.view.move.own.channel": event.EventClientMove; - "client.view.leave.own.channel": event.EventClientLeave; - - "client.view.move.own": event.EventClientMove; - - "client.nickname.change.failed": event.EventClientNicknameChangeFailed, - "client.nickname.changed": event.EventClientNicknameChanged, - "client.nickname.changed.own": event.EventClientNicknameChanged, - - "channel.create": event.EventChannelCreate, - "channel.show": event.EventChannelToggle, - "channel.hide": event.EventChannelToggle, - "channel.delete": event.EventChannelDelete, - - "client.poke.received": event.EventClientPokeReceived, - "client.poke.send": event.EventClientPokeSend, - - "private.message.received": event.EventPrivateMessageReceived, - "private.message.send": event.EventPrivateMessageSend, - - "webrtc.fatal.error": event.EventWebrtcFatalError - - "disconnected": any; -} - -export interface EventDispatcher { - log(data: TypeInfo[EventType], logger: ServerEventLog) : React.ReactNode; - notify(data: TypeInfo[EventType], logger: ServerEventLog); - sound(data: TypeInfo[EventType], logger: ServerEventLog); -} - -export interface ServerLogUIEvents { - "query_log": {}, - "notify_log": { - log: LogMessage[] - }, - "notify_log_add": { + notify_log_add: { event: LogMessage }, - "notify_show": {} + notify_log: { + events: LogMessage[] + }, + notify_handler_id: { + handlerId: string | undefined + } } \ No newline at end of file diff --git a/shared/js/ui/frames/log/Renderer.scss b/shared/js/ui/frames/log/Renderer.scss index 19415557..91750d02 100644 --- a/shared/js/ui/frames/log/Renderer.scss +++ b/shared/js/ui/frames/log/Renderer.scss @@ -17,7 +17,7 @@ flex-direction: column; justify-content: flex-start; - min-height: 2em; + min-height: 1em; overflow-x: hidden; overflow-y: auto; diff --git a/shared/js/ui/frames/log/Renderer.tsx b/shared/js/ui/frames/log/Renderer.tsx index f4d0923c..2b9308e1 100644 --- a/shared/js/ui/frames/log/Renderer.tsx +++ b/shared/js/ui/frames/log/Renderer.tsx @@ -1,12 +1,17 @@ -import {LogMessage, ServerLogUIEvents} from "tc-shared/ui/frames/log/Definitions"; import {VariadicTranslatable} from "tc-shared/ui/react-elements/i18n"; import {Registry} from "tc-shared/events"; -import {useEffect, useRef, useState} from "react"; +import {useContext, useEffect, useRef, useState} from "react"; import * as React from "react"; -import {findLogDispatcher} from "tc-shared/ui/frames/log/DispatcherLog"; +import {findLogEventRenderer} from "./RendererEvent"; +import {LogMessage} from "tc-shared/connectionlog/Definitions"; +import {ServerEventLogUiEvents} from "tc-shared/ui/frames/log/Definitions"; +import {useDependentState} from "tc-shared/ui/react-elements/Helper"; const cssStyle = require("./Renderer.scss"); +const HandlerIdContext = React.createContext(undefined); +const EventsContext = React.createContext>(undefined); + const LogFallbackDispatcher = (_unused, __unused, eventType) => (
@@ -15,12 +20,14 @@ const LogFallbackDispatcher = (_unused, __unused, eventType) => (
); -const LogEntryRenderer = React.memo((props: { entry: LogMessage, handlerId: string }) => { - const dispatcher = findLogDispatcher(props.entry.type as any) || LogFallbackDispatcher; - const rendered = dispatcher(props.entry.data, props.handlerId, props.entry.type); +const LogEntryRenderer = React.memo((props: { entry: LogMessage }) => { + const handlerId = useContext(HandlerIdContext); + const dispatcher = findLogEventRenderer(props.entry.type as any) || LogFallbackDispatcher; + const rendered = dispatcher(props.entry.data, handlerId, props.entry.type); - if(!rendered) /* hide message */ + if(!rendered) { /* hide message */ return null; + } const date = new Date(props.entry.timestamp); return ( @@ -35,25 +42,27 @@ const LogEntryRenderer = React.memo((props: { entry: LogMessage, handlerId: stri ); }); -export const ServerLogRenderer = (props: { events: Registry, handlerId: string }) => { - const [ logs, setLogs ] = useState(() => { - props.events.fire_react("query_log"); +const ServerLogRenderer = () => { + const handlerId = useContext(HandlerIdContext); + const events = useContext(EventsContext); + const [ logs, setLogs ] = useDependentState(() => { + events.fire_react("query_log"); return "loading"; - }); + }, [ handlerId ]); const [ revision, setRevision ] = useState(0); const refContainer = useRef(); const scrollOffset = useRef("bottom"); - props.events.reactUse("notify_log", event => { - const logs = event.log.slice(0); + events.reactUse("notify_log", event => { + const logs = event.events.slice(0); logs.splice(0, Math.max(0, logs.length - 100)); logs.sort((a, b) => a.timestamp - b.timestamp); setLogs(logs); }); - props.events.reactUse("notify_log_add", event => { + events.reactUse("notify_log_add", event => { if(logs === "loading") { return; } @@ -72,10 +81,6 @@ export const ServerLogRenderer = (props: { events: Registry, refContainer.current.scrollTop = scrollOffset.current === "bottom" ? refContainer.current.scrollHeight : scrollOffset.current; }; - props.events.reactUse("notify_show", () => { - requestAnimationFrame(fixScroll); - }); - useEffect(() => { const id = requestAnimationFrame(fixScroll); return () => cancelAnimationFrame(id); @@ -91,7 +96,23 @@ export const ServerLogRenderer = (props: { events: Registry, scrollOffset.current = shouldFollow ? "bottom" : top; }}> - {logs === "loading" ? null : logs.map(e => )} + {logs === "loading" ? null : logs.map(e => )}
); }; + +export const ServerLogFrame = (props: { events: Registry }) => { + const [ handlerId, setHandlerId ] = useState(() => { + props.events.fire("query_handler_id"); + return undefined; + }); + props.events.reactUse("notify_handler_id", event => setHandlerId(event.handlerId)); + + return ( + + + + + + ); +} diff --git a/shared/js/ui/frames/log/DispatcherLog.tsx b/shared/js/ui/frames/log/RendererEvent.tsx similarity index 86% rename from shared/js/ui/frames/log/DispatcherLog.tsx rename to shared/js/ui/frames/log/RendererEvent.tsx index 5da3a902..523ebbd9 100644 --- a/shared/js/ui/frames/log/DispatcherLog.tsx +++ b/shared/js/ui/frames/log/RendererEvent.tsx @@ -1,5 +1,4 @@ import {ViewReasonId} from "tc-shared/ConnectionHandler"; -import {EventChannelData, EventClient, EventType, TypeInfo} from "tc-shared/ui/frames/log/Definitions"; import * as React from "react"; import {Translatable, VariadicTranslatable} from "tc-shared/ui/react-elements/i18n"; import {formatDate} from "tc-shared/MessageFormatter"; @@ -8,22 +7,23 @@ import {format_time} from "tc-shared/ui/frames/chat"; import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration"; import {XBBCodeRenderer} from "vendor/xbbcode/react"; import {ChannelTag, ClientTag} from "tc-shared/ui/tree/EntryTags"; +import {EventChannelData, EventClient, EventType, TypeInfo} from "tc-shared/connectionlog/Definitions"; const cssStyle = require("./DispatcherLog.scss"); const cssStyleRenderer = require("./Renderer.scss"); -export type DispatcherLog = (data: TypeInfo[T], handlerId: string, eventType: T) => React.ReactNode; +export type RendererEvent = (data: TypeInfo[T], handlerId: string, eventType: T) => React.ReactNode; -const dispatchers: {[key: string]: DispatcherLog} = { }; -function registerDispatcher(key: T, builder: DispatcherLog) { - dispatchers[key] = builder; +const dispatchers: {[T in keyof TypeInfo]?: RendererEvent} = { }; +function registerRenderer(key: T, builder: RendererEvent) { + dispatchers[key] = builder as any; } -export function findLogDispatcher(type: T) : DispatcherLog { - return dispatchers[type]; +export function findLogEventRenderer(type: T) : RendererEvent { + return dispatchers[type] as any; } -export function getRegisteredLogDispatchers() : TypeInfo[] { +export function getRegisteredLogEventRenderer() : TypeInfo[] { return Object.keys(dispatchers) as any; } @@ -46,66 +46,66 @@ const ChannelRenderer = (props: { channel: EventChannelData, handlerId: string, /> ); -registerDispatcher(EventType.ERROR_CUSTOM, data =>
{data.message}
); +registerRenderer(EventType.ERROR_CUSTOM, data =>
{data.message}
); -registerDispatcher(EventType.CONNECTION_BEGIN, data => ( +registerRenderer(EventType.CONNECTION_BEGIN, data => ( <>{data.address.server_hostname} <>{data.address.server_port == 9987 ? "" : (":" + data.address.server_port)} )); -registerDispatcher(EventType.CONNECTION_HOSTNAME_RESOLVE, () => ( +registerRenderer(EventType.CONNECTION_HOSTNAME_RESOLVE, () => ( Resolving hostname )); -registerDispatcher(EventType.CONNECTION_HOSTNAME_RESOLVED, data => ( +registerRenderer(EventType.CONNECTION_HOSTNAME_RESOLVED, data => ( <>{data.address.server_hostname} <>{data.address.server_port} )); -registerDispatcher(EventType.CONNECTION_HOSTNAME_RESOLVE_ERROR, data => ( +registerRenderer(EventType.CONNECTION_HOSTNAME_RESOLVE_ERROR, data => ( <>{data.message} )); -registerDispatcher(EventType.CONNECTION_LOGIN, () => ( +registerRenderer(EventType.CONNECTION_LOGIN, () => ( Logging in... )); -registerDispatcher(EventType.CONNECTION_FAILED, () => ( +registerRenderer(EventType.CONNECTION_FAILED, () => ( Connect failed. )); -registerDispatcher(EventType.CONNECTION_CONNECTED, (data,handlerId) => ( +registerRenderer(EventType.CONNECTION_CONNECTED, (data,handlerId) => ( )); -registerDispatcher(EventType.CONNECTION_VOICE_CONNECT, () => ( +registerRenderer(EventType.CONNECTION_VOICE_CONNECT, () => ( Connecting voice bridge. )); -registerDispatcher(EventType.CONNECTION_VOICE_CONNECT_SUCCEEDED, () => ( +registerRenderer(EventType.CONNECTION_VOICE_CONNECT_SUCCEEDED, () => ( Voice bridge successfully connected. )); -registerDispatcher(EventType.CONNECTION_VOICE_CONNECT_FAILED, (data) => ( +registerRenderer(EventType.CONNECTION_VOICE_CONNECT_FAILED, (data) => ( <>{data.reason} {data.reconnect_delay > 0 ? Yes : No} )); -registerDispatcher(EventType.CONNECTION_VOICE_DROPPED, () => ( +registerRenderer(EventType.CONNECTION_VOICE_DROPPED, () => ( Voice bridge has been dropped. Trying to reconnect. )); -registerDispatcher(EventType.ERROR_PERMISSION, data => ( +registerRenderer(EventType.ERROR_PERMISSION, data => (
<>{data.permission ? data.permission.name : unknown} @@ -113,7 +113,7 @@ registerDispatcher(EventType.ERROR_PERMISSION, data => (
)); -registerDispatcher(EventType.CLIENT_VIEW_ENTER, (data, handlerId) => { +registerRenderer(EventType.CLIENT_VIEW_ENTER, (data, handlerId) => { switch (data.reason) { case ViewReasonId.VREASON_USER_ACTION: if(data.channel_from) { @@ -189,7 +189,7 @@ registerDispatcher(EventType.CLIENT_VIEW_ENTER, (data, handlerId) => { } }); -registerDispatcher(EventType.CLIENT_VIEW_ENTER_OWN_CHANNEL, (data, handlerId) => { +registerRenderer(EventType.CLIENT_VIEW_ENTER_OWN_CHANNEL, (data, handlerId) => { switch (data.reason) { case ViewReasonId.VREASON_USER_ACTION: if(data.channel_from) { @@ -265,7 +265,7 @@ registerDispatcher(EventType.CLIENT_VIEW_ENTER_OWN_CHANNEL, (data, handlerId) => } }); -registerDispatcher(EventType.CLIENT_VIEW_MOVE, (data, handlerId) => { +registerRenderer(EventType.CLIENT_VIEW_MOVE, (data, handlerId) => { switch (data.reason) { case ViewReasonId.VREASON_MOVED: return ( @@ -308,9 +308,9 @@ registerDispatcher(EventType.CLIENT_VIEW_MOVE, (data, handlerId) => { } }); -registerDispatcher(EventType.CLIENT_VIEW_MOVE_OWN_CHANNEL, findLogDispatcher(EventType.CLIENT_VIEW_MOVE)); +registerRenderer(EventType.CLIENT_VIEW_MOVE_OWN_CHANNEL, findLogEventRenderer(EventType.CLIENT_VIEW_MOVE)); -registerDispatcher(EventType.CLIENT_VIEW_MOVE_OWN, (data, handlerId) => { +registerRenderer(EventType.CLIENT_VIEW_MOVE_OWN, (data, handlerId) => { switch (data.reason) { case ViewReasonId.VREASON_MOVED: return ( @@ -353,7 +353,7 @@ registerDispatcher(EventType.CLIENT_VIEW_MOVE_OWN, (data, handlerId) => { } }); -registerDispatcher(EventType.CLIENT_VIEW_LEAVE, (data, handlerId) => { +registerRenderer(EventType.CLIENT_VIEW_LEAVE, (data, handlerId) => { switch (data.reason) { case ViewReasonId.VREASON_USER_ACTION: return ( @@ -434,7 +434,7 @@ registerDispatcher(EventType.CLIENT_VIEW_LEAVE, (data, handlerId) => { } }); -registerDispatcher(EventType.CLIENT_VIEW_LEAVE_OWN_CHANNEL, (data, handlerId) => { +registerRenderer(EventType.CLIENT_VIEW_LEAVE_OWN_CHANNEL, (data, handlerId) => { switch (data.reason) { case ViewReasonId.VREASON_USER_ACTION: return ( @@ -456,24 +456,24 @@ registerDispatcher(EventType.CLIENT_VIEW_LEAVE_OWN_CHANNEL, (data, handlerId) => ); default: - return findLogDispatcher(EventType.CLIENT_VIEW_LEAVE)(data, handlerId, EventType.CLIENT_VIEW_LEAVE); + return findLogEventRenderer(EventType.CLIENT_VIEW_LEAVE)(data, handlerId, EventType.CLIENT_VIEW_LEAVE); } }); -registerDispatcher(EventType.SERVER_WELCOME_MESSAGE,data => ( - +registerRenderer(EventType.SERVER_WELCOME_MESSAGE,(data, handlerId) => ( + )); -registerDispatcher(EventType.SERVER_HOST_MESSAGE,data => ( - +registerRenderer(EventType.SERVER_HOST_MESSAGE,(data, handlerId) => ( + )); -registerDispatcher(EventType.SERVER_HOST_MESSAGE_DISCONNECT,data => ( - +registerRenderer(EventType.SERVER_HOST_MESSAGE_DISCONNECT,(data, handlerId) => ( + )); -registerDispatcher(EventType.CLIENT_NICKNAME_CHANGED,(data, handlerId) => ( +registerRenderer(EventType.CLIENT_NICKNAME_CHANGED,(data, handlerId) => ( <>{data.old_name} @@ -481,44 +481,44 @@ registerDispatcher(EventType.CLIENT_NICKNAME_CHANGED,(data, handlerId) => ( )); -registerDispatcher(EventType.CLIENT_NICKNAME_CHANGED_OWN,() => ( +registerRenderer(EventType.CLIENT_NICKNAME_CHANGED_OWN,() => ( Nickname successfully changed. )); -registerDispatcher(EventType.CLIENT_NICKNAME_CHANGE_FAILED,(data) => ( +registerRenderer(EventType.CLIENT_NICKNAME_CHANGE_FAILED,(data) => ( <>{data.reason} )); -registerDispatcher(EventType.GLOBAL_MESSAGE, (data, handlerId) => <> +registerRenderer(EventType.GLOBAL_MESSAGE, (data, handlerId) => <> - {data.message} + ); -registerDispatcher(EventType.DISCONNECTED,() => ( +registerRenderer(EventType.DISCONNECTED,() => ( Disconnected from server )); -registerDispatcher(EventType.RECONNECT_SCHEDULED,data => ( +registerRenderer(EventType.RECONNECT_SCHEDULED,data => ( <>{format_time(data.timeout, tr("now"))} )); -registerDispatcher(EventType.RECONNECT_CANCELED,() => ( +registerRenderer(EventType.RECONNECT_CANCELED,() => ( Reconnect canceled. )); -registerDispatcher(EventType.RECONNECT_CANCELED,() => ( +registerRenderer(EventType.RECONNECT_CANCELED,() => ( Reconnecting... )); -registerDispatcher(EventType.SERVER_BANNED,(data, handlerId) => { +registerRenderer(EventType.SERVER_BANNED,(data, handlerId) => { const time = data.time === 0 ? ever : <>{format_time(data.time * 1000, tr("one second"))}; const reason = data.message ? <> Reason: {data.message} : undefined; @@ -544,11 +544,11 @@ registerDispatcher(EventType.SERVER_BANNED,(data, handlerId) => { ); }); -registerDispatcher(EventType.SERVER_REQUIRES_PASSWORD,() => ( +registerRenderer(EventType.SERVER_REQUIRES_PASSWORD,() => ( Server requires a password to connect. )); -registerDispatcher(EventType.SERVER_CLOSED,data => { +registerRenderer(EventType.SERVER_CLOSED,data => { if(data.message) return ( @@ -558,7 +558,7 @@ registerDispatcher(EventType.SERVER_CLOSED,data => { return Server has been closed.; }); -registerDispatcher(EventType.CONNECTION_COMMAND_ERROR,data => { +registerRenderer(EventType.CONNECTION_COMMAND_ERROR,data => { let message; if(typeof data.error === "string") message = data.error; @@ -576,7 +576,7 @@ registerDispatcher(EventType.CONNECTION_COMMAND_ERROR,data => { ) }); -registerDispatcher(EventType.CHANNEL_CREATE,(data, handlerId) => { +registerRenderer(EventType.CHANNEL_CREATE,(data, handlerId) => { if(data.ownAction) { return ( @@ -593,13 +593,13 @@ registerDispatcher(EventType.CHANNEL_CREATE,(data, handlerId) => { } }); -registerDispatcher("channel.show",(data, handlerId) => ( +registerRenderer("channel.show",(data, handlerId) => ( )); -registerDispatcher(EventType.CHANNEL_DELETE,(data, handlerId) => { +registerRenderer(EventType.CHANNEL_DELETE,(data, handlerId) => { if(data.ownAction) { return ( @@ -616,24 +616,24 @@ registerDispatcher(EventType.CHANNEL_DELETE,(data, handlerId) => { } }); -registerDispatcher("channel.hide",(data, handlerId) => ( +registerRenderer("channel.hide",(data, handlerId) => ( )); -registerDispatcher(EventType.CLIENT_POKE_SEND,(data, handlerId) => ( +registerRenderer(EventType.CLIENT_POKE_SEND,(data, handlerId) => ( )); -registerDispatcher(EventType.CLIENT_POKE_RECEIVED,(data, handlerId) => { +registerRenderer(EventType.CLIENT_POKE_RECEIVED,(data, handlerId) => { if(data.message) { return ( - + ); } else { @@ -645,10 +645,10 @@ registerDispatcher(EventType.CLIENT_POKE_RECEIVED,(data, handlerId) => { } }); -registerDispatcher(EventType.PRIVATE_MESSAGE_RECEIVED, () => undefined); -registerDispatcher(EventType.PRIVATE_MESSAGE_SEND, () => undefined); +registerRenderer(EventType.PRIVATE_MESSAGE_RECEIVED, () => undefined); +registerRenderer(EventType.PRIVATE_MESSAGE_SEND, () => undefined); -registerDispatcher(EventType.WEBRTC_FATAL_ERROR, (data) => { +registerRenderer(EventType.WEBRTC_FATAL_ERROR, (data) => { if(data.retryTimeout) { let time = Math.ceil(data.retryTimeout / 1000); let minutes = Math.floor(time / 60); diff --git a/shared/js/ui/frames/log/ServerEventLog.tsx b/shared/js/ui/frames/log/ServerEventLog.tsx deleted file mode 100644 index fa594fc0..00000000 --- a/shared/js/ui/frames/log/ServerEventLog.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import {ConnectionHandler} from "tc-shared/ConnectionHandler"; -import * as React from "react"; -import {LogMessage, ServerLogUIEvents, TypeInfo} from "tc-shared/ui/frames/log/Definitions"; -import {Registry} from "tc-shared/events"; -import * as ReactDOM from "react-dom"; -import {ServerLogRenderer} from "tc-shared/ui/frames/log/Renderer"; -import {findNotificationDispatcher, isNotificationEnabled} from "tc-shared/ui/frames/log/DispatcherNotifications"; -import {Settings, settings} from "tc-shared/settings"; -import {isFocusRequestEnabled, requestWindowFocus} from "tc-shared/ui/frames/log/DispatcherFocus"; - -const cssStyle = require("./Renderer.scss"); - -let uniqueLogEventId = 0; -export class ServerEventLog { - private readonly connection: ConnectionHandler; - private readonly uiEvents: Registry; - private readonly listenerHandlerVisibilityChanged; - - private htmlTag: HTMLDivElement; - - private maxHistoryLength: number = 100; - - private eventLog: LogMessage[] = []; - - constructor(connection: ConnectionHandler) { - this.connection = connection; - this.uiEvents = new Registry(); - this.htmlTag = document.createElement("div"); - this.htmlTag.classList.add(cssStyle.htmlTag); - - this.uiEvents.on("query_log", () => { - this.uiEvents.fire_react("notify_log", { log: this.eventLog.slice() }); - }); - - ReactDOM.render(, this.htmlTag); - - this.connection.events().on("notify_visibility_changed", this.listenerHandlerVisibilityChanged =event => { - if(event.visible) { - this.uiEvents.fire("notify_show"); - } - }); - } - - log(type: T, data: TypeInfo[T]) { - const event = { - data: data, - timestamp: Date.now(), - type: type as any, - uniqueId: "log-" + Date.now() + "-" + (++uniqueLogEventId) - }; - - if(settings.global(Settings.FN_EVENTS_LOG_ENABLED(type), true)) { - this.eventLog.push(event); - while(this.eventLog.length > this.maxHistoryLength) - this.eventLog.pop_front(); - - this.uiEvents.fire_react("notify_log_add", { event: event }); - } - - if(isNotificationEnabled(type as any)) { - const notification = findNotificationDispatcher(type); - if(notification) notification(data, this.connection.handlerId, type); - } - - if(isFocusRequestEnabled(type as any)) { - requestWindowFocus(); - } - } - - getHTMLTag() { - return this.htmlTag; - } - - destroy() { - if(this.htmlTag) { - ReactDOM.unmountComponentAtNode(this.htmlTag); - this.htmlTag?.remove(); - this.htmlTag = undefined; - } - - this.connection.events().off(this.listenerHandlerVisibilityChanged); - this.eventLog = undefined; - - this.uiEvents.destroy(); - } -} \ No newline at end of file diff --git a/shared/js/ui/frames/side/AbstractConversationController.ts b/shared/js/ui/frames/side/AbstractConversationController.ts index bc9ffdaa..46d68371 100644 --- a/shared/js/ui/frames/side/AbstractConversationController.ts +++ b/shared/js/ui/frames/side/AbstractConversationController.ts @@ -29,8 +29,6 @@ export abstract class AbstractConversationController< protected currentSelectedConversation: ConversationType; protected currentSelectedListener: (() => void)[]; - protected crossChannelChatSupported = true; - protected constructor() { this.uiEvents = new Registry(); this.currentSelectedListener = []; @@ -68,6 +66,12 @@ export abstract class AbstractConversationController< protected registerConversationManagerEvents(manager: Manager) { this.listenerManager.push(manager.events.on("notify_selected_changed", event => this.setCurrentlySelected(event.newConversation))); + this.listenerManager.push(manager.events.on("notify_cross_conversation_support_changed", () => { + const currentConversation = this.getCurrentConversation(); + if(currentConversation) { + this.reportStateToUI(currentConversation); + } + })); } protected registerConversationEvents(conversation: ConversationType) { @@ -138,7 +142,7 @@ export abstract class AbstractConversationController< this.uiEvents.fire_react("notify_conversation_state", { chatId: conversation.getChatId(), state: "private", - crossChannelChatSupported: this.crossChannelChatSupported + crossChannelChatSupported: this.conversationManager.hasCrossConversationSupport() }); return; } @@ -154,7 +158,7 @@ export abstract class AbstractConversationController< chatFrameMaxMessageCount: kMaxChatFrameMessageSize, unreadTimestamp: conversation.getUnreadTimestamp(), - showUserSwitchEvents: conversation.isPrivate() || !this.crossChannelChatSupported, + showUserSwitchEvents: conversation.isPrivate() || !this.conversationManager.hasCrossConversationSupport(), sendEnabled: conversation.isSendEnabled(), events: [...conversation.getPresentEvents(), ...conversation.getPresentMessages()] @@ -251,18 +255,6 @@ export abstract class AbstractConversationController< return this.currentSelectedConversation; } - protected setCrossChannelChatSupport(flag: boolean) { - if(this.crossChannelChatSupported === flag) { - return; - } - - this.crossChannelChatSupported = flag; - const currentConversation = this.getCurrentConversation(); - if(currentConversation) { - this.reportStateToUI(currentConversation); - } - } - @EventHandler("query_conversation_state") protected handleQueryConversationState(event: AbstractConversationUiEvents["query_conversation_state"]) { const conversation = this.conversationManager?.findConversationById(event.chatId); diff --git a/shared/js/ui/frames/side/AbstractConversationRenderer.scss b/shared/js/ui/frames/side/AbstractConversationRenderer.scss index 87dd94bf..0f631538 100644 --- a/shared/js/ui/frames/side/AbstractConversationRenderer.scss +++ b/shared/js/ui/frames/side/AbstractConversationRenderer.scss @@ -56,6 +56,9 @@ html:root { position: relative; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; + .containerMessages { flex-grow: 1; flex-shrink: 1; diff --git a/shared/js/ui/frames/side/AbstractConversationRenderer.tsx b/shared/js/ui/frames/side/AbstractConversationRenderer.tsx index 79357c25..9b2afe07 100644 --- a/shared/js/ui/frames/side/AbstractConversationRenderer.tsx +++ b/shared/js/ui/frames/side/AbstractConversationRenderer.tsx @@ -26,9 +26,9 @@ import {ChatBox} from "tc-shared/ui/react-elements/ChatBox"; const cssStyle = require("./AbstractConversationRenderer.scss"); -const ChatMessageTextRenderer = React.memo((props: { text: string }) => { +const ChatMessageTextRenderer = React.memo((props: { text: string, handlerId: string }) => { if(typeof props.text !== "string") { debugger; } - return ; + return ; }); const ChatEventMessageRenderer = React.memo((props: { @@ -71,7 +71,7 @@ const ChatEventMessageRenderer = React.memo((props: {
{ /* Only for copy purposes */ }
- +

{ /* Only for copy purposes */ }
diff --git a/shared/js/ui/frames/side/ChannelBarController.ts b/shared/js/ui/frames/side/ChannelBarController.ts new file mode 100644 index 00000000..c08f39f0 --- /dev/null +++ b/shared/js/ui/frames/side/ChannelBarController.ts @@ -0,0 +1,194 @@ +import {ConnectionHandler} from "tc-shared/ConnectionHandler"; +import {Registry} from "tc-shared/events"; +import {ChannelBarMode, ChannelBarUiEvents} from "tc-shared/ui/frames/side/ChannelBarDefinitions"; +import {ChannelEntry, ChannelSidebarMode} from "tc-shared/tree/Channel"; +import {ChannelConversationController} from "tc-shared/ui/frames/side/ChannelConversationController"; +import {ChannelDescriptionController} from "tc-shared/ui/frames/side/ChannelDescriptionController"; +import {LocalClientEntry} from "tc-shared/tree/Client"; + +export class ChannelBarController { + readonly uiEvents: Registry; + + private channelConversations: ChannelConversationController; + private description: ChannelDescriptionController; + + private currentConnection: ConnectionHandler; + private listenerConnection: (() => void)[]; + + private currentChannel: ChannelEntry; + private listenerChannel: (() => void)[]; + + constructor() { + this.uiEvents = new Registry(); + this.listenerConnection = []; + this.listenerChannel = []; + + this.channelConversations = new ChannelConversationController(); + this.description = new ChannelDescriptionController(); + + this.uiEvents.on("query_mode", () => this.notifyChannelMode()); + this.uiEvents.on("query_channel_id", () => this.notifyChannelId()); + this.uiEvents.on("query_data", event => this.notifyModeData(event.mode)); + } + + destroy() { + this.listenerConnection.forEach(callback => callback()); + this.listenerConnection = []; + + this.listenerChannel.forEach(callback => callback()); + this.listenerChannel = []; + + this.currentChannel = undefined; + this.currentConnection = undefined; + + this.channelConversations?.destroy(); + this.channelConversations = undefined; + + this.description?.destroy(); + this.description = undefined; + + this.uiEvents.destroy(); + } + + setConnectionHandler(handler: ConnectionHandler) { + if(this.currentConnection === handler) { + return; + } + + this.channelConversations.setConnectionHandler(handler); + + this.listenerConnection.forEach(callback => callback()); + this.listenerConnection = []; + + this.currentConnection = handler; + + const selectedEntry = handler?.channelTree.getSelectedEntry(); + if(selectedEntry instanceof ChannelEntry) { + this.setChannel(selectedEntry); + } else { + this.setChannel(undefined); + } + + if(handler) { + this.listenerConnection.push(handler.channelTree.events.on("notify_selected_entry_changed", event => { + if(event.newEntry instanceof ChannelEntry) { + this.setChannel(event.newEntry); + } + })); + + this.listenerConnection.push(handler.channelTree.events.on("notify_client_moved", event => { + if(event.client instanceof LocalClientEntry) { + if(event.oldChannel === this.currentChannel || event.newChannel === this.currentChannel) { + /* The mode may changed since we can now write in the channel */ + this.notifyChannelMode(); + } + } + })); + + this.listenerConnection.push(handler.getChannelConversations().events.on("notify_cross_conversation_support_changed", () => { + this.notifyChannelMode(); + })); + } + } + + private setChannel(channel: ChannelEntry) { + if(this.currentChannel === channel) { + return; + } + + this.description.setChannel(channel); + + this.listenerChannel.forEach(callback => callback()); + this.listenerChannel = []; + + this.currentChannel = channel; + this.notifyChannelId(); + + if(channel) { + this.listenerChannel.push(channel.events.on("notify_properties_updated", event => { + if("channel_sidebar_mode" in event.updated_properties) { + this.notifyChannelMode(); + } + })); + } + } + + private notifyChannelId() { + this.uiEvents.fire_react("notify_channel_id", { + channelId: this.currentChannel ? this.currentChannel.channelId : -1, + handlerId: this.currentConnection ? this.currentConnection.handlerId : "unbound" + }); + } + + private notifyChannelMode() { + let mode: ChannelBarMode = "none"; + + if(this.currentChannel) { + switch(this.currentChannel.properties.channel_sidebar_mode) { + case ChannelSidebarMode.Description: + mode = "description"; + break; + + case ChannelSidebarMode.FileTransfer: + mode = "file-transfer"; + break; + + case ChannelSidebarMode.Conversation: + mode = "conversation"; + break; + + case ChannelSidebarMode.Unknown: + default: + if(this.currentConnection) { + const channelConversation = this.currentConnection.getChannelConversations(); + if(channelConversation.hasCrossConversationSupport() || this.currentChannel === this.currentConnection.getClient().currentChannel()) { + mode = "conversation"; + } else { + /* A really old TeaSpeak server or a TeamSpeak server. */ + mode = "description"; + } + } else { + mode = "none"; + } + break; + } + } + + this.uiEvents.fire_react("notify_mode", { mode: mode }); + } + + private notifyModeData(mode: ChannelBarMode) { + switch (mode) { + case "none": + this.uiEvents.fire_react("notify_data", { content: "none", data: {} }); + break; + + case "conversation": + this.uiEvents.fire_react("notify_data", { + content: "conversation", + data: { + events: this.channelConversations.getUiEvents() + } + }); + break; + + case "description": + this.uiEvents.fire_react("notify_data", { + content: "description", + data: { + events: this.description.uiEvents + } + }); + break; + + case "file-transfer": + this.uiEvents.fire_react("notify_data", { + content: "file-transfer", + data: { + } + }); + /* TODO! */ + break; + } + } +} \ No newline at end of file diff --git a/shared/js/ui/frames/side/ChannelBarDefinitions.ts b/shared/js/ui/frames/side/ChannelBarDefinitions.ts new file mode 100644 index 00000000..a14ffc18 --- /dev/null +++ b/shared/js/ui/frames/side/ChannelBarDefinitions.ts @@ -0,0 +1,36 @@ +import {Registry} from "tc-shared/events"; +import {ChannelConversationUiEvents} from "tc-shared/ui/frames/side/ChannelConversationDefinitions"; +import {ChannelDescriptionUiEvents} from "tc-shared/ui/frames/side/ChannelDescriptionDefinitions"; + +export type ChannelBarMode = "conversation" | "description" | "file-transfer" | "none"; + +export interface ChannelBarModeData { + "conversation": { + events: Registry, + }, + "description": { + events: Registry + }, + "file-transfer": { + /* TODO! */ + }, + "none": {} +} + +export type ChannelBarNotifyModeData = { + content: T, + data: ChannelBarModeData[T] +} + +export interface ChannelBarUiEvents { + query_mode: {}, + query_channel_id: {}, + query_data: { mode: ChannelBarMode }, + + notify_mode: { mode: ChannelBarMode }, + notify_channel_id: { + channelId: number, + handlerId: string + }, + notify_data: ChannelBarNotifyModeData +} \ No newline at end of file diff --git a/shared/js/ui/frames/side/ChannelBarRenderer.tsx b/shared/js/ui/frames/side/ChannelBarRenderer.tsx new file mode 100644 index 00000000..bfaf031f --- /dev/null +++ b/shared/js/ui/frames/side/ChannelBarRenderer.tsx @@ -0,0 +1,89 @@ +import {Registry} from "tc-shared/events"; +import {ChannelBarMode, ChannelBarModeData, ChannelBarUiEvents} from "tc-shared/ui/frames/side/ChannelBarDefinitions"; +import {useContext, useState} from "react"; +import * as React from "react"; +import {ConversationPanel} from "tc-shared/ui/frames/side/AbstractConversationRenderer"; +import {useDependentState} from "tc-shared/ui/react-elements/Helper"; +import {ChannelDescriptionRenderer} from "tc-shared/ui/frames/side/ChannelDescriptionRenderer"; + +const EventContext = React.createContext>(undefined); +const ChannelContext = React.createContext<{ channelId: number, handlerId: string }>(undefined); + +function useModeData(type: T, dependencies: any[]) : ChannelBarModeData[T] { + const events = useContext(EventContext); + const [ contentData, setContentData ] = useDependentState(() => { + events.fire("query_data", { mode: type }); + return undefined; + }, dependencies); + events.reactUse("notify_data", event => event.content === type && setContentData(event.data)); + + return contentData; +} + +const ModeRenderer = () => { + const events = useContext(EventContext); + const channelContext = useContext(ChannelContext); + const [ mode, setMode ] = useDependentState(() => { + events.fire("query_mode"); + return "none"; + }, [ channelContext.channelId, channelContext.handlerId ]); + events.reactUse("notify_mode", event => setMode(event.mode)); + + switch (mode) { + case "conversation": + return ; + + case "description": + return ; + + case "file-transfer": + /* TODO! */ + return null; + + case "none": + default: + return null; + } +}; + +const ModeRendererConversation = React.memo(() => { + const channelContext = useContext(ChannelContext); + const data = useModeData("conversation", [ channelContext ]); + if(!data) { return null; } + + return ( + + ); +}); + +const ModeRendererDescription = React.memo(() => { + const channelContext = useContext(ChannelContext); + const data = useModeData("description", [ channelContext ]); + if(!data) { return null; } + + return ( + + ); +}); + +export const ChannelBarRenderer = (props: { events: Registry }) => { + const [ channelContext, setChannelContext ] = useState<{ channelId: number, handlerId: string }>(() => { + props.events.fire("query_channel_id"); + return { channelId: -1, handlerId: "unbound" }; + }); + props.events.reactUse("notify_channel_id", event => setChannelContext({ handlerId: event.handlerId, channelId: event.channelId })); + + return ( + + + + + + ); +} \ No newline at end of file diff --git a/shared/js/ui/frames/side/ChannelConversationController.ts b/shared/js/ui/frames/side/ChannelConversationController.ts index 0ce07034..6f06cadc 100644 --- a/shared/js/ui/frames/side/ChannelConversationController.ts +++ b/shared/js/ui/frames/side/ChannelConversationController.ts @@ -11,7 +11,6 @@ import { ChannelConversationManager, ChannelConversationManagerEvents } from "tc-shared/conversations/ChannelConversationManager"; -import {ServerFeature} from "tc-shared/connection/ServerFeatures"; import {ChannelConversationUiEvents} from "tc-shared/ui/frames/side/ChannelConversationDefinitions"; export class ChannelConversationController extends AbstractConversationController< @@ -75,18 +74,6 @@ export class ChannelConversationController extends AbstractConversationControlle this.handlePanelShow(); })); - - this.connectionListener.push(connection.events().on("notify_connection_state_changed", event => { - if(event.newState === ConnectionState.CONNECTED) { - connection.serverFeatures.awaitFeatures().then(success => { - if(!success) { return; } - - this.setCrossChannelChatSupport(connection.serverFeatures.supportsFeature(ServerFeature.ADVANCED_CHANNEL_CHAT)); - }); - } else { - this.setCrossChannelChatSupport(true); - } - })); } @EventHandler("action_delete_message") diff --git a/shared/js/ui/frames/side/ChannelDescriptionController.ts b/shared/js/ui/frames/side/ChannelDescriptionController.ts new file mode 100644 index 00000000..eb12436f --- /dev/null +++ b/shared/js/ui/frames/side/ChannelDescriptionController.ts @@ -0,0 +1,121 @@ +import {ChannelEntry} from "tc-shared/tree/Channel"; +import {Registry} from "tc-shared/events"; +import { + ChannelDescriptionStatus, + ChannelDescriptionUiEvents +} from "tc-shared/ui/frames/side/ChannelDescriptionDefinitions"; +import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration"; +import {LogCategory, logError} from "tc-shared/log"; +import {ErrorCode} from "tc-shared/connection/ErrorCode"; + +export class ChannelDescriptionController { + readonly uiEvents: Registry; + private currentChannel: ChannelEntry; + private listenerChannel: (() => void)[]; + + private descriptionSendPending = false; + private cachedDescriptionStatus: ChannelDescriptionStatus; + private cachedDescriptionAge: number; + + constructor() { + this.uiEvents = new Registry(); + this.listenerChannel = []; + + this.uiEvents.on("query_description", () => this.notifyDescription()); + this.uiEvents.enableDebug("channel-description"); + + this.cachedDescriptionAge = 0; + } + + destroy() { + this.listenerChannel.forEach(callback => callback()); + this.listenerChannel = []; + + this.currentChannel = undefined; + + this.uiEvents.destroy(); + } + + setChannel(channel: ChannelEntry) { + if(this.currentChannel === channel) { + return; + } + + this.listenerChannel.forEach(callback => callback()); + this.listenerChannel = []; + + this.currentChannel = channel; + this.cachedDescriptionStatus = undefined; + + if(channel) { + this.listenerChannel.push(channel.events.on("notify_properties_updated", event => { + if("channel_description" in event.updated_properties) { + this.notifyDescription().then(undefined); + } + })); + + this.listenerChannel.push(channel.events.on("notify_description_changed", () => { + this.notifyDescription().then(undefined); + })); + } + + this.notifyDescription().then(undefined); + } + + private async notifyDescription() { + if(this.descriptionSendPending) { + return; + } + + this.descriptionSendPending = true; + try { + if(Date.now() - this.cachedDescriptionAge > 5000 || !this.cachedDescriptionStatus) { + await this.updateCachedDescriptionStatus(); + } + + this.uiEvents.fire_react("notify_description", { status: this.cachedDescriptionStatus }); + } finally { + this.descriptionSendPending = false; + } + } + + private async updateCachedDescriptionStatus() { + try { + let description; + if(this.currentChannel) { + description = await new Promise((resolve, reject) => { + this.currentChannel.getChannelDescription().then(resolve).catch(reject); + setTimeout(() => reject(tr("timeout")), 5000); + }); + } + + this.cachedDescriptionStatus = { + status: "success", + description: description, + handlerId: this.currentChannel.channelTree.client.handlerId + }; + } catch (error) { + if(error instanceof CommandResult) { + if(error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) { + const permission = this.currentChannel?.channelTree.client.permissions.resolveInfo(parseInt(error.json["failed_permid"])); + this.cachedDescriptionStatus = { + status: "no-permissions", + failedPermission: permission ? permission.name : "unknown" + }; + return; + } + + error = error.formattedMessage(); + } else if(typeof error !== "string") { + logError(LogCategory.GENERAL, tr("Failed to get channel descriptions: %o"), error); + error = tr("lookup the console"); + } + + this.cachedDescriptionStatus = { + status: "error", + reason: error + }; + } + this.cachedDescriptionAge = Date.now(); + } +} \ No newline at end of file diff --git a/shared/js/ui/frames/side/ChannelDescriptionDefinitions.ts b/shared/js/ui/frames/side/ChannelDescriptionDefinitions.ts new file mode 100644 index 00000000..5b55c8fd --- /dev/null +++ b/shared/js/ui/frames/side/ChannelDescriptionDefinitions.ts @@ -0,0 +1,18 @@ +export type ChannelDescriptionStatus = { + status: "success", + description: string, + handlerId: string +} | { + status: "error", + reason: string +} | { + status: "no-permissions", + failedPermission: string +}; + +export interface ChannelDescriptionUiEvents { + query_description: {}, + notify_description: { + status: ChannelDescriptionStatus, + } +} \ No newline at end of file diff --git a/shared/js/ui/frames/side/ChannelDescriptionRenderer.scss b/shared/js/ui/frames/side/ChannelDescriptionRenderer.scss new file mode 100644 index 00000000..c582c4e0 --- /dev/null +++ b/shared/js/ui/frames/side/ChannelDescriptionRenderer.scss @@ -0,0 +1,51 @@ +@import "../../../../css/static/mixin"; +@import "../../../../css/static/properties"; + +.container { + display: flex; + flex-direction: column; + justify-content: flex-start; + + height: 100%; + width: 100%; + + color: #999; + + &.centeredText { + justify-content: center; + color: var(--chat-overlay); + } + + &.error { + color: #ac5353; + } + + code { + padding: 0; + } +} + +.centeredText .text { + align-self: center; + text-align: center; +} + +.descriptionContainer { + padding: .5em; + + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + + color: #999; + + overflow: auto; + @include chat-scrollbar(); + + font-size: 12px; + + img { + max-width: 100%; + } +} \ No newline at end of file diff --git a/shared/js/ui/frames/side/ChannelDescriptionRenderer.tsx b/shared/js/ui/frames/side/ChannelDescriptionRenderer.tsx new file mode 100644 index 00000000..5afcb177 --- /dev/null +++ b/shared/js/ui/frames/side/ChannelDescriptionRenderer.tsx @@ -0,0 +1,86 @@ +import { + ChannelDescriptionStatus, + ChannelDescriptionUiEvents +} from "tc-shared/ui/frames/side/ChannelDescriptionDefinitions"; +import {Registry} from "tc-shared/events"; +import * as React from "react"; +import {useState} from "react"; +import {Translatable, VariadicTranslatable} from "tc-shared/ui/react-elements/i18n"; +import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; +import {allowedBBCodes, BBCodeRenderer} from "tc-shared/text/bbcode"; +import {parse as parseBBCode} from "vendor/xbbcode/parser"; + +const cssStyle = require("./ChannelDescriptionRenderer.scss"); + +const CenteredTextRenderer = (props: { children?: React.ReactElement | (React.ReactElement | string)[], className?: string }) => { + return ( +
+
+ {props.children} +
+
+ ); +} + +const DescriptionRenderer = React.memo((props: { description: string, handlerId: string }) => { + if(!props.description) { + return ( + + Channel has no description + + ) + } + + return ( +
+ +
+ ) +}); + +const DescriptionErrorRenderer = React.memo((props: { error: string }) => ( + + An error happened while fetching the channel description:
+ {props.error} +
+)); + +const PermissionErrorRenderer = React.memo((props: { failedPermission: string }) => ( + + You don't have the permission to watch the channel description.  + {props.failedPermission} + +)); + +export const ChannelDescriptionRenderer = React.memo((props: { events: Registry }) => { + const [ description, setDescription ] = useState(() => { + props.events.fire("query_description"); + return { status: "loading" }; + }); + props.events.reactUse("notify_description", event => setDescription(event.status)); + + switch (description.status) { + case "success": + return ( + + ); + + case "error": + return ( + + ); + + case "no-permissions": + return ( + + ); + + case "loading": + default: + return ( + + loading channel description + + ); + } +}); \ No newline at end of file diff --git a/shared/js/ui/frames/side/HeaderController.ts b/shared/js/ui/frames/side/HeaderController.ts index 085137e4..6f8f0359 100644 --- a/shared/js/ui/frames/side/HeaderController.ts +++ b/shared/js/ui/frames/side/HeaderController.ts @@ -51,7 +51,7 @@ export class SideHeaderController { }); this.uiEvents.on("action_switch_channel_chat", () => { - this.connection.getSideBar().showChannelConversations(); + this.connection.getSideBar().showChannel(); }); this.uiEvents.on("action_bot_manage", () => { diff --git a/shared/js/ui/modal/settings/Notifications.tsx b/shared/js/ui/modal/settings/Notifications.tsx index 90b46370..c219c38f 100644 --- a/shared/js/ui/modal/settings/Notifications.tsx +++ b/shared/js/ui/modal/settings/Notifications.tsx @@ -3,15 +3,15 @@ import {useRef, useState} from "react"; import {Registry} from "tc-shared/events"; import {Translatable} from "tc-shared/ui/react-elements/i18n"; import {FlatInputField} from "tc-shared/ui/react-elements/InputField"; -import {EventType} from "tc-shared/ui/frames/log/Definitions"; -import { - getRegisteredNotificationDispatchers, - isNotificationEnabled -} from "tc-shared/ui/frames/log/DispatcherNotifications"; import {Settings, settings} from "tc-shared/settings"; import {Checkbox} from "tc-shared/ui/react-elements/Checkbox"; import {Tooltip} from "tc-shared/ui/react-elements/Tooltip"; -import {isFocusRequestEnabled} from "tc-shared/ui/frames/log/DispatcherFocus"; +import {TypeInfo} from "tc-shared/connectionlog/Definitions"; +import { + getRegisteredNotificationDispatchers, + isNotificationEnabled +} from "tc-shared/connectionlog/DispatcherNotifications"; +import {isFocusRequestEnabled} from "tc-shared/connectionlog/DispatcherFocus"; const cssStyle = require("./Notifications.scss"); @@ -21,7 +21,7 @@ interface EventGroup { key: string; name: string; - events?: string[]; + events?: (keyof TypeInfo)[]; subgroups?: EventGroup[]; } @@ -340,23 +340,23 @@ const knownEventGroups: EventGroup[] = [ key: "client-messages", name: "Messages", events: [ - EventType.CLIENT_POKE_RECEIVED, - EventType.CLIENT_POKE_SEND, - EventType.PRIVATE_MESSAGE_SEND, - EventType.PRIVATE_MESSAGE_RECEIVED + "client.poke.received", + "client.poke.send", + "private.message.send", + "private.message.received" ] }, { key: "client-view", name: "View", events: [ - EventType.CLIENT_VIEW_ENTER, - EventType.CLIENT_VIEW_ENTER_OWN_CHANNEL, - EventType.CLIENT_VIEW_MOVE, - EventType.CLIENT_VIEW_MOVE_OWN, - EventType.CLIENT_VIEW_MOVE_OWN_CHANNEL, - EventType.CLIENT_VIEW_LEAVE, - EventType.CLIENT_VIEW_LEAVE_OWN_CHANNEL + "client.view.enter", + "client.view.enter.own.channel", + "client.view.move", + "client.view.move.own", + "client.view.move.own.channel", + "client.view.leave", + "client.view.leave.own.channel" ] } ] @@ -365,45 +365,45 @@ const knownEventGroups: EventGroup[] = [ key: "server", name: "Server", events: [ - EventType.GLOBAL_MESSAGE, - EventType.SERVER_CLOSED, - EventType.SERVER_BANNED, + "global.message", + "server.closed", + "server.banned", ] }, { key: "connection", name: "Connection", events: [ - EventType.CONNECTION_BEGIN, - EventType.CONNECTION_CONNECTED, - EventType.CONNECTION_FAILED + "connection.begin", + "connection.connected", + "connection.failed" ] } ]; -const groupNames: { [key: string]: string } = {}; -groupNames[EventType.CLIENT_POKE_RECEIVED] = tr("You received a poke"); -groupNames[EventType.CLIENT_POKE_SEND] = tr("You send a poke"); -groupNames[EventType.PRIVATE_MESSAGE_SEND] = tr("You received a private message"); -groupNames[EventType.PRIVATE_MESSAGE_RECEIVED] = tr("You send a private message"); +const groupNames: { [T in keyof TypeInfo]?: string } = {}; +groupNames["client.poke.received"] = tr("You received a poke"); +groupNames["client.poke.send"] = tr("You send a poke"); +groupNames["private.message.send"] = tr("You received a private message"); +groupNames["private.message.received"] = tr("You send a private message"); -groupNames[EventType.CLIENT_VIEW_ENTER] = tr("A client enters your view"); -groupNames[EventType.CLIENT_VIEW_ENTER_OWN_CHANNEL] = tr("A client enters your view and your channel"); +groupNames["client.view.enter"] = tr("A client enters your view"); +groupNames["client.view.enter.own.channel"] = tr("A client enters your view and your channel"); -groupNames[EventType.CLIENT_VIEW_MOVE] = tr("A client switches/gets moved/kicked"); -groupNames[EventType.CLIENT_VIEW_MOVE_OWN_CHANNEL] = tr("A client switches/gets moved/kicked in to/out of your channel"); -groupNames[EventType.CLIENT_VIEW_MOVE_OWN] = tr("You've been moved or kicked"); +groupNames["client.view.move"] = tr("A client switches/gets moved/kicked"); +groupNames["client.view.move.own.channel"] = tr("A client switches/gets moved/kicked in to/out of your channel"); +groupNames["client.view.move.own"] = tr("You've been moved or kicked"); -groupNames[EventType.CLIENT_VIEW_LEAVE] = tr("A client leaves/disconnects of your view"); -groupNames[EventType.CLIENT_VIEW_LEAVE_OWN_CHANNEL] = tr("A client leaves/disconnects of your channel"); +groupNames["client.view.leave"] = tr("A client leaves/disconnects of your view"); +groupNames["client.view.leave.own.channel"] = tr("A client leaves/disconnects of your channel"); -groupNames[EventType.GLOBAL_MESSAGE] = tr("A server message has been send"); -groupNames[EventType.SERVER_CLOSED] = tr("The server has been closed"); -groupNames[EventType.SERVER_BANNED] = tr("You've been banned from the server"); +groupNames["global.message"] = tr("A server message has been send"); +groupNames["server.closed"] = tr("The server has been closed"); +groupNames["server.banned"] = tr("You've been banned from the server"); -groupNames[EventType.CONNECTION_BEGIN] = tr("You're connecting to a server"); -groupNames[EventType.CONNECTION_CONNECTED] = tr("You've successfully connected to the server"); -groupNames[EventType.CONNECTION_FAILED] = tr("You're connect attempt failed"); +groupNames["connection.begin"] = tr("You're connecting to a server"); +groupNames["connection.connected"] = tr("You've successfully connected to the server"); +groupNames["connection.failed"] = tr("You're connect attempt failed"); function initializeController(events: Registry) { let filter = undefined; diff --git a/shared/js/ui/tree/Controller.tsx b/shared/js/ui/tree/Controller.tsx index 7516d71b..ac3dc990 100644 --- a/shared/js/ui/tree/Controller.tsx +++ b/shared/js/ui/tree/Controller.tsx @@ -258,6 +258,8 @@ class ChannelTreeController { this.sendChannelInfo(event.newChannel); this.sendChannelStatusIcon(event.newChannel); this.sendChannelTreeEntries(); + + this.sendClientTalkStatus(event.client); } @EventHandler("notify_selected_entry_changed") diff --git a/web/app/connection/ServerConnection.ts b/web/app/connection/ServerConnection.ts index 6e87d03f..be54869e 100644 --- a/web/app/connection/ServerConnection.ts +++ b/web/app/connection/ServerConnection.ts @@ -14,7 +14,6 @@ import * as log from "tc-shared/log"; import {LogCategory, logDebug, logError, logTrace} from "tc-shared/log"; import {Regex} from "tc-shared/ui/modal/ModalConnect"; import {AbstractCommandHandlerBoss} from "tc-shared/connection/AbstractCommandHandler"; -import {EventType} from "tc-shared/ui/frames/log/Definitions"; import {WrappedWebSocket} from "tc-backend/web/connection/WrappedWebSocket"; import {AbstractVoiceConnection} from "tc-shared/connection/VoiceConnection"; import {parseCommand} from "tc-backend/web/connection/CommandParser"; @@ -27,7 +26,6 @@ import {ServerFeature} from "tc-shared/connection/ServerFeatures"; import {RTCConnection} from "tc-shared/connection/rtc/Connection"; import {RtpVideoConnection} from "tc-shared/connection/rtc/video/Connection"; import { tr } from "tc-shared/i18n/localize"; -import {createErrorModal} from "tc-shared/ui/elements/Modal"; class ReturnListener { resolve: (value?: T | PromiseLike) => void; @@ -286,7 +284,7 @@ export class ServerConnection extends AbstractServerConnection { private startHandshake() { this.updateConnectionState(ConnectionState.INITIALISING); - this.client.log.log(EventType.CONNECTION_LOGIN, {}); + this.client.log.log("connection.login", {}); this.handshakeHandler.initialize(); this.handshakeHandler.startHandshake(); } diff --git a/web/app/legacy/voice/VoiceHandler.ts b/web/app/legacy/voice/VoiceHandler.ts index 16cd6cfc..ad4279fa 100644 --- a/web/app/legacy/voice/VoiceHandler.ts +++ b/web/app/legacy/voice/VoiceHandler.ts @@ -15,7 +15,6 @@ import {ConnectionStatistics, ServerConnectionEvents} from "tc-shared/connection import {ConnectionState} from "tc-shared/ConnectionHandler"; import {VoiceBridge, VoicePacket, VoiceWhisperPacket} from "./bridge/VoiceBridge"; import {NativeWebRTCVoiceBridge} from "./bridge/NativeWebRTCVoiceBridge"; -import {EventType} from "tc-shared/ui/frames/log/Definitions"; import { kUnknownWhisperClientUniqueId, WhisperSession, @@ -191,7 +190,7 @@ export class VoiceConnection extends AbstractVoiceConnection { }, payload))) }; this.voiceBridge.callbackDisconnect = () => { - this.connection.client.log.log(EventType.CONNECTION_VOICE_DROPPED, { }); + this.connection.client.log.log("connection.voice.dropped", { }); if(!this.connectionLostModalOpen) { this.connectionLostModalOpen = true; const modal = createErrorModal(tr("Voice connection lost"), tr("Lost voice connection to the target server. Trying to reconnect...")); @@ -202,14 +201,14 @@ export class VoiceConnection extends AbstractVoiceConnection { this.executeVoiceBridgeReconnect(); } - this.connection.client.log.log(EventType.CONNECTION_VOICE_CONNECT, { attemptCount: this.connectAttemptCounter }); + this.connection.client.log.log("connection.voice.connect", { attemptCount: this.connectAttemptCounter }); this.setConnectionState(VoiceConnectionStatus.Connecting); this.voiceBridge.connect().then(result => { if(result.type === "success") { this.lastConnectAttempt = 0; this.connectAttemptCounter = 0; - this.connection.client.log.log(EventType.CONNECTION_VOICE_CONNECT_SUCCEEDED, { }); + this.connection.client.log.log("connection.voice.connect.succeeded", { }); const currentInput = this.voiceRecorder()?.input; if(currentInput) { this.voiceBridge.setInput(currentInput).catch(error => { @@ -226,7 +225,7 @@ export class VoiceConnection extends AbstractVoiceConnection { let doReconnect = result.allowReconnect && this.connectAttemptCounter < 5; logWarn(LogCategory.VOICE, tr("Failed to setup voice bridge: %s. Reconnect: %o"), result.message, doReconnect); - this.connection.client.log.log(EventType.CONNECTION_VOICE_CONNECT_FAILED, { + this.connection.client.log.log("connection.voice.connect.failed", { reason: result.message, reconnect_delay: doReconnect ? 1 : 0 }); From 5da2877c8b1d00523f530780876ba824fb915baf Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Fri, 18 Dec 2020 19:18:01 +0100 Subject: [PATCH 29/37] Added file transfer to the side bar --- shared/js/tree/Channel.ts | 2 + shared/js/ui/frames/SideBarRenderer.tsx | 20 +- .../js/ui/frames/side/ChannelBarController.ts | 10 +- .../ui/frames/side/ChannelBarDefinitions.ts | 3 +- .../js/ui/frames/side/ChannelBarRenderer.tsx | 14 +- .../side/ChannelFileBrowserController.ts | 64 ++ .../side/ChannelFileBrowserDefinitions.ts | 11 + .../side/ChannelFileBrowserRenderer.scss | 45 ++ .../side/ChannelFileBrowserRenderer.tsx | 53 ++ ...ller.ts => FileBrowserControllerRemote.ts} | 39 +- .../modal/transfer/FileBrowserRenderer.scss | 377 ++++++++++ ...ileBrowser.tsx => FileBrowserRenderer.tsx} | 672 +++++++++++++----- .../js/ui/modal/transfer/FileDefinitions.ts | 164 +++++ ...{TransferInfo.tsx => FileTransferInfo.tsx} | 55 +- ...oller.ts => FileTransferInfoController.ts} | 12 +- .../transfer/FileTransferInfoDefinitions.ts | 50 ++ ...nfo.scss => FileTransferInfoRenderer.scss} | 0 .../ui/modal/transfer/ModalFileTransfer.scss | 384 ---------- .../ui/modal/transfer/ModalFileTransfer.tsx | 195 +---- .../js/ui/react-elements/ErrorBoundary.scss | 0 shared/js/ui/react-elements/ErrorBoundary.ts | 23 + shared/js/ui/react-elements/Helper.ts | 4 + shared/js/ui/react-elements/InputField.tsx | 2 +- shared/js/ui/react-elements/Table.tsx | 3 +- 24 files changed, 1388 insertions(+), 814 deletions(-) create mode 100644 shared/js/ui/frames/side/ChannelFileBrowserController.ts create mode 100644 shared/js/ui/frames/side/ChannelFileBrowserDefinitions.ts create mode 100644 shared/js/ui/frames/side/ChannelFileBrowserRenderer.scss create mode 100644 shared/js/ui/frames/side/ChannelFileBrowserRenderer.tsx rename shared/js/ui/modal/transfer/{RemoteFileBrowserController.ts => FileBrowserControllerRemote.ts} (97%) create mode 100644 shared/js/ui/modal/transfer/FileBrowserRenderer.scss rename shared/js/ui/modal/transfer/{FileBrowser.tsx => FileBrowserRenderer.tsx} (60%) create mode 100644 shared/js/ui/modal/transfer/FileDefinitions.ts rename shared/js/ui/modal/transfer/{TransferInfo.tsx => FileTransferInfo.tsx} (93%) rename shared/js/ui/modal/transfer/{TransferInfoController.ts => FileTransferInfoController.ts} (93%) create mode 100644 shared/js/ui/modal/transfer/FileTransferInfoDefinitions.ts rename shared/js/ui/modal/transfer/{TransferInfo.scss => FileTransferInfoRenderer.scss} (100%) create mode 100644 shared/js/ui/react-elements/ErrorBoundary.scss create mode 100644 shared/js/ui/react-elements/ErrorBoundary.ts diff --git a/shared/js/tree/Channel.ts b/shared/js/tree/Channel.ts index 83483c0f..7c83220c 100644 --- a/shared/js/tree/Channel.ts +++ b/shared/js/tree/Channel.ts @@ -202,6 +202,7 @@ export class ChannelEntry extends ChannelTreeEntry { this.channelTree = channelTree; this.events = new Registry(); + this.subscribed = false; this.properties = new ChannelProperties(); this.channelId = channelId; this.properties.channel_name = channelName; @@ -712,6 +713,7 @@ export class ChannelEntry extends ChannelTreeEntry { cached_password() { return this.cachedPasswordHash; } async updateSubscribeMode() { + console.error("Update subscribe mode"); let shouldBeSubscribed = false; switch (this.subscriptionMode) { case ChannelSubscribeMode.INHERITED: diff --git a/shared/js/ui/frames/SideBarRenderer.tsx b/shared/js/ui/frames/SideBarRenderer.tsx index 4a655295..5d0752f7 100644 --- a/shared/js/ui/frames/SideBarRenderer.tsx +++ b/shared/js/ui/frames/SideBarRenderer.tsx @@ -8,6 +8,7 @@ import {PrivateConversationsPanel} from "tc-shared/ui/frames/side/PrivateConvers import {ChannelBarRenderer} from "tc-shared/ui/frames/side/ChannelBarRenderer"; import {LogCategory, logWarn} from "tc-shared/log"; import React = require("react"); +import {ErrorBoundary} from "tc-shared/ui/react-elements/ErrorBoundary"; const cssStyle = require("./SideBarRenderer.scss"); @@ -52,6 +53,7 @@ const ContentRendererClientInfo = () => { const contentData = useContentData("client-info"); if(!contentData) { return null; } + throw "XX"; return ( { const SideBarFrame = (props: { type: SideBarType }) => { switch (props.type) { case "channel": - return ; + return ( + + + + ); case "private-chat": - return ; + return ( + + + + ); case "client-info": - return ; + return ( + + + + ); case "music-manage": /* TODO! */ diff --git a/shared/js/ui/frames/side/ChannelBarController.ts b/shared/js/ui/frames/side/ChannelBarController.ts index c08f39f0..1facdf2e 100644 --- a/shared/js/ui/frames/side/ChannelBarController.ts +++ b/shared/js/ui/frames/side/ChannelBarController.ts @@ -5,12 +5,14 @@ import {ChannelEntry, ChannelSidebarMode} from "tc-shared/tree/Channel"; import {ChannelConversationController} from "tc-shared/ui/frames/side/ChannelConversationController"; import {ChannelDescriptionController} from "tc-shared/ui/frames/side/ChannelDescriptionController"; import {LocalClientEntry} from "tc-shared/tree/Client"; +import {ChannelFileBrowserController} from "tc-shared/ui/frames/side/ChannelFileBrowserController"; export class ChannelBarController { readonly uiEvents: Registry; private channelConversations: ChannelConversationController; private description: ChannelDescriptionController; + private fileBrowser: ChannelFileBrowserController; private currentConnection: ConnectionHandler; private listenerConnection: (() => void)[]; @@ -25,6 +27,7 @@ export class ChannelBarController { this.channelConversations = new ChannelConversationController(); this.description = new ChannelDescriptionController(); + this.fileBrowser = new ChannelFileBrowserController(); this.uiEvents.on("query_mode", () => this.notifyChannelMode()); this.uiEvents.on("query_channel_id", () => this.notifyChannelId()); @@ -41,6 +44,9 @@ export class ChannelBarController { this.currentChannel = undefined; this.currentConnection = undefined; + this.fileBrowser?.destroy(); + this.fileBrowser = undefined; + this.channelConversations?.destroy(); this.channelConversations = undefined; @@ -56,6 +62,7 @@ export class ChannelBarController { } this.channelConversations.setConnectionHandler(handler); + this.fileBrowser.setConnectionHandler(handler); this.listenerConnection.forEach(callback => callback()); this.listenerConnection = []; @@ -96,6 +103,7 @@ export class ChannelBarController { return; } + this.fileBrowser.setChannel(channel); this.description.setChannel(channel); this.listenerChannel.forEach(callback => callback()); @@ -185,9 +193,9 @@ export class ChannelBarController { this.uiEvents.fire_react("notify_data", { content: "file-transfer", data: { + events: this.fileBrowser.uiEvents } }); - /* TODO! */ break; } } diff --git a/shared/js/ui/frames/side/ChannelBarDefinitions.ts b/shared/js/ui/frames/side/ChannelBarDefinitions.ts index a14ffc18..51d552ed 100644 --- a/shared/js/ui/frames/side/ChannelBarDefinitions.ts +++ b/shared/js/ui/frames/side/ChannelBarDefinitions.ts @@ -1,6 +1,7 @@ import {Registry} from "tc-shared/events"; import {ChannelConversationUiEvents} from "tc-shared/ui/frames/side/ChannelConversationDefinitions"; import {ChannelDescriptionUiEvents} from "tc-shared/ui/frames/side/ChannelDescriptionDefinitions"; +import {ChannelFileBrowserUiEvents} from "tc-shared/ui/frames/side/ChannelFileBrowserDefinitions"; export type ChannelBarMode = "conversation" | "description" | "file-transfer" | "none"; @@ -12,7 +13,7 @@ export interface ChannelBarModeData { events: Registry }, "file-transfer": { - /* TODO! */ + events: Registry }, "none": {} } diff --git a/shared/js/ui/frames/side/ChannelBarRenderer.tsx b/shared/js/ui/frames/side/ChannelBarRenderer.tsx index bfaf031f..cc8b1a97 100644 --- a/shared/js/ui/frames/side/ChannelBarRenderer.tsx +++ b/shared/js/ui/frames/side/ChannelBarRenderer.tsx @@ -5,6 +5,7 @@ import * as React from "react"; import {ConversationPanel} from "tc-shared/ui/frames/side/AbstractConversationRenderer"; import {useDependentState} from "tc-shared/ui/react-elements/Helper"; import {ChannelDescriptionRenderer} from "tc-shared/ui/frames/side/ChannelDescriptionRenderer"; +import {ChannelFileBrowser} from "tc-shared/ui/frames/side/ChannelFileBrowserRenderer"; const EventContext = React.createContext>(undefined); const ChannelContext = React.createContext<{ channelId: number, handlerId: string }>(undefined); @@ -37,8 +38,7 @@ const ModeRenderer = () => { return ; case "file-transfer": - /* TODO! */ - return null; + return ; case "none": default: @@ -72,6 +72,16 @@ const ModeRendererDescription = React.memo(() => { ); }); +const ModeRendererFileTransfer = React.memo(() => { + const channelContext = useContext(ChannelContext); + const data = useModeData("file-transfer", [ channelContext ]); + if(!data) { return null; } + + return ( + + ); +}); + export const ChannelBarRenderer = (props: { events: Registry }) => { const [ channelContext, setChannelContext ] = useState<{ channelId: number, handlerId: string }>(() => { props.events.fire("query_channel_id"); diff --git a/shared/js/ui/frames/side/ChannelFileBrowserController.ts b/shared/js/ui/frames/side/ChannelFileBrowserController.ts new file mode 100644 index 00000000..49de6cd2 --- /dev/null +++ b/shared/js/ui/frames/side/ChannelFileBrowserController.ts @@ -0,0 +1,64 @@ +import {ConnectionHandler} from "tc-shared/ConnectionHandler"; +import {Registry} from "tc-shared/events"; +import {channelPathPrefix, FileBrowserEvents} from "tc-shared/ui/modal/transfer/FileDefinitions"; +import {initializeRemoteFileBrowserController} from "tc-shared/ui/modal/transfer/FileBrowserControllerRemote"; +import {ChannelFileBrowserUiEvents} from "tc-shared/ui/frames/side/ChannelFileBrowserDefinitions"; +import {ChannelEntry} from "tc-shared/tree/Channel"; + +export class ChannelFileBrowserController { + readonly uiEvents: Registry; + + private currentConnection: ConnectionHandler; + private remoteBrowseEvents: Registry; + + private currentChannel: ChannelEntry; + + constructor() { + this.uiEvents = new Registry(); + this.uiEvents.on("query_events", () => this.notifyEvents()); + } + + destroy() { + this.currentChannel = undefined; + this.setConnectionHandler(undefined); + } + + setConnectionHandler(connection: ConnectionHandler) { + if(this.currentConnection === connection) { + return; + } + + if(this.remoteBrowseEvents) { + this.remoteBrowseEvents.fire("notify_destroy"); + this.remoteBrowseEvents.destroy(); + } + + this.currentConnection = connection; + + if(connection) { + this.remoteBrowseEvents = new Registry(); + initializeRemoteFileBrowserController(connection, this.remoteBrowseEvents); + } + + this.setChannel(undefined); + this.notifyEvents(); + } + + setChannel(channel: ChannelEntry | undefined) { + if(channel === this.currentChannel) { + return; + } + + this.currentChannel = channel; + + if(channel) { + this.remoteBrowseEvents?.fire("action_navigate_to", { path: "/" + channelPathPrefix + channel.channelId + "/" }); + } else { + this.remoteBrowseEvents?.fire("action_navigate_to", { path: "/" }); + } + } + + private notifyEvents() { + this.uiEvents.fire_react("notify_events", { browserEvents: this.remoteBrowseEvents, channelId: this.currentChannel?.channelId }); + } +} \ No newline at end of file diff --git a/shared/js/ui/frames/side/ChannelFileBrowserDefinitions.ts b/shared/js/ui/frames/side/ChannelFileBrowserDefinitions.ts new file mode 100644 index 00000000..aba7fb8d --- /dev/null +++ b/shared/js/ui/frames/side/ChannelFileBrowserDefinitions.ts @@ -0,0 +1,11 @@ +import {FileBrowserEvents} from "tc-shared/ui/modal/transfer/FileDefinitions"; +import {Registry} from "tc-shared/events"; + +export interface ChannelFileBrowserUiEvents { + query_events: {}, + + notify_events: { + browserEvents: Registry, + channelId: number + }, +} \ No newline at end of file diff --git a/shared/js/ui/frames/side/ChannelFileBrowserRenderer.scss b/shared/js/ui/frames/side/ChannelFileBrowserRenderer.scss new file mode 100644 index 00000000..063bb556 --- /dev/null +++ b/shared/js/ui/frames/side/ChannelFileBrowserRenderer.scss @@ -0,0 +1,45 @@ +.container { + display: flex; + flex-direction: column; + justify-content: stretch; + + height: 100%; + width: 100%; + + color: #999; + + .navbar { + flex-shrink: 0; + flex-grow: 0; + + padding: .5em; + } +} + +.fileTable { + border: none; + border-radius: 0; + + background-color: var(--chat-background); + + .header { + background-color: var(--chat-background); + } +} + +.fileEntry:hover, .fileEntry.hovered { + background-color: var(--channel-tree-entry-hovered) !important; +} + +.fileEntrySelected { + background-color: var(--channel-tree-entry-selected) !important; +} + +.boxedInput { + background-color: var(--side-info-background); + border-color: #212121; + + &:focus-within { + background-color: #242424; + } +} \ No newline at end of file diff --git a/shared/js/ui/frames/side/ChannelFileBrowserRenderer.tsx b/shared/js/ui/frames/side/ChannelFileBrowserRenderer.tsx new file mode 100644 index 00000000..1e69b2a3 --- /dev/null +++ b/shared/js/ui/frames/side/ChannelFileBrowserRenderer.tsx @@ -0,0 +1,53 @@ +import {useState} from "react"; +import {Registry} from "tc-shared/events"; +import {ChannelFileBrowserUiEvents} from "tc-shared/ui/frames/side/ChannelFileBrowserDefinitions"; +import {channelPathPrefix, FileBrowserEvents} from "tc-shared/ui/modal/transfer/FileDefinitions"; +import { + FileBrowserClassContext, + FileBrowserRenderer, FileBrowserRendererClasses, + NavigationBar +} from "tc-shared/ui/modal/transfer/FileBrowserRenderer"; +import * as React from "react"; + +const cssStyle = require("./ChannelFileBrowserRenderer.scss"); + +const kFileBrowserClasses: FileBrowserRendererClasses = { + navigation: { + boxedInput: cssStyle.boxedInput + }, + fileTable: { + table: cssStyle.fileTable, + header: cssStyle.header + }, + fileEntry: { + entry: cssStyle.fileEntry, + dropHovered: cssStyle.hovered, + selected: cssStyle.fileEntrySelected + } +}; + +export const ChannelFileBrowser = (props: { events: Registry }) => { + const [ events, setEvents ] = useState<{ events: Registry, channelId: number }>(() => { + props.events.fire("query_events"); + return undefined; + }); + props.events.reactUse("notify_events", event => setEvents({ + events: event.browserEvents, + channelId: event.channelId + })); + + if(!events) { + return null; + } + + return ( +
+ +
+ 0 ? "/" + channelPathPrefix + events.channelId + "/" : "/"} /> +
+ 0 ? "/" + channelPathPrefix + events.channelId + "/" : "/"} events={events.events} key={"browser"} /> +
+
+ ); +}; \ No newline at end of file diff --git a/shared/js/ui/modal/transfer/RemoteFileBrowserController.ts b/shared/js/ui/modal/transfer/FileBrowserControllerRemote.ts similarity index 97% rename from shared/js/ui/modal/transfer/RemoteFileBrowserController.ts rename to shared/js/ui/modal/transfer/FileBrowserControllerRemote.ts index f1a589b0..e7cfb694 100644 --- a/shared/js/ui/modal/transfer/RemoteFileBrowserController.ts +++ b/shared/js/ui/modal/transfer/FileBrowserControllerRemote.ts @@ -18,15 +18,13 @@ import { TransferTargetType } from "../../../file/Transfer"; import {createErrorModal} from "../../../ui/elements/Modal"; +import {ErrorCode} from "../../../connection/ErrorCode"; import { avatarsPathPrefix, - channelPathPrefix, - FileBrowserEvents, - iconPathPrefix, - ListedFileInfo, + channelPathPrefix, FileBrowserEvents, + iconPathPrefix, ListedFileInfo, PathInfo -} from "../../../ui/modal/transfer/ModalFileTransfer"; -import {ErrorCode} from "../../../connection/ErrorCode"; +} from "tc-shared/ui/modal/transfer/FileDefinitions"; function parsePath(path: string, connection: ConnectionHandler): PathInfo { if (path === "/" || !path) { @@ -46,7 +44,7 @@ function parsePath(path: string, connection: ConnectionHandler): PathInfo { const channel = connection.channelTree.findChannel(channelId); if (!channel) { - throw tr("Channel not visible anymore"); + throw tr("Invalid channel id"); } return { @@ -79,13 +77,13 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand try { const info = parsePath(event.path, connection); - events.fire_react("action_navigate_to_result", { + events.fire_react("notify_current_path", { path: event.path || "/", status: "success", pathInfo: info }); } catch (error) { - events.fire_react("action_navigate_to_result", { + events.fire_react("notify_current_path", { path: event.path, status: "error", error: error @@ -284,8 +282,9 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand let sourcePath: PathInfo, targetPath: PathInfo; try { sourcePath = parsePath(event.oldPath, connection); - if (sourcePath.type !== "channel") + if (sourcePath.type !== "channel") { throw tr("Icon/avatars could not be renamed"); + } } catch (error) { events.fire_react("action_rename_file_result", { oldPath: event.oldPath, @@ -297,8 +296,9 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand } try { targetPath = parsePath(event.newPath, connection); - if (sourcePath.type !== "channel") + if (sourcePath.type !== "channel") { throw tr("Target path isn't a channel"); + } } catch (error) { events.fire_react("action_rename_file_result", { oldPath: event.oldPath, @@ -374,15 +374,22 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand let currentPath = "/"; let currentPathInfo: PathInfo; let selection: { name: string, type: FileType }[] = []; - events.on("action_navigate_to_result", result => { - if (result.status !== "success") + events.on("notify_current_path", result => { + if (result.status !== "success") { return; + } currentPathInfo = result.pathInfo; currentPath = result.path; selection = []; }); + events.on("query_current_path", () => events.fire_react("notify_current_path", { + status: "success", + path: currentPath, + pathInfo: currentPathInfo + })); + events.on("action_rename_file_result", result => { if (result.status !== "success") return; @@ -812,10 +819,10 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand }); const closeListener = () => unregisterEvents(); - events.on("notify_modal_closed", closeListener); + events.on("notify_destroy", closeListener); const unregisterEvents = () => { - events.off("notify_modal_closed", closeListener); + events.off("notify_destroy", closeListener); transfer.events.off("notify_progress", progressListener); }; }; @@ -823,7 +830,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand const registeredListener = event => listenToTransfer(event.transfer); connection.fileManager.events.on("notify_transfer_registered", registeredListener); - events.on("notify_modal_closed", () => connection.fileManager.events.off("notify_transfer_registered", registeredListener)); + events.on("notify_destroy", () => connection.fileManager.events.off("notify_transfer_registered", registeredListener)); connection.fileManager.registeredTransfers().forEach(transfer => listenToTransfer(transfer)); } diff --git a/shared/js/ui/modal/transfer/FileBrowserRenderer.scss b/shared/js/ui/modal/transfer/FileBrowserRenderer.scss new file mode 100644 index 00000000..b9920413 --- /dev/null +++ b/shared/js/ui/modal/transfer/FileBrowserRenderer.scss @@ -0,0 +1,377 @@ +@import "../../../../css/static/mixin"; +@import "../../../../css/static/properties"; + +html:root { + --modal-transfer-refresh-hover: #ffffff0e; + --modal-transfer-path-hover: #E6E6E6; + + --modal-transfer-error-overlay-text: #9e9494; + + --modal-transfer-filelist: #28292b; + --modal-transfer-filelist-border: #161616; + + --modal-transfer-entry-hover: #2c2d2f; + --modal-transfer-entry-selected: #1a1a1b; + + --modal-transfer-indicator-red: #a10000; + --modal-transfer-indicator-red-end: #e60000; + + --modal-transfer-indicator-blue: #005fa1; + --modal-transfer-indicator-blue-end: #007acc; + + --modal-transfer-indicator-green: #389738; + --modal-transfer-indicator-green-end: #4ecc4e; + + --modal-transfer-indicator-hidden: #28292b00; + --modal-transfer-indicator-hidden-end: #28292b00; +} + +.container { + position: relative; + padding: 1em 1em 4em; /* 4em for the transfer info */ + + display: flex; + flex-direction: column; + justify-content: stretch; + + flex-shrink: 1; + flex-grow: 1; + + width: 90em; + min-width: 10em; + max-width: 100%; + + height: 55em; + max-height: 100%; + min-height: 10em; +} + +.arrow { + width: 1em; + + flex-shrink: 0; + flex-grow: 0; + + display: flex; + flex-direction: column; + justify-content: center; + + .inner { + flex-grow: 0; + flex-shrink: 0; + + align-self: center; + margin-left: -.09em; + + transform: rotate(-45deg); + -webkit-transform: rotate(-45deg); + + display: inline-block; + border: solid var(--text); + + border-width: 0 0.125em 0.125em 0; + padding: 0.15em; + + height: 0.15em; + width: .15em; + } +} + +.navigation { + flex-grow: 0; + flex-shrink: 0; + + .containerIcon { + margin: auto .25em; + padding: .2em; + + display: flex; + flex-direction: column; + justify-content: center; + + cursor: pointer; + + > div { + padding: .1em; + } + } + + .refreshIcon { + border-radius: 1px; + + @include transition(background-color $button_hover_animation_time ease-in-out); + + > div { + padding: .1em; + } + + &.enabled { + cursor: pointer; + + &:hover { + background-color: var(--modal-transfer-refresh-hover); + } + } + } + + .directoryIcon { + margin-right: -.25em; + } + + input { + margin-left: .5em; + } + + .containerPath { + @include user-select(none); + + display: flex; + flex-direction: row; + justify-content: flex-start; + + overflow: hidden; + white-space: nowrap; + + width: calc(100% - 1em); /* some space for the text editing */ + + a.pathShrink { + flex-shrink: 1; + min-width: 5em; + + @include text-dotdotdot(); + } + + a { + cursor: pointer; + + @include transition(color $button_hover_animation_time ease-in-out); + + &:hover, &.hovered { + color: var(--modal-transfer-path-hover); + } + } + } +} + +.fileTable { + min-height: 5em; + + flex-grow: 1; + flex-shrink: 1; + + border: 1px var(--modal-transfer-filelist-border) solid; + border-radius: 0.2em; + background-color: var(--modal-transfer-filelist); + + .header { + z-index: 1; + padding-top: .2em; + padding-bottom: .2em; + + background-color: var(--modal-transfer-filelist); + + .columnName, .columnSize, .columnType, .columnChanged { + position: relative; + + display: flex; + flex-direction: row; + justify-content: center; + + .separator { + position: absolute; + right: .2em; + top: .2em; + bottom: .2em; + + width: .1em; + background-color: var(--text); + } + } + + .columnSize { + width: 8em; + text-align: end; + } + + .columnName { + padding-left: .5em; + } + + > div:last-of-type { + .separator { + display: none; + } + } + } + + .body { + @include user-select(none); + @include chat-scrollbar-vertical(); + + .columnName { + padding-left: .5em; + + display: flex; + flex-direction: row; + justify-content: flex-start; + + a, div, img { + align-self: center; + margin-right: .5em; + + @include text-dotdotdot(); + } + + img, div { + flex-shrink: 0; + + height: 1em; + width: 1em; + } + + input { + height: 1.3em; + align-self: center; + + flex-grow: 1; + margin-right: .5em; + + border-style: inherit; + padding: .1em; + } + } + + .overlay { + position: absolute; + + top: 0; + left: 0; + right: 0; + bottom: 0; + + display: flex; + flex-direction: column; + justify-content: center; + + a { + text-align: center; + } + } + + .overlayError { + a { + font-size: 1.2em; + color: var(--modal-transfer-error-overlay-text); + } + } + + .overlayEmptyFolder { + align-self: center; + margin-top: 1em; + } + + .directoryEntry { + cursor: pointer; + + &:hover { + background-color: var(--modal-transfer-entry-hover); + } + + &.selected { + background-color: var(--modal-transfer-entry-selected); + } + + /* drag hovered overrides selected */ + &.hovered { + background-color: var(--modal-transfer-entry-hover); + } + + $indicator_transform_time: .5s; + + .indicator { + position: absolute; + + left: 0; + right: 30%; + top: 0; + bottom: 0; + + opacity: .4; + margin-right: 10px; /* for the gradient at the end */ + + .status { + position: absolute; + + left: 0; + top: 0; + bottom: 0; + + width: 2px; + @include transition(all $indicator_transform_time ease-in-out); + } + + &:after { + content: ' '; + + position: absolute; + + top: 0; + right: 0; + bottom: 0; + + height: 100%; + width: 10px; + + background-image: linear-gradient(to right, var(--modal-transfer-filelist), var(--modal-transfer-filelist)); + @include transition(all $indicator_transform_time ease-in-out); + } + + @include transition(all $indicator_transform_time ease-in-out); + + @mixin define-indicator($color, $colorLight) { + background-color: $color; + + .status { + background-color: $colorLight; + + -webkit-box-shadow: 0 0 12px 3px $colorLight; + -moz-box-shadow: 0 0 12px 3px $colorLight; + box-shadow: 0 0 12px 3px $colorLight; + } + + &:after { + background-image: linear-gradient(to right, $color, var(--modal-transfer-filelist)); + } + } + + &.red { + @include define-indicator(var(--modal-transfer-indicator-red), var(--modal-transfer-indicator-red-end)); + } + + &.blue { + @include define-indicator(var(--modal-transfer-indicator-blue), var(--modal-transfer-indicator-blue-end)); + } + + &.green { + @include define-indicator(var(--modal-transfer-indicator-green), var(--modal-transfer-indicator-green-end)); + } + + &.hidden { + @include define-indicator(var(--modal-transfer-indicator-hidden), var(--modal-transfer-indicator-hidden-end)); + } + } + } + } + + .columnSize { + text-align: end; + + a { + margin-right: 1em; + } + } + + .columnType { + text-align: center; + } +} \ No newline at end of file diff --git a/shared/js/ui/modal/transfer/FileBrowser.tsx b/shared/js/ui/modal/transfer/FileBrowserRenderer.tsx similarity index 60% rename from shared/js/ui/modal/transfer/FileBrowser.tsx rename to shared/js/ui/modal/transfer/FileBrowserRenderer.tsx index 2e79a46f..e45c442e 100644 --- a/shared/js/ui/modal/transfer/FileBrowser.tsx +++ b/shared/js/ui/modal/transfer/FileBrowserRenderer.tsx @@ -1,5 +1,5 @@ import {EventHandler, ReactEventHandler, Registry} from "tc-shared/events"; -import {useEffect, useRef, useState} from "react"; +import {useContext, useEffect, useRef, useState} from "react"; import {FileType} from "tc-shared/file/FileManager"; import * as ppt from "tc-backend/ppt"; import {SpecialKey} from "tc-shared/PPTListener"; @@ -12,21 +12,42 @@ import {Translatable} from "tc-shared/ui/react-elements/i18n"; import * as Moment from "moment"; import {MenuEntryType, spawn_context_menu} from "tc-shared/ui/elements/ContextMenu"; import {BoxedInputField} from "tc-shared/ui/react-elements/InputField"; +import * as log from "tc-shared/log"; +import {LogCategory} from "tc-shared/log"; +import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; +import React = require("react"); import { FileBrowserEvents, FileTransferUrlMediaType, ListedFileInfo, TransferStatus -} from "tc-shared/ui/modal/transfer/ModalFileTransfer"; -import * as log from "tc-shared/log"; -import {LogCategory} from "tc-shared/log"; -import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; -import React = require("react"); +} from "tc-shared/ui/modal/transfer/FileDefinitions"; +import {joinClassList} from "tc-shared/ui/react-elements/Helper"; -const cssStyle = require("./ModalFileTransfer.scss"); +export interface FileBrowserRendererClasses { + navigation?: { + boxedInput?: string + }, + fileTable?: { + table?: string, + header?: string, + body?: string + }, + fileEntry?: { + entry?: string, + selected?: string, + dropHovered?: string + } +} + +const EventsContext = React.createContext>(undefined); +const CustomClassContext = React.createContext(undefined); +export const FileBrowserClassContext = CustomClassContext; + +const cssStyle = require("./FileBrowserRenderer.scss"); interface NavigationBarProperties { - currentPath: string; + initialPath: string; events: Registry; } @@ -36,9 +57,11 @@ interface NavigationBarState { state: "editing" | "navigating" | "normal"; } -const ArrowRight = () =>
-
-
; +const ArrowRight = () => ( +
+
+
+); const NavigationEntry = (props: { events: Registry, path: string, name: string }) => { const [dragHovered, setDragHovered] = useState(false); @@ -56,11 +79,15 @@ const NavigationEntry = (props: { events: Registry, path: str
9 ? cssStyle.pathShrink : "") + " " + (dragHovered ? cssStyle.hovered : "")} title={props.name} - onClick={() => props.events.fire("action_navigate_to", {path: props.path})} + onClick={event => { + event.preventDefault(); + props.events.fire("action_navigate_to", {path: props.path}); + }} onDragOver={event => { const types = event.dataTransfer.types; - if (types.length !== 1) + if (types.length !== 1) { return; + } if (types[0] === FileTransferUrlMediaType) { /* TODO: Detect if its remote move or internal move */ @@ -77,8 +104,9 @@ const NavigationEntry = (props: { events: Registry, path: str onDragLeave={() => setDragHovered(false)} onDrop={event => { const types = event.dataTransfer.types; - if (types.length !== 1) + if (types.length !== 1) { return; + } /* TODO: Fix this code duplicate! */ if (types[0] === FileTransferUrlMediaType) { @@ -121,7 +149,7 @@ export class NavigationBar extends ReactComponentBase -
-
-
- } + + {customClasses => ( + +
+
+
+ } - rightIcon={() => -
-
-
- } + rightIcon={() => +
+
+
+ } - onChange={path => this.onPathEntered(path)} - onBlur={() => this.onInputPathBluer()} - /> + onChange={path => this.onPathEntered(path)} + onBlur={() => this.onInputPathBluer()} + className={customClasses?.navigation?.boxedInput} + /> + )} + ); } else if (this.state.state === "navigating" || this.state.state === "normal") { input = ( - -
this.onPathClicked(event, -1)}> -
-
- } + + {customClasses => ( + +
this.onPathClicked(event, -1)}> +
+
+ } - rightIcon={() => -
this.onButtonRefreshClicked()}> -
-
- } + rightIcon={() => +
this.onButtonRefreshClicked()}> +
+
+ } - inputBox={() => -
- {this.state.currentPath.split("/").filter(e => !!e).map((e, index, arr) => [ - , - - ])} -
- } + inputBox={() => +
+ {this.state.currentPath.split("/").filter(e => !!e).map((e, index, arr) => [ + , + + ])} +
+ } - editable={this.state.state === "normal"} - onFocus={() => this.onRenderedPathClicked()} - /> + editable={this.state.state === "normal"} + onFocus={event => !event.defaultPrevented && this.onRenderedPathClicked()} + className={customClasses?.navigation?.boxedInput} + /> + )} + ); } - return
{input}
; + return ( +
+ {input} +
+ ); } componentDidUpdate(prevProps: Readonly, prevState: Readonly, snapshot?: any): void { @@ -216,8 +258,9 @@ export class NavigationBar extends ReactComponentBase this.ignoreBlur = false); } - @EventHandler("action_navigate_to_result") - private handleNavigateResult(event: FileBrowserEvents["action_navigate_to_result"]) { - if (event.status === "success") + @EventHandler("notify_current_path") + private handleCurrentPath(event: FileBrowserEvents["notify_current_path"]) { + if (event.status === "success") { this.lastSucceededPath = event.path; + } this.setState({ state: "normal", @@ -279,7 +324,7 @@ export class NavigationBar extends ReactComponentBase; } @@ -288,7 +333,8 @@ interface FileListTableState { errorMessage?: string; } -const FileName = (props: { path: string, events: Registry, file: ListedFileInfo }) => { +const FileName = (props: { path: string, file: ListedFileInfo }) => { + const events = useContext(EventsContext); const [editing, setEditing] = useState(props.file.mode === "create"); const [fileName, setFileName] = useState(props.file.name); const refInput = useRef(); @@ -333,7 +379,7 @@ const FileName = (props: { path: string, events: Registry, fi if (props.file.mode === "create") { name = name || props.file.name; - props.events.fire("action_create_directory", { + events.fire("action_create_directory", { path: props.path, name: name }); @@ -342,7 +388,7 @@ const FileName = (props: { path: string, events: Registry, fi props.file.mode = "creating"; } else { if (name.length > 0 && name !== props.file.name) { - props.events.fire("action_rename_file", { + events.fire("action_rename_file", { oldName: props.file.name, newName: name, oldPath: props.path, @@ -381,19 +427,19 @@ const FileName = (props: { path: string, events: Registry, fi return; event.stopPropagation(); - props.events.fire("action_select_files", { + events.fire("action_select_files", { mode: "exclusive", files: [{name: props.file.name, type: props.file.type}] }); - props.events.fire("action_start_rename", { + events.fire("action_start_rename", { path: props.path, name: props.file.name }); }}>{fileName}; } - props.events.reactUse("action_start_rename", event => setEditing(event.name === props.file.name && event.path === props.path)); - props.events.reactUse("action_rename_file_result", event => { + events.reactUse("action_start_rename", event => setEditing(event.name === props.file.name && event.path === props.path)); + events.reactUse("action_rename_file_result", event => { if (event.oldPath !== props.path || event.oldName !== props.file.name) return; @@ -418,10 +464,11 @@ const FileName = (props: { path: string, events: Registry, fi return <>{icon} {name}; }; -const FileSize = (props: { path: string, events: Registry, file: ListedFileInfo }) => { +const FileSize = (props: { path: string, file: ListedFileInfo }) => { + const events = useContext(EventsContext); const [size, setSize] = useState(-1); - props.events.reactUse("notify_transfer_status", event => { + events.reactUse("notify_transfer_status", event => { if (event.id !== props.file.transfer?.id) return; @@ -440,7 +487,7 @@ const FileSize = (props: { path: string, events: Registry, fi } }); - props.events.reactUse("notify_transfer_progress", event => { + events.reactUse("notify_transfer_progress", event => { if (event.id !== props.file.transfer?.id) return; @@ -450,13 +497,23 @@ const FileSize = (props: { path: string, events: Registry, fi setSize(event.fileSize); }); - if (size < 0 && (props.file.size < 0 || typeof props.file.size === "undefined")) - return
unknown; - return {network.format_bytes(size >= 0 ? size : props.file.size, { - unit: "B", - time: "", - exact: false - })}; + if (size < 0 && (props.file.size < 0 || typeof props.file.size === "undefined")) { + return ( + + unknown + + ); + } + + return ( + + {network.format_bytes(size >= 0 ? size : props.file.size, { + unit: "B", + time: "", + exact: false + })} + + ); }; const FileTransferIndicator = (props: { file: ListedFileInfo, events: Registry }) => { @@ -520,6 +577,7 @@ const FileTransferIndicator = (props: { file: ListedFileInfo, events: Registry
@@ -532,18 +590,21 @@ const FileListEntry = (props: { row: TableRow, columns: TableCol const [hidden, setHidden] = useState(false); const [selected, setSelected] = useState(false); const [dropHovered, setDropHovered] = useState(false); + const customClasses = useContext(CustomClassContext); const onDoubleClicked = () => { if (file.type === FileType.DIRECTORY) { - if (file.mode === "creating" || file.mode === "create") + if (file.mode === "creating" || file.mode === "create") { return; + } props.events.fire("action_navigate_to", { path: file.path + file.name + "/" }); } else { - if (file.mode === "uploading" || file.virtual) + if (file.mode === "uploading" || file.virtual) { return; + } props.events.fire("action_start_download", { files: [{ @@ -577,12 +638,19 @@ const FileListEntry = (props: { row: TableRow, columns: TableCol }); }, !hidden); - if (hidden) + if (hidden) { return null; + } + + const elementClassList = joinClassList( + cssStyle.directoryEntry, customClasses?.fileEntry?.entry, + selected && cssStyle.selected, selected && customClasses?.fileEntry?.selected, + dropHovered && cssStyle.hovered, dropHovered && customClasses?.fileEntry?.dropHovered + ); return ( , columns: TableCol ); }; +type FileListState = { + state: "querying" | "invalid-password" +} | { + state: "no-permissions", + failedPermission: string +} | { + state: "error", + reason: string +} | { + state: "normal", + files: ListedFileInfo[] +}; + + +function fileTableHeaderContextMenu(event: React.MouseEvent, table: Table | undefined) { + event.preventDefault(); + + if(!table) { + return; + } + + spawn_context_menu(event.pageX, event.pageY, { + type: MenuEntryType.CHECKBOX, + name: tr("Size"), + checkbox_checked: table.state.hiddenColumns.findIndex(e => e === "size") === -1, + callback: () => { + table.state.hiddenColumns.toggle("size"); + table.forceUpdate(); + } + }, { + type: MenuEntryType.CHECKBOX, + name: tr("Type"), + checkbox_checked: table.state.hiddenColumns.findIndex(e => e === "type") === -1, + callback: () => { + table.state.hiddenColumns.toggle("type"); + table.forceUpdate(); + } + }, { + type: MenuEntryType.CHECKBOX, + name: tr("Last changed"), + checkbox_checked: table.state.hiddenColumns.findIndex(e => e === "change-date") === -1, + callback: () => { + table.state.hiddenColumns.toggle("change-date"); + table.forceUpdate(); + } + }) +} + +// const FileListRenderer = React.memo((props: { path: string }) => { +// const events = useContext(EventsContext); +// const customClasses = useContext(CustomClassContext); +// +// const refTable = useRef(); +// +// const [ state, setState ] = useState(() => { +// events.fire("query_files", { path: props.path }); +// return { state: "querying" }; +// }); +// +// events.reactUse("query_files", event => { +// if(event.path === props.path) { +// setState({ state: "querying" }); +// } +// }); +// +// events.reactUse("query_files_result", event => { +// if(event.path !== props.path) { +// return; +// } +// +// switch(event.status) { +// case "no-permissions": +// setState({ state: "no-permissions", failedPermission: event.error }); +// break; +// +// case "error": +// setState({ state: "error", reason: event.error }); +// break; +// +// case "success": +// setState({ state: "normal", files: event.files }); +// break; +// +// case "invalid-password": +// setState({ state: "invalid-password" }); +// break; +// +// case "timeout": +// setState({ state: "error", reason: tr("query timeout") }); +// break; +// +// default: +// setState({ state: "error", reason: tra("invalid query result state {}", event.status) }); +// break; +// } +// }); +// +// let rows: TableRow[] = []; +// let overlay; +// +// switch (state.state) { +// case "querying": +// overlay = () => ( +//
+// loading +//
+// ); +// break; +// +// case "error": +// overlay = () => ( +// +// ); +// break; +// +// case "no-permissions": +// overlay = () => ( +// +// ); +// break; +// +// case "invalid-password": +// /* TODO: Allow the user to enter a password */ +// overlay = () => ( +// +// ); +// break; +// +// case "normal": +// if(state.files.length === 0) { +// overlay = () => ( +// +// ); +// } else { +// const directories = state.files.filter(e => e.type === FileType.DIRECTORY); +// const files = state.files.filter(e => e.type === FileType.FILE); +// +// for (const directory of directories.sort((a, b) => a.name > b.name ? 1 : -1)) { +// rows.push({ +// columns: { +// "name": () => , +// "type": () => Directory, +// "change-date": () => directory.datetime ? +// {Moment(directory.datetime).format("DD/MM/YYYY HH:mm")} : undefined +// }, +// className: cssStyle.directoryEntry, +// userData: directory +// }); +// } +// +// for (const file of files.sort((a, b) => a.name > b.name ? 1 : -1)) { +// rows.push({ +// columns: { +// "name": () => , +// "size": () => , +// "type": () => File, +// "change-date": () => file.datetime ? +// {Moment(file.datetime).format("DD/MM/YYYY HH:mm")} : +// undefined +// }, +// className: cssStyle.directoryEntry, +// userData: file +// }); +// } +// } +// break; +// } +// +// return ( +//
[ +// Name, +//
+// ], width: 80, className: cssStyle.columnName +// }, +// { +// name: "type", header: () => [ +// Type, +//
+// ], fixedWidth: "8em", className: cssStyle.columnType +// }, +// { +// name: "size", header: () => [ +// Size, +//
+// ], fixedWidth: "8em", className: cssStyle.columnSize +// }, +// { +// name: "change-date", header: () => [ +// Last changed, +//
+// ], fixedWidth: "8em", className: cssStyle.columnChanged +// }, +// ]} +// rows={rows} +// +// bodyOverlayOnly={rows.length === 0} +// bodyOverlay={overlay} +// +// hiddenColumns={["type"]} +// +// onHeaderContextMenu={e => fileTableHeaderContextMenu(e, refTable.current)} +// onBodyContextMenu={event => { +// event.preventDefault(); +// events.fire("action_select_files", { mode: "exclusive", files: [] }); +// events.fire("action_selection_context_menu", { pageY: event.pageY, pageX: event.pageX }); +// }} +// onDrop={e => this.onDrop(e)} +// onDragOver={event => { +// const types = event.dataTransfer.types; +// if (types.length !== 1) +// return; +// +// if (types[0] === FileTransferUrlMediaType) { +// /* TODO: Detect if its remote move or internal move */ +// event.dataTransfer.effectAllowed = "move"; +// } else if (types[0] === "Files") { +// event.dataTransfer.effectAllowed = "copy"; +// } else { +// return; +// } +// +// event.preventDefault(); +// }} +// +// renderRow={(row: TableRow, columns, uniqueId) => ( +// +// )} +// /> +// ); +// }); + @ReactEventHandler(e => e.props.events) -export class FileBrowser extends ReactComponentBase { +export class FileBrowserRenderer extends ReactComponentBase { private refTable = React.createRef
(); private currentPath: string; private fileList: ListedFileInfo[]; @@ -724,8 +1041,7 @@ export class FileBrowser extends ReactComponentBase a.name > b.name ? 1 : -1)) { rows.push({ columns: { - "name": () => , + "name": () => , "type": () => Directory, "change-date": () => directory.datetime ? {Moment(directory.datetime).format("DD/MM/YYYY HH:mm")} : undefined @@ -738,8 +1054,8 @@ export class FileBrowser extends ReactComponentBase a.name > b.name ? 1 : -1)) { rows.push({ columns: { - "name": () => , - "size": () => , + "name": () => , + "size": () => , "type": () => File, "change-date": () => file.datetime ? {Moment(file.datetime).format("DD/MM/YYYY HH:mm")} : undefined @@ -752,74 +1068,84 @@ export class FileBrowser extends ReactComponentBase [ - Name, -
- ], width: 80, className: cssStyle.columnName - }, - { - name: "type", header: () => [ - Type, -
- ], fixedWidth: "8em", className: cssStyle.columnType - }, - { - name: "size", header: () => [ - Size, -
- ], fixedWidth: "8em", className: cssStyle.columnSize - }, - { - name: "change-date", header: () => [ - Last changed, -
- ], fixedWidth: "8em", className: cssStyle.columnChanged - }, - ]} - rows={rows} + + + {classes => ( +
[ + Name, +
+ ], width: 80, className: cssStyle.columnName + }, + { + name: "type", header: () => [ + Type, +
+ ], fixedWidth: "8em", className: cssStyle.columnType + }, + { + name: "size", header: () => [ + Size, +
+ ], fixedWidth: "8em", className: cssStyle.columnSize + }, + { + name: "change-date", header: () => [ + Last changed, +
+ ], fixedWidth: "8em", className: cssStyle.columnChanged + }, + ]} + rows={rows} - bodyOverlayOnly={overlayOnly} - bodyOverlay={overlay} + bodyOverlayOnly={overlayOnly} + bodyOverlay={overlay} - hiddenColumns={["type"]} + hiddenColumns={["type"]} - onHeaderContextMenu={e => this.onHeaderContextMenu(e)} - onBodyContextMenu={e => this.onBodyContextMenu(e)} - onDrop={e => this.onDrop(e)} - onDragOver={event => { - const types = event.dataTransfer.types; - if (types.length !== 1) - return; + onHeaderContextMenu={e => this.onHeaderContextMenu(e)} + onBodyContextMenu={e => this.onBodyContextMenu(e)} + onDrop={e => this.onDrop(e)} + onDragOver={event => { + const types = event.dataTransfer.types; + if (types.length !== 1) + return; - if (types[0] === FileTransferUrlMediaType) { - /* TODO: Detect if its remote move or internal move */ - event.dataTransfer.effectAllowed = "move"; - } else if (types[0] === "Files") { - event.dataTransfer.effectAllowed = "copy"; - } else { - return; - } + if (types[0] === FileTransferUrlMediaType) { + /* TODO: Detect if its remote move or internal move */ + event.dataTransfer.effectAllowed = "move"; + } else if (types[0] === "Files") { + event.dataTransfer.effectAllowed = "copy"; + } else { + return; + } - event.preventDefault(); - }} + event.preventDefault(); + }} - renderRow={(row: TableRow, columns, uniqueId) => } - /> + renderRow={(row: TableRow, columns, uniqueId) => ( + + )} + /> + )} + + ); } componentDidMount(): void { this.selection = []; - this.currentPath = this.props.currentPath; + this.currentPath = this.props.initialPath; + + this.props.events.fire("query_current_path", {}); this.props.events.fire("query_files", { path: this.currentPath }); @@ -827,21 +1153,22 @@ export class FileBrowser extends ReactComponentBase decodeURIComponent(e)); for (const fileUrl of fileUrls) { @@ -903,13 +1230,14 @@ export class FileBrowser extends ReactComponentBase("action_navigate_to_result") - private handleNavigationResult(event: FileBrowserEvents["action_navigate_to_result"]) { - if (event.status !== "success") + @EventHandler("notify_current_path") + private handleNavigationResult(event: FileBrowserEvents["notify_current_path"]) { + if (event.status !== "success") { return; + } this.currentPath = event.path; this.selection = []; @@ -983,8 +1311,9 @@ export class FileBrowser extends ReactComponentBase("action_start_create_directory") private handleActionFileCreateBegin(event: FileBrowserEvents["action_start_create_directory"]) { let index = 0; - while (this.fileList.find(e => e.name === (event.defaultName + (index > 0 ? " (" + index + ")" : "")))) + while (this.fileList.find(e => e.name === (event.defaultName + (index > 0 ? " (" + index + ")" : "")))) { index++; + } const name = event.defaultName + (index > 0 ? " (" + index + ")" : ""); this.fileList.push({ @@ -998,12 +1327,14 @@ export class FileBrowser extends ReactComponentBase this.props.events.fire_react("action_select_files", { - files: [{ - name: name, - type: FileType.DIRECTORY - }], mode: "exclusive" - })); + this.forceUpdate(() => { + this.props.events.fire_react("action_select_files", { + files: [{ + name: name, + type: FileType.DIRECTORY + }], mode: "exclusive" + }); + }); } @EventHandler("action_create_directory_result") @@ -1112,8 +1443,9 @@ export class FileBrowser extends ReactComponentBase("notify_transfer_status") private handleTransferStatus(event: FileBrowserEvents["notify_transfer_status"]) { const index = this.fileList.findIndex(e => e.transfer?.id === event.id); - if (index === -1) + if (index === -1) { return; + } let element = this.fileList[index]; if (event.status === "errored") { diff --git a/shared/js/ui/modal/transfer/FileDefinitions.ts b/shared/js/ui/modal/transfer/FileDefinitions.ts new file mode 100644 index 00000000..7342c624 --- /dev/null +++ b/shared/js/ui/modal/transfer/FileDefinitions.ts @@ -0,0 +1,164 @@ +import {FileType} from "tc-shared/file/FileManager"; +import {ChannelEntry} from "tc-shared/tree/Channel"; + +export const channelPathPrefix = tr("Channel") + " "; +export const iconPathPrefix = tr("Icons"); +export const avatarsPathPrefix = tr("Avatars"); +export const FileTransferUrlMediaType = "application/x-teaspeak-ft-urls"; + +export type TransferStatus = "pending" | "transferring" | "finished" | "errored" | "none"; +export type FileMode = "password" | "empty" | "create" | "creating" | "normal" | "uploading"; + +export type ListedFileInfo = { + path: string; + name: string; + type: FileType; + + datetime: number; + size: number; + + virtual: boolean; + mode: FileMode; + + transfer?: { + id: number; + direction: "upload" | "download"; + status: TransferStatus; + percent: number; + } | undefined +}; + +export type PathInfo = { + channelId: number; + channel: ChannelEntry; + + path: string; + type: "icon" | "avatar" | "channel" | "root"; +} + +export interface FileBrowserEvents { + action_navigate_to: { + path: string + }, + action_delete_file: { + files: { + path: string, + name: string + }[] | "selection"; + mode: "force" | "ask"; + }, + action_delete_file_result: { + results: { + path: string, + name: string, + status: "success" | "timeout" | "error"; + error?: string; + }[], + }, + + action_start_create_directory: { + defaultName: string + }, + action_create_directory: { + path: string, + name: string + }, + action_create_directory_result: { + path: string, + name: string, + status: "success" | "timeout" | "error"; + + error?: string; + }, + + action_rename_file: { + oldPath: string, + oldName: string, + + newPath: string; + newName: string + }, + action_rename_file_result: { + oldPath: string, + oldName: string, + status: "success" | "timeout" | "error" | "no-changes"; + + newPath?: string, + newName?: string, + error?: string; + }, + + action_start_rename: { + path: string; + name: string; + }, + + action_select_files: { + files: { + name: string, + type: FileType + }[] + mode: "exclusive" | "toggle" + }, + action_selection_context_menu: { + pageX: number, + pageY: number + }, + + action_start_download: { + files: { + path: string, + name: string + }[] + }, + action_start_upload: { + path: string; + mode: "files" | "browse"; + + files?: File[]; + }, + + query_files: { path: string }, + query_files_result: { + path: string, + status: "success" | "timeout" | "error" | "no-permissions" | "invalid-password", + + error?: string, + files?: ListedFileInfo[] + }, + query_current_path: {}, + + notify_current_path: { + path: string, + status: "success" | "timeout" | "error"; + error?: string; + pathInfo?: PathInfo + }, + + notify_transfer_start: { + path: string; + name: string; + + id: number; + mode: "upload" | "download"; + }, + + notify_transfer_status: { + id: number; + status: TransferStatus; + fileSize?: number; + }, + notify_transfer_progress: { + id: number; + progress: number; + fileSize: number; + status: TransferStatus + } + notify_drag_ended: {}, + /* Attention: Only use in sync mode! */ + notify_drag_started: { + event: DragEvent + } + + notify_destroy: {}, +} diff --git a/shared/js/ui/modal/transfer/TransferInfo.tsx b/shared/js/ui/modal/transfer/FileTransferInfo.tsx similarity index 93% rename from shared/js/ui/modal/transfer/TransferInfo.tsx rename to shared/js/ui/modal/transfer/FileTransferInfo.tsx index 6f1bc715..4f489216 100644 --- a/shared/js/ui/modal/transfer/TransferInfo.tsx +++ b/shared/js/ui/modal/transfer/FileTransferInfo.tsx @@ -1,7 +1,6 @@ import * as React from "react"; import {useEffect, useRef, useState} from "react"; import {EventHandler, ReactEventHandler, Registry} from "tc-shared/events"; -import {TransferStatus} from "tc-shared/ui/modal/transfer/ModalFileTransfer"; import {Translatable} from "tc-shared/ui/react-elements/i18n"; import {HTMLRenderer} from "tc-shared/ui/react-elements/HTMLRenderer"; import {ProgressBar} from "tc-shared/ui/react-elements/ProgressBar"; @@ -11,60 +10,14 @@ import {format_time, network} from "tc-shared/ui/frames/chat"; import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; import {Checkbox} from "tc-shared/ui/react-elements/Checkbox"; import {Button} from "tc-shared/ui/react-elements/Button"; +import {TransferStatus} from "tc-shared/ui/modal/transfer/FileDefinitions"; +import {TransferInfoData, TransferInfoEvents} from "tc-shared/ui/modal/transfer/FileTransferInfoDefinitions"; -const cssStyle = require("./TransferInfo.scss"); +const cssStyle = require("./FileTransferInfoRenderer.scss"); const iconArrow = require("./icon_double_arrow.svg"); const iconTransferUpload = require("./icon_transfer_upload.svg"); const iconTransferDownload = require("./icon_transfer_download.svg"); -export interface TransferInfoEvents { - query_transfers: {}, - query_transfer_result: { - status: "success" | "error" | "timeout"; - - error?: string; - transfers?: TransferInfoData[], - showFinished?: boolean - } - - action_toggle_expansion: { visible: boolean }, - action_toggle_finished_transfers: { visible: boolean }, - action_remove_finished: {}, - - notify_transfer_registered: { transfer: TransferInfoData }, - notify_transfer_status: { - id: number, - status: TransferStatus, - error?: string - }, - notify_transfer_progress: { - id: number; - status: TransferStatus, - progress: TransferProgress - }, - - notify_modal_closed: {} -} - -export interface TransferInfoData { - id: number; - - direction: "upload" | "download"; - status: TransferStatus; - - name: string; - path: string; - - progress: number; - error?: string; - - timestampRegistered: number; - timestampBegin: number; - timestampEnd: number; - - transferredBytes: number; -} - const ExpendState = (props: { extended: boolean, events: Registry }) => { const [expended, setExpended] = useState(props.extended); @@ -495,7 +448,7 @@ const ExtendedInfo = (props: { events: Registry }) => {
; }; -export const TransferInfo = (props: { events: Registry }) => ( +export const FileTransferInfo = (props: { events: Registry }) => (
diff --git a/shared/js/ui/modal/transfer/TransferInfoController.ts b/shared/js/ui/modal/transfer/FileTransferInfoController.ts similarity index 93% rename from shared/js/ui/modal/transfer/TransferInfoController.ts rename to shared/js/ui/modal/transfer/FileTransferInfoController.ts index 587ad2f4..b379f784 100644 --- a/shared/js/ui/modal/transfer/TransferInfoController.ts +++ b/shared/js/ui/modal/transfer/FileTransferInfoController.ts @@ -7,14 +7,14 @@ import { TransferProgress, TransferProperties } from "../../../file/Transfer"; +import {Settings, settings} from "../../../settings"; import { avatarsPathPrefix, channelPathPrefix, iconPathPrefix, TransferStatus -} from "../../../ui/modal/transfer/ModalFileTransfer"; -import {Settings, settings} from "../../../settings"; -import {TransferInfoData, TransferInfoEvents} from "../../../ui/modal/transfer/TransferInfo"; +} from "tc-shared/ui/modal/transfer/FileDefinitions"; +import {TransferInfoData, TransferInfoEvents} from "tc-shared/ui/modal/transfer/FileTransferInfoDefinitions"; export const initializeTransferInfoController = (connection: ConnectionHandler, events: Registry) => { const generateTransferPath = (properties: TransferProperties) => { @@ -128,10 +128,10 @@ export const initializeTransferInfoController = (connection: ConnectionHandler, events.fire("notify_transfer_registered", {transfer: generateTransferInfo(transfer)}); const closeListener = () => unregisterEvents(); - events.on("notify_modal_closed", closeListener); + events.on("notify_destroy", closeListener); const unregisterEvents = () => { - events.off("notify_modal_closed", closeListener); + events.off("notify_destroy", closeListener); transfer.events.off("notify_progress", progressListener); }; }; @@ -139,7 +139,7 @@ export const initializeTransferInfoController = (connection: ConnectionHandler, const registeredListener = event => listenToTransfer(event.transfer); connection.fileManager.events.on("notify_transfer_registered", registeredListener); - events.on("notify_modal_closed", () => connection.fileManager.events.off("notify_transfer_registered", registeredListener)); + events.on("notify_destroy", () => connection.fileManager.events.off("notify_transfer_registered", registeredListener)); connection.fileManager.registeredTransfers().forEach(transfer => listenToTransfer(transfer)); } diff --git a/shared/js/ui/modal/transfer/FileTransferInfoDefinitions.ts b/shared/js/ui/modal/transfer/FileTransferInfoDefinitions.ts new file mode 100644 index 00000000..21d88789 --- /dev/null +++ b/shared/js/ui/modal/transfer/FileTransferInfoDefinitions.ts @@ -0,0 +1,50 @@ +import {TransferStatus} from "tc-shared/ui/modal/transfer/FileDefinitions"; +import {TransferProgress} from "tc-shared/file/Transfer"; + +export interface TransferInfoEvents { + query_transfers: {}, + query_transfer_result: { + status: "success" | "error" | "timeout"; + + error?: string; + transfers?: TransferInfoData[], + showFinished?: boolean + } + + action_toggle_expansion: { visible: boolean }, + action_toggle_finished_transfers: { visible: boolean }, + action_remove_finished: {}, + + notify_transfer_registered: { transfer: TransferInfoData }, + notify_transfer_status: { + id: number, + status: TransferStatus, + error?: string + }, + notify_transfer_progress: { + id: number; + status: TransferStatus, + progress: TransferProgress + }, + + notify_destroy: {} +} + +export interface TransferInfoData { + id: number; + + direction: "upload" | "download"; + status: TransferStatus; + + name: string; + path: string; + + progress: number; + error?: string; + + timestampRegistered: number; + timestampBegin: number; + timestampEnd: number; + + transferredBytes: number; +} \ No newline at end of file diff --git a/shared/js/ui/modal/transfer/TransferInfo.scss b/shared/js/ui/modal/transfer/FileTransferInfoRenderer.scss similarity index 100% rename from shared/js/ui/modal/transfer/TransferInfo.scss rename to shared/js/ui/modal/transfer/FileTransferInfoRenderer.scss diff --git a/shared/js/ui/modal/transfer/ModalFileTransfer.scss b/shared/js/ui/modal/transfer/ModalFileTransfer.scss index 75774784..e69de29b 100644 --- a/shared/js/ui/modal/transfer/ModalFileTransfer.scss +++ b/shared/js/ui/modal/transfer/ModalFileTransfer.scss @@ -1,384 +0,0 @@ -@import "../../../../css/static/mixin"; -@import "../../../../css/static/properties"; - -html:root { - --modal-transfer-refresh-hover: #ffffff0e; - --modal-transfer-path-hover: #E6E6E6; - - --modal-transfer-error-overlay-text: #9e9494; - - --modal-transfer-filelist: #28292b; - --modal-transfer-filelist-border: #161616; - - --modal-transfer-entry-hover: #2c2d2f; - --modal-transfer-entry-selected: #1a1a1b; - - --modal-transfer-indicator-red: #a10000; - --modal-transfer-indicator-red-end: #e60000; - - --modal-transfer-indicator-blue: #005fa1; - --modal-transfer-indicator-blue-end: #007acc; - - --modal-transfer-indicator-green: #389738; - --modal-transfer-indicator-green-end: #4ecc4e; - - --modal-transfer-indicator-hidden: #28292b00; - --modal-transfer-indicator-hidden-end: #28292b00; -} - -.container { - padding: 1em; - position: relative; - padding-bottom: 4em; /* for the transfer info */ - - display: flex; - flex-direction: column; - justify-content: stretch; - - flex-shrink: 1; - flex-grow: 1; - - width: 90em; - min-width: 10em; - max-width: 100%; - - height: 55em; - max-height: 100%; - min-height: 10em; - - .navigation { - flex-grow: 0; - flex-shrink: 0; - - .icon { - margin: auto .25em; - padding: .2em; - - display: flex; - flex-direction: column; - justify-content: center; - - cursor: pointer; - - > div { - padding: .1em; - } - } - - .refreshIcon { - border-radius: 1px; - - @include transition(background-color $button_hover_animation_time ease-in-out); - - > div { - padding: .1em; - } - - &.enabled { - cursor: pointer; - - &:hover { - background-color: var(--modal-transfer-refresh-hover); - } - } - } - - .directoryIcon { - margin-right: -.25em; - } - - input { - margin-left: .5em; - } - - .containerPath { - @include user-select(none); - - display: flex; - flex-direction: row; - justify-content: flex-start; - - overflow: hidden; - white-space: nowrap; - - width: calc(100% - 1em); /* some space for the text editing */ - - a.pathShrink { - flex-shrink: 1; - min-width: 5em; - - @include text-dotdotdot(); - } - - a { - cursor: pointer; - - @include transition(color $button_hover_animation_time ease-in-out); - - &:hover, &.hovered { - color: var(--modal-transfer-path-hover); - } - } - } - } - - .fileTable { - min-height: 5em; - - flex-grow: 1; - flex-shrink: 1; - - margin-top: 1em; - - border: 1px var(--modal-transfer-filelist-border) solid; - border-radius: 0.2em; - background-color: var(--modal-transfer-filelist); - - .header { - z-index: 1; - padding-top: .2em; - padding-bottom: .2em; - - background-color: var(--modal-transfer-filelist); - - .columnName, .columnSize, .columnType, .columnChanged { - position: relative; - - display: flex; - flex-direction: row; - justify-content: center; - - .seperator { - position: absolute; - right: .2em; - top: .2em; - bottom: .2em; - - width: .1em; - background-color: var(--text); - } - } - - .columnSize { - width: 8em; - text-align: end; - } - - .columnName { - padding-left: .5em; - } - - > div:last-of-type { - .seperator { - display: none; - } - } - } - - .body { - @include user-select(none); - @include chat-scrollbar-vertical(); - - .columnName { - padding-left: .5em; - - display: flex; - flex-direction: row; - justify-content: flex-start; - - a, div, img { - align-self: center; - margin-right: .5em; - - @include text-dotdotdot(); - } - - img, div { - flex-shrink: 0; - - height: 1em; - width: 1em; - } - - input { - height: 1.3em; - align-self: center; - - flex-grow: 1; - margin-right: .5em; - - border-style: inherit; - padding: .1em; - } - } - - .overlay { - position: absolute; - - top: 0; - left: 0; - right: 0; - bottom: 0; - - display: flex; - flex-direction: column; - justify-content: center; - - a { - text-align: center; - } - } - - .overlayError { - a { - font-size: 1.2em; - color: var(--modal-transfer-error-overlay-text); - } - } - - .overlayEmptyFolder { - align-self: center; - margin-top: 1em; - } - - .directoryEntry { - cursor: pointer; - - &:hover { - background-color: var(--modal-transfer-entry-hover); - } - - &.selected { - background-color: var(--modal-transfer-entry-selected); - } - - /* drag hovered overrides selected */ - &.hovered { - background-color: var(--modal-transfer-entry-hover); - } - - $indicator_transform_time: .5s; - - .indicator { - position: absolute; - - left: 0; - right: 30%; - top: 0; - bottom: 0; - - opacity: .4; - margin-right: 10px; /* for the gradient at the end */ - - .status { - position: absolute; - - left: 0; - top: 0; - bottom: 0; - - width: 2px; - @include transition(all $indicator_transform_time ease-in-out); - } - - &:after { - content: ' '; - - position: absolute; - - top: 0; - right: 0; - bottom: 0; - - height: 100%; - width: 10px; - - background-image: linear-gradient(to right, var(--modal-transfer-filelist), var(--modal-transfer-filelist)); - @include transition(all $indicator_transform_time ease-in-out); - } - - @include transition(all $indicator_transform_time ease-in-out); - - @mixin define-indicator($color, $colorLight) { - background-color: $color; - - .status { - background-color: $colorLight; - - -webkit-box-shadow: 0 0 12px 3px $colorLight; - -moz-box-shadow: 0 0 12px 3px $colorLight; - box-shadow: 0 0 12px 3px $colorLight; - } - - &:after { - background-image: linear-gradient(to right, $color, var(--modal-transfer-filelist)); - } - } - - &.red { - @include define-indicator(var(--modal-transfer-indicator-red), var(--modal-transfer-indicator-red-end)); - } - - &.blue { - @include define-indicator(var(--modal-transfer-indicator-blue), var(--modal-transfer-indicator-blue-end)); - } - - &.green { - @include define-indicator(var(--modal-transfer-indicator-green), var(--modal-transfer-indicator-green-end)); - } - - &.hidden { - @include define-indicator(var(--modal-transfer-indicator-hidden), var(--modal-transfer-indicator-hidden-end)); - } - } - } - } - - .columnSize { - text-align: end; - - a { - margin-right: 1em; - } - } - - .columnType { - text-align: center; - } - } - - .row { - - } -} - -.arrow { - width: 1em; - - flex-shrink: 0; - flex-grow: 0; - - display: flex; - flex-direction: column; - justify-content: center; - - .inner { - flex-grow: 0; - flex-shrink: 0; - - align-self: center; - margin-left: -.09em; - - transform: rotate(-45deg); - -webkit-transform: rotate(-45deg); - - display: inline-block; - border: solid var(--text); - - border-width: 0 0.125em 0.125em 0; - padding: 0.15em; - - height: 0.15em; - width: .15em; - } -} \ No newline at end of file diff --git a/shared/js/ui/modal/transfer/ModalFileTransfer.tsx b/shared/js/ui/modal/transfer/ModalFileTransfer.tsx index 25930131..42f61c98 100644 --- a/shared/js/ui/modal/transfer/ModalFileTransfer.tsx +++ b/shared/js/ui/modal/transfer/ModalFileTransfer.tsx @@ -1,180 +1,17 @@ import {spawnReactModal} from "tc-shared/ui/react-elements/Modal"; import * as React from "react"; -import {FileType} from "tc-shared/file/FileManager"; import {Registry} from "tc-shared/events"; -import {FileBrowser, NavigationBar} from "tc-shared/ui/modal/transfer/FileBrowser"; -import {TransferInfo, TransferInfoEvents} from "tc-shared/ui/modal/transfer/TransferInfo"; -import {initializeRemoteFileBrowserController} from "tc-shared/ui/modal/transfer/RemoteFileBrowserController"; -import {ChannelEntry} from "tc-shared/tree/Channel"; -import {initializeTransferInfoController} from "tc-shared/ui/modal/transfer/TransferInfoController"; +import {FileBrowserRenderer, NavigationBar} from "./FileBrowserRenderer"; +import {FileTransferInfo} from "./FileTransferInfo"; +import {initializeRemoteFileBrowserController} from "./FileBrowserControllerRemote"; +import {initializeTransferInfoController} from "./FileTransferInfoController"; import {Translatable} from "tc-shared/ui/react-elements/i18n"; import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controller"; import {server_connections} from "tc-shared/ConnectionManager"; +import {channelPathPrefix, FileBrowserEvents} from "tc-shared/ui/modal/transfer/FileDefinitions"; +import {TransferInfoEvents} from "tc-shared/ui/modal/transfer/FileTransferInfoDefinitions"; -const cssStyle = require("./ModalFileTransfer.scss"); -export const channelPathPrefix = tr("Channel") + " "; -export const iconPathPrefix = tr("Icons"); -export const avatarsPathPrefix = tr("Avatars"); -export const FileTransferUrlMediaType = "application/x-teaspeak-ft-urls"; - -export type TransferStatus = "pending" | "transferring" | "finished" | "errored" | "none"; -export type FileMode = "password" | "empty" | "create" | "creating" | "normal" | "uploading"; - -export type ListedFileInfo = { - path: string; - name: string; - type: FileType; - - datetime: number; - size: number; - - virtual: boolean; - mode: FileMode; - - transfer?: { - id: number; - direction: "upload" | "download"; - status: TransferStatus; - percent: number; - } | undefined -}; - -export type PathInfo = { - channelId: number; - channel: ChannelEntry; - - path: string; - type: "icon" | "avatar" | "channel" | "root"; -} - -export interface FileBrowserEvents { - query_files: { path: string }, - query_files_result: { - path: string, - status: "success" | "timeout" | "error" | "no-permissions" | "invalid-password", - - error?: string, - files?: ListedFileInfo[] - }, - - action_navigate_to: { - path: string - }, - action_navigate_to_result: { - path: string, - status: "success" | "timeout" | "error"; - error?: string; - pathInfo?: PathInfo - } - - action_delete_file: { - files: { - path: string, - name: string - }[] | "selection"; - mode: "force" | "ask"; - }, - action_delete_file_result: { - results: { - path: string, - name: string, - status: "success" | "timeout" | "error"; - error?: string; - }[], - }, - - action_start_create_directory: { - defaultName: string - }, - action_create_directory: { - path: string, - name: string - }, - action_create_directory_result: { - path: string, - name: string, - status: "success" | "timeout" | "error"; - - error?: string; - }, - - action_rename_file: { - oldPath: string, - oldName: string, - - newPath: string; - newName: string - }, - action_rename_file_result: { - oldPath: string, - oldName: string, - status: "success" | "timeout" | "error" | "no-changes"; - - newPath?: string, - newName?: string, - error?: string; - }, - - action_start_rename: { - path: string; - name: string; - }, - - action_select_files: { - files: { - name: string, - type: FileType - }[] - mode: "exclusive" | "toggle" - }, - action_selection_context_menu: { - pageX: number, - pageY: number - }, - - action_start_download: { - files: { - path: string, - name: string - }[] - }, - action_start_upload: { - path: string; - mode: "files" | "browse"; - - files?: File[]; - }, - - notify_transfer_start: { - path: string; - name: string; - - id: number; - mode: "upload" | "download"; - }, - - notify_transfer_status: { - id: number; - status: TransferStatus; - fileSize?: number; - }, - notify_transfer_progress: { - id: number; - progress: number; - fileSize: number; - status: TransferStatus - } - - - notify_modal_closed: {}, - notify_drag_ended: {}, - - /* Attention: Only use in sync mode! */ - notify_drag_started: { - event: DragEvent - } -} - +const cssStyle = require("./FileBrowserRenderer.scss"); class FileTransferModal extends InternalModal { readonly remoteBrowseEvents = new Registry(); @@ -196,12 +33,12 @@ class FileTransferModal extends InternalModal { protected onInitialize() { const path = this.defaultChannelId ? "/" + channelPathPrefix + this.defaultChannelId + "/" : "/"; - this.remoteBrowseEvents.fire("action_navigate_to", {path: path}); + this.remoteBrowseEvents.fire("action_navigate_to", { path: path }); } protected onDestroy() { - this.remoteBrowseEvents.fire("notify_modal_closed"); - this.transferInfoEvents.fire("notify_modal_closed"); + this.remoteBrowseEvents.fire("notify_destroy"); + this.transferInfoEvents.fire("notify_destroy"); } title() { @@ -210,11 +47,13 @@ class FileTransferModal extends InternalModal { renderBody() { const path = this.defaultChannelId ? "/" + channelPathPrefix + this.defaultChannelId + "/" : "/"; - return
- - - -
+ return ( +
+ + + +
+ ) } } diff --git a/shared/js/ui/react-elements/ErrorBoundary.scss b/shared/js/ui/react-elements/ErrorBoundary.scss new file mode 100644 index 00000000..e69de29b diff --git a/shared/js/ui/react-elements/ErrorBoundary.ts b/shared/js/ui/react-elements/ErrorBoundary.ts new file mode 100644 index 00000000..1f585c8f --- /dev/null +++ b/shared/js/ui/react-elements/ErrorBoundary.ts @@ -0,0 +1,23 @@ +import * as React from "react"; + +interface ErrorBoundaryState { + errorOccurred: boolean +} + +export class ErrorBoundary extends React.Component<{}, ErrorBoundaryState> { + render() { + if(this.state.errorOccurred) { + + } else { + return this.props.children; + } + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error("Did catch: %o - %o", error, errorInfo); + } + + static getDerivedStateFromError() : Partial { + return { errorOccurred: true }; + } +} \ No newline at end of file diff --git a/shared/js/ui/react-elements/Helper.ts b/shared/js/ui/react-elements/Helper.ts index ad4a899f..4b5cb9df 100644 --- a/shared/js/ui/react-elements/Helper.ts +++ b/shared/js/ui/react-elements/Helper.ts @@ -23,4 +23,8 @@ export function useDependentState( }, inputs); return [state, setState]; +} + +export function joinClassList(...classes: any[]) : string { + return classes.filter(value => typeof value === "string" && value.length > 0).join(" "); } \ No newline at end of file diff --git a/shared/js/ui/react-elements/InputField.tsx b/shared/js/ui/react-elements/InputField.tsx index 2723039d..98c3dd26 100644 --- a/shared/js/ui/react-elements/InputField.tsx +++ b/shared/js/ui/react-elements/InputField.tsx @@ -26,7 +26,7 @@ export interface BoxedInputFieldProperties { size?: "normal" | "large" | "small"; - onFocus?: () => void; + onFocus?: (event: React.FocusEvent | React.MouseEvent) => void; onBlur?: () => void; onChange?: (newValue: string) => void; diff --git a/shared/js/ui/react-elements/Table.tsx b/shared/js/ui/react-elements/Table.tsx index 76810c96..b466e740 100644 --- a/shared/js/ui/react-elements/Table.tsx +++ b/shared/js/ui/react-elements/Table.tsx @@ -137,8 +137,9 @@ export class Table extends React.Component { return rowRenderer(row, columns, "tr-" + row.__rowIndex); }); - if(this.props.bodyOverlay) + if(this.props.bodyOverlay) { body.push(this.props.bodyOverlay()); + } } return ( From a5afa5cce39b182edb4b553b647c36e16055a447 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Fri, 18 Dec 2020 19:25:27 +0100 Subject: [PATCH 30/37] Added some error boundaries to keep the ui functional even if elements may throw and error for a connection instance --- shared/js/ui/frames/SideBarRenderer.tsx | 5 +++-- shared/js/ui/frames/log/Renderer.tsx | 5 ++++- shared/js/ui/react-elements/ErrorBoundary.scss | 18 ++++++++++++++++++ .../{ErrorBoundary.ts => ErrorBoundary.tsx} | 15 +++++++++++++-- 4 files changed, 38 insertions(+), 5 deletions(-) rename shared/js/ui/react-elements/{ErrorBoundary.ts => ErrorBoundary.tsx} (55%) diff --git a/shared/js/ui/frames/SideBarRenderer.tsx b/shared/js/ui/frames/SideBarRenderer.tsx index 5d0752f7..63ecfbf1 100644 --- a/shared/js/ui/frames/SideBarRenderer.tsx +++ b/shared/js/ui/frames/SideBarRenderer.tsx @@ -53,7 +53,6 @@ const ContentRendererClientInfo = () => { const contentData = useContentData("client-info"); if(!contentData) { return null; } - throw "XX"; return (
- + + +
diff --git a/shared/js/ui/frames/log/Renderer.tsx b/shared/js/ui/frames/log/Renderer.tsx index 2b9308e1..2269bebc 100644 --- a/shared/js/ui/frames/log/Renderer.tsx +++ b/shared/js/ui/frames/log/Renderer.tsx @@ -6,6 +6,7 @@ import {findLogEventRenderer} from "./RendererEvent"; import {LogMessage} from "tc-shared/connectionlog/Definitions"; import {ServerEventLogUiEvents} from "tc-shared/ui/frames/log/Definitions"; import {useDependentState} from "tc-shared/ui/react-elements/Helper"; +import {ErrorBoundary} from "tc-shared/ui/react-elements/ErrorBoundary"; const cssStyle = require("./Renderer.scss"); @@ -111,7 +112,9 @@ export const ServerLogFrame = (props: { events: Registry return ( - + + + ); diff --git a/shared/js/ui/react-elements/ErrorBoundary.scss b/shared/js/ui/react-elements/ErrorBoundary.scss index e69de29b..bec1d731 100644 --- a/shared/js/ui/react-elements/ErrorBoundary.scss +++ b/shared/js/ui/react-elements/ErrorBoundary.scss @@ -0,0 +1,18 @@ +.container { + display: flex; + flex-direction: column; + justify-content: center; + + background-color: red; + + height: 100%; + width: 100%; + + .text { + align-items: center; + text-align: center; + + font-weight: bold; + color: white; + } +} \ No newline at end of file diff --git a/shared/js/ui/react-elements/ErrorBoundary.ts b/shared/js/ui/react-elements/ErrorBoundary.tsx similarity index 55% rename from shared/js/ui/react-elements/ErrorBoundary.ts rename to shared/js/ui/react-elements/ErrorBoundary.tsx index 1f585c8f..065a315d 100644 --- a/shared/js/ui/react-elements/ErrorBoundary.ts +++ b/shared/js/ui/react-elements/ErrorBoundary.tsx @@ -1,20 +1,31 @@ import * as React from "react"; +const cssStyle = require("./ErrorBoundary.scss"); + interface ErrorBoundaryState { errorOccurred: boolean } export class ErrorBoundary extends React.Component<{}, ErrorBoundaryState> { + constructor(props) { + super(props); + + this.state = { errorOccurred: false }; + } render() { if(this.state.errorOccurred) { - + return ( +
+
A rendering error has occurred
+
+ ); } else { return this.props.children; } } componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { - console.error("Did catch: %o - %o", error, errorInfo); + /* TODO: Some kind of logging? */ } static getDerivedStateFromError() : Partial { From f87dfed579c0dd69dfc923b25f37a9a07c500fbd Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 19 Dec 2020 16:14:07 +0100 Subject: [PATCH 31/37] Fixed URL emitting --- shared/js/text/chat.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/js/text/chat.ts b/shared/js/text/chat.ts index 09fa189c..58bdb672 100644 --- a/shared/js/text/chat.ts +++ b/shared/js/text/chat.ts @@ -56,7 +56,7 @@ function bbcodeLinkUrls(message: string, ignore: { start: number, end: number }[ let colonIndex = urlPath.indexOf(":"); - if(colonIndex === -1 || colonIndex + 2 < urlPath.length || urlPath[colonIndex + 1] !== "/" || urlPath[colonIndex + 2] !== "/") { + if(colonIndex === -1 || colonIndex + 2 >= urlPath.length || urlPath[colonIndex + 1] !== "/" || urlPath[colonIndex + 2] !== "/") { bbcodeUrl = "[url=https://" + urlPath + "]" + urlPath + "[/url]"; } else { bbcodeUrl = "[url]" + urlPath + "[/url]"; From c1a683e505888538d40da5abf27c5e4885ee0bb7 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 19 Dec 2020 20:14:51 +0100 Subject: [PATCH 32/37] Fixed Video preview crash and added error boundaries --- shared/js/ui/frames/video/Renderer.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/shared/js/ui/frames/video/Renderer.tsx b/shared/js/ui/frames/video/Renderer.tsx index dd0c94b7..587d03b8 100644 --- a/shared/js/ui/frames/video/Renderer.tsx +++ b/shared/js/ui/frames/video/Renderer.tsx @@ -16,6 +16,7 @@ import ResizeObserver from "resize-observer-polyfill"; import {LogCategory, logWarn} from "tc-shared/log"; import {spawnContextMenu} from "tc-shared/ui/ContextMenu"; import {VideoBroadcastType} from "tc-shared/connection/VideoConnection"; +import {ErrorBoundary} from "tc-shared/ui/react-elements/ErrorBoundary"; const SubscribeContext = React.createContext(undefined); const EventContext = React.createContext>(undefined); @@ -586,7 +587,11 @@ const VideoBar = () => {
{videos === "loading" ? undefined : - videos.map(videoId => ) + videos.map(videoId => ( + + + + )) }
@@ -640,9 +645,11 @@ export const ChannelVideoRenderer = (props: { handlerId: string, events: Registr
+ + + + - -
From 426b3288043a45d46582390f9cf8293bafef2da4 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Mon, 21 Dec 2020 17:51:51 +0100 Subject: [PATCH 33/37] Fixed a small crash --- shared/js/stats.ts | 3 ++- shared/js/ui/frames/side/ChannelDescriptionController.ts | 2 +- shared/js/ui/frames/video/Renderer.tsx | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/shared/js/stats.ts b/shared/js/stats.ts index b0147876..be501397 100644 --- a/shared/js/stats.ts +++ b/shared/js/stats.ts @@ -102,8 +102,9 @@ namespace connection { connection_state = ConnectionState.CONNECTING; connection = new WebSocket('wss://web-stats.teaspeak.de:27790'); - if(!connection) + if(!connection) { connection = new WebSocket('wss://localhost:27788'); + } { const connection_copy = connection; diff --git a/shared/js/ui/frames/side/ChannelDescriptionController.ts b/shared/js/ui/frames/side/ChannelDescriptionController.ts index eb12436f..4048eed2 100644 --- a/shared/js/ui/frames/side/ChannelDescriptionController.ts +++ b/shared/js/ui/frames/side/ChannelDescriptionController.ts @@ -92,7 +92,7 @@ export class ChannelDescriptionController { this.cachedDescriptionStatus = { status: "success", description: description, - handlerId: this.currentChannel.channelTree.client.handlerId + handlerId: this.currentChannel?.channelTree.client.handlerId || "unknown" }; } catch (error) { if(error instanceof CommandResult) { diff --git a/shared/js/ui/frames/video/Renderer.tsx b/shared/js/ui/frames/video/Renderer.tsx index 587d03b8..1647e9da 100644 --- a/shared/js/ui/frames/video/Renderer.tsx +++ b/shared/js/ui/frames/video/Renderer.tsx @@ -653,5 +653,5 @@ export const ChannelVideoRenderer = (props: { handlerId: string, events: Registr
- ) + ); }; \ No newline at end of file From 8717b3dd1d953881beb28d08fa445e6dc9fccea7 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Mon, 21 Dec 2020 18:10:16 +0100 Subject: [PATCH 34/37] Fixed the Poke message URL renderer --- shared/js/text/bbcode/url.tsx | 51 ++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/shared/js/text/bbcode/url.tsx b/shared/js/text/bbcode/url.tsx index 56aca68d..55dd62dd 100644 --- a/shared/js/text/bbcode/url.tsx +++ b/shared/js/text/bbcode/url.tsx @@ -55,30 +55,37 @@ loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, { target = '#'; } - const handlerId = useContext(BBCodeHandlerContext); + return ( + + {handlerId => { + if(handlerId) { + /* TS3-Protocol for a client */ + if(target.match(ClientUrlRegex)) { + const clientData = target.match(ClientUrlRegex); + const clientDatabaseId = parseInt(clientData[1]); + const clientUniqueId = clientDatabaseId[2]; - if(handlerId) { - /* TS3-Protocol for a client */ - if(target.match(ClientUrlRegex)) { - const clientData = target.match(ClientUrlRegex); - const clientDatabaseId = parseInt(clientData[1]); - const clientUniqueId = clientDatabaseId[2]; + return 0 ? clientDatabaseId : undefined} + handlerId={handlerId} + />; + } + } - return 0 ? clientDatabaseId : undefined} - handlerId={handlerId} - />; - } - } - - return { - event.preventDefault(); - spawnUrlContextMenu(event.pageX, event.pageY, target); - }}> - {renderer.renderContent(element)} - ; + return ( + { + event.preventDefault(); + spawnUrlContextMenu(event.pageX, event.pageY, target); + }}> + {renderer.renderContent(element)} + + ); + }} + + ); } tags(): string | string[] { From 68388f693a5c8b974a9cf238d40a9c21a6b3cd3e Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Mon, 21 Dec 2020 19:13:25 +0100 Subject: [PATCH 35/37] Fixed Travis compile --- shared/js/text/bbcode.tsx | 3 +-- shared/js/text/bbcode/renderer.ts | 5 ++++- shared/js/text/bbcode/url.tsx | 5 ++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/shared/js/text/bbcode.tsx b/shared/js/text/bbcode.tsx index 12a2c1bc..de1fabee 100644 --- a/shared/js/text/bbcode.tsx +++ b/shared/js/text/bbcode.tsx @@ -1,11 +1,10 @@ import {XBBCodeRenderer} from "vendor/xbbcode/react"; import * as React from "react"; -import {rendererHTML, rendererReact, rendererText} from "tc-shared/text/bbcode/renderer"; +import {rendererHTML, rendererReact, rendererText, BBCodeHandlerContext} from "tc-shared/text/bbcode/renderer"; import {parse as parseBBCode} from "vendor/xbbcode/parser"; import {fixupJQueryUrlTags} from "tc-shared/text/bbcode/url"; import {fixupJQueryImageTags} from "tc-shared/text/bbcode/image"; import "./bbcode.scss"; -import {BBCodeHandlerContext} from "vendor/xbbcode/renderer/react"; export const escapeBBCode = (text: string) => text.replace(/(\[)/g, "\\$1"); diff --git a/shared/js/text/bbcode/renderer.ts b/shared/js/text/bbcode/renderer.ts index add7a512..afb73346 100644 --- a/shared/js/text/bbcode/renderer.ts +++ b/shared/js/text/bbcode/renderer.ts @@ -2,6 +2,8 @@ import TextRenderer from "vendor/xbbcode/renderer/text"; import ReactRenderer from "vendor/xbbcode/renderer/react"; import HTMLRenderer from "vendor/xbbcode/renderer/html"; +export const BBCodeHandlerContext = React.createContext(undefined); + export const rendererText = new TextRenderer(); export const rendererReact = new ReactRenderer(); export const rendererHTML = new HTMLRenderer(rendererReact); @@ -10,4 +12,5 @@ import "./emoji"; import "./highlight"; import "./youtube"; import "./url"; -import "./image"; \ No newline at end of file +import "./image"; +import * as React from "react"; \ No newline at end of file diff --git a/shared/js/text/bbcode/url.tsx b/shared/js/text/bbcode/url.tsx index 55dd62dd..b00d7b8e 100644 --- a/shared/js/text/bbcode/url.tsx +++ b/shared/js/text/bbcode/url.tsx @@ -4,10 +4,9 @@ import * as loader from "tc-loader"; import {ElementRenderer} from "vendor/xbbcode/renderer/base"; import {TagElement} from "vendor/xbbcode/elements"; import * as React from "react"; -import ReactRenderer, {BBCodeHandlerContext} from "vendor/xbbcode/renderer/react"; -import {rendererReact, rendererText} from "tc-shared/text/bbcode/renderer"; +import ReactRenderer from "vendor/xbbcode/renderer/react"; +import {rendererReact, rendererText, BBCodeHandlerContext} from "tc-shared/text/bbcode/renderer"; import {ClientTag} from "tc-shared/ui/tree/EntryTags"; -import {useContext} from "react"; function spawnUrlContextMenu(pageX: number, pageY: number, target: string) { contextmenu.spawn_context_menu(pageX, pageY, { From 65406447f58fba3d69b2aa86d65a5725d749abdb Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Mon, 21 Dec 2020 20:24:00 +0100 Subject: [PATCH 36/37] Fixed too early initialize --- shared/js/text/bbcode/renderer.ts | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/shared/js/text/bbcode/renderer.ts b/shared/js/text/bbcode/renderer.ts index afb73346..9dc1cc98 100644 --- a/shared/js/text/bbcode/renderer.ts +++ b/shared/js/text/bbcode/renderer.ts @@ -1,16 +1,29 @@ +import * as loader from "tc-loader"; +import {Stage} from "tc-loader"; + +import * as React from "react"; +import {Context} from "react"; + import TextRenderer from "vendor/xbbcode/renderer/text"; import ReactRenderer from "vendor/xbbcode/renderer/react"; import HTMLRenderer from "vendor/xbbcode/renderer/html"; -export const BBCodeHandlerContext = React.createContext(undefined); - -export const rendererText = new TextRenderer(); -export const rendererReact = new ReactRenderer(); -export const rendererHTML = new HTMLRenderer(rendererReact); - import "./emoji"; import "./highlight"; import "./youtube"; import "./url"; import "./image"; -import * as React from "react"; \ No newline at end of file + +export let BBCodeHandlerContext: Context; + +export const rendererText = new TextRenderer(); +export const rendererReact = new ReactRenderer(); +export const rendererHTML = new HTMLRenderer(rendererReact); + +loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { + name: "BBCode handler context", + function: async () => { + BBCodeHandlerContext = React.createContext(undefined); + }, + priority: 80 +}) \ No newline at end of file From de052d566be02067bd569830cc741da8f722d1ee Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Mon, 21 Dec 2020 20:25:07 +0100 Subject: [PATCH 37/37] Tem changes for the new channel create modal --- shared/js/ui/modal/ModalCreateChannel.ts | 4 + shared/js/ui/modal/channel-edit/Controller.ts | 107 +++++++++---- .../channel-edit/ControllerPermissions.ts | 92 +++++++++++ .../channel-edit/ControllerProperties.ts | 151 ++++++++++++++++++ .../js/ui/modal/channel-edit/Definitions.ts | 42 +++-- shared/js/ui/modal/channel-edit/Renderer.tsx | 92 ++++++++--- 6 files changed, 423 insertions(+), 65 deletions(-) create mode 100644 shared/js/ui/modal/channel-edit/ControllerPermissions.ts create mode 100644 shared/js/ui/modal/channel-edit/ControllerProperties.ts diff --git a/shared/js/ui/modal/ModalCreateChannel.ts b/shared/js/ui/modal/ModalCreateChannel.ts index cd19b86f..47a916f2 100644 --- a/shared/js/ui/modal/ModalCreateChannel.ts +++ b/shared/js/ui/modal/ModalCreateChannel.ts @@ -12,8 +12,12 @@ import {hashPassword} from "../../utils/helpers"; import {sliderfy} from "../../ui/elements/Slider"; import {generateIconJQueryTag, getIconManager} from "tc-shared/file/Icons"; import { tr } from "tc-shared/i18n/localize"; +import {spawnChannelEditNew} from "tc-shared/ui/modal/channel-edit/Controller"; export function createChannelModal(connection: ConnectionHandler, channel: ChannelEntry | undefined, parent: ChannelEntry | undefined, permissions: PermissionManager, callback: (properties?: ChannelProperties, permissions?: PermissionValue[]) => any) { + spawnChannelEditNew(connection, channel, parent, callback); + return; + let properties: ChannelProperties = { } as ChannelProperties; //The changes properties const modal = createModal({ header: channel ? tr("Edit channel") : tr("Create channel"), diff --git a/shared/js/ui/modal/channel-edit/Controller.ts b/shared/js/ui/modal/channel-edit/Controller.ts index e3613df3..46ddcc06 100644 --- a/shared/js/ui/modal/channel-edit/Controller.ts +++ b/shared/js/ui/modal/channel-edit/Controller.ts @@ -1,41 +1,94 @@ import {ConnectionHandler} from "tc-shared/ConnectionHandler"; -import {ChannelEntry} from "tc-shared/tree/Channel"; -import {ChannelEditableProperty} from "tc-shared/ui/modal/channel-edit/Definitions"; +import {ChannelEntry, ChannelProperties} from "tc-shared/tree/Channel"; +import {ChannelEditEvents, ChannelPropertyPermission} from "tc-shared/ui/modal/channel-edit/Definitions"; +import {Registry} from "tc-shared/events"; +import {ChannelPropertyProviders} from "tc-shared/ui/modal/channel-edit/ControllerProperties"; +import {LogCategory, logError} from "tc-shared/log"; +import {ChannelPropertyPermissionsProviders} from "tc-shared/ui/modal/channel-edit/ControllerPermissions"; +import {spawnReactModal} from "tc-shared/ui/react-elements/Modal"; +import {ChannelEditModal} from "tc-shared/ui/modal/channel-edit/Renderer"; +import {PermissionValue} from "tc-shared/permission/PermissionManager"; -const spawnChannelEditNew = (connection: ConnectionHandler, channel: ChannelEntry) => { +export const spawnChannelEditNew = (connection: ConnectionHandler, channel: ChannelEntry | undefined, parent: ChannelEntry | undefined, callback: (properties?: ChannelProperties, permissions?: PermissionValue[]) => void) => { + const controller = new ChannelEditController(connection, channel); + const modal = spawnReactModal(ChannelEditModal, controller.uiEvents, typeof channel === "number"); + modal.show().then(undefined); + + + modal.events.on("destroy", () => { + controller.destroy(); + }); }; class ChannelEditController { + readonly uiEvents: Registry; + + private readonly listenerPermissions: (() => void)[]; + private readonly connection: ConnectionHandler; - private readonly channel: ChannelEntry; + private readonly channel: ChannelEntry | undefined; - constructor() { - this.getChannelProperty("sortingOrder"); - } + private readonly originalProperties: ChannelProperties; + private currentProperties: ChannelProperties; - getChannelProperty(property: T) : ChannelEditableProperty[T] { + constructor(connection: ConnectionHandler, channel: ChannelEntry | undefined) { + this.connection = connection; + this.channel = channel; + this.uiEvents = new Registry(); - const properties = this.channel.properties; + this.uiEvents.on("query_property", event => { + if (typeof ChannelPropertyProviders[event.property] !== "object") { + logError(LogCategory.CHANNEL, tr("Channel edit controller missing property provider %s."), event.property); + return; + } - /* - switch (property) { - case "name": - return properties.channel_name; + ChannelPropertyProviders[event.property as any].provider(this.currentProperties, this.channel, this.channel?.parent_channel(), this.connection.channelTree).then(value => { + this.uiEvents.fire_react("notify_property", { + property: event.property, + value: value + }); + }).catch(error => { + logError(LogCategory.CHANNEL, tr("Failed to get property value for %s: %o"), event.property, error); + }); + }); - case "phoneticName": - return properties.channel_name_phonetic; - - case "topic": - return properties.channel_topic; - - case "description": - return properties.channel_description; - - case "password": - break; + this.uiEvents.on("query_property_permission", event => this.notifyPropertyPermission(event.permission)); + this.listenerPermissions = []; + for(const key of Object.keys(ChannelPropertyPermissionsProviders)) { + const provider = ChannelPropertyPermissionsProviders[key]; + this.listenerPermissions.push( + ...provider.registerUpdates(() => this.notifyPropertyPermission(key as any), this.connection.permissions, this.channel, this.connection.channelTree) + ); } - */ - return undefined; + + if(channel) { + this.originalProperties = channel.properties; + } else { + this.originalProperties = new ChannelProperties(); + } + + /* FIXME: Correctly setup the currentProperties! */ + this.currentProperties = new ChannelProperties(); } -} \ No newline at end of file + + destroy() { + this.listenerPermissions.forEach(callback => callback()); + this.listenerPermissions.splice(0, this.listenerPermissions.length); + + this.uiEvents.destroy(); + } + + private notifyPropertyPermission(permission: keyof ChannelPropertyPermission) { + if (typeof ChannelPropertyPermissionsProviders[permission] !== "object") { + logError(LogCategory.CHANNEL, tr("Channel edit controller missing property permission provider %s."), permission); + return; + } + + const value = ChannelPropertyPermissionsProviders[permission].provider(this.connection.permissions, this.channel, this.connection.channelTree); + this.uiEvents.fire_react("notify_property_permission", { + permission: permission, + value: value + }); + } +} diff --git a/shared/js/ui/modal/channel-edit/ControllerPermissions.ts b/shared/js/ui/modal/channel-edit/ControllerPermissions.ts new file mode 100644 index 00000000..07624d08 --- /dev/null +++ b/shared/js/ui/modal/channel-edit/ControllerPermissions.ts @@ -0,0 +1,92 @@ +import {ChannelPropertyPermission} from "tc-shared/ui/modal/channel-edit/Definitions"; +import {PermissionManager} from "tc-shared/permission/PermissionManager"; +import {ChannelEntry} from "tc-shared/tree/Channel"; +import {ChannelTree} from "tc-shared/tree/ChannelTree"; +import PermissionType from "tc-shared/permission/PermissionType"; + +export type ChannelPropertyPermissionsProvider = { + provider: (permissions: PermissionManager, channel: ChannelEntry | undefined, channelTree: ChannelTree) => ChannelPropertyPermission[T], + registerUpdates: (callback: () => void, permissions: PermissionManager, channel: ChannelEntry | undefined, channelTree: ChannelTree) => (() => void)[], +}; + +export const ChannelPropertyPermissionsProviders: {[T in keyof ChannelPropertyPermission]?: ChannelPropertyPermissionsProvider} = {}; + +const SimplePermissionProvider = (createPermission: PermissionType, editPermission: PermissionType) => { + return { + provider: (permissions, channel) => { + return permissions.neededPermission(channel ? editPermission : createPermission).granted(1); + }, + registerUpdates: (callback, permissions, channel) => [ + permissions.register_needed_permission(channel ? editPermission : createPermission, callback) + ] + }; +} + +ChannelPropertyPermissionsProviders["name"] = { + provider: (permissions, channel) => { + return channel ? permissions.neededPermission(PermissionType.B_CHANNEL_MODIFY_NAME).granted(1) : true; + }, + registerUpdates: (callback, permissions) => [ + permissions.register_needed_permission(PermissionType.B_CHANNEL_MODIFY_NAME, callback) + ] +} + +ChannelPropertyPermissionsProviders["sortingOrder"] = SimplePermissionProvider(PermissionType.B_CHANNEL_CREATE_WITH_SORTORDER, PermissionType.B_CHANNEL_MODIFY_SORTORDER); +ChannelPropertyPermissionsProviders["description"] = SimplePermissionProvider(PermissionType.B_CHANNEL_CREATE_WITH_DESCRIPTION, PermissionType.B_CHANNEL_MODIFY_DESCRIPTION); +ChannelPropertyPermissionsProviders["topic"] = SimplePermissionProvider(PermissionType.B_CHANNEL_CREATE_WITH_TOPIC, PermissionType.B_CHANNEL_MODIFY_TOPIC); +ChannelPropertyPermissionsProviders["maxUsers"] = SimplePermissionProvider(PermissionType.B_CHANNEL_CREATE_WITH_MAXCLIENTS, PermissionType.B_CHANNEL_MODIFY_MAXCLIENTS); +ChannelPropertyPermissionsProviders["maxFamilyUsers"] = SimplePermissionProvider(PermissionType.B_CHANNEL_CREATE_WITH_MAXFAMILYCLIENTS, PermissionType.B_CHANNEL_MODIFY_MAXFAMILYCLIENTS); +ChannelPropertyPermissionsProviders["talkPower"] = SimplePermissionProvider(PermissionType.B_CHANNEL_CREATE_WITH_NEEDED_TALK_POWER, PermissionType.B_CHANNEL_MODIFY_NEEDED_TALK_POWER); +ChannelPropertyPermissionsProviders["encryptVoiceData"] = SimplePermissionProvider(PermissionType.B_CHANNEL_MODIFY_MAKE_CODEC_ENCRYPTED, PermissionType.B_CHANNEL_MODIFY_MAKE_CODEC_ENCRYPTED); +ChannelPropertyPermissionsProviders["password"] = { + provider: (permissions, channel) => { + return { + editable: permissions.neededPermission(channel ? PermissionType.B_CHANNEL_MODIFY_PASSWORD : PermissionType.B_CHANNEL_CREATE_WITH_PASSWORD).granted(1), + enforced: permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_MODIFY_WITH_FORCE_PASSWORD).granted(1), + }; + }, + registerUpdates: (callback, permissions, channel) => [ + permissions.register_needed_permission(channel ? PermissionType.B_CHANNEL_MODIFY_PASSWORD : PermissionType.B_CHANNEL_CREATE_WITH_PASSWORD, callback), + permissions.register_needed_permission(PermissionType.B_CHANNEL_CREATE_MODIFY_WITH_FORCE_PASSWORD, callback) + ] +}; +ChannelPropertyPermissionsProviders["channelType"] = { + provider: (permissions, channel) => { + return { + permanent: permissions.neededPermission(channel ? PermissionType.B_CHANNEL_MODIFY_MAKE_PERMANENT : PermissionType.B_CHANNEL_CREATE_PERMANENT).granted(1), + semiPermanent: permissions.neededPermission(channel ? PermissionType.B_CHANNEL_MODIFY_MAKE_SEMI_PERMANENT : PermissionType.B_CHANNEL_CREATE_SEMI_PERMANENT).granted(1), + temporary: permissions.neededPermission(channel ? PermissionType.B_CHANNEL_MODIFY_MAKE_TEMPORARY : PermissionType.B_CHANNEL_CREATE_TEMPORARY).granted(1), + default: permissions.neededPermission(channel ? PermissionType.B_CHANNEL_MODIFY_MAKE_DEFAULT : PermissionType.B_CHANNEL_CREATE_WITH_DEFAULT).granted(1), + }; + }, + registerUpdates: (callback, permissions, channel) => [ + permissions.register_needed_permission(channel ? PermissionType.B_CHANNEL_MODIFY_MAKE_PERMANENT : PermissionType.B_CHANNEL_CREATE_PERMANENT, callback), + permissions.register_needed_permission(channel ? PermissionType.B_CHANNEL_MODIFY_MAKE_SEMI_PERMANENT : PermissionType.B_CHANNEL_CREATE_SEMI_PERMANENT, callback), + permissions.register_needed_permission(channel ? PermissionType.B_CHANNEL_MODIFY_MAKE_TEMPORARY : PermissionType.B_CHANNEL_CREATE_TEMPORARY, callback), + permissions.register_needed_permission(channel ? PermissionType.B_CHANNEL_MODIFY_MAKE_DEFAULT : PermissionType.B_CHANNEL_CREATE_WITH_DEFAULT, callback), + ] +}; +ChannelPropertyPermissionsProviders["codec"] = { + provider: (permissions) => { + return { + opusMusic: permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_MODIFY_WITH_CODEC_OPUSMUSIC).granted(1), + opusVoice: permissions.neededPermission(PermissionType.B_CHANNEL_CREATE_MODIFY_WITH_CODEC_OPUSVOICE).granted(1), + }; + }, + registerUpdates: (callback, permissions) => [ + permissions.register_needed_permission(PermissionType.B_CHANNEL_CREATE_MODIFY_WITH_CODEC_OPUSMUSIC, callback), + permissions.register_needed_permission(PermissionType.B_CHANNEL_CREATE_MODIFY_WITH_CODEC_OPUSVOICE, callback), + ] +}; +ChannelPropertyPermissionsProviders["deleteDelay"] = { + provider: permissions => { + return { + editable: permissions.neededPermission(PermissionType.B_CHANNEL_MODIFY_TEMP_DELETE_DELAY).granted(1), + maxDelay: permissions.neededPermission(PermissionType.I_CHANNEL_CREATE_MODIFY_WITH_TEMP_DELETE_DELAY).valueNormalOr(-1) + } + }, + registerUpdates: (callback, permissions) => [ + permissions.register_needed_permission(PermissionType.B_CHANNEL_MODIFY_TEMP_DELETE_DELAY, callback), + permissions.register_needed_permission(PermissionType.I_CHANNEL_CREATE_MODIFY_WITH_TEMP_DELETE_DELAY, callback), + ] +}; \ No newline at end of file diff --git a/shared/js/ui/modal/channel-edit/ControllerProperties.ts b/shared/js/ui/modal/channel-edit/ControllerProperties.ts new file mode 100644 index 00000000..bda13038 --- /dev/null +++ b/shared/js/ui/modal/channel-edit/ControllerProperties.ts @@ -0,0 +1,151 @@ +import {ChannelEntry, ChannelProperties} from "tc-shared/tree/Channel"; +import {ChannelEditableProperty} from "tc-shared/ui/modal/channel-edit/Definitions"; +import {ChannelTree} from "tc-shared/tree/ChannelTree"; + +export type ChannelPropertyProvider = { + provider: (properties: ChannelProperties, channel: ChannelEntry | undefined, parentChannel: ChannelEntry | undefined, channelTree: ChannelTree) => Promise, + applier: (value: ChannelEditableProperty[T], properties: Partial, channel: ChannelEntry | undefined) => void +}; + +export const ChannelPropertyProviders: {[T in keyof ChannelEditableProperty]?: ChannelPropertyProvider} = {}; + +const SimplePropertyProvider =

(channelProperty: P, defaultValue: ChannelProperties[P]) => { + return { + provider: async properties => typeof properties[channelProperty] === "undefined" ? defaultValue : properties[channelProperty], + applier: (value, properties) => properties[channelProperty] = value + }; +} + +ChannelPropertyProviders["name"] = SimplePropertyProvider("channel_name", ""); +ChannelPropertyProviders["phoneticName"] = SimplePropertyProvider("channel_name_phonetic", ""); +ChannelPropertyProviders["type"] = { + provider: async properties => { + if(properties.channel_flag_default) { + return "default"; + } else if(properties.channel_flag_permanent) { + return "permanent"; + } else if(properties.channel_flag_semi_permanent) { + return "semi-permanent"; + } else { + return "temporary"; + } + }, + applier: (value, properties) => { + properties["channel_flag_default"] = false; + properties["channel_flag_permanent"] = false; + properties["channel_flag_semi_permanent"] = false; + + switch (value) { + case "default": + properties["channel_flag_permanent"] = true; + properties["channel_flag_default"] = true; + break; + + case "permanent": + properties["channel_flag_permanent"] = true; + break; + + case "semi-permanent": + properties["channel_flag_semi_permanent"] = true; + break; + + case "temporary": + /* Nothing to do, default state */ + } + } +} +ChannelPropertyProviders["password"] = { + provider: async properties => properties.channel_flag_password ? { state: "set" } : { state: "clear" }, + applier: (value, properties) => { + if(value.state === "set") { + properties.channel_flag_password = true; + /* FIXME: Hash the password! */ + properties.channel_password = value.password; + } else { + properties.channel_flag_password = false; + } + } +} +ChannelPropertyProviders["sortingOrder"] = { + provider: async (properties, channel, parentChannel, channelTree) => { + const availableChannels: { channelName: string, channelId: number }[] = []; + + let channelSibling: ChannelEntry = parentChannel ? parentChannel.child_channel_head : channelTree.get_first_channel(); + while(channelSibling) { + availableChannels.push({ channelId: channelSibling.channelId, channelName: channelSibling.properties.channel_name }); + channelSibling = channelSibling.channel_next; + } + + return { + previousChannelId: typeof properties.channel_order === "undefined" ? 0 : properties.channel_order, + availableChannels: availableChannels + } + }, + applier: (value, properties) => properties.channel_order = value.previousChannelId +}; +ChannelPropertyProviders["topic"] = SimplePropertyProvider("channel_topic", ""); +ChannelPropertyProviders["description"] = { + provider: async (properties, channel) => { + if(channel) { + return await channel.getChannelDescription(); + } else { + return ""; + } + }, + applier: (value, properties) => properties.channel_description = value +}; +ChannelPropertyProviders["codec"] = { + provider: async properties => { + return { + type: properties.channel_codec, + quality: properties.channel_codec_quality + }; + }, + applier: (value, properties) => { + properties.channel_codec = value.type; + properties.channel_codec_quality = value.quality; + } +} +ChannelPropertyProviders["talkPower"] = SimplePropertyProvider("channel_needed_talk_power", 0); +ChannelPropertyProviders["encryptedVoiceData"] = SimplePropertyProvider("channel_codec_is_unencrypted", true); +ChannelPropertyProviders["maxUsers"] = { + provider: async properties => { + if(properties.channel_flag_maxclients_unlimited) { + return "unlimited"; + } + return properties.channel_maxclients; + }, + applier: (value, properties) => { + if(value === "unlimited") { + properties.channel_flag_maxclients_unlimited = true; + } else { + properties.channel_flag_maxclients_unlimited = false; + properties.channel_maxclients = value; + } + } +} +ChannelPropertyProviders["maxFamilyUsers"] = { + provider: async (properties) => { + if(properties.channel_flag_maxfamilyclients_unlimited) { + return "unlimited"; + } else if(properties.channel_flag_maxfamilyclients_inherited) { + return "inherited"; + } else { + return properties.channel_maxfamilyclients; + } + }, + applier: (value, properties) => { + if(value === "unlimited") { + properties.channel_flag_maxfamilyclients_unlimited = true; + properties.channel_flag_maxfamilyclients_inherited = false; + } else if(value === "inherited") { + properties.channel_flag_maxfamilyclients_unlimited = false; + properties.channel_flag_maxfamilyclients_inherited = true; + } else { + properties.channel_flag_maxfamilyclients_unlimited = false; + properties.channel_flag_maxfamilyclients_inherited = false; + properties.channel_maxfamilyclients = value; + } + } +} +ChannelPropertyProviders["deleteDelay"] = SimplePropertyProvider("channel_delete_delay", 60); \ No newline at end of file diff --git a/shared/js/ui/modal/channel-edit/Definitions.ts b/shared/js/ui/modal/channel-edit/Definitions.ts index 08a1a3fe..e507c53a 100644 --- a/shared/js/ui/modal/channel-edit/Definitions.ts +++ b/shared/js/ui/modal/channel-edit/Definitions.ts @@ -1,19 +1,22 @@ export interface ChannelEditableProperty { "name": string, - "sortingOrder": { previousChannelId: number, availableChannels: { channelName: string, channelId: number }[] | undefined }, - /* "phoneticName": string, - "talkPower": number, + + "type": "default" | "permanent" | "semi-permanent" | "temporary", "password": { state: "set", password?: string } | { state: "clear" }, + "sortingOrder": { previousChannelId: number, availableChannels: { channelName: string, channelId: number }[] | undefined }, + "topic": string, "description": string, - "type": "default" | "permanent" | "semi-permanent" | "temporary", + + "codec": { type: number, quality: number }, + "talkPower": number, + "encryptedVoiceData": number + "maxUsers": "unlimited" | number, "maxFamilyUsers": "unlimited" | "inherited" | number, - "codec": { type: number, quality: number }, + "deleteDelay": number, - "encryptedVoiceData": number - */ } export interface ChannelPropertyPermission { @@ -25,7 +28,7 @@ export interface ChannelPropertyPermission { description: boolean, channelType: { permanent: boolean, - semipermanent: boolean, + semiPermanent: boolean, temporary: boolean, default: boolean }, @@ -37,7 +40,7 @@ export interface ChannelPropertyPermission { }, deleteDelay: { editable: boolean, - maxDelay: number, + maxDelay: number | -1, }, encryptVoiceData: boolean } @@ -47,6 +50,17 @@ export interface ChannelPropertyStatus { password: boolean } +export type ChannelEditPropertyEvent = { + property: T, + value: ChannelEditableProperty[T] +} + + +export type ChannelEditPermissionEvent = { + permission: T, + value: ChannelPropertyPermission[T] +} + export interface ChannelEditEvents { change_property: { property: keyof ChannelEditableProperty @@ -60,12 +74,6 @@ export interface ChannelEditEvents { permission: keyof ChannelPropertyPermission } - notify_property: { - property: keyof ChannelEditableProperty - value: ChannelEditableProperty[keyof ChannelEditableProperty] - }, - notify_property_permission: { - permission: keyof ChannelPropertyPermission - value: ChannelPropertyPermission[keyof ChannelPropertyPermission] - } + notify_property: ChannelEditPropertyEvent, + notify_property_permission: ChannelEditPermissionEvent } \ No newline at end of file diff --git a/shared/js/ui/modal/channel-edit/Renderer.tsx b/shared/js/ui/modal/channel-edit/Renderer.tsx index c5eb31a3..617af70f 100644 --- a/shared/js/ui/modal/channel-edit/Renderer.tsx +++ b/shared/js/ui/modal/channel-edit/Renderer.tsx @@ -2,25 +2,34 @@ import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controll import * as React from "react"; import {Translatable} from "tc-shared/ui/react-elements/i18n"; import {Registry} from "tc-shared/events"; -import {ChannelEditableProperty, ChannelEditEvents} from "tc-shared/ui/modal/channel-edit/Definitions"; +import { + ChannelEditableProperty, + ChannelEditEvents, + ChannelPropertyPermission +} from "tc-shared/ui/modal/channel-edit/Definitions"; import {useContext, useState} from "react"; import {BoxedInputField} from "tc-shared/ui/react-elements/InputField"; const cssStyle = require("./Renderer.scss"); +const ModalTypeContext = React.createContext<"channel-edit" | "channel-create">("channel-edit"); const EventContext = React.createContext>(undefined); -const ChangesApplying = React.createContext(false); -const kPropertyLoading = "loading"; +type ChannelPropertyState = { + setPropertyValue: (value: ChannelEditableProperty[T]) => void +} & ({ + propertyState: "loading", + propertyValue: undefined, +} | { + propertyState: "normal" | "applying", + propertyValue: ChannelEditableProperty[T], +}) -function useProperty(property: T) : { - originalValue: ChannelEditableProperty[T], - currentValue: ChannelEditableProperty[T], - setCurrentValue: (value: ChannelEditableProperty[T]) => void -} | typeof kPropertyLoading { +const kPropertyLoading = "____loading_____"; +function useProperty(property: T) : ChannelPropertyState { const events = useContext(EventContext); - const [ value, setValue ] = useState(() => { + const [ value, setValue ] = useState(() => { events.fire("query_property", { property: property }); return kPropertyLoading; }); @@ -33,21 +42,51 @@ function useProperty(property: T) : { setValue(event.value as any); }, undefined, []); - return kPropertyLoading; + if(value === kPropertyLoading) { + return { + propertyState: "loading", + propertyValue: undefined, + setPropertyValue: _value => {} + }; + } else { + return { + propertyState: "normal", + propertyValue: value, + setPropertyValue: setValue as any + }; + } +} + +function usePermission(permission: T, defaultValue: ChannelPropertyPermission[T]) : ChannelPropertyPermission[T] { + const events = useContext(EventContext); + const [ value, setValue ] = useState(() => { + events.fire("query_property_permission", { permission: permission }); + return defaultValue; + }); + + events.reactUse("notify_property_permission", event => event.permission === permission && setValue(event.value as any)); + + return value; } const ChannelName = () => { - const changesApplying = useContext(ChangesApplying); - const property = useProperty("name"); + const modalType = useContext(ModalTypeContext); + const { propertyValue, propertyState, setPropertyValue } = useProperty("name"); + const editable = usePermission("name", modalType === "channel-create"); + const [ edited, setEdited ] = useState(false); return ( property !== kPropertyLoading && property.setCurrentValue(newValue)} + disabled={!editable || propertyState !== "normal"} + value={propertyValue} + placeholder={propertyState === "normal" ? tr("Channel name") : tr("loading")} + onInput={value => { + setPropertyValue(value); + setEdited(true); + }} + isInvalid={edited && (typeof propertyValue !== "string" || !propertyValue || propertyValue.length > 30)} /> - ) + ); } const GeneralContainer = () => { @@ -59,12 +98,23 @@ const GeneralContainer = () => { } export class ChannelEditModal extends InternalModal { - private readonly channelExists: number; + private readonly events: Registry; + private readonly isChannelCreate: boolean; + + constructor(events: Registry, isChannelCreate: boolean) { + super(); + this.events = events; + this.isChannelCreate = isChannelCreate; + } renderBody(): React.ReactElement { - return (<> - - ); + return ( + + + + + + ); } title(): string | React.ReactElement {