mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
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.
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
+35
-12
@@ -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<boolean>): Promise<boolean> {
|
||||
type Data = Record<string, unknown>;
|
||||
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<boolean>,
|
||||
onFailed?: () => Promise<void>,
|
||||
): Promise<boolean> {
|
||||
const key = await track(change);
|
||||
await redrawAll();
|
||||
let ok = false;
|
||||
@@ -32,7 +48,8 @@ async function apply(name: string, change: PendingChange, call: () => Promise<bo
|
||||
console.warn(`[widgets] ${name} failed`, err);
|
||||
}
|
||||
if (!ok) console.warn(`[widgets] ${name} was not applied on the server`);
|
||||
await settle(key, ok);
|
||||
await settle(key, ok, { action: name, label, at: Date.now(), retry: data });
|
||||
if (!ok) await onFailed?.();
|
||||
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.
|
||||
@@ -41,16 +58,17 @@ async function apply(name: string, change: PendingChange, call: () => Promise<bo
|
||||
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 }));
|
||||
await pointTriage(widgetId, next?.id ?? null);
|
||||
}
|
||||
|
||||
function pointTriage(widgetId: number, triageId: string | null): Promise<void> {
|
||||
return serial(async () => saveLocal(widgetId, { ...(await loadLocal(widgetId)), triageId }));
|
||||
}
|
||||
|
||||
export async function handleWidgetAction(name: string, data: Data, widgetId: number): Promise<void> {
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }) => {
|
||||
</FlexWidget>
|
||||
<Divider p={p} />
|
||||
</FlexWidget>
|
||||
{notice ? (
|
||||
<FlexWidget style={{ width: 'match_parent', paddingHorizontal: 8, paddingTop: 6, paddingBottom: 6 }}>
|
||||
<NoticeBar p={p} f={f} notice={notice} />
|
||||
</FlexWidget>
|
||||
) : null}
|
||||
{empty ? (
|
||||
<Placeholder p={p} iconName="check" title={f.t('widgets.hub.all_clear', "You're all caught up")} click={open(links.calendar())} />
|
||||
) : null}
|
||||
|
||||
@@ -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 (
|
||||
<Surface p={p} style={{ padding: 12 }}>
|
||||
<FlexWidget {...open(links.message(current))} style={{ width: 'match_parent', flex: 1 }}>
|
||||
@@ -109,8 +111,10 @@ export const TriageLayout: Layout = ({ s, p, f, now, widgetId, local }) => {
|
||||
</FlexWidget>
|
||||
<Spacer size={6} />
|
||||
<Txt text={current.subject || f.t('widgets.mail.no_subject', '(no subject)')} color={p.fg} size={14} weight="600" />
|
||||
{current.preview ? <Txt text={current.preview} color={p.muted} size={13} lines={2} /> : null}
|
||||
{/* The notice takes the preview's place, so the card fits at its smallest. */}
|
||||
{current.preview && !notice ? <Txt text={current.preview} color={p.muted} size={13} lines={2} /> : null}
|
||||
</FlexWidget>
|
||||
{notice ? <NoticeBar p={p} f={f} notice={notice} /> : null}
|
||||
<Spacer size={8} />
|
||||
<FlexWidget style={{ flexDirection: 'row', width: 'match_parent' }}>
|
||||
<Button p={p} label={f.t('widgets.mail.archive', 'Archive')} iconName="archive" flex={1} click={action('archive', target)} />
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
} from '../derive';
|
||||
import { addDays, sameDay, startOfDay, type Fmt } from '../format';
|
||||
import { ring, type IconName } from '../icons';
|
||||
import { TASK_ROW_HEIGHT, TaskRow } from '../parts';
|
||||
import { NOTICE_HEIGHT, NoticeBar, TASK_ROW_HEIGHT, TaskRow } from '../parts';
|
||||
import { noticeFor } from '../pending';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
@@ -107,6 +108,7 @@ export const InvitationsLayout: Layout = ({ s, p, f, height }) => {
|
||||
const extra = inv.participants.length - names.length;
|
||||
const people = `${names.join(', ')}${extra > 0 ? ` +${extra}` : ''}`;
|
||||
const reply = (status: 'accepted' | 'tentative' | 'declined') => action('rsvp', { id: inv.id, status });
|
||||
const notice = noticeFor(s.notice, 'rsvp');
|
||||
|
||||
return (
|
||||
<Surface p={p} style={{ padding: pad }}>
|
||||
@@ -140,11 +142,13 @@ export const InvitationsLayout: Layout = ({ s, p, f, height }) => {
|
||||
</FlexWidget>
|
||||
<Txt text={untitled(f, inv.title)} color={p.fg} size={15} weight="600" />
|
||||
<MetaLine p={p} iconName="clock" text={eventWhen(f, inv)} />
|
||||
{showPeople ? <MetaLine p={p} iconName="users" text={people} /> : null}
|
||||
{showPeople && !notice ? <MetaLine p={p} iconName="users" text={people} /> : null}
|
||||
</FlexWidget>
|
||||
</FlexWidget>
|
||||
<Spacer size={8} />
|
||||
<Spacer />
|
||||
{notice ? <NoticeBar p={p} f={f} notice={notice} /> : null}
|
||||
{notice ? <Spacer size={6} /> : null}
|
||||
<FlexWidget style={{ flexDirection: 'row', width: 'match_parent' }}>
|
||||
<Button
|
||||
p={p}
|
||||
@@ -464,7 +468,9 @@ export const TasksLayout: Layout = ({ s, p, f, now, height }) => {
|
||||
// Open tasks first; sortTasks puts the completed ones last, so they only fill leftover rows.
|
||||
const sorted = sortTasks(s.tasks.items, now);
|
||||
const openCount = sorted.filter((t) => !t.done).length;
|
||||
const fit = Math.max(1, Math.floor((height - 2 - headerHeight - 1) / TASK_ROW_HEIGHT));
|
||||
const notice = noticeFor(s.notice, 'toggleTask');
|
||||
const noticeSpace = notice ? NOTICE_HEIGHT + 12 : 0;
|
||||
const fit = Math.max(1, Math.floor((height - 2 - headerHeight - 1 - noticeSpace) / TASK_ROW_HEIGHT));
|
||||
const rows = sorted.slice(0, fit);
|
||||
return (
|
||||
<Surface p={p}>
|
||||
@@ -477,6 +483,11 @@ export const TasksLayout: Layout = ({ s, p, f, now, height }) => {
|
||||
click={open(links.calendar())}
|
||||
trailing={<Fab p={p} iconName="plus" size={compact ? 28 : 32} click={open(links.calendar())} />}
|
||||
/>
|
||||
{notice ? (
|
||||
<FlexWidget style={{ width: 'match_parent', paddingHorizontal: 8, paddingTop: 6, paddingBottom: 6 }}>
|
||||
<NoticeBar p={p} f={f} notice={notice} />
|
||||
</FlexWidget>
|
||||
) : null}
|
||||
{openCount === 0 ? (
|
||||
<Placeholder p={p} iconName="check" title={f.t('widgets.tasks.empty', 'No open tasks')} click={open(links.calendar())} />
|
||||
) : (
|
||||
|
||||
+42
-1
@@ -7,7 +7,7 @@ import { action, links, open, type WidgetClick } from './clicks';
|
||||
import { dueKind } from './derive';
|
||||
import type { Fmt } from './format';
|
||||
import { Avatar, ColorBar, Dot, FilledIcon, Icon, Spacer, Txt } from './primitives';
|
||||
import type { EventItem, MailItem, TaskItem } from './snapshot';
|
||||
import type { ActionNotice, EventItem, MailItem, TaskItem } from './snapshot';
|
||||
import type { WidgetPalette } from './theme';
|
||||
|
||||
/** `bg-primary` pill with the message icon and the thread size, as in the list. */
|
||||
@@ -280,3 +280,44 @@ export function TaskRow({
|
||||
}
|
||||
|
||||
export const TASK_ROW_HEIGHT = 56;
|
||||
|
||||
export const NOTICE_HEIGHT = 36;
|
||||
|
||||
/**
|
||||
* A button's change that did not reach the server: says so in the
|
||||
* destructive tint (webmail `bg-destructive/10 text-destructive`) and repeats
|
||||
* the tap when tapped.
|
||||
*/
|
||||
export function NoticeBar({ p, f, notice }: { p: WidgetPalette; f: Fmt; notice: ActionNotice }) {
|
||||
const text = notice.action === 'archive'
|
||||
? f.t('widgets.notice.archive', "Couldn't archive. Tap to try again.")
|
||||
: notice.action === 'trash'
|
||||
? f.t('widgets.notice.trash', "Couldn't delete. Tap to try again.")
|
||||
: notice.action === 'markRead'
|
||||
? f.t('widgets.notice.mark_read', "Couldn't mark as read. Tap to try again.")
|
||||
: notice.action === 'rsvp'
|
||||
? f.t('widgets.notice.rsvp', "Couldn't send your answer. Tap to try again.")
|
||||
: f.t('widgets.notice.task', "Couldn't update “{title}”. Tap to try again.", {
|
||||
title: notice.label || f.t('widgets.tasks.no_title', '(no title)'),
|
||||
});
|
||||
return (
|
||||
<FlexWidget
|
||||
{...action(notice.action, notice.retry)}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
width: 'match_parent',
|
||||
height: NOTICE_HEIGHT,
|
||||
paddingHorizontal: 10,
|
||||
borderRadius: 6,
|
||||
backgroundColor: p.destructiveBg,
|
||||
}}
|
||||
>
|
||||
<Icon name="refresh" color={p.destructive as string} size={14} />
|
||||
<Spacer size={8} horizontal />
|
||||
<FlexWidget style={{ flex: 1 }}>
|
||||
<Txt text={text} color={p.destructive} size={12} weight="500" />
|
||||
</FlexWidget>
|
||||
</FlexWidget>
|
||||
);
|
||||
}
|
||||
|
||||
+17
-4
@@ -9,7 +9,8 @@
|
||||
// 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';
|
||||
import type { WidgetActionName } from './clicks';
|
||||
import type { ActionNotice, WidgetSnapshot } from './snapshot';
|
||||
|
||||
export const PENDING_KEY = 'widgets:pending:v1';
|
||||
|
||||
@@ -31,12 +32,16 @@ export interface PendingOp {
|
||||
|
||||
export interface PendingState {
|
||||
ops: PendingOp[];
|
||||
/** The last button change that did not reach the server, until it is retried or goes stale. */
|
||||
notice?: ActionNotice | null;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
/** How long a failure notice stays on the widget. */
|
||||
export const NOTICE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
export function emptyPending(): PendingState {
|
||||
return { ops: [] };
|
||||
@@ -46,7 +51,10 @@ 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 : [] };
|
||||
return {
|
||||
ops: Array.isArray(parsed?.ops) ? parsed!.ops : [],
|
||||
notice: parsed?.notice && typeof parsed.notice === 'object' ? parsed.notice : null,
|
||||
};
|
||||
} catch {
|
||||
return emptyPending();
|
||||
}
|
||||
@@ -58,14 +66,19 @@ export async function savePending(state: PendingState): Promise<void> {
|
||||
|
||||
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. */
|
||||
/** Drop what has expired: in-flight ops of a task that died, done ops no refresh confirmed, an old notice. */
|
||||
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)),
|
||||
notice: state.notice && now - state.notice.at < NOTICE_TTL_MS ? state.notice : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Which widget buttons a notice belongs to. */
|
||||
export function noticeFor(notice: ActionNotice | null | undefined, ...actions: WidgetActionName[]): ActionNotice | null {
|
||||
return notice && actions.includes(notice.action) ? notice : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -160,6 +160,16 @@ export interface QuotaState {
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/** Told on the widget when a button's change did not reach the server. */
|
||||
export interface ActionNotice {
|
||||
action: 'archive' | 'trash' | 'markRead' | 'rsvp' | 'toggleTask';
|
||||
/** Subject, event or task title the change was about. */
|
||||
label: string;
|
||||
at: number;
|
||||
/** The tap that failed, repeated when the notice is tapped. */
|
||||
retry: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WidgetSnapshot {
|
||||
version: 1;
|
||||
generatedAt: number;
|
||||
@@ -207,6 +217,11 @@ export interface WidgetSnapshot {
|
||||
files: { supported: boolean; items: FileItem[] };
|
||||
vacation: VacationState | null;
|
||||
quota: QuotaState | null;
|
||||
/**
|
||||
* Only in what the widgets draw, never stored: the last button change the
|
||||
* server did not take (./state.ts).
|
||||
*/
|
||||
notice?: ActionNotice | null;
|
||||
}
|
||||
|
||||
export function emptySnapshot(): WidgetSnapshot {
|
||||
|
||||
+11
-8
@@ -11,12 +11,13 @@ import {
|
||||
savePending,
|
||||
type PendingChange,
|
||||
} from './pending';
|
||||
import { emptySnapshot, loadSnapshot, saveSnapshot, type WidgetSnapshot } from './snapshot';
|
||||
import { emptySnapshot, loadSnapshot, saveSnapshot, type ActionNotice, 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);
|
||||
const [stored, raw] = await Promise.all([loadSnapshot(), loadPending()]);
|
||||
const pending = expire(raw, now);
|
||||
return { ...applyPending(stored ?? emptySnapshot(), pending.ops), notice: pending.notice ?? null };
|
||||
}
|
||||
|
||||
/** What the widgets draw: the stored snapshot with the buttons' changes laid over it. */
|
||||
@@ -42,7 +43,7 @@ export function storeRefresh(next: WidgetSnapshot, startedAt: number): Promise<v
|
||||
export function replaceAll(next: WidgetSnapshot): Promise<void> {
|
||||
return serial(async () => {
|
||||
await saveSnapshot(next);
|
||||
await savePending({ ops: [] });
|
||||
await savePending({ ops: [], notice: null });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,7 +54,8 @@ 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 }] });
|
||||
// A new tap (a retry included) answers whatever the notice was about.
|
||||
await savePending({ ops: [...pending.ops, { key, change, at: now }], notice: null });
|
||||
return key;
|
||||
});
|
||||
}
|
||||
@@ -61,14 +63,15 @@ export function track(change: PendingChange, now = Date.now()): Promise<string>
|
||||
/**
|
||||
* 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.
|
||||
* dropped, which shows the data as the server last described it, and
|
||||
* `notice` tells the user.
|
||||
*/
|
||||
export function settle(key: string, ok: boolean, now = Date.now()): Promise<void> {
|
||||
export function settle(key: string, ok: boolean, notice?: ActionNotice, 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 });
|
||||
await savePending({ ops, notice: !ok && notice ? notice : pending.notice ?? null });
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user