mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Settings get a page of their own for accessibility, with presets on top (screen reader, low vision, dyslexia, focus, keyboard and motor, motion and seizure safe, deaf and hard of hearing) that change several options at once and can be undone, then the options by topic, and a reset. Text size used to be a fixed 14/16/18px on the root, which threw away the size people set in their browser. It is now a percentage of the browser's size, from 87.5% to 200%. Motion, contrast and transparency follow the system by default and can be overridden either way; the old Animations switch becomes Reduce motion. The visual options are applied before the first paint, so the page no longer flashes at the wrong size. - `lib/accessibility-settings.ts`: the new settings, their allowed values, and the code that writes them onto `<html>` - `app/accessibility.css`: bundled fonts (Atkinson Hyperlegible Next, Lexend, OpenDyslexic), text spacing, a focus outline that survives Windows contrast themes, more-contrast colours on any theme, reduced transparency, a large pointer - admins can preset every new option but lock only shortcuts, send confirmation and the notification duration; a lock on text size or another reading aid works as a default - always-light mail and toolbar labels now sync, and imports drop values the page doesn't know - font size and animations move off Appearance, the plain text font off Reading; everyday settings such as conversation view and undo send show on both their own page and Accessibility
160 lines
8.2 KiB
TypeScript
160 lines
8.2 KiB
TypeScript
// What a plugin may learn about the app itself: where a setting is (the same
|
|
// index the settings page searches, in the user's language) and the user's
|
|
// preferences. Used by the AI assistant to answer "how do I ...?" and "why
|
|
// does the app ...?" with the app's own names and the user's own values.
|
|
|
|
import enMessages from '@/locales/en/common.json';
|
|
import { LOCALE_LOADERS } from '@/components/providers/intl-provider';
|
|
import { mergeMessages } from '@/i18n/merge-messages';
|
|
import { detectBrowserLocale } from '@/i18n/detect-locale';
|
|
import { useLocaleStore } from '@/stores/locale-store';
|
|
import { useSettingsStore } from '@/stores/settings-store';
|
|
import { useThemeStore } from '@/stores/theme-store';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
import { usePolicyStore } from '@/stores/policy-store';
|
|
import { fetchConfig, type ConfigData } from '@/hooks/use-config';
|
|
import { productName } from '@/lib/product-name';
|
|
import {
|
|
getByPath, settingsSubResults, tabKeywords, tabSearchEntries, type SettingsSearchTab,
|
|
} from '@/lib/settings-search';
|
|
import { currentSessionLimits } from '@/lib/session-policy/limits';
|
|
import type { AppSettingsView, SettingsSearchResult } from '../plugin-types';
|
|
|
|
type Messages = Record<string, unknown>;
|
|
|
|
const MAX_QUERY = 200;
|
|
const MAX_RESULTS = 20;
|
|
|
|
async function messagesFor(locale: string): Promise<Messages> {
|
|
const loader = LOCALE_LOADERS[locale];
|
|
if (locale === 'en' || !loader) return enMessages as Messages;
|
|
try {
|
|
return mergeMessages(enMessages as never, (await loader()) as never) as Messages;
|
|
} catch {
|
|
return enMessages as Messages;
|
|
}
|
|
}
|
|
|
|
/** The settings pages this user has: the server's support and the admin's gates, as on the settings page. */
|
|
function availablePages(config: ConfigData | null): Set<SettingsSearchTab> {
|
|
const client = useAuthStore.getState().client;
|
|
const policy = usePolicyStore.getState();
|
|
const gate = (feature: Parameters<typeof policy.isFeatureEnabled>[0]) => {
|
|
try { return policy.isFeatureEnabled(feature); } catch { return true; }
|
|
};
|
|
const stalwart = config?.stalwartFeaturesEnabled ?? true;
|
|
const pages = new Set<SettingsSearchTab>(['account', 'notifications', 'general', 'appearance', 'message_list', 'reading', 'composing', 'organize', 'backup']);
|
|
if (stalwart) pages.add('security');
|
|
if (client?.supportsSieve?.()) pages.add('filters');
|
|
if (client?.supportsVacationResponse?.()) pages.add('vacation');
|
|
if (client?.supportsCalendars?.() && gate('calendarEnabled')) pages.add('calendar');
|
|
if (gate('contactsEnabled')) pages.add('contacts');
|
|
if (client?.supportsFiles?.() && gate('filesEnabled')) pages.add('files');
|
|
if (gate('sidebarAppsEnabled') || gate('pluginsEnabled')) pages.add('addons');
|
|
return pages;
|
|
}
|
|
|
|
function pageName(messages: Messages, tab: SettingsSearchTab): string {
|
|
const name = getByPath(messages, `settings.tabs.${tab}`);
|
|
return typeof name === 'string' ? name : tab;
|
|
}
|
|
|
|
/**
|
|
* Settings matching a query, in the user's language: the page, the
|
|
* setting's label and its description. The query may be in the user's
|
|
* language or in English (a model often asks in English); every word counts.
|
|
*/
|
|
export async function doAppSearchSettings(query: unknown, options?: unknown): Promise<SettingsSearchResult[]> {
|
|
if (typeof query !== 'string' || !query.trim()) throw new Error('app.searchSettings: query must be a non-empty string');
|
|
const words = query.toLowerCase().slice(0, MAX_QUERY).split(/[^\p{L}\p{N}]+/u).filter((w) => w.length > 1);
|
|
if (!words.length) return [];
|
|
const rawLimit = (options as { limit?: unknown } | undefined)?.limit;
|
|
const limit = typeof rawLimit === 'number' && Number.isFinite(rawLimit) ? Math.min(MAX_RESULTS, Math.max(1, Math.floor(rawLimit))) : 8;
|
|
|
|
const locale = useLocaleStore.getState().locale || detectBrowserLocale('en');
|
|
const local = await messagesFor(locale);
|
|
const english = enMessages as Messages;
|
|
// One config read for both: an unreachable /api/config keeps the defaults.
|
|
const config = await fetchConfig().catch(() => null);
|
|
const pages = availablePages(config);
|
|
const appName = productName(config?.appName);
|
|
|
|
const scored: { score: number; order: number; result: SettingsSearchResult }[] = [];
|
|
let order = 0;
|
|
for (const tab of Object.keys(tabSearchEntries) as SettingsSearchTab[]) {
|
|
if (!pages.has(tab)) continue;
|
|
const page = pageName(local, tab);
|
|
const pageHaystack = `${page} ${pageName(english, tab)} ${tab.replace(/_/g, ' ')} ${tabKeywords[tab] ?? ''}`.toLowerCase();
|
|
// The merged catalogue has the English one's shape, so both lists line up.
|
|
const mine = settingsSubResults(local, tab, appName);
|
|
const theirs = settingsSubResults(english, tab, appName);
|
|
mine.forEach((entry, i) => {
|
|
const en = theirs[i];
|
|
const text = `${entry.label} ${entry.description ?? ''} ${en?.label ?? ''} ${en?.description ?? ''}`.toLowerCase();
|
|
let score = 0;
|
|
for (const word of words) {
|
|
if (text.includes(word)) score += 2;
|
|
else if (pageHaystack.includes(word)) score += 1;
|
|
}
|
|
if (score > 0) {
|
|
scored.push({ score, order: order++, result: { pageId: tab, page, label: entry.label, ...(entry.description ? { description: entry.description } : {}) } });
|
|
}
|
|
});
|
|
}
|
|
scored.sort((a, b) => b.score - a.score || a.order - b.order);
|
|
const seen = new Set<string>();
|
|
const out: SettingsSearchResult[] = [];
|
|
for (const { result } of scored) {
|
|
const key = `${result.pageId}|${result.label}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push(result);
|
|
if (out.length === limit) break;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* The user's preferences that say how the app behaves for them: layout,
|
|
* theme, language and formats, reading and sending behaviour, notifications.
|
|
* Plain values only; nothing about other people (trusted senders, mutes)
|
|
* and nothing that addresses a server (the push relay).
|
|
*/
|
|
export const SHARED_SETTINGS = [
|
|
'fontSize', 'density', 'interfaceLayout', 'mailLayout', 'toolbarPosition', 'showToolbarLabels', 'reduceMotion', 'proInterface',
|
|
'contrast', 'underlineLinks', 'appFont', 'focusRing', 'largeTargets', 'singleKeyShortcuts', 'toastDuration',
|
|
'dateFormat', 'timeFormat', 'firstDayOfWeek', 'timeZone',
|
|
'disableThreading', 'showPreview', 'listColumns', 'emailsPerPage', 'showVerificationCodes',
|
|
'markAsReadDelay', 'externalContentPolicy', 'attachmentPosition', 'mailAttachmentAction', 'emailAlwaysLightMode', 'returnToListAfterAction', 'plainTextFont',
|
|
'deleteAction', 'archiveMode', 'permanentlyDeleteJunk', 'confirmBulkDelete',
|
|
'sendDelaySeconds', 'sendConfirmation', 'defaultReplyMode', 'plainTextMode', 'signaturePosition', 'signatureSeparatorEnabled',
|
|
'requestReadReceiptDefault', 'readReceiptResponse', 'autoSaveDraftInterval', 'autoSelectReplyIdentity',
|
|
'showWeekNumbers', 'calendarWorkingDays', 'enableCalendarTasks', 'showBirthdayCalendar',
|
|
'emailNotificationsEnabled', 'emailNotificationSound', 'calendarNotificationsEnabled',
|
|
'enableUnifiedMailbox', 'sessionTimeout',
|
|
] as const;
|
|
|
|
export function doAppGetSettings(): AppSettingsView {
|
|
const state = useSettingsStore.getState() as unknown as Record<string, unknown>;
|
|
const out: AppSettingsView = {};
|
|
for (const key of SHARED_SETTINGS) {
|
|
const value = state[key];
|
|
if (typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value))) out[key] = value;
|
|
else if (Array.isArray(value) && value.every((v) => typeof v === 'number' || typeof v === 'string')) out[key] = value.slice(0, 20) as number[] | string[];
|
|
}
|
|
// Plugins written before Reduce motion (v9) read this switch.
|
|
out.animationsEnabled = state.reduceMotion !== 'on';
|
|
// Minutes without activity before Bulwark signs out (0 = never). The user
|
|
// setting of that name was never read; the organisation's idle timeout is
|
|
// what applies, the strictest of the accounts signed in here.
|
|
const limits = currentSessionLimits();
|
|
out.sessionTimeout = limits
|
|
? limits.idle?.minutes ?? 0
|
|
: usePolicyStore.getState().getOrganisation().session.idleMinutes;
|
|
const theme = (useThemeStore.getState() as unknown as { theme?: unknown }).theme;
|
|
if (typeof theme === 'string') out.theme = theme;
|
|
const locale = useLocaleStore.getState().locale;
|
|
if (locale) out.language = locale;
|
|
return out;
|
|
}
|