// Modes: the presets an app's feature table defines (Basic / Advanced / Power User, or whatever the // app calls them). `Onboarding` is the first-run screen: three cards, one recommended and // 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, 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 // )" and one action, "Re-apply ", lists what it turns on and off before it does. import { useEffect, useState, type ComponentType } from 'react'; 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 { 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'; 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`). `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 | 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); }; /** Tells the mode screens a mode was stored from elsewhere (an imported setup). */ export const rememberModeState = share; /** 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] { const [state, setState] = useState(cached); useEffect(() => { listeners.add(setState); if (!cached && featureTable()?.modes.length) invoke('mode_get').then(value => { cached = value; for (const listener of listeners) listener(value); }).catch(() => {}); return () => { listeners.delete(setState); }; }, []); return [state, next => { cached = next; for (const listener of listeners) listener(next); }]; } /** * Applies mode `to`: saves its flags (over the owner's own changes, or under them with `keep`), * turns off the old plugin files a fresh mode does not have, records the choice and runs the app's * own step for the mode. */ async function applyMode(table: FeatureTable, flags: Flags, from: string | null, to: string, keep: boolean, save: (flags: Flags) => Promise): Promise { const plugins = await invoke('plugins_list').catch(() => [] as PluginFileState[]); const plan = modePlan(table, flags, plugins, from, to, keep); 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('mode_set', { mode: to, onboarded: true, preset: modeById(table, to)?.flags ?? {} }); flagsChanged(); await panelSite().features?.onMode?.(to); return state; } /** "Re-apply ": 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): Promise { if (!modeById(table, mode) || !await save(modeFlags(table, mode))) return null; const state = await invoke('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('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. */ export 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 && {t('wb.modes.turnsOn', { list: flagNames(table, status.on) })}} {status.off.length > 0 && {t('wb.modes.turnsOff', { list: flagNames(table, status.off) })}} ; } function ModeCards({ table, selected, onSelect, Art }: { table: FeatureTable; selected: string | null; onSelect(id: string): void; Art?: ComponentType<{ mode: string }> }) { const t = useT(); return
{table.modes.map(mode => )}
; } /** The default illustration: how much of the page a mode touches, as filled bars. */ function ModeBars({ index, count }: { index: number; count: number }) { return {Array.from({ length: count + 2 }, (_, bar) => )} ; } /** The first-run screen. `done` runs once a mode was chosen or the screen was dismissed. */ export function Onboarding({ state, done, notify }: { state: ModeState; done(next: ModeState): void; notify: Notify }) { const t = useT(); const table = featureTable()!; const panel = panelSite(); const [flags] = useFlags(table, notify); const recommended = table.modes.find(mode => mode.recommended)?.id ?? table.modes[0]?.id ?? null; // Someone who set the app up before keeps what they have unless they pick a mode themselves. const [selected, setSelected] = useState(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) { setBusy(true); try { const result = await next(); if (result) done(result); } catch (err) { notify(getError(err), true); } finally { setBusy(false); } } const save = async (next: Flags) => { await invoke('features_set', { features: sparse(table, next) }); return true; }; const start = (keep: boolean) => flags && selected && void finish(() => applyMode(table, flags, state.mode, selected, keep, save)); const dismiss = () => void finish(() => invoke('mode_set', { mode: state.mode, onboarded: true })); return

{t('wb.modes.title', { app: panel.brand.name })}

{t('wb.modes.lede')}

{state.existing &&

{t('wb.modes.existing', { app: panel.brand.name })}

} {custom &&

{t('wb.modes.custom', { mode: tx(stored!.name, undefined, stored!.id) })}

}
{state.existing && } {mode && state.existing && } {mode && }
; } /** Settings: the mode in use and the way to another one, keeping or dropping the owner's changes. */ export function ModeSettings({ notify }: { notify: Notify }) { const table = featureTable(); return table?.modes.length ? : null; } function ModeSettingsOf({ table, notify }: { table: FeatureTable; notify: Notify }) { const t = useT(); 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); try { const next = await applyMode(table, flags!, state!.mode, to, keep, save); if (next) { setState(next); await reload(); notify(t('wb.modes.applied', { mode: name(to) })); } } catch (err) { notify(getError(err), true); } } async function choose(to: string) { const plugins = await invoke('plugins_list').catch(() => [] as PluginFileState[]); const changes = modePlan(table, flags!, plugins, state!.mode, to, false).changes; if (changes > 0) setAsking({ to, changes }); else void apply(to, false); } return
{table.modes.map(mode => { const current = state.mode === mode.id; const custom = current && status.custom; if (custom) return {t('wb.modes.custom', { mode: name(mode.id) })}{mode.recommended && {t('wb.modes.recommended')}}}> {reviewing ? {t('wb.modes.reapplyHint', { mode: name(mode.id) })} : {status.added.length > 0 && {t('wb.modes.grew', { mode: name(mode.id), count: status.added.length })}} } ; return {current && {t('wb.modes.current')}}{mode.recommended && {t('wb.modes.recommended')}}}> {asking?.to === mode.id ? {t('wb.modes.keepQuestion')} {t('wb.modes.keepHint', { count: asking.changes })} : } ; })}
; } /** * Opens the app's panel (which shows the first-run screen) when the screen has not been answered. * Call it from the app's `ShellSite.start`: it waits until the shell has restored its tabs. */ export function openOnboardingWhenDue(): void { if (!featureTable()?.modes.length) return; invoke('mode_get').then(state => { cached = state; if (state.onboarded) return; let tries = 0; const timer = setInterval(() => { tries += 1; const api = shell.api; if (api && !shell.restoring && api.panels.length > 0) { clearInterval(timer); shell.openPanel(api.activePanel?.id); } else if (tries > 80) clearInterval(timer); }, 250); }).catch(() => {}); } /** * The app menu's Mode submenu (the frame's `ShellSite.menu.modes`): the same modes, the same * question (keep my changes or take the mode as it is) and the same steps as Settings. */ export function menuModes(): MenuModes { const state = async () => (cached ??= await invoke('mode_get')); const flagsNow = async (table: FeatureTable) => normalizeFlags(table, await invoke('features_get')); const plugins = () => invoke('plugins_list').catch(() => [] as PluginFileState[]); return { list: () => (featureTable()?.modes ?? []).map(mode => ({ id: mode.id, label: tx(mode.name, undefined, mode.id) })), current: async () => (featureTable()?.modes.length ? (await state()).mode : null), async changes(to) { const table = featureTable(); return table ? modePlan(table, await flagsNow(table), await plugins(), (await state()).mode, to, false).changes : 0; }, async apply(to, keep) { const table = featureTable(); 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) share(next); }, async status(): Promise { 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)); }, }; }