From 717f18fc460626c90222bca050fc19ac70ad2531 Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Wed, 30 Sep 2026 19:22:56 +0200 Subject: [PATCH] feat(unified): say how many accounts the unified mailbox holds while logins reconnect After a load the logins other than the one on screen reconnect in the background, and until they are all back the unified counts cover only some of them. With many logins that lasts seconds, and a partial total looks exactly like the whole one. While that restore runs, the "All accounts" header shows how many logins are already in the scope, e.g. "4/10", with a translated label for screen readers. It goes away when the scope is complete or the restore is over - a login that failed to reconnect does not keep it up. `restoringAccounts` in the auth store marks the background restore; the count itself is a pure function with its own tests. --- components/layout/sidebar.tsx | 25 ++++++++++++++ lib/__tests__/unified-load-progress.test.ts | 37 +++++++++++++++++++++ lib/unified-mailbox.ts | 19 +++++++++++ locales/ar/common.json | 1 + locales/ca/common.json | 1 + locales/cs/common.json | 1 + locales/da/common.json | 1 + locales/de/common.json | 1 + locales/en/common.json | 1 + locales/es/common.json | 1 + locales/fa/common.json | 1 + locales/fr/common.json | 1 + locales/he/common.json | 1 + locales/hu/common.json | 1 + locales/it/common.json | 1 + locales/ja/common.json | 1 + locales/ko/common.json | 1 + locales/lv/common.json | 1 + locales/mn/common.json | 1 + locales/nb/common.json | 1 + locales/nl/common.json | 1 + locales/pl/common.json | 1 + locales/pt/common.json | 1 + locales/ro/common.json | 1 + locales/ru/common.json | 1 + locales/sk/common.json | 1 + locales/tr/common.json | 1 + locales/uk/common.json | 1 + locales/zh-TW/common.json | 1 + locales/zh/common.json | 1 + stores/auth-store.ts | 10 +++++- 31 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 lib/__tests__/unified-load-progress.test.ts diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index d6b2f3447..46c90670a 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -61,6 +61,7 @@ import { MAILBOX_DRAG_MIME } from "@/components/pro/pro-shell-drop"; import { useTagDrop } from "@/hooks/use-tag-drop"; import { useUIStore } from "@/stores/ui-store"; import { useAuthStore } from "@/stores/auth-store"; +import { unifiedLoadProgress } from "@/lib/unified-mailbox"; import { useVacationStore } from "@/stores/vacation-store"; import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store"; import { useEmailStore } from "@/stores/email-store"; @@ -406,6 +407,7 @@ function SidebarSectionHeader({ sub, testId, onContextMenu, + status, }: { label: string; expanded: boolean; @@ -418,6 +420,8 @@ function SidebarSectionHeader({ sub?: boolean; testId?: string; onContextMenu?: (event: React.MouseEvent) => void; + /** A short note after the label, e.g. how many accounts have loaded. */ + status?: ReactNode; }) { if (isCollapsed) { return first ? null :
; @@ -451,6 +455,7 @@ function SidebarSectionHeader({ {label} + {status} {onSettings && ( s.unifiedScope); + const restoringAccounts = useAuthStore((s) => s.restoringAccounts); + const registeredAccounts = useAccountStore((s) => s.accounts); + const unifiedProgress = useMemo(() => unifiedLoadProgress({ + crossAccountActive, restoring: restoringAccounts, scope: unifiedScope, accounts: registeredAccounts, + }), [crossAccountActive, restoringAccounts, registeredAccounts, unifiedScope]); const t = useTranslations('sidebar'); useEffect(() => { @@ -1254,6 +1269,16 @@ export function Sidebar({ onToggle={toggleUnified} isCollapsed={isCollapsed} first + status={unifiedProgress && ( + + {unifiedProgress.loaded}/{unifiedProgress.total} + + )} /> {((unifiedExpanded && !isCollapsed) || isCollapsed) && ( <> diff --git a/lib/__tests__/unified-load-progress.test.ts b/lib/__tests__/unified-load-progress.test.ts new file mode 100644 index 000000000..7b9c02e92 --- /dev/null +++ b/lib/__tests__/unified-load-progress.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { unifiedLoadProgress } from '@/lib/unified-mailbox'; + +// While a browser restores its logins the unified counts cover only the ones +// already back. The sidebar says how many, so a partial total is not read as +// the whole - and stops saying it the moment the restore is over. +const scope = (...ids: string[]) => ids.map((id) => ({ clientAccountId: id, isShared: false })); +const accounts = (n: number, failed = 0) => + Array.from({ length: n }, (_, i) => ({ hasError: i < failed })); + +describe('unifiedLoadProgress', () => { + it('counts the logins already in the scope against those expected', () => { + expect(unifiedLoadProgress({ crossAccountActive: true, restoring: true, scope: scope('a', 'b', 'c', 'd'), accounts: accounts(10) })) + .toEqual({ loaded: 4, total: 10 }); + }); + + it('leaves out logins whose restore failed', () => { + expect(unifiedLoadProgress({ crossAccountActive: true, restoring: true, scope: scope('a', 'b'), accounts: accounts(4, 1) })) + .toEqual({ loaded: 2, total: 3 }); + }); + + it('counts a login once however many shared owners it brings', () => { + const withShared = [...scope('a'), { clientAccountId: 'a', isShared: true }, { clientAccountId: 'a', isShared: true }]; + expect(unifiedLoadProgress({ crossAccountActive: true, restoring: true, scope: withShared, accounts: accounts(3) })) + .toEqual({ loaded: 1, total: 3 }); + }); + + it('says nothing once the scope is complete, the restore is over, or there is one login', () => { + expect(unifiedLoadProgress({ crossAccountActive: true, restoring: true, scope: scope('a', 'b'), accounts: accounts(2) })).toBeNull(); + expect(unifiedLoadProgress({ crossAccountActive: true, restoring: false, scope: scope('a'), accounts: accounts(5) })).toBeNull(); + expect(unifiedLoadProgress({ crossAccountActive: true, restoring: true, scope: [], accounts: accounts(1) })).toBeNull(); + }); + + it('says nothing when the unified mailbox stays within one login', () => { + expect(unifiedLoadProgress({ crossAccountActive: false, restoring: true, scope: scope('a'), accounts: accounts(5) })).toBeNull(); + }); +}); diff --git a/lib/unified-mailbox.ts b/lib/unified-mailbox.ts index 062208fe9..f65303c35 100644 --- a/lib/unified-mailbox.ts +++ b/lib/unified-mailbox.ts @@ -722,3 +722,22 @@ export function connectedAccountsGrew(previous: string | null, current: string): const before = new Set(previous ? previous.split(',').filter(Boolean) : []); return current.split(',').some((id) => id !== '' && !before.has(id)); } + +/** + * How far the unified scope has got while logins are still reconnecting: + * `{ loaded, total }` while some logins expected in it are not there yet, + * `null` once it is complete, when it spans only one login, or when no + * restore is running (a login that did not make it must not keep the note up). + */ +export function unifiedLoadProgress(input: { + crossAccountActive: boolean; + restoring: boolean; + scope: Pick[]; + accounts: { hasError?: boolean }[]; +}): { loaded: number; total: number } | null { + if (!input.crossAccountActive || !input.restoring) return null; + const total = input.accounts.filter((a) => !a.hasError).length; + if (total < 2) return null; + const loaded = new Set(input.scope.filter((e) => !e.isShared).map((e) => e.clientAccountId)).size; + return loaded < total ? { loaded, total } : null; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 47f70f28b..b5942aa1c 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -120,6 +120,7 @@ "unified_archive": "كل الأرشيف", "unified_junk": "كل البريد المزعج", "all_accounts": "كل الحسابات", + "all_accounts_loading": "جارٍ تحميل الحسابات: {loaded} من {total}", "unified_mailbox": "صندوق البريد الموحّد", "expand": "توسيع", "collapse": "طي", diff --git a/locales/ca/common.json b/locales/ca/common.json index ac53ba2fe..7980d9384 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -120,6 +120,7 @@ "unified_archive": "Tot l'arxiu", "unified_junk": "Tot el correu brossa", "all_accounts": "Tots els comptes", + "all_accounts_loading": "Carregant comptes: {loaded} de {total}", "unified_mailbox": "Bústia unificada", "expand": "Desplega", "collapse": "Replega", diff --git a/locales/cs/common.json b/locales/cs/common.json index a29ce634c..e75d45a38 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -120,6 +120,7 @@ "unified_archive": "Všechny archivy", "unified_junk": "Všechen spam", "all_accounts": "Všechny účty", + "all_accounts_loading": "Načítání účtů: {loaded} z {total}", "unified_mailbox": "Sjednocená schránka", "expand": "Rozbalit", "collapse": "Sbalit", diff --git a/locales/da/common.json b/locales/da/common.json index 42b3776e9..74645b094 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -120,6 +120,7 @@ "unified_archive": "Alle arkiver", "unified_junk": "Alt spam", "all_accounts": "Alle konti", + "all_accounts_loading": "Indlæser konti: {loaded} af {total}", "unified_mailbox": "Samlet postkasse", "expand": "Udvid", "collapse": "Skjul", diff --git a/locales/de/common.json b/locales/de/common.json index 54147f319..2555d7790 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -120,6 +120,7 @@ "unified_archive": "Alle Archive", "unified_junk": "Alle Spam", "all_accounts": "Alle Konten", + "all_accounts_loading": "Konten werden geladen: {loaded} von {total}", "unified_mailbox": "Gemeinsames Postfach", "expand": "Erweitern", "collapse": "Einklappen", diff --git a/locales/en/common.json b/locales/en/common.json index a3323e62c..ad2c3f45e 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -120,6 +120,7 @@ "unified_archive": "All Archive", "unified_junk": "All Junk", "all_accounts": "All Accounts", + "all_accounts_loading": "Loading accounts: {loaded} of {total}", "unified_mailbox": "Unified Mailbox", "expand": "Expand", "collapse": "Collapse", diff --git a/locales/es/common.json b/locales/es/common.json index 72ec44153..21a79d162 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -120,6 +120,7 @@ "unified_archive": "Todos los archivos", "unified_junk": "Todo el spam", "all_accounts": "Todas las cuentas", + "all_accounts_loading": "Cargando cuentas: {loaded} de {total}", "unified_mailbox": "Buzón unificado", "expand": "Expandir", "collapse": "Contraer", diff --git a/locales/fa/common.json b/locales/fa/common.json index 2fead4e4c..723dfdfd7 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -120,6 +120,7 @@ "unified_archive": "همه بایگانی‌ها", "unified_junk": "همه هرزنامه‌ها", "all_accounts": "همه حساب‌ها", + "all_accounts_loading": "در حال بارگیری حساب‌ها: {loaded} از {total}", "unified_mailbox": "صندوق پستی یکپارچه", "expand": "باز کردن", "collapse": "جمع کردن", diff --git a/locales/fr/common.json b/locales/fr/common.json index 2efcf454a..2d8e737f8 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -120,6 +120,7 @@ "unified_archive": "Toutes les archives", "unified_junk": "Tous les indésirables", "all_accounts": "Tous les comptes", + "all_accounts_loading": "Chargement des comptes : {loaded} sur {total}", "unified_mailbox": "Boîte aux lettres unifiée", "expand": "Développer", "collapse": "Réduire", diff --git a/locales/he/common.json b/locales/he/common.json index 0bfd3df48..234e6b769 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -142,6 +142,7 @@ "unified_archive": "הכל ארכיון", "unified_junk": "הכל דואר זבל", "all_accounts": "כל החשבונות", + "all_accounts_loading": "טוען חשבונות: {loaded} מתוך {total}", "shared": "משותף", "scheduled": "מתוכננים", "unified_all_unread": "הכל שלא נקראו", diff --git a/locales/hu/common.json b/locales/hu/common.json index 704beb680..98ac6fcc5 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -120,6 +120,7 @@ "unified_archive": "Összes archívum", "unified_junk": "Összes spam", "all_accounts": "Minden fiók", + "all_accounts_loading": "Fiókok betöltése: {loaded} / {total}", "unified_mailbox": "Egységes postafiók", "expand": "Kibontás", "collapse": "Összecsukás", diff --git a/locales/it/common.json b/locales/it/common.json index 1e1e4335f..3f7671ebf 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -120,6 +120,7 @@ "unified_archive": "Tutti gli archivi", "unified_junk": "Tutto lo spam", "all_accounts": "Tutti gli account", + "all_accounts_loading": "Caricamento account: {loaded} di {total}", "unified_mailbox": "Casella di posta unificata", "expand": "Espandi", "collapse": "Comprimi", diff --git a/locales/ja/common.json b/locales/ja/common.json index 76cca8f72..299da4724 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -120,6 +120,7 @@ "unified_archive": "すべてのアーカイブ", "unified_junk": "すべての迷惑メール", "all_accounts": "すべてのアカウント", + "all_accounts_loading": "アカウントを読み込み中: {total} 件中 {loaded} 件", "unified_mailbox": "統合メールボックス", "expand": "展開", "collapse": "折りたたむ", diff --git a/locales/ko/common.json b/locales/ko/common.json index 4533a4c43..5e4f69096 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -120,6 +120,7 @@ "unified_archive": "모든 보관함", "unified_junk": "모든 스팸함", "all_accounts": "모든 계정", + "all_accounts_loading": "계정 불러오는 중: {total}개 중 {loaded}개", "unified_mailbox": "통합 메일함", "expand": "펼치기", "collapse": "접기", diff --git a/locales/lv/common.json b/locales/lv/common.json index 90cf433d1..627ced780 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -120,6 +120,7 @@ "unified_archive": "Visi arhīvi", "unified_junk": "Viss mēstules", "all_accounts": "Visi konti", + "all_accounts_loading": "Ielādē kontus: {loaded} no {total}", "unified_mailbox": "Apvienotā pastkaste", "expand": "Izvērst", "collapse": "Sairt", diff --git a/locales/mn/common.json b/locales/mn/common.json index 25d779f07..a494a2a8d 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -120,6 +120,7 @@ "unified_archive": "Бүх архив", "unified_junk": "Бүх хог", "all_accounts": "Бүх дансууд", + "all_accounts_loading": "Бүртгэлүүдийг ачаалж байна: {total}-аас {loaded}", "unified_mailbox": "Нэгдсэн шуудангийн хайрцаг", "expand": "Өргөтгөх", "collapse": "Нуналт", diff --git a/locales/nb/common.json b/locales/nb/common.json index 9a1afb0e6..acdc41371 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -120,6 +120,7 @@ "unified_archive": "Alle arkiver", "unified_junk": "All søppelpost", "all_accounts": "Alle kontoer", + "all_accounts_loading": "Laster inn kontoer: {loaded} av {total}", "unified_mailbox": "Samlet postkasse", "expand": "Utvid", "collapse": "Fold sammen", diff --git a/locales/nl/common.json b/locales/nl/common.json index 965ebb1f9..3470bc713 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -120,6 +120,7 @@ "unified_archive": "Alle archieven", "unified_junk": "Alle spam", "all_accounts": "Alle accounts", + "all_accounts_loading": "Accounts laden: {loaded} van {total}", "unified_mailbox": "Gecombineerd postvak", "expand": "Uitklappen", "collapse": "Inklappen", diff --git a/locales/pl/common.json b/locales/pl/common.json index fc105f237..6e087623d 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -120,6 +120,7 @@ "unified_archive": "Wszystkie archiwa", "unified_junk": "Wszystkie spam", "all_accounts": "Wszystkie konta", + "all_accounts_loading": "Wczytywanie kont: {loaded} z {total}", "unified_mailbox": "Wspólna skrzynka", "expand": "Rozwiń", "collapse": "Zwiń", diff --git a/locales/pt/common.json b/locales/pt/common.json index 9aa5aba90..56ba1e162 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -120,6 +120,7 @@ "unified_archive": "Todos os arquivos", "unified_junk": "Todo o spam", "all_accounts": "Todas as contas", + "all_accounts_loading": "A carregar contas: {loaded} de {total}", "unified_mailbox": "Caixa de correio unificada", "expand": "Expandir", "collapse": "Recolher", diff --git a/locales/ro/common.json b/locales/ro/common.json index 2bbf38651..f00cb6e54 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -120,6 +120,7 @@ "unified_archive": "Arhivă completă", "unified_junk": "Toate mesajele nedorite", "all_accounts": "Toate conturile", + "all_accounts_loading": "Se încarcă conturile: {loaded} din {total}", "unified_mailbox": "Căsuță poștală unificată", "expand": "Extindeți", "collapse": "Reduceți", diff --git a/locales/ru/common.json b/locales/ru/common.json index 1691cd9d0..f636e4b19 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -120,6 +120,7 @@ "unified_archive": "Все архивы", "unified_junk": "Весь спам", "all_accounts": "Все аккаунты", + "all_accounts_loading": "Загрузка аккаунтов: {loaded} из {total}", "unified_mailbox": "Общий почтовый ящик", "expand": "Развернуть", "collapse": "Свернуть", diff --git a/locales/sk/common.json b/locales/sk/common.json index 36494f77c..63f90755e 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -121,6 +121,7 @@ "unified_archive": "Všetky archívy", "unified_junk": "Všetok spam", "all_accounts": "Všetky účty", + "all_accounts_loading": "Načítavajú sa účty: {loaded} z {total}", "expand": "Rozbaliť", "collapse": "Zbaliť", "expand_tooltip": "Rozbaliť", diff --git a/locales/tr/common.json b/locales/tr/common.json index 74ab00670..4d35b4958 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -120,6 +120,7 @@ "unified_archive": "Tüm Arşiv", "unified_junk": "Tüm Önemsiz", "all_accounts": "Tüm Hesaplar", + "all_accounts_loading": "Hesaplar yükleniyor: {loaded}/{total}", "unified_mailbox": "Birleşik Posta Kutusu", "expand": "Genişlet", "collapse": "Daralt", diff --git a/locales/uk/common.json b/locales/uk/common.json index f3ac459f0..6434bf637 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -120,6 +120,7 @@ "unified_archive": "Усі архіви", "unified_junk": "Весь спам", "all_accounts": "Усі облікові записи", + "all_accounts_loading": "Завантаження облікових записів: {loaded} з {total}", "unified_mailbox": "Спільна поштова скринька", "expand": "Розгорнути", "collapse": "Згорнути", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 3ac3b27af..a207746ac 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -120,6 +120,7 @@ "unified_archive": "所有封存郵件", "unified_junk": "所有垃圾郵件", "all_accounts": "所有帳號", + "all_accounts_loading": "正在載入帳號:{loaded}/{total}", "unified_mailbox": "整合信箱", "expand": "展開", "collapse": "收合", diff --git a/locales/zh/common.json b/locales/zh/common.json index 2fd64f7c6..6983d8e17 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -120,6 +120,7 @@ "unified_archive": "所有归档", "unified_junk": "所有垃圾邮件", "all_accounts": "所有账户", + "all_accounts_loading": "正在加载账户:{loaded}/{total}", "unified_mailbox": "统一邮箱", "expand": "展开", "collapse": "收起", diff --git a/stores/auth-store.ts b/stores/auth-store.ts index ba05aed93..b1c2eb4c3 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -72,6 +72,12 @@ interface AuthState { * (push notifications) re-run over the now-complete client set. */ connectedAccountsRevision: number; + /** + * True while the logins other than the one on screen are still being + * restored after a load, so views that span every login (the unified + * mailbox) can say they are not complete yet. + */ + restoringAccounts: boolean; login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise; /** @@ -1248,6 +1254,7 @@ export const useAuthStore = create()( activeAccountId: null, isDemoMode: false, connectedAccountsRevision: 0, + restoringAccounts: false, login: async (serverUrl, typedUsername, password, totp, rememberMe) => { set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null }); @@ -2649,8 +2656,9 @@ export const useAuthStore = create()( if (clients.has(targetId)) { if (otherAccounts.length > 0) { + set({ restoringAccounts: true }); void restoreRemaining().then(() => { - set((state) => ({ connectedAccountsRevision: state.connectedAccountsRevision + 1 })); + set((state) => ({ connectedAccountsRevision: state.connectedAccountsRevision + 1, restoringAccounts: false })); }); } } else {