Modes: a mode that no longer matches the features reads "Custom (based on <mode>)" and offers "Re-apply <mode>"
A mode is a starting point, never a lock and never applied again on its own. modeStatus() (runtime) compares the flags with the mode's preset: custom, what re-applying turns on and off, and which of those the preset gained in an update since it was applied. mode_set keeps the preset of that moment (modes.json "preset", flag names only, bounded; forgotten when the mode changes). Settings, the first-run screen and the app menu's Mode row show Custom; Re-apply lists what it turns on and off before it writes anything; a preset that grew is mentioned once on the mode row (Review), never applied. Tests: exact match, changed by the owner, preset grew, choice made before the preset was kept.
This commit is contained in:
parent
57524ae2ff
commit
7d30df6aaa
9 changed files with 288 additions and 27 deletions
|
|
@ -3,13 +3,18 @@
|
|||
// preselected; someone who set the app up before modes existed may dismiss it and keep exactly
|
||||
// what they have. `ModeSettings` is the same choice later, in Settings, with the question whether
|
||||
// the owner's own changes stay. A mode is a starting point: choosing one writes flag values
|
||||
// (`features_set`) and remembers the choice (`mode_get/set`); it never takes a switch away.
|
||||
// (`features_set`) and remembers the choice (`mode_get/set`); it never takes a switch away, and it
|
||||
// is never applied again on its own. When the flags are no longer what the mode starts from (the
|
||||
// owner changed features, or an update made the mode larger) the mode reads "Custom (based on
|
||||
// <mode>)" and one action, "Re-apply <mode>", lists what it turns on and off before it does.
|
||||
import { useEffect, useState, type ComponentType } from 'react';
|
||||
import { modeById, normalizeFlags, sparseFlags as sparse, type FeatureTable, type Flags } from '../../../runtime/src/featureTable.ts';
|
||||
import {
|
||||
areaFlag, featureWords, modeById, modeFlags, modeStatus, normalizeFlags, sparseFlags as sparse, type FeatureTable, type Flags, type ModeStatus,
|
||||
} from '../../../runtime/src/featureTable.ts';
|
||||
import { invoke } from '../frame/bridge.ts';
|
||||
import { useT } from '../frame/hooks.ts';
|
||||
import { tx } from '../frame/i18n.ts';
|
||||
import type { MenuModes } from '../frame/menuModel.ts';
|
||||
import type { MenuModeStatus, MenuModes } from '../frame/menuModel.ts';
|
||||
import { shell } from '../frame/state.ts';
|
||||
import { Badge, Button, Row, Section } from './controls.tsx';
|
||||
import { modePlan, type PluginFileState } from './featureModel.ts';
|
||||
|
|
@ -17,12 +22,16 @@ import { featureTable, flagsChanged, useFlags } from './PluginFeatures.tsx';
|
|||
import { panelSite, type Notify } from './site.ts';
|
||||
import './plugins.css';
|
||||
|
||||
/** The onboarding state native code keeps (waybetter-host `modes::ModeState`). */
|
||||
export type ModeState = { mode: string | null; onboarded: boolean; existing: boolean };
|
||||
/**
|
||||
* The onboarding state native code keeps (waybetter-host `modes::ModeState`). `preset`: the mode's
|
||||
* flags as they were when it was applied (absent: not known).
|
||||
*/
|
||||
export type ModeState = { mode: string | null; onboarded: boolean; existing: boolean; preset?: Record<string, boolean> | null };
|
||||
|
||||
const getError = (error: unknown) => error instanceof Error ? error.message : String(error);
|
||||
const listeners = new Set<(state: ModeState) => void>();
|
||||
let cached: ModeState | null = null;
|
||||
const share = (next: ModeState) => { cached = next; for (const listener of listeners) listener(next); };
|
||||
|
||||
/** The mode state, loaded once and shared by the first-run screen and Settings. Null until loaded (or without modes). */
|
||||
export function useModeState(): [ModeState | null, (next: ModeState) => void] {
|
||||
|
|
@ -46,12 +55,42 @@ async function applyMode(table: FeatureTable, flags: Flags, from: string | null,
|
|||
if (!await save(plan.flags)) return null;
|
||||
for (const file of plan.disable) await invoke('plugin_set_enabled', { file, enabled: false }).catch(() => {});
|
||||
if (plan.disable.length) window.dispatchEvent(new CustomEvent('bt-plugins-changed'));
|
||||
const state = await invoke<ModeState>('mode_set', { mode: to, onboarded: true });
|
||||
const state = await invoke<ModeState>('mode_set', { mode: to, onboarded: true, preset: modeById(table, to)?.flags ?? {} });
|
||||
flagsChanged();
|
||||
await panelSite().features?.onMode?.(to);
|
||||
return state;
|
||||
}
|
||||
|
||||
/** "Re-apply <mode>": every flag back to what the mode starts from now. Plugin files are not touched. */
|
||||
async function reapplyMode(table: FeatureTable, mode: string, save: (flags: Flags) => Promise<boolean>): Promise<ModeState | null> {
|
||||
if (!modeById(table, mode) || !await save(modeFlags(table, mode))) return null;
|
||||
const state = await invoke<ModeState>('mode_set', { mode, onboarded: true, preset: modeById(table, mode)!.flags });
|
||||
flagsChanged();
|
||||
return state;
|
||||
}
|
||||
|
||||
/** The "new features" note was looked at: the mode's preset of today is the one remembered, so it is said once. */
|
||||
const presetSeen = (table: FeatureTable, state: ModeState) =>
|
||||
invoke<ModeState>('mode_set', { mode: state.mode, onboarded: state.onboarded, preset: modeById(table, state.mode)?.flags ?? {} });
|
||||
|
||||
/** A flag as the owner knows it: the feature's label, or the area's name for its switch. */
|
||||
function flagName(table: FeatureTable, id: string): string {
|
||||
const area = table.areas.find(entry => areaFlag(entry.id) === id);
|
||||
if (area) return area.name;
|
||||
const feature = table.features.find(entry => entry.id === id);
|
||||
return feature ? tx(featureWords(feature).label, undefined, id) : id;
|
||||
}
|
||||
const flagNames = (table: FeatureTable, ids: readonly string[]) => ids.map(id => flagName(table, id)).join(', ');
|
||||
|
||||
/** What "Re-apply" would do, in words: one line for what turns on, one for what turns off. */
|
||||
function ReapplyList({ table, status }: { table: FeatureTable; status: ModeStatus }) {
|
||||
const t = useT();
|
||||
return <>
|
||||
{status.on.length > 0 && <small>{t('wb.modes.turnsOn', { list: flagNames(table, status.on) })}</small>}
|
||||
{status.off.length > 0 && <small>{t('wb.modes.turnsOff', { list: flagNames(table, status.off) })}</small>}
|
||||
</>;
|
||||
}
|
||||
|
||||
function ModeCards({ table, selected, onSelect, Art }: { table: FeatureTable; selected: string | null; onSelect(id: string): void; Art?: ComponentType<{ mode: string }> }) {
|
||||
const t = useT();
|
||||
return <div className="wbm-cards" role="radiogroup" aria-label={t('wb.modes.section')}>
|
||||
|
|
@ -82,6 +121,8 @@ export function Onboarding({ state, done, notify }: { state: ModeState; done(nex
|
|||
const [selected, setSelected] = useState<string | null>(state.existing ? null : recommended);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const mode = modeById(table, selected);
|
||||
const stored = modeById(table, state.mode);
|
||||
const custom = !!stored && !!flags && modeStatus(table, flags, stored.id, state.preset).custom;
|
||||
|
||||
async function finish(next: () => Promise<ModeState | null>) {
|
||||
setBusy(true);
|
||||
|
|
@ -99,6 +140,7 @@ export function Onboarding({ state, done, notify }: { state: ModeState; done(nex
|
|||
<p>{t('wb.modes.lede')}</p>
|
||||
<ModeCards table={table} selected={selected} onSelect={setSelected} Art={panel.features?.ModeArt}/>
|
||||
{state.existing && <p className="wbm-note">{t('wb.modes.existing', { app: panel.brand.name })}</p>}
|
||||
{custom && <p className="wbm-note">{t('wb.modes.custom', { mode: tx(stored!.name, undefined, stored!.id) })}</p>}
|
||||
<div className="wbm-actions">
|
||||
{state.existing && <Button tone={selected ? 'ghost' : 'primary'} disabled={busy} onClick={dismiss}>{t('wb.modes.keepSetup')}</Button>}
|
||||
{mode && state.existing && <Button disabled={busy || !flags} onClick={() => start(true)}>{t('wb.modes.keep')}</Button>}
|
||||
|
|
@ -118,9 +160,25 @@ function ModeSettingsOf({ table, notify }: { table: FeatureTable; notify: Notify
|
|||
const [state, setState] = useModeState();
|
||||
const [flags, , reload] = useFlags(table, notify);
|
||||
const [asking, setAsking] = useState<{ to: string; changes: number } | null>(null);
|
||||
const [reviewing, setReviewing] = useState(false);
|
||||
if (!state || !flags) return null;
|
||||
const name = (id: string) => tx(modeById(table, id)?.name ?? id, undefined, id);
|
||||
const save = async (next: Flags) => { await invoke('features_set', { features: sparse(table, next) }); return true; };
|
||||
const status = modeStatus(table, flags, state.mode, state.preset);
|
||||
|
||||
/** Opens the list of what re-applying does. Looking at it is what ends the "new features" note. */
|
||||
function review() {
|
||||
setAsking(null);
|
||||
setReviewing(true);
|
||||
if (status.added.length) presetSeen(table, state!).then(setState, () => {});
|
||||
}
|
||||
async function reapply() {
|
||||
setReviewing(false);
|
||||
try {
|
||||
const next = await reapplyMode(table, state!.mode!, save);
|
||||
if (next) { setState(next); await reload(); notify(t('wb.modes.applied', { mode: name(next.mode!) })); }
|
||||
} catch (err) { notify(getError(err), true); }
|
||||
}
|
||||
|
||||
async function apply(to: string, keep: boolean) {
|
||||
setAsking(null);
|
||||
|
|
@ -138,6 +196,19 @@ function ModeSettingsOf({ table, notify }: { table: FeatureTable; notify: Notify
|
|||
return <Section title={t('wb.modes.section')} hint={t('wb.modes.sectionHint')} id="modes">
|
||||
{table.modes.map(mode => {
|
||||
const current = state.mode === mode.id;
|
||||
const custom = current && status.custom;
|
||||
if (custom) return <Row key={mode.id} label={name(mode.id)} hint={tx(mode.description, undefined, '')} keywords={`mode preset custom ${mode.id}`} wide={reviewing}
|
||||
badge={<><Badge tone="accent">{t('wb.modes.custom', { mode: name(mode.id) })}</Badge>{mode.recommended && <Badge>{t('wb.modes.recommended')}</Badge>}</>}>
|
||||
{reviewing
|
||||
? <span className="wbf-ask" role="alertdialog">{t('wb.modes.reapplyHint', { mode: name(mode.id) })} <ReapplyList table={table} status={status}/>
|
||||
<Button size="sm" tone="primary" onClick={() => void reapply()}>{t('wb.modes.reapply', { mode: name(mode.id) })}</Button>
|
||||
<Button size="sm" tone="ghost" onClick={() => setReviewing(false)}>{t('wb.common.cancel')}</Button>
|
||||
</span>
|
||||
: <span className="wbf-ask">
|
||||
{status.added.length > 0 && <small>{t('wb.modes.grew', { mode: name(mode.id), count: status.added.length })}</small>}
|
||||
<Button size="sm" onClick={review}>{status.added.length > 0 ? t('wb.modes.review') : t('wb.modes.reapply', { mode: name(mode.id) })}</Button>
|
||||
</span>}
|
||||
</Row>;
|
||||
return <Row key={mode.id} label={name(mode.id)} hint={tx(mode.description, undefined, '')} keywords={`mode preset ${mode.id}`} wide={asking?.to === mode.id}
|
||||
badge={<>{current && <Badge tone="accent">{t('wb.modes.current')}</Badge>}{mode.recommended && <Badge>{t('wb.modes.recommended')}</Badge>}</>}>
|
||||
{asking?.to === mode.id
|
||||
|
|
@ -146,7 +217,7 @@ function ModeSettingsOf({ table, notify }: { table: FeatureTable; notify: Notify
|
|||
<Button size="sm" onClick={() => void apply(mode.id, false)}>{t('wb.modes.reset', { mode: name(mode.id) })}</Button>
|
||||
<Button size="sm" tone="ghost" onClick={() => setAsking(null)}>{t('wb.common.cancel')}</Button>
|
||||
</span>
|
||||
: <Button size="sm" onClick={() => void choose(mode.id)}>{t('wb.modes.use')}</Button>}
|
||||
: <Button size="sm" onClick={() => { setReviewing(false); void choose(mode.id); }}>{t('wb.modes.use')}</Button>}
|
||||
</Row>;
|
||||
})}
|
||||
</Section>;
|
||||
|
|
@ -191,7 +262,27 @@ export function menuModes(): MenuModes {
|
|||
if (!table) return;
|
||||
const save = async (next: Flags) => { await invoke('features_set', { features: sparse(table, next) }); return true; };
|
||||
const next = await applyMode(table, await flagsNow(table), (await state()).mode, to, keep, save);
|
||||
if (next) { cached = next; for (const listener of listeners) listener(next); }
|
||||
if (next) share(next);
|
||||
},
|
||||
async status(): Promise<MenuModeStatus> {
|
||||
const table = featureTable();
|
||||
const now = table?.modes.length ? await state() : null;
|
||||
if (!table || !now) return { custom: false, on: [], off: [], added: 0 };
|
||||
const status = modeStatus(table, await flagsNow(table), now.mode, now.preset);
|
||||
return { custom: status.custom, on: status.on.map(id => flagName(table, id)), off: status.off.map(id => flagName(table, id)), added: status.added.length };
|
||||
},
|
||||
async reapply() {
|
||||
const table = featureTable();
|
||||
const mode = (await state()).mode;
|
||||
if (!table || !mode) return;
|
||||
const save = async (next: Flags) => { await invoke('features_set', { features: sparse(table, next) }); return true; };
|
||||
const next = await reapplyMode(table, mode, save);
|
||||
if (next) share(next);
|
||||
},
|
||||
async seen() {
|
||||
const table = featureTable();
|
||||
const now = await state();
|
||||
if (table && now.mode) share(await presetSeen(table, now));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue