diff --git a/src/widgets/__tests__/actions.test.ts b/src/widgets/__tests__/actions.test.ts new file mode 100644 index 00000000..8e24d632 --- /dev/null +++ b/src/widgets/__tests__/actions.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { MailItem, WidgetSnapshot } from '../snapshot'; + +// A fake server and a fake refresh: the refresh reads the server when it +// starts and stores what it read when `release` lets it finish, which is how +// a slow refresh overlaps a second tap on the device. +const server = vi.hoisted(() => ({ inbox: [] as string[] })); +const calls = vi.hoisted(() => ({ moves: [] as Array<{ id: string; resolve: (ok: boolean) => void }> })); + +vi.mock('../jmap', () => ({ + openClient: vi.fn(async () => ({})), + markRead: vi.fn(async () => true), + rsvp: vi.fn(async () => true), + setTaskDone: vi.fn(async () => true), + moveTo: vi.fn((_client: unknown, id: string) => new Promise((resolve) => { + calls.moves.push({ + id, + resolve: (ok) => { + if (ok) server.inbox = server.inbox.filter((x) => x !== id); + resolve(ok); + }, + }); + })), +})); + +vi.mock('../render', () => ({ redrawAll: vi.fn(async () => undefined) })); + +vi.mock('../build', async () => { + const { storeRefresh } = await import('../state'); + return { + singletonServes: () => false, + refreshSnapshot: vi.fn(async () => { + const startedAt = Date.now(); + const read = [...server.inbox]; + await new Promise((r) => setTimeout(r, 5)); + await storeRefresh(snapshotOf(read), startedAt); + }), + }; +}); + +function mail(id: string): MailItem { + return { + id, threadId: `t${id}`, accountId: 'acc', fromName: id, fromEmail: `${id}@x`, initials: 'X', color: '#123456', + subject: id, preview: '', receivedAt: 0, unread: true, starred: false, hasAttachment: false, threadSize: 1, + }; +} + +function snapshotOf(ids: string[]): WidgetSnapshot { + return { + version: 1, generatedAt: Date.now(), appDataAt: 0, signedIn: true, theme: 'light', locale: 'en', hour12: false, + weekStart: 1, accounts: [], activeAccountId: 'acc', + mail: { + folders: [{ role: 'inbox', name: 'Inbox', unread: ids.length, total: ids.length }], + inbox: ids.map(mail), unified: [], starred: [], starredCount: 0, drafts: [], draftCount: 0, scheduled: [], + pendingChanges: 0, favourites: [], recentSearches: [], tags: [], attachments: [], + }, + calendar: { supported: true, events: [], invitations: [], birthdays: [] }, + tasks: { supported: true, items: [] }, + files: { supported: false, items: [] }, + vacation: null, + quota: null, + }; +} + +const { handleWidgetAction } = await import('../actions'); +const { currentView } = await import('../state'); +const { saveSnapshot } = await import('../snapshot'); + +const shown = async () => (await currentView()).mail.inbox.map((m) => m.id); +const until = async (check: () => boolean) => { + for (let i = 0; i < 200 && !check(); i++) await new Promise((r) => setTimeout(r, 2)); + expect(check()).toBe(true); +}; + +beforeEach(async () => { + await AsyncStorage.clear(); + calls.moves = []; + server.inbox = ['m1', 'm2', 'm3']; + await saveSnapshot(snapshotOf(server.inbox)); +}); + +describe('widget archive', () => { + it('keeps a message archived when a refresh read the server before its archive landed', async () => { + const first = handleWidgetAction('archive', { id: 'm1', accountId: 'acc' }, 7); + const second = handleWidgetAction('archive', { id: 'm2', accountId: 'acc' }, 7); + await until(() => calls.moves.length === 2); + expect(await shown()).toEqual(['m3']); + + // The first archive lands; its refresh reads a server that still has m2. + calls.moves[0].resolve(true); + await first; + expect(server.inbox).toEqual(['m2', 'm3']); + expect(await shown()).toEqual(['m3']); + + calls.moves[1].resolve(true); + await second; + expect(await shown()).toEqual(['m3']); + }); + + it('brings a message back when its archive did not reach the server', async () => { + const tap = handleWidgetAction('archive', { id: 'm1', accountId: 'acc' }, 7); + await until(() => calls.moves.length === 1); + expect(await shown()).toEqual(['m2', 'm3']); + calls.moves[0].resolve(false); + await tap; + expect(await shown()).toEqual(['m1', 'm2', 'm3']); + }); + + it('does not lose either of two taps that land together', async () => { + const taps = [ + handleWidgetAction('archive', { id: 'm1', accountId: 'acc' }, 7), + handleWidgetAction('trash', { id: 'm3', accountId: 'acc' }, 7), + ]; + await until(() => calls.moves.length === 2); + expect(await shown()).toEqual(['m2']); + for (const move of calls.moves) move.resolve(true); + await Promise.all(taps); + expect(await shown()).toEqual(['m2']); + }); + + it('moves the triage card on to the next unread message', async () => { + const tap = handleWidgetAction('archive', { id: 'm1', accountId: 'acc' }, 7); + await until(() => calls.moves.length === 1); + expect(JSON.parse((await AsyncStorage.getItem('widgets:local:7'))!)).toEqual({ triageId: 'm2' }); + calls.moves[0].resolve(true); + await tap; + }); +}); diff --git a/src/widgets/__tests__/pending.test.ts b/src/widgets/__tests__/pending.test.ts new file mode 100644 index 00000000..43467aa1 --- /dev/null +++ b/src/widgets/__tests__/pending.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { + applyPending, + confirmedBy, + DONE_TTL_MS, + expire, + IN_FLIGHT_TTL_MS, + type PendingOp, +} from '../pending'; +import { PREVIEW_NOW, sampleSnapshot } from '../sample-snapshot'; + +const op = (change: PendingOp['change'], extra: Partial = {}): PendingOp => ({ + key: `${change.kind}:${change.id}`, + change, + at: 1000, + ...extra, +}); + +describe('applyPending', () => { + it('takes an archived message out of every list and the inbox counts', () => { + const s = sampleSnapshot(PREVIEW_NOW); + const target = s.mail.inbox.find((m) => m.unread)!; + const inbox = s.mail.folders.find((f) => f.role === 'inbox')!; + const shown = applyPending(s, [op({ kind: 'removeMail', id: target.id })]); + expect(shown.mail.inbox.some((m) => m.id === target.id)).toBe(false); + expect(shown.mail.unified.some((m) => m.id === target.id)).toBe(false); + const after = shown.mail.folders.find((f) => f.role === 'inbox')!; + expect([after.total, after.unread]).toEqual([inbox.total - 1, inbox.unread - 1]); + // The stored snapshot is left as the server described it. + expect(s.mail.inbox.some((m) => m.id === target.id)).toBe(true); + expect(s.mail.folders.find((f) => f.role === 'inbox')!.total).toBe(inbox.total); + }); + + it('marks a message read once', () => { + const s = sampleSnapshot(PREVIEW_NOW); + const target = s.mail.inbox.find((m) => m.unread)!; + const before = s.mail.folders.find((f) => f.role === 'inbox')!.unread; + const twice = [op({ kind: 'markRead', id: target.id }), op({ kind: 'markRead', id: target.id }, { key: 'b' })]; + const shown = applyPending(s, twice); + expect(shown.mail.inbox.find((m) => m.id === target.id)!.unread).toBe(false); + expect(shown.mail.folders.find((f) => f.role === 'inbox')!.unread).toBe(before - 1); + }); + + it('answers an invitation and ticks a task', () => { + const s = sampleSnapshot(PREVIEW_NOW); + const inv = s.calendar.invitations[0]; + const task = s.tasks.items.find((t) => !t.done)!; + const shown = applyPending(s, [ + op({ kind: 'rsvp', id: inv.id, serverId: inv.serverId, status: 'accepted' }), + op({ kind: 'task', id: task.id, done: true }), + ]); + expect(shown.calendar.invitations.some((i) => i.serverId === inv.serverId)).toBe(false); + expect(shown.tasks.items.find((t) => t.id === task.id)!.done).toBe(true); + expect(s.tasks.items.find((t) => t.id === task.id)!.done).toBe(false); + }); +}); + +describe('confirmedBy', () => { + const mail = op({ kind: 'removeMail', id: 'm' }, { doneAt: 2000 }); + const task = op({ kind: 'task', id: 't', done: true }, { doneAt: 2000 }); + const inFlight = op({ kind: 'removeMail', id: 'n' }); + + it('keeps a change a refresh started before it reached the server', () => { + // The refresh read the server at 1500, before the archive landed at 2000: + // its data still has the message, so the op must keep hiding it. + expect(confirmedBy([mail, inFlight], 1500, true)).toEqual([mail, inFlight]); + }); + + it('drops a mail change once a later refresh has stored it', () => { + expect(confirmedBy([mail, inFlight], 2500, false)).toEqual([inFlight]); + }); + + it('keeps a task change until a refresh that loaded tasks', () => { + expect(confirmedBy([task], 2500, false)).toEqual([task]); + expect(confirmedBy([task], 2500, true)).toEqual([]); + }); +}); + +describe('expire', () => { + it('drops stuck and stale ops', () => { + const now = 10_000_000; + const stuck = op({ kind: 'removeMail', id: 'a' }, { at: now - IN_FLIGHT_TTL_MS - 1 }); + const fresh = op({ kind: 'removeMail', id: 'b' }, { at: now - 1000 }); + const stale = op({ kind: 'task', id: 'c', done: true }, { at: 0, doneAt: now - DONE_TTL_MS - 1 }); + const recent = op({ kind: 'task', id: 'd', done: true }, { at: 0, doneAt: now - 1000 }); + expect(expire({ ops: [stuck, fresh, stale, recent] }, now)).toEqual({ ops: [fresh, recent] }); + }); +}); diff --git a/src/widgets/actions.ts b/src/widgets/actions.ts index ec506ddc..1aa8fb20 100644 --- a/src/widgets/actions.ts +++ b/src/widgets/actions.ts @@ -1,16 +1,19 @@ // Buttons that act without opening the app: archive / delete / next on the -// triage card, RSVP on the invitation card, ticking a task. Each one updates -// the stored snapshot first so every widget redraws at once, then makes the -// JMAP call, then refreshes from the server; a failed call is undone by that -// refresh. +// triage card, RSVP on the invitation card, ticking a task. Each one lays its +// change over the stored data at once (./state.ts) so every widget redraws +// without waiting, then makes the JMAP call. A change the server took stays +// laid over the data until a refresh has caught up with it; one that failed +// is dropped again, which shows the data as the server last described it. import { jmapClient } from '../api/jmap-client'; import type { JMAPClient } from '../api/jmap-client'; -import { noteLocalChange, refreshSnapshot, singletonServes } from './build'; +import { refreshSnapshot, singletonServes } from './build'; import { markRead, moveTo, openClient, rsvp, setTaskDone } from './jmap'; import { loadLocal, saveLocal } from './local-state'; -import { updateAllWidgets } from './render'; -import { emptySnapshot, loadSnapshot, saveSnapshot, type MailItem, type WidgetSnapshot } from './snapshot'; +import type { PendingChange } from './pending'; +import { redrawAll } from './render'; +import { serial } from './serial'; +import { currentView, settle, track } from './state'; async function clientFor(registryAccountId: string | null | undefined): Promise { if (!registryAccountId) return null; @@ -18,52 +21,53 @@ async function clientFor(registryAccountId: string | null | undefined): Promise< return openClient(registryAccountId); } -async function applyLocally(mutate: (s: WidgetSnapshot) => void): Promise { - noteLocalChange(); - const s = (await loadSnapshot()) ?? emptySnapshot(); - mutate(s); - await saveSnapshot(s); - await updateAllWidgets(s); - return s; -} - -function withoutMessage(s: WidgetSnapshot, id: string): void { - const removed = s.mail.inbox.find((m) => m.id === id); - const drop = (list: MailItem[]) => list.filter((m) => m.id !== id); - s.mail.inbox = drop(s.mail.inbox); - s.mail.unified = drop(s.mail.unified); - s.mail.starred = drop(s.mail.starred); - const inbox = s.mail.folders.find((f) => f.role === 'inbox'); - if (inbox && removed) { - inbox.total = Math.max(0, inbox.total - 1); - if (removed.unread) inbox.unread = Math.max(0, inbox.unread - 1); +/** Lay `change` over the data, run `call`, and keep or drop the change by its answer. */ +async function apply(name: string, change: PendingChange, call: () => Promise): Promise { + const key = await track(change); + await redrawAll(); + let ok = false; + try { + ok = await call(); + } catch (err) { + console.warn(`[widgets] ${name} failed`, err); } -} - -async function afterServerCall(ok: boolean): Promise { - if (!ok) console.warn('[widgets] action was not applied on the server'); - const fresh = await refreshSnapshot({ after: 'change' }); - await updateAllWidgets(fresh); + if (!ok) console.warn(`[widgets] ${name} was not applied on the server`); + await settle(key, ok); + await redrawAll(); + // Catch up with the server: confirms a change that went through and picks + // up whatever else changed. Offline this fails quickly and keeps the data. + await refreshSnapshot({ after: 'change' }); + await redrawAll(); + return ok; } type Data = Record; const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined); +/** The triage card's message after `id`, among what the widget shows now. */ +async function moveTriagePointer(widgetId: number, id: string): Promise { + const view = await currentView(); + const unread = view.mail.inbox.filter((m) => m.unread); + const index = unread.findIndex((m) => m.id === id); + const next = index >= 0 ? unread[index + 1] ?? unread[index - 1] : undefined; + await serial(async () => saveLocal(widgetId, { ...(await loadLocal(widgetId)), triageId: next?.id ?? null })); +} + export async function handleWidgetAction(name: string, data: Data, widgetId: number): Promise { switch (name) { case 'refresh': - await afterServerCall(true); + await refreshSnapshot({ after: 'change' }); + await redrawAll(); return; case 'triageNext': { - const s = (await loadSnapshot()) ?? emptySnapshot(); - const unread = s.mail.inbox.filter((m) => m.unread); + const view = await currentView(); + const unread = view.mail.inbox.filter((m) => m.unread); if (unread.length === 0) return; const index = unread.findIndex((m) => m.id === data.id); const next = unread[(index + 1) % unread.length]; - const local = await loadLocal(widgetId); - await saveLocal(widgetId, { ...local, triageId: next.id }); - await updateAllWidgets(s); + await serial(async () => saveLocal(widgetId, { ...(await loadLocal(widgetId)), triageId: next.id })); + await redrawAll(); return; } @@ -72,39 +76,18 @@ export async function handleWidgetAction(name: string, data: Data, widgetId: num case 'markRead': { const id = str(data.id); if (!id) return; - if (name === 'markRead') { - await applyLocally((s) => { - const hit = s.mail.inbox.find((m) => m.id === id); - const inbox = s.mail.folders.find((f) => f.role === 'inbox'); - if (hit?.unread && inbox) inbox.unread = Math.max(0, inbox.unread - 1); - for (const list of [s.mail.inbox, s.mail.unified, s.mail.starred]) { - for (const m of list) if (m.id === id) m.unread = false; - } - }); - } else { - // The triage card moves on to the next unread message, not back to - // the first one. - const before = (await loadSnapshot()) ?? emptySnapshot(); - const unread = before.mail.inbox.filter((m) => m.unread); - const index = unread.findIndex((m) => m.id === id); - const next = index >= 0 ? unread[index + 1] ?? unread[index - 1] : undefined; - if (typeof data.widgetId === 'number') { - await saveLocal(data.widgetId, { ...(await loadLocal(data.widgetId)), triageId: next?.id ?? null }); - } - await applyLocally((s) => withoutMessage(s, id)); - } - let ok = false; - try { - const client = await clientFor(str(data.accountId)); - if (client) { - ok = name === 'markRead' - ? await markRead(client, id, str(data.jmapAccountId)) - : await moveTo(client, id, name, str(data.jmapAccountId)); - } - } catch (err) { - console.warn(`[widgets] ${name} failed`, err); - } - await afterServerCall(ok); + // The triage card moves on to the next unread message, not back to + // the first one. + if (name !== 'markRead') await moveTriagePointer(widgetId, id); + const accountId = str(data.accountId); + const jmapAccountId = str(data.jmapAccountId); + await apply(name, name === 'markRead' ? { kind: 'markRead', id } : { kind: 'removeMail', id }, async () => { + const client = await clientFor(accountId); + if (!client) return false; + return name === 'markRead' + ? markRead(client, id, jmapAccountId) + : moveTo(client, id, name, jmapAccountId); + }); return; } @@ -112,48 +95,35 @@ export async function handleWidgetAction(name: string, data: Data, widgetId: num const id = str(data.id); const status = data.status; if (!id || (status !== 'accepted' && status !== 'tentative' && status !== 'declined')) return; - const before = (await loadSnapshot()) ?? emptySnapshot(); + const before = await currentView(); const invitation = before.calendar.invitations.find((i) => i.id === id); if (!invitation) return; - await applyLocally((s) => { - s.calendar.invitations = s.calendar.invitations.filter((i) => i.id !== id); - for (const e of s.calendar.events) if (e.serverId === invitation.serverId) e.myStatus = status; - }); - let ok = false; - try { + await apply('rsvp', { kind: 'rsvp', id, serverId: invitation.serverId, status }, async () => { const client = await clientFor(before.activeAccountId); - if (client) ok = await rsvp(client, invitation.serverId, invitation.participantId, status, invitation.jmapAccountId); - } catch (err) { - console.warn('[widgets] rsvp failed', err); - } - await afterServerCall(ok); + return !!client && rsvp(client, invitation.serverId, invitation.participantId, status, invitation.jmapAccountId); + }); return; } case 'toggleTask': { const id = str(data.id); if (!id) return; - const before = (await loadSnapshot()) ?? emptySnapshot(); + const before = await currentView(); const task = before.tasks.items.find((t) => t.id === id); if (!task) return; const done = !task.done; - await applyLocally((s) => { - for (const t of s.tasks.items) if (t.id === id) t.done = done; - }); - let ok = false; - try { + await apply('task toggle', { kind: 'task', id, done }, async () => { const client = await clientFor(before.activeAccountId); - if (client) ok = await setTaskDone(client, task.serverId, done, task.jmapAccountId); + if (!client || !(await setTaskDone(client, task.serverId, done, task.jmapAccountId))) return false; // In the live app the snapshot's tasks come from the calendar store, - // which did not see this change; reload it so the refresh keeps it. - if (ok && before.activeAccountId && singletonServes(before.activeAccountId)) { + // which did not see this change; reload it before the refresh that + // follows reads it, or that refresh would undo the tick. + if (client === jmapClient) { const { useCalendarStore } = require('../stores/calendar-store') as typeof import('../stores/calendar-store'); - await useCalendarStore.getState().fetchTasks(); + await useCalendarStore.getState().fetchTasks().catch(() => undefined); } - } catch (err) { - console.warn('[widgets] task toggle failed', err); - } - await afterServerCall(ok); + return true; + }); return; } diff --git a/src/widgets/build.ts b/src/widgets/build.ts index 29aad5ed..6c01e013 100644 --- a/src/widgets/build.ts +++ b/src/widgets/build.ts @@ -22,10 +22,10 @@ import { addDays, startOfDay } from './format'; import { startOfWeek } from './derive'; import { fetchInboxPreview, fetchMailSection, openClient, type MailSection } from './jmap'; import { normalizeHex } from './theme'; +import { storeRefresh } from './state'; import { emptySnapshot, loadSnapshot, - saveSnapshot, type AccountSummary, type Birthday, type EventItem, @@ -439,21 +439,12 @@ async function buildSnapshot(previous: WidgetSnapshot): Promise } let inflight: Promise | null = null; -let localChanges = 0; - -/** - * A widget button just edited the stored snapshot. A refresh that started - * before it read the server before the change, so its result is dropped - * rather than saved over the edit. - */ -export function noteLocalChange(): void { - localChanges++; -} /** * Fetch fresh data and store it. Concurrent callers share one run: every * placed widget gets its own update broadcast, and they would otherwise each * start a refresh. On failure the previous snapshot is returned unchanged. + * Changes widget buttons made meanwhile are kept over the result (./state.ts). * * `after` is for callers that just changed something on the server (a widget * button): a run already in flight may have read the server before that @@ -466,12 +457,11 @@ export async function refreshSnapshot(opts?: { after?: 'change' }): Promise { - const changesAtStart = localChanges; + const startedAt = Date.now(); const previous = (await loadSnapshot()) ?? emptySnapshot(); try { const next = await buildSnapshot(previous); - if (localChanges !== changesAtStart) return (await loadSnapshot()) ?? next; - await saveSnapshot(next); + await storeRefresh(next, startedAt); return next; } catch (err) { console.warn('[widgets] refresh failed', err); diff --git a/src/widgets/pending.ts b/src/widgets/pending.ts new file mode 100644 index 00000000..39ad2786 --- /dev/null +++ b/src/widgets/pending.ts @@ -0,0 +1,132 @@ +// Changes a widget button made that the stored snapshot does not show yet. +// The snapshot holds what the server last said; these are laid over it when +// a widget draws, so a refresh that read the server before the change landed +// cannot bring an archived message back, and a change the server refused is +// undone by dropping it rather than by hoping the next refresh gets through. +// +// An op is pending until the server answers, then done: it stays until a +// refresh that started after the server took it has replaced the data it +// covers (mail, or the calendar/tasks part that only some refreshes load). + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { WidgetSnapshot } from './snapshot'; + +export const PENDING_KEY = 'widgets:pending:v1'; + +export type PendingChange = + | { kind: 'removeMail'; id: string } + | { kind: 'markRead'; id: string } + | { kind: 'rsvp'; id: string; serverId: string; status: 'accepted' | 'tentative' | 'declined' } + | { kind: 'task'; id: string; done: boolean }; + +export interface PendingOp { + /** Unique per tap. */ + key: string; + change: PendingChange; + /** When the tap was made. */ + at: number; + /** When the server took the change; absent while it is in flight. */ + doneAt?: number; +} + +export interface PendingState { + ops: PendingOp[]; +} + +/** A done op the next refreshes never confirmed is dropped after this long. */ +export const DONE_TTL_MS = 30 * 60 * 1000; +/** An op still in flight after this long belongs to a task that died. */ +export const IN_FLIGHT_TTL_MS = 2 * 60 * 1000; + +export function emptyPending(): PendingState { + return { ops: [] }; +} + +export async function loadPending(): Promise { + try { + const raw = await AsyncStorage.getItem(PENDING_KEY); + const parsed = raw ? (JSON.parse(raw) as Partial) : null; + return { ops: Array.isArray(parsed?.ops) ? parsed!.ops : [] }; + } catch { + return emptyPending(); + } +} + +export async function savePending(state: PendingState): Promise { + await AsyncStorage.setItem(PENDING_KEY, JSON.stringify(state)); +} + +const isMail = (c: PendingChange) => c.kind === 'removeMail' || c.kind === 'markRead'; + +/** Drop what has expired: in-flight ops of a task that died, done ops no refresh confirmed. */ +export function expire(state: PendingState, now: number): PendingState { + return { + ...state, + ops: state.ops.filter((o) => (o.doneAt ? now - o.doneAt < DONE_TTL_MS : now - o.at < IN_FLIGHT_TTL_MS)), + }; +} + +/** + * After a refresh that started at `startedAt` was stored: the done ops it + * covers are now part of the snapshot. Mail is always refreshed; calendar and + * tasks only when `appData` is set. + */ +export function confirmedBy(ops: PendingOp[], startedAt: number, appData: boolean): PendingOp[] { + return ops.filter((o) => !(o.doneAt !== undefined && o.doneAt < startedAt && (appData || isMail(o.change)))); +} + +/** The snapshot as the widgets should show it: the stored one with every op applied. */ +export function applyPending(s: WidgetSnapshot, ops: PendingOp[]): WidgetSnapshot { + if (ops.length === 0) return s; + const next: WidgetSnapshot = { + ...s, + mail: { + ...s.mail, + folders: s.mail.folders.map((f) => ({ ...f })), + inbox: [...s.mail.inbox], + unified: [...s.mail.unified], + starred: [...s.mail.starred], + }, + calendar: { ...s.calendar, events: [...s.calendar.events], invitations: [...s.calendar.invitations] }, + tasks: { ...s.tasks, items: [...s.tasks.items] }, + }; + for (const { change } of ops) { + switch (change.kind) { + case 'removeMail': { + const removed = next.mail.inbox.find((m) => m.id === change.id); + const drop = (list: T[]) => list.filter((m) => m.id !== change.id); + next.mail.inbox = drop(next.mail.inbox); + next.mail.unified = drop(next.mail.unified); + next.mail.starred = drop(next.mail.starred); + const inbox = next.mail.folders.find((f) => f.role === 'inbox'); + if (inbox && removed) { + inbox.total = Math.max(0, inbox.total - 1); + if (removed.unread) inbox.unread = Math.max(0, inbox.unread - 1); + } + break; + } + case 'markRead': { + const hit = next.mail.inbox.find((m) => m.id === change.id); + const inbox = next.mail.folders.find((f) => f.role === 'inbox'); + if (hit?.unread && inbox) inbox.unread = Math.max(0, inbox.unread - 1); + const read = (list: T[]) => + list.map((m) => (m.id === change.id && m.unread ? { ...m, unread: false } : m)); + next.mail.inbox = read(next.mail.inbox); + next.mail.unified = read(next.mail.unified); + next.mail.starred = read(next.mail.starred); + break; + } + case 'rsvp': + next.calendar.invitations = next.calendar.invitations.filter( + (i) => i.id !== change.id && i.serverId !== change.serverId, + ); + next.calendar.events = next.calendar.events.map((e) => + (e.serverId === change.serverId && e.myStatus ? { ...e, myStatus: change.status } : e)); + break; + case 'task': + next.tasks.items = next.tasks.items.map((t) => (t.id === change.id ? { ...t, done: change.done } : t)); + break; + } + } + return next; +} diff --git a/src/widgets/render.tsx b/src/widgets/render.tsx index 4846ce16..308f82ff 100644 --- a/src/widgets/render.tsx +++ b/src/widgets/render.tsx @@ -12,6 +12,8 @@ import catalog from './catalog.json'; import { makeFmt } from './format'; import { loadLocal } from './local-state'; import { Placeholder, Surface } from './primitives'; +import { serial } from './serial'; +import { readView } from './state'; import type { WidgetSnapshot } from './snapshot'; import { DARK, LIGHT, type WidgetPalette } from './theme'; import type { Layout, WidgetLocalState } from './layouts/types'; @@ -142,8 +144,7 @@ export function renderFor( } /** Redraw every placed widget of every kind from `snapshot`. */ -export async function updateAllWidgets(snapshot: WidgetSnapshot): Promise { - if (Platform.OS !== 'android') return; +async function updateAllWidgets(snapshot: WidgetSnapshot): Promise { await Promise.all(WIDGET_NAMES.map((widgetName) => requestWidgetUpdate({ widgetName, @@ -152,6 +153,29 @@ export async function updateAllWidgets(snapshot: WidgetSnapshot): Promise )); } +let queued: Promise | null = null; + +/** + * Redraw every placed widget from the current data. Draws run one at a time + * in the widgets' lane and each reads the data when it starts, so the last + * one drawn is the newest; calls made while one is still waiting share it. + */ +export function redrawAll(): Promise { + if (Platform.OS !== 'android') return Promise.resolve(); + if (queued) return queued; + const run = serial(async () => { + queued = null; + await updateAllWidgets(await readView()); + }); + queued = run; + return run; +} + +/** One widget's drawing from the current data, for the launcher's own update events. */ +export function drawOne(name: string, info: Pick): Promise { + return serial(async () => renderFor(name, await readView(), info, await loadLocal(info.widgetId))); +} + let placedCache: { at: number; value: boolean } | null = null; /** Whether any Bulwark widget is on a home screen; cached briefly. */ diff --git a/src/widgets/serial.ts b/src/widgets/serial.ts new file mode 100644 index 00000000..6420d9f1 --- /dev/null +++ b/src/widgets/serial.ts @@ -0,0 +1,16 @@ +// One lane for everything that reads and writes the stored widget data or +// redraws the widgets. Each widget event (every tap, every periodic update) +// runs as its own headless task in the same JS runtime, and they interleave +// at every await; without this, two taps load the same snapshot and the +// second save drops the first change, and a redraw of an older snapshot can +// land after a newer one, leaving buttons that act on a message the widget +// no longer shows. + +let tail: Promise = Promise.resolve(); + +/** Run `fn` after everything queued before it has finished. */ +export function serial(fn: () => Promise): Promise { + const run = tail.then(fn, fn); + tail = run.catch(() => undefined); + return run; +} diff --git a/src/widgets/state.ts b/src/widgets/state.ts new file mode 100644 index 00000000..2a43bf1c --- /dev/null +++ b/src/widgets/state.ts @@ -0,0 +1,74 @@ +// The widgets' stored data and the edits widget buttons lay over it, read and +// written in one lane (./serial.ts) so headless tasks running side by side +// neither lose each other's changes nor draw an older state over a newer one. + +import { serial } from './serial'; +import { + applyPending, + confirmedBy, + expire, + loadPending, + savePending, + type PendingChange, +} from './pending'; +import { emptySnapshot, loadSnapshot, saveSnapshot, type WidgetSnapshot } from './snapshot'; + +/** The view without queueing; only for code already running in the lane. */ +export async function readView(now = Date.now()): Promise { + const [stored, pending] = await Promise.all([loadSnapshot(), loadPending()]); + return applyPending(stored ?? emptySnapshot(), expire(pending, now).ops); +} + +/** What the widgets draw: the stored snapshot with the buttons' changes laid over it. */ +export function currentView(now = Date.now()): Promise { + return serial(() => readView(now)); +} + +/** + * Store a refresh that started reading the server at `startedAt`. Changes the + * server took before that are now in the data and stop being laid over it; + * later ones and those still in flight stay. + */ +export function storeRefresh(next: WidgetSnapshot, startedAt: number): Promise { + return serial(async () => { + await saveSnapshot(next); + const pending = expire(await loadPending(), Date.now()); + const appData = next.appDataAt >= startedAt; + await savePending({ ...pending, ops: confirmedBy(pending.ops, startedAt, appData) }); + }); +} + +/** Replace the data outright (signed out): no change of the old data may linger. */ +export function replaceAll(next: WidgetSnapshot): Promise { + return serial(async () => { + await saveSnapshot(next); + await savePending({ ops: [] }); + }); +} + +let counter = 0; + +/** Lay a button's change over the data until the server has answered. Returns its key. */ +export function track(change: PendingChange, now = Date.now()): Promise { + const key = `${now}-${++counter}`; + return serial(async () => { + const pending = expire(await loadPending(), now); + await savePending({ ...pending, ops: [...pending.ops, { key, change, at: now }] }); + return key; + }); +} + +/** + * The server answered. A change it took stays laid over the data until a + * refresh has caught up with it; one it refused (or that never reached it) is + * dropped, which shows the data as the server last described it. + */ +export function settle(key: string, ok: boolean, now = Date.now()): Promise { + return serial(async () => { + const pending = expire(await loadPending(), now); + const ops = ok + ? pending.ops.map((o) => (o.key === key ? { ...o, doneAt: now } : o)) + : pending.ops.filter((o) => o.key !== key); + await savePending({ ...pending, ops }); + }); +} diff --git a/src/widgets/sync.ts b/src/widgets/sync.ts index 6fe9f881..d26b648e 100644 --- a/src/widgets/sync.ts +++ b/src/widgets/sync.ts @@ -6,8 +6,9 @@ import { AppState, Platform } from 'react-native'; import { readRegistry, refreshSnapshot } from './build'; -import { hasPlacedWidgets, updateAllWidgets } from './render'; -import { emptySnapshot, saveSnapshot } from './snapshot'; +import { hasPlacedWidgets, redrawAll } from './render'; +import { emptySnapshot } from './snapshot'; +import { replaceAll } from './state'; /** Refreshes are spaced at least this far apart unless the app is leaving. */ const MIN_INTERVAL_MS = 45_000; @@ -20,7 +21,8 @@ async function run(): Promise { lastRun = Date.now(); try { if (!(await hasPlacedWidgets())) return; - await updateAllWidgets(await refreshSnapshot()); + await refreshSnapshot(); + await redrawAll(); } catch (err) { console.warn('[widgets] sync failed', err); } @@ -75,9 +77,8 @@ export async function signOutWidgets(): Promise { if (Platform.OS !== 'android') return; // Signed out of every account, not merely offline or mid-switch. if ((await readRegistry()).accounts.length > 0) return; - const snapshot = { ...emptySnapshot(), generatedAt: Date.now() }; - await saveSnapshot(snapshot); - await updateAllWidgets(snapshot); + await replaceAll({ ...emptySnapshot(), generatedAt: Date.now() }); + await redrawAll(); } /** @@ -88,7 +89,8 @@ export async function refreshWidgetsInBackground(): Promise { if (Platform.OS !== 'android') return; try { if (!(await hasPlacedWidgets(0))) return; - await updateAllWidgets(await refreshSnapshot()); + await refreshSnapshot(); + await redrawAll(); } catch (err) { console.warn('[widgets] background refresh failed', err); } diff --git a/src/widgets/task-handler.tsx b/src/widgets/task-handler.tsx index 872cadd5..b26369be 100644 --- a/src/widgets/task-handler.tsx +++ b/src/widgets/task-handler.tsx @@ -6,23 +6,13 @@ import type { WidgetTaskHandlerProps } from 'react-native-android-widget'; import { handleWidgetAction } from './actions'; import { refreshSnapshot } from './build'; -import { loadLocal, removeLocal } from './local-state'; -import { invalidatePlacedCache, renderFor, updateAllWidgets } from './render'; -import { emptySnapshot, loadSnapshot, type WidgetSnapshot } from './snapshot'; +import { removeLocal } from './local-state'; +import { drawOne, invalidatePlacedCache, redrawAll } from './render'; +import { loadSnapshot } from './snapshot'; /** A snapshot younger than this is drawn as it is; older ones trigger a fetch. */ const FRESH_MS = 5 * 60 * 1000; -let lastBroadcast = 0; - -async function broadcast(snapshot: WidgetSnapshot): Promise { - // Every placed widget gets its own periodic update; the first one to finish - // a refresh redraws them all, the others would only repeat it. - if (snapshot.generatedAt <= lastBroadcast) return; - lastBroadcast = snapshot.generatedAt; - await updateAllWidgets(snapshot); -} - export async function widgetTaskHandler(props: WidgetTaskHandlerProps): Promise { const { widgetInfo, widgetAction, clickAction, clickActionData, renderWidget } = props; const name = widgetInfo.widgetName; @@ -32,13 +22,14 @@ export async function widgetTaskHandler(props: WidgetTaskHandlerProps): Promise< case 'WIDGET_UPDATE': case 'WIDGET_RESIZED': { if (widgetAction === 'WIDGET_ADDED') invalidatePlacedCache(); - const snapshot = (await loadSnapshot()) ?? emptySnapshot(); - renderWidget(renderFor(name, snapshot, widgetInfo, await loadLocal(widgetInfo.widgetId))); + renderWidget(await drawOne(name, widgetInfo)); if (widgetAction === 'WIDGET_RESIZED') return; - if (Date.now() - snapshot.generatedAt < FRESH_MS) return; - const fresh = await refreshSnapshot(); - renderWidget(renderFor(name, fresh, widgetInfo, await loadLocal(widgetInfo.widgetId))); - await broadcast(fresh); + const stored = await loadSnapshot(); + if (stored && Date.now() - stored.generatedAt < FRESH_MS) return; + // Every placed widget gets its own periodic update; they share one + // refresh (see refreshSnapshot) and the redraws that follow collapse. + await refreshSnapshot(); + await redrawAll(); return; }