diff --git a/stores/settings-store.ts b/stores/settings-store.ts index fac0807b..471e8c6a 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { useThemeStore } from './theme-store'; import { useLocaleStore } from './locale-store'; +import { useAccountStore, type AccountEntry } from './account-store'; import type { NotificationSoundChoice } from '@/lib/notification-sound'; import { apiFetch } from '@/lib/browser-navigation'; import { @@ -29,6 +30,53 @@ let isLoadingFromServer = false; const SYNC_DEBOUNCE_MS = 2000; +// These are presentation preferences only. They are intentionally kept apart +// from mail behavior, identities, folders and other account-specific settings. +const VISUAL_SETTING_KEYS = [ + 'fontSize', 'density', 'animationsEnabled', + 'dateFormat', 'dateLocale', 'timeFormat', 'firstDayOfWeek', + 'showPreview', 'mailLayout', 'emailsPerPage', 'attachmentPosition', + 'emailAlwaysLightMode', 'hoverActions', 'hoverActionsMode', 'hoverActionsCorner', + 'toolbarPosition', 'showToolbarLabels', 'hideAccountSwitcher', 'showRailAccountList', + 'disableThreading', 'senderFavicons', 'showAvatarsInJunk', + 'colorfulSidebarIcons', 'tintListRowsByTag', 'showFolderTotalCount', + 'hideInlineImageAttachments', 'attachmentImagePreviewsEnabled', + 'theme', 'locale', +] as const; + +type PersistedSettings = Record; + +function visualSettings(settings: PersistedSettings): PersistedSettings { + return Object.fromEntries( + VISUAL_SETTING_KEYS + .filter((key) => key in settings) + .map((key) => [key, settings[key]]), + ); +} + +async function loadAccountSettings(account: AccountEntry): Promise { + const response = await apiFetch('/api/settings', { + headers: { + 'x-settings-username': account.username, + 'x-settings-server': account.serverUrl, + }, + }); + if (!response.ok) return null; + const { settings } = await response.json(); + return settings && typeof settings === 'object' && !Array.isArray(settings) + ? settings as PersistedSettings + : {}; +} + +async function saveAccountSettings(account: AccountEntry, settings: PersistedSettings): Promise { + const response = await apiFetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: account.username, serverUrl: account.serverUrl, settings }), + }); + if (!response.ok) throw new Error(`Failed to save settings for ${account.id}`); +} + export type FontSize = 'small' | 'medium' | 'large'; export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable'; /** @deprecated Use Density instead */ @@ -872,6 +920,28 @@ export const useSettingsStore = create()( } const { settings } = await res.json(); if (!settings) { + // Seed new account visuals only from connected accounts. + // Disconnected local accounts may belong to another browser user. + const accountStore = useAccountStore.getState(); + const target = accountStore.accounts.find( + (account) => account.username === username && account.serverUrl === serverUrl, + ); + const source = target + // Seed only from connected accounts, not every persisted local account. + ? accountStore.accounts.find((account) => account.isConnected && account.id !== target.id) + : undefined; + if (target && source) { + const sourceSettings = await loadAccountSettings(source); + if (sourceSettings) { + const visual = visualSettings(sourceSettings); + isLoadingFromServer = true; + get().importSettings(JSON.stringify(visual)); + await saveAccountSettings(target, visual); + isLoadingFromServer = false; + syncLog('Seeded visual settings for newly added account'); + return true; + } + } syncLog('No server settings found yet'); return false; } @@ -893,6 +963,7 @@ export const useSettingsStore = create()( return false; } }, + }), { name: 'settings-storage', @@ -1063,6 +1134,29 @@ if (typeof window !== 'undefined') { applyDensity(store.density); applyAnimations(store.animationsEnabled); + // Mirrors visual settings to the other connected accounts in this browser session. + const mirrorVisualSettingsToOtherAccounts = async (settings: PersistedSettings): Promise => { + const accountStore = useAccountStore.getState(); + const source = accountStore.accounts.find( + // Source must be connected; disconnected local accounts can be stale. + (account) => account.isConnected && account.username === syncUsername && account.serverUrl === syncServerUrl, + ); + if (!source) return; + + const visual = visualSettings(settings); + const otherAccounts = accountStore.accounts.filter( + // Mirror only to connected accounts, not every persisted local account. + (account) => account.isConnected && account.id !== source.id, + ); + + // Client decides the mirror targets; server only authenticates each request. + await Promise.allSettled(otherAccounts.map(async (account) => { + const existing = await loadAccountSettings(account); + if (!existing) return; + await saveAccountSettings(account, { ...existing, ...visual }); + })); + }; + // Shared sync function used by all store subscribers const syncToServer = async (retries = 1): Promise => { const settings = JSON.parse(useSettingsStore.getState().exportSettings()); @@ -1092,6 +1186,9 @@ if (typeof window !== 'undefined') { syncError('Settings sync failed:', body.error || `status ${res.status}`); } else { syncLog('Settings synced to server successfully'); + // Keep the visual subset consistent across every account while leaving + // account-specific settings untouched. + await mirrorVisualSettingsToOtherAccounts(settings); } };