Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions stores/settings-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, unknown>;

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<PersistedSettings | null> {
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<void> {
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 */
Expand Down Expand Up @@ -872,6 +920,28 @@ export const useSettingsStore = create<SettingsState>()(
}
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;
}
Expand All @@ -893,6 +963,7 @@ export const useSettingsStore = create<SettingsState>()(
return false;
}
},

}),
{
name: 'settings-storage',
Expand Down Expand Up @@ -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<void> => {
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<void> => {
const settings = JSON.parse(useSettingsStore.getState().exportSettings());
Expand Down Expand Up @@ -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);
}
};

Expand Down