Updated a lot of chat related things

canary
WolverinDEV 2020-07-17 23:56:20 +02:00
parent c559fdff6c
commit 3155fb85f0
168 changed files with 6408 additions and 18410 deletions

View File

@ -1,10 +1,22 @@
# Changelog: # Changelog:
* **XX.XX.20** * **XX.XX.20**
- Rewrote the channel conversation UI - Rewrote the channel conversation UI and manager
- Several bug fixes like the scrollbar - Several bug fixes like the scrollbar
- Updated the channel history view mode - Updated the channel history view mode
- Improved chat box behaviour - Improved chat box behaviour
- Automatically crawling all channels on server join for new messages (requires TeaSpeak 1.4.16-b2 or higher) - Automatically crawling all channels on server join for new messages (requires TeaSpeak 1.4.16-b2 or higher)
- Improved the channel chat history browsing experience
- Added support for the `qote` bbcode and markdown syntax
- Recoded the private conversation UI and manager
- Improved client disconnect/reconnect handing
- Updated several chat box issues
- Improved history view
- Improved chat experience when chatting with two different users which have the same identity
- Automatically reopening the last open private chats
- Fixed the chat partner typing indicator
- Fixed chat scroll bar after switching tabs
- Fixed the chat "scroll to new messages" button
- Finalized the loader animation
* **12.07.20** * **12.07.20**
- Made the loader compatible with ES5 to support older browsers - Made the loader compatible with ES5 to support older browsers

View File

@ -11,7 +11,14 @@ To **report an issue**, then find and push the **New Issue** button, fill all th
You can also ask questions here, if you have any. You can also ask questions here, if you have any.
# Browser support: # Browser support:
MS Edge: partitional
No voice support (missing data channel support)
| Browser | Supported |
| -- | -- |
| MS Edge | Partitional |
| MS Edge (Chromium) | Yes |
| Chrome | Yes |
| Firefox | Yes |
| Opera | Yes |
| Safari | Yes |
| Internet Explorer | No |

View File

@ -134,6 +134,7 @@ const APP_FILE_LIST_SHARED_VENDORS: ProjectResource[] = [
"type": "js", "type": "js",
"search-pattern": /.*(\.min)?\.js$/, "search-pattern": /.*(\.min)?\.js$/,
"build-target": "dev|rel", "build-target": "dev|rel",
"search-exclude": /.*xbbcode.*/g,
"path": "vendor/", "path": "vendor/",
"local-path": "./vendor/" "local-path": "./vendor/"
@ -142,6 +143,7 @@ const APP_FILE_LIST_SHARED_VENDORS: ProjectResource[] = [
"type": "css", "type": "css",
"search-pattern": /.*\.css$/, "search-pattern": /.*\.css$/,
"build-target": "dev|rel", "build-target": "dev|rel",
"search-exclude": /.*xbbcode.*/g,
"path": "vendor/", "path": "vendor/",
"local-path": "./vendor/" "local-path": "./vendor/"

View File

@ -37,6 +37,7 @@ function initializeElements() {
image.alt = lazyImage.getAttribute("alt"); image.alt = lazyImage.getAttribute("alt");
image.src = lazyImage.getAttribute(apngSupport ? "src-apng" : "src-gif") || lazyImage.getAttribute("src"); image.src = lazyImage.getAttribute(apngSupport ? "src-apng" : "src-gif") || lazyImage.getAttribute("src");
image.className = lazyImage.className; image.className = lazyImage.className;
image.draggable = false;
lazyImage.replaceWith(image); lazyImage.replaceWith(image);
} }

View File

@ -1,9 +1,15 @@
/* IE11 */ /* IE11 and safari */
if(!Element.prototype.remove) if(Element.prototype.remove === undefined)
Element.prototype.remove = function() { Object.defineProperty(Element.prototype, "remove", {
this.parentElement?.removeChild(this); enumerable: false,
}; configurable: false,
writable: false,
value: function(){
this.parentElement.removeChild(this);
}
});
/* IE11 */
function ReplaceWithPolyfill() { function ReplaceWithPolyfill() {
let parent = this.parentNode, i = arguments.length, currentNode; let parent = this.parentNode, i = arguments.length, currentNode;
if (!parent) return; if (!parent) return;

View File

@ -63,10 +63,6 @@ const loader_javascript = {
await loader.scripts.load_multiple([ await loader.scripts.load_multiple([
["vendor/jsrender/jsrender.min.js"], ["vendor/jsrender/jsrender.min.js"],
["vendor/xbbcode/src/parser.js"],
["vendor/emoji-picker/src/jquery.lsxemojipicker.js"],
["vendor/twemoji/twemoji.min.js", ""], /* empty string means not required */
["vendor/highlight/highlight.pack.js", ""], /* empty string means not required */
["vendor/remarkable/remarkable.min.js", ""], /* empty string means not required */ ["vendor/remarkable/remarkable.min.js", ""], /* empty string means not required */
], { ], {
cache_tag: cache_tag(), cache_tag: cache_tag(),
@ -127,23 +123,6 @@ const loader_webassembly = {
const loader_style = { const loader_style = {
load_style: async taskId => { load_style: async taskId => {
const options = {
cache_tag: cache_tag(),
max_parallel_requests: -1
};
await loader.style.load_multiple([
"vendor/xbbcode/src/xbbcode.css"
], options, LoaderTaskCallback(taskId));
await loader.style.load_multiple([
"vendor/emoji-picker/src/jquery.lsxemojipicker.css"
], options, LoaderTaskCallback(taskId));
await loader.style.load_multiple([
["vendor/highlight/styles/darcula.css", ""], /* empty string means not required */
], options, LoaderTaskCallback(taskId));
if(__build.mode === "debug") { if(__build.mode === "debug") {
await loader_style.load_style_debug(taskId); await loader_style.load_style_debug(taskId);
} else { } else {
@ -213,24 +192,6 @@ const loader_style = {
} }
}; };
/* register tasks */
loader.register_task(loader.Stage.INITIALIZING, {
name: "safari fix",
function: async () => {
/* safari remove "fix" */
if(Element.prototype.remove === undefined)
Object.defineProperty(Element.prototype, "remove", {
enumerable: false,
configurable: false,
writable: false,
value: function(){
this.parentElement.removeChild(this);
}
});
},
priority: 50
});
loader.register_task(loader.Stage.INITIALIZING, { loader.register_task(loader.Stage.INITIALIZING, {
name: "secure tester", name: "secure tester",
function: async () => { function: async () => {
@ -279,12 +240,6 @@ loader.register_task(loader.Stage.TEMPLATES, {
priority: 10 priority: 10
}); });
loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
name: "lsx emoji picker setup",
function: async () => await (window as any).setup_lsx_emoji_picker({twemoji: typeof(window.twemoji) !== "undefined"}),
priority: 10
});
loader.register_task(loader.Stage.SETUP, { loader.register_task(loader.Stage.SETUP, {
name: "page setup", name: "page setup",
function: async () => { function: async () => {

View File

@ -68,7 +68,7 @@ var initial_css;
<noscript> <noscript>
<div id="overlay-no-js"> <div id="overlay-no-js">
<div class="container"> <div class="container">
<img src="img/script.svg" height="128px" alt="no script" > <img draggable="false" src="img/script.svg" height="128px" alt="no script" >
<h1>Please enable JavaScript</h1> <h1>Please enable JavaScript</h1>
<h3>TeaSpeak web could not run without it!</h3> <h3>TeaSpeak web could not run without it!</h3>
<h3>Its like you, without coffee</h3> <h3>Its like you, without coffee</h3>
@ -84,7 +84,7 @@ var initial_css;
<div class="loader" id="loader-overlay"> <div class="loader" id="loader-overlay">
<div class="container"> <div class="container">
<div class="setup"> <div class="setup">
<lazy-img src-apng="img/loader/initial-sequence.png" src-gif="img/loader/initial-sequence.gif" alt="initial loading sequence"></lazy-img> <lazy-img src-apng="img/loader/initial-sequence.gif" src-gif="img/loader/initial-sequence.gif" alt="initial loading sequence"></lazy-img>
</div> </div>
<div class="idle"> <div class="idle">
<lazy-img class="bowl" src="img/loader/bowl.png" alt="bowl"></lazy-img> <lazy-img class="bowl" src="img/loader/bowl.png" alt="bowl"></lazy-img>
@ -99,7 +99,7 @@ var initial_css;
<!-- Critical load error --> <!-- Critical load error -->
<div class="fulloverlay" id="critical-load"> <div class="fulloverlay" id="critical-load">
<div class="container"> <div class="container">
<img src="img/loading_error_right.svg" alt="load error" /> <img draggable="false" src="img/loading_error_right.svg" alt="load error" />
<h1 class="error"></h1> <h1 class="error"></h1>
<h3 class="detail"></h3> <h3 class="detail"></h3>

3825
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -40,14 +40,16 @@
"@types/node": "^12.7.2", "@types/node": "^12.7.2",
"@types/react-dom": "^16.9.5", "@types/react-dom": "^16.9.5",
"@types/sha256": "^0.2.0", "@types/sha256": "^0.2.0",
"@types/twemoji": "^12.1.1",
"@types/websocket": "0.0.40", "@types/websocket": "0.0.40",
"@types/emoji-mart": "^3.0.2",
"babel-loader": "^8.1.0", "babel-loader": "^8.1.0",
"chunk-manifest-webpack-plugin": "^1.1.2", "chunk-manifest-webpack-plugin": "^1.1.2",
"circular-dependency-plugin": "^5.2.0", "circular-dependency-plugin": "^5.2.0",
"clean-css": "^4.2.1", "clean-css": "^4.2.1",
"clean-webpack-plugin": "^3.0.0", "clean-webpack-plugin": "^3.0.0",
"css-loader": "^3.6.0", "css-loader": "^3.6.0",
"csso-cli": "^2.0.2", "csso-cli": "^3.0.0",
"ejs": "^3.0.2", "ejs": "^3.0.2",
"exports-loader": "^0.7.0", "exports-loader": "^0.7.0",
"fs-extra": "latest", "fs-extra": "latest",
@ -58,7 +60,7 @@
"mime-types": "^2.1.24", "mime-types": "^2.1.24",
"mini-css-extract-plugin": "^0.9.0", "mini-css-extract-plugin": "^0.9.0",
"mkdirp": "^0.5.1", "mkdirp": "^0.5.1",
"node-sass": "^4.13.1", "node-sass": "^4.14.1",
"raw-loader": "^4.0.0", "raw-loader": "^4.0.0",
"sass": "1.22.10", "sass": "1.22.10",
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
@ -68,7 +70,7 @@
"terser": "^4.2.1", "terser": "^4.2.1",
"terser-webpack-plugin": "latest", "terser-webpack-plugin": "latest",
"ts-loader": "^6.2.2", "ts-loader": "^6.2.2",
"tsd": "latest", "tsd": "^0.13.1",
"typescript": "^3.7.0", "typescript": "^3.7.0",
"wabt": "^1.0.13", "wabt": "^1.0.13",
"webpack": "^4.42.1", "webpack": "^4.42.1",
@ -86,14 +88,16 @@
"homepage": "https://www.teaspeak.de", "homepage": "https://www.teaspeak.de",
"dependencies": { "dependencies": {
"detect-browser": "^5.1.1", "detect-browser": "^5.1.1",
"@types/emoji-mart": "^3.0.2",
"dompurify": "^2.0.8", "dompurify": "^2.0.8",
"emoji-mart": "^3.0.0", "emoji-mart": "git+https://github.com/WolverinDEV/emoji-mart.git",
"emoji-regex": "^9.0.0",
"highlight.js": "^10.1.1",
"moment": "^2.24.0", "moment": "^2.24.0",
"react": "^16.13.1", "react": "^16.13.1",
"react-dom": "^16.13.1", "react-dom": "^16.13.1",
"resize-observer-polyfill": "^1.5.1", "resize-observer-polyfill": "^1.5.1",
"simplebar-react": "^2.2.0", "simplebar-react": "^2.2.0",
"twemoji": "^13.0.0",
"webrtc-adapter": "^7.5.1" "webrtc-adapter": "^7.5.1"
} }
} }

View File

@ -11,7 +11,7 @@
padding-right: 5%; padding-right: 5%;
padding-left: 5%; padding-left: 5%;
z-index: 1000; z-index: 100000;
position: fixed; position: fixed;
top: 0; top: 0;

View File

@ -169,138 +169,6 @@
</div> </div>
</script> </script>
<script class="jsrender-template" id="tmpl_frame_chat_private" type="text/html">
<div class="container-private-conversations">
<div class="conversation-list">
<div class="no-chats">
<div>{{tr "You&nbsp;dont&nbsp;have any&nbsp;chats&nbsp;yet!" /}}</div>
</div>
</div>
<div class="container-seperator vertical" seperator-id="seperator-conversation-list-messages"></div>
<div class="conversation">
<div class="spacer"></div>
<div class="container-messages"></div>
<div class="chatbox">
<div class="container-typing">{{tr "Partner is typing..." /}}</div>
<node key="chatbox"></node>
<div class="container-format-helper"></div>
</div>
</div>
</div>
</script>
<script class="jsrender-template" id="tmpl_frame_chat_private_entry" type="text/html">
<div class="conversation-entry">
<div class="container-avatar">
<node key="avatar"></node>
<div class="chat-unread"></div>
</div>
<div class="info">
<a class="client-name">{{>client_name}}</a>
<a class="last-message">{{>last_time}}</a>
</div>
<div class="button-close">
</div>
</div>
</script>
<script class="jsrender-template" id="tmpl_frame_chat_private_message" type="text/html">
<div class="message-entry" message-id="{{>message_id}}">
<div class="container-avatar">
<node key="avatar"></node>
</div>
<div class="container-message">
<div class="info">
<a class="client-name">
<node key="client_name"/>
</a>
<a class="timestamp">{{>timestamp}}</a>
</div>
<div class="message">
<node key="message"></node>
</div>
</div>
</div>
</script>
<script class="jsrender-template" id="tmpl_frame_chat_private_spacer" type="text/html">
<div class="spacer-entry">
{{>message}}
</div>
</script>
<script class="jsrender-template" id="tmpl_frame_chat_chatbox" type="text/html">
<div class="container-chatbox">
{{if emojy_support}}
<div class="container-emojis">
<div class="button-emoji">
<div class="container-icon">
<img src="img/smiley-smile.svg">
</div>
</div>
</div>
{{/if}}
<div class="container-input">
<div class="textarea" contenteditable="true"
placeholder="{{tr 'Type your message here...' /}}"></div>
</div>
</div>
</script>
<script class="jsrender-template" id="tmpl_frame_chat_channel" type="text/html">
<div class="container-channel-chat">
<div class="container-chat"></div>
<node key="chatbox"></node>
<div class="container-format-helper"></div>
</div>
</script>
<script class="jsrender-template" id="tmpl_frame_chat_channel_messages" type="text/html">
<div class="container-channel-chat-messages">
<div class="container-messages"></div>
<div class="new-message">
<a>{{tr "Scroll to new messages" /}}</a>
</div>
<div class="no-permissions">
<div>{{tr "You don't have permissions to participate in this conversation!" /}}</div>
</div>
<div class="private-conversation">
<div>{{tr "Conversation is private. Join the channel to participate!" /}}</div>
</div>
<div class="not-supported">
<div>
{{tr "The target server does not support channel chats." /}}<br>
{{tr "You're only able to write in your own channel" /}}
</div>
</div>
</div>
</script>
<script class="jsrender-template" id="tmpl_frame_chat_channel_message" type="text/html">
<div class="message-entry" message-id="{{>message_id}}">
<div class="container-avatar">
<node key="avatar"></node>
</div>
<div class="container-message">
<div class="info">
<div class="button-delete">
<img src="img/icon_conversation_message_delete.svg" alt="X"/>
</div>
<a class="client-name">
<node key="client_name"></node>
</a>
<a class="timestamp">{{>timestamp}}</a>
<br> <!-- Only for copy purposes -->
</div>
<div class="message">
<node key="message"></node>
<br style="content: ' '"> <!-- Only for copy purposes -->
</div>
</div>
</div>
</script>
<script class="jsrender-template" id="tmpl_frame_chat_client_info" type="text/html"> <script class="jsrender-template" id="tmpl_frame_chat_client_info" type="text/html">
<div class="container-client-info"> <div class="container-client-info">
<div class="heading"> <div class="heading">

View File

@ -212,6 +212,7 @@ export class ConnectionHandler {
} }
this.event_registry.register_handler(this); this.event_registry.register_handler(this);
this.events().fire("notify_handler_initialized");
} }
initialize_client_state(source?: ConnectionHandler) { initialize_client_state(source?: ConnectionHandler) {
@ -643,7 +644,6 @@ export class ConnectionHandler {
if(this.serverConnection) if(this.serverConnection)
this.serverConnection.disconnect(); this.serverConnection.disconnect();
this.side_bar.private_conversations().clear_client_ids();
this.hostbanner.update(); this.hostbanner.update();
if(auto_reconnect) { if(auto_reconnect) {
@ -1074,5 +1074,8 @@ export interface ConnectionEvents {
/* the handler has become visible/invisible for the client */ /* the handler has become visible/invisible for the client */
notify_visibility_changed: { notify_visibility_changed: {
visible: boolean visible: boolean
} },
/* fill only trigger once, after everything has been constructed */
notify_handler_initialized: {}
} }

View File

@ -1,293 +0,0 @@
import {Settings, settings} from "tc-shared/settings";
import * as contextmenu from "tc-shared/ui/elements/ContextMenu";
import {copy_to_clipboard} from "tc-shared/utils/helpers";
import {guid} from "tc-shared/crypto/uid";
import * as loader from "tc-loader";
import * as image_preview from "./ui/frames/image_preview"
import * as DOMPurify from "dompurify";
import { parse as parseBBCode } from "vendor/xbbcode/parser";
import ReactRenderer from "vendor/xbbcode/renderer/react";
import HTMLRenderer from "vendor/xbbcode/renderer/html";
const rendererReact = new ReactRenderer();
const rendererHTML = new HTMLRenderer();
export namespace bbcode {
const sanitizer_escaped = (key: string) => "[-- sescaped: " + key + " --]";
const sanitizer_escaped_regex = /\[-- sescaped: ([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}) --]/;
const sanitizer_escaped_map: {[key: string]: string} = {};
const yt_url_regex = /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/;
export interface FormatSettings {
is_chat_message?: boolean
}
export function format(message: string, fsettings?: FormatSettings) : JQuery[] {
fsettings = fsettings || {};
single_url_parse:
if(fsettings.is_chat_message) {
/* try if its only one url */
const raw_url = message.replace(/\[url(=\S+)?](\S+)\[\/url]/, "$2");
let url: URL;
try {
url = new URL(raw_url);
} catch(error) {
break single_url_parse;
}
single_url_yt:
{
const result = raw_url.match(yt_url_regex);
if(!result) break single_url_yt;
return format("[yt]https://www.youtube.com/watch?v=" + result[5] + "[/yt]");
}
single_url_image:
{
const ext_index = url.pathname.lastIndexOf(".");
if(ext_index == -1) break single_url_image;
const ext_name = url.pathname.substr(ext_index + 1).toLowerCase();
if([
"jpeg", "jpg",
"png", "bmp", "gif",
"tiff", "pdf", "svg"
].findIndex(e => e === ext_name) == -1) break single_url_image;
return format("[img]" + message + "[/img]");
}
}
const result = parseBBCode(message, {
tag_whitelist: [
"b", "big",
"i", "italic",
"u", "underlined",
"s", "strikethrough",
"color",
"url",
"code",
"i-code", "icode",
"sub", "sup",
"size",
"hr", "br",
"left", "l", "center", "c", "right", "r",
"ul", "ol", "list",
"li",
"table",
"tr", "td", "th",
"yt", "youtube",
"img"
]
});
let html = result.map(e => rendererHTML.render(e)).join("");
if(typeof(window.twemoji) !== "undefined" && settings.static_global(Settings.KEY_CHAT_COLORED_EMOJIES))
html = twemoji.parse(html);
const container = $.spawn("div");
let sanitized = DOMPurify.sanitize(html, {
ADD_ATTR: [
"x-highlight-type",
"x-code-type",
"x-image-url"
]
});
sanitized = sanitized.replace(sanitizer_escaped_regex, data => {
const uid = data.match(sanitizer_escaped_regex)[1];
const value = sanitizer_escaped_map[uid];
if(!value) return data;
delete sanitizer_escaped_map[uid];
return value;
});
container[0].innerHTML = sanitized;
container.find("a")
.attr('target', "_blank")
.on('contextmenu', event => {
if(event.isDefaultPrevented()) return;
event.preventDefault();
const url = $(event.target).attr("href");
contextmenu.spawn_context_menu(event.pageX, event.pageY, {
callback: () => {
const win = window.open(url, '_blank');
win.focus();
},
name: tr("Open URL"),
type: contextmenu.MenuEntryType.ENTRY,
icon_class: "client-browse-addon-online"
}, {
callback: () => {
//TODO
},
name: tr("Open URL in Browser"),
type: contextmenu.MenuEntryType.ENTRY,
visible: __build.target === "client" && false // Currently not possible
}, contextmenu.Entry.HR(), {
callback: () => copy_to_clipboard(url),
name: tr("Copy URL to clipboard"),
type: contextmenu.MenuEntryType.ENTRY,
icon_class: "client-copy"
});
});
return [container.contents() as JQuery];
//return result.root_tag.content.map(e => e.build_html()).map((entry, idx, array) => $.spawn("a").css("display", (idx == 0 ? "inline" : "") + "block").html(entry == "" && idx != 0 ? "&nbsp;" : entry));
}
export function load_image(entry: HTMLImageElement) {
const url = decodeURIComponent(entry.getAttribute("x-image-url") || "");
const proxy_url = "https://images.weserv.nl/?url=" + encodeURIComponent(url);
entry.onload = undefined;
entry.src = proxy_url;
const parent = $(entry.parentElement);
parent.on('contextmenu', event => {
contextmenu.spawn_context_menu(event.pageX, event.pageY, {
callback: () => {
const win = window.open(url, '_blank');
win.focus();
},
name: tr("Open image in browser"),
type: contextmenu.MenuEntryType.ENTRY,
icon_class: "client-browse-addon-online"
}, contextmenu.Entry.HR(), {
callback: () => copy_to_clipboard(url),
name: tr("Copy image URL to clipboard"),
type: contextmenu.MenuEntryType.ENTRY,
icon_class: "client-copy"
})
});
parent.css("cursor", "pointer").on('click', () => image_preview.preview_image(proxy_url, url));
}
loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
name: "XBBCode code tag init",
function: async () => {
/* override default parser */
xbbcode.register.register_parser({
tag: ["code", "icode", "i-code"],
content_tags_whitelist: [],
build_html(layer) : string {
const klass = layer.tag_normalized != 'code' ? "tag-hljs-inline-code" : "tag-hljs-code";
const language = (layer.options || "").replace("\"", "'").toLowerCase();
/* remove heading empty lines */
let text = layer.content.map(e => e.build_text())
.reduce((a, b) => a.length == 0 && b.replace(/[ \n\r\t]+/g, "").length == 0 ? "" : a + b, "")
.replace(/^([ \n\r\t]*)(?=\n)+/g, "");
if(text.startsWith("\r") || text.startsWith("\n"))
text = text.substr(1);
let result: HighlightJSResult;
if(window.hljs.getLanguage(language))
result = window.hljs.highlight(language, text, true);
else
result = window.hljs.highlightAuto(text);
let html = '<pre class="' + klass + '">';
html += '<code class="hljs" x-code-type="' + language + '" x-highlight-type="' + result.language + '">';
html += result.value;
return html + "</code></pre>";
}
});
/* override the yt parser */
const original_parser = xbbcode.register.find_parser("yt");
if(original_parser)
xbbcode.register.register_parser({
tag: ["yt", "youtube"],
build_html(layer): string {
const result = original_parser.build_html(layer);
if(!result.startsWith("<iframe")) return result;
const url = result.match(/src="(\S+)" /)[1];
const uid = guid();
sanitizer_escaped_map[uid] = "<iframe class=\"xbbcode-tag xbbcode-tag-video\" src=\"" + url + "\" frameborder=\"0\" allow=\"autoplay; encrypted-media\" allowfullscreen></iframe>";
return sanitizer_escaped(uid);
}
});
const element: HTMLElement;
element.onended
const load_callback = guid();
/* the image parse & displayer */
xbbcode.register.register_parser({
tag: ["img", "image"],
build_html(layer): string {
const uid = guid();
const fallback_value = "[img]" + layer.build_text() + "[/img]";
let target;
let content = layer.content.map(e => e.build_text()).join("");
if (!layer.options) {
target = content;
} else
target = layer.options;
let url: URL;
try {
url = new URL(target);
if(!url.hostname) throw "";
} catch(error) {
return fallback_value;
}
sanitizer_escaped_map[uid] = "<div class='xbbcode-tag-img'><img src='img/loading_image.svg' onload='window[\"" + load_callback + "\"](this)' x-image-url='" + encodeURIComponent(target) + "' title='" + sanitize_text(target) + "' /></div>";
return sanitizer_escaped(uid);
}
});
window[load_callback] = load_image;
},
priority: 10
});
}
export function sanitize_text(text: string) : string {
return $(DOMPurify.sanitize("<a>" + text + "</a>", {
ADD_ATTR: [
"x-highlight-type",
"x-code-type",
"x-image-url"
]
})).text();
}
export function formatDate(secs: number) : string {
let years = Math.floor(secs / (60 * 60 * 24 * 365));
let days = Math.floor(secs / (60 * 60 * 24)) % 365;
let hours = Math.floor(secs / (60 * 60)) % 24;
let minutes = Math.floor(secs / 60) % 60;
let seconds = Math.floor(secs % 60);
let result = "";
if(years > 0)
result += years + " " + tr("years") + " ";
if(years > 0 || days > 0)
result += days + " " + tr("days") + " ";
if(years > 0 || days > 0 || hours > 0)
result += hours + " " + tr("hours") + " ";
if(years > 0 || days > 0 || hours > 0 || minutes > 0)
result += minutes + " " + tr("minutes") + " ";
if(years > 0 || days > 0 || hours > 0 || minutes > 0 || seconds > 0)
result += seconds + " " + tr("seconds") + " ";
else
result = tr("now") + " ";
return result.substr(0, result.length - 1);
}

View File

@ -0,0 +1,465 @@
import {Settings, settings} from "tc-shared/settings";
import * as contextmenu from "tc-shared/ui/elements/ContextMenu";
import {spawn_context_menu} from "tc-shared/ui/elements/ContextMenu";
import {copy_to_clipboard} from "tc-shared/utils/helpers";
import * as loader from "tc-loader";
import * as image_preview from "./ui/frames/image_preview"
import * as DOMPurify from "dompurify";
import {parse as parseBBCode} from "vendor/xbbcode/parser";
import ReactRenderer from "vendor/xbbcode/renderer/react";
import HTMLRenderer from "vendor/xbbcode/renderer/html";
import TextRenderer from "vendor/xbbcode/renderer/text";
import {ElementRenderer} from "vendor/xbbcode/renderer/base";
import {TagElement, TextElement} from "vendor/xbbcode/elements";
import * as React from "react";
import {XBBCodeRenderer} from "vendor/xbbcode/react";
import * as emojiRegex from "emoji-regex";
import * as hljs from 'highlight.js/lib/core';
import '!style-loader!css-loader!highlight.js/styles/darcula.css';
import {tra} from "tc-shared/i18n/localize";
const emojiRegexInstance = (emojiRegex as any)() as RegExp;
const registerLanguage = (name, language: Promise<any>) => {
language.then(lan => hljs.registerLanguage(name, lan)).catch(error => {
console.warn("Failed to load language %s (%o)", name, error);
});
};
registerLanguage("javascript", import("highlight.js/lib/languages/javascript"));
registerLanguage("actionscript", import("highlight.js/lib/languages/actionscript"));
registerLanguage("armasm", import("highlight.js/lib/languages/armasm"));
registerLanguage("basic", import("highlight.js/lib/languages/basic"));
registerLanguage("c-like", import("highlight.js/lib/languages/c-like"));
registerLanguage("c", import("highlight.js/lib/languages/c"));
registerLanguage("cmake", import("highlight.js/lib/languages/cmake"));
registerLanguage("coffeescript", import("highlight.js/lib/languages/coffeescript"));
registerLanguage("cpp", import("highlight.js/lib/languages/cpp"));
registerLanguage("csharp", import("highlight.js/lib/languages/csharp"));
registerLanguage("css", import("highlight.js/lib/languages/css"));
registerLanguage("dart", import("highlight.js/lib/languages/dart"));
registerLanguage("delphi", import("highlight.js/lib/languages/delphi"));
registerLanguage("dockerfile", import("highlight.js/lib/languages/dockerfile"));
registerLanguage("elixir", import("highlight.js/lib/languages/elixir"));
registerLanguage("erlang", import("highlight.js/lib/languages/erlang"));
registerLanguage("fortran", import("highlight.js/lib/languages/fortran"));
registerLanguage("go", import("highlight.js/lib/languages/go"));
registerLanguage("groovy", import("highlight.js/lib/languages/groovy"));
registerLanguage("ini", import("highlight.js/lib/languages/ini"));
registerLanguage("java", import("highlight.js/lib/languages/java"));
registerLanguage("javascript", import("highlight.js/lib/languages/javascript"));
registerLanguage("json", import("highlight.js/lib/languages/json"));
registerLanguage("kotlin", import("highlight.js/lib/languages/kotlin"));
registerLanguage("latex", import("highlight.js/lib/languages/latex"));
registerLanguage("lua", import("highlight.js/lib/languages/lua"));
registerLanguage("makefile", import("highlight.js/lib/languages/makefile"));
registerLanguage("markdown", import("highlight.js/lib/languages/markdown"));
registerLanguage("mathematica", import("highlight.js/lib/languages/mathematica"));
registerLanguage("matlab", import("highlight.js/lib/languages/matlab"));
registerLanguage("objectivec", import("highlight.js/lib/languages/objectivec"));
registerLanguage("perl", import("highlight.js/lib/languages/perl"));
registerLanguage("php", import("highlight.js/lib/languages/php"));
registerLanguage("plaintext", import("highlight.js/lib/languages/plaintext"));
registerLanguage("powershell", import("highlight.js/lib/languages/powershell"));
registerLanguage("protobuf", import("highlight.js/lib/languages/protobuf"));
registerLanguage("python", import("highlight.js/lib/languages/python"));
registerLanguage("ruby", import("highlight.js/lib/languages/ruby"));
registerLanguage("rust", import("highlight.js/lib/languages/rust"));
registerLanguage("scala", import("highlight.js/lib/languages/scala"));
registerLanguage("shell", import("highlight.js/lib/languages/shell"));
registerLanguage("sql", import("highlight.js/lib/languages/sql"));
registerLanguage("swift", import("highlight.js/lib/languages/swift"));
registerLanguage("typescript", import("highlight.js/lib/languages/typescript"));
registerLanguage("vbnet", import("highlight.js/lib/languages/vbnet"));
registerLanguage("vbscript", import("highlight.js/lib/languages/vbscript"));
registerLanguage("x86asm", import("highlight.js/lib/languages/x86asm"));
registerLanguage("xml", import("highlight.js/lib/languages/xml"));
registerLanguage("yaml", import("highlight.js/lib/languages/yaml"));
const rendererText = new TextRenderer();
const rendererReact = new ReactRenderer();
const rendererHTML = new HTMLRenderer(rendererReact);
export namespace bbcode {
const yt_url_regex = /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/;
export const allowedBBCodes = [
"b", "big",
"i", "italic",
"u", "underlined",
"s", "strikethrough",
"color",
"url",
"code",
"i-code", "icode",
"sub", "sup",
"size",
"hr", "br",
"left", "l", "center", "c", "right", "r",
"ul", "ol", "list",
"li",
"table",
"tr", "td", "th",
"yt", "youtube",
"img",
"quote"
];
export interface FormatSettings {
is_chat_message?: boolean
}
export function preprocessMessage(message: string, fsettings?: FormatSettings) {
fsettings = fsettings || {};
single_url_parse:
if(fsettings.is_chat_message) {
/* try if its only one url */
const raw_url = message.replace(/\[url(=\S+)?](\S+)\[\/url]/, "$2");
let url: URL;
try {
url = new URL(raw_url);
} catch(error) {
break single_url_parse;
}
single_url_yt:
{
const result = raw_url.match(yt_url_regex);
if(!result) break single_url_yt;
return "[yt]https://www.youtube.com/watch?v=" + result[5] + "[/yt]";
}
single_url_image:
{
const ext_index = url.pathname.lastIndexOf(".");
if(ext_index == -1) break single_url_image;
const ext_name = url.pathname.substr(ext_index + 1).toLowerCase();
if([
"jpeg", "jpg",
"png", "bmp", "gif",
"tiff", "pdf", "svg"
].findIndex(e => e === ext_name) == -1) break single_url_image;
return "[img]" + message + "[/img]";
}
}
return message;
}
export function format(message: string, fsettings?: FormatSettings) : JQuery[] {
message = preprocessMessage(message, fsettings);
const result = parseBBCode(message, {
tag_whitelist: allowedBBCodes
});
let html = result.map(e => rendererHTML.render(e)).join("");
/* FIXME: TODO or remove JQuery renderer
if(settings.static_global(Settings.KEY_CHAT_COLORED_EMOJIES))
html = twemoji.parse(html);
*/
const container = $.spawn("div") as JQuery;
container[0].innerHTML = html;
/* fixup some listeners */
container.find("a")
.attr('target', "_blank")
.on('contextmenu', event => {
if(event.isDefaultPrevented())
return;
event.preventDefault();
spawnUrlContextMenu(event.pageX, event.pageY, $(event.target).attr("href"));
});
container.find("img").on('load', event => load_image(event.target as HTMLImageElement));
return [container.contents() as JQuery];
//return result.root_tag.content.map(e => e.build_html()).map((entry, idx, array) => $.spawn("a").css("display", (idx == 0 ? "inline" : "") + "block").html(entry == "" && idx != 0 ? "&nbsp;" : entry));
}
function spawnUrlContextMenu(pageX: number, pageY: number, target: string) {
contextmenu.spawn_context_menu(pageX, pageY, {
callback: () => {
const win = window.open(target, '_blank');
win.focus();
},
name: tr("Open URL"),
type: contextmenu.MenuEntryType.ENTRY,
icon_class: "client-browse-addon-online"
}, {
callback: () => {
//TODO
},
name: tr("Open URL in Browser"),
type: contextmenu.MenuEntryType.ENTRY,
visible: __build.target === "client" && false // Currently not possible
}, contextmenu.Entry.HR(), {
callback: () => copy_to_clipboard(target),
name: tr("Copy URL to clipboard"),
type: contextmenu.MenuEntryType.ENTRY,
icon_class: "client-copy"
});
}
function load_image(entry: HTMLImageElement) {
if(!entry.hasAttribute("x-image-url"))
return;
const url = decodeURIComponent(entry.getAttribute("x-image-url") || "");
entry.removeAttribute("x-image-url");
let proxiedURL;
try {
const parsedURL = new URL(url);
if(parsedURL.hostname === "cdn.discordapp.com") {
proxiedURL = url;
}
} catch (e) { }
if(!proxiedURL) {
proxiedURL = "https://images.weserv.nl/?url=" + encodeURIComponent(url);
}
entry.onload = undefined;
entry.src = proxiedURL;
const parent = $(entry.parentElement);
parent.on('contextmenu', event => {
contextmenu.spawn_context_menu(event.pageX, event.pageY, {
callback: () => {
const win = window.open(url, '_blank');
win.focus();
},
name: tr("Open image in browser"),
type: contextmenu.MenuEntryType.ENTRY,
icon_class: "client-browse-addon-online"
}, contextmenu.Entry.HR(), {
callback: () => copy_to_clipboard(url),
name: tr("Copy image URL to clipboard"),
type: contextmenu.MenuEntryType.ENTRY,
icon_class: "client-copy"
})
});
parent.css("cursor", "pointer").on('click', () => image_preview.preview_image(proxiedURL, url));
}
loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
name: "XBBCode code tag init",
function: async () => {
let reactId = 0;
/* override default parser */
rendererReact.registerCustomRenderer(new class extends ElementRenderer<TagElement, React.ReactNode> {
tags(): string | string[] {
return ["code", "icode", "i-code"];
}
render(element: TagElement): React.ReactNode {
const klass = element.tagNormalized != 'code' ? "tag-hljs-inline-code" : "tag-hljs-code";
const language = (element.options || "").replace("\"", "'").toLowerCase();
let lines = rendererText.renderContent(element).join("").split("\n");
if(lines.length > 1) {
if(lines[0].length === 0)
lines = lines.slice(1);
if(lines[lines.length - 1]?.length === 0)
lines = lines.slice(0, lines.length - 1);
}
let result: HighlightJSResult;
const detectedLanguage = hljs.getLanguage(language);
if(detectedLanguage)
result = hljs.highlight(detectedLanguage.name, lines.join("\n"), true);
else
result = hljs.highlightAuto(lines.join("\n"));
return (
<pre key={"er-" + ++reactId} className={klass}>
<code
className={"hljs"}
title={tra("{} code", result.language || tr("general"))}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(result.value)
}}
onContextMenu={event => {
event.preventDefault();
spawn_context_menu(event.pageX, event.pageY, {
callback: () => copy_to_clipboard(lines.join("\n")),
name: tr("Copy code"),
type: contextmenu.MenuEntryType.ENTRY,
icon_class: "client-copy"
});
}}
/>
</pre>
);
}
});
const regexUrl = /^(?:[a-zA-Z]{1,16}):(?:\/{1,3}|\\)[-a-zA-Z0-9:;,@#%&()~_?+=\/\\.]*$/g;
rendererReact.registerCustomRenderer(new class extends ElementRenderer<TagElement, React.ReactNode> {
render(element: TagElement, renderer: ReactRenderer): React.ReactNode {
let target;
if (!element.options)
target = rendererText.render(element);
else
target = element.options;
regexUrl.lastIndex = 0;
if (!regexUrl.test(target))
target = '#';
/* TODO: Implement client URLs */
return <a key={"er-" + ++reactId} className={"xbbcode xbbcode-tag-url"} href={target} target={"_blank"} onContextMenu={event => {
event.preventDefault();
spawnUrlContextMenu(event.pageX, event.pageY, target);
}}>
{renderer.renderContent(element)}
</a>;
}
tags(): string | string[] {
return "url";
}
});
const regexImage = /^(?:https?):(?:\/{1,3}|\\)[-a-zA-Z0-9:;,@#%&()~_?+=\/\\.]*$/g;
rendererReact.registerCustomRenderer(new class extends ElementRenderer<TagElement, React.ReactNode> {
tags(): string | string[] {
return ["img", "image"];
}
render(element: TagElement): React.ReactNode {
let target;
let content = rendererText.render(element);
if (!element.options) {
target = content;
} else
target = element.options;
regexImage.lastIndex = 0;
if (!regexImage.test(target))
return <React.Fragment key={"er-" + ++reactId}>{"[img]" + content + "[/img]"}</React.Fragment>;
return (
<div key={"er-" + ++reactId} className={"xbbcode-tag-img"}>
<img src={"img/loading_image.svg"} onLoad={event => load_image(event.currentTarget)} x-image-url={encodeURIComponent(target)} title={target} alt={target} />
</div>
);
}
});
function toCodePoint(unicodeSurrogates) {
let r = [],
c = 0,
p = 0,
i = 0;
while (i < unicodeSurrogates.length) {
c = unicodeSurrogates.charCodeAt(i++);
if (p) {
r.push((0x10000 + ((p - 0xD800) << 10) + (c - 0xDC00)).toString(16));
p = 0;
} else if (0xD800 <= c && c <= 0xDBFF) {
p = c;
} else {
r.push(c.toString(16));
}
}
return r.join("-");
}
const U200D = String.fromCharCode(0x200D);
const UFE0Fg = /\uFE0F/g;
function grabTheRightIcon(rawText) {
// if variant is present as \uFE0F
return toCodePoint(rawText.indexOf(U200D) < 0 ?
rawText.replace(UFE0Fg, '') :
rawText
);
}
rendererReact.setTextRenderer(new class extends ElementRenderer<TextElement, React.ReactNode> {
render(element: TextElement, renderer: ReactRenderer): React.ReactNode {
if(!settings.static_global(Settings.KEY_CHAT_COLORED_EMOJIES))
return element.text();
let text = element.text();
emojiRegexInstance.lastIndex = 0;
const result = [];
let lastIndex = 0;
while(true) {
let match = emojiRegexInstance.exec(text);
const rawText = text.substring(lastIndex, match?.index);
if(rawText)
result.push(renderer.renderAsText(rawText, false));
if(!match)
break;
let hash = grabTheRightIcon(match[0]);
result.push(<img key={"er-" + ++reactId} draggable={false} src={"https://twemoji.maxcdn.com/v/12.1.2/72x72/" + hash + ".png"} alt={match[0]} className={"chat-emoji"} />);
lastIndex = match.index + match[0].length;
}
return result;
}
tags(): string | string[] { return undefined; }
});
},
priority: 10
});
}
export const BBCodeChatMessage = (props: { message: string }) => (
<XBBCodeRenderer options={{ tag_whitelist: bbcode.allowedBBCodes }} renderer={rendererReact}>
{bbcode.preprocessMessage(props.message, { is_chat_message: true })}
</XBBCodeRenderer>
);
export function sanitize_text(text: string) : string {
return $(DOMPurify.sanitize("<a>" + text + "</a>", {
})).text();
}
export function formatDate(secs: number) : string {
let years = Math.floor(secs / (60 * 60 * 24 * 365));
let days = Math.floor(secs / (60 * 60 * 24)) % 365;
let hours = Math.floor(secs / (60 * 60)) % 24;
let minutes = Math.floor(secs / 60) % 60;
let seconds = Math.floor(secs % 60);
let result = "";
if(years > 0)
result += years + " " + tr("years") + " ";
if(years > 0 || days > 0)
result += days + " " + tr("days") + " ";
if(years > 0 || days > 0 || hours > 0)
result += hours + " " + tr("hours") + " ";
if(years > 0 || days > 0 || hours > 0 || minutes > 0)
result += minutes + " " + tr("minutes") + " ";
if(years > 0 || days > 0 || hours > 0 || minutes > 0 || seconds > 0)
result += seconds + " " + tr("seconds") + " ";
else
result = tr("now") + " ";
return result.substr(0, result.length - 1);
}

View File

@ -18,9 +18,9 @@ import {ConnectionHandler, ConnectionState, DisconnectReason, ViewReasonId} from
import {bbcode_chat, formatMessage} from "tc-shared/ui/frames/chat"; import {bbcode_chat, formatMessage} from "tc-shared/ui/frames/chat";
import {server_connections} from "tc-shared/ui/frames/connection_handlers"; import {server_connections} from "tc-shared/ui/frames/connection_handlers";
import {spawnPoke} from "tc-shared/ui/modal/ModalPoke"; import {spawnPoke} from "tc-shared/ui/modal/ModalPoke";
import {PrivateConversationState} from "tc-shared/ui/frames/side/private_conversations";
import {AbstractCommandHandler, AbstractCommandHandlerBoss} from "tc-shared/connection/AbstractCommandHandler"; import {AbstractCommandHandler, AbstractCommandHandlerBoss} from "tc-shared/connection/AbstractCommandHandler";
import {batch_updates, BatchUpdateType, flush_batched_updates} from "tc-shared/ui/react-elements/ReactComponentBase"; import {batch_updates, BatchUpdateType, flush_batched_updates} from "tc-shared/ui/react-elements/ReactComponentBase";
import {OutOfViewClient} from "tc-shared/ui/frames/side/PrivateConversationManager";
export class ServerConnectionCommandBoss extends AbstractCommandHandlerBoss { export class ServerConnectionCommandBoss extends AbstractCommandHandlerBoss {
constructor(connection: AbstractServerConnection) { constructor(connection: AbstractServerConnection) {
@ -440,8 +440,10 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
client = new ClientEntry(parseInt(entry["clid"]), entry["client_nickname"]); client = new ClientEntry(parseInt(entry["clid"]), entry["client_nickname"]);
} }
/* TODO: Apply all other properties here as well and than register him */
client.properties.client_unique_identifier = entry["client_unique_identifier"];
client.properties.client_type = parseInt(entry["client_type"]); client.properties.client_type = parseInt(entry["client_type"]);
client = tree.insertClient(client, channel); client = tree.insertClient(client, channel, { reason: reason_id, isServerJoin: parseInt(entry["cfid"]) === 0 });
} else { } else {
tree.moveClient(client, channel); tree.moveClient(client, channel);
} }
@ -495,22 +497,6 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
client.updateVariables(...updates); client.updateVariables(...updates);
/* if its a new client join, or a system reason (like we joined) */
if(!old_channel || reason_id == 2) {
/* client new join */
const conversation_manager = this.connection_handler.side_bar.private_conversations();
const conversation = conversation_manager.find_conversation({
unique_id: client.properties.client_unique_identifier,
client_id: client.clientId(),
name: client.clientNickName()
}, {
create: false,
attach: true
});
if(conversation)
client.setUnread(conversation.is_unread());
}
if(client instanceof LocalClientEntry) { if(client instanceof LocalClientEntry) {
client.initializeListener(); client.initializeListener();
this.connection_handler.update_voice_status(); this.connection_handler.update_voice_status();
@ -548,11 +534,11 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
return; return;
} }
const targetChannelId = parseInt(entry["ctid"]);
if(this.connection_handler.areQueriesShown() || client.properties.client_type != ClientType.CLIENT_QUERY) { if(this.connection_handler.areQueriesShown() || client.properties.client_type != ClientType.CLIENT_QUERY) {
const own_channel = this.connection.client.getClient().currentChannel(); const own_channel = this.connection.client.getClient().currentChannel();
let channel_from = tree.findChannel(entry["cfid"]); let channel_from = tree.findChannel(entry["cfid"]);
let channel_to = tree.findChannel(entry["ctid"]); let channel_to = tree.findChannel(targetChannelId);
const is_own_channel = channel_from == own_channel; const is_own_channel = channel_from == own_channel;
this.connection_handler.log.log(server_log.Type.CLIENT_VIEW_LEAVE, { this.connection_handler.log.log(server_log.Type.CLIENT_VIEW_LEAVE, {
@ -585,25 +571,9 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
log.error(LogCategory.NETWORKING, tr("Unknown client left reason %d!"), reason_id); log.error(LogCategory.NETWORKING, tr("Unknown client left reason %d!"), reason_id);
} }
} }
if(!channel_to) {
/* client left the server */
const conversation_manager = this.connection_handler.side_bar.private_conversations();
const conversation = conversation_manager.find_conversation({
unique_id: client.properties.client_unique_identifier,
client_id: client.clientId(),
name: client.clientNickName()
}, {
create: false,
attach: false
});
if(conversation) {
conversation.set_state(PrivateConversationState.DISCONNECTED);
}
}
} }
tree.deleteClient(client); tree.deleteClient(client, { reason: reason_id, message: entry["reasonmsg"], serverLeave: targetChannelId === 0 });
} }
} }
@ -767,44 +737,41 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
let mode = json["targetmode"]; let mode = json["targetmode"];
if(mode == 1){ if(mode == 1){
//json["invokerid"], json["invokername"], json["invokeruid"] const targetClientId = parseInt(json["target"]);
const target_client_id = parseInt(json["target"]); const invokerClientId = parseInt(json["invokerid"]);
const target_own = target_client_id === this.connection.client.getClientId();
if(target_own && target_client_id === json["invokerid"]) { const targetClientEntry = this.connection_handler.channelTree.findClient(targetClientId);
log.error(LogCategory.NETWORKING, tr("Received conversation message from invalid client id. Data: %o"), json); const targetIsOwn = targetClientEntry instanceof LocalClientEntry;
if(targetIsOwn && targetClientId === invokerClientId) {
log.error(LogCategory.NETWORKING, tr("Received conversation message from our self. This should be impossible."), json);
return; return;
} }
const partnerClientEntry = targetIsOwn ? this.connection.client.channelTree.findClient(invokerClientId) : targetClientEntry;
const chatPartner = partnerClientEntry ? partnerClientEntry : {
clientId: targetIsOwn ? invokerClientId : targetClientId,
nickname: targetIsOwn ? json["invokername"] : undefined,
uniqueId: targetIsOwn ? json["invokeruid"] : undefined
} as OutOfViewClient;
const conversation_manager = this.connection_handler.side_bar.private_conversations(); const conversation_manager = this.connection_handler.side_bar.private_conversations();
const conversation = conversation_manager.find_conversation({ const conversation = conversation_manager.findOrCreateConversation(chatPartner);
client_id: target_own ? parseInt(json["invokerid"]) : target_client_id,
unique_id: target_own ? json["invokeruid"] : undefined,
name: target_own ? json["invokername"] : undefined
}, {
create: target_own,
attach: target_own
});
if(!conversation) {
log.error(LogCategory.NETWORKING, tr("Received conversation message for unknown conversation! (%s)"), target_own ? tr("Remote message") : tr("Own message"));
return;
}
conversation.append_message(json["msg"], { conversation.handleIncomingMessage(chatPartner, !targetIsOwn, {
type: target_own ? "partner" : "self", sender_database_id: targetClientEntry ? targetClientEntry.properties.client_database_id : 0,
name: json["invokername"], sender_name: json["invokername"],
unique_id: json["invokeruid"], sender_unique_id: json["invokeruid"],
client_id: parseInt(json["invokerid"])
});
if(target_own) { timestamp: Date.now(),
message: json["msg"]
});
if(targetIsOwn) {
this.connection_handler.sound.play(Sound.MESSAGE_RECEIVED, {default_volume: .5}); this.connection_handler.sound.play(Sound.MESSAGE_RECEIVED, {default_volume: .5});
const client = this.connection_handler.channelTree.findClient(parseInt(json["invokerid"]));
if(client) /* the client itself might be invisible */
client.setUnread(conversation.is_unread());
} else { } else {
this.connection_handler.sound.play(Sound.MESSAGE_SEND, {default_volume: .5}); this.connection_handler.sound.play(Sound.MESSAGE_SEND, {default_volume: .5});
} }
this.connection_handler.side_bar.info_frame().update_chat_counter();
} else if(mode == 2) { } else if(mode == 2) {
const invoker = this.connection_handler.channelTree.findClient(parseInt(json["invokerid"])); const invoker = this.connection_handler.channelTree.findClient(parseInt(json["invokerid"]));
const own_channel_id = this.connection.client.getClient().currentChannel().channelId; const own_channel_id = this.connection.client.getClient().currentChannel().channelId;
@ -826,10 +793,8 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
sender_unique_id: json["invokeruid"], sender_unique_id: json["invokeruid"],
timestamp: typeof(json["timestamp"]) === "undefined" ? Date.now() : parseInt(json["timestamp"]), timestamp: typeof(json["timestamp"]) === "undefined" ? Date.now() : parseInt(json["timestamp"]),
message: json["msg"], message: json["msg"]
}, invoker instanceof LocalClientEntry);
unique_id: Date.now() + " - " + Math.random()
}, !(invoker instanceof LocalClientEntry));
} else if(mode == 3) { } else if(mode == 3) {
this.connection_handler.log.log(server_log.Type.GLOBAL_MESSAGE, { this.connection_handler.log.log(server_log.Type.GLOBAL_MESSAGE, {
message: json["msg"], message: json["msg"],
@ -852,10 +817,8 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
sender_unique_id: json["invokeruid"], sender_unique_id: json["invokeruid"],
timestamp: typeof(json["timestamp"]) === "undefined" ? Date.now() : parseInt(json["timestamp"]), timestamp: typeof(json["timestamp"]) === "undefined" ? Date.now() : parseInt(json["timestamp"]),
message: json["msg"], message: json["msg"]
}, invoker instanceof LocalClientEntry);
unique_id: Date.now() + " - " + Math.random()
}, !(invoker instanceof LocalClientEntry));
} }
} }
@ -863,42 +826,21 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
json = json[0]; json = json[0];
const conversation_manager = this.connection_handler.side_bar.private_conversations(); const conversation_manager = this.connection_handler.side_bar.private_conversations();
const conversation = conversation_manager.find_conversation({ const conversation = conversation_manager.findConversation(json["cluid"]);
client_id: parseInt(json["clid"]), conversation?.handleRemoteComposing(parseInt(json["clid"]));
unique_id: json["cluid"],
name: undefined
}, {
create: false,
attach: false
});
if(!conversation)
return;
conversation.trigger_typing();
} }
handleNotifyClientChatClosed(json) { handleNotifyClientChatClosed(json) {
json = json[0]; //Only one bulk json = json[0]; //Only one bulk
//Chat partner has closed the conversation
//clid: "6"
//cluid: "YoWmG+dRGKD+Rxb7SPLAM5+B9tY="
const conversation_manager = this.connection_handler.side_bar.private_conversations(); const conversation_manager = this.connection_handler.side_bar.private_conversations();
const conversation = conversation_manager.find_conversation({ const conversation = conversation_manager.findConversation(json["cluid"]);
client_id: parseInt(json["clid"]),
unique_id: json["cluid"],
name: undefined
}, {
create: false,
attach: false
});
if(!conversation) { if(!conversation) {
log.warn(LogCategory.GENERAL, tr("Received chat close for client, but we haven't a chat open.")); log.warn(LogCategory.GENERAL, tr("Received chat close for client, but we haven't a chat open."));
return; return;
} }
conversation.set_state(PrivateConversationState.CLOSED);
conversation.handleChatRemotelyClosed(parseInt(json["clid"]));
} }
handleNotifyClientUpdated(json) { handleNotifyClientUpdated(json) {
@ -1037,7 +979,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
channel.flag_subscribed = false; channel.flag_subscribed = false;
for(const client of channel.clients(false)) for(const client of channel.clients(false))
this.connection.client.channelTree.deleteClient(client); this.connection.client.channelTree.deleteClient(client, { reason: ViewReasonId.VREASON_SYSTEM, serverLeave: false });
} }
} }

View File

@ -161,6 +161,7 @@ export class Registry<Events> {
} }
fire_async<T extends keyof Events>(event_type: T, data?: Events[T], callback?: () => void) { fire_async<T extends keyof Events>(event_type: T, data?: Events[T], callback?: () => void) {
/* TODO: Optimize, bundle them */
setTimeout(() => { setTimeout(() => {
this.fire(event_type, data); this.fire(event_type, data);
if(typeof callback === "function") if(typeof callback === "function")
@ -174,27 +175,39 @@ export class Registry<Events> {
this.event_handler_objects = []; this.event_handler_objects = [];
} }
register_handler(handler: any) { register_handler(handler: any, parentClasses?: boolean) {
if(typeof handler !== "object") if(typeof handler !== "object")
throw "event handler must be an object"; throw "event handler must be an object";
const proto = Object.getPrototypeOf(handler); const proto = Object.getPrototypeOf(handler);
if(typeof proto !== "object") if(typeof proto !== "object")
throw "event handler must have a prototype"; throw "event handler must have a prototype";
let currentPrototype = proto;
let registered_events = {}; let registered_events = {};
for(const function_name of Object.getOwnPropertyNames(proto)) { do {
if(function_name === "constructor") continue; Object.getOwnPropertyNames(currentPrototype).forEach(function_name => {
if(typeof proto[function_name] !== "function") continue; if(function_name === "constructor")
if(typeof proto[function_name][event_annotation_key] !== "object") continue; return;
const event_data = proto[function_name][event_annotation_key]; if(typeof proto[function_name] !== "function")
const ev_handler = event => proto[function_name].call(handler, event); return;
for(const event of event_data.events) {
registered_events[event] = registered_events[event] || []; if(typeof proto[function_name][event_annotation_key] !== "object")
registered_events[event].push(ev_handler); return;
this.on(event, ev_handler);
} const event_data = proto[function_name][event_annotation_key];
} const ev_handler = event => proto[function_name].call(handler, event);
for(const event of event_data.events) {
registered_events[event] = registered_events[event] || [];
registered_events[event].push(ev_handler);
this.on(event, ev_handler);
}
});
if(!parentClasses)
break;
} while ((currentPrototype = Object.getPrototypeOf(currentPrototype)))
if(Object.keys(registered_events).length === 0) { if(Object.keys(registered_events).length === 0) {
console.warn(tr("no events found in event handler")); console.warn(tr("no events found in event handler"));
return; return;

View File

@ -254,20 +254,30 @@ export class AvatarManager {
} }
update_cache(clientAvatarId: string, clientAvatarHash: string) { update_cache(clientAvatarId: string, clientAvatarHash: string) {
AvatarManager.cache.setup().then(() => { AvatarManager.cache.setup().then(async () => {
const deletePromise = AvatarManager.cache.delete("avatar_" + clientAvatarId).catch(error => {
log.warn(LogCategory.FILE_TRANSFER, tr("Failed to delete avatar %s: %o"), clientAvatarId, error);
});
const cached = this.cachedAvatars[clientAvatarId]; const cached = this.cachedAvatars[clientAvatarId];
if(!cached || cached.currentAvatarHash === clientAvatarHash) return; if(cached) {
if(cached.currentAvatarHash === clientAvatarHash)
return;
log.info(LogCategory.GENERAL, tr("Deleting cached avatar for client %s. Cached version: %s; New version: %s"), cached.currentAvatarHash, clientAvatarHash); log.info(LogCategory.GENERAL, tr("Deleting cached avatar for client %s. Cached version: %s; New version: %s"), cached.currentAvatarHash, clientAvatarHash);
deletePromise.then(() => { }
const response = await AvatarManager.cache.resolve_cached('avatar_' + clientAvatarId);
if(response) {
let cachedAvatarHash = response.headers.has("X-avatar-version") ? response.headers.get("X-avatar-version") : undefined;
if(cachedAvatarHash !== clientAvatarHash) {
await AvatarManager.cache.delete("avatar_" + clientAvatarId).catch(error => {
log.warn(LogCategory.FILE_TRANSFER, tr("Failed to delete avatar %s: %o"), clientAvatarId, error);
});
}
}
if(cached) {
cached.currentAvatarHash = clientAvatarHash; cached.currentAvatarHash = clientAvatarHash;
cached.events.fire("avatar_changed"); cached.events.fire("avatar_changed");
this.executeAvatarLoad(cached); this.executeAvatarLoad(cached);
}); }
}); });
} }

View File

@ -128,7 +128,7 @@ export class ImageCache {
ignoreSearch: true ignoreSearch: true
}); });
if(!flag) { if(!flag) {
console.warn(tr("Failed to delete key %s from cache!"), flag); console.warn(tr("Failed to delete key %s from cache!"), key);
} }
} }
} }

View File

@ -43,11 +43,6 @@ declare global {
format(arguments: string[]): string; format(arguments: string[]): string;
} }
interface Twemoji {
parse(message: string) : string;
}
let twemoji: Twemoji;
interface HighlightJS { interface HighlightJS {
listLanguages() : string[]; listLanguages() : string[];
getLanguage(name: string) : any | undefined; getLanguage(name: string) : any | undefined;
@ -75,8 +70,7 @@ declare global {
readonly Pointer_stringify: any; readonly Pointer_stringify: any;
readonly jsrender: any; readonly jsrender: any;
twemoji: Twemoji; cdhljs: HighlightJS;
hljs: HighlightJS;
remarkable: any; remarkable: any;

View File

@ -5,6 +5,7 @@ import {LogCategory, LogType} from "tc-shared/log";
import {PermissionType} from "tc-shared/permission/PermissionType"; import {PermissionType} from "tc-shared/permission/PermissionType";
import {settings, Settings} from "tc-shared/settings"; import {settings, Settings} from "tc-shared/settings";
import * as contextmenu from "tc-shared/ui/elements/ContextMenu"; import * as contextmenu from "tc-shared/ui/elements/ContextMenu";
import {MenuEntryType} from "tc-shared/ui/elements/ContextMenu";
import {Sound} from "tc-shared/sound/Sounds"; import {Sound} from "tc-shared/sound/Sounds";
import {createErrorModal, createInfoModal, createInputModal} from "tc-shared/ui/elements/Modal"; import {createErrorModal, createInfoModal, createInputModal} from "tc-shared/ui/elements/Modal";
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration"; import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
@ -18,9 +19,9 @@ import {formatMessage} from "tc-shared/ui/frames/chat";
import * as React from "react"; import * as React from "react";
import {Registry} from "tc-shared/events"; import {Registry} from "tc-shared/events";
import {ChannelTreeEntry, ChannelTreeEntryEvents} from "tc-shared/ui/TreeEntry"; import {ChannelTreeEntry, ChannelTreeEntryEvents} from "tc-shared/ui/TreeEntry";
import { ChannelEntryView as ChannelEntryView } from "./tree/Channel"; import {ChannelEntryView as ChannelEntryView} from "./tree/Channel";
import {MenuEntryType} from "tc-shared/ui/elements/ContextMenu";
import {spawnFileTransferModal} from "tc-shared/ui/modal/transfer/ModalFileTransfer"; import {spawnFileTransferModal} from "tc-shared/ui/modal/transfer/ModalFileTransfer";
import {ViewReasonId} from "tc-shared/ConnectionHandler";
export enum ChannelType { export enum ChannelType {
PERMANENT, PERMANENT,
@ -641,6 +642,9 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
} }
joinChannel(ignorePasswordFlag?: boolean) { joinChannel(ignorePasswordFlag?: boolean) {
if(this.channelTree.client.getClient().currentChannel() === this)
return;
if(this.properties.channel_flag_password === true && !this.cachedPasswordHash && !ignorePasswordFlag) { if(this.properties.channel_flag_password === true && !this.cachedPasswordHash && !ignorePasswordFlag) {
this.requestChannelPassword(PermissionType.B_CHANNEL_JOIN_IGNORE_PASSWORD).then(() => { this.requestChannelPassword(PermissionType.B_CHANNEL_JOIN_IGNORE_PASSWORD).then(() => {
this.joinChannel(true); this.joinChannel(true);
@ -719,7 +723,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
this.flag_subscribed = false; this.flag_subscribed = false;
for(const client of this.clients(false)) for(const client of this.clients(false))
this.channelTree.deleteClient(client, false); this.channelTree.deleteClient(client, { serverLeave: false, reason: ViewReasonId.VREASON_SYSTEM });
} }
} }

View File

@ -138,7 +138,12 @@ export class ClientConnectionInfo {
export interface ClientEvents extends ChannelTreeEntryEvents { export interface ClientEvents extends ChannelTreeEntryEvents {
"notify_enter_view": {}, "notify_enter_view": {},
"notify_left_view": {}, notify_client_moved: { oldChannel: ChannelEntry, newChannel: ChannelEntry }
"notify_left_view": {
reason: ViewReasonId;
message?: string;
serverLeave: boolean;
},
notify_properties_updated: { notify_properties_updated: {
updated_properties: {[Key in keyof ClientProperties]: ClientProperties[Key]}; updated_properties: {[Key in keyof ClientProperties]: ClientProperties[Key]};
@ -496,17 +501,11 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
open_text_chat() { open_text_chat() {
const chat = this.channelTree.client.side_bar; const chat = this.channelTree.client.side_bar;
const conversation = chat.private_conversations().find_conversation({ const conversation = chat.private_conversations().findOrCreateConversation(this);
name: this.clientNickName(), conversation.setActiveClientEntry(this);
client_id: this.clientId(), chat.private_conversations().setActiveConversation(conversation);
unique_id: this.clientUid()
}, {
attach: true,
create: true
});
chat.private_conversations().set_selected_conversation(conversation);
chat.show_private_conversations(); chat.show_private_conversations();
chat.private_conversations().try_input_focus(); chat.private_conversations().focusInput();
} }
showContextMenu(x: number, y: number, on_close: () => void = undefined) { showContextMenu(x: number, y: number, on_close: () => void = undefined) {
@ -753,17 +752,6 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
} }
} }
const chat = this.channelTree.client.side_bar;
const conversation = chat.private_conversations().find_conversation({
name: this.clientNickName(),
client_id: this.clientId(),
unique_id: this.clientUid()
}, {
attach: false,
create: false
});
if(conversation)
conversation.set_client_name(variable.value);
reorder_channel = true; reorder_channel = true;
} }
if(variable.key == "client_unique_identifier") { if(variable.key == "client_unique_identifier") {
@ -798,14 +786,9 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
if(client_info.current_client() === this) if(client_info.current_client() === this)
client_info.set_current_client(this, true); /* force an update */ client_info.set_current_client(this, true); /* force an update */
} }
if(update_avatar) {
this.channelTree.client.fileManager.avatars.update_cache(this.avatarId(), this.properties.client_flag_avatar);
const conversations = side_bar.private_conversations(); if(update_avatar)
const conversation = conversations.find_conversation({name: this.clientNickName(), unique_id: this.clientUid(), client_id: this.clientId()}, {create: false, attach: false}); this.channelTree.client.fileManager.avatars.update_cache(this.avatarId(), this.properties.client_flag_avatar);
if(conversation)
conversation.update_avatar();
}
/* devel-block(log-client-property-updates) */ /* devel-block(log-client-property-updates) */
group.end(); group.end();

View File

@ -3,6 +3,8 @@ import {settings, Settings} from "tc-shared/settings";
import * as log from "tc-shared/log"; import * as log from "tc-shared/log";
import {bbcode} from "tc-shared/MessageFormatter"; import {bbcode} from "tc-shared/MessageFormatter";
import * as loader from "tc-loader"; import * as loader from "tc-loader";
import { XBBCodeRenderer } from "vendor/xbbcode/react";
import * as React from "react";
export enum ChatType { export enum ChatType {
GENERAL, GENERAL,
@ -142,6 +144,7 @@ export function bbcode_chat(message: string) : JQuery[] {
}); });
} }
export namespace network { export namespace network {
export const KB = 1024; export const KB = 1024;
export const MB = 1024 * KB; export const MB = 1024 * KB;
@ -278,7 +281,7 @@ export function set_icon_size(size: string) {
_icon_size_style = $.spawn("style").appendTo($("#style")); _icon_size_style = $.spawn("style").appendTo($("#style"));
_icon_size_style.text("\n" + _icon_size_style.text("\n" +
".message > .emoji {\n" + ".chat-emoji {\n" +
" height: " + size + "!important;\n" + " height: " + size + "!important;\n" +
" width: " + size + "!important;\n" + " width: " + size + "!important;\n" +
"}\n" "}\n"

View File

@ -5,10 +5,11 @@ import {ChannelEntry} from "tc-shared/ui/channel";
import {ServerEntry} from "tc-shared/ui/server"; import {ServerEntry} from "tc-shared/ui/server";
import {openMusicManage} from "tc-shared/ui/modal/ModalMusicManage"; import {openMusicManage} from "tc-shared/ui/modal/ModalMusicManage";
import {formatMessage} from "tc-shared/ui/frames/chat"; import {formatMessage} from "tc-shared/ui/frames/chat";
import {PrivateConverations} from "tc-shared/ui/frames/side/private_conversations"; import {PrivateConverations} from "tc-shared/ui/frames/side/private_conversations_old";
import {ClientInfo} from "tc-shared/ui/frames/side/client_info"; import {ClientInfo} from "tc-shared/ui/frames/side/client_info";
import {MusicInfo} from "tc-shared/ui/frames/side/music_info"; import {MusicInfo} from "tc-shared/ui/frames/side/music_info";
import {ConversationManager} from "tc-shared/ui/frames/side/ConversationManager"; import {ConversationManager} from "tc-shared/ui/frames/side/ConversationManager";
import {PrivateConversationManager} from "tc-shared/ui/frames/side/PrivateConversationManager";
declare function setInterval(handler: TimerHandler, timeout?: number, ...arguments: any[]): number; declare function setInterval(handler: TimerHandler, timeout?: number, ...arguments: any[]): number;
declare function setTimeout(handler: TimerHandler, timeout?: number, ...arguments: any[]): number; declare function setTimeout(handler: TimerHandler, timeout?: number, ...arguments: any[]): number;
@ -64,14 +65,10 @@ export class InfoFrame {
const selected_client = this.handle.client_info().current_client(); const selected_client = this.handle.client_info().current_client();
if(!selected_client) return; if(!selected_client) return;
const conversation = selected_client ? this.handle.private_conversations().find_conversation({ const conversation = selected_client ? this.handle.private_conversations().findOrCreateConversation(selected_client) : undefined;
name: selected_client.properties.client_nickname,
unique_id: selected_client.properties.client_unique_identifier,
client_id: selected_client.clientId()
}, { create: true, attach: true }) : undefined;
if(!conversation) return; if(!conversation) return;
this.handle.private_conversations().set_selected_conversation(conversation); this.handle.private_conversations().setActiveConversation(conversation);
this.handle.show_private_conversations(); this.handle.show_private_conversations();
})[0]; })[0];
@ -214,9 +211,9 @@ export class InfoFrame {
} }
update_chat_counter() { update_chat_counter() {
const conversations = this.handle.private_conversations().conversations(); const privateConversations = this.handle.private_conversations().getConversations();
{ {
const count = conversations.filter(e => e.is_unread()).length; const count = privateConversations.filter(e => e.hasUnreadMessages()).length;
const count_container = this._html_tag.find(".container-indicator"); const count_container = this._html_tag.find(".container-indicator");
const count_tag = count_container.find(".chat-unread-counter"); const count_tag = count_container.find(".chat-unread-counter");
count_container.toggle(count > 0); count_container.toggle(count > 0);
@ -224,12 +221,12 @@ export class InfoFrame {
} }
{ {
const count_tag = this._html_tag.find(".chat-counter"); const count_tag = this._html_tag.find(".chat-counter");
if(conversations.length == 0) if(privateConversations.length == 0)
count_tag.text(tr("No conversations")); count_tag.text(tr("No conversations"));
else if(conversations.length == 1) else if(privateConversations.length == 1)
count_tag.text(tr("One conversation")); count_tag.text(tr("One conversation"));
else else
count_tag.text(conversations.length + " " + tr("conversations")); count_tag.text(privateConversations.length + " " + tr("conversations"));
} }
} }
@ -245,11 +242,7 @@ export class InfoFrame {
if(mode === InfoFrameMode.CLIENT_INFO && this._button_conversation) { if(mode === InfoFrameMode.CLIENT_INFO && this._button_conversation) {
//Will be called every time a client is shown //Will be called every time a client is shown
const selected_client = this.handle.client_info().current_client(); const selected_client = this.handle.client_info().current_client();
const conversation = selected_client ? this.handle.private_conversations().find_conversation({ const conversation = selected_client ? this.handle.private_conversations().findConversation(selected_client) : undefined;
name: selected_client.properties.client_nickname,
unique_id: selected_client.properties.client_unique_identifier,
client_id: selected_client.clientId()
}, { create: false, attach: false }) : undefined;
const visibility = (selected_client && selected_client.clientId() !== this.handle.handle.getClientId()) ? "visible" : "hidden"; const visibility = (selected_client && selected_client.clientId() !== this.handle.handle.getClientId()) ? "visible" : "hidden";
if(this._button_conversation.style.visibility !== visibility) if(this._button_conversation.style.visibility !== visibility)
@ -281,17 +274,17 @@ export class Frame {
private _container_chat: JQuery; private _container_chat: JQuery;
private _content_type: FrameContent; private _content_type: FrameContent;
private _conversations: PrivateConverations;
private _client_info: ClientInfo; private _client_info: ClientInfo;
private _music_info: MusicInfo; private _music_info: MusicInfo;
private _channel_conversations: ConversationManager; private _channel_conversations: ConversationManager;
private _private_conversations: PrivateConversationManager;
constructor(handle: ConnectionHandler) { constructor(handle: ConnectionHandler) {
this.handle = handle; this.handle = handle;
this._content_type = FrameContent.NONE; this._content_type = FrameContent.NONE;
this._info_frame = new InfoFrame(this); this._info_frame = new InfoFrame(this);
this._conversations = new PrivateConverations(this); this._private_conversations = new PrivateConversationManager(handle);
this._channel_conversations = new ConversationManager(handle); this._channel_conversations = new ConversationManager(handle);
this._client_info = new ClientInfo(this); this._client_info = new ClientInfo(this);
this._music_info = new MusicInfo(this); this._music_info = new MusicInfo(this);
@ -313,17 +306,17 @@ export class Frame {
this._info_frame && this._info_frame.destroy(); this._info_frame && this._info_frame.destroy();
this._info_frame = undefined; this._info_frame = undefined;
this._conversations && this._conversations.destroy();
this._conversations = undefined;
this._client_info && this._client_info.destroy(); this._client_info && this._client_info.destroy();
this._client_info = undefined; this._client_info = undefined;
this._music_info && this._music_info.destroy(); this._music_info && this._music_info.destroy();
this._music_info = undefined; this._music_info = undefined;
//this._channel_conversations && this._channel_conversations.destroy(); this._private_conversations && this._private_conversations.destroy();
//this._channel_conversations = undefined; this._private_conversations = undefined;
this._channel_conversations && this._channel_conversations.destroy();
this._channel_conversations = undefined;
this._container_info && this._container_info.remove(); this._container_info && this._container_info.remove();
this._container_info = undefined; this._container_info = undefined;
@ -341,8 +334,8 @@ export class Frame {
} }
private_conversations() : PrivateConverations { private_conversations() : PrivateConversationManager {
return this._conversations; return this._private_conversations;
} }
channel_conversations() : ConversationManager { channel_conversations() : ConversationManager {
@ -365,10 +358,11 @@ export class Frame {
show_private_conversations() { show_private_conversations() {
if(this._content_type === FrameContent.PRIVATE_CHAT) if(this._content_type === FrameContent.PRIVATE_CHAT)
return; return;
this._clear(); this._clear();
this._content_type = FrameContent.PRIVATE_CHAT; this._content_type = FrameContent.PRIVATE_CHAT;
this._container_chat.append(this._conversations.html_tag()); this._container_chat.append(this._private_conversations.htmlTag);
this._conversations.on_show(); this._private_conversations.handlePanelShow();
this._info_frame.set_mode(InfoFrameMode.PRIVATE_CHAT); this._info_frame.set_mode(InfoFrameMode.PRIVATE_CHAT);
} }

View File

@ -2,6 +2,8 @@
@import "../../../../css/static/properties"; @import "../../../../css/static/properties";
.container { .container {
@include user-select(none);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;

View File

@ -15,7 +15,8 @@ interface ChatBoxEvents {
message: string message: string
}, },
action_insert_text: { action_insert_text: {
text: string text: string,
focus?: boolean
}, },
notify_typing: {} notify_typing: {}
@ -47,6 +48,7 @@ const EmojiButton = (props: { events: Registry<ChatBoxEvents> }) => {
}); });
props.events.reactUse("action_set_enabled", event => setEnabled(event.enabled)); props.events.reactUse("action_set_enabled", event => setEnabled(event.enabled));
props.events.reactUse("action_submit_message", () => setShown(false));
return ( return (
<div className={cssStyle.containerEmojis} ref={refContainer}> <div className={cssStyle.containerEmojis} ref={refContainer}>
@ -65,7 +67,7 @@ const EmojiButton = (props: { events: Registry<ChatBoxEvents> }) => {
onSelect={(emoji: any) => { onSelect={(emoji: any) => {
if(enabled) { if(enabled) {
props.events.fire("action_insert_text", { text: emoji.native }); props.events.fire("action_insert_text", { text: emoji.native, focus: true });
} }
}} }}
/> />
@ -222,7 +224,11 @@ const TextInput = (props: { events: Registry<ChatBoxEvents>, enabled?: boolean,
typingTimeout.current = setTimeout(() => typingTimeout.current = undefined, 1000); typingTimeout.current = setTimeout(() => typingTimeout.current = undefined, 1000);
}); });
props.events.reactUse("action_insert_text", event => refInput.current.innerHTML = refInput.current.innerHTML + event.text); props.events.reactUse("action_insert_text", event => {
refInput.current.innerHTML = refInput.current.innerHTML + event.text;
if(event.focus)
refInput.current.focus();
});
props.events.reactUse("action_set_enabled", event => { props.events.reactUse("action_set_enabled", event => {
setEnabled(event.enabled); setEnabled(event.enabled);
if(!event.enabled) { if(!event.enabled) {
@ -263,7 +269,7 @@ export interface ChatBoxState {
export class ChatBox extends React.Component<ChatBoxProperties, ChatBoxState> { export class ChatBox extends React.Component<ChatBoxProperties, ChatBoxState> {
readonly events = new Registry<ChatBoxEvents>(); readonly events = new Registry<ChatBoxEvents>();
private callbackSubmit = event => this.props.onSubmit(event.message); private callbackSubmit = event => this.props.onSubmit(event.message);
private callbackType = event => this.props.onType && this.props.onType(); private callbackType = () => this.props.onType && this.props.onType();
constructor(props) { constructor(props) {
super(props); super(props);

View File

@ -0,0 +1,164 @@
export interface ChatMessage {
timestamp: number;
message: string;
sender_name: string;
sender_unique_id: string;
sender_database_id: number;
}
/* ---------- Chat events ---------- */
export type ChatEvent = { timestamp: number; uniqueId: string; } & (
ChatEventMessage |
ChatEventMessageSendFailed |
ChatEventLocalUserSwitch |
ChatEventQueryFailed |
ChatEventPartnerInstanceChanged |
ChatEventLocalAction |
ChatEventPartnerAction
);
export interface ChatEventMessageSendFailed {
type: "message-failed";
error: "permission" | "error";
failedPermission?: string;
errorMessage?: string;
}
export interface ChatEventMessage {
type: "message";
message: ChatMessage;
isOwnMessage: boolean;
}
export interface ChatEventLocalUserSwitch {
type: "local-user-switch";
mode: "join" | "leave";
}
export interface ChatEventQueryFailed {
type: "query-failed";
message: string;
}
export interface ChatEventPartnerInstanceChanged {
type: "partner-instance-changed";
oldClient: string;
newClient: string;
}
export interface ChatEventLocalAction {
type: "local-action";
action: "disconnect" | "reconnect";
}
export interface ChatEventPartnerAction {
type: "partner-action";
action: "disconnect" | "close" | "reconnect";
}
/* ---------- Chat States ---------- */
export type ChatState = "normal" | "loading" | "no-permissions" | "error" | "unloaded";
export type ChatHistoryState = "none" | "loading" | "available" | "error";
export interface ChatStateNormal {
state: "normal",
chatFrameMaxMessageCount: number;
sendEnabled: boolean;
unreadTimestamp: number | undefined,
events: ChatEvent[],
historyState: ChatHistoryState;
historyErrorMessage: string,
historyRetryTimestamp: number,
showUserSwitchEvents: boolean
}
export interface ChatStateNoPermissions {
state: "no-permissions",
failedPermission: string;
}
export interface ChatStateError {
state: "error";
errorMessage: string;
}
export interface ChatStateLoading {
state: "loading";
}
export interface ChatStatePrivate {
state: "private";
crossChannelChatSupported: boolean;
}
export type ChatStateData = ChatStateNormal | ChatStateNoPermissions | ChatStateError | ChatStateLoading | ChatStatePrivate;
export interface ConversationUIEvents {
action_select_chat: { chatId: "unselected" | string },
action_clear_unread_flag: { chatId: string },
action_self_typing: { chatId: string },
action_delete_message: { chatId: string, uniqueId: string },
action_send_message: { text: string, chatId: string },
action_jump_to_present: { chatId: string },
action_focus_chat: {},
query_conversation_state: { chatId: string }, /* will cause a notify_conversation_state */
notify_conversation_state: { chatId: string } & ChatStateData,
query_conversation_history: { chatId: string, timestamp: number },
notify_conversation_history: {
chatId: string;
state: "success" | "error";
errorMessage?: string;
retryTimestamp?: number;
events?: ChatEvent[];
hasMoreMessages?: boolean;
}
notify_selected_chat: { chatId: "unselected" | string },
notify_panel_show: {},
notify_chat_event: {
chatId: string,
triggerUnread: boolean,
event: ChatEvent
},
notify_chat_message_delete: {
chatId: string,
criteria: { begin: number, end: number, cldbid: number, limit: number }
},
notify_unread_timestamp_changed: {
chatId: string,
timestamp: number | undefined
}
notify_private_state_changed: {
chatId: string,
private: boolean,
}
notify_send_enabled: {
chatId: string,
enabled: boolean
}
notify_partner_typing: { chatId: string },
notify_destroy: {}
}
export interface ConversationHistoryResponse {
status: "success" | "error" | "no-permission" | "private" | "unsupported";
events?: ChatEvent[];
moreEvents?: boolean;
nextAllowedQuery?: number;
errorMessage?: string;
failedPermission?: string;
}

View File

@ -1,5 +1,4 @@
import * as React from "react"; import * as React from "react";
import {ConversationPanel} from "tc-shared/ui/frames/side/Conversations";
import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler"; import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler";
import {EventHandler, Registry} from "tc-shared/events"; import {EventHandler, Registry} from "tc-shared/events";
import * as log from "tc-shared/log"; import * as log from "tc-shared/log";
@ -7,129 +6,420 @@ import {LogCategory} from "tc-shared/log";
import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration"; import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration";
import {ServerCommand} from "tc-shared/connection/ConnectionBase"; import {ServerCommand} from "tc-shared/connection/ConnectionBase";
import {Settings} from "tc-shared/settings"; import {Settings} from "tc-shared/settings";
import ReactDOM = require("react-dom"); import {tra, traj} from "tc-shared/i18n/localize";
import {traj} from "tc-shared/i18n/localize";
import {createErrorModal} from "tc-shared/ui/elements/Modal"; import {createErrorModal} from "tc-shared/ui/elements/Modal";
import {helpers} from "tc-shared/ui/frames/side/chat_helper";
import ReactDOM = require("react-dom");
import {
ChatEvent,
ChatEventMessage, ChatHistoryState,
ChatMessage, ConversationHistoryResponse,
ChatState,
ConversationUIEvents
} from "tc-shared/ui/frames/side/ConversationDefinitions";
import {ConversationPanel} from "tc-shared/ui/frames/side/ConversationUI";
export type ChatEvent = { timestamp: number; uniqueId: string; } & (ChatEventMessage | ChatEventMessageSendFailed); const kMaxChatFrameMessageSize = 50; /* max 100 messages, since the server does not support more than 100 messages queried at once */
export interface ChatMessage { export abstract class AbstractChat<Events extends ConversationUIEvents> {
timestamp: number; protected readonly connection: ConnectionHandler;
message: string; protected readonly chatId: string;
protected readonly events: Registry<Events>;
protected presentMessages: ChatEvent[] = [];
protected presentEvents: Exclude<ChatEvent, ChatEventMessage>[] = []; /* everything excluding chat messages */
sender_name: string; protected mode: ChatState = "unloaded";
sender_unique_id: string; protected failedPermission: string;
sender_database_id: number; protected errorMessage: string;
}
export interface ChatEventMessage { protected conversationPrivate: boolean = false;
type: "message"; protected crossChannelChatSupported: boolean = true;
message: ChatMessage;
}
export interface ChatEventMessageSendFailed { protected unreadTimestamp: number | undefined = undefined;
type: "message-failed"; protected lastReadMessage: number = 0;
error: "permission" | "error"; protected historyErrorMessage: string;
failedPermission?: string; protected historyRetryTimestamp: number = 0;
errorMessage?: string; protected executingUIHistoryQuery = false;
}
export interface ConversationUIEvents { protected messageSendEnabled: boolean = true;
action_select_conversation: { chatId: number },
action_clear_unread_flag: { chatId: number },
action_delete_message: { chatId: number, uniqueId: string },
action_send_message: { text: string, chatId: number },
query_conversation_state: { chatId: number }, /* will cause a notify_conversation_state */ protected hasHistory = false;
notify_conversation_state: {
id: number,
mode: "normal" | "no-permissions" | "error" | "loading" | "private", protected constructor(connection: ConnectionHandler, chatId: string, events: Registry<Events>) {
failedPermission?: string, this.connection = connection;
errorMessage?: string; this.events = events;
this.chatId = chatId;
unreadTimestamp: number | undefined,
events: ChatEvent[],
haveOlderMessages: boolean,
conversationPrivate: boolean
},
notify_panel_show: {},
notify_local_client_channel: {
channelId: number
},
notify_chat_event: {
conversation: number,
triggerUnread: boolean,
event: ChatEvent
},
notify_chat_message_delete: {
conversation: number,
criteria: { begin: number, end: number, cldbid: number, limit: number }
},
notify_server_state: {
state: "disconnected" | "connected",
crossChannelChatSupport?: boolean
},
notify_unread_timestamp_changed: {
conversation: number,
timestamp: number | undefined
}
notify_channel_private_state_changed: {
id: number,
private: boolean
} }
notify_destroy: {} public currentMode() : ChatState { return this.mode; };
protected 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.hasHistory = true;
index -= deleteMessageCount;
if(event.isOwnMessage)
this.setUnreadTimestamp(undefined);
else if(!this.unreadTimestamp)
this.unreadTimestamp = event.message.timestamp;
/* let all other events run before */
this.events.fire_async("notify_chat_event", {
chatId: this.chatId,
triggerUnread: triggerUnread,
event: event
});
} else {
this.presentEvents.push(event);
this.presentEvents.sort((a, b) => a.timestamp - b.timestamp);
/* TODO: Cutoff too old events! */
this.events.fire("notify_chat_event", {
chatId: this.chatId,
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);
}
public reportStateToUI() {
let historyState: ChatHistoryState;
if(Date.now() < this.historyRetryTimestamp && this.historyErrorMessage) {
historyState = "error";
} else if(this.executingUIHistoryQuery) {
historyState = "loading";
} else if(this.hasHistory) {
historyState = "available";
} else {
historyState = "none";
}
switch (this.mode) {
case "normal":
if(this.conversationPrivate && !this.canClientAccessChat()) {
this.events.fire_async("notify_conversation_state", {
chatId: this.chatId,
state: "private",
crossChannelChatSupported: this.crossChannelChatSupported
});
return;
}
this.events.fire_async("notify_conversation_state", {
chatId: this.chatId,
state: "normal",
historyState: historyState,
historyErrorMessage: this.historyErrorMessage,
historyRetryTimestamp: this.historyRetryTimestamp,
chatFrameMaxMessageCount: kMaxChatFrameMessageSize,
unreadTimestamp: this.unreadTimestamp,
showUserSwitchEvents: this.conversationPrivate || !this.crossChannelChatSupported,
sendEnabled: this.messageSendEnabled,
events: [...this.presentEvents, ...this.presentMessages]
});
break;
case "loading":
case "unloaded":
this.events.fire_async("notify_conversation_state", {
chatId: this.chatId,
state: "loading"
});
break;
case "error":
this.events.fire_async("notify_conversation_state", {
chatId: this.chatId,
state: "error",
errorMessage: this.errorMessage
});
break;
case "no-permissions":
this.events.fire_async("notify_conversation_state", {
chatId: this.chatId,
state: "no-permissions",
failedPermission: this.failedPermission
});
break;
}
}
protected doSendMessage(message: string, targetMode: number, target: number) : Promise<boolean> {
let msg = helpers.preprocess_chat_message(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 === ErrorID.PERMISSION_ERROR) {
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 {
log.warn(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 isUnread() {
return this.unreadTimestamp !== undefined;
}
public setUnreadTimestamp(timestamp: number | undefined) {
if(timestamp === undefined)
this.lastReadMessage = Date.now();
if(this.unreadTimestamp === timestamp)
return;
this.unreadTimestamp = timestamp;
this.events.fire_async("notify_unread_timestamp_changed", { chatId: this.chatId, timestamp: timestamp });
}
public jumpToPresent() {
this.reportStateToUI();
}
public uiQueryHistory(timestamp: number, enforce?: boolean) {
if(this.executingUIHistoryQuery && !enforce)
return;
this.executingUIHistoryQuery = true;
this.queryHistory({ end: 1, begin: timestamp, limit: kMaxChatFrameMessageSize }).then(result => {
this.executingUIHistoryQuery = false;
this.historyErrorMessage = undefined;
this.historyRetryTimestamp = result.nextAllowedQuery;
switch (result.status) {
case "success":
this.events.fire_async("notify_conversation_history", {
chatId: this.chatId,
state: "success",
hasMoreMessages: result.moreEvents,
retryTimestamp: this.historyRetryTimestamp,
events: result.events
});
break;
case "private":
this.events.fire_async("notify_conversation_history", {
chatId: this.chatId,
state: "error",
errorMessage: this.historyErrorMessage = tr("chat is private"),
retryTimestamp: this.historyRetryTimestamp
});
break;
case "no-permission":
this.events.fire_async("notify_conversation_history", {
chatId: this.chatId,
state: "error",
errorMessage: this.historyErrorMessage = tra("failed on {}", result.failedPermission || tr("unknown permission")),
retryTimestamp: this.historyRetryTimestamp
});
break;
case "error":
this.events.fire_async("notify_conversation_history", {
chatId: this.chatId,
state: "error",
errorMessage: this.historyErrorMessage = result.errorMessage,
retryTimestamp: this.historyRetryTimestamp
});
break;
}
});
}
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_enabled", { chatId: this.chatId, enabled: enabled });
}
protected abstract canClientAccessChat() : boolean;
public abstract queryHistory(criteria: { begin?: number, end?: number, limit?: number }) : Promise<ConversationHistoryResponse>;
public abstract queryCurrentMessages();
public abstract sendMessage(text: string);
} }
export interface ConversationHistoryResponse { export abstract class AbstractChatManager<Events extends ConversationUIEvents> {
status: "success" | "error" | "no-permission" | "private"; protected readonly uiEvents: Registry<Events>;
messages?: ChatMessage[]; protected constructor() {
moreMessages?: boolean; this.uiEvents = new Registry<Events>();
}
errorMessage?: string; handlePanelShow() {
failedPermission?: string; this.uiEvents.fire("notify_panel_show");
}
protected abstract findChat(id: string) : AbstractChat<Events>;
@EventHandler<ConversationUIEvents>("query_conversation_state")
protected handleQueryConversationState(event: ConversationUIEvents["query_conversation_state"]) {
const conversation = this.findChat(event.chatId);
if(!conversation) {
this.uiEvents.fire_async("notify_conversation_state", {
state: "error",
errorMessage: tr("Unknown conversation"),
chatId: event.chatId
});
return;
}
if(conversation.currentMode() === "unloaded")
conversation.queryCurrentMessages();
else
conversation.reportStateToUI();
}
@EventHandler<ConversationUIEvents>("query_conversation_history")
protected handleQueryHistory(event: ConversationUIEvents["query_conversation_history"]) {
const conversation = this.findChat(event.chatId);
if(!conversation) {
this.uiEvents.fire_async("notify_conversation_history", {
state: "error",
errorMessage: tr("Unknown conversation"),
retryTimestamp: Date.now() + 10 * 1000,
chatId: event.chatId
});
log.error(LogCategory.CLIENT, tr("Tried to query history for an unknown conversation with id %s"), event.chatId);
return;
}
conversation.uiQueryHistory(event.timestamp);
}
@EventHandler<ConversationUIEvents>("action_clear_unread_flag")
protected handleClearUnreadFlag(event: ConversationUIEvents["action_clear_unread_flag"]) {
this.findChat(event.chatId)?.setUnreadTimestamp(undefined);
}
@EventHandler<ConversationUIEvents>("action_self_typing")
protected handleActionSelfTyping(event: ConversationUIEvents["action_self_typing"]) {
if(this.findChat(event.chatId)?.isUnread())
this.uiEvents.fire("action_clear_unread_flag", { chatId: event.chatId });
}
@EventHandler<ConversationUIEvents>("action_send_message")
protected handleSendMessage(event: ConversationUIEvents["action_send_message"]) {
const conversation = this.findChat(event.chatId);
if(!conversation) {
log.error(LogCategory.CLIENT, tr("Tried to send a chat message to an unknown conversation with id %s"), event.chatId);
return;
}
conversation.sendMessage(event.text);
}
@EventHandler<ConversationUIEvents>("action_jump_to_present")
protected handleJumpToPresent(event: ConversationUIEvents["action_jump_to_present"]) {
const conversation = this.findChat(event.chatId);
if(!conversation) {
log.error(LogCategory.CLIENT, tr("Tried to jump to present for an unknown conversation with id %s"), event.chatId);
return;
}
conversation.jumpToPresent();
}
} }
export type ConversationMode = "normal" | "loading" | "no-permissions" | "error" | "unloaded"; const kSuccessQueryThrottle = 5 * 1000;
export class Conversation { const kErrorQueryThrottle = 30 * 1000;
export class Conversation extends AbstractChat<ConversationUIEvents> {
private readonly handle: ConversationManager; private readonly handle: ConversationManager;
private readonly events: Registry<ConversationUIEvents>;
public readonly conversationId: number; public readonly conversationId: number;
private mode: ConversationMode = "unloaded";
private failedPermission: string;
private errorMessage: string;
public presentMessages: ({ uniqueId: string } & ChatMessage)[] = [];
public presentEvents: Exclude<ChatEvent, ChatEventMessage>[] = []; /* everything excluding chat messages */
private unreadTimestamp: number | undefined = undefined;
private lastReadMessage: number = 0;
private conversationPrivate: boolean = false;
private conversationVolatile: boolean = false; private conversationVolatile: boolean = false;
private queryingHistory = false; private executingHistoryQueries = false;
private pendingHistoryQueries: (() => Promise<any>)[] = []; private pendingHistoryQueries: (() => Promise<any>)[] = [];
public historyQueryResponse: ChatMessage[] = []; public historyQueryResponse: ChatMessage[] = [];
constructor(handle: ConversationManager, events: Registry<ConversationUIEvents>, id: number) { constructor(handle: ConversationManager, events: Registry<ConversationUIEvents>, id: number) {
super(handle.connection, id.toString(), events);
this.handle = handle; this.handle = handle;
this.conversationId = id; this.conversationId = id;
this.events = events;
this.lastReadMessage = handle.connection.settings.server(Settings.FN_CHANNEL_CHAT_READ(id), Date.now()); this.lastReadMessage = handle.connection.settings.server(Settings.FN_CHANNEL_CHAT_READ(id), Date.now());
} }
destroy() { } destroy() { }
currentMode() : ConversationMode { return this.mode; }
queryHistory(criteria: { begin?: number, end?: number, limit?: number }) : Promise<ConversationHistoryResponse> { queryHistory(criteria: { begin?: number, end?: number, limit?: number }) : Promise<ConversationHistoryResponse> {
return new Promise<ConversationHistoryResponse>(resolve => { return new Promise<ConversationHistoryResponse>(resolve => {
this.pendingHistoryQueries.push(() => { this.pendingHistoryQueries.push(() => {
@ -141,22 +431,46 @@ export class Conversation {
message_count: criteria.limit, message_count: criteria.limit,
timestamp_end: criteria.end timestamp_end: criteria.end
}, { flagset: [ "merge" ], process_result: false }).then(() => { }, { flagset: [ "merge" ], process_result: false }).then(() => {
resolve({ status: "success", messages: this.historyQueryResponse, moreMessages: false }); 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 => { }).catch(error => {
let errorMessage; let errorMessage;
if(error instanceof CommandResult) { if(error instanceof CommandResult) {
if(error.id === ErrorID.CONVERSATION_MORE_DATA || error.id === ErrorID.EMPTY_RESULT) { if(error.id === ErrorID.CONVERSATION_MORE_DATA || error.id === ErrorID.EMPTY_RESULT) {
resolve({ status: "success", messages: this.historyQueryResponse, moreMessages: error.id === ErrorID.CONVERSATION_MORE_DATA }); 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 === ErrorID.CONVERSATION_MORE_DATA, nextAllowedQuery: Date.now() + kSuccessQueryThrottle });
return; return;
} else if(error.id === ErrorID.PERMISSION_ERROR) { } else if(error.id === ErrorID.PERMISSION_ERROR) {
resolve({ resolve({
status: "no-permission", status: "no-permission",
failedPermission: this.handle.connection.permissions.resolveInfo(parseInt(error.json["failed_permid"]))?.name || tr("unknwon") failedPermission: this.handle.connection.permissions.resolveInfo(parseInt(error.json["failed_permid"]))?.name || tr("unknwon"),
nextAllowedQuery: Date.now() + kErrorQueryThrottle
}); });
return; return;
} else if(error.id === ErrorID.CONVERSATION_IS_PRIVATE) { } else if(error.id === ErrorID.CONVERSATION_IS_PRIVATE) {
resolve({ resolve({
status: "private" status: "private",
nextAllowedQuery: Date.now() + kErrorQueryThrottle
});
return;
} else if(error.id === ErrorID.COMMAND_NOT_FOUND) {
resolve({
status: "unsupported",
nextAllowedQuery: Date.now() + kErrorQueryThrottle
}); });
return; return;
} else { } else {
@ -168,7 +482,8 @@ export class Conversation {
} }
resolve({ resolve({
status: "error", status: "error",
errorMessage: errorMessage errorMessage: errorMessage,
nextAllowedQuery: Date.now() + 5 * 1000
}); });
}); });
}); });
@ -182,17 +497,23 @@ export class Conversation {
this.mode = "loading"; this.mode = "loading";
this.reportStateToUI(); this.reportStateToUI();
this.queryHistory({ end: 1, limit: 50 }).then(history => { this.queryHistory({ end: 1, limit: kMaxChatFrameMessageSize }).then(history => {
this.conversationPrivate = false; this.conversationPrivate = false;
this.conversationVolatile = false; this.conversationVolatile = false;
this.failedPermission = undefined; this.failedPermission = undefined;
this.errorMessage = undefined; this.errorMessage = undefined;
this.presentMessages = history.messages?.map(e => Object.assign({ uniqueId: "m-" + e.timestamp }, e)) || []; this.hasHistory = !!history.moreEvents;
this.presentMessages = history.events?.map(e => Object.assign({ uniqueId: "m-" + this.conversationId + "-" + e.timestamp }, e)) || [];
switch (history.status) { switch (history.status) {
case "error": case "error":
this.mode = "error"; this.mode = "normal";
this.errorMessage = history.errorMessage; this.presentEvents.push({
type: "query-failed",
timestamp: Date.now(),
uniqueId: "qf-" + this.conversationId + "-" + Date.now() + "-" + Math.random(),
message: history.errorMessage
});
break; break;
case "no-permission": case "no-permission":
@ -201,12 +522,19 @@ export class Conversation {
break; break;
case "private": case "private":
this.conversationPrivate = true;
this.mode = "normal"; this.mode = "normal";
break; break;
case "success": case "success":
this.mode = "normal"; this.mode = "normal";
break; break;
case "unsupported":
this.crossChannelChatSupported = false;
this.conversationPrivate = true;
this.mode = "normal";
break;
} }
/* only update the UI if needed */ /* only update the UI if needed */
@ -215,25 +543,29 @@ export class Conversation {
}); });
} }
protected canClientAccessChat() {
return this.conversationId === 0 || this.handle.connection.getClient().currentChannel()?.channelId === this.conversationId;
}
private executeHistoryQuery() { private executeHistoryQuery() {
if(this.queryingHistory) if(this.executingHistoryQueries || this.pendingHistoryQueries.length === 0)
return; return;
this.queryingHistory = true; this.executingHistoryQueries = true;
try { try {
const promise = this.pendingHistoryQueries.pop_front()(); const promise = this.pendingHistoryQueries.pop_front()();
promise promise
.catch(error => log.error(LogCategory.CLIENT, tr("Conversation history query task threw an error; this should never happen: %o"), error)) .catch(error => log.error(LogCategory.CLIENT, tr("Conversation history query task threw an error; this should never happen: %o"), error))
.then(() => this.executeHistoryQuery()); .then(() => { this.executingHistoryQueries = false; this.executeHistoryQuery(); });
} catch (e) { } catch (e) {
this.queryingHistory = false; this.executingHistoryQueries = false;
throw e; throw e;
} }
} }
public updateIndexFromServer(info: any) { public updateIndexFromServer(info: any) {
if('error_id' in info) { if('error_id' in info) {
/* FIXME: Parse error, may be flag private or similar */ /* TODO: Parse error, may be flag private or similar */
return; return;
} }
@ -249,34 +581,18 @@ export class Conversation {
} }
} }
public handleIncomingMessage(message: ChatMessage, triggerUnread: boolean) { public handleIncomingMessage(message: ChatMessage, isOwnMessage: boolean) {
let index = 0; this.registerIncomingMessage(message, isOwnMessage, "m-" + this.conversationId + "-" + message.timestamp + "-" + Math.random());
while(index < this.presentMessages.length && this.presentMessages[index].timestamp <= message.timestamp)
index++;
console.log("Insert at: %d", index);
this.presentMessages.splice(index, 0, Object.assign({ uniqueId: "m-" + message.timestamp }, message));
if(triggerUnread && !this.unreadTimestamp)
this.unreadTimestamp = message.timestamp;
const uMessage = this.presentMessages[index];
this.events.fire("notify_chat_event", {
conversation: this.conversationId,
triggerUnread: triggerUnread,
event: {
type: "message",
timestamp: message.timestamp,
message: message,
uniqueId: uMessage.uniqueId
}
});
} }
public handleDeleteMessages(criteria: { begin: number, end: number, cldbid: number, limit: number }) { public handleDeleteMessages(criteria: { begin: number, end: number, cldbid: number, limit: number }) {
let limit = { current: criteria.limit }; let limit = { current: criteria.limit };
this.presentMessages = this.presentMessages.filter(message => { this.presentMessages = this.presentMessages.filter(message => {
if(message.sender_database_id !== criteria.cldbid) if(message.type !== "message")
return;
if(message.message.sender_database_id !== criteria.cldbid)
return true; return true;
if(criteria.end != 0 && message.timestamp > criteria.end) if(criteria.end != 0 && message.timestamp > criteria.end)
@ -288,29 +604,7 @@ export class Conversation {
return --limit.current < 0; return --limit.current < 0;
}); });
this.events.fire("notify_chat_message_delete", { conversation: this.conversationId, criteria: criteria }); this.events.fire("notify_chat_message_delete", { chatId: this.conversationId.toString(), criteria: criteria });
}
public sendMessage(message: string) {
this.handle.connection.serverConnection.send_command("sendtextmessage", {
targetmode: this.conversationId == 0 ? 3 : 2,
cid: this.conversationId,
msg: message
}, { process_result: false }).catch(error => {
this.presentEvents.push({
type: "message-failed",
uniqueId: "msf-" + Date.now(),
timestamp: Date.now(),
error: "error",
errorMessage: tr("Unknown error TODO!") /* TODO! */
});
this.events.fire_async("notify_chat_event", {
conversation: this.conversationId,
triggerUnread: false,
event: this.presentEvents.last()
});
});
} }
public deleteMessage(messageUniqueId: string) { public deleteMessage(messageUniqueId: string) {
@ -320,12 +614,15 @@ export class Conversation {
return; return;
} }
if(message.type !== "message")
return;
this.handle.connection.serverConnection.send_command("conversationmessagedelete", { this.handle.connection.serverConnection.send_command("conversationmessagedelete", {
cid: this.conversationId, cid: this.conversationId,
timestamp_begin: message.timestamp - 1, timestamp_begin: message.timestamp - 1,
timestamp_end: message.timestamp + 1, timestamp_end: message.timestamp + 1,
limit: 1, limit: 1,
cldbid: message.sender_database_id cldbid: message.message.sender_database_id
}, { process_result: false }).catch(error => { }, { process_result: false }).catch(error => {
log.error(LogCategory.CHAT, tr("Failed to delete conversation message for conversation %d: %o"), this.conversationId, error); log.error(LogCategory.CHAT, tr("Failed to delete conversation message for conversation %d: %o"), this.conversationId, error);
if(error instanceof CommandResult) if(error instanceof CommandResult)
@ -335,48 +632,40 @@ export class Conversation {
}); });
} }
public reportStateToUI() { setUnreadTimestamp(timestamp: number | undefined) {
this.events.fire_async("notify_conversation_state", { super.setUnreadTimestamp(timestamp);
id: this.conversationId,
mode: this.mode === "unloaded" ? "loading" : this.mode,
unreadTimestamp: this.unreadTimestamp,
haveOlderMessages: false,
failedPermission: this.failedPermission,
conversationPrivate: this.conversationPrivate,
events: [...this.presentEvents, ...this.presentMessages.map(e => { /* we've to update the last read timestamp regardless of if we're having actual unread stuff */
return { this.handle.connection.settings.changeServer(Settings.FN_CHANNEL_CHAT_READ(this.conversationId), typeof timestamp === "number" ? timestamp : Date.now());
timestamp: e.timestamp,
uniqueId: "m-" + e.timestamp,
type: "message",
message: e
} as ChatEvent;
})]
});
} }
public setUnreadTimestamp(timestamp: number | undefined) { public localClientSwitchedChannel(type: "join" | "leave") {
if(this.unreadTimestamp === timestamp) this.presentEvents.push({
return; type: "local-user-switch",
uniqueId: "us-" + this.conversationId + "-" + Date.now() + "-" + Math.random(),
timestamp: Date.now(),
mode: type
});
this.unreadTimestamp = timestamp; if(this.conversationId === this.handle.selectedConversation())
this.handle.connection.settings.changeServer(Settings.FN_CHANNEL_CHAT_READ(this.conversationId), typeof timestamp === "number" ? timestamp : Date.now()); this.reportStateToUI();
this.events.fire_async("notify_unread_timestamp_changed", { conversation: this.conversationId, timestamp: timestamp }); }
sendMessage(text: string) {
this.doSendMessage(text, this.conversationId ? 2 : 3, this.conversationId);
} }
} }
export class ConversationManager { export class ConversationManager extends AbstractChatManager<ConversationUIEvents> {
readonly connection: ConnectionHandler; readonly connection: ConnectionHandler;
readonly htmlTag: HTMLDivElement; readonly htmlTag: HTMLDivElement;
private readonly uiEvents: Registry<ConversationUIEvents>;
private conversations: {[key: number]: Conversation} = {}; private conversations: {[key: number]: Conversation} = {};
private selectedConversation_: number; private selectedConversation_: number;
constructor(connection: ConnectionHandler) { constructor(connection: ConnectionHandler) {
super();
this.connection = connection; this.connection = connection;
this.uiEvents = new Registry<ConversationUIEvents>();
this.htmlTag = document.createElement("div"); this.htmlTag = document.createElement("div");
this.htmlTag.style.display = "flex"; this.htmlTag.style.display = "flex";
@ -384,18 +673,33 @@ export class ConversationManager {
this.htmlTag.style.justifyContent = "stretch"; this.htmlTag.style.justifyContent = "stretch";
this.htmlTag.style.height = "100%"; this.htmlTag.style.height = "100%";
ReactDOM.render(React.createElement(ConversationPanel, { events: this.uiEvents, handler: this.connection }), this.htmlTag); ReactDOM.render(React.createElement(ConversationPanel, {
events: this.uiEvents,
handler: this.connection,
noFirstMessageOverlay: false,
messagesDeletable: true
}), this.htmlTag);
this.uiEvents.on("action_select_conversation", event => this.selectedConversation_ = event.chatId); this.uiEvents.on("action_select_chat", event => this.selectedConversation_ = parseInt(event.chatId));
this.uiEvents.on("notify_destroy", connection.events().on("notify_connection_state_changed", event => { this.uiEvents.on("notify_destroy", connection.events().on("notify_connection_state_changed", event => {
if(ConnectionState.socketConnected(event.old_state) !== ConnectionState.socketConnected(event.new_state)) { if(ConnectionState.socketConnected(event.old_state) !== ConnectionState.socketConnected(event.new_state)) {
this.conversations = {}; this.conversations = {};
this.setSelectedConversation(-1); this.setSelectedConversation(-1);
} }
this.uiEvents.fire("notify_server_state", { crossChannelChatSupport: false, state: connection.connected ? "connected" : "disconnected" }); }));
this.uiEvents.on("notify_destroy", connection.events().on("notify_visibility_changed", event => {
if(!event.visible)
return;
this.handlePanelShow();
})); }));
this.uiEvents.register_handler(this); connection.events().one("notify_handler_initialized", () => this.uiEvents.on("notify_destroy", connection.getClient().events.on("notify_client_moved", event => {
this.findOrCreateConversation(event.oldChannel.channelId).localClientSwitchedChannel("leave");
this.findOrCreateConversation(event.newChannel.channelId).localClientSwitchedChannel("join");
})));
this.uiEvents.register_handler(this, true);
this.uiEvents.on("notify_destroy", connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationhistory", this.handleConversationHistory.bind(this))); this.uiEvents.on("notify_destroy", connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationhistory", this.handleConversationHistory.bind(this)));
this.uiEvents.on("notify_destroy", connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationindex", this.handleConversationIndex.bind(this))); this.uiEvents.on("notify_destroy", connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationindex", this.handleConversationIndex.bind(this)));
this.uiEvents.on("notify_destroy", connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationmessagedelete", this.handleConversationMessageDelete.bind(this))); this.uiEvents.on("notify_destroy", connection.serverConnection.command_handler_boss().register_explicit_handler("notifyconversationmessagedelete", this.handleConversationMessageDelete.bind(this)));
@ -410,6 +714,9 @@ export class ConversationManager {
} }
destroy() { destroy() {
ReactDOM.unmountComponentAtNode(this.htmlTag);
this.htmlTag.remove();
this.uiEvents.unregister_handler(this); this.uiEvents.unregister_handler(this);
this.uiEvents.fire("notify_destroy"); this.uiEvents.fire("notify_destroy");
this.uiEvents.destroy(); this.uiEvents.destroy();
@ -422,7 +729,12 @@ export class ConversationManager {
setSelectedConversation(id: number) { setSelectedConversation(id: number) {
this.findOrCreateConversation(id); this.findOrCreateConversation(id);
this.uiEvents.fire("action_select_conversation", { chatId: id }); this.uiEvents.fire("notify_selected_chat", { chatId: id.toString() });
}
@EventHandler<ConversationUIEvents>("action_select_chat")
private handleActionSelectChat(event: ConversationUIEvents["action_select_chat"]) {
this.setSelectedConversation(parseInt(event.chatId));
} }
findConversation(id: number) : Conversation { findConversation(id: number) : Conversation {
@ -432,6 +744,10 @@ export class ConversationManager {
return undefined; return undefined;
} }
protected findChat(id: string): AbstractChat<ConversationUIEvents> {
return this.findConversation(parseInt(id));
}
findOrCreateConversation(id: number) { findOrCreateConversation(id: number) {
let conversation = this.findConversation(id); let conversation = this.findConversation(id);
if(!conversation) { if(!conversation) {
@ -445,10 +761,8 @@ export class ConversationManager {
destroyConversation(id: number) { destroyConversation(id: number) {
delete this.conversations[id]; delete this.conversations[id];
if(id === this.selectedConversation_) { if(id === this.selectedConversation_)
this.uiEvents.fire("action_select_conversation", { chatId: -1 }); this.uiEvents.fire("action_select_chat", { chatId: "unselected" });
this.selectedConversation_ = -1;
}
} }
queryUnreadFlags() { queryUnreadFlags() {
@ -459,10 +773,6 @@ export class ConversationManager {
}); });
} }
handlePanelShow() {
this.uiEvents.fire("notify_panel_show");
}
private handleConversationHistory(command: ServerCommand) { private handleConversationHistory(command: ServerCommand) {
const conversation = this.findConversation(parseInt(command.arguments[0]["cid"])); const conversation = this.findConversation(parseInt(command.arguments[0]["cid"]));
if(!conversation) { if(!conversation) {
@ -505,55 +815,24 @@ export class ConversationManager {
}) })
} }
@EventHandler<ConversationUIEvents>("query_conversation_state")
private handleQueryConversationState(event: ConversationUIEvents["query_conversation_state"]) {
const conversation = this.findConversation(event.chatId);
if(!conversation) {
this.uiEvents.fire_async("notify_conversation_state", {
mode: "error",
errorMessage: tr("Unknown conversation"),
id: event.chatId,
events: [],
conversationPrivate: false,
haveOlderMessages: false,
unreadTimestamp: undefined
});
return;
}
if(conversation.currentMode() === "unloaded")
conversation.queryCurrentMessages();
else
conversation.reportStateToUI();
}
@EventHandler<ConversationUIEvents>("action_clear_unread_flag")
private handleClearUnreadFlag(event: ConversationUIEvents["action_clear_unread_flag"]) {
this.connection.channelTree.findChannel(event.chatId)?.setUnread(false);
this.findConversation(event.chatId)?.setUnreadTimestamp(undefined);
}
@EventHandler<ConversationUIEvents>("action_send_message")
private handleSendMessage(event: ConversationUIEvents["action_send_message"]) {
const conversation = this.findConversation(event.chatId);
if(!conversation) {
log.error(LogCategory.CLIENT, tr("Tried to send a chat message to an unknown conversation with id %d"), event.chatId);
return;
}
conversation.sendMessage(event.text);
}
@EventHandler<ConversationUIEvents>("action_delete_message") @EventHandler<ConversationUIEvents>("action_delete_message")
private handleMessageDelete(event: ConversationUIEvents["action_delete_message"]) { private handleMessageDelete(event: ConversationUIEvents["action_delete_message"]) {
const conversation = this.findConversation(event.chatId); const conversation = this.findConversation(parseInt(event.chatId));
if(!conversation) { if(!conversation) {
log.error(LogCategory.CLIENT, tr("Tried to delete a chat message from an unknown conversation with id %d"), event.chatId); log.error(LogCategory.CLIENT, tr("Tried to delete a chat message from an unknown conversation with id %s"), event.chatId);
return; return;
} }
conversation.deleteMessage(event.uniqueId); conversation.deleteMessage(event.uniqueId);
} }
@EventHandler<ConversationUIEvents>("notify_selected_chat")
private handleNotifySelectedChat(event: ConversationUIEvents["notify_selected_chat"]) {
this.selectedConversation_ = parseInt(event.chatId);
}
@EventHandler<ConversationUIEvents>("action_clear_unread_flag")
protected handleClearUnreadFlag1(event: ConversationUIEvents["action_clear_unread_flag"]) {
this.connection.channelTree.findChannel(parseInt(event.chatId))?.setUnread(false);
}
} }

View File

@ -14,6 +14,8 @@ $bot_thumbnail_height: 9em;
height: 100%; height: 100%;
width: 100%; width: 100%;
min-width: 250px;
position: relative; position: relative;
.containerMessages { .containerMessages {
@ -25,6 +27,7 @@ $bot_thumbnail_height: 9em;
justify-content: stretch; justify-content: stretch;
min-height: 2em; min-height: 2em;
padding-bottom: .5em;
position: relative; position: relative;
@ -57,6 +60,72 @@ $bot_thumbnail_height: 9em;
@include transition(opacity .25s ease-in-out); @include transition(opacity .25s ease-in-out);
} }
} }
.containerLoadMessages {
@include user-select(none);
display: flex;
flex-direction: row;
background: linear-gradient(rgba(53, 53, 53, 0) 10%, #353535 70%);
.inner {
flex-grow: 1;
display: flex;
flex-direction: row;
justify-content: center;
text-align: center;
background: #252525;
color: #565353;
margin-left: 4.5em;
margin-right: 2em;
border-radius: .2em;
margin-top: .4em;
padding: .1em;
cursor: pointer;
@include transition(background-color ease-in-out $button_hover_animation_time);
&:hover {
background-color: #232326;
color: #5b5757;
}
}
&.present {
position: absolute;
bottom: .2em;
left: 0;
right: 0;
}
}
.containerPartnerTyping {
position: absolute;
pointer-events: none;
bottom: 0;
left: 0;
font-size: .85em;
padding-left: .6em;
line-height: 1;
color: #4d4d4d;
opacity: 1;
@include transition(.25s ease-in-out);
&.hidden {
opacity: 0;
}
}
} }
} }
@ -99,6 +168,10 @@ $bot_thumbnail_height: 9em;
height: 2.5em; height: 2.5em;
border-radius: 50%; border-radius: 50%;
display: flex;
flex-direction: column;
justify-content: center;
} }
} }
@ -138,7 +211,6 @@ $bot_thumbnail_height: 9em;
.timestamp { .timestamp {
display: inline; display: inline;
margin-left: .5em;
font-size: 0.66em; font-size: 0.66em;
color: #5d5b5b; color: #5d5b5b;
@ -177,6 +249,65 @@ $bot_thumbnail_height: 9em;
font-weight: bold; font-weight: bold;
color: $color_client_normal; color: $color_client_normal;
} }
/* some bbcode related formatting */
hr {
border: none;
border-top: .125em solid #555;
margin-top: .1em;
margin-bottom: .1em;
}
table {
th, td {
border-color: #1e2025;
}
tr {
background-color: #303036;
}
tr:nth-child(2n) {
background-color: #25252a;
}
}
:global(.xbbcode-tag-img) {
padding: .25em;
border-radius: .25em;
overflow: hidden;
max-width: 20em;
max-height: 20em;
img {
width: 100%;
height: 100%;
}
}
:global(.chat-emoji) {
height: 1.1em;
width: 1.1em;
margin-left: .1em;
margin-right: .1em;
vertical-align: text-bottom;
&:only-child {
font-size: 300%;
margin-top: .1em;
margin-bottom: .1em;
}
}
:global(.xbbcode-tag-quote) {
border-color: #737373;
padding-left: .5em;
color: #737373;
}
} }
&:before { &:before {
@ -198,6 +329,8 @@ $bot_thumbnail_height: 9em;
} }
.containerTimestamp { .containerTimestamp {
margin-left: 2.5em;
color: #565353; color: #565353;
text-align: center; text-align: center;
} }
@ -225,7 +358,77 @@ $bot_thumbnail_height: 9em;
} }
.containerUnread { .containerUnread {
margin-left: 3em;
margin-right: .5em;
text-align: center; text-align: center;
color: #bc1515; color: #bc1515;
} }
.jumpToPresentPlaceholder {
height: 2em;
}
.containerSwitch {
position: relative;
margin-left: 3em;
margin-right: .5em;
display: flex;
flex-direction: row;
justify-content: center;
color: #535353;
a {
background: #353535;
z-index: 1;
padding-left: 1em;
padding-right: 1em;
}
div {
position: absolute;
align-self: center;
left: 0;
right: 0;
height: .1em;
background-color: #535353;
}
}
.containerQueryFailed, .containerMessageSendFailed, .containerPartnerInstanceChanged, .containerLocalAction, .containerPartnerAction {
margin-left: 3em;
margin-right: .5em;
justify-content: center;
text-align: center;
color: #524e4e;
a {
display: inline-block;
}
&.containerMessageSendFailed {
color: #ac5353;
margin-bottom: .5em;
}
&.actionClose {
color: #adad1f;
}
&.actionDisconnect {
color: #a82424;
}
&.actionReconnect {
color: hsl(120, 65%, 30%);
}
}
} }

View File

@ -0,0 +1,891 @@
import * as React from "react";
import {EventHandler, ReactEventHandler, Registry} from "tc-shared/events";
import {ChatBox} from "tc-shared/ui/frames/side/ChatBox";
import {generate_client} from "tc-shared/ui/htmltags";
import {Ref, useEffect, useRef, useState} from "react";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {AvatarRenderer} from "tc-shared/ui/react-elements/Avatar";
import {format} from "tc-shared/ui/frames/side/chat_helper";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
import {BBCodeChatMessage} from "tc-shared/MessageFormatter";
import {Countdown} from "tc-shared/ui/react-elements/Countdown";
import {
ChatEvent,
ChatEventLocalAction,
ChatEventLocalUserSwitch,
ChatEventMessageSendFailed,
ChatEventPartnerInstanceChanged,
ChatEventQueryFailed,
ChatEventPartnerAction,
ChatHistoryState,
ChatMessage,
ConversationUIEvents
} from "tc-shared/ui/frames/side/ConversationDefinitions";
import {TimestampRenderer} from "tc-shared/ui/react-elements/TimestampRenderer";
const cssStyle = require("./ConversationUI.scss");
const CMTextRenderer = React.memo((props: { text: string }) => <BBCodeChatMessage message={props.text} />);
const ChatEventMessageRenderer = React.memo((props: {
message: ChatMessage,
callbackDelete?: () => void,
events: Registry<ConversationUIEvents>,
handler: ConnectionHandler,
refHTMLElement?: Ref<HTMLDivElement>
}) => {
let deleteButton;
if(props.callbackDelete) {
deleteButton = (
<div className={cssStyle.delete} onClick={props.callbackDelete} >
<img src="img/icon_conversation_message_delete.svg" alt={""} />
</div>
);
}
return (
<div className={cssStyle.containerMessage} ref={props.refHTMLElement}>
<div className={cssStyle.avatar}>
<AvatarRenderer
className={cssStyle.imageContainer}
alt={""}
avatar={props.handler.fileManager.avatars.resolveClientAvatar({ clientUniqueId: props.message.sender_unique_id, database_id: props.message.sender_database_id })} />
</div>
<div className={cssStyle.message}>
<div className={cssStyle.info}>
{deleteButton}
<a className={cssStyle.sender} dangerouslySetInnerHTML={{ __html: generate_client({
client_database_id: props.message.sender_database_id,
client_id: -1,
client_name: props.message.sender_name,
client_unique_id: props.message.sender_unique_id,
add_braces: false
})}} />
<span> </span> { /* Only for copy purposes */}
<a className={cssStyle.timestamp}>
<TimestampRenderer timestamp={props.message.timestamp} />
</a>
<br /> { /* Only for copy purposes */ }
</div>
<div className={cssStyle.text}>
<CMTextRenderer text={props.message.message} />
</div>
<br style={{ content: " ", display: "none" }} /> { /* Only for copy purposes */ }
</div>
</div>
);
});
const TimestampEntry = (props: { timestamp: Date, refDiv: React.Ref<HTMLDivElement> }) => {
const diff = format.date.date_format(props.timestamp, new Date());
let formatted;
let update: boolean;
if(diff == format.date.ColloquialFormat.YESTERDAY) {
formatted = <Translatable key={"yesterday"}>Yesterday</Translatable>;
update = true;
} else if(diff == format.date.ColloquialFormat.TODAY) {
formatted = <Translatable key={"today"}>Today</Translatable>;
update = true;
} else if(diff == format.date.ColloquialFormat.GENERAL) {
formatted = <>{format.date.format_date_general(props.timestamp, false)}</>;
update = false;
}
const [ revision, setRevision ] = useState(0);
useEffect(() => {
if(!update)
return;
const nextHour = new Date();
nextHour.setUTCMilliseconds(0);
nextHour.setUTCMinutes(0);
nextHour.setUTCHours(nextHour.getUTCHours() + 1);
const id = setTimeout(() => {
setRevision(revision + 1);
}, nextHour.getTime() - Date.now() + 10);
return () => clearTimeout(id);
});
return (
<div className={cssStyle.containerTimestamp} ref={props.refDiv}>
{formatted}
</div>
);
};
const UnreadEntry = (props: { refDiv: React.Ref<HTMLDivElement> }) => (
<div ref={props.refDiv} className={cssStyle.containerUnread}>
<Translatable>Unread messages</Translatable>
</div>
);
const LoadOderMessages = (props: { events: Registry<ConversationUIEvents>, chatId: string, state: ChatHistoryState | "error", errorMessage?: string, retryTimestamp?: number, timestamp: number | undefined }) => {
if(props.state === "none")
return null;
let innerMessage, onClick;
if(props.state === "loading") {
innerMessage = <><Translatable>loading older messages</Translatable> <LoadingDots /></>;
} else if(props.state === "available") {
const shouldThrottle = Date.now() < props.retryTimestamp;
const [ revision, setRevision ] = useState(0);
useEffect(() => {
if(!shouldThrottle)
return;
const timeout = setTimeout(() => setRevision(revision + 1), props.retryTimestamp - Date.now());
return () => clearTimeout(timeout);
});
if(shouldThrottle) {
innerMessage = <React.Fragment key={"throttle"}>
<Translatable>please wait</Translatable>&nbsp;
<Countdown timestamp={props.retryTimestamp} finished={tr("1 second")} />
</React.Fragment>;
} else {
onClick = props.state === "available" && props.timestamp ? () => props.events.fire("query_conversation_history", { chatId: props.chatId, timestamp: props.timestamp }) : undefined;
innerMessage = <Translatable key={"can-load"}>Load older messages</Translatable>;
}
} else {
innerMessage = (
<>
<Translatable>History query failed</Translatable> ({props.errorMessage})<br/>
<Translatable>Try again in</Translatable> <Countdown timestamp={props.retryTimestamp} finished={tr("1 second")} />
</>
);
}
return (
<div className={cssStyle.containerLoadMessages}>
<div className={cssStyle.inner} onClick={onClick}>
{innerMessage}
</div>
</div>
)
};
const JumpToPresent = (props: { events: Registry<ConversationUIEvents>, chatId: string }) => (
<div
className={cssStyle.containerLoadMessages + " " + cssStyle.present}
onClick={() => props.events.fire("action_jump_to_present", { chatId: props.chatId })}
>
<div className={cssStyle.inner}>
<Translatable>Jump to present</Translatable>
</div>
</div>
);
const ChatEventLocalUserSwitchRenderer = (props: { event: ChatEventLocalUserSwitch, timestamp: number, refHTMLElement: Ref<HTMLDivElement> }) => {
return (
<div className={cssStyle.containerSwitch} ref={props.refHTMLElement}>
<a>
{props.event.mode === "join" ? <Translatable>You joined at</Translatable> : <Translatable>You left at</Translatable>}
&nbsp;
{format.date.formatDayTime(new Date(props.timestamp))}
</a>
<div />
</div>
)
};
const ChatEventQueryFailedRenderer = (props: { event: ChatEventQueryFailed, refHTMLElement: Ref<HTMLDivElement> }) => {
return (
<div className={cssStyle.containerQueryFailed} ref={props.refHTMLElement}>
<Translatable>failed to query history</Translatable>
&nbsp;
({props.event.message})
</div>
)
};
const ChatEventMessageFailedRenderer = (props: { event: ChatEventMessageSendFailed, refHTMLElement: Ref<HTMLDivElement> }) => {
if(props.event.error === "permission")
return (
<div className={cssStyle.containerMessageSendFailed} ref={props.refHTMLElement}>
<Translatable>message send failed due to permission</Translatable>
{" " + props.event.failedPermission}
</div>
);
return (
<div className={cssStyle.containerMessageSendFailed} ref={props.refHTMLElement}>
<Translatable>failed to send message:</Translatable>
&nbsp;
{props.event.errorMessage || tr("Unknown error")}
</div>
)
};
const ChatEventPartnerInstanceChangedRenderer = (props: { event: ChatEventPartnerInstanceChanged, refHTMLElement: Ref<HTMLDivElement> }) => {
return (
<div className={cssStyle.containerPartnerInstanceChanged} ref={props.refHTMLElement}>
<Translatable>You're&nbsp;now&nbsp;chatting&nbsp;with</Translatable>
&nbsp;
<a>{props.event.newClient}</a>
</div>
)
};
const ChatEventLocalActionRenderer = (props: { event: ChatEventLocalAction, refHTMLElement: Ref<HTMLDivElement> }) => {
switch (props.event.action) {
case "disconnect":
return (
<div className={cssStyle.containerLocalAction + " " + cssStyle.actionDisconnect} ref={props.refHTMLElement}>
<Translatable>You've disconnected from the server</Translatable>
</div>
);
case "reconnect":
return (
<div className={cssStyle.containerLocalAction + " " + cssStyle.actionReconnect} ref={props.refHTMLElement}>
<Translatable>Chat reconnected</Translatable>
</div>
);
}
};
const ChatEventPartnerActionRenderer = (props: { event: ChatEventPartnerAction, refHTMLElement: Ref<HTMLDivElement> }) => {
switch (props.event.action) {
case "close":
return (
<div className={cssStyle.containerPartnerAction + " " + cssStyle.actionClose} ref={props.refHTMLElement}>
<Translatable>Your chat partner has closed the conversation</Translatable>
</div>
);
case "disconnect":
return (
<div className={cssStyle.containerPartnerAction + " " + cssStyle.actionDisconnect} ref={props.refHTMLElement}>
<Translatable>Your chat partner has disconnected</Translatable>
</div>
);
case "reconnect":
return (
<div className={cssStyle.containerPartnerAction + " " + cssStyle.actionReconnect} ref={props.refHTMLElement}>
<Translatable>Your chat partner has reconnected</Translatable>
</div>
);
}
return null;
};
const PartnerTypingIndicator = (props: { events: Registry<ConversationUIEvents>, chatId: string, timeout?: number }) => {
const kTypingTimeout = props.timeout || 5000;
const [ typingTimestamp, setTypingTimestamp ] = useState(0);
props.events.reactUse("notify_partner_typing", event => {
if(event.chatId !== props.chatId)
return;
setTypingTimestamp(Date.now());
});
props.events.reactUse("notify_chat_event", event => {
if(event.chatId !== props.chatId)
return;
if(event.event.type === "message") {
if(!event.event.isOwnMessage)
setTypingTimestamp(0);
} else if(event.event.type === "partner-action" || event.event.type === "local-action") {
setTypingTimestamp(0);
}
});
const isTyping = Date.now() - kTypingTimeout < typingTimestamp;
useEffect(() => {
if(!isTyping)
return;
const timeout = setTimeout(() => {
setTypingTimestamp(0);
}, kTypingTimeout);
return () => clearTimeout(timeout);
});
return (
<div className={cssStyle.containerPartnerTyping + (isTyping ? "" : " " + cssStyle.hidden)}>
<Translatable>Partner is typing</Translatable> <LoadingDots />
</div>
)
};
interface ConversationMessagesProperties {
events: Registry<ConversationUIEvents>;
handler: ConnectionHandler;
noFirstMessageOverlay?: boolean
messagesDeletable?: boolean;
}
interface ConversationMessagesState {
mode: "normal" | "loading" | "error" | "private" | "no-permission" | "not-supported" | "unselected";
errorMessage?: string;
failedPermission?: string;
historyState: ChatHistoryState | "error";
historyErrorMessage?: string;
historyRetryTimestamp?: number;
isBrowsingHistory: boolean;
}
@ReactEventHandler<ConversationMessages>(e => e.props.events)
class ConversationMessages extends React.PureComponent<ConversationMessagesProperties, ConversationMessagesState> {
private readonly refMessages = React.createRef<HTMLDivElement>();
private readonly refUnread = React.createRef<HTMLDivElement>();
private readonly refTimestamp = React.createRef<HTMLDivElement>();
private readonly refScrollToNewMessages = React.createRef<HTMLDivElement>();
private readonly refScrollElement = React.createRef<HTMLDivElement>();
private readonly refFirstChatEvent = React.createRef<HTMLDivElement>();
private scrollElementPreviousOffset = 0;
private scrollOffset: number | "bottom" | "element";
private currentChatId: "unselected" | string = "unselected";
private chatEvents: ChatEvent[] = [];
private showSwitchEvents: boolean = false;
private scrollEventUniqueId: string;
private viewElementIndex = 0;
private viewEntries: React.ReactElement[] = [];
private unreadTimestamp: undefined | number;
private chatFrameMaxMessageCount: number;
private chatFrameMaxHistoryMessageCount: number;
private historyRetryTimer: number;
private ignoreNextScroll: boolean = false;
private scrollHistoryAutoLoadThrottle: number = 0;
constructor(props) {
super(props);
this.state = {
mode: "unselected",
historyState: "available",
isBrowsingHistory: false,
historyRetryTimestamp: 0
}
}
private scrollToBottom() {
this.ignoreNextScroll = true;
requestAnimationFrame(() => {
this.ignoreNextScroll = false;
if(this.scrollOffset !== "bottom")
return;
if(!this.refMessages.current)
return;
this.refMessages.current.scrollTop = this.refMessages.current.scrollHeight;
});
}
private scrollToNewMessage() {
this.ignoreNextScroll = true;
requestAnimationFrame(() => {
this.ignoreNextScroll = false;
if(!this.refUnread.current)
return;
this.refMessages.current.scrollTop = this.refUnread.current.offsetTop - this.refTimestamp.current.clientHeight;
});
}
private fixScroll() {
if(this.scrollOffset === "element") {
this.ignoreNextScroll = true;
requestAnimationFrame(() => {
this.ignoreNextScroll = false;
if(!this.refMessages.current)
return;
let scrollTop;
if(this.refScrollElement.current) {
/* scroll to the element */
scrollTop = this.refScrollElement.current.offsetTop - this.scrollElementPreviousOffset;
} else {
/* just scroll to the bottom */
scrollTop = this.refMessages.current.scrollHeight;
}
this.refMessages.current.scrollTop = scrollTop;
this.scrollOffset = scrollTop;
this.scrollEventUniqueId = undefined;
});
} else if(this.scrollOffset !== "bottom") {
this.ignoreNextScroll = true;
requestAnimationFrame(() => {
if(this.scrollOffset === "bottom")
return;
this.ignoreNextScroll = false;
this.refMessages.current.scrollTop = this.scrollOffset as any;
});
} else if(this.refUnread.current) {
this.scrollToNewMessage();
} else {
this.scrollToBottom();
}
}
private scrollToNewMessagesShown() {
const newMessageOffset = this.refUnread.current?.offsetTop;
return typeof this.scrollOffset === "number" && this.refMessages.current?.clientHeight + this.scrollOffset < newMessageOffset;
}
render() {
let contents = [];
switch (this.state.mode) {
case "error":
contents.push(<div key={"ol-error"} className={cssStyle.overlay}><a>{this.state.errorMessage ? this.state.errorMessage : tr("An unknown error happened.")}</a></div>);
break;
case "unselected":
contents.push(<div key={"ol-unselected"} className={cssStyle.overlay}><a><Translatable>No conversation selected</Translatable></a></div>);
break;
case "loading":
contents.push(<div key={"ol-loading"} className={cssStyle.overlay}><a><Translatable>Loading</Translatable> <LoadingDots maxDots={3}/></a></div>);
break;
case "private":
contents.push(<div key={"ol-private"} className={cssStyle.overlay}><a>
<Translatable>This conversation is private.</Translatable><br />
<Translatable>Join the channel to participate.</Translatable></a>
</div>);
break;
case "no-permission":
contents.push(<div key={"ol-permission"} className={cssStyle.overlay}><a>
<Translatable>You don't have permissions to participate in this conversation!</Translatable><br />
<Translatable>{this.state.failedPermission}</Translatable></a>
</div>);
break;
case "not-supported":
contents.push(<div key={"ol-support"} className={cssStyle.overlay}><a>
<Translatable>The target server does not support cross channel chat.</Translatable><br />
<Translatable>Join the channel if you want to write.</Translatable></a>
</div>);
break;
case "normal":
if(this.viewEntries.length === 0 && !this.props.noFirstMessageOverlay) {
contents.push(<div key={"ol-empty"} className={cssStyle.overlay}><a>
<Translatable>There have been no messages yet.</Translatable><br />
<Translatable>Be the first who talks in here!</Translatable></a>
</div>);
} else {
contents = this.viewEntries;
}
break;
}
const firstMessageTimestamp = this.chatEvents[0]?.timestamp;
return (
<div className={cssStyle.containerMessages}>
<div
className={cssStyle.messages} ref={this.refMessages}
onClick={() => this.state.mode === "normal" && this.props.events.fire("action_clear_unread_flag", { chatId: this.currentChatId })}
onScroll={() => {
if(this.ignoreNextScroll)
return;
const top = this.refMessages.current.scrollTop;
const total = this.refMessages.current.scrollHeight - this.refMessages.current.clientHeight;
const shouldFollow = top + 200 > total;
if(firstMessageTimestamp && top <= 20 && this.state.historyState === "available" && Math.max(this.scrollHistoryAutoLoadThrottle, this.state.historyRetryTimestamp) < Date.now()) {
/* only load history when we're in an upwards scroll move */
if(this.scrollOffset === "bottom" || this.scrollOffset > top) {
this.scrollHistoryAutoLoadThrottle = Date.now() + 500; /* don't spam events */
this.props.events.fire_async("query_conversation_history", { chatId: this.currentChatId, timestamp: firstMessageTimestamp });
}
}
this.scrollOffset = shouldFollow ? "bottom" : top;
}}
>
<LoadOderMessages
events={this.props.events}
chatId={this.currentChatId}
state={this.state.historyState}
timestamp={firstMessageTimestamp}
retryTimestamp={this.state.historyRetryTimestamp}
errorMessage={this.state.historyErrorMessage}
/>
{contents}
{this.state.isBrowsingHistory ? <div key={"jump-present-placeholder"} className={cssStyle.jumpToPresentPlaceholder} /> : undefined}
</div>
<div
ref={this.refScrollToNewMessages}
className={cssStyle.containerScrollNewMessage + " " + (this.scrollToNewMessagesShown() ? cssStyle.shown : "")}
onClick={() => { this.scrollOffset = "bottom"; this.scrollToNewMessage(); }}
>
<Translatable>Scroll to new messages</Translatable>
</div>
{this.state.isBrowsingHistory ?
<JumpToPresent
key={"jump-to-present"}
events={this.props.events}
chatId={this.currentChatId} /> :
undefined
}
<PartnerTypingIndicator events={this.props.events} chatId={this.currentChatId} />
</div>
);
}
componentDidMount(): void {
this.scrollToBottom();
}
componentDidUpdate(prevProps: Readonly<ConversationMessagesProperties>, prevState: Readonly<ConversationMessagesState>, snapshot?: any): void {
requestAnimationFrame(() => {
this.refScrollToNewMessages.current?.classList.toggle(cssStyle.shown, this.scrollToNewMessagesShown());
});
}
componentWillUnmount(): void {
clearTimeout(this.historyRetryTimer);
this.historyRetryTimer = undefined;
}
private sortEvents() {
this.chatEvents.sort((a, b) => a.timestamp - b.timestamp);
}
/* builds the view from the messages */
private buildView() {
this.viewEntries = [];
let timeMarker = new Date(0);
let unreadSet = false, timestampRefSet = false;
let firstEvent = true;
for(let event of this.chatEvents) {
const mdate = new Date(event.timestamp);
if(mdate.getFullYear() !== timeMarker.getFullYear() || mdate.getMonth() !== timeMarker.getMonth() || mdate.getDate() !== timeMarker.getDate()) {
timeMarker = new Date(mdate.getFullYear(), mdate.getMonth(), mdate.getDate(), 1);
this.viewEntries.push(<TimestampEntry key={"t" + this.viewElementIndex++} timestamp={timeMarker} refDiv={timestampRefSet ? undefined : this.refTimestamp} />);
timestampRefSet = true;
}
if(event.timestamp >= this.unreadTimestamp && !unreadSet) {
this.viewEntries.push(<UnreadEntry refDiv={this.refUnread} key={"u" + this.viewElementIndex++} />);
unreadSet = true;
}
let reference = this.scrollEventUniqueId === event.uniqueId ? this.refScrollElement : firstEvent ? this.refFirstChatEvent : undefined;
firstEvent = false;
switch (event.type) {
case "message":
this.viewEntries.push(<ChatEventMessageRenderer
key={event.uniqueId}
message={event.message}
events={this.props.events}
callbackDelete={this.props.messagesDeletable ? () => this.props.events.fire("action_delete_message", { chatId: this.currentChatId, uniqueId: event.uniqueId }) : undefined}
handler={this.props.handler}
refHTMLElement={reference}
/>);
break;
case "message-failed":
this.viewEntries.push(<ChatEventMessageFailedRenderer
key={event.uniqueId}
event={event}
refHTMLElement={reference}
/>);
break;
case "local-user-switch":
this.viewEntries.push(<ChatEventLocalUserSwitchRenderer
key={event.uniqueId}
timestamp={event.timestamp}
event={event}
refHTMLElement={reference}
/>);
break;
case "query-failed":
this.viewEntries.push(<ChatEventQueryFailedRenderer
key={event.uniqueId}
event={event}
refHTMLElement={reference}
/>);
break;
case "partner-instance-changed":
this.viewEntries.push(<ChatEventPartnerInstanceChangedRenderer
key={event.uniqueId}
event={event}
refHTMLElement={reference}
/>);
break;
case "local-action":
this.viewEntries.push(<ChatEventLocalActionRenderer
key={event.uniqueId}
event={event}
refHTMLElement={reference}
/>);
break;
case "partner-action":
this.viewEntries.push(<ChatEventPartnerActionRenderer
key={event.uniqueId}
event={event}
refHTMLElement={reference}
/>);
break;
}
}
}
@EventHandler<ConversationUIEvents>("notify_selected_chat")
private handleNotifySelectedChat(event: ConversationUIEvents["notify_selected_chat"]) {
if(this.currentChatId === event.chatId)
return;
this.currentChatId = event.chatId;
this.chatEvents = [];
if(this.currentChatId === "unselected") {
this.setState({ mode: "unselected" });
} else {
this.props.events.fire("query_conversation_state", {
chatId: this.currentChatId
});
this.setState({ mode: "loading" });
}
}
@EventHandler<ConversationUIEvents>("notify_conversation_state")
private handleConversationStateUpdate(event: ConversationUIEvents["notify_conversation_state"]) {
if(event.chatId !== this.currentChatId)
return;
if(event.state === "no-permissions") {
this.chatEvents = [];
this.buildView();
this.setState({
mode: "no-permission",
failedPermission: event.failedPermission
});
} else if(event.state === "loading") {
this.chatEvents = [];
this.buildView();
this.setState({
mode: "loading"
});
} else if(event.state === "normal") {
this.chatFrameMaxMessageCount = event.chatFrameMaxMessageCount;
this.chatFrameMaxHistoryMessageCount = event.chatFrameMaxMessageCount * 2;
this.showSwitchEvents = event.showUserSwitchEvents;
this.unreadTimestamp = event.unreadTimestamp;
this.chatEvents = event.events.slice(0).filter(e => e.type !== "local-user-switch" || event.showUserSwitchEvents);
this.sortEvents();
this.buildView();
this.scrollOffset = "bottom";
this.setState({
mode: "normal",
isBrowsingHistory: false,
historyState: event.historyState,
historyErrorMessage: event.historyErrorMessage,
historyRetryTimestamp: event.historyRetryTimestamp
}, () => this.scrollToBottom());
} else if(event.state === "private") {
this.chatEvents = [];
this.buildView();
this.setState({
mode: event.crossChannelChatSupported ? "private" : "not-supported"
});
} else {
this.chatEvents = [];
this.buildView();
this.setState({
mode: "error",
errorMessage: 'errorMessage' in event ? event.errorMessage : tr("Unknown error/Invalid state")
});
}
}
@EventHandler<ConversationUIEvents>("notify_chat_event")
private handleChatEvent(event: ConversationUIEvents["notify_chat_event"]) {
if(event.chatId !== this.currentChatId || this.state.isBrowsingHistory)
return;
if(event.event.type === "local-user-switch" && !this.showSwitchEvents)
return;
this.chatEvents.push(event.event);
this.sortEvents();
if(typeof this.unreadTimestamp === "undefined" && event.triggerUnread)
this.unreadTimestamp = event.event.timestamp;
const spliceCount = Math.max(0, this.chatEvents.length - this.chatFrameMaxMessageCount);
this.chatEvents.splice(0, spliceCount);
if(spliceCount > 0 && this.state.historyState === "none")
this.setState({ historyState: "available" });
this.buildView();
this.forceUpdate(() => this.scrollToBottom());
}
@EventHandler<ConversationUIEvents>("notify_chat_message_delete")
private handleMessageDeleted(event: ConversationUIEvents["notify_chat_message_delete"]) {
if(event.chatId !== this.currentChatId)
return;
let limit = { current: event.criteria.limit };
this.chatEvents = this.chatEvents.filter(mEvent => {
if(mEvent.type !== "message")
return;
const message = mEvent.message;
if(message.sender_database_id !== event.criteria.cldbid)
return true;
if(event.criteria.end != 0 && message.timestamp > event.criteria.end)
return true;
if(event.criteria.begin != 0 && message.timestamp < event.criteria.begin)
return true;
return --limit.current < 0;
});
this.buildView();
this.forceUpdate(() => this.scrollToBottom());
}
@EventHandler<ConversationUIEvents>("action_clear_unread_flag")
private handleMessageUnread(event: ConversationUIEvents["action_clear_unread_flag"]) {
if (event.chatId !== this.currentChatId || this.unreadTimestamp === undefined)
return;
this.unreadTimestamp = undefined;
this.buildView();
this.forceUpdate();
};
@EventHandler<ConversationUIEvents>("notify_panel_show")
private handlePanelShow() {
this.fixScroll();
}
@EventHandler<ConversationUIEvents>("query_conversation_history")
private handleQueryConversationHistory(event: ConversationUIEvents["query_conversation_history"]) {
if (event.chatId !== this.currentChatId)
return;
this.setState({
historyState: "loading"
});
}
@EventHandler<ConversationUIEvents>("notify_conversation_history")
private handleNotifyConversationHistory(event: ConversationUIEvents["notify_conversation_history"]) {
if (event.chatId !== this.currentChatId)
return;
clearTimeout(this.historyRetryTimer);
if(event.state === "error") {
this.setState({
historyState: "error",
historyErrorMessage: event.errorMessage,
historyRetryTimestamp: event.retryTimestamp
});
this.historyRetryTimer = setTimeout(() => {
this.setState({
historyState: "available"
});
this.historyRetryTimer = undefined;
}, event.retryTimestamp - Date.now()) as any;
} else {
this.scrollElementPreviousOffset = this.refFirstChatEvent.current ? this.refFirstChatEvent.current.offsetTop - this.refFirstChatEvent.current.parentElement.scrollTop : 0;
this.scrollEventUniqueId = this.chatEvents[0].uniqueId;
this.chatEvents.push(...event.events);
this.sortEvents();
const spliceCount = Math.max(0, this.chatEvents.length - this.chatFrameMaxHistoryMessageCount);
this.chatEvents.splice(this.chatFrameMaxHistoryMessageCount, spliceCount);
this.buildView();
this.setState({
isBrowsingHistory: true,
historyState: event.hasMoreMessages ? "available" : "none",
historyRetryTimestamp: event.retryTimestamp
}, () => {
this.scrollOffset = "element";
this.fixScroll();
});
}
}
}
export const ConversationPanel = React.memo((props: { events: Registry<ConversationUIEvents>, handler: ConnectionHandler, messagesDeletable: boolean, noFirstMessageOverlay: boolean }) => {
const currentChat = useRef({ id: "unselected" });
const chatEnabled = useRef(false);
const refChatBox = useRef<ChatBox>();
const updateChatBox = () => {
refChatBox.current.setState({ enabled: currentChat.current.id !== "unselected" && chatEnabled.current });
};
props.events.reactUse("notify_selected_chat", event => {
currentChat.current.id = event.chatId;
updateChatBox();
});
props.events.reactUse("notify_conversation_state", event => {
chatEnabled.current = event.state === "normal" && event.sendEnabled;
updateChatBox();
});
props.events.reactUse("notify_send_enabled", event => {
if(event.chatId !== currentChat.current.id)
return;
chatEnabled.current = event.enabled;
updateChatBox();
});
props.events.reactUse("action_focus_chat", () => refChatBox.current?.events.fire("action_request_focus"));
useEffect(() => {
return refChatBox.current.events.on("notify_typing", () => props.events.fire("action_self_typing", { chatId: currentChat.current.id }));
});
return <div className={cssStyle.panel}>
<ConversationMessages events={props.events} handler={props.handler} messagesDeletable={props.messagesDeletable} noFirstMessageOverlay={props.noFirstMessageOverlay} />
<ChatBox
ref={refChatBox}
onSubmit={text => props.events.fire("action_send_message", { chatId: currentChat.current.id, text: text }) }
/>
</div>
});

View File

@ -1,498 +0,0 @@
import * as React from "react";
import {EventHandler, ReactEventHandler, Registry} from "tc-shared/events";
import {
ConversationUIEvents,
ChatMessage,
ChatEvent
} from "tc-shared/ui/frames/side/ConversationManager";
import {ChatBox} from "tc-shared/ui/frames/side/ChatBox";
import {generate_client} from "tc-shared/ui/htmltags";
import {useEffect, useRef, useState} from "react";
import {bbcode_chat} from "tc-shared/ui/frames/chat";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {AvatarRenderer} from "tc-shared/ui/react-elements/Avatar";
import {format} from "tc-shared/ui/frames/side/chat_helper";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
import {XBBCodeRenderer} from "../../../../../vendor/xbbcode/src/react";
const cssStyle = require("./Conversations.scss");
const CMTextRenderer = (props: { text: string }) => {
const refElement = useRef<HTMLSpanElement>();
const elements: HTMLElement[] = [];
bbcode_chat(props.text).forEach(e => elements.push(...e));
useEffect(() => {
if(elements.length === 0)
return;
refElement.current.replaceWith(...elements);
return () => {
/* just so react is happy again */
elements[0].replaceWith(refElement.current);
elements.forEach(e => e.remove());
};
});
return <XBBCodeRenderer>{props.text}</XBBCodeRenderer>
};
const TimestampRenderer = (props: { timestamp: number }) => {
const time = format.date.format_chat_time(new Date(props.timestamp));
const [ revision, setRevision ] = useState(0);
useEffect(() => {
if(!time.next_update)
return;
const id = setTimeout(() => setRevision(revision + 1), time.next_update);
return () => clearTimeout(id);
});
return <>{time.result}</>;
};
const ChatEventMessageRenderer = (props: { message: ChatMessage, callbackDelete?: () => void, events: Registry<ConversationUIEvents>, handler: ConnectionHandler }) => {
let deleteButton;
if(props.callbackDelete) {
deleteButton = (
<div className={cssStyle.delete} onClick={props.callbackDelete} >
<img src="img/icon_conversation_message_delete.svg" alt="X" />
</div>
);
}
return (
<div className={cssStyle.containerMessage}>
<div className={cssStyle.avatar}>
<div className={cssStyle.imageContainer}>
<AvatarRenderer avatar={props.handler.fileManager.avatars.resolveClientAvatar({ clientUniqueId: props.message.sender_unique_id, database_id: props.message.sender_database_id })} />
</div>
</div>
<div className={cssStyle.message}>
<div className={cssStyle.info}>
{deleteButton}
<a className={cssStyle.sender} dangerouslySetInnerHTML={{ __html: generate_client({
client_database_id: props.message.sender_database_id,
client_id: -1,
client_name: props.message.sender_name,
client_unique_id: props.message.sender_unique_id,
add_braces: false
})}} />
<a className={cssStyle.timestamp}><TimestampRenderer timestamp={props.message.timestamp} /></a>
<br /> { /* Only for copy purposes */ }
</div>
<div className={cssStyle.text}>
<CMTextRenderer text={props.message.message} />
<br style={{ content: " " }} /> { /* Only for copy purposes */ }
</div>
</div>
</div>
);
};
const TimestampEntry = (props: { timestamp: Date, refDiv: React.Ref<HTMLDivElement> }) => {
const diff = format.date.date_format(props.timestamp, new Date());
let formatted;
let update: boolean;
if(diff == format.date.ColloquialFormat.YESTERDAY) {
formatted = <Translatable key={"yesterday"}>Yesterday</Translatable>;
update = true;
} else if(diff == format.date.ColloquialFormat.TODAY) {
formatted = <Translatable key={"today"}>Today</Translatable>;
update = true;
} else if(diff == format.date.ColloquialFormat.GENERAL) {
formatted = <>{format.date.format_date_general(props.timestamp, false)}</>;
update = false;
}
const [ revision, setRevision ] = useState(0);
useEffect(() => {
if(!update)
return;
const nextHour = new Date();
nextHour.setUTCMilliseconds(0);
nextHour.setUTCMinutes(0);
nextHour.setUTCHours(nextHour.getUTCHours() + 1);
const id = setTimeout(() => {
setRevision(revision + 1);
}, nextHour.getTime() - Date.now() + 10);
return () => clearTimeout(id);
});
return (
<div className={cssStyle.containerTimestamp} ref={props.refDiv}>
{formatted}
</div>
);
};
const UnreadEntry = (props: { refDiv: React.Ref<HTMLDivElement> }) => (
<div key={"unread"} ref={props.refDiv} className={cssStyle.containerUnread}>
<Translatable>Unread messages</Translatable>
</div>
);
interface ConversationMessagesProperties {
events: Registry<ConversationUIEvents>;
handler: ConnectionHandler;
}
interface ConversationMessagesState {
mode: "normal" | "loading" | "error" | "private" | "no-permission" | "not-supported" | "unselected";
scrollOffset: number | "bottom";
errorMessage?: string;
failedPermission?: string;
}
@ReactEventHandler<ConversationMessages>(e => e.props.events)
class ConversationMessages extends React.Component<ConversationMessagesProperties, ConversationMessagesState> {
private readonly refMessages = React.createRef<HTMLDivElement>();
private readonly refUnread = React.createRef<HTMLDivElement>();
private readonly refTimestamp = React.createRef<HTMLDivElement>();
private readonly refScrollToNewMessages = React.createRef<HTMLDivElement>();
private conversationId: number = -1;
private chatEvents: ChatEvent[] = [];
private viewElementIndex = 0;
private viewEntries: React.ReactElement[] = [];
private unreadTimestamp: undefined | number;
private scrollIgnoreTimestamp: number = 0;
private currentHistoryFrame = {
begin: undefined,
end: undefined
};
constructor(props) {
super(props);
this.state = {
scrollOffset: "bottom",
mode: "unselected",
}
}
private scrollToBottom() {
requestAnimationFrame(() => {
if(this.state.scrollOffset !== "bottom")
return;
if(!this.refMessages.current)
return;
this.scrollIgnoreTimestamp = Date.now();
this.refMessages.current.scrollTop = this.refMessages.current.scrollHeight;
});
}
private scrollToNewMessage() {
requestAnimationFrame(() => {
if(!this.refUnread.current)
return;
this.scrollIgnoreTimestamp = Date.now();
this.refMessages.current.scrollTop = this.refUnread.current.offsetTop - this.refTimestamp.current.clientHeight;
});
}
private scrollToNewMessagesShown() {
const newMessageOffset = this.refUnread.current?.offsetTop;
return this.state.scrollOffset !== "bottom" && this.refMessages.current?.clientHeight + this.state.scrollOffset < newMessageOffset;
}
render() {
let contents = [];
switch (this.state.mode) {
case "error":
contents.push(<div key={"ol-error"} className={cssStyle.overlay}><a>{this.state.errorMessage ? this.state.errorMessage : tr("An unknown error happened.")}</a></div>);
break;
case "unselected":
contents.push(<div key={"ol-unselected"} className={cssStyle.overlay}><a><Translatable>No conversation selected</Translatable></a></div>);
break;
case "loading":
contents.push(<div key={"ol-loading"} className={cssStyle.overlay}><a><Translatable>Loading</Translatable> <LoadingDots maxDots={3}/></a></div>);
break;
case "private":
contents.push(<div key={"ol-private"} className={cssStyle.overlay}><a>
<Translatable>This conversation is private.</Translatable><br />
<Translatable>Join the channel to participate.</Translatable></a>
</div>);
break;
case "no-permission":
contents.push(<div key={"ol-permission"} className={cssStyle.overlay}><a>
<Translatable>You don't have permissions to participate in this conversation!</Translatable><br />
<Translatable>{this.state.failedPermission}</Translatable></a>
</div>);
break;
case "not-supported":
contents.push(<div key={"ol-support"} className={cssStyle.overlay}><a>
<Translatable>The target server does not support the cross channel chat system.</Translatable><br />
<Translatable>Join the channel if you want to write.</Translatable></a>
</div>);
break;
case "normal":
if(this.viewEntries.length === 0) {
contents.push(<div key={"ol-empty"} className={cssStyle.overlay}><a>
<Translatable>There have been no messages yet.</Translatable><br />
<Translatable>Be the first who talks in here!</Translatable></a>
</div>);
} else {
contents = this.viewEntries;
}
break;
}
return (
<div className={cssStyle.containerMessages}>
<div
className={cssStyle.messages} ref={this.refMessages}
onClick={() => this.state.mode === "normal" && this.props.events.fire("action_clear_unread_flag", { chatId: this.conversationId })}
onScroll={() => {
if(this.scrollIgnoreTimestamp > Date.now())
return;
const top = this.refMessages.current.scrollTop;
const total = this.refMessages.current.scrollHeight - this.refMessages.current.clientHeight;
const shouldFollow = top + 200 > total;
this.setState({ scrollOffset: shouldFollow ? "bottom" : top });
}}
>
{contents}
</div>
<div
ref={this.refScrollToNewMessages}
className={cssStyle.containerScrollNewMessage + " " + (this.scrollToNewMessagesShown() ? cssStyle.shown : "")}
onClick={() => this.setState({ scrollOffset: "bottom" }, () => this.scrollToNewMessage())}
>
<Translatable>Scroll to new messages</Translatable>
</div>
</div>
);
}
componentDidMount(): void {
this.scrollToBottom();
}
componentDidUpdate(prevProps: Readonly<ConversationMessagesProperties>, prevState: Readonly<ConversationMessagesState>, snapshot?: any): void {
requestAnimationFrame(() => {
this.refScrollToNewMessages.current.classList.toggle(cssStyle.shown, this.scrollToNewMessagesShown());
});
}
/* builds the view from the messages */
private buildView() {
this.viewEntries = [];
let timeMarker = new Date(0);
let unreadSet = false, timestampRefSet = false;
for(let event of this.chatEvents) {
const mdate = new Date(event.timestamp);
if(mdate.getFullYear() !== timeMarker.getFullYear() || mdate.getMonth() !== timeMarker.getMonth() || mdate.getDate() !== timeMarker.getDate()) {
timeMarker = new Date(mdate.getFullYear(), mdate.getMonth(), mdate.getDate(), 1);
this.viewEntries.push(<TimestampEntry key={"t" + this.viewElementIndex++} timestamp={timeMarker} refDiv={timestampRefSet ? undefined : this.refTimestamp} />);
timestampRefSet = true;
}
if(event.timestamp >= this.unreadTimestamp && !unreadSet) {
this.viewEntries.push(<UnreadEntry refDiv={this.refUnread} key={"u" + this.viewElementIndex++} />);
unreadSet = true;
}
switch (event.type) {
case "message":
this.viewEntries.push(<ChatEventMessageRenderer
key={event.uniqueId}
message={event.message}
events={this.props.events}
callbackDelete={() => this.props.events.fire("action_delete_message", { chatId: this.conversationId, uniqueId: event.uniqueId })}
handler={this.props.handler} />);
break;
case "message-failed":
/* TODO! */
break;
}
}
}
@EventHandler<ConversationUIEvents>("notify_server_state")
private handleNotifyServerState(event: ConversationUIEvents["notify_server_state"]) {
if(event.state === "connected")
return;
this.setState({ mode: "unselected" });
}
@EventHandler<ConversationUIEvents>("action_select_conversation")
private handleSelectConversation(event: ConversationUIEvents["action_select_conversation"]) {
if(this.conversationId === event.chatId)
return;
this.conversationId = event.chatId;
this.chatEvents = [];
this.currentHistoryFrame = { begin: undefined, end: undefined };
if(this.conversationId < 0) {
this.setState({ mode: "unselected" });
} else {
this.props.events.fire("query_conversation_state", {
chatId: this.conversationId
});
this.setState({ mode: "loading" });
}
}
@EventHandler<ConversationUIEvents>("notify_conversation_state")
private handleConversationStateUpdate(event: ConversationUIEvents["notify_conversation_state"]) {
if(event.id !== this.conversationId)
return;
if(event.mode === "no-permissions") {
this.setState({
mode: "no-permission",
failedPermission: event.failedPermission
});
} else if(event.mode === "loading") {
this.setState({
mode: "loading"
});
} else if(event.mode === "normal") {
this.unreadTimestamp = event.unreadTimestamp;
this.chatEvents = event.events;
this.buildView();
this.setState({
mode: "normal",
scrollOffset: "bottom"
}, () => this.scrollToBottom());
} else {
this.setState({
mode: "error",
errorMessage: event.errorMessage || tr("Unknown error/Invalid state")
});
}
}
@EventHandler<ConversationUIEvents>("notify_chat_event")
private handleMessageReceived(event: ConversationUIEvents["notify_chat_event"]) {
if(event.conversation !== this.conversationId)
return;
this.chatEvents.push(event.event);
if(typeof this.unreadTimestamp === "undefined" && event.triggerUnread)
this.unreadTimestamp = event.event.timestamp;
this.buildView();
this.forceUpdate(() => this.scrollToBottom());
}
@EventHandler<ConversationUIEvents>("notify_chat_message_delete")
private handleMessageDeleted(event: ConversationUIEvents["notify_chat_message_delete"]) {
if(event.conversation !== this.conversationId)
return;
let limit = { current: event.criteria.limit };
this.chatEvents = this.chatEvents.filter(mEvent => {
if(mEvent.type !== "message")
return;
const message = mEvent.message;
if(message.sender_database_id !== event.criteria.cldbid)
return true;
if(event.criteria.end != 0 && message.timestamp > event.criteria.end)
return true;
if(event.criteria.begin != 0 && message.timestamp < event.criteria.begin)
return true;
return --limit.current < 0;
});
this.buildView();
this.forceUpdate(() => this.scrollToBottom());
}
@EventHandler<ConversationUIEvents>("action_clear_unread_flag")
private handleMessageUnread(event: ConversationUIEvents["action_clear_unread_flag"]) {
if (event.chatId !== this.conversationId || this.unreadTimestamp === undefined)
return;
this.unreadTimestamp = undefined;
this.buildView();
this.forceUpdate();
};
@EventHandler<ConversationUIEvents>("notify_panel_show")
private handlePanelShow() {
if(this.refUnread.current) {
this.scrollToNewMessage();
} else if(this.state.scrollOffset === "bottom") {
this.scrollToBottom();
} else {
requestAnimationFrame(() => {
if(this.state.scrollOffset === "bottom")
return;
this.scrollIgnoreTimestamp = Date.now() + 250;
this.refMessages.current.scrollTop = this.state.scrollOffset;
});
}
}
}
export const ConversationPanel = (props: { events: Registry<ConversationUIEvents>, handler: ConnectionHandler }) => {
const currentChat = useRef({ id: -1 });
const chatEnabled = useRef(false);
const refChatBox = useRef<ChatBox>();
let connected = false;
const updateChatBox = () => {
refChatBox.current.setState({ enabled: connected && currentChat.current.id >= 0 && chatEnabled.current });
};
props.events.reactUse("notify_server_state", event => { connected = event.state === "connected"; updateChatBox(); });
props.events.reactUse("action_select_conversation", event => {
currentChat.current.id = event.chatId;
updateChatBox();
});
props.events.reactUse("notify_conversation_state", event => {
chatEnabled.current = event.mode === "normal" || event.mode === "private";
updateChatBox();
});
useEffect(() => {
return refChatBox.current.events.on("notify_typing", () => props.events.fire("action_clear_unread_flag", { chatId: currentChat.current.id }));
});
return <div className={cssStyle.panel}>
<ConversationMessages events={props.events} handler={props.handler} />
<ChatBox
ref={refChatBox}
onSubmit={text => props.events.fire("action_send_message", { chatId: currentChat.current.id, text: text }) }
/>
</div>
};

View File

@ -0,0 +1,32 @@
import {ConversationUIEvents} from "tc-shared/ui/frames/side/ConversationDefinitions";
export type PrivateConversationInfo = {
nickname: string;
uniqueId: string;
clientId: number;
chatId: string;
lastMessage: number;
unreadMessages: boolean;
};
export interface PrivateConversationUIEvents extends ConversationUIEvents {
action_close_chat: { chatId: string },
query_private_conversations: {},
notify_private_conversations: {
conversations: PrivateConversationInfo[],
selected: string
}
notify_partner_changed: {
chatId: string,
clientId: number,
name: string
},
notify_partner_name_changed: {
chatId: string,
name: string
}
}

View File

@ -0,0 +1,291 @@
import * as loader from "tc-loader";
import {Stage} from "tc-loader";
import * as log from "tc-shared/log";
import {LogCategory} from "tc-shared/log";
import {ChatEvent} from "tc-shared/ui/frames/side/ConversationDefinitions";
const clientUniqueId2StoreName = uniqueId => "conversation-" + uniqueId;
let currentDatabase: IDBDatabase;
let databaseMode: "closed" | "opening" | "updating" | "open" = "closed";
/* will trigger only once, have to be re added */
const databaseStateChangedCallbacks: (() => void)[] = [];
async function requestDatabase() {
while(true) {
if(databaseMode === "open") {
return;
} else if(databaseMode === "opening" || databaseMode === "updating") {
await new Promise(resolve => databaseStateChangedCallbacks.push(resolve));
} else if(databaseMode === "closed") {
await doOpenDatabase(false);
}
}
}
async function executeClose() {
currentDatabase.close();
/* We don't await the close since for some reason the onclose callback never triggers */
/* await new Promise(resolve => currentDatabase.onclose = resolve); */
currentDatabase = undefined;
}
type DatabaseUpdateRequest = (database: IDBDatabase) => void;
const databaseUpdateRequests: DatabaseUpdateRequest[] = [];
async function requestDatabaseUpdate(callback: (database: IDBDatabase) => void) : Promise<void> {
while(true) {
if(databaseMode === "opening") {
await requestDatabase();
} else if(databaseMode === "updating") {
databaseUpdateRequests.push(callback);
await requestDatabase();
if(databaseUpdateRequests.indexOf(callback) === -1)
return;
} else if(databaseMode === "open") {
databaseMode = "updating";
await executeClose();
break;
} else if(databaseMode === "closed") {
databaseMode = "updating";
break;
}
}
/* lets update the database */
databaseMode = "updating";
fireDatabaseStateChanged();
databaseUpdateRequests.push(callback);
await doOpenDatabase(true);
}
function fireDatabaseStateChanged() {
while(databaseStateChangedCallbacks.length > 0) {
try {
databaseStateChangedCallbacks.pop()();
} catch (error) {
log.error(LogCategory.CHAT, tr("Database ready callback throw an unexpected exception: %o"), error);
}
}
}
let cacheImportUniqueKeyId = 0;
async function importChatsFromCacheStorage(database: IDBDatabase) {
if(!(await caches.has("chat_history")))
return;
log.info(LogCategory.CHAT, tr("Importing old chats saved via cache storage. This may take some moments."));
let chatEvents = {};
const cache = await caches.open("chat_history");
for(const chat of await cache.keys()) {
try {
if(!chat.url.startsWith("https://_local_cache/cache_request_")) {
log.warn(LogCategory.CHAT, tr("Skipping importing chat %s because URL does not match."), chat.url);
continue;
}
const clientUniqueId = chat.url.substring(35).split("_")[1];
const events: ChatEvent[] = chatEvents[clientUniqueId] || (chatEvents[clientUniqueId] = []);
const data = await (await cache.match(chat)).json();
if(!Array.isArray(data))
throw tr("array expected");
for(const event of data) {
events.push({
type: "message",
timestamp: event["timestamp"],
isOwnMessage: event["sender"] === "self",
uniqueId: "ci-m-" + event["timestamp"] + "-" + (++cacheImportUniqueKeyId),
message: {
message: event["message"],
timestamp: event["timestamp"],
sender_database_id: event["sender_database_id"],
sender_name: event["sender_name"],
sender_unique_id: event["sender_unique_id"]
}
});
}
} catch (error) {
log.warn(LogCategory.CHAT, tr("Skipping importing chat %s because of an error: %o"), chat?.url, error);
}
}
const clientUniqueIds = Object.keys(chatEvents);
if(clientUniqueIds.length === 0)
return;
log.info(LogCategory.CHAT, tr("Found %d old chats. Importing."), clientUniqueIds.length);
await requestDatabaseUpdate(database => {
for(const uniqueId of clientUniqueIds)
doInitializeUser(uniqueId, database);
});
await requestDatabase();
for(const uniqueId of clientUniqueIds) {
const storeName = clientUniqueId2StoreName(uniqueId);
const transaction = currentDatabase.transaction(storeName, "readwrite");
const store = transaction.objectStore(storeName);
chatEvents[uniqueId].forEach(event => {
store.put(event);
});
await new Promise(resolve => store.transaction.oncomplete = resolve);
}
log.info(LogCategory.CHAT, tr("All old chats have been imported. Deleting old data."));
await caches.delete("chat_history");
}
async function doOpenDatabase(forceUpgrade: boolean) {
if(databaseMode === "closed") {
databaseMode = "opening";
fireDatabaseStateChanged();
}
let localVersion = parseInt(localStorage.getItem("indexeddb-private-conversations-version") || "0");
let upgradePerformed = false;
while(true) {
const openRequest = indexedDB.open("private-conversations", forceUpgrade ? localVersion + 1 : undefined);
openRequest.onupgradeneeded = event => {
if(event.oldVersion === 0) {
/* database newly created */
importChatsFromCacheStorage(openRequest.result).catch(error => {
log.warn(LogCategory.CHAT, tr("Failed to import old chats from cache storage: %o"), error);
});
}
upgradePerformed = true;
while (databaseUpdateRequests.length > 0) {
try {
databaseUpdateRequests.pop()(openRequest.result);
} catch (error) {
log.error(LogCategory.CHAT, tr("Database update callback throw an unexpected exception: %o"), error);
}
}
};
const database = await new Promise<IDBDatabase>((resolve, reject) => {
openRequest.onblocked = () => {
reject(tr("Failed to open/upgrade the private chat database.\nPlease close all other instances of the TeaWeb client."));
};
openRequest.onerror = () => {
console.error("Private conversation history opening error: %o", openRequest.error);
reject(openRequest.error.message);
};
openRequest.onsuccess = () => resolve(openRequest.result);
});
localStorage.setItem("indexeddb-private-conversations-version", database.version.toString());
if(!upgradePerformed && forceUpgrade) {
log.warn(LogCategory.CHAT, tr("Opened private conversations database, with an update, but update didn't happened. Trying again."));
database.close();
await new Promise(resolve => database.onclose = resolve);
continue;
}
database.onversionchange = () => {
log.debug(LogCategory.CHAT, tr("Received external database version change. Closing database."));
databaseMode = "closed";
executeClose();
};
currentDatabase = database;
databaseMode = "open";
fireDatabaseStateChanged();
break;
}
}
function doInitializeUser(uniqueId: string, database: IDBDatabase) {
const storeId = clientUniqueId2StoreName(uniqueId);
if(database.objectStoreNames.contains(storeId))
return;
const store = database.createObjectStore(storeId, { keyPath: "databaseId", autoIncrement: true });
store.createIndex("timestamp", "timestamp", { unique: false });
store.createIndex("uniqueId", "uniqueId", { unique: false });
store.createIndex("type", "type", { unique: false });
}
async function initializeUser(uniqueId: string) {
await requestDatabase();
const storeId = clientUniqueId2StoreName(uniqueId);
if(currentDatabase.objectStoreNames.contains(storeId))
return;
await requestDatabaseUpdate(database => doInitializeUser(uniqueId, database));
}
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
priority: 0,
name: "Chat history setup",
function: async () => {
if(!('indexedDB' in window)) {
loader.critical_error(tr("Missing Indexed DB support"));
throw tr("Missing Indexed DB support");
}
await doOpenDatabase(false);
log.debug(LogCategory.CHAT, tr("Successfully initialized private conversation history database"));
}
});
export async function queryConversationEvents(clientUniqueId: string, query: {
begin: number,
end: number,
direction: "backwards" | "forwards",
limit: number
}) : Promise<{ events: (ChatEvent & { databaseId: number })[], hasMore: boolean }> {
const storeName = clientUniqueId2StoreName(clientUniqueId);
await requestDatabase();
if(!currentDatabase.objectStoreNames.contains(storeName))
return { events: [], hasMore: false };
const transaction = currentDatabase.transaction(storeName, "readonly");
const store = transaction.objectStore(storeName);
const cursor = store.index("timestamp").openCursor(IDBKeyRange.bound(query.end, query.begin, false, false), query.direction === "backwards" ? "prev" : "next");
const events = [];
let hasMoreEvents = false;
await new Promise((resolve, reject) => {
cursor.onsuccess = () => {
if(!cursor.result) {
/* no more results */
resolve();
return;
}
if(events.length > query.limit) {
hasMoreEvents = true;
resolve();
return;
}
events.push(cursor.result.value);
cursor.result.continue();
};
cursor.onerror = () => reject(cursor.error);
});
return { events: events, hasMore: hasMoreEvents };
}
export async function registerConversationEvent(clientUniqueId: string, event: ChatEvent) : Promise<void> {
await initializeUser(clientUniqueId);
const storeName = clientUniqueId2StoreName(clientUniqueId);
await requestDatabase();
const transaction = currentDatabase.transaction(storeName, "readwrite");
const store = transaction.objectStore(storeName);
store.put(event);
}

View File

@ -0,0 +1,464 @@
import {ClientEntry} from "tc-shared/ui/client";
import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler";
import {EventHandler, Registry} from "tc-shared/events";
import {
PrivateConversationInfo,
PrivateConversationUIEvents
} from "tc-shared/ui/frames/side/PrivateConversationDefinitions";
import * as ReactDOM from "react-dom";
import * as React from "react";
import {PrivateConversationsPanel} from "tc-shared/ui/frames/side/PrivateConversationUI";
import {
ChatEvent,
ChatMessage,
ConversationHistoryResponse,
ConversationUIEvents
} from "tc-shared/ui/frames/side/ConversationDefinitions";
import {AbstractChat, AbstractChatManager} from "tc-shared/ui/frames/side/ConversationManager";
import * as log from "tc-shared/log";
import {LogCategory} from "tc-shared/log";
import {queryConversationEvents, registerConversationEvent} from "tc-shared/ui/frames/side/PrivateConversationHistory";
export type OutOfViewClient = {
nickname: string,
clientId: number,
uniqueId: string
}
let receivingEventUniqueIdIndex = 0;
export class PrivateConversation extends AbstractChat<PrivateConversationUIEvents> {
public readonly clientUniqueId: string;
private activeClientListener: (() => void)[] | undefined = undefined;
private activeClient: ClientEntry | OutOfViewClient | undefined = undefined;
private lastClientInfo: OutOfViewClient = undefined;
private conversationOpen: boolean = false;
constructor(manager: PrivateConversationManager, events: Registry<PrivateConversationUIEvents>, client: ClientEntry | OutOfViewClient) {
super(manager.connection, client instanceof ClientEntry ? client.clientUid() : client.uniqueId, events);
this.activeClient = client;
if(client instanceof ClientEntry) {
this.registerClientEvents(client);
this.clientUniqueId = client.clientUid();
} else {
this.clientUniqueId = client.uniqueId;
}
this.updateClientInfo();
this.events.on("notify_destroy", () => this.unregisterClientEvents());
}
destroy() {
this.unregisterClientEvents();
}
getActiveClient(): ClientEntry | OutOfViewClient | undefined { return this.activeClient; }
currentClientId() {
return this.lastClientInfo.clientId;
}
/* A value of undefined means that the remote client has disconnected */
setActiveClientEntry(client: ClientEntry | OutOfViewClient | undefined) {
if(this.activeClient === client)
return;
if(this.activeClient instanceof ClientEntry) {
if(client instanceof ClientEntry) {
this.activeClient.setUnread(false); /* "transfer" the unread flag */
this.registerChatEvent({
type: "partner-instance-changed",
oldClient: this.activeClient.clientNickName(),
newClient: client.clientNickName(),
timestamp: Date.now(),
uniqueId: "pic-" + this.chatId + "-" + Date.now() + "-" + (++receivingEventUniqueIdIndex)
}, false);
}
}
this.unregisterClientEvents();
this.activeClient = client;
if(this.activeClient instanceof ClientEntry)
this.registerClientEvents(this.activeClient);
this.updateClientInfo();
}
hasUnreadMessages() : boolean {
return false;
}
handleIncomingMessage(client: ClientEntry | OutOfViewClient, isOwnMessage: boolean, message: ChatMessage) {
if(!isOwnMessage) {
this.setActiveClientEntry(client);
}
this.conversationOpen = true;
this.registerIncomingMessage(message, isOwnMessage, "m-" + this.clientUniqueId + "-" + message.timestamp + "-" + (++receivingEventUniqueIdIndex));
}
handleChatRemotelyClosed(clientId: number) {
if(clientId !== this.lastClientInfo.clientId)
return;
this.registerChatEvent({
type: "partner-action",
action: "close",
timestamp: Date.now(),
uniqueId: "pa-" + this.chatId + "-" + Date.now() + "-" + (++receivingEventUniqueIdIndex)
}, true);
}
handleClientEnteredView(client: ClientEntry, mode: "server-join" | "local-reconnect" | "appear") {
if(mode === "local-reconnect") {
this.registerChatEvent({
type: "local-action",
action: "reconnect",
timestamp: Date.now(),
uniqueId: "la-" + this.chatId + "-" + Date.now() + "-" + (++receivingEventUniqueIdIndex)
}, false);
} else if(this.lastClientInfo.clientId === 0 || mode === "server-join") {
this.registerChatEvent({
type: "partner-action",
action: "reconnect",
timestamp: Date.now(),
uniqueId: "pa-" + this.chatId + "-" + Date.now() + "-" + (++receivingEventUniqueIdIndex)
}, true);
}
this.setActiveClientEntry(client);
}
handleRemoteComposing(clientId: number) {
this.events.fire("notify_partner_typing", { chatId: this.chatId });
}
generateUIInfo() : PrivateConversationInfo {
const lastMessage = this.presentEvents.last();
return {
nickname: this.lastClientInfo.nickname,
uniqueId: this.lastClientInfo.uniqueId,
clientId: this.lastClientInfo.clientId,
chatId: this.clientUniqueId,
lastMessage: lastMessage ? lastMessage.timestamp : 0,
unreadMessages: this.unreadTimestamp !== undefined
};
}
sendMessage(text: string) {
if(this.activeClient instanceof ClientEntry)
this.doSendMessage(text, 1, this.activeClient.clientId()).then(succeeded => succeeded && (this.conversationOpen = true));
else if(this.activeClient !== undefined && this.activeClient.clientId > 0)
this.doSendMessage(text, 1, this.activeClient.clientId).then(succeeded => succeeded && (this.conversationOpen = true));
else {
this.presentEvents.push({
type: "message-failed",
uniqueId: "msf-" + this.chatId + "-" + Date.now(),
timestamp: Date.now(),
error: "error",
errorMessage: tr("target client is offline/invisible")
});
this.events.fire_async("notify_chat_event", {
chatId: this.chatId,
triggerUnread: false,
event: this.presentEvents.last()
});
}
}
sendChatClose() {
if(!this.conversationOpen)
return;
this.conversationOpen = false;
if(this.lastClientInfo.clientId > 0 && this.connection.connected) {
this.connection.serverConnection.send_command("clientchatclosed", { clid: this.lastClientInfo.clientId }, { process_result: false }).catch(() => {
/* nothing really to do here */
});
}
}
private registerClientEvents(client: ClientEntry) {
this.activeClientListener = [];
this.activeClientListener.push(client.events.on("notify_left_view", event => {
if(event.serverLeave) {
this.setActiveClientEntry(undefined);
this.registerChatEvent({
type: "partner-action",
action: "disconnect",
timestamp: Date.now(),
uniqueId: "pa-" + this.chatId + "-" + Date.now() + "-" + (++receivingEventUniqueIdIndex)
}, true);
} else {
this.setActiveClientEntry({
uniqueId: client.clientUid(),
nickname: client.clientNickName(),
clientId: client.clientId()
} as OutOfViewClient)
}
}));
this.activeClientListener.push(client.events.on("notify_properties_updated", event => {
if('client_nickname' in event.updated_properties)
this.updateClientInfo();
}));
}
private unregisterClientEvents() {
if(this.activeClientListener === undefined)
return;
this.activeClientListener.forEach(e => e());
this.activeClientListener = undefined;
}
private updateClientInfo() {
let newInfo: OutOfViewClient;
if(this.activeClient instanceof ClientEntry) {
newInfo = {
clientId: this.activeClient.clientId(),
nickname: this.activeClient.clientNickName(),
uniqueId: this.activeClient.clientUid()
};
} else {
newInfo = Object.assign({}, this.activeClient);
if(!newInfo.nickname)
newInfo.nickname = this.lastClientInfo.nickname;
if(!newInfo.uniqueId)
newInfo.uniqueId = this.clientUniqueId;
if(!newInfo.clientId || this.activeClient === undefined)
newInfo.clientId = 0;
}
if(this.lastClientInfo) {
if(newInfo.clientId !== this.lastClientInfo.clientId) {
this.events.fire("notify_partner_changed", { chatId: this.clientUniqueId, clientId: newInfo.clientId, name: newInfo.nickname });
} else if(newInfo.nickname !== this.lastClientInfo.nickname) {
this.events.fire("notify_partner_name_changed", { chatId: this.clientUniqueId, name: newInfo.nickname });
}
}
this.lastClientInfo = newInfo;
this.sendMessageSendingEnabled(this.lastClientInfo.clientId !== 0);
}
protected canClientAccessChat(): boolean {
return true;
}
handleLocalClientDisconnect(explicitDisconnect: boolean) {
this.setActiveClientEntry(undefined);
if(explicitDisconnect) {
this.registerChatEvent({
type: "local-action",
uniqueId: "la-" + this.chatId + "-" + Date.now(),
timestamp: Date.now(),
action: "disconnect"
}, false);
}
}
queryCurrentMessages() {
this.mode = "loading";
this.reportStateToUI();
queryConversationEvents(this.clientUniqueId, { limit: 50, begin: Date.now(), end: 0, direction: "backwards" }).then(result => {
this.presentEvents = result.events.filter(e => e.type !== "message") as any;
this.presentMessages = result.events.filter(e => e.type === "message");
this.hasHistory = result.hasMore;
this.mode = "normal";
this.reportStateToUI();
});
}
protected registerChatEvent(event: ChatEvent, triggerUnread: boolean) {
super.registerChatEvent(event, triggerUnread);
registerConversationEvent(this.clientUniqueId, event).catch(error => {
log.warn(LogCategory.CHAT, tr("Failed to register private conversation chat event for %s: %o"), this.clientUniqueId, error);
});
}
async queryHistory(criteria: { begin?: number; end?: number; limit?: number }): Promise<ConversationHistoryResponse> {
const result = await queryConversationEvents(this.clientUniqueId, {
limit: criteria.limit,
direction: "backwards",
begin: criteria.begin,
end: criteria.end
});
return {
status: "success",
events: result.events,
moreEvents: result.hasMore,
nextAllowedQuery: 0
}
}
}
export class PrivateConversationManager extends AbstractChatManager<PrivateConversationUIEvents> {
public readonly htmlTag: HTMLDivElement;
public readonly connection: ConnectionHandler;
private activeConversation: PrivateConversation | undefined = undefined;
private conversations: PrivateConversation[] = [];
private channelTreeInitialized = false;
constructor(connection: ConnectionHandler) {
super();
this.connection = connection;
this.htmlTag = document.createElement("div");
this.htmlTag.style.display = "flex";
this.htmlTag.style.flexDirection = "row";
this.htmlTag.style.justifyContent = "stretch";
this.htmlTag.style.height = "100%";
this.uiEvents.register_handler(this, true);
this.uiEvents.enable_debug("private-conversations");
ReactDOM.render(React.createElement(PrivateConversationsPanel, { events: this.uiEvents, handler: this.connection }), this.htmlTag);
this.uiEvents.on("notify_destroy", connection.events().on("notify_visibility_changed", event => {
if(!event.visible)
return;
this.handlePanelShow();
}));
this.uiEvents.on("notify_destroy", connection.events().on("notify_connection_state_changed", event => {
if(ConnectionState.socketConnected(event.old_state) !== ConnectionState.socketConnected(event.new_state)) {
for(const chat of this.conversations) {
chat.handleLocalClientDisconnect(event.old_state === ConnectionState.CONNECTED);
}
this.channelTreeInitialized = false;
}
}));
this.uiEvents.on("notify_destroy", connection.channelTree.events.on("notify_client_enter_view", event => {
const conversation = this.findConversation(event.client);
if(!conversation) return;
conversation.handleClientEnteredView(event.client, this.channelTreeInitialized ? event.isServerJoin ? "server-join" : "appear" : "local-reconnect");
}));
this.uiEvents.on("notify_destroy", connection.channelTree.events.on("notify_channel_list_received", event => {
this.channelTreeInitialized = true;
}));
}
destroy() {
ReactDOM.unmountComponentAtNode(this.htmlTag);
this.htmlTag.remove();
this.uiEvents.unregister_handler(this);
this.uiEvents.fire("notify_destroy");
this.uiEvents.destroy();
}
findConversation(client: ClientEntry | string) {
const uniqueId = client instanceof ClientEntry ? client.clientUid() : client;
return this.conversations.find(e => e.clientUniqueId === uniqueId);
}
protected findChat(id: string): AbstractChat<PrivateConversationUIEvents> {
return this.findConversation(id);
}
findOrCreateConversation(client: ClientEntry | OutOfViewClient) {
let conversation = this.findConversation(client instanceof ClientEntry ? client : client.uniqueId);
if(!conversation) {
this.conversations.push(conversation = new PrivateConversation(this, this.uiEvents, client));
this.reportConversationList();
}
return conversation;
}
setActiveConversation(conversation: PrivateConversation | undefined) {
if(conversation === this.activeConversation)
return;
this.activeConversation = conversation;
/* fire this after all other events have been processed, maybe reportConversationList has been called before */
this.uiEvents.fire_async("notify_selected_chat", { chatId: this.activeConversation ? this.activeConversation.clientUniqueId : "unselected" });
}
@EventHandler<PrivateConversationUIEvents>("action_select_chat")
private handleActionSelectChat(event: PrivateConversationUIEvents["action_select_chat"]) {
this.setActiveConversation(this.findConversation(event.chatId));
}
getActiveConversation() {
return this.activeConversation;
}
getConversations() {
return this.conversations;
}
focusInput() {
this.uiEvents.fire("action_focus_chat");
}
closeConversation(...conversations: PrivateConversation[]) {
for(const conversation of conversations) {
conversation.sendChatClose();
this.conversations.remove(conversation);
conversation.destroy();
if(this.activeConversation === conversation)
this.setActiveConversation(undefined);
}
this.reportConversationList();
}
private reportConversationList() {
this.uiEvents.fire_async("notify_private_conversations", {
conversations: this.conversations.map(conversation => conversation.generateUIInfo()),
selected: this.activeConversation?.clientUniqueId || "unselected"
});
}
@EventHandler<PrivateConversationUIEvents>("query_private_conversations")
private handleQueryPrivateConversations() {
this.reportConversationList();
}
@EventHandler<PrivateConversationUIEvents>("action_close_chat")
private handleConversationClose(event: PrivateConversationUIEvents["action_close_chat"]) {
const conversation = this.findConversation(event.chatId);
if(!conversation) {
log.error(LogCategory.CLIENT, tr("Tried to close a not existing private conversation with id %s"), event.chatId);
return;
}
this.closeConversation(conversation);
}
@EventHandler<PrivateConversationUIEvents>("notify_partner_typing")
private handleNotifySelectChat(event: PrivateConversationUIEvents["notify_selected_chat"]) {
/* TODO, set active chat? */
}
@EventHandler<ConversationUIEvents>("action_self_typing")
protected handleActionSelfTyping1(event: ConversationUIEvents["action_self_typing"]) {
if(!this.activeConversation)
return;
const clientId = this.activeConversation.currentClientId();
if(!clientId)
return;
this.connection.serverConnection.send_command("clientchatcomposing", { clid: clientId }).catch(error => {
log.warn(LogCategory.CHAT, tr("Failed to send chat composing to server for chat %d: %o"), clientId, error);
});
}
}

View File

@ -0,0 +1,173 @@
@import "../../../../css/static/mixin";
@import "../../../../css/static/properties";
.divider {
width: 2px!important;
min-width: 2px!important;
max-width: 2px!important;
}
.conversationList {
user-select: none;
overflow-x: hidden;
overflow-y: auto;
@include chat-scrollbar-vertical();
width: 25%;
min-width: 100px;
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-start;
.noChats, .loading {
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
> div {
display: inline-block;
color: #5a5a5a;
}
}
}
.conversationEntry {
position: relative;
display: flex;
flex-direction: row;
justify-content: stretch;
cursor: pointer;
border-bottom: 1px solid #313132;
.containerAvatar {
flex-grow: 0;
flex-shrink: 0;
position: relative;
display: inline-block;
margin: 5px 10px 5px 5px;
.avatar {
overflow: hidden;
width: 2em;
height: 2em;
border-radius: 50%;
}
.unread {
display: block;
position: absolute;
top: 0;
right: 0;
background-color: #a81414;
width: 7px;
height: 7px;
border-radius: 50%;
-webkit-box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.20);
-moz-box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.20);
box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.20);
}
}
.info {
flex-grow: 1;
flex-shrink: 1;
min-width: 50px;
display: flex;
flex-direction: column;
justify-content: center;
> * {
flex-grow: 0;
flex-shrink: 0;
display: inline-block;
width: 100%;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.name {
color: #ccc;
font-weight: bold;
margin-bottom: -.4em;
}
.timestamp {
color: #555353;
display: inline-block;
font-size: .66em;
}
}
.close {
font-size: 2em;
position: absolute;
right: 0;
top: 0;
bottom: 0;
opacity: 0.3;
width: .5em;
height: .5em;
&:hover {
opacity: 1;
}
@include transition(opacity $button_hover_animation_time ease-in-out);
&:before, &:after {
position: absolute;
left: .25em;
content: ' ';
height: .5em;
width: .05em;
background-color: #5a5a5a;
}
&:before {
transform: rotate(45deg);
}
&:after {
transform: rotate(-45deg);
}
}
&:hover {
background-color: #393939;
}
&.selected {
background-color: #2c2c2c;
}
@include transition(background-color $button_hover_animation_time ease-in-out);
}

View File

@ -0,0 +1,206 @@
import * as React from "react";
import {Registry} from "tc-shared/events";
import {
PrivateConversationInfo,
PrivateConversationUIEvents
} from "tc-shared/ui/frames/side/PrivateConversationDefinitions";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {ContextDivider} from "tc-shared/ui/react-elements/ContextDivider";
import {ConversationPanel} from "tc-shared/ui/frames/side/ConversationUI";
import {useEffect, useState} from "react";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
import {AvatarRenderer} from "tc-shared/ui/react-elements/Avatar";
import {TimestampRenderer} from "tc-shared/ui/react-elements/TimestampRenderer";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
const cssStyle = require("./PrivateConversationUI.scss");
const kTypingTimeout = 5000;
const ConversationEntryInfo = React.memo((props: { events: Registry<PrivateConversationUIEvents>, chatId: string, initialNickname: string, lastMessage: number }) => {
const [ nickname, setNickname ] = useState(props.initialNickname);
const [ lastMessage, setLastMessage ] = useState(props.lastMessage);
const [ typingTimestamp, setTypingTimestamp ] = useState(0);
props.events.reactUse("notify_partner_name_changed", event => {
if(event.chatId !== props.chatId)
return;
setNickname(event.name);
});
props.events.reactUse("notify_partner_changed", event => {
if(event.chatId !== props.chatId)
return;
setNickname(event.name);
});
props.events.reactUse("notify_chat_event", event => {
if(event.chatId !== props.chatId)
return;
if(event.event.type === "message") {
if(event.event.timestamp > lastMessage)
setLastMessage(event.event.timestamp);
if(!event.event.isOwnMessage)
setTypingTimestamp(0);
} else if(event.event.type === "partner-action" || event.event.type === "local-action") {
setTypingTimestamp(0);
}
});
props.events.reactUse("notify_conversation_state", event => {
if(event.chatId !== props.chatId || event.state !== "normal")
return;
let lastStatedMessage = event.events
.filter(e => e.type === "message")
.sort((a, b) => a.timestamp - b.timestamp)
.last()?.timestamp;
if(typeof lastStatedMessage === "number" && (typeof lastMessage === "undefined" || lastStatedMessage > lastMessage))
setLastMessage(lastStatedMessage);
});
props.events.reactUse("notify_partner_typing", event => {
if(event.chatId !== props.chatId)
return;
setTypingTimestamp(Date.now());
});
const isTyping = Date.now() - kTypingTimeout < typingTimestamp;
useEffect(() => {
if(!isTyping)
return;
const timeout = setTimeout(() => {
setTypingTimestamp(0);
}, kTypingTimeout);
return () => clearTimeout(timeout);
});
let lastMessageContent;
if(isTyping) {
lastMessageContent = <React.Fragment key={"typing"}><Translatable>Typing</Translatable> <LoadingDots /></React.Fragment>;
} else if(lastMessage === 0) {
lastMessageContent = <Translatable key={"no-message"}>No messages</Translatable>;
} else {
lastMessageContent = <TimestampRenderer key={"last-message"} timestamp={lastMessage} />;
}
return (
<div className={cssStyle.info}>
<a className={cssStyle.name}>{nickname}</a>
<a className={cssStyle.timestamp}>
{lastMessageContent}
</a>
</div>
);
});
const ConversationEntryUnreadMarker = React.memo((props: { events: Registry<PrivateConversationUIEvents>, chatId: string, initialUnread: boolean }) => {
const [ unread, setUnread ] = useState(props.initialUnread);
props.events.reactUse("notify_unread_timestamp_changed", event => {
if(event.chatId !== props.chatId)
return;
setUnread(event.timestamp !== undefined);
});
props.events.reactUse("notify_chat_event", event => {
if(event.chatId !== props.chatId || !event.triggerUnread)
return;
setUnread(true);
});
if(!unread)
return null;
return <div key={"unread-marker"} className={cssStyle.unread} />;
});
const ConversationEntry = React.memo((props: { events: Registry<PrivateConversationUIEvents>, info: PrivateConversationInfo, selected: boolean, connection: ConnectionHandler }) => {
const [ clientId, setClientId ] = useState(props.info.clientId);
props.events.reactUse("notify_partner_changed", event => {
if(event.chatId !== props.info.chatId)
return;
props.info.clientId = event.clientId;
setClientId(event.clientId);
});
return (
<div
className={cssStyle.conversationEntry + " " + (props.selected ? cssStyle.selected : "")}
onClick={() => props.events.fire("action_select_chat", { chatId: props.info.chatId })}
>
<div className={cssStyle.containerAvatar}>
<AvatarRenderer className={cssStyle.avatar} avatar={props.connection.fileManager.avatars.resolveClientAvatar({ id: clientId, database_id: 0, clientUniqueId: props.info.uniqueId })} />
<ConversationEntryUnreadMarker chatId={props.info.chatId} events={props.events} initialUnread={props.info.unreadMessages} />
</div>
<ConversationEntryInfo events={props.events} chatId={props.info.chatId} initialNickname={props.info.nickname} lastMessage={props.info.lastMessage} />
<div className={cssStyle.close} onClick={() => {
props.events.fire("action_close_chat", { chatId: props.info.chatId });
}} />
</div>
);
});
const OpenConversationsPanel = React.memo((props: { events: Registry<PrivateConversationUIEvents>, connection: ConnectionHandler }) => {
const [ conversations, setConversations ] = useState<PrivateConversationInfo[] | "loading">(() => {
props.events.fire("query_private_conversations");
return "loading";
});
const [ selected, setSelected ] = useState("unselected");
props.events.reactUse("notify_private_conversations", event => {
setConversations(event.conversations);
setSelected(event.selected);
});
props.events.reactUse("notify_selected_chat", event => {
setSelected(event.chatId);
});
let content;
if(conversations === "loading") {
content = (
<div key={"loading"} className={cssStyle.loading}>
<div>loading <LoadingDots /></div>
</div>
);
} else if(conversations.length === 0) {
content = (
<div key={"no-chats"} className={cssStyle.loading}>
<div>You&nbsp;dont&nbsp;have any&nbsp;chats&nbsp;yet!</div>
</div>
);
} else {
content = conversations.map(e => <ConversationEntry
key={"c-" + e.chatId}
events={props.events}
info={e}
selected={e.chatId === selected}
connection={props.connection}
/>)
}
return (
<div className={cssStyle.conversationList}>
{content}
</div>
);
});
export const PrivateConversationsPanel = (props: { events: Registry<PrivateConversationUIEvents>, handler: ConnectionHandler }) => (
<ContextDivider id={"seperator-conversation-list-messages"} direction={"horizontal"} defaultValue={25} separatorClassName={cssStyle.divider}>
<OpenConversationsPanel events={props.events} connection={props.handler} />
<ConversationPanel events={props.events as any} handler={props.handler} noFirstMessageOverlay={true} messagesDeletable={false} />
</ContextDivider>
);

View File

@ -1,268 +0,0 @@
import {Settings, settings} from "tc-shared/settings";
import {helpers} from "tc-shared/ui/frames/side/chat_helper";
declare function setInterval(handler: TimerHandler, timeout?: number, ...arguments: any[]): number;
declare function setTimeout(handler: TimerHandler, timeout?: number, ...arguments: any[]): number;
export class ChatBox {
private _html_tag: JQuery;
private _html_input: JQuery<HTMLDivElement>;
private _enabled: boolean;
private __callback_text_changed;
private __callback_key_down;
private __callback_key_up;
private __callback_paste;
private _typing_timeout: number; /* ID when the next callback_typing will be called */
private _typing_last_event: number; /* timestamp of the last typing event */
private _message_history: string[] = [];
private _message_history_length = 100;
private _message_history_index = 0;
typing_interval: number = 2000; /* update frequency */
callback_typing: () => any;
callback_text: (text: string) => any;
constructor() {
this._enabled = true;
this.__callback_key_up = this._callback_key_up.bind(this);
this.__callback_key_down = this._callback_key_down.bind(this);
this.__callback_text_changed = this._callback_text_changed.bind(this);
this.__callback_paste = event => this._callback_paste(event);
this._build_html_tag();
this._initialize_listener();
}
html_tag() : JQuery {
return this._html_tag;
}
destroy() {
this._html_tag && this._html_tag.remove();
this._html_tag = undefined;
this._html_input = undefined;
clearTimeout(this._typing_timeout);
this.__callback_text_changed = undefined;
this.__callback_key_down = undefined;
this.__callback_paste = undefined;
this.callback_text = undefined;
this.callback_typing = undefined;
}
private _initialize_listener() {
this._html_input.on("cut paste drop keydown keyup", (event) => this.__callback_text_changed(event));
this._html_input.on("change", this.__callback_text_changed);
this._html_input.on("keydown", this.__callback_key_down);
this._html_input.on("keyup", this.__callback_key_up);
this._html_input.on("paste", this.__callback_paste);
}
private _build_html_tag() {
this._html_tag = $("#tmpl_frame_chat_chatbox").renderTag({
emojy_support: settings.static_global(Settings.KEY_CHAT_COLORED_EMOJIES)
});
this._html_input = this._html_tag.find(".textarea") as any;
const tag: JQuery & { lsxEmojiPicker(args: any); } = this._html_tag.find('.button-emoji') as any;
tag.lsxEmojiPicker({
width: 300,
height: 400,
twemoji: typeof(window.twemoji) !== "undefined",
onSelect: emoji => this._html_input.html(this._html_input.html() + emoji.value),
closeOnSelect: false
});
}
private _callback_text_changed(event: Event) {
if(event && event.defaultPrevented)
return;
/* Auto resize */
const text = this._html_input[0];
text.style.height = "1em";
text.style.height = text.scrollHeight + 'px';
if(!event || (event.type !== "keydown" && event.type !== "keyup" && event.type !== "change"))
return;
this._typing_last_event = Date.now();
if(this._typing_timeout)
return;
const _trigger_typing = (last_time: number) => {
if(this._typing_last_event <= last_time) {
this._typing_timeout = 0;
return;
}
try {
if(this.callback_typing)
this.callback_typing();
} finally {
this._typing_timeout = setTimeout(_trigger_typing, this.typing_interval, this._typing_last_event);
}
};
_trigger_typing(0); /* We def want that*/
}
private _text(element: HTMLElement) {
if(typeof(element) !== "object")
return element;
if(element instanceof HTMLImageElement)
return element.alt || element.title;
if(element instanceof HTMLBRElement) {
return '\n';
}
if(element.childNodes.length > 0)
return [...element.childNodes].map(e => this._text(e as HTMLElement)).join("");
if(element.nodeType == Node.TEXT_NODE)
return element.textContent;
return typeof(element.innerText) === "string" ? element.innerText : "";
}
private htmlEscape(message: string) : string {
const div = document.createElement('div');
div.innerText = message;
message = div.innerHTML;
return message.replace(/ /g, '&nbsp;');
}
private _callback_paste(event: ClipboardEvent) {
const _event = (<any>event).originalEvent as ClipboardEvent || event;
const clipboard = _event.clipboardData || (<any>window).clipboardData;
if(!clipboard) return;
const raw_text = clipboard.getData('text/plain');
const selection = window.getSelection();
if (!selection.rangeCount)
return false;
let html_xml = clipboard.getData('text/html');
if(!html_xml)
html_xml = $.spawn("div").text(raw_text).html();
const parser = new DOMParser();
const nodes = parser.parseFromString(html_xml, "text/html");
let data = this._text(nodes.body);
/* fix prefix & suffix new lines */
{
let prefix_length = 0, suffix_length = 0;
{
for(let i = 0; i < raw_text.length; i++)
if(raw_text.charAt(i) === '\n')
prefix_length++;
else if(raw_text.charAt(i) !== '\r')
break;
for(let i = raw_text.length - 1; i >= 0; i++)
if(raw_text.charAt(i) === '\n')
suffix_length++;
else if(raw_text.charAt(i) !== '\r')
break;
}
data = data.replace(/^[\n\r]+|[\n\r]+$/g, '');
data = "\n".repeat(prefix_length) + data + "\n".repeat(suffix_length);
}
event.preventDefault();
selection.deleteFromDocument();
document.execCommand('insertHTML', false, this.htmlEscape(data));
}
private test_message(message: string) : boolean {
message = message
.replace(/ /gi, "")
.replace(/<br>/gi, "")
.replace(/\n/gi, "")
.replace(/<br\/>/gi, "");
return message.length > 0;
}
private _callback_key_down(event: KeyboardEvent) {
if(event.key.toLowerCase() === "enter" && !event.shiftKey) {
event.preventDefault();
/* deactivate chatbox when no callback? */
let text = this._html_input[0].innerText as string;
if(!this.test_message(text))
return;
this._message_history.push(text);
this._message_history_index = this._message_history.length;
if(this._message_history.length > this._message_history_length)
this._message_history = this._message_history.slice(this._message_history.length - this._message_history_length);
if(this.callback_text) {
this.callback_text(helpers.preprocess_chat_message(text));
}
if(this._typing_timeout)
clearTimeout(this._typing_timeout);
this._typing_timeout = 1; /* enforce no typing update while sending */
this._html_input.text("");
setTimeout(() => {
this.__callback_text_changed();
this._typing_timeout = 0; /* enable text change listener again */
});
} else if(event.key.toLowerCase() === "arrowdown") {
//TODO: Test for at the last line within the box
if(this._message_history_index < 0) return;
if(this._message_history_index >= this._message_history.length) return; /* OOB, even with the empty message */
this._message_history_index++;
this._html_input[0].innerText = this._message_history[this._message_history_index] || ""; /* OOB just returns "undefined" */
} else if(event.key.toLowerCase() === "arrowup") {
//TODO: Test for at the first line within the box
if(this._message_history_index <= 0) return; /* we cant go "down" */
this._message_history_index--;
this._html_input[0].innerText = this._message_history[this._message_history_index];
} else {
if(this._message_history_index >= 0) {
if(this._message_history_index >= this._message_history.length) {
if("" !== this._html_input[0].innerText)
this._message_history_index = -1;
} else if(this._message_history[this._message_history_index] !== this._html_input[0].innerText)
this._message_history_index = -1;
}
}
}
private _callback_key_up(event: KeyboardEvent) {
if("" === this._html_input[0].innerText)
this._message_history_index = this._message_history.length;
}
private _context_task: number;
set_enabled(flag: boolean) {
if(this._enabled === flag)
return;
if(!this._context_task) {
this._enabled = flag;
/* Allow the browser to asynchronously recalculate everything */
this._context_task = setTimeout(() => {
this._context_task = undefined;
this._html_input.each((_, e) => { e.contentEditable = this._enabled ? "true" : "false"; });
});
this._html_tag.find('.button-emoji').toggleClass("disabled", !flag);
}
}
is_enabled() {
return this._enabled;
}
focus_input() {
this._html_input.focus();
}
}

View File

@ -135,14 +135,6 @@ export namespace helpers {
"ins_open": () => "[u]", "ins_open": () => "[u]",
"ins_close": () => "[/u]", "ins_close": () => "[/u]",
/*
```
test
[/code]
test
```
*/
"code": (renderer: Renderer, token: RemarkToken) => "[i-code]" + escapeBBCode(token.content) + "[/i-code]", "code": (renderer: Renderer, token: RemarkToken) => "[i-code]" + escapeBBCode(token.content) + "[/i-code]",
"fence": (renderer: Renderer, token: RemarkToken) => "[code" + (token.params ? ("=" + token.params) : "") + "]" + escapeBBCode(token.content) + "[/code]", "fence": (renderer: Renderer, token: RemarkToken) => "[code" + (token.params ? ("=" + token.params) : "") + "]" + escapeBBCode(token.content) + "[/code]",
@ -254,45 +246,6 @@ test
return process_url ? process_urls(message) : message; return process_url ? process_urls(message) : message;
} }
export namespace history {
let _local_cache: Cache;
async function get_cache() {
if(_local_cache)
return _local_cache;
if(!('caches' in window))
throw "missing cache extension!";
return (_local_cache = await caches.open('chat_history'));
}
export async function load_history(key: string) : Promise<any | undefined> {
const cache = await get_cache();
const request = new Request("https://_local_cache/cache_request_" + key);
const cached_response = await cache.match(request);
if(!cached_response)
return undefined;
return await cached_response.json();
}
export async function save_history(key: string, value: any) {
const cache = await get_cache();
const request = new Request("https://_local_cache/cache_request_" + key);
const data = JSON.stringify(value);
const new_headers = new Headers();
new_headers.set("Content-type", "application/json");
new_headers.set("Content-length", data.length.toString());
await cache.put(request, new Response(data, {
headers: new_headers
}));
}
}
export namespace date { export namespace date {
export function same_day(a: number | Date, b: number | Date) { export function same_day(a: number | Date, b: number | Date) {
a = a instanceof Date ? a : new Date(a); a = a instanceof Date ? a : new Date(a);
@ -315,20 +268,32 @@ export namespace format {
GENERAL GENERAL
} }
function dateEqual(a: Date, b: Date) {
return a.getUTCFullYear() === b.getUTCFullYear() &&
a.getUTCMonth() === b.getUTCMonth() &&
a.getUTCDate() === b.getUTCDate();
}
export function date_format(date: Date, now: Date, ignore_settings?: boolean) : ColloquialFormat { export function date_format(date: Date, now: Date, ignore_settings?: boolean) : ColloquialFormat {
if(!ignore_settings && !settings.static_global(Settings.KEY_CHAT_COLLOQUIAL_TIMESTAMPS)) if(!ignore_settings && !settings.static_global(Settings.KEY_CHAT_COLLOQUIAL_TIMESTAMPS))
return ColloquialFormat.GENERAL; return ColloquialFormat.GENERAL;
let delta_day = now.getDate() - date.getDate(); if(dateEqual(date, now))
if(delta_day < 1) /* month change? */
delta_day = date.getDate() - now.getDate();
if(delta_day == 0)
return ColloquialFormat.TODAY; return ColloquialFormat.TODAY;
else if(delta_day == 1)
date = new Date(date.getTime());
date.setDate(date.getDate() + 1);
if(dateEqual(date, now))
return ColloquialFormat.YESTERDAY; return ColloquialFormat.YESTERDAY;
return ColloquialFormat.GENERAL; return ColloquialFormat.GENERAL;
} }
export function formatDayTime(date: Date) {
return ("0" + date.getHours()).substr(-2) + ":" + ("0" + date.getMinutes()).substr(-2);
}
export function format_date_general(date: Date, hours?: boolean) : string { export function format_date_general(date: Date, hours?: boolean) : string {
return ('00' + date.getDate()).substr(-2) + "." return ('00' + date.getDate()).substr(-2) + "."
+ ('00' + date.getMonth()).substr(-2) + "." + ('00' + date.getMonth()).substr(-2) + "."
@ -354,7 +319,7 @@ export namespace format {
time = "PM"; time = "PM";
} }
return { return {
result: (format == ColloquialFormat.YESTERDAY ? tr("Yesterday at") : tr("Today at")) + " " + hrs + ":" + date.getMinutes() + " " + time, result: (format == ColloquialFormat.YESTERDAY ? tr("Yesterday at") : tr("Today at")) + " " + ("0" + hrs).substr(-2) + ":" + ("0" + date.getMinutes()).substr(-2) + " " + time,
format: format format: format
}; };
} }

View File

@ -1,630 +0,0 @@
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");
}
}
}

View File

@ -1,904 +0,0 @@
/* the bar on the right with the chats (Channel & Client) */
import {settings, Settings} from "tc-shared/settings";
import {LogCategory} from "tc-shared/log";
import {format, helpers} from "tc-shared/ui/frames/side/chat_helper";
import {bbcode_chat} from "tc-shared/ui/frames/chat";
import {Frame} from "tc-shared/ui/frames/chat_frame";
import {ChatBox} from "tc-shared/ui/frames/side/chat_box";
import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration";
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 PrivateConversationViewEntry = {
html_tag: JQuery;
}
export type PrivateConversationMessageData = {
message_id: string;
message: string;
sender: "self" | "partner";
sender_name: string;
sender_unique_id: string;
sender_client_id: number;
timestamp: number;
};
export type PrivateConversationViewMessage = PrivateConversationMessageData & PrivateConversationViewEntry & {
time_update_id: number;
};
export type PrivateConversationViewSpacer = PrivateConversationViewEntry;
export enum PrivateConversationState {
OPEN,
CLOSED,
DISCONNECTED,
DISCONNECTED_SELF,
}
export type DisplayedMessage = {
timestamp: number;
message: PrivateConversationViewMessage | PrivateConversationViewEntry;
message_type: "spacer" | "message";
/* structure as following
1. time pointer
2. unread
3. message
*/
tag_message: JQuery;
tag_unread: PrivateConversationViewSpacer | undefined;
tag_timepointer: PrivateConversationViewSpacer | undefined;
}
export class PrivateConveration {
readonly handle: PrivateConverations;
private _html_entry_tag: JQuery;
private _message_history: PrivateConversationMessageData[] = [];
private _callback_message: (text: string) => any;
private _state: PrivateConversationState;
private _last_message_updater_id: number;
private _last_typing: number = 0;
private _typing_timeout: number = 4000;
private _typing_timeout_task: number;
_scroll_position: number | undefined; /* undefined to follow bottom | position for special stuff */
_html_message_container: JQuery; /* only set when this chat is selected! */
client_unique_id: string;
client_id: number;
client_name: string;
private _displayed_messages: DisplayedMessage[] = [];
private _displayed_messages_length: number = 500;
private _spacer_unread_message: DisplayedMessage;
constructor(handle: PrivateConverations, client_unique_id: string, client_name: string, client_id: number) {
this.handle = handle;
this.client_name = client_name;
this.client_unique_id = client_unique_id;
this.client_id = client_id;
this._state = PrivateConversationState.OPEN;
this._build_entry_tag();
this.set_unread_flag(false);
this.load_history();
}
private history_key() { return this.handle.handle.handle.channelTree.server.properties.virtualserver_unique_identifier + "_" + this.client_unique_id; }
private load_history() {
helpers.history.load_history(this.history_key()).then((data: PrivateConversationMessageData[]) => {
if(!data) return;
const flag_unread = !!this._spacer_unread_message;
for(const message of data.slice(data.length > this._displayed_messages_length ? data.length - this._displayed_messages_length : 0)) {
this.append_message(message.message, {
type: message.sender,
name: message.sender_name,
unique_id: message.sender_unique_id,
client_id: message.sender_client_id
}, new Date(message.timestamp), false);
}
if(!flag_unread)
this.set_unread_flag(false);
this.fix_scroll(false);
this.save_history();
}).catch(error => {
log.warn(LogCategory.CHAT, tr("Failed to load private conversation history for user %s on server %s: %o"),
this.client_unique_id, this.handle.handle.handle.channelTree.server.properties.virtualserver_unique_identifier, error);
})
}
private save_history() {
helpers.history.save_history(this.history_key(), this._message_history).catch(error => {
log.warn(LogCategory.CHAT, tr("Failed to save private conversation history for user %s on server %s: %o"),
this.client_unique_id, this.handle.handle.handle.channelTree.server.properties.virtualserver_unique_identifier, error);
});
}
entry_tag() : JQuery {
return this._html_entry_tag;
}
destroy() {
this._html_message_container = undefined; /* we do not own this container */
this.clear_messages(false);
this._html_entry_tag && this._html_entry_tag.remove();
this._html_entry_tag = undefined;
this._message_history = undefined;
if(this._typing_timeout_task)
clearTimeout(this._typing_timeout_task);
}
private _2d_flat<T>(array: T[][]) : T[] {
const result = [];
for(const a of array)
result.push(...a.filter(e => typeof(e) !== "undefined"));
return result;
}
messages_tags() : JQuery[] {
return this._2d_flat(this._displayed_messages.slice().reverse().map(e => [
e.tag_timepointer ? e.tag_timepointer.html_tag : undefined,
e.tag_unread ? e.tag_unread.html_tag : undefined,
e.tag_message
]));
}
append_message(message: string, sender: {
type: "self" | "partner";
name: string;
unique_id: string;
client_id: number;
}, timestamp?: Date, save_history?: boolean) {
const message_date = timestamp || new Date();
const message_timestamp = message_date.getTime();
const packed_message = {
message: message,
sender: sender.type,
sender_name: sender.name,
sender_client_id: sender.client_id,
sender_unique_id: sender.unique_id,
timestamp: message_date.getTime(),
message_id: 'undefined'
};
/* first of all register message in message history */
{
let index = 0;
for(;index < this._message_history.length; index++) {
if(this._message_history[index].timestamp > message_timestamp)
continue;
this._message_history.splice(index, 0, packed_message);
break;
}
if(index > 100)
return; /* message is too old to be displayed */
if(index >= this._message_history.length)
this._message_history.push(packed_message);
while(this._message_history.length > 100)
this._message_history.pop();
}
if(sender.type === "partner") {
clearTimeout(this._typing_timeout_task);
this._typing_timeout_task = 0;
if(this.typing_active()) {
this._last_typing = 0;
this.typing_expired();
} else {
this._update_message_timestamp();
}
} else {
this._update_message_timestamp();
}
if(typeof(save_history) !== "boolean" || save_history)
this.save_history();
/* insert in view */
{
const basic_view_entry = this._build_message(packed_message);
this._register_displayed_message({
timestamp: basic_view_entry.timestamp,
message: basic_view_entry,
message_type: "message",
tag_message: basic_view_entry.html_tag,
tag_timepointer: undefined,
tag_unread: undefined
}, true);
}
}
private _displayed_message_first_tag(message: DisplayedMessage) {
const tp = message.tag_timepointer ? message.tag_timepointer.html_tag : undefined;
const tu = message.tag_unread ? message.tag_unread.html_tag : undefined;
return tp || tu || message.tag_message;
}
private _destroy_displayed_message(message: DisplayedMessage, update_pointers: boolean) {
if(update_pointers) {
const index = this._displayed_messages.indexOf(message);
if(index != -1 && index > 0) {
const next = this._displayed_messages[index - 1];
if(!next.tag_timepointer && message.tag_timepointer) {
next.tag_timepointer = message.tag_timepointer;
message.tag_timepointer = undefined;
}
if(!next.tag_unread && message.tag_unread) {
this._spacer_unread_message = next;
next.tag_unread = message.tag_unread;
message.tag_unread = undefined;
}
}
if(message == this._spacer_unread_message)
this._spacer_unread_message = undefined;
}
this._displayed_messages.remove(message);
if(message.tag_timepointer)
this._destroy_view_entry(message.tag_timepointer);
if(message.tag_unread)
this._destroy_view_entry(message.tag_unread);
this._destroy_view_entry(message.message);
}
clear_messages(save?: boolean) {
this._message_history = [];
while(this._displayed_messages.length > 0) {
this._destroy_displayed_message(this._displayed_messages[0], false);
}
this._spacer_unread_message = undefined;
this._update_message_timestamp();
if(save)
this.save_history();
}
fix_scroll(animate: boolean) {
if(!this._html_message_container)
return;
let offset;
if(this._spacer_unread_message) {
offset = this._displayed_message_first_tag(this._spacer_unread_message)[0].offsetTop;
} else if(typeof(this._scroll_position) !== "undefined") {
offset = this._scroll_position;
} else {
offset = this._html_message_container[0].scrollHeight;
}
if(animate) {
this._html_message_container.stop(true).animate({
scrollTop: offset
}, 'slow');
} else {
this._html_message_container.stop(true).scrollTop(offset);
}
}
private _update_message_timestamp() {
if(this._last_message_updater_id)
clearTimeout(this._last_message_updater_id);
if(!this._html_entry_tag)
return; /* we got deleted, not need for updates */
if(this.typing_active()) {
this._html_entry_tag.find(".last-message").text(tr("currently typing..."));
return;
}
const last_message = this._message_history[0];
if(!last_message) {
this._html_entry_tag.find(".last-message").text(tr("no history"));
return;
}
const timestamp = new Date(last_message.timestamp);
let time = format.date.format_chat_time(timestamp);
this._html_entry_tag.find(".last-message").text(time.result);
if(time.next_update > 0) {
this._last_message_updater_id = setTimeout(() => this._update_message_timestamp(), time.next_update);
} else {
this._last_message_updater_id = 0;
}
}
private _destroy_message(message: PrivateConversationViewMessage) {
if(message.time_update_id)
clearTimeout(message.time_update_id);
}
private _build_message(message: PrivateConversationMessageData) : PrivateConversationViewMessage {
const result = message as PrivateConversationViewMessage;
if(result.html_tag)
return result;
const timestamp = new Date(message.timestamp);
let time = format.date.format_chat_time(timestamp);
result.html_tag = $("#tmpl_frame_chat_private_message").renderTag({
timestamp: time.result,
message_id: message.message_id,
client_name: htmltags.generate_client_object({
add_braces: false,
client_name: message.sender_name,
client_unique_id: message.sender_unique_id,
client_id: message.sender_client_id
}),
message: bbcode_chat(message.message),
avatar: this.handle.handle.handle.fileManager.avatars.generate_chat_tag({id: message.sender_client_id}, message.sender_unique_id)
});
if(time.next_update > 0) {
const _updater = () => {
time = format.date.format_chat_time(timestamp);
result.html_tag.find(".info .timestamp").text(time.result);
if(time.next_update > 0)
result.time_update_id = setTimeout(_updater, time.next_update);
else
result.time_update_id = 0;
};
result.time_update_id = setTimeout(_updater, time.next_update);
} else {
result.time_update_id = 0;
}
return result;
}
private _build_spacer(message: string, type: "date" | "new" | "disconnect" | "disconnect_self" | "reconnect" | "closed" | "error") : PrivateConversationViewSpacer {
const tag = $("#tmpl_frame_chat_private_spacer").renderTag({
message: message
}).addClass("type-" + type);
return {
html_tag: tag
}
}
private _register_displayed_message(message: DisplayedMessage, update_new: boolean) {
const message_date = new Date(message.timestamp);
/* before := older message; after := newer message */
let entry_before: DisplayedMessage, entry_after: DisplayedMessage;
let index = 0;
for(;index < this._displayed_messages.length; index++) {
if(this._displayed_messages[index].timestamp > message.timestamp)
continue;
entry_after = index > 0 ? this._displayed_messages[index - 1] : undefined;
entry_before = this._displayed_messages[index];
this._displayed_messages.splice(index, 0, message);
break;
}
if(index >= this._displayed_messages_length) {
return; /* message is out of view region */
}
if(index >= this._displayed_messages.length) {
entry_before = undefined;
entry_after = this._displayed_messages.last();
this._displayed_messages.push(message);
}
while(this._displayed_messages.length > this._displayed_messages_length)
this._destroy_displayed_message(this._displayed_messages.last(), true);
const flag_new_message = update_new && index == 0 && (message.message_type === "spacer" || (<PrivateConversationViewMessage>message.message).sender === "partner");
/* Timeline for before - now */
{
let append_pointer = false;
if(entry_before) {
if(!helpers.date.same_day(message.timestamp, entry_before.timestamp)) {
append_pointer = true;
}
} else {
append_pointer = true;
}
if(append_pointer) {
const diff = format.date.date_format(message_date, new Date());
if(diff == format.date.ColloquialFormat.YESTERDAY)
message.tag_timepointer = this._build_spacer(tr("Yesterday"), "date");
else if(diff == format.date.ColloquialFormat.TODAY)
message.tag_timepointer = this._build_spacer(tr("Today"), "date");
else if(diff == format.date.ColloquialFormat.GENERAL)
message.tag_timepointer = this._build_spacer(format.date.format_date_general(message_date, false), "date");
}
}
/* Timeline not and after */
{
if(entry_after) {
if(helpers.date.same_day(message_date, entry_after.timestamp)) {
if(entry_after.tag_timepointer) {
this._destroy_view_entry(entry_after.tag_timepointer);
entry_after.tag_timepointer = undefined;
}
} else if(!entry_after.tag_timepointer) {
const diff = format.date.date_format(new Date(entry_after.timestamp), new Date());
if(diff == format.date.ColloquialFormat.YESTERDAY)
entry_after.tag_timepointer = this._build_spacer(tr("Yesterday"), "date");
else if(diff == format.date.ColloquialFormat.TODAY)
entry_after.tag_timepointer = this._build_spacer(tr("Today"), "date");
else if(diff == format.date.ColloquialFormat.GENERAL)
entry_after.tag_timepointer = this._build_spacer(format.date.format_date_general(message_date, false), "date");
entry_after.tag_timepointer.html_tag.insertBefore(entry_after.tag_message);
}
}
}
/* new message flag */
if(flag_new_message) {
if(!this._spacer_unread_message) {
this._spacer_unread_message = message;
message.tag_unread = this._build_spacer(tr("Unread messages"), "new");
this.set_unread_flag(true);
}
}
if(this._html_message_container) {
if(entry_before) {
message.tag_message.insertAfter(entry_before.tag_message);
} else if(entry_after) {
message.tag_message.insertBefore(this._displayed_message_first_tag(entry_after));
} else {
this._html_message_container.append(message.tag_message);
}
/* first time pointer */
if(message.tag_timepointer)
message.tag_timepointer.html_tag.insertBefore(message.tag_message);
/* the unread */
if(message.tag_unread)
message.tag_unread.html_tag.insertBefore(message.tag_message);
}
this.fix_scroll(true);
}
private _destroy_view_entry(entry: PrivateConversationViewEntry) {
if(!entry.html_tag)
return;
entry.html_tag.remove();
if('sender' in entry)
this._destroy_message(entry);
}
private _build_entry_tag() {
this._html_entry_tag = $("#tmpl_frame_chat_private_entry").renderTag({
client_name: this.client_name,
last_time: tr("error no timestamp"),
avatar: this.handle.handle.handle.fileManager.avatars.generate_chat_tag({id: this.client_id}, this.client_unique_id)
});
this._html_entry_tag.on('click', event => {
if(event.isDefaultPrevented())
return;
this.handle.set_selected_conversation(this);
});
this._html_entry_tag.find('.button-close').on('click', event => {
event.preventDefault();
this.close_conversation();
});
this._update_message_timestamp();
}
update_avatar() {
const container = this._html_entry_tag.find(".container-avatar");
container.find(".avatar").remove();
container.append(this.handle.handle.handle.fileManager.avatars.generate_chat_tag({id: this.client_id}, this.client_unique_id));
}
close_conversation() {
this.handle.delete_conversation(this, true);
}
set_client_name(name: string) {
if(this.client_name === name)
return;
this.client_name = name;
this._html_entry_tag.find(".client-name").text(name);
}
set_unread_flag(flag: boolean, update_chat_counter?: boolean) {
/* unread message pointer */
if(flag != (typeof(this._spacer_unread_message) !== "undefined")) {
if(flag) {
if(this._displayed_messages.length > 0) /* without messages we cant be unread */
return;
if(!this._spacer_unread_message) {
this._spacer_unread_message = this._displayed_messages[0];
this._spacer_unread_message.tag_unread = this._build_spacer(tr("Unread messages"), "new");
this._spacer_unread_message.tag_unread.html_tag.insertBefore(this._spacer_unread_message.tag_message);
}
} else {
const ctree = this.handle.handle.handle.channelTree;
if(ctree && ctree.tag_tree() && this.client_id)
ctree.findClient(this.client_id)?.setUnread(false);
if(this._spacer_unread_message) {
this._destroy_view_entry(this._spacer_unread_message.tag_unread);
this._spacer_unread_message.tag_unread = undefined;
this._spacer_unread_message = undefined;
}
}
}
/* general notify */
this._html_entry_tag.toggleClass("unread", flag);
if(typeof(update_chat_counter) !== "boolean" || update_chat_counter)
this.handle.handle.info_frame().update_chat_counter();
}
is_unread() : boolean { return !!this._spacer_unread_message; }
private _append_state_change(state: "disconnect" | "disconnect_self" | "reconnect" | "closed") {
let message;
if(state == "closed")
message = tr("Your chat partner has closed the conversation");
else if(state == "reconnect")
message = this._state === PrivateConversationState.DISCONNECTED_SELF ?tr("You've reconnected to the server") : tr("Your chat partner has reconnected");
else if(state === "disconnect")
message = tr("Your chat partner has disconnected");
else
message = tr("You've disconnected from the server");
const spacer = this._build_spacer(message, state);
this._register_displayed_message({
timestamp: Date.now(),
message: spacer,
message_type: "spacer",
tag_message: spacer.html_tag,
tag_timepointer: undefined,
tag_unread: undefined
}, state === "disconnect");
}
state() : PrivateConversationState {
return this._state;
}
set_state(state: PrivateConversationState) {
if(this._state == state)
return;
if(state == PrivateConversationState.DISCONNECTED) {
this._append_state_change("disconnect");
this.client_id = 0;
} else if(state == PrivateConversationState.OPEN && this._state != PrivateConversationState.CLOSED)
this._append_state_change("reconnect");
else if(state == PrivateConversationState.CLOSED)
this._append_state_change("closed");
else if(state == PrivateConversationState.DISCONNECTED_SELF)
this._append_state_change("disconnect_self");
this._state = state;
}
set_text_callback(callback: (text: string) => any, update_enabled_state?: boolean) {
this._callback_message = callback;
if(typeof (update_enabled_state) !== "boolean" || update_enabled_state)
this.handle.update_chatbox_state();
}
chat_enabled() {
return typeof(this._callback_message) !== "undefined" && (this._state == PrivateConversationState.OPEN || this._state == PrivateConversationState.CLOSED);
}
append_error(message: string, date?: number) {
const spacer = this._build_spacer(message, "error");
this._register_displayed_message({
timestamp: date || Date.now(),
message: spacer,
message_type: "spacer",
tag_message: spacer.html_tag,
tag_timepointer: undefined,
tag_unread: undefined
}, true);
}
call_message(message: string) {
if(this._callback_message)
this._callback_message(message);
else {
log.warn(LogCategory.CHAT, tr("Dropping conversation message for client %o because of no message callback."), {
client_name: this.client_name,
client_id: this.client_id,
client_unique_id: this.client_unique_id
});
}
}
private typing_expired() {
this._update_message_timestamp();
if(this.handle.current_conversation() === this)
this.handle.update_typing_state();
}
trigger_typing() {
let _new = Date.now() - this._last_typing > this._typing_timeout;
this._last_typing = Date.now();
if(this._typing_timeout_task)
clearTimeout(this._typing_timeout_task);
if(_new)
this._update_message_timestamp();
if(this.handle.current_conversation() === this)
this.handle.update_typing_state();
this._typing_timeout_task = setTimeout(() => this.typing_expired(), this._typing_timeout);
}
typing_active() {
return Date.now() - this._last_typing < this._typing_timeout;
}
}
export class PrivateConverations {
readonly handle: Frame;
private _chat_box: ChatBox;
private _html_tag: JQuery;
private _container_conversation: JQuery;
private _container_conversation_messages: JQuery;
private _container_conversation_list: JQuery;
private _container_typing: JQuery;
private _html_no_chats: JQuery;
private _conversations: PrivateConveration[] = [];
private _current_conversation: PrivateConveration = undefined;
private _select_read_timer: number;
constructor(handle: Frame) {
this.handle = handle;
this._chat_box = new ChatBox();
this._build_html_tag();
this.update_chatbox_state();
this.update_typing_state();
this._chat_box.callback_text = message => {
if(!this._current_conversation) {
log.warn(LogCategory.CHAT, tr("Dropping conversation message because of no active conversation."));
return;
}
this._current_conversation.call_message(message);
};
this._chat_box.callback_typing = () => {
if(!this._current_conversation) {
log.warn(LogCategory.CHAT, tr("Dropping conversation typing action because of no active conversation."));
return;
}
const connection = this.handle.handle.serverConnection;
if(!connection || !connection.connected())
return;
connection.send_command("clientchatcomposing", {
clid: this._current_conversation.client_id
});
}
}
clear_client_ids() {
this._conversations.forEach(e => {
e.client_id = 0;
e.set_state(PrivateConversationState.DISCONNECTED_SELF);
});
}
html_tag() : JQuery { return this._html_tag; }
destroy() {
this._chat_box && this._chat_box.destroy();
this._chat_box = undefined;
for(const conversation of this._conversations)
conversation.destroy();
this._conversations = [];
this._current_conversation = undefined;
clearTimeout(this._select_read_timer);
this._html_tag && this._html_tag.remove();
this._html_tag = undefined;
}
current_conversation() : PrivateConveration | undefined { return this._current_conversation; }
conversations() : PrivateConveration[] { return this._conversations; }
create_conversation(client_uid: string, client_name: string, client_id: number) : PrivateConveration {
const conv = new PrivateConveration(this, client_uid, client_name, client_id);
this._conversations.push(conv);
this._html_no_chats.hide();
this._container_conversation_list.append(conv.entry_tag());
this.handle.info_frame().update_chat_counter();
return conv;
}
delete_conversation(conv: PrivateConveration, update_chat_couner?: boolean) {
if(!this._conversations.remove(conv))
return;
//TODO: May animate?
conv.destroy();
conv.clear_messages(false);
this._html_no_chats.toggle(this._conversations.length == 0);
if(conv === this._current_conversation)
this.set_selected_conversation(undefined);
if(update_chat_couner || typeof(update_chat_couner) !== "boolean")
this.handle.info_frame().update_chat_counter();
}
find_conversation(partner: { name: string; unique_id: string; client_id: number }, mode: { create: boolean, attach: boolean }) : PrivateConveration | undefined {
for(const conversation of this.conversations())
if(conversation.client_id == partner.client_id && (!partner.unique_id || conversation.client_unique_id == partner.unique_id)) {
if(conversation.state() != PrivateConversationState.OPEN)
conversation.set_state(PrivateConversationState.OPEN);
return conversation;
}
let conv: PrivateConveration;
if(mode.attach) {
for(const conversation of this.conversations())
if(conversation.client_unique_id == partner.unique_id && conversation.state() != PrivateConversationState.OPEN) {
conversation.set_state(PrivateConversationState.OPEN);
conversation.client_id = partner.client_id;
conversation.set_client_name(partner.name);
conv = conversation;
break;
}
}
if(mode.create && !conv) {
conv = this.create_conversation(partner.unique_id, partner.name, partner.client_id);
conv.client_id = partner.client_id;
conv.set_client_name(partner.name);
}
if(conv) {
conv.set_text_callback(message => {
log.debug(LogCategory.CLIENT, tr("Sending text message %s to %o"), message, partner);
this.handle.handle.serverConnection.send_command("sendtextmessage", {"targetmode": 1, "target": partner.client_id, "msg": message}).catch(error => {
if(error instanceof CommandResult) {
if(error.id == ErrorID.CLIENT_INVALID_ID) {
conv.set_state(PrivateConversationState.DISCONNECTED);
conv.set_text_callback(undefined);
} else if(error.id == ErrorID.PERMISSION_ERROR) {
/* may notify for no permissions? */
} else {
conv.append_error(tr("Failed to send message: ") + (error.extra_message || error.message));
}
} else {
conv.append_error(tr("Failed to send message. Lookup the console for more details"));
log.error(LogCategory.CHAT, tr("Failed to send conversation message: %o"), error);
}
});
});
}
return conv;
}
clear_conversations() {
while(this._conversations.length > 0)
this.delete_conversation(this._conversations[0], false);
this.handle.info_frame().update_chat_counter();
}
set_selected_conversation(conv: PrivateConveration | undefined) {
if(conv === this._current_conversation)
return;
if(this._select_read_timer)
clearTimeout(this._select_read_timer);
if(this._current_conversation)
this._current_conversation._html_message_container = undefined;
this._container_conversation_list.find(".selected").removeClass("selected");
this._container_conversation_messages.children().detach();
this._current_conversation = conv;
if(!this._current_conversation) {
this.update_chatbox_state();
return;
}
this._current_conversation._html_message_container = this._container_conversation_messages;
const messages = this._current_conversation.messages_tags();
/* TODO: Check if the messages are empty and display "No messages" */
this._container_conversation_messages.append(...messages);
if(this._current_conversation.is_unread() && false) {
this._select_read_timer = setTimeout(() => {
this._current_conversation.set_unread_flag(false, true);
}, 20 * 1000); /* Lets guess you've read the new messages within 5 seconds */
}
this._current_conversation.fix_scroll(false);
this._current_conversation.entry_tag().addClass("selected");
this.update_chatbox_state();
}
update_chatbox_state() {
this._chat_box.set_enabled(!!this._current_conversation && this._current_conversation.chat_enabled());
}
update_typing_state() {
this._container_typing.toggleClass("hidden", !this._current_conversation || !this._current_conversation.typing_active());
}
private _build_html_tag() {
this._html_tag = $("#tmpl_frame_chat_private").renderTag({
chatbox: this._chat_box.html_tag()
}).dividerfy();
this._container_conversation = this._html_tag.find(".conversation");
this._container_conversation.on('click', event => { /* lets think if a user clicks within that field that he has read the messages */
if(this._current_conversation)
this._current_conversation.set_unread_flag(false, true); /* only updates everything if the state changes */
});
this._container_conversation_messages = this._container_conversation.find(".container-messages");
this._container_conversation_messages.on('scroll', event => {
if(!this._current_conversation)
return;
const current_view = this._container_conversation_messages[0].scrollTop + this._container_conversation_messages[0].clientHeight + this._container_conversation_messages[0].clientHeight * .125;
if(current_view > this._container_conversation_messages[0].scrollHeight)
this._current_conversation._scroll_position = undefined;
else
this._current_conversation._scroll_position = this._container_conversation_messages[0].scrollTop;
});
this._container_conversation_list = this._html_tag.find(".conversation-list");
this._html_no_chats = this._container_conversation_list.find(".no-chats");
this._container_typing = this._html_tag.find(".container-typing");
this.update_input_format_helper();
}
try_input_focus() {
this._chat_box.focus_input();
}
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");
}
}
}

View File

@ -190,7 +190,6 @@ export namespace callbacks {
} }
window[callback_object_id] = callbacks; window[callback_object_id] = callbacks;
declare const xbbcode;
namespace bbcodes { namespace bbcodes {
/* the = because we sometimes get that */ /* the = because we sometimes get that */
//const url_client_regex = /?client:\/\/(?<client_id>[0-9]+)\/(?<client_unique_id>[a-zA-Z0-9+=#]+)~(?<client_name>(?:[^%]|%[0-9A-Fa-f]{2})+)$/g; //const url_client_regex = /?client:\/\/(?<client_id>[0-9]+)\/(?<client_unique_id>[a-zA-Z0-9+=#]+)~(?<client_name>(?:[^%]|%[0-9A-Fa-f]{2})+)$/g;
@ -198,6 +197,8 @@ namespace bbcodes {
const url_channel_regex = /channel:\/\/([0-9]+)~((?:[^%]|%[0-9A-Fa-f]{2})+)$/g; const url_channel_regex = /channel:\/\/([0-9]+)~((?:[^%]|%[0-9A-Fa-f]{2})+)$/g;
function initialize() { function initialize() {
/* FIXME: Reimplement client BB codes */
/*
const origin_url = xbbcode.register.find_parser('url'); const origin_url = xbbcode.register.find_parser('url');
xbbcode.register.register_parser({ xbbcode.register.register_parser({
tag: 'url', tag: 'url',
@ -234,6 +235,7 @@ namespace bbcodes {
return origin_url.build_html_tag_close(layer); return origin_url.build_html_tag_close(layer);
} }
}); });
*/
} }
initialize(); initialize();
} }

View File

@ -243,8 +243,8 @@ export function spawnClientVolumeChange(client: ClientEntry) {
return <VolumeChangeModal remote={false} clientName={client.clientNickName()} events={events} />; return <VolumeChangeModal remote={false} clientName={client.clientNickName()} events={events} />;
} }
title(): string { title() {
return tr("Change local volume"); return <Translatable>Change local volume</Translatable>;
} }
}); });
@ -278,8 +278,8 @@ export function spawnMusicBotVolumeChange(client: MusicClientEntry, maxValue: nu
return <VolumeChangeModal remote={true} clientName={client.clientNickName()} maxVolume={maxValue} events={events} />; return <VolumeChangeModal remote={true} clientName={client.clientNickName()} maxVolume={maxValue} events={events} />;
} }
title(): string { title() {
return tr("Change remote volume"); return <Translatable>Change remote volume</Translatable>;
} }
}); });

View File

@ -269,8 +269,8 @@ class ModalGroupCreate extends Modal {
</div>; </div>;
} }
title(): string { title() {
return this.target === "server" ? tr("Create a new server group") : tr("Create a new channel group"); return this.target === "server" ? <Translatable>Create a new server group</Translatable> : <Translatable>Create a new channel group</Translatable>;
} }
} }

View File

@ -156,8 +156,8 @@ class ModalGroupPermissionCopy extends Modal {
</div>; </div>;
} }
title(): string { title() {
return tr("Copy group permissions"); return <Translatable>Copy group permissions</Translatable>;
} }
} }

View File

@ -321,8 +321,8 @@ class PermissionEditorModal extends Modal {
); );
} }
title(): string { title() : React.ReactElement<Translatable> {
return tr("Server permissions"); return <Translatable>Server permissions</Translatable>;
} }
} }

View File

@ -11,6 +11,7 @@ import {
import {initializeRemoteFileBrowserController} from "tc-shared/ui/modal/transfer/RemoteFileBrowserController"; import {initializeRemoteFileBrowserController} from "tc-shared/ui/modal/transfer/RemoteFileBrowserController";
import {ChannelEntry} from "tc-shared/ui/channel"; import {ChannelEntry} from "tc-shared/ui/channel";
import {initializeTransferInfoController} from "tc-shared/ui/modal/transfer/TransferInfoController"; import {initializeTransferInfoController} from "tc-shared/ui/modal/transfer/TransferInfoController";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
const cssStyle = require("./ModalFileTransfer.scss"); const cssStyle = require("./ModalFileTransfer.scss");
export const channelPathPrefix = tr("Channel") + " "; export const channelPathPrefix = tr("Channel") + " ";
@ -206,8 +207,8 @@ class FileTransferModal extends Modal {
this.transferInfoEvents.fire("notify_modal_closed"); this.transferInfoEvents.fire("notify_modal_closed");
} }
title(): string { title() {
return tr("File Browser"); return <Translatable>File Browser</Translatable>;
} }
renderBody() { renderBody() {

View File

@ -1,27 +1,54 @@
import * as React from "react"; import * as React from "react";
import {ClientAvatar} from "tc-shared/file/Avatars"; import {ClientAvatar} from "tc-shared/file/Avatars";
import {useState} from "react"; import {useState} from "react";
import * as image_preview from "tc-shared/ui/frames/image_preview";
const ImageStyle = { height: "100%", width: "100%" }; const ImageStyle = { height: "100%", width: "100%", cursor: "pointer" };
export const AvatarRenderer = (props: { avatar: ClientAvatar, className?: string }) => { export const AvatarRenderer = React.memo((props: { avatar: ClientAvatar, className?: string, alt?: string }) => {
let [ revision, setRevision ] = useState(0); let [ revision, setRevision ] = useState(0);
let image; let image;
switch (props.avatar.state) { switch (props.avatar.state) {
case "unset": case "unset":
image = <img key={"default"} title={tr("default avatar")} alt={tr("default avatar")} src={props.avatar.avatarUrl} style={ImageStyle} />; image = <img
key={"default"}
title={tr("default avatar")}
alt={typeof props.alt === "string" ? props.alt : tr("default avatar")}
src={props.avatar.avatarUrl}
style={ImageStyle}
onClick={event => {
if(event.isDefaultPrevented())
return;
event.preventDefault();
image_preview.preview_image(props.avatar.avatarUrl, undefined);
}}
/>;
break; break;
case "loaded": case "loaded":
image = <img key={"user-" + props.avatar.currentAvatarHash} alt={tr("user avatar")} title={tr("user avatar")} src={props.avatar.avatarUrl} style={ImageStyle} />; image = <img
key={"user-" + props.avatar.currentAvatarHash}
alt={typeof props.alt === "string" ? props.alt : tr("user avatar")}
title={tr("user avatar")}
src={props.avatar.avatarUrl}
style={ImageStyle}
onClick={event => {
if(event.isDefaultPrevented())
return;
event.preventDefault();
image_preview.preview_image(props.avatar.avatarUrl, undefined);
}}
/>;
break; break;
case "errored": case "errored":
image = <img key={"error"} alt={tr("error")} title={tr("avatar failed to load:\n") + props.avatar.loadError} src={props.avatar.avatarUrl} style={ImageStyle} />; image = <img key={"error"} alt={typeof props.alt === "string" ? props.alt : tr("error")} title={tr("avatar failed to load:\n") + props.avatar.loadError} src={props.avatar.avatarUrl} style={ImageStyle} />;
break; break;
case "loading": case "loading":
image = <img key={"loading"} alt={tr("loading")} title={tr("loading avatar")} src={"img/loading_image.svg"} style={ImageStyle} />; image = <img key={"loading"} alt={typeof props.alt === "string" ? props.alt : tr("loading")} title={tr("loading avatar")} src={"img/loading_image.svg"} style={ImageStyle} />;
break; break;
} }
@ -32,4 +59,4 @@ export const AvatarRenderer = (props: { avatar: ClientAvatar, className?: string
{image} {image}
</div> </div>
) )
}; });

View File

@ -14,8 +14,8 @@ $animation_separator_length: .1s;
&.vertical { &.vertical {
height: $separator_thickness; height: $separator_thickness;
min-height: $separator_thickness!important; min-height: $separator_thickness;
max-height: $separator_thickness!important; max-height: $separator_thickness;
//width: 100%; //width: 100%;
cursor: row-resize; cursor: row-resize;
@ -23,8 +23,8 @@ $animation_separator_length: .1s;
&.horizontal { &.horizontal {
width: $separator_thickness; width: $separator_thickness;
min-width: $separator_thickness!important; min-width: $separator_thickness;
max-width: $separator_thickness!important; max-width: $separator_thickness;
//height: 100%; //height: 100%;
cursor: col-resize; cursor: col-resize;

View File

@ -86,13 +86,16 @@ export class ContextDivider extends React.Component<ContextDividerProperties, Co
} }
render() { render() {
let separatorClassNames = cssStyle.separator + " " + (this.props.separatorClassName || ""); let separatorClassNames = cssStyle.separator;
if(this.props.direction === "vertical") if(this.props.direction === "vertical")
separatorClassNames += " " + cssStyle.vertical; separatorClassNames += " " + cssStyle.vertical;
else else
separatorClassNames += " " + cssStyle.horizontal; separatorClassNames += " " + cssStyle.horizontal;
if(this.props.separatorClassName)
separatorClassNames += " " + this.props.separatorClassName;
if(this.state.active && this.props.separatorClassName) if(this.state.active && this.props.separatorClassName)
separatorClassNames += " " + this.props.separatorClassName; separatorClassNames += " " + this.props.separatorClassName;
@ -145,7 +148,6 @@ export class ContextDivider extends React.Component<ContextDividerProperties, Co
if(this.props.direction === "horizontal") { if(this.props.direction === "horizontal") {
const center = this.refSeparator.current.clientWidth; const center = this.refSeparator.current.clientWidth;
previousElement.style.width = `calc(${this.value}% - ${center / 2}px)`; previousElement.style.width = `calc(${this.value}% - ${center / 2}px)`;
nextElement.style.width = `calc(${100 - this.value}% - ${center / 2}px)`; nextElement.style.width = `calc(${100 - this.value}% - ${center / 2}px)`;
} else { } else {

View File

@ -0,0 +1,51 @@
import * as React from "react";
import {useEffect, useState} from "react";
const SecondsDiv = 1000;
const MinutesDiv = 60 * SecondsDiv;
const HoursDiv = 60 * MinutesDiv;
export const Countdown = (props: { timestamp: number, finished?: React.ReactNode, callbackFinished?: () => void }) => {
const [ revision, setRevision ] = useState(0);
let difference = props.timestamp - Date.now();
useEffect(() => {
if(difference <= 0)
return;
const timeout = setTimeout(() => { setRevision(revision + 1)}, 1000);
return () => clearTimeout(timeout);
});
if(difference <= 0) {
props.callbackFinished && setTimeout(props.callbackFinished);
return <React.Fragment key={"finished"}>{props.finished}</React.Fragment>;
}
if(difference % 1000 !== 0)
difference += 1000;
const hours = Math.floor(difference / HoursDiv);
const minutes = Math.floor((difference % HoursDiv) / MinutesDiv);
const seconds = Math.floor((difference % MinutesDiv) / SecondsDiv);
let message = "";
if(hours > 1)
message += " " +hours + " " + tr("hours");
else if(hours === 1)
message += " " + tr("1 hour");
if(minutes > 1)
message += " " +minutes + " " + tr("minutes");
else if(minutes === 1)
message += " " + tr("1 minute");
if(seconds > 1)
message += " " + seconds + " " + tr("seconds");
else if(seconds === 1)
message += " " + tr("1 second");
return <>{message.substr(1)}</>;
};

View File

@ -2,18 +2,19 @@ import {useEffect, useState} from "react";
import * as React from "react"; import * as React from "react";
export const LoadingDots = (props: { maxDots?: number, speed?: number }) => { export const LoadingDots = (props: { maxDots?: number, speed?: number }) => {
if(!props.maxDots || props.maxDots < 1) let { maxDots, speed } = props;
props.maxDots = 3; if(!maxDots || maxDots < 1)
maxDots = 3;
const [dots, setDots] = useState(0); const [dots, setDots] = useState(0);
useEffect(() => { useEffect(() => {
const timeout = setTimeout(() => setDots(dots + 1), props.speed || 500); const timeout = setTimeout(() => setDots(dots + 1), speed || 500);
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
}); });
let result = "."; let result = ".";
for(let index = 0; index < dots % props.maxDots; index++) for(let index = 0; index < dots % maxDots; index++)
result += "."; result += ".";
return <div style={{ width: (props.maxDots / 3) + "em", display: "inline-block", textAlign: "left" }}>{result}</div>; return <div style={{ width: (maxDots / 3) + "em", display: "inline-block", textAlign: "left" }}>{result}</div>;
}; };

View File

@ -15,7 +15,7 @@ html:root {
padding-right: 5%; padding-right: 5%;
padding-left: 5%; padding-left: 5%;
z-index: 1000; z-index: 100000;
position: fixed; position: fixed;
top: 0; top: 0;

View File

@ -2,6 +2,7 @@ import * as React from "react";
import * as ReactDOM from "react-dom"; import * as ReactDOM from "react-dom";
import {ReactElement} from "react"; import {ReactElement} from "react";
import {Registry} from "tc-shared/events"; import {Registry} from "tc-shared/events";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
const cssStyle = require("./Modal.scss"); const cssStyle = require("./Modal.scss");
@ -106,7 +107,7 @@ export abstract class Modal {
type() : ModalType { return "none"; } type() : ModalType { return "none"; }
abstract renderBody() : ReactElement; abstract renderBody() : ReactElement;
abstract title() : string; abstract title() : string | React.ReactElement<Translatable>;
/** /**
* Will only return a modal controller when the modal has not been destroyed * Will only return a modal controller when the modal has not been destroyed
@ -170,6 +171,7 @@ class ModalImpl extends React.PureComponent<{ controller: ModalController }, {
} }
} }
export function spawnReactModal<ModalClass extends Modal, A1>(modalClass: new () => ModalClass) : ModalController<ModalClass>;
export function spawnReactModal<ModalClass extends Modal, A1>(modalClass: new (..._: [A1]) => ModalClass, arg1: A1) : ModalController<ModalClass>; export function spawnReactModal<ModalClass extends Modal, A1>(modalClass: new (..._: [A1]) => ModalClass, arg1: A1) : ModalController<ModalClass>;
export function spawnReactModal<ModalClass extends Modal, A1, A2>(modalClass: new (..._: [A1, A2]) => ModalClass, arg1: A1, arg2: A2) : ModalController<ModalClass>; export function spawnReactModal<ModalClass extends Modal, A1, A2>(modalClass: new (..._: [A1, A2]) => ModalClass, arg1: A1, arg2: A2) : ModalController<ModalClass>;
export function spawnReactModal<ModalClass extends Modal, A1, A2, A3>(modalClass: new (..._: [A1, A2, A3]) => ModalClass, arg1: A1, arg2: A2, arg3: A3) : ModalController<ModalClass>; export function spawnReactModal<ModalClass extends Modal, A1, A2, A3>(modalClass: new (..._: [A1, A2, A3]) => ModalClass, arg1: A1, arg2: A2, arg3: A3) : ModalController<ModalClass>;

View File

@ -0,0 +1,18 @@
import {format} from "tc-shared/ui/frames/side/chat_helper";
import {useEffect, useState} from "react";
import * as React from "react";
export const TimestampRenderer = (props: { timestamp: number }) => {
const time = format.date.format_chat_time(new Date(props.timestamp));
const [ revision, setRevision ] = useState(0);
useEffect(() => {
if(!time.next_update)
return;
const id = setTimeout(() => setRevision(revision + 1), time.next_update);
return () => clearTimeout(id);
});
return <>{time.result}</>;
};

View File

@ -1,11 +1,39 @@
import * as React from "react"; import * as React from "react";
export class Translatable extends React.Component<{ message: string, children?: never } | { children: string }, any> { let instances = [];
export class Translatable extends React.Component<{ message: string, children?: never } | { children: string }, { translated: string }> {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = {
translated: /* @tr-ignore */ tr(typeof this.props.children === "string" ? this.props.children : (this.props as any).message)
}
} }
render() { render() {
return /* @tr-ignore */ tr(typeof this.props.children === "string" ? this.props.children : (this.props as any).message); return this.state.translated;
}
componentDidMount(): void {
instances.push(this);
}
componentWillUnmount(): void {
const index = instances.indexOf(this);
if(index === -1) {
/* TODO: Log warning */
return;
}
instances.splice(index, 1);
} }
} }
declare global {
interface Window {
i18nInstances: Translatable[];
}
}
window.i18nInstances = instances;

View File

@ -181,8 +181,10 @@ export class ServerEntry extends ChannelTreeEntry<ServerEvents> {
if(!singleSelect) return; if(!singleSelect) return;
if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) { if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) {
this.channelTree.client.side_bar.channel_conversations().setSelectedConversation(0); const sidebar = this.channelTree.client.side_bar;
this.channelTree.client.side_bar.show_channel_conversations(); sidebar.channel_conversations().findOrCreateConversation(0);
sidebar.channel_conversations().setSelectedConversation(0);
sidebar.show_channel_conversations();
} }
} }

View File

@ -83,6 +83,9 @@ export class ChannelTreeView extends ReactComponentBase<ChannelTreeViewPropertie
} }
componentDidMount(): void { componentDidMount(): void {
(window as any).channelTrees = (window as any).channelTrees || [];
(window as any).channelTrees.push(this);
this.resize_observer = new ResizeObserver(entries => { this.resize_observer = new ResizeObserver(entries => {
if(entries.length !== 1) { if(entries.length !== 1) {
if(entries.length === 0) if(entries.length === 0)
@ -104,6 +107,8 @@ export class ChannelTreeView extends ReactComponentBase<ChannelTreeViewPropertie
} }
componentWillUnmount(): void { componentWillUnmount(): void {
(window as any).channelTrees?.remove(this);
this.resize_observer.disconnect(); this.resize_observer.disconnect();
this.resize_observer = undefined; this.resize_observer = undefined;
@ -111,7 +116,6 @@ export class ChannelTreeView extends ReactComponentBase<ChannelTreeViewPropertie
} }
protected initialize() { protected initialize() {
(window as any).do_tree_update = () => this.handleTreeUpdate();
this.listener_client_change = () => this.handleTreeUpdate(); this.listener_client_change = () => this.handleTreeUpdate();
this.listener_channel_change = () => this.handleTreeUpdate(); this.listener_channel_change = () => this.handleTreeUpdate();
this.listener_state_collapsed = () => this.handleTreeUpdate(); this.listener_state_collapsed = () => this.handleTreeUpdate();
@ -143,7 +147,6 @@ export class ChannelTreeView extends ReactComponentBase<ChannelTreeViewPropertie
this.setState({ smoothScroll: true }); this.setState({ smoothScroll: true });
}, 50); }, 50);
}, 50); }, 50);
console.log("Update scroll!");
} }
} }

View File

@ -48,6 +48,18 @@ export interface ChannelTreeEvents {
notify_entry_move_begin: {}, notify_entry_move_begin: {},
notify_entry_move_end: {}, notify_entry_move_end: {},
notify_client_enter_view: {
client: ClientEntry,
reason: ViewReasonId,
isServerJoin: boolean
},
notify_client_leave_view: {
client: ClientEntry,
reason: ViewReasonId,
message?: string,
isServerLeave: boolean
},
notify_channel_updated: { notify_channel_updated: {
channel: ChannelEntry, channel: ChannelEntry,
channelProperties: ChannelProperties, channelProperties: ChannelProperties,
@ -333,7 +345,7 @@ export class ChannelTree {
if(channel.clients(false).length !== 0) { if(channel.clients(false).length !== 0) {
log.warn(LogCategory.CHANNEL, tr("Deleting a non empty channel! This could cause some errors.")); log.warn(LogCategory.CHANNEL, tr("Deleting a non empty channel! This could cause some errors."));
for(const client of channel.clients(false)) for(const client of channel.clients(false))
this.deleteClient(client, false); this.deleteClient(client, { reason: ViewReasonId.VREASON_SYSTEM, serverLeave: false });
} }
const is_root_tree = !channel.parent; const is_root_tree = !channel.parent;
@ -464,16 +476,17 @@ export class ChannelTree {
this.events.fire("notify_root_channel_changed"); this.events.fire("notify_root_channel_changed");
} }
deleteClient(client: ClientEntry, animate_tag?: boolean) { deleteClient(client: ClientEntry, reason: { reason: ViewReasonId, message?: string, serverLeave: boolean }) {
const old_channel = client.currentChannel(); const old_channel = client.currentChannel();
old_channel?.unregisterClient(client); old_channel?.unregisterClient(client);
this.clients.remove(client); this.clients.remove(client);
client.events.fire("notify_left_view", reason);
if(old_channel) { if(old_channel) {
this.events.fire("notify_client_leave_view", { client: client, message: reason.message, reason: reason.reason, isServerLeave: reason.serverLeave });
this.client.side_bar.info_frame().update_channel_client_count(old_channel); this.client.side_bar.info_frame().update_channel_client_count(old_channel);
} }
//FIXME: Trigger the notify_clients_changed event! //FIXME: Trigger the notify_clients_changed event!
const voice_connection = this.client.serverConnection.voice_connection(); const voice_connection = this.client.serverConnection.voice_connection();
if(client.get_audio_handle()) { if(client.get_audio_handle()) {
@ -501,7 +514,7 @@ export class ChannelTree {
return; return;
} }
insertClient(client: ClientEntry, channel: ChannelEntry) : ClientEntry { insertClient(client: ClientEntry, channel: ChannelEntry, reason: { reason: ViewReasonId, isServerJoin: boolean }) : ClientEntry {
batch_updates(BatchUpdateType.CHANNEL_TREE); batch_updates(BatchUpdateType.CHANNEL_TREE);
try { try {
let newClient = this.findClient(client.clientId()); let newClient = this.findClient(client.clientId());
@ -515,26 +528,27 @@ export class ChannelTree {
client["_channel"] = channel; client["_channel"] = channel;
channel.registerClient(client); channel.registerClient(client);
this.events.fire("notify_client_enter_view", { client: client, reason: reason.reason, isServerJoin: reason.isServerJoin });
return client; return client;
} finally { } finally {
flush_batched_updates(BatchUpdateType.CHANNEL_TREE); flush_batched_updates(BatchUpdateType.CHANNEL_TREE);
} }
} }
moveClient(client: ClientEntry, channel: ChannelEntry) { moveClient(client: ClientEntry, targetChannel: ChannelEntry) {
batch_updates(BatchUpdateType.CHANNEL_TREE); batch_updates(BatchUpdateType.CHANNEL_TREE);
try { try {
let oldChannel = client.currentChannel(); let oldChannel = client.currentChannel();
oldChannel?.unregisterClient(client); oldChannel?.unregisterClient(client);
client["_channel"] = channel; client["_channel"] = targetChannel;
channel?.registerClient(client); targetChannel?.registerClient(client);
if(oldChannel) { if(oldChannel)
this.client.side_bar.info_frame().update_channel_client_count(oldChannel); this.client.side_bar.info_frame().update_channel_client_count(oldChannel);
} if(targetChannel)
if(channel) { this.client.side_bar.info_frame().update_channel_client_count(targetChannel);
this.client.side_bar.info_frame().update_channel_client_count(channel); if(oldChannel && targetChannel)
} client.events.fire("notify_client_moved", { oldChannel: oldChannel, newChannel: targetChannel });
client.speaking = false; client.speaking = false;
} finally { } finally {
flush_batched_updates(BatchUpdateType.CHANNEL_TREE); flush_batched_updates(BatchUpdateType.CHANNEL_TREE);

View File

@ -26,7 +26,6 @@
"shared/generated", "shared/generated",
"web/declarations/**/*.d.ts", "web/declarations/**/*.d.ts",
"web/generated/", "web/generated/",
"web/environment/", "web/environment/"
"vendor/**/*.ts"
] ]
} }

View File

@ -1 +0,0 @@
/node_modules/

View File

@ -1,3 +0,0 @@
LsxEmojiPicker
A simple and lightweight emoji picker plugin for jQuery
(c) 2018 Lascaux s.r.l.

View File

@ -1,2 +0,0 @@
# lsx-emojipicker
A simple emoji picker plugin for jQuery using https://github.com/twitter/twemoji

View File

@ -1,70 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lascaux jQuery Emoji picker plugin demo</title>
<link href="https://www.jqueryscript.net/css/jquerysctipttop.css" rel="stylesheet" type="text/css">
<link href="src/jquery.lsxemojipicker.css" rel="stylesheet">
<script src="https://twemoji.maxcdn.com/2/twemoji.min.js"></script>
<style>
body { background-color: #fafafa; font-family: 'Roboto'; }
.wrapper { margin: 150px auto; }
</style>
</head>
<body>
<div id="jquery-script-menu">
<div class="jquery-script-center">
<ul>
<li><a href="https://www.jqueryscript.net/text/Emoji-Picker-jQuery-Twemoji.html">Download This Plugin</a></li>
<li><a href="https://www.jqueryscript.net/">Back To jQueryScript.Net</a></li>
</ul>
<div class="jquery-script-ads"><script type="text/javascript"><!--
google_ad_client = "ca-pub-2783044520727903";
/* jQuery_demo */
google_ad_slot = "2780937993";
google_ad_width = 728;
google_ad_height = 90;
//-->
</script>
<script type="text/javascript"
src="https://pagead2.googlesyndication.com/pagead/show_ads.js">
</script></div>
<div class="jquery-script-clear"></div>
</div>
</div>
<div class="wrapper">
<h1>Lascaux jQuery Emoji picker plugin demo</h1>
<button style="margin-top: 300px;margin-left: 250px;" class="picker btn btn-danger">Click me!</button>
<div class="container"></div>
</div>
<script src="https://code.jquery.com/jquery-3.3.1.min.js" integrity="sha384-tsQFqpEReu7ZLhBV2VZlAu7zcOV+rXbYlF2cqB8txI/8aZajjp4Bqd+V6D5IgvKT" crossorigin="anonymous"></script>
<script src="src/jquery.lsxemojipicker.js"></script>
<script>
$('.picker').lsxEmojiPicker({
closeOnSelect: true,
twemoji: true,
onSelect: function(emoji){
console.log(emoji);
}
});
</script>
<script type="text/javascript">
var _gaq = _gaq || [];
_gaq.push(['_setAccount', 'UA-36251023-1']);
_gaq.push(['_setDomainName', 'jqueryscript.net']);
_gaq.push(['_trackPageview']);
(function() {
var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
})();
</script>
</body>
</html>

View File

@ -1,29 +0,0 @@
{
"name": "lsx-emojipicker",
"version": "1.1.2",
"description": "A simple emoji picker plugin for jQuery",
"repository": "https://github.com/LascauxSRL/lsx-emojipicker.git",
"author": "Lascaux S.r.l.",
"license": "MIT",
"scripts": {
"build": "yarn build:dev",
"build:dev": "webpack --config webpack.config.js --progress",
"start": "webpack --config webpack.config.js --progress --watch",
"build:prod": "webpack --config webpack.config.js --env=production",
"version": "yarn install && yarn build:prod && git add -A",
"postversion": "git push"
},
"devDependencies": {
"css-loader": "^0.28.2",
"style-loader": "^0.18.0",
"uglifyjs-webpack-plugin": "1.1.2",
"webpack": "3.10.0"
},
"contributors": [
{
"name": "Lorenzo Cioni",
"email": "lorenzo.cioni@lascaux.it",
"url": "https://github.com/lorecioni"
}
]
}

View File

@ -1,103 +0,0 @@
.lsx-emojipicker-emoji span {
display: inline-block;
font-size: 24px;
width: 33px;
height: 35px;
cursor: pointer;
}
.lsx-emojipicker-appender {
position: relative;
}
.lsx-emojipicker-container {
background: #ffffff;
border-radius: 5px;
z-index: 99999999999;
position: absolute;
top: -270px;
right: -20px;
box-shadow: 0 12px 29px rgba(0,0,0,.2);
transition: all 0.5s ease-in-out;
-webkit-transition: all 0.5s ease-in-out;
display: none;
}
ul.lsx-emojipicker-tabs {
margin: 0;
padding: 0 10px;
list-style: none;
text-align: left;
background-color: #eee;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-top: 1px solid #ddd;
}
ul.lsx-emojipicker-tabs li {
display: inline-block;
text-align: left;
font-size: 15px;
padding: 6px;
cursor: pointer;
align-self: center;
opacity: 0.5;
}
ul.lsx-emojipicker-tabs li.selected,
ul.lsx-emojipicker-tabs li:hover {
opacity: 1;
}
.lsx-emojipicker-tabs img.emoji {
width: 22px;
margin: 5px 10px;
opacity: 0.5;
cursor: pointer;
}
.lsx-emojipicker-tabs img.emoji:hover,
.lsx-emojipicker-tabs li.selected img.emoji {
opacity: 1;
}
.lsx-emojipicker-wrapper .lsx-emoji-tab {
width: 220px;
padding: 8px;
height: 200px;
border-radius: 4px;
overflow: auto;
}
.lsx-emojipicker-wrapper .lsx-emoji-tab img.emoji {
width: 25px;
margin: 5px 4px;
cursor: pointer;
transition: all 0.1s ease-in-out;
-webkit-transition: all 0.1s ease-in-out;
}
.lsx-emojipicker-wrapper span:hover,
.lsx-emojipicker-wrapper img.emoji:hover {
transform: scale(1.1);
-webkit-transform: scale(1.1);
-ms-transform: scale(1.1);
}
ul.lsx-emojipicker-tabs li.selected {
border-bottom: 2px solid #b5b5b5;
}
.lsx-emojipicker-wrapper {
width: 100%;
height: 100%;
position: relative;
}
.lsx-emojipicker-container:after {
position: absolute;
display: block;
content: '';
clear: both;
top: 100%;
right: 35px;
margin-bottom: -15px;
width: 0;
height: 0;
border-style: solid;
border-width: 15px 15px 0 15px;
border-color: #eeeeee transparent transparent transparent;
}
.lsx-emojipicker-emoji.lsx-emoji-tab.hidden {
display: none;
}

View File

@ -1,834 +0,0 @@
declare let twemoji: any;
interface Window {
setup_lsx_emoji_picker: (options: JSXEmojiPickerSetupOptions) => Promise<void>;
}
interface JSXEmojiPickerSetupOptions {
twemoji: boolean
}
if(typeof jQuery !== 'undefined'){
(function ($, win) {
'use strict';
let setup_options: JSXEmojiPickerSetupOptions;
let open_pickers: { tag: JQuery, close: () => any}[] = [];
const emoji = {
'people': [
{'name': 'smile', 'value': '&#x1f604'},
{'name': 'smiley', 'value': '&#x1f603'},
{'name': 'grinning', 'value': '&#x1f600'},
{'name': 'blush', 'value': '&#x1f60a'},
{'name': 'wink', 'value': '&#x1f609'},
{'name': 'heart-eyes', 'value': '&#x1f60d'},
{'name': 'kissing-heart', 'value': '&#x1f618'},
{'name': 'kissing-closed-eyes', 'value': '&#x1f61a'},
{'name': 'kissing', 'value': '&#x1f617'},
{'name': 'kissing-smiling-eyes', 'value': '&#x1f619'},
{'name': 'stuck-out-tongue-winking-eye', 'value': '&#x1f61c'},
{'name': 'stuck-out-tongue-closed-eyes', 'value': '&#x1f61d'},
{'name': 'stuck-out-tongue', 'value': '&#x1f61b'},
{'name': 'flushed', 'value': '&#x1f633'},
{'name': 'grin', 'value': '&#x1f601'},
{'name': 'pensive', 'value': '&#x1f614'},
{'name': 'satisfied', 'value': '&#x1f60c'},
{'name': 'unamused', 'value': '&#x1f612'},
{'name': 'disappointed', 'value': '&#x1f61e'},
{'name': 'persevere', 'value': '&#x1f623'},
{'name': 'cry', 'value': '&#x1f622'},
{'name': 'joy', 'value': '&#x1f602'},
{'name': 'sob', 'value': '&#x1f62d'},
{'name': 'sleepy', 'value': '&#x1f62a'},
{'name': 'relieved', 'value': '&#x1f625'},
{'name': 'cold-sweat', 'value': '&#x1f630'},
{'name': 'sweat-smile', 'value': '&#x1f605'},
{'name': 'sweat', 'value': '&#x1f613'},
{'name': 'weary', 'value': '&#x1f629'},
{'name': 'tired-face', 'value': '&#x1f62b'},
{'name': 'fearful', 'value': '&#x1f628'},
{'name': 'scream', 'value': '&#x1f631'},
{'name': 'angry', 'value': '&#x1f620'},
{'name': 'rage', 'value': '&#x1f621'},
{'name': 'triumph', 'value': '&#x1f624'},
{'name': 'confounded', 'value': '&#x1f616'},
{'name': 'laughing', 'value': '&#x1f606'},
{'name': 'yum', 'value': '&#x1f60b'},
{'name': 'mask', 'value': '&#x1f637'},
{'name': 'sunglasses', 'value': '&#x1f60e'},
{'name': 'sleeping', 'value': '&#x1f634'},
{'name': 'dizzy-face', 'value': '&#x1f635'},
{'name': 'astonished', 'value': '&#x1f632'},
{'name': 'worried', 'value': '&#x1f61f'},
{'name': 'frowning', 'value': '&#x1f626'},
{'name': 'anguished', 'value': '&#x1f627'},
{'name': 'smiling-imp', 'value': '&#x1f608'},
{'name': 'imp', 'value': '&#x1f47f'},
{'name': 'open-mouth', 'value': '&#x1f62e'},
{'name': 'grimacing', 'value': '&#x1f62c'},
{'name': 'neutral-face', 'value': '&#x1f610'},
{'name': 'confused', 'value': '&#x1f615'},
{'name': 'hushed', 'value': '&#x1f62f'},
{'name': 'no-mouth', 'value': '&#x1f636'},
{'name': 'innocent', 'value': '&#x1f607'},
{'name': 'smirk', 'value': '&#x1f60f'},
{'name': 'expressionless', 'value': '&#x1f611'},
{'name': 'man-with-gua-pi-mao', 'value': '&#x1f472'},
{'name': 'man-with-turban', 'value': '&#x1f473'},
{'name': 'cop', 'value': '&#x1f46e'},
{'name': 'construction-worker', 'value': '&#x1f477'},
{'name': 'guardsman', 'value': '&#x1f482'},
{'name': 'baby', 'value': '&#x1f476'},
{'name': 'boy', 'value': '&#x1f466'},
{'name': 'girl', 'value': '&#x1f467'},
{'name': 'man', 'value': '&#x1f468'},
{'name': 'woman', 'value': '&#x1f469'},
{'name': 'older-man', 'value': '&#x1f474'},
{'name': 'older-woman', 'value': '&#x1f475'},
{'name': 'person-with-blond-hair', 'value': '&#x1f471'},
{'name': 'angel', 'value': '&#x1f47c'},
{'name': 'princess', 'value': '&#x1f478'},
{'name': 'smiley-cat', 'value': '&#x1f63a'},
{'name': 'smile-cat', 'value': '&#x1f638'},
{'name': 'heart-eyes-cat', 'value': '&#x1f63b'},
{'name': 'kissing-cat', 'value': '&#x1f63d'},
{'name': 'smirk-cat', 'value': '&#x1f63c'},
{'name': 'scream-cat', 'value': '&#x1f640'},
{'name': 'crying-cat-face', 'value': '&#x1f63f'},
{'name': 'joy-cat', 'value': '&#x1f639'},
{'name': 'pouting-cat', 'value': '&#x1f63e'},
{'name': 'japanese-ogre', 'value': '&#x1f479'},
{'name': 'japanese-goblin', 'value': '&#x1f47a'},
{'name': 'see-no-evil', 'value': '&#x1f648'},
{'name': 'hear-no-evil', 'value': '&#x1f649'},
{'name': 'speak-no-evil', 'value': '&#x1f64a'},
{'name': 'skull', 'value': '&#x1f480'},
{'name': 'alien', 'value': '&#x1f47d'},
{'name': 'poop', 'value': '&#x1f4a9'},
{'name': 'fire', 'value': '&#x1f525'},
{'name': 'sparkles', 'value': '&#x2728'},
{'name': 'star2', 'value': '&#x1f31f'},
{'name': 'dizzy', 'value': '&#x1f4ab'},
{'name': 'boom', 'value': '&#x1f4a5'},
{'name': 'anger', 'value': '&#x1f4a2'},
{'name': 'sweat-drops', 'value': '&#x1f4a6'},
{'name': 'droplet', 'value': '&#x1f4a7'},
{'name': 'zzz', 'value': '&#x1f4a4'},
{'name': 'dash', 'value': '&#x1f4a8'},
{'name': 'ear', 'value': '&#x1f442'},
{'name': 'eyes', 'value': '&#x1f440'},
{'name': 'nose', 'value': '&#x1f443'},
{'name': 'tongue', 'value': '&#x1f445'},
{'name': 'lips', 'value': '&#x1f444'},
{'name': 'thumbsup', 'value': '&#x1f44d'},
{'name': 'thumbsdown', 'value': '&#x1f44e'},
{'name': 'ok-hand', 'value': '&#x1f44c'},
{'name': 'punch', 'value': '&#x1f44a'},
{'name': 'fist', 'value': '&#x270a'},
{'name': 'v', 'value': '&#x270c'},
{'name': 'wave', 'value': '&#x1f44b'},
{'name': 'hand', 'value': '&#x270b'},
{'name': 'open-hands', 'value': '&#x1f450'},
{'name': 'point-up-2', 'value': '&#x1f446'},
{'name': 'point-down', 'value': '&#x1f447'},
{'name': 'point-right', 'value': '&#x1f449'},
{'name': 'point-left', 'value': '&#x1f448'},
{'name': 'raised-hands', 'value': '&#x1f64c'},
{'name': 'pray', 'value': '&#x1f64f'},
{'name': 'point-up', 'value': '&#x261d'},
{'name': 'clap', 'value': '&#x1f44f'},
{'name': 'muscle', 'value': '&#x1f4aa'},
{'name': 'walking', 'value': '&#x1f6b6'},
{'name': 'runner', 'value': '&#x1f3c3'},
{'name': 'dancer', 'value': '&#x1f483'},
{'name': 'couple', 'value': '&#x1f46b'},
{'name': 'family', 'value': '&#x1f46a'},
{'name': 'two-men-holding-hands', 'value': '&#x1f46c'},
{'name': 'two-women-holding-hands', 'value': '&#x1f46d'},
{'name': 'couplekiss', 'value': '&#x1f48f'},
{'name': 'couple-with-heart', 'value': '&#x1f491'},
{'name': 'dancers', 'value': '&#x1f46f'},
{'name': 'ok-woman', 'value': '&#x1f646'},
{'name': 'no-good', 'value': '&#x1f645'},
{'name': 'information-desk-person', 'value': '&#x1f481'},
{'name': 'raised-hand', 'value': '&#x1f64b'},
{'name': 'massage', 'value': '&#x1f486'},
{'name': 'haircut', 'value': '&#x1f487'},
{'name': 'nail-care', 'value': '&#x1f485'},
{'name': 'bride-with-veil', 'value': '&#x1f470'},
{'name': 'person-with-pouting-face', 'value': '&#x1f64e'},
{'name': 'person-frowning', 'value': '&#x1f64d'},
{'name': 'bow', 'value': '&#x1f647'},
{'name': 'tophat', 'value': '&#x1f3a9'},
{'name': 'crown', 'value': '&#x1f451'},
{'name': 'womans-hat', 'value': '&#x1f452'},
{'name': 'athletic-shoe', 'value': '&#x1f45f'},
{'name': 'mans-shoe', 'value': '&#x1f45e'},
{'name': 'sandal', 'value': '&#x1f461'},
{'name': 'high-heel', 'value': '&#x1f460'},
{'name': 'boot', 'value': '&#x1f462'},
{'name': 'shirt', 'value': '&#x1f455'},
{'name': 'necktie', 'value': '&#x1f454'},
{'name': 'womans-clothes', 'value': '&#x1f45a'},
{'name': 'dress', 'value': '&#x1f457'},
{'name': 'running-shirt-with-sash', 'value': '&#x1f3bd'},
{'name': 'jeans', 'value': '&#x1f456'},
{'name': 'kimono', 'value': '&#x1f458'},
{'name': 'bikini', 'value': '&#x1f459'},
{'name': 'briefcase', 'value': '&#x1f4bc'},
{'name': 'handbag', 'value': '&#x1f45c'},
{'name': 'pouch', 'value': '&#x1f45d'},
{'name': 'purse', 'value': '&#x1f45b'},
{'name': 'eyeglasses', 'value': '&#x1f453'},
{'name': 'ribbon', 'value': '&#x1f380'},
{'name': 'closed-umbrella', 'value': '&#x1f302'},
{'name': 'lipstick', 'value': '&#x1f484'},
{'name': 'yellow-heart', 'value': '&#x1f49b'},
{'name': 'blue-heart', 'value': '&#x1f499'},
{'name': 'purple-heart', 'value': '&#x1f49c'},
{'name': 'green-heart', 'value': '&#x1f49a'},
{'name': 'heart', 'value': '&#x2764'},
{'name': 'broken-heart', 'value': '&#x1f494'},
{'name': 'heartpulse', 'value': '&#x1f497'},
{'name': 'heartbeat', 'value': '&#x1f493'},
{'name': 'two-hearts', 'value': '&#x1f495'},
{'name': 'sparkling-heart', 'value': '&#x1f496'},
{'name': 'revolving-hearts', 'value': '&#x1f49e'},
{'name': 'love-letter', 'value': '&#x1f48c'},
{'name': 'cupid', 'value': '&#x1f498'},
{'name': 'kiss', 'value': '&#x1f48b'},
{'name': 'ring', 'value': '&#x1f48d'},
{'name': 'gem', 'value': '&#x1f48e'},
{'name': 'bust-in-silhouette', 'value': '&#x1f464'},
{'name': 'busts-in-silhouette', 'value': '&#x1f465'},
{'name': 'speech-balloon', 'value': '&#x1f4ac'},
{'name': 'feet', 'value': '&#x1f463'},
{'name': 'thought-balloon', 'value': '&#x1f4ad'}
],
'nature': [
{'name': 'dog', 'value': '&#x1f436'},
{'name': 'wolf', 'value': '&#x1f43a'},
{'name': 'cat', 'value': '&#x1f431'},
{'name': 'mouse', 'value': '&#x1f42d'},
{'name': 'hamster', 'value': '&#x1f439'},
{'name': 'rabbit', 'value': '&#x1f430'},
{'name': 'frog', 'value': '&#x1f438'},
{'name': 'tiger', 'value': '&#x1f42f'},
{'name': 'koala', 'value': '&#x1f428'},
{'name': 'bear', 'value': '&#x1f43b'},
{'name': 'pig', 'value': '&#x1f437'},
{'name': 'pig-nose', 'value': '&#x1f43d'},
{'name': 'cow', 'value': '&#x1f42e'},
{'name': 'boar', 'value': '&#x1f417'},
{'name': 'monkey-face', 'value': '&#x1f435'},
{'name': 'monkey', 'value': '&#x1f412'},
{'name': 'horse', 'value': '&#x1f434'},
{'name': 'sheep', 'value': '&#x1f411'},
{'name': 'elephant', 'value': '&#x1f418'},
{'name': 'panda-face', 'value': '&#x1f43c'},
{'name': 'penguin', 'value': '&#x1f427'},
{'name': 'bird', 'value': '&#x1f426'},
{'name': 'baby-chick', 'value': '&#x1f424'},
{'name': 'hatched-chick', 'value': '&#x1f425'},
{'name': 'hatching-chick', 'value': '&#x1f423'},
{'name': 'chicken', 'value': '&#x1f414'},
{'name': 'snake', 'value': '&#x1f40d'},
{'name': 'turtle', 'value': '&#x1f422'},
{'name': 'bug', 'value': '&#x1f41b'},
{'name': 'honeybee', 'value': '&#x1f41d'},
{'name': 'ant', 'value': '&#x1f41c'},
{'name': 'beetle', 'value': '&#x1f41e'},
{'name': 'snail', 'value': '&#x1f40c'},
{'name': 'octopus', 'value': '&#x1f419'},
{'name': 'shell', 'value': '&#x1f41a'},
{'name': 'tropical-fish', 'value': '&#x1f420'},
{'name': 'fish', 'value': '&#x1f41f'},
{'name': 'dolphin', 'value': '&#x1f42c'},
{'name': 'whale', 'value': '&#x1f433'},
{'name': 'whale2', 'value': '&#x1f40b'},
{'name': 'cow2', 'value': '&#x1f404'},
{'name': 'ram', 'value': '&#x1f40f'},
{'name': 'rat', 'value': '&#x1f400'},
{'name': 'water-buffalo', 'value': '&#x1f403'},
{'name': 'tiger2', 'value': '&#x1f405'},
{'name': 'rabbit2', 'value': '&#x1f407'},
{'name': 'dragon', 'value': '&#x1f409'},
{'name': 'racehorse', 'value': '&#x1f40e'},
{'name': 'goat', 'value': '&#x1f410'},
{'name': 'rooster', 'value': '&#x1f413'},
{'name': 'dog2', 'value': '&#x1f415'},
{'name': 'pig2', 'value': '&#x1f416'},
{'name': 'mouse2', 'value': '&#x1f401'},
{'name': 'ox', 'value': '&#x1f402'},
{'name': 'dragon-face', 'value': '&#x1f432'},
{'name': 'blowfish', 'value': '&#x1f421'},
{'name': 'crocodile', 'value': '&#x1f40a'},
{'name': 'camel', 'value': '&#x1f42b'},
{'name': 'dromedary-camel', 'value': '&#x1f42a'},
{'name': 'leopard', 'value': '&#x1f406'},
{'name': 'cat2', 'value': '&#x1f408'},
{'name': 'poodle', 'value': '&#x1f429'},
{'name': 'paw-prints', 'value': '&#x1f43e'},
{'name': 'bouquet', 'value': '&#x1f490'},
{'name': 'cherry-blossom', 'value': '&#x1f338'},
{'name': 'tulip', 'value': '&#x1f337'},
{'name': 'four-leaf-clover', 'value': '&#x1f340'},
{'name': 'rose', 'value': '&#x1f339'},
{'name': 'sunflower', 'value': '&#x1f33b'},
{'name': 'hibiscus', 'value': '&#x1f33a'},
{'name': 'maple-leaf', 'value': '&#x1f341'},
{'name': 'leaves', 'value': '&#x1f343'},
{'name': 'fallen-leaf', 'value': '&#x1f342'},
{'name': 'herb', 'value': '&#x1f33f'},
{'name': 'ear-of-rice', 'value': '&#x1f33e'},
{'name': 'mushroom', 'value': '&#x1f344'},
{'name': 'cactus', 'value': '&#x1f335'},
{'name': 'palm-tree', 'value': '&#x1f334'},
{'name': 'evergreen-tree', 'value': '&#x1f332'},
{'name': 'deciduous-tree', 'value': '&#x1f333'},
{'name': 'chestnut', 'value': '&#x1f330'},
{'name': 'seedling', 'value': '&#x1f331'},
{'name': 'blossom', 'value': '&#x1f33c'},
{'name': 'globe-with-meridians', 'value': '&#x1f310'},
{'name': 'sun-with-face', 'value': '&#x1f31e'},
{'name': 'full-moon-with-face', 'value': '&#x1f31d'},
{'name': 'new-moon-with-face', 'value': '&#x1f31a'},
{'name': 'new-moon', 'value': '&#x1f311'},
{'name': 'waxing-crescent-moon', 'value': '&#x1f312'},
{'name': 'first-quarter-moon', 'value': '&#x1f313'},
{'name': 'waxing-gibbous-moon', 'value': '&#x1f314'},
{'name': 'full-moon', 'value': '&#x1f315'},
{'name': 'waning-gibbous-moon', 'value': '&#x1f316'},
{'name': 'last-quarter-moon', 'value': '&#x1f317'},
{'name': 'waning-crescent-moon', 'value': '&#x1f318'},
{'name': 'last-quarter-moon-with-face', 'value': '&#x1f31c'},
{'name': 'first-quarter-moon-with-face', 'value': '&#x1f31b'},
{'name': 'moon', 'value': '&#x1f319'},
{'name': 'earth-africa', 'value': '&#x1f30d'},
{'name': 'earth-americas', 'value': '&#x1f30e'},
{'name': 'earth-asia', 'value': '&#x1f30f'},
{'name': 'volcano', 'value': '&#x1f30b'},
{'name': 'milky-way', 'value': '&#x1f30c'},
{'name': 'shooting-star', 'value': '&#x1f320'},
{'name': 'star', 'value': '&#x2b50'},
{'name': 'sunny', 'value': '&#x2600'},
{'name': 'partly-sunny', 'value': '&#x26c5'},
{'name': 'cloud', 'value': '&#x2601'},
{'name': 'zap', 'value': '&#x26a1'},
{'name': 'umbrella', 'value': '&#x2614'},
{'name': 'snowflake', 'value': '&#x2744'},
{'name': 'snowman', 'value': '&#x26c4'},
{'name': 'cyclone', 'value': '&#x1f300'},
{'name': 'foggy', 'value': '&#x1f301'},
{'name': 'rainbow', 'value': '&#x1f308'},
{'name': 'ocean', 'value': '&#x1f30a'}
],
'object': [
{'name': 'bamboo', 'value': '&#x1f38d'},
{'name': 'gift-heart', 'value': '&#x1f49d'},
{'name': 'dolls', 'value': '&#x1f38e'},
{'name': 'school-satchel', 'value': '&#x1f392'},
{'name': 'mortar-board', 'value': '&#x1f393'},
{'name': 'flags', 'value': '&#x1f38f'},
{'name': 'fireworks', 'value': '&#x1f386'},
{'name': 'sparkler', 'value': '&#x1f387'},
{'name': 'wind-chime', 'value': '&#x1f390'},
{'name': 'rice-scene', 'value': '&#x1f391'},
{'name': 'jack-o-lantern', 'value': '&#x1f383'},
{'name': 'ghost', 'value': '&#x1f47b'},
{'name': 'santa', 'value': '&#x1f385'},
{'name': 'christmas-tree', 'value': '&#x1f384'},
{'name': 'gift', 'value': '&#x1f381'},
{'name': 'tanabata-tree', 'value': '&#x1f38b'},
{'name': 'tada', 'value': '&#x1f389'},
{'name': 'confetti-ball', 'value': '&#x1f38a'},
{'name': 'balloon', 'value': '&#x1f388'},
{'name': 'crossed-flags', 'value': '&#x1f38c'},
{'name': 'crystal-ball', 'value': '&#x1f52e'},
{'name': 'movie-camera', 'value': '&#x1f3a5'},
{'name': 'camera', 'value': '&#x1f4f7'},
{'name': 'video-camera', 'value': '&#x1f4f9'},
{'name': 'vhs', 'value': '&#x1f4fc'},
{'name': 'cd', 'value': '&#x1f4bf'},
{'name': 'dvd', 'value': '&#x1f4c0'},
{'name': 'minidisc', 'value': '&#x1f4bd'},
{'name': 'floppy-disk', 'value': '&#x1f4be'},
{'name': 'computer', 'value': '&#x1f4bb'},
{'name': 'iphone', 'value': '&#x1f4f1'},
{'name': 'phone', 'value': '&#x260e'},
{'name': 'telephone-receiver', 'value': '&#x1f4de'},
{'name': 'pager', 'value': '&#x1f4df'},
{'name': 'fax', 'value': '&#x1f4e0'},
{'name': 'satellite', 'value': '&#x1f4e1'},
{'name': 'tv', 'value': '&#x1f4fa'},
{'name': 'radio', 'value': '&#x1f4fb'},
{'name': 'speaker-waves', 'value': '&#x1f50a'},
{'name': 'sound', 'value': '&#x1f509'},
{'name': 'speaker', 'value': '&#x1f508'},
{'name': 'mute', 'value': '&#x1f507'},
{'name': 'bell', 'value': '&#x1f514'},
{'name': 'no-bell', 'value': '&#x1f515'},
{'name': 'loudspeaker', 'value': '&#x1f4e2'},
{'name': 'mega', 'value': '&#x1f4e3'},
{'name': 'hourglass-flowing-sand', 'value': '&#x23f3'},
{'name': 'hourglass', 'value': '&#x231b'},
{'name': 'alarm-clock', 'value': '&#x23f0'},
{'name': 'watch', 'value': '&#x231a'},
{'name': 'unlock', 'value': '&#x1f513'},
{'name': 'lock', 'value': '&#x1f512'},
{'name': 'lock-with-ink-pen', 'value': '&#x1f50f'},
{'name': 'closed-lock-with-key', 'value': '&#x1f510'},
{'name': 'key', 'value': '&#x1f511'},
{'name': 'mag-right', 'value': '&#x1f50e'},
{'name': 'bulb', 'value': '&#x1f4a1'},
{'name': 'flashlight', 'value': '&#x1f526'},
{'name': 'high-brightness', 'value': '&#x1f506'},
{'name': 'low-brightness', 'value': '&#x1f505'},
{'name': 'electric-plug', 'value': '&#x1f50c'},
{'name': 'battery', 'value': '&#x1f50b'},
{'name': 'mag', 'value': '&#x1f50d'},
{'name': 'bathtub', 'value': '&#x1f6c1'},
{'name': 'bath', 'value': '&#x1f6c0'},
{'name': 'shower', 'value': '&#x1f6bf'},
{'name': 'toilet', 'value': '&#x1f6bd'},
{'name': 'wrench', 'value': '&#x1f527'},
{'name': 'nut-and-bolt', 'value': '&#x1f529'},
{'name': 'hammer', 'value': '&#x1f528'},
{'name': 'door', 'value': '&#x1f6aa'},
{'name': 'smoking', 'value': '&#x1f6ac'},
{'name': 'bomb', 'value': '&#x1f4a3'},
{'name': 'gun', 'value': '&#x1f52b'},
{'name': 'hocho', 'value': '&#x1f52a'},
{'name': 'pill', 'value': '&#x1f48a'},
{'name': 'syringe', 'value': '&#x1f489'},
{'name': 'moneybag', 'value': '&#x1f4b0'},
{'name': 'yen', 'value': '&#x1f4b4'},
{'name': 'dollar', 'value': '&#x1f4b5'},
{'name': 'pound', 'value': '&#x1f4b7'},
{'name': 'euro', 'value': '&#x1f4b6'},
{'name': 'credit-card', 'value': '&#x1f4b3'},
{'name': 'money-with-wings', 'value': '&#x1f4b8'},
{'name': 'calling', 'value': '&#x1f4f2'},
{'name': 'e-mail', 'value': '&#x1f4e7'},
{'name': 'inbox-tray', 'value': '&#x1f4e5'},
{'name': 'outbox-tray', 'value': '&#x1f4e4'},
{'name': 'email', 'value': '&#x2709'},
{'name': 'enveloppe', 'value': '&#x1f4e9'},
{'name': 'incoming-envelope', 'value': '&#x1f4e8'},
{'name': 'postal-horn', 'value': '&#x1f4ef'},
{'name': 'mailbox', 'value': '&#x1f4eb'},
{'name': 'mailbox-closed', 'value': '&#x1f4ea'},
{'name': 'mailbox-with-mail', 'value': '&#x1f4ec'},
{'name': 'mailbox-with-no-mail', 'value': '&#x1f4ed'},
{'name': 'postbox', 'value': '&#x1f4ee'},
{'name': 'package', 'value': '&#x1f4e6'},
{'name': 'memo', 'value': '&#x1f4dd'},
{'name': 'page-facing-up', 'value': '&#x1f4c4'},
{'name': 'page-with-curl', 'value': '&#x1f4c3'},
{'name': 'bookmark-tabs', 'value': '&#x1f4d1'},
{'name': 'bar-chart', 'value': '&#x1f4ca'},
{'name': 'chart-with-upwards-trend', 'value': '&#x1f4c8'},
{'name': 'chart-with-downwards-trend', 'value': '&#x1f4c9'},
{'name': 'scroll', 'value': '&#x1f4dc'},
{'name': 'clipboard', 'value': '&#x1f4cb'},
{'name': 'date', 'value': '&#x1f4c5'},
{'name': 'calendar', 'value': '&#x1f4c6'},
{'name': 'card-index', 'value': '&#x1f4c7'},
{'name': 'file-folder', 'value': '&#x1f4c1'},
{'name': 'open-file-folder', 'value': '&#x1f4c2'},
{'name': 'scissors', 'value': '&#x2702'},
{'name': 'pushpin', 'value': '&#x1f4cc'},
{'name': 'paperclip', 'value': '&#x1f4ce'},
{'name': 'black-nib', 'value': '&#x2712'},
{'name': 'pencil2', 'value': '&#x270f'},
{'name': 'straight-ruler', 'value': '&#x1f4cf'},
{'name': 'triangular-ruler', 'value': '&#x1f4d0'},
{'name': 'closed-book', 'value': '&#x1f4d5'},
{'name': 'green-book', 'value': '&#x1f4d7'},
{'name': 'blue-book', 'value': '&#x1f4d8'},
{'name': 'orange-book', 'value': '&#x1f4d9'},
{'name': 'notebook', 'value': '&#x1f4d3'},
{'name': 'notebook-with-decorative-cover', 'value': '&#x1f4d4'},
{'name': 'ledger', 'value': '&#x1f4d2'},
{'name': 'books', 'value': '&#x1f4da'},
{'name': 'open-book', 'value': '&#x1f4d6'},
{'name': 'bookmark', 'value': '&#x1f516'},
{'name': 'name-badge', 'value': '&#x1f4db'},
{'name': 'microscope', 'value': '&#x1f52c'},
{'name': 'telescope', 'value': '&#x1f52d'},
{'name': 'newspaper', 'value': '&#x1f4f0'},
{'name': 'art', 'value': '&#x1f3a8'},
{'name': 'clapper', 'value': '&#x1f3ac'},
{'name': 'microphone', 'value': '&#x1f3a4'},
{'name': 'headphones', 'value': '&#x1f3a7'},
{'name': 'musical-score', 'value': '&#x1f3bc'},
{'name': 'musical-note', 'value': '&#x1f3b5'},
{'name': 'notes', 'value': '&#x1f3b6'},
{'name': 'musical-keyboard', 'value': '&#x1f3b9'},
{'name': 'violin', 'value': '&#x1f3bb'},
{'name': 'trumpet', 'value': '&#x1f3ba'},
{'name': 'saxophone', 'value': '&#x1f3b7'},
{'name': 'guitar', 'value': '&#x1f3b8'},
{'name': 'space-invader', 'value': '&#x1f47e'},
{'name': 'video-game', 'value': '&#x1f3ae'},
{'name': 'black-joker', 'value': '&#x1f0cf'},
{'name': 'flower-playing-cards', 'value': '&#x1f3b4'},
{'name': 'mahjong', 'value': '&#x1f004'},
{'name': 'game-die', 'value': '&#x1f3b2'},
{'name': 'dart', 'value': '&#x1f3af'},
{'name': 'football', 'value': '&#x1f3c8'},
{'name': 'basketball', 'value': '&#x1f3c0'},
{'name': 'soccer', 'value': '&#x26bd'},
{'name': 'baseball', 'value': '&#x26be'},
{'name': 'tennis', 'value': '&#x1f3be'},
{'name': '8ball', 'value': '&#x1f3b1'},
{'name': 'rugby-football', 'value': '&#x1f3c9'},
{'name': 'bowling', 'value': '&#x1f3b3'},
{'name': 'golf', 'value': '&#x26f3'},
{'name': 'mountain-bicyclist', 'value': '&#x1f6b5'},
{'name': 'bicyclist', 'value': '&#x1f6b4'},
{'name': 'checkered-flag', 'value': '&#x1f3c1'},
{'name': 'horse-racing', 'value': '&#x1f3c7'},
{'name': 'trophy', 'value': '&#x1f3c6'},
{'name': 'ski', 'value': '&#x1f3bf'},
{'name': 'snowboarder', 'value': '&#x1f3c2'},
{'name': 'swimmer', 'value': '&#x1f3ca'},
{'name': 'surfer', 'value': '&#x1f3c4'},
{'name': 'fishing-pole-and-fish', 'value': '&#x1f3a3'},
{'name': 'coffee', 'value': '&#x2615'},
{'name': 'tea', 'value': '&#x1f375'},
{'name': 'sake', 'value': '&#x1f376'},
{'name': 'baby-bottle', 'value': '&#x1f37c'},
{'name': 'beer', 'value': '&#x1f37a'},
{'name': 'beers', 'value': '&#x1f37b'},
{'name': 'cocktail', 'value': '&#x1f378'},
{'name': 'tropical-drink', 'value': '&#x1f379'},
{'name': 'wine-glass', 'value': '&#x1f377'},
{'name': 'fork-and-knife', 'value': '&#x1f374'},
{'name': 'pizza', 'value': '&#x1f355'},
{'name': 'hamburger', 'value': '&#x1f354'},
{'name': 'fries', 'value': '&#x1f35f'},
{'name': 'poultry-leg', 'value': '&#x1f357'},
{'name': 'meat-on-bone', 'value': '&#x1f356'},
{'name': 'spaghetti', 'value': '&#x1f35d'},
{'name': 'curry', 'value': '&#x1f35b'},
{'name': 'fried-shrimp', 'value': '&#x1f364'},
{'name': 'bento', 'value': '&#x1f371'},
{'name': 'sushi', 'value': '&#x1f363'},
{'name': 'fish-cake', 'value': '&#x1f365'},
{'name': 'rice-ball', 'value': '&#x1f359'},
{'name': 'rice-cracker', 'value': '&#x1f358'},
{'name': 'rice', 'value': '&#x1f35a'},
{'name': 'ramen', 'value': '&#x1f35c'},
{'name': 'stew', 'value': '&#x1f372'},
{'name': 'oden', 'value': '&#x1f362'},
{'name': 'dango', 'value': '&#x1f361'},
{'name': 'egg', 'value': '&#x1f373'},
{'name': 'bread', 'value': '&#x1f35e'},
{'name': 'doughnut', 'value': '&#x1f369'},
{'name': 'custard', 'value': '&#x1f36e'},
{'name': 'icecream', 'value': '&#x1f366'},
{'name': 'ice-cream', 'value': '&#x1f368'},
{'name': 'shaved-ice', 'value': '&#x1f367'},
{'name': 'birthday', 'value': '&#x1f382'},
{'name': 'cake', 'value': '&#x1f370'},
{'name': 'cookie', 'value': '&#x1f36a'},
{'name': 'chocolate-bar', 'value': '&#x1f36b'},
{'name': 'candy', 'value': '&#x1f36c'},
{'name': 'lollipop', 'value': '&#x1f36d'},
{'name': 'honey-pot', 'value': '&#x1f36f'},
{'name': 'apple', 'value': '&#x1f34e'},
{'name': 'green-apple', 'value': '&#x1f34f'},
{'name': 'tangerine', 'value': '&#x1f34a'},
{'name': 'lemon', 'value': '&#x1f34b'},
{'name': 'cherries', 'value': '&#x1f352'},
{'name': 'grapes', 'value': '&#x1f347'},
{'name': 'watermelon', 'value': '&#x1f349'},
{'name': 'strawberry', 'value': '&#x1f353'},
{'name': 'peach', 'value': '&#x1f351'},
{'name': 'melon', 'value': '&#x1f348'},
{'name': 'banana', 'value': '&#x1f34c'},
{'name': 'pear', 'value': '&#x1f350'},
{'name': 'pineapple', 'value': '&#x1f34d'},
{'name': 'sweet-potato', 'value': '&#x1f360'},
{'name': 'eggplant', 'value': '&#x1f346'},
{'name': 'tomato', 'value': '&#x1f345'},
{'name': 'corn', 'value': '&#x1f33d'}
],
'place': [
{'name': 'house', 'value': '&#x1f3e0'},
{'name': 'house-with-garden', 'value': '&#x1f3e1'},
{'name': 'school', 'value': '&#x1f3eb'},
{'name': 'office', 'value': '&#x1f3e2'},
{'name': 'post-office', 'value': '&#x1f3e3'},
{'name': 'hospital', 'value': '&#x1f3e5'},
{'name': 'bank', 'value': '&#x1f3e6'},
{'name': 'convenience-store', 'value': '&#x1f3ea'},
{'name': 'love-hotel', 'value': '&#x1f3e9'},
{'name': 'hotel', 'value': '&#x1f3e8'},
{'name': 'wedding', 'value': '&#x1f492'},
{'name': 'church', 'value': '&#x26ea'},
{'name': 'department-store', 'value': '&#x1f3ec'},
{'name': 'european-post-office', 'value': '&#x1f3e4'},
{'name': 'private-use', 'value': '&#xe50a'},
{'name': 'city-sunrise', 'value': '&#x1f307'},
{'name': 'city-sunset', 'value': '&#x1f306'},
{'name': 'japanese-castle', 'value': '&#x1f3ef'},
{'name': 'european-castle', 'value': '&#x1f3f0'},
{'name': 'tent', 'value': '&#x26fa'},
{'name': 'factory', 'value': '&#x1f3ed'},
{'name': 'tokyo-tower', 'value': '&#x1f5fc'},
{'name': 'japan', 'value': '&#x1f5fe'},
{'name': 'mount-fuji', 'value': '&#x1f5fb'},
{'name': 'sunrise-over-mountains', 'value': '&#x1f304'},
{'name': 'sunrise', 'value': '&#x1f305'},
{'name': 'stars', 'value': '&#x1f303'},
{'name': 'statue-of-liberty', 'value': '&#x1f5fd'},
{'name': 'bridge-at-night', 'value': '&#x1f309'},
{'name': 'carousel-horse', 'value': '&#x1f3a0'},
{'name': 'ferris-wheel', 'value': '&#x1f3a1'},
{'name': 'fountain', 'value': '&#x26f2'},
{'name': 'roller-coaster', 'value': '&#x1f3a2'},
{'name': 'ship', 'value': '&#x1f6a2'},
{'name': 'boat', 'value': '&#x26f5'},
{'name': 'speedboat', 'value': '&#x1f6a4'},
{'name': 'rowboat', 'value': '&#x1f6a3'},
{'name': 'anchor', 'value': '&#x2693'},
{'name': 'rocket', 'value': '&#x1f680'},
{'name': 'airplane', 'value': '&#x2708'},
{'name': 'seat', 'value': '&#x1f4ba'},
{'name': 'helicopter', 'value': '&#x1f681'},
{'name': 'steam-locomotive', 'value': '&#x1f682'},
{'name': 'tram', 'value': '&#x1f68a'},
{'name': 'station', 'value': '&#x1f689'},
{'name': 'mountain-railway', 'value': '&#x1f69e'},
{'name': 'train2', 'value': '&#x1f686'},
{'name': 'bullettrain-side', 'value': '&#x1f684'},
{'name': 'bullettrain-front', 'value': '&#x1f685'},
{'name': 'light-rail', 'value': '&#x1f688'},
{'name': 'metro', 'value': '&#x1f687'},
{'name': 'monorail', 'value': '&#x1f69d'},
{'name': 'tram-car', 'value': '&#x1f68b'},
{'name': 'railway-car', 'value': '&#x1f683'},
{'name': 'trolleybus', 'value': '&#x1f68e'},
{'name': 'bus', 'value': '&#x1f68c'},
{'name': 'oncoming-bus', 'value': '&#x1f68d'},
{'name': 'blue-car', 'value': '&#x1f699'},
{'name': 'oncoming-automobile', 'value': '&#x1f698'},
{'name': 'car', 'value': '&#x1f697'},
{'name': 'taxi', 'value': '&#x1f695'},
{'name': 'oncoming-taxi', 'value': '&#x1f696'},
{'name': 'articulated-lorry', 'value': '&#x1f69b'},
{'name': 'truck', 'value': '&#x1f69a'},
{'name': 'rotating-light', 'value': '&#x1f6a8'},
{'name': 'police-car', 'value': '&#x1f693'},
{'name': 'oncoming-police-car', 'value': '&#x1f694'},
{'name': 'fire-engine', 'value': '&#x1f692'},
{'name': 'ambulance', 'value': '&#x1f691'},
{'name': 'minibus', 'value': '&#x1f690'},
{'name': 'bike', 'value': '&#x1f6b2'},
{'name': 'aerial-tramway', 'value': '&#x1f6a1'},
{'name': 'suspension-railway', 'value': '&#x1f69f'},
{'name': 'mountain-cableway', 'value': '&#x1f6a0'},
{'name': 'tractor', 'value': '&#x1f69c'},
{'name': 'barber', 'value': '&#x1f488'},
{'name': 'busstop', 'value': '&#x1f68f'},
{'name': 'ticket', 'value': '&#x1f3ab'},
{'name': 'vertical-traffic-light', 'value': '&#x1f6a6'},
{'name': 'traffic-light', 'value': '&#x1f6a5'},
{'name': 'warning', 'value': '&#x26a0'},
{'name': 'construction', 'value': '&#x1f6a7'},
{'name': 'beginner', 'value': '&#x1f530'},
{'name': 'fuelpump', 'value': '&#x26fd'},
{'name': 'izakaya-lantern', 'value': '&#x1f3ee'},
{'name': 'slot-machine', 'value': '&#x1f3b0'},
{'name': 'hotsprings', 'value': '&#x2668'},
{'name': 'moyai', 'value': '&#x1f5ff'},
{'name': 'circus-tent', 'value': '&#x1f3aa'},
{'name': 'performing-arts', 'value': '&#x1f3ad'},
{'name': 'round-pushpin', 'value': '&#x1f4cd'},
{'name': 'triangular-flag-on-post', 'value': '&#x1f6a9'},
{'name': 'cn', 'value': '&#x1f1e8;&#x1f1f3'},
{'name': 'de', 'value': '&#x1f1e9;&#x1f1ea'},
{'name': 'es', 'value': '&#x1f1ea;&#x1f1f8'},
{'name': 'fr', 'value': '&#x1f1eb;&#x1f1f7'},
{'name': 'gb', 'value': '&#x1f1ec;&#x1f1e7'},
{'name': 'it', 'value': '&#x1f1ee;&#x1f1f9'},
{'name': 'jp', 'value': '&#x1f1ef;&#x1f1f5'},
{'name': 'kr', 'value': '&#x1f1f0;&#x1f1f7'},
{'name': 'ru', 'value': '&#x1f1f7;&#x1f1fa'},
{'name': 'us', 'value': '&#x1f1fa;&#x1f1f8'}
]
};
/* preprocess */
/* ~10ms in total, each section about 2ms so we're not really blocking the side totally */
window.setup_lsx_emoji_picker = options => {
setup_options = options;
window.addEventListener('click', event => {
const close_tags = open_pickers.filter(e => !e.tag[0].contains(event.target as Node));
for(const c of close_tags) {
c.close();
const idx = open_pickers.indexOf(c);
idx >= 0 && open_pickers.splice(idx, 1);
}
});
if(options.twemoji) {
const generator = function*() {
for(const category_name of Object.keys(emoji)) {
for(const entry of emoji[category_name]) {
entry["str"] = entry.value.replace(/&#x([0-9a-f]{1,6});?/g, (match, $1) => {
return twemoji.convert.fromCodePoint($1);
});
entry["img"] = twemoji.parse(entry.str, {
folder: 'svg',
ext: '.svg'
});
}
yield category_name;
}
}();
return new Promise(resolve => {
const _loop = () => {
if(generator.next().done)
resolve();
else
setTimeout(_loop, 0);
};
_loop();
});
}
return Promise.resolve();
};
(<any>$.fn).lsxEmojiPicker = function (options) {
if(typeof(setup_options) === "undefined")
throw "lsx emoji picker hasn't been initialized";
// Overriding default options
let settings = $.extend({
width: 220,
height: 200,
twemoji: false,
closeOnSelect: true,
onSelect: function(em){}
}, options);
if(settings.twemoji && !setup_options.twemoji)
throw "lsx emoji picker hasn't been initialized with twenmoji support"
var appender = $('<div></div>')
.addClass('lsx-emojipicker-appender');
var container = $('<div></div>')
.addClass('lsx-emojipicker-container')
.css({
'top': "-" + (settings.height + 37 + 15) + "px" //37 is the bottom and 15 the select thing
});
var wrapper = $('<div></div>')
.addClass('lsx-emojipicker-wrapper');
var spinnerContainer = $('<div></div>')
.addClass('spinner-container');
var spinner = $('<div></div>')
.addClass('loader');
spinnerContainer.append(spinner);
var tabs = $('<ul></ul>')
.addClass('lsx-emojipicker-tabs');
const create_category_li = (icon, name, selected) => $(document.createElement("li"))
.html(settings.twemoji ? icon.img : icon.value)
.click(event => {
event.preventDefault();
tabs.find("li.selected").removeClass("selected");
$(event.target).parent("li").addClass("selected");
wrapper.find("> .lsx-emoji-tab").addClass("hidden");
wrapper.find("> .lsx-emoji-tab.lsx-emoji-" + name).removeClass("hidden");
}).toggleClass("selected", selected);
tabs.append(create_category_li(emoji['people'][1], "people", true))
.append(create_category_li(emoji['nature'][0], "nature", false))
.append(create_category_li(emoji['place'][38], "place", false))
.append(create_category_li(emoji['object'][4], "object", false));
(async () => {
const _tab = (name: string, hidden: boolean) => {
let tab_html = '<div ' +
'class="lsx-emojipicker-emoji lsx-emoji-tab lsx-emoji-' + name + (hidden ? " hidden" : "") + '"' +
' style="width: ' + settings.width + 'px; height: ' + settings.height + 'px;"' +
'>';
if(settings.twemoji) {
for(const e of emoji[name])
tab_html += e.img;
} else {
for(const e of emoji[name])
tab_html += "<span value='" + e.value + "' title='" + e.name + "'>" + e.value + "</span>";
}
return tab_html + "</div>";
};
//wrapper.append(spinnerContainer);
wrapper[0].innerHTML =
_tab("people", false) +
_tab("nature", true) +
_tab("place", true) +
_tab("object", true);
wrapper.find("img, span").on('click', event => {
const target = $(event.target);
settings.onSelect({
name: target.attr("title"),
value: target.attr("alt") || target.attr("value")
});
if(settings.closeOnSelect){
wrapper.hide();
}
});
wrapper.append(tabs);
container.append(wrapper);
appender.append(container);
})();
this.append(appender);
const picker = {
tag: this,
close: () => {
container.hide();
const idx = open_pickers.indexOf(picker);
idx >= 0 && open_pickers.splice(idx, 1);
}
};
this.click(e => {
if(this.hasClass("disabled"))
return;
e.preventDefault();
if(!$(e.target).parent().hasClass('lsx-emojipicker-tabs')
&& !$(e.target).parent().parent().hasClass('lsx-emojipicker-tabs')
&& !$(e.target).parent().hasClass('lsx-emoji-tab')
&& !$(e.target).parent().parent().hasClass('lsx-emoji-tab')){
if(container.is(':visible')){
picker.close();
} else {
open_pickers.push(picker);
container.fadeIn();
}
}
});
return this;
};
}(jQuery, window));
}

View File

@ -1,57 +0,0 @@
//Webpack configuration file
var webpack = require('webpack');
var path = require('path');
var fs = require("fs");
var plugins = [], outputFile;
var APP_NAME = 'jquery.lsxemojipicker';
outputFile = APP_NAME + '.min.js';
module.exports = (env) => {
if(false && env === 'production'){
console.log('Production mode enabled');
plugins.push(new webpack.optimize.UglifyJsPlugin({
beautify: false,
comments: false,
sourceMap: false,
compress: {
screw_ie8: true,
warnings: false
},
mangle: {
keep_fnames: true,
screw_i8: true
}
}));
} else {
console.log('Development mode enabled');
}
//Banner plugin for license
plugins.push(new webpack.BannerPlugin(fs.readFileSync('./LICENSE', 'utf8')));
plugins.push(new webpack.DefinePlugin({
PRODUCTION_MODE: env === 'production'
}));
return {
entry: [
__dirname + '/src/jquery.lsxemojipicker.css',
__dirname + '/src/jquery.lsxemojipicker.js'
],
devtool: 'source-map',
output: {
path: __dirname,
filename: outputFile,
library: APP_NAME,
libraryTarget: 'umd',
umdNamedDefine: true
},
module: {
loaders: [
{ test: /\.s?css/, loader: "style-loader!css-loader" }
]
},
plugins: plugins
};
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +0,0 @@
Copyright (c) 2006, Ivan Sagalaev
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of highlight.js nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND ANY
EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE REGENTS AND CONTRIBUTORS BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@ -1,188 +0,0 @@
# Highlight.js
[![Build Status](https://travis-ci.org/highlightjs/highlight.js.svg?branch=master)](https://travis-ci.org/highlightjs/highlight.js) [![Greenkeeper badge](https://badges.greenkeeper.io/highlightjs/highlight.js.svg)](https://greenkeeper.io/)
Highlight.js is a syntax highlighter written in JavaScript. It works in
the browser as well as on the server. It works with pretty much any
markup, doesnt depend on any framework, and has automatic language
detection.
## Getting Started
The bare minimum for using highlight.js on a web page is linking to the
library along with one of the styles and calling
[`initHighlightingOnLoad`][1]:
```html
<link rel="stylesheet" href="/path/to/styles/default.css">
<script src="/path/to/highlight.pack.js"></script>
<script>hljs.initHighlightingOnLoad();</script>
```
This will find and highlight code inside of `<pre><code>` tags; it tries
to detect the language automatically. If automatic detection doesnt
work for you, you can specify the language in the `class` attribute:
```html
<pre><code class="html">...</code></pre>
```
The list of supported language classes is available in the [class
reference][2]. Classes can also be prefixed with either `language-` or
`lang-`.
To make arbitrary text look like code, but without highlighting, use the
`plaintext` class:
```html
<pre><code class="plaintext">...</code></pre>
```
To disable highlighting altogether use the `nohighlight` class:
```html
<pre><code class="nohighlight">...</code></pre>
```
## Custom Initialization
When you need a bit more control over the initialization of
highlight.js, you can use the [`highlightBlock`][3] and [`configure`][4]
functions. This allows you to control *what* to highlight and *when*.
Heres an equivalent way to calling [`initHighlightingOnLoad`][1] using
vanilla JS:
```js
document.addEventListener('DOMContentLoaded', (event) => {
document.querySelectorAll('pre code').forEach((block) => {
hljs.highlightBlock(block);
});
});
```
You can use any tags instead of `<pre><code>` to mark up your code. If
you don't use a container that preserves line breaks you will need to
configure highlight.js to use the `<br>` tag:
```js
hljs.configure({useBR: true});
document.querySelectorAll('div.code').forEach((block) => {
hljs.highlightBlock(block);
});
```
For other options refer to the documentation for [`configure`][4].
## Web Workers
You can run highlighting inside a web worker to avoid freezing the browser
window while dealing with very big chunks of code.
In your main script:
```js
addEventListener('load', () => {
const code = document.querySelector('#code');
const worker = new Worker('worker.js');
worker.onmessage = (event) => { code.innerHTML = event.data; }
worker.postMessage(code.textContent);
});
```
In worker.js:
```js
onmessage = (event) => {
importScripts('<path>/highlight.pack.js');
const result = self.hljs.highlightAuto(event.data);
postMessage(result.value);
};
```
## Getting the Library
You can get highlight.js as a hosted, or custom-build, browser script or
as a server module. Right out of the box the browser script supports
both AMD and CommonJS, so if you wish you can use RequireJS or
Browserify without having to build from source. The server module also
works perfectly fine with Browserify, but there is the option to use a
build specific to browsers rather than something meant for a server.
Head over to the [download page][5] for all the options.
**Don't link to GitHub directly.** The library is not supposed to work straight
from the source, it requires building. If none of the pre-packaged options
work for you refer to the [building documentation][6].
**The CDN-hosted package doesn't have all the languages.** Otherwise it'd be
too big. If you don't see the language you need in the ["Common" section][5],
it can be added manually:
```html
<script
charset="UTF-8"
src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/languages/go.min.js"></script>
```
**On Almond.** You need to use the optimizer to give the module a name. For
example:
```bash
r.js -o name=hljs paths.hljs=/path/to/highlight out=highlight.js
```
### CommonJS
You can import Highlight.js as a CommonJS-module:
```bash
npm install highlight.js --save
```
In your application:
```js
import hljs from 'highlight.js';
```
The default import imports all languages! Therefore it is likely to be more efficient to import only the library and the languages you need:
```js
import hljs from 'highlight.js/lib/highlight';
import javascript from 'highlight.js/lib/languages/javascript';
hljs.registerLanguage('javascript', javascript);
```
To set the syntax highlighting style, if your build tool processes CSS from your JavaScript entry point, you can import the stylesheet directly into your CommonJS-module:
```js
import hljs from 'highlight.js/lib/highlight';
import 'highlight.js/styles/github.css';
```
## License
Highlight.js is released under the BSD License. See [LICENSE][7] file
for details.
## Links
The official site for the library is at <https://highlightjs.org/>.
Further in-depth documentation for the API and other topics is at
<http://highlightjs.readthedocs.io/>.
Authors and contributors are listed in the [AUTHORS.en.txt][8] file.
[1]: http://highlightjs.readthedocs.io/en/latest/api.html#inithighlightingonload
[2]: http://highlightjs.readthedocs.io/en/latest/css-classes-reference.html
[3]: http://highlightjs.readthedocs.io/en/latest/api.html#highlightblock-block
[4]: http://highlightjs.readthedocs.io/en/latest/api.html#configure-options
[5]: https://highlightjs.org/download/
[6]: http://highlightjs.readthedocs.io/en/latest/building-testing.html
[7]: https://github.com/highlightjs/highlight.js/blob/master/LICENSE
[8]: https://github.com/highlightjs/highlight.js/blob/master/AUTHORS.en.txt

View File

@ -1,142 +0,0 @@
# Highlight.js
Highlight.js — это инструмент для подсветки синтаксиса, написанный на JavaScript. Он работает
и в браузере, и на сервере. Он работает с практически любой HTML разметкой, не
зависит от каких-либо фреймворков и умеет автоматически определять язык.
## Начало работы
Минимум, что нужно сделать для использования highlight.js на веб-странице — это
подключить библиотеку, CSS-стили и вызывать [`initHighlightingOnLoad`][1]:
```html
<link rel="stylesheet" href="/path/to/styles/default.css">
<script src="/path/to/highlight.pack.js"></script>
<script>hljs.initHighlightingOnLoad();</script>
```
Библиотека найдёт и раскрасит код внутри тегов `<pre><code>`, попытавшись
автоматически определить язык. Когда автоопределение не срабатывает, можно явно
указать язык в атрибуте class:
```html
<pre><code class="html">...</code></pre>
```
Список поддерживаемых классов языков доступен в [справочнике по классам][2].
Класс также можно предварить префиксами `language-` или `lang-`.
Чтобы отключить подсветку для какого-то блока, используйте класс `nohighlight`:
```html
<pre><code class="nohighlight">...</code></pre>
```
## Инициализация вручную
Чтобы иметь чуть больше контроля за инициализацией подсветки, вы можете
использовать функции [`highlightBlock`][3] и [`configure`][4]. Таким образом
можно управлять тем, *что* и *когда* подсвечивать.
Вот пример инициализации, эквивалентной вызову [`initHighlightingOnLoad`][1], но
с использованием `document.addEventListener`:
```js
document.addEventListener('DOMContentLoaded', (event) => {
document.querySelectorAll('pre code').forEach((block) => {
hljs.highlightBlock(block);
});
});
```
Вы можете использовать любые теги разметки вместо `<pre><code>`. Если
используете контейнер, не сохраняющий переводы строк, вам нужно сказать
highlight.js использовать для них тег `<br>`:
```js
hljs.configure({useBR: true});
document.querySelectorAll('div.code').forEach((block) => {
hljs.highlightBlock(block);
});
```
Другие опции можно найти в документации функции [`configure`][4].
## Web Workers
Подсветку можно запустить внутри web worker'а, чтобы окно
браузера не подтормаживало при работе с большими кусками кода.
В основном скрипте:
```js
addEventListener('load', () => {
const code = document.querySelector('#code');
const worker = new Worker('worker.js');
worker.onmessage = (event) => { code.innerHTML = event.data; }
worker.postMessage(code.textContent);
});
```
В worker.js:
```js
onmessage = (event) => {
importScripts('<path>/highlight.pack.js');
const result = self.hljs.highlightAuto(event.data);
postMessage(result.value);
};
```
## Установка библиотеки
Highlight.js можно использовать в браузере прямо с CDN хостинга или скачать
индивидуальную сборку, а также установив модуль на сервере. На
[странице загрузки][5] подробно описаны все варианты.
**Не подключайте GitHub напрямую.** Библиотека не предназначена для
использования в виде исходного кода, а требует отдельной сборки. Если вам не
подходит ни один из готовых вариантов, читайте [документацию по сборке][6].
**Файл на CDN содержит не все языки.** Иначе он будет слишком большого размера.
Если нужного вам языка нет в [категории "Common"][5], можно дообавить его
вручную:
```html
<script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.4.0/languages/go.min.js"></script>
```
**Про Almond.** Нужно задать имя модуля в оптимизаторе, например:
```
r.js -o name=hljs paths.hljs=/path/to/highlight out=highlight.js
```
## Лицензия
Highlight.js распространяется под лицензией BSD. Подробнее читайте файл
[LICENSE][7].
## Ссылки
Официальный сайт билиотеки расположен по адресу <https://highlightjs.org/>.
Более подробная документация по API и другим темам расположена на
<http://highlightjs.readthedocs.io/>.
Авторы и контрибьюторы перечислены в файле [AUTHORS.ru.txt][8] file.
[1]: http://highlightjs.readthedocs.io/en/latest/api.html#inithighlightingonload
[2]: http://highlightjs.readthedocs.io/en/latest/css-classes-reference.html
[3]: http://highlightjs.readthedocs.io/en/latest/api.html#highlightblock-block
[4]: http://highlightjs.readthedocs.io/en/latest/api.html#configure-options
[5]: https://highlightjs.org/download/
[6]: http://highlightjs.readthedocs.io/en/latest/building-testing.html
[7]: https://github.com/highlightjs/highlight.js/blob/master/LICENSE
[8]: https://github.com/highlightjs/highlight.js/blob/master/AUTHORS.ru.txt

File diff suppressed because one or more lines are too long

View File

@ -1,99 +0,0 @@
/* a11y-dark theme */
/* Based on the Tomorrow Night Eighties theme: https://github.com/isagalaev/highlight.js/blob/master/src/styles/tomorrow-night-eighties.css */
/* @author: ericwbailey */
/* Comment */
.hljs-comment,
.hljs-quote {
color: #d4d0ab;
}
/* Red */
.hljs-variable,
.hljs-template-variable,
.hljs-tag,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class,
.hljs-regexp,
.hljs-deletion {
color: #ffa07a;
}
/* Orange */
.hljs-number,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params,
.hljs-meta,
.hljs-link {
color: #f5ab35;
}
/* Yellow */
.hljs-attribute {
color: #ffd700;
}
/* Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet,
.hljs-addition {
color: #abe338;
}
/* Blue */
.hljs-title,
.hljs-section {
color: #00e0e0;
}
/* Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #dcc6e0;
}
.hljs {
display: block;
overflow-x: auto;
background: #2b2b2b;
color: #f8f8f2;
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
@media screen and (-ms-high-contrast: active) {
.hljs-addition,
.hljs-attribute,
.hljs-built_in,
.hljs-builtin-name,
.hljs-bullet,
.hljs-comment,
.hljs-link,
.hljs-literal,
.hljs-meta,
.hljs-number,
.hljs-params,
.hljs-string,
.hljs-symbol,
.hljs-type,
.hljs-quote {
color: highlight;
}
.hljs-keyword,
.hljs-selector-tag {
font-weight: bold;
}
}

View File

@ -1,99 +0,0 @@
/* a11y-light theme */
/* Based on the Tomorrow Night Eighties theme: https://github.com/isagalaev/highlight.js/blob/master/src/styles/tomorrow-night-eighties.css */
/* @author: ericwbailey */
/* Comment */
.hljs-comment,
.hljs-quote {
color: #696969;
}
/* Red */
.hljs-variable,
.hljs-template-variable,
.hljs-tag,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class,
.hljs-regexp,
.hljs-deletion {
color: #d91e18;
}
/* Orange */
.hljs-number,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params,
.hljs-meta,
.hljs-link {
color: #aa5d00;
}
/* Yellow */
.hljs-attribute {
color: #aa5d00;
}
/* Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet,
.hljs-addition {
color: #008000;
}
/* Blue */
.hljs-title,
.hljs-section {
color: #007faa;
}
/* Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #7928a1;
}
.hljs {
display: block;
overflow-x: auto;
background: #fefefe;
color: #545454;
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
@media screen and (-ms-high-contrast: active) {
.hljs-addition,
.hljs-attribute,
.hljs-built_in,
.hljs-builtin-name,
.hljs-bullet,
.hljs-comment,
.hljs-link,
.hljs-literal,
.hljs-meta,
.hljs-number,
.hljs-params,
.hljs-string,
.hljs-symbol,
.hljs-type,
.hljs-quote {
color: highlight;
}
.hljs-keyword,
.hljs-selector-tag {
font-weight: bold;
}
}

View File

@ -1,108 +0,0 @@
/*!
* Agate by Taufik Nurrohman <https://github.com/tovic>
* ----------------------------------------------------
*
* #ade5fc
* #a2fca2
* #c6b4f0
* #d36363
* #fcc28c
* #fc9b9b
* #ffa
* #fff
* #333
* #62c8f3
* #888
*
*/
.hljs {
display: block;
overflow-x: auto;
padding: 0.5em;
background: #333;
color: white;
}
.hljs-name,
.hljs-strong {
font-weight: bold;
}
.hljs-code,
.hljs-emphasis {
font-style: italic;
}
.hljs-tag {
color: #62c8f3;
}
.hljs-variable,
.hljs-template-variable,
.hljs-selector-id,
.hljs-selector-class {
color: #ade5fc;
}
.hljs-string,
.hljs-bullet {
color: #a2fca2;
}
.hljs-type,
.hljs-title,
.hljs-section,
.hljs-attribute,
.hljs-quote,
.hljs-built_in,
.hljs-builtin-name {
color: #ffa;
}
.hljs-number,
.hljs-symbol,
.hljs-bullet {
color: #d36363;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-literal {
color: #fcc28c;
}
.hljs-comment,
.hljs-deletion,
.hljs-code {
color: #888;
}
.hljs-regexp,
.hljs-link {
color: #c6b4f0;
}
.hljs-meta {
color: #fc9b9b;
}
.hljs-deletion {
background-color: #fc9b9b;
color: #333;
}
.hljs-addition {
background-color: #a2fca2;
color: #333;
}
.hljs a {
color: inherit;
}
.hljs a:focus,
.hljs a:hover {
color: inherit;
text-decoration: underline;
}

View File

@ -1,89 +0,0 @@
/*
An Old Hope Star Wars Syntax (c) Gustavo Costa <gusbemacbe@gmail.com>
Original theme - Ocean Dark Theme by https://github.com/gavsiu
Based on Jesse Leite's Atom syntax theme 'An Old Hope' https://github.com/JesseLeite/an-old-hope-syntax-atom
*/
/* Death Star Comment */
.hljs-comment,
.hljs-quote
{
color: #B6B18B;
}
/* Darth Vader */
.hljs-variable,
.hljs-template-variable,
.hljs-tag,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class,
.hljs-regexp,
.hljs-deletion
{
color: #EB3C54;
}
/* Threepio */
.hljs-number,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params,
.hljs-meta,
.hljs-link
{
color: #E7CE56;
}
/* Luke Skywalker */
.hljs-attribute
{
color: #EE7C2B;
}
/* Obi Wan Kenobi */
.hljs-string,
.hljs-symbol,
.hljs-bullet,
.hljs-addition
{
color: #4FB4D7;
}
/* Yoda */
.hljs-title,
.hljs-section
{
color: #78BB65;
}
/* Mace Windu */
.hljs-keyword,
.hljs-selector-tag
{
color: #B45EA4;
}
/* Millenium Falcon */
.hljs
{
display: block;
overflow-x: auto;
background: #1C1D21;
color: #c0c5ce;
padding: 0.5em;
}
.hljs-emphasis
{
font-style: italic;
}
.hljs-strong
{
font-weight: bold;
}

View File

@ -1,66 +0,0 @@
/*
Date: 24 Fev 2015
Author: Pedro Oliveira <kanytu@gmail . com>
*/
.hljs {
color: #a9b7c6;
background: #282b2e;
display: block;
overflow-x: auto;
padding: 0.5em;
}
.hljs-number,
.hljs-literal,
.hljs-symbol,
.hljs-bullet {
color: #6897BB;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-deletion {
color: #cc7832;
}
.hljs-variable,
.hljs-template-variable,
.hljs-link {
color: #629755;
}
.hljs-comment,
.hljs-quote {
color: #808080;
}
.hljs-meta {
color: #bbb529;
}
.hljs-string,
.hljs-attribute,
.hljs-addition {
color: #6A8759;
}
.hljs-section,
.hljs-title,
.hljs-type {
color: #ffc66d;
}
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #e8bf6a;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}

View File

@ -1,88 +0,0 @@
/*
Arduino® Light Theme - Stefania Mellai <s.mellai@arduino.cc>
*/
.hljs {
display: block;
overflow-x: auto;
padding: 0.5em;
background: #FFFFFF;
}
.hljs,
.hljs-subst {
color: #434f54;
}
.hljs-keyword,
.hljs-attribute,
.hljs-selector-tag,
.hljs-doctag,
.hljs-name {
color: #00979D;
}
.hljs-built_in,
.hljs-literal,
.hljs-bullet,
.hljs-code,
.hljs-addition {
color: #D35400;
}
.hljs-regexp,
.hljs-symbol,
.hljs-variable,
.hljs-template-variable,
.hljs-link,
.hljs-selector-attr,
.hljs-selector-pseudo {
color: #00979D;
}
.hljs-type,
.hljs-string,
.hljs-selector-id,
.hljs-selector-class,
.hljs-quote,
.hljs-template-tag,
.hljs-deletion {
color: #005C5F;
}
.hljs-title,
.hljs-section {
color: #880000;
font-weight: bold;
}
.hljs-comment {
color: rgba(149,165,166,.8);
}
.hljs-meta-keyword {
color: #728E00;
}
.hljs-meta {
color: #728E00;
color: #434f54;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
.hljs-function {
color: #728E00;
}
.hljs-number {
color: #8A7B52;
}

View File

@ -1,73 +0,0 @@
/*
Date: 17.V.2011
Author: pumbur <pumbur@pumbur.net>
*/
.hljs {
display: block;
overflow-x: auto;
padding: 0.5em;
background: #222;
}
.hljs,
.hljs-subst {
color: #aaa;
}
.hljs-section {
color: #fff;
}
.hljs-comment,
.hljs-quote,
.hljs-meta {
color: #444;
}
.hljs-string,
.hljs-symbol,
.hljs-bullet,
.hljs-regexp {
color: #ffcc33;
}
.hljs-number,
.hljs-addition {
color: #00cc66;
}
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-template-variable,
.hljs-attribute,
.hljs-link {
color: #32aaee;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #6644aa;
}
.hljs-title,
.hljs-variable,
.hljs-deletion,
.hljs-template-tag {
color: #bb1166;
}
.hljs-section,
.hljs-doctag,
.hljs-strong {
font-weight: bold;
}
.hljs-emphasis {
font-style: italic;
}

View File

@ -1,45 +0,0 @@
/*
Original style from softwaremaniacs.org (c) Ivan Sagalaev <Maniac@SoftwareManiacs.Org>
*/
.hljs {
display: block;
overflow-x: auto;
padding: 0.5em;
background: white;
color: black;
}
.hljs-string,
.hljs-variable,
.hljs-template-variable,
.hljs-symbol,
.hljs-bullet,
.hljs-section,
.hljs-addition,
.hljs-attribute,
.hljs-link {
color: #888;
}
.hljs-comment,
.hljs-quote,
.hljs-meta,
.hljs-deletion {
color: #ccc;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-section,
.hljs-name,
.hljs-type,
.hljs-strong {
font-weight: bold;
}
.hljs-emphasis {
font-style: italic;
}

View File

@ -1,83 +0,0 @@
/* Base16 Atelier Cave Dark - Theme */
/* by Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/cave) */
/* Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) */
/* Atelier-Cave Comment */
.hljs-comment,
.hljs-quote {
color: #7e7887;
}
/* Atelier-Cave Red */
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-regexp,
.hljs-link,
.hljs-tag,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #be4678;
}
/* Atelier-Cave Orange */
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #aa573c;
}
/* Atelier-Cave Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #2a9292;
}
/* Atelier-Cave Blue */
.hljs-title,
.hljs-section {
color: #576ddb;
}
/* Atelier-Cave Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #955ae7;
}
.hljs-deletion,
.hljs-addition {
color: #19171c;
display: inline-block;
width: 100%;
}
.hljs-deletion {
background-color: #be4678;
}
.hljs-addition {
background-color: #2a9292;
}
.hljs {
display: block;
overflow-x: auto;
background: #19171c;
color: #8b8792;
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}

View File

@ -1,85 +0,0 @@
/* Base16 Atelier Cave Light - Theme */
/* by Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/cave) */
/* Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) */
/* Atelier-Cave Comment */
.hljs-comment,
.hljs-quote {
color: #655f6d;
}
/* Atelier-Cave Red */
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #be4678;
}
/* Atelier-Cave Orange */
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #aa573c;
}
/* Atelier-Cave Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #2a9292;
}
/* Atelier-Cave Blue */
.hljs-title,
.hljs-section {
color: #576ddb;
}
/* Atelier-Cave Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #955ae7;
}
.hljs-deletion,
.hljs-addition {
color: #19171c;
display: inline-block;
width: 100%;
}
.hljs-deletion {
background-color: #be4678;
}
.hljs-addition {
background-color: #2a9292;
}
.hljs {
display: block;
overflow-x: auto;
background: #efecf4;
color: #585260;
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}

View File

@ -1,69 +0,0 @@
/* Base16 Atelier Dune Dark - Theme */
/* by Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/dune) */
/* Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) */
/* Atelier-Dune Comment */
.hljs-comment,
.hljs-quote {
color: #999580;
}
/* Atelier-Dune Red */
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #d73737;
}
/* Atelier-Dune Orange */
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #b65611;
}
/* Atelier-Dune Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #60ac39;
}
/* Atelier-Dune Blue */
.hljs-title,
.hljs-section {
color: #6684e1;
}
/* Atelier-Dune Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #b854d4;
}
.hljs {
display: block;
overflow-x: auto;
background: #20201d;
color: #a6a28c;
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}

View File

@ -1,69 +0,0 @@
/* Base16 Atelier Dune Light - Theme */
/* by Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/dune) */
/* Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) */
/* Atelier-Dune Comment */
.hljs-comment,
.hljs-quote {
color: #7d7a68;
}
/* Atelier-Dune Red */
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #d73737;
}
/* Atelier-Dune Orange */
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #b65611;
}
/* Atelier-Dune Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #60ac39;
}
/* Atelier-Dune Blue */
.hljs-title,
.hljs-section {
color: #6684e1;
}
/* Atelier-Dune Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #b854d4;
}
.hljs {
display: block;
overflow-x: auto;
background: #fefbec;
color: #6e6b5e;
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}

View File

@ -1,84 +0,0 @@
/* Base16 Atelier Estuary Dark - Theme */
/* by Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/estuary) */
/* Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) */
/* Atelier-Estuary Comment */
.hljs-comment,
.hljs-quote {
color: #878573;
}
/* Atelier-Estuary Red */
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #ba6236;
}
/* Atelier-Estuary Orange */
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #ae7313;
}
/* Atelier-Estuary Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #7d9726;
}
/* Atelier-Estuary Blue */
.hljs-title,
.hljs-section {
color: #36a166;
}
/* Atelier-Estuary Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #5f9182;
}
.hljs-deletion,
.hljs-addition {
color: #22221b;
display: inline-block;
width: 100%;
}
.hljs-deletion {
background-color: #ba6236;
}
.hljs-addition {
background-color: #7d9726;
}
.hljs {
display: block;
overflow-x: auto;
background: #22221b;
color: #929181;
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}

View File

@ -1,84 +0,0 @@
/* Base16 Atelier Estuary Light - Theme */
/* by Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/estuary) */
/* Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) */
/* Atelier-Estuary Comment */
.hljs-comment,
.hljs-quote {
color: #6c6b5a;
}
/* Atelier-Estuary Red */
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #ba6236;
}
/* Atelier-Estuary Orange */
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #ae7313;
}
/* Atelier-Estuary Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #7d9726;
}
/* Atelier-Estuary Blue */
.hljs-title,
.hljs-section {
color: #36a166;
}
/* Atelier-Estuary Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #5f9182;
}
.hljs-deletion,
.hljs-addition {
color: #22221b;
display: inline-block;
width: 100%;
}
.hljs-deletion {
background-color: #ba6236;
}
.hljs-addition {
background-color: #7d9726;
}
.hljs {
display: block;
overflow-x: auto;
background: #f4f3ec;
color: #5f5e4e;
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}

View File

@ -1,69 +0,0 @@
/* Base16 Atelier Forest Dark - Theme */
/* by Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/forest) */
/* Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) */
/* Atelier-Forest Comment */
.hljs-comment,
.hljs-quote {
color: #9c9491;
}
/* Atelier-Forest Red */
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #f22c40;
}
/* Atelier-Forest Orange */
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #df5320;
}
/* Atelier-Forest Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #7b9726;
}
/* Atelier-Forest Blue */
.hljs-title,
.hljs-section {
color: #407ee7;
}
/* Atelier-Forest Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #6666ea;
}
.hljs {
display: block;
overflow-x: auto;
background: #1b1918;
color: #a8a19f;
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}

View File

@ -1,69 +0,0 @@
/* Base16 Atelier Forest Light - Theme */
/* by Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/forest) */
/* Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) */
/* Atelier-Forest Comment */
.hljs-comment,
.hljs-quote {
color: #766e6b;
}
/* Atelier-Forest Red */
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #f22c40;
}
/* Atelier-Forest Orange */
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #df5320;
}
/* Atelier-Forest Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #7b9726;
}
/* Atelier-Forest Blue */
.hljs-title,
.hljs-section {
color: #407ee7;
}
/* Atelier-Forest Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #6666ea;
}
.hljs {
display: block;
overflow-x: auto;
background: #f1efee;
color: #68615e;
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}

View File

@ -1,69 +0,0 @@
/* Base16 Atelier Heath Dark - Theme */
/* by Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/heath) */
/* Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) */
/* Atelier-Heath Comment */
.hljs-comment,
.hljs-quote {
color: #9e8f9e;
}
/* Atelier-Heath Red */
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #ca402b;
}
/* Atelier-Heath Orange */
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #a65926;
}
/* Atelier-Heath Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #918b3b;
}
/* Atelier-Heath Blue */
.hljs-title,
.hljs-section {
color: #516aec;
}
/* Atelier-Heath Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #7b59c0;
}
.hljs {
display: block;
overflow-x: auto;
background: #1b181b;
color: #ab9bab;
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}

View File

@ -1,69 +0,0 @@
/* Base16 Atelier Heath Light - Theme */
/* by Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/heath) */
/* Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) */
/* Atelier-Heath Comment */
.hljs-comment,
.hljs-quote {
color: #776977;
}
/* Atelier-Heath Red */
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #ca402b;
}
/* Atelier-Heath Orange */
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #a65926;
}
/* Atelier-Heath Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #918b3b;
}
/* Atelier-Heath Blue */
.hljs-title,
.hljs-section {
color: #516aec;
}
/* Atelier-Heath Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #7b59c0;
}
.hljs {
display: block;
overflow-x: auto;
background: #f7f3f7;
color: #695d69;
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}

View File

@ -1,69 +0,0 @@
/* Base16 Atelier Lakeside Dark - Theme */
/* by Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/lakeside) */
/* Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) */
/* Atelier-Lakeside Comment */
.hljs-comment,
.hljs-quote {
color: #7195a8;
}
/* Atelier-Lakeside Red */
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #d22d72;
}
/* Atelier-Lakeside Orange */
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #935c25;
}
/* Atelier-Lakeside Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #568c3b;
}
/* Atelier-Lakeside Blue */
.hljs-title,
.hljs-section {
color: #257fad;
}
/* Atelier-Lakeside Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #6b6bb8;
}
.hljs {
display: block;
overflow-x: auto;
background: #161b1d;
color: #7ea2b4;
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}

View File

@ -1,69 +0,0 @@
/* Base16 Atelier Lakeside Light - Theme */
/* by Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/lakeside) */
/* Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) */
/* Atelier-Lakeside Comment */
.hljs-comment,
.hljs-quote {
color: #5a7b8c;
}
/* Atelier-Lakeside Red */
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #d22d72;
}
/* Atelier-Lakeside Orange */
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #935c25;
}
/* Atelier-Lakeside Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #568c3b;
}
/* Atelier-Lakeside Blue */
.hljs-title,
.hljs-section {
color: #257fad;
}
/* Atelier-Lakeside Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #6b6bb8;
}
.hljs {
display: block;
overflow-x: auto;
background: #ebf8ff;
color: #516d7b;
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}

View File

@ -1,84 +0,0 @@
/* Base16 Atelier Plateau Dark - Theme */
/* by Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/plateau) */
/* Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) */
/* Atelier-Plateau Comment */
.hljs-comment,
.hljs-quote {
color: #7e7777;
}
/* Atelier-Plateau Red */
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #ca4949;
}
/* Atelier-Plateau Orange */
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #b45a3c;
}
/* Atelier-Plateau Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #4b8b8b;
}
/* Atelier-Plateau Blue */
.hljs-title,
.hljs-section {
color: #7272ca;
}
/* Atelier-Plateau Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #8464c4;
}
.hljs-deletion,
.hljs-addition {
color: #1b1818;
display: inline-block;
width: 100%;
}
.hljs-deletion {
background-color: #ca4949;
}
.hljs-addition {
background-color: #4b8b8b;
}
.hljs {
display: block;
overflow-x: auto;
background: #1b1818;
color: #8a8585;
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}

View File

@ -1,84 +0,0 @@
/* Base16 Atelier Plateau Light - Theme */
/* by Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/plateau) */
/* Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) */
/* Atelier-Plateau Comment */
.hljs-comment,
.hljs-quote {
color: #655d5d;
}
/* Atelier-Plateau Red */
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #ca4949;
}
/* Atelier-Plateau Orange */
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #b45a3c;
}
/* Atelier-Plateau Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #4b8b8b;
}
/* Atelier-Plateau Blue */
.hljs-title,
.hljs-section {
color: #7272ca;
}
/* Atelier-Plateau Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #8464c4;
}
.hljs-deletion,
.hljs-addition {
color: #1b1818;
display: inline-block;
width: 100%;
}
.hljs-deletion {
background-color: #ca4949;
}
.hljs-addition {
background-color: #4b8b8b;
}
.hljs {
display: block;
overflow-x: auto;
background: #f4ecec;
color: #585050;
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}

View File

@ -1,84 +0,0 @@
/* Base16 Atelier Savanna Dark - Theme */
/* by Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/savanna) */
/* Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) */
/* Atelier-Savanna Comment */
.hljs-comment,
.hljs-quote {
color: #78877d;
}
/* Atelier-Savanna Red */
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #b16139;
}
/* Atelier-Savanna Orange */
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #9f713c;
}
/* Atelier-Savanna Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #489963;
}
/* Atelier-Savanna Blue */
.hljs-title,
.hljs-section {
color: #478c90;
}
/* Atelier-Savanna Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #55859b;
}
.hljs-deletion,
.hljs-addition {
color: #171c19;
display: inline-block;
width: 100%;
}
.hljs-deletion {
background-color: #b16139;
}
.hljs-addition {
background-color: #489963;
}
.hljs {
display: block;
overflow-x: auto;
background: #171c19;
color: #87928a;
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}

View File

@ -1,84 +0,0 @@
/* Base16 Atelier Savanna Light - Theme */
/* by Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/savanna) */
/* Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) */
/* Atelier-Savanna Comment */
.hljs-comment,
.hljs-quote {
color: #5f6d64;
}
/* Atelier-Savanna Red */
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #b16139;
}
/* Atelier-Savanna Orange */
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #9f713c;
}
/* Atelier-Savanna Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #489963;
}
/* Atelier-Savanna Blue */
.hljs-title,
.hljs-section {
color: #478c90;
}
/* Atelier-Savanna Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #55859b;
}
.hljs-deletion,
.hljs-addition {
color: #171c19;
display: inline-block;
width: 100%;
}
.hljs-deletion {
background-color: #b16139;
}
.hljs-addition {
background-color: #489963;
}
.hljs {
display: block;
overflow-x: auto;
background: #ecf4ee;
color: #526057;
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}

View File

@ -1,69 +0,0 @@
/* Base16 Atelier Seaside Dark - Theme */
/* by Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/seaside) */
/* Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) */
/* Atelier-Seaside Comment */
.hljs-comment,
.hljs-quote {
color: #809980;
}
/* Atelier-Seaside Red */
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #e6193c;
}
/* Atelier-Seaside Orange */
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #87711d;
}
/* Atelier-Seaside Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #29a329;
}
/* Atelier-Seaside Blue */
.hljs-title,
.hljs-section {
color: #3d62f5;
}
/* Atelier-Seaside Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #ad2bee;
}
.hljs {
display: block;
overflow-x: auto;
background: #131513;
color: #8ca68c;
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}

View File

@ -1,69 +0,0 @@
/* Base16 Atelier Seaside Light - Theme */
/* by Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/seaside) */
/* Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) */
/* Atelier-Seaside Comment */
.hljs-comment,
.hljs-quote {
color: #687d68;
}
/* Atelier-Seaside Red */
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #e6193c;
}
/* Atelier-Seaside Orange */
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #87711d;
}
/* Atelier-Seaside Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #29a329;
}
/* Atelier-Seaside Blue */
.hljs-title,
.hljs-section {
color: #3d62f5;
}
/* Atelier-Seaside Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #ad2bee;
}
.hljs {
display: block;
overflow-x: auto;
background: #f4fbf4;
color: #5e6e5e;
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}

View File

@ -1,69 +0,0 @@
/* Base16 Atelier Sulphurpool Dark - Theme */
/* by Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/sulphurpool) */
/* Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) */
/* Atelier-Sulphurpool Comment */
.hljs-comment,
.hljs-quote {
color: #898ea4;
}
/* Atelier-Sulphurpool Red */
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #c94922;
}
/* Atelier-Sulphurpool Orange */
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #c76b29;
}
/* Atelier-Sulphurpool Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #ac9739;
}
/* Atelier-Sulphurpool Blue */
.hljs-title,
.hljs-section {
color: #3d8fd1;
}
/* Atelier-Sulphurpool Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #6679cc;
}
.hljs {
display: block;
overflow-x: auto;
background: #202746;
color: #979db4;
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}

View File

@ -1,69 +0,0 @@
/* Base16 Atelier Sulphurpool Light - Theme */
/* by Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/sulphurpool) */
/* Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) */
/* Atelier-Sulphurpool Comment */
.hljs-comment,
.hljs-quote {
color: #6b7394;
}
/* Atelier-Sulphurpool Red */
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #c94922;
}
/* Atelier-Sulphurpool Orange */
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #c76b29;
}
/* Atelier-Sulphurpool Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #ac9739;
}
/* Atelier-Sulphurpool Blue */
.hljs-title,
.hljs-section {
color: #3d8fd1;
}
/* Atelier-Sulphurpool Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #6679cc;
}
.hljs {
display: block;
overflow-x: auto;
background: #f5f7ff;
color: #5e6687;
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}

Some files were not shown because too many files have changed in this diff Show More