mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
feat: send calendar edits with the outbox and use kept files offline
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user