Merge pull request #1140 from lucamzanon/feat/unified-loading-indicator

feat(unified): show how many accounts the unified mailbox holds while logins reconnect
This commit is contained in:
Linus Rath
2026-10-01 11:00:06 +02:00
31 changed files with 117 additions and 1 deletions
+25
View File
@@ -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 : <div className="h-px bg-border/50 mx-2 my-2" aria-hidden />;
@@ -451,6 +455,7 @@ function SidebarSectionHeader({
<span className={cn(textClass, icon ? "ms-1.5" : "ms-1.5")}>
{label}
</span>
{status}
{onSettings && (
<span
role="button"
@@ -914,6 +919,16 @@ export function Sidebar({
(multiAccountMode || enableUnifiedMailbox) &&
(crossAccountActive || (includeGroupInUnified && hasGroupInboxes) || anyCrossViewEnabled);
const { unifiedCounts } = useEmailStore();
// While the other logins are still reconnecting after a load, the unified
// counts cover only the ones already back: say how many, so a partial total
// is not read as the whole. Gone as soon as the restore finishes, whatever
// became of the logins that did not make it.
const unifiedScope = useEmailStore((s) => 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 && (
<span
className="ms-2 text-xs font-normal tabular-nums text-muted-foreground animate-pulse"
aria-label={t("all_accounts_loading", unifiedProgress)}
title={t("all_accounts_loading", unifiedProgress)}
data-testid="unified-accounts-loading"
>
{unifiedProgress.loaded}/{unifiedProgress.total}
</span>
)}
/>
{((unifiedExpanded && !isCollapsed) || isCollapsed) && (
<>
@@ -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();
});
});
+19
View File
@@ -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<UnifiedAccountClient, 'clientAccountId' | 'isShared'>[];
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;
}
+1
View File
@@ -120,6 +120,7 @@
"unified_archive": "كل الأرشيف",
"unified_junk": "كل البريد المزعج",
"all_accounts": "كل الحسابات",
"all_accounts_loading": "جارٍ تحميل الحسابات: {loaded} من {total}",
"unified_mailbox": "صندوق البريد الموحّد",
"expand": "توسيع",
"collapse": "طي",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -120,6 +120,7 @@
"unified_archive": "همه بایگانی‌ها",
"unified_junk": "همه هرزنامه‌ها",
"all_accounts": "همه حساب‌ها",
"all_accounts_loading": "در حال بارگیری حساب‌ها: {loaded} از {total}",
"unified_mailbox": "صندوق پستی یکپارچه",
"expand": "باز کردن",
"collapse": "جمع کردن",
+1
View File
@@ -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",
+1
View File
@@ -142,6 +142,7 @@
"unified_archive": "הכל ארכיון",
"unified_junk": "הכל דואר זבל",
"all_accounts": "כל החשבונות",
"all_accounts_loading": "טוען חשבונות: {loaded} מתוך {total}",
"shared": "משותף",
"scheduled": "מתוכננים",
"unified_all_unread": "הכל שלא נקראו",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -120,6 +120,7 @@
"unified_archive": "すべてのアーカイブ",
"unified_junk": "すべての迷惑メール",
"all_accounts": "すべてのアカウント",
"all_accounts_loading": "アカウントを読み込み中: {total} 件中 {loaded} 件",
"unified_mailbox": "統合メールボックス",
"expand": "展開",
"collapse": "折りたたむ",
+1
View File
@@ -120,6 +120,7 @@
"unified_archive": "모든 보관함",
"unified_junk": "모든 스팸함",
"all_accounts": "모든 계정",
"all_accounts_loading": "계정 불러오는 중: {total}개 중 {loaded}개",
"unified_mailbox": "통합 메일함",
"expand": "펼치기",
"collapse": "접기",
+1
View File
@@ -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",
+1
View File
@@ -120,6 +120,7 @@
"unified_archive": "Бүх архив",
"unified_junk": "Бүх хог",
"all_accounts": "Бүх дансууд",
"all_accounts_loading": "Бүртгэлүүдийг ачаалж байна: {total}-аас {loaded}",
"unified_mailbox": "Нэгдсэн шуудангийн хайрцаг",
"expand": "Өргөтгөх",
"collapse": "Нуналт",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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ń",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -120,6 +120,7 @@
"unified_archive": "Все архивы",
"unified_junk": "Весь спам",
"all_accounts": "Все аккаунты",
"all_accounts_loading": "Загрузка аккаунтов: {loaded} из {total}",
"unified_mailbox": "Общий почтовый ящик",
"expand": "Развернуть",
"collapse": "Свернуть",
+1
View File
@@ -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ť",
+1
View File
@@ -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",
+1
View File
@@ -120,6 +120,7 @@
"unified_archive": "Усі архіви",
"unified_junk": "Весь спам",
"all_accounts": "Усі облікові записи",
"all_accounts_loading": "Завантаження облікових записів: {loaded} з {total}",
"unified_mailbox": "Спільна поштова скринька",
"expand": "Розгорнути",
"collapse": "Згорнути",
+1
View File
@@ -120,6 +120,7 @@
"unified_archive": "所有封存郵件",
"unified_junk": "所有垃圾郵件",
"all_accounts": "所有帳號",
"all_accounts_loading": "正在載入帳號:{loaded}/{total}",
"unified_mailbox": "整合信箱",
"expand": "展開",
"collapse": "收合",
+1
View File
@@ -120,6 +120,7 @@
"unified_archive": "所有归档",
"unified_junk": "所有垃圾邮件",
"all_accounts": "所有账户",
"all_accounts_loading": "正在加载账户:{loaded}/{total}",
"unified_mailbox": "统一邮箱",
"expand": "展开",
"collapse": "收起",
+9 -1
View File
@@ -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<boolean>;
/**
@@ -1248,6 +1254,7 @@ export const useAuthStore = create<AuthState>()(
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<AuthState>()(
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 {