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}