feat: send calendar edits with the outbox and use kept files offline

This commit is contained in:
Linus Rath
2026-11-03 07:02:40 +01:00
parent 20b2e7054e
commit d3bd93b80d
4 changed files with 111 additions and 7 deletions
+23 -1
View File
@@ -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');
+41
View File
@@ -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();
+44 -5
View File
@@ -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<Record<string, string | null>>(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<KeptBlob | null>(account, 'mail.getBlob', { jmap, blobId }).catch(() => null)
?? await getStore().area<KeptBlob | null>(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<ReturnType<typeof onlineClientFor>>): Promise<void> {
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<void> {
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<void> {
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<void> {
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);
+3 -1
View File
@@ -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<FileState>((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);