From 08968b3d5482bc9827e62988c5b34af373044d38 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Mon, 7 Dec 2020 15:07:47 +0100 Subject: [PATCH] Improced BBCode support --- ChangeLog.md | 11 ++ package-lock.json | 26 ++++- package.json | 1 + shared/js/log.ts | 17 +-- shared/js/text/bbcode.scss | 53 +++++++++ shared/js/text/bbcode.tsx | 11 +- shared/js/text/bbcode/highlight.scss | 8 +- shared/js/text/chat.ts | 102 ++++++++-------- shared/js/text/markdown.ts | 63 ++++++---- shared/js/ui/frames/chat.ts | 5 +- shared/js/ui/frames/chat_frame.ts | 110 +++++++++--------- shared/js/ui/frames/side/ChatBox.tsx | 44 ++++--- shared/js/ui/frames/side/ConversationUI.scss | 34 ------ .../ui/frames/side/PrivateConversationUI.tsx | 95 +++++++++------ shared/js/ui/modal/channel-edit/Controller.ts | 41 +++++++ .../js/ui/modal/channel-edit/Definitions.ts | 71 +++++++++++ shared/js/ui/modal/channel-edit/Renderer.scss | 7 ++ shared/js/ui/modal/channel-edit/Renderer.tsx | 73 ++++++++++++ vendor/xbbcode | 2 +- 19 files changed, 540 insertions(+), 234 deletions(-) create mode 100644 shared/js/text/bbcode.scss create mode 100644 shared/js/ui/modal/channel-edit/Controller.ts create mode 100644 shared/js/ui/modal/channel-edit/Definitions.ts create mode 100644 shared/js/ui/modal/channel-edit/Renderer.scss create mode 100644 shared/js/ui/modal/channel-edit/Renderer.tsx diff --git a/ChangeLog.md b/ChangeLog.md index c5766eb7..3d1b42cc 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,4 +1,15 @@ # Changelog: +* **07.12.20** + - Fixed the Markdown to BBCode transpiler falsely emitting empty lines + - Fixed invalid BBCode escaping + - Added proper BBCode support for lazy close tags + - Improved the URL detecting and replace support (Reduced false positives and proper protocol checking) + - Adding support for list bb codes and background color + - Fixed some minor BBCode parser bugs + - Fixed BBCode inline code style + - URL tags can not contain any other tags + - Correctly parsing the "lazy close tag" `[/]` + * **05.12.20** - Fixed the webclient for Firefox in incognito mode diff --git a/package-lock.json b/package-lock.json index 676cf82e..cabbb6e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3962,8 +3962,7 @@ "decode-uri-component": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", - "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", - "dev": true + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=" }, "decompress-response": { "version": "3.3.0", @@ -12297,8 +12296,7 @@ "strict-uri-encode": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", - "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=", - "dev": true + "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=" }, "string-width": { "version": "1.0.2", @@ -13511,6 +13509,26 @@ } } }, + "url-knife": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/url-knife/-/url-knife-3.1.3.tgz", + "integrity": "sha512-6GXFPWBHtUBCSzkbJwirszFzL6It73p+KDKejzRO1CEAETrt0uN7WXKihTiHbzaY94nCcXZpqEm+pKkshe2+/A==", + "requires": { + "query-string": "^5.1.1" + }, + "dependencies": { + "query-string": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-5.1.1.tgz", + "integrity": "sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw==", + "requires": { + "decode-uri-component": "^0.2.0", + "object-assign": "^4.1.0", + "strict-uri-encode": "^1.0.0" + } + } + } + }, "url-parse-lax": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", diff --git a/package.json b/package.json index ecf8364d..3d100cc7 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "sdp-transform": "^2.14.0", "simplebar-react": "^2.2.0", "twemoji": "^13.0.0", + "url-knife": "^3.1.3", "webcrypto-liner": "^1.2.3", "webrtc-adapter": "^7.5.1" } diff --git a/shared/js/log.ts b/shared/js/log.ts index 91c9aa09..c3fc19af 100644 --- a/shared/js/log.ts +++ b/shared/js/log.ts @@ -232,30 +232,33 @@ export class Group { } log(message: string, ...optionalParams: any[]) : this { - if(!this.enabled) + if(!this.enabled) { return this; + } if(!this.initialized) { if(this.mode == GroupMode.NATIVE) { - if(this._collapsed && console.groupCollapsed) + if(this._collapsed && console.groupCollapsed) { console.groupCollapsed(this.name, ...this.optionalParams); - else + } else { console.group(this.name, ...this.optionalParams); + } } else { this._log_prefix = " "; let parent = this.owner; while(parent) { - if(parent.mode == GroupMode.PREFIX) + if(parent.mode == GroupMode.PREFIX) { this._log_prefix = this._log_prefix + parent._log_prefix; - else + } else { break; + } } } this.initialized = true; } - if(this.mode == GroupMode.NATIVE) + if(this.mode == GroupMode.NATIVE) { logDirect(this.level, message, ...optionalParams); - else { + } else { logDirect(this.level, "[%s] " + this._log_prefix + message, category_mapping.get(this.category), ...optionalParams); } return this; diff --git a/shared/js/text/bbcode.scss b/shared/js/text/bbcode.scss new file mode 100644 index 00000000..6ad15555 --- /dev/null +++ b/shared/js/text/bbcode.scss @@ -0,0 +1,53 @@ +:global { + .xbbcode-tag { + &.xbbcode-tag-hr { + border: none; + border-top: .125em solid #555; + + margin-top: .1em; + margin-bottom: .1em; + } + + &.xbbcode-tag-table { + th, td { + border-color: var(--chat-message-table-border); + } + + tr { + background-color: var(--chat-message-table-row-background); + } + + tr:nth-child(2n) { + background-color: var(--chat-message-table-row-even-background); + } + } + + &.xbbcode-tag-bg-color { + padding: 0 .25em; + line-height: 1em; + + border-radius: 2px; + display: inline-block; + } + + &.xbbcode-tag-quote { + border-color: var(--chat-message-quote-border); + padding-left: .5em; + color: var(--chat-message-quote); + } + + &.xbbcode-tag-img { + padding: .25em; + border-radius: .25em; + + overflow: hidden; + max-width: 20em; + max-height: 20em; + + img { + width: 100%; + height: 100%; + } + } + } +} \ No newline at end of file diff --git a/shared/js/text/bbcode.tsx b/shared/js/text/bbcode.tsx index e24e4941..7d0501cd 100644 --- a/shared/js/text/bbcode.tsx +++ b/shared/js/text/bbcode.tsx @@ -4,15 +4,16 @@ import {rendererHTML, rendererReact, rendererText} from "tc-shared/text/bbcode/r import {parse as parseBBCode} from "vendor/xbbcode/parser"; import {fixupJQueryUrlTags} from "tc-shared/text/bbcode/url"; import {fixupJQueryImageTags} from "tc-shared/text/bbcode/image"; +import "./bbcode.scss"; -export const escapeBBCode = (text: string) => text.replace(/([\[\]])/g, "\\$1"); +export const escapeBBCode = (text: string) => text.replace(/(\[)/g, "\\$1"); export const allowedBBCodes = [ "b", "big", "i", "italic", "u", "underlined", "s", "strikethrough", - "color", + "color", "bgcolor", "url", "code", "i-code", "icode", @@ -22,7 +23,9 @@ export const allowedBBCodes = [ "left", "l", "center", "c", "right", "r", "ul", "ol", "list", + "ulist", "olist", "li", + "*", "table", "tr", "td", "th", @@ -37,7 +40,7 @@ export interface BBCodeRenderOptions { convertSingleUrls: boolean; } -const yt_url_regex = /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/; +const youtubeUrlRegex = /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/; function preprocessMessage(message: string, settings: BBCodeRenderOptions) : string { /* try if its only one url */ @@ -53,7 +56,7 @@ function preprocessMessage(message: string, settings: BBCodeRenderOptions) : str single_url_yt: { - const result = raw_url.match(yt_url_regex); + const result = raw_url.match(youtubeUrlRegex); if(!result) break single_url_yt; return "[yt]https://www.youtube.com/watch?v=" + result[5] + "[/yt]"; diff --git a/shared/js/text/bbcode/highlight.scss b/shared/js/text/bbcode/highlight.scss index bf1bcb4f..28e4d252 100644 --- a/shared/js/text/bbcode/highlight.scss +++ b/shared/js/text/bbcode/highlight.scss @@ -13,13 +13,13 @@ &.inlineCode { display: inline-block; - > .hljs { - padding: 0 .25em!important; - } - white-space: pre-wrap; margin: 0 0 -0.1em; vertical-align: bottom; + + > :global(.hljs) { + padding: 0 .25em!important; + } } &.code { word-wrap: normal; diff --git a/shared/js/text/chat.ts b/shared/js/text/chat.ts index 6cfc2cf3..e043094d 100644 --- a/shared/js/text/chat.ts +++ b/shared/js/text/chat.ts @@ -1,70 +1,74 @@ -//https://regex101.com/r/YQbfcX/2 -//static readonly URL_REGEX = /^(?([a-zA-Z0-9-]+\.)+[a-zA-Z0-9-]{2,63})(?:\/(?(?:[^\s?]+)?)(?:\?(?\S+))?)?$/gm; -import * as log from "../log"; -import {LogCategory} from "../log"; +import UrlKnife from 'url-knife'; import {Settings, settings} from "../settings"; import {renderMarkdownAsBBCode} from "../text/markdown"; import {escapeBBCode} from "../text/bbcode"; -import { tr } from "tc-shared/i18n/localize"; -const URL_REGEX = /^(([a-zA-Z0-9-]+\.)+[a-zA-Z0-9-]{2,63})(?:\/((?:[^\s?]+)?)(?:\?(\S+))?)?$/gm; -function process_urls(message: string) : string { - const words = message.split(/[ \n]/); - for(let index = 0; index < words.length; index++) { - const flag_escaped = words[index].startsWith('!'); - const unescaped = flag_escaped ? words[index].substr(1) : words[index]; +interface UrlKnifeUrl { + value: { + url: string, + }, + area: string, + index: { + start: number, + end: number + } +} - _try: - try { - const url = new URL(unescaped); - log.debug(LogCategory.GENERAL, tr("Chat message contains URL: %o"), url); - if(url.protocol !== 'http:' && url.protocol !== 'https:') - break _try; - if(flag_escaped) { - message = undefined; - words[index] = unescaped; - } else { - message = undefined; - words[index] = "[url=" + url.toString() + "]" + url.toString() + "[/url]"; - } - } catch(e) { /* word isn't an url */ } +function bbcodeLinkUrls(message: string) : string { + const urls: UrlKnifeUrl[] = UrlKnife.TextArea.extractAllUrls(message, { + 'ip_v4' : true, + 'ip_v6' : false, + 'localhost' : false, + 'intranet' : true + }); - if(unescaped.match(URL_REGEX)) { - if(flag_escaped) { - message = undefined; - words[index] = unescaped; - } else { - message = undefined; - words[index] = "[url=" + unescaped + "]" + unescaped + "[/url]"; - } + /* we want to go through the urls from the back to the front */ + urls.sort((a, b) => b.index.start - a.index.start); + for(const url of urls) { + const prefix = message.substr(0, url.index.start); + const suffix = message.substr(url.index.end); + const urlPath = message.substring(url.index.start, url.index.end); + let bbcodeUrl; + + let colonIndex = urlPath.indexOf(":"); + if(colonIndex === -1 || colonIndex + 2 < urlPath.length || urlPath[colonIndex + 1] !== "/" || urlPath[colonIndex + 2] !== "/") { + bbcodeUrl = "[url=https://" + urlPath + "]" + urlPath + "[/url]"; + } else { + bbcodeUrl = "[url]" + urlPath + "[/url]"; } + + message = prefix + bbcodeUrl + suffix; } - return message || words.join(" "); + return message; } export function preprocessChatMessageForSend(message: string) : string { - const process_url = settings.static_global(Settings.KEY_CHAT_TAG_URLS); - const parse_markdown = settings.static_global(Settings.KEY_CHAT_ENABLE_MARKDOWN); - const escape_bb = !settings.static_global(Settings.KEY_CHAT_ENABLE_BBCODE); + const processUrls = settings.static_global(Settings.KEY_CHAT_TAG_URLS); + const parseMarkdown = settings.static_global(Settings.KEY_CHAT_ENABLE_MARKDOWN); + const escapeBBCodes = !settings.static_global(Settings.KEY_CHAT_ENABLE_BBCODE); - if(parse_markdown) { + if(parseMarkdown) { return renderMarkdownAsBBCode(message, text => { - if(escape_bb) + if(escapeBBCodes) { text = escapeBBCode(text); + } - if(process_url) - text = process_urls(text); + if(processUrls) { + text = bbcodeLinkUrls(text); + } return text; }); + } else { + if(escapeBBCodes) { + message = escapeBBCode(message); + } + + if(processUrls) { + message = bbcodeLinkUrls(message); + } + + return message; } - - if(escape_bb) - message = escapeBBCode(message); - - if(process_url) - message = process_urls(message); - - return message; } \ No newline at end of file diff --git a/shared/js/text/markdown.ts b/shared/js/text/markdown.ts index 57dd1a47..4a7b1643 100644 --- a/shared/js/text/markdown.ts +++ b/shared/js/text/markdown.ts @@ -1,32 +1,35 @@ import * as log from "../log"; -import {LogCategory} from "../log"; +import {LogCategory, logTrace} from "../log"; import { - CodeToken, Env, FenceToken, HeadingOpenToken, + CodeToken, + Env, + FenceToken, + HeadingOpenToken, ImageToken, - LinkOpenToken, Options, - ParagraphOpenToken, + LinkOpenToken, + Options, + ParagraphCloseToken, SubToken, SupToken, TextToken, Token } from "remarkable/lib"; import {escapeBBCode} from "../text/bbcode"; -import { tr } from "tc-shared/i18n/localize"; +import {tr} from "tc-shared/i18n/localize"; + const { Remarkable } = require("remarkable"); export class MD2BBCodeRenderer { private static renderers: {[key: string]:(renderer: MD2BBCodeRenderer, token: Token) => string} = { - "text": (renderer: MD2BBCodeRenderer, token: TextToken) => renderer.options().textProcessor(token.content), + "text": (renderer: MD2BBCodeRenderer, token: TextToken) => { + renderer.currentLineCount += token.content.split("\n").length; + return renderer.options().textProcessor(token.content); + }, "softbreak": () => "\n", "hardbreak": () => "\n", - "paragraph_open": (renderer: MD2BBCodeRenderer, token: ParagraphOpenToken) => { - debugger; - const last_line = !renderer.last_paragraph || !renderer.last_paragraph.lines ? 0 : renderer.last_paragraph.lines[1]; - const lines = token.lines[0] - last_line; - return [...new Array(lines)].map(() => "[br]").join(""); - }, - "paragraph_close": () => "", + "paragraph_open": () => "", + "paragraph_close": (_, token: ParagraphCloseToken) => token.tight ? "" : "[br]", "strong_open": () => "[b]", "strong_close": () => "[/b]", @@ -70,7 +73,10 @@ export class MD2BBCodeRenderer { "link_open": (renderer: MD2BBCodeRenderer, token: LinkOpenToken) => "[url" + (token.href ? ("=" + token.href) : "") + "]", "link_close": () => "[/url]", - "image": (renderer: MD2BBCodeRenderer, token: ImageToken) => "[img=" + (token.src) + "]" + (token.alt || token.src) + "[/img]", + "image": (renderer: MD2BBCodeRenderer, token: ImageToken) => { + renderer.currentLineCount += 1; + return "[img=" + (token.src) + "]" + (token.alt || token.src) + "[/img]"; + }, //footnote_ref @@ -96,14 +102,26 @@ export class MD2BBCodeRenderer { }; private _options; - last_paragraph: Token; + currentLineCount: number; + + reset() { + this._options = undefined; + this.currentLineCount = 0; + } render(tokens: Token[], options: Options, env: Env): string { - this.last_paragraph = undefined; this._options = options; + let result = ''; for(let index = 0; index < tokens.length; index++) { + if(tokens[index].lines?.length) { + while(this.currentLineCount < tokens[index].lines[0]) { + this.currentLineCount += 1; + result += "[br]"; + } + } + if (tokens[index].type === 'inline') { /* we're just ignoring the inline fact */ result += this.render((tokens[index] as any).children, options, env); @@ -124,10 +142,7 @@ export class MD2BBCodeRenderer { return 'content' in token ? this.options().textProcessor(token.content) : ""; } - const result = renderer(this, token); - if(token.type === "paragraph_open") - this.last_paragraph = token; - return result; + return renderer(this, token); } options() : any { @@ -135,17 +150,19 @@ export class MD2BBCodeRenderer { } } +const md2bbCodeRenderer = new MD2BBCodeRenderer(); const remarkableRenderer = new Remarkable("full", { typographer: true }); -remarkableRenderer.renderer = new MD2BBCodeRenderer() as any; +remarkableRenderer.renderer = md2bbCodeRenderer as any; remarkableRenderer.inline.ruler.disable([ 'newline', 'autolink' ]); export function renderMarkdownAsBBCode(message: string, textProcessor: (text: string) => string) : string { remarkableRenderer.set({ textProcessor: textProcessor } as any); + md2bbCodeRenderer.reset(); + let result = remarkableRenderer.render(message); - if(result.endsWith("\n")) - result = result.substr(0, result.length - 1); + logTrace(LogCategory.CHAT, tr("Markdown render result:\n%s"), result); return result; } \ No newline at end of file diff --git a/shared/js/ui/frames/chat.ts b/shared/js/ui/frames/chat.ts index c99a5622..019e1f36 100644 --- a/shared/js/ui/frames/chat.ts +++ b/shared/js/ui/frames/chat.ts @@ -19,10 +19,11 @@ export function htmlEscape(message: string) : string[] { } export function formatElement(object: any, escape_html: boolean = true) : JQuery[] { - if($.isArray(object)) { + if(Array.isArray(object)) { let result = []; - for(let element of object) + for(let element of object) { result.push(...formatElement(element, escape_html)); + } return result; } else if(typeof(object) == "string") { if(object.length == 0) return []; diff --git a/shared/js/ui/frames/chat_frame.ts b/shared/js/ui/frames/chat_frame.ts index 9d7944db..356328e0 100644 --- a/shared/js/ui/frames/chat_frame.ts +++ b/shared/js/ui/frames/chat_frame.ts @@ -275,91 +275,91 @@ export enum FrameContent { export class Frame { readonly handle: ConnectionHandler; - private _info_frame: InfoFrame; - private _html_tag: JQuery; - private _container_info: JQuery; - private _container_chat: JQuery; + private infoFrame: InfoFrame; + private htmlTag: JQuery; + private containerInfo: JQuery; + private containerChannelChat: JQuery; private _content_type: FrameContent; - private _client_info: ClientInfo; - private _music_info: MusicInfo; - private _channel_conversations: ConversationManager; - private _private_conversations: PrivateConversationManager; + private clientInfo: ClientInfo; + private musicInfo: MusicInfo; + private channelConversations: ConversationManager; + private privateConversations: PrivateConversationManager; constructor(handle: ConnectionHandler) { this.handle = handle; this._content_type = FrameContent.NONE; - this._info_frame = new InfoFrame(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); + this.infoFrame = new InfoFrame(this); + this.privateConversations = new PrivateConversationManager(handle); + this.channelConversations = new ConversationManager(handle); + this.clientInfo = new ClientInfo(this); + this.musicInfo = new MusicInfo(this); this._build_html_tag(); this.show_channel_conversations(); this.info_frame().update_chat_counter(); } - html_tag() : JQuery { return this._html_tag; } - info_frame() : InfoFrame { return this._info_frame; } + html_tag() : JQuery { return this.htmlTag; } + info_frame() : InfoFrame { return this.infoFrame; } content_type() : FrameContent { return this._content_type; } destroy() { - this._html_tag && this._html_tag.remove(); - this._html_tag = undefined; + this.htmlTag && this.htmlTag.remove(); + this.htmlTag = undefined; - this._info_frame && this._info_frame.destroy(); - this._info_frame = undefined; + this.infoFrame && this.infoFrame.destroy(); + this.infoFrame = undefined; - this._client_info && this._client_info.destroy(); - this._client_info = undefined; + this.clientInfo && this.clientInfo.destroy(); + this.clientInfo = undefined; - this._music_info && this._music_info.destroy(); - this._music_info = undefined; + this.musicInfo && this.musicInfo.destroy(); + this.musicInfo = undefined; - this._private_conversations && this._private_conversations.destroy(); - this._private_conversations = undefined; + this.privateConversations && this.privateConversations.destroy(); + this.privateConversations = undefined; - this._channel_conversations && this._channel_conversations.destroy(); - this._channel_conversations = undefined; + this.channelConversations && this.channelConversations.destroy(); + this.channelConversations = undefined; - this._container_info && this._container_info.remove(); - this._container_info = undefined; + this.containerInfo && this.containerInfo.remove(); + this.containerInfo = undefined; - this._container_chat && this._container_chat.remove(); - this._container_chat = undefined; + this.containerChannelChat && this.containerChannelChat.remove(); + this.containerChannelChat = undefined; } private _build_html_tag() { - this._html_tag = $("#tmpl_frame_chat").renderTag(); - this._container_info = this._html_tag.find(".container-info"); - this._container_chat = this._html_tag.find(".container-chat"); + this.htmlTag = $("#tmpl_frame_chat").renderTag(); + this.containerInfo = this.htmlTag.find(".container-info"); + this.containerChannelChat = this.htmlTag.find(".container-chat"); - this._info_frame.html_tag().appendTo(this._container_info); + this.infoFrame.html_tag().appendTo(this.containerInfo); } private_conversations() : PrivateConversationManager { - return this._private_conversations; + return this.privateConversations; } channel_conversations() : ConversationManager { - return this._channel_conversations; + return this.channelConversations; } client_info() : ClientInfo { - return this._client_info; + return this.clientInfo; } music_info() : MusicInfo { - return this._music_info; + return this.musicInfo; } private _clear() { this._content_type = FrameContent.NONE; - this._container_chat.children().detach(); + this.containerChannelChat.children().detach(); } show_private_conversations() { @@ -368,9 +368,9 @@ export class Frame { this._clear(); this._content_type = FrameContent.PRIVATE_CHAT; - this._container_chat.append(this._private_conversations.htmlTag); - this._private_conversations.handlePanelShow(); - this._info_frame.set_mode(InfoFrameMode.PRIVATE_CHAT); + this.containerChannelChat.append(this.privateConversations.htmlTag); + this.privateConversations.handlePanelShow(); + this.infoFrame.set_mode(InfoFrameMode.PRIVATE_CHAT); } show_channel_conversations() { @@ -379,36 +379,36 @@ export class Frame { this._clear(); this._content_type = FrameContent.CHANNEL_CHAT; - this._container_chat.append(this._channel_conversations.htmlTag); - this._channel_conversations.handlePanelShow(); + this.containerChannelChat.append(this.channelConversations.htmlTag); + this.channelConversations.handlePanelShow(); - this._info_frame.set_mode(InfoFrameMode.CHANNEL_CHAT); + this.infoFrame.set_mode(InfoFrameMode.CHANNEL_CHAT); } show_client_info(client: ClientEntry) { - this._client_info.set_current_client(client); - this._info_frame.set_mode(InfoFrameMode.CLIENT_INFO); /* specially needs an update here to update the conversation button */ + this.clientInfo.set_current_client(client); + this.infoFrame.set_mode(InfoFrameMode.CLIENT_INFO); /* specially needs an update here to update the conversation button */ if(this._content_type === FrameContent.CLIENT_INFO) return; - this._client_info.previous_frame_content = this._content_type; + this.clientInfo.previous_frame_content = this._content_type; this._clear(); this._content_type = FrameContent.CLIENT_INFO; - this._container_chat.append(this._client_info.html_tag()); + this.containerChannelChat.append(this.clientInfo.html_tag()); } show_music_player(client: MusicClientEntry) { - this._music_info.set_current_bot(client); + this.musicInfo.set_current_bot(client); if(this._content_type === FrameContent.MUSIC_BOT) return; - this._info_frame.set_mode(InfoFrameMode.MUSIC_BOT); - this._music_info.previous_frame_content = this._content_type; + this.infoFrame.set_mode(InfoFrameMode.MUSIC_BOT); + this.musicInfo.previous_frame_content = this._content_type; this._clear(); this._content_type = FrameContent.MUSIC_BOT; - this._container_chat.append(this._music_info.html_tag()); + this.containerChannelChat.append(this.musicInfo.html_tag()); } set_content(type: FrameContent) { @@ -422,7 +422,7 @@ export class Frame { else { this._clear(); this._content_type = FrameContent.NONE; - this._info_frame.set_mode(InfoFrameMode.NONE); + this.infoFrame.set_mode(InfoFrameMode.NONE); } } } \ No newline at end of file diff --git a/shared/js/ui/frames/side/ChatBox.tsx b/shared/js/ui/frames/side/ChatBox.tsx index 7f2cc8e5..0c29798e 100644 --- a/shared/js/ui/frames/side/ChatBox.tsx +++ b/shared/js/ui/frames/side/ChatBox.tsx @@ -31,8 +31,9 @@ const EmojiButton = (props: { events: Registry }) => { const refContainer = useRef(); useEffect(() => { - if(!shown) + if(!shown) { return; + } const clickListener = (event: MouseEvent) => { let target = event.target as HTMLElement; @@ -90,8 +91,9 @@ const nodeToText = (element: Node) => { return '\n'; } - if(element.children.length > 0) + if(element.children.length > 0) { return [...element.childNodes].map(nodeToText).join(""); + } return typeof(element.innerText) === "string" ? element.innerText : ""; } else { @@ -139,14 +141,14 @@ const TextInput = (props: { events: Registry, enabled?: boolean, const clipboard = event.clipboardData || (window as any).clipboardData; if(!clipboard) return; - const raw_text = clipboard.getData('text/plain'); + const rawText = clipboard.getData('text/plain'); const selection = window.getSelection(); if (!selection.rangeCount) return false; let htmlXML = clipboard.getData('text/html'); if(!htmlXML) { - pasteTextTransformElement.textContent = raw_text; + pasteTextTransformElement.textContent = rawText; htmlXML = pasteTextTransformElement.innerHTML; } @@ -159,17 +161,21 @@ const TextInput = (props: { events: Registry, enabled?: boolean, { let prefix_length = 0, suffix_length = 0; { - for (let i = 0; i < raw_text.length; i++) - if (raw_text.charAt(i) === '\n') + for (let i = 0; i < rawText.length; i++) { + if (rawText.charAt(i) === '\n') { prefix_length++; - else if (raw_text.charAt(i) !== '\r') + } else if (rawText.charAt(i) !== '\r') { break; + } + } - for (let i = raw_text.length - 1; i >= 0; i++) - if (raw_text.charAt(i) === '\n') + for (let i = rawText.length - 1; i >= 0; i++) { + if (rawText.charAt(i) === '\n') { suffix_length++; - else if (raw_text.charAt(i) !== '\r') + } else if (rawText.charAt(i) !== '\r') { break; + } + } } data = data.replace(/^[\n\r]+|[\n\r]+$/g, ''); @@ -186,14 +192,16 @@ const TextInput = (props: { events: Registry, enabled?: boolean, const inputEmpty = refInput.current.innerText.trim().length === 0; if(event.key === "Enter" && !event.shiftKey) { - if(inputEmpty) + if(inputEmpty) { return; + } const text = refInput.current.innerText; props.events.fire("action_submit_message", { message: text }); history.current.push(text); - while(history.current.length > 10) + while(history.current.length > 10) { history.current.pop_front(); + } refInput.current.innerText = ""; setHistoryIndex(-1); @@ -221,15 +229,17 @@ const TextInput = (props: { events: Registry, enabled?: boolean, props.events.reactUse("action_request_focus", () => refInput.current?.focus()); props.events.reactUse("notify_typing", () => { - if(typeof typingTimeout.current === "number") + if(typeof typingTimeout.current === "number") { return; + } typingTimeout.current = setTimeout(() => typingTimeout.current = undefined, 1000); }); props.events.reactUse("action_insert_text", event => { refInput.current.innerHTML = refInput.current.innerHTML + event.text; - if(event.focus) + if(event.focus) { refInput.current.focus(); + } }); props.events.reactUse("action_set_enabled", event => { setEnabled(event.enabled); @@ -273,8 +283,9 @@ const MarkdownFormatHelper = () => { const [ visible, setVisible ] = useState(settings.global(Settings.KEY_CHAT_ENABLE_MARKDOWN)); settings.events.reactUse("notify_setting_changed", event => { - if(event.setting !== Settings.KEY_CHAT_ENABLE_MARKDOWN.key) + if(event.setting !== Settings.KEY_CHAT_ENABLE_MARKDOWN.key) { return; + } setVisible(settings.global(Settings.KEY_CHAT_ENABLE_MARKDOWN)); }); @@ -323,7 +334,8 @@ export class ChatBox extends React.Component { } componentDidUpdate(prevProps: Readonly, prevState: Readonly, snapshot?: any): void { - if(prevState.enabled !== this.state.enabled) + if(prevState.enabled !== this.state.enabled) { this.events.fire_react("action_set_enabled", { enabled: this.state.enabled }); + } } } \ No newline at end of file diff --git a/shared/js/ui/frames/side/ConversationUI.scss b/shared/js/ui/frames/side/ConversationUI.scss index a81a7d97..87dd94bf 100644 --- a/shared/js/ui/frames/side/ConversationUI.scss +++ b/shared/js/ui/frames/side/ConversationUI.scss @@ -298,34 +298,6 @@ html:root { margin-bottom: .1em; } - table { - th, td { - border-color: var(--chat-message-table-border); - } - - tr { - background-color: var(--chat-message-table-row-background); - } - - tr:nth-child(2n) { - background-color: var(--chat-message-table-row-even-background); - } - } - - :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; @@ -341,12 +313,6 @@ html:root { margin-bottom: .1em; } } - - :global(.xbbcode-tag-quote) { - border-color: var(--chat-message-quote-border); - padding-left: .5em; - color: var(--chat-message-quote); - } } &:before { diff --git a/shared/js/ui/frames/side/PrivateConversationUI.tsx b/shared/js/ui/frames/side/PrivateConversationUI.tsx index 3faadae7..2a5becce 100644 --- a/shared/js/ui/frames/side/PrivateConversationUI.tsx +++ b/shared/js/ui/frames/side/PrivateConversationUI.tsx @@ -7,64 +7,79 @@ import { 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 {useContext, 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"; +import {getGlobalAvatarManagerFactory} from "tc-shared/file/Avatars"; const cssStyle = require("./PrivateConversationUI.scss"); const kTypingTimeout = 5000; -const ConversationEntryInfo = React.memo((props: { events: Registry, chatId: string, initialNickname: string, lastMessage: number }) => { +const HandlerIdContext = React.createContext(undefined); +const EventContext = React.createContext>(undefined); + +const ConversationEntryInfo = React.memo((props: { chatId: string, initialNickname: string, lastMessage: number }) => { + const events = useContext(EventContext); + 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) + 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) + 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) + events.reactUse("notify_chat_event", event => { + if(event.chatId !== props.chatId) { return; + } if(event.event.type === "message") { - if(event.event.timestamp > lastMessage) + if(event.event.timestamp > lastMessage) { setLastMessage(event.event.timestamp); + } - if(!event.event.isOwnMessage) + 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") + 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)) + + if(typeof lastStatedMessage === "number" && (typeof lastMessage === "undefined" || lastStatedMessage > lastMessage)) { setLastMessage(lastStatedMessage); + } }); - props.events.reactUse("notify_partner_typing", event => { - if(event.chatId !== props.chatId) + events.reactUse("notify_partner_typing", event => { + if(event.chatId !== props.chatId) { return; + } setTypingTimestamp(Date.now()); }); @@ -100,17 +115,18 @@ const ConversationEntryInfo = React.memo((props: { events: Registry ); }); -const ConversationEntryUnreadMarker = React.memo((props: { events: Registry, chatId: string, initialUnread: boolean }) => { +const ConversationEntryUnreadMarker = React.memo((props: { chatId: string, initialUnread: boolean }) => { + const events = useContext(EventContext); const [ unread, setUnread ] = useState(props.initialUnread); - props.events.reactUse("notify_unread_timestamp_changed", event => { + events.reactUse("notify_unread_timestamp_changed", event => { if(event.chatId !== props.chatId) return; setUnread(event.timestamp !== undefined); }); - props.events.reactUse("notify_chat_event", event => { + events.reactUse("notify_chat_event", event => { if(event.chatId !== props.chatId || !event.triggerUnread) return; @@ -123,10 +139,13 @@ const ConversationEntryUnreadMarker = React.memo((props: { events: Registry; }); -const ConversationEntry = React.memo((props: { events: Registry, info: PrivateConversationInfo, selected: boolean, connection: ConnectionHandler }) => { +const ConversationEntry = React.memo((props: { info: PrivateConversationInfo, selected: boolean }) => { + const events = useContext(EventContext); + const handlerId = useContext(HandlerIdContext); + const [ clientId, setClientId ] = useState(props.info.clientId); - props.events.reactUse("notify_partner_changed", event => { + events.reactUse("notify_partner_changed", event => { if(event.chatId !== props.info.chatId) return; @@ -134,37 +153,41 @@ const ConversationEntry = React.memo((props: { events: Registry props.events.fire("action_select_chat", { chatId: props.info.chatId })} + onClick={() => events.fire("action_select_chat", { chatId: props.info.chatId })} >
- - + +
- +
{ - props.events.fire("action_close_chat", { chatId: props.info.chatId }); + events.fire("action_close_chat", { chatId: props.info.chatId }); }} />
); }); -const OpenConversationsPanel = React.memo((props: { events: Registry, connection: ConnectionHandler }) => { +const OpenConversationsPanel = React.memo(() => { + const events = useContext(EventContext); + const [ conversations, setConversations ] = useState(() => { - props.events.fire("query_private_conversations"); + events.fire("query_private_conversations"); return "loading"; }); const [ selected, setSelected ] = useState("unselected"); - props.events.reactUse("notify_private_conversations", event => { + events.reactUse("notify_private_conversations", event => { setConversations(event.conversations); setSelected(event.selected); }); - props.events.reactUse("notify_selected_chat", event => { + events.reactUse("notify_selected_chat", event => { setSelected(event.chatId); }); @@ -184,10 +207,8 @@ const OpenConversationsPanel = React.memo((props: { events: Registry ) } @@ -200,8 +221,12 @@ const OpenConversationsPanel = React.memo((props: { events: Registry, handler: ConnectionHandler }) => ( - - - - + + + + + + + + ); \ No newline at end of file diff --git a/shared/js/ui/modal/channel-edit/Controller.ts b/shared/js/ui/modal/channel-edit/Controller.ts new file mode 100644 index 00000000..e3613df3 --- /dev/null +++ b/shared/js/ui/modal/channel-edit/Controller.ts @@ -0,0 +1,41 @@ +import {ConnectionHandler} from "tc-shared/ConnectionHandler"; +import {ChannelEntry} from "tc-shared/tree/Channel"; +import {ChannelEditableProperty} from "tc-shared/ui/modal/channel-edit/Definitions"; + +const spawnChannelEditNew = (connection: ConnectionHandler, channel: ChannelEntry) => { +}; + +class ChannelEditController { + private readonly connection: ConnectionHandler; + private readonly channel: ChannelEntry; + + constructor() { + this.getChannelProperty("sortingOrder"); + } + + getChannelProperty(property: T) : ChannelEditableProperty[T] { + + const properties = this.channel.properties; + + /* + switch (property) { + case "name": + return properties.channel_name; + + case "phoneticName": + return properties.channel_name_phonetic; + + case "topic": + return properties.channel_topic; + + case "description": + return properties.channel_description; + + case "password": + break; + + } + */ + return undefined; + } +} \ No newline at end of file diff --git a/shared/js/ui/modal/channel-edit/Definitions.ts b/shared/js/ui/modal/channel-edit/Definitions.ts new file mode 100644 index 00000000..08a1a3fe --- /dev/null +++ b/shared/js/ui/modal/channel-edit/Definitions.ts @@ -0,0 +1,71 @@ +export interface ChannelEditableProperty { + "name": string, + "sortingOrder": { previousChannelId: number, availableChannels: { channelName: string, channelId: number }[] | undefined }, + /* + "phoneticName": string, + "talkPower": number, + "password": { state: "set", password?: string } | { state: "clear" }, + "topic": string, + "description": string, + "type": "default" | "permanent" | "semi-permanent" | "temporary", + "maxUsers": "unlimited" | number, + "maxFamilyUsers": "unlimited" | "inherited" | number, + "codec": { type: number, quality: number }, + "deleteDelay": number, + "encryptedVoiceData": number + */ +} + +export interface ChannelPropertyPermission { + name: boolean, + password: { editable: boolean, enforced: boolean }, + talkPower: boolean, + sortingOrder: boolean, + topic: boolean, + description: boolean, + channelType: { + permanent: boolean, + semipermanent: boolean, + temporary: boolean, + default: boolean + }, + maxUsers: boolean, + maxFamilyUsers: boolean, + codec: { + opusVoice: boolean, + opusMusic: boolean + }, + deleteDelay: { + editable: boolean, + maxDelay: number, + }, + encryptVoiceData: boolean +} + +export interface ChannelPropertyStatus { + name: boolean, + password: boolean +} + +export interface ChannelEditEvents { + change_property: { + property: keyof ChannelEditableProperty + value: ChannelEditableProperty[keyof ChannelEditableProperty] + }, + + query_property: { + property: keyof ChannelEditableProperty + }, + query_property_permission: { + permission: keyof ChannelPropertyPermission + } + + notify_property: { + property: keyof ChannelEditableProperty + value: ChannelEditableProperty[keyof ChannelEditableProperty] + }, + notify_property_permission: { + permission: keyof ChannelPropertyPermission + value: ChannelPropertyPermission[keyof ChannelPropertyPermission] + } +} \ No newline at end of file diff --git a/shared/js/ui/modal/channel-edit/Renderer.scss b/shared/js/ui/modal/channel-edit/Renderer.scss new file mode 100644 index 00000000..d8b0309a --- /dev/null +++ b/shared/js/ui/modal/channel-edit/Renderer.scss @@ -0,0 +1,7 @@ +.containerGeneral { + flex-shrink: 0; + + display: flex; + flex-direction: column; + justify-content: flex-start; +} \ No newline at end of file diff --git a/shared/js/ui/modal/channel-edit/Renderer.tsx b/shared/js/ui/modal/channel-edit/Renderer.tsx new file mode 100644 index 00000000..c5eb31a3 --- /dev/null +++ b/shared/js/ui/modal/channel-edit/Renderer.tsx @@ -0,0 +1,73 @@ +import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controller"; +import * as React from "react"; +import {Translatable} from "tc-shared/ui/react-elements/i18n"; +import {Registry} from "tc-shared/events"; +import {ChannelEditableProperty, ChannelEditEvents} from "tc-shared/ui/modal/channel-edit/Definitions"; +import {useContext, useState} from "react"; +import {BoxedInputField} from "tc-shared/ui/react-elements/InputField"; + +const cssStyle = require("./Renderer.scss"); + +const EventContext = React.createContext>(undefined); +const ChangesApplying = React.createContext(false); + +const kPropertyLoading = "loading"; + +function useProperty(property: T) : { + originalValue: ChannelEditableProperty[T], + currentValue: ChannelEditableProperty[T], + setCurrentValue: (value: ChannelEditableProperty[T]) => void +} | typeof kPropertyLoading { + const events = useContext(EventContext); + + const [ value, setValue ] = useState(() => { + events.fire("query_property", { property: property }); + return kPropertyLoading; + }); + + events.reactUse("notify_property", event => { + if(event.property !== property) { + return; + } + + setValue(event.value as any); + }, undefined, []); + + return kPropertyLoading; +} + +const ChannelName = () => { + const changesApplying = useContext(ChangesApplying); + const property = useProperty("name"); + + return ( + property !== kPropertyLoading && property.setCurrentValue(newValue)} + /> + ) +} + +const GeneralContainer = () => { + return ( +
+ +
+ ); +} + +export class ChannelEditModal extends InternalModal { + private readonly channelExists: number; + + renderBody(): React.ReactElement { + return (<> + + ); + } + + title(): string | React.ReactElement { + return Create channel; + } +} \ No newline at end of file diff --git a/vendor/xbbcode b/vendor/xbbcode index df9aada5..b884828d 160000 --- a/vendor/xbbcode +++ b/vendor/xbbcode @@ -1 +1 @@ -Subproject commit df9aada506995d35787098694d1ed2e2e2b5a619 +Subproject commit b884828db27025abf0802a015b2fa46bf2c2e44c