491 lines
20 KiB
TypeScript
491 lines
20 KiB
TypeScript
import {
|
|
AbstractChat,
|
|
AbstractConversationEvents,
|
|
AbstractChatManager,
|
|
AbstractChatManagerEvents,
|
|
kMaxChatFrameMessageSize
|
|
} from "./AbstractConversion";
|
|
import {ChatMessage, ConversationHistoryResponse} from "../ui/frames/side/AbstractConversationDefinitions";
|
|
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 {trJQuery} from "tc-shared/i18n/localize";
|
|
import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler";
|
|
import {LocalClientEntry} from "tc-shared/tree/Client";
|
|
import {ServerCommand} from "tc-shared/connection/ConnectionBase";
|
|
import {ChannelConversationMode} from "tc-shared/tree/Channel";
|
|
import {ServerFeature} from "tc-shared/connection/ServerFeatures";
|
|
|
|
export interface ChannelConversationEvents extends AbstractConversationEvents {
|
|
notify_messages_deleted: { messages: string[] },
|
|
notify_messages_loaded: {}
|
|
}
|
|
|
|
const kSuccessQueryThrottle = 5 * 1000;
|
|
const kErrorQueryThrottle = 30 * 1000;
|
|
export class ChannelConversation extends AbstractChat<ChannelConversationEvents> {
|
|
private readonly handle: ChannelConversationManager;
|
|
public readonly conversationId: number;
|
|
|
|
private conversationVolatile: boolean = false;
|
|
private preventUnreadUpdate = false;
|
|
|
|
private executingHistoryQueries = false;
|
|
private pendingHistoryQueries: (() => Promise<any>)[] = [];
|
|
public historyQueryResponse: ChatMessage[] = [];
|
|
|
|
constructor(handle: ChannelConversationManager, id: number) {
|
|
super(handle.connection, id.toString());
|
|
this.handle = handle;
|
|
this.conversationId = id;
|
|
|
|
this.preventUnreadUpdate = true;
|
|
const unreadTimestamp = handle.connection.settings.getValue(Settings.FN_CHANNEL_CHAT_READ(id), Date.now());
|
|
this.setUnreadTimestamp(unreadTimestamp);
|
|
this.preventUnreadUpdate = false;
|
|
|
|
this.events.on(["notify_unread_state_changed", "notify_read_state_changed"], () => {
|
|
this.handle.connection.channelTree.findChannel(this.conversationId)?.setUnread(this.isReadable() && this.isUnread());
|
|
});
|
|
}
|
|
|
|
destroy() {
|
|
super.destroy();
|
|
}
|
|
|
|
queryHistory(criteria: { begin?: number, end?: number, limit?: number }) : Promise<ConversationHistoryResponse> {
|
|
return new Promise<ConversationHistoryResponse>(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 {
|
|
logError(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.setCurrentMode("loading");
|
|
|
|
this.queryHistory({ end: 1, limit: kMaxChatFrameMessageSize }).then(history => {
|
|
this.conversationVolatile = false;
|
|
this.failedPermission = undefined;
|
|
this.errorMessage = undefined;
|
|
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.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.setCurrentMode("no-permissions");
|
|
this.failedPermission = history.failedPermission;
|
|
break;
|
|
|
|
case "private":
|
|
this.setConversationMode(ChannelConversationMode.Private, true);
|
|
this.setCurrentMode("normal");
|
|
break;
|
|
|
|
case "success":
|
|
this.setConversationMode(ChannelConversationMode.Public, true);
|
|
this.setCurrentMode("normal");
|
|
break;
|
|
|
|
case "unsupported":
|
|
this.setConversationMode(ChannelConversationMode.Private, false);
|
|
this.setCurrentMode("normal");
|
|
break;
|
|
}
|
|
|
|
this.events.fire("notify_messages_loaded");
|
|
});
|
|
}
|
|
|
|
/* TODO: Query this state and if changed notify state */
|
|
public 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 => 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;
|
|
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(this.unreadTimestamp < timestamp) {
|
|
this.registerChatEvent({
|
|
type: "unread-trigger",
|
|
timestamp: timestamp,
|
|
uniqueId: "unread-trigger-" + Date.now() + " - " + timestamp
|
|
}, true);
|
|
}
|
|
}
|
|
|
|
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 };
|
|
|
|
const deletedMessages = this.presentMessages.filter(message => {
|
|
if(message.type !== "message") {
|
|
return false;
|
|
}
|
|
|
|
if(message.message.sender_database_id !== criteria.cldbid) {
|
|
return false;
|
|
}
|
|
|
|
if(criteria.end != 0 && message.timestamp > criteria.end) {
|
|
return false;
|
|
}
|
|
|
|
if(criteria.begin != 0 && message.timestamp < criteria.begin) {
|
|
return false;
|
|
}
|
|
|
|
/* if the limit is zero it means all messages */
|
|
return --limit.current >= 0;
|
|
});
|
|
|
|
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) {
|
|
logWarn(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 => {
|
|
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"), trJQuery("Failed to delete conversation message{:br:}Error: {}", error)).open();
|
|
});
|
|
}
|
|
|
|
setUnreadTimestamp(timestamp: number) {
|
|
super.setUnreadTimestamp(timestamp);
|
|
|
|
if(this.preventUnreadUpdate) {
|
|
return;
|
|
}
|
|
|
|
this.handle.connection.settings.setValue(Settings.FN_CHANNEL_CHAT_READ(this.conversationId), timestamp);
|
|
}
|
|
|
|
public setConversationMode(mode: ChannelConversationMode, logChange: boolean) {
|
|
super.setConversationMode(mode, logChange);
|
|
this.updateAccessState();
|
|
}
|
|
|
|
public localClientSwitchedChannel(type: "join" | "leave") {
|
|
this.registerChatEvent({
|
|
type: "local-user-switch",
|
|
uniqueId: "us-" + this.conversationId + "-" + Date.now() + "-" + Math.random(),
|
|
timestamp: Date.now(),
|
|
mode: type
|
|
}, false);
|
|
|
|
/* TODO: Update can access state! */
|
|
}
|
|
|
|
sendMessage(text: string) {
|
|
this.doSendMessage(text, this.conversationId ? 2 : 3, this.conversationId).then(() => {});
|
|
}
|
|
|
|
updateAccessState() {
|
|
if(this.isPrivate()) {
|
|
this.setReadable(this.connection.getClient().currentChannel()?.getChannelId() === this.conversationId);
|
|
} else {
|
|
this.setReadable(true);
|
|
}
|
|
}
|
|
}
|
|
|
|
export interface ChannelConversationManagerEvents extends AbstractChatManagerEvents<ChannelConversation> { }
|
|
|
|
export class ChannelConversationManager extends AbstractChatManager<ChannelConversationManagerEvents, ChannelConversation, ChannelConversationEvents> {
|
|
readonly connection: ConnectionHandler;
|
|
|
|
constructor(connection: ConnectionHandler) {
|
|
super(connection);
|
|
this.connection = connection;
|
|
|
|
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.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();
|
|
});
|
|
}
|
|
|
|
if(event.newState === ConnectionState.CONNECTED) {
|
|
connection.serverFeatures.awaitFeatures().then(success => {
|
|
if(!success) { return; }
|
|
|
|
this.setCrossConversationSupport(connection.serverFeatures.supportsFeature(ServerFeature.ADVANCED_CHANNEL_CHAT));
|
|
});
|
|
} else {
|
|
this.setCrossConversationSupport(true);
|
|
}
|
|
}));
|
|
|
|
this.listenerConnection.push(connection.channelTree.events.on("notify_channel_updated", event => {
|
|
const conversation = this.findConversation(event.channel.channelId);
|
|
if(!conversation) {
|
|
return;
|
|
}
|
|
|
|
if("channel_conversation_mode" in event.updatedProperties) {
|
|
conversation.setConversationMode(event.channel.properties.channel_conversation_mode, true);
|
|
conversation.updateAccessState();
|
|
}
|
|
}));
|
|
|
|
this.listenerConnection.push(connection.channelTree.events.on("notify_client_moved", event => {
|
|
if(event.client instanceof LocalClientEntry) {
|
|
const fromConversation = this.findConversation(event.oldChannel?.channelId);
|
|
const targetConversation = this.findConversation(event.newChannel?.channelId);
|
|
|
|
fromConversation?.updateAccessState();
|
|
targetConversation?.updateAccessState();
|
|
}
|
|
}));
|
|
|
|
this.listenerConnection.push(connection.channelTree.events.on("notify_channel_deleted", event => {
|
|
this.destroyConversation(event.channel.getChannelId());
|
|
}));
|
|
|
|
this.listenerConnection.push(connection.channelTree.events.on("notify_client_enter_view", event => {
|
|
if(event.client instanceof LocalClientEntry) {
|
|
const targetConversation = this.findConversation(event.targetChannel.channelId);
|
|
targetConversation?.updateAccessState();
|
|
}
|
|
}));
|
|
|
|
/* TODO: Permission listener for text send power! */
|
|
|
|
this.listenerConnection.push(connection.serverConnection.getCommandHandler().registerCommandHandler("notifyconversationhistory", this.handleConversationHistory.bind(this)));
|
|
this.listenerConnection.push(connection.serverConnection.getCommandHandler().registerCommandHandler("notifyconversationindex", this.handleConversationIndex.bind(this)));
|
|
this.listenerConnection.push(connection.serverConnection.getCommandHandler().registerCommandHandler("notifyconversationmessagedelete", this.handleConversationMessageDelete.bind(this)));
|
|
|
|
this.listenerConnection.push(this.connection.channelTree.events.on("notify_channel_list_received", () => {
|
|
this.queryUnreadFlags();
|
|
}));
|
|
|
|
this.listenerConnection.push(this.connection.channelTree.events.on("notify_channel_updated", () => {
|
|
/* TODO private flag! */
|
|
}));
|
|
}
|
|
|
|
destroy() {
|
|
super.destroy();
|
|
}
|
|
|
|
findConversation(channelId: number) : ChannelConversation {
|
|
return this.findConversationById(channelId?.toString());
|
|
}
|
|
|
|
findOrCreateConversation(channelId: number) {
|
|
let conversation = this.findConversation(channelId);
|
|
if(!conversation) {
|
|
conversation = new ChannelConversation(this, channelId);
|
|
const channel = this.connection.channelTree.findChannel(channelId);
|
|
if(channel) {
|
|
conversation.setConversationMode(channel.properties.channel_conversation_mode, false);
|
|
}
|
|
this.registerConversation(conversation);
|
|
}
|
|
|
|
return conversation;
|
|
}
|
|
|
|
destroyConversation(id: number) {
|
|
const conversation = this.findConversation(id);
|
|
if(!conversation) {
|
|
return;
|
|
}
|
|
|
|
this.unregisterConversation(conversation);
|
|
conversation.destroy();
|
|
}
|
|
|
|
queryUnreadFlags() {
|
|
const commandData = this.connection.channelTree.channels.map(e => { return { cid: e.channelId, cpw: e.getCachedPasswordHash() }});
|
|
this.connection.serverConnection.send_command("conversationfetch", commandData).catch(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) {
|
|
logWarn(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"])
|
|
})
|
|
}
|
|
} |