Improced BBCode support

canary
WolverinDEV 2020-12-07 15:07:47 +01:00
parent e94a8a3b4f
commit 08968b3d54
19 changed files with 540 additions and 234 deletions

View File

@ -1,4 +1,15 @@
# Changelog:
* **07.12.20**
- Fixed the Markdown to BBCode transpiler falsely emitting empty lines
- Fixed invalid BBCode escaping
- Added proper BBCode support for lazy close tags
- Improved the URL detecting and replace support (Reduced false positives and proper protocol checking)
- Adding support for list bb codes and background color
- Fixed some minor BBCode parser bugs
- Fixed BBCode inline code style
- URL tags can not contain any other tags
- Correctly parsing the "lazy close tag" `[/]`
* **05.12.20**
- Fixed the webclient for Firefox in incognito mode

26
package-lock.json generated
View File

@ -3962,8 +3962,7 @@
"decode-uri-component": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
"integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=",
"dev": true
"integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU="
},
"decompress-response": {
"version": "3.3.0",
@ -12297,8 +12296,7 @@
"strict-uri-encode": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz",
"integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=",
"dev": true
"integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM="
},
"string-width": {
"version": "1.0.2",
@ -13511,6 +13509,26 @@
}
}
},
"url-knife": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/url-knife/-/url-knife-3.1.3.tgz",
"integrity": "sha512-6GXFPWBHtUBCSzkbJwirszFzL6It73p+KDKejzRO1CEAETrt0uN7WXKihTiHbzaY94nCcXZpqEm+pKkshe2+/A==",
"requires": {
"query-string": "^5.1.1"
},
"dependencies": {
"query-string": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-5.1.1.tgz",
"integrity": "sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw==",
"requires": {
"decode-uri-component": "^0.2.0",
"object-assign": "^4.1.0",
"strict-uri-encode": "^1.0.0"
}
}
}
},
"url-parse-lax": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz",

View File

@ -109,6 +109,7 @@
"sdp-transform": "^2.14.0",
"simplebar-react": "^2.2.0",
"twemoji": "^13.0.0",
"url-knife": "^3.1.3",
"webcrypto-liner": "^1.2.3",
"webrtc-adapter": "^7.5.1"
}

View File

@ -232,30 +232,33 @@ export class Group {
}
log(message: string, ...optionalParams: any[]) : this {
if(!this.enabled)
if(!this.enabled) {
return this;
}
if(!this.initialized) {
if(this.mode == GroupMode.NATIVE) {
if(this._collapsed && console.groupCollapsed)
if(this._collapsed && console.groupCollapsed) {
console.groupCollapsed(this.name, ...this.optionalParams);
else
} else {
console.group(this.name, ...this.optionalParams);
}
} else {
this._log_prefix = " ";
let parent = this.owner;
while(parent) {
if(parent.mode == GroupMode.PREFIX)
if(parent.mode == GroupMode.PREFIX) {
this._log_prefix = this._log_prefix + parent._log_prefix;
else
} else {
break;
}
}
}
this.initialized = true;
}
if(this.mode == GroupMode.NATIVE)
if(this.mode == GroupMode.NATIVE) {
logDirect(this.level, message, ...optionalParams);
else {
} else {
logDirect(this.level, "[%s] " + this._log_prefix + message, category_mapping.get(this.category), ...optionalParams);
}
return this;

View File

@ -0,0 +1,53 @@
:global {
.xbbcode-tag {
&.xbbcode-tag-hr {
border: none;
border-top: .125em solid #555;
margin-top: .1em;
margin-bottom: .1em;
}
&.xbbcode-tag-table {
th, td {
border-color: var(--chat-message-table-border);
}
tr {
background-color: var(--chat-message-table-row-background);
}
tr:nth-child(2n) {
background-color: var(--chat-message-table-row-even-background);
}
}
&.xbbcode-tag-bg-color {
padding: 0 .25em;
line-height: 1em;
border-radius: 2px;
display: inline-block;
}
&.xbbcode-tag-quote {
border-color: var(--chat-message-quote-border);
padding-left: .5em;
color: var(--chat-message-quote);
}
&.xbbcode-tag-img {
padding: .25em;
border-radius: .25em;
overflow: hidden;
max-width: 20em;
max-height: 20em;
img {
width: 100%;
height: 100%;
}
}
}
}

View File

@ -4,15 +4,16 @@ import {rendererHTML, rendererReact, rendererText} from "tc-shared/text/bbcode/r
import {parse as parseBBCode} from "vendor/xbbcode/parser";
import {fixupJQueryUrlTags} from "tc-shared/text/bbcode/url";
import {fixupJQueryImageTags} from "tc-shared/text/bbcode/image";
import "./bbcode.scss";
export const escapeBBCode = (text: string) => text.replace(/([\[\]])/g, "\\$1");
export const escapeBBCode = (text: string) => text.replace(/(\[)/g, "\\$1");
export const allowedBBCodes = [
"b", "big",
"i", "italic",
"u", "underlined",
"s", "strikethrough",
"color",
"color", "bgcolor",
"url",
"code",
"i-code", "icode",
@ -22,7 +23,9 @@ export const allowedBBCodes = [
"left", "l", "center", "c", "right", "r",
"ul", "ol", "list",
"ulist", "olist",
"li",
"*",
"table",
"tr", "td", "th",
@ -37,7 +40,7 @@ export interface BBCodeRenderOptions {
convertSingleUrls: boolean;
}
const yt_url_regex = /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/;
const youtubeUrlRegex = /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/;
function preprocessMessage(message: string, settings: BBCodeRenderOptions) : string {
/* try if its only one url */
@ -53,7 +56,7 @@ function preprocessMessage(message: string, settings: BBCodeRenderOptions) : str
single_url_yt:
{
const result = raw_url.match(yt_url_regex);
const result = raw_url.match(youtubeUrlRegex);
if(!result) break single_url_yt;
return "[yt]https://www.youtube.com/watch?v=" + result[5] + "[/yt]";

View File

@ -13,13 +13,13 @@
&.inlineCode {
display: inline-block;
> .hljs {
padding: 0 .25em!important;
}
white-space: pre-wrap;
margin: 0 0 -0.1em;
vertical-align: bottom;
> :global(.hljs) {
padding: 0 .25em!important;
}
}
&.code {
word-wrap: normal;

View File

@ -1,70 +1,74 @@
//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 "../log";
import {LogCategory} from "../log";
import UrlKnife from 'url-knife';
import {Settings, settings} from "../settings";
import {renderMarkdownAsBBCode} from "../text/markdown";
import {escapeBBCode} from "../text/bbcode";
import { tr } from "tc-shared/i18n/localize";
const URL_REGEX = /^(([a-zA-Z0-9-]+\.)+[a-zA-Z0-9-]{2,63})(?:\/((?:[^\s?]+)?)(?:\?(\S+))?)?$/gm;
function process_urls(message: string) : string {
const words = message.split(/[ \n]/);
for(let index = 0; index < words.length; index++) {
const flag_escaped = words[index].startsWith('!');
const unescaped = flag_escaped ? words[index].substr(1) : words[index];
_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]";
}
interface UrlKnifeUrl {
value: {
url: string,
},
area: string,
index: {
start: number,
end: number
}
}
return message || words.join(" ");
}
export function preprocessChatMessageForSend(message: string) : string {
const process_url = settings.static_global(Settings.KEY_CHAT_TAG_URLS);
const parse_markdown = settings.static_global(Settings.KEY_CHAT_ENABLE_MARKDOWN);
const escape_bb = !settings.static_global(Settings.KEY_CHAT_ENABLE_BBCODE);
if(parse_markdown) {
return renderMarkdownAsBBCode(message, text => {
if(escape_bb)
text = escapeBBCode(text);
if(process_url)
text = process_urls(text);
return text;
function bbcodeLinkUrls(message: string) : string {
const urls: UrlKnifeUrl[] = UrlKnife.TextArea.extractAllUrls(message, {
'ip_v4' : true,
'ip_v6' : false,
'localhost' : false,
'intranet' : true
});
/* we want to go through the urls from the back to the front */
urls.sort((a, b) => b.index.start - a.index.start);
for(const url of urls) {
const prefix = message.substr(0, url.index.start);
const suffix = message.substr(url.index.end);
const urlPath = message.substring(url.index.start, url.index.end);
let bbcodeUrl;
let colonIndex = urlPath.indexOf(":");
if(colonIndex === -1 || colonIndex + 2 < urlPath.length || urlPath[colonIndex + 1] !== "/" || urlPath[colonIndex + 2] !== "/") {
bbcodeUrl = "[url=https://" + urlPath + "]" + urlPath + "[/url]";
} else {
bbcodeUrl = "[url]" + urlPath + "[/url]";
}
if(escape_bb)
message = escapeBBCode(message);
if(process_url)
message = process_urls(message);
message = prefix + bbcodeUrl + suffix;
}
return message;
}
export function preprocessChatMessageForSend(message: string) : string {
const processUrls = settings.static_global(Settings.KEY_CHAT_TAG_URLS);
const parseMarkdown = settings.static_global(Settings.KEY_CHAT_ENABLE_MARKDOWN);
const escapeBBCodes = !settings.static_global(Settings.KEY_CHAT_ENABLE_BBCODE);
if(parseMarkdown) {
return renderMarkdownAsBBCode(message, text => {
if(escapeBBCodes) {
text = escapeBBCode(text);
}
if(processUrls) {
text = bbcodeLinkUrls(text);
}
return text;
});
} else {
if(escapeBBCodes) {
message = escapeBBCode(message);
}
if(processUrls) {
message = bbcodeLinkUrls(message);
}
return message;
}
}

View File

@ -1,10 +1,14 @@
import * as log from "../log";
import {LogCategory} from "../log";
import {LogCategory, logTrace} from "../log";
import {
CodeToken, Env, FenceToken, HeadingOpenToken,
CodeToken,
Env,
FenceToken,
HeadingOpenToken,
ImageToken,
LinkOpenToken, Options,
ParagraphOpenToken,
LinkOpenToken,
Options,
ParagraphCloseToken,
SubToken,
SupToken,
TextToken,
@ -12,21 +16,20 @@ import {
} from "remarkable/lib";
import {escapeBBCode} from "../text/bbcode";
import {tr} from "tc-shared/i18n/localize";
const { Remarkable } = require("remarkable");
export class MD2BBCodeRenderer {
private static renderers: {[key: string]:(renderer: MD2BBCodeRenderer, token: Token) => string} = {
"text": (renderer: MD2BBCodeRenderer, token: TextToken) => renderer.options().textProcessor(token.content),
"text": (renderer: MD2BBCodeRenderer, token: TextToken) => {
renderer.currentLineCount += token.content.split("\n").length;
return renderer.options().textProcessor(token.content);
},
"softbreak": () => "\n",
"hardbreak": () => "\n",
"paragraph_open": (renderer: MD2BBCodeRenderer, token: ParagraphOpenToken) => {
debugger;
const last_line = !renderer.last_paragraph || !renderer.last_paragraph.lines ? 0 : renderer.last_paragraph.lines[1];
const lines = token.lines[0] - last_line;
return [...new Array(lines)].map(() => "[br]").join("");
},
"paragraph_close": () => "",
"paragraph_open": () => "",
"paragraph_close": (_, token: ParagraphCloseToken) => token.tight ? "" : "[br]",
"strong_open": () => "[b]",
"strong_close": () => "[/b]",
@ -70,7 +73,10 @@ export class MD2BBCodeRenderer {
"link_open": (renderer: MD2BBCodeRenderer, token: LinkOpenToken) => "[url" + (token.href ? ("=" + token.href) : "") + "]",
"link_close": () => "[/url]",
"image": (renderer: MD2BBCodeRenderer, token: ImageToken) => "[img=" + (token.src) + "]" + (token.alt || token.src) + "[/img]",
"image": (renderer: MD2BBCodeRenderer, token: ImageToken) => {
renderer.currentLineCount += 1;
return "[img=" + (token.src) + "]" + (token.alt || token.src) + "[/img]";
},
//footnote_ref
@ -96,14 +102,26 @@ export class MD2BBCodeRenderer {
};
private _options;
last_paragraph: Token;
currentLineCount: number;
reset() {
this._options = undefined;
this.currentLineCount = 0;
}
render(tokens: Token[], options: Options, env: Env): string {
this.last_paragraph = undefined;
this._options = options;
let result = '';
for(let index = 0; index < tokens.length; index++) {
if(tokens[index].lines?.length) {
while(this.currentLineCount < tokens[index].lines[0]) {
this.currentLineCount += 1;
result += "[br]";
}
}
if (tokens[index].type === 'inline') {
/* we're just ignoring the inline fact */
result += this.render((tokens[index] as any).children, options, env);
@ -124,10 +142,7 @@ export class MD2BBCodeRenderer {
return 'content' in token ? this.options().textProcessor(token.content) : "";
}
const result = renderer(this, token);
if(token.type === "paragraph_open")
this.last_paragraph = token;
return result;
return renderer(this, token);
}
options() : any {
@ -135,17 +150,19 @@ export class MD2BBCodeRenderer {
}
}
const md2bbCodeRenderer = new MD2BBCodeRenderer();
const remarkableRenderer = new Remarkable("full", {
typographer: true
});
remarkableRenderer.renderer = new MD2BBCodeRenderer() as any;
remarkableRenderer.renderer = md2bbCodeRenderer as any;
remarkableRenderer.inline.ruler.disable([ 'newline', 'autolink' ]);
export function renderMarkdownAsBBCode(message: string, textProcessor: (text: string) => string) : string {
remarkableRenderer.set({ textProcessor: textProcessor } as any);
md2bbCodeRenderer.reset();
let result = remarkableRenderer.render(message);
if(result.endsWith("\n"))
result = result.substr(0, result.length - 1);
logTrace(LogCategory.CHAT, tr("Markdown render result:\n%s"), result);
return result;
}

View File

@ -19,10 +19,11 @@ export function htmlEscape(message: string) : string[] {
}
export function formatElement(object: any, escape_html: boolean = true) : JQuery[] {
if($.isArray(object)) {
if(Array.isArray(object)) {
let result = [];
for(let element of object)
for(let element of object) {
result.push(...formatElement(element, escape_html));
}
return result;
} else if(typeof(object) == "string") {
if(object.length == 0) return [];

View File

@ -275,91 +275,91 @@ export enum FrameContent {
export class Frame {
readonly handle: ConnectionHandler;
private _info_frame: InfoFrame;
private _html_tag: JQuery;
private _container_info: JQuery;
private _container_chat: JQuery;
private infoFrame: InfoFrame;
private htmlTag: JQuery;
private containerInfo: JQuery;
private containerChannelChat: JQuery;
private _content_type: FrameContent;
private _client_info: ClientInfo;
private _music_info: MusicInfo;
private _channel_conversations: ConversationManager;
private _private_conversations: PrivateConversationManager;
private clientInfo: ClientInfo;
private musicInfo: MusicInfo;
private channelConversations: ConversationManager;
private privateConversations: PrivateConversationManager;
constructor(handle: ConnectionHandler) {
this.handle = handle;
this._content_type = FrameContent.NONE;
this._info_frame = new InfoFrame(this);
this._private_conversations = new PrivateConversationManager(handle);
this._channel_conversations = new ConversationManager(handle);
this._client_info = new ClientInfo(this);
this._music_info = new MusicInfo(this);
this.infoFrame = new InfoFrame(this);
this.privateConversations = new PrivateConversationManager(handle);
this.channelConversations = new ConversationManager(handle);
this.clientInfo = new ClientInfo(this);
this.musicInfo = new MusicInfo(this);
this._build_html_tag();
this.show_channel_conversations();
this.info_frame().update_chat_counter();
}
html_tag() : JQuery { return this._html_tag; }
info_frame() : InfoFrame { return this._info_frame; }
html_tag() : JQuery { return this.htmlTag; }
info_frame() : InfoFrame { return this.infoFrame; }
content_type() : FrameContent { return this._content_type; }
destroy() {
this._html_tag && this._html_tag.remove();
this._html_tag = undefined;
this.htmlTag && this.htmlTag.remove();
this.htmlTag = undefined;
this._info_frame && this._info_frame.destroy();
this._info_frame = undefined;
this.infoFrame && this.infoFrame.destroy();
this.infoFrame = undefined;
this._client_info && this._client_info.destroy();
this._client_info = undefined;
this.clientInfo && this.clientInfo.destroy();
this.clientInfo = undefined;
this._music_info && this._music_info.destroy();
this._music_info = undefined;
this.musicInfo && this.musicInfo.destroy();
this.musicInfo = undefined;
this._private_conversations && this._private_conversations.destroy();
this._private_conversations = undefined;
this.privateConversations && this.privateConversations.destroy();
this.privateConversations = undefined;
this._channel_conversations && this._channel_conversations.destroy();
this._channel_conversations = undefined;
this.channelConversations && this.channelConversations.destroy();
this.channelConversations = undefined;
this._container_info && this._container_info.remove();
this._container_info = undefined;
this.containerInfo && this.containerInfo.remove();
this.containerInfo = undefined;
this._container_chat && this._container_chat.remove();
this._container_chat = undefined;
this.containerChannelChat && this.containerChannelChat.remove();
this.containerChannelChat = undefined;
}
private _build_html_tag() {
this._html_tag = $("#tmpl_frame_chat").renderTag();
this._container_info = this._html_tag.find(".container-info");
this._container_chat = this._html_tag.find(".container-chat");
this.htmlTag = $("#tmpl_frame_chat").renderTag();
this.containerInfo = this.htmlTag.find(".container-info");
this.containerChannelChat = this.htmlTag.find(".container-chat");
this._info_frame.html_tag().appendTo(this._container_info);
this.infoFrame.html_tag().appendTo(this.containerInfo);
}
private_conversations() : PrivateConversationManager {
return this._private_conversations;
return this.privateConversations;
}
channel_conversations() : ConversationManager {
return this._channel_conversations;
return this.channelConversations;
}
client_info() : ClientInfo {
return this._client_info;
return this.clientInfo;
}
music_info() : MusicInfo {
return this._music_info;
return this.musicInfo;
}
private _clear() {
this._content_type = FrameContent.NONE;
this._container_chat.children().detach();
this.containerChannelChat.children().detach();
}
show_private_conversations() {
@ -368,9 +368,9 @@ export class Frame {
this._clear();
this._content_type = FrameContent.PRIVATE_CHAT;
this._container_chat.append(this._private_conversations.htmlTag);
this._private_conversations.handlePanelShow();
this._info_frame.set_mode(InfoFrameMode.PRIVATE_CHAT);
this.containerChannelChat.append(this.privateConversations.htmlTag);
this.privateConversations.handlePanelShow();
this.infoFrame.set_mode(InfoFrameMode.PRIVATE_CHAT);
}
show_channel_conversations() {
@ -379,36 +379,36 @@ export class Frame {
this._clear();
this._content_type = FrameContent.CHANNEL_CHAT;
this._container_chat.append(this._channel_conversations.htmlTag);
this._channel_conversations.handlePanelShow();
this.containerChannelChat.append(this.channelConversations.htmlTag);
this.channelConversations.handlePanelShow();
this._info_frame.set_mode(InfoFrameMode.CHANNEL_CHAT);
this.infoFrame.set_mode(InfoFrameMode.CHANNEL_CHAT);
}
show_client_info(client: ClientEntry) {
this._client_info.set_current_client(client);
this._info_frame.set_mode(InfoFrameMode.CLIENT_INFO); /* specially needs an update here to update the conversation button */
this.clientInfo.set_current_client(client);
this.infoFrame.set_mode(InfoFrameMode.CLIENT_INFO); /* specially needs an update here to update the conversation button */
if(this._content_type === FrameContent.CLIENT_INFO)
return;
this._client_info.previous_frame_content = this._content_type;
this.clientInfo.previous_frame_content = this._content_type;
this._clear();
this._content_type = FrameContent.CLIENT_INFO;
this._container_chat.append(this._client_info.html_tag());
this.containerChannelChat.append(this.clientInfo.html_tag());
}
show_music_player(client: MusicClientEntry) {
this._music_info.set_current_bot(client);
this.musicInfo.set_current_bot(client);
if(this._content_type === FrameContent.MUSIC_BOT)
return;
this._info_frame.set_mode(InfoFrameMode.MUSIC_BOT);
this._music_info.previous_frame_content = this._content_type;
this.infoFrame.set_mode(InfoFrameMode.MUSIC_BOT);
this.musicInfo.previous_frame_content = this._content_type;
this._clear();
this._content_type = FrameContent.MUSIC_BOT;
this._container_chat.append(this._music_info.html_tag());
this.containerChannelChat.append(this.musicInfo.html_tag());
}
set_content(type: FrameContent) {
@ -422,7 +422,7 @@ export class Frame {
else {
this._clear();
this._content_type = FrameContent.NONE;
this._info_frame.set_mode(InfoFrameMode.NONE);
this.infoFrame.set_mode(InfoFrameMode.NONE);
}
}
}

View File

@ -31,8 +31,9 @@ const EmojiButton = (props: { events: Registry<ChatBoxEvents> }) => {
const refContainer = useRef();
useEffect(() => {
if(!shown)
if(!shown) {
return;
}
const clickListener = (event: MouseEvent) => {
let target = event.target as HTMLElement;
@ -90,8 +91,9 @@ const nodeToText = (element: Node) => {
return '\n';
}
if(element.children.length > 0)
if(element.children.length > 0) {
return [...element.childNodes].map(nodeToText).join("");
}
return typeof(element.innerText) === "string" ? element.innerText : "";
} else {
@ -139,14 +141,14 @@ const TextInput = (props: { events: Registry<ChatBoxEvents>, enabled?: boolean,
const clipboard = event.clipboardData || (window as any).clipboardData;
if(!clipboard) return;
const raw_text = clipboard.getData('text/plain');
const rawText = clipboard.getData('text/plain');
const selection = window.getSelection();
if (!selection.rangeCount)
return false;
let htmlXML = clipboard.getData('text/html');
if(!htmlXML) {
pasteTextTransformElement.textContent = raw_text;
pasteTextTransformElement.textContent = rawText;
htmlXML = pasteTextTransformElement.innerHTML;
}
@ -159,18 +161,22 @@ const TextInput = (props: { events: Registry<ChatBoxEvents>, enabled?: boolean,
{
let prefix_length = 0, suffix_length = 0;
{
for (let i = 0; i < raw_text.length; i++)
if (raw_text.charAt(i) === '\n')
for (let i = 0; i < rawText.length; i++) {
if (rawText.charAt(i) === '\n') {
prefix_length++;
else if (raw_text.charAt(i) !== '\r')
} else if (rawText.charAt(i) !== '\r') {
break;
}
}
for (let i = raw_text.length - 1; i >= 0; i++)
if (raw_text.charAt(i) === '\n')
for (let i = rawText.length - 1; i >= 0; i++) {
if (rawText.charAt(i) === '\n') {
suffix_length++;
else if (raw_text.charAt(i) !== '\r')
} else if (rawText.charAt(i) !== '\r') {
break;
}
}
}
data = data.replace(/^[\n\r]+|[\n\r]+$/g, '');
data = "\n".repeat(prefix_length) + data + "\n".repeat(suffix_length);
@ -186,14 +192,16 @@ const TextInput = (props: { events: Registry<ChatBoxEvents>, enabled?: boolean,
const inputEmpty = refInput.current.innerText.trim().length === 0;
if(event.key === "Enter" && !event.shiftKey) {
if(inputEmpty)
if(inputEmpty) {
return;
}
const text = refInput.current.innerText;
props.events.fire("action_submit_message", { message: text });
history.current.push(text);
while(history.current.length > 10)
while(history.current.length > 10) {
history.current.pop_front();
}
refInput.current.innerText = "";
setHistoryIndex(-1);
@ -221,15 +229,17 @@ const TextInput = (props: { events: Registry<ChatBoxEvents>, enabled?: boolean,
props.events.reactUse("action_request_focus", () => refInput.current?.focus());
props.events.reactUse("notify_typing", () => {
if(typeof typingTimeout.current === "number")
if(typeof typingTimeout.current === "number") {
return;
}
typingTimeout.current = setTimeout(() => typingTimeout.current = undefined, 1000);
});
props.events.reactUse("action_insert_text", event => {
refInput.current.innerHTML = refInput.current.innerHTML + event.text;
if(event.focus)
if(event.focus) {
refInput.current.focus();
}
});
props.events.reactUse("action_set_enabled", event => {
setEnabled(event.enabled);
@ -273,8 +283,9 @@ const MarkdownFormatHelper = () => {
const [ visible, setVisible ] = useState(settings.global(Settings.KEY_CHAT_ENABLE_MARKDOWN));
settings.events.reactUse("notify_setting_changed", event => {
if(event.setting !== Settings.KEY_CHAT_ENABLE_MARKDOWN.key)
if(event.setting !== Settings.KEY_CHAT_ENABLE_MARKDOWN.key) {
return;
}
setVisible(settings.global(Settings.KEY_CHAT_ENABLE_MARKDOWN));
});
@ -323,7 +334,8 @@ export class ChatBox extends React.Component<ChatBoxProperties, ChatBoxState> {
}
componentDidUpdate(prevProps: Readonly<ChatBoxProperties>, prevState: Readonly<ChatBoxState>, snapshot?: any): void {
if(prevState.enabled !== this.state.enabled)
if(prevState.enabled !== this.state.enabled) {
this.events.fire_react("action_set_enabled", { enabled: this.state.enabled });
}
}
}

View File

@ -298,34 +298,6 @@ html:root {
margin-bottom: .1em;
}
table {
th, td {
border-color: var(--chat-message-table-border);
}
tr {
background-color: var(--chat-message-table-row-background);
}
tr:nth-child(2n) {
background-color: var(--chat-message-table-row-even-background);
}
}
:global(.xbbcode-tag-img) {
padding: .25em;
border-radius: .25em;
overflow: hidden;
max-width: 20em;
max-height: 20em;
img {
width: 100%;
height: 100%;
}
}
:global(.chat-emoji) {
height: 1.1em;
width: 1.1em;
@ -341,12 +313,6 @@ html:root {
margin-bottom: .1em;
}
}
:global(.xbbcode-tag-quote) {
border-color: var(--chat-message-quote-border);
padding-left: .5em;
color: var(--chat-message-quote);
}
}
&:before {

View File

@ -7,64 +7,79 @@ import {
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {ContextDivider} from "tc-shared/ui/react-elements/ContextDivider";
import {ConversationPanel} from "tc-shared/ui/frames/side/ConversationUI";
import {useEffect, useState} from "react";
import {useContext, useEffect, useState} from "react";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
import {AvatarRenderer} from "tc-shared/ui/react-elements/Avatar";
import {TimestampRenderer} from "tc-shared/ui/react-elements/TimestampRenderer";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {getGlobalAvatarManagerFactory} from "tc-shared/file/Avatars";
const cssStyle = require("./PrivateConversationUI.scss");
const kTypingTimeout = 5000;
const ConversationEntryInfo = React.memo((props: { events: Registry<PrivateConversationUIEvents>, chatId: string, initialNickname: string, lastMessage: number }) => {
const HandlerIdContext = React.createContext<string>(undefined);
const EventContext = React.createContext<Registry<PrivateConversationUIEvents>>(undefined);
const ConversationEntryInfo = React.memo((props: { chatId: string, initialNickname: string, lastMessage: number }) => {
const events = useContext(EventContext);
const [ nickname, setNickname ] = useState(props.initialNickname);
const [ lastMessage, setLastMessage ] = useState(props.lastMessage);
const [ typingTimestamp, setTypingTimestamp ] = useState(0);
props.events.reactUse("notify_partner_name_changed", event => {
if(event.chatId !== props.chatId)
events.reactUse("notify_partner_name_changed", event => {
if(event.chatId !== props.chatId) {
return;
}
setNickname(event.name);
});
props.events.reactUse("notify_partner_changed", event => {
if(event.chatId !== props.chatId)
events.reactUse("notify_partner_changed", event => {
if(event.chatId !== props.chatId) {
return;
}
setNickname(event.name);
});
props.events.reactUse("notify_chat_event", event => {
if(event.chatId !== props.chatId)
events.reactUse("notify_chat_event", event => {
if(event.chatId !== props.chatId) {
return;
}
if(event.event.type === "message") {
if(event.event.timestamp > lastMessage)
if(event.event.timestamp > lastMessage) {
setLastMessage(event.event.timestamp);
}
if(!event.event.isOwnMessage)
if(!event.event.isOwnMessage) {
setTypingTimestamp(0);
}
} else if(event.event.type === "partner-action" || event.event.type === "local-action") {
setTypingTimestamp(0);
}
});
props.events.reactUse("notify_conversation_state", event => {
if(event.chatId !== props.chatId || event.state !== "normal")
events.reactUse("notify_conversation_state", event => {
if(event.chatId !== props.chatId || event.state !== "normal") {
return;
}
let lastStatedMessage = event.events
.filter(e => e.type === "message")
.sort((a, b) => a.timestamp - b.timestamp)
.last()?.timestamp;
if(typeof lastStatedMessage === "number" && (typeof lastMessage === "undefined" || lastStatedMessage > lastMessage))
if(typeof lastStatedMessage === "number" && (typeof lastMessage === "undefined" || lastStatedMessage > lastMessage)) {
setLastMessage(lastStatedMessage);
}
});
props.events.reactUse("notify_partner_typing", event => {
if(event.chatId !== props.chatId)
events.reactUse("notify_partner_typing", event => {
if(event.chatId !== props.chatId) {
return;
}
setTypingTimestamp(Date.now());
});
@ -100,17 +115,18 @@ const ConversationEntryInfo = React.memo((props: { events: Registry<PrivateConve
</div>
);
});
const ConversationEntryUnreadMarker = React.memo((props: { events: Registry<PrivateConversationUIEvents>, chatId: string, initialUnread: boolean }) => {
const ConversationEntryUnreadMarker = React.memo((props: { chatId: string, initialUnread: boolean }) => {
const events = useContext(EventContext);
const [ unread, setUnread ] = useState(props.initialUnread);
props.events.reactUse("notify_unread_timestamp_changed", event => {
events.reactUse("notify_unread_timestamp_changed", event => {
if(event.chatId !== props.chatId)
return;
setUnread(event.timestamp !== undefined);
});
props.events.reactUse("notify_chat_event", event => {
events.reactUse("notify_chat_event", event => {
if(event.chatId !== props.chatId || !event.triggerUnread)
return;
@ -123,10 +139,13 @@ const ConversationEntryUnreadMarker = React.memo((props: { events: Registry<Priv
return <div key={"unread-marker"} className={cssStyle.unread} />;
});
const ConversationEntry = React.memo((props: { events: Registry<PrivateConversationUIEvents>, info: PrivateConversationInfo, selected: boolean, connection: ConnectionHandler }) => {
const ConversationEntry = React.memo((props: { info: PrivateConversationInfo, selected: boolean }) => {
const events = useContext(EventContext);
const handlerId = useContext(HandlerIdContext);
const [ clientId, setClientId ] = useState(props.info.clientId);
props.events.reactUse("notify_partner_changed", event => {
events.reactUse("notify_partner_changed", event => {
if(event.chatId !== props.info.chatId)
return;
@ -134,37 +153,41 @@ const ConversationEntry = React.memo((props: { events: Registry<PrivateConversat
setClientId(event.clientId);
});
const avatarFactory = getGlobalAvatarManagerFactory().getManager(handlerId);
return (
<div
className={cssStyle.conversationEntry + " " + (props.selected ? cssStyle.selected : "")}
onClick={() => props.events.fire("action_select_chat", { chatId: props.info.chatId })}
onClick={() => events.fire("action_select_chat", { chatId: props.info.chatId })}
>
<div className={cssStyle.containerAvatar}>
<AvatarRenderer className={cssStyle.avatar} avatar={props.connection.fileManager.avatars.resolveClientAvatar({ id: clientId, database_id: 0, clientUniqueId: props.info.uniqueId })} />
<ConversationEntryUnreadMarker chatId={props.info.chatId} events={props.events} initialUnread={props.info.unreadMessages} />
<AvatarRenderer className={cssStyle.avatar} avatar={avatarFactory.resolveClientAvatar({ id: clientId, database_id: 0, clientUniqueId: props.info.uniqueId })} />
<ConversationEntryUnreadMarker chatId={props.info.chatId} initialUnread={props.info.unreadMessages} />
</div>
<ConversationEntryInfo events={props.events} chatId={props.info.chatId} initialNickname={props.info.nickname} lastMessage={props.info.lastMessage} />
<ConversationEntryInfo chatId={props.info.chatId} initialNickname={props.info.nickname} lastMessage={props.info.lastMessage} />
<div className={cssStyle.close} onClick={() => {
props.events.fire("action_close_chat", { chatId: props.info.chatId });
events.fire("action_close_chat", { chatId: props.info.chatId });
}} />
</div>
);
});
const OpenConversationsPanel = React.memo((props: { events: Registry<PrivateConversationUIEvents>, connection: ConnectionHandler }) => {
const OpenConversationsPanel = React.memo(() => {
const events = useContext(EventContext);
const [ conversations, setConversations ] = useState<PrivateConversationInfo[] | "loading">(() => {
props.events.fire("query_private_conversations");
events.fire("query_private_conversations");
return "loading";
});
const [ selected, setSelected ] = useState("unselected");
props.events.reactUse("notify_private_conversations", event => {
events.reactUse("notify_private_conversations", event => {
setConversations(event.conversations);
setSelected(event.selected);
});
props.events.reactUse("notify_selected_chat", event => {
events.reactUse("notify_selected_chat", event => {
setSelected(event.chatId);
});
@ -184,10 +207,8 @@ const OpenConversationsPanel = React.memo((props: { events: Registry<PrivateConv
} else {
content = conversations.map(e => <ConversationEntry
key={"c-" + e.chatId}
events={props.events}
info={e}
selected={e.chatId === selected}
connection={props.connection}
/>)
}
@ -200,8 +221,12 @@ const OpenConversationsPanel = React.memo((props: { events: Registry<PrivateConv
export const PrivateConversationsPanel = (props: { events: Registry<PrivateConversationUIEvents>, handler: ConnectionHandler }) => (
<HandlerIdContext.Provider value={props.handler.handlerId}>
<EventContext.Provider value={props.events}>
<ContextDivider id={"seperator-conversation-list-messages"} direction={"horizontal"} defaultValue={25} separatorClassName={cssStyle.divider}>
<OpenConversationsPanel events={props.events} connection={props.handler} />
<OpenConversationsPanel />
<ConversationPanel events={props.events as any} handlerId={props.handler.handlerId} noFirstMessageOverlay={true} messagesDeletable={false} />
</ContextDivider>
</EventContext.Provider>
</HandlerIdContext.Provider>
);

View File

@ -0,0 +1,41 @@
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {ChannelEntry} from "tc-shared/tree/Channel";
import {ChannelEditableProperty} from "tc-shared/ui/modal/channel-edit/Definitions";
const spawnChannelEditNew = (connection: ConnectionHandler, channel: ChannelEntry) => {
};
class ChannelEditController {
private readonly connection: ConnectionHandler;
private readonly channel: ChannelEntry;
constructor() {
this.getChannelProperty("sortingOrder");
}
getChannelProperty<T extends keyof ChannelEditableProperty>(property: T) : ChannelEditableProperty[T] {
const properties = this.channel.properties;
/*
switch (property) {
case "name":
return properties.channel_name;
case "phoneticName":
return properties.channel_name_phonetic;
case "topic":
return properties.channel_topic;
case "description":
return properties.channel_description;
case "password":
break;
}
*/
return undefined;
}
}

View File

@ -0,0 +1,71 @@
export interface ChannelEditableProperty {
"name": string,
"sortingOrder": { previousChannelId: number, availableChannels: { channelName: string, channelId: number }[] | undefined },
/*
"phoneticName": string,
"talkPower": number,
"password": { state: "set", password?: string } | { state: "clear" },
"topic": string,
"description": string,
"type": "default" | "permanent" | "semi-permanent" | "temporary",
"maxUsers": "unlimited" | number,
"maxFamilyUsers": "unlimited" | "inherited" | number,
"codec": { type: number, quality: number },
"deleteDelay": number,
"encryptedVoiceData": number
*/
}
export interface ChannelPropertyPermission {
name: boolean,
password: { editable: boolean, enforced: boolean },
talkPower: boolean,
sortingOrder: boolean,
topic: boolean,
description: boolean,
channelType: {
permanent: boolean,
semipermanent: boolean,
temporary: boolean,
default: boolean
},
maxUsers: boolean,
maxFamilyUsers: boolean,
codec: {
opusVoice: boolean,
opusMusic: boolean
},
deleteDelay: {
editable: boolean,
maxDelay: number,
},
encryptVoiceData: boolean
}
export interface ChannelPropertyStatus {
name: boolean,
password: boolean
}
export interface ChannelEditEvents {
change_property: {
property: keyof ChannelEditableProperty
value: ChannelEditableProperty[keyof ChannelEditableProperty]
},
query_property: {
property: keyof ChannelEditableProperty
},
query_property_permission: {
permission: keyof ChannelPropertyPermission
}
notify_property: {
property: keyof ChannelEditableProperty
value: ChannelEditableProperty[keyof ChannelEditableProperty]
},
notify_property_permission: {
permission: keyof ChannelPropertyPermission
value: ChannelPropertyPermission[keyof ChannelPropertyPermission]
}
}

View File

@ -0,0 +1,7 @@
.containerGeneral {
flex-shrink: 0;
display: flex;
flex-direction: column;
justify-content: flex-start;
}

View File

@ -0,0 +1,73 @@
import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controller";
import * as React from "react";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {Registry} from "tc-shared/events";
import {ChannelEditableProperty, ChannelEditEvents} from "tc-shared/ui/modal/channel-edit/Definitions";
import {useContext, useState} from "react";
import {BoxedInputField} from "tc-shared/ui/react-elements/InputField";
const cssStyle = require("./Renderer.scss");
const EventContext = React.createContext<Registry<ChannelEditEvents>>(undefined);
const ChangesApplying = React.createContext(false);
const kPropertyLoading = "loading";
function useProperty<T extends keyof ChannelEditableProperty>(property: T) : {
originalValue: ChannelEditableProperty[T],
currentValue: ChannelEditableProperty[T],
setCurrentValue: (value: ChannelEditableProperty[T]) => void
} | typeof kPropertyLoading {
const events = useContext(EventContext);
const [ value, setValue ] = useState(() => {
events.fire("query_property", { property: property });
return kPropertyLoading;
});
events.reactUse("notify_property", event => {
if(event.property !== property) {
return;
}
setValue(event.value as any);
}, undefined, []);
return kPropertyLoading;
}
const ChannelName = () => {
const changesApplying = useContext(ChangesApplying);
const property = useProperty("name");
return (
<BoxedInputField
disabled={changesApplying || property === kPropertyLoading}
value={property === kPropertyLoading ? null : property.currentValue}
placeholder={property === kPropertyLoading ? tr("loading") : tr("Channel name")}
onInput={newValue => property !== kPropertyLoading && property.setCurrentValue(newValue)}
/>
)
}
const GeneralContainer = () => {
return (
<div className={cssStyle.containerGeneral}>
<ChannelName />
</div>
);
}
export class ChannelEditModal extends InternalModal {
private readonly channelExists: number;
renderBody(): React.ReactElement {
return (<>
<GeneralContainer />
</>);
}
title(): string | React.ReactElement {
return <Translatable key={"create"}>Create channel</Translatable>;
}
}

2
vendor/xbbcode vendored

@ -1 +1 @@
Subproject commit df9aada506995d35787098694d1ed2e2e2b5a619
Subproject commit b884828db27025abf0802a015b2fa46bf2c2e44c