Reworked the channel info modal with React

master
WolverinDEV 2021-04-24 22:56:52 +02:00
parent feeb086cb8
commit eebe7aa6b9
25 changed files with 841 additions and 465 deletions

View File

@ -8,7 +8,6 @@ import "./static/modal.scss"
import "./static/modals.scss"
import "./static/modal-banclient.scss"
import "./static/modal-banlist.scss"
import "./static/modal-channelinfo.scss"
import "./static/modal-clientinfo.scss"
import "./static/modal-group-assignment.scss"
import "./static/modal-icons.scss"

View File

@ -1,163 +0,0 @@
@import "mixin";
@import "properties";
:global {
.modal-body.modal-channel-info {
display: flex!important;
flex-direction: column!important;
justify-content: stretch!important;
min-width: 30em!important;
max-height: calc(100vh - 10em)!important;
padding: 0 !important;
.row {
flex-grow: 0;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: stretch;
padding-top: 1em;
padding-left: .5em;
padding-right: .5em;
.column {
flex-grow: 1;
flex-shrink: 1;
min-width: 6em;
width: 10em;
margin-right: .5em;
margin-left: .5em;
.title {
text-transform: uppercase;
color: #557edc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.value {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&.audio-encrypted {
/* looks better */
.value {
height: 1.6em;
overflow: visible;
}
}
}
}
.container-description {
flex-grow: 1;
flex-shrink: 1;
min-height: 8em; /* description plus title */
display: flex;
flex-direction: column;
justify-content: stretch;
padding-top: 1em;
padding-left: 1em;
padding-right: 1em;
.title {
display: flex;
flex-direction: row;
justify-content: flex-start;
flex-grow: 0;
flex-shrink: 0;
text-transform: uppercase;
color: #557edc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.button-copy {
display: flex;
flex-direction: column;
justify-content: center;
margin-top: .1em; /* looks a bit better */
margin-left: .5em;
border-radius: .2em;
width: 1.3em;
height: 1.3em;
cursor: pointer;
div {
align-self: center;
}
&:hover {
background-color: #313135;
}
@include transition($button_hover_animation_time ease-in-out);
}
}
.value {
display: block;
flex-grow: 1;
flex-shrink: 1;
border-radius: 0.2em;
border: 1px solid #212324;
background-color: #3a3b3f;
padding: .5em;
height: max-content;
min-height: 6em;
max-height: 40em;
overflow-y: auto;
overflow-x: hidden;
@include chat-scrollbar-vertical();
}
.no-value {
flex-grow: 0;
flex-shrink: 0;
font-size: 1.25em;
height: (6em / 1.25); /* min value height and a bit more */
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
color: #666666;
}
}
.container-buttons {
flex-grow: 0;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: flex-end;
padding: 1em;
}
}
}

View File

@ -1,7 +1,7 @@
import {XBBCodeRenderer} from "vendor/xbbcode/react";
import * as React from "react";
import {rendererHTML, rendererReact, rendererText, BBCodeHandlerContext} from "tc-shared/text/bbcode/renderer";
import {parse as parseBBCode} from "vendor/xbbcode/parser";
import {parseBBCode 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";

View File

@ -2,7 +2,7 @@ import * as React from "react";
import * as loader from "tc-loader";
import {BBCodeHandlerContext, rendererReact, rendererText} from "tc-shared/text/bbcode/renderer";
import {ElementRenderer} from "vendor/xbbcode/renderer/base";
import {TagElement} from "vendor/xbbcode/elements";
import {BBCodeTagElement} from "vendor/xbbcode/elements";
import {BBCodeRenderer} from "tc-shared/text/bbcode";
import {spawn_context_menu} from "tc-shared/ui/elements/ContextMenu";
import {copyToClipboard} from "tc-shared/utils/helpers";
@ -78,8 +78,8 @@ loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
name: "XBBCode code tag init",
function: async () => {
ipcChannel = getIpcInstance().createCoreControlChannel("bbcode-youtube");
rendererReact.registerCustomRenderer(new class extends ElementRenderer<TagElement, React.ReactNode> {
render(element: TagElement): React.ReactNode {
rendererReact.registerCustomRenderer(new class extends ElementRenderer<BBCodeTagElement, React.ReactNode> {
render(element: BBCodeTagElement): React.ReactNode {
const text = rendererText.render(element);
return <YoutubeRenderer url={text} />;
}

View File

@ -2,7 +2,7 @@ import * as loader from "tc-loader";
import * as React from "react";
import { rendererReact } from "tc-shared/text/bbcode/renderer";
import {ElementRenderer} from "vendor/xbbcode/renderer/base";
import {TextElement} from "vendor/xbbcode/elements";
import {BBCodeTextElement} from "vendor/xbbcode/elements";
import ReactRenderer from "vendor/xbbcode/renderer/react";
import {Settings, settings} from "tc-shared/settings";
@ -16,8 +16,8 @@ loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
function: async () => {
let reactId = 0;
rendererReact.setTextRenderer(new class extends ElementRenderer<TextElement, React.ReactNode> {
render(element: TextElement, renderer: ReactRenderer): React.ReactNode {
rendererReact.setTextRenderer(new class extends ElementRenderer<BBCodeTextElement, React.ReactNode> {
render(element: BBCodeTextElement, renderer: ReactRenderer): React.ReactNode {
if(!settings.getValue(Settings.KEY_CHAT_COLORED_EMOJIES)) {
return element.text();
}

View File

@ -2,7 +2,7 @@ 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 {BBCodeTagElement} from "vendor/xbbcode/elements";
import * as React from "react";
import {tra} from "tc-shared/i18n/localize";
import * as DOMPurify from "dompurify";
@ -92,12 +92,12 @@ loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
return;
}
/* override default parser */
rendererReact.registerCustomRenderer(new class extends ElementRenderer<TagElement, React.ReactNode> {
rendererReact.registerCustomRenderer(new class extends ElementRenderer<BBCodeTagElement, React.ReactNode> {
tags(): string | string[] {
return ["code", "icode", "i-code"];
}
render(element: TagElement): React.ReactNode {
render(element: BBCodeTagElement): React.ReactNode {
const klass = element.tagNormalized != 'code' ? cssStyle.inlineCode : cssStyle.code;
const language = (element.options || "").replace("\"", "'").toLowerCase();

View File

@ -1,5 +1,5 @@
import {ElementRenderer} from "vendor/xbbcode/renderer/base";
import {TagElement} from "vendor/xbbcode/elements";
import {BBCodeTagElement} from "vendor/xbbcode/elements";
import * as React from "react";
import * as loader from "tc-loader";
import {rendererReact, rendererText} from "tc-shared/text/bbcode/renderer";
@ -57,12 +57,12 @@ loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
function: async () => {
let reactId = 0;
rendererReact.registerCustomRenderer(new class extends ElementRenderer<TagElement, React.ReactNode> {
rendererReact.registerCustomRenderer(new class extends ElementRenderer<BBCodeTagElement, React.ReactNode> {
tags(): string | string[] {
return ["img", "image"];
}
render(element: TagElement): React.ReactNode {
render(element: BBCodeTagElement): React.ReactNode {
let target;
let content = rendererText.render(element);
if (!element.options) {

View File

@ -10,7 +10,7 @@ import HTMLRenderer from "vendor/xbbcode/renderer/html";
import "./emoji";
import "./highlight";
import "./YoutubeController";
import "./YoutubeRenderer";
import "./url";
import "./image";

View File

@ -2,7 +2,7 @@ import * as contextmenu from "tc-shared/ui/elements/ContextMenu";
import {copyToClipboard} 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 {BBCodeTagElement} from "vendor/xbbcode/elements";
import * as React from "react";
import ReactRenderer from "vendor/xbbcode/renderer/react";
import {rendererReact, rendererText, BBCodeHandlerContext} from "tc-shared/text/bbcode/renderer";
@ -53,8 +53,8 @@ loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
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 {
rendererReact.registerCustomRenderer(new class extends ElementRenderer<BBCodeTagElement, React.ReactNode> {
render(element: BBCodeTagElement, renderer: ReactRenderer): React.ReactNode {
let target: string;
if (!element.options) {
target = rendererText.render(element);

View File

@ -2,8 +2,8 @@ import UrlKnife from 'url-knife';
import {Settings, settings} from "../settings";
import {renderMarkdownAsBBCode} from "../text/markdown";
import {escapeBBCode} from "../text/bbcode";
import {parse as parseBBCode} from "vendor/xbbcode/parser";
import {TagElement} from "vendor/xbbcode/elements";
import {parseBBCode as parseBBCode} from "vendor/xbbcode/parser";
import {BBCodeTagElement} from "vendor/xbbcode/elements";
import {regexImage} from "tc-shared/text/bbcode/image";
interface UrlKnifeUrl {
@ -88,7 +88,7 @@ export function preprocessChatMessageForSend(message: string) : string {
break;
}
if(!(element instanceof TagElement)) {
if(!(element instanceof BBCodeTagElement)) {
continue;
}

View File

@ -1,7 +1,7 @@
import {ChannelTree} from "./ChannelTree";
import {ClientEntry, ClientEvents} from "./Client";
import * as log from "../log";
import {LogCategory, logInfo, LogType, logWarn} from "../log";
import {LogCategory, logError, logInfo, LogType, logWarn} from "../log";
import {PermissionType} from "../permission/PermissionType";
import {settings, Settings} from "../settings";
import * as contextmenu from "../ui/elements/ContextMenu";
@ -10,7 +10,6 @@ import {Sound} from "../audio/Sounds";
import {createErrorModal, createInfoModal, createInputModal} from "../ui/elements/Modal";
import {CommandResult} from "../connection/ServerConnectionDeclaration";
import {hashPassword} from "../utils/helpers";
import {openChannelInfo} from "../ui/modal/ModalChannelInfo";
import {formatMessage} from "../ui/frames/chat";
import {Registry} from "../events";
@ -18,10 +17,13 @@ import {ChannelTreeEntry, ChannelTreeEntryEvents} from "./ChannelTreeEntry";
import {spawnFileTransferModal} from "../ui/modal/transfer/ModalFileTransfer";
import {ErrorCode} from "../connection/ErrorCode";
import {ClientIcon} from "svg-sprites/client-icons";
import { tr } from "tc-shared/i18n/localize";
import {tr} from "tc-shared/i18n/localize";
import {EventChannelData} from "tc-shared/connectionlog/Definitions";
import {spawnChannelEditNew} from "tc-shared/ui/modal/channel-edit/Controller";
import {spawnInviteGenerator} from "tc-shared/ui/modal/invite/Controller";
import {NoThrow} from "tc-shared/proto";
import {ChannelDescriptionResult} from "tc-shared/tree/ChannelDefinitions";
import {spawnChannelInfo} from "tc-shared/ui/modal/channel-info/Controller";
export enum ChannelType {
PERMANENT,
@ -203,9 +205,9 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
private _destroyed = false;
private cachedPasswordHash: string;
private channelDescriptionCached: boolean;
private channelDescriptionCacheTimestamp: number;
private channelDescriptionCallback: ((success: boolean) => void)[];
private channelDescriptionPromise: Promise<string>;
private channelDescriptionPromise: Promise<ChannelDescriptionResult>;
private collapsed: boolean;
private subscribed: boolean;
@ -243,7 +245,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
this.collapsed = this.channelTree.client.settings.getValue(Settings.FN_SERVER_CHANNEL_COLLAPSED(this.channelId));
this.subscriptionMode = this.channelTree.client.settings.getValue(Settings.FN_SERVER_CHANNEL_SUBSCRIBE_MODE(this.channelId), ChannelSubscribeMode.INHERITED);
this.channelDescriptionCached = false;
this.channelDescriptionCacheTimestamp = 0;
this.channelDescriptionCallback = [];
}
@ -280,45 +282,78 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
return this.parsed_channel_name.text;
}
async getChannelDescription() : Promise<string> {
if(this.channelDescriptionPromise) {
return this.channelDescriptionPromise;
clearDescriptionCache() {
this.channelDescriptionPromise = undefined;
this.channelDescriptionCacheTimestamp = 0;
}
@NoThrow
async getChannelDescription(ignoreCache: boolean) : Promise<ChannelDescriptionResult> {
if(ignoreCache || Date.now() - 120 * 1000 > this.channelDescriptionCacheTimestamp) {
this.channelDescriptionPromise = this.doGetChannelDescriptionNew();
}
const promise = this.doGetChannelDescription();
this.channelDescriptionPromise = promise;
promise
.then(() => this.channelDescriptionPromise = undefined)
.catch(() => this.channelDescriptionPromise = undefined);
return promise;
return await this.channelDescriptionPromise;
}
isDescriptionCached() {
return this.channelDescriptionCached;
}
private async doGetChannelDescription() {
if(!this.channelDescriptionCached) {
@NoThrow
private async doGetChannelDescriptionNew() : Promise<ChannelDescriptionResult> {
try {
await this.channelTree.client.serverConnection.send_command("channelgetdescription", {
cid: this.channelId
}, {
process_result: false
});
if(!this.channelDescriptionCached) {
/* since the channel description is a low command it will not be processed in sync */
await new Promise((resolve, reject) => {
this.channelDescriptionCallback.push(succeeded => {
if(succeeded) {
resolve();
} else {
reject(tr("failed to receive description"));
}
})
});
} catch (error) {
if(error instanceof CommandResult) {
if(error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
return {
status: "no-permissions",
failedPermission: this.channelTree.client.permissions.getFailedPermission(error)
}
} else {
return {
status: "error",
message: error.formattedMessage()
}
}
} else if(typeof error === "string") {
return {
status: "error",
message: error
}
} else {
logError(LogCategory.CHANNEL, tr("Failed to query channel description for channel %d: %o"), this.channelId, error);
return {
status: "error",
message: tr("lookup the console")
};
}
}
return this.properties.channel_description;
if(!this.channelDescriptionCacheTimestamp) {
/* since the channel description is a low command it will not be processed later */
const result = await new Promise(resolve => {
this.channelDescriptionCallback.push(resolve);
setTimeout(() => {
this.channelDescriptionCallback.remove(resolve);
resolve(false);
}, 5000);
});
if(!result) {
return { status: "error", message: tr("description query failed") };
}
}
return {
status: "success",
description: this.properties.channel_description
}
}
isDescriptionCached() {
return this.channelDescriptionCacheTimestamp > Date.now() - 120 * 1000;
}
registerClient(client: ClientEntry) {
@ -481,7 +516,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
name: tr("Show channel info"),
callback: () => {
trigger_close = false;
openChannelInfo(this);
spawnChannelInfo(this);
},
icon_class: "client-about"
}, {
@ -646,7 +681,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
const hasUpdate = JSON.map_field_to(this.properties, value, variable.key);
if(key == "channel_description") {
this.channelDescriptionCached = true;
this.channelDescriptionCacheTimestamp = Date.now();
this.channelDescriptionCallback.forEach(callback => callback(true));
this.channelDescriptionCallback = [];
}
@ -681,10 +716,14 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
return "[url=channel://" + this.channelId + "/" + encodeURIComponent(this.properties.channel_name) + "]" + this.formattedChannelName() + "[/url]";
}
channelType() : ChannelType {
if(this.properties.channel_flag_permanent == true) return ChannelType.PERMANENT;
if(this.properties.channel_flag_semi_permanent == true) return ChannelType.SEMI_PERMANENT;
return ChannelType.TEMPORARY;
getChannelType() : ChannelType {
if(this.properties.channel_flag_permanent == true) {
return ChannelType.PERMANENT;
} else if(this.properties.channel_flag_semi_permanent == true) {
return ChannelType.SEMI_PERMANENT;
} else {
return ChannelType.TEMPORARY;
}
}
async joinChannel(ignorePasswordFlag?: boolean) : Promise<boolean> {
@ -870,11 +909,11 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
}
handleDescriptionChanged() {
if(!this.channelDescriptionCached) {
if(!this.channelDescriptionCacheTimestamp) {
return;
}
this.channelDescriptionCached = false;
this.channelDescriptionCacheTimestamp = 0;
this.properties.channel_description = undefined;
this.events.fire("notify_description_changed");
}

View File

@ -0,0 +1,13 @@
export type ChannelDescriptionResult = {
status: "success",
description: string,
handlerId: string
} | {
status: "empty"
} | {
status: "no-permissions",
failedPermission: string
} | {
status: "error",
message: string
};

View File

@ -16,7 +16,7 @@ import {spawnServerInfoNew} from "tc-shared/ui/modal/server-info/Controller";
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
import {ErrorCode} from "tc-shared/connection/ErrorCode";
import {
kServerConnectionInfoFields,
kServerConnectionInfoFields, ServerAudioEncryptionMode,
ServerConnectionInfo,
ServerConnectionInfoResult,
ServerProperties
@ -401,4 +401,18 @@ export class ServerEntry extends ChannelTreeEntry<ServerEvents> {
updateInterval: this.properties.virtualserver_hostbanner_gfx_interval,
};
}
getAudioEncryptionMode() : ServerAudioEncryptionMode {
switch (this.properties.virtualserver_codec_encryption_mode) {
case 0:
return "globally-off";
default:
case 1:
return "individual";
case 2:
return "globally-on";
}
}
}

View File

@ -1,3 +1,5 @@
export type ServerAudioEncryptionMode = "globally-on" | "globally-off" | "individual";
export class ServerProperties {
virtualserver_host: string = "";
virtualserver_port: number = 0;

View File

@ -80,40 +80,39 @@ export class ChannelDescriptionController {
}
private async updateCachedDescriptionStatus() {
try {
let description;
if(this.currentChannel) {
description = await new Promise<any>((resolve, reject) => {
this.currentChannel.getChannelDescription().then(resolve).catch(reject);
setTimeout(() => reject(tr("timeout")), 5000);
});
}
if(this.currentChannel) {
const handlerId = this.currentChannel.channelTree.client.handlerId;
const result = await this.currentChannel.getChannelDescription(false);
switch (result.status) {
case "success":
case "empty":
this.cachedDescriptionStatus = {
status: "success",
description: result.status === "success" ? result.description : undefined,
handlerId: handlerId
};
break;
this.cachedDescriptionStatus = {
status: "success",
description: description,
handlerId: this.currentChannel?.channelTree.client.handlerId || "unknown"
};
} catch (error) {
if(error instanceof CommandResult) {
if(error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
const permission = this.currentChannel?.channelTree.client.permissions.resolveInfo(parseInt(error.json["failed_permid"]));
case "no-permissions":
this.cachedDescriptionStatus = {
status: "no-permissions",
failedPermission: permission ? permission.name : "unknown"
failedPermission: result.failedPermission
};
return;
}
break;
error = error.formattedMessage();
} else if(typeof error !== "string") {
logError(LogCategory.GENERAL, tr("Failed to get channel descriptions: %o"), error);
error = tr("lookup the console");
case "error":
default:
this.cachedDescriptionStatus = {
status: "error",
reason: result.message || tr("unknown query result"),
};
break;
}
} else {
this.cachedDescriptionStatus = {
status: "error",
reason: error
status: "success",
description: undefined,
handlerId: "unknown"
};
}
this.cachedDescriptionAge = Date.now();

View File

@ -1,161 +0,0 @@
import {createInfoModal, createModal, Modal} from "../../ui/elements/Modal";
import {ChannelEntry} from "../../tree/Channel";
import {copyToClipboard} from "../../utils/helpers";
import * as tooltip from "../../ui/elements/Tooltip";
import {formatMessage} from "../../ui/frames/chat";
import {renderBBCodeAsJQuery} from "tc-shared/text/bbcode";
import {tr} from "tc-shared/i18n/localize";
export function openChannelInfo(channel: ChannelEntry) {
let modal: Modal;
modal = createModal({
header: tr("Channel information: ") + channel.channelName(),
body: () => {
const template = $("#tmpl_channel_info").renderTag();
const update_values = (container) => {
apply_channel_description(container.find(".container-description"), channel);
apply_general(container, channel);
};
template.find(".button-copy").on('click', event => {
copyToClipboard(channel.properties.channel_description);
createInfoModal(tr("Description copied"), tr("The channel description has been copied to your clipboard!")).open();
});
const button_update = template.find(".button-update");
button_update.on('click', event => update_values(modal.htmlTag));
update_values(template);
tooltip.initialize(template);
return template.children();
},
footer: null,
width: "65em"
});
modal.htmlTag.find(".button-close").on('click', event => modal.close());
modal.htmlTag.find(".modal-body").addClass("modal-channel-info");
modal.open();
}
function apply_channel_description(container: JQuery, channel: ChannelEntry) {
const container_value = container.find(".value");
const container_no_value = container.find(".no-value");
channel.getChannelDescription().then(description => {
container_value.empty();
if (description) {
renderBBCodeAsJQuery(description, { convertSingleUrls: true }).forEach(element => container_value.append(element));
container_no_value.hide();
container_value.show();
} else {
container_no_value.text(tr("Channel has no description"));
}
});
container_value.hide();
container_no_value.text(tr("loading...")).show();
}
const codec_names = [
tr("Speex Narrowband"),
tr("Speex Wideband"),
tr("Speex Ultra-Wideband"),
tr("CELT Mono"),
tr("Opus Voice"),
tr("Opus Music")
];
function apply_general(container: JQuery, channel: ChannelEntry) {
/* channel type */
{
const tag = container.find(".channel-type .value").empty();
if (channel.properties.channel_flag_permanent)
tag.text(tr("Permanent"));
else if (channel.properties.channel_flag_semi_permanent)
tag.text(tr("Semi permanent"));
else
//TODO: Channel delete delay!
tag.text(tr("Temporary"));
}
/* chat mode */
{
const tag = container.find(".chat-mode .value").empty();
if(channel.properties.channel_conversation_mode === 0 || channel.properties.channel_flag_password) {
tag.text(tr("Private"));
} else if(channel.properties.channel_conversation_mode === 1) {
if (channel.properties.channel_conversation_history_length == -1) {
tag.text(tr("Public; Semi permanent message saving"));
} else if (channel.properties.channel_conversation_history_length == 0) {
tag.text(tr("Public; Permanent message saving"));
} else {
tag.append(formatMessage(tr("Public; Saving last {} messages"), channel.properties.channel_conversation_history_length));
}
} else {
tag.text(tr("No chatting"));
}
}
/* current clients */
{
const tag = container.find(".current-clients .value").empty();
if (channel.isSubscribed()) {
const current = channel.clients().length;
let channel_limit = tr("Unlimited");
if (!channel.properties.channel_flag_maxclients_unlimited)
channel_limit = "" + channel.properties.channel_maxclients;
else if (!channel.properties.channel_flag_maxfamilyclients_unlimited) {
if (channel.properties.channel_maxfamilyclients >= 0)
channel_limit = "" + channel.properties.channel_maxfamilyclients;
}
tag.text(current + " / " + channel_limit);
} else {
tag.text(tr("Channel not subscribed"));
}
}
/* audio codec */
{
const tag = container.find(".audio-codec .value").empty();
tag.text((codec_names[channel.properties.channel_codec] || tr("Unknown")) + " (" + channel.properties.channel_codec_quality + ")")
}
/* audio encrypted */
{
const tag = container.find(".audio-encrypted .value").empty();
const mode = channel.channelTree.server.properties.virtualserver_codec_encryption_mode;
let appendix;
if (mode == 1)
appendix = tr("Overridden by the server with Unencrypted!");
else if (mode == 2)
appendix = tr("Overridden by the server with Encrypted!");
tag.html((channel.properties.channel_codec_is_unencrypted ? tr("Unencrypted") : tr("Encrypted")) + (appendix ? "<br>" + appendix : ""))
}
/* flag password */
{
const tag = container.find(".flag-password .value").empty();
if (channel.properties.channel_flag_password)
tag.text(tr("Yes"));
else
tag.text(tr("No"));
}
/* topic */
{
const container_tag = container.find(".topic");
const tag = container_tag.find(".value").empty();
if (channel.properties.channel_topic) {
container_tag.show();
tag.text(channel.properties.channel_topic);
} else {
container_tag.hide();
}
}
}

View File

@ -1,45 +0,0 @@
import {BodyCreator, createModal, Modal, ModalFunctions} from "../../ui/elements/Modal";
export function spawnYesNo(header: BodyCreator, body: BodyCreator, callback: (_: boolean) => any, properties?: {
text_yes?: string,
text_no?: string,
closeable?: boolean;
}) : Modal {
properties = properties || {};
const props = ModalFunctions.warpProperties({});
props.template_properties || (props.template_properties = {});
props.template_properties.text_yes = properties.text_yes || tr("Yes");
props.template_properties.text_no = properties.text_no || tr("No");
props.template = "#tmpl_modal_yesno";
props.header = header;
props.template_properties.question = ModalFunctions.jqueriefy(body);
props.closeable = typeof (properties.closeable) !== "boolean" || properties.closeable;
const modal = createModal(props);
let submited = false;
const button_yes = modal.htmlTag.find(".button-yes");
const button_no = modal.htmlTag.find(".button-no");
button_yes.on('click', event => {
if (!submited) {
submited = true;
callback(true);
}
modal.close();
});
button_no.on('click', event => {
if (!submited) {
submited = true;
callback(false);
}
modal.close();
});
modal.close_listener.push(() => button_no.trigger('click'));
modal.open();
return modal;
}

View File

@ -176,10 +176,9 @@ ChannelPropertyProviders["sortingOrder"] = {
ChannelPropertyProviders["topic"] = SimplePropertyProvider("channel_topic", "");
ChannelPropertyProviders["description"] = {
provider: async (properties, channel) => {
if(typeof properties.channel_description !== "undefined" && (properties.channel_description.length !== 0 || channel?.isDescriptionCached())) {
return properties.channel_description;
} else if(channel) {
return await channel.getChannelDescription();
const description = await channel.getChannelDescription(false);
if(description.status === "success") {
return description.description;
} else {
return "";
}

View File

@ -0,0 +1,172 @@
import {Registry} from "tc-events";
import {ModalChannelInfoEvents, ModalChannelInfoVariables} from "tc-shared/ui/modal/channel-info/Definitions";
import {IpcUiVariableProvider} from "tc-shared/ui/utils/IpcVariable";
import {ChannelEntry, ChannelProperties, ChannelType} from "tc-shared/tree/Channel";
import {CallOnce, ignorePromise} from "tc-shared/proto";
import {spawnModal} from "tc-shared/ui/react-elements/modal";
import _ from "lodash";
const kChannelUpdateMapping: {[key in keyof ChannelProperties]?: (keyof ModalChannelInfoVariables)[] } = {
channel_name: [ "name" ],
channel_description: [ "description" ],
channel_topic: [ "topic" ],
channel_conversation_history_length: [ "chatMode" ],
channel_conversation_mode: [ "chatMode" ],
channel_maxclients: [ "currentClients" ],
channel_maxfamilyclients: [ "currentClients" ],
channel_flag_maxclients_unlimited: [ "currentClients" ],
channel_flag_maxfamilyclients_unlimited: [ "currentClients" ],
channel_flag_maxfamilyclients_inherited: [ "currentClients" ],
channel_codec_quality: [ "audioCodec" ],
channel_codec: [ "audioCodec" ],
channel_codec_is_unencrypted: [ "audioEncrypted" ],
channel_flag_password: [ "password" ],
channel_flag_semi_permanent: [ "type" ],
channel_flag_permanent: [ "type" ],
channel_delete_delay: [ "type" ],
channel_flag_default: [ "type" ],
}
class Controller {
readonly channel: ChannelEntry;
readonly events: Registry<ModalChannelInfoEvents>;
readonly variables: IpcUiVariableProvider<ModalChannelInfoVariables>;
private channelEvents: (() => void)[];
constructor(channel: ChannelEntry) {
this.channel = channel;
this.events = new Registry<ModalChannelInfoEvents>();
this.variables = new IpcUiVariableProvider<ModalChannelInfoVariables>();
this.initialize();
}
@CallOnce
destroy() {
this.channelEvents?.forEach(callback => callback());
this.channelEvents = undefined;
this.events.destroy();
this.variables.destroy();
}
@CallOnce
initialize() {
this.variables.setVariableProvider("name", () => this.channel.properties.channel_name);
this.variables.setVariableProvider("type", () => {
switch (this.channel.getChannelType()) {
case ChannelType.PERMANENT:
return "permanent";
case ChannelType.SEMI_PERMANENT:
return "semi-permanent";
case ChannelType.TEMPORARY:
return "temporary";
default:
return "unknown";
}
});
this.variables.setVariableProvider("chatMode", () => {
if(this.channel.properties.channel_conversation_mode === 0 || this.channel.properties.channel_flag_password) {
return { mode: "private" };
} else if(this.channel.properties.channel_conversation_mode === 1) {
return { mode: "public", history: this.channel.properties.channel_conversation_history_length };
} else {
return { mode: "none" };
}
});
this.variables.setVariableProvider("currentClients", () => {
if(!this.channel.isSubscribed()) {
return { status: "unsubscribed" };
}
let limit;
if(!this.channel.properties.channel_flag_maxclients_unlimited) {
limit = this.channel.properties.channel_maxclients;
} else if(!this.channel.properties.channel_flag_maxfamilyclients_unlimited) {
if(this.channel.properties.channel_flag_maxfamilyclients_inherited) {
limit = "inherited";
} else {
limit = this.channel.properties.channel_maxfamilyclients;
}
} else {
limit = "unlimited";
}
return {
status: "subscribed",
online: this.channel.clients().length,
limit: limit
};
});
this.variables.setVariableProvider("audioCodec", () => ({
codec: this.channel.properties.channel_codec,
quality: this.channel.properties.channel_codec_quality
}));
this.variables.setVariableProvider("audioEncrypted", () => ({
channel: this.channel.properties.channel_codec_is_unencrypted,
server: this.channel.channelTree.server.getAudioEncryptionMode()
}));
this.variables.setVariableProvider("password", () => this.channel.properties.channel_flag_password);
this.variables.setVariableProvider("topic", () => this.channel.properties.channel_topic);
this.variables.setVariableProvider("description", async () => {
const result = _.cloneDeep(await this.channel.getChannelDescription(false));
if(result.status === "success") {
result.handlerId = this.channel.channelTree.client.handlerId;
return result;
}
return result;
});
this.events.on("action_reload_description", () => {
this.channel.clearDescriptionCache();
this.variables.sendVariable("description");
})
this.channelEvents = [];
this.channelEvents.push(this.channel.events.on("notify_properties_updated", event => {
const updatedVariables = new Set<keyof ModalChannelInfoVariables>();
for(const key of Object.keys(event.updated_properties)) {
kChannelUpdateMapping[key]?.forEach(update => updatedVariables.add(update));
}
updatedVariables.forEach(entry => this.variables.sendVariable(entry));
}));
this.channelEvents.push(this.channel.events.on("notify_subscribe_state_changed", () => {
this.variables.sendVariable("currentClients");
}));
this.channelEvents.push(this.channel.channelTree.events.on("notify_client_enter_view", event => {
if(event.targetChannel === this.channel) {
this.variables.sendVariable("currentClients");
}
}));
this.channelEvents.push(this.channel.channelTree.events.on("notify_client_moved", event => {
if(event.oldChannel === this.channel || event.newChannel === this.channel) {
this.variables.sendVariable("currentClients");
}
}));
this.channelEvents.push(this.channel.channelTree.events.on("notify_client_leave_view", event => {
if(event.sourceChannel === this.channel) {
this.variables.sendVariable("currentClients");
}
}));
}
}
export function spawnChannelInfo(channel: ChannelEntry) {
const controller = new Controller(channel);
controller.initialize();
const modal = spawnModal("channel-info", [
controller.events.generateIpcDescription(),
controller.variables.generateConsumerDescription(),
], {
popoutable: true
});
modal.getEvents().on("destroy", () => controller.destroy());
ignorePromise(modal.show());
}

View File

@ -0,0 +1,19 @@
/* TODO: If channel is temporary: delete delay! */
import {ServerAudioEncryptionMode} from "tc-shared/tree/ServerDefinitions";
import {ChannelDescriptionResult} from "tc-shared/tree/ChannelDefinitions";
export interface ModalChannelInfoVariables {
readonly name: string,
readonly type: "default" | "permanent" | "semi-permanent" | "temporary" | "unknown";
readonly chatMode: { mode: "private" | "none" } | { mode: "public", history: number | 0 | -1 }
readonly currentClients: { status: "subscribed", online: number, limit: number | "unlimited" | "inherited" } | { status: "unsubscribed" },
readonly audioCodec: { codec: number, quality: number },
readonly audioEncrypted: { channel: boolean, server: ServerAudioEncryptionMode },
readonly password: boolean,
readonly topic: string,
readonly description: ChannelDescriptionResult,
}
export interface ModalChannelInfoEvents {
action_reload_description: {}
}

View File

@ -0,0 +1,173 @@
@import "../../../../css/static/mixin";
@import "../../../../css/static/properties";
.container {
display: flex;
flex-direction: column;
justify-content: stretch;
padding: 0;
&.windowed {
width: 100%;
height: 100%;
}
&:not(.windowed) {
min-width: 30em;
max-height: calc(100vh - 10em);
}
}
.row {
flex-grow: 0;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: stretch;
padding-top: 1em;
padding-left: .5em;
padding-right: .5em;
.column {
flex-grow: 1;
flex-shrink: 1;
min-width: 6em;
width: 10em;
margin-right: .5em;
margin-left: .5em;
.title {
text-transform: uppercase;
color: #557edc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.value {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&.audioEncrypted {
/* looks better */
.value {
height: 1.6em;
overflow: visible;
}
}
}
}
.description {
flex-grow: 1;
flex-shrink: 1;
min-height: 8em; /* description plus title */
display: flex;
flex-direction: column;
justify-content: stretch;
padding-top: 1em;
padding-left: 1em;
padding-right: 1em;
.title {
display: flex;
flex-direction: row;
justify-content: flex-start;
flex-grow: 0;
flex-shrink: 0;
text-transform: uppercase;
color: #557edc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.buttonCopy {
display: flex;
flex-direction: column;
justify-content: center;
margin-top: .1em; /* looks a bit better */
margin-left: .5em;
border-radius: .2em;
width: 1.3em;
height: 1.3em;
cursor: pointer;
div {
align-self: center;
}
&:hover {
background-color: #313135;
}
@include transition($button_hover_animation_time ease-in-out);
}
}
.value {
display: block;
flex-grow: 1;
flex-shrink: 1;
border-radius: 0.2em;
border: 1px solid #212324;
background-color: #3a3b3f;
padding: .5em;
height: max-content;
min-height: 6em;
max-height: 40em;
overflow-y: auto;
overflow-x: hidden;
@include chat-scrollbar-vertical();
}
.overlay {
flex-grow: 0;
flex-shrink: 0;
font-size: 1.25em;
height: (6em / 1.25); /* min value height and a bit more */
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
color: #666666;
&.hidden {
display: none;
}
}
}
.buttons {
flex-grow: 0;
flex-shrink: 0;
display: flex;
flex-direction: row;
justify-content: flex-end;
padding: 1em;
}

View File

@ -0,0 +1,305 @@
import {AbstractModal} from "tc-shared/ui/react-elements/modal/Definitions";
import React, {useContext} from "react";
import {IpcRegistryDescription, Registry} from "tc-events";
import {ModalChannelInfoEvents, ModalChannelInfoVariables} from "tc-shared/ui/modal/channel-info/Definitions";
import {UiVariableConsumer} from "tc-shared/ui/utils/Variable";
import {createIpcUiVariableConsumer, IpcVariableDescriptor} from "tc-shared/ui/utils/IpcVariable";
import {Translatable, VariadicTranslatable} from "tc-shared/ui/react-elements/i18n";
import {tr} from "tc-shared/i18n/localize";
import {joinClassList, useTr} from "tc-shared/ui/react-elements/Helper";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
import {Button} from "tc-shared/ui/react-elements/Button";
import {BBCodeRenderer} from "tc-shared/text/bbcode";
const cssStyle = require("./Renderer.scss");
const VariablesContext = React.createContext<UiVariableConsumer<ModalChannelInfoVariables>>(undefined);
const EventContext = React.createContext<Registry<ModalChannelInfoEvents>>(undefined);
const kCodecNames = [
() => useTr("Speex Narrowband"),
() => useTr("Speex Wideband"),
() => useTr("Speex Ultra-Wideband"),
() => useTr("CELT Mono"),
() => useTr("Opus Voice"),
() => useTr("Opus Music")
];
const TitleRenderer = React.memo(() => {
const title = useContext(VariablesContext).useReadOnly("name");
if(title.status === "loaded") {
return (
<VariadicTranslatable text={"Channel information: {}"} key={"channel"}>
{title.value}
</VariadicTranslatable>
);
} else {
return (
<Translatable key={"general"}>Channel information</Translatable>
);
}
});
const TopicRenderer = React.memo(() => {
const topic = useContext(VariablesContext).useReadOnly("topic");
if(topic.status !== "loaded" || !topic.value) {
return null;
}
return (
<div className={cssStyle.row}>
<div className={cssStyle.title}>
<Translatable>Topic</Translatable>
</div>
<div className={cssStyle.value}>
{topic.value}
</div>
</div>
)
});
const DescriptionRenderer = React.memo(() => {
const description = useContext(VariablesContext).useReadOnly("description");
let overlay;
let descriptionBody;
if(description.status === "loaded") {
switch(description.value.status) {
case "success":
descriptionBody = (
<BBCodeRenderer
message={description.value.description}
settings={{ convertSingleUrls: true }}
handlerId={description.value.handlerId}
/>
);
break;
case "empty":
overlay = <Translatable key={"no-description"}>Channel has no description</Translatable>;
break;
case "no-permissions":
overlay = (
<VariadicTranslatable key={"no-permissions"} text={"No permissions to view the channel description:\n{}"}>
{description.value.failedPermission}
</VariadicTranslatable>
);
break;
case "error":
default:
overlay = (
<VariadicTranslatable key={"error"} text={"Failed to query channel description:\n{}"}>
{description.value.status === "error" ? description.value.message : tr("Unknown error")}
</VariadicTranslatable>
);
break;
}
} else {
overlay = <React.Fragment><Translatable>Loading</Translatable> <LoadingDots /></React.Fragment>;
}
return (
<div className={cssStyle.description}>
<div className={cssStyle.title}>
<Translatable>Description</Translatable>
</div>
<div className={cssStyle.value} style={{ display: descriptionBody ? undefined : "none" }}>
{descriptionBody}
</div>
<div className={joinClassList(cssStyle.overlay, descriptionBody && cssStyle.hidden)}>
{overlay}
</div>
</div>
);
});
const kVariablePropertyName: {[T in keyof ModalChannelInfoVariables]?: () => React.ReactElement } = {
name: () => <Translatable>Channel Name</Translatable>,
type: () => <Translatable>Channel Type</Translatable>,
chatMode: () => <Translatable>Chat Mode</Translatable>,
currentClients: () => <Translatable>Current Clients</Translatable>,
audioCodec: () => <Translatable>Audio Codec</Translatable>,
audioEncrypted: () => <Translatable>Audio Encrypted</Translatable>,
password: () => <Translatable>Password protected</Translatable>,
topic: () => <Translatable>Topic</Translatable>,
description: () => <Translatable>Description</Translatable>
};
const PropertyRenderer = <T extends keyof ModalChannelInfoVariables>(props: {
property: T,
children: (value: ModalChannelInfoVariables[T]) => React.ReactNode | React.ReactNode[],
className?: string,
}) => {
const value = useContext(VariablesContext).useReadOnly(props.property);
return (
<div className={cssStyle.column + " " + props.className}>
<div className={cssStyle.title}>
{kVariablePropertyName[props.property]()}
</div>
<div className={cssStyle.value}>
{value.status === "loaded" ? props.children(value.value) : undefined}
</div>
</div>
);
};
class Modal extends AbstractModal {
private readonly events: Registry<ModalChannelInfoEvents>;
private readonly variables: UiVariableConsumer<ModalChannelInfoVariables>;
constructor(events: IpcRegistryDescription<ModalChannelInfoEvents>, variables: IpcVariableDescriptor<ModalChannelInfoVariables>) {
super();
this.events = Registry.fromIpcDescription(events);
this.variables = createIpcUiVariableConsumer(variables);
}
protected onDestroy() {
super.onDestroy();
this.events.destroy();
this.variables.destroy();
}
renderBody(): React.ReactElement {
return (
<EventContext.Provider value={this.events}>
<VariablesContext.Provider value={this.variables}>
<div className={joinClassList(cssStyle.container, this.properties.windowed && cssStyle.windowed)}>
<div className={cssStyle.row}>
<PropertyRenderer property={"type"}>
{value => {
switch (value) {
case "default":
return <Translatable key={value}>Default Channel</Translatable>;
case "permanent":
return <Translatable key={value}>Permanent</Translatable>;
case "semi-permanent":
return <Translatable key={value}>Semi-Permanent</Translatable>;
case "temporary":
return <Translatable key={value}>Temporary</Translatable>;
case "unknown":
default:
return <Translatable key={value}>Unknown</Translatable>;
}
}}
</PropertyRenderer>
<PropertyRenderer property={"chatMode"}>
{value => {
switch (value.mode) {
case "private":
return <Translatable key={"private"}>Private</Translatable>;
case "none":
return <Translatable key={"disabled"}>disabled</Translatable>;
case "public":
if(value.history === -1) {
return <Translatable key={"semi-permanent"}>Semi-Permanent</Translatable>;
} else if(value.history === 0) {
return <Translatable key={"permanent"}>Permanent</Translatable>;
} else {
return (
<VariadicTranslatable key={"public"} text={"Public; Saving last {} messages"}>
{value.history}
</VariadicTranslatable>
);
}
default:
return <Translatable key={"unknown"}>Unknown</Translatable>;
}
}}
</PropertyRenderer>
<PropertyRenderer property={"currentClients"}>
{value => {
if(value.status === "subscribed") {
let limit;
if(value.limit === "unlimited") {
limit = <Translatable key={"unlimited"}>Unlimited</Translatable>;
} else if(value.limit === "inherited") {
limit = <Translatable key={"inherited"}>Inherited</Translatable>;
} else {
limit = value.limit.toString();
}
return (
<React.Fragment>
{value.online} / {limit}
</React.Fragment>
);
} else if(value.status === "unsubscribed") {
return <Translatable key={"unsubscribed"}>Not subscribed</Translatable>;
} else {
return <Translatable key={"unknown"}>Unknown</Translatable>;
}
}}
</PropertyRenderer>
</div>
<div className={cssStyle.row}>
<PropertyRenderer property={"audioCodec"}>
{value => (
<React.Fragment>
{kCodecNames[value.codec]() || tr("Unknown")} ({value.quality})
</React.Fragment>
)}
</PropertyRenderer>
<PropertyRenderer property={"audioEncrypted"} className={cssStyle.audioEncrypted}>
{value => {
let textValue = value.channel ? "Encrypted" : "Unencrypted";
switch(value.server) {
case "globally-on":
return (
<VariadicTranslatable text={"{}\nOverridden by the server with encrypted!"} key={"global-on"}>
{textValue}
</VariadicTranslatable>
);
case "globally-off":
return (
<VariadicTranslatable text={"{}\nOverridden by the server with unencrypted!"} key={"global-off"}>
{textValue}
</VariadicTranslatable>
);
default:
return textValue;
}
}}
</PropertyRenderer>
<PropertyRenderer property={"password"}>
{value => value ? (
<Translatable key={"enabled"}>Yes</Translatable>
) : (
<Translatable key={"disabled"}>No</Translatable>
)}
</PropertyRenderer>
</div>
<TopicRenderer />
<DescriptionRenderer />
<div className={cssStyle.buttons}>
<Button color={"green"} onClick={() => this.events.fire("action_reload_description")}>
<Translatable>Refresh</Translatable>
</Button>
</div>
</div>
</VariablesContext.Provider>
</EventContext.Provider>
);
}
renderTitle(): string | React.ReactElement {
return (
<VariablesContext.Provider value={this.variables}>
<TitleRenderer />
</VariablesContext.Provider>
);
}
}
export default Modal;

View File

@ -25,6 +25,7 @@ import {ModalServerInfoEvents, ModalServerInfoVariables} from "tc-shared/ui/moda
import {ModalAboutVariables} from "tc-shared/ui/modal/about/Definitions";
import {ModalServerBandwidthEvents} from "tc-shared/ui/modal/server-bandwidth/Definitions";
import {ModalYesNoEvents, ModalYesNoVariables} from "tc-shared/ui/modal/yes-no/Definitions";
import {ModalChannelInfoEvents, ModalChannelInfoVariables} from "tc-shared/ui/modal/channel-info/Definitions";
export type ModalType = "error" | "warning" | "info" | "none";
export type ModalRenderType = "page" | "dialog";
@ -179,6 +180,10 @@ export interface ModalConstructorArguments {
/* events */ IpcRegistryDescription<ChannelEditEvents>,
/* isChannelCreate */ boolean
],
"channel-info": [
/* events */ IpcRegistryDescription<ModalChannelInfoEvents>,
/* variables */ IpcVariableDescriptor<ModalChannelInfoVariables>
],
"echo-test": [
/* events */ IpcRegistryDescription<EchoTestEvents>
],

View File

@ -37,6 +37,12 @@ registerModal({
popoutSupported: false /* TODO: Needs style fixing */
});
registerModal({
modalId: "channel-info",
classLoader: async () => await import("tc-shared/ui/modal/channel-info/Renderer"),
popoutSupported: true
});
registerModal({
modalId: "echo-test",
classLoader: async () => await import("tc-shared/ui/modal/echo-test/Renderer"),

2
vendor/xbbcode vendored

@ -1 +1 @@
Subproject commit d75b0292e8726646867967c8b35cbb52c1580769
Subproject commit e61434dc048c5de71c31ca3a11f7db97217b0231