fix: open the offline outbox from the banner without leaving the page #523

This commit is contained in:
Linus Rath
2026-11-01 12:14:50 +01:00
parent 6428c2b880
commit da4c880c5f
3 changed files with 62 additions and 31 deletions
@@ -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(<OfflineBanner />);
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(<OfflineBanner />);
expect(screen.queryByTestId('offline-banner')).toBeNull();
});
});
+37 -28
View File
@@ -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 (
<div
role="status"
data-testid="offline-banner"
className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-1 border-b border-border bg-muted px-3 py-2 text-sm text-foreground sm:px-4"
>
{offline && (
<span className="flex min-w-0 items-start gap-2">
<CloudOff className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<span className="min-w-0 [overflow-wrap:anywhere]">{t('offline')}</span>
</span>
)}
{waiting > 0 && (
<button
type="button"
data-testid="offline-banner-outbox"
onClick={() => 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 })}
</button>
)}
</div>
<>
<div
role="status"
data-testid="offline-banner"
className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-1 border-b border-border bg-muted px-3 py-2 text-sm text-foreground sm:px-4"
>
{offline && (
<span className="flex min-w-0 items-start gap-2">
<CloudOff className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<span className="min-w-0 [overflow-wrap:anywhere]">{t('banner.offline')}</span>
</span>
)}
{waiting > 0 && (
<button
type="button"
data-testid="offline-banner-outbox"
aria-haspopup="dialog"
onClick={() => 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 })}
</button>
)}
</div>
<Dialog isOpen={open} onClose={() => setOpen(false)} title={t('outbox.title')} testId="offline-outbox-dialog">
<OutboxList heading={false} />
</Dialog>
</>
);
}
+3 -3
View File
@@ -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 (
<div className="pt-2" data-testid="offline-outbox">
<h4 className="text-sm font-medium text-foreground">{t('title')}</h4>
<div className={heading ? 'pt-2' : undefined} data-testid="offline-outbox">
{heading && <h4 className="text-sm font-medium text-foreground">{t('title')}</h4>}
<p className="mt-1 text-xs text-muted-foreground">{t('description')}</p>
{rows.length === 0 ? (
<p className="mt-3 text-sm text-muted-foreground" data-testid="offline-outbox-empty">{t('empty')}</p>