diff --git a/stores/__tests__/settings-store-cross-account-views.test.ts b/stores/__tests__/settings-store-cross-account-views.test.ts new file mode 100644 index 000000000..f24753de4 --- /dev/null +++ b/stores/__tests__/settings-store-cross-account-views.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const apiFetch = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/browser-navigation', () => ({ apiFetch })); + +import { useSettingsStore } from '../settings-store'; +import { useAccountStore } from '../account-store'; + +// An account whose stored settings predate the merged mailbox. +const STORED = { enableUnifiedMailbox: false, unifiedCrossAccount: false, enableCrossAllView: false, sendDelaySeconds: 30 }; + +function serverAnswers(settings: Record) { + apiFetch.mockResolvedValue(new Response(JSON.stringify({ settings }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); +} + +describe('views spanning every account across an account switch', () => { + beforeEach(() => { + apiFetch.mockReset(); + useSettingsStore.getState().disableSync(); + useSettingsStore.setState({ + enableUnifiedMailbox: true, + unifiedCrossAccount: true, + enableCrossAllView: true, + sendDelaySeconds: 0, + }); + }); + + it('keeps the device\'s merged-mailbox choice when another of its logins loads its settings', async () => { + useAccountStore.setState({ accounts: [{ id: 'a' }, { id: 'b' }] as never }); + serverAnswers(STORED); + + await useSettingsStore.getState().loadFromServer('b@example.com', 'https://mail-b.example.com'); + + const s = useSettingsStore.getState(); + expect(s.enableUnifiedMailbox).toBe(true); + expect(s.unifiedCrossAccount).toBe(true); + expect(s.enableCrossAllView).toBe(true); + // Everything else still follows the account. + expect(s.sendDelaySeconds).toBe(30); + }); + + it('takes them from the server on a device\'s first login', async () => { + useAccountStore.setState({ accounts: [{ id: 'a' }] as never }); + serverAnswers(STORED); + + await useSettingsStore.getState().loadFromServer('a@example.com', 'https://mail-a.example.com'); + + expect(useSettingsStore.getState().enableUnifiedMailbox).toBe(false); + expect(useSettingsStore.getState().unifiedCrossAccount).toBe(false); + }); +}); diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 971a13e67..68cabc39a 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -147,6 +147,23 @@ export type ProtocolOpenMode = 'active-session' | 'new-tab'; */ const DEVICE_LOCAL_SETTING_KEYS = new Set(['proInterface']); +/** + * Settings that shape views spanning every logged-in account. They are synced + * with the active account's settings like everything else, but once a device + * holds several logins they are that device's choice: switching to another + * account must not turn the merged mailbox off just because that account's + * stored settings predate it. A first login on a device still takes them from + * the server. + */ +const CROSS_ACCOUNT_VIEW_KEYS = new Set([ + 'enableUnifiedMailbox', + 'includeGroupInUnified', + 'unifiedCrossAccount', + 'enableCrossUnreadView', + 'enableCrossStarredView', + 'enableCrossAllView', +]); + export type HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam'; /** Action fired by a mobile list-row swipe. 'none' disables that direction. */ export type SwipeAction = 'none' | 'archive' | 'delete' | 'markRead' | 'star' | 'spam'; @@ -513,7 +530,7 @@ interface SettingsState { ) => void; resetToDefaults: () => void; exportSettings: () => string; - importSettings: (json: string, opts?: { serverAccountId?: string }) => boolean; + importSettings: (json: string, opts?: { serverAccountId?: string; keepCrossAccountViews?: boolean }) => boolean; // Folder icons setFolderIcon: (mailboxId: string, icon: string) => void; @@ -905,7 +922,7 @@ export const useSettingsStore = create()( return JSON.stringify(settings, null, 2); }, - importSettings: (json: string, opts?: { serverAccountId?: string }) => { + importSettings: (json: string, opts?: { serverAccountId?: string; keepCrossAccountViews?: boolean }) => { try { const settings = JSON.parse(json); @@ -957,6 +974,9 @@ export const useSettingsStore = create()( if (DEVICE_LOCAL_SETTING_KEYS.has(key)) { return; } + if (opts?.keepCrossAccountViews && CROSS_ACCOUNT_VIEW_KEYS.has(key)) { + return; + } // Per-account maps (accountId -> value) live in every account's // synced settings blob, so a per-account server load must NOT // replace the whole map - each account's server is authoritative @@ -1168,11 +1188,16 @@ export const useSettingsStore = create()( return false; } if (settings && typeof settings === 'object') { + // With several logins on this device, the views spanning them stay + // as the device has them (see CROSS_ACCOUNT_VIEW_KEYS). + const { useAccountStore } = await import('./account-store'); + const keepCrossAccountViews = useAccountStore.getState().accounts.length > 1; isLoadingFromServer = true; // Merge (not replace) per-account maps for the account being loaded, // so multi-account logins don't clobber each other by login order. get().importSettings(JSON.stringify(settings), { serverAccountId: generateAccountId(username, serverUrl), + keepCrossAccountViews, }); isLoadingFromServer = false; syncLog('Settings loaded from server successfully');