@supersuit/suggest-a-change 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +2 -0
- package/dist/client/detail.js +61 -4
- package/dist/client/entry.d.ts +4 -2
- package/dist/client/entry.js +2 -2
- package/dist/client/mine.js +71 -11
- package/dist/client/reports.d.ts +2 -0
- package/dist/client/view-cache.d.ts +26 -0
- package/dist/client/view-cache.js +72 -0
- package/dist/client/words.d.ts +2 -0
- package/dist/client/words.js +2 -0
- package/package.json +1 -1
- package/src/client/detail.tsx +33 -3
- package/src/client/entry.tsx +3 -3
- package/src/client/mine.tsx +57 -10
- package/src/client/reports.ts +2 -0
- package/src/client/view-cache.ts +54 -0
- package/src/client/words.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.3 (2026-10-13)
|
|
4
|
+
|
|
5
|
+
- **The phone's saved answers belong to whoever asked.** A list or report that the app answers after the signed-in account has changed is neither saved under the earlier account's key nor drawn: the account is asked again after the answer, as the outbox does before a write.
|
|
6
|
+
- **A refusal is not "could not reach the app".** `mine()` and `one(id)` signal a refusal by throwing an error with `refused: true` or a `status` of 401, 403 or 404 (`one` may also answer an object with a string `refused`). On a refusal the saved copy is deleted and the screen says the suggestion could not be opened (or the list could not be loaded); any other failure keeps the saved copy under the "Could not reach the app" line, as in 0.1.2. Documented on `StoreAdapter`.
|
|
7
|
+
- **The detail view follows its `id`.** Changing the id prop resets what is shown, so one report is never drawn under another's address.
|
|
8
|
+
- **The saved views are kept tidy.** After a successful list, saved reports the app no longer lists are deleted, and signing out clears the account's saved views from the phone.
|
|
9
|
+
- "Updating" shows only until the first answer after the screen opens, not on every periodic reload.
|
|
10
|
+
|
|
11
|
+
## 0.1.2 (2026-10-13)
|
|
12
|
+
|
|
13
|
+
- **My suggestions and the detail view draw at once.** Each keeps its last successful answer (`mine()` and each `one(id)`) in the module's own IndexedDB store, keyed by app and account like a draft, and draws it as soon as the phone has read it, with a quiet "Updating" line until the app answers. The app's answer replaces it (a report that vanished on the app disappears). If the app cannot be reached, the saved rows stay and the screen says so: "Could not reach the app. Showing what this phone last saw." A first open with nothing saved shows the loading state, as before. Reports waiting in the outbox show as they did. Another account never reads this account's saved answer. New strings `updating` and `offlineShowingSaved` (replaceable through `words.text`).
|
|
14
|
+
- **`<SuggestEntry role>`.** The entry button takes an ARIA `role` (for a host that shows it as a menu row with `role="menuitem"`), beside `className`. Unset by default.
|
|
15
|
+
|
|
3
16
|
## 0.1.1 (2026-10-13)
|
|
4
17
|
|
|
5
18
|
- **A report sent with the Mac away is sent when it is back, even with the sheet closed.** The outbox used to move only while the sheet, My suggestions or a detail screen was mounted, so a report waited until the person opened one again. `<SuggestEntry>` now keeps the outbox moving while it is mounted, and `KeepSending` is exported from `./client` for a host with no always-mounted entry (it draws nothing).
|
package/README.md
CHANGED
|
@@ -47,6 +47,8 @@ const adapters: Adapters = {
|
|
|
47
47
|
// then: {open && <SuggestSheet adapters={adapters} where={{ screen: "Photos", route: location.pathname }} onClose={() => setOpen(false)} />}
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
+
`SuggestEntry` also takes `className` and `role` (for example `role="menuitem"` when a host shows it as a menu row); no role is set by default. My suggestions and the detail view keep their last answer from the app on the phone (module-owned IndexedDB, keyed by app and account like a draft), draw it at once with a quiet "Updating" line, and replace it when the app answers; if the app cannot be reached they keep the saved rows and say so. An adapter tells a refusal from an unreachable app: `mine()` and `one()` throw an error with `refused: true` or a `status` of 401, 403 or 404 for a refusal (the saved copy is then deleted), and anything else thrown is unreachable. Saved reports the list no longer holds are pruned, and signing out clears them. A first open with nothing saved shows "Loading".
|
|
51
|
+
|
|
50
52
|
The entry also keeps the phone's outbox moving while it is mounted (a report sent with the Mac away goes when it is back, with the sheet closed); a host with no always-mounted entry renders `<KeepSending adapters={adapters} />` once, high in the app. The entry carries `data-suggest-entry` (the attribute the screen check looks for; see "The check" below). Signed out, it opens nothing and says why. The receipt says "Got it" only after the adapter has answered `done` for the report and every attachment; until then it says the report is on the phone. Every text field the module renders sits in a `[data-voice-field]` container with a `[data-voice-control]`; a host's `Field` slot must keep that shape (it receives `FieldProps`, which is the Helm's voice field props). A voice note alone is a whole report: the recording is kept and uploaded as a part, and the receiving Mac writes the transcript.
|
|
51
53
|
|
|
52
54
|
Styling: structure only, class names prefixed `sac-`. `@supersuit/suggest-a-change/styles.css` is optional defaults (16px inputs so iOS does not zoom, 44px touch targets, safe-area padding on the sheet). Re-skin it with these custom properties:
|
package/dist/client/detail.js
CHANGED
|
@@ -11,6 +11,7 @@ import { recoverTakes } from './recorder.js';
|
|
|
11
11
|
import { queueFollowUp } from './reports.js';
|
|
12
12
|
import { useRuntime, useTick } from './runtime.js';
|
|
13
13
|
import { Toasts } from './toast.js';
|
|
14
|
+
import { dropCache, isRefusal, oneCacheKey, readCache, useClearOnSignOut, writeCache } from './view-cache.js';
|
|
14
15
|
import { effectiveCeilings, lengthWords, sizeWords, statusMeaning, statusWords, stringsFor } from './words.js';
|
|
15
16
|
export function statusOf(r) {
|
|
16
17
|
if (typeof r.status === 'string')
|
|
@@ -79,6 +80,8 @@ export function SuggestionDetail({ adapters, id }) {
|
|
|
79
80
|
const [note, setNote] = useState('');
|
|
80
81
|
const [bad, setBad] = useState('');
|
|
81
82
|
const [ready, setReady] = useState(false);
|
|
83
|
+
const [updating, setUpdating] = useState(false);
|
|
84
|
+
const [offline, setOffline] = useState(false);
|
|
82
85
|
// The follow-up being written: its id is made when the person starts it, so queuing it twice is one follow-up.
|
|
83
86
|
const followId = useRef(null);
|
|
84
87
|
const n = useRef(0);
|
|
@@ -88,10 +91,13 @@ export function SuggestionDetail({ adapters, id }) {
|
|
|
88
91
|
// Each load takes a number; only the newest may paint, so a load still running for the account that
|
|
89
92
|
// was just replaced never draws its rows under the new one.
|
|
90
93
|
const loads = useRef(0);
|
|
94
|
+
// True once a report from the phone's copy or the app is on screen for this id and account.
|
|
95
|
+
const shown = useRef(false);
|
|
91
96
|
// Anything that writes the follow-up waits until its draft has been read, so the counter is right.
|
|
92
97
|
const restored = useRef(makeDeferred());
|
|
93
98
|
// A different account (or none) shows nothing of the last one.
|
|
94
|
-
|
|
99
|
+
useClearOnSignOut(rt, who, adapters.words.app);
|
|
100
|
+
useEffect(() => { loads.current++; shown.current = false; setUpdating(false); setOffline(false); setReport(undefined); setPending([]); setReady(false); setMore(''); setHeard([]); followId.current = null; n.current = 0; restored.current = makeDeferred(); }, [rt, id]);
|
|
95
101
|
const load = useCallback(async () => {
|
|
96
102
|
if (!rt)
|
|
97
103
|
return;
|
|
@@ -109,9 +115,60 @@ export function SuggestionDetail({ adapters, id }) {
|
|
|
109
115
|
return;
|
|
110
116
|
}
|
|
111
117
|
setRefusal(undefined);
|
|
112
|
-
const
|
|
113
|
-
|
|
118
|
+
const ckey = oneCacheKey({ app: adapters.words.app, account: rt.who.account }, id);
|
|
119
|
+
// Draw at once from the phone's copy, then ask the app.
|
|
120
|
+
if (!shown.current) {
|
|
121
|
+
const saved = await readCache(rt.store, ckey);
|
|
122
|
+
if (!fresh())
|
|
123
|
+
return;
|
|
124
|
+
if (saved && typeof saved === 'object') {
|
|
125
|
+
shown.current = true;
|
|
126
|
+
setReport(saved);
|
|
127
|
+
setUpdating(true);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
let r;
|
|
131
|
+
let refused = false;
|
|
132
|
+
try {
|
|
133
|
+
const a = (await adapters.store.one(id));
|
|
134
|
+
if (a && typeof a === 'object' && typeof a.refused === 'string')
|
|
135
|
+
refused = true;
|
|
136
|
+
else
|
|
137
|
+
r = a;
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
r = undefined;
|
|
141
|
+
refused = isRefusal(e);
|
|
142
|
+
}
|
|
143
|
+
// The answer belongs to whoever asked: if the signed-in account is no longer that one, nothing is kept or drawn.
|
|
144
|
+
let now = null;
|
|
145
|
+
try {
|
|
146
|
+
now = await adapters.identity();
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
now = null;
|
|
150
|
+
}
|
|
151
|
+
if (!fresh())
|
|
152
|
+
return;
|
|
153
|
+
if (!now || now.account !== rt.who.account)
|
|
154
|
+
return;
|
|
155
|
+
setUpdating(false);
|
|
156
|
+
if (r) {
|
|
157
|
+
shown.current = true;
|
|
158
|
+
setOffline(false);
|
|
114
159
|
setReport(r);
|
|
160
|
+
void writeCache(rt.store, ckey, r);
|
|
161
|
+
}
|
|
162
|
+
else if (refused) {
|
|
163
|
+
shown.current = false;
|
|
164
|
+
setOffline(false);
|
|
165
|
+
void dropCache(rt.store, ckey);
|
|
166
|
+
setReport(null);
|
|
167
|
+
}
|
|
168
|
+
else if (shown.current)
|
|
169
|
+
setOffline(true);
|
|
170
|
+
else
|
|
171
|
+
setReport(null);
|
|
115
172
|
}
|
|
116
173
|
catch {
|
|
117
174
|
if (fresh())
|
|
@@ -211,5 +268,5 @@ export function SuggestionDetail({ adapters, id }) {
|
|
|
211
268
|
setHeard((cur) => [...cur, { key: k, file, ms }]);
|
|
212
269
|
return { audio: k, text: '' };
|
|
213
270
|
}
|
|
214
|
-
return (_jsxs("article", { className: "sac-detail", "data-report": id, children: [_jsx("h3", { className: "sac-status", "data-status": status, children: statusWords(status, owner) }), _jsxs("p", { className: "sac-muted", children: [statusMeaning(status, owner), status === 'live' && typeof report.liveAt === 'string' ? ` (${new Date(report.liveAt).toLocaleDateString()})` : ''] }), refusal ? (_jsxs(_Fragment, { children: [_jsx("p", { role: "alert", className: "sac-error", children: refusal }), rt ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.retry(id), children: S.retry }) : null] })) : null, _jsx(StatusStrip, { status: status, owner: owner, label: S.stepLabel, step: stepOf(report), timeline: timelineOf(report) }), typeof report.words === 'string' && report.words ? _jsx("p", { className: "sac-words", children: report.words }) : null, Object.entries(answers).filter(([, v]) => v).map(([k, v]) => _jsx("p", { className: "sac-words", "data-answer": k, children: v }, k)), voice.length ? _jsx("ul", { className: "sac-list", "aria-label": S.voiceNotesLabel, children: voice.map((v, i) => _jsx("li", { className: "sac-item", children: S.voiceNote(i + 1, lengthWords(v.ms)) }, v.file)) }) : null, media.length ? _jsx("ul", { className: "sac-list", "aria-label": S.attachmentsLabel, children: media.map((m) => _jsxs("li", { className: "sac-item", children: [m.file, ", ", sizeWords(m.bytes)] }, m.file)) }) : null, Array.isArray(report.added) ? report.added.map((a, i) => _jsx("p", { className: "sac-words", children: a.words }, i)) : null, pending.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.waitingToSend, "data-followups": "", children: pending.map((f) => (_jsxs("li", { className: "sac-item", "data-followup": f.followId, children: [_jsx("span", { className: "sac-words", children: f.words || S.attachmentsLabel }), f.error ? _jsx("span", { role: "alert", className: "sac-error", children: f.error }) : _jsx("span", { className: "sac-muted", children: S.waitingToSend }), f.error && rt ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.retry(id), children: S.retry }) : null, f.error && rt ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.discardFollowUp(id, f.followId), children: S.discard }) : null] }, f.followId))) })) : null, open ? (_jsxs("form", { className: "sac-form", onSubmit: (e) => { e.preventDefault(); void add(); }, children: [rt ? (_jsx(Field, { rt: { store: rt.store }, id: `append-${id}`, label: S.appendLabel, value: more, onChange: setMore, multiline: true, persist: false, startTake: adapters.startTake, maxMs: ceilings.voiceMs, scope: key ?? undefined, labels: labels, ...(adapters.appendVoice === false ? {} : { upload: (k, b, ms) => keepHeard(k, b, ms) }) })) : null, heard.length ? _jsx("ul", { className: "sac-list", "aria-label": S.voiceNotesLabel, children: heard.map((h, i) => _jsx("li", { className: "sac-item", children: S.voiceNote(i + 1, lengthWords(h.ms)) }, h.key)) }) : null, bad ? _jsx("p", { role: "alert", className: "sac-error", children: bad }) : null, note ? _jsx("p", { role: "status", children: note }) : null, _jsx("button", { type: "submit", className: "sac-button", disabled: !more.trim() && !heard.length, children: S.appendSend })] })) : null, _jsx(Toasts, {})] }));
|
|
271
|
+
return (_jsxs("article", { className: "sac-detail", "data-report": id, children: [_jsx("h3", { className: "sac-status", "data-status": status, children: statusWords(status, owner) }), _jsxs("p", { className: "sac-muted", children: [statusMeaning(status, owner), status === 'live' && typeof report.liveAt === 'string' ? ` (${new Date(report.liveAt).toLocaleDateString()})` : ''] }), refusal ? (_jsxs(_Fragment, { children: [_jsx("p", { role: "alert", className: "sac-error", children: refusal }), rt ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.retry(id), children: S.retry }) : null] })) : null, updating ? _jsx("p", { role: "status", className: "sac-muted", "data-updating": "", children: S.updating }) : null, offline ? _jsx("p", { role: "alert", className: "sac-error", children: S.offlineShowingSaved }) : null, _jsx(StatusStrip, { status: status, owner: owner, label: S.stepLabel, step: stepOf(report), timeline: timelineOf(report) }), typeof report.words === 'string' && report.words ? _jsx("p", { className: "sac-words", children: report.words }) : null, Object.entries(answers).filter(([, v]) => v).map(([k, v]) => _jsx("p", { className: "sac-words", "data-answer": k, children: v }, k)), voice.length ? _jsx("ul", { className: "sac-list", "aria-label": S.voiceNotesLabel, children: voice.map((v, i) => _jsx("li", { className: "sac-item", children: S.voiceNote(i + 1, lengthWords(v.ms)) }, v.file)) }) : null, media.length ? _jsx("ul", { className: "sac-list", "aria-label": S.attachmentsLabel, children: media.map((m) => _jsxs("li", { className: "sac-item", children: [m.file, ", ", sizeWords(m.bytes)] }, m.file)) }) : null, Array.isArray(report.added) ? report.added.map((a, i) => _jsx("p", { className: "sac-words", children: a.words }, i)) : null, pending.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.waitingToSend, "data-followups": "", children: pending.map((f) => (_jsxs("li", { className: "sac-item", "data-followup": f.followId, children: [_jsx("span", { className: "sac-words", children: f.words || S.attachmentsLabel }), f.error ? _jsx("span", { role: "alert", className: "sac-error", children: f.error }) : _jsx("span", { className: "sac-muted", children: S.waitingToSend }), f.error && rt ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.retry(id), children: S.retry }) : null, f.error && rt ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.discardFollowUp(id, f.followId), children: S.discard }) : null] }, f.followId))) })) : null, open ? (_jsxs("form", { className: "sac-form", onSubmit: (e) => { e.preventDefault(); void add(); }, children: [rt ? (_jsx(Field, { rt: { store: rt.store }, id: `append-${id}`, label: S.appendLabel, value: more, onChange: setMore, multiline: true, persist: false, startTake: adapters.startTake, maxMs: ceilings.voiceMs, scope: key ?? undefined, labels: labels, ...(adapters.appendVoice === false ? {} : { upload: (k, b, ms) => keepHeard(k, b, ms) }) })) : null, heard.length ? _jsx("ul", { className: "sac-list", "aria-label": S.voiceNotesLabel, children: heard.map((h, i) => _jsx("li", { className: "sac-item", children: S.voiceNote(i + 1, lengthWords(h.ms)) }, h.key)) }) : null, bad ? _jsx("p", { role: "alert", className: "sac-error", children: bad }) : null, note ? _jsx("p", { role: "status", children: note }) : null, _jsx("button", { type: "submit", className: "sac-button", disabled: !more.trim() && !heard.length, children: S.appendSend })] })) : null, _jsx(Toasts, {})] }));
|
|
215
272
|
}
|
package/dist/client/entry.d.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
+
import { type AriaRole } from 'react';
|
|
1
2
|
import type { Adapters } from './adapters.ts';
|
|
2
3
|
/** Keeps the phone's outbox moving (on reconnect and every 15 seconds) for as long as it is mounted, with nothing drawn.
|
|
3
4
|
* <SuggestEntry> already does this; a host with no always-mounted entry mounts this once, high in the app. */
|
|
4
5
|
export declare function KeepSending({ adapters }: {
|
|
5
6
|
adapters: Adapters;
|
|
6
7
|
}): null;
|
|
7
|
-
export declare function SuggestEntry({ adapters, onOpen, className }: {
|
|
8
|
+
export declare function SuggestEntry({ adapters, onOpen, className, role }: {
|
|
8
9
|
adapters: Adapters;
|
|
9
10
|
onOpen(): void;
|
|
10
|
-
className?: string;
|
|
11
|
+
className?: string; /** An ARIA role for the button (for example `menuitem` when the host shows it as a menu row). Unset by default. */
|
|
12
|
+
role?: AriaRole;
|
|
11
13
|
}): import("react").JSX.Element;
|
|
12
14
|
/** For an error screen: opens the sheet as "something is broken", with the screen and what went wrong filled in. */
|
|
13
15
|
export declare function ReportProblem({ adapters, screen, error }: {
|
package/dist/client/entry.js
CHANGED
|
@@ -13,7 +13,7 @@ export function KeepSending({ adapters }) {
|
|
|
13
13
|
useRuntime(adapters);
|
|
14
14
|
return null;
|
|
15
15
|
}
|
|
16
|
-
export function SuggestEntry({ adapters, onOpen, className }) {
|
|
16
|
+
export function SuggestEntry({ adapters, onOpen, className, role }) {
|
|
17
17
|
const S = stringsFor(adapters.words);
|
|
18
18
|
const [why, setWhy] = useState('');
|
|
19
19
|
const [busy, setBusy] = useState(false);
|
|
@@ -35,7 +35,7 @@ export function SuggestEntry({ adapters, onOpen, className }) {
|
|
|
35
35
|
setWhy('');
|
|
36
36
|
onOpen();
|
|
37
37
|
}
|
|
38
|
-
return (_jsxs("span", { className: "sac-entry-wrap", children: [_jsx(KeepSending, { adapters: adapters }), _jsx("button", { type: "button", "data-suggest-entry": "", className: `sac-entry${className ? ` ${className}` : ''}`, "aria-busy": busy || undefined, onClick: () => void press(), children: S.entry }), why ? _jsx("span", { role: "status", className: "sac-entry-why", children: why }) : null] }));
|
|
38
|
+
return (_jsxs("span", { className: "sac-entry-wrap", children: [_jsx(KeepSending, { adapters: adapters }), _jsx("button", { type: "button", role: role, "data-suggest-entry": "", className: `sac-entry${className ? ` ${className}` : ''}`, "aria-busy": busy || undefined, onClick: () => void press(), children: S.entry }), why ? _jsx("span", { role: "status", className: "sac-entry-why", children: why }) : null] }));
|
|
39
39
|
}
|
|
40
40
|
/** For an error screen: opens the sheet as "something is broken", with the screen and what went wrong filled in. */
|
|
41
41
|
export function ReportProblem({ adapters, screen, error }) {
|
package/dist/client/mine.js
CHANGED
|
@@ -7,6 +7,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
7
7
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
8
8
|
import { SuggestionDetail } from './detail.js';
|
|
9
9
|
import { useRuntime, useTick } from './runtime.js';
|
|
10
|
+
import { dropCache, isRefusal, mineCacheKey, pruneOnes, readCache, useClearOnSignOut, writeCache } from './view-cache.js';
|
|
10
11
|
import { Toasts } from './toast.js';
|
|
11
12
|
import { statusMeaning, statusWords, stringsFor } from './words.js';
|
|
12
13
|
export function MySuggestions({ adapters, onOpen }) {
|
|
@@ -17,38 +18,97 @@ export function MySuggestions({ adapters, onOpen }) {
|
|
|
17
18
|
const [rows, setRows] = useState(null);
|
|
18
19
|
const [open, setOpen] = useState(null);
|
|
19
20
|
const [bad, setBad] = useState('');
|
|
21
|
+
const [updating, setUpdating] = useState(false);
|
|
20
22
|
// Another account (or none) shows nothing of the last one's rows.
|
|
21
23
|
const loads = useRef(0);
|
|
22
|
-
|
|
24
|
+
// What the app last said (or the phone kept of it) for this account; null until either is known.
|
|
25
|
+
const known = useRef(null);
|
|
26
|
+
// The first answer after mount ends "Updating"; later periodic reloads refresh quietly.
|
|
27
|
+
const answered = useRef(false);
|
|
28
|
+
useClearOnSignOut(rt, who, adapters.words.app);
|
|
29
|
+
useEffect(() => { loads.current++; known.current = null; answered.current = false; setRows(null); setOpen(null); setBad(''); setUpdating(false); }, [rt]);
|
|
23
30
|
const load = useCallback(async () => {
|
|
24
31
|
if (!rt)
|
|
25
32
|
return;
|
|
26
33
|
// Only the newest load may paint: one still running for the account just replaced must not draw its rows.
|
|
27
34
|
const mine_ = ++loads.current;
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
const key = mineCacheKey({ app: adapters.words.app, account: rt.who.account });
|
|
36
|
+
const paint = (theirs, local) => {
|
|
37
|
+
const waiting = new Map(local.map((l) => [l.id, l]));
|
|
38
|
+
// A report the app already lists keeps its own status; one still on the phone is listed as on the phone.
|
|
39
|
+
const phone = local.filter((l) => !l.onlyFollowUp && !theirs.some((t) => t.id === l.id)).map((l) => ({ id: l.id, status: 'on-phone', at: l.at, ...(l.words ? { words: l.words } : {}), ...(l.error ? { error: l.error } : {}) }));
|
|
40
|
+
setRows([...theirs.map((t) => ({ ...t, ...(waiting.get(t.id)?.error ? { error: waiting.get(t.id).error } : {}) })), ...phone].sort((a, b) => (b.at ?? 0) - (a.at ?? 0)));
|
|
41
|
+
};
|
|
42
|
+
// Draw at once from what the phone already holds, then ask the app.
|
|
43
|
+
if (known.current === null) {
|
|
44
|
+
const saved = await readCache(rt.store, key);
|
|
45
|
+
if (mine_ !== loads.current)
|
|
46
|
+
return;
|
|
47
|
+
if (Array.isArray(saved))
|
|
48
|
+
known.current = saved;
|
|
49
|
+
}
|
|
50
|
+
if (known.current !== null) {
|
|
51
|
+
const local = await rt.waiting();
|
|
52
|
+
if (mine_ !== loads.current)
|
|
53
|
+
return;
|
|
54
|
+
paint(known.current, local);
|
|
55
|
+
if (!answered.current)
|
|
56
|
+
setUpdating(true);
|
|
57
|
+
}
|
|
58
|
+
let theirs = null;
|
|
59
|
+
let refused = false;
|
|
30
60
|
try {
|
|
31
61
|
theirs = await adapters.store.mine();
|
|
32
62
|
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
theirs = null;
|
|
65
|
+
refused = isRefusal(e);
|
|
66
|
+
}
|
|
67
|
+
// The answer belongs to whoever asked. If the signed-in account is no longer that one, nothing is kept or drawn.
|
|
68
|
+
let now = null;
|
|
69
|
+
try {
|
|
70
|
+
now = await adapters.identity();
|
|
71
|
+
}
|
|
33
72
|
catch {
|
|
34
|
-
|
|
73
|
+
now = null;
|
|
35
74
|
}
|
|
36
75
|
const local = await rt.waiting();
|
|
37
76
|
if (mine_ !== loads.current)
|
|
38
77
|
return;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
78
|
+
if (!now || now.account !== rt.who.account)
|
|
79
|
+
return;
|
|
80
|
+
answered.current = true;
|
|
81
|
+
setUpdating(false);
|
|
82
|
+
if (theirs) {
|
|
83
|
+
known.current = theirs;
|
|
84
|
+
void writeCache(rt.store, key, theirs);
|
|
85
|
+
void pruneOnes(rt.store, { app: adapters.words.app, account: rt.who.account }, theirs.map((t) => t.id));
|
|
86
|
+
setBad('');
|
|
87
|
+
paint(theirs, local);
|
|
88
|
+
}
|
|
89
|
+
else if (refused) {
|
|
90
|
+
// The app said no: what the phone held is no longer this person's to see.
|
|
91
|
+
known.current = null;
|
|
92
|
+
void dropCache(rt.store, key);
|
|
93
|
+
setBad(S.couldNotLoadMine);
|
|
94
|
+
paint([], local);
|
|
95
|
+
}
|
|
96
|
+
else if (known.current !== null) {
|
|
97
|
+
setBad(S.offlineShowingSaved);
|
|
98
|
+
paint(known.current, local);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
setBad(S.couldNotLoadMine);
|
|
102
|
+
paint([], local);
|
|
103
|
+
}
|
|
104
|
+
}, [adapters, rt, S.couldNotLoadMine, S.offlineShowingSaved]);
|
|
45
105
|
useEffect(() => { void load(); }, [load, tick]);
|
|
46
106
|
if (who === null)
|
|
47
107
|
return _jsx("p", { role: "alert", children: S.signedOutMine });
|
|
48
108
|
if (open && !onOpen) {
|
|
49
109
|
return (_jsxs("section", { className: "sac-mine", children: [_jsx("button", { type: "button", className: "sac-button", onClick: () => setOpen(null), children: S.back }), _jsx(SuggestionDetail, { adapters: adapters, id: open })] }));
|
|
50
110
|
}
|
|
51
|
-
return (_jsxs("section", { className: "sac-mine", "aria-labelledby": "sac-mine-title", children: [_jsx("h2", { id: "sac-mine-title", className: "sac-title", children: S.mineTitle }), bad ? _jsx("p", { role: "alert", className: "sac-error", children: bad }) : null, rows === null ? _jsx("p", { children: S.loading }) : rows.length === 0 ? _jsx("p", { children: S.mineEmpty }) : (_jsx("ul", { className: "sac-list", children: rows.map((r) => {
|
|
111
|
+
return (_jsxs("section", { className: "sac-mine", "aria-labelledby": "sac-mine-title", children: [_jsx("h2", { id: "sac-mine-title", className: "sac-title", children: S.mineTitle }), bad ? _jsx("p", { role: "alert", className: "sac-error", children: bad }) : null, updating ? _jsx("p", { role: "status", className: "sac-muted", "data-updating": "", children: S.updating }) : null, rows === null ? _jsx("p", { children: S.loading }) : rows.length === 0 ? _jsx("p", { children: S.mineEmpty }) : (_jsx("ul", { className: "sac-list", children: rows.map((r) => {
|
|
52
112
|
const preview = typeof r.words === 'string' ? r.words : typeof r.title === 'string' ? r.title : '';
|
|
53
113
|
return (_jsxs("li", { className: "sac-row", "data-status": r.status, children: [_jsxs("button", { type: "button", className: "sac-row-button", onClick: () => (onOpen ? onOpen(r.id) : setOpen(r.id)), children: [preview ? _jsx("span", { className: "sac-row-words", children: preview }) : null, _jsx("span", { className: "sac-row-status", children: statusWords(r.status, owner) }), _jsx("span", { className: "sac-muted", children: statusMeaning(r.status, owner) })] }), r.error ? (_jsxs(_Fragment, { children: [_jsx("p", { role: "alert", className: "sac-error", children: r.error }), rt ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.retry(r.id), children: S.retry }) : null] })) : null] }, r.id));
|
|
54
114
|
}) })), _jsx(Toasts, {})] }));
|
package/dist/client/reports.d.ts
CHANGED
|
@@ -22,7 +22,9 @@ export interface StoreAdapter {
|
|
|
22
22
|
uploadPart(reportId: string, file: string, part: number, bytes: Blob): Promise<'done' | {
|
|
23
23
|
refused: string;
|
|
24
24
|
}>;
|
|
25
|
+
/** An unreachable app THROWS (a network error). A refusal (the list is no longer this person's) throws an error with `refused: true` or a `status` of 401, 403 or 404: the phone then drops what it kept. */
|
|
25
26
|
mine(): Promise<Summary[]>;
|
|
27
|
+
/** Same contract as `mine`: a report that is gone or not this person's is a refusal (`refused: true`, or status 401/403/404, or an answer object with a string `refused`), never a plain failure, so the phone stops showing its saved copy. */
|
|
26
28
|
one(id: string): Promise<Report>;
|
|
27
29
|
/** Adds to a report. Also at-least-once: it must dedupe by (id, `more.followId`). */
|
|
28
30
|
append(id: string, more: Record<string, unknown>): Promise<'done' | {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Store } from './idb.ts';
|
|
2
|
+
type Id = {
|
|
3
|
+
app: string;
|
|
4
|
+
account: string;
|
|
5
|
+
};
|
|
6
|
+
export declare const mineCacheKey: (id: Id) => string;
|
|
7
|
+
export declare const oneCacheKey: (id: Id, reportId: string) => string;
|
|
8
|
+
export declare function readCache<T>(store: Store, key: string): Promise<T | undefined>;
|
|
9
|
+
export declare function writeCache(store: Store, key: string, value: unknown): Promise<void>;
|
|
10
|
+
export declare function dropCache(store: Store, key: string): Promise<void>;
|
|
11
|
+
/** After a successful list: cached reports the app no longer lists (deleted, or no longer this person's) are dropped. */
|
|
12
|
+
export declare function pruneOnes(store: Store, id: Id, keep: string[]): Promise<void>;
|
|
13
|
+
/** Everything cached for one account: the list and every report. */
|
|
14
|
+
export declare function clearViewCache(store: Store, id: Id): Promise<void>;
|
|
15
|
+
/** True when the app REFUSED (the report is gone, or this person may not see it), as against being unreachable.
|
|
16
|
+
* The adapter contract: `mine()` and `one()` throw an error carrying `refused: true`, or a `status` of 401, 403 or 404;
|
|
17
|
+
* `one()` may instead answer an object with a string `refused`. Anything else thrown is a failure to reach the app. */
|
|
18
|
+
export declare function isRefusal(e: unknown): boolean;
|
|
19
|
+
/** When the person signs out (identity becomes null), the account that was signed in loses its cached views on this phone. */
|
|
20
|
+
export declare function useClearOnSignOut(rt: {
|
|
21
|
+
store: Store;
|
|
22
|
+
who: {
|
|
23
|
+
account: string;
|
|
24
|
+
};
|
|
25
|
+
} | null, who: unknown, app: string): void;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
// THE LAST ANSWER, KEPT ON THE PHONE. My suggestions and the detail view draw from this at once and then
|
|
3
|
+
// refresh from the app, so opening them never waits on the app being reachable. The key names the app and
|
|
4
|
+
// the account exactly as a draft's does, so one person's cache is never read for another.
|
|
5
|
+
import { useEffect, useRef } from 'react';
|
|
6
|
+
import { draftKey } from './drafts.js';
|
|
7
|
+
const base = (id) => `${draftKey(id)}#view`;
|
|
8
|
+
export const mineCacheKey = (id) => `${base(id)}:mine`;
|
|
9
|
+
export const oneCacheKey = (id, reportId) => `${base(id)}:one:${reportId}`;
|
|
10
|
+
export async function readCache(store, key) {
|
|
11
|
+
try {
|
|
12
|
+
return await store.get('kv', key);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function writeCache(store, key, value) {
|
|
19
|
+
try {
|
|
20
|
+
await store.put('kv', key, value);
|
|
21
|
+
}
|
|
22
|
+
catch { /* a full or blocked database only costs the next open its head start */ }
|
|
23
|
+
}
|
|
24
|
+
export async function dropCache(store, key) {
|
|
25
|
+
try {
|
|
26
|
+
await store.del('kv', key);
|
|
27
|
+
}
|
|
28
|
+
catch { /* nothing to drop */ }
|
|
29
|
+
}
|
|
30
|
+
/** After a successful list: cached reports the app no longer lists (deleted, or no longer this person's) are dropped. */
|
|
31
|
+
export async function pruneOnes(store, id, keep) {
|
|
32
|
+
try {
|
|
33
|
+
const prefix = `${base(id)}:one:`;
|
|
34
|
+
const live = new Set(keep);
|
|
35
|
+
for (const row of await store.all('kv', prefix))
|
|
36
|
+
if (!live.has(row.key.slice(prefix.length)))
|
|
37
|
+
await store.del('kv', row.key);
|
|
38
|
+
}
|
|
39
|
+
catch { /* the next list prunes again */ }
|
|
40
|
+
}
|
|
41
|
+
/** Everything cached for one account: the list and every report. */
|
|
42
|
+
export async function clearViewCache(store, id) {
|
|
43
|
+
try {
|
|
44
|
+
for (const row of await store.all('kv', base(id)))
|
|
45
|
+
await store.del('kv', row.key);
|
|
46
|
+
}
|
|
47
|
+
catch { /* best effort */ }
|
|
48
|
+
}
|
|
49
|
+
/** True when the app REFUSED (the report is gone, or this person may not see it), as against being unreachable.
|
|
50
|
+
* The adapter contract: `mine()` and `one()` throw an error carrying `refused: true`, or a `status` of 401, 403 or 404;
|
|
51
|
+
* `one()` may instead answer an object with a string `refused`. Anything else thrown is a failure to reach the app. */
|
|
52
|
+
export function isRefusal(e) {
|
|
53
|
+
if (!e || typeof e !== 'object')
|
|
54
|
+
return false;
|
|
55
|
+
const x = e;
|
|
56
|
+
return x.refused === true || (typeof x.refused === 'string' && x.refused !== '') || x.status === 401 || x.status === 403 || x.status === 404;
|
|
57
|
+
}
|
|
58
|
+
/** When the person signs out (identity becomes null), the account that was signed in loses its cached views on this phone. */
|
|
59
|
+
export function useClearOnSignOut(rt, who, app) {
|
|
60
|
+
const last = useRef(null);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (rt) {
|
|
63
|
+
last.current = { store: rt.store, id: { app, account: rt.who.account } };
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (who === null && last.current) {
|
|
67
|
+
const l = last.current;
|
|
68
|
+
last.current = null;
|
|
69
|
+
void clearViewCache(l.store, l.id);
|
|
70
|
+
}
|
|
71
|
+
}, [rt, who, app]);
|
|
72
|
+
}
|
package/dist/client/words.d.ts
CHANGED
package/dist/client/words.js
CHANGED
|
@@ -79,6 +79,8 @@ export const DEFAULT_STRINGS = {
|
|
|
79
79
|
screenshotFailed: 'The screenshot could not be taken.',
|
|
80
80
|
couldNotOpen: 'This suggestion could not be opened.',
|
|
81
81
|
couldNotLoadMine: 'Your suggestions could not be loaded.',
|
|
82
|
+
updating: 'Updating',
|
|
83
|
+
offlineShowingSaved: 'Could not reach the app. Showing what this phone last saw.',
|
|
82
84
|
addFailed: 'That could not be added. Try again.',
|
|
83
85
|
overLimit: 'Something in this suggestion is over a limit. Remove an attachment and try again.',
|
|
84
86
|
stepLabel: 'Where it is',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/suggest-a-change",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "The report format and flow behind a Suggest a change button: what a report is, who acts at each step, the screens, and the check every app runs. The contract has zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/client/detail.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import { recoverTakes } from './recorder.ts'
|
|
|
11
11
|
import { queueFollowUp, type Report } from './reports.ts'
|
|
12
12
|
import { useRuntime, useTick, type FollowUp } from './runtime.ts'
|
|
13
13
|
import { Toasts } from './toast.tsx'
|
|
14
|
+
import { dropCache, isRefusal, oneCacheKey, readCache, useClearOnSignOut, writeCache } from './view-cache.ts'
|
|
14
15
|
import { effectiveCeilings, lengthWords, sizeWords, statusMeaning, statusWords, stringsFor } from './words.ts'
|
|
15
16
|
|
|
16
17
|
export function statusOf(r: Record<string, unknown>): string {
|
|
@@ -84,6 +85,8 @@ export function SuggestionDetail({ adapters, id }: { adapters: Adapters; id: str
|
|
|
84
85
|
const [note, setNote] = useState('')
|
|
85
86
|
const [bad, setBad] = useState('')
|
|
86
87
|
const [ready, setReady] = useState(false)
|
|
88
|
+
const [updating, setUpdating] = useState(false)
|
|
89
|
+
const [offline, setOffline] = useState(false)
|
|
87
90
|
// The follow-up being written: its id is made when the person starts it, so queuing it twice is one follow-up.
|
|
88
91
|
const followId = useRef<string | null>(null)
|
|
89
92
|
const n = useRef(0)
|
|
@@ -94,11 +97,14 @@ export function SuggestionDetail({ adapters, id }: { adapters: Adapters; id: str
|
|
|
94
97
|
// Each load takes a number; only the newest may paint, so a load still running for the account that
|
|
95
98
|
// was just replaced never draws its rows under the new one.
|
|
96
99
|
const loads = useRef(0)
|
|
100
|
+
// True once a report from the phone's copy or the app is on screen for this id and account.
|
|
101
|
+
const shown = useRef(false)
|
|
97
102
|
// Anything that writes the follow-up waits until its draft has been read, so the counter is right.
|
|
98
103
|
const restored = useRef(makeDeferred())
|
|
99
104
|
|
|
100
105
|
// A different account (or none) shows nothing of the last one.
|
|
101
|
-
|
|
106
|
+
useClearOnSignOut(rt, who, adapters.words.app)
|
|
107
|
+
useEffect(() => { loads.current++; shown.current = false; setUpdating(false); setOffline(false); setReport(undefined); setPending([]); setReady(false); setMore(''); setHeard([]); followId.current = null; n.current = 0; restored.current = makeDeferred() }, [rt, id])
|
|
102
108
|
|
|
103
109
|
const load = useCallback(async () => {
|
|
104
110
|
if (!rt) return
|
|
@@ -111,8 +117,30 @@ export function SuggestionDetail({ adapters, id }: { adapters: Adapters; id: str
|
|
|
111
117
|
setPending(waiting)
|
|
112
118
|
if (mine) { setReport({ ...(mine.report as Report & Record<string, unknown>), status: 'on-phone' }); setRefusal(mine.error); return }
|
|
113
119
|
setRefusal(undefined)
|
|
114
|
-
const
|
|
115
|
-
|
|
120
|
+
const ckey = oneCacheKey({ app: adapters.words.app, account: rt.who.account }, id)
|
|
121
|
+
// Draw at once from the phone's copy, then ask the app.
|
|
122
|
+
if (!shown.current) {
|
|
123
|
+
const saved = await readCache<Report & Record<string, unknown>>(rt.store, ckey)
|
|
124
|
+
if (!fresh()) return
|
|
125
|
+
if (saved && typeof saved === 'object') { shown.current = true; setReport(saved); setUpdating(true) }
|
|
126
|
+
}
|
|
127
|
+
let r: (Report & Record<string, unknown>) | undefined
|
|
128
|
+
let refused = false
|
|
129
|
+
try {
|
|
130
|
+
const a = (await adapters.store.one(id)) as unknown
|
|
131
|
+
if (a && typeof a === 'object' && typeof (a as { refused?: unknown }).refused === 'string') refused = true
|
|
132
|
+
else r = a as Report & Record<string, unknown>
|
|
133
|
+
} catch (e) { r = undefined; refused = isRefusal(e) }
|
|
134
|
+
// The answer belongs to whoever asked: if the signed-in account is no longer that one, nothing is kept or drawn.
|
|
135
|
+
let now: { account: string } | null = null
|
|
136
|
+
try { now = await adapters.identity() } catch { now = null }
|
|
137
|
+
if (!fresh()) return
|
|
138
|
+
if (!now || now.account !== rt.who.account) return
|
|
139
|
+
setUpdating(false)
|
|
140
|
+
if (r) { shown.current = true; setOffline(false); setReport(r); void writeCache(rt.store, ckey, r) }
|
|
141
|
+
else if (refused) { shown.current = false; setOffline(false); void dropCache(rt.store, ckey); setReport(null) }
|
|
142
|
+
else if (shown.current) setOffline(true)
|
|
143
|
+
else setReport(null)
|
|
116
144
|
} catch { if (fresh()) setReport(null) }
|
|
117
145
|
}, [adapters, id, rt])
|
|
118
146
|
useEffect(() => { void load() }, [load, tick])
|
|
@@ -203,6 +231,8 @@ export function SuggestionDetail({ adapters, id }: { adapters: Adapters; id: str
|
|
|
203
231
|
{rt ? <button type="button" className="sac-button" onClick={() => void rt.retry(id)}>{S.retry}</button> : null}
|
|
204
232
|
</>
|
|
205
233
|
) : null}
|
|
234
|
+
{updating ? <p role="status" className="sac-muted" data-updating="">{S.updating}</p> : null}
|
|
235
|
+
{offline ? <p role="alert" className="sac-error">{S.offlineShowingSaved}</p> : null}
|
|
206
236
|
<StatusStrip status={status} owner={owner} label={S.stepLabel} step={stepOf(report)} timeline={timelineOf(report)} />
|
|
207
237
|
{typeof report.words === 'string' && report.words ? <p className="sac-words">{report.words}</p> : null}
|
|
208
238
|
{Object.entries(answers).filter(([, v]) => v).map(([k, v]) => <p key={k} className="sac-words" data-answer={k}>{v}</p>)}
|
package/src/client/entry.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// THE ENTRY: one control, `data-suggest-entry`, which is the contract the screen checker looks for.
|
|
3
3
|
// Signed out, it opens nothing and says why. Signed in, it hands over to the host (`onOpen`), which
|
|
4
4
|
// shows the sheet. `<ReportProblem>` is the same control for an error screen, already filled in.
|
|
5
|
-
import { useEffect, useState } from 'react'
|
|
5
|
+
import { useEffect, useState, type AriaRole } from 'react'
|
|
6
6
|
import type { Adapters } from './adapters.ts'
|
|
7
7
|
import { useRuntime } from './runtime.ts'
|
|
8
8
|
import { SuggestSheet } from './sheet.tsx'
|
|
@@ -15,7 +15,7 @@ export function KeepSending({ adapters }: { adapters: Adapters }) {
|
|
|
15
15
|
return null
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export function SuggestEntry({ adapters, onOpen, className }: { adapters: Adapters; onOpen(): void; className?: string }) {
|
|
18
|
+
export function SuggestEntry({ adapters, onOpen, className, role }: { adapters: Adapters; onOpen(): void; className?: string; /** An ARIA role for the button (for example `menuitem` when the host shows it as a menu row). Unset by default. */ role?: AriaRole }) {
|
|
19
19
|
const S = stringsFor(adapters.words)
|
|
20
20
|
const [why, setWhy] = useState('')
|
|
21
21
|
const [busy, setBusy] = useState(false)
|
|
@@ -32,7 +32,7 @@ export function SuggestEntry({ adapters, onOpen, className }: { adapters: Adapte
|
|
|
32
32
|
return (
|
|
33
33
|
<span className="sac-entry-wrap">
|
|
34
34
|
<KeepSending adapters={adapters} />
|
|
35
|
-
<button type="button" data-suggest-entry="" className={`sac-entry${className ? ` ${className}` : ''}`} aria-busy={busy || undefined} onClick={() => void press()}>{S.entry}</button>
|
|
35
|
+
<button type="button" role={role} data-suggest-entry="" className={`sac-entry${className ? ` ${className}` : ''}`} aria-busy={busy || undefined} onClick={() => void press()}>{S.entry}</button>
|
|
36
36
|
{why ? <span role="status" className="sac-entry-why">{why}</span> : null}
|
|
37
37
|
</span>
|
|
38
38
|
)
|
package/src/client/mine.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import type { Adapters } from './adapters.ts'
|
|
|
8
8
|
import { SuggestionDetail } from './detail.tsx'
|
|
9
9
|
import type { Summary } from './reports.ts'
|
|
10
10
|
import { useRuntime, useTick } from './runtime.ts'
|
|
11
|
+
import { dropCache, isRefusal, mineCacheKey, pruneOnes, readCache, useClearOnSignOut, writeCache } from './view-cache.ts'
|
|
11
12
|
import { Toasts } from './toast.tsx'
|
|
12
13
|
import { statusMeaning, statusWords, stringsFor } from './words.ts'
|
|
13
14
|
|
|
@@ -21,26 +22,71 @@ export function MySuggestions({ adapters, onOpen }: { adapters: Adapters; onOpen
|
|
|
21
22
|
const [rows, setRows] = useState<Row[] | null>(null)
|
|
22
23
|
const [open, setOpen] = useState<string | null>(null)
|
|
23
24
|
const [bad, setBad] = useState('')
|
|
25
|
+
const [updating, setUpdating] = useState(false)
|
|
24
26
|
|
|
25
27
|
// Another account (or none) shows nothing of the last one's rows.
|
|
26
28
|
const loads = useRef(0)
|
|
27
|
-
|
|
29
|
+
// What the app last said (or the phone kept of it) for this account; null until either is known.
|
|
30
|
+
const known = useRef<Summary[] | null>(null)
|
|
31
|
+
// The first answer after mount ends "Updating"; later periodic reloads refresh quietly.
|
|
32
|
+
const answered = useRef(false)
|
|
33
|
+
useClearOnSignOut(rt, who, adapters.words.app)
|
|
34
|
+
useEffect(() => { loads.current++; known.current = null; answered.current = false; setRows(null); setOpen(null); setBad(''); setUpdating(false) }, [rt])
|
|
28
35
|
|
|
29
36
|
const load = useCallback(async () => {
|
|
30
37
|
if (!rt) return
|
|
31
38
|
// Only the newest load may paint: one still running for the account just replaced must not draw its rows.
|
|
32
39
|
const mine_ = ++loads.current
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
const key = mineCacheKey({ app: adapters.words.app, account: rt.who.account })
|
|
41
|
+
const paint = (theirs: Summary[], local: Awaited<ReturnType<typeof rt.waiting>>) => {
|
|
42
|
+
const waiting = new Map(local.map((l) => [l.id, l]))
|
|
43
|
+
// A report the app already lists keeps its own status; one still on the phone is listed as on the phone.
|
|
44
|
+
const phone: Row[] = local.filter((l) => !l.onlyFollowUp && !theirs.some((t) => t.id === l.id)).map((l) => ({ id: l.id, status: 'on-phone', at: l.at, ...(l.words ? { words: l.words } : {}), ...(l.error ? { error: l.error } : {}) }))
|
|
45
|
+
setRows([...theirs.map((t) => ({ ...t, ...(waiting.get(t.id)?.error ? { error: waiting.get(t.id)!.error } : {}) })), ...phone].sort((a, b) => (b.at ?? 0) - (a.at ?? 0)))
|
|
46
|
+
}
|
|
47
|
+
// Draw at once from what the phone already holds, then ask the app.
|
|
48
|
+
if (known.current === null) {
|
|
49
|
+
const saved = await readCache<Summary[]>(rt.store, key)
|
|
50
|
+
if (mine_ !== loads.current) return
|
|
51
|
+
if (Array.isArray(saved)) known.current = saved
|
|
52
|
+
}
|
|
53
|
+
if (known.current !== null) {
|
|
54
|
+
const local = await rt.waiting()
|
|
55
|
+
if (mine_ !== loads.current) return
|
|
56
|
+
paint(known.current, local)
|
|
57
|
+
if (!answered.current) setUpdating(true)
|
|
58
|
+
}
|
|
59
|
+
let theirs: Summary[] | null = null
|
|
60
|
+
let refused = false
|
|
61
|
+
try { theirs = await adapters.store.mine() } catch (e) { theirs = null; refused = isRefusal(e) }
|
|
62
|
+
// The answer belongs to whoever asked. If the signed-in account is no longer that one, nothing is kept or drawn.
|
|
63
|
+
let now: { account: string } | null = null
|
|
64
|
+
try { now = await adapters.identity() } catch { now = null }
|
|
36
65
|
const local = await rt.waiting()
|
|
37
66
|
if (mine_ !== loads.current) return
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
67
|
+
if (!now || now.account !== rt.who.account) return
|
|
68
|
+
answered.current = true
|
|
69
|
+
setUpdating(false)
|
|
70
|
+
if (theirs) {
|
|
71
|
+
known.current = theirs
|
|
72
|
+
void writeCache(rt.store, key, theirs)
|
|
73
|
+
void pruneOnes(rt.store, { app: adapters.words.app, account: rt.who.account }, theirs.map((t) => t.id))
|
|
74
|
+
setBad('')
|
|
75
|
+
paint(theirs, local)
|
|
76
|
+
} else if (refused) {
|
|
77
|
+
// The app said no: what the phone held is no longer this person's to see.
|
|
78
|
+
known.current = null
|
|
79
|
+
void dropCache(rt.store, key)
|
|
80
|
+
setBad(S.couldNotLoadMine)
|
|
81
|
+
paint([], local)
|
|
82
|
+
} else if (known.current !== null) {
|
|
83
|
+
setBad(S.offlineShowingSaved)
|
|
84
|
+
paint(known.current, local)
|
|
85
|
+
} else {
|
|
86
|
+
setBad(S.couldNotLoadMine)
|
|
87
|
+
paint([], local)
|
|
88
|
+
}
|
|
89
|
+
}, [adapters, rt, S.couldNotLoadMine, S.offlineShowingSaved])
|
|
44
90
|
useEffect(() => { void load() }, [load, tick])
|
|
45
91
|
|
|
46
92
|
if (who === null) return <p role="alert">{S.signedOutMine}</p>
|
|
@@ -56,6 +102,7 @@ export function MySuggestions({ adapters, onOpen }: { adapters: Adapters; onOpen
|
|
|
56
102
|
<section className="sac-mine" aria-labelledby="sac-mine-title">
|
|
57
103
|
<h2 id="sac-mine-title" className="sac-title">{S.mineTitle}</h2>
|
|
58
104
|
{bad ? <p role="alert" className="sac-error">{bad}</p> : null}
|
|
105
|
+
{updating ? <p role="status" className="sac-muted" data-updating="">{S.updating}</p> : null}
|
|
59
106
|
{rows === null ? <p>{S.loading}</p> : rows.length === 0 ? <p>{S.mineEmpty}</p> : (
|
|
60
107
|
<ul className="sac-list">
|
|
61
108
|
{rows.map((r) => {
|
package/src/client/reports.ts
CHANGED
|
@@ -21,7 +21,9 @@ export interface Summary { id: string; status: string; kind?: string; at?: numbe
|
|
|
21
21
|
export interface StoreAdapter {
|
|
22
22
|
submit(report: Report): Promise<'done' | { refused: string }>
|
|
23
23
|
uploadPart(reportId: string, file: string, part: number, bytes: Blob): Promise<'done' | { refused: string }>
|
|
24
|
+
/** An unreachable app THROWS (a network error). A refusal (the list is no longer this person's) throws an error with `refused: true` or a `status` of 401, 403 or 404: the phone then drops what it kept. */
|
|
24
25
|
mine(): Promise<Summary[]>
|
|
26
|
+
/** Same contract as `mine`: a report that is gone or not this person's is a refusal (`refused: true`, or status 401/403/404, or an answer object with a string `refused`), never a plain failure, so the phone stops showing its saved copy. */
|
|
25
27
|
one(id: string): Promise<Report>
|
|
26
28
|
/** Adds to a report. Also at-least-once: it must dedupe by (id, `more.followId`). */
|
|
27
29
|
append(id: string, more: Record<string, unknown>): Promise<'done' | { refused: string }>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
// THE LAST ANSWER, KEPT ON THE PHONE. My suggestions and the detail view draw from this at once and then
|
|
3
|
+
// refresh from the app, so opening them never waits on the app being reachable. The key names the app and
|
|
4
|
+
// the account exactly as a draft's does, so one person's cache is never read for another.
|
|
5
|
+
import { useEffect, useRef } from 'react'
|
|
6
|
+
import { draftKey } from './drafts.ts'
|
|
7
|
+
import type { Store } from './idb.ts'
|
|
8
|
+
|
|
9
|
+
type Id = { app: string; account: string }
|
|
10
|
+
const base = (id: Id) => `${draftKey(id)}#view`
|
|
11
|
+
export const mineCacheKey = (id: Id) => `${base(id)}:mine`
|
|
12
|
+
export const oneCacheKey = (id: Id, reportId: string) => `${base(id)}:one:${reportId}`
|
|
13
|
+
|
|
14
|
+
export async function readCache<T>(store: Store, key: string): Promise<T | undefined> {
|
|
15
|
+
try { return await store.get<T>('kv', key) } catch { return undefined }
|
|
16
|
+
}
|
|
17
|
+
export async function writeCache(store: Store, key: string, value: unknown): Promise<void> {
|
|
18
|
+
try { await store.put('kv', key, value) } catch { /* a full or blocked database only costs the next open its head start */ }
|
|
19
|
+
}
|
|
20
|
+
export async function dropCache(store: Store, key: string): Promise<void> {
|
|
21
|
+
try { await store.del('kv', key) } catch { /* nothing to drop */ }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** After a successful list: cached reports the app no longer lists (deleted, or no longer this person's) are dropped. */
|
|
25
|
+
export async function pruneOnes(store: Store, id: Id, keep: string[]): Promise<void> {
|
|
26
|
+
try {
|
|
27
|
+
const prefix = `${base(id)}:one:`
|
|
28
|
+
const live = new Set(keep)
|
|
29
|
+
for (const row of await store.all('kv', prefix)) if (!live.has(row.key.slice(prefix.length))) await store.del('kv', row.key)
|
|
30
|
+
} catch { /* the next list prunes again */ }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Everything cached for one account: the list and every report. */
|
|
34
|
+
export async function clearViewCache(store: Store, id: Id): Promise<void> {
|
|
35
|
+
try { for (const row of await store.all('kv', base(id))) await store.del('kv', row.key) } catch { /* best effort */ }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** True when the app REFUSED (the report is gone, or this person may not see it), as against being unreachable.
|
|
39
|
+
* The adapter contract: `mine()` and `one()` throw an error carrying `refused: true`, or a `status` of 401, 403 or 404;
|
|
40
|
+
* `one()` may instead answer an object with a string `refused`. Anything else thrown is a failure to reach the app. */
|
|
41
|
+
export function isRefusal(e: unknown): boolean {
|
|
42
|
+
if (!e || typeof e !== 'object') return false
|
|
43
|
+
const x = e as { refused?: unknown; status?: unknown }
|
|
44
|
+
return x.refused === true || (typeof x.refused === 'string' && x.refused !== '') || x.status === 401 || x.status === 403 || x.status === 404
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** When the person signs out (identity becomes null), the account that was signed in loses its cached views on this phone. */
|
|
48
|
+
export function useClearOnSignOut(rt: { store: Store; who: { account: string } } | null, who: unknown, app: string): void {
|
|
49
|
+
const last = useRef<{ store: Store; id: Id } | null>(null)
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (rt) { last.current = { store: rt.store, id: { app, account: rt.who.account } }; return }
|
|
52
|
+
if (who === null && last.current) { const l = last.current; last.current = null; void clearViewCache(l.store, l.id) }
|
|
53
|
+
}, [rt, who, app])
|
|
54
|
+
}
|
package/src/client/words.ts
CHANGED
|
@@ -91,6 +91,8 @@ export interface Strings {
|
|
|
91
91
|
screenshotFailed: string
|
|
92
92
|
couldNotOpen: string
|
|
93
93
|
couldNotLoadMine: string
|
|
94
|
+
updating: string
|
|
95
|
+
offlineShowingSaved: string
|
|
94
96
|
addFailed: string
|
|
95
97
|
overLimit: string
|
|
96
98
|
stepLabel: string
|
|
@@ -172,6 +174,8 @@ export const DEFAULT_STRINGS: Strings = {
|
|
|
172
174
|
screenshotFailed: 'The screenshot could not be taken.',
|
|
173
175
|
couldNotOpen: 'This suggestion could not be opened.',
|
|
174
176
|
couldNotLoadMine: 'Your suggestions could not be loaded.',
|
|
177
|
+
updating: 'Updating',
|
|
178
|
+
offlineShowingSaved: 'Could not reach the app. Showing what this phone last saw.',
|
|
175
179
|
addFailed: 'That could not be added. Try again.',
|
|
176
180
|
overLimit: 'Something in this suggestion is over a limit. Remove an attachment and try again.',
|
|
177
181
|
stepLabel: 'Where it is',
|