TeaWeb/shared/js/conversations/AbstractConversion.ts

455 lines
15 KiB
TypeScript

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<Events extends AbstractConversationEvents> {
readonly events: Registry<Events>;
protected readonly connection: ConnectionHandler;
protected readonly chatId: string;
protected presentMessages: ChatEvent[] = [];
protected presentEvents: Exclude<ChatEvent, ChatEventMessage>[] = []; /* 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<Events>();
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<boolean> {
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<ConversationHistoryResponse>;
public abstract queryCurrentMessages();
public abstract sendMessage(text: string);
}
export interface AbstractChatManagerEvents<ConversationType> {
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<ManagerEvents extends AbstractChatManagerEvents<ConversationType>, ConversationType extends AbstractChat<ConversationEvents>, ConversationEvents extends AbstractConversationEvents> {
readonly events: Registry<ManagerEvents>;
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<ManagerEvents>();
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 });
}
}