Introducing the new UI variable system

master
WolverinDEV 2021-01-17 23:11:21 +01:00
parent 7918af1b31
commit c8b6998e35
24 changed files with 706 additions and 633 deletions

View File

@ -22,7 +22,7 @@ export class SingletonEvent implements Event<SingletonEvents, "singletone-instan
as<T extends keyof SingletonEvents>() : SingletonEvents[T] { return; }
}
export interface EventReceiver<Events extends { [key: string]: any } = { [key: string]: any }> {
export interface EventSender<Events extends { [key: string]: any } = { [key: string]: any }> {
fire<T extends keyof Events>(event_type: T, data?: Events[T], overrideTypeKey?: boolean);
/**
@ -44,11 +44,11 @@ export interface EventReceiver<Events extends { [key: string]: any } = { [key: s
}
const event_annotation_key = guid();
export class Registry<Events extends { [key: string]: any } = { [key: string]: any }> implements EventReceiver<Events> {
export class Registry<Events extends { [key: string]: any } = { [key: string]: any }> implements EventSender<Events> {
private readonly registryUuid;
private handler: {[key: string]: ((event) => void)[]} = {};
private connections: {[key: string]: EventReceiver<Events>[]} = {};
private connections: {[key: string]: EventSender<Events>[]} = {};
private eventHandlerObjects: {
object: any,
handlers: {[key: string]: ((event) => void)[]}
@ -66,12 +66,11 @@ export class Registry<Events extends { [key: string]: any } = { [key: string]: a
this.registryUuid = "evreg_data_" + guid();
}
enableDebug(prefix: string) { this.debugPrefix = prefix || "---"; }
disableDebug() { this.debugPrefix = undefined; }
enable_warn_unhandled_events() { this.warnUnhandledEvents = true; }
disable_warn_unhandled_events() { this.warnUnhandledEvents = false; }
enableWarnUnhandledEvents() { this.warnUnhandledEvents = true; }
disableWarnUnhandledEvents() { this.warnUnhandledEvents = false; }
on<T extends keyof Events>(event: T, handler: (event?: Events[T] & Event<Events, T>) => void) : () => void;
on(events: (keyof Events)[], handler: (event?: Event<Events, keyof Events>) => void) : () => void;
@ -158,21 +157,21 @@ export class Registry<Events extends { [key: string]: any } = { [key: string]: a
}, reactEffectDependencies);
}
connectAll<EOther, T extends keyof Events & keyof EOther>(target: EventReceiver<Events>) {
connectAll<EOther, T extends keyof Events & keyof EOther>(target: EventSender<Events>) {
(this.connections[null as any] || (this.connections[null as any] = [])).push(target as any);
}
connect<EOther, T extends (keyof Events & keyof EOther)>(events: T | T[], target: EventReceiver<EOther>) {
connect<EOther, T extends (keyof Events & keyof EOther)>(events: T | T[], target: EventSender<EOther>) {
for(const event of Array.isArray(events) ? events : [events])
(this.connections[event as string] || (this.connections[event as string] = [])).push(target as any);
}
disconnect<EOther, T extends keyof Events & keyof EOther>(events: T | T[], target: EventReceiver<Events>) {
disconnect<EOther, T extends keyof Events & keyof EOther>(events: T | T[], target: EventSender<Events>) {
for(const event of Array.isArray(events) ? events : [events])
(this.connections[event as string] || []).remove(target as any);
}
disconnectAll<EOther>(target: EventReceiver<EOther>) {
disconnectAll<EOther>(target: EventSender<EOther>) {
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<Events extends { [key: string]: any } = { [key: string]: a
this.pendingReactCallbacksFrame = 0;
this.pendingReactCallbacks = undefined;
/* run this after the requestAnimationFrame has been finished */
/* run this after the requestAnimationFrame has been finished since else it might be fired instantly */
setTimeout(() => {
/* batch all react updates */
unstable_batchedUpdates(() => {
@ -344,8 +343,9 @@ export class Registry<Events extends { [key: string]: any } = { [key: string]: a
this.eventHandlerObjects.remove(data);
for(const key of Object.keys(data.handlers)) {
for(const evhandler of data.handlers[key])
for(const evhandler of data.handlers[key]) {
this.off(evhandler);
}
}
}
}
@ -376,8 +376,9 @@ export function ReactEventHandler<ObjectClass = React.Component<any, any>, 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<ObjectClass = React.Component<any, any>, 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": {}
}
}
}

View File

@ -68,7 +68,7 @@ export let enabled_mapping = new Map<number, boolean>([
[LogCategory.VOICE, true],
[LogCategory.AUDIO, true],
[LogCategory.CHAT, true],
[LogCategory.I18N, true],
[LogCategory.I18N, false],
[LogCategory.IDENTITIES, true],
[LogCategory.IPC, true],
[LogCategory.STATISTICS, true],

View File

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

View File

@ -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

@ -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

@ -161,7 +161,7 @@ class ModalGroupPermissionCopy extends InternalModal {
</div>;
}
title() {
renderTitle() {
return <Translatable>Copy group permissions</Translatable>;
}
}

View File

@ -1162,7 +1162,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 {

View File

@ -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<ConnectUiEvents>;
readonly uiVariables: UiVariableProvider<ConnectUiVariables>;
private readonly defaultAddress: string;
private readonly propertyProvider: {[K in keyof ConnectProperties]?: () => Promise<ConnectProperties[K]>} = {};
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<ConnectUiVariables>) {
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 +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<ConnectUiVariables>();
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", () => {

View File

@ -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<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

@ -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<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.useVariable("server_address_valid");
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.localValue ? 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 });
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 (
<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 valid = variables.useVariable("nickname_valid");
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>}
invalid={!!valid.localValue ? 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 });
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 = <Translatable key={"no-profile"}>Please select a profile</Translatable>;
@ -150,19 +124,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 +152,7 @@ const InputProfile = () => {
const ConnectContainer = () => (
<div className={cssStyle.connectContainer}>
<div className={cssStyle.row}>
{/* <InputServerAddress /> */}
<InputServerAddress />
<InputServerPassword />
</div>
@ -185,11 +164,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 +188,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,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 <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 { 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 => <HistoryTableEntry entry={entry} key={"entry-" + entry.id} selected={entry.id === history.selected} />);
@ -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 (
<div className={joinClassList(cssStyle.historyContainer, historyShown && cssStyle.shown)}>
@ -422,11 +391,13 @@ const HistoryContainer = () => {
export class ConnectModal extends InternalModal {
private readonly events: Registry<ConnectUiEvents>;
private readonly variables: UiVariableConsumer<ConnectUiVariables>;
private readonly connectNewTabByDefault: boolean;
constructor(events: Registry<ConnectUiEvents>, connectNewTabByDefault: boolean) {
constructor(events: Registry<ConnectUiEvents>, variables: UiVariableConsumer<ConnectUiVariables>, 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 (
<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>;
}

View File

@ -420,7 +420,7 @@ class PopoutConversationUI extends AbstractModal {
);
}
title() {
renderTitle() {
return "CSS Variable editor";
}
}

View File

@ -31,7 +31,7 @@ export function spawnEchoTestModal(connection: ConnectionHandler) {
);
}
title(): string | React.ReactElement<Translatable> {
renderTitle(): string | React.ReactElement<Translatable> {
return <Translatable>Voice echo test</Translatable>;
}
});

View File

@ -189,7 +189,7 @@ export class ModalGlobalSettingsEditor extends InternalModal {
);
}
title(): string | React.ReactElement<Translatable> {
renderTitle(): string | React.ReactElement<Translatable> {
return <Translatable>Global settings registry</Translatable>;
}
}

View File

@ -343,7 +343,7 @@ class PermissionEditorModal extends InternalModal {
);
}
title(): React.ReactElement<Translatable> {
renderTitle(): React.ReactElement<Translatable> {
return <Translatable>Server permissions</Translatable>;
}

View File

@ -41,7 +41,7 @@ class FileTransferModal extends InternalModal {
this.transferInfoEvents.fire("notify_destroy");
}
title() {
renderTitle() {
return <Translatable>File Browser</Translatable>;
}

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

@ -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

@ -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"; }

View File

@ -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<Type extends "controller" | "popout">
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<Type extends "controller" | "popout">
}
}
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<Type extends "controller" | "popout">
}
};
return new class implements EventReceiver {
return new class implements EventSender {
fire<T extends keyof {}>(eventType: T, data?: any[T], overrideTypeKey?: boolean) {
if(refThis.omitEventType === eventType && refThis.omitEventData === data) {
refThis.omitEventType = undefined;

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);
}
}
}

View File

@ -52,7 +52,7 @@ class ChannelTreeModal extends AbstractModal {
)
}
title(): React.ReactElement {
renderTitle(): React.ReactElement {
return <TitleRenderer events={this.eventsUI} />;
}
}

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, consumer];
}

View File

@ -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<UiVariable>
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 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<T extends keyof Variables>(variable: T, customData?: any, ignoreCache?: boolean) : Promise<Variables[T]> {
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<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: undefined,
remoteValue: undefined
} | {
status: "loaded" | "applying",
localValue: Omit<Variables[T], "__readonly">,
remoteValue: Omit<Variables[T], "__readonly">,
}) & (Variables[T] extends { __readonly } ? {} : { setValue: (newValue: Variables[T], localOnly?: boolean) => void });
export type UiReadOnlyVariableStatus<Variables extends UiVariableMap, T extends keyof Variables, DefaultValue = undefined> = {
status: "loading",
value: DefaultValue,
} | {
status: "loaded" | "applying",
value: Omit<Variables[T], "__readonly">,
};
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<Variables extends UiVariableMap> {
private variableCache: {[key: string]: UiVariableCacheEntry[]} = {};
destroy() {
this.variableCache = {};
}
useVariable<T extends keyof Variables>(
variable: T,
customData?: any
) : UiVariableStatus<Variables, T> {
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<T extends keyof Variables, DefaultValue>(
variable: T,
customData?: any,
defaultValue?: DefaultValue
) : UiReadOnlyVariableStatus<Variables, T, DefaultValue> {
/* 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> | void;
}

View File

@ -498,7 +498,7 @@ class ModalVideoPopout extends AbstractModal {
this.events = registryMap["default"] as any;
}
title(): string | React.ReactElement<Translatable> {
renderTitle(): string | React.ReactElement<Translatable> {
return <TitleRenderer events={this.events} />;
}