@supersuit/suggest-a-change 0.1.10 → 0.1.12
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 +10 -0
- package/dist/client/recorder.d.ts +15 -1
- package/dist/client/recorder.js +19 -2
- package/dist/client/sheet.js +17 -14
- package/dist/client/words.d.ts +3 -0
- package/dist/client/words.js +3 -1
- package/package.json +1 -1
- package/src/client/recorder.ts +21 -3
- package/src/client/sheet.tsx +23 -33
- package/src/client/styles.css +5 -0
- package/src/client/words.ts +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.1.12 (2026-10-19)
|
|
6
|
+
|
|
7
|
+
- **The kind is one small menu, and saying it is one big box for every kind.** The sheet drew three radio rows for the kind and a one-line field for the words, so the choice took the space and the place to speak looked like an afterthought. The kind is now a compact `<select>` labelled "What kind?", starting on "Change something", and the words go in one multi-line box (at least 9rem tall whatever field the host supplies, through `.sac-sayit textarea`) whose placeholder follows the kind. "Something is broken" no longer adds three question boxes: its placeholder asks what happened, what was expected and how to make it happen again, and the answer goes in `words`. A report no longer carries `answers` (the contract still accepts them). A draft left with answers from an earlier version folds them into the box once. New `Words` keys: `sayItPlaceholderBroken`, `sayItPlaceholderFeedback`; `brokenHappened`, `brokenExpected`, `brokenSteps` and `optional` stay so an override still typechecks. `ReportProblem`'s `happened` now starts the box.
|
|
8
|
+
|
|
9
|
+
## 0.1.11 (2026-10-18)
|
|
10
|
+
|
|
11
|
+
- **A second tab no longer takes over the first tab's recording.** Two tabs share one IndexedDB, and a tab that opened while another was recording ran `recoverTakes` over the live take: it deleted its marker and chunks, so every second said after that had no marker and was lost if the recording tab then died (freedom-app-template#1). Each second of a take now refreshes `beatAt` on its marker, and `recoverTakes` leaves a take that beat within `LIVE_TAKE_MS` (5 s, exported) exactly as it is; it is made whole on an open after it goes quiet. `recoverTakes(store, claim, { now, liveMs })` takes the clock for tests. An app reopened within 5 s of a crash recovers that take on its next open instead of this one; nothing is lost.
|
|
12
|
+
|
|
3
13
|
## 0.1.10 (2026-10-17)
|
|
4
14
|
|
|
5
15
|
- **What goes with it is one small line.** The sheet no longer draws a section with a legend, an explainer and four switches above Send. Every diagnostic (the app's version, the time, the screen size, the phone type) goes with every report, beside the screen's name and address, and one muted line says what goes and to whom. Whoever acts on a report needs all of them, so leaving one out only ever cost a round trip. The `Words` keys the switches used (`withLegend`, `alwaysSent`, `include`, `screenName`) stay, so an app that overrides them still typechecks.
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import type { Store } from './idb.ts';
|
|
2
2
|
export declare function pickMime(): string;
|
|
3
|
+
/**
|
|
4
|
+
* A take whose last second landed this recently is LIVE in some page, never abandoned. Two tabs
|
|
5
|
+
* share one IndexedDB, so a second tab opening while the first records would otherwise "recover"
|
|
6
|
+
* the first tab's take mid-recording: delete its marker and chunks, and leave every second said
|
|
7
|
+
* after that with no marker, lost if that tab then dies (freedom-app-template#1). Each second of a
|
|
8
|
+
* take refreshes `beatAt`, so a take nobody is recording goes quiet within a second and is made
|
|
9
|
+
* whole on the next open after this window. Chunks arrive every 1000 ms; the margin covers a busy
|
|
10
|
+
* main thread.
|
|
11
|
+
*/
|
|
12
|
+
export declare const LIVE_TAKE_MS = 5000;
|
|
3
13
|
export interface Recorded {
|
|
4
14
|
key: string;
|
|
5
15
|
blob: Blob;
|
|
@@ -14,7 +24,11 @@ export declare function startTake(store: Store, key: string, questionId: string)
|
|
|
14
24
|
}>;
|
|
15
25
|
/** Takes the app was closed in the middle of, made whole again. */
|
|
16
26
|
/** `claim` limits the recovery to takes the caller owns (by question id); the rest are left exactly as they are for their owner. */
|
|
17
|
-
|
|
27
|
+
/** A take still beating (another tab recording it, LIVE_TAKE_MS) is left exactly as it is; it is recovered on an open after it goes quiet. */
|
|
28
|
+
export declare function recoverTakes(store: Store, claim?: (questionId: string) => boolean, { now, liveMs }?: {
|
|
29
|
+
now?: number;
|
|
30
|
+
liveMs?: number;
|
|
31
|
+
}): Promise<{
|
|
18
32
|
questionId: string;
|
|
19
33
|
key: string;
|
|
20
34
|
ms: number;
|
package/dist/client/recorder.js
CHANGED
|
@@ -6,6 +6,16 @@ export function pickMime() {
|
|
|
6
6
|
return m;
|
|
7
7
|
return '';
|
|
8
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* A take whose last second landed this recently is LIVE in some page, never abandoned. Two tabs
|
|
11
|
+
* share one IndexedDB, so a second tab opening while the first records would otherwise "recover"
|
|
12
|
+
* the first tab's take mid-recording: delete its marker and chunks, and leave every second said
|
|
13
|
+
* after that with no marker, lost if that tab then dies (freedom-app-template#1). Each second of a
|
|
14
|
+
* take refreshes `beatAt`, so a take nobody is recording goes quiet within a second and is made
|
|
15
|
+
* whole on the next open after this window. Chunks arrive every 1000 ms; the margin covers a busy
|
|
16
|
+
* main thread.
|
|
17
|
+
*/
|
|
18
|
+
export const LIVE_TAKE_MS = 5000;
|
|
9
19
|
export async function startTake(store, key, questionId) {
|
|
10
20
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
|
|
11
21
|
const mime = pickMime();
|
|
@@ -14,13 +24,16 @@ export async function startTake(store, key, questionId) {
|
|
|
14
24
|
const started = Date.now();
|
|
15
25
|
const chunks = [];
|
|
16
26
|
const writes = [];
|
|
17
|
-
|
|
27
|
+
const meta = { questionId, mime: type, startedAt: started, beatAt: started };
|
|
28
|
+
await store.put('kv', `rec:${key}`, meta);
|
|
18
29
|
rec.ondataavailable = (e) => {
|
|
19
30
|
if (!e.data?.size)
|
|
20
31
|
return;
|
|
21
32
|
const seq = chunks.length;
|
|
22
33
|
chunks.push(e.data);
|
|
23
34
|
writes.push(store.put('blobs', `${key}#${String(seq).padStart(5, '0')}`, e.data).catch(() => { }));
|
|
35
|
+
// The heartbeat another tab's recovery reads (LIVE_TAKE_MS).
|
|
36
|
+
writes.push(store.put('kv', `rec:${key}`, { ...meta, beatAt: Date.now() }).catch(() => { }));
|
|
24
37
|
};
|
|
25
38
|
rec.start(1000);
|
|
26
39
|
const meter = makeMeter(stream);
|
|
@@ -100,7 +113,8 @@ function makeMeter(stream) {
|
|
|
100
113
|
}
|
|
101
114
|
/** Takes the app was closed in the middle of, made whole again. */
|
|
102
115
|
/** `claim` limits the recovery to takes the caller owns (by question id); the rest are left exactly as they are for their owner. */
|
|
103
|
-
|
|
116
|
+
/** A take still beating (another tab recording it, LIVE_TAKE_MS) is left exactly as it is; it is recovered on an open after it goes quiet. */
|
|
117
|
+
export async function recoverTakes(store, claim, { now = Date.now(), liveMs = LIVE_TAKE_MS } = {}) {
|
|
104
118
|
const out = [];
|
|
105
119
|
// By prefix: this runs before the first paint, and `kv` also holds every saved view.
|
|
106
120
|
const metas = (await store.all('kv', 'rec:')).filter((r) => r.key.startsWith('rec:'));
|
|
@@ -110,6 +124,9 @@ export async function recoverTakes(store, claim) {
|
|
|
110
124
|
for (const { key: metaKey, value: m } of metas) {
|
|
111
125
|
if (claim && !claim(m.questionId))
|
|
112
126
|
continue;
|
|
127
|
+
const beat = Number(m.beatAt ?? m.startedAt);
|
|
128
|
+
if (Number.isFinite(beat) && now - beat < liveMs)
|
|
129
|
+
continue;
|
|
113
130
|
const key = metaKey.slice(4);
|
|
114
131
|
const parts = blobs.filter((b) => b.key.startsWith(`${key}#`)).sort((a, b) => a.key.localeCompare(b.key));
|
|
115
132
|
if (parts.length) {
|
package/dist/client/sheet.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
// THE SHEET (design section 2): kind, say it, show it, what goes with it, send. Phone first.
|
|
3
|
+
// THE SHEET (design section 2): kind, say it, show it, what goes with it, send. Phone first. The kind is one
|
|
4
|
+
// small menu and saying it is one big box for every kind, broken included: the box is what takes the space,
|
|
5
|
+
// so a person sees first that they will be heard out (the operator, 2026-10-19). Everything
|
|
4
6
|
// typed is saved as it is typed, every recording as it is made, and nothing leaves the phone except
|
|
5
7
|
// through the outbox, so a closed app loses nothing. A voice note alone is a whole report.
|
|
6
8
|
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
@@ -15,8 +17,10 @@ import { useRuntime, useTick } from './runtime.js';
|
|
|
15
17
|
import { toast, Toasts } from './toast.js';
|
|
16
18
|
import { effectiveCeilings, lengthWords, phoneType, screenSize, sizeWords, stringsFor } from './words.js';
|
|
17
19
|
const NO_ANSWERS = { happened: '', expected: '', steps: '' };
|
|
20
|
+
const joinWords = (...parts) => parts.map((p) => p.trim()).filter(Boolean).join('\n\n');
|
|
18
21
|
// The fields of this sheet. A take cut off by a closed app is routed back by its question id, which names
|
|
19
|
-
// the draft (app and account), so only the same account's interrupted take rejoins its draft.
|
|
22
|
+
// the draft (app and account), so only the same account's interrupted take rejoins its draft. The three
|
|
23
|
+
// broken questions are gone from the sheet; their ids stay so a take cut off before the change still rejoins.
|
|
20
24
|
const OUR_FIELDS = ['words', 'happened', 'expected', 'steps'];
|
|
21
25
|
const metaKey = (key) => `sac-meta:${key}`;
|
|
22
26
|
const answersKey = (draft) => `${draft}#answers`;
|
|
@@ -65,8 +69,8 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
|
|
|
65
69
|
const box = useRef(null);
|
|
66
70
|
useDialog(box, onClose);
|
|
67
71
|
const [kind, setKind] = useState(initial?.kind ?? 'change');
|
|
68
|
-
|
|
69
|
-
const [
|
|
72
|
+
// "Report a problem" can open with what went wrong already said: it starts the box.
|
|
73
|
+
const [words, setWords] = useState(initial?.happened ?? '');
|
|
70
74
|
const [voice, setVoice] = useState([]);
|
|
71
75
|
const [media, setMedia] = useState([]);
|
|
72
76
|
const [error, setError] = useState('');
|
|
@@ -107,9 +111,12 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
|
|
|
107
111
|
// "Report a problem" opens as broken whatever kind an earlier draft was.
|
|
108
112
|
if (!initial?.kind && (d.kind === 'change' || d.kind === 'broken' || d.kind === 'feedback'))
|
|
109
113
|
setKind(d.kind);
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
114
|
+
// A draft from when broken asked three questions keeps its answers: they join the one box, once.
|
|
115
|
+
const restored = joinWords(d.words, a.happened, a.expected, a.steps);
|
|
116
|
+
setWords((cur) => (restored.includes(cur.trim()) ? restored : joinWords(restored, cur)));
|
|
117
|
+
if (a !== NO_ANSWERS)
|
|
118
|
+
await rt.store.del('kv', answersKey(key));
|
|
119
|
+
if (restored.trim() || takes.length || metas.length)
|
|
113
120
|
toast({ id: 'sac-restored', text: S.restored });
|
|
114
121
|
}
|
|
115
122
|
voiceN.current = highest(takes.map((t) => t.key));
|
|
@@ -148,9 +155,8 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
|
|
|
148
155
|
const fields = { kind, words, attachments: [...voice.map((v) => v.key), ...media.map((m) => m.key)] };
|
|
149
156
|
chain.current = chain.current.then(async () => {
|
|
150
157
|
await saveDraft(rt.store, key, fields);
|
|
151
|
-
await rt.store.put('kv', answersKey(key), answers);
|
|
152
158
|
}).catch(() => { });
|
|
153
|
-
}, [rt, key, loaded, sent, kind, words,
|
|
159
|
+
}, [rt, key, loaded, sent, kind, words, voice, media]);
|
|
154
160
|
const ensureId = useCallback(async () => {
|
|
155
161
|
await chain.current;
|
|
156
162
|
return (await saveDraft(rt.store, key, {})).reportId;
|
|
@@ -262,8 +268,7 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
|
|
|
262
268
|
].filter((d) => Boolean(d.value));
|
|
263
269
|
const lineParts = [`this screen (${where.screen}, ${sanitizeRoute(where.route)})`, ...avail.map((d) => d.label)];
|
|
264
270
|
const summary = `${S.goesTo(adapters.words.owner)} ${S.includes(lineParts)}`;
|
|
265
|
-
|
|
266
|
-
const hasAnything = Boolean(words.trim() || voice.length || media.length || (kind === 'broken' && (answers.happened.trim() || answers.expected.trim() || answers.steps.trim())));
|
|
271
|
+
const hasAnything = Boolean(words.trim() || voice.length || media.length);
|
|
267
272
|
async function send() {
|
|
268
273
|
if (!rt || !who || !key || !hasAnything)
|
|
269
274
|
return;
|
|
@@ -279,7 +284,6 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
|
|
|
279
284
|
...(adapters.about ? { app: Object.fromEntries(Object.entries(adapters.about).filter(([k]) => k !== 'version')) } : {}),
|
|
280
285
|
who: { member: who.account, role: who.role },
|
|
281
286
|
words: words.trim(),
|
|
282
|
-
...(kind === 'broken' ? { answers: { happened: answers.happened.trim(), expected: answers.expected.trim(), steps: answers.steps.trim() } } : {}),
|
|
283
287
|
voice: voice.map((v) => ({ file: v.file, ms: v.ms, field: v.field })),
|
|
284
288
|
media: await Promise.all(media.map(async (m) => {
|
|
285
289
|
const blob = await rt.store.get('blobs', m.key);
|
|
@@ -299,7 +303,6 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
|
|
|
299
303
|
...media.map((m) => ({ file: m.file, part: 0, blob: m.key })),
|
|
300
304
|
];
|
|
301
305
|
await queueReport({ store: rt.store, outbox: rt.outbox, report, parts: partRefs, draft: key, ...(adapters.phone?.now ? { now: adapters.phone.now } : {}) });
|
|
302
|
-
await rt.store.del('kv', answersKey(key));
|
|
303
306
|
for (const v of voice)
|
|
304
307
|
await rt.store.del('kv', metaKey(v.key));
|
|
305
308
|
for (const m of media)
|
|
@@ -331,5 +334,5 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
|
|
|
331
334
|
return (_jsx("div", { className: "sac-backdrop", "data-suggest-sheet": "", children: _jsxs("div", { ref: box, className: "sac-sheet", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId, tabIndex: -1, children: [_jsxs("header", { className: "sac-header", children: [_jsx("h2", { id: titleId, className: "sac-title", children: S.sheetTitle }), onClose ? _jsx("button", { type: "button", className: "sac-button sac-close", onClick: onClose, children: S.close }) : null] }), _jsx("div", { className: "sac-sr", "data-sac-live": "", role: "status", "aria-live": "polite", "aria-atomic": "true", children: announce }), who === undefined ? _jsx("p", { children: S.loading }) : who === null ? _jsx("p", { role: "alert", children: S.signedOut }) : !rt ? _jsx("p", { children: S.loading }) : sent ? (_jsxs("div", { className: "sac-sent", children: [_jsx(Receipt, { adapters: adapters, reportId: sent.id, quiet: true, onLine: setAnnounce }), sent.files.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.attachmentsLabel, children: sent.files.map((f) => {
|
|
332
335
|
const p = rt.part(sent.id, f.file);
|
|
333
336
|
return (_jsxs("li", { className: "sac-item", "data-attachment-state": p.state, children: [_jsxs("span", { className: "sac-item-name", children: [f.name, f.bytes ? `, ${sizeWords(f.bytes)}` : ''] }), _jsxs("span", { className: "sac-item-state", children: [p.state === 'sending' ? _jsx("progress", { "aria-label": `${S.sending} ${f.name}` }) : null, p.state === 'waiting' ? S.waitingToSend : p.state === 'sending' ? S.sending : p.state === 'sent' ? S.sentWord : S.didNotSend(p.reason ?? '')] }), p.state === 'failed' ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.retry(sent.id), children: S.retry }) : null] }, f.file));
|
|
334
|
-
}) })) : null, onClose ? _jsx("button", { type: "button", className: "sac-button sac-primary", onClick: onClose, children: S.done }) : null] })) : (_jsxs("form", { className: "sac-form", onSubmit: (e) => { e.preventDefault(); void send(); }, children: [_jsxs("
|
|
337
|
+
}) })) : null, onClose ? _jsx("button", { type: "button", className: "sac-button sac-primary", onClick: onClose, children: S.done }) : null] })) : (_jsxs("form", { className: "sac-form", onSubmit: (e) => { e.preventDefault(); void send(); }, children: [_jsxs("div", { className: "sac-kind", children: [_jsx("label", { htmlFor: `${titleId}-kind`, children: S.kindLegend }), _jsx("select", { id: `${titleId}-kind`, className: "sac-select", value: kind, onChange: (e) => setKind(e.target.value), children: kinds.map((k) => _jsx("option", { value: k.id, children: k.label }, k.id)) })] }), _jsxs("fieldset", { className: "sac-group sac-sayit", children: [_jsx("legend", { children: S.sayItLegend }), fieldFor('words', S.sayItLabel, words, setWords, { placeholder: kind === 'broken' ? S.sayItPlaceholderBroken : kind === 'feedback' ? S.sayItPlaceholderFeedback : S.sayItPlaceholder }), voice.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.voiceNotesLabel, children: voice.map((v, i) => (_jsxs("li", { className: "sac-item", children: [_jsx("span", { className: "sac-item-name", children: S.voiceNote(i + 1, lengthWords(v.ms)) }), _jsx("button", { type: "button", className: "sac-button", "aria-label": `${S.remove} ${S.voiceNote(i + 1, lengthWords(v.ms))}`, onClick: () => void removeVoice(v), children: S.remove })] }, v.key))) })) : null] }), _jsxs("fieldset", { className: "sac-group", children: [_jsx("legend", { children: S.showItLegend }), _jsxs("div", { className: "sac-actions", children: [_jsx("button", { type: "button", className: "sac-button", onClick: () => fileInput.current?.click(), children: S.addImages }), _jsx("button", { type: "button", className: "sac-button", onClick: () => videoInput.current?.click(), children: S.addVideo }), adapters.snapshot ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void takeScreenshot(), children: S.takeScreenshot }) : null] }), _jsx("input", { ref: fileInput, type: "file", accept: "image/*", multiple: true, hidden: true, "aria-label": S.addImages, "data-sac-images": "", onChange: (e) => { const f = [...(e.target.files ?? [])]; e.target.value = ''; void addMedia(f, 'image'); } }), _jsx("input", { ref: videoInput, type: "file", accept: "video/*", hidden: true, "aria-label": S.addVideo, "data-sac-video": "", onChange: (e) => { const f = [...(e.target.files ?? [])]; e.target.value = ''; void addMedia(f, 'video'); } }), media.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.attachmentsLabel, children: media.map((m) => (_jsxs("li", { className: "sac-item", children: [_jsxs("span", { className: "sac-item-name", children: [m.name, ", ", sizeWords(m.bytes)] }), _jsx("button", { type: "button", className: "sac-button", "aria-label": `${S.remove} ${m.name}`, onClick: () => void removeMedia(m), children: S.remove })] }, m.key))) })) : null] }), _jsx("p", { className: "sac-muted sac-fyi", "data-sac-summary": "", children: summary }), error ? _jsx("p", { role: "alert", className: "sac-error", children: error }) : null, _jsxs("div", { className: "sac-actions", children: [_jsx("button", { type: "submit", className: "sac-button sac-primary", disabled: !hasAnything, "aria-describedby": hasAnything ? undefined : `${titleId}-hint`, children: S.send }), hasAnything ? null : _jsx("span", { id: `${titleId}-hint`, className: "sac-muted", children: S.sendHint })] })] })), _jsx(Toasts, {})] }) }));
|
|
335
338
|
}
|
package/dist/client/words.d.ts
CHANGED
|
@@ -25,6 +25,9 @@ export interface Strings {
|
|
|
25
25
|
sayItLegend: string;
|
|
26
26
|
sayItLabel: string;
|
|
27
27
|
sayItPlaceholder: string;
|
|
28
|
+
/** The one box's placeholder when the kind is "Something is broken": it asks what the three questions used to. */
|
|
29
|
+
sayItPlaceholderBroken: string;
|
|
30
|
+
sayItPlaceholderFeedback: string;
|
|
28
31
|
showItLegend: string;
|
|
29
32
|
addImages: string;
|
|
30
33
|
addVideo: string;
|
package/dist/client/words.js
CHANGED
|
@@ -20,7 +20,9 @@ export const DEFAULT_STRINGS = {
|
|
|
20
20
|
optional: 'optional',
|
|
21
21
|
sayItLegend: 'Say it',
|
|
22
22
|
sayItLabel: 'Your suggestion',
|
|
23
|
-
sayItPlaceholder: 'Type it, or hold the microphone and say it',
|
|
23
|
+
sayItPlaceholder: 'What would you change? Type it, or hold the microphone and say it',
|
|
24
|
+
sayItPlaceholderBroken: 'What happened, what you expected, and how to make it happen again. Type it, or hold the microphone and say it',
|
|
25
|
+
sayItPlaceholderFeedback: 'Whatever is on your mind. Type it, or hold the microphone and say it',
|
|
24
26
|
showItLegend: 'Show it',
|
|
25
27
|
addImages: 'Add a screenshot or photo',
|
|
26
28
|
addVideo: 'Add a short video',
|
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.12",
|
|
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/recorder.ts
CHANGED
|
@@ -10,7 +10,18 @@ export function pickMime(): string {
|
|
|
10
10
|
return ''
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
interface Meta { questionId: string; mime: string; startedAt: number }
|
|
13
|
+
interface Meta { questionId: string; mime: string; startedAt: number; beatAt?: number }
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A take whose last second landed this recently is LIVE in some page, never abandoned. Two tabs
|
|
17
|
+
* share one IndexedDB, so a second tab opening while the first records would otherwise "recover"
|
|
18
|
+
* the first tab's take mid-recording: delete its marker and chunks, and leave every second said
|
|
19
|
+
* after that with no marker, lost if that tab then dies (freedom-app-template#1). Each second of a
|
|
20
|
+
* take refreshes `beatAt`, so a take nobody is recording goes quiet within a second and is made
|
|
21
|
+
* whole on the next open after this window. Chunks arrive every 1000 ms; the margin covers a busy
|
|
22
|
+
* main thread.
|
|
23
|
+
*/
|
|
24
|
+
export const LIVE_TAKE_MS = 5000
|
|
14
25
|
export interface Recorded { key: string; blob: Blob; ms: number }
|
|
15
26
|
|
|
16
27
|
export async function startTake(store: Store, key: string, questionId: string) {
|
|
@@ -21,12 +32,15 @@ export async function startTake(store: Store, key: string, questionId: string) {
|
|
|
21
32
|
const started = Date.now()
|
|
22
33
|
const chunks: Blob[] = []
|
|
23
34
|
const writes: Promise<void>[] = []
|
|
24
|
-
|
|
35
|
+
const meta: Meta = { questionId, mime: type, startedAt: started, beatAt: started }
|
|
36
|
+
await store.put('kv', `rec:${key}`, meta)
|
|
25
37
|
rec.ondataavailable = (e) => {
|
|
26
38
|
if (!e.data?.size) return
|
|
27
39
|
const seq = chunks.length
|
|
28
40
|
chunks.push(e.data)
|
|
29
41
|
writes.push(store.put('blobs', `${key}#${String(seq).padStart(5, '0')}`, e.data).catch(() => {}))
|
|
42
|
+
// The heartbeat another tab's recovery reads (LIVE_TAKE_MS).
|
|
43
|
+
writes.push(store.put('kv', `rec:${key}`, { ...meta, beatAt: Date.now() }).catch(() => {}))
|
|
30
44
|
}
|
|
31
45
|
rec.start(1000)
|
|
32
46
|
const meter = makeMeter(stream)
|
|
@@ -88,7 +102,9 @@ function makeMeter(stream: MediaStream): { level(): number; close(): void } | nu
|
|
|
88
102
|
|
|
89
103
|
/** Takes the app was closed in the middle of, made whole again. */
|
|
90
104
|
/** `claim` limits the recovery to takes the caller owns (by question id); the rest are left exactly as they are for their owner. */
|
|
91
|
-
|
|
105
|
+
/** A take still beating (another tab recording it, LIVE_TAKE_MS) is left exactly as it is; it is recovered on an open after it goes quiet. */
|
|
106
|
+
export async function recoverTakes(store: Store, claim?: (questionId: string) => boolean,
|
|
107
|
+
{ now = Date.now(), liveMs = LIVE_TAKE_MS }: { now?: number; liveMs?: number } = {}): Promise<{ questionId: string; key: string; ms: number }[]> {
|
|
92
108
|
const out: { questionId: string; key: string; ms: number }[] = []
|
|
93
109
|
// By prefix: this runs before the first paint, and `kv` also holds every saved view.
|
|
94
110
|
const metas = (await store.all<Meta>('kv', 'rec:')).filter((r) => r.key.startsWith('rec:'))
|
|
@@ -96,6 +112,8 @@ export async function recoverTakes(store: Store, claim?: (questionId: string) =>
|
|
|
96
112
|
const blobs = await store.all<Blob>('blobs')
|
|
97
113
|
for (const { key: metaKey, value: m } of metas) {
|
|
98
114
|
if (claim && !claim(m.questionId)) continue
|
|
115
|
+
const beat = Number(m.beatAt ?? m.startedAt)
|
|
116
|
+
if (Number.isFinite(beat) && now - beat < liveMs) continue
|
|
99
117
|
const key = metaKey.slice(4)
|
|
100
118
|
const parts = blobs.filter((b) => b.key.startsWith(`${key}#`)).sort((a, b) => a.key.localeCompare(b.key))
|
|
101
119
|
if (parts.length) {
|
package/src/client/sheet.tsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
|
-
// THE SHEET (design section 2): kind, say it, show it, what goes with it, send. Phone first.
|
|
2
|
+
// THE SHEET (design section 2): kind, say it, show it, what goes with it, send. Phone first. The kind is one
|
|
3
|
+
// small menu and saying it is one big box for every kind, broken included: the box is what takes the space,
|
|
4
|
+
// so a person sees first that they will be heard out (the operator, 2026-10-19). Everything
|
|
3
5
|
// typed is saved as it is typed, every recording as it is made, and nothing leaves the phone except
|
|
4
6
|
// through the outbox, so a closed app loses nothing. A voice note alone is a whole report.
|
|
5
7
|
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
|
@@ -20,9 +22,11 @@ export interface Voice { key: string; file: string; ms: number; field: string }
|
|
|
20
22
|
export interface Media { key: string; file: string; name: string; type: string; bytes: number; ms?: number }
|
|
21
23
|
type Answers = { happened: string; expected: string; steps: string }
|
|
22
24
|
const NO_ANSWERS: Answers = { happened: '', expected: '', steps: '' }
|
|
25
|
+
const joinWords = (...parts: string[]) => parts.map((p) => p.trim()).filter(Boolean).join('\n\n')
|
|
23
26
|
type Diag = 'appVersion' | 'at' | 'screen' | 'device'
|
|
24
27
|
// The fields of this sheet. A take cut off by a closed app is routed back by its question id, which names
|
|
25
|
-
// the draft (app and account), so only the same account's interrupted take rejoins its draft.
|
|
28
|
+
// the draft (app and account), so only the same account's interrupted take rejoins its draft. The three
|
|
29
|
+
// broken questions are gone from the sheet; their ids stay so a take cut off before the change still rejoins.
|
|
26
30
|
const OUR_FIELDS = ['words', 'happened', 'expected', 'steps']
|
|
27
31
|
|
|
28
32
|
const metaKey = (key: string) => `sac-meta:${key}`
|
|
@@ -71,8 +75,8 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
|
|
|
71
75
|
useDialog(box, onClose)
|
|
72
76
|
|
|
73
77
|
const [kind, setKind] = useState<Kind>(initial?.kind ?? 'change')
|
|
74
|
-
|
|
75
|
-
const [
|
|
78
|
+
// "Report a problem" can open with what went wrong already said: it starts the box.
|
|
79
|
+
const [words, setWords] = useState(initial?.happened ?? '')
|
|
76
80
|
const [voice, setVoice] = useState<Voice[]>([])
|
|
77
81
|
const [media, setMedia] = useState<Media[]>([])
|
|
78
82
|
const [error, setError] = useState('')
|
|
@@ -110,9 +114,11 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
|
|
|
110
114
|
if (!live) return
|
|
111
115
|
// "Report a problem" opens as broken whatever kind an earlier draft was.
|
|
112
116
|
if (!initial?.kind && (d.kind === 'change' || d.kind === 'broken' || d.kind === 'feedback')) setKind(d.kind)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
117
|
+
// A draft from when broken asked three questions keeps its answers: they join the one box, once.
|
|
118
|
+
const restored = joinWords(d.words, a.happened, a.expected, a.steps)
|
|
119
|
+
setWords((cur) => (restored.includes(cur.trim()) ? restored : joinWords(restored, cur)))
|
|
120
|
+
if (a !== NO_ANSWERS) await rt.store.del('kv', answersKey(key))
|
|
121
|
+
if (restored.trim() || takes.length || metas.length) toast({ id: 'sac-restored', text: S.restored })
|
|
116
122
|
}
|
|
117
123
|
voiceN.current = highest(takes.map((t) => t.key))
|
|
118
124
|
mediaN.current = highest(metas.map((m) => m.key))
|
|
@@ -148,9 +154,8 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
|
|
|
148
154
|
const fields = { kind, words, attachments: [...voice.map((v) => v.key), ...media.map((m) => m.key)] }
|
|
149
155
|
chain.current = chain.current.then(async () => {
|
|
150
156
|
await saveDraft(rt.store, key, fields)
|
|
151
|
-
await rt.store.put('kv', answersKey(key), answers)
|
|
152
157
|
}).catch(() => {})
|
|
153
|
-
}, [rt, key, loaded, sent, kind, words,
|
|
158
|
+
}, [rt, key, loaded, sent, kind, words, voice, media])
|
|
154
159
|
|
|
155
160
|
const ensureId = useCallback(async () => {
|
|
156
161
|
await chain.current
|
|
@@ -261,8 +266,7 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
|
|
|
261
266
|
const lineParts = [`this screen (${where.screen}, ${sanitizeRoute(where.route)})`, ...avail.map((d) => d.label)]
|
|
262
267
|
const summary = `${S.goesTo(adapters.words.owner)} ${S.includes(lineParts)}`
|
|
263
268
|
|
|
264
|
-
|
|
265
|
-
const hasAnything = Boolean(words.trim() || voice.length || media.length || (kind === 'broken' && (answers.happened.trim() || answers.expected.trim() || answers.steps.trim())))
|
|
269
|
+
const hasAnything = Boolean(words.trim() || voice.length || media.length)
|
|
266
270
|
|
|
267
271
|
async function send() {
|
|
268
272
|
if (!rt || !who || !key || !hasAnything) return
|
|
@@ -277,7 +281,6 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
|
|
|
277
281
|
...(adapters.about ? { app: Object.fromEntries(Object.entries(adapters.about).filter(([k]) => k !== 'version')) } : {}),
|
|
278
282
|
who: { member: who.account, role: who.role },
|
|
279
283
|
words: words.trim(),
|
|
280
|
-
...(kind === 'broken' ? { answers: { happened: answers.happened.trim(), expected: answers.expected.trim(), steps: answers.steps.trim() } } : {}),
|
|
281
284
|
voice: voice.map((v) => ({ file: v.file, ms: v.ms, field: v.field })),
|
|
282
285
|
media: await Promise.all(media.map(async (m) => {
|
|
283
286
|
const blob = await rt.store.get<Blob>('blobs', m.key)
|
|
@@ -294,7 +297,6 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
|
|
|
294
297
|
...media.map((m) => ({ file: m.file, part: 0, blob: m.key })),
|
|
295
298
|
]
|
|
296
299
|
await queueReport({ store: rt.store, outbox: rt.outbox, report, parts: partRefs, draft: key, ...(adapters.phone?.now ? { now: adapters.phone.now } : {}) })
|
|
297
|
-
await rt.store.del('kv', answersKey(key))
|
|
298
300
|
for (const v of voice) await rt.store.del('kv', metaKey(v.key))
|
|
299
301
|
for (const m of media) await rt.store.del('kv', metaKey(m.key))
|
|
300
302
|
setSent({ id: report.id, files: [...voice.map((v, i) => ({ file: v.file, name: S.voiceNote(i + 1, lengthWords(v.ms)), bytes: 0 })), ...media.map((m) => ({ file: m.file, name: m.name, bytes: m.bytes }))] })
|
|
@@ -353,28 +355,16 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
|
|
|
353
355
|
</div>
|
|
354
356
|
) : (
|
|
355
357
|
<form className="sac-form" onSubmit={(e) => { e.preventDefault(); void send() }}>
|
|
356
|
-
<
|
|
357
|
-
<
|
|
358
|
-
{
|
|
359
|
-
<
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
</label>
|
|
363
|
-
))}
|
|
364
|
-
</fieldset>
|
|
365
|
-
|
|
366
|
-
{kind === 'broken' ? (
|
|
367
|
-
<fieldset className="sac-group">
|
|
368
|
-
<legend>{S.kindBroken} ({S.optional})</legend>
|
|
369
|
-
{fieldFor('happened', S.brokenHappened, answers.happened, (v) => setAnswers((a) => ({ ...a, happened: v })))}
|
|
370
|
-
{fieldFor('expected', S.brokenExpected, answers.expected, (v) => setAnswers((a) => ({ ...a, expected: v })))}
|
|
371
|
-
{fieldFor('steps', S.brokenSteps, answers.steps, (v) => setAnswers((a) => ({ ...a, steps: v })))}
|
|
372
|
-
</fieldset>
|
|
373
|
-
) : null}
|
|
358
|
+
<div className="sac-kind">
|
|
359
|
+
<label htmlFor={`${titleId}-kind`}>{S.kindLegend}</label>
|
|
360
|
+
<select id={`${titleId}-kind`} className="sac-select" value={kind} onChange={(e) => setKind(e.target.value as Kind)}>
|
|
361
|
+
{kinds.map((k) => <option key={k.id} value={k.id}>{k.label}</option>)}
|
|
362
|
+
</select>
|
|
363
|
+
</div>
|
|
374
364
|
|
|
375
|
-
<fieldset className="sac-group">
|
|
365
|
+
<fieldset className="sac-group sac-sayit">
|
|
376
366
|
<legend>{S.sayItLegend}</legend>
|
|
377
|
-
{fieldFor('words', S.sayItLabel, words, setWords, { placeholder: S.sayItPlaceholder })}
|
|
367
|
+
{fieldFor('words', S.sayItLabel, words, setWords, { placeholder: kind === 'broken' ? S.sayItPlaceholderBroken : kind === 'feedback' ? S.sayItPlaceholderFeedback : S.sayItPlaceholder })}
|
|
378
368
|
{voice.length ? (
|
|
379
369
|
<ul className="sac-list" aria-label={S.voiceNotesLabel}>
|
|
380
370
|
{voice.map((v, i) => (
|
package/src/client/styles.css
CHANGED
|
@@ -32,6 +32,11 @@
|
|
|
32
32
|
.sac-group > legend { font-weight: 600; padding: 0 4px; }
|
|
33
33
|
.sac-choice, .sac-switch { display: flex; align-items: center; gap: 10px; min-height: 44px; }
|
|
34
34
|
.sac-choice input, .sac-switch input { width: 24px; height: 24px; flex: none; }
|
|
35
|
+
.sac-kind { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
36
|
+
.sac-kind label { color: var(--sac-muted); font-size: 0.9rem; }
|
|
37
|
+
.sac-select { font: inherit; font-size: 16px; min-height: 36px; padding: 4px 10px; color: inherit; background: var(--sac-bg); border: 1px solid var(--sac-border); border-radius: var(--sac-radius); }
|
|
38
|
+
/* Saying it is the sheet's biggest thing, whatever field the host puts in it. */
|
|
39
|
+
.sac-sayit textarea { min-height: 9rem; }
|
|
35
40
|
.sac-voice { display: flex; align-items: flex-end; gap: 8px; min-width: 0; }
|
|
36
41
|
.sac-input, .sac-voice textarea, .sac-voice input { flex: 1; min-width: 0; min-height: 44px; font: inherit; font-size: 16px; color: inherit; background: var(--sac-bg); border: 1px solid var(--sac-border); border-radius: var(--sac-radius); padding: 10px 12px; box-sizing: border-box; }
|
|
37
42
|
.sac-record { flex: none; width: 48px; height: 48px; min-width: 44px; min-height: 44px; border-radius: 50%; border: 1px solid var(--sac-border); background: var(--sac-bg); display: grid; place-items: center; touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; cursor: pointer; }
|
package/src/client/words.ts
CHANGED
|
@@ -31,6 +31,9 @@ export interface Strings {
|
|
|
31
31
|
sayItLegend: string
|
|
32
32
|
sayItLabel: string
|
|
33
33
|
sayItPlaceholder: string
|
|
34
|
+
/** The one box's placeholder when the kind is "Something is broken": it asks what the three questions used to. */
|
|
35
|
+
sayItPlaceholderBroken: string
|
|
36
|
+
sayItPlaceholderFeedback: string
|
|
34
37
|
showItLegend: string
|
|
35
38
|
addImages: string
|
|
36
39
|
addVideo: string
|
|
@@ -121,7 +124,9 @@ export const DEFAULT_STRINGS: Strings = {
|
|
|
121
124
|
optional: 'optional',
|
|
122
125
|
sayItLegend: 'Say it',
|
|
123
126
|
sayItLabel: 'Your suggestion',
|
|
124
|
-
sayItPlaceholder: 'Type it, or hold the microphone and say it',
|
|
127
|
+
sayItPlaceholder: 'What would you change? Type it, or hold the microphone and say it',
|
|
128
|
+
sayItPlaceholderBroken: 'What happened, what you expected, and how to make it happen again. Type it, or hold the microphone and say it',
|
|
129
|
+
sayItPlaceholderFeedback: 'Whatever is on your mind. Type it, or hold the microphone and say it',
|
|
125
130
|
showItLegend: 'Show it',
|
|
126
131
|
addImages: 'Add a screenshot or photo',
|
|
127
132
|
addVideo: 'Add a short video',
|