From 7fe4a1ba27a540a0ca69ba4f0ef7aa628c95d4c3 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:41:22 +0200 Subject: [PATCH] feat: show on the widget when an action could not reach the server #52 A widget button whose JMAP call failed (offline, refused, the account's credentials unavailable) only logged a console warning, so to the user the button had simply done nothing, or had worked. The change is now undone on the widget and a notice says what failed: "Couldn't archive. Tap to try again." on the triage card, which goes back to the message, and the same for RSVPs on the invitation card and task ticks on Tasks and Today overview. Tapping the notice repeats the tap; any new tap or ten minutes clear it. --- locales/rn/en.json | 7 ++++ src/widgets/__tests__/actions.test.ts | 19 +++++++++++ src/widgets/__tests__/layouts.test.ts | 15 +++++++++ src/widgets/__tests__/pending.test.ts | 8 +++-- src/widgets/actions.ts | 47 ++++++++++++++++++++------- src/widgets/layouts/hubs.tsx | 11 +++++-- src/widgets/layouts/mail.tsx | 8 +++-- src/widgets/layouts/plans.tsx | 17 ++++++++-- src/widgets/parts.tsx | 43 +++++++++++++++++++++++- src/widgets/pending.ts | 21 +++++++++--- src/widgets/snapshot.ts | 15 +++++++++ src/widgets/state.ts | 19 ++++++----- 12 files changed, 196 insertions(+), 34 deletions(-) diff --git a/locales/rn/en.json b/locales/rn/en.json index 6693ef7f..7f79fe4d 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -1292,6 +1292,13 @@ "starred": "Starred", "unread": "unread" }, + "notice": { + "archive": "Couldn't archive. Tap to try again.", + "mark_read": "Couldn't mark as read. Tap to try again.", + "rsvp": "Couldn't send your answer. Tap to try again.", + "task": "Couldn't update “{title}”. Tap to try again.", + "trash": "Couldn't delete. Tap to try again." + }, "people": { "empty": "No favourites yet", "empty_body": "The people you hear from most show up here.", diff --git a/src/widgets/__tests__/actions.test.ts b/src/widgets/__tests__/actions.test.ts index 8e24d632..57e964fe 100644 --- a/src/widgets/__tests__/actions.test.ts +++ b/src/widgets/__tests__/actions.test.ts @@ -107,6 +107,25 @@ describe('widget archive', () => { expect(await shown()).toEqual(['m1', 'm2', 'm3']); }); + it('says so on the widget, with the tap to repeat, and shows the message again', async () => { + const tap = handleWidgetAction('archive', { id: 'm2', accountId: 'acc' }, 7); + await until(() => calls.moves.length === 1); + calls.moves[0].resolve(false); + await tap; + const view = await currentView(); + expect(view.notice).toMatchObject({ action: 'archive', label: 'm2', retry: { id: 'm2', accountId: 'acc' } }); + expect(JSON.parse((await AsyncStorage.getItem('widgets:local:7'))!)).toEqual({ triageId: 'm2' }); + + // Tapping the notice repeats the archive and clears it. + const retry = handleWidgetAction(view.notice!.action, view.notice!.retry, 7); + await until(() => calls.moves.length === 2); + expect((await currentView()).notice).toBeNull(); + calls.moves[1].resolve(true); + await retry; + expect(await shown()).toEqual(['m1', 'm3']); + expect((await currentView()).notice).toBeNull(); + }); + it('does not lose either of two taps that land together', async () => { const taps = [ handleWidgetAction('archive', { id: 'm1', accountId: 'acc' }, 7), diff --git a/src/widgets/__tests__/layouts.test.ts b/src/widgets/__tests__/layouts.test.ts index c7b24767..09e593eb 100644 --- a/src/widgets/__tests__/layouts.test.ts +++ b/src/widgets/__tests__/layouts.test.ts @@ -118,6 +118,21 @@ describe('widget layouts', () => { } }); + it('builds every action widget with a failure notice at every size', () => { + const notices = [ + ['TriageWidget', 'archive'], + ['TriageWidget', 'trash'], + ['InvitationsWidget', 'rsvp'], + ['TasksWidget', 'toggleTask'], + ['TodayHubWidget', 'toggleTask'], + ] as const; + for (const [name, action] of notices) { + const w = catalog.widgets.find((x) => x.name === name)!; + const s = { ...sample, notice: { action, label: 'A task with a rather long title', at: PREVIEW_NOW, retry: { id: 'x' } } }; + for (const [cols, rows] of sizes(w)) build(name, s, cols, rows); + } + }); + it('builds the triage card on a message that is gone', () => { build('TriageWidget', sample, 4, 2, { triageId: 'no-such-message' }); build('TriageWidget', { ...sample, mail: { ...sample.mail, inbox: [] } }, 4, 2, { triageId: 'm1' }); diff --git a/src/widgets/__tests__/pending.test.ts b/src/widgets/__tests__/pending.test.ts index 43467aa1..5dfa46ae 100644 --- a/src/widgets/__tests__/pending.test.ts +++ b/src/widgets/__tests__/pending.test.ts @@ -5,6 +5,7 @@ import { DONE_TTL_MS, expire, IN_FLIGHT_TTL_MS, + NOTICE_TTL_MS, type PendingOp, } from '../pending'; import { PREVIEW_NOW, sampleSnapshot } from '../sample-snapshot'; @@ -77,12 +78,15 @@ describe('confirmedBy', () => { }); describe('expire', () => { - it('drops stuck and stale ops', () => { + it('drops stuck and stale ops and an old notice', () => { 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] }); + const notice = (at: number) => ({ action: 'archive' as const, label: 'x', at, retry: {} }); + expect(expire({ ops: [stuck, fresh, stale, recent], notice: notice(now - NOTICE_TTL_MS - 1) }, now)) + .toEqual({ ops: [fresh, recent], notice: null }); + expect(expire({ ops: [], notice: notice(now - 1000) }, now).notice).toEqual(notice(now - 1000)); }); }); diff --git a/src/widgets/actions.ts b/src/widgets/actions.ts index 1aa8fb20..260d92fe 100644 --- a/src/widgets/actions.ts +++ b/src/widgets/actions.ts @@ -3,7 +3,8 @@ // 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. +// is dropped again, which shows the data as the server last described it, +// and the widget says so with a notice that repeats the tap. import { jmapClient } from '../api/jmap-client'; import type { JMAPClient } from '../api/jmap-client'; @@ -11,6 +12,7 @@ import { refreshSnapshot, singletonServes } from './build'; import { markRead, moveTo, openClient, rsvp, setTaskDone } from './jmap'; import { loadLocal, saveLocal } from './local-state'; import type { PendingChange } from './pending'; +import type { ActionNotice } from './snapshot'; import { redrawAll } from './render'; import { serial } from './serial'; import { currentView, settle, track } from './state'; @@ -21,8 +23,22 @@ async function clientFor(registryAccountId: string | null | undefined): Promise< return openClient(registryAccountId); } -/** 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 { +type Data = Record; +const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined); + +/** + * Lay `change` over the data, run `call`, and keep or drop the change by its + * answer. On failure the widget shows a notice about `label` that repeats + * the tap (`name` with `data`). + */ +async function apply( + name: ActionNotice['action'], + data: Data, + label: string, + change: PendingChange, + call: () => Promise, + onFailed?: () => Promise, +): Promise { const key = await track(change); await redrawAll(); let ok = false; @@ -32,7 +48,8 @@ async function apply(name: string, change: PendingChange, call: () => Promise Promise; -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 })); + await pointTriage(widgetId, next?.id ?? null); +} + +function pointTriage(widgetId: number, triageId: string | null): Promise { + return serial(async () => saveLocal(widgetId, { ...(await loadLocal(widgetId)), triageId })); } export async function handleWidgetAction(name: string, data: Data, widgetId: number): Promise { @@ -66,7 +84,7 @@ export async function handleWidgetAction(name: string, data: Data, widgetId: num if (unread.length === 0) return; const index = unread.findIndex((m) => m.id === data.id); const next = unread[(index + 1) % unread.length]; - await serial(async () => saveLocal(widgetId, { ...(await loadLocal(widgetId)), triageId: next.id })); + await pointTriage(widgetId, next.id); await redrawAll(); return; } @@ -76,17 +94,22 @@ export async function handleWidgetAction(name: string, data: Data, widgetId: num case 'markRead': { const id = str(data.id); if (!id) return; + const subject = (await currentView()).mail.inbox.find((m) => m.id === id)?.subject ?? ''; // 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 change: PendingChange = name === 'markRead' ? { kind: 'markRead', id } : { kind: 'removeMail', id }; + await apply(name, data, subject, change, async () => { const client = await clientFor(accountId); if (!client) return false; return name === 'markRead' ? markRead(client, id, jmapAccountId) : moveTo(client, id, name, jmapAccountId); + }, async () => { + // The card goes back to the message, next to the notice about it. + if (name !== 'markRead') await pointTriage(widgetId, id); }); return; } @@ -98,7 +121,7 @@ export async function handleWidgetAction(name: string, data: Data, widgetId: num const before = await currentView(); const invitation = before.calendar.invitations.find((i) => i.id === id); if (!invitation) return; - await apply('rsvp', { kind: 'rsvp', id, serverId: invitation.serverId, status }, async () => { + await apply('rsvp', data, invitation.title, { kind: 'rsvp', id, serverId: invitation.serverId, status }, async () => { const client = await clientFor(before.activeAccountId); return !!client && rsvp(client, invitation.serverId, invitation.participantId, status, invitation.jmapAccountId); }); @@ -112,7 +135,7 @@ export async function handleWidgetAction(name: string, data: Data, widgetId: num const task = before.tasks.items.find((t) => t.id === id); if (!task) return; const done = !task.done; - await apply('task toggle', { kind: 'task', id, done }, async () => { + await apply('toggleTask', data, task.title, { kind: 'task', id, done }, async () => { const client = await clientFor(before.activeAccountId); 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, diff --git a/src/widgets/layouts/hubs.tsx b/src/widgets/layouts/hubs.tsx index bd018ada..5abeca89 100644 --- a/src/widgets/layouts/hubs.tsx +++ b/src/widgets/layouts/hubs.tsx @@ -8,7 +8,8 @@ import { action, links, open, type WidgetClick } from '../clicks'; import { dueKind, nextEvent, sortTasks, tasksDueNow, upcomingTimed } from '../derive'; import { addDays, sameDay, startOfDay, type Fmt } from '../format'; import { ring, type IconName } from '../icons'; -import { AGENDA_ROW_HEIGHT, AgendaRow, MAIL_ROW_HEIGHT, MailRow } from '../parts'; +import { AGENDA_ROW_HEIGHT, AgendaRow, MAIL_ROW_HEIGHT, MailRow, NOTICE_HEIGHT, NoticeBar } from '../parts'; +import { noticeFor } from '../pending'; import { Divider, EventChip, @@ -173,7 +174,8 @@ export const TodayHubLayout: Layout = ({ s, p, f, now, height }) => { // Each section first gets its label and one row, in order; what is left // then goes to a second message and more tasks in turn. - let budget = height - 2 - HUB_HEADER_HEIGHT; + const notice = noticeFor(s.notice, 'toggleTask'); + let budget = height - 2 - HUB_HEADER_HEIGHT - (notice ? NOTICE_HEIGHT + 12 : 0); const nextCost = SECTION_LABEL_HEIGHT + AGENDA_ROW_HEIGHT; const showNext = !!next && budget >= nextCost; if (showNext) budget -= nextCost; @@ -242,6 +244,11 @@ export const TodayHubLayout: Layout = ({ s, p, f, now, height }) => { + {notice ? ( + + + + ) : null} {empty ? ( ) : null} diff --git a/src/widgets/layouts/mail.tsx b/src/widgets/layouts/mail.tsx index 80d14d5d..410b5184 100644 --- a/src/widgets/layouts/mail.tsx +++ b/src/widgets/layouts/mail.tsx @@ -4,7 +4,8 @@ import React from 'react'; import { FlexWidget, TextWidget } from 'react-native-android-widget'; import { action, links, open } from '../clicks'; -import { MAIL_ROW_HEIGHT, MailRow } from '../parts'; +import { MAIL_ROW_HEIGHT, MailRow, NoticeBar } from '../parts'; +import { noticeFor } from '../pending'; import { Avatar, Button, @@ -91,6 +92,7 @@ export const TriageLayout: Layout = ({ s, p, f, now, widgetId, local }) => { } const position = candidates.indexOf(current) + 1; const target = { id: current.id, accountId: current.accountId, jmapAccountId: current.jmapAccountId, widgetId }; + const notice = noticeFor(s.notice, 'archive', 'trash'); return ( @@ -109,8 +111,10 @@ export const TriageLayout: Layout = ({ s, p, f, now, widgetId, local }) => { - {current.preview ? : null} + {/* The notice takes the preview's place, so the card fits at its smallest. */} + {current.preview && !notice ? : null} + {notice ? : null}