/* Hallmark · component: Settings mobile header · genre: modern-minimal · theme: Quiet · pre-emit critique: P5 H5 E5 S5 R5 V5 */ import { useQueryClient } from '@tanstack/react-query' import { createFileRoute } from '@tanstack/react-router' import { Check, Images, KeyRound, LogOut, Menu, Settings as SettingsIcon, UserRound } from 'lucide-react' import { useEffect, useMemo, useRef, useState } from 'react' import { AppRailLogo, AppRailMobileNav, AppRailNav } from '#app/components/AppRail' import { MobileTabBar } from '#app/components/MobileTabBar' import { CHROME_ROW_CLASS, CHROME_ROW_SHELL_CLASS } from '#app/config/layout' import { affectsUserPreferences, availableTimezones, isSupportedTimezone, type UserPreferences, useUserPreferences, } from '#app/preferences/user-preferences' import { mailboxInfoQueryOptions } from '#app/query/mailbox-info' import { clearTrustedImageSenders } from '#features/mail/lib/image-sender-trust' import { getAccountCapabilities, getMailboxInfo, resetMailboxPassword, updateMailboxDisplayName, } from '#server/fns' import { Sheet } from '#shared/components/Sheet' import { Button } from '#shared/components/ui/button' import { cn } from '#shared/lib/utils' import { OWNMAIL_VERSION } from '#shared/lib/version' export const Route = createFileRoute('/settings')({ loader: async () => { const [info, capabilities] = await Promise.all([getMailboxInfo(), getAccountCapabilities()]) return { info, capabilities } }, component: SettingsPage, }) type PasswordFeedback = { kind: 'success' | 'error'; message: string } type SettingsFeedback = { kind: 'success' | 'error'; message: string } function normalizeSettingsPreferences( displayName: string, draft: UserPreferences, fallbackPrimaryTimezone: string, ): UserPreferences { const primaryTimezone = isSupportedTimezone(draft.primaryTimezone) ? draft.primaryTimezone : fallbackPrimaryTimezone const secondaryTimezone = draft.secondaryTimezone && isSupportedTimezone(draft.secondaryTimezone) && draft.secondaryTimezone !== primaryTimezone ? draft.secondaryTimezone : '' return { ...draft, displayName: displayName.trim(), primaryTimezone, secondaryTimezone, } } function preferencesMatch(left: UserPreferences, right: UserPreferences): boolean { return JSON.stringify(left) === JSON.stringify(right) } function SettingsPage() { const { info, capabilities } = Route.useLoaderData() const queryClient = useQueryClient() const [preferences, savePreferences] = useUserPreferences() const [draft, setDraft] = useState(preferences) const [displayName, setDisplayName] = useState(info.displayName ?? '') const [persistedDisplayName, setPersistedDisplayName] = useState(info.displayName ?? '') const [saveStatus, setSaveStatus] = useState(null) const [imageChoiceStatus, setImageChoiceStatus] = useState(null) const [saving, setSaving] = useState(false) const savePendingRef = useRef(false) const settingsRevisionRef = useRef(0) const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [passwordStatus, setPasswordStatus] = useState(null) const [resettingPassword, setResettingPassword] = useState(false) const passwordPendingRef = useRef(false) const [navigationOpen, setNavigationOpen] = useState(false) const timezones = useMemo(availableTimezones, []) useEffect(() => { settingsRevisionRef.current += 1 setDraft(preferences) }, [preferences]) useEffect(() => { const invalidatePendingSave = (event: Event) => { if (!affectsUserPreferences(event)) return settingsRevisionRef.current += 1 } window.addEventListener('storage', invalidatePendingSave) window.addEventListener('ownmail:user-preferences', invalidatePendingSave) return () => { window.removeEventListener('storage', invalidatePendingSave) window.removeEventListener('ownmail:user-preferences', invalidatePendingSave) } }, []) const normalizedDraft = normalizeSettingsPreferences(displayName, draft, preferences.primaryTimezone) const persistedSettings = normalizeSettingsPreferences( persistedDisplayName, { ...preferences, displayName: persistedDisplayName }, preferences.primaryTimezone, ) const hasSettingsChanges = !preferencesMatch(normalizedDraft, persistedSettings) function update(next: Partial) { /* v8 ignore next -- Disabled preference controls make this guard defense-in-depth. @preserve */ if (savePendingRef.current) return settingsRevisionRef.current += 1 setDraft((current) => ({ ...current, ...next })) setSaveStatus(null) } function clearSenderImageChoices() { setImageChoiceStatus( clearTrustedImageSenders() ? { kind: 'success', message: 'Saved sender choices cleared.' } : { kind: 'error', message: 'We could not clear saved sender choices. Try again.' }, ) } async function save() { if (savePendingRef.current || !normalizedDraft.displayName || !hasSettingsChanges) return savePendingRef.current = true const revision = settingsRevisionRef.current const snapshot = { ...normalizedDraft } setSaveStatus(null) setSaving(true) try { const account = snapshot.displayName === persistedDisplayName ? { displayName: persistedDisplayName } : await updateMailboxDisplayName({ data: { displayName: snapshot.displayName } }) if (settingsRevisionRef.current !== revision) return queryClient.setQueryData(mailboxInfoQueryOptions().queryKey, { ...info, displayName: account.displayName, }) savePreferences({ ...snapshot, displayName: account.displayName, }) setDisplayName(account.displayName) setPersistedDisplayName(account.displayName) setSaveStatus({ kind: 'success', message: 'Settings saved.' }) } catch { if (settingsRevisionRef.current === revision) { setSaveStatus({ kind: 'error', message: 'We could not save your settings. Check the display name and try again.', }) } } finally { savePendingRef.current = false setSaving(false) } } async function changePassword(event: React.FormEvent) { event.preventDefault() if (passwordPendingRef.current || !password || !confirmPassword) return setPasswordStatus(null) if (password !== confirmPassword) { setPasswordStatus({ kind: 'error', message: 'The passwords do not match.' }) return } passwordPendingRef.current = true const passwordSnapshot = password setResettingPassword(true) try { await resetMailboxPassword({ data: { password: passwordSnapshot } }) setPassword('') setConfirmPassword('') setPasswordStatus({ kind: 'success', message: 'Password updated.' }) } catch { setPasswordStatus({ kind: 'error', message: 'We could not update your password. Check the requirements and try again.', }) } finally { passwordPendingRef.current = false setResettingPassword(false) } } const preview = (timezone: string) => new Intl.DateTimeFormat(undefined, { timeZone: isSupportedTimezone(timezone) ? timezone : preferences.primaryTimezone, weekday: 'short', hour: 'numeric', minute: '2-digit', }).format(new Date()) return (

Settings

Profile

Set the account name shown in {info.appName} and on messages you send.

{ /* v8 ignore next -- A disabled input cannot emit a user change. @preserve */ if (savePendingRef.current) return settingsRevisionRef.current += 1 setDisplayName(event.target.value) setSaveStatus(null) }} disabled={saving} minLength={1} maxLength={120} autoComplete="name" required className="mt-1 h-11 w-full rounded-md border border-border bg-card px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/40" />

Mail preferences

“Ask” keeps images off until you show them once or remember a sender.

{imageChoiceStatus ? ( {imageChoiceStatus.message} ) : null}
update({ primaryTimezone })} timezones={timezones} preview={preview(draft.primaryTimezone)} /> update({ secondaryTimezone })} timezones={timezones} preview={draft.secondaryTimezone ? preview(draft.secondaryTimezone) : 'Not shown'} includeNone />
{saveStatus ? ( {saveStatus.message} ) : null}

Password

{capabilities.passwordResetEnabled ? (

Use 18–40 characters with uppercase, lowercase, a number, and a symbol. Changing it signs in new clients with the new password.

{ /* v8 ignore next -- A disabled password input cannot emit a user change. @preserve */ if (passwordPendingRef.current) return setPassword(value) setPasswordStatus(null) }} /> { /* v8 ignore next -- A disabled password input cannot emit a user change. @preserve */ if (passwordPendingRef.current) return setConfirmPassword(value) setPasswordStatus(null) }} />
{passwordStatus ? ( {passwordStatus.message} ) : null}
) : (

Password changes are disabled by your administrator.

)}

OwnMail v{OWNMAIL_VERSION}

Sign out

End your session on this device. Your connected inboxes and settings will be preserved.

setNavigationOpen(false)} title="Navigation"> setNavigationOpen(false)} showDestinations={false} />
) } function TimezoneField({ id, label, value, disabled, onChange, timezones, preview, includeNone = false, }: { id: string label: string value: string disabled: boolean onChange: (value: string) => void timezones: string[] preview: string includeNone?: boolean }) { return ( ) } function PasswordField({ id, label, value, disabled, onChange, }: { id: string label: string value: string disabled: boolean onChange: (value: string) => void }) { return ( ) }