Improced BBCode support

This commit is contained in:
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: # 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** * **05.12.20**
- Fixed the webclient for Firefox in incognito mode - Fixed the webclient for Firefox in incognito mode

26
package-lock.json generated
View file

@ -3962,8 +3962,7 @@
"decode-uri-component": { "decode-uri-component": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
"integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU="
"dev": true
}, },
"decompress-response": { "decompress-response": {
"version": "3.3.0", "version": "3.3.0",
@ -12297,8 +12296,7 @@
"strict-uri-encode": { "strict-uri-encode": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz",
"integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=", "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM="
"dev": true
}, },
"string-width": { "string-width": {
"version": "1.0.2", "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": { "url-parse-lax": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", "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", "sdp-transform": "^2.14.0",
"simplebar-react": "^2.2.0", "simplebar-react": "^2.2.0",
"twemoji": "^13.0.0", "twemoji": "^13.0.0",
"url-knife": "^3.1.3",
"webcrypto-liner": "^1.2.3", "webcrypto-liner": "^1.2.3",
"webrtc-adapter": "^7.5.1" "webrtc-adapter": "^7.5.1"
} }

View file

@ -232,30 +232,33 @@ export class Group {
} }
log(message: string, ...optionalParams: any[]) : this { log(message: string, ...optionalParams: any[]) : this {
if(!this.enabled) if(!this.enabled) {
return this; return this;
}
if(!this.initialized) { if(!this.initialized) {
if(this.mode == GroupMode.NATIVE) { if(this.mode == GroupMode.NATIVE) {
if(this._collapsed && console.groupCollapsed) if(this._collapsed && console.groupCollapsed) {
console.groupCollapsed(this.name, ...this.optionalParams); console.groupCollapsed(this.name, ...this.optionalParams);
else } else {
console.group(this.name, ...this.optionalParams); console.group(this.name, ...this.optionalParams);
}
} else { } else {
this._log_prefix = " "; this._log_prefix = " ";
let parent = this.owner; let parent = this.owner;
while(parent) { while(parent) {
if(parent.mode == GroupMode.PREFIX) if(parent.mode == GroupMode.PREFIX) {
this._log_prefix = this._log_prefix + parent._log_prefix; this._log_prefix = this._log_prefix + parent._log_prefix;
else } else {
break; break;
} }
} }
}
this.initialized = true; this.initialized = true;
} }
if(this.mode == GroupMode.NATIVE) if(this.mode == GroupMode.NATIVE) {
logDirect(this.level, message, ...optionalParams); logDirect(this.level, message, ...optionalParams);
else { } else {
logDirect(this.level, "[%s] " + this._log_prefix + message, category_mapping.get(this.category), ...optionalParams); logDirect(this.level, "[%s] " + this._log_prefix + message, category_mapping.get(this.category), ...optionalParams);
} }
return this; 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 {parse as parseBBCode} from "vendor/xbbcode/parser";
import {fixupJQueryUrlTags} from "tc-shared/text/bbcode/url"; import {fixupJQueryUrlTags} from "tc-shared/text/bbcode/url";
import {fixupJQueryImageTags} from "tc-shared/text/bbcode/image"; 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 = [ export const allowedBBCodes = [
"b", "big", "b", "big",
"i", "italic", "i", "italic",
"u", "underlined", "u", "underlined",
"s", "strikethrough", "s", "strikethrough",
"color", "color", "bgcolor",
"url", "url",
"code", "code",
"i-code", "icode", "i-code", "icode",
@ -22,7 +23,9 @@ export const allowedBBCodes = [
"left", "l", "center", "c", "right", "r", "left", "l", "center", "c", "right", "r",
"ul", "ol", "list", "ul", "ol", "list",
"ulist", "olist",
"li", "li",
"*",
"table", "table",
"tr", "td", "th", "tr", "td", "th",
@ -37,7 +40,7 @@ export interface BBCodeRenderOptions {
convertSingleUrls: boolean; 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 { function preprocessMessage(message: string, settings: BBCodeRenderOptions) : string {
/* try if its only one url */ /* try if its only one url */
@ -53,7 +56,7 @@ function preprocessMessage(message: string, settings: BBCodeRenderOptions) : str
single_url_yt: single_url_yt:
{ {
const result = raw_url.match(yt_url_regex); const result = raw_url.match(youtubeUrlRegex);
if(!result) break single_url_yt; if(!result) break single_url_yt;
return "[yt]https://www.youtube.com/watch?v=" + result[5] + "[/yt]"; return "[yt]https://www.youtube.com/watch?v=" + result[5] + "[/yt]";

View file

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

View file

@ -1,70 +1,74 @@
//https://regex101.com/r/YQbfcX/2 import UrlKnife from 'url-knife';
//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 {Settings, settings} from "../settings"; import {Settings, settings} from "../settings";
import {renderMarkdownAsBBCode} from "../text/markdown"; import {renderMarkdownAsBBCode} from "../text/markdown";
import {escapeBBCode} from "../text/bbcode"; 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; interface UrlKnifeUrl {
function process_urls(message: string) : string { value: {
const words = message.split(/[ \n]/); url: string,
for(let index = 0; index < words.length; index++) { },
const flag_escaped = words[index].startsWith('!'); area: string,
const unescaped = flag_escaped ? words[index].substr(1) : words[index]; index: {
start: number,
_try: end: number
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(" "); function bbcodeLinkUrls(message: string) : string {
} const urls: UrlKnifeUrl[] = UrlKnife.TextArea.extractAllUrls(message, {
'ip_v4' : true,
export function preprocessChatMessageForSend(message: string) : string { 'ip_v6' : false,
const process_url = settings.static_global(Settings.KEY_CHAT_TAG_URLS); 'localhost' : false,
const parse_markdown = settings.static_global(Settings.KEY_CHAT_ENABLE_MARKDOWN); 'intranet' : true
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;
}); });
/* 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 = prefix + bbcodeUrl + suffix;
message = escapeBBCode(message); }
if(process_url)
message = process_urls(message);
return message; 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 * as log from "../log";
import {LogCategory} from "../log"; import {LogCategory, logTrace} from "../log";
import { import {
CodeToken, Env, FenceToken, HeadingOpenToken, CodeToken,
Env,
FenceToken,
HeadingOpenToken,
ImageToken, ImageToken,
LinkOpenToken, Options, LinkOpenToken,
ParagraphOpenToken, Options,
ParagraphCloseToken,
SubToken, SubToken,
SupToken, SupToken,
TextToken, TextToken,
@ -12,21 +16,20 @@ import {
} from "remarkable/lib"; } from "remarkable/lib";
import {escapeBBCode} from "../text/bbcode"; import {escapeBBCode} from "../text/bbcode";
import {tr} from "tc-shared/i18n/localize"; import {tr} from "tc-shared/i18n/localize";
const { Remarkable } = require("remarkable"); const { Remarkable } = require("remarkable");
export class MD2BBCodeRenderer { export class MD2BBCodeRenderer {
private static renderers: {[key: string]:(renderer: MD2BBCodeRenderer, token: Token) => string} = { 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", "softbreak": () => "\n",
"hardbreak": () => "\n", "hardbreak": () => "\n",
"paragraph_open": (renderer: MD2BBCodeRenderer, token: ParagraphOpenToken) => { "paragraph_open": () => "",
debugger; "paragraph_close": (_, token: ParagraphCloseToken) => token.tight ? "" : "[br]",
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_open": () => "[b]",
"strong_close": () => "[/b]", "strong_close": () => "[/b]",
@ -70,7 +73,10 @@ export class MD2BBCodeRenderer {
"link_open": (renderer: MD2BBCodeRenderer, token: LinkOpenToken) => "[url" + (token.href ? ("=" + token.href) : "") + "]", "link_open": (renderer: MD2BBCodeRenderer, token: LinkOpenToken) => "[url" + (token.href ? ("=" + token.href) : "") + "]",
"link_close": () => "[/url]", "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 //footnote_ref
@ -96,14 +102,26 @@ export class MD2BBCodeRenderer {
}; };
private _options; private _options;
last_paragraph: Token; currentLineCount: number;
reset() {
this._options = undefined;
this.currentLineCount = 0;
}
render(tokens: Token[], options: Options, env: Env): string { render(tokens: Token[], options: Options, env: Env): string {
this.last_paragraph = undefined;
this._options = options; this._options = options;
let result = ''; let result = '';
for(let index = 0; index < tokens.length; index++) { 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') { if (tokens[index].type === 'inline') {
/* we're just ignoring the inline fact */ /* we're just ignoring the inline fact */
result += this.render((tokens[index] as any).children, options, env); 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) : ""; return 'content' in token ? this.options().textProcessor(token.content) : "";
} }
const result = renderer(this, token); return renderer(this, token);
if(token.type === "paragraph_open")
this.last_paragraph = token;
return result;
} }
options() : any { options() : any {
@ -135,17 +150,19 @@ export class MD2BBCodeRenderer {
} }
} }
const md2bbCodeRenderer = new MD2BBCodeRenderer();
const remarkableRenderer = new Remarkable("full", { const remarkableRenderer = new Remarkable("full", {
typographer: true typographer: true
}); });
remarkableRenderer.renderer = new MD2BBCodeRenderer() as any; remarkableRenderer.renderer = md2bbCodeRenderer as any;
remarkableRenderer.inline.ruler.disable([ 'newline', 'autolink' ]); remarkableRenderer.inline.ruler.disable([ 'newline', 'autolink' ]);
export function renderMarkdownAsBBCode(message: string, textProcessor: (text: string) => string) : string { export function renderMarkdownAsBBCode(message: string, textProcessor: (text: string) => string) : string {
remarkableRenderer.set({ textProcessor: textProcessor } as any); remarkableRenderer.set({ textProcessor: textProcessor } as any);
md2bbCodeRenderer.reset();
let result = remarkableRenderer.render(message); let result = remarkableRenderer.render(message);
if(result.endsWith("\n")) logTrace(LogCategory.CHAT, tr("Markdown render result:\n%s"), result);
result = result.substr(0, result.length - 1);
return 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[] { export function formatElement(object: any, escape_html: boolean = true) : JQuery[] {
if($.isArray(object)) { if(Array.isArray(object)) {
let result = []; let result = [];
for(let element of object) for(let element of object) {
result.push(...formatElement(element, escape_html)); result.push(...formatElement(element, escape_html));
}
return result; return result;
} else if(typeof(object) == "string") { } else if(typeof(object) == "string") {
if(object.length == 0) return []; if(object.length == 0) return [];

View file

@ -275,91 +275,91 @@ export enum FrameContent {
export class Frame { export class Frame {
readonly handle: ConnectionHandler; readonly handle: ConnectionHandler;
private _info_frame: InfoFrame; private infoFrame: InfoFrame;
private _html_tag: JQuery; private htmlTag: JQuery;
private _container_info: JQuery; private containerInfo: JQuery;
private _container_chat: JQuery; private containerChannelChat: JQuery;
private _content_type: FrameContent; private _content_type: FrameContent;
private _client_info: ClientInfo; private clientInfo: ClientInfo;
private _music_info: MusicInfo; private musicInfo: MusicInfo;
private _channel_conversations: ConversationManager; private channelConversations: ConversationManager;
private _private_conversations: PrivateConversationManager; private privateConversations: PrivateConversationManager;
constructor(handle: ConnectionHandler) { constructor(handle: ConnectionHandler) {
this.handle = handle; this.handle = handle;
this._content_type = FrameContent.NONE; this._content_type = FrameContent.NONE;
this._info_frame = new InfoFrame(this); this.infoFrame = new InfoFrame(this);
this._private_conversations = new PrivateConversationManager(handle); this.privateConversations = new PrivateConversationManager(handle);
this._channel_conversations = new ConversationManager(handle); this.channelConversations = new ConversationManager(handle);
this._client_info = new ClientInfo(this); this.clientInfo = new ClientInfo(this);
this._music_info = new MusicInfo(this); this.musicInfo = new MusicInfo(this);
this._build_html_tag(); this._build_html_tag();
this.show_channel_conversations(); this.show_channel_conversations();
this.info_frame().update_chat_counter(); this.info_frame().update_chat_counter();
} }
html_tag() : JQuery { return this._html_tag; } html_tag() : JQuery { return this.htmlTag; }
info_frame() : InfoFrame { return this._info_frame; } info_frame() : InfoFrame { return this.infoFrame; }
content_type() : FrameContent { return this._content_type; } content_type() : FrameContent { return this._content_type; }
destroy() { destroy() {
this._html_tag && this._html_tag.remove(); this.htmlTag && this.htmlTag.remove();
this._html_tag = undefined; this.htmlTag = undefined;
this._info_frame && this._info_frame.destroy(); this.infoFrame && this.infoFrame.destroy();
this._info_frame = undefined; this.infoFrame = undefined;
this._client_info && this._client_info.destroy(); this.clientInfo && this.clientInfo.destroy();
this._client_info = undefined; this.clientInfo = undefined;
this._music_info && this._music_info.destroy(); this.musicInfo && this.musicInfo.destroy();
this._music_info = undefined; this.musicInfo = undefined;
this._private_conversations && this._private_conversations.destroy(); this.privateConversations && this.privateConversations.destroy();
this._private_conversations = undefined; this.privateConversations = undefined;
this._channel_conversations && this._channel_conversations.destroy(); this.channelConversations && this.channelConversations.destroy();
this._channel_conversations = undefined; this.channelConversations = undefined;
this._container_info && this._container_info.remove(); this.containerInfo && this.containerInfo.remove();
this._container_info = undefined; this.containerInfo = undefined;
this._container_chat && this._container_chat.remove(); this.containerChannelChat && this.containerChannelChat.remove();
this._container_chat = undefined; this.containerChannelChat = undefined;
} }
private _build_html_tag() { private _build_html_tag() {
this._html_tag = $("#tmpl_frame_chat").renderTag(); this.htmlTag = $("#tmpl_frame_chat").renderTag();
this._container_info = this._html_tag.find(".container-info"); this.containerInfo = this.htmlTag.find(".container-info");
this._container_chat = this._html_tag.find(".container-chat"); 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 { private_conversations() : PrivateConversationManager {
return this._private_conversations; return this.privateConversations;
} }
channel_conversations() : ConversationManager { channel_conversations() : ConversationManager {
return this._channel_conversations; return this.channelConversations;
} }
client_info() : ClientInfo { client_info() : ClientInfo {
return this._client_info; return this.clientInfo;
} }
music_info() : MusicInfo { music_info() : MusicInfo {
return this._music_info; return this.musicInfo;
} }
private _clear() { private _clear() {
this._content_type = FrameContent.NONE; this._content_type = FrameContent.NONE;
this._container_chat.children().detach(); this.containerChannelChat.children().detach();
} }
show_private_conversations() { show_private_conversations() {
@ -368,9 +368,9 @@ export class Frame {
this._clear(); this._clear();
this._content_type = FrameContent.PRIVATE_CHAT; this._content_type = FrameContent.PRIVATE_CHAT;
this._container_chat.append(this._private_conversations.htmlTag); this.containerChannelChat.append(this.privateConversations.htmlTag);
this._private_conversations.handlePanelShow(); this.privateConversations.handlePanelShow();
this._info_frame.set_mode(InfoFrameMode.PRIVATE_CHAT); this.infoFrame.set_mode(InfoFrameMode.PRIVATE_CHAT);
} }
show_channel_conversations() { show_channel_conversations() {
@ -379,36 +379,36 @@ export class Frame {
this._clear(); this._clear();
this._content_type = FrameContent.CHANNEL_CHAT; this._content_type = FrameContent.CHANNEL_CHAT;
this._container_chat.append(this._channel_conversations.htmlTag); this.containerChannelChat.append(this.channelConversations.htmlTag);
this._channel_conversations.handlePanelShow(); this.channelConversations.handlePanelShow();
this._info_frame.set_mode(InfoFrameMode.CHANNEL_CHAT); this.infoFrame.set_mode(InfoFrameMode.CHANNEL_CHAT);
} }
show_client_info(client: ClientEntry) { show_client_info(client: ClientEntry) {
this._client_info.set_current_client(client); this.clientInfo.set_current_client(client);
this._info_frame.set_mode(InfoFrameMode.CLIENT_INFO); /* specially needs an update here to update the conversation button */ this.infoFrame.set_mode(InfoFrameMode.CLIENT_INFO); /* specially needs an update here to update the conversation button */
if(this._content_type === FrameContent.CLIENT_INFO) if(this._content_type === FrameContent.CLIENT_INFO)
return; return;
this._client_info.previous_frame_content = this._content_type; this.clientInfo.previous_frame_content = this._content_type;
this._clear(); this._clear();
this._content_type = FrameContent.CLIENT_INFO; 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) { 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) if(this._content_type === FrameContent.MUSIC_BOT)
return; return;
this._info_frame.set_mode(InfoFrameMode.MUSIC_BOT); this.infoFrame.set_mode(InfoFrameMode.MUSIC_BOT);
this._music_info.previous_frame_content = this._content_type; this.musicInfo.previous_frame_content = this._content_type;
this._clear(); this._clear();
this._content_type = FrameContent.MUSIC_BOT; 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) { set_content(type: FrameContent) {
@ -422,7 +422,7 @@ export class Frame {
else { else {
this._clear(); this._clear();
this._content_type = FrameContent.NONE; 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(); const refContainer = useRef();
useEffect(() => { useEffect(() => {
if(!shown) if(!shown) {
return; return;
}
const clickListener = (event: MouseEvent) => { const clickListener = (event: MouseEvent) => {
let target = event.target as HTMLElement; let target = event.target as HTMLElement;
@ -90,8 +91,9 @@ const nodeToText = (element: Node) => {
return '\n'; return '\n';
} }
if(element.children.length > 0) if(element.children.length > 0) {
return [...element.childNodes].map(nodeToText).join(""); return [...element.childNodes].map(nodeToText).join("");
}
return typeof(element.innerText) === "string" ? element.innerText : ""; return typeof(element.innerText) === "string" ? element.innerText : "";
} else { } else {
@ -139,14 +141,14 @@ const TextInput = (props: { events: Registry<ChatBoxEvents>, enabled?: boolean,
const clipboard = event.clipboardData || (window as any).clipboardData; const clipboard = event.clipboardData || (window as any).clipboardData;
if(!clipboard) return; if(!clipboard) return;
const raw_text = clipboard.getData('text/plain'); const rawText = clipboard.getData('text/plain');
const selection = window.getSelection(); const selection = window.getSelection();
if (!selection.rangeCount) if (!selection.rangeCount)
return false; return false;
let htmlXML = clipboard.getData('text/html'); let htmlXML = clipboard.getData('text/html');
if(!htmlXML) { if(!htmlXML) {
pasteTextTransformElement.textContent = raw_text; pasteTextTransformElement.textContent = rawText;
htmlXML = pasteTextTransformElement.innerHTML; htmlXML = pasteTextTransformElement.innerHTML;
} }
@ -159,18 +161,22 @@ const TextInput = (props: { events: Registry<ChatBoxEvents>, enabled?: boolean,
{ {
let prefix_length = 0, suffix_length = 0; let prefix_length = 0, suffix_length = 0;
{ {
for (let i = 0; i < raw_text.length; i++) for (let i = 0; i < rawText.length; i++) {
if (raw_text.charAt(i) === '\n') if (rawText.charAt(i) === '\n') {
prefix_length++; prefix_length++;
else if (raw_text.charAt(i) !== '\r') } else if (rawText.charAt(i) !== '\r') {
break; break;
}
}
for (let i = raw_text.length - 1; i >= 0; i++) for (let i = rawText.length - 1; i >= 0; i++) {
if (raw_text.charAt(i) === '\n') if (rawText.charAt(i) === '\n') {
suffix_length++; suffix_length++;
else if (raw_text.charAt(i) !== '\r') } else if (rawText.charAt(i) !== '\r') {
break; break;
} }
}
}
data = data.replace(/^[\n\r]+|[\n\r]+$/g, ''); data = data.replace(/^[\n\r]+|[\n\r]+$/g, '');
data = "\n".repeat(prefix_length) + data + "\n".repeat(suffix_length); 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; const inputEmpty = refInput.current.innerText.trim().length === 0;
if(event.key === "Enter" && !event.shiftKey) { if(event.key === "Enter" && !event.shiftKey) {
if(inputEmpty) if(inputEmpty) {
return; return;
}
const text = refInput.current.innerText; const text = refInput.current.innerText;
props.events.fire("action_submit_message", { message: text }); props.events.fire("action_submit_message", { message: text });
history.current.push(text); history.current.push(text);
while(history.current.length > 10) while(history.current.length > 10) {
history.current.pop_front(); history.current.pop_front();
}
refInput.current.innerText = ""; refInput.current.innerText = "";
setHistoryIndex(-1); 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("action_request_focus", () => refInput.current?.focus());
props.events.reactUse("notify_typing", () => { props.events.reactUse("notify_typing", () => {
if(typeof typingTimeout.current === "number") if(typeof typingTimeout.current === "number") {
return; return;
}
typingTimeout.current = setTimeout(() => typingTimeout.current = undefined, 1000); typingTimeout.current = setTimeout(() => typingTimeout.current = undefined, 1000);
}); });
props.events.reactUse("action_insert_text", event => { props.events.reactUse("action_insert_text", event => {
refInput.current.innerHTML = refInput.current.innerHTML + event.text; refInput.current.innerHTML = refInput.current.innerHTML + event.text;
if(event.focus) if(event.focus) {
refInput.current.focus(); refInput.current.focus();
}
}); });
props.events.reactUse("action_set_enabled", event => { props.events.reactUse("action_set_enabled", event => {
setEnabled(event.enabled); setEnabled(event.enabled);
@ -273,8 +283,9 @@ const MarkdownFormatHelper = () => {
const [ visible, setVisible ] = useState(settings.global(Settings.KEY_CHAT_ENABLE_MARKDOWN)); const [ visible, setVisible ] = useState(settings.global(Settings.KEY_CHAT_ENABLE_MARKDOWN));
settings.events.reactUse("notify_setting_changed", event => { 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; return;
}
setVisible(settings.global(Settings.KEY_CHAT_ENABLE_MARKDOWN)); 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 { 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 }); this.events.fire_react("action_set_enabled", { enabled: this.state.enabled });
} }
} }
}

View file

@ -298,34 +298,6 @@ html:root {
margin-bottom: .1em; 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) { :global(.chat-emoji) {
height: 1.1em; height: 1.1em;
width: 1.1em; width: 1.1em;
@ -341,12 +313,6 @@ html:root {
margin-bottom: .1em; margin-bottom: .1em;
} }
} }
:global(.xbbcode-tag-quote) {
border-color: var(--chat-message-quote-border);
padding-left: .5em;
color: var(--chat-message-quote);
}
} }
&:before { &:before {

View file

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