Updated a lot of chat related things
parent
c559fdff6c
commit
3155fb85f0
14
ChangeLog.md
14
ChangeLog.md
|
@ -1,10 +1,22 @@
|
|||
# Changelog:
|
||||
* **XX.XX.20**
|
||||
- Rewrote the channel conversation UI
|
||||
- Rewrote the channel conversation UI and manager
|
||||
- Several bug fixes like the scrollbar
|
||||
- Updated the channel history view mode
|
||||
- Improved chat box behaviour
|
||||
- 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**
|
||||
- Made the loader compatible with ES5 to support older browsers
|
||||
|
|
11
README.md
11
README.md
|
@ -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.
|
||||
|
||||
# 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 |
|
||||
|
||||
|
|
2
file.ts
2
file.ts
|
@ -134,6 +134,7 @@ const APP_FILE_LIST_SHARED_VENDORS: ProjectResource[] = [
|
|||
"type": "js",
|
||||
"search-pattern": /.*(\.min)?\.js$/,
|
||||
"build-target": "dev|rel",
|
||||
"search-exclude": /.*xbbcode.*/g,
|
||||
|
||||
"path": "vendor/",
|
||||
"local-path": "./vendor/"
|
||||
|
@ -142,6 +143,7 @@ const APP_FILE_LIST_SHARED_VENDORS: ProjectResource[] = [
|
|||
"type": "css",
|
||||
"search-pattern": /.*\.css$/,
|
||||
"build-target": "dev|rel",
|
||||
"search-exclude": /.*xbbcode.*/g,
|
||||
|
||||
"path": "vendor/",
|
||||
"local-path": "./vendor/"
|
||||
|
|
|
@ -37,6 +37,7 @@ function initializeElements() {
|
|||
image.alt = lazyImage.getAttribute("alt");
|
||||
image.src = lazyImage.getAttribute(apngSupport ? "src-apng" : "src-gif") || lazyImage.getAttribute("src");
|
||||
image.className = lazyImage.className;
|
||||
image.draggable = false;
|
||||
lazyImage.replaceWith(image);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,9 +1,15 @@
|
|||
/* IE11 */
|
||||
if(!Element.prototype.remove)
|
||||
Element.prototype.remove = function() {
|
||||
this.parentElement?.removeChild(this);
|
||||
};
|
||||
/* IE11 and safari */
|
||||
if(Element.prototype.remove === undefined)
|
||||
Object.defineProperty(Element.prototype, "remove", {
|
||||
enumerable: false,
|
||||
configurable: false,
|
||||
writable: false,
|
||||
value: function(){
|
||||
this.parentElement.removeChild(this);
|
||||
}
|
||||
});
|
||||
|
||||
/* IE11 */
|
||||
function ReplaceWithPolyfill() {
|
||||
let parent = this.parentNode, i = arguments.length, currentNode;
|
||||
if (!parent) return;
|
||||
|
|
|
@ -63,10 +63,6 @@ const loader_javascript = {
|
|||
|
||||
await loader.scripts.load_multiple([
|
||||
["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 */
|
||||
], {
|
||||
cache_tag: cache_tag(),
|
||||
|
@ -127,23 +123,6 @@ const loader_webassembly = {
|
|||
|
||||
const loader_style = {
|
||||
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") {
|
||||
await loader_style.load_style_debug(taskId);
|
||||
} 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, {
|
||||
name: "secure tester",
|
||||
function: async () => {
|
||||
|
@ -279,12 +240,6 @@ loader.register_task(loader.Stage.TEMPLATES, {
|
|||
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, {
|
||||
name: "page setup",
|
||||
function: async () => {
|
||||
|
|
|
@ -68,7 +68,7 @@ var initial_css;
|
|||
<noscript>
|
||||
<div id="overlay-no-js">
|
||||
<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>
|
||||
<h3>TeaSpeak web could not run without it!</h3>
|
||||
<h3>Its like you, without coffee</h3>
|
||||
|
@ -84,7 +84,7 @@ var initial_css;
|
|||
<div class="loader" id="loader-overlay">
|
||||
<div class="container">
|
||||
<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 class="idle">
|
||||
<lazy-img class="bowl" src="img/loader/bowl.png" alt="bowl"></lazy-img>
|
||||
|
@ -99,7 +99,7 @@ var initial_css;
|
|||
<!-- Critical load error -->
|
||||
<div class="fulloverlay" id="critical-load">
|
||||
<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>
|
||||
<h3 class="detail"></h3>
|
||||
|
|
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
|
@ -40,14 +40,16 @@
|
|||
"@types/node": "^12.7.2",
|
||||
"@types/react-dom": "^16.9.5",
|
||||
"@types/sha256": "^0.2.0",
|
||||
"@types/twemoji": "^12.1.1",
|
||||
"@types/websocket": "0.0.40",
|
||||
"@types/emoji-mart": "^3.0.2",
|
||||
"babel-loader": "^8.1.0",
|
||||
"chunk-manifest-webpack-plugin": "^1.1.2",
|
||||
"circular-dependency-plugin": "^5.2.0",
|
||||
"clean-css": "^4.2.1",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"css-loader": "^3.6.0",
|
||||
"csso-cli": "^2.0.2",
|
||||
"csso-cli": "^3.0.0",
|
||||
"ejs": "^3.0.2",
|
||||
"exports-loader": "^0.7.0",
|
||||
"fs-extra": "latest",
|
||||
|
@ -58,7 +60,7 @@
|
|||
"mime-types": "^2.1.24",
|
||||
"mini-css-extract-plugin": "^0.9.0",
|
||||
"mkdirp": "^0.5.1",
|
||||
"node-sass": "^4.13.1",
|
||||
"node-sass": "^4.14.1",
|
||||
"raw-loader": "^4.0.0",
|
||||
"sass": "1.22.10",
|
||||
"sass-loader": "^8.0.2",
|
||||
|
@ -68,7 +70,7 @@
|
|||
"terser": "^4.2.1",
|
||||
"terser-webpack-plugin": "latest",
|
||||
"ts-loader": "^6.2.2",
|
||||
"tsd": "latest",
|
||||
"tsd": "^0.13.1",
|
||||
"typescript": "^3.7.0",
|
||||
"wabt": "^1.0.13",
|
||||
"webpack": "^4.42.1",
|
||||
|
@ -86,14 +88,16 @@
|
|||
"homepage": "https://www.teaspeak.de",
|
||||
"dependencies": {
|
||||
"detect-browser": "^5.1.1",
|
||||
"@types/emoji-mart": "^3.0.2",
|
||||
"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",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"simplebar-react": "^2.2.0",
|
||||
"twemoji": "^13.0.0",
|
||||
"webrtc-adapter": "^7.5.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
padding-right: 5%;
|
||||
padding-left: 5%;
|
||||
|
||||
z-index: 1000;
|
||||
z-index: 100000;
|
||||
position: fixed;
|
||||
|
||||
top: 0;
|
||||
|
|
|
@ -169,138 +169,6 @@
|
|||
</div>
|
||||
</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 dont have any chats 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">
|
||||
<div class="container-client-info">
|
||||
<div class="heading">
|
||||
|
|
|
@ -212,6 +212,7 @@ export class ConnectionHandler {
|
|||
}
|
||||
|
||||
this.event_registry.register_handler(this);
|
||||
this.events().fire("notify_handler_initialized");
|
||||
}
|
||||
|
||||
initialize_client_state(source?: ConnectionHandler) {
|
||||
|
@ -643,7 +644,6 @@ export class ConnectionHandler {
|
|||
if(this.serverConnection)
|
||||
this.serverConnection.disconnect();
|
||||
|
||||
this.side_bar.private_conversations().clear_client_ids();
|
||||
this.hostbanner.update();
|
||||
|
||||
if(auto_reconnect) {
|
||||
|
@ -1074,5 +1074,8 @@ export interface ConnectionEvents {
|
|||
/* the handler has become visible/invisible for the client */
|
||||
notify_visibility_changed: {
|
||||
visible: boolean
|
||||
}
|
||||
},
|
||||
|
||||
/* fill only trigger once, after everything has been constructed */
|
||||
notify_handler_initialized: {}
|
||||
}
|
|
@ -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 ? " " : 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);
|
||||
}
|
|
@ -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 ? " " : 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);
|
||||
}
|
|
@ -18,9 +18,9 @@ import {ConnectionHandler, ConnectionState, DisconnectReason, ViewReasonId} from
|
|||
import {bbcode_chat, formatMessage} from "tc-shared/ui/frames/chat";
|
||||
import {server_connections} from "tc-shared/ui/frames/connection_handlers";
|
||||
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 {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 {
|
||||
constructor(connection: AbstractServerConnection) {
|
||||
|
@ -440,8 +440,10 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
|
|||
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 = tree.insertClient(client, channel);
|
||||
client = tree.insertClient(client, channel, { reason: reason_id, isServerJoin: parseInt(entry["cfid"]) === 0 });
|
||||
} else {
|
||||
tree.moveClient(client, channel);
|
||||
}
|
||||
|
@ -495,22 +497,6 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
|
|||
|
||||
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) {
|
||||
client.initializeListener();
|
||||
this.connection_handler.update_voice_status();
|
||||
|
@ -548,11 +534,11 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
|
|||
return;
|
||||
}
|
||||
|
||||
|
||||
const targetChannelId = parseInt(entry["ctid"]);
|
||||
if(this.connection_handler.areQueriesShown() || client.properties.client_type != ClientType.CLIENT_QUERY) {
|
||||
const own_channel = this.connection.client.getClient().currentChannel();
|
||||
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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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"];
|
||||
if(mode == 1){
|
||||
//json["invokerid"], json["invokername"], json["invokeruid"]
|
||||
const target_client_id = parseInt(json["target"]);
|
||||
const target_own = target_client_id === this.connection.client.getClientId();
|
||||
const targetClientId = parseInt(json["target"]);
|
||||
const invokerClientId = parseInt(json["invokerid"]);
|
||||
|
||||
if(target_own && target_client_id === json["invokerid"]) {
|
||||
log.error(LogCategory.NETWORKING, tr("Received conversation message from invalid client id. Data: %o"), json);
|
||||
const targetClientEntry = this.connection_handler.channelTree.findClient(targetClientId);
|
||||
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;
|
||||
}
|
||||
|
||||
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 = conversation_manager.find_conversation({
|
||||
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;
|
||||
}
|
||||
const conversation = conversation_manager.findOrCreateConversation(chatPartner);
|
||||
|
||||
conversation.append_message(json["msg"], {
|
||||
type: target_own ? "partner" : "self",
|
||||
name: json["invokername"],
|
||||
unique_id: json["invokeruid"],
|
||||
client_id: parseInt(json["invokerid"])
|
||||
});
|
||||
conversation.handleIncomingMessage(chatPartner, !targetIsOwn, {
|
||||
sender_database_id: targetClientEntry ? targetClientEntry.properties.client_database_id : 0,
|
||||
sender_name: json["invokername"],
|
||||
sender_unique_id: json["invokeruid"],
|
||||
|
||||
if(target_own) {
|
||||
timestamp: Date.now(),
|
||||
message: json["msg"]
|
||||
});
|
||||
if(targetIsOwn) {
|
||||
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 {
|
||||
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) {
|
||||
const invoker = this.connection_handler.channelTree.findClient(parseInt(json["invokerid"]));
|
||||
const own_channel_id = this.connection.client.getClient().currentChannel().channelId;
|
||||
|
@ -826,10 +793,8 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
|
|||
sender_unique_id: json["invokeruid"],
|
||||
|
||||
timestamp: typeof(json["timestamp"]) === "undefined" ? Date.now() : parseInt(json["timestamp"]),
|
||||
message: json["msg"],
|
||||
|
||||
unique_id: Date.now() + " - " + Math.random()
|
||||
}, !(invoker instanceof LocalClientEntry));
|
||||
message: json["msg"]
|
||||
}, invoker instanceof LocalClientEntry);
|
||||
} else if(mode == 3) {
|
||||
this.connection_handler.log.log(server_log.Type.GLOBAL_MESSAGE, {
|
||||
message: json["msg"],
|
||||
|
@ -852,10 +817,8 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
|
|||
sender_unique_id: json["invokeruid"],
|
||||
|
||||
timestamp: typeof(json["timestamp"]) === "undefined" ? Date.now() : parseInt(json["timestamp"]),
|
||||
message: json["msg"],
|
||||
|
||||
unique_id: Date.now() + " - " + Math.random()
|
||||
}, !(invoker instanceof LocalClientEntry));
|
||||
message: json["msg"]
|
||||
}, invoker instanceof LocalClientEntry);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -863,42 +826,21 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
|
|||
json = json[0];
|
||||
|
||||
const conversation_manager = this.connection_handler.side_bar.private_conversations();
|
||||
const conversation = conversation_manager.find_conversation({
|
||||
client_id: parseInt(json["clid"]),
|
||||
unique_id: json["cluid"],
|
||||
name: undefined
|
||||
}, {
|
||||
create: false,
|
||||
attach: false
|
||||
});
|
||||
if(!conversation)
|
||||
return;
|
||||
|
||||
conversation.trigger_typing();
|
||||
const conversation = conversation_manager.findConversation(json["cluid"]);
|
||||
conversation?.handleRemoteComposing(parseInt(json["clid"]));
|
||||
}
|
||||
|
||||
handleNotifyClientChatClosed(json) {
|
||||
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 = conversation_manager.find_conversation({
|
||||
client_id: parseInt(json["clid"]),
|
||||
unique_id: json["cluid"],
|
||||
name: undefined
|
||||
}, {
|
||||
create: false,
|
||||
attach: false
|
||||
});
|
||||
const conversation = conversation_manager.findConversation(json["cluid"]);
|
||||
if(!conversation) {
|
||||
log.warn(LogCategory.GENERAL, tr("Received chat close for client, but we haven't a chat open."));
|
||||
return;
|
||||
}
|
||||
conversation.set_state(PrivateConversationState.CLOSED);
|
||||
|
||||
conversation.handleChatRemotelyClosed(parseInt(json["clid"]));
|
||||
}
|
||||
|
||||
handleNotifyClientUpdated(json) {
|
||||
|
@ -1037,7 +979,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
|
|||
|
||||
channel.flag_subscribed = 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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -161,6 +161,7 @@ export class Registry<Events> {
|
|||
}
|
||||
|
||||
fire_async<T extends keyof Events>(event_type: T, data?: Events[T], callback?: () => void) {
|
||||
/* TODO: Optimize, bundle them */
|
||||
setTimeout(() => {
|
||||
this.fire(event_type, data);
|
||||
if(typeof callback === "function")
|
||||
|
@ -174,27 +175,39 @@ export class Registry<Events> {
|
|||
this.event_handler_objects = [];
|
||||
}
|
||||
|
||||
register_handler(handler: any) {
|
||||
register_handler(handler: any, parentClasses?: boolean) {
|
||||
if(typeof handler !== "object")
|
||||
throw "event handler must be an object";
|
||||
|
||||
const proto = Object.getPrototypeOf(handler);
|
||||
if(typeof proto !== "object")
|
||||
throw "event handler must have a prototype";
|
||||
|
||||
let currentPrototype = proto;
|
||||
let registered_events = {};
|
||||
for(const function_name of Object.getOwnPropertyNames(proto)) {
|
||||
if(function_name === "constructor") continue;
|
||||
if(typeof proto[function_name] !== "function") continue;
|
||||
if(typeof proto[function_name][event_annotation_key] !== "object") continue;
|
||||
do {
|
||||
Object.getOwnPropertyNames(currentPrototype).forEach(function_name => {
|
||||
if(function_name === "constructor")
|
||||
return;
|
||||
|
||||
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(typeof proto[function_name] !== "function")
|
||||
return;
|
||||
|
||||
if(typeof proto[function_name][event_annotation_key] !== "object")
|
||||
return;
|
||||
|
||||
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) {
|
||||
console.warn(tr("no events found in event handler"));
|
||||
return;
|
||||
|
|
|
@ -254,20 +254,30 @@ export class AvatarManager {
|
|||
}
|
||||
|
||||
update_cache(clientAvatarId: string, clientAvatarHash: string) {
|
||||
AvatarManager.cache.setup().then(() => {
|
||||
const deletePromise = AvatarManager.cache.delete("avatar_" + clientAvatarId).catch(error => {
|
||||
log.warn(LogCategory.FILE_TRANSFER, tr("Failed to delete avatar %s: %o"), clientAvatarId, error);
|
||||
});
|
||||
|
||||
AvatarManager.cache.setup().then(async () => {
|
||||
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);
|
||||
deletePromise.then(() => {
|
||||
log.info(LogCategory.GENERAL, tr("Deleting cached avatar for client %s. Cached version: %s; New version: %s"), cached.currentAvatarHash, clientAvatarHash);
|
||||
}
|
||||
|
||||
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.events.fire("avatar_changed");
|
||||
this.executeAvatarLoad(cached);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -128,7 +128,7 @@ export class ImageCache {
|
|||
ignoreSearch: true
|
||||
});
|
||||
if(!flag) {
|
||||
console.warn(tr("Failed to delete key %s from cache!"), flag);
|
||||
console.warn(tr("Failed to delete key %s from cache!"), key);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -43,11 +43,6 @@ declare global {
|
|||
format(arguments: string[]): string;
|
||||
}
|
||||
|
||||
interface Twemoji {
|
||||
parse(message: string) : string;
|
||||
}
|
||||
let twemoji: Twemoji;
|
||||
|
||||
interface HighlightJS {
|
||||
listLanguages() : string[];
|
||||
getLanguage(name: string) : any | undefined;
|
||||
|
@ -75,8 +70,7 @@ declare global {
|
|||
readonly Pointer_stringify: any;
|
||||
readonly jsrender: any;
|
||||
|
||||
twemoji: Twemoji;
|
||||
hljs: HighlightJS;
|
||||
cdhljs: HighlightJS;
|
||||
remarkable: any;
|
||||
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ import {LogCategory, LogType} from "tc-shared/log";
|
|||
import {PermissionType} from "tc-shared/permission/PermissionType";
|
||||
import {settings, Settings} from "tc-shared/settings";
|
||||
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 {createErrorModal, createInfoModal, createInputModal} from "tc-shared/ui/elements/Modal";
|
||||
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 {Registry} from "tc-shared/events";
|
||||
import {ChannelTreeEntry, ChannelTreeEntryEvents} from "tc-shared/ui/TreeEntry";
|
||||
import { ChannelEntryView as ChannelEntryView } from "./tree/Channel";
|
||||
import {MenuEntryType} from "tc-shared/ui/elements/ContextMenu";
|
||||
import {ChannelEntryView as ChannelEntryView} from "./tree/Channel";
|
||||
import {spawnFileTransferModal} from "tc-shared/ui/modal/transfer/ModalFileTransfer";
|
||||
import {ViewReasonId} from "tc-shared/ConnectionHandler";
|
||||
|
||||
export enum ChannelType {
|
||||
PERMANENT,
|
||||
|
@ -641,6 +642,9 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
|
|||
}
|
||||
|
||||
joinChannel(ignorePasswordFlag?: boolean) {
|
||||
if(this.channelTree.client.getClient().currentChannel() === this)
|
||||
return;
|
||||
|
||||
if(this.properties.channel_flag_password === true && !this.cachedPasswordHash && !ignorePasswordFlag) {
|
||||
this.requestChannelPassword(PermissionType.B_CHANNEL_JOIN_IGNORE_PASSWORD).then(() => {
|
||||
this.joinChannel(true);
|
||||
|
@ -719,7 +723,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
|
|||
this.flag_subscribed = false;
|
||||
|
||||
for(const client of this.clients(false))
|
||||
this.channelTree.deleteClient(client, false);
|
||||
this.channelTree.deleteClient(client, { serverLeave: false, reason: ViewReasonId.VREASON_SYSTEM });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -138,7 +138,12 @@ export class ClientConnectionInfo {
|
|||
|
||||
export interface ClientEvents extends ChannelTreeEntryEvents {
|
||||
"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: {
|
||||
updated_properties: {[Key in keyof ClientProperties]: ClientProperties[Key]};
|
||||
|
@ -496,17 +501,11 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
|
|||
|
||||
open_text_chat() {
|
||||
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: true,
|
||||
create: true
|
||||
});
|
||||
chat.private_conversations().set_selected_conversation(conversation);
|
||||
const conversation = chat.private_conversations().findOrCreateConversation(this);
|
||||
conversation.setActiveClientEntry(this);
|
||||
chat.private_conversations().setActiveConversation(conversation);
|
||||
chat.show_private_conversations();
|
||||
chat.private_conversations().try_input_focus();
|
||||
chat.private_conversations().focusInput();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if(variable.key == "client_unique_identifier") {
|
||||
|
@ -798,14 +786,9 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
|
|||
if(client_info.current_client() === this)
|
||||
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();
|
||||
const conversation = conversations.find_conversation({name: this.clientNickName(), unique_id: this.clientUid(), client_id: this.clientId()}, {create: false, attach: false});
|
||||
if(conversation)
|
||||
conversation.update_avatar();
|
||||
}
|
||||
if(update_avatar)
|
||||
this.channelTree.client.fileManager.avatars.update_cache(this.avatarId(), this.properties.client_flag_avatar);
|
||||
|
||||
/* devel-block(log-client-property-updates) */
|
||||
group.end();
|
||||
|
|
|
@ -3,6 +3,8 @@ import {settings, Settings} from "tc-shared/settings";
|
|||
import * as log from "tc-shared/log";
|
||||
import {bbcode} from "tc-shared/MessageFormatter";
|
||||
import * as loader from "tc-loader";
|
||||
import { XBBCodeRenderer } from "vendor/xbbcode/react";
|
||||
import * as React from "react";
|
||||
|
||||
export enum ChatType {
|
||||
GENERAL,
|
||||
|
@ -142,6 +144,7 @@ export function bbcode_chat(message: string) : JQuery[] {
|
|||
});
|
||||
}
|
||||
|
||||
|
||||
export namespace network {
|
||||
export const KB = 1024;
|
||||
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.text("\n" +
|
||||
".message > .emoji {\n" +
|
||||
".chat-emoji {\n" +
|
||||
" height: " + size + "!important;\n" +
|
||||
" width: " + size + "!important;\n" +
|
||||
"}\n"
|
||||
|
|
|
@ -5,10 +5,11 @@ import {ChannelEntry} from "tc-shared/ui/channel";
|
|||
import {ServerEntry} from "tc-shared/ui/server";
|
||||
import {openMusicManage} from "tc-shared/ui/modal/ModalMusicManage";
|
||||
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 {MusicInfo} from "tc-shared/ui/frames/side/music_info";
|
||||
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 setTimeout(handler: TimerHandler, timeout?: number, ...arguments: any[]): number;
|
||||
|
@ -64,14 +65,10 @@ export class InfoFrame {
|
|||
const selected_client = this.handle.client_info().current_client();
|
||||
if(!selected_client) return;
|
||||
|
||||
const conversation = selected_client ? this.handle.private_conversations().find_conversation({
|
||||
name: selected_client.properties.client_nickname,
|
||||
unique_id: selected_client.properties.client_unique_identifier,
|
||||
client_id: selected_client.clientId()
|
||||
}, { create: true, attach: true }) : undefined;
|
||||
const conversation = selected_client ? this.handle.private_conversations().findOrCreateConversation(selected_client) : undefined;
|
||||
if(!conversation) return;
|
||||
|
||||
this.handle.private_conversations().set_selected_conversation(conversation);
|
||||
this.handle.private_conversations().setActiveConversation(conversation);
|
||||
this.handle.show_private_conversations();
|
||||
})[0];
|
||||
|
||||
|
@ -214,9 +211,9 @@ export class InfoFrame {
|
|||
}
|
||||
|
||||
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_tag = count_container.find(".chat-unread-counter");
|
||||
count_container.toggle(count > 0);
|
||||
|
@ -224,12 +221,12 @@ export class InfoFrame {
|
|||
}
|
||||
{
|
||||
const count_tag = this._html_tag.find(".chat-counter");
|
||||
if(conversations.length == 0)
|
||||
if(privateConversations.length == 0)
|
||||
count_tag.text(tr("No conversations"));
|
||||
else if(conversations.length == 1)
|
||||
else if(privateConversations.length == 1)
|
||||
count_tag.text(tr("One conversation"));
|
||||
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) {
|
||||
//Will be called every time a client is shown
|
||||
const selected_client = this.handle.client_info().current_client();
|
||||
const conversation = selected_client ? this.handle.private_conversations().find_conversation({
|
||||
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 conversation = selected_client ? this.handle.private_conversations().findConversation(selected_client) : undefined;
|
||||
|
||||
const visibility = (selected_client && selected_client.clientId() !== this.handle.handle.getClientId()) ? "visible" : "hidden";
|
||||
if(this._button_conversation.style.visibility !== visibility)
|
||||
|
@ -281,17 +274,17 @@ export class Frame {
|
|||
private _container_chat: JQuery;
|
||||
private _content_type: FrameContent;
|
||||
|
||||
private _conversations: PrivateConverations;
|
||||
private _client_info: ClientInfo;
|
||||
private _music_info: MusicInfo;
|
||||
private _channel_conversations: ConversationManager;
|
||||
private _private_conversations: PrivateConversationManager;
|
||||
|
||||
constructor(handle: ConnectionHandler) {
|
||||
this.handle = handle;
|
||||
|
||||
this._content_type = FrameContent.NONE;
|
||||
this._info_frame = new InfoFrame(this);
|
||||
this._conversations = new PrivateConverations(this);
|
||||
this._private_conversations = new PrivateConversationManager(handle);
|
||||
this._channel_conversations = new ConversationManager(handle);
|
||||
this._client_info = new ClientInfo(this);
|
||||
this._music_info = new MusicInfo(this);
|
||||
|
@ -313,17 +306,17 @@ export class Frame {
|
|||
this._info_frame && this._info_frame.destroy();
|
||||
this._info_frame = undefined;
|
||||
|
||||
this._conversations && this._conversations.destroy();
|
||||
this._conversations = undefined;
|
||||
|
||||
this._client_info && this._client_info.destroy();
|
||||
this._client_info = undefined;
|
||||
|
||||
this._music_info && this._music_info.destroy();
|
||||
this._music_info = undefined;
|
||||
|
||||
//this._channel_conversations && this._channel_conversations.destroy();
|
||||
//this._channel_conversations = undefined;
|
||||
this._private_conversations && this._private_conversations.destroy();
|
||||
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 = undefined;
|
||||
|
@ -341,8 +334,8 @@ export class Frame {
|
|||
}
|
||||
|
||||
|
||||
private_conversations() : PrivateConverations {
|
||||
return this._conversations;
|
||||
private_conversations() : PrivateConversationManager {
|
||||
return this._private_conversations;
|
||||
}
|
||||
|
||||
channel_conversations() : ConversationManager {
|
||||
|
@ -365,10 +358,11 @@ export class Frame {
|
|||
show_private_conversations() {
|
||||
if(this._content_type === FrameContent.PRIVATE_CHAT)
|
||||
return;
|
||||
|
||||
this._clear();
|
||||
this._content_type = FrameContent.PRIVATE_CHAT;
|
||||
this._container_chat.append(this._conversations.html_tag());
|
||||
this._conversations.on_show();
|
||||
this._container_chat.append(this._private_conversations.htmlTag);
|
||||
this._private_conversations.handlePanelShow();
|
||||
this._info_frame.set_mode(InfoFrameMode.PRIVATE_CHAT);
|
||||
}
|
||||
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
@import "../../../../css/static/properties";
|
||||
|
||||
.container {
|
||||
@include user-select(none);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
|
|
|
@ -15,7 +15,8 @@ interface ChatBoxEvents {
|
|||
message: string
|
||||
},
|
||||
action_insert_text: {
|
||||
text: string
|
||||
text: string,
|
||||
focus?: boolean
|
||||
},
|
||||
|
||||
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_submit_message", () => setShown(false));
|
||||
|
||||
return (
|
||||
<div className={cssStyle.containerEmojis} ref={refContainer}>
|
||||
|
@ -65,7 +67,7 @@ const EmojiButton = (props: { events: Registry<ChatBoxEvents> }) => {
|
|||
|
||||
onSelect={(emoji: any) => {
|
||||
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);
|
||||
});
|
||||
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 => {
|
||||
setEnabled(event.enabled);
|
||||
if(!event.enabled) {
|
||||
|
@ -263,7 +269,7 @@ export interface ChatBoxState {
|
|||
export class ChatBox extends React.Component<ChatBoxProperties, ChatBoxState> {
|
||||
readonly events = new Registry<ChatBoxEvents>();
|
||||
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) {
|
||||
super(props);
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -1,5 +1,4 @@
|
|||
import * as React from "react";
|
||||
import {ConversationPanel} from "tc-shared/ui/frames/side/Conversations";
|
||||
import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler";
|
||||
import {EventHandler, Registry} from "tc-shared/events";
|
||||
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 {ServerCommand} from "tc-shared/connection/ConnectionBase";
|
||||
import {Settings} from "tc-shared/settings";
|
||||
import ReactDOM = require("react-dom");
|
||||
import {traj} from "tc-shared/i18n/localize";
|
||||
import {tra, traj} from "tc-shared/i18n/localize";
|
||||
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 {
|
||||
timestamp: number;
|
||||
message: string;
|
||||
export abstract class AbstractChat<Events extends ConversationUIEvents> {
|
||||
protected readonly connection: ConnectionHandler;
|
||||
protected readonly chatId: string;
|
||||
protected readonly events: Registry<Events>;
|
||||
protected presentMessages: ChatEvent[] = [];
|
||||
protected presentEvents: Exclude<ChatEvent, ChatEventMessage>[] = []; /* everything excluding chat messages */
|
||||
|
||||
sender_name: string;
|
||||
sender_unique_id: string;
|
||||
sender_database_id: number;
|
||||
}
|
||||
protected mode: ChatState = "unloaded";
|
||||
protected failedPermission: string;
|
||||
protected errorMessage: string;
|
||||
|
||||
export interface ChatEventMessage {
|
||||
type: "message";
|
||||
message: ChatMessage;
|
||||
}
|
||||
protected conversationPrivate: boolean = false;
|
||||
protected crossChannelChatSupported: boolean = true;
|
||||
|
||||
export interface ChatEventMessageSendFailed {
|
||||
type: "message-failed";
|
||||
protected unreadTimestamp: number | undefined = undefined;
|
||||
protected lastReadMessage: number = 0;
|
||||
|
||||
error: "permission" | "error";
|
||||
failedPermission?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
protected historyErrorMessage: string;
|
||||
protected historyRetryTimestamp: number = 0;
|
||||
protected executingUIHistoryQuery = false;
|
||||
|
||||
export interface ConversationUIEvents {
|
||||
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 },
|
||||
protected messageSendEnabled: boolean = true;
|
||||
|
||||
query_conversation_state: { chatId: number }, /* will cause a notify_conversation_state */
|
||||
notify_conversation_state: {
|
||||
id: number,
|
||||
protected hasHistory = false;
|
||||
|
||||
mode: "normal" | "no-permissions" | "error" | "loading" | "private",
|
||||
failedPermission?: string,
|
||||
errorMessage?: string;
|
||||
|
||||
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
|
||||
protected constructor(connection: ConnectionHandler, chatId: string, events: Registry<Events>) {
|
||||
this.connection = connection;
|
||||
this.events = events;
|
||||
this.chatId = chatId;
|
||||
}
|
||||
|
||||
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 {
|
||||
status: "success" | "error" | "no-permission" | "private";
|
||||
export abstract class AbstractChatManager<Events extends ConversationUIEvents> {
|
||||
protected readonly uiEvents: Registry<Events>;
|
||||
|
||||
messages?: ChatMessage[];
|
||||
moreMessages?: boolean;
|
||||
protected constructor() {
|
||||
this.uiEvents = new Registry<Events>();
|
||||
}
|
||||
|
||||
errorMessage?: string;
|
||||
failedPermission?: string;
|
||||
handlePanelShow() {
|
||||
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";
|
||||
export class Conversation {
|
||||
const kSuccessQueryThrottle = 5 * 1000;
|
||||
const kErrorQueryThrottle = 30 * 1000;
|
||||
export class Conversation extends AbstractChat<ConversationUIEvents> {
|
||||
private readonly handle: ConversationManager;
|
||||
private readonly events: Registry<ConversationUIEvents>;
|
||||
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 queryingHistory = false;
|
||||
private executingHistoryQueries = false;
|
||||
private pendingHistoryQueries: (() => Promise<any>)[] = [];
|
||||
public historyQueryResponse: ChatMessage[] = [];
|
||||
|
||||
constructor(handle: ConversationManager, events: Registry<ConversationUIEvents>, id: number) {
|
||||
super(handle.connection, id.toString(), events);
|
||||
this.handle = handle;
|
||||
this.conversationId = id;
|
||||
this.events = events;
|
||||
|
||||
this.lastReadMessage = handle.connection.settings.server(Settings.FN_CHANNEL_CHAT_READ(id), Date.now());
|
||||
}
|
||||
|
||||
destroy() { }
|
||||
|
||||
currentMode() : ConversationMode { return this.mode; }
|
||||
|
||||
queryHistory(criteria: { begin?: number, end?: number, limit?: number }) : Promise<ConversationHistoryResponse> {
|
||||
return new Promise<ConversationHistoryResponse>(resolve => {
|
||||
this.pendingHistoryQueries.push(() => {
|
||||
|
@ -141,22 +431,46 @@ export class Conversation {
|
|||
message_count: criteria.limit,
|
||||
timestamp_end: criteria.end
|
||||
}, { 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 => {
|
||||
let errorMessage;
|
||||
if(error instanceof CommandResult) {
|
||||
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;
|
||||
} else if(error.id === ErrorID.PERMISSION_ERROR) {
|
||||
resolve({
|
||||
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;
|
||||
} else if(error.id === ErrorID.CONVERSATION_IS_PRIVATE) {
|
||||
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;
|
||||
} else {
|
||||
|
@ -168,7 +482,8 @@ export class Conversation {
|
|||
}
|
||||
resolve({
|
||||
status: "error",
|
||||
errorMessage: errorMessage
|
||||
errorMessage: errorMessage,
|
||||
nextAllowedQuery: Date.now() + 5 * 1000
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -182,17 +497,23 @@ export class Conversation {
|
|||
this.mode = "loading";
|
||||
|
||||
this.reportStateToUI();
|
||||
this.queryHistory({ end: 1, limit: 50 }).then(history => {
|
||||
this.queryHistory({ end: 1, limit: kMaxChatFrameMessageSize }).then(history => {
|
||||
this.conversationPrivate = false;
|
||||
this.conversationVolatile = false;
|
||||
this.failedPermission = 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) {
|
||||
case "error":
|
||||
this.mode = "error";
|
||||
this.errorMessage = history.errorMessage;
|
||||
this.mode = "normal";
|
||||
this.presentEvents.push({
|
||||
type: "query-failed",
|
||||
timestamp: Date.now(),
|
||||
uniqueId: "qf-" + this.conversationId + "-" + Date.now() + "-" + Math.random(),
|
||||
message: history.errorMessage
|
||||
});
|
||||
break;
|
||||
|
||||
case "no-permission":
|
||||
|
@ -201,12 +522,19 @@ export class Conversation {
|
|||
break;
|
||||
|
||||
case "private":
|
||||
this.conversationPrivate = true;
|
||||
this.mode = "normal";
|
||||
break;
|
||||
|
||||
case "success":
|
||||
this.mode = "normal";
|
||||
break;
|
||||
|
||||
case "unsupported":
|
||||
this.crossChannelChatSupported = false;
|
||||
this.conversationPrivate = true;
|
||||
this.mode = "normal";
|
||||
break;
|
||||
}
|
||||
|
||||
/* 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() {
|
||||
if(this.queryingHistory)
|
||||
if(this.executingHistoryQueries || this.pendingHistoryQueries.length === 0)
|
||||
return;
|
||||
|
||||
this.queryingHistory = true;
|
||||
this.executingHistoryQueries = true;
|
||||
try {
|
||||
const promise = this.pendingHistoryQueries.pop_front()();
|
||||
promise
|
||||
.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) {
|
||||
this.queryingHistory = false;
|
||||
this.executingHistoryQueries = false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public updateIndexFromServer(info: any) {
|
||||
if('error_id' in info) {
|
||||
/* FIXME: Parse error, may be flag private or similar */
|
||||
/* TODO: Parse error, may be flag private or similar */
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -249,34 +581,18 @@ export class Conversation {
|
|||
}
|
||||
}
|
||||
|
||||
public handleIncomingMessage(message: ChatMessage, triggerUnread: boolean) {
|
||||
let index = 0;
|
||||
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 handleIncomingMessage(message: ChatMessage, isOwnMessage: boolean) {
|
||||
this.registerIncomingMessage(message, isOwnMessage, "m-" + this.conversationId + "-" + message.timestamp + "-" + Math.random());
|
||||
}
|
||||
|
||||
public handleDeleteMessages(criteria: { begin: number, end: number, cldbid: number, limit: number }) {
|
||||
let limit = { current: criteria.limit };
|
||||
|
||||
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;
|
||||
|
||||
if(criteria.end != 0 && message.timestamp > criteria.end)
|
||||
|
@ -288,29 +604,7 @@ export class Conversation {
|
|||
return --limit.current < 0;
|
||||
});
|
||||
|
||||
this.events.fire("notify_chat_message_delete", { conversation: this.conversationId, 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()
|
||||
});
|
||||
});
|
||||
this.events.fire("notify_chat_message_delete", { chatId: this.conversationId.toString(), criteria: criteria });
|
||||
}
|
||||
|
||||
public deleteMessage(messageUniqueId: string) {
|
||||
|
@ -320,12 +614,15 @@ export class Conversation {
|
|||
return;
|
||||
}
|
||||
|
||||
if(message.type !== "message")
|
||||
return;
|
||||
|
||||
this.handle.connection.serverConnection.send_command("conversationmessagedelete", {
|
||||
cid: this.conversationId,
|
||||
timestamp_begin: message.timestamp - 1,
|
||||
timestamp_end: message.timestamp + 1,
|
||||
limit: 1,
|
||||
cldbid: message.sender_database_id
|
||||
cldbid: message.message.sender_database_id
|
||||
}, { process_result: false }).catch(error => {
|
||||
log.error(LogCategory.CHAT, tr("Failed to delete conversation message for conversation %d: %o"), this.conversationId, error);
|
||||
if(error instanceof CommandResult)
|
||||
|
@ -335,48 +632,40 @@ export class Conversation {
|
|||
});
|
||||
}
|
||||
|
||||
public reportStateToUI() {
|
||||
this.events.fire_async("notify_conversation_state", {
|
||||
id: this.conversationId,
|
||||
mode: this.mode === "unloaded" ? "loading" : this.mode,
|
||||
unreadTimestamp: this.unreadTimestamp,
|
||||
haveOlderMessages: false,
|
||||
failedPermission: this.failedPermission,
|
||||
conversationPrivate: this.conversationPrivate,
|
||||
setUnreadTimestamp(timestamp: number | undefined) {
|
||||
super.setUnreadTimestamp(timestamp);
|
||||
|
||||
events: [...this.presentEvents, ...this.presentMessages.map(e => {
|
||||
return {
|
||||
timestamp: e.timestamp,
|
||||
uniqueId: "m-" + e.timestamp,
|
||||
type: "message",
|
||||
message: e
|
||||
} as ChatEvent;
|
||||
})]
|
||||
});
|
||||
/* we've to update the last read timestamp regardless of if we're having actual unread stuff */
|
||||
this.handle.connection.settings.changeServer(Settings.FN_CHANNEL_CHAT_READ(this.conversationId), typeof timestamp === "number" ? timestamp : Date.now());
|
||||
}
|
||||
|
||||
public setUnreadTimestamp(timestamp: number | undefined) {
|
||||
if(this.unreadTimestamp === timestamp)
|
||||
return;
|
||||
public localClientSwitchedChannel(type: "join" | "leave") {
|
||||
this.presentEvents.push({
|
||||
type: "local-user-switch",
|
||||
uniqueId: "us-" + this.conversationId + "-" + Date.now() + "-" + Math.random(),
|
||||
timestamp: Date.now(),
|
||||
mode: type
|
||||
});
|
||||
|
||||
this.unreadTimestamp = timestamp;
|
||||
this.handle.connection.settings.changeServer(Settings.FN_CHANNEL_CHAT_READ(this.conversationId), typeof timestamp === "number" ? timestamp : Date.now());
|
||||
this.events.fire_async("notify_unread_timestamp_changed", { conversation: this.conversationId, timestamp: timestamp });
|
||||
if(this.conversationId === this.handle.selectedConversation())
|
||||
this.reportStateToUI();
|
||||
}
|
||||
|
||||
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 htmlTag: HTMLDivElement;
|
||||
|
||||
private readonly uiEvents: Registry<ConversationUIEvents>;
|
||||
|
||||
private conversations: {[key: number]: Conversation} = {};
|
||||
private selectedConversation_: number;
|
||||
|
||||
constructor(connection: ConnectionHandler) {
|
||||
super();
|
||||
this.connection = connection;
|
||||
this.uiEvents = new Registry<ConversationUIEvents>();
|
||||
|
||||
this.htmlTag = document.createElement("div");
|
||||
this.htmlTag.style.display = "flex";
|
||||
|
@ -384,18 +673,33 @@ export class ConversationManager {
|
|||
this.htmlTag.style.justifyContent = "stretch";
|
||||
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 => {
|
||||
if(ConnectionState.socketConnected(event.old_state) !== ConnectionState.socketConnected(event.new_state)) {
|
||||
this.conversations = {};
|
||||
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("notifyconversationindex", this.handleConversationIndex.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() {
|
||||
ReactDOM.unmountComponentAtNode(this.htmlTag);
|
||||
this.htmlTag.remove();
|
||||
|
||||
this.uiEvents.unregister_handler(this);
|
||||
this.uiEvents.fire("notify_destroy");
|
||||
this.uiEvents.destroy();
|
||||
|
@ -422,7 +729,12 @@ export class ConversationManager {
|
|||
setSelectedConversation(id: number) {
|
||||
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 {
|
||||
|
@ -432,6 +744,10 @@ export class ConversationManager {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
protected findChat(id: string): AbstractChat<ConversationUIEvents> {
|
||||
return this.findConversation(parseInt(id));
|
||||
}
|
||||
|
||||
findOrCreateConversation(id: number) {
|
||||
let conversation = this.findConversation(id);
|
||||
if(!conversation) {
|
||||
|
@ -445,10 +761,8 @@ export class ConversationManager {
|
|||
destroyConversation(id: number) {
|
||||
delete this.conversations[id];
|
||||
|
||||
if(id === this.selectedConversation_) {
|
||||
this.uiEvents.fire("action_select_conversation", { chatId: -1 });
|
||||
this.selectedConversation_ = -1;
|
||||
}
|
||||
if(id === this.selectedConversation_)
|
||||
this.uiEvents.fire("action_select_chat", { chatId: "unselected" });
|
||||
}
|
||||
|
||||
queryUnreadFlags() {
|
||||
|
@ -459,10 +773,6 @@ export class ConversationManager {
|
|||
});
|
||||
}
|
||||
|
||||
handlePanelShow() {
|
||||
this.uiEvents.fire("notify_panel_show");
|
||||
}
|
||||
|
||||
private handleConversationHistory(command: ServerCommand) {
|
||||
const conversation = this.findConversation(parseInt(command.arguments[0]["cid"]));
|
||||
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")
|
||||
private handleMessageDelete(event: ConversationUIEvents["action_delete_message"]) {
|
||||
const conversation = this.findConversation(event.chatId);
|
||||
const conversation = this.findConversation(parseInt(event.chatId));
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
|
@ -14,6 +14,8 @@ $bot_thumbnail_height: 9em;
|
|||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
min-width: 250px;
|
||||
|
||||
position: relative;
|
||||
|
||||
.containerMessages {
|
||||
|
@ -25,6 +27,7 @@ $bot_thumbnail_height: 9em;
|
|||
justify-content: stretch;
|
||||
|
||||
min-height: 2em;
|
||||
padding-bottom: .5em;
|
||||
|
||||
position: relative;
|
||||
|
||||
|
@ -57,6 +60,72 @@ $bot_thumbnail_height: 9em;
|
|||
@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;
|
||||
|
||||
border-radius: 50%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -138,7 +211,6 @@ $bot_thumbnail_height: 9em;
|
|||
|
||||
.timestamp {
|
||||
display: inline;
|
||||
margin-left: .5em;
|
||||
|
||||
font-size: 0.66em;
|
||||
color: #5d5b5b;
|
||||
|
@ -177,6 +249,65 @@ $bot_thumbnail_height: 9em;
|
|||
font-weight: bold;
|
||||
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 {
|
||||
|
@ -198,6 +329,8 @@ $bot_thumbnail_height: 9em;
|
|||
}
|
||||
|
||||
.containerTimestamp {
|
||||
margin-left: 2.5em;
|
||||
|
||||
color: #565353;
|
||||
text-align: center;
|
||||
}
|
||||
|
@ -225,7 +358,77 @@ $bot_thumbnail_height: 9em;
|
|||
}
|
||||
|
||||
.containerUnread {
|
||||
margin-left: 3em;
|
||||
margin-right: .5em;
|
||||
|
||||
text-align: center;
|
||||
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%);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
<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>}
|
||||
|
||||
{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>
|
||||
|
||||
({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>
|
||||
|
||||
{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 now chatting with</Translatable>
|
||||
|
||||
<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>
|
||||
});
|
|
@ -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>
|
||||
};
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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 dont have any chats 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>
|
||||
);
|
|
@ -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, ' ');
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
|
@ -135,14 +135,6 @@ export namespace helpers {
|
|||
"ins_open": () => "[u]",
|
||||
"ins_close": () => "[/u]",
|
||||
|
||||
/*
|
||||
```
|
||||
test
|
||||
[/code]
|
||||
test
|
||||
```
|
||||
*/
|
||||
|
||||
"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]",
|
||||
|
||||
|
@ -254,45 +246,6 @@ test
|
|||
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 function same_day(a: number | Date, b: number | Date) {
|
||||
a = a instanceof Date ? a : new Date(a);
|
||||
|
@ -315,20 +268,32 @@ export namespace format {
|
|||
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 {
|
||||
if(!ignore_settings && !settings.static_global(Settings.KEY_CHAT_COLLOQUIAL_TIMESTAMPS))
|
||||
return ColloquialFormat.GENERAL;
|
||||
|
||||
let delta_day = now.getDate() - date.getDate();
|
||||
if(delta_day < 1) /* month change? */
|
||||
delta_day = date.getDate() - now.getDate();
|
||||
if(delta_day == 0)
|
||||
if(dateEqual(date, now))
|
||||
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.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 {
|
||||
return ('00' + date.getDate()).substr(-2) + "."
|
||||
+ ('00' + date.getMonth()).substr(-2) + "."
|
||||
|
@ -354,7 +319,7 @@ export namespace format {
|
|||
time = "PM";
|
||||
}
|
||||
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
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -190,7 +190,6 @@ export namespace callbacks {
|
|||
}
|
||||
window[callback_object_id] = callbacks;
|
||||
|
||||
declare const xbbcode;
|
||||
namespace bbcodes {
|
||||
/* 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;
|
||||
|
@ -198,6 +197,8 @@ namespace bbcodes {
|
|||
const url_channel_regex = /channel:\/\/([0-9]+)~((?:[^%]|%[0-9A-Fa-f]{2})+)$/g;
|
||||
|
||||
function initialize() {
|
||||
/* FIXME: Reimplement client BB codes */
|
||||
/*
|
||||
const origin_url = xbbcode.register.find_parser('url');
|
||||
xbbcode.register.register_parser({
|
||||
tag: 'url',
|
||||
|
@ -234,6 +235,7 @@ namespace bbcodes {
|
|||
return origin_url.build_html_tag_close(layer);
|
||||
}
|
||||
});
|
||||
*/
|
||||
}
|
||||
initialize();
|
||||
}
|
|
@ -243,8 +243,8 @@ export function spawnClientVolumeChange(client: ClientEntry) {
|
|||
return <VolumeChangeModal remote={false} clientName={client.clientNickName()} events={events} />;
|
||||
}
|
||||
|
||||
title(): string {
|
||||
return tr("Change local volume");
|
||||
title() {
|
||||
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} />;
|
||||
}
|
||||
|
||||
title(): string {
|
||||
return tr("Change remote volume");
|
||||
title() {
|
||||
return <Translatable>Change remote volume</Translatable>;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -269,8 +269,8 @@ class ModalGroupCreate extends Modal {
|
|||
</div>;
|
||||
}
|
||||
|
||||
title(): string {
|
||||
return this.target === "server" ? tr("Create a new server group") : tr("Create a new channel group");
|
||||
title() {
|
||||
return this.target === "server" ? <Translatable>Create a new server group</Translatable> : <Translatable>Create a new channel group</Translatable>;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -156,8 +156,8 @@ class ModalGroupPermissionCopy extends Modal {
|
|||
</div>;
|
||||
}
|
||||
|
||||
title(): string {
|
||||
return tr("Copy group permissions");
|
||||
title() {
|
||||
return <Translatable>Copy group permissions</Translatable>;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -321,8 +321,8 @@ class PermissionEditorModal extends Modal {
|
|||
);
|
||||
}
|
||||
|
||||
title(): string {
|
||||
return tr("Server permissions");
|
||||
title() : React.ReactElement<Translatable> {
|
||||
return <Translatable>Server permissions</Translatable>;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
import {initializeRemoteFileBrowserController} from "tc-shared/ui/modal/transfer/RemoteFileBrowserController";
|
||||
import {ChannelEntry} from "tc-shared/ui/channel";
|
||||
import {initializeTransferInfoController} from "tc-shared/ui/modal/transfer/TransferInfoController";
|
||||
import {Translatable} from "tc-shared/ui/react-elements/i18n";
|
||||
|
||||
const cssStyle = require("./ModalFileTransfer.scss");
|
||||
export const channelPathPrefix = tr("Channel") + " ";
|
||||
|
@ -206,8 +207,8 @@ class FileTransferModal extends Modal {
|
|||
this.transferInfoEvents.fire("notify_modal_closed");
|
||||
}
|
||||
|
||||
title(): string {
|
||||
return tr("File Browser");
|
||||
title() {
|
||||
return <Translatable>File Browser</Translatable>;
|
||||
}
|
||||
|
||||
renderBody() {
|
||||
|
|
|
@ -1,27 +1,54 @@
|
|||
import * as React from "react";
|
||||
import {ClientAvatar} from "tc-shared/file/Avatars";
|
||||
import {useState} from "react";
|
||||
import * as image_preview from "tc-shared/ui/frames/image_preview";
|
||||
|
||||
const ImageStyle = { height: "100%", width: "100%" };
|
||||
export const AvatarRenderer = (props: { avatar: ClientAvatar, className?: string }) => {
|
||||
const ImageStyle = { height: "100%", width: "100%", cursor: "pointer" };
|
||||
export const AvatarRenderer = React.memo((props: { avatar: ClientAvatar, className?: string, alt?: string }) => {
|
||||
let [ revision, setRevision ] = useState(0);
|
||||
|
||||
let image;
|
||||
switch (props.avatar.state) {
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -32,4 +59,4 @@ export const AvatarRenderer = (props: { avatar: ClientAvatar, className?: string
|
|||
{image}
|
||||
</div>
|
||||
)
|
||||
};
|
||||
});
|
|
@ -14,8 +14,8 @@ $animation_separator_length: .1s;
|
|||
|
||||
&.vertical {
|
||||
height: $separator_thickness;
|
||||
min-height: $separator_thickness!important;
|
||||
max-height: $separator_thickness!important;
|
||||
min-height: $separator_thickness;
|
||||
max-height: $separator_thickness;
|
||||
|
||||
//width: 100%;
|
||||
cursor: row-resize;
|
||||
|
@ -23,8 +23,8 @@ $animation_separator_length: .1s;
|
|||
|
||||
&.horizontal {
|
||||
width: $separator_thickness;
|
||||
min-width: $separator_thickness!important;
|
||||
max-width: $separator_thickness!important;
|
||||
min-width: $separator_thickness;
|
||||
max-width: $separator_thickness;
|
||||
|
||||
//height: 100%;
|
||||
cursor: col-resize;
|
||||
|
|
|
@ -86,13 +86,16 @@ export class ContextDivider extends React.Component<ContextDividerProperties, Co
|
|||
}
|
||||
|
||||
render() {
|
||||
let separatorClassNames = cssStyle.separator + " " + (this.props.separatorClassName || "");
|
||||
let separatorClassNames = cssStyle.separator;
|
||||
|
||||
if(this.props.direction === "vertical")
|
||||
separatorClassNames += " " + cssStyle.vertical;
|
||||
else
|
||||
separatorClassNames += " " + cssStyle.horizontal;
|
||||
|
||||
if(this.props.separatorClassName)
|
||||
separatorClassNames += " " + this.props.separatorClassName;
|
||||
|
||||
if(this.state.active && this.props.separatorClassName)
|
||||
separatorClassNames += " " + this.props.separatorClassName;
|
||||
|
||||
|
@ -145,7 +148,6 @@ export class ContextDivider extends React.Component<ContextDividerProperties, Co
|
|||
|
||||
if(this.props.direction === "horizontal") {
|
||||
const center = this.refSeparator.current.clientWidth;
|
||||
|
||||
previousElement.style.width = `calc(${this.value}% - ${center / 2}px)`;
|
||||
nextElement.style.width = `calc(${100 - this.value}% - ${center / 2}px)`;
|
||||
} else {
|
||||
|
|
|
@ -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)}</>;
|
||||
};
|
|
@ -2,18 +2,19 @@ import {useEffect, useState} from "react";
|
|||
import * as React from "react";
|
||||
|
||||
export const LoadingDots = (props: { maxDots?: number, speed?: number }) => {
|
||||
if(!props.maxDots || props.maxDots < 1)
|
||||
props.maxDots = 3;
|
||||
let { maxDots, speed } = props;
|
||||
if(!maxDots || maxDots < 1)
|
||||
maxDots = 3;
|
||||
|
||||
const [dots, setDots] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setDots(dots + 1), props.speed || 500);
|
||||
const timeout = setTimeout(() => setDots(dots + 1), speed || 500);
|
||||
return () => clearTimeout(timeout);
|
||||
});
|
||||
|
||||
let result = ".";
|
||||
for(let index = 0; index < dots % props.maxDots; index++)
|
||||
for(let index = 0; index < dots % maxDots; index++)
|
||||
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>;
|
||||
};
|
|
@ -15,7 +15,7 @@ html:root {
|
|||
padding-right: 5%;
|
||||
padding-left: 5%;
|
||||
|
||||
z-index: 1000;
|
||||
z-index: 100000;
|
||||
position: fixed;
|
||||
|
||||
top: 0;
|
||||
|
|
|
@ -2,6 +2,7 @@ import * as React from "react";
|
|||
import * as ReactDOM from "react-dom";
|
||||
import {ReactElement} from "react";
|
||||
import {Registry} from "tc-shared/events";
|
||||
import {Translatable} from "tc-shared/ui/react-elements/i18n";
|
||||
|
||||
const cssStyle = require("./Modal.scss");
|
||||
|
||||
|
@ -106,7 +107,7 @@ export abstract class Modal {
|
|||
|
||||
type() : ModalType { return "none"; }
|
||||
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
|
||||
|
@ -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, 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>;
|
||||
|
|
|
@ -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}</>;
|
||||
};
|
|
@ -1,11 +1,39 @@
|
|||
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) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
translated: /* @tr-ignore */ tr(typeof this.props.children === "string" ? this.props.children : (this.props as any).message)
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
|
@ -181,8 +181,10 @@ export class ServerEntry extends ChannelTreeEntry<ServerEvents> {
|
|||
if(!singleSelect) return;
|
||||
|
||||
if(settings.static_global(Settings.KEY_SWITCH_INSTANT_CHAT)) {
|
||||
this.channelTree.client.side_bar.channel_conversations().setSelectedConversation(0);
|
||||
this.channelTree.client.side_bar.show_channel_conversations();
|
||||
const sidebar = this.channelTree.client.side_bar;
|
||||
sidebar.channel_conversations().findOrCreateConversation(0);
|
||||
sidebar.channel_conversations().setSelectedConversation(0);
|
||||
sidebar.show_channel_conversations();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -83,6 +83,9 @@ export class ChannelTreeView extends ReactComponentBase<ChannelTreeViewPropertie
|
|||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
(window as any).channelTrees = (window as any).channelTrees || [];
|
||||
(window as any).channelTrees.push(this);
|
||||
|
||||
this.resize_observer = new ResizeObserver(entries => {
|
||||
if(entries.length !== 1) {
|
||||
if(entries.length === 0)
|
||||
|
@ -104,6 +107,8 @@ export class ChannelTreeView extends ReactComponentBase<ChannelTreeViewPropertie
|
|||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
(window as any).channelTrees?.remove(this);
|
||||
|
||||
this.resize_observer.disconnect();
|
||||
this.resize_observer = undefined;
|
||||
|
||||
|
@ -111,7 +116,6 @@ export class ChannelTreeView extends ReactComponentBase<ChannelTreeViewPropertie
|
|||
}
|
||||
|
||||
protected initialize() {
|
||||
(window as any).do_tree_update = () => this.handleTreeUpdate();
|
||||
this.listener_client_change = () => this.handleTreeUpdate();
|
||||
this.listener_channel_change = () => this.handleTreeUpdate();
|
||||
this.listener_state_collapsed = () => this.handleTreeUpdate();
|
||||
|
@ -143,7 +147,6 @@ export class ChannelTreeView extends ReactComponentBase<ChannelTreeViewPropertie
|
|||
this.setState({ smoothScroll: true });
|
||||
}, 50);
|
||||
}, 50);
|
||||
console.log("Update scroll!");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -48,6 +48,18 @@ export interface ChannelTreeEvents {
|
|||
notify_entry_move_begin: {},
|
||||
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: {
|
||||
channel: ChannelEntry,
|
||||
channelProperties: ChannelProperties,
|
||||
|
@ -333,7 +345,7 @@ export class ChannelTree {
|
|||
if(channel.clients(false).length !== 0) {
|
||||
log.warn(LogCategory.CHANNEL, tr("Deleting a non empty channel! This could cause some errors."));
|
||||
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;
|
||||
|
@ -464,16 +476,17 @@ export class ChannelTree {
|
|||
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();
|
||||
old_channel?.unregisterClient(client);
|
||||
this.clients.remove(client);
|
||||
|
||||
client.events.fire("notify_left_view", reason);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
//FIXME: Trigger the notify_clients_changed event!
|
||||
const voice_connection = this.client.serverConnection.voice_connection();
|
||||
if(client.get_audio_handle()) {
|
||||
|
@ -501,7 +514,7 @@ export class ChannelTree {
|
|||
return;
|
||||
}
|
||||
|
||||
insertClient(client: ClientEntry, channel: ChannelEntry) : ClientEntry {
|
||||
insertClient(client: ClientEntry, channel: ChannelEntry, reason: { reason: ViewReasonId, isServerJoin: boolean }) : ClientEntry {
|
||||
batch_updates(BatchUpdateType.CHANNEL_TREE);
|
||||
try {
|
||||
let newClient = this.findClient(client.clientId());
|
||||
|
@ -515,26 +528,27 @@ export class ChannelTree {
|
|||
client["_channel"] = channel;
|
||||
channel.registerClient(client);
|
||||
|
||||
this.events.fire("notify_client_enter_view", { client: client, reason: reason.reason, isServerJoin: reason.isServerJoin });
|
||||
return client;
|
||||
} finally {
|
||||
flush_batched_updates(BatchUpdateType.CHANNEL_TREE);
|
||||
}
|
||||
}
|
||||
|
||||
moveClient(client: ClientEntry, channel: ChannelEntry) {
|
||||
moveClient(client: ClientEntry, targetChannel: ChannelEntry) {
|
||||
batch_updates(BatchUpdateType.CHANNEL_TREE);
|
||||
try {
|
||||
let oldChannel = client.currentChannel();
|
||||
oldChannel?.unregisterClient(client);
|
||||
client["_channel"] = channel;
|
||||
channel?.registerClient(client);
|
||||
client["_channel"] = targetChannel;
|
||||
targetChannel?.registerClient(client);
|
||||
|
||||
if(oldChannel) {
|
||||
if(oldChannel)
|
||||
this.client.side_bar.info_frame().update_channel_client_count(oldChannel);
|
||||
}
|
||||
if(channel) {
|
||||
this.client.side_bar.info_frame().update_channel_client_count(channel);
|
||||
}
|
||||
if(targetChannel)
|
||||
this.client.side_bar.info_frame().update_channel_client_count(targetChannel);
|
||||
if(oldChannel && targetChannel)
|
||||
client.events.fire("notify_client_moved", { oldChannel: oldChannel, newChannel: targetChannel });
|
||||
client.speaking = false;
|
||||
} finally {
|
||||
flush_batched_updates(BatchUpdateType.CHANNEL_TREE);
|
||||
|
|
|
@ -26,7 +26,6 @@
|
|||
"shared/generated",
|
||||
"web/declarations/**/*.d.ts",
|
||||
"web/generated/",
|
||||
"web/environment/",
|
||||
"vendor/**/*.ts"
|
||||
"web/environment/"
|
||||
]
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
/node_modules/
|
|
@ -1,3 +0,0 @@
|
|||
LsxEmojiPicker
|
||||
A simple and lightweight emoji picker plugin for jQuery
|
||||
(c) 2018 Lascaux s.r.l.
|
|
@ -1,2 +0,0 @@
|
|||
# lsx-emojipicker
|
||||
A simple emoji picker plugin for jQuery using https://github.com/twitter/twemoji
|
|
@ -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>
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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': '😄'},
|
||||
{'name': 'smiley', 'value': '😃'},
|
||||
{'name': 'grinning', 'value': '😀'},
|
||||
{'name': 'blush', 'value': '😊'},
|
||||
{'name': 'wink', 'value': '😉'},
|
||||
{'name': 'heart-eyes', 'value': '😍'},
|
||||
{'name': 'kissing-heart', 'value': '😘'},
|
||||
{'name': 'kissing-closed-eyes', 'value': '😚'},
|
||||
{'name': 'kissing', 'value': '😗'},
|
||||
{'name': 'kissing-smiling-eyes', 'value': '😙'},
|
||||
{'name': 'stuck-out-tongue-winking-eye', 'value': '😜'},
|
||||
{'name': 'stuck-out-tongue-closed-eyes', 'value': '😝'},
|
||||
{'name': 'stuck-out-tongue', 'value': '😛'},
|
||||
{'name': 'flushed', 'value': '😳'},
|
||||
{'name': 'grin', 'value': '😁'},
|
||||
{'name': 'pensive', 'value': '😔'},
|
||||
{'name': 'satisfied', 'value': '😌'},
|
||||
{'name': 'unamused', 'value': '😒'},
|
||||
{'name': 'disappointed', 'value': '😞'},
|
||||
{'name': 'persevere', 'value': '😣'},
|
||||
{'name': 'cry', 'value': '😢'},
|
||||
{'name': 'joy', 'value': '😂'},
|
||||
{'name': 'sob', 'value': '😭'},
|
||||
{'name': 'sleepy', 'value': '😪'},
|
||||
{'name': 'relieved', 'value': '😥'},
|
||||
{'name': 'cold-sweat', 'value': '😰'},
|
||||
{'name': 'sweat-smile', 'value': '😅'},
|
||||
{'name': 'sweat', 'value': '😓'},
|
||||
{'name': 'weary', 'value': '😩'},
|
||||
{'name': 'tired-face', 'value': '😫'},
|
||||
{'name': 'fearful', 'value': '😨'},
|
||||
{'name': 'scream', 'value': '😱'},
|
||||
{'name': 'angry', 'value': '😠'},
|
||||
{'name': 'rage', 'value': '😡'},
|
||||
{'name': 'triumph', 'value': '😤'},
|
||||
{'name': 'confounded', 'value': '😖'},
|
||||
{'name': 'laughing', 'value': '😆'},
|
||||
{'name': 'yum', 'value': '😋'},
|
||||
{'name': 'mask', 'value': '😷'},
|
||||
{'name': 'sunglasses', 'value': '😎'},
|
||||
{'name': 'sleeping', 'value': '😴'},
|
||||
{'name': 'dizzy-face', 'value': '😵'},
|
||||
{'name': 'astonished', 'value': '😲'},
|
||||
{'name': 'worried', 'value': '😟'},
|
||||
{'name': 'frowning', 'value': '😦'},
|
||||
{'name': 'anguished', 'value': '😧'},
|
||||
{'name': 'smiling-imp', 'value': '😈'},
|
||||
{'name': 'imp', 'value': '👿'},
|
||||
{'name': 'open-mouth', 'value': '😮'},
|
||||
{'name': 'grimacing', 'value': '😬'},
|
||||
{'name': 'neutral-face', 'value': '😐'},
|
||||
{'name': 'confused', 'value': '😕'},
|
||||
{'name': 'hushed', 'value': '😯'},
|
||||
{'name': 'no-mouth', 'value': '😶'},
|
||||
{'name': 'innocent', 'value': '😇'},
|
||||
{'name': 'smirk', 'value': '😏'},
|
||||
{'name': 'expressionless', 'value': '😑'},
|
||||
{'name': 'man-with-gua-pi-mao', 'value': '👲'},
|
||||
{'name': 'man-with-turban', 'value': '👳'},
|
||||
{'name': 'cop', 'value': '👮'},
|
||||
{'name': 'construction-worker', 'value': '👷'},
|
||||
{'name': 'guardsman', 'value': '💂'},
|
||||
{'name': 'baby', 'value': '👶'},
|
||||
{'name': 'boy', 'value': '👦'},
|
||||
{'name': 'girl', 'value': '👧'},
|
||||
{'name': 'man', 'value': '👨'},
|
||||
{'name': 'woman', 'value': '👩'},
|
||||
{'name': 'older-man', 'value': '👴'},
|
||||
{'name': 'older-woman', 'value': '👵'},
|
||||
{'name': 'person-with-blond-hair', 'value': '👱'},
|
||||
{'name': 'angel', 'value': '👼'},
|
||||
{'name': 'princess', 'value': '👸'},
|
||||
{'name': 'smiley-cat', 'value': '😺'},
|
||||
{'name': 'smile-cat', 'value': '😸'},
|
||||
{'name': 'heart-eyes-cat', 'value': '😻'},
|
||||
{'name': 'kissing-cat', 'value': '😽'},
|
||||
{'name': 'smirk-cat', 'value': '😼'},
|
||||
{'name': 'scream-cat', 'value': '🙀'},
|
||||
{'name': 'crying-cat-face', 'value': '😿'},
|
||||
{'name': 'joy-cat', 'value': '😹'},
|
||||
{'name': 'pouting-cat', 'value': '😾'},
|
||||
{'name': 'japanese-ogre', 'value': '👹'},
|
||||
{'name': 'japanese-goblin', 'value': '👺'},
|
||||
{'name': 'see-no-evil', 'value': '🙈'},
|
||||
{'name': 'hear-no-evil', 'value': '🙉'},
|
||||
{'name': 'speak-no-evil', 'value': '🙊'},
|
||||
{'name': 'skull', 'value': '💀'},
|
||||
{'name': 'alien', 'value': '👽'},
|
||||
{'name': 'poop', 'value': '💩'},
|
||||
{'name': 'fire', 'value': '🔥'},
|
||||
{'name': 'sparkles', 'value': '✨'},
|
||||
{'name': 'star2', 'value': '🌟'},
|
||||
{'name': 'dizzy', 'value': '💫'},
|
||||
{'name': 'boom', 'value': '💥'},
|
||||
{'name': 'anger', 'value': '💢'},
|
||||
{'name': 'sweat-drops', 'value': '💦'},
|
||||
{'name': 'droplet', 'value': '💧'},
|
||||
{'name': 'zzz', 'value': '💤'},
|
||||
{'name': 'dash', 'value': '💨'},
|
||||
{'name': 'ear', 'value': '👂'},
|
||||
{'name': 'eyes', 'value': '👀'},
|
||||
{'name': 'nose', 'value': '👃'},
|
||||
{'name': 'tongue', 'value': '👅'},
|
||||
{'name': 'lips', 'value': '👄'},
|
||||
{'name': 'thumbsup', 'value': '👍'},
|
||||
{'name': 'thumbsdown', 'value': '👎'},
|
||||
{'name': 'ok-hand', 'value': '👌'},
|
||||
{'name': 'punch', 'value': '👊'},
|
||||
{'name': 'fist', 'value': '✊'},
|
||||
{'name': 'v', 'value': '✌'},
|
||||
{'name': 'wave', 'value': '👋'},
|
||||
{'name': 'hand', 'value': '✋'},
|
||||
{'name': 'open-hands', 'value': '👐'},
|
||||
{'name': 'point-up-2', 'value': '👆'},
|
||||
{'name': 'point-down', 'value': '👇'},
|
||||
{'name': 'point-right', 'value': '👉'},
|
||||
{'name': 'point-left', 'value': '👈'},
|
||||
{'name': 'raised-hands', 'value': '🙌'},
|
||||
{'name': 'pray', 'value': '🙏'},
|
||||
{'name': 'point-up', 'value': '☝'},
|
||||
{'name': 'clap', 'value': '👏'},
|
||||
{'name': 'muscle', 'value': '💪'},
|
||||
{'name': 'walking', 'value': '🚶'},
|
||||
{'name': 'runner', 'value': '🏃'},
|
||||
{'name': 'dancer', 'value': '💃'},
|
||||
{'name': 'couple', 'value': '👫'},
|
||||
{'name': 'family', 'value': '👪'},
|
||||
{'name': 'two-men-holding-hands', 'value': '👬'},
|
||||
{'name': 'two-women-holding-hands', 'value': '👭'},
|
||||
{'name': 'couplekiss', 'value': '💏'},
|
||||
{'name': 'couple-with-heart', 'value': '💑'},
|
||||
{'name': 'dancers', 'value': '👯'},
|
||||
{'name': 'ok-woman', 'value': '🙆'},
|
||||
{'name': 'no-good', 'value': '🙅'},
|
||||
{'name': 'information-desk-person', 'value': '💁'},
|
||||
{'name': 'raised-hand', 'value': '🙋'},
|
||||
{'name': 'massage', 'value': '💆'},
|
||||
{'name': 'haircut', 'value': '💇'},
|
||||
{'name': 'nail-care', 'value': '💅'},
|
||||
{'name': 'bride-with-veil', 'value': '👰'},
|
||||
{'name': 'person-with-pouting-face', 'value': '🙎'},
|
||||
{'name': 'person-frowning', 'value': '🙍'},
|
||||
{'name': 'bow', 'value': '🙇'},
|
||||
{'name': 'tophat', 'value': '🎩'},
|
||||
{'name': 'crown', 'value': '👑'},
|
||||
{'name': 'womans-hat', 'value': '👒'},
|
||||
{'name': 'athletic-shoe', 'value': '👟'},
|
||||
{'name': 'mans-shoe', 'value': '👞'},
|
||||
{'name': 'sandal', 'value': '👡'},
|
||||
{'name': 'high-heel', 'value': '👠'},
|
||||
{'name': 'boot', 'value': '👢'},
|
||||
{'name': 'shirt', 'value': '👕'},
|
||||
{'name': 'necktie', 'value': '👔'},
|
||||
{'name': 'womans-clothes', 'value': '👚'},
|
||||
{'name': 'dress', 'value': '👗'},
|
||||
{'name': 'running-shirt-with-sash', 'value': '🎽'},
|
||||
{'name': 'jeans', 'value': '👖'},
|
||||
{'name': 'kimono', 'value': '👘'},
|
||||
{'name': 'bikini', 'value': '👙'},
|
||||
{'name': 'briefcase', 'value': '💼'},
|
||||
{'name': 'handbag', 'value': '👜'},
|
||||
{'name': 'pouch', 'value': '👝'},
|
||||
{'name': 'purse', 'value': '👛'},
|
||||
{'name': 'eyeglasses', 'value': '👓'},
|
||||
{'name': 'ribbon', 'value': '🎀'},
|
||||
{'name': 'closed-umbrella', 'value': '🌂'},
|
||||
{'name': 'lipstick', 'value': '💄'},
|
||||
{'name': 'yellow-heart', 'value': '💛'},
|
||||
{'name': 'blue-heart', 'value': '💙'},
|
||||
{'name': 'purple-heart', 'value': '💜'},
|
||||
{'name': 'green-heart', 'value': '💚'},
|
||||
{'name': 'heart', 'value': '❤'},
|
||||
{'name': 'broken-heart', 'value': '💔'},
|
||||
{'name': 'heartpulse', 'value': '💗'},
|
||||
{'name': 'heartbeat', 'value': '💓'},
|
||||
{'name': 'two-hearts', 'value': '💕'},
|
||||
{'name': 'sparkling-heart', 'value': '💖'},
|
||||
{'name': 'revolving-hearts', 'value': '💞'},
|
||||
{'name': 'love-letter', 'value': '💌'},
|
||||
{'name': 'cupid', 'value': '💘'},
|
||||
{'name': 'kiss', 'value': '💋'},
|
||||
{'name': 'ring', 'value': '💍'},
|
||||
{'name': 'gem', 'value': '💎'},
|
||||
{'name': 'bust-in-silhouette', 'value': '👤'},
|
||||
{'name': 'busts-in-silhouette', 'value': '👥'},
|
||||
{'name': 'speech-balloon', 'value': '💬'},
|
||||
{'name': 'feet', 'value': '👣'},
|
||||
{'name': 'thought-balloon', 'value': '💭'}
|
||||
],
|
||||
'nature': [
|
||||
{'name': 'dog', 'value': '🐶'},
|
||||
{'name': 'wolf', 'value': '🐺'},
|
||||
{'name': 'cat', 'value': '🐱'},
|
||||
{'name': 'mouse', 'value': '🐭'},
|
||||
{'name': 'hamster', 'value': '🐹'},
|
||||
{'name': 'rabbit', 'value': '🐰'},
|
||||
{'name': 'frog', 'value': '🐸'},
|
||||
{'name': 'tiger', 'value': '🐯'},
|
||||
{'name': 'koala', 'value': '🐨'},
|
||||
{'name': 'bear', 'value': '🐻'},
|
||||
{'name': 'pig', 'value': '🐷'},
|
||||
{'name': 'pig-nose', 'value': '🐽'},
|
||||
{'name': 'cow', 'value': '🐮'},
|
||||
{'name': 'boar', 'value': '🐗'},
|
||||
{'name': 'monkey-face', 'value': '🐵'},
|
||||
{'name': 'monkey', 'value': '🐒'},
|
||||
{'name': 'horse', 'value': '🐴'},
|
||||
{'name': 'sheep', 'value': '🐑'},
|
||||
{'name': 'elephant', 'value': '🐘'},
|
||||
{'name': 'panda-face', 'value': '🐼'},
|
||||
{'name': 'penguin', 'value': '🐧'},
|
||||
{'name': 'bird', 'value': '🐦'},
|
||||
{'name': 'baby-chick', 'value': '🐤'},
|
||||
{'name': 'hatched-chick', 'value': '🐥'},
|
||||
{'name': 'hatching-chick', 'value': '🐣'},
|
||||
{'name': 'chicken', 'value': '🐔'},
|
||||
{'name': 'snake', 'value': '🐍'},
|
||||
{'name': 'turtle', 'value': '🐢'},
|
||||
{'name': 'bug', 'value': '🐛'},
|
||||
{'name': 'honeybee', 'value': '🐝'},
|
||||
{'name': 'ant', 'value': '🐜'},
|
||||
{'name': 'beetle', 'value': '🐞'},
|
||||
{'name': 'snail', 'value': '🐌'},
|
||||
{'name': 'octopus', 'value': '🐙'},
|
||||
{'name': 'shell', 'value': '🐚'},
|
||||
{'name': 'tropical-fish', 'value': '🐠'},
|
||||
{'name': 'fish', 'value': '🐟'},
|
||||
{'name': 'dolphin', 'value': '🐬'},
|
||||
{'name': 'whale', 'value': '🐳'},
|
||||
{'name': 'whale2', 'value': '🐋'},
|
||||
{'name': 'cow2', 'value': '🐄'},
|
||||
{'name': 'ram', 'value': '🐏'},
|
||||
{'name': 'rat', 'value': '🐀'},
|
||||
{'name': 'water-buffalo', 'value': '🐃'},
|
||||
{'name': 'tiger2', 'value': '🐅'},
|
||||
{'name': 'rabbit2', 'value': '🐇'},
|
||||
{'name': 'dragon', 'value': '🐉'},
|
||||
{'name': 'racehorse', 'value': '🐎'},
|
||||
{'name': 'goat', 'value': '🐐'},
|
||||
{'name': 'rooster', 'value': '🐓'},
|
||||
{'name': 'dog2', 'value': '🐕'},
|
||||
{'name': 'pig2', 'value': '🐖'},
|
||||
{'name': 'mouse2', 'value': '🐁'},
|
||||
{'name': 'ox', 'value': '🐂'},
|
||||
{'name': 'dragon-face', 'value': '🐲'},
|
||||
{'name': 'blowfish', 'value': '🐡'},
|
||||
{'name': 'crocodile', 'value': '🐊'},
|
||||
{'name': 'camel', 'value': '🐫'},
|
||||
{'name': 'dromedary-camel', 'value': '🐪'},
|
||||
{'name': 'leopard', 'value': '🐆'},
|
||||
{'name': 'cat2', 'value': '🐈'},
|
||||
{'name': 'poodle', 'value': '🐩'},
|
||||
{'name': 'paw-prints', 'value': '🐾'},
|
||||
{'name': 'bouquet', 'value': '💐'},
|
||||
{'name': 'cherry-blossom', 'value': '🌸'},
|
||||
{'name': 'tulip', 'value': '🌷'},
|
||||
{'name': 'four-leaf-clover', 'value': '🍀'},
|
||||
{'name': 'rose', 'value': '🌹'},
|
||||
{'name': 'sunflower', 'value': '🌻'},
|
||||
{'name': 'hibiscus', 'value': '🌺'},
|
||||
{'name': 'maple-leaf', 'value': '🍁'},
|
||||
{'name': 'leaves', 'value': '🍃'},
|
||||
{'name': 'fallen-leaf', 'value': '🍂'},
|
||||
{'name': 'herb', 'value': '🌿'},
|
||||
{'name': 'ear-of-rice', 'value': '🌾'},
|
||||
{'name': 'mushroom', 'value': '🍄'},
|
||||
{'name': 'cactus', 'value': '🌵'},
|
||||
{'name': 'palm-tree', 'value': '🌴'},
|
||||
{'name': 'evergreen-tree', 'value': '🌲'},
|
||||
{'name': 'deciduous-tree', 'value': '🌳'},
|
||||
{'name': 'chestnut', 'value': '🌰'},
|
||||
{'name': 'seedling', 'value': '🌱'},
|
||||
{'name': 'blossom', 'value': '🌼'},
|
||||
{'name': 'globe-with-meridians', 'value': '🌐'},
|
||||
{'name': 'sun-with-face', 'value': '🌞'},
|
||||
{'name': 'full-moon-with-face', 'value': '🌝'},
|
||||
{'name': 'new-moon-with-face', 'value': '🌚'},
|
||||
{'name': 'new-moon', 'value': '🌑'},
|
||||
{'name': 'waxing-crescent-moon', 'value': '🌒'},
|
||||
{'name': 'first-quarter-moon', 'value': '🌓'},
|
||||
{'name': 'waxing-gibbous-moon', 'value': '🌔'},
|
||||
{'name': 'full-moon', 'value': '🌕'},
|
||||
{'name': 'waning-gibbous-moon', 'value': '🌖'},
|
||||
{'name': 'last-quarter-moon', 'value': '🌗'},
|
||||
{'name': 'waning-crescent-moon', 'value': '🌘'},
|
||||
{'name': 'last-quarter-moon-with-face', 'value': '🌜'},
|
||||
{'name': 'first-quarter-moon-with-face', 'value': '🌛'},
|
||||
{'name': 'moon', 'value': '🌙'},
|
||||
{'name': 'earth-africa', 'value': '🌍'},
|
||||
{'name': 'earth-americas', 'value': '🌎'},
|
||||
{'name': 'earth-asia', 'value': '🌏'},
|
||||
{'name': 'volcano', 'value': '🌋'},
|
||||
{'name': 'milky-way', 'value': '🌌'},
|
||||
{'name': 'shooting-star', 'value': '🌠'},
|
||||
{'name': 'star', 'value': '⭐'},
|
||||
{'name': 'sunny', 'value': '☀'},
|
||||
{'name': 'partly-sunny', 'value': '⛅'},
|
||||
{'name': 'cloud', 'value': '☁'},
|
||||
{'name': 'zap', 'value': '⚡'},
|
||||
{'name': 'umbrella', 'value': '☔'},
|
||||
{'name': 'snowflake', 'value': '❄'},
|
||||
{'name': 'snowman', 'value': '⛄'},
|
||||
{'name': 'cyclone', 'value': '🌀'},
|
||||
{'name': 'foggy', 'value': '🌁'},
|
||||
{'name': 'rainbow', 'value': '🌈'},
|
||||
{'name': 'ocean', 'value': '🌊'}
|
||||
],
|
||||
'object': [
|
||||
{'name': 'bamboo', 'value': '🎍'},
|
||||
{'name': 'gift-heart', 'value': '💝'},
|
||||
{'name': 'dolls', 'value': '🎎'},
|
||||
{'name': 'school-satchel', 'value': '🎒'},
|
||||
{'name': 'mortar-board', 'value': '🎓'},
|
||||
{'name': 'flags', 'value': '🎏'},
|
||||
{'name': 'fireworks', 'value': '🎆'},
|
||||
{'name': 'sparkler', 'value': '🎇'},
|
||||
{'name': 'wind-chime', 'value': '🎐'},
|
||||
{'name': 'rice-scene', 'value': '🎑'},
|
||||
{'name': 'jack-o-lantern', 'value': '🎃'},
|
||||
{'name': 'ghost', 'value': '👻'},
|
||||
{'name': 'santa', 'value': '🎅'},
|
||||
{'name': 'christmas-tree', 'value': '🎄'},
|
||||
{'name': 'gift', 'value': '🎁'},
|
||||
{'name': 'tanabata-tree', 'value': '🎋'},
|
||||
{'name': 'tada', 'value': '🎉'},
|
||||
{'name': 'confetti-ball', 'value': '🎊'},
|
||||
{'name': 'balloon', 'value': '🎈'},
|
||||
{'name': 'crossed-flags', 'value': '🎌'},
|
||||
{'name': 'crystal-ball', 'value': '🔮'},
|
||||
{'name': 'movie-camera', 'value': '🎥'},
|
||||
{'name': 'camera', 'value': '📷'},
|
||||
{'name': 'video-camera', 'value': '📹'},
|
||||
{'name': 'vhs', 'value': '📼'},
|
||||
{'name': 'cd', 'value': '💿'},
|
||||
{'name': 'dvd', 'value': '📀'},
|
||||
{'name': 'minidisc', 'value': '💽'},
|
||||
{'name': 'floppy-disk', 'value': '💾'},
|
||||
{'name': 'computer', 'value': '💻'},
|
||||
{'name': 'iphone', 'value': '📱'},
|
||||
{'name': 'phone', 'value': '☎'},
|
||||
{'name': 'telephone-receiver', 'value': '📞'},
|
||||
{'name': 'pager', 'value': '📟'},
|
||||
{'name': 'fax', 'value': '📠'},
|
||||
{'name': 'satellite', 'value': '📡'},
|
||||
{'name': 'tv', 'value': '📺'},
|
||||
{'name': 'radio', 'value': '📻'},
|
||||
{'name': 'speaker-waves', 'value': '🔊'},
|
||||
{'name': 'sound', 'value': '🔉'},
|
||||
{'name': 'speaker', 'value': '🔈'},
|
||||
{'name': 'mute', 'value': '🔇'},
|
||||
{'name': 'bell', 'value': '🔔'},
|
||||
{'name': 'no-bell', 'value': '🔕'},
|
||||
{'name': 'loudspeaker', 'value': '📢'},
|
||||
{'name': 'mega', 'value': '📣'},
|
||||
{'name': 'hourglass-flowing-sand', 'value': '⏳'},
|
||||
{'name': 'hourglass', 'value': '⌛'},
|
||||
{'name': 'alarm-clock', 'value': '⏰'},
|
||||
{'name': 'watch', 'value': '⌚'},
|
||||
{'name': 'unlock', 'value': '🔓'},
|
||||
{'name': 'lock', 'value': '🔒'},
|
||||
{'name': 'lock-with-ink-pen', 'value': '🔏'},
|
||||
{'name': 'closed-lock-with-key', 'value': '🔐'},
|
||||
{'name': 'key', 'value': '🔑'},
|
||||
{'name': 'mag-right', 'value': '🔎'},
|
||||
{'name': 'bulb', 'value': '💡'},
|
||||
{'name': 'flashlight', 'value': '🔦'},
|
||||
{'name': 'high-brightness', 'value': '🔆'},
|
||||
{'name': 'low-brightness', 'value': '🔅'},
|
||||
{'name': 'electric-plug', 'value': '🔌'},
|
||||
{'name': 'battery', 'value': '🔋'},
|
||||
{'name': 'mag', 'value': '🔍'},
|
||||
{'name': 'bathtub', 'value': '🛁'},
|
||||
{'name': 'bath', 'value': '🛀'},
|
||||
{'name': 'shower', 'value': '🚿'},
|
||||
{'name': 'toilet', 'value': '🚽'},
|
||||
{'name': 'wrench', 'value': '🔧'},
|
||||
{'name': 'nut-and-bolt', 'value': '🔩'},
|
||||
{'name': 'hammer', 'value': '🔨'},
|
||||
{'name': 'door', 'value': '🚪'},
|
||||
{'name': 'smoking', 'value': '🚬'},
|
||||
{'name': 'bomb', 'value': '💣'},
|
||||
{'name': 'gun', 'value': '🔫'},
|
||||
{'name': 'hocho', 'value': '🔪'},
|
||||
{'name': 'pill', 'value': '💊'},
|
||||
{'name': 'syringe', 'value': '💉'},
|
||||
{'name': 'moneybag', 'value': '💰'},
|
||||
{'name': 'yen', 'value': '💴'},
|
||||
{'name': 'dollar', 'value': '💵'},
|
||||
{'name': 'pound', 'value': '💷'},
|
||||
{'name': 'euro', 'value': '💶'},
|
||||
{'name': 'credit-card', 'value': '💳'},
|
||||
{'name': 'money-with-wings', 'value': '💸'},
|
||||
{'name': 'calling', 'value': '📲'},
|
||||
{'name': 'e-mail', 'value': '📧'},
|
||||
{'name': 'inbox-tray', 'value': '📥'},
|
||||
{'name': 'outbox-tray', 'value': '📤'},
|
||||
{'name': 'email', 'value': '✉'},
|
||||
{'name': 'enveloppe', 'value': '📩'},
|
||||
{'name': 'incoming-envelope', 'value': '📨'},
|
||||
{'name': 'postal-horn', 'value': '📯'},
|
||||
{'name': 'mailbox', 'value': '📫'},
|
||||
{'name': 'mailbox-closed', 'value': '📪'},
|
||||
{'name': 'mailbox-with-mail', 'value': '📬'},
|
||||
{'name': 'mailbox-with-no-mail', 'value': '📭'},
|
||||
{'name': 'postbox', 'value': '📮'},
|
||||
{'name': 'package', 'value': '📦'},
|
||||
{'name': 'memo', 'value': '📝'},
|
||||
{'name': 'page-facing-up', 'value': '📄'},
|
||||
{'name': 'page-with-curl', 'value': '📃'},
|
||||
{'name': 'bookmark-tabs', 'value': '📑'},
|
||||
{'name': 'bar-chart', 'value': '📊'},
|
||||
{'name': 'chart-with-upwards-trend', 'value': '📈'},
|
||||
{'name': 'chart-with-downwards-trend', 'value': '📉'},
|
||||
{'name': 'scroll', 'value': '📜'},
|
||||
{'name': 'clipboard', 'value': '📋'},
|
||||
{'name': 'date', 'value': '📅'},
|
||||
{'name': 'calendar', 'value': '📆'},
|
||||
{'name': 'card-index', 'value': '📇'},
|
||||
{'name': 'file-folder', 'value': '📁'},
|
||||
{'name': 'open-file-folder', 'value': '📂'},
|
||||
{'name': 'scissors', 'value': '✂'},
|
||||
{'name': 'pushpin', 'value': '📌'},
|
||||
{'name': 'paperclip', 'value': '📎'},
|
||||
{'name': 'black-nib', 'value': '✒'},
|
||||
{'name': 'pencil2', 'value': '✏'},
|
||||
{'name': 'straight-ruler', 'value': '📏'},
|
||||
{'name': 'triangular-ruler', 'value': '📐'},
|
||||
{'name': 'closed-book', 'value': '📕'},
|
||||
{'name': 'green-book', 'value': '📗'},
|
||||
{'name': 'blue-book', 'value': '📘'},
|
||||
{'name': 'orange-book', 'value': '📙'},
|
||||
{'name': 'notebook', 'value': '📓'},
|
||||
{'name': 'notebook-with-decorative-cover', 'value': '📔'},
|
||||
{'name': 'ledger', 'value': '📒'},
|
||||
{'name': 'books', 'value': '📚'},
|
||||
{'name': 'open-book', 'value': '📖'},
|
||||
{'name': 'bookmark', 'value': '🔖'},
|
||||
{'name': 'name-badge', 'value': '📛'},
|
||||
{'name': 'microscope', 'value': '🔬'},
|
||||
{'name': 'telescope', 'value': '🔭'},
|
||||
{'name': 'newspaper', 'value': '📰'},
|
||||
{'name': 'art', 'value': '🎨'},
|
||||
{'name': 'clapper', 'value': '🎬'},
|
||||
{'name': 'microphone', 'value': '🎤'},
|
||||
{'name': 'headphones', 'value': '🎧'},
|
||||
{'name': 'musical-score', 'value': '🎼'},
|
||||
{'name': 'musical-note', 'value': '🎵'},
|
||||
{'name': 'notes', 'value': '🎶'},
|
||||
{'name': 'musical-keyboard', 'value': '🎹'},
|
||||
{'name': 'violin', 'value': '🎻'},
|
||||
{'name': 'trumpet', 'value': '🎺'},
|
||||
{'name': 'saxophone', 'value': '🎷'},
|
||||
{'name': 'guitar', 'value': '🎸'},
|
||||
{'name': 'space-invader', 'value': '👾'},
|
||||
{'name': 'video-game', 'value': '🎮'},
|
||||
{'name': 'black-joker', 'value': '🃏'},
|
||||
{'name': 'flower-playing-cards', 'value': '🎴'},
|
||||
{'name': 'mahjong', 'value': '🀄'},
|
||||
{'name': 'game-die', 'value': '🎲'},
|
||||
{'name': 'dart', 'value': '🎯'},
|
||||
{'name': 'football', 'value': '🏈'},
|
||||
{'name': 'basketball', 'value': '🏀'},
|
||||
{'name': 'soccer', 'value': '⚽'},
|
||||
{'name': 'baseball', 'value': '⚾'},
|
||||
{'name': 'tennis', 'value': '🎾'},
|
||||
{'name': '8ball', 'value': '🎱'},
|
||||
{'name': 'rugby-football', 'value': '🏉'},
|
||||
{'name': 'bowling', 'value': '🎳'},
|
||||
{'name': 'golf', 'value': '⛳'},
|
||||
{'name': 'mountain-bicyclist', 'value': '🚵'},
|
||||
{'name': 'bicyclist', 'value': '🚴'},
|
||||
{'name': 'checkered-flag', 'value': '🏁'},
|
||||
{'name': 'horse-racing', 'value': '🏇'},
|
||||
{'name': 'trophy', 'value': '🏆'},
|
||||
{'name': 'ski', 'value': '🎿'},
|
||||
{'name': 'snowboarder', 'value': '🏂'},
|
||||
{'name': 'swimmer', 'value': '🏊'},
|
||||
{'name': 'surfer', 'value': '🏄'},
|
||||
{'name': 'fishing-pole-and-fish', 'value': '🎣'},
|
||||
{'name': 'coffee', 'value': '☕'},
|
||||
{'name': 'tea', 'value': '🍵'},
|
||||
{'name': 'sake', 'value': '🍶'},
|
||||
{'name': 'baby-bottle', 'value': '🍼'},
|
||||
{'name': 'beer', 'value': '🍺'},
|
||||
{'name': 'beers', 'value': '🍻'},
|
||||
{'name': 'cocktail', 'value': '🍸'},
|
||||
{'name': 'tropical-drink', 'value': '🍹'},
|
||||
{'name': 'wine-glass', 'value': '🍷'},
|
||||
{'name': 'fork-and-knife', 'value': '🍴'},
|
||||
{'name': 'pizza', 'value': '🍕'},
|
||||
{'name': 'hamburger', 'value': '🍔'},
|
||||
{'name': 'fries', 'value': '🍟'},
|
||||
{'name': 'poultry-leg', 'value': '🍗'},
|
||||
{'name': 'meat-on-bone', 'value': '🍖'},
|
||||
{'name': 'spaghetti', 'value': '🍝'},
|
||||
{'name': 'curry', 'value': '🍛'},
|
||||
{'name': 'fried-shrimp', 'value': '🍤'},
|
||||
{'name': 'bento', 'value': '🍱'},
|
||||
{'name': 'sushi', 'value': '🍣'},
|
||||
{'name': 'fish-cake', 'value': '🍥'},
|
||||
{'name': 'rice-ball', 'value': '🍙'},
|
||||
{'name': 'rice-cracker', 'value': '🍘'},
|
||||
{'name': 'rice', 'value': '🍚'},
|
||||
{'name': 'ramen', 'value': '🍜'},
|
||||
{'name': 'stew', 'value': '🍲'},
|
||||
{'name': 'oden', 'value': '🍢'},
|
||||
{'name': 'dango', 'value': '🍡'},
|
||||
{'name': 'egg', 'value': '🍳'},
|
||||
{'name': 'bread', 'value': '🍞'},
|
||||
{'name': 'doughnut', 'value': '🍩'},
|
||||
{'name': 'custard', 'value': '🍮'},
|
||||
{'name': 'icecream', 'value': '🍦'},
|
||||
{'name': 'ice-cream', 'value': '🍨'},
|
||||
{'name': 'shaved-ice', 'value': '🍧'},
|
||||
{'name': 'birthday', 'value': '🎂'},
|
||||
{'name': 'cake', 'value': '🍰'},
|
||||
{'name': 'cookie', 'value': '🍪'},
|
||||
{'name': 'chocolate-bar', 'value': '🍫'},
|
||||
{'name': 'candy', 'value': '🍬'},
|
||||
{'name': 'lollipop', 'value': '🍭'},
|
||||
{'name': 'honey-pot', 'value': '🍯'},
|
||||
{'name': 'apple', 'value': '🍎'},
|
||||
{'name': 'green-apple', 'value': '🍏'},
|
||||
{'name': 'tangerine', 'value': '🍊'},
|
||||
{'name': 'lemon', 'value': '🍋'},
|
||||
{'name': 'cherries', 'value': '🍒'},
|
||||
{'name': 'grapes', 'value': '🍇'},
|
||||
{'name': 'watermelon', 'value': '🍉'},
|
||||
{'name': 'strawberry', 'value': '🍓'},
|
||||
{'name': 'peach', 'value': '🍑'},
|
||||
{'name': 'melon', 'value': '🍈'},
|
||||
{'name': 'banana', 'value': '🍌'},
|
||||
{'name': 'pear', 'value': '🍐'},
|
||||
{'name': 'pineapple', 'value': '🍍'},
|
||||
{'name': 'sweet-potato', 'value': '🍠'},
|
||||
{'name': 'eggplant', 'value': '🍆'},
|
||||
{'name': 'tomato', 'value': '🍅'},
|
||||
{'name': 'corn', 'value': '🌽'}
|
||||
],
|
||||
'place': [
|
||||
{'name': 'house', 'value': '🏠'},
|
||||
{'name': 'house-with-garden', 'value': '🏡'},
|
||||
{'name': 'school', 'value': '🏫'},
|
||||
{'name': 'office', 'value': '🏢'},
|
||||
{'name': 'post-office', 'value': '🏣'},
|
||||
{'name': 'hospital', 'value': '🏥'},
|
||||
{'name': 'bank', 'value': '🏦'},
|
||||
{'name': 'convenience-store', 'value': '🏪'},
|
||||
{'name': 'love-hotel', 'value': '🏩'},
|
||||
{'name': 'hotel', 'value': '🏨'},
|
||||
{'name': 'wedding', 'value': '💒'},
|
||||
{'name': 'church', 'value': '⛪'},
|
||||
{'name': 'department-store', 'value': '🏬'},
|
||||
{'name': 'european-post-office', 'value': '🏤'},
|
||||
{'name': 'private-use', 'value': ''},
|
||||
{'name': 'city-sunrise', 'value': '🌇'},
|
||||
{'name': 'city-sunset', 'value': '🌆'},
|
||||
{'name': 'japanese-castle', 'value': '🏯'},
|
||||
{'name': 'european-castle', 'value': '🏰'},
|
||||
{'name': 'tent', 'value': '⛺'},
|
||||
{'name': 'factory', 'value': '🏭'},
|
||||
{'name': 'tokyo-tower', 'value': '🗼'},
|
||||
{'name': 'japan', 'value': '🗾'},
|
||||
{'name': 'mount-fuji', 'value': '🗻'},
|
||||
{'name': 'sunrise-over-mountains', 'value': '🌄'},
|
||||
{'name': 'sunrise', 'value': '🌅'},
|
||||
{'name': 'stars', 'value': '🌃'},
|
||||
{'name': 'statue-of-liberty', 'value': '🗽'},
|
||||
{'name': 'bridge-at-night', 'value': '🌉'},
|
||||
{'name': 'carousel-horse', 'value': '🎠'},
|
||||
{'name': 'ferris-wheel', 'value': '🎡'},
|
||||
{'name': 'fountain', 'value': '⛲'},
|
||||
{'name': 'roller-coaster', 'value': '🎢'},
|
||||
{'name': 'ship', 'value': '🚢'},
|
||||
{'name': 'boat', 'value': '⛵'},
|
||||
{'name': 'speedboat', 'value': '🚤'},
|
||||
{'name': 'rowboat', 'value': '🚣'},
|
||||
{'name': 'anchor', 'value': '⚓'},
|
||||
{'name': 'rocket', 'value': '🚀'},
|
||||
{'name': 'airplane', 'value': '✈'},
|
||||
{'name': 'seat', 'value': '💺'},
|
||||
{'name': 'helicopter', 'value': '🚁'},
|
||||
{'name': 'steam-locomotive', 'value': '🚂'},
|
||||
{'name': 'tram', 'value': '🚊'},
|
||||
{'name': 'station', 'value': '🚉'},
|
||||
{'name': 'mountain-railway', 'value': '🚞'},
|
||||
{'name': 'train2', 'value': '🚆'},
|
||||
{'name': 'bullettrain-side', 'value': '🚄'},
|
||||
{'name': 'bullettrain-front', 'value': '🚅'},
|
||||
{'name': 'light-rail', 'value': '🚈'},
|
||||
{'name': 'metro', 'value': '🚇'},
|
||||
{'name': 'monorail', 'value': '🚝'},
|
||||
{'name': 'tram-car', 'value': '🚋'},
|
||||
{'name': 'railway-car', 'value': '🚃'},
|
||||
{'name': 'trolleybus', 'value': '🚎'},
|
||||
{'name': 'bus', 'value': '🚌'},
|
||||
{'name': 'oncoming-bus', 'value': '🚍'},
|
||||
{'name': 'blue-car', 'value': '🚙'},
|
||||
{'name': 'oncoming-automobile', 'value': '🚘'},
|
||||
{'name': 'car', 'value': '🚗'},
|
||||
{'name': 'taxi', 'value': '🚕'},
|
||||
{'name': 'oncoming-taxi', 'value': '🚖'},
|
||||
{'name': 'articulated-lorry', 'value': '🚛'},
|
||||
{'name': 'truck', 'value': '🚚'},
|
||||
{'name': 'rotating-light', 'value': '🚨'},
|
||||
{'name': 'police-car', 'value': '🚓'},
|
||||
{'name': 'oncoming-police-car', 'value': '🚔'},
|
||||
{'name': 'fire-engine', 'value': '🚒'},
|
||||
{'name': 'ambulance', 'value': '🚑'},
|
||||
{'name': 'minibus', 'value': '🚐'},
|
||||
{'name': 'bike', 'value': '🚲'},
|
||||
{'name': 'aerial-tramway', 'value': '🚡'},
|
||||
{'name': 'suspension-railway', 'value': '🚟'},
|
||||
{'name': 'mountain-cableway', 'value': '🚠'},
|
||||
{'name': 'tractor', 'value': '🚜'},
|
||||
{'name': 'barber', 'value': '💈'},
|
||||
{'name': 'busstop', 'value': '🚏'},
|
||||
{'name': 'ticket', 'value': '🎫'},
|
||||
{'name': 'vertical-traffic-light', 'value': '🚦'},
|
||||
{'name': 'traffic-light', 'value': '🚥'},
|
||||
{'name': 'warning', 'value': '⚠'},
|
||||
{'name': 'construction', 'value': '🚧'},
|
||||
{'name': 'beginner', 'value': '🔰'},
|
||||
{'name': 'fuelpump', 'value': '⛽'},
|
||||
{'name': 'izakaya-lantern', 'value': '🏮'},
|
||||
{'name': 'slot-machine', 'value': '🎰'},
|
||||
{'name': 'hotsprings', 'value': '♨'},
|
||||
{'name': 'moyai', 'value': '🗿'},
|
||||
{'name': 'circus-tent', 'value': '🎪'},
|
||||
{'name': 'performing-arts', 'value': '🎭'},
|
||||
{'name': 'round-pushpin', 'value': '📍'},
|
||||
{'name': 'triangular-flag-on-post', 'value': '🚩'},
|
||||
{'name': 'cn', 'value': '🇨🇳'},
|
||||
{'name': 'de', 'value': '🇩🇪'},
|
||||
{'name': 'es', 'value': '🇪🇸'},
|
||||
{'name': 'fr', 'value': '🇫🇷'},
|
||||
{'name': 'gb', 'value': '🇬🇧'},
|
||||
{'name': 'it', 'value': '🇮🇹'},
|
||||
{'name': 'jp', 'value': '🇯🇵'},
|
||||
{'name': 'kr', 'value': '🇰🇷'},
|
||||
{'name': 'ru', 'value': '🇷🇺'},
|
||||
{'name': 'us', 'value': '🇺🇸'}
|
||||
]
|
||||
};
|
||||
|
||||
/* 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));
|
||||
}
|
|
@ -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
|
@ -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.
|
|
@ -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, doesn’t 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 doesn’t
|
||||
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*.
|
||||
|
||||
Here’s 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
|
|
@ -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
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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
Loading…
Reference in New Issue