useSettings: add prefix/wildcard settings path matching (#3783)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
V 2025-11-21 13:09:07 +01:00 committed by GitHub
parent f87a513150
commit 52e76a9cfc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 65 additions and 11 deletions

View file

@ -183,8 +183,21 @@ export function useSettings(paths?: UseSettings<Settings>[]) {
useEffect(() => {
if (paths) {
paths.forEach(p => SettingsStore.addChangeListener(p, forceUpdate));
return () => paths.forEach(p => SettingsStore.removeChangeListener(p, forceUpdate));
paths.forEach(p => {
if (p.endsWith(".*")) {
SettingsStore.addPrefixChangeListener(p.slice(0, -2), forceUpdate);
} else {
SettingsStore.addChangeListener(p, forceUpdate);
}
});
return () => paths.forEach(p => {
if (p.endsWith(".*")) {
SettingsStore.removePrefixChangeListener(p.slice(0, -2), forceUpdate);
} else {
SettingsStore.removeChangeListener(p, forceUpdate);
}
});
} else {
SettingsStore.addGlobalChangeListener(forceUpdate);
return () => SettingsStore.removeGlobalChangeListener(forceUpdate);
@ -234,9 +247,11 @@ export function definePluginSettings<
if (!definedSettings.pluginName) throw new Error("Cannot access settings before plugin is initialized");
return PlainSettings.plugins[definedSettings.pluginName] as any;
},
use: settings => useSettings(
settings?.map(name => `plugins.${definedSettings.pluginName}.${name}`) as UseSettings<Settings>[]
).plugins[definedSettings.pluginName] as any,
use: settings => useSettings((
settings
? settings.map(name => `plugins.${definedSettings.pluginName}.${name}`)
: [`plugins.${definedSettings.pluginName}.*`]
) as UseSettings<Settings>[]).plugins[definedSettings.pluginName] as any,
def,
checks: checks ?? {} as any,
pluginName: "",
@ -256,7 +271,7 @@ type ResolveUseSettings<T extends object> = {
Key extends string
? T[Key] extends Record<string, unknown>
// @ts-expect-error "Type instantiation is excessively deep and possibly infinite"
? UseSettings<T[Key]> extends string ? `${Key}.${UseSettings<T[Key]>}` : never
? `${Key}.*` | (ResolveUseSettings<T[Key]> extends Record<string, string> ? `${Key}.${ResolveUseSettings<T[Key]>[keyof T[Key]]}` : never)
: Key
: never;
};

View file

@ -69,7 +69,7 @@ function makeDummyUser(user: { username: string; id?: string; avatar?: string; }
}
export default function PluginModal({ plugin, onRestartNeeded, onClose, transitionState }: PluginModalProps) {
const pluginSettings = useSettings().plugins[plugin.name];
const pluginSettings = useSettings([`plugins.${plugin.name}.*`]).plugins[plugin.name];
const hasSettings = Boolean(pluginSettings && plugin.options && !isObjectEmpty(plugin.options));
const [authors, setAuthors] = useState<Partial<User>[]>([]);

View file

@ -49,7 +49,7 @@ export function openNotificationSettingsModal() {
}
function NotificationSettings() {
const settings = useSettings().notifications;
const settings = useSettings(["notifications.*"]).notifications;
return (
<div style={{ padding: "1em 0" }}>

View file

@ -10,7 +10,12 @@ export const SYM_IS_PROXY = Symbol("SettingsStore.isProxy");
export const SYM_GET_RAW_TARGET = Symbol("SettingsStore.getRawTarget");
// Resolves a possibly nested prop in the form of "some.nested.prop" to type of T.some.nested.prop
type ResolvePropDeep<T, P> = P extends `${infer Pre}.${infer Suf}`
type ResolvePropDeep<T, P> =
P extends `${infer Pre}.*` ?
Pre extends keyof T
? T[Pre][keyof T[Pre]]
: any
: P extends `${infer Pre}.${infer Suf}`
? Pre extends keyof T
? ResolvePropDeep<T[Pre], Suf>
: any
@ -42,6 +47,7 @@ interface ProxyContext<T extends object = any> {
*/
export class SettingsStore<T extends object> {
private pathListeners = new Map<string, Set<(newData: any) => void>>();
private prefixListeners = new Map<string, Set<(newData: any, path: string) => void>>();
private globalListeners = new Set<(newData: T, path: string) => void>();
private readonly proxyContexts = new WeakMap<any, ProxyContext<T>>();
@ -152,6 +158,13 @@ export class SettingsStore<T extends object> {
return new Proxy(object, this.proxyHandler);
}
private notifyPrefixListeners(pathString: string, pathElements: string[], value: any) {
for (let i = 1; i <= pathElements.length; i++) {
const prefix = pathElements.slice(0, i).join(".");
this.prefixListeners.get(prefix)?.forEach(cb => cb(value, pathString));
}
}
private notifyListeners(pathStr: string, value: any, root: T) {
const paths = pathStr.split(".");
@ -172,6 +185,7 @@ export class SettingsStore<T extends object> {
}
this.pathListeners.get(pathStr)?.forEach(cb => cb(value));
this.notifyPrefixListeners(pathStr, paths, value);
}
/**
@ -203,6 +217,7 @@ export class SettingsStore<T extends object> {
}
this.pathListeners.get(pathToNotify)?.forEach(cb => cb(v));
this.notifyPrefixListeners(pathToNotify, path, v);
}
this.markAsChanged();
@ -229,8 +244,6 @@ export class SettingsStore<T extends object> {
* ```js
* Setting.store.foo.baz = "hi"
* ```
* @param path
* @param cb
*/
public addChangeListener<P extends LiteralUnion<keyof T, string>>(
path: P,
@ -241,6 +254,20 @@ export class SettingsStore<T extends object> {
this.pathListeners.set(path as string, listeners);
}
/**
* Add a prefix change listener that will fire whenever a setting matching the specified prefix is changed.
* For example if prefix is `"foo"`, the listener will fire on
* ```js
* Setting.store.foo.bar = "hi"
* Setting.store.foo.baz = "hi"
* ```
*/
public addPrefixChangeListener<P extends string>(prefix: P, cb: (data: ResolvePropDeep<T, P>, path: string) => void) {
const listeners = this.prefixListeners.get(prefix) ?? new Set();
listeners.add(cb);
this.prefixListeners.set(prefix, listeners);
}
/**
* Remove a global listener
* @see {@link addGlobalChangeListener}
@ -261,6 +288,18 @@ export class SettingsStore<T extends object> {
if (!listeners.size) this.pathListeners.delete(path as string);
}
/**
* Remove a prefix listener
* @see {@link addPrefixChangeListener}
*/
public removePrefixChangeListener(prefix: string, cb: (data: any, path: string) => void) {
const listeners = this.prefixListeners.get(prefix);
if (!listeners) return;
listeners.delete(cb);
if (!listeners.size) this.prefixListeners.delete(prefix);
}
/**
* Call all global change listeners
*/