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} 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"; const kSuccessQueryThrottle = 5 * 1000; const kErrorQueryThrottle = 30 * 1000; export class Conversation extends AbstractChat { private readonly handle: ConversationManager; public readonly conversationId: number; private conversationVolatile: boolean = false; private executingHistoryQueries = false; private pendingHistoryQueries: (() => Promise)[] = []; public historyQueryResponse: ChatMessage[] = []; constructor(handle: ConversationManager, events: Registry, id: number) { super(handle.connection, id.toString(), events); this.handle = handle; this.conversationId = id; this.lastReadMessage = handle.connection.settings.server(Settings.FN_CHANNEL_CHAT_READ(id), Date.now()); } destroy() { } queryHistory(criteria: { begin?: number, end?: number, limit?: number }) : Promise { return new Promise(resolve => { this.pendingHistoryQueries.push(() => { this.historyQueryResponse = []; const requestObject = { cid: this.conversationId } as any; if(typeof criteria.begin === "number") requestObject.timestamp_begin = criteria.begin; if(typeof criteria.end === "number") requestObject.timestamp_end = criteria.end; if(typeof criteria.limit === "number") requestObject.message_count = criteria.limit; return this.handle.connection.serverConnection.send_command("conversationhistory", requestObject, { flagset: [ "merge" ], process_result: false }).then(() => { resolve({ status: "success", events: this.historyQueryResponse.map(e => { return { type: "message", message: e, timestamp: e.timestamp, uniqueId: "cm-" + this.conversationId + "-" + e.timestamp + "-" + Date.now(), isOwnMessage: false } }), moreEvents: false, nextAllowedQuery: Date.now() + kSuccessQueryThrottle }); }).catch(error => { let errorMessage; if(error instanceof CommandResult) { if(error.id === ErrorCode.CONVERSATION_MORE_DATA || error.id === ErrorCode.DATABASE_EMPTY_RESULT) { resolve({ status: "success", events: this.historyQueryResponse.map(e => { return { type: "message", message: e, timestamp: e.timestamp, uniqueId: "cm-" + this.conversationId + "-" + e.timestamp + "-" + Date.now(), isOwnMessage: false } }), moreEvents: error.id === ErrorCode.CONVERSATION_MORE_DATA, nextAllowedQuery: Date.now() + kSuccessQueryThrottle }); return; } else if(error.id === ErrorCode.PERMISSION_ERROR) { resolve({ status: "no-permission", failedPermission: this.handle.connection.permissions.resolveInfo(parseInt(error.json["failed_permid"]))?.name || tr("unknwon"), nextAllowedQuery: Date.now() + kErrorQueryThrottle }); return; } else if(error.id === ErrorCode.CONVERSATION_IS_PRIVATE) { resolve({ status: "private", nextAllowedQuery: Date.now() + kErrorQueryThrottle }); return; } else if(error.id === ErrorCode.COMMAND_NOT_FOUND) { resolve({ status: "unsupported", nextAllowedQuery: Date.now() + kErrorQueryThrottle }); return; } else { errorMessage = error.formattedMessage(); } } else { log.error(LogCategory.CHAT, tr("Failed to fetch conversation history. %o"), error); errorMessage = tr("lookup the console"); } resolve({ status: "error", errorMessage: errorMessage, nextAllowedQuery: Date.now() + 5 * 1000 }); }); }); this.executeHistoryQuery(); }); } queryCurrentMessages() { this.presentMessages = []; this.mode = "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.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({ type: "query-failed", timestamp: Date.now(), uniqueId: "qf-" + this.conversationId + "-" + Date.now() + "-" + Math.random(), message: history.errorMessage }); break; case "no-permission": this.mode = "no-permissions"; this.failedPermission = history.failedPermission; break; case "private": this.conversationPrivate = true; this.mode = "normal"; break; case "success": this.mode = "normal"; break; case "unsupported": this.crossChannelChatSupported = false; this.conversationPrivate = true; this.mode = "normal"; break; } /* only update the UI if needed */ if(this.handle.selectedConversation() === this.conversationId) this.reportStateToUI(); }); } protected canClientAccessChat() { return this.conversationId === 0 || this.handle.connection.getClient().currentChannel()?.channelId === this.conversationId; } private executeHistoryQuery() { if(this.executingHistoryQueries || this.pendingHistoryQueries.length === 0) return; this.executingHistoryQueries = true; 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)) .then(() => { this.executingHistoryQueries = false; this.executeHistoryQuery(); }); } catch (e) { this.executingHistoryQueries = false; throw e; } } public updateIndexFromServer(info: any) { if('error_id' in info) { /* TODO: Parse error, may be flag private or similar */ return; } const timestamp = parseInt(info["timestamp"]); if(isNaN(timestamp)) return; if(timestamp > this.lastReadMessage) this.setUnreadTimestamp(this.lastReadMessage); } public handleIncomingMessage(message: ChatMessage, isOwnMessage: boolean) { this.registerIncomingMessage(message, isOwnMessage, "m-" + this.conversationId + "-" + message.timestamp + "-" + Math.random()); } 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; if(message.message.sender_database_id !== criteria.cldbid) return true; if(criteria.end != 0 && message.timestamp > criteria.end) return true; if(criteria.begin != 0 && message.timestamp < criteria.begin) return true; return --limit.current < 0; }); this.events.fire("notify_chat_message_delete", { chatId: this.conversationId.toString(), criteria: criteria }); } 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); return; } if(message.type !== "message") return; this.handle.connection.serverConnection.send_command("conversationmessagedelete", { cid: this.conversationId, timestamp_begin: message.timestamp - 1, timestamp_end: message.timestamp + 1, 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) 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) { 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); } public localClientSwitchedChannel(type: "join" | "leave") { this.presentEvents.push({ type: "local-user-switch", uniqueId: "us-" + this.conversationId + "-" + Date.now() + "-" + Math.random(), timestamp: Date.now(), mode: type }); if(this.conversationId === this.handle.selectedConversation()) this.reportStateToUI(); } sendMessage(text: string) { this.doSendMessage(text, this.conversationId ? 2 : 3, this.conversationId); } } export class ConversationManager extends AbstractChatManager { readonly connection: ConnectionHandler; readonly htmlTag: HTMLDivElement; private conversations: {[key: number]: Conversation} = {}; private selectedConversation_: number; constructor(connection: ConnectionHandler) { super(); 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.getClient().events.on("notify_client_moved", event => { 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.uiEvents.on("notify_destroy", 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 => { /* TODO private flag! */ })); } destroy() { ReactDOM.unmountComponentAtNode(this.htmlTag); this.htmlTag.remove(); this.uiEvents.unregister_handler(this); this.uiEvents.fire("notify_destroy"); this.uiEvents.destroy(); } selectedConversation() { return this.selectedConversation_; } 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); if(!conversation) { conversation = new Conversation(this, this.uiEvents, id); this.conversations[id] = conversation; } return conversation; } destroyConversation(id: number) { delete this.conversations[id]; if(id === this.selectedConversation_) this.uiEvents.fire("action_select_chat", { chatId: "unselected" }); } queryUnreadFlags() { /* FIXME: Test for old TeaSpeak servers which don't support this */ return; 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); }); } 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"]); return; } for(const entry of command.arguments) { conversation.historyQueryResponse.push({ timestamp: parseInt(entry["timestamp"]), sender_database_id: parseInt(entry["sender_database_id"]), sender_unique_id: entry["sender_unique_id"], sender_name: entry["sender_name"], message: entry["msg"] }); } } private handleConversationIndex(command: ServerCommand) { for(const entry of command.arguments) { const conversation = this.findOrCreateConversation(parseInt(entry["cid"])); conversation.updateIndexFromServer(entry); } } private handleConversationMessageDelete(command: ServerCommand) { const data = command.arguments[0]; const conversation = this.findConversation(parseInt(data["cid"])); if(!conversation) return; conversation.handleDeleteMessages({ limit: parseInt(data["limit"]), begin: parseInt(data["timestamp_begin"]), end: parseInt(data["timestamp_end"]), cldbid: parseInt(data["cldbid"]) }) } @EventHandler("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(event: ConversationUIEvents["query_selected_chat"]) { this.uiEvents.fire_async("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); } }