From e468b08fb718f05b2f054716e5b4a40441fa7b7a Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Sat, 19 Sep 2026 05:33:10 +0200 Subject: [PATCH 01/23] feat: enable push notifications for several accounts at once The notification panel only ever addresses the account on screen, so arming a browser for five accounts meant switching account five times and answering the same permission prompt on each pass. The browser holds a single push subscription: enabling an account registers it against that one subscription rather than creating another. A section in the notification settings now lists every connected account with its current state and turns push on or off for the ones selected, walking them against the same subscription. A refusal at the browser level - unsupported, or permission denied - applies to every account, so it stops the walk instead of failing each account in turn and prompting repeatedly. --- components/settings/multi-account-push.tsx | 111 ++++++++++++++++++ components/settings/notification-settings.tsx | 3 + lib/__tests__/web-push.test.ts | 38 ++++++ lib/web-push.ts | 51 ++++++++ locales/ar/common.json | 12 ++ locales/ca/common.json | 12 ++ locales/cs/common.json | 12 ++ locales/da/common.json | 12 ++ locales/de/common.json | 12 ++ locales/en/common.json | 12 ++ locales/es/common.json | 12 ++ locales/fa/common.json | 12 ++ locales/fr/common.json | 12 ++ locales/he/common.json | 12 ++ locales/hu/common.json | 12 ++ locales/it/common.json | 12 ++ locales/ja/common.json | 12 ++ locales/ko/common.json | 12 ++ locales/lv/common.json | 12 ++ locales/mn/common.json | 12 ++ locales/nb/common.json | 12 ++ locales/nl/common.json | 12 ++ locales/pl/common.json | 12 ++ locales/pt/common.json | 12 ++ locales/ro/common.json | 12 ++ locales/ru/common.json | 12 ++ locales/sk/common.json | 12 ++ locales/tr/common.json | 12 ++ locales/uk/common.json | 12 ++ locales/zh-TW/common.json | 12 ++ locales/zh/common.json | 12 ++ 31 files changed, 527 insertions(+) create mode 100644 components/settings/multi-account-push.tsx diff --git a/components/settings/multi-account-push.tsx b/components/settings/multi-account-push.tsx new file mode 100644 index 000000000..fb0038d20 --- /dev/null +++ b/components/settings/multi-account-push.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { SettingsSection } from './settings-section'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { disableWebPush, enableWebPushForAccounts, isWebPushEnabled, isWebPushSupported } from '@/lib/web-push'; + +/** + * Turn push on or off for several accounts at once. + * + * The per-account panel above only ever addresses the account on screen, so + * arming a fresh device used to mean switching account N times. One browser + * subscription already serves every account (see `enableWebPushForAccounts`); + * what was missing was a place to register them all against it in one go. + */ +export function MultiAccountPushSettings({ relayBaseUrl }: { relayBaseUrl?: string }) { + const t = useTranslations('settings.notifications.push_all'); + const accounts = useAccountStore(s => s.accounts); + // Re-read the client map whenever a connection appears or drops. + const revision = useAuthStore(s => s.connectedAccountsRevision); + const [enabled, setEnabled] = useState>({}); + const [chosen, setChosen] = useState>(new Set()); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(''); + + const connected = accounts.filter(a => a.isConnected && useAuthStore.getState().getClientForAccount(a.id)); + + const refresh = useCallback(async () => { + const entries = await Promise.all( + useAccountStore.getState().accounts.map(async a => [a.id, await isWebPushEnabled(a.id)] as const), + ); + setEnabled(Object.fromEntries(entries)); + }, []); + + useEffect(() => { void refresh(); }, [refresh, revision]); + + if (!isWebPushSupported() || connected.length < 2) return null; + + const toggle = (id: string, on: boolean) => setChosen(prev => { + const next = new Set(prev); + if (on) next.add(id); else next.delete(id); + return next; + }); + + const run = async (action: 'enable' | 'disable') => { + const auth = useAuthStore.getState(); + const targets = connected.filter(a => chosen.has(a.id)); + if (!targets.length) return; + setBusy(true); setNotice(''); + try { + if (action === 'enable') { + const outcome = await enableWebPushForAccounts( + targets.map(a => ({ accountId: a.id, client: auth.getClientForAccount(a.id)!, accountLabel: a.username })), + { relayBaseUrl }, + ); + setNotice(outcome.failed.length + ? t('result_partial', { enabled: outcome.enabled.length, failed: outcome.failed.length }) + : t('result_enabled', { count: outcome.enabled.length })); + } else { + // Teardown swallows its own failures, so every account ends up local-off. + for (const account of targets) { + await disableWebPush({ client: auth.getClientForAccount(account.id)!, relayBaseUrl }); + } + setNotice(t('result_disabled', { count: targets.length })); + } + setChosen(new Set()); + } finally { + setBusy(false); + await refresh(); + } + }; + + const allChosen = connected.length > 0 && connected.every(a => chosen.has(a.id)); + + return +
+ +
    + {connected.map(account =>
  • + +
  • )} +
+
+ + +
+ {notice &&

{notice}

} +
+
; +} diff --git a/components/settings/notification-settings.tsx b/components/settings/notification-settings.tsx index 2864b3958..3170c8007 100644 --- a/components/settings/notification-settings.tsx +++ b/components/settings/notification-settings.tsx @@ -11,6 +11,7 @@ import { Loader2, RefreshCw, Volume2, XCircle } from 'lucide-react'; import { usePolicyStore } from '@/stores/policy-store'; import { useAuthStore } from '@/stores/auth-store'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { MultiAccountPushSettings } from './multi-account-push'; import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; import { WebPushUnsupportedError, @@ -251,6 +252,8 @@ export function NotificationSettings() { )} + + {client && (
diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index 369266b2a..02fc80183 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -4,6 +4,7 @@ import type { EmailPushConfig, PushSubscription as JmapPushSubscription } from ' import { disableWebPush, enableWebPush, + enableWebPushForAccounts, listPushDevices, resetWebPushResyncState, resyncWebPush, @@ -454,3 +455,40 @@ describe('revokePushDevice', () => { expect(localStorage.getItem(SUB_KEY)).toBeNull(); }); }); + +describe('enableWebPushForAccounts', () => { + it('registers every account against the one browser subscription', async () => { + const first = makeClient([]); + const second = makeClient([]); + installFetch({}); + + const result = await enableWebPushForAccounts([ + { accountId: 'a', client: first, accountLabel: 'one' }, + { accountId: 'b', client: second, accountLabel: 'two' }, + ], { relayBaseUrl: RELAY }); + + expect(result.enabled).toEqual(['a', 'b']); + expect(result.failed).toEqual([]); + expect(first.createPushSubscription).toHaveBeenCalledTimes(1); + expect(second.createPushSubscription).toHaveBeenCalledTimes(1); + }); + + it('stops at a browser-level refusal instead of prompting once per account', async () => { + const first = makeClient([]); + const second = makeClient([]); + installFetch({}); + // Blocked notifications are a property of the browser, not of an account: + // walking on would fail identically for every one of them. + (globalThis as unknown as { Notification: unknown }).Notification = { permission: 'denied' }; + (window as unknown as { Notification: unknown }).Notification = { permission: 'denied' }; + + const result = await enableWebPushForAccounts([ + { accountId: 'a', client: first }, + { accountId: 'b', client: second }, + ], { relayBaseUrl: RELAY }); + + expect(result.enabled).toEqual([]); + expect(result.failed.map(f => f.accountId)).toEqual(['a', 'b']); + expect(second.createPushSubscription).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/web-push.ts b/lib/web-push.ts index 973e1cd5f..ad4ab123d 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -695,3 +695,54 @@ export async function resyncWebPush(params: ResyncWebPushParams): Promise; +} + +/** + * Enable push for several accounts against one browser subscription. + * + * The browser only ever holds a single PushSubscription: `enableWebPush` reuses + * it and registers one server-side subscription per account, which is why every + * account has to be walked separately. Sequential on purpose - the first call + * is the one that may prompt for permission, and parallel prompts race. + * + * A browser-level refusal (unsupported, permission denied or dismissed) applies + * to every account, so it stops the walk instead of failing them one by one. + */ +export async function enableWebPushForAccounts( + targets: BulkWebPushTarget[], + options: { relayBaseUrl?: string; forceRecreate?: boolean } = {}, +): Promise { + const result: BulkWebPushResult = { enabled: [], failed: [] }; + let browserRefusal: unknown; + for (const target of targets) { + if (browserRefusal !== undefined) { + result.failed.push({ accountId: target.accountId, error: browserRefusal }); + continue; + } + try { + await enableWebPush({ + client: target.client, + relayBaseUrl: options.relayBaseUrl, + accountLabel: target.accountLabel, + forceRecreate: options.forceRecreate, + }); + result.enabled.push(target.accountId); + } catch (error) { + if (error instanceof WebPushUnsupportedError || !isWebPushSupported() + || Notification.permission !== 'granted') browserRefusal = error; + result.failed.push({ accountId: target.accountId, error }); + } + } + return result; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 53d4bf6d8..40027e47e 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -1183,6 +1183,18 @@ "confirm_revoke_message": "سيتوقّف فورًا عن تلقّي الإشعارات في الخلفية. تفعيل الدفع مجددًا على ذلك الجهاز سيسجّله من جديد.", "confirm_revoke_message_this": "سيتوقّف هذا الجهاز فورًا عن تلقّي الإشعارات في الخلفية." }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "صوت الإشعار", "description": "اختر الصوت الذي يُشغَّل للإشعارات", diff --git a/locales/ca/common.json b/locales/ca/common.json index 686039db0..0ce9f1c47 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -1183,6 +1183,18 @@ "confirm_revoke_message": "Deixarà de rebre notificacions en segon pla immediatament. Si hi tornes a activar les notificacions push, es registrarà de nou.", "confirm_revoke_message_this": "Aquest dispositiu deixarà de rebre notificacions en segon pla immediatament." }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "So de notificació", "description": "Trieu quin so es reprodueix per a les notificacions", diff --git a/locales/cs/common.json b/locales/cs/common.json index 5262ee69d..a5f64effd 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1144,6 +1144,18 @@ "swift": "Rychlý", "relax": "Relaxační" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Zvuk oznámení", "description": "Vyberte zvuk, který se přehraje při oznámení", diff --git a/locales/da/common.json b/locales/da/common.json index 09552435b..15560af42 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1183,6 +1183,18 @@ "confirm_revoke_message": "Den stopper med at modtage baggrundsnotifikationer med det samme. Slår du push til igen på enheden, registreres den på ny.", "confirm_revoke_message_this": "Denne enhed stopper med at modtage baggrundsnotifikationer med det samme." }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Notifikationslyd", "description": "Vælg hvilken lyd der skal afspilles for notifikationer", diff --git a/locales/de/common.json b/locales/de/common.json index 2d39bfc03..673beedd9 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1144,6 +1144,18 @@ "swift": "Schnelle Geste", "relax": "Entspannt" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Benachrichtigungston", "description": "Wählen Sie den Ton für Benachrichtigungen", diff --git a/locales/en/common.json b/locales/en/common.json index 78a509e09..19346029c 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1183,6 +1183,18 @@ "confirm_revoke_message": "It will stop receiving background notifications immediately. Enabling push again on that device registers it anew.", "confirm_revoke_message_this": "This device will stop receiving background notifications immediately." }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Notification Sound", "description": "Choose which sound to play for notifications", diff --git a/locales/es/common.json b/locales/es/common.json index ae2413a7f..85f70d57e 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1144,6 +1144,18 @@ "swift": "Gesto rápido", "relax": "Relajado" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Sonido de notificación", "description": "Elige qué sonido reproducir para las notificaciones", diff --git a/locales/fa/common.json b/locales/fa/common.json index c454007f8..380af698a 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1183,6 +1183,18 @@ "confirm_revoke_message": "بی‌درنگ دریافت اعلان‌های پس‌زمینه را متوقف می‌کند. با روشن‌کردن دوبارهٔ پوش روی آن دستگاه، دوباره ثبت می‌شود.", "confirm_revoke_message_this": "این دستگاه بی‌درنگ دریافت اعلان‌های پس‌زمینه را متوقف می‌کند." }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "صدای اعلان", "description": "صدای پخش شده برای اعلان‌ها", diff --git a/locales/fr/common.json b/locales/fr/common.json index 738fd0b3f..5e1237dce 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1144,6 +1144,18 @@ "swift": "Geste rapide", "relax": "Détente" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Son de notification", "description": "Choisissez le son à jouer pour les notifications", diff --git a/locales/he/common.json b/locales/he/common.json index a03914d4f..4b6432e52 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -1109,6 +1109,18 @@ "swift": "מחווה מהירה", "relax": "תירגע" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "צליל התראה", "description": "בחר איזה צליל להפעיל עבור התראות", diff --git a/locales/hu/common.json b/locales/hu/common.json index d348bccb5..5482d43fe 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1183,6 +1183,18 @@ "confirm_revoke_message": "Azonnal nem kap több háttérértesítést. Ha az eszközön újra bekapcsolja a push értesítéseket, újra regisztrálódik.", "confirm_revoke_message_this": "Ez az eszköz azonnal nem kap több háttérértesítést." }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Értesítési hang", "description": "Válaszd ki az értesítésekhez használt hangot", diff --git a/locales/it/common.json b/locales/it/common.json index 30c7ab7af..bbffa0958 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1144,6 +1144,18 @@ "swift": "Gesto veloce", "relax": "Rilassante" }, + "push_all": { + "title": "Tutti gli account", + "description": "Attiva o disattiva le notifiche per più account in una volta sola. Una sola iscrizione del browser li serve tutti.", + "select_all": "Seleziona tutti gli account collegati", + "state_on": "notifiche attive", + "state_off": "notifiche non attive", + "enable_selected": "Attiva per i selezionati", + "disable_selected": "Disattiva per i selezionati", + "result_enabled": "Notifiche attivate per {count} account.", + "result_partial": "Attivate per {enabled} account; {failed} non è stato possibile attivarle.", + "result_disabled": "Notifiche disattivate per {count} account." + }, "sound_selection": { "title": "Suono di notifica", "description": "Scegli quale suono riprodurre per le notifiche", diff --git a/locales/ja/common.json b/locales/ja/common.json index c6555f190..bed548ec7 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1144,6 +1144,18 @@ "swift": "スウィフトジェスチャー", "relax": "リラックス" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "通知音", "description": "通知に使用する音を選択", diff --git a/locales/ko/common.json b/locales/ko/common.json index 93f56bda1..52073f6b1 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1144,6 +1144,18 @@ "swift": "빠르게", "relax": "차분하게" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "알림음", "description": "알림이 올 때 재생할 소리를 선택해 주세요", diff --git a/locales/lv/common.json b/locales/lv/common.json index b31dd7047..bdab467b3 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1144,6 +1144,18 @@ "swift": "Ātrs", "relax": "Relaksējošs" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Paziņojuma skaņa", "description": "Izvēlieties skaņu paziņojumiem", diff --git a/locales/mn/common.json b/locales/mn/common.json index 0fc2b17f2..22053f418 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -1183,6 +1183,18 @@ "confirm_revoke_message": "Тэр даруй араарх мэдэгдэл хүлээн авахаа болино. Тухайн төхөөрөмж дээр push-ыг дахин асаавал дахин бүртгэгднэ.", "confirm_revoke_message_this": "Энэ төхөөрөмж тэр даруй араарх мэдэгдэл хүлээн авахаа болино." }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Мэдэгдэлийн дуу", "description": "Мэдэгдэл авахын тулд ямар дууг тоглуулахаа сонгоно уу", diff --git a/locales/nb/common.json b/locales/nb/common.json index 3648dccfc..555de1f2f 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -1183,6 +1183,18 @@ "confirm_revoke_message": "Enheten slutter umiddelbart å motta bakgrunnsvarsler. Hvis pushvarsler aktiveres på nytt på enheten, registreres den på nytt.", "confirm_revoke_message_this": "Denne enheten slutter umiddelbart å motta bakgrunnsvarsler." }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Varslingslyd", "description": "Velg hvilken lyd som skal spilles av for varsler", diff --git a/locales/nl/common.json b/locales/nl/common.json index 12e564a77..4b629004b 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1144,6 +1144,18 @@ "swift": "Snel gebaar", "relax": "Ontspannen" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Meldingsgeluid", "description": "Kies welk geluid wordt afgespeeld voor meldingen", diff --git a/locales/pl/common.json b/locales/pl/common.json index e903226ee..cfc7d6dd5 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1144,6 +1144,18 @@ "swift": "Szybki gest", "relax": "Relaks" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Dźwięk powiadomienia", "description": "Wybierz dźwięk odtwarzany dla powiadomień", diff --git a/locales/pt/common.json b/locales/pt/common.json index bb8f8c8fc..d623f1cde 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1144,6 +1144,18 @@ "swift": "Gesto rápido", "relax": "Relaxante" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Som de notificação", "description": "Escolha qual som reproduzir para notificações", diff --git a/locales/ro/common.json b/locales/ro/common.json index 0af8fbf90..f9690457c 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -1183,6 +1183,18 @@ "confirm_revoke_message": "Va înceta imediat să primească notificări în fundal. Reactivarea push pe acel dispozitiv îl va înregistra din nou.", "confirm_revoke_message_this": "Acest dispozitiv va înceta imediat să primească notificări în fundal." }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Sunet de notificare", "description": "Alegeți ce sunet să se redea pentru notificări", diff --git a/locales/ru/common.json b/locales/ru/common.json index bd08b879c..fb8648b06 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1144,6 +1144,18 @@ "swift": "Быстрый жест", "relax": "Релакс" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Звук уведомления", "description": "Выберите звук для уведомлений", diff --git a/locales/sk/common.json b/locales/sk/common.json index 2e17b787a..43879446a 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1147,6 +1147,18 @@ "swift": "Rýchly", "relax": "Relaxačný" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Zvuk oznámenia", "description": "Vyberte zvuk, ktorý sa prehrá pri oznámení", diff --git a/locales/tr/common.json b/locales/tr/common.json index f6755de49..4cc0783c1 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1144,6 +1144,18 @@ "swift": "Hızlı Jest", "relax": "Rahatla" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Bildirim Sesi", "description": "Bildirimler için hangi sesin çalınacağını seçin", diff --git a/locales/uk/common.json b/locales/uk/common.json index a896c863c..63d19c065 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1144,6 +1144,18 @@ "swift": "Швидкий жест", "relax": "Розслабтеся" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "Звук сповіщення", "description": "Виберіть звук для сповіщень", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 289ce6d53..6f8818fb6 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -1183,6 +1183,18 @@ "confirm_revoke_message": "此裝置會立即停止接收背景通知。若在該裝置上再次啟用推送,將重新註冊。", "confirm_revoke_message_this": "此裝置會立即停止接收背景通知。" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "通知聲音", "description": "選擇通知時播放的聲音", diff --git a/locales/zh/common.json b/locales/zh/common.json index 2cf732396..fba5f24d2 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1144,6 +1144,18 @@ "swift": "轻快手势", "relax": "放松" }, + "push_all": { + "title": "All accounts", + "description": "Turn push on or off for several accounts at once. One browser subscription serves them all.", + "select_all": "Select every connected account", + "state_on": "notifications on", + "state_off": "notifications off", + "enable_selected": "Enable for selected", + "disable_selected": "Disable for selected", + "result_enabled": "Notifications enabled for {count} accounts.", + "result_partial": "Enabled for {enabled} accounts; {failed} could not be enabled.", + "result_disabled": "Notifications disabled for {count} accounts." + }, "sound_selection": { "title": "通知声音", "description": "选择通知时播放的声音", From fac3bdf31c14eedbb111ddce0a29d46c074b9846 Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Sat, 19 Sep 2026 08:59:21 +0200 Subject: [PATCH 02/23] fix: scope batch selection to the owning account Email ids are unique only within their account, so in a view that merges several - the unified mailbox, and since #1038 the tag view - two unrelated messages can share one. Selection was a set of bare ids resolved with `emails.filter(e => selected.has(e.id))`, so ticking one row also ticked its namesake in another account, and batch delete, move, archive or mark-read then acted on a message nobody selected. Reported by @alyyousuf7 on #1013, where `eiaaaabc` exists in two accounts at once. Selection is now keyed by `emailKeyFor` - the same account scoping #1013 introduced for threads - and the actions take the email rather than its id, so a caller cannot reintroduce the collision by passing a bare id. Unstamped emails keep the bare id as their key, so single-account views are unchanged. The batch actions derive the JMAP ids from the selected emails themselves instead of looking each id up again, which is where the namesake used to ride along, and every place that pruned the selection after a delete or a move now prunes by key. --- .../email/__tests__/thread-list-item.test.tsx | 14 +- components/email/email-list.tsx | 20 +- components/email/thread-email-item.tsx | 14 +- components/email/thread-list-item.tsx | 36 ++-- components/mail/mail-app.tsx | 42 ++-- hooks/use-email-drag.ts | 16 +- hooks/use-mailbox-drop.ts | 6 +- lib/__tests__/account-state-manager.test.ts | 4 +- lib/account-state-manager.ts | 2 +- lib/thread-utils.ts | 17 ++ stores/__tests__/email-archive.test.ts | 4 +- .../email-selection-cross-account.test.ts | 62 ++++++ stores/__tests__/email-selection.test.ts | 62 +++--- .../email-store-archive-missing.test.ts | 4 +- .../email-store-multi-account.test.ts | 6 +- .../email-store-retained-in-view.test.ts | 4 +- .../email-store-shared-folder-actions.test.ts | 4 +- ...email-store-shared-folder-keywords.test.ts | 2 +- .../__tests__/email-store-tag-counts.test.ts | 4 +- .../email-store-tag-view-accounts.test.ts | 6 +- .../email-store-unified-actions.test.ts | 16 +- stores/email-store.ts | 183 ++++++++++-------- 22 files changed, 325 insertions(+), 203 deletions(-) create mode 100644 stores/__tests__/email-selection-cross-account.test.ts diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx index f9b312e39..4b3f6d249 100644 --- a/components/email/__tests__/thread-list-item.test.tsx +++ b/components/email/__tests__/thread-list-item.test.tsx @@ -53,7 +53,7 @@ describe('ThreadListItem tag badge', () => { mailLayout: 'split', }); useEmailStore.setState({ - selectedEmailIds: new Set(), + selectedEmailKeys: new Set(), selectedMailbox: 'inbox', }); }); @@ -126,7 +126,7 @@ describe('ThreadListItem multi-message thread', () => { mailLayout: 'split', }); useEmailStore.setState({ - selectedEmailIds: new Set(), + selectedEmailKeys: new Set(), selectedMailbox: 'inbox', }); }); @@ -187,7 +187,7 @@ describe('ThreadListItem row content', () => { mailLayout: 'split', }); useEmailStore.setState({ - selectedEmailIds: new Set(), + selectedEmailKeys: new Set(), selectedMailbox: 'inbox', }); }); @@ -226,8 +226,8 @@ describe('ThreadListItem shift-range avatar selection', () => { // Selection mode active, with the anchor on e1. useEmailStore.setState({ emails: [e1, e2, e3], - selectedEmailIds: new Set(['e1']), - lastSelectedEmailId: 'e1', + selectedEmailKeys: new Set(['e1']), + lastSelectedEmailKey: 'e1', selectedMailbox: 'inbox', }); @@ -237,7 +237,7 @@ describe('ThreadListItem shift-range avatar selection', () => { avatar.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true })); }); - const selected = useEmailStore.getState().selectedEmailIds; + const selected = useEmailStore.getState().selectedEmailKeys; expect(selected.has('e1')).toBe(true); expect(selected.has('e2')).toBe(true); // the row in between got filled in expect(selected.has('e3')).toBe(true); @@ -256,7 +256,7 @@ describe('ThreadListItem row tint', () => { tintListRowsByTag: true, }); useEmailStore.setState({ - selectedEmailIds: new Set(['email-1']), + selectedEmailKeys: new Set(['email-1']), selectedMailbox: 'inbox', }); }); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 71ef9e256..37bb72a15 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -88,7 +88,7 @@ export function EmailList({ const tSpam = useTranslations('email_viewer.spam'); const { client } = useAuthStore(); const { - selectedEmailIds, + selectedEmailKeys, selectAllEmails: _selectAllEmails, clearSelection, batchMarkAsRead, @@ -227,7 +227,7 @@ export function EmailList({
); - const hasSelection = selectedEmailIds.size > 0; + const hasSelection = selectedEmailKeys.size > 0; const handleBatchMarkAsRead = async (read: boolean) => { if (!client || isProcessing) return; @@ -243,7 +243,7 @@ export function EmailList({ if (!client || isProcessing) return; setIsProcessing(true); try { - const emailIds = Array.from(selectedEmailIds); + const emailIds = Array.from(selectedEmailKeys); await batchUndoSpam(client, emailIds); const { toast } = await import('sonner'); toast.success(tSpam('toast_not_spam_batch', { count: emailIds.length })); @@ -266,8 +266,8 @@ export function EmailList({ ? t('permanent_delete_confirm_title') : t('batch_actions.delete_confirm_title'), message: isInTrash - ? t('permanent_delete_confirm_batch_message', { count: selectedEmailIds.size }) - : t('batch_actions.delete_confirm_message', { count: selectedEmailIds.size }), + ? t('permanent_delete_confirm_batch_message', { count: selectedEmailKeys.size }) + : t('batch_actions.delete_confirm_message', { count: selectedEmailKeys.size }), confirmText: isInTrash ? t('permanent_delete') : t('batch_actions.delete'), @@ -391,7 +391,7 @@ export function EmailList({
- {t('batch_actions.selected_messages', { count: selectedEmailIds.size })} + {t('batch_actions.selected_messages', { count: selectedEmailKeys.size })}
@@ -629,8 +629,8 @@ export function EmailList({ mailboxes={mailboxes} selectedMailbox={selectedMailbox} currentMailboxRole={effectiveMailboxRole} - isMultiSelect={selectedEmailIds.has(contextMenuEmail.id)} - selectedCount={selectedEmailIds.size} + isMultiSelect={selectedEmailKeys.has(contextMenuEmail.id)} + selectedCount={selectedEmailKeys.size} onReply={() => onReply?.(contextMenuEmail!)} onReplyAll={() => onReplyAll?.(contextMenuEmail!)} onForward={() => onForward?.(contextMenuEmail!)} @@ -660,7 +660,7 @@ export function EmailList({ onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)} onBatchMarkAsSpam={async () => { if (client) { - const emailIds = Array.from(selectedEmailIds); + const emailIds = Array.from(selectedEmailKeys); try { await batchMarkAsSpam(client, emailIds); const { toast } = await import('sonner'); @@ -675,7 +675,7 @@ export function EmailList({ }} onBatchUndoSpam={async () => { if (client) { - const emailIds = Array.from(selectedEmailIds); + const emailIds = Array.from(selectedEmailKeys); try { await batchUndoSpam(client, emailIds); const { toast } = await import('sonner'); diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx index 24c97d7e7..ca6b99aaf 100644 --- a/components/email/thread-email-item.tsx +++ b/components/email/thread-email-item.tsx @@ -45,9 +45,9 @@ export function ThreadEmailItem({ // header pools them, so without this they disappear on the way in. const tagIds = sortTagIds(getEmailTagIds(email.keywords)); const sender = email.from?.[0]; - const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); + const { selectedMailbox, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); const density = useSettingsStore((state) => state.density); - const isChecked = selectedEmailIds.has(email.id); + const isChecked = selectedEmailKeys.has(email.id); const { dragHandlers, isDragging } = useEmailDrag({ email, @@ -73,18 +73,18 @@ export function ThreadEmailItem({ const handleCheckboxClick = (e: React.MouseEvent) => { e.stopPropagation(); - toggleEmailSelection(email.id); + toggleEmailSelection(email); }; const handleClick = (e: React.MouseEvent) => { if (e.ctrlKey || e.metaKey) { e.preventDefault(); - toggleEmailSelection(email.id); + toggleEmailSelection(email); } else if (e.shiftKey) { e.preventDefault(); - selectRangeEmails(email.id); + selectRangeEmails(email); } else { - if (selectedEmailIds.size > 0) clearSelection(); + if (selectedEmailKeys.size > 0) clearSelection(); onClick?.(); } }; @@ -118,7 +118,7 @@ export function ThreadEmailItem({ >
{/* Checkbox - only visible when in selection mode */} - {selectedEmailIds.size > 0 && ( + {selectedEmailKeys.size > 0 && ( )} + {showBackButton && mvMobileViewer && ( + <> + + + + )} {isScheduled && canCancelScheduled && ( <> )} - {!isScheduled && !isDraft && (<> + {!isScheduled && !isDraft && !mvViewer && (<> {moreMenuOpen && !isMobile && (
)}
- {/* Date/time on the right of subject row - hidden on mobile, shown next to sender */} -
+ {/* Date/time on the right of subject row - hidden on mobile, shown next to sender. + Mountain View puts it on the sender's row instead, and shows no size. */} +
{formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} @@ -4012,7 +4050,7 @@ export function EmailViewer({ )} {/* Email Content Area */} -
+
{/* === SENDER INFO (Desktop) === */} @@ -4047,6 +4085,9 @@ export function EmailViewer({ ) : ( {t('unknown_sender')} )} + {mvViewer && sender?.email && sender?.name && ( + <{sender.email}> + )} {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( )} + {mvViewer && ( + + {formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} + + )}
- {/* Email address under name */} - {sender?.email && sender?.name && ( + {/* Email address under name (on the name's line under Mountain View) */} + {!mvViewer && sender?.email && sender?.name && (
{sender.email}
)}
@@ -4336,8 +4382,8 @@ export function EmailViewer({ /> )}
- {/* Email address under name */} - {sender?.email && sender?.name && ( + {/* Email address under name - Mountain View keeps it in the details */} + {!mvMobileViewer && sender?.email && sender?.name && (
{sender.email}
)} {/* Row 2: Recipients */} @@ -4379,9 +4425,11 @@ export function EmailViewer({ {/* Date/time + size on the right (mobile) */}
- {formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} + {mvMobileViewer + ? formatDate(emailDisplayDate(email)) + : formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} - {email.size > 0 && ( + {email.size > 0 && !mvMobileViewer && (
{formatFileSize(email.size)}
@@ -5322,8 +5370,29 @@ export function EmailViewer({ + {/* Mountain View: Reply / Reply all / Forward as buttons under the message. */} + {mvReplyRow && !isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && ( +
+ + + +
+ )} + {/* Quick Reply Section - hidden for drafts and while loading a new email */} - {!isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (
+ {!mvReplyRow && !isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (
("normal"); + const minimized = state === "minimized"; + const maximized = state === "maximized"; + + return ( + <> + {maximized && ( +
setState("normal")} + /> + )} +
+
+ + +
+ {/* Folded down, the title bar is the whole window and opens it again. */} + {minimized && ( +
+ + ); +} diff --git a/components/email/mv-list-toolbar.tsx b/components/email/mv-list-toolbar.tsx new file mode 100644 index 000000000..3f320d975 --- /dev/null +++ b/components/email/mv-list-toolbar.tsx @@ -0,0 +1,529 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + Archive, + CheckSquare, + Folder, + FolderInput, + Loader2, + Mail, + MailOpen, + MoreVertical, + RotateCcw, + ShieldAlert, + ShieldCheck, + Square, + Tag, + Trash2, +} from "@/components/icons"; +import { useTranslations } from "next-intl"; +import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; +import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; +import { useMenuNavigation } from "@/hooks/use-menu-navigation"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { TagPicker } from "@/components/email/tag-picker"; +import { localizeMailboxName } from "@/lib/mailbox-label"; +import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets"; +import { getEmailTagIds } from "@/lib/thread-utils"; +import { cn, type MailboxNode } from "@/lib/utils"; + +interface MvListToolbarProps { + /** Loaded conversation count, for the right-hand range readout. */ + loadedCount: number; + /** Total in the folder, when the server has told us. */ + totalCount?: number; + onRefresh: () => void; + isRefreshing?: boolean; + onMarkFolderRead?: () => void; + onMarkAllFoldersRead?: () => void; + /** Only offered where emptying is the usual thing to do: spam and the bin. */ + onEmptyFolder?: () => void; + /** Scheduled view: only the per-message scheduling actions make sense. */ + disabled?: boolean; +} + +function ToolbarButton({ + icon: Icon, + label, + onClick, + disabled, + busy, + className, +}: { + icon: typeof Archive; + label: string; + onClick: () => void; + disabled?: boolean; + busy?: boolean; + className?: string; +}) { + return ( + + ); +} + +/** + * Mountain View's single list toolbar: one row above the list that morphs. + * With nothing selected it offers select-all, refresh and an overflow menu; + * as soon as a conversation is ticked the batch verbs appear in the same row, + * rather than in a second bar that pushes the list down. + */ +export function MvListToolbar({ + loadedCount, + totalCount, + onRefresh, + isRefreshing = false, + onMarkFolderRead, + onMarkAllFoldersRead, + onEmptyFolder, + disabled = false, +}: MvListToolbarProps) { + const t = useTranslations("email_list"); + const tBatch = useTranslations("email_list.batch_actions"); + const tActions = useTranslations("settings.email_behavior.hover_actions"); + const tFolder = useTranslations("mailbox_context_menu"); + const tMenu = useTranslations("context_menu"); + const tSidebar = useTranslations("sidebar"); + + const client = useAuthStore((s) => s.client); + const { + emails, + mailboxes, + selectedMailbox, + selectedEmailIds, + selectAllEmails, + clearSelection, + batchArchive, + batchDelete, + batchMarkAsRead, + batchMarkAsSpam, + batchUndoSpam, + batchMoveToMailbox, + batchSetTag, + isUnifiedView, + unifiedRole, + } = useEmailStore(); + + const [isProcessing, setIsProcessing] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const menuButtonRef = useRef(null); + const closeMenu = useCallback(() => setMenuOpen(false), []); + const { menuRef, onKeyDown: onMenuKeyDown } = useMenuNavigation({ + open: menuOpen, + onClose: closeMenu, + triggerRef: menuButtonRef, + }); + const [moveOpen, setMoveOpen] = useState(false); + const moveButtonRef = useRef(null); + const closeMove = useCallback(() => setMoveOpen(false), []); + const { menuRef: moveRef, onKeyDown: onMoveKeyDown } = useMenuNavigation({ + open: moveOpen, + onClose: closeMove, + triggerRef: moveButtonRef, + }); + + const [tagOpen, setTagOpen] = useState(false); + const tagButtonRef = useRef(null); + const closeTag = useCallback(() => setTagOpen(false), []); + const { menuRef: tagRef, onKeyDown: onTagKeyDown } = useMenuNavigation({ + open: tagOpen, + onClose: closeTag, + triggerRef: tagButtonRef, + }); + + const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); + + useEffect(() => { + if (!menuOpen) return; + const onPointerDown = (e: MouseEvent) => { + if ( + menuButtonRef.current?.contains(e.target as Node) || + menuRef.current?.contains(e.target as Node) + ) return; + setMenuOpen(false); + }; + document.addEventListener("mousedown", onPointerDown); + return () => document.removeEventListener("mousedown", onPointerDown); + }, [menuOpen, menuRef]); + + useEffect(() => { + if (!moveOpen && !tagOpen) return; + const onPointerDown = (e: MouseEvent) => { + const inside = (button: HTMLElement | null, menu: HTMLElement | null) => + button?.contains(e.target as Node) || menu?.contains(e.target as Node); + if (!inside(moveButtonRef.current, moveRef.current)) setMoveOpen(false); + if (!inside(tagButtonRef.current, tagRef.current)) setTagOpen(false); + }; + document.addEventListener("mousedown", onPointerDown); + return () => document.removeEventListener("mousedown", onPointerDown); + }, [moveOpen, tagOpen, moveRef, tagRef]); + + const selectionCount = selectedEmailIds.size; + const hasSelection = selectionCount > 0; + const allSelected = hasSelection && selectionCount === emails.length; + // Optimistic updates and demo fixtures can briefly leave the server total + // behind the rows already loaded. Never render an impossible "23 of 22". + const displayedTotal = Math.max(totalCount ?? loadedCount, loadedCount); + + const currentRole = mailboxes.find((mb) => mb.id === selectedMailbox)?.role + ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined); + const isInJunk = currentRole === "junk"; + const isInTrash = currentRole === "trash"; + // Marking your own drafts or sent mail as spam is meaningless. + const spamApplicable = !["sent", "drafts", "scheduled"].includes(currentRole ?? ""); + + const run = async (action: () => Promise) => { + if (!client || isProcessing) return; + setIsProcessing(true); + try { + await action(); + } finally { + setTimeout(() => setIsProcessing(false), 400); + } + }; + + const handleSelectAllToggle = () => { + if (disabled) return; + if (hasSelection) { + if (allSelected) clearSelection(); + else selectAllEmails(); + return; + } + if (emails.length > 0) selectAllEmails(); + }; + + const handleDelete = () => + run(async () => { + if (!client) return; + const confirmed = await confirmDialog({ + title: isInTrash ? t("permanent_delete_confirm_title") : tBatch("delete_confirm_title"), + message: isInTrash + ? t("permanent_delete_confirm_batch_message", { count: selectionCount }) + : tBatch("delete_confirm_message", { count: selectionCount }), + confirmText: isInTrash ? t("permanent_delete") : tBatch("delete"), + variant: "destructive", + }); + if (!confirmed) return; + await batchDelete(client, isInTrash); + }); + + const selectedEmails = emails.filter((email) => selectedEmailIds.has(email.id)); + + // The same targets the row's own context menu offers, with the account of + // the selection leading (#1149). + const { tree: moveTree, targetIds: moveTargetIds } = buildMoveTargets(mailboxes, { + currentMailboxId: selectedMailbox, + ownerAccountId: resolveMoveOwnerAccountId(selectedEmails[0], mailboxes, selectedMailbox), + }); + + const renderMoveNodes = (nodes: MailboxNode[], depth = 0): React.ReactNode => + nodes.map((node) => { + const label = localizeMailboxName(node.role, node.name, (key) => tSidebar(`mailboxes.${key}`)); + const isTarget = moveTargetIds.has(node.id); + return ( +
+ {isTarget ? ( + + ) : ( +
+ + {label} +
+ )} + {node.children.length > 0 && renderMoveNodes(node.children, depth + 1)} +
+ ); + }); + + // What the tag picker shows: a tag every selected message carries is + // checked, one only some of them carry is drawn as partial. + const tagCounts = new Map(); + for (const email of selectedEmails) { + for (const tagId of getEmailTagIds(email.keywords)) tagCounts.set(tagId, (tagCounts.get(tagId) ?? 0) + 1); + } + const appliedTags: string[] = []; + const partialTags: string[] = []; + for (const [tagId, count] of tagCounts) (count === selectedEmails.length ? appliedTags : partialTags).push(tagId); + + const handleMove = (mailboxId: string) => { + setMoveOpen(false); + void run(async () => { + if (client) await batchMoveToMailbox(client, mailboxId); + }); + }; + + // A tag the whole selection carries comes off; any other goes onto all of + // it, as in the default toolbar. + const handleToggleTag = (tagId: string) => { + const add = !appliedTags.includes(tagId); + void run(async () => { + if (client) await batchSetTag(client, tagId, add); + }); + }; + + const handleSpam = () => + run(async () => { + if (!client) return; + const ids = Array.from(selectedEmailIds); + if (isInJunk) await batchUndoSpam(client, ids); + else await batchMarkAsSpam(client, ids); + }); + + return ( +
+ + + + +
+ + {menuOpen && ( +
+ + {onMarkAllFoldersRead && ( + + )} + {/* Offered only where emptying a folder is the usual thing to + do: spam and the bin. */} + {onEmptyFolder && (isInJunk || isInTrash) && ( + <> +
+ + + )} +
+ )} +
+ + {hasSelection && !disabled && ( + <> +
+ run(async () => { if (client) await batchArchive(client); })} + disabled={isProcessing} + busy={isProcessing} + /> + {spamApplicable && ( + + )} + +
+ {moveTree.length > 0 && ( +
+ + {moveOpen && ( +
+ {renderMoveNodes(moveTree)} +
+ )} +
+ )} +
+ + {tagOpen && ( +
+ +
+ )} +
+
+ run(async () => { if (client) await batchMarkAsRead(client, true); })} + disabled={isProcessing} + /> + run(async () => { if (client) await batchMarkAsRead(client, false); })} + disabled={isProcessing} + /> + + {tBatch("selected_messages", { count: selectionCount })} + + + )} + +
+ + {loadedCount > 0 && ( + + {/* The range readout. The list grows as it scrolls rather than + paging, so there are no previous/next arrows to go with it. */} + {t("conversations_range", { from: 1, to: loadedCount, total: displayedTotal })} + + )} + + +
+ ); +} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index f8b7ea24c..96a0545a1 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -12,6 +12,7 @@ import { SelectableAvatar } from "@/components/email/selectable-avatar"; import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder, Archive, Trash2, MailOpen, ShieldAlert } from "@/components/icons"; import type { AppIcon } from "@/components/icons"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; +import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout"; import { accountTintKey, generateAvatarColor } from "@/lib/account-utils"; import { useUIStore } from "@/stores/ui-store"; import { useEmailStore } from "@/stores/email-store"; @@ -66,6 +67,101 @@ function RowChips({ email, loadAttachments, onOpenAttachment }: { return leading ?
{leading}
: null; } +/** + * Mountain View's leading controls: a checkbox and a star, both always + * visible and both acting on the row without opening it. They take the slot + * the default layout gives to the sender avatar. + */ +function MvRowLead({ + checked, + onToggleChecked, + starred, + onToggleStar, + selectLabel, + starLabel, +}: { + checked: boolean; + onToggleChecked: (e: React.MouseEvent) => void; + starred: boolean; + onToggleStar?: () => void; + selectLabel: string; + starLabel: string; +}) { + return ( +
+ + {onToggleStar && ( + + )} +
+ ); +} + +function MvMobileStar({ + starred, + onToggle, + label, +}: { + starred: boolean; + onToggle?: () => void; + label: string; +}) { + return ( + + ); +} + function StatusIcon({ icon: Icon, label, className }: { icon: AppIcon; label: string; className: string }) { return ( <> @@ -178,6 +274,7 @@ const SingleEmailItem = React.forwardRef( function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam, onOpenAttachment, loadAttachments }, ref) { const t = useTranslations('email_viewer'); const tBatch = useTranslations('email_list.batch_actions'); + const tHover = useTranslations('settings.email_behavior.hover_actions'); const tStatus = useTranslations('email_list'); const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; @@ -198,9 +295,10 @@ const SingleEmailItem = React.forwardRef( const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); const tintListRowsByAccount = useSettingsStore((state) => state.tintListRowsByAccount); const density = useSettingsStore((state) => state.density); - const mailLayout = useSettingsStore((state) => state.mailLayout); + const mailLayout = useEffectiveMailLayout(); const timeFormat = useSettingsStore((state) => state.timeFormat); const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); + const interfaceLayout = useSettingsStore((state) => state.interfaceLayout); const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk; // Show the originating folder in the aggregate "All …" views. const showSourceFolder = isUnifiedView && !!email.sourceFolder; @@ -212,6 +310,12 @@ const SingleEmailItem = React.forwardRef( const isMobile = useUIStore((state) => state.isMobile); // The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile. const isFocusedMailLayout = mailLayout === 'focus' && !isMobile; + // Mountain View leads its rows with a checkbox and a clickable star + // instead of an avatar, and marks unread with weight alone. Only the + // one-line layout is shaped that way, so the two conditions travel + // together. + const mvRow = interfaceLayout === 'mountain-view' && isFocusedMailLayout; + const mvMobileRow = interfaceLayout === 'mountain-view' && isMobile; const trimmedPreview = cleanPreview(email.preview); const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : ''; // Search hits carry server snippets with the matched terms marked; they @@ -337,19 +441,23 @@ const SingleEmailItem = React.forwardRef( data-subject={email.subject || ''} data-unread={isUnread ? 'true' : 'false'} data-starred={email.keywords?.$flagged ? 'true' : 'false'} + data-mv-mobile-row={mvMobileRow ? "" : undefined} aria-current={selected ? 'true' : undefined} className={cn( "relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden", + mvMobileRow && "border-b-transparent", resolvedRowTint ? resolvedRowTint : ( selected ? "bg-accent" - : "bg-background" + : mvRow && !isUnread + ? "bg-muted/60" + : "bg-background" ), selected && !resolvedRowTint && "shadow-sm", !resolvedRowTint && !selected && !isChecked && "hover:bg-muted hover:shadow-sm", !resolvedRowTint && (selected || isChecked) && "hover:bg-accent hover:shadow-sm", resolvedRowTint && "hover:brightness-95 dark:hover:brightness-110", - isUnread && !resolvedRowTint && "bg-accent/30", + isUnread && !resolvedRowTint && !mvRow && !mvMobileRow && "bg-accent/30", isChecked && "ring-2 ring-primary/20", isChecked && !resolvedRowTint && "bg-accent/40", isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30", @@ -384,7 +492,7 @@ const SingleEmailItem = React.forwardRef( style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)', transform: swipeEnabled && offsetX ? `translateX(${offsetX}px)` : undefined, transition: swipeEnabled && offsetX === 0 ? 'transform 200ms ease-out' : undefined }} > {/* Checkbox - only for extra-compact density (no avatar) while in selection mode */} - {density === 'extra-compact' && selectedEmailIds.size > 0 && ( + {!mvRow && density === 'extra-compact' && selectedEmailIds.size > 0 && ( )} - {density !== 'extra-compact' && ( + {mvRow && ( + + )} + + {!mvRow && density !== 'extra-compact' && (
(
)} - {isUnread && density === 'extra-compact' && ( + {isUnread && !mvRow && density === 'extra-compact' && ( )}
{isFocusedMailLayout ? ( + <>
{isUnifiedView && email.accountId && accountColor && !tintListRowsByAccount && ( @@ -469,7 +589,7 @@ const SingleEmailItem = React.forwardRef(
{isPinned && } - {isStarred && } + {isStarred && !mvRow && } {isAnswered && !isForwarded && } {isForwarded && !isAnswered && } {isAnswered && isForwarded && ( @@ -498,6 +618,10 @@ const SingleEmailItem = React.forwardRef( )}
+ {mvRow && ( + + )} + ) : ( <>
@@ -530,7 +654,7 @@ const SingleEmailItem = React.forwardRef( {isPinned && ( )} - {isStarred && ( + {isStarred && !mvMobileRow && ( )} {isAnswered && !isForwarded && ( @@ -570,6 +694,13 @@ const SingleEmailItem = React.forwardRef( {formatDate(email.receivedAt)} )} + {mvMobileRow && !(showPreview && density !== 'extra-compact' && density !== 'compact') && ( + + )}
@@ -592,14 +723,24 @@ const SingleEmailItem = React.forwardRef(
{showPreview && density !== 'extra-compact' && density !== 'compact' && ( -

- {previewSnippet ? : (trimmedPreview || t('no_preview_available'))} -

+
+

+ {previewSnippet ? : (trimmedPreview || t('no_preview_available'))} +

+ {mvMobileRow && ( + + )} +
)} @@ -654,16 +795,24 @@ export const ThreadListItem = React.forwardRef state.showPreview); const density = useSettingsStore((state) => state.density); - const mailLayout = useSettingsStore((state) => state.mailLayout); + const mailLayout = useEffectiveMailLayout(); const timeFormat = useSettingsStore((state) => state.timeFormat); const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); + const interfaceLayout = useSettingsStore((state) => state.interfaceLayout); const isMobile = useUIStore((state) => state.isMobile); const { latestEmail, participantNames, hasUnread, hasStarred, hasPinned, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread; // The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile. const isFocusedMailLayout = mailLayout === 'focus' && !isMobile; + // Mountain View leads its rows with a checkbox and a clickable star + // instead of an avatar, and marks unread with weight alone. Only the + // one-line layout is shaped that way, so the two conditions travel + // together. + const mvRow = interfaceLayout === 'mountain-view' && isFocusedMailLayout; + const mvMobileRow = interfaceLayout === 'mountain-view' && isMobile; const trimmedPreview = cleanPreview(latestEmail.preview); const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : ''; // In a search the matched mail need not be the thread's latest one: show @@ -817,23 +966,31 @@ export const ThreadListItem = React.forwardRef +
{/* Checkbox for thread selection - only for extra-compact density (no avatar) while in selection mode */} - {density === 'extra-compact' && selectedEmailIds.size > 0 && ( + {!mvRow && density === 'extra-compact' && selectedEmailIds.size > 0 && ( +
+ )} + + {/* Account List. Under Mountain View the active account is already the + card above, so with a single login there is no list to draw. */} +
+ {listedAccounts.map((account) => { const isActive = account.id === activeAccountId; const isDraggable = !account.isDefault && accounts.length > 2; return ( @@ -304,11 +403,14 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher {account.isDefault && ( )} +

{toUnicodeEmail(account.email || account.username)}

-
+ {/* Server and connection state: Mountain View keeps the line + only for an account in trouble. */} +
{account.hasError ? ( ) : ( @@ -348,8 +450,36 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher })}
+ {/* Mountain View: "Add another account" and "Sign out of all accounts" as two + pills side by side. */} + {mvPopover && ( +
+ {accounts.length < getMaxAccounts() && ( + + )} + {accounts.length > 1 && ( + + )} +
+ )} + {/* Separator + Add Account */} - {accounts.length < getMaxAccounts() && ( + {!mvPopover && accounts.length < getMaxAccounts() && (
- {accounts.length > 1 && ( + {!mvPopover && accounts.length > 1 && (
); diff --git a/components/layout/mv-top-bar.tsx b/components/layout/mv-top-bar.tsx new file mode 100644 index 000000000..ae1a3a0a3 --- /dev/null +++ b/components/layout/mv-top-bar.tsx @@ -0,0 +1,335 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + BookUser, + Calendar, + Filter, + Grid3x3, + HardDrive, + Keyboard, + Mail, + Menu, + Settings, + iconForName, +} from "@/components/icons"; +import type { AppIcon as AppIconType } from "@/components/icons"; +import { useTranslations } from "next-intl"; +import { Link, usePathname } from "@/i18n/navigation"; +import { useAuthStore } from "@/stores/auth-store"; +import { useCalendarStore } from "@/stores/calendar-store"; +import { useConfig } from "@/hooks/use-config"; +import { usePolicyStore } from "@/stores/policy-store"; +import { useResolvedSidebarApps } from "@/hooks/use-resolved-sidebar-apps"; +import { useThemeStore } from "@/stores/theme-store"; +import { useUIStore } from "@/stores/ui-store"; +import { useMenuNavigation } from "@/hooks/use-menu-navigation"; +import { withBasePath } from "@/lib/browser-navigation"; +import { cn } from "@/lib/utils"; +import { AccountSwitcher } from "@/components/layout/account-switcher"; +import { SearchBox, type ContactSearchField } from "@/components/search/search-box"; +import type { ContactSuggestion } from "@/lib/search-suggestions"; + +interface MvTopBarProps { + searchQuery: string; + onSearchQueryChange: (value: string) => void; + onSearchSubmit: (query: string) => void; + onSearchClear: () => void; + onSelectContact: (contact: ContactSuggestion, field: ContactSearchField) => void; + /** Opens the advanced-filter panel that lives under the message list. */ + onToggleFilters: () => void; + filtersOpen: boolean; + activeFilterCount: number; + searchDisabled?: boolean; + onShowShortcuts?: () => void; + onManageApps?: () => void; + onInlineApp?: (appId: string, url: string, name: string) => void; + onCloseInlineApp?: () => void; + activeAppId?: string | null; + /** Opens the merged across-accounts inbox from the account popover. */ + onSelectAllInboxes?: () => void; + allInboxesSelected?: boolean; + /** + * Receives the node under the search field that the advanced-filter panel is + * portalled into, so the panel drops from the field itself; without this + * the panel would stay behind in the message-list column, two bars away from + * the button that opens it. + */ + onFilterAnchorChange?: (node: HTMLDivElement | null) => void; +} + +/** + * Mountain View's global header: one 64px row that owns the hamburger, the + * brand, the search field and the account cluster. It only renders on + * desktop under `interfaceLayout === 'mountain-view'`; the default layout keeps + * search inside the list column and navigation in the left rail, and neither + * of those is touched here. + */ +export function MvTopBar({ + searchQuery, + onSearchQueryChange, + onSearchSubmit, + onSearchClear, + onSelectContact, + onToggleFilters, + filtersOpen, + activeFilterCount, + searchDisabled = false, + onShowShortcuts, + onManageApps, + onInlineApp, + onCloseInlineApp, + activeAppId, + onSelectAllInboxes, + allInboxesSelected = false, + onFilterAnchorChange, +}: MvTopBarProps) { + const t = useTranslations("sidebar"); + const tSearch = useTranslations("advanced_search"); + const pathname = usePathname(); + const { appName, appLogoLightUrl, appLogoDarkUrl } = useConfig(); + const resolvedTheme = useThemeStore((s) => s.resolvedTheme); + const toggleSidebarCollapsed = useUIStore((s) => s.toggleSidebarCollapsed); + + const client = useAuthStore((s) => s.client); + const { supportsCalendar } = useCalendarStore(); + const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled("calendarEnabled")); + const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled("contactsEnabled")); + const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled("filesEnabled")); + const sidebarApps = useResolvedSidebarApps(); + + const [appsOpen, setAppsOpen] = useState(false); + const appsButtonRef = useRef(null); + const closeApps = useCallback(() => setAppsOpen(false), []); + const { menuRef: appsMenuRef, onKeyDown: onAppsKeyDown } = useMenuNavigation({ + open: appsOpen, + onClose: closeApps, + triggerRef: appsButtonRef, + }); + + useEffect(() => { + if (!appsOpen) return; + const onPointerDown = (e: MouseEvent) => { + if ( + appsButtonRef.current?.contains(e.target as Node) || + appsMenuRef.current?.contains(e.target as Node) + ) return; + setAppsOpen(false); + }; + document.addEventListener("mousedown", onPointerDown); + return () => document.removeEventListener("mousedown", onPointerDown); + }, [appsOpen, appsMenuRef]); + + const apps: Array<{ id: string; icon: AppIconType; label: string; href: string }> = [ + { id: "mail", icon: Mail, label: t("mail"), href: "/" }, + ...(supportsCalendar && calendarEnabled + ? [{ id: "calendar", icon: Calendar, label: t("calendar"), href: "/calendar" }] + : []), + ...((client?.supportsContacts() ?? false) && contactsEnabled + ? [{ id: "contacts", icon: BookUser, label: t("contacts"), href: "/contacts" }] + : []), + ...((client?.supportsFiles() ?? false) && filesEnabled + ? [{ id: "files", icon: HardDrive, label: t("files"), href: "/files" }] + : []), + { id: "settings", icon: Settings, label: t("settings"), href: "/settings" }, + ]; + + const isActive = (href: string) => + !activeAppId && (href === "/" ? pathname === "/" || pathname === "" : pathname.startsWith(href)); + + const logoUrl = withBasePath( + resolvedTheme === "dark" ? appLogoDarkUrl || appLogoLightUrl : appLogoLightUrl || appLogoDarkUrl + ); + + return ( +
+ {/* Brand block: occupies the sidebar's own column width */} +
+ + + {logoUrl ? : null} + {appName} + +
+ + {/* Search: left-anchored and capped, not centred */} +
+ + + + {/* Anchor for the advanced-filter panel: the search field's own box, + filter button included, so the panel shares the field's edges. It + only marks the spot; the panel itself is drawn at the end of . */} +
+
+ + {/* Spacer keeps the right cluster pinned while search stays left */} +
+ +
+ {onShowShortcuts && ( + + )} + + + + +
+ + {appsOpen && ( +
+
+ {apps.map((app) => { + const Icon = app.icon; + return ( + { + if (activeAppId) onCloseInlineApp?.(); + setAppsOpen(false); + }} + className={cn( + "flex flex-col items-center gap-1.5 rounded-lg py-3 px-1 text-center transition-colors hover:bg-muted", + isActive(app.href) && "bg-muted" + )} + > + + {app.label} + + ); + })} + {sidebarApps.map((app) => { + const AppIcon = iconForName(app.icon); + return ( + + ); + })} +
+ {onManageApps && ( + + )} +
+ )} +
+ + +
+
+ ); +} diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index dd8940a5a..66cabd71d 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -213,6 +213,7 @@ export function NavigationRail({ const supportsFiles = client?.supportsFiles() ?? false; const supportsContacts = client?.supportsContacts() ?? false; const showRailAccountList = useSettingsStore((s) => s.showRailAccountList); + const mountainView = useSettingsStore((s) => s.interfaceLayout === 'mountain-view'); const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled')); const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled')); const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled')); @@ -338,7 +339,8 @@ export function NavigationRail({ return activeItemId === itemId; } if (href === "/") { - return pathname === "/" || pathname === ""; + // Mountain View's bottom bar marks the mail tab on mail routes too. + return pathname === "/" || pathname === "" || (mountainView && pathname.startsWith("/mail")); } return pathname.startsWith(href); }; @@ -360,7 +362,12 @@ export function NavigationRail({ if (orientation === "horizontal") { return (