Introducing the new UI variable system
parent
7918af1b31
commit
c8b6998e35
|
@ -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,11 +343,12 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type RegistryMap = {[key: string]: any /* can't use Registry here since the template parameter is missing */ };
|
||||
|
||||
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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],
|
||||
|
|
|
@ -23,7 +23,7 @@ class PopoutConversationRenderer extends AbstractModal {
|
|||
noFirstMessageOverlay={this.userData.noFirstMessageOverlay} />;
|
||||
}
|
||||
|
||||
title() {
|
||||
renderTitle() {
|
||||
return "Conversations";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
@ -161,7 +161,7 @@ class ModalGroupPermissionCopy extends InternalModal {
|
|||
</div>;
|
||||
}
|
||||
|
||||
title() {
|
||||
renderTitle() {
|
||||
return <Translatable>Copy group permissions</Translatable>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,30 +201,14 @@ 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);
|
||||
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: {
|
||||
this.uiVariables.setVariableProvider("history_entry", async customData => {
|
||||
const info = await connectionHistory.queryServerInfo(customData.serverUniqueId);
|
||||
return {
|
||||
icon: {
|
||||
iconId: info.iconId,
|
||||
serverUniqueId: event.serverUniqueId,
|
||||
serverUniqueId: customData.serverUniqueId,
|
||||
handlerId: undefined
|
||||
},
|
||||
name: info.name,
|
||||
|
@ -164,99 +216,52 @@ class ConnectController {
|
|||
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.historyShown = event.enabled;
|
||||
this.sendProperty("historyShown").then(undefined);
|
||||
settings.setValue(Settings.KEY_CONNECT_SHOW_HISTORY, event.enabled);
|
||||
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;
|
||||
});
|
||||
|
||||
|
||||
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.uiVariables.setVariableProvider("profiles", () => ({
|
||||
selected: this.currentProfile?.id,
|
||||
profiles: availableConnectProfiles().map(profile => ({
|
||||
id: profile.id,
|
||||
valid: profile.valid(),
|
||||
name: profile.profileName
|
||||
}))
|
||||
}));
|
||||
|
||||
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", () => {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 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", {
|
||||
const { value } = useContext(VariablesContext).useVariableReadOnly("history_connections", {
|
||||
target,
|
||||
targetType
|
||||
});
|
||||
return -1;
|
||||
});
|
||||
}, -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,6 +405,7 @@ export class ConnectModal extends InternalModal {
|
|||
renderBody(): React.ReactElement {
|
||||
return (
|
||||
<EventContext.Provider value={this.events}>
|
||||
<VariablesContext.Provider value={this.variables}>
|
||||
<ConnectDefaultNewTabContext.Provider value={this.connectNewTabByDefault}>
|
||||
<div className={cssStyle.container}>
|
||||
<ConnectContainer />
|
||||
|
@ -441,11 +413,12 @@ export class ConnectModal extends InternalModal {
|
|||
<HistoryContainer />
|
||||
</div>
|
||||
</ConnectDefaultNewTabContext.Provider>
|
||||
</VariablesContext.Provider>
|
||||
</EventContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
title(): string | React.ReactElement {
|
||||
renderTitle(): string | React.ReactElement {
|
||||
return <Translatable>Connect to a server</Translatable>;
|
||||
}
|
||||
|
||||
|
|
|
@ -420,7 +420,7 @@ class PopoutConversationUI extends AbstractModal {
|
|||
);
|
||||
}
|
||||
|
||||
title() {
|
||||
renderTitle() {
|
||||
return "CSS Variable editor";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -343,7 +343,7 @@ class PermissionEditorModal extends InternalModal {
|
|||
);
|
||||
}
|
||||
|
||||
title(): React.ReactElement<Translatable> {
|
||||
renderTitle(): React.ReactElement<Translatable> {
|
||||
return <Translatable>Server permissions</Translatable>;
|
||||
}
|
||||
|
||||
|
|
|
@ -41,7 +41,7 @@ class FileTransferModal extends InternalModal {
|
|||
this.transferInfoEvents.fire("notify_destroy");
|
||||
}
|
||||
|
||||
title() {
|
||||
renderTitle() {
|
||||
return <Translatable>File Browser</Translatable>;
|
||||
}
|
||||
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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"; }
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,7 +52,7 @@ class ChannelTreeModal extends AbstractModal {
|
|||
)
|
||||
}
|
||||
|
||||
title(): React.ReactElement {
|
||||
renderTitle(): React.ReactElement {
|
||||
return <TitleRenderer events={this.eventsUI} />;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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];
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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} />;
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue