import { ChatEvent, ChatEventMessage, ChatMessage, ChatState, ConversationHistoryResponse } from "../ui/frames/side/AbstractConversationDefinitions"; import {Registry} from "tc-shared/events"; import {ConnectionHandler} from "tc-shared/ConnectionHandler"; import {preprocessChatMessageForSend} from "tc-shared/text/chat"; import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration"; import {ErrorCode} from "tc-shared/connection/ErrorCode"; import {LogCategory, logWarn} from "tc-shared/log"; import {ChannelConversationMode} from "tc-shared/tree/Channel"; import {guid} from "tc-shared/crypto/uid"; export const kMaxChatFrameMessageSize = 50; /* max 100 messages, since the server does not support more than 100 messages queried at once */ export interface AbstractConversationEvents { 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 }, notify_conversation_mode_changed: { newMode: ChannelConversationMode }, notify_read_state_changed: { readable: 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; private conversationMode: ChannelConversationMode; protected unreadTimestamp: number; protected unreadState: boolean = false; protected messageSendEnabled: boolean = true; private conversationReadable = true; private history = false; protected constructor(connection: ConnectionHandler, chatId: string) { this.events = new Registry(); this.connection = connection; this.chatId = chatId; this.unreadTimestamp = Date.now(); this.conversationMode = ChannelConversationMode.Public; } 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 if(!this.isUnread()) { /* 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 if(!this.isUnread()) { /* 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 getConversationMode() : ChannelConversationMode { return this.conversationMode; } public isPrivate() : boolean { return this.conversationMode === ChannelConversationMode.Private; } protected setConversationMode(mode: ChannelConversationMode, logChange: boolean) { if(this.conversationMode === mode) { return; } if(logChange) { this.registerChatEvent({ type: "mode-changed", uniqueId: guid() + "-mode-change", timestamp: Date.now(), newMode: mode === ChannelConversationMode.Public ? "normal" : mode === ChannelConversationMode.Private ? "private" : "none" }, true); } this.conversationMode = mode; this.events.fire("notify_conversation_mode_changed", { newMode: mode }); } public isReadable() { return this.conversationReadable; } protected setReadable(flag: boolean) { if(this.conversationReadable === flag) { return; } this.conversationReadable = flag; this.events.fire("notify_read_state_changed", { readable: flag }); } public isSendEnabled() : boolean { 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 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 }, notify_cross_conversation_support_changed: { crossConversationSupported: boolean } } export abstract class AbstractChatManager, ConversationType extends AbstractChat, ConversationEvents extends AbstractConversationEvents> { 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; private crossConversationSupport: boolean; /* FIXME: Access modifier */ public historyUiStates: {[id: string]: { executingUIHistoryQuery: boolean, historyErrorMessage: string | undefined, historyRetryTimestamp: number }} = {}; protected constructor(connection: ConnectionHandler) { this.connection = connection; this.events = new Registry(); this.listenerConnection = []; this.currentUnreadCount = 0; this.crossConversationSupport = true; 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; } hasCrossConversationSupport() : boolean { return this.crossConversationSupport; } 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); } delete this.historyUiStates[conversation.getChatId()]; conversation.events.off("notify_unread_state_changed", this.listenerUnreadTimestamp); delete this.conversations[conversation.getChatId()]; this.events.fire("notify_conversation_destroyed", { conversation: conversation }); this.listenerUnreadTimestamp(); } protected setCrossConversationSupport(supported: boolean) { if(this.crossConversationSupport === supported) { return; } this.crossConversationSupport = supported; this.events.fire("notify_cross_conversation_support_changed", { crossConversationSupported: supported }); } }