From da4c880c5f31d8f3cdf18fcfdf9c952579d8aa80 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 1 Nov 2026 12:14:50 +0100
Subject: [PATCH] fix: open the offline outbox from the banner without leaving
the page #523
---
.../__tests__/offline-settings.test.tsx | 22 +++++++
components/offline/offline-banner.tsx | 65 +++++++++++--------
components/offline/outbox-list.tsx | 6 +-
3 files changed, 62 insertions(+), 31 deletions(-)
diff --git a/components/offline/__tests__/offline-settings.test.tsx b/components/offline/__tests__/offline-settings.test.tsx
index 7a3a35f58..7096b3efc 100644
--- a/components/offline/__tests__/offline-settings.test.tsx
+++ b/components/offline/__tests__/offline-settings.test.tsx
@@ -74,3 +74,25 @@ describe('Settings > Offline', () => {
expect(screen.getByRole('button', { name: 'retry' })).toBeInTheDocument();
});
});
+
+describe('the offline banner', () => {
+ it('says the app is offline and opens the outbox in place', async () => {
+ const { OfflineBanner } = await import('../offline-banner');
+ useOfflineStore.setState({
+ enabled: ['acc'],
+ browserOffline: true,
+ outbox: [{ id: 1, account: 'acc', createdAt: Date.now(), subject: 'Quarterly', to: ['b@example.org'], status: 'waiting' }],
+ });
+ render( );
+ expect(screen.getByTestId('offline-banner').textContent).toContain('banner.offline');
+ fireEvent.click(screen.getByTestId('offline-banner-outbox'));
+ expect(screen.getByTestId('offline-outbox-row').textContent).toContain('Quarterly');
+ });
+
+ it('stays away for an account that doesn\'t keep mail here', async () => {
+ const { OfflineBanner } = await import('../offline-banner');
+ useOfflineStore.setState({ enabled: [], browserOffline: true, outbox: [] });
+ render( );
+ expect(screen.queryByTestId('offline-banner')).toBeNull();
+ });
+});
diff --git a/components/offline/offline-banner.tsx b/components/offline/offline-banner.tsx
index ecd498e2d..b48eccab8 100644
--- a/components/offline/offline-banner.tsx
+++ b/components/offline/offline-banner.tsx
@@ -1,48 +1,57 @@
'use client';
+import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { CloudOff } from '@/components/icons';
-import { useRouter } from '@/i18n/navigation';
-import { buildSettingsPath } from '@/lib/deep-links';
+import { Dialog } from '@/components/ui/dialog';
import { IS_LITE } from '@/lib/lite';
import { useOfflineStore } from '@/lib/offline/state';
import { useAuthStore } from '@/stores/auth-store';
+import { OutboxList } from './outbox-list';
/**
* A strip across the top of the apps while offline mode (#523) answers from
* this device: the app is offline, and how many messages wait in the
- * outbox (a link to it). Shown only for an account that keeps mail here.
+ * outbox. The count opens the outbox in a dialog, which works without a
+ * network (a page change might not). Shown only for an account that keeps
+ * mail here.
*/
export function OfflineBanner() {
- const t = useTranslations('offline.banner');
- const router = useRouter();
+ const t = useTranslations('offline');
const account = useAuthStore((s) => s.activeAccountId);
const active = useOfflineStore((s) => !!account && s.enabled.includes(account));
const offline = useOfflineStore((s) => s.browserOffline || s.networkDown || s.startedOffline);
const waiting = useOfflineStore((s) => s.outbox.length);
- if (IS_LITE || !active || (!offline && waiting === 0)) return null;
+ const [open, setOpen] = useState(false);
+ if (IS_LITE || !active || (!offline && waiting === 0 && !open)) return null;
return (
-
- {offline && (
-
-
- {t('offline')}
-
- )}
- {waiting > 0 && (
- router.push(buildSettingsPath('backup', 'offline'))}
- className="min-h-11 rounded-md px-1 font-medium underline underline-offset-2 hover:bg-foreground/5 sm:min-h-0"
- >
- {t('outbox', { count: waiting })}
-
- )}
-
+ <>
+
+ {offline && (
+
+
+ {t('banner.offline')}
+
+ )}
+ {waiting > 0 && (
+ setOpen(true)}
+ className="min-h-11 rounded-md px-1 font-medium underline underline-offset-2 hover:bg-foreground/5 sm:min-h-0"
+ >
+ {t('banner.outbox', { count: waiting })}
+
+ )}
+
+ setOpen(false)} title={t('outbox.title')} testId="offline-outbox-dialog">
+
+
+ >
);
}
diff --git a/components/offline/outbox-list.tsx b/components/offline/outbox-list.tsx
index 2fdc22f42..c89382699 100644
--- a/components/offline/outbox-list.tsx
+++ b/components/offline/outbox-list.tsx
@@ -15,7 +15,7 @@ import { useAccountStore } from '@/stores/account-store';
* be sent again or deleted; one whose connection broke on the way is never
* sent again on its own (it may have gone out).
*/
-export function OutboxList() {
+export function OutboxList({ heading = true }: { heading?: boolean }) {
const t = useTranslations('offline.outbox');
const format = useFormatter();
const rows = useOfflineStore((s) => s.outbox);
@@ -40,8 +40,8 @@ export function OutboxList() {
};
return (
-
-
{t('title')}
+
+ {heading &&
{t('title')} }
{t('description')}
{rows.length === 0 ? (
{t('empty')}