Reworked the channel info modal with React
parent
feeb086cb8
commit
eebe7aa6b9
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
|
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -10,7 +10,7 @@ import HTMLRenderer from "vendor/xbbcode/renderer/html";
|
|||
|
||||
import "./emoji";
|
||||
import "./highlight";
|
||||
import "./YoutubeController";
|
||||
import "./YoutubeRenderer";
|
||||
import "./url";
|
||||
import "./image";
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
export type ChannelDescriptionResult = {
|
||||
status: "success",
|
||||
description: string,
|
||||
handlerId: string
|
||||
} | {
|
||||
status: "empty"
|
||||
} | {
|
||||
status: "no-permissions",
|
||||
failedPermission: string
|
||||
} | {
|
||||
status: "error",
|
||||
message: string
|
||||
};
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
export type ServerAudioEncryptionMode = "globally-on" | "globally-off" | "individual";
|
||||
|
||||
export class ServerProperties {
|
||||
virtualserver_host: string = "";
|
||||
virtualserver_port: number = 0;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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 "";
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
|
@ -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: {}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
],
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit d75b0292e8726646867967c8b35cbb52c1580769
|
||||
Subproject commit e61434dc048c5de71c31ca3a11f7db97217b0231
|
Loading…
Reference in New Issue