mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
The month view showed one month at a time and only the arrows moved it. With the new "Free scrolling" setting (on by default, the webmail's `calendarFreeScroll`) it is one continuous list of weeks, as in the webmail (15a5497a, ae403b06): - the month under the upper part of the grid is drawn at full strength, names the title and is where the arrows step from; the first day of each month carries the month's name - reaching the top or the bottom adds weeks there and loads only their events; weeks added above keep the rows on screen in place - rows have a fixed height and snap to whole weeks, so the list stays windowed and navigation lands exactly on the month Turning the setting off brings back the paged month and a fixed 30-day agenda. The week view still pages.
862 lines
31 KiB
TypeScript
862 lines
31 KiB
TypeScript
import { create } from 'zustand';
|
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
import type { Identity } from '../api/types';
|
|
import { getIdentities as fetchIdentities } from '../api/identity';
|
|
import { jmapClient } from '../api/jmap-client';
|
|
import type { SortLevel, MessageListOrderScope } from '../lib/message-list-order';
|
|
|
|
export type ExternalContentPolicy = 'allow' | 'block' | 'ask';
|
|
export type ThemeMode = 'light' | 'dark' | 'system';
|
|
export type FontSize = 'small' | 'medium' | 'large';
|
|
export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
|
|
export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent';
|
|
export type MailAttachmentAction = 'preview' | 'download';
|
|
export type AttachmentPosition = 'beside-sender' | 'below-header';
|
|
export type PlainTextFont = 'sans' | 'mono';
|
|
export type MessageSpacing = 'auto' | 'always' | 'edge';
|
|
export type ReadReceiptResponse = 'ask' | 'always' | 'never';
|
|
export type PostExportAction = 'keep' | 'archive' | 'trash';
|
|
export type SwipeAction =
|
|
| 'none'
|
|
| 'archive'
|
|
| 'delete'
|
|
| 'spam'
|
|
| 'read'
|
|
| 'star'
|
|
| 'pin'
|
|
| 'move';
|
|
export type SwipeMode = 'instant' | 'reveal';
|
|
export type SignaturePosition = 'above_quote' | 'below_quote';
|
|
// With autoSelectReplyIdentity on: 'exact' = configured identities only,
|
|
// 'domain' = also same-domain catch-all addresses (rewrites From), #1000.
|
|
export type ReplyIdentityMatch = 'exact' | 'domain';
|
|
// Actions that can be placed in the email reader's bottom quick-action bar.
|
|
// The first three are the reply family (the default bar); any reply-family
|
|
// action the user removes from the bar is relocated to the top toolbar so it
|
|
// stays reachable.
|
|
export type QuickAction =
|
|
| 'reply'
|
|
| 'replyAll'
|
|
| 'forward'
|
|
| 'delete'
|
|
| 'archive'
|
|
| 'markUnread'
|
|
| 'star'
|
|
| 'move'
|
|
| 'spam'
|
|
| 'tag';
|
|
|
|
export const REPLY_QUICK_ACTIONS: QuickAction[] = ['reply', 'replyAll', 'forward'];
|
|
|
|
export const ALL_QUICK_ACTIONS: QuickAction[] = [
|
|
'reply',
|
|
'replyAll',
|
|
'forward',
|
|
'delete',
|
|
'archive',
|
|
'markUnread',
|
|
'star',
|
|
'move',
|
|
'spam',
|
|
'tag',
|
|
];
|
|
|
|
// The reader bottom bar always shows exactly three quick actions (between the
|
|
// prev/next nav buttons). Coerce any persisted value into three unique, valid
|
|
// ids, backfilling from the reply-family default when entries are missing.
|
|
export function normalizeBottomQuickActions(value: unknown): QuickAction[] {
|
|
const out: QuickAction[] = [];
|
|
if (Array.isArray(value)) {
|
|
for (const a of value) {
|
|
if (ALL_QUICK_ACTIONS.includes(a as QuickAction) && !out.includes(a as QuickAction)) {
|
|
out.push(a as QuickAction);
|
|
}
|
|
}
|
|
}
|
|
for (const d of REPLY_QUICK_ACTIONS) {
|
|
if (out.length >= 3) break;
|
|
if (!out.includes(d)) out.push(d);
|
|
}
|
|
return out.slice(0, 3);
|
|
}
|
|
export type ArchiveMode = 'single' | 'year' | 'month';
|
|
export type CalendarView = 'month' | 'week' | 'day' | 'agenda';
|
|
// 0 = Sunday, 1 = Monday, 6 = Saturday (same values as the webmail).
|
|
export type FirstDayOfWeek = 0 | 1 | 6;
|
|
export type TimeFormat = '12h' | '24h';
|
|
// Email-list date rendering style. Mirrors the webmail `dateFormat` setting:
|
|
// smart — locale-aware, age-bucketed (today→time, this week→weekday+time, older→date)
|
|
// relative — "1h ago", "2d ago"
|
|
// full — always the full locale date + time
|
|
export type DateFormat = 'smart' | 'relative' | 'full';
|
|
export type CalendarHoverPreview = 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s' | 'off';
|
|
export type FilesFolderLayout = 'inline' | 'sidebar';
|
|
export type FilesViewMode = 'list' | 'grid';
|
|
export type FilesSortKey = 'name' | 'size' | 'modified';
|
|
export type FilesSortDir = 'asc' | 'desc';
|
|
// Filename transform for downloads/exports (mirrors webmail SpaceReplacement).
|
|
export type SpaceReplacement = 'keep' | 'underscore' | 'dash';
|
|
|
|
// Debug log categories (mirrors the webmail's DebugCategory). Used by lib/debug.ts.
|
|
export type DebugCategory =
|
|
| 'jmap'
|
|
| 'calendar'
|
|
| 'tasks'
|
|
| 'auth'
|
|
| 'filters'
|
|
| 'email'
|
|
| 'push'
|
|
| 'contacts';
|
|
|
|
export const ALL_DEBUG_CATEGORIES: DebugCategory[] = [
|
|
'jmap', 'calendar', 'tasks', 'auth', 'filters', 'email', 'push', 'contacts',
|
|
];
|
|
|
|
const STORAGE_KEY = 'webmail:settings:v1';
|
|
|
|
export interface SidebarApp {
|
|
id: string;
|
|
name: string;
|
|
url: string;
|
|
icon: string;
|
|
openMode: 'tab' | 'inline';
|
|
showOnMobile: boolean;
|
|
}
|
|
|
|
interface PersistedSettings {
|
|
// Privacy & content
|
|
externalContentPolicy: ExternalContentPolicy;
|
|
trustedSenders: string[];
|
|
// null = not decided yet and treated as off; only the user's toggle in
|
|
// Content & Senders turns it on, false = user opted out.
|
|
trustedSendersAddressBook: boolean | null;
|
|
senderFavicons: boolean;
|
|
hideInlineImageAttachments: boolean;
|
|
|
|
// Language, region & time
|
|
dateFormat: DateFormat;
|
|
timeFormat: TimeFormat;
|
|
|
|
// Unified inbox: also pull in group/shared inboxes reachable through each
|
|
// logged-in account (parity with the webmail `includeGroupInUnified` setting).
|
|
includeGroupInUnified: boolean;
|
|
|
|
// Contacts
|
|
groupContactsByLetter: boolean;
|
|
// Sort (and group) the contact list by surname instead of given name so
|
|
// family members sit together (#963).
|
|
sortContactsByLastName: boolean;
|
|
|
|
// Appearance
|
|
theme: ThemeMode;
|
|
fontSize: FontSize;
|
|
density: Density;
|
|
showToolbarLabels: boolean;
|
|
animationsEnabled: boolean;
|
|
emailAlwaysLightMode: boolean;
|
|
activeThemeId: string | null;
|
|
|
|
// Composing
|
|
autoSelectReplyIdentity: boolean;
|
|
replyIdentityMatch: ReplyIdentityMatch;
|
|
attachmentReminderEnabled: boolean;
|
|
attachmentReminderKeywords: string[];
|
|
plainTextMode: boolean;
|
|
// Undo-send window: every send is deferred by this many seconds (via the
|
|
// server's FUTURERELEASE support) so it can be cancelled. 0 = send instantly.
|
|
sendDelaySeconds: number;
|
|
// Signature placement in replies/forwards and the RFC 3676 "-- " separator.
|
|
signaturePosition: SignaturePosition;
|
|
signatureSeparatorEnabled: boolean;
|
|
// Pre-check "request read receipt" in the composer.
|
|
requestReadReceiptDefault: boolean;
|
|
// Confirm before sending a message without a subject (#684).
|
|
emptySubjectWarningEnabled: boolean;
|
|
// Draft autosave debounce, milliseconds.
|
|
autoSaveDraftInterval: number;
|
|
// Character separating user from tag (e.g. "user+tag@"), RFC 5233.
|
|
subAddressDelimiter: string;
|
|
// Default sender identity per JMAP account id (#507).
|
|
preferredIdentityIds: Record<string, string>;
|
|
|
|
// Reading
|
|
markAsReadDelay: number;
|
|
deleteAction: DeleteAction;
|
|
permanentlyDeleteJunk: boolean;
|
|
showPreview: boolean;
|
|
emailsPerPage: number;
|
|
// Mail list sort order: oldest-first when true. Applies to every mailbox
|
|
// (the JMAP Email/query sorts by receivedAt).
|
|
mailSortAscending: boolean;
|
|
disableThreading: boolean;
|
|
// Configurable list order (#718): presets / up to 3 levels mapped onto the
|
|
// JMAP sort, applied to the Inbox only or to every folder. Same shape as
|
|
// the webmail so a settings blob round-trips.
|
|
messageListOrder: SortLevel[];
|
|
messageListOrderScope: MessageListOrderScope;
|
|
// Load sender favicons inside Junk (off by default, webmail 1.5.1).
|
|
showAvatarsInJunk: boolean;
|
|
// Show the "/ total" part of the folder counts in the drawer (#498).
|
|
showFolderTotalCount: boolean;
|
|
// Opening a folder drops an active search and filters and browses it,
|
|
// instead of re-running the search there (#553, the default).
|
|
clearSearchOnFolderChange: boolean;
|
|
// Unified views span every logged-in account instead of just the active
|
|
// one (own + its shared/group folders). Off by default like the webmail.
|
|
unifiedCrossAccount: boolean;
|
|
mailAttachmentAction: MailAttachmentAction;
|
|
attachmentPosition: AttachmentPosition;
|
|
// Reader body: font for text/plain bodies (#830), gutter around the body,
|
|
// how to answer read-receipt requests (RFC 8098) and what to do with a
|
|
// message after it was exported as .eml.
|
|
plainTextFont: PlainTextFont;
|
|
messageSpacing: MessageSpacing;
|
|
readReceiptResponse: ReadReceiptResponse;
|
|
postExportAction: PostExportAction;
|
|
|
|
// Layout / list interactions
|
|
swipeLeftAction: SwipeAction;
|
|
swipeRightAction: SwipeAction;
|
|
swipeMode: SwipeMode;
|
|
|
|
// Email reader's bottom quick-action bar (3 slots). Defaults to the reply
|
|
// family; reply-family actions removed from here move to the top toolbar.
|
|
bottomQuickActions: QuickAction[];
|
|
|
|
// Archive
|
|
archiveMode: ArchiveMode;
|
|
|
|
// Calendar
|
|
calendarDefaultView: CalendarView;
|
|
calendarFirstDayOfWeek: FirstDayOfWeek;
|
|
calendarTimeFormat: TimeFormat;
|
|
calendarShowTimeInMonth: boolean;
|
|
calendarShowWeekNumbers: boolean;
|
|
// Scroll continuously through months, weeks and days (#759) instead of
|
|
// one period at a time. Same key as the webmail's setting.
|
|
calendarFreeScroll: boolean;
|
|
calendarHoverPreview: CalendarHoverPreview;
|
|
// IANA zone the calendar works in, or 'auto' to follow the device (#755).
|
|
// Same key semantics as the webmail's `timeZone` setting.
|
|
calendarTimeZone: string;
|
|
showBirthdayCalendar: boolean;
|
|
enableCalendarTasks: boolean;
|
|
showTasksOnCalendar: boolean;
|
|
// Per-viewer color overrides for shared calendars, keyed by
|
|
// sharedCalendarColorKey(). Lets the user recolor calendars shared with
|
|
// them without changing the owner's color (parity with webmail #345).
|
|
sharedCalendarColors: Record<string, string>;
|
|
|
|
// Files
|
|
filesFolderLayout: FilesFolderLayout;
|
|
filesDefaultViewMode: FilesViewMode;
|
|
filesDefaultSortKey: FilesSortKey;
|
|
filesDefaultSortDir: FilesSortDir;
|
|
filesShowIcons: boolean;
|
|
filesColoredIcons: boolean;
|
|
filesShowThumbnails: boolean;
|
|
filesShowHiddenFiles: boolean;
|
|
|
|
// Notifications. Sound/vibration live in the Android notification channel
|
|
// (the OS owns them after channel creation), so there are no sound keys.
|
|
emailNotificationsEnabled: boolean;
|
|
calendarNotificationsEnabled: boolean;
|
|
calendarInvitationParsingEnabled: boolean;
|
|
// Inbox unread count on the app icon (iOS, see lib/app-badge). Same
|
|
// preference as the webmail's `faviconUnreadBadge` (tab and app icon).
|
|
appIconUnreadBadge: boolean;
|
|
|
|
// Sidebar apps
|
|
sidebarApps: SidebarApp[];
|
|
keepAppsLoaded: boolean;
|
|
|
|
// Filters UI state
|
|
filtersExpandedView: boolean;
|
|
|
|
// Debug logging (see lib/debug.ts). Persisted like the webmail so a support
|
|
// session survives restarts.
|
|
debugMode: boolean;
|
|
debugCategories: Record<DebugCategory, boolean>;
|
|
|
|
// Downloads / export filenames: templates and a filename transform applied
|
|
// when exporting a message as .eml or saving an attachment.
|
|
emailExportTemplate: string;
|
|
attachmentExportTemplate: string;
|
|
exportSpaceReplacement: SpaceReplacement;
|
|
exportLowercase: boolean;
|
|
exportStripDiacritics: boolean;
|
|
|
|
// Offline mail cache: download recent message bodies in the background so
|
|
// they can be opened without network. Days windows the lookback. Attachment
|
|
// caching is intentionally not implemented yet — bodies-only is much
|
|
// smaller and covers the "open recent mail offline" UX on its own.
|
|
offlineCacheEnabled: boolean;
|
|
offlineCacheDays: number;
|
|
// Hard cap on the on-disk body cache, in megabytes. When a sync pushes the
|
|
// cache past this, the oldest messages are evicted to fit.
|
|
offlineCacheMaxMB: number;
|
|
}
|
|
|
|
const DEFAULT_PERSISTED: PersistedSettings = {
|
|
dateFormat: 'smart',
|
|
timeFormat: '24h',
|
|
includeGroupInUnified: true,
|
|
|
|
externalContentPolicy: 'ask',
|
|
trustedSenders: [],
|
|
trustedSendersAddressBook: null,
|
|
senderFavicons: true,
|
|
hideInlineImageAttachments: true,
|
|
|
|
groupContactsByLetter: true,
|
|
sortContactsByLastName: false,
|
|
|
|
theme: 'system',
|
|
fontSize: 'medium',
|
|
density: 'regular',
|
|
showToolbarLabels: true,
|
|
animationsEnabled: true,
|
|
emailAlwaysLightMode: false,
|
|
activeThemeId: null,
|
|
|
|
autoSelectReplyIdentity: false,
|
|
replyIdentityMatch: 'domain',
|
|
attachmentReminderEnabled: true,
|
|
// Same multilingual list as the webmail so a synced/imported settings blob
|
|
// does not flip the reminder behaviour between clients.
|
|
attachmentReminderKeywords: [
|
|
// English
|
|
'attached', 'attachment', 'attachments', 'see attached', 'find attached', 'please find attached',
|
|
// German
|
|
'angehängt', 'anhang', 'anbei', 'im anhang',
|
|
// French
|
|
'ci-joint', 'pièce jointe',
|
|
// Spanish
|
|
'adjunto', 'adjunta', 'en adjunto',
|
|
// Italian
|
|
'allegato', 'in allegato',
|
|
// Dutch
|
|
'bijgevoegd', 'bijlage',
|
|
// Portuguese
|
|
'em anexo', 'anexo',
|
|
// Polish
|
|
'w załączniku',
|
|
// Russian
|
|
'во вложении',
|
|
// Japanese
|
|
'添付',
|
|
// Chinese
|
|
'附件',
|
|
// Korean
|
|
'첨부',
|
|
// Latvian
|
|
'pielikumā',
|
|
],
|
|
plainTextMode: false,
|
|
sendDelaySeconds: 0,
|
|
signaturePosition: 'below_quote',
|
|
signatureSeparatorEnabled: true,
|
|
requestReadReceiptDefault: false,
|
|
emptySubjectWarningEnabled: true,
|
|
autoSaveDraftInterval: 60000,
|
|
subAddressDelimiter: '+',
|
|
preferredIdentityIds: {},
|
|
|
|
markAsReadDelay: 0,
|
|
deleteAction: 'trash',
|
|
permanentlyDeleteJunk: false,
|
|
showPreview: true,
|
|
emailsPerPage: 25,
|
|
mailSortAscending: false,
|
|
disableThreading: false,
|
|
messageListOrder: [],
|
|
messageListOrderScope: 'inbox',
|
|
showAvatarsInJunk: false,
|
|
showFolderTotalCount: true,
|
|
clearSearchOnFolderChange: false,
|
|
unifiedCrossAccount: false,
|
|
mailAttachmentAction: 'preview',
|
|
attachmentPosition: 'beside-sender',
|
|
plainTextFont: 'sans',
|
|
messageSpacing: 'auto',
|
|
readReceiptResponse: 'ask',
|
|
postExportAction: 'keep',
|
|
|
|
swipeLeftAction: 'archive',
|
|
swipeRightAction: 'read',
|
|
swipeMode: 'instant',
|
|
|
|
bottomQuickActions: ['reply', 'replyAll', 'forward'],
|
|
|
|
archiveMode: 'single',
|
|
|
|
calendarDefaultView: 'month',
|
|
calendarFirstDayOfWeek: 1,
|
|
calendarTimeFormat: '24h',
|
|
calendarShowTimeInMonth: true,
|
|
calendarShowWeekNumbers: false,
|
|
calendarFreeScroll: true,
|
|
calendarHoverPreview: 'delay-500ms',
|
|
calendarTimeZone: 'auto',
|
|
showBirthdayCalendar: false,
|
|
enableCalendarTasks: false,
|
|
showTasksOnCalendar: true,
|
|
sharedCalendarColors: {},
|
|
|
|
filesFolderLayout: 'inline',
|
|
filesDefaultViewMode: 'list',
|
|
filesDefaultSortKey: 'name',
|
|
filesDefaultSortDir: 'asc',
|
|
filesShowIcons: true,
|
|
filesColoredIcons: true,
|
|
filesShowThumbnails: true,
|
|
filesShowHiddenFiles: false,
|
|
|
|
emailNotificationsEnabled: true,
|
|
calendarNotificationsEnabled: true,
|
|
calendarInvitationParsingEnabled: true,
|
|
appIconUnreadBadge: true,
|
|
|
|
sidebarApps: [],
|
|
keepAppsLoaded: false,
|
|
|
|
filtersExpandedView: false,
|
|
|
|
debugMode: false,
|
|
debugCategories: {
|
|
jmap: true,
|
|
calendar: true,
|
|
tasks: true,
|
|
auth: true,
|
|
filters: true,
|
|
email: true,
|
|
push: true,
|
|
contacts: true,
|
|
},
|
|
|
|
emailExportTemplate: '{date} ({from}-{to}) {subject}',
|
|
attachmentExportTemplate: '{filename}',
|
|
exportSpaceReplacement: 'keep',
|
|
exportLowercase: false,
|
|
exportStripDiacritics: false,
|
|
|
|
offlineCacheEnabled: false,
|
|
offlineCacheDays: 7,
|
|
offlineCacheMaxMB: 50,
|
|
};
|
|
|
|
export interface SettingsState extends PersistedSettings {
|
|
identities: Identity[];
|
|
/** The signed-in account (server, login, JMAP account) `identities` were read for. */
|
|
identitiesFor: string | null;
|
|
loading: boolean;
|
|
error: string | null;
|
|
hydrated: boolean;
|
|
|
|
/** Read the identities; concurrent calls share one request. */
|
|
fetchIdentities: () => Promise<void>;
|
|
/**
|
|
* Read the identities only when they are not held for the signed-in account
|
|
* yet. An account without identities is not asked again on every call.
|
|
*/
|
|
ensureIdentities: () => Promise<void>;
|
|
hydrate: () => Promise<void>;
|
|
|
|
// Generic setter — preferred for new code.
|
|
updateSetting: <K extends keyof PersistedSettings>(
|
|
key: K,
|
|
value: PersistedSettings[K],
|
|
) => void;
|
|
|
|
// Legacy named setters — preserved so existing call sites keep working.
|
|
setExternalContentPolicy: (policy: ExternalContentPolicy) => void;
|
|
setSenderFavicons: (enabled: boolean) => void;
|
|
setGroupContactsByLetter: (enabled: boolean) => void;
|
|
setTheme: (theme: ThemeMode) => void;
|
|
setFontSize: (size: FontSize) => void;
|
|
setDensity: (density: Density) => void;
|
|
setShowToolbarLabels: (enabled: boolean) => void;
|
|
setAnimationsEnabled: (enabled: boolean) => void;
|
|
setEmailAlwaysLightMode: (enabled: boolean) => void;
|
|
setAutoSelectReplyIdentity: (enabled: boolean) => void;
|
|
setAttachmentReminderEnabled: (enabled: boolean) => void;
|
|
setAttachmentReminderKeywords: (keywords: string[]) => void;
|
|
setSwipeLeftAction: (action: SwipeAction) => void;
|
|
setSwipeRightAction: (action: SwipeAction) => void;
|
|
setSwipeMode: (mode: SwipeMode) => void;
|
|
setArchiveMode: (mode: ArchiveMode) => void;
|
|
|
|
// Trusted senders
|
|
addTrustedSender: (email: string) => void;
|
|
removeTrustedSender: (email: string) => void;
|
|
isSenderTrusted: (email: string) => boolean;
|
|
|
|
// Shared-calendar color overrides
|
|
setSharedCalendarColor: (key: string, color: string) => void;
|
|
removeSharedCalendarColor: (key: string) => void;
|
|
|
|
// Sidebar apps
|
|
addSidebarApp: (app: Omit<SidebarApp, 'id'>) => void;
|
|
updateSidebarApp: (id: string, updates: Partial<Omit<SidebarApp, 'id'>>) => void;
|
|
removeSidebarApp: (id: string) => void;
|
|
reorderSidebarApps: (apps: SidebarApp[]) => void;
|
|
|
|
// Restore every persisted key to its default (keeps identities/session state).
|
|
resetToDefaults: () => void;
|
|
// JSON blob in the webmail's export shape (lib/settings export) so a file
|
|
// round-trips between the two clients. See SETTINGS_KEY_MAP.
|
|
exportSettings: () => string;
|
|
// Returns false when the JSON is not a settings object. Unknown keys and
|
|
// invalid values are ignored; device-local keys are never imported.
|
|
importSettings: (json: string) => boolean;
|
|
|
|
reset: () => void;
|
|
}
|
|
|
|
const PERSIST_KEYS: (keyof PersistedSettings)[] = Object.keys(
|
|
DEFAULT_PERSISTED,
|
|
) as (keyof PersistedSettings)[];
|
|
|
|
function snapshot(state: SettingsState): PersistedSettings {
|
|
const out: Record<string, unknown> = {};
|
|
for (const k of PERSIST_KEYS) {
|
|
out[k] = state[k];
|
|
}
|
|
return out as unknown as PersistedSettings;
|
|
}
|
|
|
|
function persist(state: PersistedSettings): void {
|
|
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(state)).catch((err) => {
|
|
console.warn('[settings-store] persist failed', err);
|
|
});
|
|
}
|
|
|
|
const oneOf = (values: readonly unknown[]) => (v: unknown) => values.includes(v);
|
|
const intBetween = (min: number, max: number) => (v: unknown) =>
|
|
typeof v === 'number' && Number.isInteger(v) && v >= min && v <= max;
|
|
const stringArray = (v: unknown) => Array.isArray(v) && v.every((x) => typeof x === 'string');
|
|
|
|
export const SWIPE_ACTIONS: SwipeAction[] = ['none', 'archive', 'delete', 'spam', 'read', 'star', 'pin', 'move'];
|
|
|
|
// Per-key validators applied on hydrate and import. A value that fails falls
|
|
// back to the default rather than flowing into the UI (a corrupt
|
|
// `density: "x"` used to break every row-height lookup).
|
|
const VALIDATORS: Partial<Record<keyof PersistedSettings, (v: unknown) => boolean>> = {
|
|
externalContentPolicy: oneOf(['allow', 'block', 'ask']),
|
|
trustedSenders: stringArray,
|
|
dateFormat: oneOf(['smart', 'relative', 'full']),
|
|
timeFormat: oneOf(['12h', '24h']),
|
|
theme: oneOf(['light', 'dark', 'system']),
|
|
messageListOrderScope: oneOf(['inbox', 'all']),
|
|
// Levels are sanitized by the consumer (lib/message-list-order sanitizeSortLevels).
|
|
messageListOrder: (v) => Array.isArray(v),
|
|
fontSize: oneOf(['small', 'medium', 'large']),
|
|
density: oneOf(['extra-compact', 'compact', 'regular', 'comfortable']),
|
|
attachmentReminderKeywords: stringArray,
|
|
// Same set the webmail accepts (stores/settings-store.ts importSettings).
|
|
sendDelaySeconds: oneOf([0, 10, 30, 60]),
|
|
signaturePosition: oneOf(['above_quote', 'below_quote']),
|
|
replyIdentityMatch: oneOf(['exact', 'domain']),
|
|
autoSaveDraftInterval: intBetween(1000, 3600000),
|
|
// RFC 5321 atext specials minus alphanumerics and "@" (lib/sub-addressing).
|
|
subAddressDelimiter: (v) => typeof v === 'string' && /^[!#$%&'*+\-./=?^_`{|}~]$/.test(v),
|
|
preferredIdentityIds: (v) => !!v && typeof v === 'object' && !Array.isArray(v)
|
|
&& Object.values(v as Record<string, unknown>).every((x) => typeof x === 'string'),
|
|
markAsReadDelay: (v) => typeof v === 'number' && Number.isFinite(v) && v >= -1,
|
|
deleteAction: oneOf(['trash', 'trash-and-read', 'permanent']),
|
|
emailsPerPage: intBetween(1, 500),
|
|
mailAttachmentAction: oneOf(['preview', 'download']),
|
|
attachmentPosition: oneOf(['beside-sender', 'below-header']),
|
|
plainTextFont: oneOf(['sans', 'mono']),
|
|
messageSpacing: oneOf(['auto', 'always', 'edge']),
|
|
readReceiptResponse: oneOf(['ask', 'always', 'never']),
|
|
postExportAction: oneOf(['keep', 'archive', 'trash']),
|
|
swipeLeftAction: oneOf(SWIPE_ACTIONS),
|
|
swipeRightAction: oneOf(SWIPE_ACTIONS),
|
|
swipeMode: oneOf(['instant', 'reveal']),
|
|
archiveMode: oneOf(['single', 'year', 'month']),
|
|
calendarDefaultView: oneOf(['month', 'week', 'day', 'agenda']),
|
|
calendarFirstDayOfWeek: oneOf([0, 1]),
|
|
calendarTimeFormat: oneOf(['12h', '24h']),
|
|
calendarHoverPreview: oneOf(['instant', 'delay-500ms', 'delay-1s', 'delay-2s', 'off']),
|
|
filesFolderLayout: oneOf(['inline', 'sidebar']),
|
|
filesDefaultViewMode: oneOf(['list', 'grid']),
|
|
filesDefaultSortKey: oneOf(['name', 'size', 'modified']),
|
|
filesDefaultSortDir: oneOf(['asc', 'desc']),
|
|
exportSpaceReplacement: oneOf(['keep', 'underscore', 'dash']),
|
|
offlineCacheDays: intBetween(1, 3650),
|
|
offlineCacheMaxMB: intBetween(1, 100000),
|
|
sidebarApps: (v) => Array.isArray(v) && v.every((a) =>
|
|
a && typeof a === 'object'
|
|
&& typeof (a as SidebarApp).id === 'string'
|
|
&& typeof (a as SidebarApp).name === 'string'
|
|
&& typeof (a as SidebarApp).url === 'string'),
|
|
};
|
|
|
|
export function mergeWithDefaults(parsed: Partial<PersistedSettings>): PersistedSettings {
|
|
const out: Record<string, unknown> = { ...DEFAULT_PERSISTED };
|
|
for (const k of PERSIST_KEYS) {
|
|
const v = parsed[k];
|
|
if (v === undefined || v === null) continue;
|
|
const def = DEFAULT_PERSISTED[k];
|
|
// Type-tolerant merge: only adopt when the basic shape matches the default
|
|
// and the per-key validator (when there is one) accepts the value.
|
|
const validator = VALIDATORS[k];
|
|
if (validator && !validator(v)) continue;
|
|
if (k === 'bottomQuickActions') {
|
|
out[k] = normalizeBottomQuickActions(v);
|
|
} else if (Array.isArray(def)) {
|
|
if (Array.isArray(v)) out[k] = v;
|
|
} else if (typeof def === 'object') {
|
|
if (typeof v === 'object' && !Array.isArray(v)) out[k] = { ...(def as object), ...(v as object) };
|
|
} else if (typeof def === typeof v) {
|
|
out[k] = v;
|
|
}
|
|
}
|
|
return out as unknown as PersistedSettings;
|
|
}
|
|
|
|
// RN key → webmail key for the keys whose names differ. Everything else is
|
|
// exported under its own name. Device-local keys (DEVICE_LOCAL_KEYS) are
|
|
// never exported or imported. This is also the mapping a future settings
|
|
// sync would use (see docs/parity/08-settings-push-i18n-ui.md).
|
|
export const SETTINGS_KEY_MAP: Partial<Record<keyof PersistedSettings, string>> = {
|
|
calendarFirstDayOfWeek: 'firstDayOfWeek',
|
|
calendarTimeZone: 'timeZone',
|
|
calendarShowTimeInMonth: 'showTimeInMonthView',
|
|
calendarShowWeekNumbers: 'showWeekNumbers',
|
|
emailExportTemplate: 'emailDownloadTemplate',
|
|
attachmentExportTemplate: 'attachmentDownloadTemplate',
|
|
exportSpaceReplacement: 'filenameSpaceReplacement',
|
|
exportLowercase: 'filenameLowercase',
|
|
exportStripDiacritics: 'filenameStripDiacritics',
|
|
filtersExpandedView: 'expandedFilterView',
|
|
appIconUnreadBadge: 'faviconUnreadBadge',
|
|
};
|
|
|
|
// Keys that describe this device rather than the user's preferences.
|
|
export const DEVICE_LOCAL_KEYS: ReadonlySet<keyof PersistedSettings> = new Set<keyof PersistedSettings>([
|
|
'swipeMode',
|
|
'bottomQuickActions',
|
|
'offlineCacheEnabled',
|
|
'offlineCacheDays',
|
|
'offlineCacheMaxMB',
|
|
'mailSortAscending',
|
|
'filesFolderLayout',
|
|
'filesDefaultViewMode',
|
|
'filesDefaultSortKey',
|
|
'filesDefaultSortDir',
|
|
'filesShowIcons',
|
|
'filesColoredIcons',
|
|
'filesShowThumbnails',
|
|
'filesShowHiddenFiles',
|
|
'calendarDefaultView',
|
|
]);
|
|
|
|
export function toExportShape(state: PersistedSettings): Record<string, unknown> {
|
|
const out: Record<string, unknown> = {};
|
|
for (const k of PERSIST_KEYS) {
|
|
if (DEVICE_LOCAL_KEYS.has(k)) continue;
|
|
out[SETTINGS_KEY_MAP[k] ?? k] = state[k];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function fromExportShape(input: Record<string, unknown>): Partial<PersistedSettings> {
|
|
const reverse = new Map<string, keyof PersistedSettings>();
|
|
for (const [rnKey, webKey] of Object.entries(SETTINGS_KEY_MAP)) {
|
|
reverse.set(webKey as string, rnKey as keyof PersistedSettings);
|
|
}
|
|
const out: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(input)) {
|
|
const rnKey = reverse.get(key) ?? (PERSIST_KEYS.includes(key as keyof PersistedSettings) ? (key as keyof PersistedSettings) : null);
|
|
if (!rnKey || DEVICE_LOCAL_KEYS.has(rnKey)) continue;
|
|
out[rnKey] = value;
|
|
}
|
|
return out as Partial<PersistedSettings>;
|
|
}
|
|
|
|
function stripDisplayName(email: string): string {
|
|
// "Name <addr>" → addr, matching the webmail's trusted-sender normalisation.
|
|
const trimmed = email.trim();
|
|
const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/);
|
|
return (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim();
|
|
}
|
|
|
|
// Which signed-in account the identities belong to. Two servers can hand out
|
|
// the same JMAP account ids, so the server and login are part of it.
|
|
function identityScope(): string | null {
|
|
try {
|
|
return `${jmapClient.serverUrl ?? ''}|${jmapClient.username ?? ''}|${jmapClient.accountId}`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
let identitiesInFlight: { scope: string | null; promise: Promise<void> } | null = null;
|
|
|
|
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
|
...DEFAULT_PERSISTED,
|
|
identities: [],
|
|
identitiesFor: null,
|
|
loading: false,
|
|
error: null,
|
|
hydrated: false,
|
|
|
|
fetchIdentities: () => {
|
|
const scope = identityScope();
|
|
if (identitiesInFlight && identitiesInFlight.scope === scope) return identitiesInFlight.promise;
|
|
let promise: Promise<void> | undefined;
|
|
promise = (async () => {
|
|
set({ loading: true, error: null });
|
|
try {
|
|
const identities = await fetchIdentities();
|
|
// Signed in to another account meanwhile: these are not its identities.
|
|
if (identityScope() !== scope) {
|
|
set({ loading: false });
|
|
return;
|
|
}
|
|
set({ identities, identitiesFor: scope, loading: false });
|
|
} catch (err) {
|
|
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load identities' });
|
|
} finally {
|
|
if (identitiesInFlight?.promise === promise) identitiesInFlight = null;
|
|
}
|
|
})();
|
|
identitiesInFlight = { scope, promise };
|
|
return promise;
|
|
},
|
|
|
|
ensureIdentities: () => {
|
|
const scope = identityScope();
|
|
const held = get().identitiesFor;
|
|
if (scope !== null && held === scope) return Promise.resolve();
|
|
// Another account's identities must not stand in (the quick reply would
|
|
// send through one this account does not have).
|
|
if (held !== null && held !== scope) set({ identities: [], identitiesFor: null });
|
|
return get().fetchIdentities();
|
|
},
|
|
|
|
hydrate: async () => {
|
|
if (get().hydrated) return;
|
|
try {
|
|
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
|
if (raw) {
|
|
const parsed = JSON.parse(raw) as Partial<PersistedSettings>;
|
|
set({ ...mergeWithDefaults(parsed), hydrated: true });
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
console.warn('[settings-store] hydrate failed', err);
|
|
}
|
|
set({ hydrated: true });
|
|
},
|
|
|
|
updateSetting: (key, value) => {
|
|
set({ [key]: value } as Partial<SettingsState>);
|
|
persist(snapshot(get()));
|
|
},
|
|
|
|
setExternalContentPolicy: (policy) => { set({ externalContentPolicy: policy }); persist(snapshot(get())); },
|
|
setSenderFavicons: (enabled) => { set({ senderFavicons: enabled }); persist(snapshot(get())); },
|
|
setGroupContactsByLetter: (enabled) => { set({ groupContactsByLetter: enabled }); persist(snapshot(get())); },
|
|
setTheme: (theme) => { set({ theme }); persist(snapshot(get())); },
|
|
setFontSize: (fontSize) => { set({ fontSize }); persist(snapshot(get())); },
|
|
setDensity: (density) => { set({ density }); persist(snapshot(get())); },
|
|
setShowToolbarLabels: (enabled) => { set({ showToolbarLabels: enabled }); persist(snapshot(get())); },
|
|
setAnimationsEnabled: (enabled) => { set({ animationsEnabled: enabled }); persist(snapshot(get())); },
|
|
setEmailAlwaysLightMode: (enabled) => { set({ emailAlwaysLightMode: enabled }); persist(snapshot(get())); },
|
|
setAutoSelectReplyIdentity: (enabled) => { set({ autoSelectReplyIdentity: enabled }); persist(snapshot(get())); },
|
|
setAttachmentReminderEnabled: (enabled) => { set({ attachmentReminderEnabled: enabled }); persist(snapshot(get())); },
|
|
setAttachmentReminderKeywords: (keywords) => { set({ attachmentReminderKeywords: keywords }); persist(snapshot(get())); },
|
|
setSwipeLeftAction: (action) => { set({ swipeLeftAction: action }); persist(snapshot(get())); },
|
|
setSwipeRightAction: (action) => { set({ swipeRightAction: action }); persist(snapshot(get())); },
|
|
setSwipeMode: (mode) => { set({ swipeMode: mode }); persist(snapshot(get())); },
|
|
setArchiveMode: (mode) => { set({ archiveMode: mode }); persist(snapshot(get())); },
|
|
|
|
addTrustedSender: (email) => {
|
|
const normalized = stripDisplayName(email);
|
|
if (!normalized) return;
|
|
const current = get().trustedSenders;
|
|
if (current.includes(normalized)) return;
|
|
set({ trustedSenders: [...current, normalized] });
|
|
persist(snapshot(get()));
|
|
},
|
|
|
|
removeTrustedSender: (email) => {
|
|
const normalized = stripDisplayName(email);
|
|
set({ trustedSenders: get().trustedSenders.filter((e) => e !== normalized) });
|
|
persist(snapshot(get()));
|
|
},
|
|
|
|
isSenderTrusted: (email) => {
|
|
const normalized = stripDisplayName(email);
|
|
return get().trustedSenders.includes(normalized);
|
|
},
|
|
|
|
setSharedCalendarColor: (key, color) => {
|
|
set({ sharedCalendarColors: { ...get().sharedCalendarColors, [key]: color } });
|
|
persist(snapshot(get()));
|
|
},
|
|
|
|
removeSharedCalendarColor: (key) => {
|
|
const { [key]: _removed, ...rest } = get().sharedCalendarColors;
|
|
set({ sharedCalendarColors: rest });
|
|
persist(snapshot(get()));
|
|
},
|
|
|
|
addSidebarApp: (app) => {
|
|
const id = `app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
|
|
set({ sidebarApps: [...get().sidebarApps, { ...app, id }] });
|
|
persist(snapshot(get()));
|
|
},
|
|
|
|
updateSidebarApp: (id, updates) => {
|
|
set({
|
|
sidebarApps: get().sidebarApps.map((a) => (a.id === id ? { ...a, ...updates } : a)),
|
|
});
|
|
persist(snapshot(get()));
|
|
},
|
|
|
|
removeSidebarApp: (id) => {
|
|
set({ sidebarApps: get().sidebarApps.filter((a) => a.id !== id) });
|
|
persist(snapshot(get()));
|
|
},
|
|
|
|
reorderSidebarApps: (apps) => {
|
|
set({ sidebarApps: apps });
|
|
persist(snapshot(get()));
|
|
},
|
|
|
|
resetToDefaults: () => {
|
|
set({ ...DEFAULT_PERSISTED });
|
|
persist(snapshot(get()));
|
|
},
|
|
|
|
exportSettings: () => JSON.stringify(toExportShape(snapshot(get())), null, 2),
|
|
|
|
importSettings: (json) => {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(json);
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
|
|
const incoming = fromExportShape(parsed as Record<string, unknown>);
|
|
// Validate against the current state so keys absent from the file keep
|
|
// their value instead of snapping back to the default.
|
|
const merged = mergeWithDefaults({ ...snapshot(get()), ...incoming });
|
|
set({ ...merged });
|
|
persist(snapshot(get()));
|
|
return true;
|
|
},
|
|
|
|
reset: () => set({
|
|
identities: [],
|
|
identitiesFor: null,
|
|
loading: false,
|
|
error: null,
|
|
}),
|
|
}));
|