From d3bd93b80da5102e8becef0b4d435465f260c3d0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:02:40 +0100 Subject: [PATCH] feat: send calendar edits with the outbox and use kept files offline --- lib/offline/__tests__/client-fallback.test.ts | 24 ++++++++- lib/offline/__tests__/outbox.test.ts | 41 ++++++++++++++++ lib/offline/offline-mode.ts | 49 +++++++++++++++++-- stores/file-store.ts | 4 +- 4 files changed, 111 insertions(+), 7 deletions(-) diff --git a/lib/offline/__tests__/client-fallback.test.ts b/lib/offline/__tests__/client-fallback.test.ts index a81cc7c95..a4aa5ffa5 100644 --- a/lib/offline/__tests__/client-fallback.test.ts +++ b/lib/offline/__tests__/client-fallback.test.ts @@ -242,7 +242,7 @@ describe('what the store answers and is told', () => { const { client, stop } = await ready(); const note = vi.spyOn(store, 'noteStateChange'); const { getOfflineHooks } = await import('@/lib/jmap/offline-hooks'); - getOfflineHooks()?.stateChange(client, { changed: { acc1: { Email: 's99' } } }); + getOfflineHooks()?.stateChange?.(client, { changed: { acc1: { Email: 's99' } } }); expect(note).toHaveBeenCalledWith('acc', { acc1: { Email: 's99' } }); stop(); }); @@ -258,6 +258,28 @@ describe('what the store answers and is told', () => { stop(); }); + it('hands the other areas\u2019 lookups to the store', async () => { + const { client, stop } = await ready(); + const { getOfflineHooks } = await import('@/lib/jmap/offline-hooks'); + const area = vi.spyOn(store, 'area'); + const hooks = getOfflineHooks(); + + area.mockResolvedValueOnce({ 'bulwark-new-1': 'real-1' }); + expect(await hooks?.resolveStandIns?.(client, ['bulwark-new-1'])).toEqual({ 'bulwark-new-1': 'real-1' }); + expect(area).toHaveBeenLastCalledWith('acc', 'calendar.resolveStandIns', { ids: ['bulwark-new-1'] }); + + hooks?.fileOpened?.(client, 'acc1', 'node-1'); + expect(area).toHaveBeenLastCalledWith('acc', 'files.noteOpened', { jmap: 'acc1', id: 'node-1' }); + + // Not an attachment of a kept message: maybe a kept file. + area.mockResolvedValueOnce(null).mockResolvedValueOnce({ type: 'text/plain', data: new TextEncoder().encode('kept') }); + const blob = await hooks?.blob(client, 'blob-1', 'acc1'); + expect(await blob?.text()).toBe('kept'); + expect(area).toHaveBeenLastCalledWith('acc', 'files.blob', { jmap: 'acc1', blobId: 'blob-1' }); + area.mockRestore(); + stop(); + }); + it('answers a search for the merge with the server', async () => { const { client, stop } = await ready(); const { getOfflineHooks } = await import('@/lib/jmap/offline-hooks'); diff --git a/lib/offline/__tests__/outbox.test.ts b/lib/offline/__tests__/outbox.test.ts index 6d18aa28c..03e61e04b 100644 --- a/lib/offline/__tests__/outbox.test.ts +++ b/lib/offline/__tests__/outbox.test.ts @@ -357,6 +357,47 @@ describe('a send whose response got lost', () => { }); }); +describe('what the other areas queued', () => { + const flushWith = async (tally: unknown) => { + const { flushAll } = await import('../offline-mode'); + const client = fakeClient(); + useAuthStore.setState({ client: client as never, activeAccountId: 'acc' }); + usePolicyStore.setState({ fetchPolicy: vi.fn(async () => {}), source: 'server' } as never); + const realArea = store.area.bind(store); + const calls: string[] = []; + store.area = (async (account: string, name: string, params?: unknown) => { + if (name.endsWith('.flush')) { + calls.push(name); + return tally; + } + return realArea(account, name, params); + }) as typeof store.area; + const { subscribePushStateChanges } = await import('@/lib/push-events'); + const pushes: unknown[] = []; + const unsubscribe = subscribePushStateChanges((change, _client, role) => pushes.push([change.changed, role])); + useAccountStore.setState({ accounts: [{ id: 'acc', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] }); + useAuthStore.setState({ getClientForAccount: () => client } as never); + writeEnabled(['acc']); + await flushAll(); + unsubscribe(); + return { calls, pushes }; + }; + + it('flushes them, also with nothing in the outbox, and has the page read again', async () => { + const { calls, pushes } = await flushWith({ sent: 1, conflicts: 0, pending: 0 }); + expect(calls).toEqual(['calendar.flush', 'contacts.flush']); + expect(pushes).toEqual([ + [{ acc1: { Calendar: 'flushed', CalendarEvent: 'flushed' } }, 'active'], + [{ acc1: { AddressBook: 'flushed', ContactCard: 'flushed' } }, 'active'], + ]); + }); + + it('doesn\u2019t have the page read again when nothing went out', async () => { + const { pushes } = await flushWith({ sent: 0, conflicts: 0, pending: 0 }); + expect(pushes).toEqual([]); + }); +}); + describe('conflicts reach the page', () => { it('lists a refused change and tells the user', async () => { const client = fakeClient(); diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index ffb1c1fda..981dbe437 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -25,6 +25,7 @@ import { devicesForAccount } from '@/lib/device-policy'; import { accountSubjectPending, useAccountSubjects } from '@/lib/org/account-subjects'; import { labelsPolicyFor } from '@/lib/outgoing/classification'; import { getSendGuardSource, guardState, guardsSending } from '@/lib/plugin-send-guards'; +import { emitPushStateChange } from '@/lib/push-events'; import { requestOutboxSync } from '@/lib/pwa-notify/outbox-sync'; import { STORAGE_ERROR_AREA } from '@/lib/store/area'; import { configureStore, getStore, type StorePageHooks } from '@/lib/store/client/api'; @@ -242,6 +243,16 @@ const hooks: OfflineHooks = { } return answered; }, + async resolveStandIns(client, ids) { + const account = accountForClient(client); + if (!offlineActive(account) || !account) return {}; + return getStore().area>(account, 'calendar.resolveStandIns', { ids }).catch(() => ({})); + }, + fileOpened(client, accountId, id) { + const account = accountForClient(client); + if (!offlineActive(account) || !account) return; + void getStore().area(account, 'files.noteOpened', { jmap: accountId, id }).catch(() => {}); + }, async searchLocal(client, methodCalls) { const account = accountForClient(client); if (!offlineActive(account) || !account) return null; @@ -293,7 +304,9 @@ const hooks: OfflineHooks = { const account = accountForClient(client); if (!account || !offlineActive(account)) return null; if (isLocalBlobId(blobId)) return heldFile(account, blobId); - const kept = await getStore().area<{ type: string | null; data: Uint8Array } | null>(account, 'mail.getBlob', { jmap, blobId }).catch(() => null); + // An attachment, or a file from the Files area. + const kept = await getStore().area(account, 'mail.getBlob', { jmap, blobId }).catch(() => null) + ?? await getStore().area(account, 'files.blob', { jmap, blobId }).catch(() => null); return kept ? new Blob([kept.data as BlobPart], { type: kept.type || 'application/octet-stream' }) : null; }, recordBlob(client, blobId, jmap, blob) { @@ -312,6 +325,8 @@ const hooks: OfflineHooks = { }, }; +interface KeptBlob { type: string | null; data: Uint8Array } + /** Attachments and images opened while online are kept up to this size. */ const MAX_RECORDED_BLOB_BYTES = 5 * 1024 * 1024; @@ -554,13 +569,37 @@ function offerUndo(entry: OutboxEntry, result: SendEmailResult): void { }); } +/** + * What the other areas queued. The page may hold stand-in ids for what just + * went out, so it reads again, the way a push makes it. Only the types go by + * that way: the "state" isn't one, and nothing stores it. + */ +const AREA_FLUSHES = [ + { call: 'calendar.flush', types: ['Calendar', 'CalendarEvent'] }, + { call: 'contacts.flush', types: ['AddressBook', 'ContactCard'] }, +]; + +async function flushAreas(account: string, client: NonNullable>): Promise { + for (const { call, types } of AREA_FLUSHES) { + // Contacts may not be there in this build: nothing to flush then. + const tally = await getStore().area<{ sent?: number; conflicts?: number | unknown[] } | null>(account, call).catch(() => null); + if (!tally) continue; + const conflicts = Array.isArray(tally.conflicts) ? tally.conflicts.length : tally.conflicts ?? 0; + if (!tally.sent && !conflicts) continue; + const role = (useAuthStore.getState().client as unknown) === client ? 'active' : 'background'; + const changed = { [client.getAccountId()]: Object.fromEntries(types.map((type) => [type, 'flushed'])) }; + emitPushStateChange({ '@type': 'StateChange', changed } as never, client, role); + } +} + export async function flushAll(): Promise { if (useOfflineStore.getState().startedOffline) { void leaveOfflineStart(); return; } - const { outbox: rows, pendingChanges } = useOfflineStore.getState(); - if (rows.length === 0 && pendingChanges === 0) return; + // Calendar and contact edits wait in their own areas, so what waits isn't known here. + const { outbox: rows, enabled } = useOfflineStore.getState(); + if (rows.length === 0 && enabled.length === 0) return; // The organisation's current word before anything goes: a label now // required, a send check now required or offline mode now forbidden must // stop what waited under the old rules. @@ -570,8 +609,7 @@ export async function flushAll(): Promise { return; } enforcePolicy(); - const accounts = new Set(rows.map((r) => r.account)); - if (pendingChanges > 0) for (const id of useOfflineStore.getState().enabled) accounts.add(id); + const accounts = new Set([...rows.map((r) => r.account), ...useOfflineStore.getState().enabled]); for (const account of accounts) { const client = onlineClientFor(account); if (!client || !offlineActive(account)) continue; @@ -582,6 +620,7 @@ export async function flushAll(): Promise { onScheduled: offerUndo, retryLater: () => scheduleFlushAll(LOOKUP_AGAIN_MS), }).catch(() => {}); + await flushAreas(account, client); } await refreshQueueState(); for (const account of useOfflineStore.getState().enabled) scheduleSync(account, 3_000); diff --git a/stores/file-store.ts b/stores/file-store.ts index f9ec1a6b5..190c37264 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -1,3 +1,4 @@ +import { getOfflineHooks, type OfflineClientRef } from '@/lib/jmap/offline-hooks'; import { create } from 'zustand'; import type { FileNodeListings, IJMAPClient } from '@/lib/jmap/client-interface'; import type { FileNode, FileNodeRights } from '@/lib/jmap/types'; @@ -1688,7 +1689,8 @@ export const useFileStore = create((set, get) => ({ }, addRecentFile: (name: string, id: string) => { - const { recentFiles } = get(); + const { recentFiles, client } = get(); + if (client && id) getOfflineHooks()?.fileOpened?.(client as OfflineClientRef, client.getFilesAccountId(), id); const entry = { name, id, timestamp: Date.now() }; const filtered = recentFiles.filter(r => r.id !== id); const next = [entry, ...filtered].slice(0, 20);