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