some general project restructuring

canary
WolverinDEV 2020-07-19 16:34:08 +02:00
parent 3816700703
commit 873aecbc2e
20 changed files with 738 additions and 682 deletions

View File

@ -52,7 +52,7 @@ function load_script_url(url: string) : Promise<void> {
document.getElementById("scripts").appendChild(script_tag); document.getElementById("scripts").appendChild(script_tag);
script_tag.src = config.baseUrl + url; script_tag.src = config.baseUrl + url;
})).then(result => { })).then(() => {
/* cleanup memory */ /* cleanup memory */
_script_promises[url] = Promise.resolve(); /* this promise does not holds the whole script tag and other memory */ _script_promises[url] = Promise.resolve(); /* this promise does not holds the whole script tag and other memory */
return _script_promises[url]; return _script_promises[url];

View File

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

View File

@ -15,12 +15,13 @@ import {
} from "tc-shared/ui/client"; } from "tc-shared/ui/client";
import {ChannelEntry} from "tc-shared/ui/channel"; import {ChannelEntry} from "tc-shared/ui/channel";
import {ConnectionHandler, ConnectionState, DisconnectReason, ViewReasonId} from "tc-shared/ConnectionHandler"; 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 {server_connections} from "tc-shared/ui/frames/connection_handlers";
import {spawnPoke} from "tc-shared/ui/modal/ModalPoke"; import {spawnPoke} from "tc-shared/ui/modal/ModalPoke";
import {AbstractCommandHandler, AbstractCommandHandlerBoss} from "tc-shared/connection/AbstractCommandHandler"; import {AbstractCommandHandler, AbstractCommandHandlerBoss} from "tc-shared/connection/AbstractCommandHandler";
import {batch_updates, BatchUpdateType, flush_batched_updates} from "tc-shared/ui/react-elements/ReactComponentBase"; import {batch_updates, BatchUpdateType, flush_batched_updates} from "tc-shared/ui/react-elements/ReactComponentBase";
import {OutOfViewClient} from "tc-shared/ui/frames/side/PrivateConversationManager"; import {OutOfViewClient} from "tc-shared/ui/frames/side/PrivateConversationManager";
import {renderBBCodeAsJQuery} from "tc-shared/text/bbcode";
export class ServerConnectionCommandBoss extends AbstractCommandHandlerBoss { export class ServerConnectionCommandBoss extends AbstractCommandHandlerBoss {
constructor(connection: AbstractServerConnection) { constructor(connection: AbstractServerConnection) {
@ -218,7 +219,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
if(properties.virtualserver_hostmessage || properties.virtualserver_hostmessage_mode == 3) if(properties.virtualserver_hostmessage || properties.virtualserver_hostmessage_mode == 3)
createModal({ createModal({
header: tr("Host message"), header: tr("Host message"),
body: bbcode_chat(properties.virtualserver_hostmessage), body: renderBBCodeAsJQuery(properties.virtualserver_hostmessage, { convertSingleUrls: false }),
footer: undefined footer: undefined
}).open(); }).open();

View File

@ -1,6 +1,9 @@
//Used by CertAccept popup //Used by CertAccept popup
declare global { declare global {
function setInterval(handler: TimerHandler, timeout?: number, ...arguments: any[]): number;
function setTimeout(handler: TimerHandler, timeout?: number, ...arguments: any[]): number;
interface Array<T> { interface Array<T> {
remove(elem?: T): boolean; remove(elem?: T): boolean;
last?(): T; last?(): T;

103
shared/js/text/bbcode.tsx Normal file
View File

@ -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 }) => (
<XBBCodeRenderer options={{ tag_whitelist: allowedBBCodes }} renderer={rendererReact}>
{preprocessMessage(props.message, props.settings)}
</XBBCodeRenderer>
);
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];
}

View File

@ -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<TextElement, React.ReactNode> {
render(element: TextElement, renderer: ReactRenderer): React.ReactNode {
if(!settings.static_global(Settings.KEY_CHAT_COLORED_EMOJIES))
return element.text();
let text = element.text();
emojiRegexInstance.lastIndex = 0;
const result = [];
let lastIndex = 0;
while(true) {
let match = emojiRegexInstance.exec(text);
const rawText = text.substring(lastIndex, match?.index);
if(rawText)
result.push(renderer.renderAsText(rawText, false));
if(!match)
break;
let hash = grabTheRightIcon(match[0]);
result.push(<img key={"er-" + ++reactId} draggable={false} src={"https://twemoji.maxcdn.com/v/12.1.2/72x72/" + hash + ".png"} alt={match[0]} className={"chat-emoji"} />);
lastIndex = match.index + match[0].length;
}
return result;
}
tags(): string | string[] { return undefined; }
});
},
priority: 10
});

View File

@ -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<any>) => {
language.then(lan => hljs.registerLanguage(name, lan)).catch(error => {
console.warn("Failed to load language %s (%o)", name, error);
});
};
registerLanguage("javascript", import("highlight.js/lib/languages/javascript"));
registerLanguage("actionscript", import("highlight.js/lib/languages/actionscript"));
registerLanguage("armasm", import("highlight.js/lib/languages/armasm"));
registerLanguage("basic", import("highlight.js/lib/languages/basic"));
registerLanguage("c-like", import("highlight.js/lib/languages/c-like"));
registerLanguage("c", import("highlight.js/lib/languages/c"));
registerLanguage("cmake", import("highlight.js/lib/languages/cmake"));
registerLanguage("coffeescript", import("highlight.js/lib/languages/coffeescript"));
registerLanguage("cpp", import("highlight.js/lib/languages/cpp"));
registerLanguage("csharp", import("highlight.js/lib/languages/csharp"));
registerLanguage("css", import("highlight.js/lib/languages/css"));
registerLanguage("dart", import("highlight.js/lib/languages/dart"));
registerLanguage("delphi", import("highlight.js/lib/languages/delphi"));
registerLanguage("dockerfile", import("highlight.js/lib/languages/dockerfile"));
registerLanguage("elixir", import("highlight.js/lib/languages/elixir"));
registerLanguage("erlang", import("highlight.js/lib/languages/erlang"));
registerLanguage("fortran", import("highlight.js/lib/languages/fortran"));
registerLanguage("go", import("highlight.js/lib/languages/go"));
registerLanguage("groovy", import("highlight.js/lib/languages/groovy"));
registerLanguage("ini", import("highlight.js/lib/languages/ini"));
registerLanguage("java", import("highlight.js/lib/languages/java"));
registerLanguage("javascript", import("highlight.js/lib/languages/javascript"));
registerLanguage("json", import("highlight.js/lib/languages/json"));
registerLanguage("kotlin", import("highlight.js/lib/languages/kotlin"));
registerLanguage("latex", import("highlight.js/lib/languages/latex"));
registerLanguage("lua", import("highlight.js/lib/languages/lua"));
registerLanguage("makefile", import("highlight.js/lib/languages/makefile"));
registerLanguage("markdown", import("highlight.js/lib/languages/markdown"));
registerLanguage("mathematica", import("highlight.js/lib/languages/mathematica"));
registerLanguage("matlab", import("highlight.js/lib/languages/matlab"));
registerLanguage("objectivec", import("highlight.js/lib/languages/objectivec"));
registerLanguage("perl", import("highlight.js/lib/languages/perl"));
registerLanguage("php", import("highlight.js/lib/languages/php"));
registerLanguage("plaintext", import("highlight.js/lib/languages/plaintext"));
registerLanguage("powershell", import("highlight.js/lib/languages/powershell"));
registerLanguage("protobuf", import("highlight.js/lib/languages/protobuf"));
registerLanguage("python", import("highlight.js/lib/languages/python"));
registerLanguage("ruby", import("highlight.js/lib/languages/ruby"));
registerLanguage("rust", import("highlight.js/lib/languages/rust"));
registerLanguage("scala", import("highlight.js/lib/languages/scala"));
registerLanguage("shell", import("highlight.js/lib/languages/shell"));
registerLanguage("sql", import("highlight.js/lib/languages/sql"));
registerLanguage("swift", import("highlight.js/lib/languages/swift"));
registerLanguage("typescript", import("highlight.js/lib/languages/typescript"));
registerLanguage("vbnet", import("highlight.js/lib/languages/vbnet"));
registerLanguage("vbscript", import("highlight.js/lib/languages/vbscript"));
registerLanguage("x86asm", import("highlight.js/lib/languages/x86asm"));
registerLanguage("xml", import("highlight.js/lib/languages/xml"));
registerLanguage("yaml", import("highlight.js/lib/languages/yaml"));
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<TagElement, React.ReactNode> {
tags(): string | string[] {
return ["code", "icode", "i-code"];
}
render(element: TagElement): React.ReactNode {
const klass = element.tagNormalized != 'code' ? "tag-hljs-inline-code" : "tag-hljs-code";
const language = (element.options || "").replace("\"", "'").toLowerCase();
let lines = rendererText.renderContent(element).join("").split("\n");
if(lines.length > 1) {
if(lines[0].length === 0)
lines = lines.slice(1);
if(lines[lines.length - 1]?.length === 0)
lines = lines.slice(0, lines.length - 1);
}
let result: HighlightJSResult;
const detectedLanguage = hljs.getLanguage(language);
if(detectedLanguage)
result = hljs.highlight(detectedLanguage.name, lines.join("\n"), true);
else
result = hljs.highlightAuto(lines.join("\n"));
return (
<pre key={"hrc-" + ++reactId} className={klass}>
<code
className={"hljs"}
title={tra("{} code", result.language || tr("general"))}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(result.value)
}}
onContextMenu={event => {
event.preventDefault();
spawn_context_menu(event.pageX, event.pageY, {
callback: () => copy_to_clipboard(lines.join("\n")),
name: tr("Copy code"),
type: MenuEntryType.ENTRY,
icon_class: "client-copy"
});
}}
/>
</pre>
);
}
});
},
priority: 10
});

View File

@ -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<TagElement, React.ReactNode> {
tags(): string | string[] {
return ["img", "image"];
}
render(element: TagElement): React.ReactNode {
let target;
let content = rendererText.render(element);
if (!element.options) {
target = content;
} else
target = element.options;
regexImage.lastIndex = 0;
if (!regexImage.test(target))
return <React.Fragment key={"er-" + ++reactId}>{"[img]" + content + "[/img]"}</React.Fragment>;
return (
<div key={"irc-" + ++reactId} className={"xbbcode-tag-img"}>
<img src={"img/loading_image.svg"} onLoad={event => loadImageForElement(event.currentTarget)} x-image-url={encodeURIComponent(target)} title={target} alt={target} />
</div>
);
}
});
},
priority: 10
});
export function fixupJQueryImageTags(container: JQuery) {
container.find("img").on('load', event => loadImageForElement(event.target as HTMLImageElement));
}

View File

@ -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);

View File

@ -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<TagElement, React.ReactNode> {
render(element: TagElement, renderer: ReactRenderer): React.ReactNode {
let target;
if (!element.options)
target = rendererText.render(element);
else
target = element.options;
regexUrl.lastIndex = 0;
if (!regexUrl.test(target))
target = '#';
/* TODO: Implement client URLs */
return <a key={"er-" + ++reactId} className={"xbbcode xbbcode-tag-url"} href={target} target={"_blank"} onContextMenu={event => {
event.preventDefault();
spawnUrlContextMenu(event.pageX, event.pageY, target);
}}>
{renderer.renderContent(element)}
</a>;
}
tags(): string | string[] {
return "url";
}
});
},
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"));
});
}

69
shared/js/text/chat.ts Normal file
View File

@ -0,0 +1,69 @@
//https://regex101.com/r/YQbfcX/2
//static readonly URL_REGEX = /^(?<hostname>([a-zA-Z0-9-]+\.)+[a-zA-Z0-9-]{2,63})(?:\/(?<path>(?:[^\s?]+)?)(?:\?(?<query>\S+))?)?$/gm;
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;
}

149
shared/js/text/markdown.ts Normal file
View File

@ -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;
}

View File

@ -1,10 +1,7 @@
import {LogCategory} from "tc-shared/log"; import {LogCategory} from "tc-shared/log";
import {settings, Settings} from "tc-shared/settings"; import {settings, Settings} from "tc-shared/settings";
import * as log from "tc-shared/log"; import * as log from "tc-shared/log";
import {bbcode} from "tc-shared/MessageFormatter";
import * as loader from "tc-loader"; import * as loader from "tc-loader";
import { XBBCodeRenderer } from "vendor/xbbcode/react";
import * as React from "react";
export enum ChatType { export enum ChatType {
GENERAL, GENERAL,
@ -137,14 +134,6 @@ export function formatMessageString(pattern: string, ...args: string[]) : string
return result.join(""); 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 namespace network {
export const KB = 1024; export const KB = 1024;
export const MB = 1024 * KB; export const MB = 1024 * KB;

View File

@ -10,9 +10,6 @@ import {MusicInfo} from "tc-shared/ui/frames/side/music_info";
import {ConversationManager} from "tc-shared/ui/frames/side/ConversationManager"; import {ConversationManager} from "tc-shared/ui/frames/side/ConversationManager";
import {PrivateConversationManager} from "tc-shared/ui/frames/side/PrivateConversationManager"; 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 { export enum InfoFrameMode {
NONE = "none", NONE = "none",
CHANNEL_CHAT = "channel_chat", CHANNEL_CHAT = "channel_chat",

View File

@ -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 {PermissionInfo} from "tc-shared/permission/PermissionManager";
import {ConnectionHandler, ViewReasonId} from "tc-shared/ConnectionHandler"; import {ConnectionHandler, ViewReasonId} from "tc-shared/ConnectionHandler";
import * as htmltags from "tc-shared/ui/htmltags"; 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 {formatDate} from "tc-shared/MessageFormatter";
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration"; import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
import {BBCodeRenderOptions, renderBBCodeAsJQuery} from "tc-shared/text/bbcode";
export enum Type { export enum Type {
CONNECTION_BEGIN = "connection_begin", CONNECTION_BEGIN = "connection_begin",
@ -259,7 +260,7 @@ export interface TypeInfo {
} }
export type MessageBuilderOptions = {}; export type MessageBuilderOptions = {};
export type MessageBuilder<T extends keyof TypeInfo> = (data: TypeInfo[T], options: MessageBuilderOptions) => JQuery[] | undefined; export type MessageBuilder<T extends keyof TypeInfo> = (data: TypeInfo[T], options: MessageBuilderOptions) => JQuery[] | string | undefined;
export const MessageBuilders: {[key: string]: MessageBuilder<any>} = { export const MessageBuilders: {[key: string]: MessageBuilder<any>} = {
"error_custom": (data: event.ErrorCustom) => { "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 + ")")]; 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) => { 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) => { 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) => { 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")); const time = data.time == 0 ? tr("ever") : format_time(data.time * 1000, tr("one second"));
if(data.invoker.client_id > 0) { if(data.invoker.client_id > 0) {
if(data.message) 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 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 { } else {
if(data.message) 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 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")); return result.map(e => e.addClass("log-error"));

View File

@ -66,8 +66,9 @@ export class PrivateConversation extends AbstractChat<PrivateConversationUIEvent
return; return;
if(this.activeClient instanceof ClientEntry) { if(this.activeClient instanceof ClientEntry) {
this.activeClient.setUnread(false); /* clear the unread flag */
if(client instanceof ClientEntry) { if(client instanceof ClientEntry) {
this.activeClient.setUnread(false); /* "transfer" the unread flag */
this.registerChatEvent({ this.registerChatEvent({
type: "partner-instance-changed", type: "partner-instance-changed",
oldClient: this.activeClient.clientNickName(), oldClient: this.activeClient.clientNickName(),
@ -87,7 +88,7 @@ export class PrivateConversation extends AbstractChat<PrivateConversationUIEvent
} }
hasUnreadMessages() : boolean { hasUnreadMessages() : boolean {
return false; return this.unreadTimestamp !== undefined;
} }
handleIncomingMessage(client: ClientEntry | OutOfViewClient, isOwnMessage: boolean, message: ChatMessage) { handleIncomingMessage(client: ClientEntry | OutOfViewClient, isOwnMessage: boolean, message: ChatMessage) {
@ -245,6 +246,14 @@ export class PrivateConversation extends AbstractChat<PrivateConversationUIEvent
this.sendMessageSendingEnabled(this.lastClientInfo.clientId !== 0); this.sendMessageSendingEnabled(this.lastClientInfo.clientId !== 0);
} }
setUnreadTimestamp(timestamp: number | undefined) {
super.setUnreadTimestamp(timestamp);
/* TODO: Move this somehow to the client itself? */
if(this.activeClient instanceof ClientEntry)
this.activeClient.setUnread(timestamp === undefined);
}
protected canClientAccessChat(): boolean { protected canClientAccessChat(): boolean {
return true; return true;
} }

View File

@ -1,215 +1,10 @@
import * as log from "tc-shared/log"; import * as log from "tc-shared/log";
import {LogCategory} from "tc-shared/log"; import {LogCategory} from "tc-shared/log";
import {Settings, settings} from "tc-shared/settings"; import {Settings, settings} from "tc-shared/settings";
const { Remarkable } = require("remarkable");
console.error(Remarkable);
const escapeBBCode = (text: string) => text.replace(/([\[\]])/g, "\\$1"); const escapeBBCode = (text: string) => text.replace(/([\[\]])/g, "\\$1");
export namespace helpers { export namespace helpers {
//https://regex101.com/r/YQbfcX/2
//static readonly URL_REGEX = /^(?<hostname>([a-zA-Z0-9-]+\.)+[a-zA-Z0-9-]{2,63})(?:\/(?<path>(?:[^\s?]+)?)(?:\?(?<query>\S+))?)?$/gm;
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 namespace date {
export function same_day(a: number | Date, b: number | Date) { export function same_day(a: number | Date, b: number | Date) {
a = a instanceof Date ? a : new Date(a); a = a instanceof Date ? a : new Date(a);

View File

@ -7,9 +7,6 @@ import * as image_preview from "../image_preview";
import {format} from "tc-shared/ui/frames/side/chat_helper"; import {format} from "tc-shared/ui/frames/side/chat_helper";
import * as i18nc from "tc-shared/i18n/country"; 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 { export class ClientInfo {
readonly handle: Frame; readonly handle: Frame;
private _html_tag: JQuery; private _html_tag: JQuery;

View File

@ -9,8 +9,6 @@ import {createErrorModal, createInputModal} from "tc-shared/ui/elements/Modal";
import * as log from "tc-shared/log"; import * as log from "tc-shared/log";
import * as image_preview from "../image_preview"; import * as image_preview from "../image_preview";
declare function setTimeout(handler: TimerHandler, timeout?: number, ...arguments: any[]): number;
interface LoadedSongData { interface LoadedSongData {
description: string; description: string;
title: string; title: string;

View File

@ -1,8 +1,8 @@
import {ConnectionHandler} from "tc-shared/ConnectionHandler"; import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {createModal, Modal} from "tc-shared/ui/elements/Modal"; import {createModal, Modal} from "tc-shared/ui/elements/Modal";
import * as htmltags from "tc-shared/ui/htmltags"; import * as htmltags from "tc-shared/ui/htmltags";
import {bbcode_chat} from "tc-shared/ui/frames/chat";
import * as moment from "moment"; import * as moment from "moment";
import {renderBBCodeAsJQuery} from "tc-shared/text/bbcode";
let global_modal: PokeModal; let global_modal: PokeModal;
@ -60,7 +60,7 @@ class PokeModal {
}))).appendTo(container); }))).appendTo(container);
if(message) { if(message) {
$.spawn("div").addClass("text").text(tr("pokes you:")).appendTo(container); $.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 { } else {
$.spawn("div").addClass("text").text(tr("pokes you.")).appendTo(container); $.spawn("div").addClass("text").text(tr("pokes you.")).appendTo(container);
} }