TeaWeb/shared/js/ui/frames/side/conversations.ts

630 lines
No EOL
25 KiB
TypeScript

import {settings, Settings} from "tc-shared/settings";
import {format} from "tc-shared/ui/frames/side/chat_helper";
import {bbcode_chat, formatMessage} from "tc-shared/ui/frames/chat";
import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration";
import {LogCategory} from "tc-shared/log";
import {PermissionType} from "tc-shared/permission/PermissionType";
import {ChatBox} from "tc-shared/ui/frames/side/chat_box";
import {Frame, FrameContent} from "tc-shared/ui/frames/chat_frame";
import {createErrorModal} from "tc-shared/ui/elements/Modal";
import * as log from "tc-shared/log";
import * as htmltags from "tc-shared/ui/htmltags";
declare function setInterval(handler: TimerHandler, timeout?: number, ...arguments: any[]): number;
declare function setTimeout(handler: TimerHandler, timeout?: number, ...arguments: any[]): number;
export type ViewEntry = {
html_element: JQuery;
update_timer?: number;
}
export type MessageData = {
timestamp: number;
message: string;
sender_name: string;
sender_unique_id: string;
sender_database_id: number;
}
export type Message = MessageData & ViewEntry;
export class Conversation {
readonly handle: ConversationManager;
readonly channel_id: number;
private _flag_private: boolean;
private _html_tag: JQuery;
private _container_messages: JQuery;
private _container_new_message: JQuery;
private _container_no_permissions: JQuery;
private _container_no_permissions_shown: boolean = false;
private _container_is_private: JQuery;
private _container_is_private_shown: boolean = false;
private _container_no_support: JQuery;
private _container_no_support_shown: boolean = false;
private _view_max_messages = 40; /* reset to 40 again as soon we tab out :) */
private _view_older_messages: ViewEntry;
private _has_older_messages: boolean; /* undefined := not known | else flag */
private _view_entries: ViewEntry[] = [];
private _last_messages: MessageData[] = [];
private _last_messages_timestamp: number = 0;
private _first_unread_message: Message;
private _first_unread_message_pointer: ViewEntry;
private _scroll_position: number | undefined; /* undefined to follow bottom | position for special stuff */
constructor(handle: ConversationManager, channel_id: number) {
this.handle = handle;
this.channel_id = channel_id;
this._build_html_tag();
}
html_tag() : JQuery { return this._html_tag; }
destroy() {
this._first_unread_message_pointer.html_element.detach();
this._first_unread_message_pointer = undefined;
this._view_older_messages.html_element.detach();
this._view_older_messages = undefined;
for(const view_entry of this._view_entries) {
view_entry.html_element.detach();
clearTimeout(view_entry.update_timer);
}
this._view_entries = [];
}
private _build_html_tag() {
this._html_tag = $("#tmpl_frame_chat_channel_messages").renderTag();
this._container_new_message = this._html_tag.find(".new-message");
this._container_no_permissions = this._html_tag.find(".no-permissions").hide();
this._container_is_private = this._html_tag.find(".private-conversation").hide();
this._container_no_support = this._html_tag.find(".not-supported").hide();
this._container_messages = this._html_tag.find(".container-messages");
this._container_messages.on('scroll', event => {
const exact_position = this._container_messages[0].scrollTop + this._container_messages[0].clientHeight;
const current_view = exact_position + this._container_messages[0].clientHeight * .125;
if(current_view > this._container_messages[0].scrollHeight) {
this._scroll_position = undefined;
} else {
this._scroll_position = this._container_messages[0].scrollTop;
}
const will_visible = !!this._first_unread_message && this._first_unread_message_pointer.html_element[0].offsetTop > exact_position;
const is_visible = this._container_new_message[0].classList.contains("shown");
if(!is_visible && will_visible)
this._container_new_message[0].classList.add("shown");
if(is_visible && !will_visible)
this._container_new_message[0].classList.remove("shown");
//This causes a Layout recalc (Forced reflow)
//this._container_new_message.toggleClass("shown",!!this._first_unread_message && this._first_unread_message_pointer.html_element[0].offsetTop > exact_position);
});
this._view_older_messages = this._generate_view_spacer(tr("Load older messages"), "old");
this._first_unread_message_pointer = this._generate_view_spacer(tr("Unread messages"), "new");
this._view_older_messages.html_element.appendTo(this._container_messages).on('click', event => {
this.fetch_older_messages();
});
this._container_new_message.on('click', event => {
if(!this._first_unread_message)
return;
this._scroll_position = this._first_unread_message_pointer.html_element[0].offsetTop;
this.fix_scroll(true);
});
this._container_messages.on('click', event => {
if(this._container_new_message.hasClass('shown'))
return; /* we have clicked, but no chance to see the unread message pointer */
this._mark_read();
});
this.set_flag_private(false);
}
is_unread() { return !!this._first_unread_message; }
mark_read() { this._mark_read(); }
private _mark_read() {
if(this._first_unread_message) {
this._first_unread_message = undefined;
const ctree = this.handle.handle.handle.channelTree;
if(ctree && ctree.tag_tree()) {
if(this.channel_id === 0)
ctree.server.setUnread(false);
else
ctree.findChannel(this.channel_id).setUnread(false);
}
}
this._first_unread_message_pointer.html_element.detach();
}
private _generate_view_message(data: MessageData) : Message {
const response = data as Message;
if(response.html_element)
return response;
const timestamp = new Date(data.timestamp);
let time = format.date.format_chat_time(timestamp);
response.html_element = $("#tmpl_frame_chat_channel_message").renderTag({
timestamp: time.result,
client_name: htmltags.generate_client_object({
add_braces: false,
client_name: data.sender_name,
client_unique_id: data.sender_unique_id,
client_id: 0
}),
message: bbcode_chat(data.message),
avatar: this.handle.handle.handle.fileManager.avatars.generate_chat_tag({database_id: data.sender_database_id}, data.sender_unique_id)
});
response.html_element.find(".button-delete").on('click', () => this.delete_message(data));
if(time.next_update > 0) {
const _updater = () => {
time = format.date.format_chat_time(timestamp);
response.html_element.find(".info .timestamp").text(time.result);
if(time.next_update > 0)
response.update_timer = setTimeout(_updater, time.next_update);
else
response.update_timer = 0;
};
response.update_timer = setTimeout(_updater, time.next_update);
} else {
response.update_timer = 0;
}
return response;
}
private _generate_view_spacer(message: string, type: "date" | "new" | "old" | "error") : ViewEntry {
const tag = $("#tmpl_frame_chat_private_spacer").renderTag({
message: message
}).addClass("type-" + type);
return {
html_element: tag,
update_timer: 0
}
}
last_messages_timestamp() : number {
return this._last_messages_timestamp;
}
fetch_last_messages() {
const fetch_count = this._view_max_messages - this._last_messages.length;
const fetch_timestamp_end = this._last_messages_timestamp + 1; /* we want newer messages then the last message we have */
//conversationhistory cid=1 [cpw=xxx] [timestamp_begin] [timestamp_end (0 := no end)] [message_count (default 25| max 100)] [-merge]
this.handle.handle.handle.serverConnection.send_command("conversationhistory", {
cid: this.channel_id,
timestamp_end: fetch_timestamp_end,
message_count: fetch_count
}, {flagset: ["merge"], process_result: false }).catch(error => {
this._view_older_messages.html_element.toggleClass('shown', false);
if(error instanceof CommandResult) {
if(error.id == ErrorID.CONVERSATION_MORE_DATA) {
if(typeof(this._has_older_messages) === "undefined")
this._has_older_messages = true;
this._view_older_messages.html_element.toggleClass('shown', true);
return;
} else if(error.id == ErrorID.PERMISSION_ERROR) {
this._container_no_permissions.show();
this._container_no_permissions_shown = true;
} else if(error.id == ErrorID.CONVERSATION_IS_PRIVATE) {
this.set_flag_private(true);
}
/*
else if(error.id == ErrorID.NOT_IMPLEMENTED || error.id == ErrorID.COMMAND_NOT_FOUND) {
this._container_no_support.show();
this._container_no_support_shown = true;
}
*/
}
//TODO log and handle!
log.error(LogCategory.CHAT, tr("Failed to fetch conversation history. %o"), error);
}).then(() => {
this.fix_scroll(true);
this.handle.update_chat_box();
});
}
fetch_older_messages() {
this._view_older_messages.html_element.toggleClass('shown', false);
const entry = this._view_entries.slice().reverse().find(e => 'timestamp' in e) as any as {timestamp: number};
//conversationhistory cid=1 [cpw=xxx] [timestamp_begin] [timestamp_end (0 := no end)] [message_count (default 25| max 100)] [-merge]
this.handle.handle.handle.serverConnection.send_command("conversationhistory", {
cid: this.channel_id,
timestamp_begin: entry.timestamp - 1,
message_count: this._view_max_messages
}, {flagset: ["merge"]}).catch(error => {
this._view_older_messages.html_element.toggleClass('shown', false);
if(error instanceof CommandResult) {
if(error.id == ErrorID.CONVERSATION_MORE_DATA) {
this._view_older_messages.html_element.toggleClass('shown', true);
this.handle.update_chat_box();
return;
}
}
//TODO log and handle!
log.error(LogCategory.CHAT, tr("Failed to fetch conversation history. %o"), error);
}).then(() => {
this.fix_scroll(true);
});
}
register_new_message(message: MessageData, update_view?: boolean) {
/* lets insert the message at the right index */
let _new_message = false;
{
let spliced = false;
for(let index = 0; index < this._last_messages.length; index++) {
if(this._last_messages[index].timestamp < message.timestamp) {
this._last_messages.splice(index, 0, message);
spliced = true;
_new_message = index == 0; /* only set flag if this has been inserted at the front */
break;
} else if(this._last_messages[index].timestamp == message.timestamp && this._last_messages[index].sender_database_id == message.sender_database_id) {
return; /* we already have that message */
}
}
if(this._last_messages.length === 0)
_new_message = true;
if(!spliced && this._last_messages.length < this._view_max_messages) {
this._last_messages.push(message);
}
this._last_messages_timestamp = this._last_messages[0].timestamp;
while(this._last_messages.length > this._view_max_messages) {
if(this._last_messages[this._last_messages.length - 1] == this._first_unread_message)
break;
this._last_messages.pop();
}
}
/* message is within view */
{
const entry = this._generate_view_message(message);
let previous: ViewEntry;
for(let index = 0; index < this._view_entries.length; index++) {
const current_entry = this._view_entries[index];
if(!('timestamp' in current_entry))
continue;
if((current_entry as Message).timestamp < message.timestamp) {
this._view_entries.splice(index, 0, entry);
previous = current_entry;
break;
}
}
if(!previous)
this._view_entries.push(entry);
if(previous)
entry.html_element.insertAfter(previous.html_element);
else
entry.html_element.insertAfter(this._view_older_messages.html_element); /* last element is already the current element */
if(_new_message && (typeof(this._scroll_position) === "number" || this.handle.current_channel() !== this.channel_id || this.handle.handle.content_type() !== FrameContent.CHANNEL_CHAT)) {
if(typeof(this._first_unread_message) === "undefined")
this._first_unread_message = entry;
this._first_unread_message_pointer.html_element.insertBefore(entry.html_element);
this._container_messages.trigger('scroll'); /* updates the new message stuff */
}
if(typeof(update_view) !== "boolean" || update_view)
this.fix_scroll(true);
}
/* update chat state */
this._container_no_permissions.hide();
this._container_no_permissions_shown = false;
if(update_view) this.handle.update_chat_box();
}
/* using a timeout here to not cause a force style recalculation */
private _scroll_fix_timer: number;
private _scroll_animate: boolean;
fix_scroll(animate: boolean) {
if(this._scroll_fix_timer) {
this._scroll_animate = this._scroll_animate && animate;
return;
}
this._scroll_fix_timer = setTimeout(() => {
this._scroll_fix_timer = undefined;
let offset;
if(this._first_unread_message) {
offset = this._first_unread_message.html_element[0].offsetTop;
} else if(typeof(this._scroll_position) !== "undefined") {
offset = this._scroll_position;
} else {
offset = this._container_messages[0].scrollHeight;
}
if(this._scroll_animate) {
this._container_messages.stop(true).animate({
scrollTop: offset
}, 'slow');
} else {
this._container_messages.stop(true).scrollTop(offset);
}
}, 5);
}
fix_view_size() {
this._view_older_messages.html_element.toggleClass('shown', !!this._has_older_messages);
let count = 0;
for(let index = 0; index < this._view_entries.length; index++) {
if('timestamp' in this._view_entries[index])
count++;
if(count > this._view_max_messages) {
this._view_entries.splice(index, this._view_entries.length - index).forEach(e => {
clearTimeout(e.update_timer);
e.html_element.remove();
});
this._has_older_messages = true;
this._view_older_messages.html_element.toggleClass('shown', true);
break;
}
}
}
chat_available() : boolean {
return !this._container_no_permissions_shown && !this._container_is_private_shown && !this._container_no_support_shown;
}
text_send_failed(error: CommandResult | any) {
log.warn(LogCategory.CHAT, "Failed to send text message! (%o)", error);
//TODO: Log if message send failed?
if(error instanceof CommandResult) {
if(error.id == ErrorID.PERMISSION_ERROR) {
//TODO: Split up between channel_text_message_send permission and no view permission
if(error.json["failed_permid"] == 0) {
this._container_no_permissions_shown = true;
this._container_no_permissions.show();
this.handle.update_chat_box();
}
}
}
}
set_flag_private(flag: boolean) {
if(this._flag_private === flag)
return;
this._flag_private = flag;
this.update_private_state();
if(!flag)
this.fetch_last_messages();
}
update_private_state() {
if(!this._flag_private) {
this._container_is_private.hide();
this._container_is_private_shown = false;
} else {
const client = this.handle.handle.handle.getClient();
if(client && client.currentChannel() && client.currentChannel().channelId === this.channel_id) {
this._container_is_private_shown = false;
this._container_is_private.hide();
} else {
this._container_is_private.show();
this._container_is_private_shown = true;
}
}
}
delete_message(message: MessageData) {
//TODO A lot of checks!
//conversationmessagedelete cid=2 timestamp_begin= timestamp_end= cldbid= limit=1
this.handle.handle.handle.serverConnection.send_command('conversationmessagedelete', {
cid: this.channel_id,
cldbid: message.sender_database_id,
timestamp_begin: message.timestamp - 1,
timestamp_end: message.timestamp + 1,
limit: 1
}).then(() => {
return; /* in general it gets deleted via notify */
}).catch(error => {
log.error(LogCategory.CHAT, tr("Failed to delete conversation message for conversation %o: %o"), this.channel_id, error);
if(error instanceof CommandResult)
error = error.extra_message || error.message;
createErrorModal(tr("Failed to delete message"), formatMessage(tr("Failed to delete conversation message{:br:}Error: {}"), error)).open();
});
log.debug(LogCategory.CLIENT, tr("Deleting text message %o"), message);
}
delete_messages(begin: number, end: number, sender: number, limit: number) {
let count = 0;
for(const message of this._view_entries.slice()) {
if(!('sender_database_id' in message))
continue;
const cmsg = message as Message;
if(end != 0 && cmsg.timestamp > end)
continue;
if(begin != 0 && cmsg.timestamp < begin)
break;
if(cmsg.sender_database_id !== sender)
continue;
this._delete_message(message);
if(--count >= limit)
return;
}
//TODO remove in cache? (_last_messages)
}
private _delete_message(message: Message) {
if('html_element' in message) {
const cmessage = message as Message;
cmessage.html_element.remove();
clearTimeout(cmessage.update_timer);
this._view_entries.remove(message as any);
}
this._last_messages.remove(message);
}
}
export class ConversationManager {
readonly handle: Frame;
private _html_tag: JQuery;
private _chat_box: ChatBox;
private _container_conversation: JQuery;
private _conversations: Conversation[] = [];
private _current_conversation: Conversation | undefined;
private _needed_listener = () => this.update_chat_box();
constructor(handle: Frame) {
this.handle = handle;
this._chat_box = new ChatBox();
this._build_html_tag();
this._chat_box.callback_text = text => {
if(!this._current_conversation)
return;
const conv = this._current_conversation;
this.handle.handle.serverConnection.send_command("sendtextmessage", {targetmode: conv.channel_id == 0 ? 3 : 2, cid: conv.channel_id, msg: text}, {process_result: false}).catch(error => {
conv.text_send_failed(error);
});
};
this.update_chat_box();
}
initialize_needed_listener() {
this.handle.handle.permissions.register_needed_permission(PermissionType.B_CLIENT_CHANNEL_TEXTMESSAGE_SEND, this._needed_listener);
this.handle.handle.permissions.register_needed_permission(PermissionType.B_CLIENT_SERVER_TEXTMESSAGE_SEND, this._needed_listener);
}
html_tag() : JQuery { return this._html_tag; }
destroy() {
if(this.handle.handle.permissions)
this.handle.handle.permissions.unregister_needed_permission(PermissionType.B_CLIENT_CHANNEL_TEXTMESSAGE_SEND, this._needed_listener);
this.handle.handle.permissions.unregister_needed_permission(PermissionType.B_CLIENT_SERVER_TEXTMESSAGE_SEND, this._needed_listener);
this._needed_listener = undefined;
this._chat_box && this._chat_box.destroy();
this._chat_box = undefined;
this._html_tag && this._html_tag.remove();
this._html_tag = undefined;
this._container_conversation = undefined;
for(const conversation of this._conversations)
conversation.destroy();
this._conversations = [];
this._current_conversation = undefined;
}
update_chat_box() {
let flag = true;
flag = flag && !!this._current_conversation; /* test if we have a conversation */
flag = flag && !!this.handle.handle.permissions; /* test if we got permissions to test with */
flag = flag && this.handle.handle.permissions.neededPermission(this._current_conversation.channel_id == 0 ? PermissionType.B_CLIENT_SERVER_TEXTMESSAGE_SEND : PermissionType.B_CLIENT_CHANNEL_TEXTMESSAGE_SEND).granted(1);
flag = flag && this._current_conversation.chat_available();
this._chat_box.set_enabled(flag);
}
private _build_html_tag() {
this._html_tag = $("#tmpl_frame_chat_channel").renderTag({
chatbox: this._chat_box.html_tag()
});
this._container_conversation = this._html_tag.find(".container-chat");
this._chat_box.html_tag().on('focus', event => {
if(this._current_conversation)
this._current_conversation.mark_read();
});
this.update_input_format_helper();
}
set_current_channel(channel_id: number, update_info_frame?: boolean) {
if(this._current_conversation && this._current_conversation.channel_id === channel_id)
return;
let conversation = this.conversation(channel_id);
this._current_conversation = conversation;
if(this._current_conversation) {
this._container_conversation.children().detach();
this._container_conversation.append(conversation.html_tag());
this._current_conversation.fix_view_size();
this._current_conversation.fix_scroll(false);
this.update_chat_box();
}
if(typeof(update_info_frame) === "undefined" || update_info_frame)
this.handle.info_frame().update_channel_text();
}
current_channel() : number { return this._current_conversation ? this._current_conversation.channel_id : 0; }
/* Used by notifychanneldeleted */
delete_conversation(channel_id: number) {
const entry = this._conversations.find(e => e.channel_id === channel_id);
if(!entry)
return;
this._conversations.remove(entry);
entry.html_tag().detach();
entry.destroy();
}
reset() {
while(this._conversations.length > 0)
this.delete_conversation(this._conversations[0].channel_id);
}
conversation(channel_id: number, create?: boolean) : Conversation {
let conversation = this._conversations.find(e => e.channel_id === channel_id);
if(!conversation && channel_id >= 0 && (typeof (create) === "undefined" || create)) {
conversation = new Conversation(this, channel_id);
this._conversations.push(conversation);
conversation.fetch_last_messages();
}
return conversation;
}
on_show() {
if(this._current_conversation)
this._current_conversation.fix_scroll(false);
}
update_input_format_helper() {
const tag = this._html_tag.find(".container-format-helper");
if(settings.static_global(Settings.KEY_CHAT_ENABLE_MARKDOWN)) {
tag.removeClass("hidden").text(tr("*italic*, **bold**, ~~strikethrough~~, `code`, and more..."));
} else {
tag.addClass("hidden");
}
}
}