feat: merge offline drafts and kept attachments

This commit is contained in:
Linus Rath
2026-11-03 11:46:03 +01:00
81 changed files with 5811 additions and 281 deletions
@@ -4,6 +4,12 @@ import React from 'react';
import { EmailComposer, type ComposerDraftData } from '../email-composer';
import { useAuthStore } from '@/stores/auth-store';
const offlineQueue = vi.hoisted(() => ({ noteDraftReplacedElsewhere: vi.fn(async () => {}) }));
vi.mock('@/lib/offline/queue', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/offline/queue')>()),
noteDraftReplacedElsewhere: offlineQueue.noteDraftReplacedElsewhere,
}));
// ─── Heavy component mocks (mirrors composer-close-guard.test.tsx) ────────────
vi.mock('@/components/email/rich-text-editor', () => ({
@@ -293,6 +299,19 @@ describe('a draft and the login it was saved with', () => {
expect(onDiscardDraft).toHaveBeenCalledWith('draft-b2', { accountId: undefined, localAccountId: 'login-b' });
});
it('leaves the old version in its own login while the new one is only on the device', async () => {
const composer = open(DRAFT);
await composer.editSubject('Hello', 'Hello 2');
(clientB.createDraft as ReturnType<typeof vi.fn>).mockReset();
(clientB.createDraft as ReturnType<typeof vi.fn>).mockResolvedValue('local-standin');
composer.switchFrom('login-b::id-b');
await composer.editSubject('Hello 2', 'Hello 3');
expect(clientB.createDraft).toHaveBeenCalledTimes(1);
expect(clientA.deleteEmail).not.toHaveBeenCalled();
expect(offlineQueue.noteDraftReplacedElsewhere).toHaveBeenCalledWith('login-b', 'local-standin', { login: 'login-a', jmap: undefined, id: 'draft-a2' });
});
it('keeps replacing versions through createDraft while the login stays the same', async () => {
const composer = open({ ...DRAFT, draftLocalAccountId: 'login-a' });
+13 -2
View File
@@ -90,6 +90,8 @@ import { FilePickerDialog } from "@/components/files/file-picker-dialog";
import { DirectoryPickerDialog } from "@/components/contacts/directory-picker-dialog";
import type { DirectoryEntry } from "@/stores/contact-store";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { isStandInId } from "@/lib/jmap/stand-in-ids";
import { noteDraftReplacedElsewhere } from "@/lib/offline/queue";
import type { FileNode } from "@/lib/jmap/types";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { renderTemplateWithPlugins, notifyTemplateApplied } from "@/stores/template-store";
@@ -560,6 +562,7 @@ export function EmailComposer({
const tViewer = useTranslations('email_viewer');
const tClassification = useTranslations('classification');
const tProofreading = useTranslations('proofreading');
const tOfflineDrafts = useTranslations('offlineDrafts');
const tOffline = useTranslations('offline');
const tOfflineNotes = useTranslations('offlineConflicts');
const tSendGuards = useTranslations('send_guards');
@@ -1050,6 +1053,8 @@ export function EmailComposer({
const [draftLocalAccountId, setDraftLocalAccountId] = useState<string | undefined>(initialData?.draftLocalAccountId);
const draftLocalAccountIdRef = useRef<string | undefined>(initialData?.draftLocalAccountId);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
// The last save stayed on this device: the server wasn't there.
const [savedOnDevice, setSavedOnDevice] = useState(false);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const saveCloseAttemptRef = useRef<{ cancelled: boolean; saving: boolean } | null>(null);
const [isSavingAndClosing, setIsSavingAndClosing] = useState(false);
@@ -2998,7 +3003,12 @@ export function EmailComposer({
{ priority, accountId: draftAccountId, previousDraftAccountId: replacedElsewhere ? undefined : previousDraftAccountId, classification: classificationRef.current.draftChoice },
);
if (replacedElsewhere) {
await removeDraftVersion(savedDraft.draftId!, previousDraftLogin, previousDraftAccountId);
if (isStandInId(savedDraftId) && savingLogin) {
// The new version is only on this device: the old one stays until it's up.
await noteDraftReplacedElsewhere(savingLogin, savedDraftId, { login: previousDraftLogin!, jmap: previousDraftAccountId, id: savedDraft.draftId! });
} else {
await removeDraftVersion(savedDraft.draftId!, previousDraftLogin, previousDraftAccountId);
}
}
// Update the ref synchronously so a queued save sees the new id and
@@ -3050,6 +3060,7 @@ export function EmailComposer({
}
}
setSavedOnDevice(isStandInId(savedDraftId));
setSaveStatus('saved');
saveFailedRef.current = false;
lastDraftIdRef.current = savedDraftId;
@@ -4671,7 +4682,7 @@ export function EmailComposer({
{saveStatus === 'saved' && (
<div className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<Check className="w-3 h-3" />
<span className="hidden md:inline">{t('draft_saved')}</span>
<span className="hidden md:inline">{savedOnDevice ? tOfflineDrafts('saved_on_device') : t('draft_saved')}</span>
</div>
)}
{saveStatus === 'error' && (
+14
View File
@@ -55,6 +55,7 @@ import { useAuthStore, redirectToLogin, saveRedirectAfterLogin, canEnterLoginInP
import { useRouter } from '@/i18n/navigation';
import { useSettingsStore } from "@/stores/settings-store";
import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
import { useOfflineStore } from "@/lib/offline/state";
import { useContactStore } from "@/stores/contact-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useUIStore, SIDEBAR_MIN, SIDEBAR_MAX, EMAIL_LIST_MIN, EMAIL_LIST_MAX, EMAIL_LIST_HEIGHT_MIN, EMAIL_LIST_HEIGHT_MAX } from "@/stores/ui-store";
@@ -2704,6 +2705,19 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
console.error('Cancelled the scheduled send, but opening the draft failed:', error);
};
// A draft that's only on this device, opened from the outbox: the server
// doesn't list it, so nothing else can open it while online.
const draftToOpen = useOfflineStore((s) => s.draftToOpen);
useEffect(() => {
if (!draftToOpen || !client) return;
useOfflineStore.setState({ draftToOpen: null });
const draftClient = useAuthStore.getState().getClientForAccount(draftToOpen.account) ?? client;
void draftClient.getEmail(draftToOpen.id, draftToOpen.jmap)
.then((draft) => (draft ? handleEditDraft(draft, { accountId: draftToOpen.jmap, localAccountId: draftToOpen.account }) : undefined))
.catch((error) => console.error('Opening a draft kept on this device failed:', error));
// eslint-disable-next-line react-hooks/exhaustive-deps -- runs when the outbox asks, with the handler of that render
}, [draftToOpen, client]);
/**
* "Cancel and edit" from the list or the viewer, neither of which waits for
* it: the draft to open (null for S/MIME, which is composed again), or
@@ -78,6 +78,30 @@ describe('Settings > Offline', () => {
expect(screen.queryByTestId('offline-switch')).toBeNull();
});
it('asks before turning off when a draft is only on this device, and counts both lists', () => {
useOfflineStore.setState({
enabled: ['acc'],
localDrafts: [{ account: 'acc', localId: 'local-1', jmap: 'j', subject: 'Only here', status: 'waiting', createdAt: 0 }],
outbox: [{ id: 1, account: 'acc', createdAt: 0, subject: 'Queued', to: [], status: 'waiting' }],
});
render(<OfflineSettings />);
fireEvent.click(screen.getByTestId('offline-switch'));
expect(offline.disableOffline).not.toHaveBeenCalled();
expect(screen.getByText('off_confirm_title')).toBeInTheDocument();
useOfflineStore.setState({ localDrafts: [], outbox: [] });
});
it('asks for one draft alone', () => {
useOfflineStore.setState({
enabled: ['acc'],
localDrafts: [{ account: 'acc', localId: 'local-1', jmap: 'j', subject: 'Only here', status: 'waiting', createdAt: 0 }],
});
render(<OfflineSettings />);
fireEvent.click(screen.getByTestId('offline-switch'));
expect(offline.disableOffline).not.toHaveBeenCalled();
useOfflineStore.setState({ localDrafts: [] });
});
it('lists the outbox', () => {
useOfflineStore.setState({ outbox: [{ id: 1, account: 'acc', createdAt: Date.now(), subject: 'Quarterly', to: ['b@example.org'], status: 'failed', error: { text: 'Rejected' } }] });
render(<OfflineSettings />);
@@ -0,0 +1,125 @@
import 'fake-indexeddb/auto';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { setStoreBackend, type StoreBackend } from '@/lib/store/client/api';
import { useOfflineStore } from '@/lib/offline/state';
import type { LocalDraftRow } from '@/lib/offline/queue';
import { useAuthStore } from '@/stores/auth-store';
import { OutboxList } from '../outbox-list';
import { OfflineBanner } from '../offline-banner';
const push = vi.fn();
let pathname = '/settings/backup/offline';
vi.mock('@/i18n/navigation', () => ({
useRouter: () => ({ push }),
usePathname: () => pathname,
}));
const draft = (overrides: Partial<LocalDraftRow> = {}): LocalDraftRow => ({
account: 'acc',
localId: 'local-1',
jmap: 'j1',
subject: 'Only here',
status: 'waiting',
createdAt: Date.UTC(2026, 10, 3),
...overrides,
});
let area: ReturnType<typeof vi.fn>;
beforeEach(() => {
push.mockClear();
pathname = '/settings/backup/offline';
area = vi.fn(async (_account: string, name: string) => {
if (name === 'outbox.read') return { patches: 0, entries: [], conflicts: [] };
if (name === 'drafts.list') return [];
return undefined;
});
setStoreBackend({ supported: () => true, area } as unknown as StoreBackend);
useAuthStore.setState({ activeAccountId: 'acc' });
useOfflineStore.setState({ enabled: ['acc'], outbox: [], pendingChanges: 0, conflicts: [], localDrafts: [], draftToOpen: null, browserOffline: false, downServers: [], startedOffline: false });
});
afterEach(() => {
setStoreBackend(null);
useOfflineStore.setState({ enabled: [], conflicts: [], localDrafts: [], draftToOpen: null });
});
describe('drafts that are only on this device', () => {
it('shows nothing when there are none', () => {
render(<OutboxList />);
expect(screen.queryByTestId('offline-local-drafts')).toBeNull();
});
it('shows a refused one with the server\'s reason, and a way to open and retry it', () => {
useOfflineStore.setState({ localDrafts: [draft({ status: 'refused', error: { type: 'overQuota', text: 'No room left' } })] });
render(<OutboxList />);
const row = screen.getByTestId('offline-local-draft-row');
expect(row.getAttribute('data-status')).toBe('refused');
expect(row.textContent).toContain('Only here');
expect(row.textContent).toContain('status_refused');
expect(screen.getByRole('button', { name: 'open_label' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'retry_label' })).toBeTruthy();
});
it('offers no retry for one that waits by itself', () => {
useOfflineStore.setState({ localDrafts: [draft()] });
render(<OutboxList />);
expect(screen.getByTestId('offline-local-draft-row').textContent).toContain('status_waiting');
expect(screen.queryByRole('button', { name: 'retry_label' })).toBeNull();
});
it('opens it on the mail page, going there from elsewhere', () => {
useOfflineStore.setState({ localDrafts: [draft()] });
const onOpenDraft = vi.fn();
render(<OutboxList onOpenDraft={onOpenDraft} />);
fireEvent.click(screen.getByRole('button', { name: 'open_label' }));
expect(useOfflineStore.getState().draftToOpen).toEqual({ account: 'acc', jmap: 'j1', id: 'local-1' });
expect(onOpenDraft).toHaveBeenCalled();
expect(push).toHaveBeenCalledWith('/mail');
});
it('stays on the mail page when it\'s already there', () => {
pathname = '/mail';
useOfflineStore.setState({ localDrafts: [draft()] });
render(<OutboxList />);
fireEvent.click(screen.getByRole('button', { name: 'open_label' }));
expect(push).not.toHaveBeenCalled();
});
it('asks the store to try again, and reads the queue', async () => {
useOfflineStore.setState({ localDrafts: [draft({ status: 'refused' })] });
render(<OutboxList />);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'retry_label' }));
});
expect(area).toHaveBeenCalledWith('acc', 'drafts.retry', { localId: 'local-1' });
expect(area).toHaveBeenCalledWith('acc', 'drafts.list');
expect(useOfflineStore.getState().localDrafts).toEqual([]);
});
it('says both versions are kept for a draft changed elsewhere', () => {
useOfflineStore.setState({
conflicts: [
{ id: 1, account: 'acc', recordId: 'r', createdAt: 0, summary: { subject: 'A', op: 'draft', reason: 'changed' } },
{ id: 2, account: 'acc', recordId: 's', createdAt: 0, summary: { subject: 'B', op: 'draft', reason: 'gone' } },
],
});
render(<OutboxList />);
const rows = screen.getAllByTestId('offline-conflict-row');
expect(rows[0].textContent).toContain('conflict_changed');
expect(rows[1].textContent).toContain('conflict_gone');
});
it('says in the banner while online that drafts wait, and which were refused', () => {
useOfflineStore.setState({ localDrafts: [draft()] });
const { rerender } = render(<OfflineBanner />);
expect(screen.queryByTestId('offline-banner-drafts')).toBeNull();
expect(screen.getByTestId('offline-banner-drafts-waiting').textContent).toContain('banner_waiting');
act(() => useOfflineStore.setState({ localDrafts: [draft({ status: 'refused' })] }));
rerender(<OfflineBanner />);
expect(screen.getByTestId('offline-banner-drafts').textContent).toContain('banner_refused');
expect(screen.queryByTestId('offline-banner-drafts-waiting')).toBeNull();
});
});
+27 -2
View File
@@ -25,10 +25,13 @@ export function OfflineBanner() {
const serverDown = useOfflineStore((s) => !!server && s.downServers.includes(serverKey(server)));
const waiting = useOfflineStore((s) => s.outbox.length);
const conflicts = useOfflineStore((s) => s.conflicts.length);
const refusedDrafts = useOfflineStore((s) => s.localDrafts.filter((draft) => draft.status === 'refused').length);
const waitingDrafts = useOfflineStore((s) => s.localDrafts.filter((draft) => draft.status !== 'refused').length);
const tConflicts = useTranslations('offlineConflicts');
const tDrafts = useTranslations('offlineDrafts');
const [open, setOpen] = useState(false);
const offline = browserOffline || serverDown;
if (IS_LITE || !active || (!offline && waiting === 0 && conflicts === 0 && !open)) return null;
if (IS_LITE || !active || (!offline && waiting === 0 && conflicts === 0 && refusedDrafts === 0 && waitingDrafts === 0 && !open)) return null;
return (
<>
<div
@@ -63,9 +66,31 @@ export function OfflineBanner() {
{tConflicts('banner', { count: conflicts })}
</button>
)}
{waitingDrafts > 0 && (
<button
type="button"
data-testid="offline-banner-drafts-waiting"
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"
>
{tDrafts('banner_waiting', { count: waitingDrafts })}
</button>
)}
{refusedDrafts > 0 && (
<button
type="button"
data-testid="offline-banner-drafts"
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"
>
{tDrafts('banner_refused', { count: refusedDrafts })}
</button>
)}
</div>
<Dialog isOpen={open} onClose={() => setOpen(false)} title={t('outbox.title')} testId="offline-outbox-dialog">
<OutboxList heading={false} />
<OutboxList heading={false} onOpenDraft={() => setOpen(false)} />
</Dialog>
</>
);
+11
View File
@@ -22,6 +22,9 @@ export function OfflineHost() {
const tConflicts = useTranslations('offlineConflicts');
const tConflictsRef = useRef(tConflicts);
tConflictsRef.current = tConflicts;
const tDrafts = useTranslations('offlineDrafts');
const tDraftsRef = useRef(tDrafts);
tDraftsRef.current = tDrafts;
const { dialogProps, confirm } = useConfirmDialog();
const confirmRef = useRef(confirm);
confirmRef.current = confirm;
@@ -50,6 +53,14 @@ export function OfflineHost() {
case 'conflict':
toast.warning(tConflictsRef.current('toast', { count: notice.count }));
break;
case 'draft-refused':
toast.error(tDraftsRef.current('toast_refused', { subject: notice.subject || tr('outbox.no_subject') }), {
message: notice.error.code ? tDraftsRef.current(notice.error.code) : notice.error.text,
});
break;
case 'draft-conflict':
toast.warning(tDraftsRef.current('toast_conflict', { subject: notice.subject || tr('outbox.no_subject') }));
break;
case 'storage-failed':
toast.warning(tConflictsRef.current('storage_failed'), { duration: 0 });
break;
+1 -1
View File
@@ -29,7 +29,7 @@ export function OfflineSettings() {
const usage = useOfflineStore((s) => s.usageBytes);
const syncing = useOfflineStore((s) => !!account && s.syncing.includes(account));
const lastSynced = useOfflineStore((s) => (account ? s.lastSyncedAt[account] : undefined));
const unsent = useOfflineStore((s) => s.outbox.filter((row) => row.account === account).length);
const unsent = useOfflineStore((s) => [...s.outbox, ...s.localDrafts].filter((row) => row.account === account).length);
const blocked = useOfflineStore((s) => s.storageBlocked);
const tNotes = useTranslations('offlineConflicts');
const [confirmOff, setConfirmOff] = useState(false);
+55 -3
View File
@@ -2,11 +2,12 @@
import { useState } from 'react';
import { useFormatter, useTranslations } from 'next-intl';
import { usePathname, useRouter } from '@/i18n/navigation';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { deleteOutboxEntry, retryOutboxEntry } from '@/lib/offline/flush';
import { refreshQueueState, scheduleFlushAll } from '@/lib/offline/offline-mode';
import { dismissConflict, type ConflictEntry } from '@/lib/offline/queue';
import { dismissConflict, retryLocalDraft, type ConflictEntry, type LocalDraftRow } from '@/lib/offline/queue';
import { isOfflineNow, useOfflineStore, type OutboxRow } from '@/lib/offline/state';
import { useAccountStore } from '@/stores/account-store';
@@ -16,12 +17,18 @@ 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); one held because the
* organisation's rules changed can only be deleted (and written again).
* Changes that didn't go through ("changed on another device") are listed too.
* Changes that didn't go through ("changed on another device") are listed too,
* and drafts that are only on this device: on their way up, or refused by the
* server, in which case they can still be opened and tried again.
*/
export function OutboxList({ heading = true }: { heading?: boolean }) {
export function OutboxList({ heading = true, onOpenDraft }: { heading?: boolean; onOpenDraft?: () => void }) {
const t = useTranslations('offline.outbox');
const tErrors = useTranslations('offline.errors');
const tConflicts = useTranslations('offlineConflicts');
const tDrafts = useTranslations('offlineDrafts');
const router = useRouter();
const pathname = usePathname();
const localDrafts = useOfflineStore((s) => s.localDrafts);
const format = useFormatter();
const rows = useOfflineStore((s) => s.outbox);
const changes = useOfflineStore((s) => s.pendingChanges);
@@ -46,7 +53,29 @@ export function OutboxList({ heading = true }: { heading?: boolean }) {
if (!isOfflineNow()) scheduleFlushAll(0);
};
const draftStatus = (draft: LocalDraftRow) => {
switch (draft.status) {
case 'refused': return tDrafts('status_refused', { error: draft.error?.code ? tDrafts(draft.error.code) : (draft.error?.text ?? '') });
case 'sending': return tDrafts('status_sending');
case 'uncertain': return tDrafts('status_uncertain');
default: return tDrafts('status_waiting');
}
};
const openDraft = (draft: LocalDraftRow) => {
useOfflineStore.setState({ draftToOpen: { account: draft.account, jmap: draft.jmap, id: draft.localId } });
onOpenDraft?.();
if (!pathname.startsWith('/mail')) router.push('/mail');
};
const retryDraft = async (draft: LocalDraftRow) => {
await retryLocalDraft(draft.account, draft.localId);
await refreshQueueState();
if (!isOfflineNow()) scheduleFlushAll(0);
};
const conflictText = (conflict: ConflictEntry) => {
if (conflict.summary.op === 'draft') return tDrafts(conflict.summary.reason === 'gone' ? 'conflict_gone' : 'conflict_changed');
if (conflict.summary.reason === 'refused') return tConflicts('refused', { reason: conflict.summary.detail ?? '' });
if (conflict.summary.reason === 'gone') return tConflicts('gone');
return tConflicts(conflict.summary.op === 'destroy' ? 'changed_delete' : 'changed');
@@ -89,6 +118,29 @@ export function OutboxList({ heading = true }: { heading?: boolean }) {
))}
</ul>
)}
{localDrafts.length > 0 && (
<div className="mt-3" data-testid="offline-local-drafts">
<p className="text-xs text-muted-foreground">{tDrafts('description')}</p>
<ul className="mt-2 divide-y divide-border rounded-md border border-border" aria-label={tDrafts('title')}>
{localDrafts.map((draft) => (
<li key={`${draft.account}:${draft.localId}`} className="flex flex-col gap-2 px-3 py-2.5 sm:flex-row sm:items-start sm:justify-between" data-testid="offline-local-draft-row" data-status={draft.status}>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{draft.subject || t('no_subject')}</p>
<p className={draft.status === 'refused' ? 'text-xs text-destructive [overflow-wrap:anywhere]' : 'text-xs text-muted-foreground'}>
{draftStatus(draft)} · {format.dateTime(draft.createdAt, { dateStyle: 'short', timeStyle: 'short' })}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Button variant="outline" size="sm" aria-label={tDrafts('open_label', { subject: draft.subject || t('no_subject') })} onClick={() => openDraft(draft)}>{tDrafts('open')}</Button>
{draft.status === 'refused' && (
<Button variant="outline" size="sm" aria-label={tDrafts('retry_label', { subject: draft.subject || t('no_subject') })} onClick={() => void retryDraft(draft)}>{tDrafts('retry')}</Button>
)}
</div>
</li>
))}
</ul>
</div>
)}
{changes > 0 && (
<p className="mt-2 text-xs text-muted-foreground" data-testid="offline-pending-changes">{t('changes', { count: changes })}</p>
)}