Merge pull request #167 from TeaSpeak/develop

Develop
master f75a6e2
WolverinDEV 2021-02-12 11:48:26 +01:00 committed by GitHub
commit f75a6e29fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
89 changed files with 2586 additions and 1546 deletions

View File

@ -1,4 +1,19 @@
# Changelog:
* **22.01.21**
- Allowing the user to easily change the channel name mode
- Fixed channel name mode parsing
- Improved the modal algorithms as preparation for easier popoutable modals
* **16.01.21**
- Various bugfixes (Thanks to Vafin)
* **15.01.21**
- Fixed the history toggle (Thanks to Vafin)
* **12.01.21**
- Fixed bug where the quick video select popup did not start the video broadcasting
- Fixed a bug where an invalid H264 codec may caused video connection setup failure
* **09.01.21**
- The connect modal now connects when pressing `Enter` on the address line

View File

@ -33,7 +33,7 @@ function load_script_url(url: string) : Promise<void> {
const timeout_handle = setTimeout(() => {
cleanup();
reject("timeout");
}, 15 * 1000);
}, 120 * 1000);
script_tag.type = "application/javascript";
script_tag.async = true;
script_tag.defer = true;

View File

@ -41,6 +41,9 @@ import {ServerEventLog} from "tc-shared/connectionlog/ServerEventLog";
import {PlaylistManager} from "tc-shared/music/PlaylistManager";
import {connectionHistory} from "tc-shared/connectionlog/History";
import {ConnectParameters} from "tc-shared/ui/modal/connect/Controller";
import {assertMainApplication} from "tc-shared/ui/utils";
assertMainApplication();
export enum InputHardwareState {
MISSING,
@ -240,7 +243,7 @@ export class ConnectionHandler {
this.localClient = new LocalClientEntry(this);
this.localClient.channelTree = this.channelTree;
this.events_.register_handler(this);
this.events_.registerHandler(this);
this.pluginCmdRegistry.registerHandler(new W2GPluginCmdHandler());
this.events_.fire("notify_handler_initialized");
@ -1073,7 +1076,7 @@ export class ConnectionHandler {
}
destroy() {
this.events_.unregister_handler(this);
this.events_.unregisterHandler(this);
this.cancelAutoReconnect(true);
this.pluginCmdRegistry?.destroy();

View File

@ -2,6 +2,9 @@ import {ConnectionHandler, DisconnectReason} from "./ConnectionHandler";
import {Registry} from "./events";
import {Stage} from "tc-loader";
import * as loader from "tc-loader";
import {assertMainApplication} from "tc-shared/ui/utils";
assertMainApplication();
export interface ConnectionManagerEvents {
notify_handler_created: {

View File

@ -74,6 +74,7 @@ class ClientServiceConnection {
let address;
address = "client-services.teaspeak.de:27791";
//address = "localhost:1244";
//address = "192.168.40.135:1244";
this.connection = new WebSocket(`wss://${address}/ws-api/v${kApiVersion}`);
this.connection.onclose = event => {
@ -375,7 +376,7 @@ export class ClientServices {
} else {
const os = window.detectedBrowser.os;
const osParts = os.split(" ");
if(osParts.last().match(/^[0-9]+$/)) {
if(osParts.last().match(/^[0-9\.]+$/)) {
payload.platform_version = osParts.last();
osParts.splice(osParts.length - 1, 1);
}

View File

@ -29,7 +29,7 @@ export class ServerFeatures {
readonly events: Registry<ServerFeatureEvents>;
private readonly connection: ConnectionHandler;
private readonly explicitCommandHandler: ExplicitCommandHandler;
private readonly stateChangeListener: (event: ConnectionEvents["notify_connection_state_changed"]) => void;
private readonly stateChangeListener: () => void;
private featureAwait: Promise<boolean>;
private featureAwaitCallback: (success: boolean) => void;
@ -68,7 +68,7 @@ export class ServerFeatures {
}
});
this.connection.events().on("notify_connection_state_changed", this.stateChangeListener = event => {
this.stateChangeListener = this.connection.events().on("notify_connection_state_changed", event => {
if(event.newState === ConnectionState.CONNECTED) {
this.connection.getServerConnection().send_command("listfeaturesupport").catch(error => {
this.disableAllFeatures();
@ -95,7 +95,7 @@ export class ServerFeatures {
}
destroy() {
this.connection.events().off(this.stateChangeListener);
this.stateChangeListener();
this.connection.getServerConnection()?.command_handler_boss()?.unregister_explicit_handler("notifyfeaturesupport", this.explicitCommandHandler);
if(this.featureAwaitCallback) {

View File

@ -63,7 +63,6 @@ class RetryTimeCalculator {
}
calculateRetryTime() {
return 0;
if(this.retryCount >= 5) {
/* no more retries */
return 0;
@ -542,6 +541,8 @@ export class RTCConnection {
this.reset(true);
this.connection.events.on("notify_connection_state_changed", event => this.handleConnectionStateChanged(event));
(window as any).rtp = this;
}
destroy() {

View File

@ -48,7 +48,7 @@ export class SdpProcessor {
rate: 90000,
rtcpFb: [ "nack", "nack pli", "ccm fir", "transport-cc" ],
},
{
window.detectedBrowser.name.indexOf("ios") === -1 && window.detectedBrowser.name !== "safari" ? {
payload: H264_PAYLOAD_TYPE,
codec: "H264",
rate: 90000,
@ -57,10 +57,10 @@ export class SdpProcessor {
fmtp: {
"level-asymmetry-allowed": 1,
"packetization-mode": 1,
"profile-level-id": "4d0028",
"profile-level-id": "42001f",
"max-fr": 30,
}
},
} : undefined,
];
private rtpRemoteChannelMapping: {[key: string]: number};
@ -150,7 +150,11 @@ export class SdpProcessor {
media.rtcpFb = [];
media.rtcpFbTrrInt = [];
for(let codec of (media.type === "audio" ? this.kAudioCodecs : this.kVideoCodecs)) {
for(const codec of (media.type === "audio" ? this.kAudioCodecs : this.kVideoCodecs)) {
if(!codec) {
continue;
}
media.rtp.push({
payload: codec.payload,
codec: codec.codec,

File diff suppressed because it is too large Load Diff

View File

@ -29,6 +29,7 @@ import {IPCChannel} from "../ipc/BrowserIPC";
import {ConnectionHandler} from "../ConnectionHandler";
import {ErrorCode} from "../connection/ErrorCode";
import {server_connections} from "tc-shared/ConnectionManager";
import {EventDispatchType} from "tc-shared/events";
/* FIXME: Retry avatar download after some time! */
@ -424,8 +425,10 @@ class LocalAvatarManagerFactory extends AbstractAvatarManagerFactory {
subscribedAvatars.push({
avatar: avatar,
remoteAvatarId: avatarId,
unregisterCallback: avatar.events.onAll(event => {
this.ipcChannel.sendMessage("avatar-event", { handlerId: handlerId, avatarId: avatarId, event: event }, remoteId);
unregisterCallback: avatar.events.registerConsumer({
handleEvent(mode: EventDispatchType, type: string, payload: any) {
this.ipcChannel.sendMessage("avatar-event", { handlerId: handlerId, avatarId: avatarId, type, payload }, remoteId);
}
})
});

View File

@ -142,11 +142,11 @@ class RemoteAvatarManager extends AbstractAvatarManager {
avatar.updateStateFromRemote(data.state, data.stateData);
}
handleAvatarEvent(data: any) {
const avatar = this.knownAvatars.find(e => e.avatarId === data.avatarId);
handleAvatarEvent(type: string, payload: any) {
const avatar = this.knownAvatars.find(e => e.avatarId === payload.avatarId);
if(!avatar) return;
avatar.events.fire(data.event.type, data.event, true);
avatar.events.fire(type as any, payload, true);
}
}
@ -211,7 +211,7 @@ class RemoteAvatarManagerFactory extends AbstractAvatarManagerFactory {
manager?.handleAvatarLoadCallback(message.data);
} else if(message.type === "avatar-event") {
const manager = this.manager[message.data.handlerId];
manager?.handleAvatarEvent(message.data);
manager?.handleAvatarEvent(message.data.type, message.data.payload);
}
}
}

View File

@ -1,4 +1,4 @@
import {LogCategory, logError, logInfo, logWarn} from "../log";
import {LogCategory, logDebug, logError, logInfo, logTrace, logWarn} from "../log";
import {guid} from "../crypto/uid";
import {Settings, StaticSettings} from "../settings";
import * as loader from "tc-loader";
@ -48,22 +48,29 @@ export interface TranslationRepository {
}
let translations: Translation[] = [];
let fast_translate: { [key:string]:string; } = {};
export function tr(message: string, key?: string) {
const sloppy = fast_translate[message];
if(sloppy) return sloppy;
let translateCache: { [key:string]: string; } = {};
export function tr(message: string, key?: string) : string {
const sloppy = translateCache[message];
if(sloppy) {
return sloppy;
}
logInfo(LogCategory.I18N, "Translating \"%s\". Default: \"%s\"", key, message);
logTrace(LogCategory.I18N, "Translating \"%s\". Default: \"%s\"", key, message);
let translated = message;
let translated;
for(const translation of translations) {
if(translation.key.message == message) {
if(translation.key.message === message) {
translated = translation.translated;
break;
}
}
fast_translate[message] = translated;
if(typeof translated === "string") {
translateCache[message] = translated;
} else {
logDebug(LogCategory.I18N, "Missing translation for \"%s\".", message);
translateCache[message] = translated = message;
}
return translated;
}
@ -316,6 +323,7 @@ export async function initialize() {
if(cfg.current_translation_url) {
try {
await load_file(cfg.current_translation_url, cfg.current_translation_path);
translateCache = {};
} catch (error) {
logError(LogCategory.I18N, tr("Failed to initialize selected translation: %o"), error);
const show_error = () => {

View File

@ -47,6 +47,9 @@ import "./ui/elements/ContextDivider";
import "./ui/elements/Tab";
import "./clientservice";
import {initializeKeyControl} from "./KeyControl";
import {assertMainApplication} from "tc-shared/ui/utils";
assertMainApplication();
let preventWelcomeUI = false;
async function initialize() {

View File

@ -248,9 +248,9 @@ export class WebVideoSource implements VideoSource {
private readonly deviceId: string;
private readonly displayName: string;
private readonly stream: MediaStream;
private readonly initialSettings: VideoSourceInitialSettings;
private referenceCount = 1;
private initialSettings: VideoSourceInitialSettings;
constructor(deviceId: string, displayName: string, stream: MediaStream) {
this.deviceId = deviceId;
@ -291,13 +291,13 @@ export class WebVideoSource implements VideoSource {
return {
minWidth: capabilities?.width?.min || 1,
maxWidth: capabilities?.width?.max || this.initialSettings.width,
maxWidth: capabilities?.width?.max || this.initialSettings.width || undefined,
minHeight: capabilities?.height?.min || 1,
maxHeight: capabilities?.height?.max || this.initialSettings.height,
maxHeight: capabilities?.height?.max || this.initialSettings.height || undefined,
minFrameRate: capabilities?.frameRate?.min || 1,
maxFrameRate: capabilities?.frameRate?.max || this.initialSettings.frameRate
maxFrameRate: capabilities?.frameRate?.max || this.initialSettings.frameRate || undefined
};
}

View File

@ -143,6 +143,7 @@ export namespace AppParameters {
parseParameters();
}
(window as any).AppParameters = AppParameters;
export namespace AppParameters {
export const KEY_CONNECT_ADDRESS: RegistryKey<string> = {
@ -216,6 +217,12 @@ export namespace AppParameters {
description: "Address of the owner for IPC communication."
};
export const KEY_IPC_REMOTE_POPOUT_CHANNEL: RegistryKey<string> = {
key: "ipc-channel",
valueType: "string",
description: "The channel name of the popout channel communication id"
};
export const KEY_MODAL_TARGET: RegistryKey<string> = {
key: "modal-target",
valueType: "string",
@ -644,6 +651,20 @@ export class Settings {
valueType: "number",
};
static readonly KEY_VIDEO_DEFAULT_MAX_BANDWIDTH: ValuedRegistryKey<number> = {
key: "video_default_max_bandwidth",
defaultValue: 1_600_000,
description: "The default video bandwidth to use in bits/seconds.\nA too high value might not be allowed by all server permissions.",
valueType: "number",
};
static readonly KEY_VIDEO_DEFAULT_KEYFRAME_INTERVAL: ValuedRegistryKey<number> = {
key: "video_default_keyframe_interval",
defaultValue: 0,
description: "The default interval to forcibly request a keyframe from ourself in seconds. A value of zero means no such interval.",
valueType: "number",
};
static readonly KEY_VIDEO_DYNAMIC_QUALITY: ValuedRegistryKey<boolean> = {
key: "video_dynamic_quality",
defaultValue: true,

View File

@ -114,54 +114,72 @@ export interface ChannelEvents extends ChannelTreeEntryEvents {
notify_description_changed: {}
}
export class ParsedChannelName {
export type ChannelNameAlignment = "center" | "right" | "left" | "normal" | "repetitive";
export class ChannelNameParser {
readonly originalName: string;
alignment: "center" | "right" | "left" | "normal" | "repetitive";
alignment: ChannelNameAlignment;
text: string; /* does not contain any alignment codes */
uniqueId: string;
constructor(name: string, hasParentChannel: boolean) {
this.originalName = name;
this.parse(hasParentChannel);
}
private parse(has_parent_channel: boolean) {
private parse(hasParentChannel: boolean) {
this.alignment = "normal";
if(this.originalName.length < 3) {
this.text = this.originalName;
return;
}
parse_type:
if(!has_parent_channel && this.originalName.charAt(0) == '[') {
parseType:
if(!hasParentChannel && this.originalName.charAt(0) == '[') {
let end = this.originalName.indexOf(']');
if(end === -1) break parse_type;
if(end === -1) {
break parseType;
}
let options = this.originalName.substr(1, end - 1);
if(options.indexOf("spacer") === -1) break parse_type;
options = options.substr(0, options.indexOf("spacer"));
const spacerIndex = options.indexOf("spacer");
if(spacerIndex === -1) break parseType;
this.uniqueId = options.substring(spacerIndex + 6);
options = options.substr(0, spacerIndex);
if(options.length == 0)
if(options.length == 0) {
options = "l";
else if(options.length > 1)
} else if(options.length > 1) {
options = options[0];
}
switch (options) {
case "r":
this.alignment = "right";
break;
case "l":
this.alignment = "center";
this.alignment = "left";
break;
case "c":
this.alignment = "center";
break;
case "*":
this.alignment = "repetitive";
break;
default:
break parse_type;
break parseType;
}
this.text = this.originalName.substr(end + 1);
}
if(!this.text && this.alignment === "normal")
if(!this.text && this.alignment === "normal") {
this.text = this.originalName;
}
}
}
@ -177,7 +195,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
readonly events: Registry<ChannelEvents>;
parsed_channel_name: ParsedChannelName;
parsed_channel_name: ChannelNameParser;
private _family_index: number = 0;
@ -206,7 +224,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
this.properties = new ChannelProperties();
this.channelId = channelId;
this.properties.channel_name = channelName;
this.parsed_channel_name = new ParsedChannelName(channelName, false);
this.parsed_channel_name = new ChannelNameParser(channelName, false);
this.clientPropertyChangedListener = (event: ClientEvents["notify_properties_updated"]) => {
if("client_nickname" in event.updated_properties || "client_talk_power" in event.updated_properties) {
@ -627,7 +645,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
if(hasUpdate) {
if(key == "channel_name") {
this.parsed_channel_name = new ParsedChannelName(value, this.hasParent());
this.parsed_channel_name = new ChannelNameParser(value, this.hasParent());
} else if(key == "channel_order") {
let order = this.channelTree.findChannel(this.properties.channel_order);
this.channelTree.moveChannel(this, order, this.parent, false);

View File

@ -36,14 +36,14 @@ export class ChannelConversationController extends AbstractConversationControlle
});
*/
this.uiEvents.register_handler(this, true);
this.uiEvents.registerHandler(this, true);
}
destroy() {
this.connectionListener.forEach(callback => callback());
this.connectionListener = [];
this.uiEvents.unregister_handler(this);
this.uiEvents.unregisterHandler(this);
super.destroy();
}

View File

@ -23,7 +23,7 @@ class PopoutConversationRenderer extends AbstractModal {
noFirstMessageOverlay={this.userData.noFirstMessageOverlay} />;
}
title() {
renderTitle() {
return "Conversations";
}
}

View File

@ -52,14 +52,14 @@ export class PrivateConversationController extends AbstractConversationControlle
this.connectionListener = [];
this.listenerConversation = {};
this.uiEvents.register_handler(this, true);
this.uiEvents.registerHandler(this, true);
this.uiEvents.enableDebug("private-conversations");
}
destroy() {
/* listenerConversation will be cleaned up via the listenerManager callbacks */
this.uiEvents.unregister_handler(this);
this.uiEvents.unregisterHandler(this);
super.destroy();
}

View File

@ -1,4 +1,4 @@
import {spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {spawnReactModal} from "tc-shared/ui/react-elements/modal";
import * as React from "react";
import {Slider} from "tc-shared/ui/react-elements/Slider";
import {Button} from "tc-shared/ui/react-elements/Button";
@ -243,7 +243,7 @@ class VolumeChange extends InternalModal {
return <VolumeChangeModal remote={false} clientName={this.client.clientNickName()} events={this.events} />;
}
title() {
renderTitle() {
return <Translatable>Change local volume</Translatable>;
}
}
@ -284,7 +284,7 @@ class VolumeChangeBot extends InternalModal {
return <VolumeChangeModal remote={true} clientName={this.client.clientNickName()} maxVolume={this.maxValue} events={this.events} />;
}
title() {
renderTitle() {
return <Translatable>Change remote volume</Translatable>;
}
}

View File

@ -1,4 +1,4 @@
import {spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {spawnReactModal} from "tc-shared/ui/react-elements/modal";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {Registry} from "tc-shared/events";
import {FlatInputField, Select} from "tc-shared/ui/react-elements/InputField";
@ -272,7 +272,7 @@ class ModalGroupCreate extends InternalModal {
</div>;
}
title() {
renderTitle() {
return this.target === "server" ? <Translatable>Create a new server group</Translatable> : <Translatable>Create a new channel group</Translatable>;
}

View File

@ -1,4 +1,4 @@
import {spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {spawnReactModal} from "tc-shared/ui/react-elements/modal";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {Registry} from "tc-shared/events";
import * as React from "react";
@ -161,7 +161,7 @@ class ModalGroupPermissionCopy extends InternalModal {
</div>;
}
title() {
renderTitle() {
return <Translatable>Copy group permissions</Translatable>;
}
}

View File

@ -1,7 +1,7 @@
import {createErrorModal, createModal} from "../../ui/elements/Modal";
import {ConnectionHandler} from "../../ConnectionHandler";
import {MusicClientEntry} from "../../tree/Client";
import {modal, Registry} from "../../events";
import {Registry} from "../../events";
import {CommandResult} from "../../connection/ServerConnectionDeclaration";
import {LogCategory, logError, logWarn} from "../../log";
import {tr, tra} from "../../i18n/localize";
@ -12,8 +12,160 @@ import * as htmltags from "../../ui/htmltags";
import {ErrorCode} from "../../connection/ErrorCode";
import ServerGroup = find.ServerGroup;
type BotStatusType = "name" | "description" | "volume" | "country_code" | "channel_commander" | "priority_speaker";
type PlaylistStatusType = "replay_mode" | "finished" | "delete_played" | "max_size" | "notify_song_change";
interface music_manage {
show_container: { container: "settings" | "permissions"; };
/* setting relevant */
query_bot_status: {},
bot_status: {
status: "success" | "error";
error_msg?: string;
data?: {
name: string,
description: string,
volume: number,
country_code: string,
default_country_code: string,
channel_commander: boolean,
priority_speaker: boolean,
client_version: string,
client_platform: string,
uptime_mode: number,
bot_type: number
}
},
set_bot_status: {
key: BotStatusType,
value: any
},
set_bot_status_result: {
key: BotStatusType,
status: "success" | "error" | "timeout",
error_msg?: string,
value?: any
}
query_playlist_status: {},
playlist_status: {
status: "success" | "error",
error_msg?: string,
data?: {
replay_mode: number,
finished: boolean,
delete_played: boolean,
max_size: number,
notify_song_change: boolean
}
},
set_playlist_status: {
key: PlaylistStatusType,
value: any
},
set_playlist_status_result: {
key: PlaylistStatusType,
status: "success" | "error" | "timeout",
error_msg?: string,
value?: any
}
/* permission relevant */
show_client_list: {},
hide_client_list: {},
filter_client_list: { filter: string | undefined },
"refresh_permissions": {},
query_special_clients: {},
special_client_list: {
status: "success" | "error" | "error-permission",
error_msg?: string,
clients?: {
name: string,
unique_id: string,
database_id: number
}[]
},
search_client: { text: string },
search_client_result: {
status: "error" | "timeout" | "empty" | "success",
error_msg?: string,
client?: {
name: string,
unique_id: string,
database_id: number
}
},
/* sets a client to set the permission for */
special_client_set: {
client?: {
name: string,
unique_id: string,
database_id: number
}
},
"query_general_permissions": {},
"general_permissions": {
status: "error" | "timeout" | "success",
error_msg?: string,
permissions?: {[key: string]:number}
},
"set_general_permission_result": {
status: "error" | "success",
key: string,
value?: number,
error_msg?: string
},
"set_general_permission": { /* try to change a permission for the server */
key: string,
value: number
},
"query_client_permissions": { client_database_id: number },
"client_permissions": {
status: "error" | "timeout" | "success",
client_database_id: number,
error_msg?: string,
permissions?: {[key: string]:number}
},
"set_client_permission_result": {
status: "error" | "success",
client_database_id: number,
key: string,
value?: number,
error_msg?: string
},
"set_client_permission": { /* try to change a permission for the server */
client_database_id: number,
key: string,
value: number
},
"query_group_permissions": { permission_name: string },
"group_permissions": {
permission_name: string;
status: "error" | "timeout" | "success"
groups?: {
name: string,
value: number,
id: number
}[],
error_msg?: string
}
}
export function openMusicManage(client: ConnectionHandler, bot: MusicClientEntry) {
const ev_registry = new Registry<modal.music_manage>();
const ev_registry = new Registry<music_manage>();
ev_registry.enableDebug("music-manage");
//dummy_controller(ev_registry);
permission_controller(ev_registry, bot, client);
@ -36,7 +188,7 @@ export function openMusicManage(client: ConnectionHandler, bot: MusicClientEntry
modal.open();
}
function permission_controller(event_registry: Registry<modal.music_manage>, bot: MusicClientEntry, client: ConnectionHandler) {
function permission_controller(event_registry: Registry<music_manage>, bot: MusicClientEntry, client: ConnectionHandler) {
const error_msg = error => {
if (error instanceof CommandResult) {
if (error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
@ -380,7 +532,7 @@ function permission_controller(event_registry: Registry<modal.music_manage>, bot
}
}
function dummy_controller(event_registry: Registry<modal.music_manage>) {
function dummy_controller(event_registry: Registry<music_manage>) {
/* settings */
{
event_registry.on("query_bot_status", event => {
@ -510,7 +662,7 @@ function dummy_controller(event_registry: Registry<modal.music_manage>) {
}
function build_modal(event_registry: Registry<modal.music_manage>): JQuery<HTMLElement> {
function build_modal(event_registry: Registry<music_manage>): JQuery<HTMLElement> {
const tag = $("#tmpl_music_manage").renderTag();
const container_settings = tag.find(".body > .category-settings");
@ -563,7 +715,7 @@ function build_modal(event_registry: Registry<modal.music_manage>): JQuery<HTMLE
return tag.children();
}
function build_settings_container(event_registry: Registry<modal.music_manage>, tag: JQuery<HTMLElement>) {
function build_settings_container(event_registry: Registry<music_manage>, tag: JQuery<HTMLElement>) {
const show_change_error = (header, message) => {
createErrorModal(tr("Failed to change value"), header + "<br>" + message).open();
};
@ -1169,7 +1321,7 @@ function build_settings_container(event_registry: Registry<modal.music_manage>,
}
}
function build_permission_container(event_registry: Registry<modal.music_manage>, tag: JQuery<HTMLElement>) {
function build_permission_container(event_registry: Registry<music_manage>, tag: JQuery<HTMLElement>) {
/* client search mechanism */
{
const container = tag.find(".table-head .column-client-specific .client-select");

View File

@ -1,7 +1,7 @@
import {createModal, Modal} from "tc-shared/ui/elements/Modal";
import {tra} from "tc-shared/i18n/localize";
import {modal as emodal, Registry} from "tc-shared/events";
import {modal_settings} from "tc-shared/ui/modal/ModalSettings";
import {Registry} from "tc-shared/events";
import {modal_settings, SettingProfileEvents} from "tc-shared/ui/modal/ModalSettings";
import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo";
import {initialize_audio_microphone_controller, MicrophoneSettingsEvents} from "tc-shared/ui/modal/settings/Microphone";
import {MicrophoneSettings} from "tc-shared/ui/modal/settings/MicrophoneRenderer";
@ -151,7 +151,7 @@ function initializeStepFinish(tag: JQuery, event_registry: Registry<EventModalNe
}
function initializeStepIdentity(tag: JQuery, event_registry: Registry<EventModalNewcomer>) {
const profile_events = new Registry<emodal.settings.profiles>();
const profile_events = new Registry<SettingProfileEvents>();
profile_events.enableDebug("settings-identity");
modal_settings.initialize_identity_profiles_controller(profile_events);
modal_settings.initialize_identity_profiles_view(tag, profile_events, {forum_setuppable: false});

View File

@ -28,6 +28,164 @@ import {NotificationSettings} from "tc-shared/ui/modal/settings/Notifications";
import {initialize_audio_microphone_controller, MicrophoneSettingsEvents} from "tc-shared/ui/modal/settings/Microphone";
import {MicrophoneSettings} from "tc-shared/ui/modal/settings/MicrophoneRenderer";
type ProfileInfoEvent = {
id: string,
name: string,
nickname: string,
identity_type: "teaforo" | "teamspeak" | "nickname",
identity_forum?: {
valid: boolean,
fallback_name: string
},
identity_nickname?: {
name: string,
fallback_name: string
},
identity_teamspeak?: {
unique_id: string,
fallback_name: string
}
}
export interface SettingProfileEvents {
"reload-profile": { profile_id?: string },
"select-profile": { profile_id: string },
"query-profile-list": { },
"query-profile-list-result": {
status: "error" | "success" | "timeout",
error?: string;
profiles?: ProfileInfoEvent[]
}
"query-profile": { profile_id: string },
"query-profile-result": {
status: "error" | "success" | "timeout",
profile_id: string,
error?: string;
info?: ProfileInfoEvent
},
"select-identity-type": {
profile_id: string,
identity_type: "teamspeak" | "teaforo" | "nickname" | "unset"
},
"query-profile-validity": { profile_id: string },
"query-profile-validity-result": {
profile_id: string,
status: "error" | "success" | "timeout",
error?: string,
valid?: boolean
}
"create-profile": { name: string },
"create-profile-result": {
status: "error" | "success" | "timeout",
name: string;
profile_id?: string;
error?: string;
},
"delete-profile": { profile_id: string },
"delete-profile-result": {
status: "error" | "success" | "timeout",
profile_id: string,
error?: string
}
"set-default-profile": { profile_id: string },
"set-default-profile-result": {
status: "error" | "success" | "timeout",
/* the profile which now has the id "default" */
old_profile_id: string,
/* the "default" profile which now has a new id */
new_profile_id?: string
error?: string;
}
/* profile name events */
"set-profile-name": {
profile_id: string,
name: string
},
"set-profile-name-result": {
status: "error" | "success" | "timeout",
profile_id: string,
name?: string
},
/* profile nickname events */
"set-default-name": {
profile_id: string,
name: string | null
},
"set-default-name-result": {
status: "error" | "success" | "timeout",
profile_id: string,
name?: string | null
},
"query-identity-teamspeak": { profile_id: string },
"query-identity-teamspeak-result": {
status: "error" | "success" | "timeout",
profile_id: string,
error?: string,
level?: number
}
"set-identity-name-name": { profile_id: string, name: string },
"set-identity-name-name-result": {
status: "error" | "success" | "timeout",
profile_id: string,
error?: string,
name?: string
},
"generate-identity-teamspeak": { profile_id: string },
"generate-identity-teamspeak-result": {
profile_id: string,
status: "error" | "success" | "timeout",
error?: string,
level?: number
unique_id?: string
},
"improve-identity-teamspeak-level": { profile_id: string },
"improve-identity-teamspeak-level-update": {
profile_id: string,
new_level: number
},
"import-identity-teamspeak": { profile_id: string },
"import-identity-teamspeak-result": {
profile_id: string,
level?: number
unique_id?: string
}
"export-identity-teamspeak": {
profile_id: string,
filename: string
},
"setup-forum-connection": {}
}
export function spawnSettingsModal(default_page?: string): Modal {
let modal: Modal;
modal = createModal({
@ -449,7 +607,7 @@ function settings_audio_microphone(container: JQuery, modal: Modal) {
}
function settings_identity_profiles(container: JQuery, modal: Modal) {
const registry = new Registry<events.modal.settings.profiles>();
const registry = new Registry<SettingProfileEvents>();
//registry.enable_debug("settings-identity");
modal_settings.initialize_identity_profiles_controller(registry);
modal_settings.initialize_identity_profiles_view(container, registry, {
@ -689,7 +847,7 @@ export namespace modal_settings {
forum_setuppable: boolean
}
export function initialize_identity_profiles_controller(event_registry: Registry<events.modal.settings.profiles>) {
export function initialize_identity_profiles_controller(event_registry: Registry<SettingProfileEvents>) {
const send_error = (event, profile, text) => event_registry.fire_react(event, {
status: "error",
profile_id: profile,
@ -997,7 +1155,7 @@ export namespace modal_settings {
});
}
export function initialize_identity_profiles_view(container: JQuery, event_registry: Registry<events.modal.settings.profiles>, settings: ProfileViewSettings) {
export function initialize_identity_profiles_view(container: JQuery, event_registry: Registry<SettingProfileEvents>, settings: ProfileViewSettings) {
/* profile list */
{
const container_profiles = container.find(".container-profiles");
@ -1007,7 +1165,7 @@ export namespace modal_settings {
const overlay_timeout = container_profiles.find(".overlay-timeout");
const overlay_empty = container_profiles.find(".overlay-empty");
const build_profile = (profile: events.modal.settings.ProfileInfo, selected: boolean) => {
const build_profile = (profile: ProfileInfoEvent, selected: boolean) => {
let tag_avatar: JQuery, tag_default: JQuery;
let tag = $.spawn("div").addClass("profile").attr("profile-id", profile.id).append(
tag_avatar = $.spawn("div").addClass("container-avatar"),
@ -1693,7 +1851,7 @@ export namespace modal_settings {
});
}
const create_standard_timeout = (event: keyof events.modal.settings.profiles, response_event: keyof events.modal.settings.profiles, key: string) => {
const create_standard_timeout = (event: keyof SettingProfileEvents, response_event: keyof SettingProfileEvents, key: string) => {
const timeouts = {};
event_registry.on(event, event => {
clearTimeout(timeouts[event[key]]);

View File

@ -10,8 +10,7 @@ import {Registry} from "tc-shared/events";
import {ChannelPropertyProviders} from "tc-shared/ui/modal/channel-edit/ControllerProperties";
import {LogCategory, logDebug, logError} from "tc-shared/log";
import {ChannelPropertyPermissionsProviders} from "tc-shared/ui/modal/channel-edit/ControllerPermissions";
import {spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {ChannelEditModal} from "tc-shared/ui/modal/channel-edit/Renderer";
import {spawnModal} from "tc-shared/ui/react-elements/modal";
import {PermissionValue} from "tc-shared/permission/PermissionManager";
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
import PermissionType from "tc-shared/permission/PermissionType";
@ -25,10 +24,13 @@ export type ChannelEditChangedPermission = { permission: PermissionType, value:
export const spawnChannelEditNew = (connection: ConnectionHandler, channel: ChannelEntry | undefined, parent: ChannelEntry | undefined, callback: ChannelEditCallback) => {
const controller = new ChannelEditController(connection, channel, parent);
const modal = spawnReactModal(ChannelEditModal, controller.uiEvents, typeof channel !== "object");
const modal = spawnModal("channel-edit", [controller.uiEvents.generateIpcDescription(), typeof channel !== "object"], {
popedOut: false,
popoutable: true
});
modal.show().then(undefined);
modal.events.on("destroy", () => {
modal.getEvents().on("destroy", () => {
controller.destroy();
});

View File

@ -1,4 +1,4 @@
import {ChannelEntry, ChannelProperties, ChannelSidebarMode} from "tc-shared/tree/Channel";
import {ChannelEntry, ChannelNameParser, ChannelProperties, ChannelSidebarMode} from "tc-shared/tree/Channel";
import {ChannelEditableProperty} from "tc-shared/ui/modal/channel-edit/Definitions";
import {ChannelTree} from "tc-shared/tree/ChannelTree";
import {ServerFeature} from "tc-shared/connection/ServerFeatures";
@ -17,7 +17,41 @@ const SimplePropertyProvider = <P extends keyof ChannelProperties>(channelProper
};
}
ChannelPropertyProviders["name"] = SimplePropertyProvider("channel_name", "");
ChannelPropertyProviders["name"] = {
provider: async (properties, channel, parentChannel, channelTree) => {
let spacerUniqueId = 0;
const hasParent = !!(channel?.hasParent() || parentChannel);
if(!hasParent) {
const channels = channelTree.rootChannel();
while(true) {
let matchFound = false;
for(const channel of channels) {
if(channel.parsed_channel_name.uniqueId === spacerUniqueId.toString()) {
matchFound = true;
break;
}
}
if(!matchFound) {
break;
}
spacerUniqueId++;
}
}
const parsed = new ChannelNameParser(properties.channel_name, hasParent);
return {
rawName: properties.channel_name,
spacerUniqueId: parsed.uniqueId || spacerUniqueId.toString(),
hasParent,
maxNameLength: 30 - (parsed.originalName.length - parsed.text.length),
parsedAlignment: parsed.alignment,
parsedName: parsed.text
}
},
applier: (value, properties) => properties.channel_name = value.rawName
}
ChannelPropertyProviders["phoneticName"] = SimplePropertyProvider("channel_name_phonetic", "");
ChannelPropertyProviders["icon"] = {
provider: async (properties, _channel, _parentChannel, channelTree) => {

View File

@ -32,7 +32,14 @@ export type ChannelEditPermissionsState = {
};
export interface ChannelEditableProperty {
"name": string,
"name": {
rawName: string,
parsedName?: string,
parsedAlignment?: "center" | "right" | "left" | "normal" | "repetitive",
maxNameLength?: number,
hasParent?: boolean,
spacerUniqueId?: string
},
"phoneticName": string,
"icon": {

View File

@ -40,6 +40,26 @@
}
}
.channelName {
display: flex;
flex-direction: row;
justify-content: stretch;
width: 100%;
min-width: 10em;
.select {
margin-right: 1em;
width: 10em;
}
&.hasParent {
.select {
display: none;
}
}
}
.buttons {
margin-top: 1em;

View File

@ -1,8 +1,7 @@
import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controller";
import * as React from "react";
import {useContext, useEffect, useRef, useState} from "react";
import {Translatable, VariadicTranslatable} from "tc-shared/ui/react-elements/i18n";
import {Registry} from "tc-shared/events";
import {IpcRegistryDescription, Registry} from "tc-shared/events";
import {
ChannelEditablePermissions,
ChannelEditablePermissionValue,
@ -16,13 +15,15 @@ import {Switch} from "tc-shared/ui/react-elements/Switch";
import {Button} from "tc-shared/ui/react-elements/Button";
import {Tab, TabEntry} from "tc-shared/ui/react-elements/Tab";
import {Settings, settings} from "tc-shared/settings";
import {useTr} from "tc-shared/ui/react-elements/Helper";
import {joinClassList, useTr} from "tc-shared/ui/react-elements/Helper";
import {IconTooltip} from "tc-shared/ui/react-elements/Tooltip";
import {RadioButton} from "tc-shared/ui/react-elements/RadioButton";
import {Slider} from "tc-shared/ui/react-elements/Slider";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
import {RemoteIconRenderer} from "tc-shared/ui/react-elements/Icon";
import {getIconManager} from "tc-shared/file/Icons";
import {AbstractModal} from "tc-shared/ui/react-elements/modal/Definitions";
import {ChannelNameAlignment, ChannelNameParser} from "tc-shared/tree/Channel";
const cssStyle = require("./Renderer.scss");
@ -121,6 +122,10 @@ function useValidationState<T extends keyof ChannelEditableProperty>(property: T
return valid;
}
const ChannelNameType = (props: { selected: ChannelNameAlignment }) => {
}
const ChannelName = React.memo(() => {
const modalType = useContext(ModalTypeContext);
@ -128,16 +133,73 @@ const ChannelName = React.memo(() => {
const editable = usePropertyPermission("name", modalType === "channel-create");
const valid = useValidationState("name");
const refSelect = useRef<HTMLSelectElement>();
const setValue = (text: string | undefined, localOnly: boolean) => {
let rawName;
switch(propertyValue.hasParent ? "normal" : refSelect.current.value) {
case "center":
rawName = "[cspacer" + propertyValue.spacerUniqueId + "]" + text;
break;
case "left":
rawName = "[lspacer" + propertyValue.spacerUniqueId + "]" + text;
break;
case "right":
rawName = "[rspacer" + propertyValue.spacerUniqueId + "]" + text;
break;
case "repetitive":
rawName ="[*spacer" + propertyValue.spacerUniqueId + "]" + text;
break;
default:
case "normal":
rawName = text;
break;
}
setPropertyValue({
rawName,
parsedName: text,
hasParent: propertyValue.hasParent,
spacerUniqueId: propertyValue.spacerUniqueId,
maxNameLength: propertyValue.maxNameLength,
parsedAlignment: propertyValue.parsedAlignment
}, localOnly);
}
return (
<BoxedInputField
className={cssStyle.input}
disabled={!editable || propertyState !== "normal"}
value={propertyValue || ""}
placeholder={propertyState === "normal" ? tr("Channel name") : tr("loading")}
onInput={value => setPropertyValue(value, true)}
onChange={value => setPropertyValue(value)}
isInvalid={!valid}
/>
<div className={joinClassList(cssStyle.channelName, propertyValue?.hasParent && cssStyle.hasParent)}>
<Select
value={propertyValue?.parsedAlignment || "loading"}
className={cssStyle.select}
onChange={() => setValue(propertyValue.parsedName, false)}
type={"boxed"}
title={useTr("Channel name mode")}
refSelect={refSelect}
disabled={!editable}
>
<option value={"loading"} style={{ display: "none" }}>{useTr("loading")}</option>
<option value={"normal"}>{useTr("Normal Name")}</option>
<option value={"center"}>{useTr("Centered")}</option>
<option value={"left"}>{useTr("Left Aligned")}</option>
<option value={"right"}>{useTr("Right Aligned")}</option>
<option value={"repetitive"}>{useTr("Repetitive")}</option>
</Select>
<BoxedInputField
className={cssStyle.input}
disabled={!editable || propertyState !== "normal"}
value={(propertyValue?.hasParent ? propertyValue?.rawName : propertyValue?.parsedName) || ""}
placeholder={propertyState === "normal" ? tr("Channel name") : tr("loading")}
onInput={value => setValue(value, true)}
onChange={value => setValue(value, false)}
isInvalid={!valid}
maxLength={propertyValue?.maxNameLength}
/>
</div>
);
});
@ -159,7 +221,7 @@ const ChannelIcon = () => {
<div className="arrow down" />
</div>
<div className={cssStyle.dropdown}>
<div className={cssStyle.entry} onClick={() => enabled && events.fire("action_icon_select")}>Edit icon</div>
<div className={cssStyle.entry} onClick={() => enabled && events.fire("action_icon_select")}><Translatable>Edit icon</Translatable></div>
<div className={cssStyle.entry} onClick={() => {
if(!enabled) {
return;
@ -173,7 +235,7 @@ const ChannelIcon = () => {
serverUniqueId: propertyValue.remoteIcon.serverUniqueId
}
});
}}>Remove icon</div>
}}><Translatable>Remove icon</Translatable></div>
</div>
</div>
</div>
@ -351,31 +413,31 @@ const SidebarType = React.memo(() => {
);
});
type SimpleCodecQualityTemplate = { id: string, codec: number, quality: number, name: string };
type SimpleCodecQualityTemplate = { id: string, codec: number, quality: number, name: () => string };
const kCodecTemplates: SimpleCodecQualityTemplate[] = [
{
id: "mobile",
codec: 4,
quality: 4,
name: useTr("Mobile")
name: () => useTr("Mobile")
},
{
id: "voice",
codec: 4,
quality: 6,
name: useTr("Voice")
name: () => useTr("Voice")
},
{
id: "music",
codec: 5,
quality: 6,
name: useTr("Music")
name: () => useTr("Music")
},
{
id: "loading",
codec: undefined,
quality: undefined,
name: useTr("loading")
name: () => useTr("loading")
}
];
@ -404,7 +466,7 @@ const SimpleCodecQuality = React.memo(() => {
>
{
kCodecTemplates.map(template => (
<option style={{ display: template.id === "loading" ? "none" : undefined }} key={template.id} value={template.id} disabled={!hasPermission(template)}>{template.name}</option>
<option style={{ display: template.id === "loading" ? "none" : undefined }} key={template.id} value={template.id} disabled={!hasPermission(template)}>{template.name()}</option>
))
}
<option style={{ display: "none" }} key={"advanced"} value={"advanced"}>{useTr("Custom (Advanced settings)")}</option>
@ -431,7 +493,7 @@ const AdvancedCodecPresets = React.memo(() => {
disabled={!hasPermission(template) || propertyState !== "normal"}
onChange={() => setPropertyValue({ quality: template.quality, type: template.codec})}
>
<div>{template.name}</div>
<div>{template.name()}</div>
</RadioButton>
))
}
@ -1138,13 +1200,13 @@ const Buttons = React.memo(() => {
)
});
export class ChannelEditModal extends InternalModal {
class ChannelEditModal extends AbstractModal {
private readonly events: Registry<ChannelEditEvents>;
private readonly isChannelCreate: boolean;
constructor(events: Registry<ChannelEditEvents>, isChannelCreate: boolean) {
constructor(events: IpcRegistryDescription<ChannelEditEvents>, isChannelCreate: boolean) {
super();
this.events = events;
this.events = Registry.fromIpcDescription(events);
this.isChannelCreate = isChannelCreate;
}
@ -1162,7 +1224,7 @@ export class ChannelEditModal extends InternalModal {
);
}
title(): string | React.ReactElement {
renderTitle(): string | React.ReactElement {
if(this.isChannelCreate) {
return <Translatable key={"create"}>Create channel</Translatable>;
} else {
@ -1174,4 +1236,6 @@ export class ChannelEditModal extends InternalModal {
color(): "none" | "blue" {
return "blue";
}
}
}
export = ChannelEditModal;

View File

@ -1,7 +1,8 @@
import {Registry} from "tc-shared/events";
import {ConnectProperties, ConnectUiEvents, PropertyValidState} from "tc-shared/ui/modal/connect/Definitions";
import {spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {ConnectModal} from "tc-shared/ui/modal/connect/Renderer";
import {
ConnectUiEvents,
ConnectUiVariables,
} from "tc-shared/ui/modal/connect/Definitions";
import {LogCategory, logError, logWarn} from "tc-shared/log";
import {
availableConnectProfiles,
@ -17,7 +18,9 @@ import {server_connections} from "tc-shared/ConnectionManager";
import {parseServerAddress} from "tc-shared/tree/Server";
import {spawnSettingsModal} from "tc-shared/ui/modal/ModalSettings";
import * as ipRegex from "ip-regex";
import _ = require("lodash");
import {UiVariableProvider} from "tc-shared/ui/utils/Variable";
import {createIpcUiVariableProvider} from "tc-shared/ui/utils/IpcVariable";
import {spawnModal} from "tc-shared/ui/react-elements/modal";
const kRegexDomain = /^(localhost|((([a-zA-Z0-9_-]{0,63}\.){0,253})?[a-zA-Z0-9_-]{0,63}\.[a-zA-Z]{2,64}))$/i;
@ -37,19 +40,11 @@ export type ConnectParameters = {
defaultChannelPassword?: string,
}
type ValidityStates = {[T in keyof PropertyValidState]: boolean};
const kDefaultValidityStates: ValidityStates = {
address: false,
nickname: false,
password: false,
profile: false
}
class ConnectController {
readonly uiEvents: Registry<ConnectUiEvents>;
readonly uiVariables: UiVariableProvider<ConnectUiVariables>;
private readonly defaultAddress: string;
private readonly propertyProvider: {[K in keyof ConnectProperties]?: () => Promise<ConnectProperties[K]>} = {};
private historyShown: boolean;
@ -59,32 +54,22 @@ class ConnectController {
private currentPasswordHashed: boolean;
private currentProfile: ConnectionProfile | undefined;
private addressChanged: boolean;
private nicknameChanged: boolean;
private selectedHistoryId: number;
private history: ConnectionHistoryEntry[];
private validStates: {[T in keyof PropertyValidState]: boolean} = {
address: false,
nickname: false,
password: false,
profile: false
};
private validateNickname: boolean;
private validateAddress: boolean;
private validateStates: {[T in keyof PropertyValidState]: boolean} = {
profile: false,
password: false,
nickname: false,
address: false
};
constructor() {
constructor(uiVariables: UiVariableProvider<ConnectUiVariables>) {7
this.uiEvents = new Registry<ConnectUiEvents>();
this.uiEvents.enableDebug("modal-connect");
this.uiVariables = uiVariables;
this.history = undefined;
this.validateNickname = false;
this.validateAddress = false;
this.defaultAddress = "ts.teaspeak.de";
this.historyShown = settings.getValue(Settings.KEY_CONNECT_SHOW_HISTORY);
@ -92,35 +77,117 @@ class ConnectController {
this.currentProfile = findConnectProfile(settings.getValue(Settings.KEY_CONNECT_PROFILE)) || defaultConnectProfile();
this.currentNickname = settings.getValue(Settings.KEY_CONNECT_USERNAME);
this.addressChanged = false;
this.nicknameChanged = false;
this.uiEvents.on("action_delete_history", event => {
connectionHistory.deleteConnectionAttempts(event.target, event.targetType).then(() => {
this.history = undefined;
this.uiVariables.sendVariable("history");
}).catch(error => {
logWarn(LogCategory.GENERAL, tr("Failed to delete connection attempts: %o"), error);
})
});
this.propertyProvider["nickname"] = async () => ({
this.uiEvents.on("action_manage_profiles", () => {
/* TODO: This is more a hack. Proper solution is that the connection profiles fire events if they've been changed... */
const modal = spawnSettingsModal("identity-profiles");
modal.close_listener.push(() => {
this.uiVariables.sendVariable("profiles", undefined);
});
});
this.uiEvents.on("action_select_history", event => this.setSelectedHistoryId(event.id));
this.uiEvents.on("action_connect", () => {
this.validateNickname = true;
this.validateAddress = true;
this.updateValidityStates();
});
this.uiVariables.setVariableProvider("server_address", () => ({
currentAddress: this.currentAddress,
defaultAddress: this.defaultAddress
}));
this.uiVariables.setVariableProvider("server_address_valid", () => {
if(this.validateAddress) {
const address = this.currentAddress || this.defaultAddress || "";
const parsedAddress = parseServerAddress(address);
if(parsedAddress) {
kRegexDomain.lastIndex = 0;
return kRegexDomain.test(parsedAddress.host) || ipRegex({ exact: true }).test(parsedAddress.host);
} else {
return false;
}
} else {
return true;
}
});
this.uiVariables.setVariableEditor("server_address", newValue => {
if(this.currentAddress === newValue.currentAddress) {
return false;
}
this.setSelectedAddress(newValue.currentAddress, true, false);
return true;
});
this.uiVariables.setVariableProvider("nickname", () => ({
defaultNickname: this.currentProfile?.connectUsername(),
currentNickname: this.currentNickname,
}));
this.uiVariables.setVariableProvider("nickname_valid", () => {
if(this.validateNickname) {
const nickname = this.currentNickname || this.currentProfile?.connectUsername() || "";
return nickname.length >= 3 && nickname.length <= 30;
} else {
return true;
}
});
this.propertyProvider["address"] = async () => ({
currentAddress: this.currentAddress,
defaultAddress: this.defaultAddress,
this.uiVariables.setVariableEditor("nickname", newValue => {
if(this.currentNickname === newValue.currentNickname) {
return false;
}
this.currentNickname = newValue.currentNickname;
settings.setValue(Settings.KEY_CONNECT_USERNAME, this.currentNickname);
this.validateNickname = true;
this.uiVariables.sendVariable("nickname_valid");
return true;
});
this.propertyProvider["password"] = async () => this.currentPassword ? ({
hashed: this.currentPasswordHashed,
password: this.currentPassword
}) : undefined;
this.uiVariables.setVariableProvider("password", () => ({
password: this.currentPassword,
hashed: this.currentPasswordHashed
}));
this.propertyProvider["profiles"] = async () => ({
selected: this.currentProfile?.id,
profiles: availableConnectProfiles().map(profile => ({
id: profile.id,
valid: profile.valid(),
name: profile.profileName
}))
this.uiVariables.setVariableEditor("password", newValue => {
if(this.currentPassword === newValue.password) {
return false;
}
this.currentPassword = newValue.password;
this.currentPasswordHashed = newValue.hashed;
return true;
});
this.propertyProvider["historyShown"] = async () => this.historyShown;
this.propertyProvider["history"] = async () => {
this.uiVariables.setVariableProvider("profile_valid", () => !!this.currentProfile?.valid());
this.uiVariables.setVariableProvider("historyShown", () => this.historyShown);
this.uiVariables.setVariableEditor("historyShown", newValue => {
if(this.historyShown === newValue) {
return false;
}
this.historyShown = newValue;
settings.setValue(Settings.KEY_CONNECT_SHOW_HISTORY, newValue);
return true;
});
this.uiVariables.setVariableProvider("history",async () => {
if(!this.history) {
this.history = await connectionHistory.lastConnectedServers(10);
}
@ -133,130 +200,67 @@ class ConnectController {
uniqueServerId: entry.serverUniqueId
}))
};
};
});
this.uiEvents.on("query_property", event => this.sendProperty(event.property));
this.uiEvents.on("query_property_valid", event => this.uiEvents.fire_react("notify_property_valid", { property: event.property, value: this.validStates[event.property] }));
this.uiEvents.on("query_history_connections", event => {
connectionHistory.countConnectCount(event.target, event.targetType).catch(async error => {
logError(LogCategory.GENERAL, tr("Failed to query the connect count for %s (%s): %o"), event.target, event.targetType, error);
this.uiVariables.setVariableProvider("history_entry", async customData => {
const info = await connectionHistory.queryServerInfo(customData.serverUniqueId);
return {
icon: {
iconId: info.iconId,
serverUniqueId: customData.serverUniqueId,
handlerId: undefined
},
name: info.name,
password: info.passwordProtected,
country: info.country,
clients: info.clientsOnline,
maxClients: info.clientsMax
};
});
this.uiVariables.setVariableProvider("history_connections", async customData => {
return await connectionHistory.countConnectCount(customData.target, customData.targetType).catch(async error => {
logError(LogCategory.GENERAL, tr("Failed to query the connect count for %s (%s): %o"), customData.target, customData.targetType, error);
return -1;
}).then(count => {
this.uiEvents.fire_react("notify_history_connections", {
target: event.target,
targetType: event.targetType,
value: count
});
});
});
this.uiEvents.on("query_history_entry", event => {
connectionHistory.queryServerInfo(event.serverUniqueId).then(info => {
this.uiEvents.fire_react("notify_history_entry", {
serverUniqueId: event.serverUniqueId,
info: {
icon: {
iconId: info.iconId,
serverUniqueId: event.serverUniqueId,
handlerId: undefined
},
name: info.name,
password: info.passwordProtected,
country: info.country,
clients: info.clientsOnline,
maxClients: info.clientsMax
}
});
}).catch(async error => {
logError(LogCategory.GENERAL, tr("Failed to query the history server info for %s: %o"), event.serverUniqueId, error);
});
});
})
this.uiEvents.on("action_toggle_history", event => {
if(this.historyShown === event.enabled) {
return;
}
this.uiVariables.setVariableProvider("profiles", () => ({
selected: this.currentProfile?.id,
profiles: availableConnectProfiles().map(profile => ({
id: profile.id,
valid: profile.valid(),
name: profile.profileName
}))
}));
this.historyShown = event.enabled;
this.sendProperty("historyShown").then(undefined);
settings.setValue(Settings.KEY_CONNECT_SHOW_HISTORY, event.enabled);
});
this.uiEvents.on("action_delete_history", event => {
connectionHistory.deleteConnectionAttempts(event.target, event.targetType).then(() => {
this.history = undefined;
this.sendProperty("history").then(undefined);
}).catch(error => {
logWarn(LogCategory.GENERAL, tr("Failed to delete connection attempts: %o"), error);
})
});
this.uiEvents.on("action_manage_profiles", () => {
/* TODO: This is more a hack. Proper solution is that the connection profiles fire events if they've been changed... */
const modal = spawnSettingsModal("identity-profiles");
modal.close_listener.push(() => {
this.sendProperty("profiles").then(undefined);
});
});
this.uiEvents.on("action_select_profile", event => {
const profile = findConnectProfile(event.id);
this.uiVariables.setVariableEditor("profiles", newValue => {
const profile = findConnectProfile(newValue.selected);
if(!profile) {
createErrorModal(tr("Invalid profile"), tr("Target connect profile is missing.")).open();
return;
return false;
}
this.setSelectedProfile(profile);
return; /* No need to update anything. The ui should received the values needed already */
});
this.uiEvents.on("action_set_address", event => this.setSelectedAddress(event.address, event.validate, event.updateUi));
this.uiEvents.on("action_set_nickname", event => {
if(this.currentNickname !== event.nickname) {
this.currentNickname = event.nickname;
settings.setValue(Settings.KEY_CONNECT_USERNAME, event.nickname);
if(event.updateUi) {
this.sendProperty("nickname").then(undefined);
}
}
this.validateStates["nickname"] = event.validate;
this.updateValidityStates();
});
this.uiEvents.on("action_set_password", event => {
if(this.currentPassword === event.password) {
return;
}
this.currentPassword = event.password;
this.currentPasswordHashed = event.hashed;
if(event.updateUi) {
this.sendProperty("password").then(undefined);
}
this.validateStates["password"] = true;
this.updateValidityStates();
});
this.uiEvents.on("action_select_history", event => this.setSelectedHistoryId(event.id));
this.uiEvents.on("action_connect", () => {
Object.keys(this.validateStates).forEach(key => this.validateStates[key] = true);
this.updateValidityStates();
});
this.updateValidityStates();
}
destroy() {
Object.keys(this.propertyProvider).forEach(key => delete this.propertyProvider[key]);
this.uiEvents.destroy();
this.uiVariables.destroy();
}
generateConnectParameters() : ConnectParameters | undefined {
if(Object.keys(this.validStates).findIndex(key => this.validStates[key] === false) !== -1) {
if(!this.uiVariables.getVariableSync("nickname_valid", undefined, true)) {
return undefined;
}
if(!this.uiVariables.getVariableSync("server_address_valid", undefined, true)) {
return undefined;
}
if(!this.uiVariables.getVariableSync("profile_valid", undefined, true)) {
return undefined;
}
@ -279,7 +283,7 @@ class ConnectController {
}
this.selectedHistoryId = id;
this.sendProperty("history").then(undefined);
this.uiVariables.sendVariable("history");
const historyEntry = this.history?.find(entry => entry.id === id);
if(!historyEntry) { return; }
@ -289,9 +293,9 @@ class ConnectController {
this.currentPassword = historyEntry.hashedPassword;
this.currentPasswordHashed = true;
this.sendProperty("address").then(undefined);
this.sendProperty("password").then(undefined);
this.sendProperty("nickname").then(undefined);
this.uiVariables.sendVariable("server_address");
this.uiVariables.sendVariable("password");
this.uiVariables.sendVariable("nickname");
}
setSelectedAddress(address: string | undefined, validate: boolean, updateUi: boolean) {
@ -301,12 +305,12 @@ class ConnectController {
this.setSelectedHistoryId(-1);
if(updateUi) {
this.sendProperty("address").then(undefined);
this.uiVariables.sendVariable("server_address");
}
}
this.validateStates["address"] = validate;
this.updateValidityStates();
this.validateAddress = true;
this.uiVariables.sendVariable("server_address_valid");
}
setSelectedProfile(profile: ConnectionProfile | undefined) {
@ -315,62 +319,19 @@ class ConnectController {
}
this.currentProfile = profile;
this.sendProperty("profiles").then(undefined);
this.uiVariables.sendVariable("profile_valid");
this.uiVariables.sendVariable("profiles");
settings.setValue(Settings.KEY_CONNECT_PROFILE, profile.id);
/* Clear out the nickname on profile switch and use the default nickname */
this.uiEvents.fire("action_set_nickname", { nickname: undefined, validate: true, updateUi: true });
this.validateStates["profile"] = true;
this.updateValidityStates();
this.currentNickname = undefined;
this.uiVariables.sendVariable("nickname");
}
private updateValidityStates() {
const newStates = Object.assign({}, kDefaultValidityStates);
if(this.validateStates["nickname"]) {
const nickname = this.currentNickname || this.currentProfile?.connectUsername() || "";
newStates["nickname"] = nickname.length >= 3 && nickname.length <= 30;
} else {
newStates["nickname"] = true;
}
if(this.validateStates["address"]) {
const address = this.currentAddress || this.defaultAddress || "";
const parsedAddress = parseServerAddress(address);
if(parsedAddress) {
kRegexDomain.lastIndex = 0;
newStates["address"] = kRegexDomain.test(parsedAddress.host) || ipRegex({ exact: true }).test(parsedAddress.host);
} else {
newStates["address"] = false;
}
} else {
newStates["address"] = true;
}
newStates["profile"] = !!this.currentProfile?.valid();
newStates["password"] = true;
for(const key of Object.keys(newStates)) {
if(_.isEqual(this.validStates[key], newStates[key])) {
continue;
}
this.validStates[key] = newStates[key];
this.uiEvents.fire_react("notify_property_valid", { property: key as any, value: this.validStates[key] });
}
}
private async sendProperty(property: keyof ConnectProperties) {
if(!this.propertyProvider[property]) {
logWarn(LogCategory.GENERAL, tr("Tried to send a property where we don't have a provider for"));
return;
}
this.uiEvents.fire_react("notify_property", {
property: property,
value: await this.propertyProvider[property]()
});
this.uiVariables.sendVariable("server_address_valid");
this.uiVariables.sendVariable("nickname_valid");
this.uiVariables.sendVariable("profile_valid");
}
}
@ -382,7 +343,8 @@ export type ConnectModalOptions = {
}
export function spawnConnectModalNew(options: ConnectModalOptions) {
const controller = new ConnectController();
const variableProvider = createIpcUiVariableProvider();
const controller = new ConnectController(variableProvider);
if(typeof options.selectedAddress === "string") {
controller.setSelectedAddress(options.selectedAddress, false, true);
@ -392,10 +354,9 @@ export function spawnConnectModalNew(options: ConnectModalOptions) {
controller.setSelectedProfile(options.selectedProfile);
}
const modal = spawnReactModal(ConnectModal, controller.uiEvents, options.connectInANewTab || false);
const modal = spawnModal("modal-connect", [controller.uiEvents.generateIpcDescription(), variableProvider.generateConsumerDescription(), options.connectInANewTab || false]);
modal.show();
modal.events.one("destroy", () => {
modal.getEvents().one("destroy", () => {
controller.destroy();
});

View File

@ -1,6 +1,6 @@
import {kUnknownHistoryServerUniqueId} from "tc-shared/connectionlog/History";
import {RemoteIconInfo} from "tc-shared/file/Icons";
export const kUnknownHistoryServerUniqueId = "unknown";
export type ConnectProfileEntry = {
id: string,
name: string,
@ -23,81 +23,46 @@ export type ConnectHistoryServerInfo = {
maxClients: number | -1
}
export interface ConnectProperties {
address: {
export interface ConnectUiVariables {
"server_address": {
currentAddress: string,
defaultAddress: string,
defaultAddress?: string,
},
nickname: {
readonly "server_address_valid": boolean,
"nickname": {
currentNickname: string | undefined,
defaultNickname: string | undefined,
defaultNickname?: string,
},
password: {
readonly "nickname_valid": boolean,
"password": {
password: string,
hashed: boolean
} | undefined,
profiles: {
profiles: ConnectProfileEntry[],
"profiles": {
profiles?: ConnectProfileEntry[],
selected: string
},
historyShown: boolean,
history: {
readonly "profile_valid": boolean,
"historyShown": boolean,
readonly "history": {
history: ConnectHistoryEntry[],
selected: number | -1,
},
}
export interface PropertyValidState {
address: boolean,
nickname: boolean,
password: boolean,
profile: boolean
readonly "history_entry": ConnectHistoryServerInfo,
readonly "history_connections": number
}
type IAccess<I, T extends keyof I> = {
property: T,
value: I[T]
};
export interface ConnectUiEvents {
action_manage_profiles: {},
action_select_profile: { id: string },
action_select_history: { id: number },
action_connect: { newTab: boolean },
action_toggle_history: { enabled: boolean }
action_delete_history: {
target: string,
targetType: "address" | "server-unique-id"
},
action_set_nickname: { nickname: string, validate: boolean, updateUi: boolean },
action_set_address: { address: string, validate: boolean, updateUi: boolean },
action_set_password: { password: string, hashed: boolean, updateUi: boolean },
query_property: {
property: keyof ConnectProperties
},
query_property_valid: {
property: keyof PropertyValidState
},
notify_property: IAccess<ConnectProperties, keyof ConnectProperties>,
notify_property_valid: IAccess<PropertyValidState, keyof PropertyValidState>,
query_history_entry: {
serverUniqueId: string
},
query_history_connections: {
target: string,
targetType: "address" | "server-unique-id"
}
notify_history_entry: {
serverUniqueId: string,
info: ConnectHistoryServerInfo
},
notify_history_connections: {
target: string,
targetType: "address" | "server-unique-id",
value: number
}
}

View File

@ -106,7 +106,8 @@
.buttonShowHistory {
.containerText {
display: inline-block;
width: 10em;
min-width: 10em;
flex-shrink: 0;
}
.containerArrow {
@ -239,6 +240,8 @@
max-width: 100%;
text-overflow: ellipsis;
overflow: hidden;
@include text-dotdotdot();
}
}

View File

@ -1,141 +1,109 @@
import {
ConnectHistoryEntry,
ConnectHistoryServerInfo,
ConnectProperties,
ConnectUiEvents,
PropertyValidState
ConnectUiEvents, ConnectUiVariables, kUnknownHistoryServerUniqueId,
} from "tc-shared/ui/modal/connect/Definitions";
import * as React from "react";
import {useContext, useState} from "react";
import {Registry} from "tc-shared/events";
import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controller";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {ControlledFlatInputField, ControlledSelect, FlatInputField} from "tc-shared/ui/react-elements/InputField";
import {useContext} from "react";
import {IpcRegistryDescription, Registry} from "tc-shared/events";
import {joinClassList, useTr} from "tc-shared/ui/react-elements/Helper";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {Button} from "tc-shared/ui/react-elements/Button";
import {kUnknownHistoryServerUniqueId} from "tc-shared/connectionlog/History";
import {ControlledFlatInputField, ControlledSelect, FlatInputField} from "tc-shared/ui/react-elements/InputField";
import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
import {ClientIcon} from "svg-sprites/client-icons";
import * as i18n from "../../../i18n/country";
import {getIconManager} from "tc-shared/file/Icons";
import {RemoteIconRenderer} from "tc-shared/ui/react-elements/Icon";
import {UiVariableConsumer} from "tc-shared/ui/utils/Variable";
import {createIpcUiVariableConsumer, IpcVariableDescriptor} from "tc-shared/ui/utils/IpcVariable";
import {AbstractModal} from "tc-shared/ui/react-elements/ModalDefinitions";
const EventContext = React.createContext<Registry<ConnectUiEvents>>(undefined);
const VariablesContext = React.createContext<UiVariableConsumer<ConnectUiVariables>>(undefined);
const ConnectDefaultNewTabContext = React.createContext<boolean>(false);
const cssStyle = require("./Renderer.scss");
function useProperty<T extends keyof ConnectProperties, V>(key: T, defaultValue: V) : [ConnectProperties[T] | V, (value: ConnectProperties[T]) => void] {
const InputServerAddress = React.memo(() => {
const events = useContext(EventContext);
const [ value, setValue ] = useState<ConnectProperties[T] | V>(() => {
events.fire("query_property", { property: key });
return defaultValue;
});
events.reactUse("notify_property", event => event.property === key && setValue(event.value as any));
return [value, setValue];
}
function usePropertyValid<T extends keyof PropertyValidState>(key: T, defaultValue: PropertyValidState[T]) : PropertyValidState[T] {
const events = useContext(EventContext);
const [ value, setValue ] = useState<PropertyValidState[T]>(() => {
events.fire("query_property_valid", { property: key });
return defaultValue;
});
events.reactUse("notify_property_valid", event => event.property === key && setValue(event.value as any));
return value;
}
const InputServerAddress = () => {
const events = useContext(EventContext);
const [address, setAddress] = useProperty("address", undefined);
const valid = usePropertyValid("address", true);
const newTab = useContext(ConnectDefaultNewTabContext);
const variables = useContext(VariablesContext);
const address = variables.useVariable("server_address");
const addressValid = variables.useReadOnly("server_address_valid", undefined, true) || address.localValue !== address.remoteValue;
return (
<ControlledFlatInputField
value={address?.currentAddress || ""}
placeholder={address?.defaultAddress || tr("Please enter a address")}
value={address.localValue?.currentAddress || ""}
placeholder={address.remoteValue?.defaultAddress || tr("Please enter a address")}
className={cssStyle.inputAddress}
label={<Translatable>Server address</Translatable>}
labelType={"static"}
invalid={valid ? undefined : <Translatable>Please enter a valid server address</Translatable>}
invalid={addressValid ? undefined : <Translatable>Please enter a valid server address</Translatable>}
editable={address.status === "loaded"}
onInput={value => {
setAddress({ currentAddress: value, defaultAddress: address.defaultAddress });
events.fire("action_set_address", { address: value, validate: true, updateUi: false });
}}
onBlur={() => {
events.fire("action_set_address", { address: address?.currentAddress, validate: true, updateUi: true });
}}
onInput={value => address.setValue({ currentAddress: value }, true)}
onBlur={() => address.setValue({ currentAddress: address.localValue?.currentAddress })}
onEnter={() => {
/* Setting the address just to ensure */
events.fire("action_set_address", { address: address?.currentAddress, validate: true, updateUi: true });
address.setValue({ currentAddress: address.localValue?.currentAddress });
events.fire("action_connect", { newTab });
}}
/>
)
}
});
const InputServerPassword = () => {
const events = useContext(EventContext);
const [password, setPassword] = useProperty("password", undefined);
const variables = useContext(VariablesContext);
const password = variables.useVariable("password");
return (
<FlatInputField
className={cssStyle.inputPassword}
value={!password?.hashed ? password?.password || "" : ""}
placeholder={password?.hashed ? tr("Password Hidden") : null}
value={!password.localValue?.hashed ? password.localValue?.password || "" : ""}
placeholder={password.localValue?.hashed ? tr("Password Hidden") : null}
type={"password"}
label={<Translatable>Server password</Translatable>}
labelType={password?.hashed ? "static" : "floating"}
onInput={value => {
setPassword({ password: value, hashed: false });
events.fire("action_set_password", { password: value, hashed: false, updateUi: false });
}}
onBlur={() => {
if(password) {
events.fire("action_set_password", { password: password.password, hashed: password.hashed, updateUi: true });
}
}}
labelType={password.localValue?.hashed ? "static" : "floating"}
onInput={value => password.setValue({ password: value, hashed: false }, true)}
onBlur={() => password.setValue(password.localValue)}
/>
)
}
const InputNickname = () => {
const events = useContext(EventContext);
const [nickname, setNickname] = useProperty("nickname", undefined);
const valid = usePropertyValid("nickname", true);
const variables = useContext(VariablesContext);
const nickname = variables.useVariable("nickname");
const validState = variables.useReadOnly("nickname_valid", undefined, true) || nickname.localValue !== nickname.remoteValue;
return (
<ControlledFlatInputField
className={cssStyle.inputNickname}
value={nickname?.currentNickname || ""}
placeholder={nickname ? nickname.defaultNickname ? nickname.defaultNickname : tr("Please enter a nickname") : tr("loading...")}
value={nickname.localValue?.currentNickname || ""}
placeholder={nickname.remoteValue ? nickname.remoteValue.defaultNickname ? nickname.remoteValue.defaultNickname : tr("Please enter a nickname") : tr("loading...")}
label={<Translatable>Nickname</Translatable>}
labelType={"static"}
invalid={valid ? undefined : <Translatable>Nickname too short or too long</Translatable>}
onInput={value => {
setNickname({ currentNickname: value, defaultNickname: nickname.defaultNickname });
events.fire("action_set_nickname", { nickname: value, validate: true, updateUi: false });
}}
onBlur={() => events.fire("action_set_nickname", { nickname: nickname?.currentNickname, validate: true, updateUi: true })}
invalid={validState ? undefined : <Translatable>Nickname too short or too long</Translatable>}
onInput={value => nickname.setValue({ currentNickname: value }, true)}
onBlur={() => nickname.setValue({ currentNickname: nickname.localValue?.currentNickname })}
/>
);
}
const InputProfile = () => {
const events = useContext(EventContext);
const [profiles] = useProperty("profiles", undefined);
const selectedProfile = profiles?.profiles.find(profile => profile.id === profiles?.selected);
const variables = useContext(VariablesContext);
const profiles = variables.useVariable("profiles");
const selectedProfile = profiles.remoteValue?.profiles.find(profile => profile.id === profiles.remoteValue?.selected);
let invalidMarker;
if(profiles) {
if(!profiles.selected) {
if(!profiles.remoteValue?.selected) {
/* We have to select a profile. */
/* TODO: Only show if we've tried to press connect */
//invalidMarker = <Translatable key={"no-profile"}>Please select a profile</Translatable>;
@ -150,19 +118,23 @@ const InputProfile = () => {
<div className={cssStyle.inputProfile}>
<ControlledSelect
className={cssStyle.input}
value={selectedProfile ? selectedProfile.id : profiles?.selected ? "invalid" : profiles ? "no-selected" : "loading"}
value={selectedProfile ? selectedProfile.id : profiles.remoteValue?.selected ? "invalid" : profiles ? "no-selected" : "loading"}
type={"flat"}
label={<Translatable>Connect profile</Translatable>}
invalid={invalidMarker}
invalidClassName={cssStyle.invalidFeedback}
onChange={event => events.fire("action_select_profile", { id: event.target.value })}
onChange={event => profiles.setValue({ selected: event.target.value })}
>
<option key={"no-selected"} value={"no-selected"} style={{ display: "none" }}>{useTr("please select")}</option>
<option key={"invalid"} value={"invalid"} style={{ display: "none" }}>{useTr("unknown profile")}</option>
<option key={"loading"} value={"loading"} style={{ display: "none" }}>{useTr("loading") + "..."}</option>
{profiles?.profiles.map(profile => (
<option key={"profile-" + profile.id} value={profile.id}>{profile.name}</option>
))}
<option value={"no-selected"} style={{ display: "none" }}>{useTr("please select")}</option>
<option value={"invalid"} style={{ display: "none" }}>{useTr("unknown profile")}</option>
<option value={"loading"} style={{ display: "none" }}>{useTr("loading") + "..."}</option>
<React.Fragment>
{
profiles.remoteValue?.profiles.map(profile => (
<option key={"profile-" + profile.id} value={profile.id}>{profile.name}</option>
))
}
</React.Fragment>
</ControlledSelect>
<Button className={cssStyle.button} type={"small"} color={"none"} onClick={() => events.fire("action_manage_profiles")}>
<Translatable>Profiles</Translatable>
@ -174,6 +146,7 @@ const InputProfile = () => {
const ConnectContainer = () => (
<div className={cssStyle.connectContainer}>
<div className={cssStyle.row}>
{/* <InputServerAddress /> */}
<InputServerAddress />
<InputServerPassword />
</div>
@ -185,11 +158,11 @@ const ConnectContainer = () => (
);
const ButtonToggleHistory = () => {
const state = useProperty("historyShown", false);
const events = useContext(EventContext);
const variables = useContext(VariablesContext);
const historyShown = variables.useVariable("historyShown");
let body;
if(state) {
if(historyShown.localValue) {
body = (
<React.Fragment key={"hide"}>
<div className={cssStyle.containerText}><Translatable>Hide connect history</Translatable></div>
@ -209,7 +182,7 @@ const ButtonToggleHistory = () => {
className={cssStyle.buttonShowHistory + " " + cssStyle.button}
type={"small"}
color={"none"}
onClick={() => events.fire("action_toggle_history", { enabled: !state })}
onClick={() => historyShown.setValue(!historyShown.localValue)}
>
{body}
</Button>
@ -281,35 +254,24 @@ const HistoryTableEntryConnectCount = React.memo((props: { entry: ConnectHistory
const targetType = props.entry.uniqueServerId === kUnknownHistoryServerUniqueId ? "address" : "server-unique-id";
const target = targetType === "address" ? props.entry.targetAddress : props.entry.uniqueServerId;
const events = useContext(EventContext);
const [ amount, setAmount ] = useState(() => {
events.fire("query_history_connections", {
target,
targetType
});
return -1;
});
const value = useContext(VariablesContext).useReadOnly("history_connections", {
target,
targetType
}, -1);
events.reactUse("notify_history_connections", event => event.targetType === targetType && event.target === target && setAmount(event.value));
if(amount >= 0) {
return <React.Fragment key={"set"}>{amount}</React.Fragment>;
if(value >= 0) {
return <React.Fragment key={"set"}>{value}</React.Fragment>;
} else {
return null;
}
});
const HistoryTableEntry = React.memo((props: { entry: ConnectHistoryEntry, selected: boolean }) => {
const connectNewTab = useContext(ConnectDefaultNewTabContext);
const events = useContext(EventContext);
const [ info, setInfo ] = useState<ConnectHistoryServerInfo>(() => {
if(props.entry.uniqueServerId !== kUnknownHistoryServerUniqueId) {
events.fire("query_history_entry", { serverUniqueId: props.entry.uniqueServerId });
}
return undefined;
});
events.reactUse("notify_history_entry", event => event.serverUniqueId === props.entry.uniqueServerId && setInfo(event.info));
const connectNewTab = useContext(ConnectDefaultNewTabContext);
const variables = useContext(VariablesContext);
const info = variables.useReadOnly("history_entry", { serverUniqueId: props.entry.uniqueServerId }, undefined);
const icon = getIconManager().resolveIcon(info ? info.icon.iconId : 0, info?.icon.serverUniqueId, info?.icon.handlerId);
return (
@ -364,9 +326,9 @@ const HistoryTableEntry = React.memo((props: { entry: ConnectHistoryEntry, selec
});
const HistoryTable = () => {
const [history] = useProperty("history", undefined);
let body;
const history = useContext(VariablesContext).useReadOnly("history", undefined, undefined);
let body;
if(history) {
if(history.history.length > 0) {
body = history.history.map(entry => <HistoryTableEntry entry={entry} key={"entry-" + entry.id} selected={entry.id === history.selected} />);
@ -385,22 +347,22 @@ const HistoryTable = () => {
<div className={cssStyle.head}>
<div className={cssStyle.column + " " + cssStyle.delete} />
<div className={cssStyle.column + " " + cssStyle.name}>
<Translatable>Name</Translatable>
<a title={useTr("Name")}><Translatable>Name</Translatable></a>
</div>
<div className={cssStyle.column + " " + cssStyle.address}>
<Translatable>Address</Translatable>
<a title={useTr("Address")}><Translatable>Address</Translatable></a>
</div>
<div className={cssStyle.column + " " + cssStyle.password}>
<Translatable>Password</Translatable>
<a title={useTr("Password")}><Translatable>Password</Translatable></a>
</div>
<div className={cssStyle.column + " " + cssStyle.country}>
<Translatable>Country</Translatable>
<a title={useTr("Country")}><Translatable>Country</Translatable></a>
</div>
<div className={cssStyle.column + " " + cssStyle.clients}>
<Translatable>Clients</Translatable>
<a title={useTr("Clients")}><Translatable>Clients</Translatable></a>
</div>
<div className={cssStyle.column + " " + cssStyle.connections}>
<Translatable>Connections</Translatable>
<a title={useTr("Connections")}><Translatable>Connections</Translatable></a>
</div>
</div>
<div className={cssStyle.body}>
@ -411,7 +373,8 @@ const HistoryTable = () => {
}
const HistoryContainer = () => {
const historyShown = useProperty("historyShown", false);
const variables = useContext(VariablesContext);
const historyShown = variables.useReadOnly("historyShown", undefined, false);
return (
<div className={joinClassList(cssStyle.historyContainer, historyShown && cssStyle.shown)}>
@ -420,32 +383,42 @@ const HistoryContainer = () => {
)
}
export class ConnectModal extends InternalModal {
class ConnectModal extends AbstractModal {
private readonly events: Registry<ConnectUiEvents>;
private readonly variables: UiVariableConsumer<ConnectUiVariables>;
private readonly connectNewTabByDefault: boolean;
constructor(events: Registry<ConnectUiEvents>, connectNewTabByDefault: boolean) {
constructor(events: IpcRegistryDescription<ConnectUiEvents>, variables: IpcVariableDescriptor<ConnectUiVariables>, connectNewTabByDefault: boolean) {
super();
this.events = events;
this.variables = createIpcUiVariableConsumer(variables);
this.events = Registry.fromIpcDescription(events);
this.connectNewTabByDefault = connectNewTabByDefault;
}
protected onDestroy() {
super.onDestroy();
this.variables.destroy();
}
renderBody(): React.ReactElement {
return (
<EventContext.Provider value={this.events}>
<ConnectDefaultNewTabContext.Provider value={this.connectNewTabByDefault}>
<div className={cssStyle.container}>
<ConnectContainer />
<ButtonContainer />
<HistoryContainer />
</div>
</ConnectDefaultNewTabContext.Provider>
<VariablesContext.Provider value={this.variables}>
<ConnectDefaultNewTabContext.Provider value={this.connectNewTabByDefault}>
<div className={cssStyle.container}>
<ConnectContainer />
<ButtonContainer />
<HistoryContainer />
</div>
</ConnectDefaultNewTabContext.Provider>
</VariablesContext.Provider>
</EventContext.Provider>
);
}
title(): string | React.ReactElement {
renderTitle(): string | React.ReactElement {
return <Translatable>Connect to a server</Translatable>;
}
@ -456,4 +429,5 @@ export class ConnectModal extends InternalModal {
verticalAlignment(): "top" | "center" | "bottom" {
return "top";
}
}
}
export = ConnectModal;

View File

@ -1,10 +1,10 @@
import * as loader from "tc-loader";
import {Stage} from "tc-loader";
import {CssEditorEvents, CssVariable} from "../../../ui/modal/css-editor/Definitions";
import {spawnExternalModal} from "../../../ui/react-elements/external-modal";
import {Registry} from "../../../events";
import {LogCategory, logWarn} from "../../../log";
import {tr} from "tc-shared/i18n/localize";
import {spawnModal} from "tc-shared/ui/react-elements/modal";
interface CustomVariable {
name: string;
@ -172,7 +172,7 @@ export function spawnModalCssVariableEditor() {
const events = new Registry<CssEditorEvents>();
cssVariableEditorController(events);
const modal = spawnExternalModal("css-editor", { default: events }, {});
const modal = spawnModal("css-editor", [ events.generateIpcDescription() ], { popedOut: true });
modal.show();
}

View File

@ -6,10 +6,6 @@ export interface CssVariable {
customValue?: string;
}
export interface CssEditorUserData {
}
export interface CssEditorEvents {
action_set_filter: { filter: string | undefined },
action_select_entry: { variable: CssVariable },

View File

@ -1,7 +1,7 @@
import * as React from "react";
import {useState} from "react";
import {CssEditorEvents, CssEditorUserData, CssVariable} from "tc-shared/ui/modal/css-editor/Definitions";
import {Registry, RegistryMap} from "tc-shared/events";
import {CssEditorEvents, CssVariable} from "tc-shared/ui/modal/css-editor/Definitions";
import {IpcRegistryDescription, Registry} from "tc-shared/events";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {BoxedInputField, FlatInputField} from "tc-shared/ui/react-elements/InputField";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
@ -391,14 +391,11 @@ const requestFileAsText = async (): Promise<string> => {
class PopoutConversationUI extends AbstractModal {
private readonly events: Registry<CssEditorEvents>;
private readonly userData: CssEditorUserData;
constructor(registryMap: RegistryMap, userData: CssEditorUserData) {
constructor(events: IpcRegistryDescription<CssEditorEvents>) {
super();
this.userData = userData;
this.events = registryMap["default"] as any;
this.events = Registry.fromIpcDescription(events);
this.events.on("notify_export_result", event => {
createInfoModal(tr("Config exported successfully"), tr("The config has been exported successfully.")).open();
downloadTextAsFile(event.config, "teaweb-style.json");
@ -420,8 +417,8 @@ class PopoutConversationUI extends AbstractModal {
);
}
title() {
return "CSS Variable editor";
renderTitle() {
return <Translatable>"CSS Variable editor"</Translatable>;
}
}

View File

@ -1,8 +1,5 @@
import {spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controller";
import {spawnModal} from "tc-shared/ui/react-elements/modal";
import * as React from "react";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {EchoTestEventRegistry, EchoTestModal} from "tc-shared/ui/modal/echo-test/Renderer";
import {Registry} from "tc-shared/events";
import {EchoTestEvents, TestState} from "tc-shared/ui/modal/echo-test/Definitions";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
@ -18,30 +15,13 @@ export function spawnEchoTestModal(connection: ConnectionHandler) {
initializeController(connection, events);
const modal = spawnReactModal(class extends InternalModal {
constructor() {
super();
}
renderBody(): React.ReactElement {
return (
<EchoTestEventRegistry.Provider value={events}>
<EchoTestModal/>
</EchoTestEventRegistry.Provider>
);
}
title(): string | React.ReactElement<Translatable> {
return <Translatable>Voice echo test</Translatable>;
}
});
const modal = spawnModal("echo-test", [ events.generateIpcDescription() ], { popedOut: false });
events.on("action_close", () => {
modal.destroy();
});
modal.events.on("close", () => events.fire_react("notify_close"));
modal.events.on("destroy", () => {
modal.getEvents().on("close", () => events.fire_react("notify_close"));
modal.getEvents().on("destroy", () => {
events.fire("notify_destroy");
events.destroy();
});

View File

@ -1,6 +1,6 @@
import * as React from "react";
import {useContext, useState} from "react";
import {Registry} from "tc-shared/events";
import {IpcRegistryDescription, Registry} from "tc-shared/events";
import {EchoTestEvents, TestState, VoiceConnectionState} from "./Definitions";
import {Translatable, VariadicTranslatable} from "tc-shared/ui/react-elements/i18n";
import {ClientIcon} from "svg-sprites/client-icons";
@ -8,10 +8,11 @@ import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
import {Checkbox} from "tc-shared/ui/react-elements/Checkbox";
import {Button} from "tc-shared/ui/react-elements/Button";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
import {AbstractModal} from "tc-shared/ui/react-elements/modal/Definitions";
const cssStyle = require("./Renderer.scss");
export const EchoTestEventRegistry = React.createContext<Registry<EchoTestEvents>>(undefined);
const EchoTestEventRegistry = React.createContext<Registry<EchoTestEvents>>(undefined);
const VoiceStateOverlay = () => {
const events = useContext(EchoTestEventRegistry);
@ -235,7 +236,7 @@ const TroubleshootingSoundOverlay = () => {
)
}
export const TestToggle = () => {
const TestToggle = () => {
const events = useContext(EchoTestEventRegistry);
const [state, setState] = useState<"loading" | boolean>(() => {
@ -255,7 +256,7 @@ export const TestToggle = () => {
)
}
export const EchoTestModal = () => {
const EchoTestModalRenderer = () => {
const events = useContext(EchoTestEventRegistry);
return (
@ -290,4 +291,28 @@ export const EchoTestModal = () => {
<TroubleshootingSoundOverlay/>
</div>
);
};
};
class ModalEchoTest extends AbstractModal {
private readonly events: Registry<EchoTestEvents>;
constructor(events: IpcRegistryDescription<EchoTestEvents>) {
super();
this.events = Registry.fromIpcDescription(events);
}
renderBody(): React.ReactElement {
return (
<EchoTestEventRegistry.Provider value={this.events}>
<EchoTestModalRenderer />
</EchoTestEventRegistry.Provider>
);
}
renderTitle(): string | React.ReactElement<Translatable> {
return <Translatable>Voice echo test</Translatable>;
}
}
export = ModalEchoTest;

View File

@ -1,5 +1,4 @@
import {spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {ModalGlobalSettingsEditor} from "tc-shared/ui/modal/global-settings-editor/Renderer";
import {spawnModal} from "tc-shared/ui/react-elements/modal";
import {Registry} from "tc-shared/events";
import {ModalGlobalSettingsEditorEvents, Setting} from "tc-shared/ui/modal/global-settings-editor/Definitions";
import {RegistryKey, RegistryValueType, Settings, settings} from "tc-shared/settings";
@ -8,9 +7,9 @@ export function spawnGlobalSettingsEditor() {
const events = new Registry<ModalGlobalSettingsEditorEvents>();
initializeController(events);
const modal = spawnReactModal(ModalGlobalSettingsEditor, events);
const modal = spawnModal("global-settings-editor", [ events.generateIpcDescription() ], { popoutable: true, popedOut: false });
modal.show();
modal.events.on("destroy", () => {
modal.getEvents().on("destroy", () => {
events.fire("notify_destroy");
events.destroy();
});

View File

@ -1,11 +1,11 @@
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import * as React from "react";
import {createContext, useContext, useRef, useState} from "react";
import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controller";
import {Registry} from "tc-shared/events";
import {IpcRegistryDescription, Registry} from "tc-shared/events";
import {ModalGlobalSettingsEditorEvents, Setting} from "tc-shared/ui/modal/global-settings-editor/Definitions";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
import {FlatInputField} from "tc-shared/ui/react-elements/InputField";
import {AbstractModal} from "tc-shared/ui/react-elements/modal/Definitions";
const ModalEvents = createContext<Registry<ModalGlobalSettingsEditorEvents>>(undefined);
const cssStyle = require("./Renderer.scss");
@ -159,13 +159,13 @@ const SettingList = () => {
);
}
export class ModalGlobalSettingsEditor extends InternalModal {
class ModalGlobalSettingsEditor extends AbstractModal {
protected readonly events: Registry<ModalGlobalSettingsEditorEvents>;
constructor(events: Registry<ModalGlobalSettingsEditorEvents>) {
constructor(events: IpcRegistryDescription<ModalGlobalSettingsEditorEvents>) {
super();
this.events = events;
this.events = Registry.fromIpcDescription(events);
}
renderBody(): React.ReactElement {
@ -189,8 +189,9 @@ export class ModalGlobalSettingsEditor extends InternalModal {
);
}
title(): string | React.ReactElement<Translatable> {
renderTitle(): string | React.ReactElement<Translatable> {
return <Translatable>Global settings registry</Translatable>;
}
}
export = ModalGlobalSettingsEditor;

View File

@ -54,10 +54,11 @@ html:root {
min-width: 20em;
max-width: 100%;
min-height: 20em;
flex-shrink: 1;
flex-grow: 1;
.contextContainer {
display: flex;
flex-direction: column;

View File

@ -1,4 +1,4 @@
import {spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {spawnReactModal} from "tc-shared/ui/react-elements/modal";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import * as React from "react";
import {useState} from "react";
@ -34,6 +34,7 @@ import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controll
import {ErrorCode} from "tc-shared/connection/ErrorCode";
import {PermissionEditorTab} from "tc-shared/events/GlobalEvents";
import {LogCategory, logError, logWarn} from "tc-shared/log";
import {useTr} from "tc-shared/ui/react-elements/Helper";
const cssStyle = require("./ModalPermissionEditor.scss");
@ -44,12 +45,12 @@ export type PermissionEditorSubject =
| "client"
| "client-channel"
| "none";
export const PermissionTabName: { [T in PermissionEditorTab]: { name: string, translated: string } } = {
"groups-server": {name: "Server Groups", translated: tr("Server Groups")},
"groups-channel": {name: "Channel Groups", translated: tr("Channel Groups")},
"channel": {name: "Channel Permissions", translated: tr("Channel Permissions")},
"client": {name: "Client Permissions", translated: tr("Client Permissions")},
"client-channel": {name: "Client Channel Permissions", translated: tr("Client Channel Permissions")},
export const PermissionTabName: { [T in PermissionEditorTab]: { name: string, useTranslate: () => string, renderTranslate: () => React.ReactNode } } = {
"groups-server": {name: "Server Groups", useTranslate: () => useTr("Server Groups"), renderTranslate: () => <Translatable>Server Groups</Translatable>},
"groups-channel": {name: "Channel Groups", useTranslate: () => useTr("Channel Groups"), renderTranslate: () => <Translatable>Channel Groups</Translatable>},
"channel": {name: "Channel Permissions", useTranslate: () => useTr("Channel Permissions"), renderTranslate: () => <Translatable>Channel Permissions</Translatable>},
"client": {name: "Client Permissions", useTranslate: () => useTr("Client Permissions"), renderTranslate: () => <Translatable>Client Permissions</Translatable>},
"client-channel": {name: "Client Channel Permissions", useTranslate: () => useTr("Client Channel Permissions"), renderTranslate: () => <Translatable>Client Channel Permissions</Translatable>},
};
export type GroupProperties = {
@ -244,13 +245,15 @@ const ActiveTabInfo = (props: { events: Registry<PermissionModalEvents> }) => {
const [activeTab, setActiveTab] = useState<PermissionEditorTab>("groups-server");
props.events.reactUse("action_activate_tab", event => setActiveTab(event.tab));
return <div className={cssStyle.header + " " + cssStyle.activeTabInfo}>
<div className={cssStyle.entry}>
<a title={PermissionTabName[activeTab].translated}>
<Translatable trIgnore={true}>{PermissionTabName[activeTab].name}</Translatable>
</a>
return (
<div className={cssStyle.header + " " + cssStyle.activeTabInfo}>
<div className={cssStyle.entry}>
<a title={PermissionTabName[activeTab].useTranslate()} key={"tab-" + activeTab}>
{PermissionTabName[activeTab].renderTranslate()}
</a>
</div>
</div>
</div>
);
};
const TabSelectorEntry = (props: { events: Registry<PermissionModalEvents>, entry: PermissionEditorTab }) => {
@ -258,22 +261,26 @@ const TabSelectorEntry = (props: { events: Registry<PermissionModalEvents>, entr
props.events.reactUse("action_activate_tab", event => setActive(event.tab === props.entry));
return <div className={cssStyle.entry + " " + (active ? cssStyle.selected : "")}
onClick={() => !active && props.events.fire("action_activate_tab", {tab: props.entry})}>
<a title={PermissionTabName[props.entry].translated}>
<Translatable trIgnore={true}>{PermissionTabName[props.entry].translated}</Translatable>
</a>
</div>;
return (
<div className={cssStyle.entry + " " + (active ? cssStyle.selected : "")}
onClick={() => !active && props.events.fire("action_activate_tab", {tab: props.entry})}>
<a title={PermissionTabName[props.entry].useTranslate()}>
{PermissionTabName[props.entry].renderTranslate()}
</a>
</div>
);
};
const TabSelector = (props: { events: Registry<PermissionModalEvents> }) => {
return <div className={cssStyle.header + " " + cssStyle.tabSelector}>
<TabSelectorEntry events={props.events} entry={"groups-server"}/>
<TabSelectorEntry events={props.events} entry={"groups-channel"}/>
<TabSelectorEntry events={props.events} entry={"channel"}/>
<TabSelectorEntry events={props.events} entry={"client"}/>
<TabSelectorEntry events={props.events} entry={"client-channel"}/>
</div>;
return (
<div className={cssStyle.header + " " + cssStyle.tabSelector}>
<TabSelectorEntry events={props.events} entry={"groups-server"}/>
<TabSelectorEntry events={props.events} entry={"groups-channel"}/>
<TabSelectorEntry events={props.events} entry={"channel"}/>
<TabSelectorEntry events={props.events} entry={"client"}/>
<TabSelectorEntry events={props.events} entry={"client-channel"}/>
</div>
);
};
export type DefaultTabValues = { groupId?: number, channelId?: number, clientDatabaseId?: number };
@ -336,7 +343,7 @@ class PermissionEditorModal extends InternalModal {
);
}
title(): React.ReactElement<Translatable> {
renderTitle(): React.ReactElement<Translatable> {
return <Translatable>Server permissions</Translatable>;
}

View File

@ -1354,7 +1354,7 @@ export class PermissionEditor extends React.Component<PermissionEditorProperties
<div key={"footer"} className={cssStyle.containerFooter}>
<RefreshButton events={this.props.events}/>
</div>
]
];
}
componentDidMount(): void {

View File

@ -409,8 +409,9 @@ function initializeController(events: Registry<NotificationSettingsEvents>) {
let filter = undefined;
events.on(["query_events", "action_set_filter"], event => {
if (event.type === "action_set_filter")
filter = event.as<"action_set_filter">().filter;
if (event.type === "action_set_filter") {
filter = event.asUnchecked("action_set_filter").filter;
}
const groupMapper = (group: EventGroup) => {
const result = {

View File

@ -1,4 +1,4 @@
import {spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {spawnReactModal} from "tc-shared/ui/react-elements/modal";
import * as React from "react";
import {Registry} from "tc-shared/events";
import {FileBrowserRenderer, NavigationBar} from "./FileBrowserRenderer";
@ -41,7 +41,7 @@ class FileTransferModal extends InternalModal {
this.transferInfoEvents.fire("notify_destroy");
}
title() {
renderTitle() {
return <Translatable>File Browser</Translatable>;
}

View File

@ -1,5 +1,5 @@
import {Registry} from "tc-shared/events";
import {spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {spawnReactModal} from "tc-shared/ui/react-elements/modal";
import {ModalVideoSourceEvents} from "tc-shared/ui/modal/video-source/Definitions";
import {ModalVideoSource} from "tc-shared/ui/modal/video-source/Renderer";
import {getVideoDriver, VideoPermissionStatus, VideoSource} from "tc-shared/video/VideoSource";
@ -94,7 +94,10 @@ export async function spawnVideoSourceSelectModal(type: VideoBroadcastType, mode
}
if(event.status.status === "preview") {
/* we've successfully selected something */
/* We've successfully selected something. Use that device instead. */
result.source?.deref();
result.source = controller.getCurrentSource()?.ref();
result.config = controller.getBroadcastConstraints();
modal.destroy();
}
});
@ -125,13 +128,20 @@ async function generateAndApplyDefaultConfig(source: VideoSource) : Promise<Vide
const trackSettings = videoTrack.getSettings();
const capabilities = source.getCapabilities();
maxHeight = Math.min(maxHeight, capabilities.maxHeight);
maxWidth = Math.min(maxWidth, capabilities.maxWidth);
/* Safari */
if(trackSettings.height === 0) {
trackSettings.height = capabilities.maxHeight;
}
if(trackSettings.width === 0) {
trackSettings.width = capabilities.maxWidth;
}
maxHeight = maxHeight ? Math.min(maxHeight, capabilities.maxHeight) : capabilities.maxHeight;
maxWidth = maxWidth ? Math.min(maxWidth, capabilities.maxWidth) : capabilities.maxWidth;
/* FIXME: Get these values somewhere else! */
const broadcastConstraints: VideoBroadcastConfig = {
maxBandwidth: 1_600_000,
keyframeInterval: 0
maxBandwidth: settings.getValue(Settings.KEY_VIDEO_DEFAULT_MAX_BANDWIDTH),
keyframeInterval: settings.getValue(Settings.KEY_VIDEO_DEFAULT_KEYFRAME_INTERVAL)
} as VideoBroadcastConfig;
{
@ -160,7 +170,7 @@ async function generateAndApplyDefaultConfig(source: VideoSource) : Promise<Vide
try {
await applyBroadcastConfig(source, broadcastConstraints);
} catch (error) {
logWarn(LogCategory.VIDEO, tr("Failed to apply initial default broadcast config: %o"), error);
logWarn(LogCategory.VIDEO, tr("Failed to apply initial default broadcast config (%o): %o"), broadcastConstraints, error);
}
updateBroadcastConfigFromSource(source, broadcastConstraints);

View File

@ -855,7 +855,7 @@ export class ModalVideoSource extends InternalModal {
);
}
title(): string | React.ReactElement<Translatable> {
renderTitle(): string | React.ReactElement<Translatable> {
return <Translatable>Start video Broadcasting</Translatable>;
}
}

View File

@ -1,4 +1,4 @@
import {spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {spawnReactModal} from "tc-shared/ui/react-elements/modal";
import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controller";
import * as React from "react";
import {WhatsNew} from "tc-shared/ui/modal/whats-new/Renderer";
@ -15,7 +15,7 @@ export function spawnUpdatedModal(changes: { changesUI?: ChangeLog, changesClien
return <WhatsNew changesUI={changes.changesUI} changesClient={changes.changesClient}/>;
}
title(): string | React.ReactElement<Translatable> {
renderTitle(): string | React.ReactElement<Translatable> {
return <Translatable>We've updated the client for you</Translatable>;
}
});

View File

@ -28,6 +28,7 @@ export class ErrorBoundary extends React.Component<{}, ErrorBoundaryState> {
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
/* TODO: Some kind of logging? */
console.error(error);
}
static getDerivedStateFromError() : Partial<ErrorBoundaryState> {

View File

@ -23,6 +23,7 @@ export interface BoxedInputFieldProperties {
isInvalid?: boolean;
className?: string;
maxLength?: number,
size?: "normal" | "large" | "small";
type?: "text" | "password" | "number";
@ -86,6 +87,7 @@ export class BoxedInputField extends React.Component<BoxedInputFieldProperties,
disabled={this.state.disabled || this.props.disabled}
onInput={this.props.onInput && (event => this.props.onInput(event.currentTarget.value))}
onKeyDown={e => this.onKeyDown(e)}
maxLength={this.props.maxLength}
/>
}
{this.props.suffix ? <a key={"suffix"} className={cssStyle.suffix}>{this.props.suffix}</a> : undefined}
@ -399,6 +401,7 @@ export const ControlledSelect = (props: {
export interface SelectProperties {
type?: "flat" | "boxed";
refSelect?: React.RefObject<HTMLSelectElement>,
defaultValue?: string;
value?: string;
@ -416,6 +419,8 @@ export interface SelectProperties {
disabled?: boolean;
editable?: boolean;
title?: string,
onFocus?: () => void;
onBlur?: () => void;
@ -430,11 +435,13 @@ export interface SelectFieldState {
}
export class Select extends React.Component<SelectProperties, SelectFieldState> {
private refSelect = React.createRef<HTMLSelectElement>();
private refSelect;
constructor(props) {
super(props);
this.refSelect = this.props.refSelect || React.createRef<HTMLSelectElement>();
this.state = {
isInvalid: false,
invalidMessage: ""
@ -444,7 +451,12 @@ export class Select extends React.Component<SelectProperties, SelectFieldState>
render() {
const disabled = typeof this.state.disabled === "boolean" ? this.state.disabled : typeof this.props.disabled === "boolean" ? this.props.disabled : false;
return (
<div className={(this.props.type === "boxed" ? cssStyle.containerBoxed : cssStyle.containerFlat) + " " + cssStyle["size-normal"] + " " + (this.state.isInvalid ? cssStyle.isInvalid : "") + " " + (this.props.className || "") + " " + cssStyle.noLeftIcon + " " + cssStyle.noRightIcon}>
<div className={
(this.props.type === "boxed" ? cssStyle.containerBoxed : cssStyle.containerFlat) + " " +
cssStyle["size-normal"] + " " + (this.state.isInvalid ? cssStyle.isInvalid : "") + " " +
(this.props.className || "") + " " + cssStyle.noLeftIcon + " " + cssStyle.noRightIcon + " " +
(this.props.disabled ? cssStyle.disabled : "")
}>
{this.props.label ?
<label className={cssStyle["type-static"] + " " + (this.props.labelClassName || "")}>{this.props.label}</label> : undefined}
<select
@ -453,6 +465,7 @@ export class Select extends React.Component<SelectProperties, SelectFieldState>
value={this.props.value}
defaultValue={this.props.defaultValue}
disabled={disabled}
title={this.props.title}
onFocus={this.props.onFocus}
onBlur={this.props.onBlur}

View File

@ -1,11 +0,0 @@
import {InternalModal, InternalModalController} from "tc-shared/ui/react-elements/internal-modal/Controller";
export function spawnReactModal<ModalClass extends InternalModal, A1>(modalClass: new () => ModalClass) : InternalModalController<ModalClass>;
export function spawnReactModal<ModalClass extends InternalModal, A1>(modalClass: new (..._: [A1]) => ModalClass, arg1: A1) : InternalModalController<ModalClass>;
export function spawnReactModal<ModalClass extends InternalModal, A1, A2>(modalClass: new (..._: [A1, A2]) => ModalClass, arg1: A1, arg2: A2) : InternalModalController<ModalClass>;
export function spawnReactModal<ModalClass extends InternalModal, A1, A2, A3>(modalClass: new (..._: [A1, A2, A3]) => ModalClass, arg1: A1, arg2: A2, arg3: A3) : InternalModalController<ModalClass>;
export function spawnReactModal<ModalClass extends InternalModal, A1, A2, A3, A4>(modalClass: new (..._: [A1, A2, A3, A4]) => ModalClass, arg1: A1, arg2: A2, arg3: A3, arg4: A4) : InternalModalController<ModalClass>;
export function spawnReactModal<ModalClass extends InternalModal, A1, A2, A3, A4, A5>(modalClass: new (..._: [A1, A2, A3, A4]) => ModalClass, arg1: A1, arg2: A2, arg3: A3, arg4: A4, arg5: A5) : InternalModalController<ModalClass>;
export function spawnReactModal<ModalClass extends InternalModal>(modalClass: new (..._: any[]) => ModalClass, ...args: any[]) : InternalModalController<ModalClass> {
return new InternalModalController(new modalClass(...args));
}

View File

@ -1,59 +1,3 @@
import * as React from "react";
import {ReactElement} from "react";
import {Registry} from "../../events";
export type ModalType = "error" | "warning" | "info" | "none";
export interface ModalOptions {
destroyOnClose?: boolean;
defaultSize?: { width: number, height: number };
}
export interface ModalEvents {
"open": {},
"close": {},
/* create is implicitly at object creation */
"destroy": {}
}
export enum ModalState {
SHOWN,
HIDDEN,
DESTROYED
}
export interface ModalController {
getOptions() : Readonly<ModalOptions>;
getEvents() : Registry<ModalEvents>;
getState() : ModalState;
show() : Promise<void>;
hide() : Promise<void>;
destroy();
}
export abstract class AbstractModal {
protected constructor() {}
abstract renderBody() : ReactElement;
abstract title() : string | React.ReactElement;
/* only valid for the "inline" modals */
type() : ModalType { return "none"; }
color() : "none" | "blue" { return "none"; }
verticalAlignment() : "top" | "center" | "bottom" { return "center"; }
protected onInitialize() {}
protected onDestroy() {}
protected onClose() {}
protected onOpen() {}
}
export interface ModalRenderer {
renderModal(modal: AbstractModal | undefined);
}
/* TODO: Remove this! */
import * as definitions from "./modal/Definitions";
export = definitions;

View File

@ -1,7 +1,7 @@
import {LogCategory, logDebug, logTrace, logWarn} from "../../../log";
import * as ipc from "../../../ipc/BrowserIPC";
import {ChannelMessage} from "../../../ipc/BrowserIPC";
import {Registry, RegistryMap} from "../../../events";
import {Registry} from "../../../events";
import {
EventControllerBase,
Popout2ControllerMessages,
@ -11,7 +11,7 @@ import {ModalController, ModalEvents, ModalOptions, ModalState} from "../../../u
export abstract class AbstractExternalModalController extends EventControllerBase<"controller"> implements ModalController {
public readonly modalType: string;
public readonly userData: any;
public readonly constructorArguments: any[];
private readonly modalEvents: Registry<ModalEvents>;
private modalState: ModalState = ModalState.DESTROYED;
@ -19,15 +19,13 @@ export abstract class AbstractExternalModalController extends EventControllerBas
private readonly documentUnloadListener: () => void;
private callbackWindowInitialized: (error?: string) => void;
protected constructor(modal: string, registries: RegistryMap, userData: any) {
protected constructor(modalType: string, constructorArguments: any[]) {
super();
this.initializeRegistries(registries);
this.modalType = modalType;
this.constructorArguments = constructorArguments;
this.modalEvents = new Registry<ModalEvents>();
this.modalType = modal;
this.userData = userData;
this.ipcChannel = ipc.getIpcInstance().createChannel();
this.ipcChannel.messageHandler = this.handleIPCMessage.bind(this);
@ -156,15 +154,10 @@ export abstract class AbstractExternalModalController extends EventControllerBas
this.callbackWindowInitialized = undefined;
}
this.sendIPCMessage("hello-controller", { accepted: true, userData: this.userData, registries: Object.keys(this.localRegistries) });
this.sendIPCMessage("hello-controller", { accepted: true, constructorArguments: this.constructorArguments });
break;
}
case "fire-event":
case "fire-event-callback":
/* already handled by out base class */
break;
case "invoke-modal-action":
/* must be handled by the underlying handler */
break;

View File

@ -1,29 +1,15 @@
import {ChannelMessage, IPCChannel} from "../../../ipc/BrowserIPC";
import {EventReceiver, RegistryMap} from "../../../events";
export interface PopoutIPCMessage {
"hello-popout": { version: string },
"hello-controller": { accepted: boolean, message?: string, userData?: any, registries?: string[] },
"fire-event": {
type: "sync" | "react" | "later";
eventType: string;
payload: any;
callbackId: string;
registry: string;
},
"fire-event-callback": {
callbackId: string
},
"hello-controller": { accepted: boolean, message?: string, constructorArguments?: any[] },
"invoke-modal-action": {
action: "close" | "minimize"
}
}
export type Controller2PopoutMessages = "hello-controller" | "fire-event" | "fire-event-callback";
export type Popout2ControllerMessages = "hello-popout" | "fire-event" | "fire-event-callback" | "invoke-modal-action";
export type Controller2PopoutMessages = "hello-controller";
export type Popout2ControllerMessages = "hello-popout" | "invoke-modal-action";
export interface SendIPCMessage {
"controller": Controller2PopoutMessages;
@ -35,71 +21,12 @@ export interface ReceivedIPCMessage {
"popout": Controller2PopoutMessages;
}
let callbackIdIndex = 0;
export abstract class EventControllerBase<Type extends "controller" | "popout"> {
protected ipcChannel: IPCChannel;
protected ipcRemoteId: string;
protected localRegistries: RegistryMap;
private localEventReceiver: {[key: string]: EventReceiver};
private omitEventType: string = undefined;
private omitEventData: any;
private eventFiredListeners: {[key: string]:{ callback: () => void, timeout: number }} = {};
protected constructor() { }
protected initializeRegistries(registries: RegistryMap) {
if(typeof this.localRegistries !== "undefined") { throw "event registries have already been initialized" };
this.localEventReceiver = {};
this.localRegistries = registries;
for(const key of Object.keys(this.localRegistries)) {
this.localEventReceiver[key] = this.createEventReceiver(key);
this.localRegistries[key].connectAll(this.localEventReceiver[key]);
}
}
private createEventReceiver(key: string) : EventReceiver {
let refThis = this;
const fireEvent = (type: "react" | "later", eventType: any, data?: any[], callback?: () => void) => {
const callbackId = callback ? (++callbackIdIndex) + "-ev-cb" : undefined;
refThis.sendIPCMessage("fire-event", { type: type, eventType: eventType, payload: data, callbackId: callbackId, registry: key });
if(callbackId) {
const timeout = setTimeout(() => {
delete refThis.eventFiredListeners[callbackId];
callback();
}, 2500);
refThis.eventFiredListeners[callbackId] = {
callback: callback,
timeout: timeout
}
}
};
return new class implements EventReceiver {
fire<T extends keyof {}>(eventType: T, data?: any[T], overrideTypeKey?: boolean) {
if(refThis.omitEventType === eventType && refThis.omitEventData === data) {
refThis.omitEventType = undefined;
return;
}
refThis.sendIPCMessage("fire-event", { type: "sync", eventType: eventType, payload: data, callbackId: undefined, registry: key });
}
fire_later<T extends keyof { [p: string]: any }>(eventType: T, data?: { [p: string]: any }[T], callback?: () => void) {
fireEvent("later", eventType, data, callback);
}
fire_react<T extends keyof {}>(eventType: T, data?: any[T], callback?: () => void) {
fireEvent("react", eventType, data, callback);
}
};
}
protected handleIPCMessage(remoteId: string, broadcast: boolean, message: ChannelMessage) {
if(this.ipcRemoteId !== remoteId) {
console.warn("Received message from unknown end: %s. Expected: %s", remoteId, this.ipcRemoteId);
@ -113,37 +40,10 @@ export abstract class EventControllerBase<Type extends "controller" | "popout">
this.ipcChannel.sendMessage(type, payload, this.ipcRemoteId);
}
protected handleTypedIPCMessage<T extends ReceivedIPCMessage[Type]>(type: T, payload: PopoutIPCMessage[T]) {
switch (type) {
case "fire-event": {
const tpayload = payload as PopoutIPCMessage["fire-event"];
/* FIXME: Pay respect to the different event types and may bundle react updates! */
this.omitEventData = tpayload.payload;
this.omitEventType = tpayload.eventType;
this.localRegistries[tpayload.registry].fire(tpayload.eventType, tpayload.payload);
if(tpayload.callbackId)
this.sendIPCMessage("fire-event-callback", { callbackId: tpayload.callbackId });
break;
}
case "fire-event-callback": {
const tpayload = payload as PopoutIPCMessage["fire-event-callback"];
const callback = this.eventFiredListeners[tpayload.callbackId];
delete this.eventFiredListeners[tpayload.callbackId];
if(callback) {
clearTimeout(callback.timeout);
callback.callback();
}
break;
}
}
}
protected handleTypedIPCMessage<T extends ReceivedIPCMessage[Type]>(type: T, payload: PopoutIPCMessage[T]) {}
protected destroyIPC() {
Object.keys(this.localRegistries).forEach(key => this.localRegistries[key].disconnectAll(this.localEventReceiver[key]));
this.ipcChannel = undefined;
this.ipcRemoteId = undefined;
this.eventFiredListeners = {};
}
}

View File

@ -5,29 +5,32 @@ import {
EventControllerBase,
PopoutIPCMessage
} from "../../../ui/react-elements/external-modal/IPCMessage";
import {Registry, RegistryMap} from "../../../events";
let controller: PopoutController;
export function getPopoutController() {
if(!controller)
if(!controller) {
controller = new PopoutController();
}
return controller;
}
class PopoutController extends EventControllerBase<"popout"> {
private userData: any;
private constructorArguments: any[];
private callbackControllerHello: (accepted: boolean | string) => void;
constructor() {
super();
this.ipcRemoteId = AppParameters.getValue(AppParameters.KEY_IPC_REMOTE_ADDRESS, "invalid");
this.ipcChannel = getIPCInstance().createChannel(this.ipcRemoteId, AppParameters.getValue(AppParameters.KEY_IPC_REMOTE_ADDRESS, "invalid"));
this.ipcChannel = getIPCInstance().createChannel(this.ipcRemoteId, AppParameters.getValue(AppParameters.KEY_IPC_REMOTE_POPOUT_CHANNEL, "invalid"));
this.ipcChannel.messageHandler = this.handleIPCMessage.bind(this);
}
getEventRegistries() : RegistryMap { return this.localRegistries; }
getConstructorArguments() : any[] {
return this.constructorArguments;
}
async initialize() {
this.sendIPCMessage("hello-popout", { version: __build.version });
@ -63,39 +66,17 @@ class PopoutController extends EventControllerBase<"popout"> {
return;
}
if(this.getEventRegistries()) {
const registries = this.getEventRegistries();
const invalidIndex = tpayload.registries.findIndex(reg => !registries[reg]);
if(invalidIndex !== -1) {
console.error("Received miss matching event registry keys (missing %s)", tpayload.registries[invalidIndex]);
this.callbackControllerHello("miss matching registry keys (locally)");
}
} else {
let map = {};
tpayload.registries.forEach(reg => map[reg] = new Registry());
this.initializeRegistries(map);
}
this.userData = tpayload.userData;
this.constructorArguments = tpayload.constructorArguments;
this.callbackControllerHello(tpayload.accepted ? true : tpayload.message || false);
break;
}
case "fire-event-callback":
case "fire-event":
/* handled by out base class */
break;
default:
console.warn("Received unknown message type from controller: %s", type);
return;
}
}
getUserData() {
return this.userData;
}
doClose() {
this.sendIPCMessage("invoke-modal-action", { action: "close" });
}

View File

@ -5,14 +5,13 @@ import * as i18n from "../../../i18n/localize";
import {AbstractModal, ModalRenderer} from "../../../ui/react-elements/ModalDefinitions";
import {AppParameters} from "../../../settings";
import {getPopoutController} from "./PopoutController";
import {findPopoutHandler} from "../../../ui/react-elements/external-modal/PopoutRegistry";
import {RegistryMap} from "../../../events";
import {WebModalRenderer} from "../../../ui/react-elements/external-modal/PopoutRendererWeb";
import {ClientModalRenderer} from "../../../ui/react-elements/external-modal/PopoutRendererClient";
import {setupJSRender} from "../../../ui/jsrender";
import "../../../file/RemoteAvatars";
import "../../../file/RemoteIcons";
import {findRegisteredModal} from "tc-shared/ui/react-elements/modal/Registry";
if("__native_client_init_shared" in window) {
(window as any).__native_client_init_shared(__webpack_require__);
@ -20,7 +19,7 @@ if("__native_client_init_shared" in window) {
let modalRenderer: ModalRenderer;
let modalInstance: AbstractModal;
let modalClass: new (events: RegistryMap, userData: any) => AbstractModal;
let modalClass: new (...args: any[]) => AbstractModal;
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
name: "setup",
@ -70,13 +69,13 @@ loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
const modalTarget = AppParameters.getValue(AppParameters.KEY_MODAL_TARGET, "unknown");
console.error("Loading modal class %s", modalTarget);
try {
const handler = findPopoutHandler(modalTarget);
if(!handler) {
const registeredModal = findRegisteredModal(modalTarget as any);
if(!registeredModal) {
loader.critical_error("Missing popout handler", "Handler " + modalTarget + " is missing.");
throw "missing handler";
}
modalClass = await handler.loadClass();
modalClass = await registeredModal.classLoader();
} catch(error) {
loader.critical_error("Failed to load modal", "Lookup the console for more detail");
console.error("Failed to load modal %s: %o", modalTarget, error);
@ -89,7 +88,7 @@ loader.register_task(Stage.LOADED, {
priority: 100,
function: async () => {
try {
modalInstance = new modalClass(getPopoutController().getEventRegistries(), getPopoutController().getUserData());
modalInstance = new modalClass(...getPopoutController().getConstructorArguments());
modalRenderer.renderModal(modalInstance);
} catch(error) {
loader.critical_error("Failed to invoker modal", "Lookup the console for more detail");

View File

@ -1,38 +0,0 @@
import {AbstractModal} from "../../../ui/react-elements/ModalDefinitions";
export interface PopoutHandler {
name: string;
loadClass: <T extends AbstractModal>() => Promise<any>;
}
const registeredHandler: {[key: string]: PopoutHandler} = {};
export function findPopoutHandler(name: string) {
return registeredHandler[name];
}
function registerHandler(handler: PopoutHandler) {
registeredHandler[handler.name] = handler;
}
registerHandler({
name: "video-viewer",
loadClass: async () => await import("tc-shared/video-viewer/Renderer")
});
registerHandler({
name: "conversation",
loadClass: async () => await import("../../frames/side/PopoutConversationRenderer")
});
registerHandler({
name: "css-editor",
loadClass: async () => await import("tc-shared/ui/modal/css-editor/Renderer")
});
registerHandler({
name: "channel-tree",
loadClass: async () => await import("tc-shared/ui/tree/popout/RendererModal")
});

View File

@ -13,7 +13,8 @@ html, body {
height: 100vh;
width: 100vw;
background: #212529;
background: #19191b;
color: #999;
}
.container {
@ -53,4 +54,4 @@ html, body {
overflow: auto;
@include chat-scrollbar();
}
}
}

View File

@ -1,6 +1,6 @@
import {InternalModalContentRenderer} from "tc-shared/ui/react-elements/internal-modal/Renderer";
import {AbstractModal, ModalRenderer} from "tc-shared/ui/react-elements/ModalDefinitions";
import * as ReactDOM from "react-dom";
import {InternalModalContentRenderer} from "tc-shared/ui/react-elements/internal-modal/Renderer";
import * as React from "react";
export interface ModalControlFunctions {
@ -39,11 +39,13 @@ export class ClientModalRenderer implements ModalRenderer {
}
renderModal(modal: AbstractModal | undefined) {
if(this.currentModal === modal)
if(this.currentModal === modal) {
return;
}
this.titleChangeObserver.disconnect();
ReactDOM.unmountComponentAtNode(this.container);
this.currentModal = modal;
ReactDOM.render(
<InternalModalContentRenderer
@ -71,8 +73,9 @@ export class ClientModalRenderer implements ModalRenderer {
}
private updateTitle() {
if(!this.titleContainer)
if(!this.titleContainer) {
return;
}
this.titleElement.innerText = this.titleContainer.textContent;
}

View File

@ -25,7 +25,7 @@ class TitleRenderer {
this.modalInstance = instance;
if(this.modalInstance) {
ReactDOM.render(<>{this.modalInstance.title()}</>, this.htmlContainer);
ReactDOM.render(<>{this.modalInstance.renderTitle()}</>, this.htmlContainer);
}
}
}
@ -65,8 +65,9 @@ export class WebModalRenderer implements ModalRenderer {
}
renderModal(modal: AbstractModal | undefined) {
if(this.currentModal === modal)
if(this.currentModal === modal) {
return;
}
this.currentModal = modal;
this.titleRenderer.setInstance(modal);

View File

@ -1,17 +1,17 @@
import {RegistryMap} from "../../../events";
import "./Controller";
import {ModalController} from "../../../ui/react-elements/ModalDefinitions"; /* we've to reference him here, else the client would not */
import {ModalController, ModalOptions} from "../ModalDefinitions";
export type ControllerFactory = (modal: string, registryMap: RegistryMap, userData: any, uniqueModalId: string) => ModalController;
export type ControllerFactory = (modalType: string, constructorArguments?: any[], options?: ModalOptions) => ModalController;
let modalControllerFactory: ControllerFactory;
export function setExternalModalControllerFactory(factory: ControllerFactory) {
modalControllerFactory = factory;
}
export function spawnExternalModal<EventClass extends { [key: string]: any }>(modal: string, registryMap: RegistryMap, userData: any, uniqueModalId?: string) : ModalController {
if(typeof modalControllerFactory === "undefined")
export function spawnExternalModal<EventClass extends { [key: string]: any }>(modalType: string, constructorArguments?: any[], options?: ModalOptions) : ModalController {
if(typeof modalControllerFactory === "undefined") {
throw tr("No external modal factory has been set");
}
return modalControllerFactory(modal, registryMap, userData, uniqueModalId);
return modalControllerFactory(modalType, constructorArguments, options);
}

View File

@ -10,10 +10,14 @@ import {
} from "../../../ui/react-elements/ModalDefinitions";
import {InternalModalRenderer} from "../../../ui/react-elements/internal-modal/Renderer";
import {tr} from "tc-shared/i18n/localize";
import {RegisteredModal} from "tc-shared/ui/react-elements/modal/Registry";
export class InternalModalController<InstanceType extends InternalModal = InternalModal> implements ModalController {
export class InternalModalController implements ModalController {
readonly events: Registry<ModalEvents>;
readonly modalInstance: InstanceType;
private readonly modalType: RegisteredModal<any>;
private readonly constructorArguments: any[];
private modalInstance: AbstractModal;
private initializedPromise: Promise<void>;
@ -21,10 +25,12 @@ export class InternalModalController<InstanceType extends InternalModal = Intern
private refModal: React.RefObject<InternalModalRenderer>;
private modalState_: ModalState = ModalState.HIDDEN;
constructor(instance: InstanceType) {
this.modalInstance = instance;
constructor(modalType: RegisteredModal<any>, constructorArguments: any[]) {
this.modalType = modalType;
this.constructorArguments = constructorArguments;
this.events = new Registry<ModalEvents>();
this.initialize();
this.initializedPromise = this.initialize();
}
getOptions(): Readonly<ModalOptions> {
@ -40,17 +46,19 @@ export class InternalModalController<InstanceType extends InternalModal = Intern
return this.modalState_;
}
private initialize() {
private async initialize() {
this.refModal = React.createRef();
this.domElement = document.createElement("div");
this.modalInstance = new (await this.modalType.classLoader())(...this.constructorArguments);
console.error(this.modalInstance);
const element = React.createElement(InternalModalRenderer, {
ref: this.refModal,
modal: this.modalInstance,
onClose: () => this.destroy()
});
document.body.appendChild(this.domElement);
this.initializedPromise = new Promise<void>(resolve => {
await new Promise<void>(resolve => {
ReactDOM.render(element, this.domElement, () => setTimeout(resolve, 0));
});
@ -59,10 +67,11 @@ export class InternalModalController<InstanceType extends InternalModal = Intern
async show() : Promise<void> {
await this.initializedPromise;
if(this.modalState_ === ModalState.DESTROYED)
if(this.modalState_ === ModalState.DESTROYED) {
throw tr("modal has been destroyed");
else if(this.modalState_ === ModalState.SHOWN)
} else if(this.modalState_ === ModalState.SHOWN) {
return;
}
this.refModal.current?.setState({ show: true });
this.modalState_ = ModalState.SHOWN;

View File

@ -211,6 +211,8 @@ html:root {
/* explicitly set the background color so the next element could use background-color: inherited; */
background: var(--modal-content-background);
@include chat-scrollbar();
}
}

View File

@ -2,10 +2,11 @@ import * as React from "react";
import {AbstractModal} from "tc-shared/ui/react-elements/ModalDefinitions";
import {ClientIcon} from "svg-sprites/client-icons";
import {ErrorBoundary} from "tc-shared/ui/react-elements/ErrorBoundary";
import {useMemo} from "react";
const cssStyle = require("./Modal.scss");
export const InternalModalContentRenderer = (props: {
export const InternalModalContentRenderer = React.memo((props: {
modal: AbstractModal,
onClose?: () => void,
@ -18,6 +19,9 @@ export const InternalModalContentRenderer = (props: {
refContent?: React.Ref<HTMLDivElement>
}) => {
const body = useMemo(() => props.modal.renderBody(), [props.modal]);
const title = useMemo(() => props.modal.renderTitle(), [props.modal]);
return (
<div className={cssStyle.content + " " + props.containerClass} ref={props.refContent}>
<div className={cssStyle.header + " " + props.headerClass}>
@ -26,7 +30,7 @@ export const InternalModalContentRenderer = (props: {
</div>
<div className={cssStyle.title + " " + props.headerTitleClass}>
<ErrorBoundary>
{props.modal.title()}
{title}
</ErrorBoundary>
</div>
{!props.onMinimize ? undefined : (
@ -42,12 +46,12 @@ export const InternalModalContentRenderer = (props: {
</div>
<div className={cssStyle.body + " " + props.bodyClass}>
<ErrorBoundary>
{props.modal.renderBody()}
{body}
</ErrorBoundary>
</div>
</div>
);
};
});
export class InternalModalRenderer extends React.PureComponent<{ modal: AbstractModal, onClose: () => void }, { show: boolean }> {
private readonly refModal = React.createRef<HTMLDivElement>();

View File

@ -0,0 +1,128 @@
import {IpcRegistryDescription, Registry} from "tc-shared/events";
import {VideoViewerEvents} from "tc-shared/video-viewer/Definitions";
import {ReactElement} from "react";
import * as React from "react";
import {ChannelEditEvents} from "tc-shared/ui/modal/channel-edit/Definitions";
import {EchoTestEvents} from "tc-shared/ui/modal/echo-test/Definitions";
import {ModalGlobalSettingsEditorEvents} from "tc-shared/ui/modal/global-settings-editor/Definitions";
export type ModalType = "error" | "warning" | "info" | "none";
export type ModalRenderType = "page" | "dialog";
export interface ModalOptions {
/**
* Unique modal id.
*/
uniqueId?: string,
/**
* Destroy the modal if it has been closed.
* If the value is `false` it *might* destroy the modal anyways.
* Default: `true`.
*/
destroyOnClose?: boolean,
/**
* Default size of the modal in pixel.
* This value might or might not be respected.
*/
defaultSize?: { width: number, height: number },
/**
* Determines if the modal is resizeable or now.
* Some browsers might not support non resizeable modals.
* Default: `both`
*/
resizeable?: "none" | "vertical" | "horizontal" | "both",
/**
* If the modal should be popoutable.
* Default: `false`
*/
popoutable?: boolean,
/**
* The default popout state.
* Default: `false`
*/
popedOut?: boolean
}
export interface ModalFunctionController {
minimize();
supportMinimize() : boolean;
maximize();
supportMaximize() : boolean;
close();
}
export interface ModalEvents {
"open": {},
"close": {},
/* create is implicitly at object creation */
"destroy": {}
}
export enum ModalState {
SHOWN,
HIDDEN,
DESTROYED
}
export interface ModalController {
getOptions() : Readonly<ModalOptions>;
getEvents() : Registry<ModalEvents>;
getState() : ModalState;
show() : Promise<void>;
hide() : Promise<void>;
destroy();
}
export abstract class AbstractModal {
protected constructor() {}
abstract renderBody() : ReactElement;
abstract renderTitle() : string | React.ReactElement;
/* only valid for the "inline" modals */
type() : ModalType { return "none"; }
color() : "none" | "blue" { return "none"; }
verticalAlignment() : "top" | "center" | "bottom" { return "center"; }
protected onInitialize() {}
protected onDestroy() {}
protected onClose() {}
protected onOpen() {}
}
export interface ModalRenderer {
renderModal(modal: AbstractModal | undefined);
}
export interface ModalConstructorArguments {
"video-viewer": [
/* events */ IpcRegistryDescription<VideoViewerEvents>,
/* handlerId */ string,
],
"channel-edit": [
/* events */ IpcRegistryDescription<ChannelEditEvents>,
/* isChannelCreate */ boolean
],
"echo-test": [
/* events */ IpcRegistryDescription<EchoTestEvents>
],
"global-settings-editor": [
/* events */ IpcRegistryDescription<ModalGlobalSettingsEditorEvents>
],
"conversation": any,
"css-editor": any,
"channel-tree": any,
"modal-connect": any
}

View File

@ -0,0 +1,68 @@
import {AbstractModal} from "../../../ui/react-elements/ModalDefinitions";
import {ModalConstructorArguments} from "tc-shared/ui/react-elements/modal/Definitions";
export interface RegisteredModal<T extends keyof ModalConstructorArguments> {
modalId: T,
classLoader: () => Promise<new (...args: ModalConstructorArguments[T]) => AbstractModal>,
popoutSupported: boolean
}
const registeredModals: {
[T in keyof ModalConstructorArguments]?: RegisteredModal<T>
} = {};
export function findRegisteredModal<T extends keyof ModalConstructorArguments>(name: T) : RegisteredModal<T> | undefined {
return registeredModals[name] as any;
}
function registerModal<T extends keyof ModalConstructorArguments>(modal: RegisteredModal<T>) {
registeredModals[modal.modalId] = modal as any;
}
registerModal({
modalId: "video-viewer",
classLoader: async () => await import("tc-shared/video-viewer/Renderer"),
popoutSupported: true
});
registerModal({
modalId: "channel-edit",
classLoader: async () => await import("tc-shared/ui/modal/channel-edit/Renderer"),
popoutSupported: false /* TODO: Needs style fixing */
});
registerModal({
modalId: "echo-test",
classLoader: async () => await import("tc-shared/ui/modal/echo-test/Renderer"),
popoutSupported: false /* TODO: Needs style fixing */
});
registerModal({
modalId: "global-settings-editor",
classLoader: async () => await import("tc-shared/ui/modal/global-settings-editor/Renderer"),
popoutSupported: true
});
registerModal({
modalId: "conversation",
classLoader: async () => await import("../../frames/side/PopoutConversationRenderer"),
popoutSupported: true
});
registerModal({
modalId: "css-editor",
classLoader: async () => await import("tc-shared/ui/modal/css-editor/Renderer"),
popoutSupported: true
});
registerModal({
modalId: "channel-tree",
classLoader: async () => await import("tc-shared/ui/tree/popout/RendererModal"),
popoutSupported: true
});
registerModal({
modalId: "modal-connect",
classLoader: async () => await import("tc-shared/ui/modal/connect/Renderer"),
popoutSupported: true
});

View File

@ -0,0 +1,43 @@
import {
AbstractModal,
ModalFunctionController,
ModalOptions,
ModalRenderType
} from "tc-shared/ui/react-elements/modal/Definitions";
import {useContext} from "react";
const ControllerContext = useContext<ModalRendererController>(undefined);
interface RendererControllerEvents {
}
export class ModalRendererController {
readonly renderType: ModalRenderType;
readonly modal: AbstractModal;
constructor(renderType: ModalRenderType, modal: AbstractModal,) {
this.renderType = renderType;
this.modal = modal;
}
setShown(shown: boolean) {
}
}
export const ModalRenderer = (props: {
mode: "page" | "dialog",
modal: AbstractModal,
modalOptions: ModalOptions,
modalActions: ModalFunctionController
}) => {
}
const ModalRendererDialog = (props: {
modal: AbstractModal,
modalOptions: ModalOptions,
modalActions: ModalFunctionController
}) => {
}

View File

@ -0,0 +1,31 @@
import {ModalConstructorArguments} from "tc-shared/ui/react-elements/modal/Definitions";
import {ModalController, ModalOptions} from "tc-shared/ui/react-elements/ModalDefinitions";
import {spawnExternalModal} from "tc-shared/ui/react-elements/external-modal";
import {InternalModal, InternalModalController} from "tc-shared/ui/react-elements/internal-modal/Controller";
import {findRegisteredModal} from "tc-shared/ui/react-elements/modal/Registry";
export function spawnModal<T extends keyof ModalConstructorArguments>(modal: T, constructorArguments: ModalConstructorArguments[T], options?: ModalOptions) : ModalController {
if(options?.popedOut) {
return spawnExternalModal(modal, constructorArguments, options);
} else {
return spawnInternalModal(modal, constructorArguments, options);
}
}
export function spawnReactModal<ModalClass extends InternalModal, A1>(modalClass: new () => ModalClass) : InternalModalController;
export function spawnReactModal<ModalClass extends InternalModal, A1>(modalClass: new (..._: [A1]) => ModalClass, arg1: A1) : InternalModalController;
export function spawnReactModal<ModalClass extends InternalModal, A1, A2>(modalClass: new (..._: [A1, A2]) => ModalClass, arg1: A1, arg2: A2) : InternalModalController;
export function spawnReactModal<ModalClass extends InternalModal, A1, A2, A3>(modalClass: new (..._: [A1, A2, A3]) => ModalClass, arg1: A1, arg2: A2, arg3: A3) : InternalModalController;
export function spawnReactModal<ModalClass extends InternalModal, A1, A2, A3, A4>(modalClass: new (..._: [A1, A2, A3, A4]) => ModalClass, arg1: A1, arg2: A2, arg3: A3, arg4: A4) : InternalModalController;
export function spawnReactModal<ModalClass extends InternalModal, A1, A2, A3, A4, A5>(modalClass: new (..._: [A1, A2, A3, A4]) => ModalClass, arg1: A1, arg2: A2, arg3: A3, arg4: A4, arg5: A5) : InternalModalController;
export function spawnReactModal<ModalClass extends InternalModal>(modalClass: new (..._: any[]) => ModalClass, ...args: any[]) : InternalModalController {
return new InternalModalController({
popoutSupported: false,
modalId: "__internal__unregistered",
classLoader: async () => modalClass
}, args);
}
export function spawnInternalModal<T extends keyof ModalConstructorArguments>(modal: T, constructorArguments: ModalConstructorArguments[T], options?: ModalOptions) : InternalModalController {
return new InternalModalController(findRegisteredModal(modal), constructorArguments);
}

View File

@ -50,8 +50,13 @@ html:root {
max-width: 100%; /* important for the repetitive channel name! */
overflow-x: hidden;
&.align-left {
justify-content: flex-start;
}
&.align-right {
justify-content: right;
justify-content: flex-end;
padding-right: .25em;
}
&.align-center, &.align-repetitive {

View File

@ -247,7 +247,7 @@ class ChannelTreeController {
this.channelTree.client.groups.events.on("notify_groups_received", this.groupsReceivedListener);
this.initializeServerEvents(this.channelTree.server);
this.channelTree.events.register_handler(this);
this.channelTree.events.registerHandler(this);
if(this.channelTree.channelsInitialized) {
this.handleChannelListReceived();
@ -261,7 +261,7 @@ class ChannelTreeController {
this.channelTree.client.groups.events.off("notify_groups_received", this.groupsReceivedListener);
this.finalizeEvents(this.channelTree.server);
this.channelTree.events.unregister_handler(this);
this.channelTree.events.unregisterHandler(this);
Object.values(this.eventListeners).forEach(callbacks => callbacks.forEach(callback => callback()));
this.eventListeners = {};
}

View File

@ -338,7 +338,7 @@ export class RDPChannelTree {
}
initialize() {
this.events.register_handler(this);
this.events.registerHandler(this);
const events = this.registeredEventHandlers;
@ -466,7 +466,7 @@ export class RDPChannelTree {
document.removeEventListener("focusout", this.documentDragStopListener);
document.removeEventListener("mouseout", this.documentDragStopListener);
this.events.unregister_handler(this);
this.events.unregisterHandler(this);
this.registeredEventHandlers.forEach(callback => callback());
this.registeredEventHandlers = [];
}

View File

@ -1,14 +1,14 @@
import {Registry} from "tc-shared/events";
import {ChannelTreeUIEvents} from "tc-shared/ui/tree/Definitions";
import {spawnExternalModal} from "tc-shared/ui/react-elements/external-modal";
import {initializeChannelTreeController} from "tc-shared/ui/tree/Controller";
import {ControlBarEvents} from "tc-shared/ui/frames/control-bar/Definitions";
import {initializePopoutControlBarController} from "tc-shared/ui/frames/control-bar/Controller";
import {ChannelTree} from "tc-shared/tree/ChannelTree";
import {ModalController} from "tc-shared/ui/react-elements/ModalDefinitions";
import {ChannelTreePopoutEvents} from "tc-shared/ui/tree/popout/Definitions";
import {ChannelTreePopoutConstructorArguments, ChannelTreePopoutEvents} from "tc-shared/ui/tree/popout/Definitions";
import {ConnectionState} from "tc-shared/ConnectionHandler";
import {tr, tra} from "tc-shared/i18n/localize";
import {spawnModal} from "tc-shared/ui/react-elements/modal";
export class ChannelTreePopoutController {
readonly channelTree: ChannelTree;
@ -58,11 +58,15 @@ export class ChannelTreePopoutController {
this.controlBarEvents = new Registry<ControlBarEvents>();
initializePopoutControlBarController(this.controlBarEvents, this.channelTree.client);
this.popoutInstance = spawnExternalModal("channel-tree", {
tree: this.treeEvents,
controlBar: this.controlBarEvents,
base: this.uiEvents
}, { handlerId: this.channelTree.client.handlerId }, "channel-tree-" + this.channelTree.client.handlerId);
this.popoutInstance = spawnModal("channel-tree", [{
events: this.uiEvents.generateIpcDescription(),
eventsTree: this.treeEvents.generateIpcDescription(),
eventsControlBar: this.controlBarEvents.generateIpcDescription(),
handlerId: this.channelTree.client.handlerId
} as ChannelTreePopoutConstructorArguments], {
uniqueId: "channel-tree-" + this.channelTree.client.handlerId,
popedOut: true
});
this.popoutInstance.getEvents().one("destroy", () => {
this.treeEvents.fire("notify_destroy");

View File

@ -1,4 +1,15 @@
import {IpcRegistryDescription} from "tc-shared/events";
import {ChannelTreeUIEvents} from "tc-shared/ui/tree/Definitions";
import {ControlBarEvents} from "tc-shared/ui/frames/control-bar/Definitions";
export interface ChannelTreePopoutEvents {
query_title: {},
notify_title: { title: string }
}
}
export type ChannelTreePopoutConstructorArguments = {
events: IpcRegistryDescription<ChannelTreePopoutEvents>,
eventsTree: IpcRegistryDescription<ChannelTreeUIEvents>,
eventsControlBar: IpcRegistryDescription<ControlBarEvents>,
handlerId: string
};

View File

@ -1,12 +1,12 @@
import {AbstractModal} from "tc-shared/ui/react-elements/ModalDefinitions";
import {Registry, RegistryMap} from "tc-shared/events";
import {Registry} from "tc-shared/events";
import {ChannelTreeUIEvents} from "tc-shared/ui/tree/Definitions";
import * as React from "react";
import {useState} from "react";
import {ChannelTreeRenderer} from "tc-shared/ui/tree/Renderer";
import {ControlBarEvents} from "tc-shared/ui/frames/control-bar/Definitions";
import {ControlBar2} from "tc-shared/ui/frames/control-bar/Renderer";
import {ChannelTreePopoutEvents} from "tc-shared/ui/tree/popout/Definitions";
import {ChannelTreePopoutConstructorArguments, ChannelTreePopoutEvents} from "tc-shared/ui/tree/popout/Definitions";
const TitleRenderer = (props: { events: Registry<ChannelTreePopoutEvents> }) => {
const [ title, setTitle ] = useState<string>(() => {
@ -26,13 +26,13 @@ class ChannelTreeModal extends AbstractModal {
readonly handlerId: string;
constructor(registryMap: RegistryMap, userData: any) {
constructor(info: ChannelTreePopoutConstructorArguments) {
super();
this.handlerId = userData.handlerId;
this.eventsUI = registryMap["base"] as any;
this.eventsTree = registryMap["tree"] as any;
this.eventsControlBar = registryMap["controlBar"] as any;
this.handlerId = info.handlerId;
this.eventsUI = Registry.fromIpcDescription(info.events);
this.eventsTree = Registry.fromIpcDescription(info.eventsTree);
this.eventsControlBar = Registry.fromIpcDescription(info.eventsControlBar);
this.eventsUI.fire("query_title");
}
@ -52,7 +52,7 @@ class ChannelTreeModal extends AbstractModal {
)
}
title(): React.ReactElement {
renderTitle(): React.ReactElement {
return <TitleRenderer events={this.eventsUI} />;
}
}

23
shared/js/ui/utils.ts Normal file
View File

@ -0,0 +1,23 @@
import * as loader from "tc-loader";
const getUrlParameter = key => {
const match = location.search.match(new RegExp("(.*[?&]|^)" + key + "=([^&]+)($|&.*)"));
if(!match) {
return undefined;
}
return match[2];
};
/**
* Ensure that the module has been loaded within the main application and not
* within a popout.
*/
export function assertMainApplication() {
/* TODO: get this directly from the loader itself */
if((getUrlParameter("loader-target") || "app") !== "app") {
debugger;
loader.critical_error("Invalid module context", "Module only available in the main app context");
throw "invalid module context";
}
}

View File

@ -0,0 +1,173 @@
import {UiVariableConsumer, UiVariableMap, UiVariableProvider} from "tc-shared/ui/utils/Variable";
import {guid} from "tc-shared/crypto/uid";
import {LogCategory, logWarn} from "tc-shared/log";
class IpcUiVariableProvider<Variables extends UiVariableMap> extends UiVariableProvider<Variables> {
readonly ipcChannelId: string;
private broadcastChannel: BroadcastChannel;
constructor() {
super();
this.ipcChannelId = "teaspeak-ipc-vars-" + guid();
this.broadcastChannel = new BroadcastChannel(this.ipcChannelId);
this.broadcastChannel.onmessage = event => this.handleIpcMessage(event.data, event.source, event.origin);
}
destroy() {
super.destroy();
if(this.broadcastChannel) {
this.broadcastChannel.onmessage = undefined;
this.broadcastChannel.onmessageerror = undefined;
this.broadcastChannel.close();
}
this.broadcastChannel = undefined;
}
protected doSendVariable(variable: string, customData: any, value: any) {
this.broadcastChannel.postMessage({
type: "notify",
variable,
customData,
value
});
}
private handleIpcMessage(message: any, source: MessageEventSource | null, origin: string) {
if(message.type === "edit") {
const token = message.token;
const sendResult = (error?: any) => {
if(source) {
// @ts-ignore
source.postMessage({
type: "edit-result",
token,
error
});
} else {
this.broadcastChannel.postMessage({
type: "edit-result",
token,
error
});
}
}
try {
const result = this.doEditVariable(message.variable, message.customData, message.newValue);
if(result instanceof Promise) {
result.then(sendResult)
.catch(error => {
logWarn(LogCategory.GENERAL, tr("Failed to edit variable %s: %o"), message.variable, error);
sendResult(tr("invoke error"));
});
} else {
sendResult();
}
} catch (error) {
logWarn(LogCategory.GENERAL, tr("Failed to edit variable %s: %o"), message.variable, error);
sendResult(tr("invoke error"));
}
} else if(message.type === "query") {
this.sendVariable(message.variable, message.customData, true);
}
}
generateConsumerDescription() : IpcVariableDescriptor<Variables> {
return {
ipcChannelId: this.ipcChannelId
};
}
}
export type IpcVariableDescriptor<Variables extends UiVariableMap> = {
readonly ipcChannelId: string
}
let editTokenIndex = 0;
class IpcUiVariableConsumer<Variables extends UiVariableMap> extends UiVariableConsumer<Variables> {
readonly description: IpcVariableDescriptor<Variables>;
private broadcastChannel: BroadcastChannel;
private editListener: {[key: string]: { resolve: () => void, reject: (error) => void }};
constructor(description: IpcVariableDescriptor<Variables>) {
super();
this.description = description;
this.editListener = {};
this.broadcastChannel = new BroadcastChannel(this.description.ipcChannelId);
this.broadcastChannel.onmessage = event => this.handleIpcMessage(event.data, event.source);
}
destroy() {
super.destroy();
if(this.broadcastChannel) {
this.broadcastChannel.onmessage = undefined;
this.broadcastChannel.onmessageerror = undefined;
this.broadcastChannel.close();
}
this.broadcastChannel = undefined;
Object.values(this.editListener).forEach(listener => listener.reject(tr("consumer destroyed")));
this.editListener = {};
}
protected doEditVariable(variable: string, customData: any, newValue: any): Promise<void> | void {
const token = "t" + ++editTokenIndex;
return new Promise((resolve, reject) => {
this.broadcastChannel.postMessage({
type: "edit",
token,
variable,
customData,
newValue
});
this.editListener[token] = {
reject,
resolve
}
});
}
protected doRequestVariable(variable: string, customData: any) {
this.broadcastChannel.postMessage({
type: "query",
variable,
customData,
});
}
private handleIpcMessage(message: any, _source: MessageEventSource | null) {
if(message.type === "notify") {
console.error("Received notify %s", message.variable);
this.notifyRemoteVariable(message.variable, message.customData, message.value);
} else if(message.type === "edit-result") {
const payload = this.editListener[message.token];
if(!payload) {
return;
}
delete this.editListener[message.token];
if(typeof message.error !== "undefined") {
payload.reject(message.error);
} else {
payload.resolve();
}
}
}
}
export function createIpcUiVariableProvider<Variables extends UiVariableMap>() : IpcUiVariableProvider<Variables> {
return new IpcUiVariableProvider();
}
export function createIpcUiVariableConsumer<Variables extends UiVariableMap>(description: IpcVariableDescriptor<Variables>) : IpcUiVariableConsumer<Variables> {
return new IpcUiVariableConsumer<Variables>(description);
}

View File

@ -0,0 +1,60 @@
import {UiVariableConsumer, UiVariableMap, UiVariableProvider} from "tc-shared/ui/utils/Variable";
class LocalUiVariableProvider<Variables extends UiVariableMap> extends UiVariableProvider<Variables> {
private consumer: LocalUiVariableConsumer<Variables>;
constructor() {
super();
}
destroy() {
super.destroy();
this.consumer = undefined;
}
setConsumer(consumer: LocalUiVariableConsumer<Variables>) {
this.consumer = consumer;
}
protected doSendVariable(variable: string, customData: any, value: any) {
this.consumer.notifyRemoteVariable(variable, customData, value);
}
public doEditVariable(variable: string, customData: any, newValue: any): Promise<void> | void {
return super.doEditVariable(variable, customData, newValue);
}
}
class LocalUiVariableConsumer<Variables extends UiVariableMap> extends UiVariableConsumer<Variables> {
private provider: LocalUiVariableProvider<Variables>;
constructor(provider: LocalUiVariableProvider<Variables>) {
super();
this.provider = provider;
}
destroy() {
super.destroy();
this.provider = undefined;
}
protected doEditVariable(variable: string, customData: any, value: any): Promise<void> | void {
return this.provider.doEditVariable(variable, customData, value);
}
protected doRequestVariable(variable: string, customData: any) {
return this.provider.sendVariable(variable, customData);
}
public notifyRemoteVariable(variable: string, customData: any, value: any) {
super.notifyRemoteVariable(variable, customData, value);
}
}
export function createLocalUiVariables<Variables extends UiVariableMap>() : [UiVariableProvider<Variables>, UiVariableConsumer<Variables>] {
const provider = new LocalUiVariableProvider();
const consumer = new LocalUiVariableConsumer(provider);
provider.setConsumer(consumer);
return [provider as any, consumer as any];
}

View File

@ -0,0 +1,403 @@
import {useEffect, useState} from "react";
import * as _ from "lodash";
/*
* To deliver optimized performance, we only promisify the values we need.
* If done so, we have the change to instantaneously load local values without needing
* to rerender the ui.
*/
export type UiVariable = Transferable | undefined | null | number | string | object;
export type UiVariableMap = { [key: string]: any }; //UiVariable | Readonly<UiVariable>
type IfEquals<X, Y, A=X, B=never> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2) ? A : B;
type WritableKeys<T> = {
[P in keyof T]-?: IfEquals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, P, never>
}[keyof T];
type ReadonlyKeys<T> = {
[P in keyof T]: IfEquals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, never, P>
}[keyof T];
export type ReadonlyVariables<Variables extends UiVariableMap> = Pick<Variables, ReadonlyKeys<Variables>>
export type WriteableVariables<Variables extends UiVariableMap> = Pick<Variables, WritableKeys<Variables>>
type UiVariableEditor<Variables extends UiVariableMap, T extends keyof Variables> = Variables[T] extends { __readonly } ?
never :
(newValue: Variables[T], customData: any) => Variables[T] | void | boolean;
type UiVariableEditorPromise<Variables extends UiVariableMap, T extends keyof Variables> = Variables[T] extends { __readonly } ?
never :
(newValue: Variables[T], customData: any) => Promise<Variables[T] | void | boolean>;
export abstract class UiVariableProvider<Variables extends UiVariableMap> {
private variableProvider: {[key: string]: (customData: any) => any | Promise<any>} = {};
private variableEditor: {[key: string]: (newValue, customData: any) => any | Promise<any>} = {};
protected constructor() { }
destroy() { }
setVariableProvider<T extends keyof Variables>(variable: T, provider: (customData: any) => Variables[T] | Promise<Variables[T]>) {
this.variableProvider[variable as any] = provider;
}
setVariableEditor<T extends keyof Variables>(variable: T, editor: UiVariableEditor<Variables, T>) {
this.variableEditor[variable as any] = editor;
}
setVariableEditorAsync<T extends keyof Variables>(variable: T, editor: UiVariableEditorPromise<Variables, T>) {
this.variableEditor[variable as any] = editor;
}
/**
* Send/update a variable
* @param variable The target variable to send.
* @param customData
* @param forceSend If `true` the variable will be send event though it hasn't changed.
*/
sendVariable<T extends keyof Variables>(variable: T, customData?: any, forceSend?: boolean) : void | Promise<void> {
const providers = this.variableProvider[variable as any];
if(!providers) {
throw tra("missing provider for {}", variable);
}
const result = providers(customData);
if(result instanceof Promise) {
return result
.then(result => this.doSendVariable(variable as any, customData, result))
.catch(error => {
console.error(error);
});
} else {
this.doSendVariable(variable as any, customData, result);
}
}
async getVariable<T extends keyof Variables>(variable: T, customData?: any, ignoreCache?: boolean) : Promise<Variables[T]> {
const providers = this.variableProvider[variable as any];
if(!providers) {
throw tra("missing provider for {}", variable);
}
const result = providers(customData);
if(result instanceof Promise) {
return await result;
} else {
return result;
}
}
getVariableSync<T extends keyof Variables>(variable: T, customData?: any, ignoreCache?: boolean) : Variables[T] {
const providers = this.variableProvider[variable as any];
if(!providers) {
throw tr("missing provider");
}
const result = providers(customData);
if(result instanceof Promise) {
throw tr("tried to get an async variable synchronous");
}
return result;
}
protected resolveVariable(variable: string, customData: any): Promise<any> | any {
const providers = this.variableProvider[variable];
if(!providers) {
throw tr("missing provider");
}
return providers(customData);
}
protected doEditVariable(variable: string, customData: any, newValue: any) : Promise<void> | void {
const editor = this.variableEditor[variable];
if(!editor) {
throw tr("variable is read only");
}
const handleEditResult = result => {
if(typeof result === "undefined") {
/* change succeeded, no need to notify any variable since the consumer already has the newest value */
} else if(result === true || result === false) {
/* The new variable has been accepted/rejected and the variable should be updated on the remote side. */
/* TODO: Use cached value if the result is `false` */
this.sendVariable(variable, customData, true);
} else {
/* The new value hasn't been accepted. Instead a new value has been returned. */
this.doSendVariable(variable, customData, result);
}
}
const handleEditError = error => {
console.error("Failed to change variable %s: %o", variable, error);
this.sendVariable(variable, customData, true);
}
try {
let result = editor(newValue, customData);
if(result instanceof Promise) {
return result.then(handleEditResult).catch(handleEditError);
} else {
handleEditResult(result);
}
} catch (error) {
handleEditError(error);
}
}
protected abstract doSendVariable(variable: string, customData: any, value: any);
}
export type UiVariableStatus<Variables extends UiVariableMap, T extends keyof Variables> = {
status: "loading",
localValue: Variables[T] | undefined,
remoteValue: undefined,
/* Will do nothing */
setValue: (newValue: Variables[T], localOnly?: boolean) => void
} | {
status: "loaded" | "applying",
localValue: Variables[T],
remoteValue: Variables[T],
setValue: (newValue: Variables[T], localOnly?: boolean) => void
};
export type UiReadOnlyVariableStatus<Variables extends UiVariableMap, T extends keyof Variables> = {
status: "loading" | "loaded",
value: Variables[T],
};
type UiVariableCacheEntry = {
key: string,
useCount: number,
customData: any | undefined,
currentValue: any | undefined,
status: "loading" | "loaded" | "applying",
updateListener: ((clearLocalValue: boolean) => void)[]
}
type LocalVariableValue = {
status: "set" | "default",
value: any
} | {
status: "unset"
}
let staticRevisionId = 0;
export abstract class UiVariableConsumer<Variables extends UiVariableMap> {
private variableCache: {[key: string]: UiVariableCacheEntry[]} = {};
destroy() {
this.variableCache = {};
}
private getOrCreateVariable<T extends keyof Variables>(
variable: string,
customData?: any
) : UiVariableCacheEntry {
let cacheEntry = this.variableCache[variable]?.find(variable => _.isEqual(variable.customData, customData));
if(!cacheEntry) {
this.variableCache[variable] = this.variableCache[variable] || [];
this.variableCache[variable].push(cacheEntry = {
key: variable,
customData,
currentValue: undefined,
status: "loading",
useCount: 0,
updateListener: []
});
/* Might already call notifyRemoteVariable */
this.doRequestVariable(variable, customData);
}
return cacheEntry;
}
private derefVariable(variable: UiVariableCacheEntry) {
if(--variable.useCount === 0) {
const cache = this.variableCache[variable.key];
if(!cache) {
return;
}
cache.remove(variable);
if(cache.length === 0) {
delete this.variableCache[variable.key];
}
}
}
useVariable<T extends keyof WriteableVariables<Variables>>(
variable: T,
customData?: any,
defaultValue?: Variables[T]
) : UiVariableStatus<Variables, T> {
const haveDefaultValue = arguments.length >= 3;
const cacheEntry = this.getOrCreateVariable(variable as string, customData);
const [ localValue, setLocalValue ] = useState<LocalVariableValue>(() => {
/* Variable constructor */
cacheEntry.useCount++;
if(cacheEntry.status === "loading") {
return {
status: "set",
value: cacheEntry.currentValue
};
} else if(haveDefaultValue) {
return {
status: "default",
value: defaultValue
};
} else {
return {
status: "unset"
};
}
});
const [, setRemoteVersion ] = useState(0);
useEffect(() => {
/* Initial rendered */
if(cacheEntry.status === "loaded" && localValue.status !== "set") {
/* Update the local value to the current state */
setLocalValue(cacheEntry.currentValue);
}
let listener;
cacheEntry.updateListener.push(listener = clearLocalValue => {
if(clearLocalValue) {
setLocalValue({ status: "unset" });
}
/* We can't just increment the old one by one since this update listener may fires twice before rendering */
setRemoteVersion(++staticRevisionId);
});
return () => {
cacheEntry.updateListener.remove(listener);
this.derefVariable(cacheEntry);
};
}, []);
if(cacheEntry.status === "loading") {
return {
status: "loading",
localValue: localValue.status === "unset" ? undefined : localValue.value,
remoteValue: undefined,
setValue: () => {}
};
} else {
return {
status: cacheEntry.status,
localValue: localValue.status === "set" ? localValue.value : cacheEntry.currentValue,
remoteValue: cacheEntry.currentValue,
setValue: (newValue, localOnly) => {
if(!localOnly && !_.isEqual(cacheEntry.currentValue, newValue)) {
const editingFinished = (succeeded: boolean) => {
if(cacheEntry.status !== "applying") {
/* A new value has already been emitted */
return;
}
cacheEntry.status = "loaded";
cacheEntry.currentValue = succeeded ? newValue : cacheEntry.currentValue;
cacheEntry.updateListener.forEach(callback => callback(true));
};
cacheEntry.status = "applying";
const result = this.doEditVariable(variable as string, customData, newValue);
if(result instanceof Promise) {
result
.then(() => editingFinished(true))
.catch(async error => {
console.error("Failed to change variable %s: %o", variable, error);
editingFinished(false);
});
/* State has changed, enforce a rerender */
cacheEntry.updateListener.forEach(callback => callback(false));
} else {
editingFinished(true);
return;
}
}
if(localValue.status !== "set" || !_.isEqual(newValue, localValue.value)) {
setLocalValue({
status: "set",
value: newValue
});
}
}
};
}
}
useReadOnly<T extends keyof Variables>(
variable: T,
customData?: any,
defaultValue?: never
) : UiReadOnlyVariableStatus<Variables, T>;
useReadOnly<T extends keyof Variables>(
variable: T,
customData: any | undefined,
defaultValue: Variables[T]
) : Variables[T];
useReadOnly(variable, customData?, defaultValue?) {
const cacheEntry = this.getOrCreateVariable(variable as string, customData);
const [, setRemoteVersion ] = useState(0);
useEffect(() => {
/* Initial rendered */
cacheEntry.useCount++;
let listener;
cacheEntry.updateListener.push(listener = () => {
/* We can't just increment the old one by one since this update listener may fires twice before rendering */
setRemoteVersion(++staticRevisionId);
});
return () => {
cacheEntry.updateListener.remove(listener);
this.derefVariable(cacheEntry);
};
}, []);
if(arguments.length >= 3) {
return cacheEntry.status === "loaded" ? cacheEntry.currentValue : defaultValue;
} else {
return {
status: cacheEntry.status,
value: cacheEntry.currentValue
};
}
}
protected notifyRemoteVariable(variable: string, customData: any | undefined, value: any) {
let cacheEntry = this.variableCache[variable]?.find(variable => _.isEqual(variable.customData, customData));
if(!cacheEntry) {
return;
}
cacheEntry.status = "loaded";
cacheEntry.currentValue = value;
cacheEntry.updateListener.forEach(callback => callback(true));
}
protected abstract doRequestVariable(variable: string, customData: any | undefined);
protected abstract doEditVariable(variable: string, customData: any | undefined, value: any) : Promise<void> | void;
}

View File

@ -1,6 +1,4 @@
import * as log from "../log";
import {LogCategory, logError, logWarn} from "../log";
import {spawnExternalModal} from "../ui/react-elements/external-modal";
import {EventHandler, Registry} from "../events";
import {VideoViewerEvents} from "./Definitions";
import {ConnectionHandler} from "../ConnectionHandler";
@ -11,6 +9,7 @@ import {createErrorModal} from "../ui/elements/Modal";
import {ModalController} from "../ui/react-elements/ModalDefinitions";
import {server_connections} from "tc-shared/ConnectionManager";
import { tr, tra } from "tc-shared/i18n/localize";
import {spawnModal} from "tc-shared/ui/react-elements/modal";
const parseWatcherId = (id: string): { clientId: number, clientUniqueId: string} => {
const [ clientIdString, clientUniqueId ] = id.split(" - ");
@ -34,14 +33,16 @@ class VideoViewer {
this.connection = connection;
this.events = new Registry<VideoViewerEvents>();
this.events.register_handler(this);
this.events.registerHandler(this);
this.plugin = connection.getPluginCmdRegistry().getPluginHandler<W2GPluginCmdHandler>(W2GPluginCmdHandler.kPluginChannel);
if(!this.plugin) {
throw tr("Missing video viewer plugin");
}
this.modal = spawnExternalModal("video-viewer", { default: this.events }, { handlerId: connection.handlerId });
this.modal = spawnModal("video-viewer", [ this.events.generateIpcDescription(), connection.handlerId ], {
popedOut: true,
});
this.registerPluginListeners();
this.plugin.getCurrentWatchers().forEach(watcher => this.registerWatcherEvents(watcher));
@ -57,7 +58,7 @@ class VideoViewer {
this.plugin.setLocalPlayerClosed();
this.events.fire("notify_destroy");
this.events.unregister_handler(this);
this.events.unregisterHandler(this);
this.modal.destroy();
this.events.destroy();

View File

@ -2,9 +2,7 @@
@import "../../css/static/properties";
$sidebar-width: 20em;
.container {
background: #19191b;
.outerContainer {
display: flex;
flex-direction: row;
justify-content: stretch;
@ -15,13 +13,29 @@ $sidebar-width: 20em;
min-height: 10em;
min-width: 20em;
position: absolute;
/* We're using the full with by default */
width: 100vw;
height: 100vh;
max-height: 100%;
max-width: 100%;
position: relative;
}
.container {
background: #19191b;
display: flex;
flex-direction: row;
justify-content: stretch;
top: 0;
left: 0;
right: 0;
bottom: 0;
position: absolute;
overflow: hidden;
}

View File

@ -2,14 +2,12 @@ import {LogCategory, logDebug, logTrace} from "tc-shared/log";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import * as React from "react";
import {useEffect, useRef, useState} from "react";
import {Registry, RegistryMap} from "tc-shared/events";
import {IpcRegistryDescription, Registry} from "tc-shared/events";
import {PlayerStatus, VideoViewerEvents} from "./Definitions";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
import ReactPlayer from 'react-player'
import {HTMLRenderer} from "tc-shared/ui/react-elements/HTMLRenderer";
import {Button} from "tc-shared/ui/react-elements/Button";
import "tc-shared/file/RemoteAvatars";
import {AvatarRenderer} from "tc-shared/ui/react-elements/Avatar";
import {getGlobalAvatarManagerFactory} from "tc-shared/file/Avatars";
import {Settings, settings} from "tc-shared/settings";
@ -491,25 +489,29 @@ class ModalVideoPopout extends AbstractModal {
readonly events: Registry<VideoViewerEvents>;
readonly handlerId: string;
constructor(registryMap: RegistryMap, userData: any) {
constructor(events: IpcRegistryDescription<VideoViewerEvents>, handlerId: any) {
super();
this.handlerId = userData.handlerId;
this.events = registryMap["default"] as any;
this.events = Registry.fromIpcDescription(events);
this.handlerId = handlerId;
}
title(): string | React.ReactElement<Translatable> {
renderTitle(): string | React.ReactElement<Translatable> {
return <TitleRenderer events={this.events} />;
}
renderBody(): React.ReactElement {
return <div className={cssStyle.container} >
<Sidebar events={this.events} handlerId={this.handlerId} />
<ToggleSidebarButton events={this.events} />
<div className={cssStyle.containerPlayer}>
<PlayerController events={this.events} />
return (
<div className={cssStyle.outerContainer}>
<div className={cssStyle.container} >
<Sidebar events={this.events} handlerId={this.handlerId} />
<ToggleSidebarButton events={this.events} />
<div className={cssStyle.containerPlayer}>
<PlayerController events={this.events} />
</div>
</div>
</div>
</div>;
);
}
}

View File

@ -423,11 +423,11 @@ export class W2GPluginCmdHandler extends PluginCmdHandler {
private handleLocalWatcherEvent(event: Event<W2GWatcherEvents, "notify_watcher_url_changed" | "notify_watcher_status_changed" | "notify_destroyed">) {
switch (event.type) {
case "notify_watcher_url_changed":
this.events.fire("notify_following_url", { newUrl: event.as<"notify_watcher_url_changed">().newVideo });
this.events.fire("notify_following_url", { newUrl: event.asUnchecked("notify_watcher_url_changed").newVideo });
break;
case "notify_watcher_status_changed":
this.events.fire("notify_following_watcher_status", { newStatus: event.as<"notify_watcher_status_changed">().newStatus });
this.events.fire("notify_following_watcher_status", { newStatus: event.asUnchecked("notify_watcher_status_changed").newStatus });
break;
case "notify_destroyed":

View File

@ -13,8 +13,8 @@ export interface VideoSourceCapabilities {
}
export interface VideoSourceInitialSettings {
width: number,
height: number,
width: number | 0,
height: number | 0,
frameRate: number
}

View File

@ -4,23 +4,24 @@ import * as ipc from "tc-shared/ipc/BrowserIPC";
import {ChannelMessage} from "tc-shared/ipc/BrowserIPC";
import {LogCategory, logDebug, logWarn} from "tc-shared/log";
import {Popout2ControllerMessages, PopoutIPCMessage} from "tc-shared/ui/react-elements/external-modal/IPCMessage";
import {RegistryMap} from "tc-shared/events";
import {tr, tra} from "tc-shared/i18n/localize";
import {ModalOptions} from "tc-shared/ui/react-elements/modal/Definitions";
export class ExternalModalController extends AbstractExternalModalController {
private readonly uniqueModalId: string;
private readonly options: ModalOptions;
private currentWindow: Window;
private windowClosedTestInterval: number = 0;
private windowClosedTimeout: number;
constructor(modal: string, registries: RegistryMap, userData: any, uniqueModalId: string) {
super(modal, registries, userData);
this.uniqueModalId = uniqueModalId || modal;
constructor(modalType: string, constructorArguments: any[] | undefined, options: ModalOptions | undefined) {
super(modalType, constructorArguments);
this.options = options || {};
}
protected async spawnWindow() : Promise<boolean> {
if(this.currentWindow)
if(this.currentWindow) {
return true;
}
this.currentWindow = this.trySpawnWindow0();
if(!this.currentWindow) {
@ -106,7 +107,7 @@ export class ExternalModalController extends AbstractExternalModalController {
let baseUrl = location.origin + location.pathname + "?";
return window.open(
baseUrl + Object.keys(parameters).map(e => e + "=" + encodeURIComponent(parameters[e])).join("&"),
this.uniqueModalId,
this.options?.uniqueId || this.modalType,
Object.keys(features).map(e => e + "=" + features[e]).join(",")
);
}

View File

@ -8,7 +8,7 @@ import {RecorderProfile} from "tc-shared/voice/RecorderProfile";
import {VoiceClient} from "tc-shared/voice/VoiceClient";
import {WhisperSession, WhisperTarget} from "tc-shared/voice/VoiceWhisper";
import {AbstractServerConnection, ConnectionStatistics} from "tc-shared/connection/ConnectionBase";
import {Registry} from "tc-shared/events";
import {EventDispatchType, Registry} from "tc-shared/events";
import {VoicePlayerEvents, VoicePlayerLatencySettings, VoicePlayerState} from "tc-shared/voice/VoicePlayer";
import { tr } from "tc-shared/i18n/localize";
import {RtpVoiceConnection} from "tc-backend/web/voice/Connection";
@ -21,6 +21,7 @@ class ProxiedVoiceClient implements VoiceClient {
private volume: number;
private latencySettings: VoicePlayerLatencySettings | undefined;
private eventDisconnect: () => void;
constructor(clientId: number) {
this.clientId = clientId;
@ -30,14 +31,36 @@ class ProxiedVoiceClient implements VoiceClient {
}
setHandle(handle: VoiceClient | undefined) {
this.handle?.events.disconnectAll(this.events);
if(this.eventDisconnect) {
this.eventDisconnect();
this.eventDisconnect = undefined;
}
this.handle = handle;
if(this.latencySettings) {
this.handle?.setLatencySettings(this.latencySettings);
}
this.handle?.setVolume(this.volume);
this.handle?.events.connectAll(this.events);
if(this.handle) {
const targetEvents = this.events;
this.eventDisconnect = this.handle.events.registerConsumer({
handleEvent(mode: EventDispatchType, type: string, data: any) {
switch (mode) {
case "later":
targetEvents.fire_later(type as any, data);
break;
case "react":
targetEvents.fire_react(type as any, data);
break;
case "sync":
targetEvents.fire(type as any, data);
break;
}
}
});
}
}
abortReplay() {
@ -86,6 +109,7 @@ export class LegacySupportVoiceBridge extends AbstractVoiceConnection {
private readonly oldVoiceBridge: VoiceConnection;
private activeBridge: AbstractVoiceConnection;
private disconnectEvents: () => void;
private encoderCodec: number;
private currentRecorder: RecorderProfile;
@ -108,11 +132,31 @@ export class LegacySupportVoiceBridge extends AbstractVoiceConnection {
e.setHandle(undefined);
}
});
this.activeBridge?.events.disconnectAll(this.events);
if(this.disconnectEvents) {
this.disconnectEvents();
this.disconnectEvents = undefined;
}
this.activeBridge = type === "old" ? this.oldVoiceBridge : type === "new" ? this.newVoiceBride : undefined;
if(this.activeBridge) {
this.activeBridge.events.connectAll(this.events);
const targetEvents = this.events;
this.disconnectEvents = this.activeBridge.events.registerConsumer({
handleEvent(mode: EventDispatchType, type: string, data: any) {
switch (mode) {
case "later":
targetEvents.fire_later(type as any, data);
break;
case "react":
targetEvents.fire_react(type as any, data);
break;
case "sync":
targetEvents.fire(type as any, data);
break;
}
}
});
this.registeredClients.forEach(e => {
if(!e.handle) {

View File

@ -7,6 +7,6 @@ loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
priority: 50,
name: "external modal controller factory setup",
function: async () => {
setExternalModalControllerFactory((modal, events, userData, uniqueModalId) => new ExternalModalController(modal, events, userData, uniqueModalId));
setExternalModalControllerFactory((modalType, constructorArguments, options) => new ExternalModalController(modalType, constructorArguments, options));
}
});

View File

@ -23,7 +23,7 @@ export class AudioLibrary {
private static spawnNewWorker() : Worker {
/*
* Attention don't use () => new Worker(...).
* Attention don't use () => new Worker(...).
* This confuses the worker plugin and will not emit any modules
*/
return new Worker("./worker/index.ts", { type: "module" });

View File

@ -23,6 +23,7 @@ export class VoicePlayer implements VoicePlayer {
private currentRtpTrack: RemoteRTPAudioTrack;
constructor() {
this.volume = 1;
this.listenerTrackStateChanged = event => this.handleTrackStateChanged(event.newState);
this.events = new Registry<VoicePlayerEvents>();
this.currentState = VoicePlayerState.STOPPED;