mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
Offline sat in the middle of Backup & reset, privacy settings were spread
over Reading, Composing and Message list, and the version card took room
from the page list in the sidebar's footer. The search matched letters
inside words ("lock" found Time format and Send Checks) and knew no other
words for a setting ("offline", "autoreply", "logout" found nothing).
- Offline is a page in the top group: Keep mail on this device, what is
kept, Remove everything from this device, and the Outbox as its own
section. Old `/settings/backup/offline` links and the offline notice
land there.
- Privacy holds remote images and trusted senders, read receipts, sender
pictures from the web, the desktop app's usage info and crash reports,
and a link to the push relay on Notifications. Reading, Composing and
Message list keep a link row; old links to those sections land on
Privacy.
- About is the last page and holds the version card; the footer keeps the
Advanced switch.
- The search matches whole words and word starts (substrings only in
Chinese, Japanese and Thai), and each row can carry synonyms in the
user's language. A page matches by its name and keywords or when one of
its rows has every word. Account gets a Sign out row so "logout" leads
somewhere.
- The index test now also fails when a page draws a section the index
doesn't know, and a new test fails on two indexed rows with the same
name. Tests that rendered remote content and sender pictures on Reading
and Message list render Privacy instead, since that's where the rows are.
292 lines
15 KiB
TypeScript
292 lines
15 KiB
TypeScript
// Area c, settings: app.describeSettings, app.setSettings and
|
|
// app.undoSettings over the allow-list in lib/settings-schema.ts.
|
|
//
|
|
// A change is checked at the moment it is made, the way the settings page
|
|
// would show it: the key must be in the allow-list, the value one the
|
|
// setting may take there, the row must be shown (its feature gate, its
|
|
// page's gate, no-third-party mode, the admin's hidden flag), not locked by
|
|
// the admin and within the admin's restriction. It is written through the
|
|
// same setters the pages use (updateSetting, setTheme, setLocale,
|
|
// setViewMode), so plugin hooks run, the open page shows it and it syncs;
|
|
// then it is read back, since another plugin may have stopped it.
|
|
|
|
import { useSettingsStore } from '@/stores/settings-store';
|
|
import { useThemeStore } from '@/stores/theme-store';
|
|
import { useLocaleStore } from '@/stores/locale-store';
|
|
import { useCalendarStore } from '@/stores/calendar-store';
|
|
import { usePolicyStore } from '@/stores/policy-store';
|
|
import { settingsHooks } from '../../plugin-hooks';
|
|
import { noThirdPartyTraffic } from '../../third-party-mode';
|
|
import { toastDurationAtLeast } from '../../accessibility-settings';
|
|
import { detectBrowserLocale } from '@/i18n/detect-locale';
|
|
import { getByPath, settingsTabLabelKey } from '../../settings-search';
|
|
import {
|
|
DENIED_SETTINGS, PAGE_GATES, SETTINGS_SCHEMA, isAllowedSettingValue, settingValueLabelKey,
|
|
type SettingSchemaEntry,
|
|
} from '../../settings-schema';
|
|
import { isPlainRecord, sessionClient } from '../host-api-input';
|
|
import { messagesFor } from '../host-api-app';
|
|
import { issueUndoToken, undoAccount, withUndoToken } from './c.undo';
|
|
import type {
|
|
SettingDescription, SettingRefusal, SettingValue, SettingsChangeResult, SettingsUndoResult,
|
|
} from './c.types';
|
|
|
|
/** Most settings one call names. */
|
|
export const MAX_PLUGIN_SETTINGS = 20;
|
|
/** How long a change may wait for another plugin's onBeforeSettingChange. */
|
|
const VETO_WAIT_MS = 3000;
|
|
|
|
type Messages = Record<string, unknown>;
|
|
|
|
export function currentSettingValue(entry: SettingSchemaEntry): SettingValue | null {
|
|
let value: unknown;
|
|
switch (entry.store) {
|
|
case 'theme': value = (useThemeStore.getState() as unknown as { theme?: unknown }).theme; break;
|
|
case 'locale': value = useLocaleStore.getState().locale; break;
|
|
case 'calendar': value = useCalendarStore.getState().viewMode; break;
|
|
default: value = (useSettingsStore.getState() as unknown as Record<string, unknown>)[entry.key];
|
|
}
|
|
if (Array.isArray(value)) return [...value] as SettingValue;
|
|
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? value : null;
|
|
}
|
|
|
|
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
|
|
|
|
/**
|
|
* app.getSettings names two settings after what they mean to a reader
|
|
* (language, calendarView); setSettings takes those names too.
|
|
*/
|
|
const SETTING_ALIASES: Readonly<Record<string, string>> = { language: 'locale', calendarView: 'viewMode' };
|
|
export const canonicalSetting = (name: string): string => SETTING_ALIASES[name] ?? name;
|
|
const schemaEntry = (key: string): SettingSchemaEntry | undefined => (Object.prototype.hasOwnProperty.call(SETTINGS_SCHEMA, key) ? SETTINGS_SCHEMA[key] : undefined);
|
|
|
|
function gateOn(gate: Parameters<ReturnType<typeof usePolicyStore.getState>['isFeatureEnabled']>[0]): boolean {
|
|
try { return usePolicyStore.getState().isFeatureEnabled(gate); } catch { return true; }
|
|
}
|
|
|
|
/**
|
|
* Why `key` can't be changed (to `value`, when given) right now, or null.
|
|
* `pending` holds the other values of the same call (calendar hours are
|
|
* checked against each other).
|
|
*/
|
|
export function settingRefusal(name: string, value?: unknown, pending: Record<string, unknown> = {}): SettingRefusal | null {
|
|
const key = canonicalSetting(name);
|
|
const entry = schemaEntry(key);
|
|
if (!entry) return DENIED_SETTINGS.includes(key) || key in (useSettingsStore.getState() as object) ? 'not_allowed' : 'unknown';
|
|
const policy = usePolicyStore.getState();
|
|
if (entry.gate && !gateOn(entry.gate)) return 'unavailable';
|
|
const pageGate = PAGE_GATES[entry.page];
|
|
if (pageGate && !gateOn(pageGate)) return 'unavailable';
|
|
if (entry.thirdParty && noThirdPartyTraffic()) return 'unavailable';
|
|
if (entry.store === 'settings' && policy.isSettingHidden(key)) return 'hidden';
|
|
if (entry.store === 'settings' && policy.isSettingLocked(key) && !entry.preset) return 'locked';
|
|
if (entry.store === 'theme' && policy.getColorModePolicy().locked) return 'locked';
|
|
if (value === undefined) return null;
|
|
if (!isAllowedSettingValue(entry, value)) return 'bad_value';
|
|
if (entry.kind.type === 'choice' || entry.kind.type === 'list') {
|
|
const values = Array.isArray(value) ? value : [value];
|
|
const gated = entry.kind.options.filter((o) => o.gate && !gateOn(o.gate));
|
|
if (gated.some((o) => values.includes(o.value))) return 'unavailable';
|
|
}
|
|
const restricted = restrictionRefusal(entry, value);
|
|
if (restricted) return restricted;
|
|
if (key === 'calendarDayStartHour' || key === 'calendarDayEndHour') {
|
|
const state = useSettingsStore.getState();
|
|
const start = Number(key === 'calendarDayStartHour' ? value : pending.calendarDayStartHour ?? state.calendarDayStartHour);
|
|
const end = Number(key === 'calendarDayEndHour' ? value : pending.calendarDayEndHour ?? state.calendarDayEndHour);
|
|
if (!(end > start)) return 'bad_value';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** The admin's restriction on the value (allowed values, minimum, maximum), or null. */
|
|
function restrictionRefusal(entry: SettingSchemaEntry, value: unknown): SettingRefusal | null {
|
|
const key = entry.key;
|
|
const restriction = entry.store === 'settings' ? usePolicyStore.getState().getRestriction(key) : undefined;
|
|
if (!restriction) return null;
|
|
if (restriction.allowedValues && !restriction.allowedValues.some((v) => same(v, value))) return 'bad_value';
|
|
if (typeof value === 'number') {
|
|
if (key === 'toastDuration' && typeof restriction.min === 'number') {
|
|
if (!toastDurationAtLeast(value, restriction.min)) return 'bad_value';
|
|
} else if (typeof restriction.min === 'number' && value < restriction.min) return 'bad_value';
|
|
if (typeof restriction.max === 'number' && value > restriction.max) return 'bad_value';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function format(messages: Messages, key: string, params: Record<string, string> = {}): string | null {
|
|
const raw = getByPath(messages, key);
|
|
if (typeof raw !== 'string') return null;
|
|
return raw.replace(/\{(\w+)\}/g, (all, name: string) => params[name] ?? all);
|
|
}
|
|
|
|
function userLocale(): string {
|
|
return useLocaleStore.getState().locale || detectBrowserLocale('en');
|
|
}
|
|
|
|
/** A value in the user's words: the page's own label, else a formatted value. */
|
|
export function settingValueLabel(entry: SettingSchemaEntry, value: unknown, messages: Messages, locale = userLocale()): string {
|
|
if (value === null || value === undefined) return '';
|
|
const key = settingValueLabelKey(entry, value);
|
|
if (key) {
|
|
const zone = (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return 'UTC'; } })();
|
|
const text = format(messages, key, { zone });
|
|
if (text) return text;
|
|
}
|
|
const kind = entry.kind;
|
|
if ((kind.type === 'choice' || kind.type === 'list')) {
|
|
const label = (v: unknown) => {
|
|
const option = kind.options.find((o) => o.value === v);
|
|
return (option?.label && format(messages, option.label)) || option?.text || String(v);
|
|
};
|
|
return Array.isArray(value) ? value.map(label).join(', ') : label(value);
|
|
}
|
|
if (kind.type === 'hour' && typeof value === 'number') return `${String(value).padStart(2, '0')}:00`;
|
|
if (kind.type === 'days' && Array.isArray(value)) {
|
|
// 2023-01-01 was a Sunday (day 0).
|
|
return value.map((d) => {
|
|
try { return new Intl.DateTimeFormat(locale, { weekday: 'long', timeZone: 'UTC' }).format(new Date(Date.UTC(2023, 0, 1 + Number(d)))); } catch { return String(d); }
|
|
}).join(', ');
|
|
}
|
|
if (kind.type === 'locale' && typeof value === 'string' && value && value !== 'auto') {
|
|
try { return new Intl.DisplayNames([value], { type: 'language' }).of(value) ?? value; } catch { return value; }
|
|
}
|
|
return Array.isArray(value) ? value.join(', ') : String(value);
|
|
}
|
|
|
|
function readChanges(arg: unknown, method: string): Record<string, unknown> {
|
|
if (!isPlainRecord(arg)) throw new Error(`${method}: changes must be an object of setting names and values`);
|
|
const keys = Object.keys(arg);
|
|
if (keys.length === 0 || keys.length > MAX_PLUGIN_SETTINGS) throw new Error(`${method}: name 1 to ${MAX_PLUGIN_SETTINGS} settings`);
|
|
return arg;
|
|
}
|
|
|
|
/**
|
|
* The settings named, in the user's language: label, page, current value
|
|
* and, for a change, the new one, and whether it may be made now. `arg` is
|
|
* an object of changes or an array of setting names.
|
|
*/
|
|
export async function doDescribeSettings(arg: unknown): Promise<Record<string, SettingDescription>> {
|
|
const method = 'app.describeSettings';
|
|
const changes: Record<string, unknown> = Array.isArray(arg)
|
|
? Object.fromEntries(arg.slice(0, MAX_PLUGIN_SETTINGS + 1).map((k) => [String(k), undefined]))
|
|
: readChanges(arg, method);
|
|
if (Object.keys(changes).length === 0 || Object.keys(changes).length > MAX_PLUGIN_SETTINGS) {
|
|
throw new Error(`${method}: name 1 to ${MAX_PLUGIN_SETTINGS} settings`);
|
|
}
|
|
const locale = userLocale();
|
|
const messages = await messagesFor(locale);
|
|
const out: Record<string, SettingDescription> = {};
|
|
for (const [key, next] of Object.entries(changes)) {
|
|
if (key.length > 100) continue;
|
|
const reason = settingRefusal(key, next, changes);
|
|
const entry = schemaEntry(canonicalSetting(key));
|
|
if (!entry) {
|
|
out[key] = { key, label: key, page: '', pageLabel: '', current: null, currentLabel: '', allowed: false, reason: reason ?? 'unknown' };
|
|
continue;
|
|
}
|
|
const current = currentSettingValue(entry);
|
|
const options = entry.kind.type === 'choice' || entry.kind.type === 'boolean'
|
|
? (entry.kind.type === 'boolean' ? [true, false] : entry.kind.options.map((o) => o.value))
|
|
.filter((v) => settingRefusal(entry.key, v, changes) === null)
|
|
.map((v) => ({ value: v, label: settingValueLabel(entry, v, messages, locale) }))
|
|
: undefined;
|
|
out[key] = {
|
|
key,
|
|
label: format(messages, entry.label) ?? key,
|
|
page: entry.page,
|
|
pageLabel: format(messages, settingsTabLabelKey(entry.page)) ?? entry.page,
|
|
current,
|
|
currentLabel: settingValueLabel(entry, current, messages, locale),
|
|
...(next !== undefined ? { next: next as SettingValue, nextLabel: settingValueLabel(entry, next, messages, locale) } : {}),
|
|
allowed: reason === null,
|
|
...(reason ? { reason } : {}),
|
|
...(options && options.length ? { options } : {}),
|
|
};
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Writes one value through the setter the settings page uses. */
|
|
function write(entry: SettingSchemaEntry, value: unknown): void {
|
|
switch (entry.store) {
|
|
case 'theme': useThemeStore.getState().setTheme(value as 'light' | 'dark' | 'system'); return;
|
|
case 'locale': useLocaleStore.getState().setLocale(value as string); return;
|
|
case 'calendar': useCalendarStore.getState().setViewMode(value as never); return;
|
|
default: useSettingsStore.getState().updateSetting(entry.key as never, value as never);
|
|
}
|
|
}
|
|
|
|
/** Writes and reads back: false when the value didn't stick (another plugin stopped it). */
|
|
async function writeAndCheck(entry: SettingSchemaEntry, value: unknown): Promise<boolean> {
|
|
write(entry, value);
|
|
if (same(currentSettingValue(entry), value)) return true;
|
|
// With another plugin's onBeforeSettingChange the store applies the change
|
|
// once that plugin answered, or never.
|
|
if (entry.store !== 'settings' || settingsHooks.onBeforeSettingChange.size === 0) return false;
|
|
const until = Date.now() + VETO_WAIT_MS;
|
|
while (Date.now() < until) {
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
if (same(currentSettingValue(entry), value)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Change settings for the user. Each key is checked and written on its own;
|
|
* the result says which were applied (old and new value) and which were
|
|
* refused and why, plus a token for app.undoSettings.
|
|
*/
|
|
export async function doSetSettings(pluginId: string, arg: unknown): Promise<SettingsChangeResult> {
|
|
const method = 'app.setSettings';
|
|
const changes = readChanges(arg, method);
|
|
const client = sessionClient(method);
|
|
const applied: SettingsChangeResult['applied'] = {};
|
|
const refused: SettingsChangeResult['refused'] = {};
|
|
const previous: Record<string, SettingValue | null> = {};
|
|
for (const [key, value] of Object.entries(changes)) {
|
|
const reason = settingRefusal(key, value, changes);
|
|
if (reason) { refused[key] = reason; continue; }
|
|
const entry = SETTINGS_SCHEMA[canonicalSetting(key)];
|
|
const old = currentSettingValue(entry);
|
|
if (same(old, value)) { applied[key] = { old, new: value as SettingValue }; continue; }
|
|
if (!(await writeAndCheck(entry, value))) { refused[key] = 'stopped'; continue; }
|
|
applied[key] = { old, new: value as SettingValue };
|
|
previous[entry.key] = old;
|
|
}
|
|
const undoToken = Object.keys(previous).length > 0
|
|
? issueUndoToken(pluginId, undoAccount(client), 'settings', previous)
|
|
: null;
|
|
return { applied, refused, undoToken };
|
|
}
|
|
|
|
/**
|
|
* Put back what app.setSettings changed: the exact old value, even one the
|
|
* allow-list wouldn't offer (the user chose it), but not where the admin
|
|
* has locked the setting since.
|
|
*/
|
|
export async function doUndoSettings(pluginId: string, token: unknown): Promise<SettingsUndoResult> {
|
|
const method = 'app.undoSettings';
|
|
const client = sessionClient(method);
|
|
return withUndoToken<Record<string, SettingValue | null>, SettingsUndoResult>(method, pluginId, undoAccount(client), 'settings', token, (previous) => restoreSettings(previous));
|
|
}
|
|
|
|
/** Writes the old values back, setting by setting (see doUndoSettings). */
|
|
async function restoreSettings(previous: Record<string, SettingValue | null>): Promise<SettingsUndoResult> {
|
|
const restored: string[] = [];
|
|
const refused: SettingsUndoResult['refused'] = {};
|
|
for (const [key, old] of Object.entries(previous)) {
|
|
const entry = schemaEntry(key);
|
|
if (!entry || old === null) { refused[key] = 'unknown'; continue; }
|
|
// As for a change: not where the admin has locked or hidden the setting
|
|
// since, a gate now hides its row, or the admin's restriction rules the
|
|
// old value out. The value itself needn't be one the assistant offers:
|
|
// the user had it.
|
|
const reason = settingRefusal(key) ?? restrictionRefusal(entry, old);
|
|
if (reason) { refused[key] = reason; continue; }
|
|
if (same(currentSettingValue(entry), old) || (await writeAndCheck(entry, old))) restored.push(key);
|
|
else refused[key] = 'stopped';
|
|
}
|
|
return { restored, refused };
|
|
}
|