@supersuit/suggest-a-change 0.0.0-stage → 0.1.0
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 +15 -0
- package/LICENSE +21 -0
- package/README.md +218 -2
- package/bin/check.mjs +4 -0
- package/bin/init.mjs +4 -0
- package/bin/suggest-a-change.mjs +23 -0
- package/dist/client/adapters.d.ts +101 -0
- package/dist/client/adapters.js +1 -0
- package/dist/client/default-field.d.ts +2 -0
- package/dist/client/default-field.js +99 -0
- package/dist/client/detail.d.ts +17 -0
- package/dist/client/detail.js +215 -0
- package/dist/client/dialog.d.ts +2 -0
- package/dist/client/dialog.js +53 -0
- package/dist/client/drafts.d.ts +28 -0
- package/dist/client/drafts.js +39 -0
- package/dist/client/entry.d.ts +12 -0
- package/dist/client/entry.js +55 -0
- package/dist/client/idb.d.ts +12 -0
- package/dist/client/idb.js +22 -0
- package/dist/client/index.d.ts +16 -0
- package/dist/client/index.js +16 -0
- package/dist/client/mine.d.ts +5 -0
- package/dist/client/mine.js +55 -0
- package/dist/client/op-id.d.ts +5 -0
- package/dist/client/op-id.js +27 -0
- package/dist/client/outbox.d.ts +35 -0
- package/dist/client/outbox.js +70 -0
- package/dist/client/receipt.d.ts +15 -0
- package/dist/client/receipt.js +44 -0
- package/dist/client/recorder.d.ts +21 -0
- package/dist/client/recorder.js +124 -0
- package/dist/client/reports.d.ts +76 -0
- package/dist/client/reports.js +91 -0
- package/dist/client/runtime.d.ts +63 -0
- package/dist/client/runtime.js +225 -0
- package/dist/client/sheet.d.ts +28 -0
- package/dist/client/sheet.js +339 -0
- package/dist/client/toast.d.ts +20 -0
- package/dist/client/toast.js +73 -0
- package/dist/client/words.d.ts +106 -0
- package/dist/client/words.js +150 -0
- package/dist/contract.d.mts +83 -0
- package/dist/contract.mjs +155 -0
- package/dist/server/conformance.d.ts +27 -0
- package/dist/server/conformance.js +303 -0
- package/dist/server/handlers.d.ts +177 -0
- package/dist/server/handlers.js +525 -0
- package/dist/server/index.d.ts +7 -0
- package/dist/server/index.js +5 -0
- package/dist/server/rate.d.ts +16 -0
- package/dist/server/rate.js +27 -0
- package/dist/server/sniff.d.ts +5 -0
- package/dist/server/sniff.js +90 -0
- package/package.json +81 -4
- package/src/check/args.mjs +16 -0
- package/src/check/cli.mjs +45 -0
- package/src/check/index.mjs +437 -0
- package/src/check/init.mjs +183 -0
- package/src/client/adapters.ts +78 -0
- package/src/client/default-field.tsx +101 -0
- package/src/client/detail.tsx +240 -0
- package/src/client/dialog.ts +34 -0
- package/src/client/drafts.ts +59 -0
- package/src/client/entry.tsx +54 -0
- package/src/client/idb.ts +35 -0
- package/src/client/index.ts +16 -0
- package/src/client/mine.tsx +84 -0
- package/src/client/op-id.ts +26 -0
- package/src/client/outbox.ts +80 -0
- package/src/client/receipt.tsx +47 -0
- package/src/client/recorder.ts +109 -0
- package/src/client/reports.ts +131 -0
- package/src/client/runtime.ts +222 -0
- package/src/client/sheet.tsx +437 -0
- package/src/client/styles.css +63 -0
- package/src/client/toast.tsx +72 -0
- package/src/client/words.ts +237 -0
- package/src/contract.mjs +146 -0
- package/src/server/conformance.ts +282 -0
- package/src/server/handlers.ts +469 -0
- package/src/server/index.ts +8 -0
- package/src/server/rate.ts +29 -0
- package/src/server/sniff.ts +68 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// ONE SUGGESTION: the words, the attachments, the step strip from the flow, and a way to add to it until
|
|
4
|
+
// it has an end status. A report's words are data: they are rendered as text, never as markup. A report
|
|
5
|
+
// that is still only on this phone is shown from the phone's own copy, with the app's refusal if it gave one.
|
|
6
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
7
|
+
import { END, STATUSES } from '../contract.mjs';
|
|
8
|
+
import { DefaultField } from './default-field.js';
|
|
9
|
+
import { draftKey } from './drafts.js';
|
|
10
|
+
import { recoverTakes } from './recorder.js';
|
|
11
|
+
import { queueFollowUp } from './reports.js';
|
|
12
|
+
import { useRuntime, useTick } from './runtime.js';
|
|
13
|
+
import { Toasts } from './toast.js';
|
|
14
|
+
import { effectiveCeilings, lengthWords, sizeWords, statusMeaning, statusWords, stringsFor } from './words.js';
|
|
15
|
+
export function statusOf(r) {
|
|
16
|
+
if (typeof r.status === 'string')
|
|
17
|
+
return r.status;
|
|
18
|
+
const t = r.timeline;
|
|
19
|
+
if (Array.isArray(t) && t.length) {
|
|
20
|
+
const last = t[t.length - 1];
|
|
21
|
+
if (typeof last?.status === 'string')
|
|
22
|
+
return last.status;
|
|
23
|
+
}
|
|
24
|
+
return 'received';
|
|
25
|
+
}
|
|
26
|
+
/** The step shown under "Being made". The timeline entry is the truth (that is where the receiving side puts it); a top-level `step` is read only when there is no timeline. */
|
|
27
|
+
export function stepOf(r) {
|
|
28
|
+
const t = r.timeline;
|
|
29
|
+
if (Array.isArray(t) && t.length) {
|
|
30
|
+
const s = t[t.length - 1]?.step;
|
|
31
|
+
return typeof s === 'string' && s ? s : undefined;
|
|
32
|
+
}
|
|
33
|
+
return typeof r.step === 'string' ? r.step : undefined;
|
|
34
|
+
}
|
|
35
|
+
const timelineOf = (r) => Array.isArray(r?.timeline) ? r.timeline.map((t) => String(t?.status)) : undefined;
|
|
36
|
+
/** The strip is made from the contract's own statuses, one step per distinct word ("planned" reads as "With Freeda"),
|
|
37
|
+
* and a step is marked from the report's timeline: reached, skipped (the report went past it), or still ahead. */
|
|
38
|
+
export function StatusStrip({ status, owner, step, timeline, label }) {
|
|
39
|
+
const nodes = useMemo(() => {
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const id of STATUSES.filter((s) => !END.includes(s))) {
|
|
42
|
+
const words = statusWords(id, owner);
|
|
43
|
+
const had = out.find((n) => n.words === words);
|
|
44
|
+
if (had)
|
|
45
|
+
had.ids.push(id);
|
|
46
|
+
else
|
|
47
|
+
out.push({ words, ids: [id] });
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}, [owner]);
|
|
51
|
+
const ended = END.includes(status);
|
|
52
|
+
const here = nodes.findIndex((n) => n.ids.includes(status));
|
|
53
|
+
const at = ended ? nodes.length : Math.max(0, here);
|
|
54
|
+
const items = [...nodes, { words: statusWords(ended ? status : 'live', owner), ids: [ended ? status : 'end'] }];
|
|
55
|
+
const reached = new Set(timeline ?? []);
|
|
56
|
+
return (_jsx("ol", { className: "sac-strip", "aria-label": label ?? stringsFor({ app: 'x', owner }).stepLabel, "data-status": status, children: items.map((it, i) => {
|
|
57
|
+
const state = i === at ? 'now'
|
|
58
|
+
: it.ids.some((id) => reached.has(id)) ? 'past'
|
|
59
|
+
: i > at ? 'next'
|
|
60
|
+
: timeline ? 'skipped'
|
|
61
|
+
// No timeline to read: an open report has passed what is before it; an ended one may have skipped any step.
|
|
62
|
+
: ended ? 'unknown' : 'past';
|
|
63
|
+
return (_jsxs("li", { className: "sac-step", "data-state": state, "aria-current": i === at ? 'step' : undefined, children: [_jsx("span", { children: it.words }), i === at && it.ids.includes('being-made') && step ? _jsx("span", { className: "sac-step-detail", children: step }) : null] }, it.words + i));
|
|
64
|
+
}) }));
|
|
65
|
+
}
|
|
66
|
+
function makeDeferred() { let done = () => { }; const ready = new Promise((r) => { done = r; }); return { ready, done: () => done() }; }
|
|
67
|
+
const newFollowId = () => `f-${Math.random().toString(36).slice(2, 8)}`;
|
|
68
|
+
export function SuggestionDetail({ adapters, id }) {
|
|
69
|
+
const S = stringsFor(adapters.words);
|
|
70
|
+
const Field = adapters.Field ?? DefaultField;
|
|
71
|
+
const { rt, who } = useRuntime(adapters);
|
|
72
|
+
const tick = useTick(rt);
|
|
73
|
+
const ceilings = useMemo(() => effectiveCeilings(adapters.limits), [adapters.limits]);
|
|
74
|
+
const [report, setReport] = useState(undefined);
|
|
75
|
+
const [refusal, setRefusal] = useState();
|
|
76
|
+
const [pending, setPending] = useState([]);
|
|
77
|
+
const [more, setMore] = useState('');
|
|
78
|
+
const [heard, setHeard] = useState([]);
|
|
79
|
+
const [note, setNote] = useState('');
|
|
80
|
+
const [bad, setBad] = useState('');
|
|
81
|
+
const [ready, setReady] = useState(false);
|
|
82
|
+
// The follow-up being written: its id is made when the person starts it, so queuing it twice is one follow-up.
|
|
83
|
+
const followId = useRef(null);
|
|
84
|
+
const n = useRef(0);
|
|
85
|
+
const key = who ? draftKey({ app: adapters.words.app, account: who.account }) : null;
|
|
86
|
+
const fuKey = key ? `${key}#fu:${id}` : null;
|
|
87
|
+
const labels = { holdToTalk: S.holdToTalk, stopTalking: S.stopTalking, micDenied: S.micDenied, voiceUnavailable: S.voiceUnavailable, voiceTotalLimit: S.voiceTotalLimit };
|
|
88
|
+
// Each load takes a number; only the newest may paint, so a load still running for the account that
|
|
89
|
+
// was just replaced never draws its rows under the new one.
|
|
90
|
+
const loads = useRef(0);
|
|
91
|
+
// Anything that writes the follow-up waits until its draft has been read, so the counter is right.
|
|
92
|
+
const restored = useRef(makeDeferred());
|
|
93
|
+
// A different account (or none) shows nothing of the last one.
|
|
94
|
+
useEffect(() => { loads.current++; setReport(undefined); setPending([]); setReady(false); setMore(''); setHeard([]); followId.current = null; n.current = 0; restored.current = makeDeferred(); }, [rt]);
|
|
95
|
+
const load = useCallback(async () => {
|
|
96
|
+
if (!rt)
|
|
97
|
+
return;
|
|
98
|
+
const mine_ = ++loads.current;
|
|
99
|
+
const fresh = () => mine_ === loads.current;
|
|
100
|
+
try {
|
|
101
|
+
const waiting = await rt.followUps(id);
|
|
102
|
+
const mine = await rt.local(id);
|
|
103
|
+
if (!fresh())
|
|
104
|
+
return;
|
|
105
|
+
setPending(waiting);
|
|
106
|
+
if (mine) {
|
|
107
|
+
setReport({ ...mine.report, status: 'on-phone' });
|
|
108
|
+
setRefusal(mine.error);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
setRefusal(undefined);
|
|
112
|
+
const r = (await adapters.store.one(id));
|
|
113
|
+
if (fresh())
|
|
114
|
+
setReport(r);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
if (fresh())
|
|
118
|
+
setReport(null);
|
|
119
|
+
}
|
|
120
|
+
}, [adapters, id, rt]);
|
|
121
|
+
useEffect(() => { void load(); }, [load, tick]);
|
|
122
|
+
// The follow-up in progress is kept on the phone as it is written, and a take cut off by a closed app rejoins it.
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (!rt || !fuKey || !key)
|
|
125
|
+
return;
|
|
126
|
+
let live = true;
|
|
127
|
+
void (async () => {
|
|
128
|
+
const d = await rt.store.get('kv', fuKey);
|
|
129
|
+
let list = d?.heard ?? [];
|
|
130
|
+
// The counter starts from the notes already kept, before a rescued take takes the next number.
|
|
131
|
+
followId.current = d?.followId ?? followId.current;
|
|
132
|
+
n.current = list.reduce((m, h) => Math.max(m, Number(h.key.slice(h.key.lastIndexOf(':') + 1)) || 0), 0);
|
|
133
|
+
const rescued = await recoverTakes(rt.store, (q) => q === `${key}|field:append-${id}`);
|
|
134
|
+
for (const t of rescued) {
|
|
135
|
+
const blob = await rt.store.get('blobs', t.key);
|
|
136
|
+
if (!blob)
|
|
137
|
+
continue;
|
|
138
|
+
followId.current ??= d?.followId ?? newFollowId();
|
|
139
|
+
const k = `fu:${id}:${followId.current}:${++n.current}`;
|
|
140
|
+
await rt.store.put('blobs', k, blob);
|
|
141
|
+
await rt.store.del('blobs', t.key);
|
|
142
|
+
list = [...list, { key: k, file: `added-${followId.current}-${n.current}.${(blob.type ?? '').split('/')[1]?.split(';')[0] || 'bin'}`, ms: t.ms }];
|
|
143
|
+
}
|
|
144
|
+
if (!live)
|
|
145
|
+
return;
|
|
146
|
+
// What was typed while this was loading wins over the saved words.
|
|
147
|
+
if (d)
|
|
148
|
+
setMore((cur) => (cur.trim() ? cur : d.words));
|
|
149
|
+
setHeard((cur) => [...list, ...cur]);
|
|
150
|
+
setReady(true);
|
|
151
|
+
restored.current.done();
|
|
152
|
+
})();
|
|
153
|
+
return () => { live = false; };
|
|
154
|
+
}, [rt, fuKey, key, id]);
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (!rt || !fuKey || !ready)
|
|
157
|
+
return;
|
|
158
|
+
if (!more.trim() && !heard.length)
|
|
159
|
+
void rt.store.del('kv', fuKey);
|
|
160
|
+
else
|
|
161
|
+
void rt.store.put('kv', fuKey, { followId: (followId.current ??= newFollowId()), words: more, heard });
|
|
162
|
+
}, [rt, fuKey, ready, more, heard]);
|
|
163
|
+
if (who === null)
|
|
164
|
+
return _jsx("p", { role: "alert", children: S.signedOutMine });
|
|
165
|
+
if (report === undefined)
|
|
166
|
+
return _jsx("p", { children: S.loading });
|
|
167
|
+
if (report === null)
|
|
168
|
+
return _jsx("p", { role: "alert", children: S.couldNotOpen });
|
|
169
|
+
const status = statusOf(report);
|
|
170
|
+
const owner = adapters.words.owner;
|
|
171
|
+
const open = !END.includes(status) && status !== 'on-phone';
|
|
172
|
+
const answers = (report.answers ?? {});
|
|
173
|
+
const voice = Array.isArray(report.voice) ? report.voice : [];
|
|
174
|
+
const media = Array.isArray(report.media) ? report.media : [];
|
|
175
|
+
// Words and voice notes follow the report through the outbox, so they survive a bad connection like the report did.
|
|
176
|
+
async function add() {
|
|
177
|
+
const words = more.trim();
|
|
178
|
+
if (!rt || !key || !fuKey || (!words && !heard.length))
|
|
179
|
+
return;
|
|
180
|
+
setBad('');
|
|
181
|
+
setNote('');
|
|
182
|
+
try {
|
|
183
|
+
const fid = (followId.current ??= newFollowId());
|
|
184
|
+
await queueFollowUp({ outbox: rt.outbox, reportId: id, followId: fid, words, voice: heard.map((h) => ({ file: h.file, ms: h.ms, blob: h.key })), draft: key, ...(adapters.phone?.now ? { now: adapters.phone.now } : {}) });
|
|
185
|
+
await rt.store.del('kv', fuKey);
|
|
186
|
+
followId.current = null;
|
|
187
|
+
n.current = 0;
|
|
188
|
+
setMore('');
|
|
189
|
+
setHeard([]);
|
|
190
|
+
setNote(S.appended);
|
|
191
|
+
void rt.drain();
|
|
192
|
+
void load();
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
setBad(S.addFailed);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// A note recorded here is kept on the phone (the recorder's own blob goes) and sent when Add is tapped.
|
|
199
|
+
async function keepHeard(original, blob, ms) {
|
|
200
|
+
if (!rt)
|
|
201
|
+
return { refused: S.loading };
|
|
202
|
+
if (ms > ceilings.voiceMs)
|
|
203
|
+
return { refused: S.voiceTooLong };
|
|
204
|
+
await restored.current.ready;
|
|
205
|
+
const fid = (followId.current ??= newFollowId());
|
|
206
|
+
const k = `fu:${id}:${fid}:${++n.current}`;
|
|
207
|
+
await rt.store.put('blobs', k, blob);
|
|
208
|
+
if (original && original !== k)
|
|
209
|
+
await rt.store.del('blobs', original);
|
|
210
|
+
const file = `added-${fid}-${n.current}.${(blob.type ?? '').split('/')[1]?.split(';')[0] || 'bin'}`;
|
|
211
|
+
setHeard((cur) => [...cur, { key: k, file, ms }]);
|
|
212
|
+
return { audio: k, text: '' };
|
|
213
|
+
}
|
|
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, {})] }));
|
|
215
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
// A labelled dialog's behaviour: focus goes in when it opens, Tab stays inside it (listened for on the
|
|
3
|
+
// document, so it holds even after the focused button has gone), Escape closes it, and focus goes back
|
|
4
|
+
// to what had it when it closes.
|
|
5
|
+
import { useEffect } from 'react';
|
|
6
|
+
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
7
|
+
export function useDialog(ref, onClose) {
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const box = ref.current;
|
|
10
|
+
if (!box)
|
|
11
|
+
return;
|
|
12
|
+
const before = document.activeElement;
|
|
13
|
+
const items = () => [...box.querySelectorAll(FOCUSABLE)].filter((el) => !el.hasAttribute('hidden'));
|
|
14
|
+
(items()[0] ?? box).focus();
|
|
15
|
+
function onKey(e) {
|
|
16
|
+
if (!box.isConnected)
|
|
17
|
+
return;
|
|
18
|
+
if (e.key === 'Escape' && onClose) {
|
|
19
|
+
e.stopPropagation();
|
|
20
|
+
onClose();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (e.key !== 'Tab')
|
|
24
|
+
return;
|
|
25
|
+
const list = items();
|
|
26
|
+
if (!list.length) {
|
|
27
|
+
e.preventDefault();
|
|
28
|
+
box.focus();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const first = list[0];
|
|
32
|
+
const last = list[list.length - 1];
|
|
33
|
+
const active = document.activeElement;
|
|
34
|
+
if (!box.contains(active)) {
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
(e.shiftKey ? last : first).focus();
|
|
37
|
+
}
|
|
38
|
+
else if (e.shiftKey && active === first) {
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
last.focus();
|
|
41
|
+
}
|
|
42
|
+
else if (!e.shiftKey && active === last) {
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
first.focus();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
document.addEventListener('keydown', onKey);
|
|
48
|
+
return () => { document.removeEventListener('keydown', onKey); if (before && document.contains(before))
|
|
49
|
+
before.focus(); };
|
|
50
|
+
// The dialog opens once per mount; onClose is read through the closure it was given.
|
|
51
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
52
|
+
}, []);
|
|
53
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Store } from './idb.ts';
|
|
2
|
+
export interface Draft {
|
|
3
|
+
/** Made when the draft starts; it is the dedupe key everywhere the report travels. */
|
|
4
|
+
reportId: string;
|
|
5
|
+
kind?: string;
|
|
6
|
+
words: string;
|
|
7
|
+
/** Keys of attachments held in the `blobs` store (voice takes, images, one video). */
|
|
8
|
+
attachments: string[];
|
|
9
|
+
updatedAt: number;
|
|
10
|
+
/** Set when the draft's report has been put in the outbox. The next save then starts a new draft. */
|
|
11
|
+
queued?: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** `app` may not contain ':' (the account may), so two different pairs can never make the same key. */
|
|
14
|
+
export declare function draftKey({ app, account }: {
|
|
15
|
+
app: string;
|
|
16
|
+
account: string;
|
|
17
|
+
}): string;
|
|
18
|
+
/** A draft with `queued: true` is read-only for a UI: the next saveDraft replaces it with a fresh one. */
|
|
19
|
+
export declare function loadDraft(store: Store, key: string): Promise<Draft | undefined>;
|
|
20
|
+
/** Saves what the person has so far. The first save of a draft makes its report id; later saves keep it,
|
|
21
|
+
* until the report is queued: a save after that starts a new draft with a new id, so words typed while
|
|
22
|
+
* the old report is still sending are never part of it and never lost when it lands. */
|
|
23
|
+
export declare function saveDraft(store: Store, key: string, fields?: Partial<Pick<Draft, 'kind' | 'words' | 'attachments'>>, { now, makeId }?: {
|
|
24
|
+
now?: () => number;
|
|
25
|
+
makeId?: () => string;
|
|
26
|
+
}): Promise<Draft>;
|
|
27
|
+
/** Clears the draft. With `reportId`, only if the draft still belongs to that report. */
|
|
28
|
+
export declare function clearDraft(store: Store, key: string, reportId?: string): Promise<void>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// A DRAFT BELONGS TO ONE APP AND ONE ACCOUNT. Two people can use one phone (a shared tablet, a signed-out
|
|
2
|
+
// and signed-in again), and a draft is the most private thing the button holds: half a thought, maybe a
|
|
3
|
+
// complaint about the person who is about to pick the phone up. So the key names both, and switching
|
|
4
|
+
// people can never show the other's draft. The report id is made the moment the draft starts and stays
|
|
5
|
+
// with it, so a send that is repeated (a double tap, a retry after a lost reply) is the same report.
|
|
6
|
+
import { newReportId } from '../contract.mjs';
|
|
7
|
+
/** `app` may not contain ':' (the account may), so two different pairs can never make the same key. */
|
|
8
|
+
export function draftKey({ app, account }) {
|
|
9
|
+
if (app.includes(':'))
|
|
10
|
+
throw new Error(`an app name cannot contain ":" (got ${JSON.stringify(app)})`);
|
|
11
|
+
return `sac:${app}:${account}`;
|
|
12
|
+
}
|
|
13
|
+
/** A draft with `queued: true` is read-only for a UI: the next saveDraft replaces it with a fresh one. */
|
|
14
|
+
export async function loadDraft(store, key) {
|
|
15
|
+
return store.get('kv', key);
|
|
16
|
+
}
|
|
17
|
+
/** Saves what the person has so far. The first save of a draft makes its report id; later saves keep it,
|
|
18
|
+
* until the report is queued: a save after that starts a new draft with a new id, so words typed while
|
|
19
|
+
* the old report is still sending are never part of it and never lost when it lands. */
|
|
20
|
+
export async function saveDraft(store, key, fields = {}, { now = () => Date.now(), makeId = () => newReportId() } = {}) {
|
|
21
|
+
const queuedBefore = await loadDraft(store, key);
|
|
22
|
+
const had = queuedBefore?.queued ? undefined : queuedBefore;
|
|
23
|
+
const draft = {
|
|
24
|
+
reportId: had?.reportId ?? makeId(),
|
|
25
|
+
words: '',
|
|
26
|
+
attachments: [],
|
|
27
|
+
...had,
|
|
28
|
+
...fields,
|
|
29
|
+
updatedAt: now(),
|
|
30
|
+
};
|
|
31
|
+
await store.put('kv', key, draft);
|
|
32
|
+
return draft;
|
|
33
|
+
}
|
|
34
|
+
/** Clears the draft. With `reportId`, only if the draft still belongs to that report. */
|
|
35
|
+
export async function clearDraft(store, key, reportId) {
|
|
36
|
+
if (reportId !== undefined && (await loadDraft(store, key))?.reportId !== reportId)
|
|
37
|
+
return;
|
|
38
|
+
await store.del('kv', key);
|
|
39
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Adapters } from './adapters.ts';
|
|
2
|
+
export declare function SuggestEntry({ adapters, onOpen, className }: {
|
|
3
|
+
adapters: Adapters;
|
|
4
|
+
onOpen(): void;
|
|
5
|
+
className?: string;
|
|
6
|
+
}): import("react").JSX.Element;
|
|
7
|
+
/** For an error screen: opens the sheet as "something is broken", with the screen and what went wrong filled in. */
|
|
8
|
+
export declare function ReportProblem({ adapters, screen, error }: {
|
|
9
|
+
adapters: Adapters;
|
|
10
|
+
screen: string;
|
|
11
|
+
error?: unknown;
|
|
12
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// THE ENTRY: one control, `data-suggest-entry`, which is the contract the screen checker looks for.
|
|
4
|
+
// Signed out, it opens nothing and says why. Signed in, it hands over to the host (`onOpen`), which
|
|
5
|
+
// shows the sheet. `<ReportProblem>` is the same control for an error screen, already filled in.
|
|
6
|
+
import { useEffect, useState } from 'react';
|
|
7
|
+
import { SuggestSheet } from './sheet.js';
|
|
8
|
+
import { stringsFor } from './words.js';
|
|
9
|
+
export function SuggestEntry({ adapters, onOpen, className }) {
|
|
10
|
+
const S = stringsFor(adapters.words);
|
|
11
|
+
const [why, setWhy] = useState('');
|
|
12
|
+
const [busy, setBusy] = useState(false);
|
|
13
|
+
useEffect(() => { setWhy(''); }, [adapters]);
|
|
14
|
+
async function press() {
|
|
15
|
+
setBusy(true);
|
|
16
|
+
let who = null;
|
|
17
|
+
try {
|
|
18
|
+
who = await adapters.identity();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
who = null;
|
|
22
|
+
}
|
|
23
|
+
setBusy(false);
|
|
24
|
+
if (!who) {
|
|
25
|
+
setWhy(S.signedOut);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
setWhy('');
|
|
29
|
+
onOpen();
|
|
30
|
+
}
|
|
31
|
+
return (_jsxs("span", { className: "sac-entry-wrap", children: [_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] }));
|
|
32
|
+
}
|
|
33
|
+
/** For an error screen: opens the sheet as "something is broken", with the screen and what went wrong filled in. */
|
|
34
|
+
export function ReportProblem({ adapters, screen, error }) {
|
|
35
|
+
const S = stringsFor(adapters.words);
|
|
36
|
+
const [open, setOpen] = useState(false);
|
|
37
|
+
const [why, setWhy] = useState('');
|
|
38
|
+
const happened = error instanceof Error ? error.message : error == null ? '' : String(error);
|
|
39
|
+
async function press() {
|
|
40
|
+
let who = null;
|
|
41
|
+
try {
|
|
42
|
+
who = await adapters.identity();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
who = null;
|
|
46
|
+
}
|
|
47
|
+
if (!who) {
|
|
48
|
+
setWhy(S.signedOut);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
setWhy('');
|
|
52
|
+
setOpen(true);
|
|
53
|
+
}
|
|
54
|
+
return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "sac-button sac-report-problem", onClick: () => void press(), children: S.reportProblem }), why ? _jsx("span", { role: "status", className: "sac-entry-why", children: why }) : null, open ? (_jsx(SuggestSheet, { adapters: adapters, where: { screen, route: typeof location === 'undefined' ? '' : location.pathname }, initial: { kind: 'broken', happened }, onClose: () => setOpen(false) })) : null] }));
|
|
55
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type StoreName = 'kv' | 'outbox' | 'blobs';
|
|
2
|
+
export interface Store {
|
|
3
|
+
get<T = unknown>(store: StoreName, key: string): Promise<T | undefined>;
|
|
4
|
+
put(store: StoreName, key: string, value: unknown): Promise<void>;
|
|
5
|
+
del(store: StoreName, key: string): Promise<void>;
|
|
6
|
+
/** Every record, or only those whose key starts with `prefix` (read without touching the rest). */
|
|
7
|
+
all<T = unknown>(store: StoreName, prefix?: string): Promise<{
|
|
8
|
+
key: string;
|
|
9
|
+
value: T;
|
|
10
|
+
}[]>;
|
|
11
|
+
}
|
|
12
|
+
export declare function openStore(name?: string): Promise<Store>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const STORES = ['kv', 'outbox', 'blobs'];
|
|
2
|
+
const req = (r) => new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });
|
|
3
|
+
export async function openStore(name = 'suggest-a-change') {
|
|
4
|
+
const open = indexedDB.open(name, 1);
|
|
5
|
+
open.onupgradeneeded = () => { for (const s of STORES)
|
|
6
|
+
if (!open.result.objectStoreNames.contains(s))
|
|
7
|
+
open.result.createObjectStore(s); };
|
|
8
|
+
const db = await req(open);
|
|
9
|
+
const tx = (s, mode) => db.transaction(s, mode).objectStore(s);
|
|
10
|
+
const done = (t) => new Promise((res, rej) => { t.transaction.oncomplete = () => res(); t.transaction.onerror = () => rej(t.transaction.error); t.transaction.onabort = () => rej(t.transaction.error); });
|
|
11
|
+
return {
|
|
12
|
+
get: (s, k) => req(tx(s, 'readonly').get(k)),
|
|
13
|
+
async put(s, k, v) { const t = tx(s, 'readwrite'); t.put(v, k); await done(t); },
|
|
14
|
+
async del(s, k) { const t = tx(s, 'readwrite'); t.delete(k); await done(t); },
|
|
15
|
+
async all(s, prefix) {
|
|
16
|
+
const t = tx(s, 'readonly');
|
|
17
|
+
const range = prefix ? IDBKeyRange.bound(prefix, `${prefix}`) : undefined;
|
|
18
|
+
const [keys, values] = await Promise.all([req(t.getAllKeys(range)), req(t.getAll(range))]);
|
|
19
|
+
return keys.map((k, i) => ({ key: String(k), value: values[i] }));
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export * from './idb.ts';
|
|
2
|
+
export * from './op-id.ts';
|
|
3
|
+
export * from './outbox.ts';
|
|
4
|
+
export * from './recorder.ts';
|
|
5
|
+
export * from './drafts.ts';
|
|
6
|
+
export * from './reports.ts';
|
|
7
|
+
export * from './words.ts';
|
|
8
|
+
export * from './adapters.ts';
|
|
9
|
+
export * from './toast.tsx';
|
|
10
|
+
export * from './default-field.tsx';
|
|
11
|
+
export * from './receipt.tsx';
|
|
12
|
+
export * from './entry.tsx';
|
|
13
|
+
export * from './sheet.tsx';
|
|
14
|
+
export * from './mine.tsx';
|
|
15
|
+
export * from './detail.tsx';
|
|
16
|
+
export { refreshIdentity } from './runtime.ts';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export * from './idb.js';
|
|
2
|
+
export * from './op-id.js';
|
|
3
|
+
export * from './outbox.js';
|
|
4
|
+
export * from './recorder.js';
|
|
5
|
+
export * from './drafts.js';
|
|
6
|
+
export * from './reports.js';
|
|
7
|
+
export * from './words.js';
|
|
8
|
+
export * from './adapters.js';
|
|
9
|
+
export * from './toast.js';
|
|
10
|
+
export * from './default-field.js';
|
|
11
|
+
export * from './receipt.js';
|
|
12
|
+
export * from './entry.js';
|
|
13
|
+
export * from './sheet.js';
|
|
14
|
+
export * from './mine.js';
|
|
15
|
+
export * from './detail.js';
|
|
16
|
+
export { refreshIdentity } from './runtime.js';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// MY SUGGESTIONS: everything this person has sent, newest first, each with its status in plain words.
|
|
4
|
+
// A report still on the phone is listed too (as "On this phone"), with the app's refusal and a Try again
|
|
5
|
+
// when it gave one. Only this signed-in account's reports are shown; another person's waiting reports
|
|
6
|
+
// on the same phone are not.
|
|
7
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
8
|
+
import { SuggestionDetail } from './detail.js';
|
|
9
|
+
import { useRuntime, useTick } from './runtime.js';
|
|
10
|
+
import { Toasts } from './toast.js';
|
|
11
|
+
import { statusMeaning, statusWords, stringsFor } from './words.js';
|
|
12
|
+
export function MySuggestions({ adapters, onOpen }) {
|
|
13
|
+
const S = stringsFor(adapters.words);
|
|
14
|
+
const owner = adapters.words.owner;
|
|
15
|
+
const { rt, who } = useRuntime(adapters);
|
|
16
|
+
const tick = useTick(rt);
|
|
17
|
+
const [rows, setRows] = useState(null);
|
|
18
|
+
const [open, setOpen] = useState(null);
|
|
19
|
+
const [bad, setBad] = useState('');
|
|
20
|
+
// Another account (or none) shows nothing of the last one's rows.
|
|
21
|
+
const loads = useRef(0);
|
|
22
|
+
useEffect(() => { loads.current++; setRows(null); setOpen(null); }, [rt]);
|
|
23
|
+
const load = useCallback(async () => {
|
|
24
|
+
if (!rt)
|
|
25
|
+
return;
|
|
26
|
+
// Only the newest load may paint: one still running for the account just replaced must not draw its rows.
|
|
27
|
+
const mine_ = ++loads.current;
|
|
28
|
+
let theirs = [];
|
|
29
|
+
let failed = false;
|
|
30
|
+
try {
|
|
31
|
+
theirs = await adapters.store.mine();
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
failed = true;
|
|
35
|
+
}
|
|
36
|
+
const local = await rt.waiting();
|
|
37
|
+
if (mine_ !== loads.current)
|
|
38
|
+
return;
|
|
39
|
+
setBad(failed ? S.couldNotLoadMine : '');
|
|
40
|
+
const waiting = new Map(local.map((l) => [l.id, l]));
|
|
41
|
+
// A report the app already lists keeps its own status; one still on the phone is listed as on the phone.
|
|
42
|
+
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 } : {}) }));
|
|
43
|
+
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)));
|
|
44
|
+
}, [adapters, rt, S.couldNotLoadMine]);
|
|
45
|
+
useEffect(() => { void load(); }, [load, tick]);
|
|
46
|
+
if (who === null)
|
|
47
|
+
return _jsx("p", { role: "alert", children: S.signedOutMine });
|
|
48
|
+
if (open && !onOpen) {
|
|
49
|
+
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
|
+
}
|
|
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) => {
|
|
52
|
+
const preview = typeof r.words === 'string' ? r.words : typeof r.title === 'string' ? r.title : '';
|
|
53
|
+
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
|
+
}) })), _jsx(Toasts, {})] }));
|
|
55
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const stableHash: (s: string) => string;
|
|
2
|
+
export declare function opId(...parts: string[]): string;
|
|
3
|
+
/** One take's own id inside a write: the write's id plus the take's key, so two takes into one note
|
|
4
|
+
* are two receipts, and a replay of one take (its reply was lost) is still answered once. */
|
|
5
|
+
export declare const takeOpId: (writeId: string, takeKey: string) => string;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// AN OP ID THE MAC ALWAYS TAKES. The receiving side answers a write or a take exactly once by its op id,
|
|
2
|
+
// and refuses any id outside ^[\w-]{2,80}$ . An id built from data (a project
|
|
3
|
+
// slug with a dot in it, a long judgment id) can fall outside that, and then every retry is refused
|
|
4
|
+
// and the write never lands. opId builds the id from its parts deterministically, so the same write
|
|
5
|
+
// made twice (a remount, a replay) still gets the same id: the plain joined parts when they already
|
|
6
|
+
// fit, else a sanitized, truncated form with a stable hash of the original on the end.
|
|
7
|
+
const OP = /^[\w-]{2,80}$/;
|
|
8
|
+
/** FNV-1a, 32 bits, from a seed: two of these make a stable 64-bit fingerprint with no dependencies. */
|
|
9
|
+
function fnv(s, seed) {
|
|
10
|
+
let h = seed >>> 0;
|
|
11
|
+
for (let i = 0; i < s.length; i++) {
|
|
12
|
+
h ^= s.charCodeAt(i);
|
|
13
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
14
|
+
}
|
|
15
|
+
return h >>> 0;
|
|
16
|
+
}
|
|
17
|
+
export const stableHash = (s) => fnv(s, 0x811c9dc5).toString(36) + fnv(s, 0x9747b28c).toString(36);
|
|
18
|
+
export function opId(...parts) {
|
|
19
|
+
const plain = parts.join('-');
|
|
20
|
+
if (OP.test(plain))
|
|
21
|
+
return plain;
|
|
22
|
+
const clean = plain.replace(/[^\w-]+/g, '_').slice(0, 60);
|
|
23
|
+
return `${clean}-${stableHash(plain)}`;
|
|
24
|
+
}
|
|
25
|
+
/** One take's own id inside a write: the write's id plus the take's key, so two takes into one note
|
|
26
|
+
* are two receipts, and a replay of one take (its reply was lost) is still answered once. */
|
|
27
|
+
export const takeOpId = (writeId, takeKey) => opId(writeId, takeKey);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Store } from './idb.ts';
|
|
2
|
+
export interface Op {
|
|
3
|
+
id: string;
|
|
4
|
+
/** A name the host app chooses for its own writes. The module adds 'report' and 'reportPart' (see reports.ts). */
|
|
5
|
+
kind: string;
|
|
6
|
+
body: Record<string, unknown>;
|
|
7
|
+
/** A recorded take in the `blobs` store, deleted with the op once it lands. */
|
|
8
|
+
blob?: string;
|
|
9
|
+
/** The id of the write this one belongs to (a part's report or follow-up). While that write is refused, this one is held, not sent. */
|
|
10
|
+
parent?: string;
|
|
11
|
+
at: number;
|
|
12
|
+
error?: string;
|
|
13
|
+
}
|
|
14
|
+
export type Sent = 'done' | {
|
|
15
|
+
refused: string;
|
|
16
|
+
};
|
|
17
|
+
export interface OutboxState {
|
|
18
|
+
waiting: number;
|
|
19
|
+
refused: number;
|
|
20
|
+
landed?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function makeOutbox({ store, send, reach }: {
|
|
23
|
+
store: Store;
|
|
24
|
+
send(op: Op): Promise<Sent>;
|
|
25
|
+
reach: {
|
|
26
|
+
check(force?: boolean): Promise<boolean>;
|
|
27
|
+
mark(up: boolean): void;
|
|
28
|
+
};
|
|
29
|
+
}): {
|
|
30
|
+
pending: () => Promise<Op[]>;
|
|
31
|
+
enqueue(op: Op): Promise<void>;
|
|
32
|
+
drain(): Promise<void>;
|
|
33
|
+
subscribe(fn: (s: OutboxState) => void): () => void;
|
|
34
|
+
refresh: () => Promise<void>;
|
|
35
|
+
};
|