diff --git a/loader/app/loader/script_loader.ts b/loader/app/loader/script_loader.ts index 8a7a10a9..2a7c1551 100644 --- a/loader/app/loader/script_loader.ts +++ b/loader/app/loader/script_loader.ts @@ -52,7 +52,7 @@ function load_script_url(url: string) : Promise { document.getElementById("scripts").appendChild(script_tag); script_tag.src = config.baseUrl + url; - })).then(result => { + })).then(() => { /* cleanup memory */ _script_promises[url] = Promise.resolve(); /* this promise does not holds the whole script tag and other memory */ return _script_promises[url]; diff --git a/shared/js/MessageFormatter.tsx b/shared/js/MessageFormatter.tsx index 6b6ade80..1fbb1002 100644 --- a/shared/js/MessageFormatter.tsx +++ b/shared/js/MessageFormatter.tsx @@ -1,445 +1,3 @@ -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) => { - 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 { - 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 ( -
-                             {
-                                    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"
-                                    });
-                                }}
-                            />
-                        
- ); - } - }); - - const regexUrl = /^(?:[a-zA-Z]{1,16}):(?:\/{1,3}|\\)[-a-zA-Z0-9:;,@#%&()~_?+=\/\\.]*$/g; - rendererReact.registerCustomRenderer(new class extends ElementRenderer { - 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 { - event.preventDefault(); - spawnUrlContextMenu(event.pageX, event.pageY, target); - }}> - {renderer.renderContent(element)} - ; - } - - tags(): string | string[] { - return "url"; - } - }); - - const regexImage = /^(?:https?):(?:\/{1,3}|\\)[-a-zA-Z0-9:;,@#%&()~_?+=\/\\.]*$/g; - rendererReact.registerCustomRenderer(new class extends ElementRenderer { - 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 {"[img]" + content + "[/img]"}; - - return ( -
- load_image(event.currentTarget)} x-image-url={encodeURIComponent(target)} title={target} alt={target} /> -
- ); - } - }); - - 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 { - 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({match[0]}); - lastIndex = match.index + match[0].length; - } - - return result; - } - - tags(): string | string[] { return undefined; } - }); - }, - priority: 10 - }); -} - -export const BBCodeChatMessage = (props: { message: string }) => ( - - {bbcode.preprocessMessage(props.message, { is_chat_message: true })} - -); - -export function sanitize_text(text: string) : string { - return $(DOMPurify.sanitize("" + text + "", { - })).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; diff --git a/shared/js/connection/CommandHandler.ts b/shared/js/connection/CommandHandler.ts index fd7ac028..b55b20f5 100644 --- a/shared/js/connection/CommandHandler.ts +++ b/shared/js/connection/CommandHandler.ts @@ -15,12 +15,13 @@ import { } from "tc-shared/ui/client"; import {ChannelEntry} from "tc-shared/ui/channel"; import {ConnectionHandler, ConnectionState, DisconnectReason, ViewReasonId} from "tc-shared/ConnectionHandler"; -import {bbcode_chat, formatMessage} from "tc-shared/ui/frames/chat"; +import {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 {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"; +import {renderBBCodeAsJQuery} from "tc-shared/text/bbcode"; export class ServerConnectionCommandBoss extends AbstractCommandHandlerBoss { constructor(connection: AbstractServerConnection) { @@ -218,7 +219,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { if(properties.virtualserver_hostmessage || properties.virtualserver_hostmessage_mode == 3) createModal({ header: tr("Host message"), - body: bbcode_chat(properties.virtualserver_hostmessage), + body: renderBBCodeAsJQuery(properties.virtualserver_hostmessage, { convertSingleUrls: false }), footer: undefined }).open(); diff --git a/shared/js/proto.ts b/shared/js/proto.ts index 916bcf4e..7f1345a9 100644 --- a/shared/js/proto.ts +++ b/shared/js/proto.ts @@ -1,6 +1,9 @@ //Used by CertAccept popup declare global { + function setInterval(handler: TimerHandler, timeout?: number, ...arguments: any[]): number; + function setTimeout(handler: TimerHandler, timeout?: number, ...arguments: any[]): number; + interface Array { remove(elem?: T): boolean; last?(): T; diff --git a/shared/js/text/bbcode.tsx b/shared/js/text/bbcode.tsx new file mode 100644 index 00000000..cf36517c --- /dev/null +++ b/shared/js/text/bbcode.tsx @@ -0,0 +1,103 @@ +import {XBBCodeRenderer} from "vendor/xbbcode/react"; +import * as React from "react"; +import {rendererHTML, rendererReact} from "tc-shared/text/bbcode/renderer"; +import {parse as parseBBCode} from "vendor/xbbcode/parser"; +import {fixupJQueryUrlTags} from "tc-shared/text/bbcode/url"; +import {fixupJQueryImageTags} from "tc-shared/text/bbcode/image"; + +export const escapeBBCode = (text: string) => text.replace(/([\[\]])/g, "\\$1"); + +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 BBCodeRenderOptions { + convertSingleUrls: boolean; +} + +const yt_url_regex = /^((?: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 */ + single_url_parse: + if(settings.convertSingleUrls) { + 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 const BBCodeRenderer = (props: { message: string, settings: BBCodeRenderOptions }) => ( + + {preprocessMessage(props.message, props.settings)} + +); + + +export function renderBBCodeAsJQuery(message: string, settings: BBCodeRenderOptions) : JQuery[] { + const result = parseBBCode(preprocessMessage(message, settings), { + tag_whitelist: allowedBBCodes + }); + + let html = result.map(e => rendererHTML.render(e)).join(""); + + const container = $.spawn("div") as JQuery; + container[0].innerHTML = html; + + /* fixup some listeners */ + fixupJQueryUrlTags(container); + fixupJQueryImageTags(container); + + return [container.contents() as JQuery]; +} \ No newline at end of file diff --git a/shared/js/text/bbcode/emoji.tsx b/shared/js/text/bbcode/emoji.tsx new file mode 100644 index 00000000..8bd77fa3 --- /dev/null +++ b/shared/js/text/bbcode/emoji.tsx @@ -0,0 +1,80 @@ +import * as loader from "tc-loader"; +import { rendererReact } from "tc-shared/text/bbcode/renderer"; +import {ElementRenderer} from "vendor/xbbcode/renderer/base"; +import {TextElement} from "vendor/xbbcode/elements"; +import * as React from "react"; +import ReactRenderer from "vendor/xbbcode/renderer/react"; +import {Settings, settings} from "tc-shared/settings"; + +import * as emojiRegex from "emoji-regex"; + +const emojiRegexInstance = (emojiRegex as any)() as RegExp; + +loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, { + name: "XBBCode emoji init", + function: async () => { + let reactId = 0; + + 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 { + 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({match[0]}); + lastIndex = match.index + match[0].length; + } + + return result; + } + + tags(): string | string[] { return undefined; } + }); + }, + priority: 10 +}); \ No newline at end of file diff --git a/shared/js/text/bbcode/highlight.tsx b/shared/js/text/bbcode/highlight.tsx new file mode 100644 index 00000000..524cdf8a --- /dev/null +++ b/shared/js/text/bbcode/highlight.tsx @@ -0,0 +1,126 @@ +import * as hljs from "highlight.js/lib/core"; +import * as loader from "tc-loader"; +import {ElementRenderer} from "vendor/xbbcode/renderer/base"; +import {TagElement} from "vendor/xbbcode/elements"; +import * as React from "react"; +import {tra} from "tc-shared/i18n/localize"; +import * as DOMPurify from "dompurify"; +import {copy_to_clipboard} from "tc-shared/utils/helpers"; +import {rendererReact, rendererText} from "tc-shared/text/bbcode/renderer"; +import {MenuEntryType, spawn_context_menu} from "tc-shared/ui/elements/ContextMenu"; + +import '!style-loader!css-loader!highlight.js/styles/darcula.css'; + +const registerLanguage = (name, language: Promise) => { + 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")); + +loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, { + name: "XBBCode highlight init", + function: async () => { + let reactId = 0; + + /* override default parser */ + rendererReact.registerCustomRenderer(new class extends ElementRenderer { + 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 ( +
+                         {
+                                event.preventDefault();
+                                spawn_context_menu(event.pageX, event.pageY, {
+                                    callback: () => copy_to_clipboard(lines.join("\n")),
+                                    name: tr("Copy code"),
+                                    type: MenuEntryType.ENTRY,
+                                    icon_class: "client-copy"
+                                });
+                            }}
+                        />
+                    
+ ); + } + }); + }, + priority: 10 +}); \ No newline at end of file diff --git a/shared/js/text/bbcode/image.tsx b/shared/js/text/bbcode/image.tsx new file mode 100644 index 00000000..aca17136 --- /dev/null +++ b/shared/js/text/bbcode/image.tsx @@ -0,0 +1,89 @@ +import {ElementRenderer} from "vendor/xbbcode/renderer/base"; +import {TagElement} from "vendor/xbbcode/elements"; +import * as React from "react"; +import * as loader from "tc-loader"; +import {rendererReact, rendererText} from "tc-shared/text/bbcode/renderer"; +import * as contextmenu from "tc-shared/ui/elements/ContextMenu"; +import {copy_to_clipboard} from "tc-shared/utils/helpers"; +import * as image_preview from "tc-shared/ui/frames/image_preview"; + +const regexImage = /^(?:https?):(?:\/{1,3}|\\)[-a-zA-Z0-9:;,@#%&()~_?+=\/\\.]*$/g; + +function loadImageForElement(element: HTMLImageElement) { + if(!element.hasAttribute("x-image-url")) + return; + + const url = decodeURIComponent(element.getAttribute("x-image-url") || ""); + element.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); + } + + element.onload = undefined; + element.src = proxiedURL; + + const parent = $(element.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 emoji init", + function: async () => { + let reactId = 0; + + rendererReact.registerCustomRenderer(new class extends ElementRenderer { + 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 {"[img]" + content + "[/img]"}; + + return ( +
+ loadImageForElement(event.currentTarget)} x-image-url={encodeURIComponent(target)} title={target} alt={target} /> +
+ ); + } + }); + }, + priority: 10 +}); + +export function fixupJQueryImageTags(container: JQuery) { + container.find("img").on('load', event => loadImageForElement(event.target as HTMLImageElement)); +} \ No newline at end of file diff --git a/shared/js/text/bbcode/renderer.ts b/shared/js/text/bbcode/renderer.ts new file mode 100644 index 00000000..b001d971 --- /dev/null +++ b/shared/js/text/bbcode/renderer.ts @@ -0,0 +1,10 @@ +import TextRenderer from "vendor/xbbcode/renderer/text"; +import ReactRenderer from "vendor/xbbcode/renderer/react"; +import HTMLRenderer from "vendor/xbbcode/renderer/html"; + +import "./emoji"; +import "./highlight"; + +export const rendererText = new TextRenderer(); +export const rendererReact = new ReactRenderer(); +export const rendererHTML = new HTMLRenderer(rendererReact); \ No newline at end of file diff --git a/shared/js/text/bbcode/url.tsx b/shared/js/text/bbcode/url.tsx new file mode 100644 index 00000000..ccf1c170 --- /dev/null +++ b/shared/js/text/bbcode/url.tsx @@ -0,0 +1,78 @@ +import * as contextmenu from "tc-shared/ui/elements/ContextMenu"; +import {copy_to_clipboard} from "tc-shared/utils/helpers"; +import * as loader from "tc-loader"; +import {ElementRenderer} from "vendor/xbbcode/renderer/base"; +import {TagElement} from "vendor/xbbcode/elements"; +import * as React from "react"; +import ReactRenderer from "vendor/xbbcode/renderer/react"; +import {rendererReact, rendererText} from "tc-shared/text/bbcode/renderer"; + +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" + }); +} + +loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, { + name: "XBBCode code tag init", + function: async () => { + let reactId = 0; + + const regexUrl = /^(?:[a-zA-Z]{1,16}):(?:\/{1,3}|\\)[-a-zA-Z0-9:;,@#%&()~_?+=\/\\.]*$/g; + rendererReact.registerCustomRenderer(new class extends ElementRenderer { + 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 { + event.preventDefault(); + spawnUrlContextMenu(event.pageX, event.pageY, target); + }}> + {renderer.renderContent(element)} + ; + } + + tags(): string | string[] { + return "url"; + } + }); + }, + priority: 10 +}); + + +export function fixupJQueryUrlTags(container: JQuery) { + container.find("a").on('contextmenu', event => { + if(event.isDefaultPrevented()) + return; + + event.preventDefault(); + spawnUrlContextMenu(event.pageX, event.pageY, $(event.target).attr("href")); + }); +} \ No newline at end of file diff --git a/shared/js/text/chat.ts b/shared/js/text/chat.ts new file mode 100644 index 00000000..03673690 --- /dev/null +++ b/shared/js/text/chat.ts @@ -0,0 +1,69 @@ +//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 "tc-shared/log"; +import {LogCategory} from "tc-shared/log"; +import {Settings, settings} from "tc-shared/settings"; +import {renderMarkdownAsBBCode} from "tc-shared/text/markdown"; +import {escapeBBCode} from "tc-shared/text/bbcode"; + +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]; + + _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 */ } + + if(unescaped.match(URL_REGEX)) { + if(flag_escaped) { + message = undefined; + words[index] = unescaped; + } else { + message = undefined; + words[index] = "[url=" + unescaped + "]" + unescaped + "[/url]"; + } + } + } + + return message || words.join(" "); +} + +export function preprocess_chat_message(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); + + if(parse_markdown) { + return renderMarkdownAsBBCode(message, text => { + if(escape_bb) + text = escapeBBCode(text); + + if(process_url) + text = process_urls(text); + + return text; + }); + } + + 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 new file mode 100644 index 00000000..d6f9244e --- /dev/null +++ b/shared/js/text/markdown.ts @@ -0,0 +1,149 @@ +import * as log from "tc-shared/log"; +import {LogCategory} from "tc-shared/log"; +import { + CodeToken, Env, FenceToken, HeadingOpenToken, + ImageToken, + LinkOpenToken, Options, + ParagraphOpenToken, + SubToken, + SupToken, + TextToken, + Token +} from "remarkable/lib"; +import {escapeBBCode} from "tc-shared/text/BBCodeHelper"; +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), + "softbreak": () => "\n", + "hardbreak": () => "\n", + + "paragraph_open": (renderer: MD2BBCodeRenderer, token: ParagraphOpenToken) => { + 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": () => "", + + "strong_open": () => "[b]", + "strong_close": () => "[/b]", + + "em_open": () => "[i]", + "em_close": () => "[/i]", + + "del_open": () => "[s]", + "del_close": () => "[/s]", + + "sup": (renderer: MD2BBCodeRenderer, token: SupToken) => "[sup]" + renderer.options().textProcessor(token.content) + "[/sup]", + "sub": (renderer: MD2BBCodeRenderer, token: SubToken) => "[sub]" + renderer.options().textProcessor(token.content) + "[/sub]", + + "bullet_list_open": () => "[ul]", + "bullet_list_close": () => "[/ul]", + + "ordered_list_open": () => "[ol]", + "ordered_list_close": () => "[/ol]", + + "list_item_open": () => "[li]", + "list_item_close": () => "[/li]", + + "table_open": () => "[table]", + "table_close": () => "[/table]", + + "thead_open": () => "", + "thead_close": () => "", + + "tbody_open": () => "", + "tbody_close": () => "", + + "tr_open": () => "[tr]", + "tr_close": () => "[/tr]", + + "th_open": (renderer: MD2BBCodeRenderer, token: any) => "[th" + (token.align ? ("=" + token.align) : "") + "]", + "th_close": () => "[/th]", + + "td_open": () => "[td]", + "td_close": () => "[/td]", + + "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]", + + //footnote_ref + + //"content": "==Marked text==", + //mark_open + //mark_close + + //++Inserted text++ + "ins_open": () => "[u]", + "ins_close": () => "[/u]", + + "code": (renderer: MD2BBCodeRenderer, token: CodeToken) => "[i-code]" + escapeBBCode(token.content) + "[/i-code]", + "fence": (renderer: MD2BBCodeRenderer, token: FenceToken) => "[code" + (token.params ? ("=" + token.params) : "") + "]" + escapeBBCode(token.content) + "[/code]", + + "heading_open": (renderer: MD2BBCodeRenderer, token: HeadingOpenToken) => "[size=" + (9 - Math.min(4, token.hLevel)) + "]", + "heading_close": () => "[/size][hr]", + + "hr": () => "[hr]", + + //> Experience real-time editing with Remarkable! + "blockquote_open": () => "[quote]", + "blockquote_close": () => "[/quote]" + }; + + private _options; + last_paragraph: Token; + + 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].type === 'inline') { + /* we're just ignoring the inline fact */ + result += this.render((tokens[index] as any).children, options, env); + } else { + result += this.renderToken(tokens[index]); + } + } + + this._options = undefined; + return result; + } + + private renderToken(token: Token) { + log.debug(LogCategory.GENERAL, tr("Render Markdown token: %o"), token); + const renderer = MD2BBCodeRenderer.renderers[token.type]; + if(typeof(renderer) === "undefined") { + log.warn(LogCategory.CHAT, tr("Missing markdown to bbcode renderer for token %s: %o"), token.type, token); + 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; + } + + options() : any { + return this._options; + } +} + +const remarkableRenderer = new Remarkable("full", { + typographer: true +}); +remarkableRenderer.renderer = new 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); + + let result = remarkableRenderer.render(message); + if(result.endsWith("\n")) + result = result.substr(0, result.length - 1); + 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 e68542e3..0f23f9df 100644 --- a/shared/js/ui/frames/chat.ts +++ b/shared/js/ui/frames/chat.ts @@ -1,10 +1,7 @@ import {LogCategory} from "tc-shared/log"; 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, @@ -137,14 +134,6 @@ export function formatMessageString(pattern: string, ...args: string[]) : string return result.join(""); } -//TODO: Remove this (only legacy) -export function bbcode_chat(message: string) : JQuery[] { - return bbcode.format(message, { - is_chat_message: true - }); -} - - export namespace network { export const KB = 1024; export const MB = 1024 * KB; diff --git a/shared/js/ui/frames/chat_frame.ts b/shared/js/ui/frames/chat_frame.ts index 2cc04070..8ae13565 100644 --- a/shared/js/ui/frames/chat_frame.ts +++ b/shared/js/ui/frames/chat_frame.ts @@ -10,9 +10,6 @@ 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; - export enum InfoFrameMode { NONE = "none", CHANNEL_CHAT = "channel_chat", diff --git a/shared/js/ui/frames/server_log.ts b/shared/js/ui/frames/server_log.ts index 053605b2..80c91ab8 100644 --- a/shared/js/ui/frames/server_log.ts +++ b/shared/js/ui/frames/server_log.ts @@ -1,10 +1,11 @@ -import {tra} from "tc-shared/i18n/localize"; +import {tra, traj} from "tc-shared/i18n/localize"; import {PermissionInfo} from "tc-shared/permission/PermissionManager"; import {ConnectionHandler, ViewReasonId} from "tc-shared/ConnectionHandler"; import * as htmltags from "tc-shared/ui/htmltags"; -import {bbcode_chat, format_time, formatMessage} from "tc-shared/ui/frames/chat"; +import {format_time, formatMessage} from "tc-shared/ui/frames/chat"; import {formatDate} from "tc-shared/MessageFormatter"; import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration"; +import {BBCodeRenderOptions, renderBBCodeAsJQuery} from "tc-shared/text/bbcode"; export enum Type { CONNECTION_BEGIN = "connection_begin", @@ -259,7 +260,7 @@ export interface TypeInfo { } export type MessageBuilderOptions = {}; -export type MessageBuilder = (data: TypeInfo[T], options: MessageBuilderOptions) => JQuery[] | undefined; +export type MessageBuilder = (data: TypeInfo[T], options: MessageBuilderOptions) => JQuery[] | string | undefined; export const MessageBuilders: {[key: string]: MessageBuilder} = { "error_custom": (data: event.ErrorCustom) => { @@ -513,12 +514,16 @@ MessageBuilders["client_view_leave"] = (data: event.ClientLeave) => { return [$.spawn("div").addClass("log-error").text("Invalid view leave reason id (" + data.reason + ")")]; }; +const bbcodeRenderOptions: BBCodeRenderOptions = { + convertSingleUrls: false +}; + MessageBuilders["server_welcome_message"] = (data: event.WelcomeMessage) => { - return bbcode_chat("[color=green]" + data.message + "[/color]"); + return renderBBCodeAsJQuery("[color=green]" + data.message + "[/color]", bbcodeRenderOptions); }; MessageBuilders["server_host_message"] = (data: event.WelcomeMessage) => { - return bbcode_chat("[color=green]" + data.message + "[/color]"); + return renderBBCodeAsJQuery("[color=green]" + data.message + "[/color]", bbcodeRenderOptions); }; MessageBuilders["client_nickname_changed"] = (data: event.ClientNicknameChanged) => { @@ -557,14 +562,14 @@ MessageBuilders["server_banned"] = (data: event.ServerBanned) => { const time = data.time == 0 ? tr("ever") : format_time(data.time * 1000, tr("one second")); if(data.invoker.client_id > 0) { if(data.message) - result = tra("You've been banned from the server by {0} for {1}. Reason: {2}", client_tag(data.invoker), time, data.message); + result = traj("You've been banned from the server by {0} for {1}. Reason: {2}", client_tag(data.invoker), time, data.message); else - result = tra("You've been banned from the server by {0} for {1}.", client_tag(data.invoker), time); + result = traj("You've been banned from the server by {0} for {1}.", client_tag(data.invoker), time); } else { if(data.message) - result = tra("You've been banned from the server for {0}. Reason: {1}", time, data.message); + result = traj("You've been banned from the server for {0}. Reason: {1}", time, data.message); else - result = tra("You've been banned from the server for {0}.", time); + result = traj("You've been banned from the server for {0}.", time); } return result.map(e => e.addClass("log-error")); diff --git a/shared/js/ui/frames/side/PrivateConversationManager.ts b/shared/js/ui/frames/side/PrivateConversationManager.ts index 980913b3..e082c7f3 100644 --- a/shared/js/ui/frames/side/PrivateConversationManager.ts +++ b/shared/js/ui/frames/side/PrivateConversationManager.ts @@ -66,8 +66,9 @@ export class PrivateConversation extends AbstractChat text.replace(/([\[\]])/g, "\\$1"); export namespace helpers { - //https://regex101.com/r/YQbfcX/2 - //static readonly URL_REGEX = /^(?([a-zA-Z0-9-]+\.)+[a-zA-Z0-9-]{2,63})(?:\/(?(?:[^\s?]+)?)(?:\?(?\S+))?)?$/gm; - 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]; - - _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 */ } - - if(unescaped.match(URL_REGEX)) { - if(flag_escaped) { - message = undefined; - words[index] = unescaped; - } else { - message = undefined; - words[index] = "[url=" + unescaped + "]" + unescaped + "[/url]"; - } - } - } - - return message || words.join(" "); - } - - export class MD2BBCodeRenderer { - private static renderers: {[key: string]:(renderer: MD2BBCodeRenderer, token: Remarkable.Token) => string} = { - "text": (renderer: MD2BBCodeRenderer, token: Remarkable.TextToken) => renderer.options().process_url ? process_urls(renderer.maybe_escape_bb(token.content)) : renderer.maybe_escape_bb(token.content), - "softbreak": () => "\n", - "hardbreak": () => "\n", - - "paragraph_open": (renderer: MD2BBCodeRenderer, token: Remarkable.ParagraphOpenToken) => { - 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": () => "", - - "strong_open": () => "[b]", - "strong_close": () => "[/b]", - - "em_open": () => "[i]", - "em_close": () => "[/i]", - - "del_open": () => "[s]", - "del_close": () => "[/s]", - - "sup": (renderer: MD2BBCodeRenderer, token: Remarkable.SupToken) => "[sup]" + renderer.maybe_escape_bb(token.content) + "[/sup]", - "sub": (renderer: MD2BBCodeRenderer, token: Remarkable.SubToken) => "[sub]" + renderer.maybe_escape_bb(token.content) + "[/sub]", - - "bullet_list_open": () => "[ul]", - "bullet_list_close": () => "[/ul]", - - "ordered_list_open": () => "[ol]", - "ordered_list_close": () => "[/ol]", - - "list_item_open": () => "[li]", - "list_item_close": () => "[/li]", - - "table_open": () => "[table]", - "table_close": () => "[/table]", - - "thead_open": () => "", - "thead_close": () => "", - - "tbody_open": () => "", - "tbody_close": () => "", - - "tr_open": () => "[tr]", - "tr_close": () => "[/tr]", - - "th_open": (renderer: MD2BBCodeRenderer, token: any) => "[th" + (token.align ? ("=" + token.align) : "") + "]", - "th_close": () => "[/th]", - - "td_open": () => "[td]", - "td_close": () => "[/td]", - - "link_open": (renderer: MD2BBCodeRenderer, token: Remarkable.LinkOpenToken) => "[url" + (token.href ? ("=" + token.href) : "") + "]", - "link_close": () => "[/url]", - - "image": (renderer: MD2BBCodeRenderer, token: Remarkable.ImageToken) => "[img=" + (token.src) + "]" + (token.alt || token.src) + "[/img]", - - //footnote_ref - - //"content": "==Marked text==", - //mark_open - //mark_close - - //++Inserted text++ - "ins_open": () => "[u]", - "ins_close": () => "[/u]", - - "code": (renderer: MD2BBCodeRenderer, token: Remarkable.CodeToken) => "[i-code]" + escapeBBCode(token.content) + "[/i-code]", - "fence": (renderer: MD2BBCodeRenderer, token: Remarkable.FenceToken) => "[code" + (token.params ? ("=" + token.params) : "") + "]" + escapeBBCode(token.content) + "[/code]", - - "heading_open": (renderer: MD2BBCodeRenderer, token: Remarkable.HeadingOpenToken) => "[size=" + (9 - Math.min(4, token.hLevel)) + "]", - "heading_close": () => "[/size][hr]", - - "hr": () => "[hr]", - - //> Experience real-time editing with Remarkable! - "blockquote_open": () => "[quote]", - "blockquote_close": () => "[/quote]" - }; - - private _options; - last_paragraph: Remarkable.Token; - - render(tokens: Remarkable.Token[], options: Remarkable.Options, env: Remarkable.Env): string { - this.last_paragraph = undefined; - this._options = options; - let result = ''; - - //TODO: Escape BB-Codes - for(let index = 0; index < tokens.length; index++) { - if (tokens[index].type === 'inline') { - /* we're just ignoring the inline fact */ - result += this.render((tokens[index] as any).children, options, env); - } else { - result += this.renderToken(tokens[index], index); - } - } - - this._options = undefined; - return result; - } - - private renderToken(token: Remarkable.Token, index: number) { - log.debug(LogCategory.GENERAL, tr("Render Markdown token: %o"), token); - const renderer = MD2BBCodeRenderer.renderers[token.type]; - if(typeof(renderer) === "undefined") { - log.warn(LogCategory.CHAT, tr("Missing markdown to bbcode renderer for token %s: %o"), token.type, token); - return 'content' in token ? token.content : ""; - } - - const result = renderer(this, token); - if(token.type === "paragraph_open") - this.last_paragraph = token; - return result; - } - - options() : any { - return this._options; - } - - maybe_escape_bb(text: string) { - if(this._options.escape_bb) - return escapeBBCode(text); - return text; - } - } - - const remarkableRenderer = new Remarkable("full", { - typographer: true - }); - remarkableRenderer.renderer = new MD2BBCodeRenderer() as any; - remarkableRenderer.inline.ruler.disable([ 'newline', 'autolink' ]); - - function process_markdown(message: string, options: { - process_url?: boolean, - escape_bb?: boolean - }) : string { - - remarkableRenderer.set({ - process_url: !!options.process_url, - escape_bb: !!options.escape_bb - } as any); - - let result: string = remarkableRenderer.render(message); - if(result.endsWith("\n")) - result = result.substr(0, result.length - 1); - return result; - } - - export function preprocess_chat_message(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); - - if(parse_markdown) - return process_markdown(message, { - process_url: process_url, - escape_bb: escape_bb - }); - - if(escape_bb) - message = escapeBBCode(message); - return process_url ? process_urls(message) : message; - } - export namespace date { export function same_day(a: number | Date, b: number | Date) { a = a instanceof Date ? a : new Date(a); diff --git a/shared/js/ui/frames/side/client_info.ts b/shared/js/ui/frames/side/client_info.ts index 92f6fda0..3eb2a348 100644 --- a/shared/js/ui/frames/side/client_info.ts +++ b/shared/js/ui/frames/side/client_info.ts @@ -7,9 +7,6 @@ import * as image_preview from "../image_preview"; import {format} from "tc-shared/ui/frames/side/chat_helper"; import * as i18nc from "tc-shared/i18n/country"; -declare function setInterval(handler: TimerHandler, timeout?: number, ...arguments: any[]): number; -declare function setTimeout(handler: TimerHandler, timeout?: number, ...arguments: any[]): number; - export class ClientInfo { readonly handle: Frame; private _html_tag: JQuery; diff --git a/shared/js/ui/frames/side/music_info.ts b/shared/js/ui/frames/side/music_info.ts index 27a1390b..25aa09ed 100644 --- a/shared/js/ui/frames/side/music_info.ts +++ b/shared/js/ui/frames/side/music_info.ts @@ -9,8 +9,6 @@ import {createErrorModal, createInputModal} from "tc-shared/ui/elements/Modal"; import * as log from "tc-shared/log"; import * as image_preview from "../image_preview"; -declare function setTimeout(handler: TimerHandler, timeout?: number, ...arguments: any[]): number; - interface LoadedSongData { description: string; title: string; diff --git a/shared/js/ui/modal/ModalPoke.ts b/shared/js/ui/modal/ModalPoke.ts index 95a6cd26..e83a5d41 100644 --- a/shared/js/ui/modal/ModalPoke.ts +++ b/shared/js/ui/modal/ModalPoke.ts @@ -1,8 +1,8 @@ import {ConnectionHandler} from "tc-shared/ConnectionHandler"; import {createModal, Modal} from "tc-shared/ui/elements/Modal"; import * as htmltags from "tc-shared/ui/htmltags"; -import {bbcode_chat} from "tc-shared/ui/frames/chat"; import * as moment from "moment"; +import {renderBBCodeAsJQuery} from "tc-shared/text/bbcode"; let global_modal: PokeModal; @@ -60,7 +60,7 @@ class PokeModal { }))).appendTo(container); if(message) { $.spawn("div").addClass("text").text(tr("pokes you:")).appendTo(container); - $.spawn("div").addClass("poke-message").append(...bbcode_chat(message)).appendTo(container); + $.spawn("div").addClass("poke-message").append(...renderBBCodeAsJQuery(message, { convertSingleUrls: false })).appendTo(container); } else { $.spawn("div").addClass("text").text(tr("pokes you.")).appendTo(container); }