@unbound-app/api 0.9.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ambient/image-modules.d.ts +58 -0
- package/package.json +30 -5
- package/src/_internal.d.ts +6110 -462
- package/src/{utils.d.ts → _types.d.ts} +10 -0
- package/src/assets.d.ts +64 -26
- package/src/builtins.d.ts +17 -0
- package/src/commands.d.ts +331 -0
- package/src/dialogs.d.ts +134 -0
- package/src/flux.d.ts +317 -0
- package/src/fonts.d.ts +51 -0
- package/src/fs.d.ts +16 -26
- package/src/global.d.ts +514 -31
- package/src/i18n.d.ts +33 -31
- package/src/iconPacks.d.ts +43 -0
- package/src/index.d.ts +16 -0
- package/src/marketplace.d.ts +50 -0
- package/src/metro/api.d.ts +10 -0
- package/src/metro/common.d.ts +34 -5
- package/src/metro/components.d.ts +100 -16
- package/src/metro/filters.d.ts +31 -16
- package/src/metro/index.d.ts +26097 -82
- package/src/metro/stores.d.ts +5 -0
- package/src/native.d.ts +123 -3
- package/src/patcher.d.ts +214 -57
- package/src/plugins.d.ts +55 -0
- package/src/settings.d.ts +9 -17
- package/src/shims.d.ts +5 -0
- package/src/storage.d.ts +80 -223
- package/src/sync.d.ts +51 -0
- package/src/themes.d.ts +43 -0
- package/src/toasts.d.ts +62 -9
- package/src/ui.d.ts +650 -0
- package/src/updates.d.ts +25 -0
- package/src/utils/index.d.ts +53 -0
- package/src/utils/react/index.d.ts +82 -0
- package/testing/fake-api.ts +256 -0
- package/testing/fake-components.ts +183 -0
- package/testing/fake-feedback.ts +258 -0
- package/testing/fake-fs.ts +54 -0
- package/testing/fake-java.ts +513 -0
- package/testing/fake-locales.ts +85 -0
- package/testing/fake-metro.ts +346 -0
- package/testing/fake-native-bridge.ts +98 -0
- package/testing/fake-native.ts +211 -0
- package/testing/fake-objc.ts +388 -0
- package/testing/fake-plugins.ts +176 -0
- package/testing/fake-react-native-modules.ts +362 -0
- package/testing/fake-react-native.ts +180 -0
- package/testing/fake-react.ts +54 -0
- package/testing/fake-settings-panel.ts +173 -0
- package/testing/fake-settings.ts +428 -0
- package/testing/fake-timers.ts +74 -0
- package/testing/fake-utils.ts +216 -0
- package/testing/icu-format.ts +132 -0
- package/testing/icu-parse.ts +277 -0
- package/testing/index.ts +42 -0
- package/testing/install-addon-globals.ts +260 -0
- package/testing/listener-registry.ts +39 -0
- package/testing/memory-storage.ts +149 -0
- package/testing/plugin-files.ts +155 -0
- package/testing/plugin-lifecycle.ts +149 -0
- package/testing/plugin-types.ts +34 -0
- package/testing/preset.ts +263 -0
- package/testing/react-patcher.ts +154 -0
- package/testing/react-value.ts +3 -0
- package/testing/recorders.ts +183 -0
- package/testing/recording-patcher.ts +173 -0
- package/testing/render.ts +84 -0
- package/testing/session.ts +77 -0
- package/testing/test-node.ts +163 -0
- package/testing/test-renderer.ts +753 -0
- package/testing/unfrozen-elements.ts +49 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { createElement, type ReactElement } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { SchemaNode, SettingsSchema, SettingsValues, SettingValue } from './fake-settings';
|
|
4
|
+
|
|
5
|
+
/** What the generated panel needs from the fake settings it is drawn for. */
|
|
6
|
+
export type PanelSource = {
|
|
7
|
+
schema: SettingsSchema;
|
|
8
|
+
/** The settings as `settings.ts` predicates read them, defaults applied. */
|
|
9
|
+
values: SettingsValues;
|
|
10
|
+
/** Reads a stored setting by dot-separated key. */
|
|
11
|
+
read: (key: string) => SettingValue | SettingsValues;
|
|
12
|
+
/** Saves a setting by dot-separated key. */
|
|
13
|
+
write: (key: string, value: SettingValue) => void;
|
|
14
|
+
/** Re-renders the panel when any setting changes. */
|
|
15
|
+
useChange: () => void;
|
|
16
|
+
/** Looks a message key up in the active locale, re-rendering when it changes. */
|
|
17
|
+
translate: (key: string) => string;
|
|
18
|
+
/** Runs the export of src/actions.ts a button names. */
|
|
19
|
+
runAction: (name: string) => Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type Condition = string | { predicate: true; run?: (settings: SettingsValues) => boolean };
|
|
23
|
+
|
|
24
|
+
const ROWS: Record<string, string> = {
|
|
25
|
+
'boolean:switch': 'SwitchRow',
|
|
26
|
+
'boolean:checkbox': 'CheckboxRow',
|
|
27
|
+
'integer:input': 'NumberRow',
|
|
28
|
+
'integer:slider': 'SliderRow',
|
|
29
|
+
'integer:stepper': 'StepperRow',
|
|
30
|
+
'integer:duration': 'DurationRow',
|
|
31
|
+
'string:input': 'InputRow',
|
|
32
|
+
'string:multiline': 'MultilineRow',
|
|
33
|
+
'string:secret': 'SecretRow',
|
|
34
|
+
'string:url': 'UrlRow',
|
|
35
|
+
'string:color': 'ColorRow',
|
|
36
|
+
'choice:radio': 'RadioRow',
|
|
37
|
+
'choice:picker': 'PickerRow',
|
|
38
|
+
'choice:segmented': 'SegmentedRow',
|
|
39
|
+
'array:list': 'ListRow',
|
|
40
|
+
'array:checkboxes': 'ChecklistRow',
|
|
41
|
+
button: 'ButtonRow',
|
|
42
|
+
notice: 'NoticeRow',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// `number` shares the integer rows, and each entity display is an entity row.
|
|
46
|
+
function rowName(node: SchemaNode): string {
|
|
47
|
+
const kind = node.kind === 'number' ? 'integer' : node.kind;
|
|
48
|
+
const display = String(node.displayAs);
|
|
49
|
+
const entity = ['user', 'channel', 'guild', 'role'].includes(display);
|
|
50
|
+
|
|
51
|
+
if (display === 'color' && node.palette === true) {
|
|
52
|
+
return 'PaletteRow';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
ROWS[kind] ??
|
|
57
|
+
ROWS[`${kind}:${display}`] ??
|
|
58
|
+
(entity ? 'EntityRow' : `${display[0]?.toUpperCase()}${display.slice(1)}Row`)
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Mirrors the client's `evaluateCondition`: a boolean setting's key, or the predicate the build lifted out of settings.ts.
|
|
63
|
+
function holds(source: PanelSource, condition: Condition | undefined): boolean | undefined {
|
|
64
|
+
if (condition === undefined) return;
|
|
65
|
+
|
|
66
|
+
if (typeof condition === 'string') {
|
|
67
|
+
return source.read(condition) === true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return condition.run ? Boolean(condition.run(source.values)) : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function row(source: PanelSource, path: string, node: SchemaNode): ReactElement | null {
|
|
74
|
+
if (
|
|
75
|
+
node.hidden === true ||
|
|
76
|
+
holds(source, node.visibleWhen as Condition | undefined) === false
|
|
77
|
+
) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const { translate } = source;
|
|
82
|
+
const title = node.title === undefined ? path : translate(node.title);
|
|
83
|
+
const subtitle = node.subtitle === undefined ? undefined : translate(node.subtitle);
|
|
84
|
+
const texts = [title, subtitle].filter((text) => text !== undefined);
|
|
85
|
+
const common = {
|
|
86
|
+
path,
|
|
87
|
+
title,
|
|
88
|
+
subtitle,
|
|
89
|
+
disabled: holds(source, node.disabledWhen as Condition | undefined) === true,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if (node.kind === 'notice') {
|
|
93
|
+
return createElement('NoticeRow', { key: path, ...common, tone: node.tone }, ...texts);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (node.kind === 'button') {
|
|
97
|
+
return createElement(
|
|
98
|
+
'ButtonRow',
|
|
99
|
+
{
|
|
100
|
+
key: path,
|
|
101
|
+
...common,
|
|
102
|
+
variant: node.displayAs,
|
|
103
|
+
confirm: node.confirm === undefined ? undefined : translate(String(node.confirm)),
|
|
104
|
+
onPress: () => source.runAction(String(node.action)),
|
|
105
|
+
},
|
|
106
|
+
...texts,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return createElement(
|
|
111
|
+
rowName(node),
|
|
112
|
+
{
|
|
113
|
+
key: path,
|
|
114
|
+
...common,
|
|
115
|
+
value: source.read(path),
|
|
116
|
+
onChange: (value: SettingValue) => source.write(path, value),
|
|
117
|
+
},
|
|
118
|
+
...texts,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function section(source: PanelSource, path: string, node: SchemaNode): ReactElement | null {
|
|
123
|
+
if (
|
|
124
|
+
node.hidden === true ||
|
|
125
|
+
holds(source, node.visibleWhen as Condition | undefined) === false
|
|
126
|
+
) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const title = node.title === undefined ? undefined : source.translate(node.title);
|
|
131
|
+
const rows = Object.entries(node.properties ?? {}).map(([key, child]) =>
|
|
132
|
+
row(source, path ? `${path}.${key}` : key, child),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
return createElement(
|
|
136
|
+
'Section',
|
|
137
|
+
{ key: path || 'settings', path, title },
|
|
138
|
+
...(title === undefined ? [] : [title]),
|
|
139
|
+
...rows,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @description Creates the fake generated settings panel: one named row per setting, button and notice, grouped in a section
|
|
145
|
+
* per `setting.section`, with `hidden`, `visibleWhen` and `disabledWhen` honoured as the client's panel does.
|
|
146
|
+
* @param source The settings it draws, and what its rows call.
|
|
147
|
+
* @returns The panel component.
|
|
148
|
+
*/
|
|
149
|
+
export function createSettingsPanel(source: PanelSource): () => ReactElement {
|
|
150
|
+
function SettingsPanel() {
|
|
151
|
+
source.useChange();
|
|
152
|
+
|
|
153
|
+
const top: Record<string, SchemaNode> = {};
|
|
154
|
+
const sections: [string, SchemaNode][] = [];
|
|
155
|
+
|
|
156
|
+
for (const [key, node] of Object.entries(source.schema.properties)) {
|
|
157
|
+
if (node.kind === 'section') {
|
|
158
|
+
sections.push([key, node]);
|
|
159
|
+
} else {
|
|
160
|
+
top[key] = node;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return createElement(
|
|
165
|
+
'SettingsPanel',
|
|
166
|
+
{ schema: source.schema },
|
|
167
|
+
section(source, '', { kind: 'section', title: source.schema.title, properties: top }),
|
|
168
|
+
...sections.map(([key, node]) => section(source, key, node)),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return SettingsPanel;
|
|
173
|
+
}
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import { createElement, useEffect, useState, type ReactElement } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { SettingsPayload } from '../src/storage';
|
|
4
|
+
import type { MemoryStorage } from './memory-storage';
|
|
5
|
+
import type { FakeModule } from './fake-metro';
|
|
6
|
+
|
|
7
|
+
import { createSettingsPanel } from './fake-settings-panel';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One node of a settings schema, as `@unbound-app/settings/schema`'s builders produce it once read as JSON. The
|
|
11
|
+
* options a builder copies that only the panel reads, such as `placeholder` or `action`, stay untyped.
|
|
12
|
+
*/
|
|
13
|
+
export type SchemaNode = FakeModule & {
|
|
14
|
+
kind: string;
|
|
15
|
+
displayAs?: string;
|
|
16
|
+
title?: string;
|
|
17
|
+
default?: SettingValue;
|
|
18
|
+
minimum?: number;
|
|
19
|
+
maximum?: number;
|
|
20
|
+
multipleOf?: number;
|
|
21
|
+
minLength?: number;
|
|
22
|
+
maxLength?: number;
|
|
23
|
+
alpha?: boolean;
|
|
24
|
+
minItems?: number;
|
|
25
|
+
maxItems?: number;
|
|
26
|
+
options?: SchemaChoice[];
|
|
27
|
+
of?: SchemaNode;
|
|
28
|
+
properties?: Record<string, SchemaNode>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** One option of a choice setting. */
|
|
32
|
+
export type SchemaChoice = { const: string; title?: string };
|
|
33
|
+
|
|
34
|
+
/** What `defineSettings` takes besides the settings. */
|
|
35
|
+
export type DefineOptions = { title?: string };
|
|
36
|
+
|
|
37
|
+
/** What `defineSettings` returns: the schema of a plugin's settings.ts. */
|
|
38
|
+
export type SettingsSchema = SchemaNode & {
|
|
39
|
+
kind: 'settings';
|
|
40
|
+
properties: Record<string, SchemaNode>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** A value one setting holds. */
|
|
44
|
+
export type SettingValue = string | number | boolean | string[];
|
|
45
|
+
|
|
46
|
+
/** A plugin's settings as an object: reads apply defaults, writes are validated and saved. Sections nest. */
|
|
47
|
+
export type SettingsValues = { [key: string]: SettingValue | SettingsValues };
|
|
48
|
+
|
|
49
|
+
/** The fake `@unbound-app/settings`, bound to a memory Bucket. */
|
|
50
|
+
export type FakeSettings = {
|
|
51
|
+
schema: SettingsSchema;
|
|
52
|
+
settings: SettingsValues;
|
|
53
|
+
/** Reads a setting, a section, or a section's setting by dot-separated key, and re-renders when it changes. */
|
|
54
|
+
useSetting: (key: string) => SettingValue | SettingsValues;
|
|
55
|
+
/** Puts every setting back to its default. */
|
|
56
|
+
resetSettings: () => void;
|
|
57
|
+
/** Saves a setting by dot-separated key; throws when the schema rejects the value. */
|
|
58
|
+
set: (key: string, value: SettingValue) => void;
|
|
59
|
+
SettingsPanel: () => ReactElement;
|
|
60
|
+
getSettingsPanel: () => ReactElement;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** A builder's options: the plugin's generated typings check them; here they are read as plain JSON. */
|
|
64
|
+
type Options = FakeModule;
|
|
65
|
+
|
|
66
|
+
const COMMON = ['title', 'subtitle', 'hidden', 'requiresRestart'];
|
|
67
|
+
const CONDITIONS = ['visibleWhen', 'disabledWhen'];
|
|
68
|
+
const STRING = ['minLength', 'maxLength', 'placeholder', 'alpha', 'presets', 'palette', 'maxSize'];
|
|
69
|
+
|
|
70
|
+
// Mirrors the build's builders: the JSON keeps these options, and a predicate becomes `{ predicate: true }`.
|
|
71
|
+
function meta(kind: string, displayAs: string, options: Options, keys: string[] = []): SchemaNode {
|
|
72
|
+
const picked: SchemaNode = { kind, displayAs };
|
|
73
|
+
|
|
74
|
+
for (const key of [...COMMON, ...keys, 'default']) {
|
|
75
|
+
if (options[key] !== undefined) {
|
|
76
|
+
picked[key] = options[key];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const key of CONDITIONS) {
|
|
81
|
+
const condition = options[key];
|
|
82
|
+
|
|
83
|
+
if (condition !== undefined) {
|
|
84
|
+
// The panel runs the predicate the schema JSON drops: it rides on the marker, out of sight of equality and JSON.
|
|
85
|
+
picked[key] =
|
|
86
|
+
typeof condition === 'function'
|
|
87
|
+
? Object.defineProperty({ predicate: true }, 'run', { value: condition })
|
|
88
|
+
: condition;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return picked;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function numeric(kind: string, options: Options): SchemaNode {
|
|
96
|
+
const ranged = options.min !== undefined && options.max !== undefined;
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
...meta(kind, options.displayAs ?? (ranged ? 'slider' : 'input'), options, ['units']),
|
|
100
|
+
...(typeof options.min === 'number' ? { minimum: options.min } : {}),
|
|
101
|
+
...(typeof options.max === 'number' ? { maximum: options.max } : {}),
|
|
102
|
+
...(typeof options.step === 'number' ? { multipleOf: options.step } : {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function choices(options: Options): SchemaChoice[] {
|
|
107
|
+
const labels: Options =
|
|
108
|
+
typeof options.options === 'object' && options.options ? options.options : {};
|
|
109
|
+
const entries = Object.entries(labels);
|
|
110
|
+
|
|
111
|
+
return entries.map(([value, label]) =>
|
|
112
|
+
typeof label === 'string' ? { const: value, title: label } : { const: value, ...label },
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The `setting` builders of `@unbound-app/settings/schema`: one per stored primitive, `section`, `button` and `notice`; `displayAs` picks the widget. */
|
|
117
|
+
export const setting = createSettingBuilders();
|
|
118
|
+
|
|
119
|
+
function createSettingBuilders() {
|
|
120
|
+
return {
|
|
121
|
+
boolean: (options: Options = {}) => meta('boolean', options.displayAs ?? 'switch', options),
|
|
122
|
+
number: (options: Options = {}) => numeric('number', options),
|
|
123
|
+
integer: (options: Options = {}) => numeric('integer', options),
|
|
124
|
+
string: (options: Options = {}) =>
|
|
125
|
+
meta('string', options.displayAs ?? 'input', options, STRING),
|
|
126
|
+
choice(options: Options = {}): SchemaNode {
|
|
127
|
+
const list = choices(options);
|
|
128
|
+
const displayAs = options.displayAs ?? (list.length > 5 ? 'picker' : 'radio');
|
|
129
|
+
|
|
130
|
+
return { ...meta('choice', displayAs, options), options: list };
|
|
131
|
+
},
|
|
132
|
+
array(options: Options = {}): SchemaNode {
|
|
133
|
+
const of: Options =
|
|
134
|
+
typeof options.of === 'object' && options.of ? { ...options.of } : {};
|
|
135
|
+
const displayAs = options.displayAs ?? (of.kind === 'choice' ? 'checkboxes' : 'list');
|
|
136
|
+
|
|
137
|
+
delete of.default;
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
...meta('array', displayAs, options, ['minItems', 'maxItems']),
|
|
141
|
+
of: { ...of, kind: String(of.kind) },
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
section: (options: Options, properties: Record<string, SchemaNode>): SchemaNode => ({
|
|
145
|
+
...meta('section', options.displayAs ?? 'group', options),
|
|
146
|
+
properties,
|
|
147
|
+
}),
|
|
148
|
+
button: (options: Options) =>
|
|
149
|
+
meta('button', options.displayAs ?? 'button', options, ['action', 'confirm']),
|
|
150
|
+
notice: (options: Options) => ({
|
|
151
|
+
...meta('notice', 'notice', options, ['tone']),
|
|
152
|
+
tone: options.tone ?? 'info',
|
|
153
|
+
}),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @description Declares a plugin's settings: the default export of its settings.ts.
|
|
159
|
+
* @param properties The settings, sections and actions, built with {@link setting}.
|
|
160
|
+
* @param options The message key of the title of the section holding the top-level settings.
|
|
161
|
+
* @returns The schema.
|
|
162
|
+
*/
|
|
163
|
+
export function defineSettings(
|
|
164
|
+
properties: Record<string, SchemaNode>,
|
|
165
|
+
options: DefineOptions = {},
|
|
166
|
+
): SettingsSchema {
|
|
167
|
+
return {
|
|
168
|
+
kind: 'settings',
|
|
169
|
+
...(options.title === undefined ? {} : { title: options.title }),
|
|
170
|
+
properties,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isMultiple(value: number, step: number): boolean {
|
|
175
|
+
const ratio = value / step;
|
|
176
|
+
|
|
177
|
+
return Math.abs(ratio - Math.round(ratio)) < 1e-9;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Mirrors the client's `isSettingValue` for the checks a test can trip: the primitive, its range and its length.
|
|
181
|
+
function isValue(field: SchemaNode, value: unknown): value is SettingValue {
|
|
182
|
+
switch (field.kind) {
|
|
183
|
+
case 'boolean':
|
|
184
|
+
return typeof value === 'boolean';
|
|
185
|
+
case 'string':
|
|
186
|
+
return (
|
|
187
|
+
typeof value === 'string' &&
|
|
188
|
+
value.length >= (field.minLength ?? 0) &&
|
|
189
|
+
value.length <= (field.maxLength ?? Infinity)
|
|
190
|
+
);
|
|
191
|
+
case 'choice':
|
|
192
|
+
return (field.options ?? []).some((choice) => choice.const === value);
|
|
193
|
+
case 'array':
|
|
194
|
+
return (
|
|
195
|
+
Array.isArray(value) &&
|
|
196
|
+
value.length >= (field.minItems ?? 0) &&
|
|
197
|
+
value.length <= (field.maxItems ?? Infinity) &&
|
|
198
|
+
new Set(value).size === value.length &&
|
|
199
|
+
value.every((item) => !field.of || isValue(field.of, item))
|
|
200
|
+
);
|
|
201
|
+
default:
|
|
202
|
+
return (
|
|
203
|
+
typeof value === 'number' &&
|
|
204
|
+
Number.isFinite(value) &&
|
|
205
|
+
(field.kind !== 'integer' || Number.isInteger(value)) &&
|
|
206
|
+
value >= (field.minimum ?? -Infinity) &&
|
|
207
|
+
value <= (field.maximum ?? Infinity) &&
|
|
208
|
+
(field.multipleOf === undefined || isMultiple(value, field.multipleOf))
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// A setting whose builder took no default, such as an entity picker, reads empty.
|
|
214
|
+
function emptyValue(field: SchemaNode): SettingValue {
|
|
215
|
+
switch (field.kind) {
|
|
216
|
+
case 'boolean':
|
|
217
|
+
return false;
|
|
218
|
+
case 'string':
|
|
219
|
+
return '';
|
|
220
|
+
case 'choice':
|
|
221
|
+
return field.options?.[0]?.const ?? '';
|
|
222
|
+
case 'array':
|
|
223
|
+
return [];
|
|
224
|
+
default:
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function entries(schema: SettingsSchema): [string, SchemaNode][] {
|
|
230
|
+
return Object.entries(schema.properties)
|
|
231
|
+
.flatMap(([key, node]): [string, SchemaNode][] =>
|
|
232
|
+
node.kind === 'section'
|
|
233
|
+
? Object.entries(node.properties ?? {}).map(([child, field]) => [
|
|
234
|
+
`${key}.${child}`,
|
|
235
|
+
field,
|
|
236
|
+
])
|
|
237
|
+
: [[key, node]],
|
|
238
|
+
)
|
|
239
|
+
.filter(([, field]) => field.kind !== 'button' && field.kind !== 'notice');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function affects(payload: SettingsPayload, key: string): boolean {
|
|
243
|
+
return (
|
|
244
|
+
payload.key === null ||
|
|
245
|
+
payload.key === key ||
|
|
246
|
+
key.startsWith(`${payload.key}.`) ||
|
|
247
|
+
payload.key.startsWith(`${key}.`)
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** What the generated panel reads besides the settings: message lookup and actions. */
|
|
252
|
+
export type PanelOptions = {
|
|
253
|
+
/** Looks a message key up in the active locale, re-rendering when it changes. Defaults to the key. */
|
|
254
|
+
translate?: (key: string) => string;
|
|
255
|
+
/** Runs the export of the plugin's src/actions.ts a button names. Defaults to throwing, naming the action. */
|
|
256
|
+
runAction?: (name: string) => Promise<void>;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* @description Binds a settings schema to a memory Bucket, as the client's `storage.define` does: defaults apply on
|
|
261
|
+
* read, and each write is checked against the schema.
|
|
262
|
+
* @param schema The schema settings.ts evaluated to.
|
|
263
|
+
* @param storage The memory storage holding the Bucket.
|
|
264
|
+
* @param bucketName The Bucket, the plugin's id.
|
|
265
|
+
* @param panel How the generated panel translates titles and runs buttons.
|
|
266
|
+
* @returns The settings, their hook, reset and panel.
|
|
267
|
+
*/
|
|
268
|
+
export function createFakeSettings(
|
|
269
|
+
schema: SettingsSchema,
|
|
270
|
+
storage: MemoryStorage,
|
|
271
|
+
bucketName: string,
|
|
272
|
+
panel: PanelOptions = {},
|
|
273
|
+
): FakeSettings {
|
|
274
|
+
const bucket = storage.getBucket(bucketName);
|
|
275
|
+
const fields = new Map(entries(schema));
|
|
276
|
+
|
|
277
|
+
function field(key: string): SchemaNode {
|
|
278
|
+
const found = fields.get(key);
|
|
279
|
+
|
|
280
|
+
if (!found) {
|
|
281
|
+
throw new TypeError(`Failed to find setting ${key}: it is not in the schema.`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return found;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function get(key: string): SettingValue {
|
|
288
|
+
const setting = field(key);
|
|
289
|
+
const stored = bucket.get<unknown>(key, undefined);
|
|
290
|
+
|
|
291
|
+
return isValue(setting, stored) ? stored : (setting.default ?? emptyValue(setting));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function set(key: string, value: SettingValue) {
|
|
295
|
+
if (!isValue(field(key), value)) {
|
|
296
|
+
throw new TypeError(
|
|
297
|
+
`Failed to set setting ${key}: ${JSON.stringify(value)} does not match its schema.`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
bucket.set(key, value);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function view(prefix: string, properties: Record<string, SchemaNode>): SettingsValues {
|
|
305
|
+
const sections = new Map<string, SettingsValues>();
|
|
306
|
+
const pathOf = (key: PropertyKey) => (prefix ? `${prefix}.${String(key)}` : String(key));
|
|
307
|
+
// A button or notice stores nothing, so it is not one of the settings.
|
|
308
|
+
const owns = (key: PropertyKey) =>
|
|
309
|
+
typeof key === 'string' &&
|
|
310
|
+
Object.hasOwn(properties, key) &&
|
|
311
|
+
properties[key]?.kind !== 'button' &&
|
|
312
|
+
properties[key]?.kind !== 'notice';
|
|
313
|
+
|
|
314
|
+
function read(key: string) {
|
|
315
|
+
const node = properties[key];
|
|
316
|
+
|
|
317
|
+
if (node?.kind !== 'section') {
|
|
318
|
+
return get(pathOf(key));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
let section = sections.get(key);
|
|
322
|
+
|
|
323
|
+
if (!section) {
|
|
324
|
+
section = view(pathOf(key), node.properties ?? {});
|
|
325
|
+
sections.set(key, section);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return section;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return new Proxy<SettingsValues>(
|
|
332
|
+
{},
|
|
333
|
+
{
|
|
334
|
+
get: (_target, key) => (owns(key) ? read(String(key)) : undefined),
|
|
335
|
+
set(_target, key, value: SettingValue) {
|
|
336
|
+
const node = typeof key === 'string' ? properties[key] : undefined;
|
|
337
|
+
|
|
338
|
+
if (!node || node.kind === 'section') {
|
|
339
|
+
throw new TypeError(
|
|
340
|
+
`Failed to set setting ${pathOf(key)}: it is not a setting.`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
set(pathOf(key), value);
|
|
345
|
+
|
|
346
|
+
return true;
|
|
347
|
+
},
|
|
348
|
+
has: (_target, key) => owns(key),
|
|
349
|
+
ownKeys: () => Object.keys(properties).filter(owns),
|
|
350
|
+
getOwnPropertyDescriptor: (_target, key) =>
|
|
351
|
+
owns(key)
|
|
352
|
+
? {
|
|
353
|
+
value: read(String(key)),
|
|
354
|
+
writable: true,
|
|
355
|
+
enumerable: true,
|
|
356
|
+
configurable: true,
|
|
357
|
+
}
|
|
358
|
+
: undefined,
|
|
359
|
+
},
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const values = view('', schema.properties);
|
|
364
|
+
|
|
365
|
+
function read(key: string): SettingValue | SettingsValues {
|
|
366
|
+
if (fields.has(key)) {
|
|
367
|
+
return get(key);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const section = values[key];
|
|
371
|
+
|
|
372
|
+
if (section === undefined) {
|
|
373
|
+
throw new TypeError(`Failed to find setting ${key}: it is not in the schema.`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return section;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const SettingsPanel = createSettingsPanel({
|
|
380
|
+
schema,
|
|
381
|
+
values,
|
|
382
|
+
read,
|
|
383
|
+
write: set,
|
|
384
|
+
translate: panel.translate ?? ((key) => key),
|
|
385
|
+
runAction:
|
|
386
|
+
panel.runAction ??
|
|
387
|
+
(async (name) => {
|
|
388
|
+
throw new Error(`Failed to run action ${name}: the plugin has no src/actions.ts.`);
|
|
389
|
+
}),
|
|
390
|
+
useChange() {
|
|
391
|
+
const [, forceUpdate] = useState({});
|
|
392
|
+
|
|
393
|
+
useEffect(
|
|
394
|
+
() =>
|
|
395
|
+
storage.onChange(
|
|
396
|
+
(payload) => payload.store === bucketName,
|
|
397
|
+
() => forceUpdate({}),
|
|
398
|
+
),
|
|
399
|
+
[],
|
|
400
|
+
);
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
schema,
|
|
406
|
+
settings: values,
|
|
407
|
+
set,
|
|
408
|
+
useSetting(key) {
|
|
409
|
+
const [, forceUpdate] = useState({});
|
|
410
|
+
|
|
411
|
+
useEffect(
|
|
412
|
+
() =>
|
|
413
|
+
storage.onChange(
|
|
414
|
+
(payload) => payload.store === bucketName && affects(payload, key),
|
|
415
|
+
() => forceUpdate({}),
|
|
416
|
+
),
|
|
417
|
+
[key],
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
return read(key);
|
|
421
|
+
},
|
|
422
|
+
resetSettings() {
|
|
423
|
+
for (const key of fields.keys()) bucket.remove(key);
|
|
424
|
+
},
|
|
425
|
+
SettingsPanel,
|
|
426
|
+
getSettingsPanel: () => createElement(SettingsPanel),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
type TimerHandle = ReturnType<typeof setTimeout>;
|
|
2
|
+
|
|
3
|
+
type Schedule = <A extends unknown[]>(
|
|
4
|
+
callback: (...args: A) => void,
|
|
5
|
+
delay?: number,
|
|
6
|
+
...args: A
|
|
7
|
+
) => TimerHandle;
|
|
8
|
+
|
|
9
|
+
type Clear = (handle: TimerHandle | undefined) => void;
|
|
10
|
+
|
|
11
|
+
/** The timer functions of the plugin scope: they run for real, and the scope clears what is still pending when the plugin stops. */
|
|
12
|
+
export type FakeTimers = {
|
|
13
|
+
setTimeout: Schedule;
|
|
14
|
+
setInterval: Schedule;
|
|
15
|
+
clearTimeout: Clear;
|
|
16
|
+
clearInterval: Clear;
|
|
17
|
+
/** How many timeouts and intervals are scheduled and not yet run or cleared. */
|
|
18
|
+
pending: () => number;
|
|
19
|
+
/** Clears every pending timeout and interval, as the scope does on stop. */
|
|
20
|
+
clearAll: () => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @description Creates the scoped timers, over the timer functions that exist when it is called.
|
|
25
|
+
* @returns The timers.
|
|
26
|
+
*/
|
|
27
|
+
export function createFakeTimers(): FakeTimers {
|
|
28
|
+
const real = {
|
|
29
|
+
setTimeout: globalThis.setTimeout.bind(globalThis),
|
|
30
|
+
setInterval: globalThis.setInterval.bind(globalThis),
|
|
31
|
+
clearTimeout: globalThis.clearTimeout.bind(globalThis),
|
|
32
|
+
clearInterval: globalThis.clearInterval.bind(globalThis),
|
|
33
|
+
};
|
|
34
|
+
const pending = new Map<TimerHandle, Clear>();
|
|
35
|
+
|
|
36
|
+
function track(handle: TimerHandle, clear: Clear): TimerHandle {
|
|
37
|
+
pending.set(handle, clear);
|
|
38
|
+
|
|
39
|
+
return handle;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function release(handle: TimerHandle | undefined, clear: Clear) {
|
|
43
|
+
if (handle !== undefined) {
|
|
44
|
+
pending.delete(handle);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
clear(handle);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
setTimeout(callback, delay, ...args) {
|
|
52
|
+
const handle = real.setTimeout(
|
|
53
|
+
(...params: typeof args) => {
|
|
54
|
+
pending.delete(handle);
|
|
55
|
+
callback(...params);
|
|
56
|
+
},
|
|
57
|
+
delay,
|
|
58
|
+
...args,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return track(handle, real.clearTimeout);
|
|
62
|
+
},
|
|
63
|
+
setInterval: (callback, delay, ...args) =>
|
|
64
|
+
track(real.setInterval(callback, delay, ...args), real.clearInterval),
|
|
65
|
+
clearTimeout: (handle) => release(handle, real.clearTimeout),
|
|
66
|
+
clearInterval: (handle) => release(handle, real.clearInterval),
|
|
67
|
+
pending: () => pending.size,
|
|
68
|
+
clearAll() {
|
|
69
|
+
for (const [handle, clear] of pending) clear(handle);
|
|
70
|
+
|
|
71
|
+
pending.clear();
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|