fix(identity): sync default sender identity per account (#507)
The default sender identity (`preferredPrimaryId`) lived only in the browser-local `identity-storage` store and was never written to the synced settings, so the choice was lost on clearing site data / switching browsers and never appeared in exported settings. Persist it in the synced settings store, keyed **per account** (`preferredIdentityIds: Record<accountId, identityId>`), mirroring the existing per-account `allMailFolderIds`. Per-account keying is required because JMAP identity ids are account-scoped and would otherwise collide across accounts / the unified mailbox. This supersedes the earlier username-keyed fix that had landed on main: the username-keyed map, `loadIdentities()` fallback write, and the `applyPreferredIdentityOrdering` store action (plus its settings-store hook) are removed so a single account-keyed mechanism remains. - settings-store: `preferredIdentityIds` (accountId -> identityId) in state, defaults, export, import (non-record guard), rehydrate coercion, v6 migration. - auth-store: `applyPreferredIdentity(accountId?)` reorders the active account's identities once synced settings load, and performs the one-time migration of the pre-#507 browser-local default into the synced map (keyed by accountId). Invoked from every `loadFromServer().finally()` (login / OAuth / SSO / switch / restore). `loadIdentities()` now only applies the local fallback ordering. - identity-manager-modal: the star action writes the choice by `activeAccountId`. - identity-store: `preferredPrimaryId` kept as a local (sync-off) fallback. - tests: per-account independence, export/import round-trip, import guard, and applyPreferredIdentity reorder / active-account gating / local-default migration.
This commit is contained in:
committed by
Linus Rath
parent
20d02214df
commit
01e5cd69cf
@@ -9,6 +9,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
|||||||
import { IdentityForm } from './identity-form';
|
import { IdentityForm } from './identity-form';
|
||||||
import { useIdentityStore } from '@/stores/identity-store';
|
import { useIdentityStore } from '@/stores/identity-store';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { useAccountStore } from '@/stores/account-store';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
|
||||||
function useSyncIdentities() {
|
function useSyncIdentities() {
|
||||||
@@ -207,15 +208,16 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
|||||||
|
|
||||||
const handleSetPrimary = useCallback((identity: Identity) => {
|
const handleSetPrimary = useCallback((identity: Identity) => {
|
||||||
setPreferredPrimary(identity.id);
|
setPreferredPrimary(identity.id);
|
||||||
// Persist to the synced settings (keyed by username, matching how
|
// Persist the choice per account in the synced settings store so it
|
||||||
// loadIdentities reads it back) so the choice survives a new browser /
|
// survives clearing site data, follows the user across devices, and shows
|
||||||
// cleared site data and reaches other devices (#507).
|
// up in exported settings (issue #507). JMAP identity ids are account-
|
||||||
const username = useAuthStore.getState().username || '';
|
// scoped, so the default is keyed by the active account.
|
||||||
if (username) {
|
const activeAccountId = useAccountStore.getState().activeAccountId;
|
||||||
|
if (activeAccountId) {
|
||||||
const current = useSettingsStore.getState().preferredIdentityIds;
|
const current = useSettingsStore.getState().preferredIdentityIds;
|
||||||
useSettingsStore.getState().updateSetting('preferredIdentityIds', {
|
useSettingsStore.getState().updateSetting('preferredIdentityIds', {
|
||||||
...current,
|
...current,
|
||||||
[username]: identity.id,
|
[activeAccountId]: identity.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Re-sort: move the preferred identity to the front
|
// Re-sort: move the preferred identity to the front
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { applyPreferredIdentity, useAuthStore } from '../auth-store';
|
||||||
|
import { useIdentityStore } from '../identity-store';
|
||||||
|
import { useAccountStore } from '../account-store';
|
||||||
|
import { useSettingsStore } from '../settings-store';
|
||||||
|
import type { Identity } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
const makeIdentity = (overrides: Partial<Identity> = {}): Identity => ({
|
||||||
|
id: 'id-1',
|
||||||
|
name: 'Test User',
|
||||||
|
email: 'test@example.com',
|
||||||
|
mayDelete: true,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const IDS = [
|
||||||
|
makeIdentity({ id: 'id-1', name: 'Alice', email: 'alice@example.com' }),
|
||||||
|
makeIdentity({ id: 'id-2', name: 'Bob', email: 'bob@example.com' }),
|
||||||
|
makeIdentity({ id: 'id-3', name: 'Carol', email: 'carol@example.com' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* applyPreferredIdentity() is the single mechanism that honours the synced,
|
||||||
|
* per-account default sender identity (#507). These tests drive the real
|
||||||
|
* zustand stores directly (as the other auth-store tests do).
|
||||||
|
*/
|
||||||
|
describe('applyPreferredIdentity (issue #507)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useIdentityStore.setState({ identities: [...IDS], preferredPrimaryId: null });
|
||||||
|
useAuthStore.setState({ identities: [...IDS], primaryIdentity: IDS[0] });
|
||||||
|
useAccountStore.setState({ activeAccountId: 'acc-1' });
|
||||||
|
useSettingsStore.setState({ preferredIdentityIds: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reorders the active account so the synced preferred identity is primary', () => {
|
||||||
|
useSettingsStore.setState({ preferredIdentityIds: { 'acc-1': 'id-3' } });
|
||||||
|
|
||||||
|
applyPreferredIdentity('acc-1');
|
||||||
|
|
||||||
|
expect(useAuthStore.getState().identities.map((i) => i.id)).toEqual(['id-3', 'id-1', 'id-2']);
|
||||||
|
expect(useAuthStore.getState().primaryIdentity?.id).toBe('id-3');
|
||||||
|
expect(useIdentityStore.getState().preferredPrimaryId).toBe('id-3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to the active account when no accountId is passed', () => {
|
||||||
|
useSettingsStore.setState({ preferredIdentityIds: { 'acc-1': 'id-2' } });
|
||||||
|
|
||||||
|
applyPreferredIdentity();
|
||||||
|
|
||||||
|
expect(useAuthStore.getState().identities[0].id).toBe('id-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when the target is not the active account', () => {
|
||||||
|
useSettingsStore.setState({ preferredIdentityIds: { 'acc-2': 'id-3' } });
|
||||||
|
|
||||||
|
applyPreferredIdentity('acc-2');
|
||||||
|
|
||||||
|
// active account's live ordering must be untouched
|
||||||
|
expect(useAuthStore.getState().identities.map((i) => i.id)).toEqual(['id-1', 'id-2', 'id-3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when the account has no synced default and no local fallback', () => {
|
||||||
|
applyPreferredIdentity('acc-1');
|
||||||
|
|
||||||
|
expect(useAuthStore.getState().identities.map((i) => i.id)).toEqual(['id-1', 'id-2', 'id-3']);
|
||||||
|
expect(useSettingsStore.getState().preferredIdentityIds).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('migrates the pre-#507 browser-local default into the synced map, keyed by accountId', () => {
|
||||||
|
// No synced entry, but a local (identity-storage) preferred primary exists.
|
||||||
|
useIdentityStore.setState({ preferredPrimaryId: 'id-2' });
|
||||||
|
|
||||||
|
applyPreferredIdentity('acc-1');
|
||||||
|
|
||||||
|
// adopted, persisted per account, and applied to the live ordering
|
||||||
|
expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ 'acc-1': 'id-2' });
|
||||||
|
expect(useAuthStore.getState().identities[0].id).toBe('id-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the synced value over the local fallback', () => {
|
||||||
|
useIdentityStore.setState({ preferredPrimaryId: 'id-2' });
|
||||||
|
useSettingsStore.setState({ preferredIdentityIds: { 'acc-1': 'id-3' } });
|
||||||
|
|
||||||
|
applyPreferredIdentity('acc-1');
|
||||||
|
|
||||||
|
expect(useAuthStore.getState().identities[0].id).toBe('id-3');
|
||||||
|
// the synced value is not overwritten by the migration
|
||||||
|
expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ 'acc-1': 'id-3' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { useSettingsStore } from '../settings-store';
|
||||||
|
|
||||||
|
describe('settings-store per-account preferredIdentityIds (issue #507)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useSettingsStore.setState({ preferredIdentityIds: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to an empty record (no account has a synced default)', () => {
|
||||||
|
expect(useSettingsStore.getState().preferredIdentityIds).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps each account default independent', () => {
|
||||||
|
useSettingsStore.setState({
|
||||||
|
preferredIdentityIds: { 'acct-1': 'b', 'acct-2': 'c' },
|
||||||
|
});
|
||||||
|
const map = useSettingsStore.getState().preferredIdentityIds;
|
||||||
|
expect(map['acct-1']).toBe('b');
|
||||||
|
expect(map['acct-2']).toBe('c');
|
||||||
|
expect(map['acct-3']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips through export -> import so the choice survives clearing site data', () => {
|
||||||
|
useSettingsStore.setState({ preferredIdentityIds: { 'acct-1': 'b' } });
|
||||||
|
const json = useSettingsStore.getState().exportSettings();
|
||||||
|
// Appears in exported JSON (issue #507 acceptance criterion).
|
||||||
|
expect(JSON.parse(json).preferredIdentityIds).toEqual({ 'acct-1': 'b' });
|
||||||
|
|
||||||
|
// Simulate a fresh browser: clear, then import the exported settings.
|
||||||
|
useSettingsStore.setState({ preferredIdentityIds: {} });
|
||||||
|
expect(useSettingsStore.getState().importSettings(json)).toBe(true);
|
||||||
|
expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ 'acct-1': 'b' });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('importSettings non-record guard', () => {
|
||||||
|
it('ignores a legacy array shape', () => {
|
||||||
|
useSettingsStore.setState({ preferredIdentityIds: { 'acct-1': 'b' } });
|
||||||
|
const ok = useSettingsStore.getState().importSettings(
|
||||||
|
JSON.stringify({ preferredIdentityIds: ['b'] }),
|
||||||
|
);
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ 'acct-1': 'b' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a null value', () => {
|
||||||
|
useSettingsStore.setState({ preferredIdentityIds: { 'acct-1': 'b' } });
|
||||||
|
useSettingsStore.getState().importSettings(JSON.stringify({ preferredIdentityIds: null }));
|
||||||
|
expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ 'acct-1': 'b' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a proper per-account record', () => {
|
||||||
|
useSettingsStore.getState().importSettings(
|
||||||
|
JSON.stringify({ preferredIdentityIds: { 'acct-9': 'a' } }),
|
||||||
|
);
|
||||||
|
expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ 'acct-9': 'a' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+65
-39
@@ -49,7 +49,6 @@ interface AuthState {
|
|||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
syncIdentities: () => void;
|
syncIdentities: () => void;
|
||||||
refreshIdentities: () => Promise<void>;
|
refreshIdentities: () => Promise<void>;
|
||||||
applyPreferredIdentityOrdering: () => void;
|
|
||||||
getClientForAccount: (accountId: string) => JMAPClient | undefined;
|
getClientForAccount: (accountId: string) => JMAPClient | undefined;
|
||||||
getAllConnectedClients: () => Map<string, JMAPClient>;
|
getAllConnectedClients: () => Map<string, JMAPClient>;
|
||||||
}
|
}
|
||||||
@@ -198,25 +197,16 @@ function sortIdentities(rawIdentities: Identity[], username: string): Identity[]
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadIdentities(rawIdentities: Identity[], username: string): { identities: Identity[]; primaryIdentity: Identity | null } {
|
function loadIdentities(rawIdentities: Identity[], username: string): { identities: Identity[]; primaryIdentity: Identity | null } {
|
||||||
const settings = useSettingsStore.getState();
|
// The synced per-account default sender identity (#507) is keyed by
|
||||||
const preferredMap = settings.preferredIdentityIds || {};
|
// AccountEntry.id and re-applied by applyPreferredIdentity() once
|
||||||
let preferredPrimaryId = preferredMap[username] ?? null;
|
// loadFromServer resolves (the accountId isn't known here). At load time we
|
||||||
|
// only honour the browser-local fallback (identity-storage) so the ordering
|
||||||
// One-time migration: builds before #507 stored the preferred identity only
|
// is stable before - or entirely without - settings sync.
|
||||||
// in the browser-local identity-storage (never synced). If the synced
|
const preferredPrimaryId = useIdentityStore.getState().preferredPrimaryId;
|
||||||
// settings have no entry for this account yet, adopt that legacy local value
|
|
||||||
// and write it into the synced settings so it persists across devices.
|
|
||||||
if (preferredPrimaryId == null) {
|
|
||||||
const legacy = useIdentityStore.getState().preferredPrimaryId;
|
|
||||||
if (legacy) {
|
|
||||||
preferredPrimaryId = legacy;
|
|
||||||
settings.updateSetting('preferredIdentityIds', { ...preferredMap, [username]: legacy });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const identities = sortIdentities(rawIdentities, username);
|
const identities = sortIdentities(rawIdentities, username);
|
||||||
|
|
||||||
// If user has a preferred primary, move it to front
|
// If a local preferred primary is set, move it to the front.
|
||||||
if (preferredPrimaryId) {
|
if (preferredPrimaryId) {
|
||||||
const idx = identities.findIndex((id) => id.id === preferredPrimaryId);
|
const idx = identities.findIndex((id) => id.id === preferredPrimaryId);
|
||||||
if (idx > 0) {
|
if (idx > 0) {
|
||||||
@@ -227,12 +217,60 @@ function loadIdentities(rawIdentities: Identity[], username: string): { identiti
|
|||||||
|
|
||||||
const primaryIdentity = identities[0] ?? null;
|
const primaryIdentity = identities[0] ?? null;
|
||||||
useIdentityStore.getState().setIdentities(identities);
|
useIdentityStore.getState().setIdentities(identities);
|
||||||
// Mirror the resolved choice into the identity store so the identity-manager
|
|
||||||
// UI (the ⭐ marker) reflects the active account's preferred identity.
|
|
||||||
useIdentityStore.setState({ preferredPrimaryId });
|
|
||||||
return { identities, primaryIdentity };
|
return { identities, primaryIdentity };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-apply the per-account default sender identity once synced settings are
|
||||||
|
* available (issue #507). The choice is stored server-side in the settings
|
||||||
|
* store (`preferredIdentityIds`, keyed by AccountEntry.id), so it can only be
|
||||||
|
* applied after `loadFromServer` resolves. It reorders the account's identities
|
||||||
|
* so the preferred one is primary - the composer defaults its `From` to
|
||||||
|
* identities[0]. No-op when nothing is configured for the account.
|
||||||
|
*
|
||||||
|
* Also performs the one-time migration of the pre-#507 browser-local default
|
||||||
|
* (identity-storage) into the synced per-account map, keyed by accountId.
|
||||||
|
*
|
||||||
|
* @param accountId The account to apply for; defaults to the active account.
|
||||||
|
*/
|
||||||
|
export function applyPreferredIdentity(accountId?: string | null): void {
|
||||||
|
const targetId = accountId ?? useAccountStore.getState().activeAccountId;
|
||||||
|
if (!targetId) return;
|
||||||
|
|
||||||
|
const idStore = useIdentityStore.getState();
|
||||||
|
// Only touch the live identity store when it currently holds this account's
|
||||||
|
// identities (true for the active account). Switching snapshots/restores the
|
||||||
|
// ordering per account, so a background account's order is restored later.
|
||||||
|
// The local fallback below also belongs to the active account, so gate first.
|
||||||
|
if (useAccountStore.getState().activeAccountId !== targetId) return;
|
||||||
|
|
||||||
|
let preferred = useSettingsStore.getState().preferredIdentityIds[targetId] ?? null;
|
||||||
|
|
||||||
|
// One-time migration: before #507 the default lived only in the browser-local
|
||||||
|
// identity-storage (never synced). If the synced map has no entry for this
|
||||||
|
// account yet, adopt that local value and persist it (keyed by accountId) so
|
||||||
|
// it follows the user across devices.
|
||||||
|
if (!preferred) {
|
||||||
|
const legacy = idStore.preferredPrimaryId;
|
||||||
|
if (legacy) {
|
||||||
|
preferred = legacy;
|
||||||
|
const current = useSettingsStore.getState().preferredIdentityIds;
|
||||||
|
useSettingsStore.getState().updateSetting('preferredIdentityIds', { ...current, [targetId]: legacy });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!preferred) return;
|
||||||
|
|
||||||
|
idStore.setPreferredPrimary(preferred);
|
||||||
|
const ids = [...idStore.identities];
|
||||||
|
const idx = ids.findIndex((i) => i.id === preferred);
|
||||||
|
if (idx > 0) {
|
||||||
|
const [p] = ids.splice(idx, 1);
|
||||||
|
ids.unshift(p);
|
||||||
|
idStore.setIdentities(ids);
|
||||||
|
}
|
||||||
|
useAuthStore.setState({ identities: ids, primaryIdentity: ids[0] ?? null });
|
||||||
|
}
|
||||||
|
|
||||||
function getLocaleLoginPath(): string {
|
function getLocaleLoginPath(): string {
|
||||||
if (typeof window === 'undefined') return '/en/login';
|
if (typeof window === 'undefined') return '/en/login';
|
||||||
|
|
||||||
@@ -638,6 +676,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
if (!config.settingsSyncEnabled) return;
|
if (!config.settingsSyncEnabled) return;
|
||||||
useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => {
|
useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => {
|
||||||
useSettingsStore.getState().enableSync(username, serverUrl);
|
useSettingsStore.getState().enableSync(username, serverUrl);
|
||||||
|
applyPreferredIdentity(accountId);
|
||||||
});
|
});
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
@@ -840,6 +879,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
if (!config.settingsSyncEnabled) return;
|
if (!config.settingsSyncEnabled) return;
|
||||||
useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => {
|
useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => {
|
||||||
useSettingsStore.getState().enableSync(username, serverUrl);
|
useSettingsStore.getState().enableSync(username, serverUrl);
|
||||||
|
applyPreferredIdentity(accountId);
|
||||||
});
|
});
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
@@ -977,6 +1017,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
if (!cfg.settingsSyncEnabled) return;
|
if (!cfg.settingsSyncEnabled) return;
|
||||||
useSettingsStore.getState().loadFromServer(username, ssoServerUrl).finally(() => {
|
useSettingsStore.getState().loadFromServer(username, ssoServerUrl).finally(() => {
|
||||||
useSettingsStore.getState().enableSync(username, ssoServerUrl);
|
useSettingsStore.getState().enableSync(username, ssoServerUrl);
|
||||||
|
applyPreferredIdentity(accountId);
|
||||||
});
|
});
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
@@ -1363,6 +1404,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
if (!config.settingsSyncEnabled) return;
|
if (!config.settingsSyncEnabled) return;
|
||||||
useSettingsStore.getState().loadFromServer(targetAccount.username, targetAccount.serverUrl).finally(() => {
|
useSettingsStore.getState().loadFromServer(targetAccount.username, targetAccount.serverUrl).finally(() => {
|
||||||
useSettingsStore.getState().enableSync(targetAccount.username, targetAccount.serverUrl);
|
useSettingsStore.getState().enableSync(targetAccount.username, targetAccount.serverUrl);
|
||||||
|
applyPreferredIdentity(targetAccount.id);
|
||||||
});
|
});
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
},
|
},
|
||||||
@@ -1534,6 +1576,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
if (!config.settingsSyncEnabled) return;
|
if (!config.settingsSyncEnabled) return;
|
||||||
useSettingsStore.getState().loadFromServer(targetAccount.username, targetAccount.serverUrl).finally(() => {
|
useSettingsStore.getState().loadFromServer(targetAccount.username, targetAccount.serverUrl).finally(() => {
|
||||||
useSettingsStore.getState().enableSync(targetAccount.username, targetAccount.serverUrl);
|
useSettingsStore.getState().enableSync(targetAccount.username, targetAccount.serverUrl);
|
||||||
|
applyPreferredIdentity(targetAccount.id);
|
||||||
});
|
});
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
return;
|
return;
|
||||||
@@ -1647,6 +1690,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
if (!config.settingsSyncEnabled) return;
|
if (!config.settingsSyncEnabled) return;
|
||||||
useSettingsStore.getState().loadFromServer(state.username || '', state.serverUrl!).finally(() => {
|
useSettingsStore.getState().loadFromServer(state.username || '', state.serverUrl!).finally(() => {
|
||||||
useSettingsStore.getState().enableSync(state.username || '', state.serverUrl!);
|
useSettingsStore.getState().enableSync(state.username || '', state.serverUrl!);
|
||||||
|
applyPreferredIdentity(accountId);
|
||||||
});
|
});
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
return;
|
return;
|
||||||
@@ -1718,6 +1762,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
if (!config.settingsSyncEnabled) return;
|
if (!config.settingsSyncEnabled) return;
|
||||||
useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => {
|
useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => {
|
||||||
useSettingsStore.getState().enableSync(username, serverUrl);
|
useSettingsStore.getState().enableSync(username, serverUrl);
|
||||||
|
applyPreferredIdentity(accountId);
|
||||||
});
|
});
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
return;
|
return;
|
||||||
@@ -1761,25 +1806,6 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
set({ identities, primaryIdentity });
|
set({ identities, primaryIdentity });
|
||||||
},
|
},
|
||||||
|
|
||||||
// Re-sort the already-loaded identities to honor the active account's
|
|
||||||
// synced preferred-primary identity, without a network round-trip. Used
|
|
||||||
// after settings load from the server so a fresh browser reflects the
|
|
||||||
// synced default (#507).
|
|
||||||
applyPreferredIdentityOrdering: () => {
|
|
||||||
const { username, identities } = get();
|
|
||||||
if (!username || identities.length === 0) return;
|
|
||||||
const preferredId = useSettingsStore.getState().preferredIdentityIds?.[username] ?? null;
|
|
||||||
useIdentityStore.setState({ preferredPrimaryId: preferredId });
|
|
||||||
if (!preferredId) return;
|
|
||||||
const idx = identities.findIndex((id) => id.id === preferredId);
|
|
||||||
if (idx <= 0) return; // already first, or not present
|
|
||||||
const reordered = [...identities];
|
|
||||||
const [preferred] = reordered.splice(idx, 1);
|
|
||||||
reordered.unshift(preferred);
|
|
||||||
useIdentityStore.getState().setIdentities(reordered);
|
|
||||||
set({ identities: reordered, primaryIdentity: reordered[0] ?? null });
|
|
||||||
},
|
|
||||||
|
|
||||||
refreshIdentities: async () => {
|
refreshIdentities: async () => {
|
||||||
const { client, username } = get();
|
const { client, username } = get();
|
||||||
if (!client || !username) return;
|
if (!client || !username) return;
|
||||||
|
|||||||
@@ -123,7 +123,14 @@ export const useIdentityStore = create<IdentityStore>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'identity-storage',
|
name: 'identity-storage',
|
||||||
// Only persist sub-addressing data, not identities (they're server-side)
|
// Only persist sub-addressing data, not identities (they're server-side).
|
||||||
|
// The default sender identity (`preferredPrimaryId`) is the per-account
|
||||||
|
// value for the *active* account; it is kept here purely as a local
|
||||||
|
// fallback so the choice survives a reload when settings sync is off.
|
||||||
|
// The durable, cross-device, exportable source of truth is the synced
|
||||||
|
// settings store, keyed per account (`preferredIdentityIds`), which is
|
||||||
|
// re-applied via applyPreferredIdentity() once server settings load and
|
||||||
|
// overrides this value per account (issue #507).
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
subAddress: state.subAddress,
|
subAddress: state.subAddress,
|
||||||
preferredPrimaryId: state.preferredPrimaryId,
|
preferredPrimaryId: state.preferredPrimaryId,
|
||||||
|
|||||||
+20
-22
@@ -178,13 +178,6 @@ interface SettingsState {
|
|||||||
requestReadReceiptDefault: boolean; // Pre-check "request read receipt" in the composer
|
requestReadReceiptDefault: boolean; // Pre-check "request read receipt" in the composer
|
||||||
readReceiptResponse: ReadReceiptResponse; // How to respond to incoming read-receipt requests
|
readReceiptResponse: ReadReceiptResponse; // How to respond to incoming read-receipt requests
|
||||||
|
|
||||||
// Identities
|
|
||||||
// Per-account default ("preferred primary") sender identity, keyed by
|
|
||||||
// username (the same key settings sync uses). A JMAP identity id is only
|
|
||||||
// meaningful within its own account, so this must be account-scoped. Synced
|
|
||||||
// so the choice survives a new browser / cleared site data (#507).
|
|
||||||
preferredIdentityIds: Record<string, string | null>;
|
|
||||||
|
|
||||||
// Privacy & Security
|
// Privacy & Security
|
||||||
sessionTimeout: number; // minutes (0 = never)
|
sessionTimeout: number; // minutes (0 = never)
|
||||||
trustedSenders: string[]; // Email addresses that can load external content
|
trustedSenders: string[]; // Email addresses that can load external content
|
||||||
@@ -258,6 +251,12 @@ interface SettingsState {
|
|||||||
// explicit [] = "no folders". (Replaced the legacy global string[] | null.)
|
// explicit [] = "no folders". (Replaced the legacy global string[] | null.)
|
||||||
allMailFolderIds: Record<string, string[]>;
|
allMailFolderIds: Record<string, string[]>;
|
||||||
|
|
||||||
|
// Per-account default sender identity, keyed by AccountEntry.id -> JMAP
|
||||||
|
// Identity id. Synced (and exported) so the chosen default survives clearing
|
||||||
|
// site data and follows the user across browsers/devices (issue #507). Kept
|
||||||
|
// per account because JMAP identity ids are account-scoped and would collide.
|
||||||
|
preferredIdentityIds: Record<string, string>;
|
||||||
|
|
||||||
// Email Display
|
// Email Display
|
||||||
disableThreading: boolean; // Show emails as individual messages instead of grouped by conversation
|
disableThreading: boolean; // Show emails as individual messages instead of grouped by conversation
|
||||||
|
|
||||||
@@ -396,9 +395,6 @@ const DEFAULT_SETTINGS = {
|
|||||||
requestReadReceiptDefault: false,
|
requestReadReceiptDefault: false,
|
||||||
readReceiptResponse: 'ask' as ReadReceiptResponse,
|
readReceiptResponse: 'ask' as ReadReceiptResponse,
|
||||||
|
|
||||||
// Identities
|
|
||||||
preferredIdentityIds: {} as Record<string, string | null>,
|
|
||||||
|
|
||||||
// Privacy & Security
|
// Privacy & Security
|
||||||
sessionTimeout: 0, // Never
|
sessionTimeout: 0, // Never
|
||||||
trustedSenders: [] as string[],
|
trustedSenders: [] as string[],
|
||||||
@@ -452,6 +448,7 @@ const DEFAULT_SETTINGS = {
|
|||||||
// All Mail view (gated)
|
// All Mail view (gated)
|
||||||
enableAllMailView: false,
|
enableAllMailView: false,
|
||||||
allMailFolderIds: {} as Record<string, string[]>,
|
allMailFolderIds: {} as Record<string, string[]>,
|
||||||
|
preferredIdentityIds: {} as Record<string, string>,
|
||||||
|
|
||||||
enableCrossUnreadView: false,
|
enableCrossUnreadView: false,
|
||||||
enableCrossStarredView: false,
|
enableCrossStarredView: false,
|
||||||
@@ -609,7 +606,6 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
signatureSeparatorEnabled: state.signatureSeparatorEnabled,
|
signatureSeparatorEnabled: state.signatureSeparatorEnabled,
|
||||||
requestReadReceiptDefault: state.requestReadReceiptDefault,
|
requestReadReceiptDefault: state.requestReadReceiptDefault,
|
||||||
readReceiptResponse: state.readReceiptResponse,
|
readReceiptResponse: state.readReceiptResponse,
|
||||||
preferredIdentityIds: state.preferredIdentityIds,
|
|
||||||
sessionTimeout: state.sessionTimeout,
|
sessionTimeout: state.sessionTimeout,
|
||||||
emailNotificationsEnabled: state.emailNotificationsEnabled,
|
emailNotificationsEnabled: state.emailNotificationsEnabled,
|
||||||
emailNotificationSound: state.emailNotificationSound,
|
emailNotificationSound: state.emailNotificationSound,
|
||||||
@@ -637,6 +633,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
includeGroupInUnified: state.includeGroupInUnified,
|
includeGroupInUnified: state.includeGroupInUnified,
|
||||||
enableAllMailView: state.enableAllMailView,
|
enableAllMailView: state.enableAllMailView,
|
||||||
allMailFolderIds: state.allMailFolderIds,
|
allMailFolderIds: state.allMailFolderIds,
|
||||||
|
preferredIdentityIds: state.preferredIdentityIds,
|
||||||
enableCrossUnreadView: state.enableCrossUnreadView,
|
enableCrossUnreadView: state.enableCrossUnreadView,
|
||||||
enableCrossStarredView: state.enableCrossStarredView,
|
enableCrossStarredView: state.enableCrossStarredView,
|
||||||
enableCrossAllView: state.enableCrossAllView,
|
enableCrossAllView: state.enableCrossAllView,
|
||||||
@@ -694,8 +691,8 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
if (key === 'allMailFolderIds' && !isPlainRecord(settings[key])) {
|
if (key === 'allMailFolderIds' && !isPlainRecord(settings[key])) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Defensive: a non-record (e.g. a legacy scalar) would break the
|
// Per-account map (accountId -> identityId); ignore any legacy
|
||||||
// per-account map lookups - ignore it.
|
// global/non-record value rather than corrupting the map.
|
||||||
if (key === 'preferredIdentityIds' && !isPlainRecord(settings[key])) {
|
if (key === 'preferredIdentityIds' && !isPlainRecord(settings[key])) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -877,14 +874,10 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
get().importSettings(JSON.stringify(settings));
|
get().importSettings(JSON.stringify(settings));
|
||||||
isLoadingFromServer = false;
|
isLoadingFromServer = false;
|
||||||
syncLog('Settings loaded from server successfully');
|
syncLog('Settings loaded from server successfully');
|
||||||
// Re-apply the (possibly server-updated) per-account preferred
|
// The per-account preferred sender identity (#507) is re-applied by
|
||||||
// sender identity to the already-loaded identities, so a fresh
|
// applyPreferredIdentity() in auth-store, invoked from the
|
||||||
// browser reflects the synced default without waiting for the next
|
// loadFromServer().finally() of every login / switch / restore path,
|
||||||
// identity refresh. Dynamic import avoids a static import cycle
|
// so no extra hook is needed here.
|
||||||
// (auth-store imports this store). (#507)
|
|
||||||
import('./auth-store')
|
|
||||||
.then(({ useAuthStore }) => useAuthStore.getState().applyPreferredIdentityOrdering())
|
|
||||||
.catch(() => {});
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -897,7 +890,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'settings-storage',
|
name: 'settings-storage',
|
||||||
version: 5,
|
version: 6,
|
||||||
migrate: (persisted, version) => {
|
migrate: (persisted, version) => {
|
||||||
const state = persisted as Record<string, unknown>;
|
const state = persisted as Record<string, unknown>;
|
||||||
if (version < 2 && state.listDensity) {
|
if (version < 2 && state.listDensity) {
|
||||||
@@ -925,6 +918,11 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
if (version < 5 || !isPlainRecord(state.allMailFolderIds)) {
|
if (version < 5 || !isPlainRecord(state.allMailFolderIds)) {
|
||||||
state.allMailFolderIds = {};
|
state.allMailFolderIds = {};
|
||||||
}
|
}
|
||||||
|
// v6: introduced the per-account default-identity map (issue #507).
|
||||||
|
// Coerce any missing/legacy value to an empty record.
|
||||||
|
if (version < 6 || !isPlainRecord(state.preferredIdentityIds)) {
|
||||||
|
state.preferredIdentityIds = {};
|
||||||
|
}
|
||||||
return state as unknown as SettingsState;
|
return state as unknown as SettingsState;
|
||||||
},
|
},
|
||||||
onRehydrateStorage: () => {
|
onRehydrateStorage: () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user