fix: keep a widget archive when a second tap's refresh lands before it #52

Every widget tap runs as its own headless task in the same JS runtime,
and the tasks interleave at every await. Each one loaded the stored
snapshot, edited it and saved it back, then refreshed from the server.
That went wrong in three ways:

- two taps loaded the same snapshot and the second save dropped the
  first change;
- the refresh of the first tap read the server before the second tap's
  archive landed and was stored anyway, so the message came back (seen
  on the emulator with a slow `Email/set`);
- a redraw of an older snapshot could land after a newer one, leaving
  buttons that act on a message the widget no longer shows.

The snapshot now only holds what the server last said. A button's
change is kept as a pending op next to it and laid over it when a
widget draws; it stays until a refresh that started after the server
took it has been stored (calendar and task changes only count as caught
up by a refresh that loaded calendar and tasks). A call that fails
drops its op, which also undoes an archive made offline: before, the
failed refresh kept the edited snapshot and the message stayed hidden
although it was never archived. Reads, writes and redraws of the
widget data run one at a time, and every redraw draws the latest view.
This commit is contained in:
Linus Rath
2026-10-02 11:32:38 +02:00
parent bc74d3bbac
commit 26d0cf3ccc
10 changed files with 555 additions and 139 deletions
+129
View File
@@ -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<boolean>((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;
});
});
+88
View File
@@ -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> = {}): 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] });
});
});
+67 -97
View File
@@ -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<JMAPClient | null> {
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<WidgetSnapshot> {
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<boolean>): Promise<boolean> {
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<void> {
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<string, unknown>;
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<void> {
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<void> {
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;
}
+4 -14
View File
@@ -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<WidgetSnapshot>
}
let inflight: Promise<WidgetSnapshot> | 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<Widg
}
if (inflight) return inflight;
inflight = (async () => {
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);
+132
View File
@@ -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<PendingState> {
try {
const raw = await AsyncStorage.getItem(PENDING_KEY);
const parsed = raw ? (JSON.parse(raw) as Partial<PendingState>) : null;
return { ops: Array.isArray(parsed?.ops) ? parsed!.ops : [] };
} catch {
return emptyPending();
}
}
export async function savePending(state: PendingState): Promise<void> {
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 = <T extends { id: string }>(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 = <T extends { id: string; unread: boolean }>(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;
}
+26 -2
View File
@@ -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<void> {
if (Platform.OS !== 'android') return;
async function updateAllWidgets(snapshot: WidgetSnapshot): Promise<void> {
await Promise.all(WIDGET_NAMES.map((widgetName) =>
requestWidgetUpdate({
widgetName,
@@ -152,6 +153,29 @@ export async function updateAllWidgets(snapshot: WidgetSnapshot): Promise<void>
));
}
let queued: Promise<void> | 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<void> {
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<WidgetInfo, 'width' | 'height' | 'widgetId'>): Promise<WidgetRepresentation> {
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. */
+16
View File
@@ -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<unknown> = Promise.resolve();
/** Run `fn` after everything queued before it has finished. */
export function serial<T>(fn: () => Promise<T>): Promise<T> {
const run = tail.then(fn, fn);
tail = run.catch(() => undefined);
return run;
}
+74
View File
@@ -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<WidgetSnapshot> {
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<WidgetSnapshot> {
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<void> {
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<void> {
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<string> {
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<void> {
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 });
});
}
+9 -7
View File
@@ -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<void> {
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<void> {
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<void> {
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);
}
+10 -19
View File
@@ -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<void> {
// 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<void> {
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;
}