Using the new variable concept

master
WolverinDEV 2021-01-21 17:54:55 +01:00
parent c8b6998e35
commit 26ea806686
4 changed files with 161 additions and 97 deletions

View File

@ -61,7 +61,7 @@ class ConnectController {
private validateNickname: boolean; private validateNickname: boolean;
private validateAddress: boolean; private validateAddress: boolean;
constructor(uiVariables: UiVariableProvider<ConnectUiVariables>) { constructor(uiVariables: UiVariableProvider<ConnectUiVariables>) {7
this.uiEvents = new Registry<ConnectUiEvents>(); this.uiEvents = new Registry<ConnectUiEvents>();
this.uiEvents.enableDebug("modal-connect"); this.uiEvents.enableDebug("modal-connect");

View File

@ -28,13 +28,13 @@ export interface ConnectUiVariables {
currentAddress: string, currentAddress: string,
defaultAddress?: string, defaultAddress?: string,
}, },
"server_address_valid": boolean, readonly "server_address_valid": boolean,
"nickname": { "nickname": {
currentNickname: string | undefined, currentNickname: string | undefined,
defaultNickname?: string, defaultNickname?: string,
}, },
"nickname_valid": boolean, readonly "nickname_valid": boolean,
"password": { "password": {
password: string, password: string,
@ -45,17 +45,16 @@ export interface ConnectUiVariables {
profiles?: ConnectProfileEntry[], profiles?: ConnectProfileEntry[],
selected: string selected: string
}, },
"profile_valid": boolean, readonly "profile_valid": boolean,
"historyShown": boolean, "historyShown": boolean,
"history": { readonly "history": {
__readonly?,
history: ConnectHistoryEntry[], history: ConnectHistoryEntry[],
selected: number | -1, selected: number | -1,
}, },
"history_entry": ConnectHistoryServerInfo, readonly "history_entry": ConnectHistoryServerInfo,
"history_connections": number readonly "history_connections": number
} }
export interface ConnectUiEvents { export interface ConnectUiEvents {

View File

@ -31,7 +31,7 @@ const InputServerAddress = React.memo(() => {
const variables = useContext(VariablesContext); const variables = useContext(VariablesContext);
const address = variables.useVariable("server_address"); const address = variables.useVariable("server_address");
const addressValid = variables.useVariable("server_address_valid"); const addressValid = variables.useReadOnly("server_address_valid", true) || address.localValue !== address.remoteValue;
return ( return (
<ControlledFlatInputField <ControlledFlatInputField
@ -43,13 +43,10 @@ const InputServerAddress = React.memo(() => {
label={<Translatable>Server address</Translatable>} label={<Translatable>Server address</Translatable>}
labelType={"static"} labelType={"static"}
invalid={!!addressValid.localValue ? undefined : <Translatable>Please enter a valid server address</Translatable>} invalid={addressValid ? undefined : <Translatable>Please enter a valid server address</Translatable>}
editable={address.status === "loaded"} editable={address.status === "loaded"}
onInput={value => { onInput={value => address.setValue({ currentAddress: value }, true)}
address.setValue({ currentAddress: value }, true);
addressValid.setValue(true, true);
}}
onBlur={() => address.setValue({ currentAddress: address.localValue?.currentAddress })} onBlur={() => address.setValue({ currentAddress: address.localValue?.currentAddress })}
onEnter={() => { onEnter={() => {
/* Setting the address just to ensure */ /* Setting the address just to ensure */
@ -82,7 +79,7 @@ const InputNickname = () => {
const variables = useContext(VariablesContext); const variables = useContext(VariablesContext);
const nickname = variables.useVariable("nickname"); const nickname = variables.useVariable("nickname");
const valid = variables.useVariable("nickname_valid"); const validState = variables.useReadOnly("nickname_valid", true) || nickname.localValue !== nickname.remoteValue;
return ( return (
<ControlledFlatInputField <ControlledFlatInputField
@ -91,11 +88,8 @@ const InputNickname = () => {
placeholder={nickname.remoteValue ? nickname.remoteValue.defaultNickname ? nickname.remoteValue.defaultNickname : tr("Please enter a nickname") : tr("loading...")} placeholder={nickname.remoteValue ? nickname.remoteValue.defaultNickname ? nickname.remoteValue.defaultNickname : tr("Please enter a nickname") : tr("loading...")}
label={<Translatable>Nickname</Translatable>} label={<Translatable>Nickname</Translatable>}
labelType={"static"} labelType={"static"}
invalid={!!valid.localValue ? undefined : <Translatable>Nickname too short or too long</Translatable>} invalid={validState ? undefined : <Translatable>Nickname too short or too long</Translatable>}
onInput={value => { onInput={value => nickname.setValue({ currentNickname: value }, true)}
nickname.setValue({ currentNickname: value }, true);
valid.setValue(true, true);
}}
onBlur={() => nickname.setValue({ currentNickname: nickname.localValue?.currentNickname })} onBlur={() => nickname.setValue({ currentNickname: nickname.localValue?.currentNickname })}
/> />
); );
@ -260,7 +254,7 @@ const HistoryTableEntryConnectCount = React.memo((props: { entry: ConnectHistory
const targetType = props.entry.uniqueServerId === kUnknownHistoryServerUniqueId ? "address" : "server-unique-id"; const targetType = props.entry.uniqueServerId === kUnknownHistoryServerUniqueId ? "address" : "server-unique-id";
const target = targetType === "address" ? props.entry.targetAddress : props.entry.uniqueServerId; const target = targetType === "address" ? props.entry.targetAddress : props.entry.uniqueServerId;
const { value } = useContext(VariablesContext).useVariableReadOnly("history_connections", { const value = useContext(VariablesContext).useReadOnly("history_connections", {
target, target,
targetType targetType
}, -1); }, -1);
@ -276,8 +270,8 @@ const HistoryTableEntry = React.memo((props: { entry: ConnectHistoryEntry, selec
const events = useContext(EventContext); const events = useContext(EventContext);
const connectNewTab = useContext(ConnectDefaultNewTabContext); const connectNewTab = useContext(ConnectDefaultNewTabContext);
const variables = useContext(VariablesContext); const variables = useContext(VariablesContext);
const { value: info } = variables.useVariableReadOnly("history_entry", { serverUniqueId: props.entry.uniqueServerId }, undefined);
const info = variables.useReadOnly("history_entry", { serverUniqueId: props.entry.uniqueServerId }, undefined);
const icon = getIconManager().resolveIcon(info ? info.icon.iconId : 0, info?.icon.serverUniqueId, info?.icon.handlerId); const icon = getIconManager().resolveIcon(info ? info.icon.iconId : 0, info?.icon.serverUniqueId, info?.icon.handlerId);
return ( return (
@ -332,7 +326,7 @@ const HistoryTableEntry = React.memo((props: { entry: ConnectHistoryEntry, selec
}); });
const HistoryTable = () => { const HistoryTable = () => {
const { value: history } = useContext(VariablesContext).useVariableReadOnly("history", undefined, undefined); const history = useContext(VariablesContext).useReadOnly("history", undefined, undefined);
let body; let body;
if(history) { if(history) {
@ -380,7 +374,7 @@ const HistoryTable = () => {
const HistoryContainer = () => { const HistoryContainer = () => {
const variables = useContext(VariablesContext); const variables = useContext(VariablesContext);
const { value: historyShown } = variables.useVariableReadOnly("historyShown", undefined, false); const historyShown = variables.useReadOnly("historyShown", undefined, false);
return ( return (
<div className={joinClassList(cssStyle.historyContainer, historyShown && cssStyle.shown)}> <div className={joinClassList(cssStyle.historyContainer, historyShown && cssStyle.shown)}>

View File

@ -10,6 +10,21 @@ import * as _ from "lodash";
export type UiVariable = Transferable | undefined | null | number | string | object; export type UiVariable = Transferable | undefined | null | number | string | object;
export type UiVariableMap = { [key: string]: any }; //UiVariable | Readonly<UiVariable> export type UiVariableMap = { [key: string]: any }; //UiVariable | Readonly<UiVariable>
type IfEquals<X, Y, A=X, B=never> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2) ? A : B;
type WritableKeys<T> = {
[P in keyof T]-?: IfEquals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, P, never>
}[keyof T];
type ReadonlyKeys<T> = {
[P in keyof T]: IfEquals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, never, P>
}[keyof T];
export type ReadonlyVariables<Variables extends UiVariableMap> = Pick<Variables, ReadonlyKeys<Variables>>
export type WriteableVariables<Variables extends UiVariableMap> = Pick<Variables, WritableKeys<Variables>>
type UiVariableEditor<Variables extends UiVariableMap, T extends keyof Variables> = Variables[T] extends { __readonly } ? type UiVariableEditor<Variables extends UiVariableMap, T extends keyof Variables> = Variables[T] extends { __readonly } ?
never : never :
(newValue: Variables[T], customData: any) => Variables[T] | void | boolean; (newValue: Variables[T], customData: any) => Variables[T] | void | boolean;
@ -47,7 +62,7 @@ export abstract class UiVariableProvider<Variables extends UiVariableMap> {
sendVariable<T extends keyof Variables>(variable: T, customData?: any, forceSend?: boolean) : void | Promise<void> { sendVariable<T extends keyof Variables>(variable: T, customData?: any, forceSend?: boolean) : void | Promise<void> {
const providers = this.variableProvider[variable as any]; const providers = this.variableProvider[variable as any];
if(!providers) { if(!providers) {
throw tr("missing provider"); throw tra("missing provider for {}", variable);
} }
const result = providers(customData); const result = providers(customData);
@ -65,7 +80,7 @@ export abstract class UiVariableProvider<Variables extends UiVariableMap> {
async getVariable<T extends keyof Variables>(variable: T, customData?: any, ignoreCache?: boolean) : Promise<Variables[T]> { async getVariable<T extends keyof Variables>(variable: T, customData?: any, ignoreCache?: boolean) : Promise<Variables[T]> {
const providers = this.variableProvider[variable as any]; const providers = this.variableProvider[variable as any];
if(!providers) { if(!providers) {
throw tr("missing provider"); throw tra("missing provider for {}", variable);
} }
const result = providers(customData); const result = providers(customData);
@ -138,38 +153,45 @@ export abstract class UiVariableProvider<Variables extends UiVariableMap> {
protected abstract doSendVariable(variable: string, customData: any, value: any); protected abstract doSendVariable(variable: string, customData: any, value: any);
} }
export type UiVariableStatus<Variables extends UiVariableMap, T extends keyof Variables> = ({ export type UiVariableStatus<Variables extends UiVariableMap, T extends keyof Variables> = {
status: "loading", status: "loading",
localValue: undefined, localValue: Variables[T] | undefined,
remoteValue: undefined remoteValue: undefined,
/* Will do nothing */
setValue: (newValue: Variables[T], localOnly?: boolean) => void
} | { } | {
status: "loaded" | "applying", status: "loaded" | "applying",
localValue: Omit<Variables[T], "__readonly">, localValue: Variables[T],
remoteValue: Omit<Variables[T], "__readonly">, remoteValue: Variables[T],
}) & (Variables[T] extends { __readonly } ? {} : { setValue: (newValue: Variables[T], localOnly?: boolean) => void });
export type UiReadOnlyVariableStatus<Variables extends UiVariableMap, T extends keyof Variables, DefaultValue = undefined> = { setValue: (newValue: Variables[T], localOnly?: boolean) => void
status: "loading", };
value: DefaultValue,
} | { export type UiReadOnlyVariableStatus<Variables extends UiVariableMap, T extends keyof Variables> = {
status: "loaded" | "applying", status: "loading" | "loaded",
value: Omit<Variables[T], "__readonly">, value: Variables[T],
}; };
type UiVariableCacheEntry = { type UiVariableCacheEntry = {
key: string,
useCount: number, useCount: number,
customData: any | undefined, customData: any | undefined,
currentValue: any | undefined, currentValue: any | undefined,
status: "loading" | "loaded" | "applying", status: "loading" | "loaded" | "applying",
updateListener: ((forceSetLocalVariable: boolean) => void)[] updateListener: ((clearLocalValue: boolean) => void)[]
}
type LocalVariableValue = {
status: "set" | "default",
value: any
} | {
status: "unset"
} }
let staticRevisionId = 0; let staticRevisionId = 0;
/**
* @returns An array containing variable information `[ value, setValue(newValue, localOnly), remoteValue ]`
*/
export abstract class UiVariableConsumer<Variables extends UiVariableMap> { export abstract class UiVariableConsumer<Variables extends UiVariableMap> {
private variableCache: {[key: string]: UiVariableCacheEntry[]} = {}; private variableCache: {[key: string]: UiVariableCacheEntry[]} = {};
@ -177,14 +199,15 @@ export abstract class UiVariableConsumer<Variables extends UiVariableMap> {
this.variableCache = {}; this.variableCache = {};
} }
useVariable<T extends keyof Variables>( private getOrCreateVariable<T extends keyof Variables>(
variable: T, variable: string,
customData?: any customData?: any
) : UiVariableStatus<Variables, T> { ) : UiVariableCacheEntry {
let cacheEntry = this.variableCache[variable as string]?.find(variable => _.isEqual(variable.customData, customData)); let cacheEntry = this.variableCache[variable]?.find(variable => _.isEqual(variable.customData, customData));
if(!cacheEntry) { if(!cacheEntry) {
this.variableCache[variable as string] = this.variableCache[variable as string] || []; this.variableCache[variable] = this.variableCache[variable] || [];
this.variableCache[variable as string].push(cacheEntry = { this.variableCache[variable].push(cacheEntry = {
key: variable,
customData, customData,
currentValue: undefined, currentValue: undefined,
status: "loading", status: "loading",
@ -193,28 +216,67 @@ export abstract class UiVariableConsumer<Variables extends UiVariableMap> {
}); });
/* Might already call notifyRemoteVariable */ /* Might already call notifyRemoteVariable */
this.doRequestVariable(variable as string, customData); this.doRequestVariable(variable, customData);
} }
return cacheEntry;
}
const [ localValue, setLocalValue ] = useState(() => { private derefVariable(variable: UiVariableCacheEntry) {
if(--variable.useCount === 0) {
const cache = this.variableCache[variable.key];
if(!cache) {
return;
}
cache.remove(variable);
if(cache.length === 0) {
delete this.variableCache[variable.key];
}
}
}
useVariable<T extends keyof WriteableVariables<Variables>>(
variable: T,
customData?: any,
defaultValue?: Variables[T]
) : UiVariableStatus<Variables, T> {
const haveDefaultValue = arguments.length >= 3;
const cacheEntry = this.getOrCreateVariable(variable as string, customData);
const [ localValue, setLocalValue ] = useState<LocalVariableValue>(() => {
/* Variable constructor */ /* Variable constructor */
cacheEntry.useCount++; cacheEntry.useCount++;
return cacheEntry.currentValue;
if(cacheEntry.status === "loading") {
return {
status: "set",
value: cacheEntry.currentValue
};
} else if(haveDefaultValue) {
return {
status: "default",
value: defaultValue
};
} else {
return {
status: "unset"
};
}
}); });
const [, setRemoteVersion ] = useState(0); const [, setRemoteVersion ] = useState(0);
useEffect(() => { useEffect(() => {
/* Initial rendered */ /* Initial rendered */
if(cacheEntry.status === "loaded" && !_.isEqual(localValue, cacheEntry.currentValue)) { if(cacheEntry.status === "loaded" && localValue.status !== "set") {
/* Update value to the, now, up 2 date value*/ /* Update the local value to the current state */
setLocalValue(cacheEntry.currentValue); setLocalValue(cacheEntry.currentValue);
} }
let listener; let listener;
cacheEntry.updateListener.push(listener = forceUpdateLocalVariable => { cacheEntry.updateListener.push(listener = clearLocalValue => {
if(forceUpdateLocalVariable) { if(clearLocalValue) {
setLocalValue(cacheEntry.currentValue); setLocalValue({ status: "unset" });
} }
/* We can't just increment the old one by one since this update listener may fires twice before rendering */ /* We can't just increment the old one by one since this update listener may fires twice before rendering */
@ -223,35 +285,24 @@ export abstract class UiVariableConsumer<Variables extends UiVariableMap> {
return () => { return () => {
cacheEntry.updateListener.remove(listener); cacheEntry.updateListener.remove(listener);
this.derefVariable(cacheEntry);
/* 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") { if(cacheEntry.status === "loading") {
return { return {
status: "loading", status: "loading",
localValue: localValue, localValue: localValue.status === "unset" ? undefined : localValue.value,
remoteValue: undefined, remoteValue: undefined,
setValue: () => {} setValue: () => {}
}; };
} else { } else {
return { return {
status: cacheEntry.status, status: cacheEntry.status,
localValue: localValue,
localValue: localValue.status === "set" ? localValue.value : cacheEntry.currentValue,
remoteValue: cacheEntry.currentValue, remoteValue: cacheEntry.currentValue,
setValue: (newValue, localOnly) => { setValue: (newValue, localOnly) => {
if(!localOnly && !_.isEqual(cacheEntry.currentValue, newValue)) { if(!localOnly && !_.isEqual(cacheEntry.currentValue, newValue)) {
const editingFinished = (succeeded: boolean) => { const editingFinished = (succeeded: boolean) => {
@ -260,23 +311,20 @@ export abstract class UiVariableConsumer<Variables extends UiVariableMap> {
return; return;
} }
let value = newValue;
if(!succeeded) {
value = cacheEntry.currentValue;
}
cacheEntry.status = "loaded"; cacheEntry.status = "loaded";
cacheEntry.currentValue = value; cacheEntry.currentValue = succeeded ? newValue : cacheEntry.currentValue;
cacheEntry.updateListener.forEach(callback => callback(true)); cacheEntry.updateListener.forEach(callback => callback(true));
}; };
cacheEntry.status = "applying"; cacheEntry.status = "applying";
const result = this.doEditVariable(variable as string, customData, newValue); const result = this.doEditVariable(variable as string, customData, newValue);
if(result instanceof Promise) { if(result instanceof Promise) {
result.then(() => editingFinished(true)).catch(async error => { result
console.error("Failed to change variable %s: %o", variable, error); .then(() => editingFinished(true))
editingFinished(false); .catch(async error => {
}); console.error("Failed to change variable %s: %o", variable, error);
editingFinished(false);
});
/* State has changed, enforce a rerender */ /* State has changed, enforce a rerender */
cacheEntry.updateListener.forEach(callback => callback(false)); cacheEntry.updateListener.forEach(callback => callback(false));
@ -286,31 +334,54 @@ export abstract class UiVariableConsumer<Variables extends UiVariableMap> {
} }
} }
if(!_.isEqual(newValue, localValue)) { if(localValue.status !== "set" || !_.isEqual(newValue, localValue.value)) {
setLocalValue(newValue); setLocalValue({
status: "set",
value: newValue
});
} }
} }
} as any; };
} }
} }
useVariableReadOnly<T extends keyof Variables, DefaultValue>( useReadOnly<T extends keyof Variables>(
variable: T, variable: T,
customData?: any, customData?: any,
defaultValue?: DefaultValue defaultValue?: never
) : UiReadOnlyVariableStatus<Variables, T, DefaultValue> { ) : UiReadOnlyVariableStatus<Variables, T>;
/* TODO: Use a simplified logic here */
const v = this.useVariable(variable, customData); useReadOnly<T extends keyof Variables>(
if(v.status === "loading") { variable: T,
return { customData: any | undefined,
status: "loading", defaultValue: Variables[T]
value: defaultValue ) : Variables[T];
useReadOnly(variable, customData?, defaultValue?) {
const cacheEntry = this.getOrCreateVariable(variable as string, customData);
const [, setRemoteVersion ] = useState(0);
useEffect(() => {
/* Initial rendered */
let listener;
cacheEntry.updateListener.push(listener = () => {
/* We can't just increment the old one by one since this update listener may fires twice before rendering */
setRemoteVersion(++staticRevisionId);
});
return () => {
cacheEntry.updateListener.remove(listener);
this.derefVariable(cacheEntry);
}; };
}, []);
if(arguments.length >= 3) {
return cacheEntry.status === "loaded" ? cacheEntry.currentValue : defaultValue;
} else { } else {
return { return {
status: v.status, status: cacheEntry.status,
value: v.remoteValue value: cacheEntry.currentValue
} };
} }
} }