diff --git a/shared/js/events.ts b/shared/js/events.ts index b9d6dbe8..722d58c0 100644 --- a/shared/js/events.ts +++ b/shared/js/events.ts @@ -22,7 +22,7 @@ export class SingletonEvent implements Event() : SingletonEvents[T] { return; } } -export interface EventReceiver { +export interface EventSender { fire(event_type: T, data?: Events[T], overrideTypeKey?: boolean); /** @@ -44,11 +44,11 @@ export interface EventReceiver implements EventReceiver { +export class Registry implements EventSender { private readonly registryUuid; private handler: {[key: string]: ((event) => void)[]} = {}; - private connections: {[key: string]: EventReceiver[]} = {}; + private connections: {[key: string]: EventSender[]} = {}; private eventHandlerObjects: { object: any, handlers: {[key: string]: ((event) => void)[]} @@ -66,12 +66,11 @@ export class Registry(event: T, handler: (event?: Events[T] & Event) => void) : () => void; on(events: (keyof Events)[], handler: (event?: Event) => void) : () => void; @@ -158,21 +157,21 @@ export class Registry(target: EventReceiver) { + connectAll(target: EventSender) { (this.connections[null as any] || (this.connections[null as any] = [])).push(target as any); } - connect(events: T | T[], target: EventReceiver) { + connect(events: T | T[], target: EventSender) { for(const event of Array.isArray(events) ? events : [events]) (this.connections[event as string] || (this.connections[event as string] = [])).push(target as any); } - disconnect(events: T | T[], target: EventReceiver) { + disconnect(events: T | T[], target: EventSender) { for(const event of Array.isArray(events) ? events : [events]) (this.connections[event as string] || []).remove(target as any); } - disconnectAll(target: EventReceiver) { + disconnectAll(target: EventSender) { this.connections[null as any]?.remove(target as any); for(const event of Object.keys(this.connections)) this.connections[event].remove(target as any); @@ -266,7 +265,7 @@ export class Registry { /* batch all react updates */ unstable_batchedUpdates(() => { @@ -344,8 +343,9 @@ export class Registry, Event if(!registry) throw "Event registry returned for an event object is invalid"; registry.register_handler(this); - if(typeof didMount === "function") + if(typeof didMount === "function") { didMount.call(this, arguments); + } }; const willUnmount = constructor.prototype.componentWillUnmount; @@ -390,165 +391,14 @@ export function ReactEventHandler, Event console.warn("Failed to unregister event handler: %o", error); } - if(typeof willUnmount === "function") + if(typeof willUnmount === "function") { willUnmount.call(this, arguments); + } }; } } export namespace modal { - export type BotStatusType = "name" | "description" | "volume" | "country_code" | "channel_commander" | "priority_speaker"; - export type PlaylistStatusType = "replay_mode" | "finished" | "delete_played" | "max_size" | "notify_song_change"; - export 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 namespace settings { export type ProfileInfo = { id: string, @@ -707,74 +557,5 @@ export namespace modal { "setup-forum-connection": {} } - - export type MicrophoneSettings = "volume" | "vad-type" | "ppt-key" | "ppt-release-delay" | "ppt-release-delay-active" | "threshold-threshold"; - export interface microphone { - "query-devices": { refresh_list: boolean }, - "query-device-result": { - status: "success" | "error" | "timeout", - - error?: string, - devices?: { - id: string, - name: string, - driver: string - }[] - active_device?: string; - }, - - "query-settings": {}, - "query-settings-result": { - status: "success" | "error" | "timeout", - - error?: string, - info?: { - volume: number, - vad_type: string, - - vad_ppt: { - key: any, /* ppt.KeyDescriptor */ - release_delay: number, - release_delay_active: boolean - }, - vad_threshold: { - threshold: number - } - } - }, - - "set-device": { device_id: string }, - "set-device-result": { - device_id: string, - status: "success" | "error" | "timeout", - - error?: string - }, - - "set-setting": { - setting: MicrophoneSettings; - value: any; - }, - "set-setting-result": { - setting: MicrophoneSettings, - status: "success" | "error" | "timeout", - - error?: string, - value?: any - }, - - "update-device-level": { - devices: { - device_id: string, - status: "success" | "error", - - level?: number, - error?: string - }[] - }, - - "audio-initialized": {}, - "deinitialize": {} - } } } \ No newline at end of file diff --git a/shared/js/log.ts b/shared/js/log.ts index 72c1a4ed..dfc75909 100644 --- a/shared/js/log.ts +++ b/shared/js/log.ts @@ -68,7 +68,7 @@ export let enabled_mapping = new Map([ [LogCategory.VOICE, true], [LogCategory.AUDIO, true], [LogCategory.CHAT, true], - [LogCategory.I18N, true], + [LogCategory.I18N, false], [LogCategory.IDENTITIES, true], [LogCategory.IPC, true], [LogCategory.STATISTICS, true], diff --git a/shared/js/ui/frames/side/PopoutConversationRenderer.tsx b/shared/js/ui/frames/side/PopoutConversationRenderer.tsx index 79ff39d8..fe17eca0 100644 --- a/shared/js/ui/frames/side/PopoutConversationRenderer.tsx +++ b/shared/js/ui/frames/side/PopoutConversationRenderer.tsx @@ -23,7 +23,7 @@ class PopoutConversationRenderer extends AbstractModal { noFirstMessageOverlay={this.userData.noFirstMessageOverlay} />; } - title() { + renderTitle() { return "Conversations"; } } diff --git a/shared/js/ui/modal/ModalChangeVolumeNew.tsx b/shared/js/ui/modal/ModalChangeVolumeNew.tsx index 18049aed..5d97536e 100644 --- a/shared/js/ui/modal/ModalChangeVolumeNew.tsx +++ b/shared/js/ui/modal/ModalChangeVolumeNew.tsx @@ -243,7 +243,7 @@ class VolumeChange extends InternalModal { return ; } - title() { + renderTitle() { return Change local volume; } } @@ -284,7 +284,7 @@ class VolumeChangeBot extends InternalModal { return ; } - title() { + renderTitle() { return Change remote volume; } } diff --git a/shared/js/ui/modal/ModalGroupCreate.tsx b/shared/js/ui/modal/ModalGroupCreate.tsx index 8975716b..f210189c 100644 --- a/shared/js/ui/modal/ModalGroupCreate.tsx +++ b/shared/js/ui/modal/ModalGroupCreate.tsx @@ -272,7 +272,7 @@ class ModalGroupCreate extends InternalModal { ; } - title() { + renderTitle() { return this.target === "server" ? Create a new server group : Create a new channel group; } diff --git a/shared/js/ui/modal/ModalGroupPermissionCopy.tsx b/shared/js/ui/modal/ModalGroupPermissionCopy.tsx index 27f141bb..d6e07339 100644 --- a/shared/js/ui/modal/ModalGroupPermissionCopy.tsx +++ b/shared/js/ui/modal/ModalGroupPermissionCopy.tsx @@ -161,7 +161,7 @@ class ModalGroupPermissionCopy extends InternalModal { ; } - title() { + renderTitle() { return Copy group permissions; } } diff --git a/shared/js/ui/modal/channel-edit/Renderer.tsx b/shared/js/ui/modal/channel-edit/Renderer.tsx index 457d5e73..8b62a403 100644 --- a/shared/js/ui/modal/channel-edit/Renderer.tsx +++ b/shared/js/ui/modal/channel-edit/Renderer.tsx @@ -1162,7 +1162,7 @@ export class ChannelEditModal extends InternalModal { ); } - title(): string | React.ReactElement { + renderTitle(): string | React.ReactElement { if(this.isChannelCreate) { return Create channel; } else { diff --git a/shared/js/ui/modal/connect/Controller.ts b/shared/js/ui/modal/connect/Controller.ts index 1da7f9d1..ca35e5d3 100644 --- a/shared/js/ui/modal/connect/Controller.ts +++ b/shared/js/ui/modal/connect/Controller.ts @@ -1,5 +1,8 @@ import {Registry} from "tc-shared/events"; -import {ConnectProperties, ConnectUiEvents, PropertyValidState} from "tc-shared/ui/modal/connect/Definitions"; +import { + ConnectUiEvents, + ConnectUiVariables, +} 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 {LogCategory, logError, logWarn} from "tc-shared/log"; @@ -17,7 +20,8 @@ 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 {createLocalUiVariables} from "tc-shared/ui/utils/LocalVariable"; 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 +41,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; + readonly uiVariables: UiVariableProvider; private readonly defaultAddress: string; - private readonly propertyProvider: {[K in keyof ConnectProperties]?: () => Promise} = {}; private historyShown: boolean; @@ -59,32 +55,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) { this.uiEvents = new Registry(); 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 +78,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 +201,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 +284,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 +294,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 +306,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 +320,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 +344,8 @@ export type ConnectModalOptions = { } export function spawnConnectModalNew(options: ConnectModalOptions) { - const controller = new ConnectController(); + const [ variableProvider, variableConsumer ] = createLocalUiVariables(); + const controller = new ConnectController(variableProvider); if(typeof options.selectedAddress === "string") { controller.setSelectedAddress(options.selectedAddress, false, true); @@ -392,7 +355,7 @@ export function spawnConnectModalNew(options: ConnectModalOptions) { controller.setSelectedProfile(options.selectedProfile); } - const modal = spawnReactModal(ConnectModal, controller.uiEvents, options.connectInANewTab || false); + const modal = spawnReactModal(ConnectModal, controller.uiEvents, variableConsumer, options.connectInANewTab || false); modal.show(); modal.events.one("destroy", () => { diff --git a/shared/js/ui/modal/connect/Definitions.ts b/shared/js/ui/modal/connect/Definitions.ts index 31ec994d..8e23106d 100644 --- a/shared/js/ui/modal/connect/Definitions.ts +++ b/shared/js/ui/modal/connect/Definitions.ts @@ -23,81 +23,47 @@ export type ConnectHistoryServerInfo = { maxClients: number | -1 } -export interface ConnectProperties { - address: { +export interface ConnectUiVariables { + "server_address": { currentAddress: string, - defaultAddress: string, + defaultAddress?: string, }, - nickname: { + "server_address_valid": boolean, + + "nickname": { currentNickname: string | undefined, - defaultNickname: string | undefined, + defaultNickname?: string, }, - password: { + "nickname_valid": boolean, + + "password": { password: string, hashed: boolean } | undefined, - profiles: { - profiles: ConnectProfileEntry[], + + "profiles": { + profiles?: ConnectProfileEntry[], selected: string }, - historyShown: boolean, - history: { + "profile_valid": boolean, + + "historyShown": boolean, + "history": { + __readonly?, history: ConnectHistoryEntry[], selected: number | -1, }, -} -export interface PropertyValidState { - address: boolean, - nickname: boolean, - password: boolean, - profile: boolean + "history_entry": ConnectHistoryServerInfo, + "history_connections": number } -type IAccess = { - 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, - notify_property_valid: IAccess, - - 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 - } } \ No newline at end of file diff --git a/shared/js/ui/modal/connect/Renderer.tsx b/shared/js/ui/modal/connect/Renderer.tsx index 10cc03bb..460da032 100644 --- a/shared/js/ui/modal/connect/Renderer.tsx +++ b/shared/js/ui/modal/connect/Renderer.tsx @@ -1,12 +1,9 @@ import { ConnectHistoryEntry, - ConnectHistoryServerInfo, - ConnectProperties, - ConnectUiEvents, - PropertyValidState + ConnectUiEvents, ConnectUiVariables, } from "tc-shared/ui/modal/connect/Definitions"; import * as React from "react"; -import {useContext, useState} from "react"; +import {useContext} 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"; @@ -19,123 +16,100 @@ 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"; const EventContext = React.createContext>(undefined); +const VariablesContext = React.createContext>(undefined); + const ConnectDefaultNewTabContext = React.createContext(false); const cssStyle = require("./Renderer.scss"); -function useProperty(key: T, defaultValue: V) : [ConnectProperties[T] | V, (value: ConnectProperties[T]) => void] { +const InputServerAddress = React.memo(() => { const events = useContext(EventContext); - const [ value, setValue ] = useState(() => { - 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(key: T, defaultValue: PropertyValidState[T]) : PropertyValidState[T] { - const events = useContext(EventContext); - const [ value, setValue ] = useState(() => { - 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.useVariable("server_address_valid"); + return ( Server address} labelType={"static"} - invalid={valid ? undefined : Please enter a valid server address} + invalid={!!addressValid.localValue ? undefined : Please enter a valid server address} + 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 }); + address.setValue({ currentAddress: value }, true); + addressValid.setValue(true, 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 ( Server password} - 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 valid = variables.useVariable("nickname_valid"); return ( Nickname} labelType={"static"} - invalid={valid ? undefined : Nickname too short or too long} + invalid={!!valid.localValue ? undefined : Nickname too short or too long} onInput={value => { - setNickname({ currentNickname: value, defaultNickname: nickname.defaultNickname }); - events.fire("action_set_nickname", { nickname: value, validate: true, updateUi: false }); + nickname.setValue({ currentNickname: value }, true); + valid.setValue(true, true); }} - onBlur={() => events.fire("action_set_nickname", { nickname: nickname?.currentNickname, validate: true, updateUi: 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 = Please select a profile; @@ -150,19 +124,23 @@ const InputProfile = () => {
Connect profile} invalid={invalidMarker} invalidClassName={cssStyle.invalidFeedback} - onChange={event => events.fire("action_select_profile", { id: event.target.value })} + onChange={event => profiles.setValue({ selected: event.target.value })} > - - - - {profiles?.profiles.map(profile => ( - - ))} + + + + + { + profiles.remoteValue?.profiles.map(profile => ( + + )) + } + @@ -281,34 +260,23 @@ 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).useVariableReadOnly("history_connections", { + target, + targetType + }, -1); - events.reactUse("notify_history_connections", event => event.targetType === targetType && event.target === target && setAmount(event.value)); - - if(amount >= 0) { - return {amount}; + if(value >= 0) { + return {value}; } else { return null; } }); const HistoryTableEntry = React.memo((props: { entry: ConnectHistoryEntry, selected: boolean }) => { - const connectNewTab = useContext(ConnectDefaultNewTabContext); const events = useContext(EventContext); - const [ info, setInfo ] = useState(() => { - 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 { value: info } = variables.useVariableReadOnly("history_entry", { serverUniqueId: props.entry.uniqueServerId }, undefined); const icon = getIconManager().resolveIcon(info ? info.icon.iconId : 0, info?.icon.serverUniqueId, info?.icon.handlerId); @@ -364,9 +332,9 @@ const HistoryTableEntry = React.memo((props: { entry: ConnectHistoryEntry, selec }); const HistoryTable = () => { - const [history] = useProperty("history", undefined); - let body; + const { value: history } = useContext(VariablesContext).useVariableReadOnly("history", undefined, undefined); + let body; if(history) { if(history.history.length > 0) { body = history.history.map(entry => ); @@ -411,7 +379,8 @@ const HistoryTable = () => { } const HistoryContainer = () => { - const [historyShown] = useProperty("historyShown", false); + const variables = useContext(VariablesContext); + const { value: historyShown } = variables.useVariableReadOnly("historyShown", undefined, false); return (
@@ -422,11 +391,13 @@ const HistoryContainer = () => { export class ConnectModal extends InternalModal { private readonly events: Registry; + private readonly variables: UiVariableConsumer; private readonly connectNewTabByDefault: boolean; - constructor(events: Registry, connectNewTabByDefault: boolean) { + constructor(events: Registry, variables: UiVariableConsumer, connectNewTabByDefault: boolean) { super(); + this.variables = variables; this.events = events; this.connectNewTabByDefault = connectNewTabByDefault; } @@ -434,18 +405,20 @@ export class ConnectModal extends InternalModal { renderBody(): React.ReactElement { return ( - -
- - - -
-
+ + +
+ + + +
+
+
); } - title(): string | React.ReactElement { + renderTitle(): string | React.ReactElement { return Connect to a server; } diff --git a/shared/js/ui/modal/css-editor/Renderer.tsx b/shared/js/ui/modal/css-editor/Renderer.tsx index 0156e42e..f7ad09b1 100644 --- a/shared/js/ui/modal/css-editor/Renderer.tsx +++ b/shared/js/ui/modal/css-editor/Renderer.tsx @@ -420,7 +420,7 @@ class PopoutConversationUI extends AbstractModal { ); } - title() { + renderTitle() { return "CSS Variable editor"; } } diff --git a/shared/js/ui/modal/echo-test/Controller.tsx b/shared/js/ui/modal/echo-test/Controller.tsx index e9bb5a2c..d4d5d8f1 100644 --- a/shared/js/ui/modal/echo-test/Controller.tsx +++ b/shared/js/ui/modal/echo-test/Controller.tsx @@ -31,7 +31,7 @@ export function spawnEchoTestModal(connection: ConnectionHandler) { ); } - title(): string | React.ReactElement { + renderTitle(): string | React.ReactElement { return Voice echo test; } }); diff --git a/shared/js/ui/modal/global-settings-editor/Renderer.tsx b/shared/js/ui/modal/global-settings-editor/Renderer.tsx index d2de2032..e589e4a7 100644 --- a/shared/js/ui/modal/global-settings-editor/Renderer.tsx +++ b/shared/js/ui/modal/global-settings-editor/Renderer.tsx @@ -189,7 +189,7 @@ export class ModalGlobalSettingsEditor extends InternalModal { ); } - title(): string | React.ReactElement { + renderTitle(): string | React.ReactElement { return Global settings registry; } } diff --git a/shared/js/ui/modal/permission/ModalPermissionEditor.tsx b/shared/js/ui/modal/permission/ModalPermissionEditor.tsx index adf7cb14..32501a8c 100644 --- a/shared/js/ui/modal/permission/ModalPermissionEditor.tsx +++ b/shared/js/ui/modal/permission/ModalPermissionEditor.tsx @@ -343,7 +343,7 @@ class PermissionEditorModal extends InternalModal { ); } - title(): React.ReactElement { + renderTitle(): React.ReactElement { return Server permissions; } diff --git a/shared/js/ui/modal/transfer/ModalFileTransfer.tsx b/shared/js/ui/modal/transfer/ModalFileTransfer.tsx index 0a13bb6f..511daa0d 100644 --- a/shared/js/ui/modal/transfer/ModalFileTransfer.tsx +++ b/shared/js/ui/modal/transfer/ModalFileTransfer.tsx @@ -41,7 +41,7 @@ class FileTransferModal extends InternalModal { this.transferInfoEvents.fire("notify_destroy"); } - title() { + renderTitle() { return File Browser; } diff --git a/shared/js/ui/modal/video-source/Renderer.tsx b/shared/js/ui/modal/video-source/Renderer.tsx index 5efad38e..17134c89 100644 --- a/shared/js/ui/modal/video-source/Renderer.tsx +++ b/shared/js/ui/modal/video-source/Renderer.tsx @@ -855,7 +855,7 @@ export class ModalVideoSource extends InternalModal { ); } - title(): string | React.ReactElement { + renderTitle(): string | React.ReactElement { return Start video Broadcasting; } } diff --git a/shared/js/ui/modal/whats-new/Controller.tsx b/shared/js/ui/modal/whats-new/Controller.tsx index ad678a5f..ca045a86 100644 --- a/shared/js/ui/modal/whats-new/Controller.tsx +++ b/shared/js/ui/modal/whats-new/Controller.tsx @@ -15,7 +15,7 @@ export function spawnUpdatedModal(changes: { changesUI?: ChangeLog, changesClien return ; } - title(): string | React.ReactElement { + renderTitle(): string | React.ReactElement { return We've updated the client for you; } }); diff --git a/shared/js/ui/react-elements/ModalDefinitions.ts b/shared/js/ui/react-elements/ModalDefinitions.ts index f47bdc18..2f3f4b0c 100644 --- a/shared/js/ui/react-elements/ModalDefinitions.ts +++ b/shared/js/ui/react-elements/ModalDefinitions.ts @@ -39,7 +39,7 @@ export abstract class AbstractModal { protected constructor() {} abstract renderBody() : ReactElement; - abstract title() : string | React.ReactElement; + abstract renderTitle() : string | React.ReactElement; /* only valid for the "inline" modals */ type() : ModalType { return "none"; } diff --git a/shared/js/ui/react-elements/external-modal/IPCMessage.ts b/shared/js/ui/react-elements/external-modal/IPCMessage.ts index 13ffde96..909c2077 100644 --- a/shared/js/ui/react-elements/external-modal/IPCMessage.ts +++ b/shared/js/ui/react-elements/external-modal/IPCMessage.ts @@ -1,5 +1,5 @@ import {ChannelMessage, IPCChannel} from "../../../ipc/BrowserIPC"; -import {EventReceiver, RegistryMap} from "../../../events"; +import {EventSender, RegistryMap} from "../../../events"; export interface PopoutIPCMessage { "hello-popout": { version: string }, @@ -41,7 +41,7 @@ export abstract class EventControllerBase protected ipcRemoteId: string; protected localRegistries: RegistryMap; - private localEventReceiver: {[key: string]: EventReceiver}; + private localEventReceiver: {[key: string]: EventSender}; private omitEventType: string = undefined; private omitEventData: any; @@ -61,7 +61,7 @@ export abstract class EventControllerBase } } - private createEventReceiver(key: string) : EventReceiver { + private createEventReceiver(key: string) : EventSender { let refThis = this; const fireEvent = (type: "react" | "later", eventType: any, data?: any[], callback?: () => void) => { @@ -80,7 +80,7 @@ export abstract class EventControllerBase } }; - return new class implements EventReceiver { + return new class implements EventSender { fire(eventType: T, data?: any[T], overrideTypeKey?: boolean) { if(refThis.omitEventType === eventType && refThis.omitEventData === data) { refThis.omitEventType = undefined; diff --git a/shared/js/ui/react-elements/external-modal/PopoutRendererWeb.tsx b/shared/js/ui/react-elements/external-modal/PopoutRendererWeb.tsx index e9fbbcca..7d3a9f4d 100644 --- a/shared/js/ui/react-elements/external-modal/PopoutRendererWeb.tsx +++ b/shared/js/ui/react-elements/external-modal/PopoutRendererWeb.tsx @@ -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); } } } diff --git a/shared/js/ui/tree/popout/RendererModal.tsx b/shared/js/ui/tree/popout/RendererModal.tsx index 6593887d..c8a06305 100644 --- a/shared/js/ui/tree/popout/RendererModal.tsx +++ b/shared/js/ui/tree/popout/RendererModal.tsx @@ -52,7 +52,7 @@ class ChannelTreeModal extends AbstractModal { ) } - title(): React.ReactElement { + renderTitle(): React.ReactElement { return ; } } diff --git a/shared/js/ui/utils/LocalVariable.ts b/shared/js/ui/utils/LocalVariable.ts new file mode 100644 index 00000000..e53c53a4 --- /dev/null +++ b/shared/js/ui/utils/LocalVariable.ts @@ -0,0 +1,60 @@ +import {UiVariableConsumer, UiVariableMap, UiVariableProvider} from "tc-shared/ui/utils/Variable"; + +class LocalUiVariableProvider extends UiVariableProvider { + private consumer: LocalUiVariableConsumer; + + constructor() { + super(); + } + + destroy() { + super.destroy(); + this.consumer = undefined; + } + + setConsumer(consumer: LocalUiVariableConsumer) { + 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 { + return super.doEditVariable(variable, customData, newValue); + } +} + +class LocalUiVariableConsumer extends UiVariableConsumer { + private provider: LocalUiVariableProvider; + + constructor(provider: LocalUiVariableProvider) { + super(); + + this.provider = provider; + } + + destroy() { + super.destroy(); + this.provider = undefined; + } + + protected doEditVariable(variable: string, customData: any, value: any): Promise | 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() : [UiVariableProvider, UiVariableConsumer] { + const provider = new LocalUiVariableProvider(); + const consumer = new LocalUiVariableConsumer(provider); + provider.setConsumer(consumer); + return [provider, consumer]; +} \ No newline at end of file diff --git a/shared/js/ui/utils/Variable.ts b/shared/js/ui/utils/Variable.ts new file mode 100644 index 00000000..9a7d8493 --- /dev/null +++ b/shared/js/ui/utils/Variable.ts @@ -0,0 +1,330 @@ +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 + +type UiVariableEditor = Variables[T] extends { __readonly } ? + never : + (newValue: Variables[T], customData: any) => Variables[T] | void | boolean; + +type UiVariableEditorPromise = Variables[T] extends { __readonly } ? + never : + (newValue: Variables[T], customData: any) => Promise; + +export abstract class UiVariableProvider { + private variableProvider: {[key: string]: (customData: any) => any | Promise} = {}; + private variableEditor: {[key: string]: (newValue, customData: any) => any | Promise} = {}; + + protected constructor() { } + + destroy() { } + + setVariableProvider(variable: T, provider: (customData: any) => Variables[T] | Promise) { + this.variableProvider[variable as any] = provider; + } + + setVariableEditor(variable: T, editor: UiVariableEditor) { + this.variableEditor[variable as any] = editor; + } + + setVariableEditorAsync(variable: T, editor: UiVariableEditorPromise) { + 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(variable: T, customData?: any, forceSend?: boolean) : void | Promise { + const providers = this.variableProvider[variable as any]; + if(!providers) { + throw tr("missing provider"); + } + + 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(variable: T, customData?: any, ignoreCache?: boolean) : Promise { + const providers = this.variableProvider[variable as any]; + if(!providers) { + throw tr("missing provider"); + } + + const result = providers(customData); + if(result instanceof Promise) { + return await result; + } else { + return result; + } + } + + getVariableSync(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 { + const providers = this.variableProvider[variable]; + if(!providers) { + throw tr("missing provider"); + } + + return providers(customData); + } + + protected doEditVariable(variable: string, customData: any, newValue: any) : Promise | 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 = ({ + status: "loading", + + localValue: undefined, + remoteValue: undefined +} | { + status: "loaded" | "applying", + + localValue: Omit, + remoteValue: Omit, +}) & (Variables[T] extends { __readonly } ? {} : { setValue: (newValue: Variables[T], localOnly?: boolean) => void }); + +export type UiReadOnlyVariableStatus = { + status: "loading", + value: DefaultValue, +} | { + status: "loaded" | "applying", + value: Omit, +}; + +type UiVariableCacheEntry = { + useCount: number, + customData: any | undefined, + currentValue: any | undefined, + status: "loading" | "loaded" | "applying", + updateListener: ((forceSetLocalVariable: boolean) => void)[] +} + +let staticRevisionId = 0; +/** + * @returns An array containing variable information `[ value, setValue(newValue, localOnly), remoteValue ]` + */ +export abstract class UiVariableConsumer { + private variableCache: {[key: string]: UiVariableCacheEntry[]} = {}; + + destroy() { + this.variableCache = {}; + } + + useVariable( + variable: T, + customData?: any + ) : UiVariableStatus { + let cacheEntry = this.variableCache[variable as string]?.find(variable => _.isEqual(variable.customData, customData)); + if(!cacheEntry) { + this.variableCache[variable as string] = this.variableCache[variable as string] || []; + this.variableCache[variable as string].push(cacheEntry = { + customData, + currentValue: undefined, + status: "loading", + useCount: 0, + updateListener: [] + }); + + /* Might already call notifyRemoteVariable */ + this.doRequestVariable(variable as string, customData); + } + + const [ localValue, setLocalValue ] = useState(() => { + /* Variable constructor */ + cacheEntry.useCount++; + return cacheEntry.currentValue; + }); + + const [, setRemoteVersion ] = useState(0); + + useEffect(() => { + /* Initial rendered */ + if(cacheEntry.status === "loaded" && !_.isEqual(localValue, cacheEntry.currentValue)) { + /* Update value to the, now, up 2 date value*/ + setLocalValue(cacheEntry.currentValue); + } + + let listener; + cacheEntry.updateListener.push(listener = forceUpdateLocalVariable => { + if(forceUpdateLocalVariable) { + setLocalValue(cacheEntry.currentValue); + } + + /* 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); + + /* Variable destructor */ + if(--cacheEntry.useCount === 0) { + const cache = this.variableCache[variable as string]; + if(!cache) { + return; + } + + cache.remove(cacheEntry); + if(cache.length === 0) { + delete this.variableCache[variable as string]; + } + } + }; + }, []); + + + if(cacheEntry.status === "loading") { + return { + status: "loading", + localValue: localValue, + remoteValue: undefined, + setValue: () => {} + }; + } else { + return { + status: cacheEntry.status, + localValue: localValue, + 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; + } + + let value = newValue; + if(!succeeded) { + value = cacheEntry.currentValue; + } + + cacheEntry.status = "loaded"; + cacheEntry.currentValue = value; + 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(!_.isEqual(newValue, localValue)) { + setLocalValue(newValue); + } + } + } as any; + } + } + + useVariableReadOnly( + variable: T, + customData?: any, + defaultValue?: DefaultValue + ) : UiReadOnlyVariableStatus { + /* TODO: Use a simplified logic here */ + const v = this.useVariable(variable, customData); + if(v.status === "loading") { + return { + status: "loading", + value: defaultValue + }; + } else { + return { + status: v.status, + value: v.remoteValue + } + } + } + + 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; +} diff --git a/shared/js/video-viewer/Renderer.tsx b/shared/js/video-viewer/Renderer.tsx index bd7c4752..fb6d0160 100644 --- a/shared/js/video-viewer/Renderer.tsx +++ b/shared/js/video-viewer/Renderer.tsx @@ -498,7 +498,7 @@ class ModalVideoPopout extends AbstractModal { this.events = registryMap["default"] as any; } - title(): string | React.ReactElement { + renderTitle(): string | React.ReactElement { return ; }