533 lines
18 KiB
TypeScript
533 lines
18 KiB
TypeScript
import LogType = log.LogType;
|
|
|
|
enum ChatType {
|
|
GENERAL,
|
|
SERVER,
|
|
CHANNEL,
|
|
CLIENT
|
|
}
|
|
|
|
namespace MessageHelper {
|
|
export function htmlEscape(message: string) : string[] {
|
|
const div = document.createElement('div');
|
|
div.innerText = message;
|
|
message = div.innerHTML;
|
|
return message.replace(/ /g, ' ').split(/<br>/);
|
|
}
|
|
|
|
export function formatElement(object: any, escape_html: boolean = true) : JQuery[] {
|
|
if($.isArray(object)) {
|
|
let result = [];
|
|
for(let element of object)
|
|
result.push(...formatElement(element, escape_html));
|
|
return result;
|
|
} else if(typeof(object) == "string") {
|
|
if(object.length == 0) return [];
|
|
|
|
return escape_html ?
|
|
htmlEscape(object).map((entry, idx, array) => $.spawn("a").css("display", (idx == 0 || idx + 1 == array.length ? "inline" : "") + "block").html(entry == "" && idx != 0 ? " " : entry)) :
|
|
[$.spawn("div").css("display", "inline-block").html(object)];
|
|
} else if(typeof(object) === "object") {
|
|
if(object instanceof $)
|
|
return [object as any];
|
|
return formatElement("<unknwon object>");
|
|
} else if(typeof(object) === "function") return formatElement(object(), escape_html);
|
|
else if(typeof(object) === "undefined") return formatElement("<undefined>");
|
|
else if(typeof(object) === "number") return [$.spawn("a").text(object)];
|
|
return formatElement("<unknown object type " + typeof object + ">");
|
|
}
|
|
|
|
export function formatMessage(pattern: string, ...objects: any[]) : JQuery[] {
|
|
let begin = 0, found = 0;
|
|
|
|
let result: JQuery[] = [];
|
|
do {
|
|
found = pattern.indexOf('{', found);
|
|
if(found == -1 || pattern.length <= found + 1) {
|
|
result.push(...formatElement(pattern.substr(begin)));
|
|
break;
|
|
}
|
|
|
|
if(found > 0 && pattern[found - 1] == '\\') {
|
|
//TODO remove the escape!
|
|
found++;
|
|
continue;
|
|
}
|
|
|
|
result.push(...formatElement(pattern.substr(begin, found - begin))); //Append the text
|
|
|
|
let number;
|
|
let offset = 0;
|
|
while ("0123456789".includes(pattern[found + 1 + offset])) offset++;
|
|
number = parseInt(offset > 0 ? pattern.substr(found + 1, offset) : "0");
|
|
|
|
if(pattern[found + offset + 1] != '}') {
|
|
found++;
|
|
continue;
|
|
}
|
|
|
|
if(objects.length < number)
|
|
log.warn(LogCategory.GENERAL, tr("Message to format contains invalid index (%o)"), number);
|
|
|
|
result.push(...formatElement(objects[number]));
|
|
found = found + 1 + offset;
|
|
begin = found + 1;
|
|
} while(found++);
|
|
|
|
return result;
|
|
}
|
|
|
|
export function bbcode_chat(message: string) : JQuery[] {
|
|
let result = XBBCODE.process({
|
|
text: message,
|
|
escapeHtml: true,
|
|
addInLineBreaks: false,
|
|
|
|
/* TODO make this configurable and allow IMG */
|
|
tag_whitelist: [
|
|
"b",
|
|
"i",
|
|
"u",
|
|
"color",
|
|
"url"
|
|
]
|
|
});
|
|
|
|
if(result.error) {
|
|
log.error(LogCategory.GENERAL, tr("BBCode parse error: %o"), result.errorQueue);
|
|
return formatElement(message);
|
|
}
|
|
|
|
return result.html.split("\n").map((entry, idx, array) => $.spawn("a").css("display", (idx == 0 ? "inline" : "") + "block").html(entry == "" && idx != 0 ? " " : entry));
|
|
}
|
|
}
|
|
|
|
class ChatMessage {
|
|
date: Date;
|
|
message: JQuery[];
|
|
private _html_tag: JQuery<HTMLElement>;
|
|
|
|
constructor(message: JQuery[]) {
|
|
this.date = new Date();
|
|
this.message = message;
|
|
}
|
|
|
|
private num(num: number) : string {
|
|
let str = num.toString();
|
|
while(str.length < 2) str = '0' + str;
|
|
return str;
|
|
}
|
|
|
|
get html_tag() {
|
|
if(this._html_tag) return this._html_tag;
|
|
|
|
let tag = $.spawn("div");
|
|
tag.addClass("message");
|
|
|
|
let dateTag = $.spawn("div");
|
|
dateTag.text("<" + this.num(this.date.getUTCHours()) + ":" + this.num(this.date.getUTCMinutes()) + ":" + this.num(this.date.getUTCSeconds()) + "> ");
|
|
dateTag.css("margin-right", "4px");
|
|
dateTag.css("color", "dodgerblue");
|
|
|
|
this._html_tag = tag;
|
|
tag.append(dateTag);
|
|
this.message.forEach(e => e.appendTo(tag));
|
|
return tag;
|
|
}
|
|
}
|
|
|
|
class ChatEntry {
|
|
readonly handle: ChatBox;
|
|
type: ChatType;
|
|
key: string;
|
|
history: ChatMessage[];
|
|
|
|
owner_unique_id?: string;
|
|
|
|
private _name: string;
|
|
private _html_tag: any;
|
|
|
|
private _flag_closeable: boolean = true;
|
|
private _flag_unread : boolean = false;
|
|
private _flag_offline: boolean = false;
|
|
|
|
onMessageSend: (text: string) => void;
|
|
onClose: () => boolean = () => true;
|
|
|
|
constructor(handle, type : ChatType, key) {
|
|
this.handle = handle;
|
|
this.type = type;
|
|
this.key = key;
|
|
this._name = key;
|
|
this.history = [];
|
|
}
|
|
|
|
appendError(message: string, ...args) {
|
|
let entries = MessageHelper.formatMessage(message, ...args);
|
|
entries.forEach(e => e.css("color", "red"));
|
|
this.pushChatMessage(new ChatMessage(entries));
|
|
}
|
|
|
|
appendMessage(message : string, fmt: boolean = true, ...args) {
|
|
this.pushChatMessage(new ChatMessage(MessageHelper.formatMessage(message, ...args)));
|
|
}
|
|
|
|
private pushChatMessage(entry: ChatMessage) {
|
|
this.history.push(entry);
|
|
while(this.history.length > 100) {
|
|
let elm = this.history.pop_front();
|
|
elm.html_tag.animate({opacity: 0}, 200, function () {
|
|
$(this).detach();
|
|
});
|
|
}
|
|
if(this.handle.activeChat === this) {
|
|
let box = $(this.handle.htmlTag).find(".messages");
|
|
let mbox = $(this.handle.htmlTag).find(".message_box");
|
|
let bottom : boolean = box.scrollTop() + box.height() + 1 >= mbox.height();
|
|
mbox.append(entry.html_tag);
|
|
entry.html_tag.css("opacity", "0").animate({opacity: 1}, 100);
|
|
if(bottom) box.scrollTop(mbox.height());
|
|
} else {
|
|
this.flag_unread = true;
|
|
}
|
|
}
|
|
|
|
displayHistory() {
|
|
this.flag_unread = false;
|
|
let box = this.handle.htmlTag.find(".messages");
|
|
let mbox = box.find(".message_box").detach(); /* detach the message box to improve performance */
|
|
mbox.empty();
|
|
|
|
for(let e of this.history) {
|
|
mbox.append(e.html_tag);
|
|
/* TODO Is this really totally useless?
|
|
Because its at least a performance bottleneck because is(...) recalculates the page style
|
|
if(e.htmlTag.is(":hidden"))
|
|
e.htmlTag.show();
|
|
*/
|
|
}
|
|
|
|
mbox.appendTo(box);
|
|
box.scrollTop(mbox.height());
|
|
}
|
|
|
|
get html_tag() {
|
|
if(this._html_tag)
|
|
return this._html_tag;
|
|
|
|
let tag = $.spawn("div");
|
|
tag.addClass("chat");
|
|
if(this._flag_unread)
|
|
tag.addClass('unread');
|
|
if(this._flag_offline)
|
|
tag.addClass('offline');
|
|
if(this._flag_closeable)
|
|
tag.addClass('closeable');
|
|
|
|
tag.append($.spawn("div").addClass("chat-type icon " + this.chat_icon()));
|
|
tag.append($.spawn("a").addClass("name").text(this._name));
|
|
|
|
let tag_close = $.spawn("div");
|
|
tag_close.addClass("btn_close icon client-tab_close_button");
|
|
if(!this._flag_closeable) tag_close.hide();
|
|
tag.append(tag_close);
|
|
|
|
tag.click(() => { this.handle.activeChat = this; });
|
|
tag.on("contextmenu", (e) => {
|
|
e.preventDefault();
|
|
|
|
let actions: ContextMenuEntry[] = [];
|
|
actions.push({
|
|
type: MenuEntryType.ENTRY,
|
|
icon: "",
|
|
name: tr("Clear"),
|
|
callback: () => {
|
|
this.history = [];
|
|
this.displayHistory();
|
|
}
|
|
});
|
|
if(this.flag_closeable) {
|
|
actions.push({
|
|
type: MenuEntryType.ENTRY,
|
|
icon: "client-tab_close_button",
|
|
name: tr("Close"),
|
|
callback: () => {
|
|
chat.deleteChat(this);
|
|
}
|
|
});
|
|
}
|
|
|
|
actions.push({
|
|
type: MenuEntryType.ENTRY,
|
|
icon: "client-tab_close_button",
|
|
name: tr("Close all private tabs"),
|
|
callback: () => {
|
|
//TODO Implement this?
|
|
},
|
|
visible: false
|
|
});
|
|
spawn_context_menu(e.pageX, e.pageY, ...actions);
|
|
});
|
|
|
|
tag_close.click(() => {
|
|
if($.isFunction(this.onClose) && !this.onClose())
|
|
return;
|
|
|
|
this.handle.deleteChat(this);
|
|
});
|
|
|
|
return this._html_tag = tag;
|
|
}
|
|
|
|
focus() {
|
|
this.handle.activeChat = this;
|
|
this.handle.htmlTag.find(".input_box").focus();
|
|
}
|
|
|
|
set name(newName : string) {
|
|
this._name = newName;
|
|
this.html_tag.find(".name").text(this._name);
|
|
}
|
|
|
|
set flag_closeable(flag : boolean) {
|
|
if(this._flag_closeable == flag) return;
|
|
|
|
this._flag_closeable = flag;
|
|
|
|
this.html_tag.toggleClass('closeable', flag);
|
|
}
|
|
|
|
set flag_unread(flag : boolean) {
|
|
if(this._flag_unread == flag) return;
|
|
this._flag_unread = flag;
|
|
this.html_tag.find(".chat-type").attr("class", "chat-type icon " + this.chat_icon());
|
|
this.html_tag.toggleClass('unread', flag);
|
|
}
|
|
|
|
get flag_offline() { return this._flag_offline; }
|
|
|
|
set flag_offline(flag: boolean) {
|
|
if(flag == this._flag_offline)
|
|
return;
|
|
|
|
this._flag_offline = flag;
|
|
this.html_tag.toggleClass('offline', flag);
|
|
}
|
|
|
|
private chat_icon() : string {
|
|
if(this._flag_unread) {
|
|
switch (this.type) {
|
|
case ChatType.CLIENT:
|
|
return "client-new_chat";
|
|
}
|
|
}
|
|
switch (this.type) {
|
|
case ChatType.SERVER:
|
|
return "client-server_log";
|
|
case ChatType.CHANNEL:
|
|
return "client-channel_chat";
|
|
case ChatType.CLIENT:
|
|
return "client-player_chat";
|
|
case ChatType.GENERAL:
|
|
return "client-channel_chat";
|
|
}
|
|
return "";
|
|
}
|
|
}
|
|
|
|
|
|
class ChatBox {
|
|
//https://regex101.com/r/YQbfcX/2
|
|
//static readonly URL_REGEX = /^(?<hostname>([a-zA-Z0-9-]+\.)+[a-zA-Z0-9-]{2,63})(?:\/(?<path>(?:[^\s?]+)?)(?:\?(?<query>\S+))?)?$/gm;
|
|
static readonly URL_REGEX = /^(([a-zA-Z0-9-]+\.)+[a-zA-Z0-9-]{2,63})(?:\/((?:[^\s?]+)?)(?:\?(\S+))?)?$/gm;
|
|
|
|
htmlTag: JQuery;
|
|
chats: ChatEntry[];
|
|
private _activeChat: ChatEntry;
|
|
|
|
private _button_send: JQuery;
|
|
private _input_message: JQuery;
|
|
|
|
constructor(htmlTag: JQuery) {
|
|
this.htmlTag = htmlTag;
|
|
|
|
this._button_send = this.htmlTag.find(".button-send");
|
|
this._input_message = this.htmlTag.find(".input-message");
|
|
|
|
this._button_send.click(this.onSend.bind(this));
|
|
this._input_message.keypress(event => {
|
|
if(event.keyCode == JQuery.Key.Enter && !event.shiftKey) {
|
|
this.onSend();
|
|
return false;
|
|
}
|
|
}).on('input', (event) => {
|
|
let text = $(event.target).val().toString();
|
|
if(this.testMessage(text))
|
|
this._button_send.removeAttr("disabled");
|
|
else
|
|
this._button_send.attr("disabled", "true");
|
|
}).trigger("input");
|
|
|
|
this.chats = [];
|
|
this._activeChat = undefined;
|
|
|
|
this.createChat("chat_server", ChatType.SERVER).onMessageSend = (text: string) => {
|
|
if(!globalClient.serverConnection) {
|
|
chat.serverChat().appendError(tr("Could not send chant message (Not connected)"));
|
|
return;
|
|
}
|
|
globalClient.serverConnection.command_helper.sendMessage(text, ChatType.SERVER).catch(error => {
|
|
if(error instanceof CommandResult)
|
|
return;
|
|
|
|
chat.serverChat().appendMessage(tr("Failed to send text message."));
|
|
log.error(LogCategory.GENERAL, tr("Failed to send server text message: %o"), error);
|
|
});
|
|
};
|
|
this.serverChat().name = tr("Server chat");
|
|
this.serverChat().flag_closeable = false;
|
|
|
|
this.createChat("chat_channel", ChatType.CHANNEL).onMessageSend = (text: string) => {
|
|
if(!globalClient.serverConnection) {
|
|
chat.channelChat().appendError(tr("Could not send chant message (Not connected)"));
|
|
return;
|
|
}
|
|
|
|
globalClient.serverConnection.command_helper.sendMessage(text, ChatType.CHANNEL, globalClient.getClient().currentChannel()).catch(error => {
|
|
chat.channelChat().appendMessage(tr("Failed to send text message."));
|
|
log.error(LogCategory.GENERAL, tr("Failed to send channel text message: %o"), error);
|
|
});
|
|
};
|
|
this.channelChat().name = tr("Channel chat");
|
|
this.channelChat().flag_closeable = false;
|
|
|
|
globalClient.permissions.initializedListener.push(flag => {
|
|
if(flag) this.activeChat0(this._activeChat);
|
|
});
|
|
}
|
|
|
|
createChat(key, type : ChatType = ChatType.CLIENT) : ChatEntry {
|
|
let chat = new ChatEntry(this, type, key);
|
|
this.chats.push(chat);
|
|
this.htmlTag.find(".chats").append(chat.html_tag);
|
|
if(!this._activeChat) this.activeChat = chat;
|
|
return chat;
|
|
}
|
|
|
|
open_chats() : ChatEntry[] {
|
|
return this.chats;
|
|
}
|
|
|
|
findChat(key : string) : ChatEntry {
|
|
for(let e of this.chats)
|
|
if(e.key == key) return e;
|
|
return undefined;
|
|
}
|
|
|
|
deleteChat(chat : ChatEntry) {
|
|
this.chats.remove(chat);
|
|
chat.html_tag.detach();
|
|
if(this._activeChat === chat) {
|
|
if(this.chats.length > 0)
|
|
this.activeChat = this.chats.last();
|
|
else
|
|
this.activeChat = undefined;
|
|
}
|
|
}
|
|
|
|
|
|
onSend() {
|
|
let text = this._input_message.val().toString();
|
|
if(!this.testMessage(text)) return;
|
|
this._input_message.val("");
|
|
this._input_message.trigger("input");
|
|
|
|
/* preprocessing text */
|
|
const words = text.split(/[ \n]/);
|
|
for(let index = 0; index < words.length; index++) {
|
|
const flag_escaped = words[index].startsWith('!');
|
|
const unescaped = flag_escaped ? words[index].substr(1) : words[index];
|
|
|
|
_try:
|
|
try {
|
|
const url = new URL(unescaped);
|
|
log.debug(LogCategory.GENERAL, tr("Chat message contains URL: %o"), url);
|
|
if(url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
break _try;
|
|
if(flag_escaped)
|
|
words[index] = unescaped;
|
|
else {
|
|
text = undefined;
|
|
words[index] = "[url=" + url.toString() + "]" + url.toString() + "[/url]";
|
|
}
|
|
} catch(e) { /* word isn't an url */ }
|
|
|
|
if(unescaped.match(ChatBox.URL_REGEX)) {
|
|
if(flag_escaped)
|
|
words[index] = unescaped;
|
|
else {
|
|
text = undefined;
|
|
words[index] = "[url=" + unescaped + "]" + unescaped + "[/url]";
|
|
}
|
|
}
|
|
}
|
|
|
|
if(this._activeChat && $.isFunction(this._activeChat.onMessageSend))
|
|
this._activeChat.onMessageSend(text || words.join(" "));
|
|
}
|
|
|
|
set activeChat(chat : ChatEntry) {
|
|
if(this.chats.indexOf(chat) === -1) return;
|
|
if(this._activeChat == chat) return;
|
|
this.activeChat0(chat);
|
|
}
|
|
|
|
private activeChat0(chat: ChatEntry) {
|
|
this._activeChat = chat;
|
|
for(let e of this.chats)
|
|
e.html_tag.removeClass("active");
|
|
|
|
let disable_input = !chat;
|
|
if(this._activeChat) {
|
|
this._activeChat.html_tag.addClass("active");
|
|
this._activeChat.displayHistory();
|
|
|
|
if(!disable_input && globalClient && globalClient.permissions && globalClient.permissions.initialized())
|
|
switch (this._activeChat.type) {
|
|
case ChatType.CLIENT:
|
|
disable_input = false;
|
|
break;
|
|
case ChatType.SERVER:
|
|
disable_input = !globalClient.permissions.neededPermission(PermissionType.B_CLIENT_SERVER_TEXTMESSAGE_SEND).granted(1);
|
|
break;
|
|
case ChatType.CHANNEL:
|
|
disable_input = !globalClient.permissions.neededPermission(PermissionType.B_CLIENT_CHANNEL_TEXTMESSAGE_SEND).granted(1);
|
|
break;
|
|
}
|
|
}
|
|
this._input_message.prop("disabled", disable_input);
|
|
}
|
|
|
|
get activeChat(){ return this._activeChat; }
|
|
|
|
channelChat() : ChatEntry {
|
|
return this.findChat("chat_channel");
|
|
}
|
|
|
|
serverChat() {
|
|
return this.findChat("chat_server");
|
|
}
|
|
|
|
focus(){
|
|
this._input_message.focus();
|
|
}
|
|
|
|
private testMessage(message: string) : boolean {
|
|
message = message
|
|
.replace(/ /gi, "")
|
|
.replace(/<br>/gi, "")
|
|
.replace(/\n/gi, "")
|
|
.replace(/<br\/>/gi, "");
|
|
return message.length > 0;
|
|
}
|
|
} |