@supersuit/artifacts 0.11.1 → 0.12.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.
@@ -23,6 +23,9 @@ import { createPortal } from 'react-dom';
23
23
  import { MAX_QUOTE_CHARS, textAnchorFrom } from '../artifacts/anchor.js';
24
24
  import { COMMENTS_SLOT } from '../artifacts/comments.js';
25
25
  import { CommentCard } from './comment-card.js';
26
+ import { AudioPlay, hostVoice, newMemoId } from './voice-recorder.js';
27
+ import { deviceAudio as defaultDeviceAudio, isDeviceAudio } from './device-audio.js';
28
+ import { audioExtFor } from '../artifacts/audio.js';
26
29
  import { themeVarsAround } from './theme-vars.js';
27
30
  import { addDeviceNote, deviceNotes, removeDeviceNote, replaceDeviceNote } from './device-notes.js';
28
31
  import { boxOf, headingAt, indexText, offsetAt, placeAnchor, regionFrom } from './comment-place.js';
@@ -52,12 +55,20 @@ function sharedFrom(body) {
52
55
  for (const r of rows.values()) {
53
56
  if (!isValue(r.value))
54
57
  continue;
55
- out.push({ id: String(r.id), kind: 'shared', where: 'server', value: r.value, name: r.mine === true ? 'You' : typeof r.name === 'string' ? r.name : 'a reader', mine: r.mine === true, at: String(r.at ?? '') });
58
+ out.push({
59
+ id: String(r.id), kind: 'shared', where: 'server', value: r.value, name: r.mine === true ? 'You' : typeof r.name === 'string' ? r.name : 'a reader', mine: r.mine === true, at: String(r.at ?? ''),
60
+ ...(typeof r.audioUrl === 'string' ? { audioUrl: r.audioUrl } : {}),
61
+ });
56
62
  }
57
63
  return out.sort((a, b) => a.at.localeCompare(b.at));
58
64
  }
59
- const personalFrom = (notes, where) => notes.filter((n) => isValue(n.value)).map((n) => ({ id: n.id, kind: 'personal', where, value: n.value, name: 'You', mine: true, at: n.at }));
60
- export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare, signedIn, isOwner, version, accent, ground, signIn, }) {
65
+ const personalFrom = (notes, where) => notes.filter((n) => isValue(n.value)).map((n) => ({
66
+ id: n.id, kind: 'personal', where, value: n.value, name: 'You', mine: true, at: n.at,
67
+ ...(typeof n.audioUrl === 'string' ? { audioUrl: n.audioUrl } : {}),
68
+ }));
69
+ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare, signedIn, isOwner, version, accent, ground, signIn, voice, audioStore, }) {
70
+ const host = useMemo(() => voice ?? hostVoice(artifactId), [voice, artifactId]);
71
+ const audioDb = useMemo(() => audioStore ?? defaultDeviceAudio, [audioStore]);
61
72
  const stateUrl = `/api/artifacts/${artifactId}/state`;
62
73
  const personalUrl = `/api/artifacts/${artifactId}/personal`;
63
74
  const [mounted, setMounted] = useState(false);
@@ -110,12 +121,34 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
110
121
  }
111
122
  let notes = r.body.notes ?? [];
112
123
  // Notes kept on this device before signing in move to the account, then leave the device.
124
+ // A recording kept on the device goes up to the account first, then the note names it there.
113
125
  for (const n of deviceNotes(artifactId)) {
114
- const m = await call(personalUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value: n.value }) });
126
+ const value = { ...n.value };
127
+ const key = isDeviceAudio(value.audio) ? value.audio : null;
128
+ if (key) {
129
+ const blob = await audioDb.get(key);
130
+ const path = blob ? await host.upload({ blob, mime: blob.type }, 'personal') : null;
131
+ if (blob && !path)
132
+ break;
133
+ if (path)
134
+ value.audio = path;
135
+ else {
136
+ // The recording is gone from this device: keep the words, or nothing is left to keep.
137
+ delete value.audio;
138
+ delete value.transcript;
139
+ if (!value.body.trim()) {
140
+ removeDeviceNote(artifactId, n.id);
141
+ continue;
142
+ }
143
+ }
144
+ }
145
+ const m = await call(personalUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
115
146
  if (m.status !== 200)
116
147
  break;
117
148
  notes = m.body.notes ?? notes;
118
149
  removeDeviceNote(artifactId, n.id);
150
+ if (key)
151
+ await audioDb.remove(key);
119
152
  }
120
153
  if (!live)
121
154
  return;
@@ -130,7 +163,7 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
130
163
  if (ro && root)
131
164
  ro.observe(root);
132
165
  return () => { live = false; window.removeEventListener('resize', relayout); ro?.disconnect(); };
133
- }, [artifactId, comments, signedIn, stateUrl, personalUrl, rootId, call, refreshDevice]);
166
+ }, [artifactId, comments, signedIn, stateUrl, personalUrl, rootId, call, refreshDevice, host, audioDb]);
134
167
  // Text selection inside the page shows the Comment chip.
135
168
  useEffect(() => {
136
169
  const root = document.getElementById(rootId);
@@ -284,8 +317,26 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
284
317
  return { placed, earlier };
285
318
  // `tick` re-measures after a resize.
286
319
  }, [mounted, rootId, tops, tick]); // eslint-disable-line react-hooks/exhaustive-deps
287
- const saveNew = async (body, share, d) => {
288
- const value = { anchor: d.anchor, body, version };
320
+ /** The recording's path for where it is being saved: reused when the card already uploaded it
321
+ * there, uploaded now when the reader switched between Share and Just for me after recording. */
322
+ const memoPath = async (m, scope) => m.uploaded?.scope === scope ? m.uploaded.path : host.upload(m.memo, scope);
323
+ const LOST = 'The recording did not upload. Try again, or discard it and save the text.';
324
+ const saveNew = async (body, share, d, memo) => {
325
+ const value = { anchor: d.anchor, body, version, ...(memo?.transcript ? { transcript: memo.transcript } : {}) };
326
+ const toDevice = !share && !(signedIn && onServer);
327
+ if (memo && !toDevice) {
328
+ const path = await memoPath(memo, share ? 'comment' : 'personal');
329
+ if (!path)
330
+ return LOST;
331
+ value.audio = path;
332
+ }
333
+ if (memo && toDevice) {
334
+ // Not signed in: the recording stays in this browser with the note, and never leaves it.
335
+ const key = `device/${newMemoId()}.${audioExtFor(memo.memo.mime) ?? 'webm'}`;
336
+ if (!(await audioDb.put(key, memo.memo.blob)))
337
+ return 'This browser would not keep the recording. Discard it and save the text.';
338
+ value.audio = key;
339
+ }
289
340
  if (share) {
290
341
  const r = await call(stateUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slot: COMMENTS_SLOT, op: 'append', value }) });
291
342
  if (r.status !== 200)
@@ -315,6 +366,8 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
315
366
  const remove = async (item) => {
316
367
  if (item.where === 'device') {
317
368
  removeDeviceNote(artifactId, item.id);
369
+ if (isDeviceAudio(item.value.audio))
370
+ await audioDb.remove(item.value.audio);
318
371
  refreshDevice();
319
372
  }
320
373
  else if (item.kind === 'personal') {
@@ -359,7 +412,7 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
359
412
  const open = layout.placed.find((p) => p.item.id === thread);
360
413
  const openItem = open?.item ?? layout.earlier.find((i) => i.id === thread);
361
414
  const count = tops.length;
362
- const overlay = mounted ? (_jsxs("div", { "data-comment-ui": true, "data-nospeak": true, className: "font-sans", style: theme, children: [showPins ? layout.placed.map(({ item, left, top, box }) => (_jsxs("div", { children: [box ? (_jsx("div", { "data-comment-box": item.id, "aria-hidden": true, className: "pointer-events-none absolute z-30 rounded-md border-2", style: { left: box.left, top: box.top, width: box.width, height: box.height, borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid', opacity: thread === item.id ? 1 : 0.55 } })) : null, _jsx(Pin, { item: item, left: left, top: top, accent: accent, ground: ground, onOpen: () => setThread(thread === item.id ? null : item.id) })] }, item.id))) : null, chip && !draft ? (_jsx("button", { type: "button", "data-comment-chip": true, className: "absolute z-50 -translate-x-full rounded-full px-3 py-1 text-xs font-medium shadow-lg", style: { left: chip.x, top: chip.y, background: accent, color: 'var(--a-on-accent, #111)' }, onMouseDown: (e) => e.preventDefault(), onClick: () => { setDraft(chip); setChip(null); }, children: "Comment" })) : null, draft ? (_jsx(Floating, { x: draft.x, y: draft.y, children: _jsx(CommentCard, { ownerName: ownerName, mode: comments, canShare: canShare, signedIn: signedIn && onServer, quote: draft.quote, accent: accent, signIn: signIn, onSave: (body, share) => saveNew(body, share, draft), onCancel: () => setDraft(null) }) })) : null, openItem && open ? (_jsx(Floating, { x: open.left + 28, y: open.top, children: _jsx(Thread, { item: openItem, replies: items.filter((i) => i.value.parent === openItem.id), canReply: openItem.kind === 'shared' && canShare, accent: accent, onClose: () => setThread(null), onRemove: remove, onEdit: edit, onReply: reply }) })) : null, drag ? (_jsx("div", { "aria-hidden": true, className: "pointer-events-none fixed z-40 rounded-md border-2 border-dashed", style: { left: Math.min(drag.x1, drag.x2), top: Math.min(drag.y1, drag.y2), width: Math.abs(drag.x2 - drag.x1), height: Math.abs(drag.y2 - drag.y1), borderColor: accent, background: `color-mix(in srgb, ${accent} 12%, transparent)` } })) : null, _jsxs("div", { className: "fixed right-4 z-40 flex flex-col items-end gap-2", style: { bottom: 'calc(env(safe-area-inset-bottom, 0px) + 5.5rem)' }, children: [panel ? (_jsxs("div", { "data-comment-panel": true, className: "w-64 rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-xs text-[color:var(--a-strong)] shadow-xl", children: [_jsx("p", { className: "opacity-80", children: "Select any text, or hold and drag a box over anything, to leave a comment." }), isOwner ? _jsx("p", { "data-comment-owner": true, className: "mt-1 opacity-80", children: "This is your page: you see every comment, with the name of who left it." }) : null, _jsx("button", { type: "button", "data-comment-pins-toggle": true, className: "mt-2 underline", onClick: () => setShowPins((v) => !v), children: showPins ? 'Hide comments' : 'Show comments' })] })) : null, _jsx("button", { type: "button", "data-comment-button": true, "aria-expanded": panel, onClick: () => setPanel((v) => !v), className: "rounded-full border px-3 py-1.5 text-xs font-medium shadow-lg backdrop-blur", style: { borderColor: accent, color: accent, background: `color-mix(in srgb, ${ground} 85%, transparent)` }, children: count ? `Comments · ${count}` : 'Comment' })] })] })) : null;
415
+ const overlay = mounted ? (_jsxs("div", { "data-comment-ui": true, "data-nospeak": true, className: "font-sans", style: theme, children: [showPins ? layout.placed.map(({ item, left, top, box }) => (_jsxs("div", { children: [box ? (_jsx("div", { "data-comment-box": item.id, "aria-hidden": true, className: "pointer-events-none absolute z-30 rounded-md border-2", style: { left: box.left, top: box.top, width: box.width, height: box.height, borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid', opacity: thread === item.id ? 1 : 0.55 } })) : null, _jsx(Pin, { item: item, left: left, top: top, accent: accent, ground: ground, onOpen: () => setThread(thread === item.id ? null : item.id) })] }, item.id))) : null, chip && !draft ? (_jsx("button", { type: "button", "data-comment-chip": true, className: "absolute z-50 -translate-x-full rounded-full px-3 py-1 text-xs font-medium shadow-lg", style: { left: chip.x, top: chip.y, background: accent, color: 'var(--a-on-accent, #111)' }, onMouseDown: (e) => e.preventDefault(), onClick: () => { setDraft(chip); setChip(null); }, children: "Comment" })) : null, draft ? (_jsx(Floating, { x: draft.x, y: draft.y, children: _jsx(CommentCard, { ownerName: ownerName, mode: comments, canShare: canShare, signedIn: signedIn && onServer, quote: draft.quote, accent: accent, signIn: signIn, voice: host, onSave: (body, share, memo) => saveNew(body, share, draft, memo), onCancel: () => setDraft(null) }) })) : null, openItem && open ? (_jsx(Floating, { x: open.left + 28, y: open.top, children: _jsx(Thread, { item: openItem, replies: items.filter((i) => i.value.parent === openItem.id), canReply: openItem.kind === 'shared' && canShare, accent: accent, audioDb: audioDb, onClose: () => setThread(null), onRemove: remove, onEdit: edit, onReply: reply }) })) : null, drag ? (_jsx("div", { "aria-hidden": true, className: "pointer-events-none fixed z-40 rounded-md border-2 border-dashed", style: { left: Math.min(drag.x1, drag.x2), top: Math.min(drag.y1, drag.y2), width: Math.abs(drag.x2 - drag.x1), height: Math.abs(drag.y2 - drag.y1), borderColor: accent, background: `color-mix(in srgb, ${accent} 12%, transparent)` } })) : null, _jsxs("div", { className: "fixed right-4 z-40 flex flex-col items-end gap-2", style: { bottom: 'calc(env(safe-area-inset-bottom, 0px) + 5.5rem)' }, children: [panel ? (_jsxs("div", { "data-comment-panel": true, className: "w-64 rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-xs text-[color:var(--a-strong)] shadow-xl", children: [_jsx("p", { className: "opacity-80", children: "Select any text, or hold and drag a box over anything, to leave a comment." }), isOwner ? _jsx("p", { "data-comment-owner": true, className: "mt-1 opacity-80", children: "This is your page: you see every comment, with the name of who left it." }) : null, _jsx("button", { type: "button", "data-comment-pins-toggle": true, className: "mt-2 underline", onClick: () => setShowPins((v) => !v), children: showPins ? 'Hide comments' : 'Show comments' })] })) : null, _jsx("button", { type: "button", "data-comment-button": true, "aria-expanded": panel, onClick: () => setPanel((v) => !v), className: "rounded-full border px-3 py-1.5 text-xs font-medium shadow-lg backdrop-blur", style: { borderColor: accent, color: accent, background: `color-mix(in srgb, ${ground} 85%, transparent)` }, children: count ? `Comments · ${count}` : 'Comment' })] })] })) : null;
363
416
  return (_jsxs(_Fragment, { children: [_jsx("span", { hidden: true, "data-comment-layer": comments, "data-comment-can-share": canShare ? 'yes' : 'no' }), mounted && layout.earlier.length ? (_jsxs("section", { "data-comments-earlier": true, "data-comment-ui": true, "data-nospeak": true, className: "mx-auto max-w-2xl px-6 pb-16 text-sm", children: [_jsx("p", { className: "text-[11px] uppercase tracking-[0.2em]", style: { color: accent }, children: "Comments on earlier versions" }), _jsx("ul", { className: "m-0 mt-2 grid list-none gap-3 p-0", children: layout.earlier.map((item) => (_jsxs("li", { className: "m-0 border-l-2 py-1 pl-3", style: { borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid' }, children: [item.value.anchor.kind === 'text' ? _jsxs("span", { className: "block text-[13px] italic opacity-70", children: ["\u201C", item.value.anchor.quote, "\u201D"] }) : _jsx("span", { className: "block text-[13px] italic opacity-70", children: "A box on part of the page that has changed" }), _jsx("span", { className: "block whitespace-pre-wrap text-[15px] text-[color:var(--a-strong)]", children: item.value.body }), _jsxs("span", { className: "block text-xs opacity-60", children: [item.kind === 'personal' ? 'only you' : item.name, ` · version ${item.value.version}`, item.mine ? _jsx("button", { type: "button", onClick: () => void remove(item), className: "ml-2 underline", children: "delete" }) : null] })] }, item.id))) })] })) : null, overlay ? createPortal(overlay, document.body) : null] }));
364
417
  }
365
418
  function Pin({ item, left, top, accent, ground, onOpen }) {
@@ -372,19 +425,38 @@ function Floating({ x, y, children }) {
372
425
  const left = typeof window === 'undefined' ? x : Math.max(window.scrollX + 8, Math.min(x, window.scrollX + window.innerWidth - width - 8));
373
426
  return _jsx("div", { className: "absolute z-50", style: { left, top: y }, children: children });
374
427
  }
375
- function Thread({ item, replies, canReply, accent, onClose, onRemove, onEdit, onReply }) {
428
+ function Thread({ item, replies, canReply, accent, audioDb, onClose, onRemove, onEdit, onReply }) {
376
429
  const [replying, setReplying] = useState(false);
377
- return (_jsxs("div", { "data-comment-thread": true, "data-comment-ui": true, "data-nospeak": true, className: "w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-sm text-[color:var(--a-strong)] shadow-xl", children: [_jsxs("div", { className: "mb-2 flex items-center justify-between text-[11px] uppercase tracking-[0.15em]", style: { color: accent }, children: [_jsx("span", { children: item.kind === 'personal' ? 'Only you' : 'Comment' }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close", className: "opacity-70 hover:opacity-100", children: "\u00D7" })] }), item.value.anchor.kind === 'text' ? _jsx("p", { className: "mb-2 line-clamp-2 border-l-2 pl-2 text-[13px] italic opacity-70", style: { borderColor: accent }, children: item.value.anchor.quote }) : null, _jsx(Entry, { item: item, onRemove: onRemove, onEdit: onEdit }), replies.length ? _jsx("ul", { className: "m-0 mt-2 grid list-none gap-2 border-l pl-3 p-0", children: replies.map((r) => _jsx("li", { className: "m-0", children: _jsx(Entry, { item: r, onRemove: onRemove, onEdit: onEdit }) }, r.id)) }) : null, canReply ? (replying
430
+ return (_jsxs("div", { "data-comment-thread": true, "data-comment-ui": true, "data-nospeak": true, className: "w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-sm text-[color:var(--a-strong)] shadow-xl", children: [_jsxs("div", { className: "mb-2 flex items-center justify-between text-[11px] uppercase tracking-[0.15em]", style: { color: accent }, children: [_jsx("span", { children: item.kind === 'personal' ? 'Only you' : 'Comment' }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close", className: "opacity-70 hover:opacity-100", children: "\u00D7" })] }), item.value.anchor.kind === 'text' ? _jsx("p", { className: "mb-2 line-clamp-2 border-l-2 pl-2 text-[13px] italic opacity-70", style: { borderColor: accent }, children: item.value.anchor.quote }) : null, _jsx(Entry, { item: item, accent: accent, audioDb: audioDb, onRemove: onRemove, onEdit: onEdit }), replies.length ? _jsx("ul", { className: "m-0 mt-2 grid list-none gap-2 border-l pl-3 p-0", children: replies.map((r) => _jsx("li", { className: "m-0", children: _jsx(Entry, { item: r, accent: accent, audioDb: audioDb, onRemove: onRemove, onEdit: onEdit }) }, r.id)) }) : null, canReply ? (replying
378
431
  ? _jsx(InlineForm, { label: "Reply", accent: accent, onCancel: () => setReplying(false), onSubmit: async (b) => { const e = await onReply(item, b); if (!e)
379
432
  setReplying(false); return e; } })
380
433
  : _jsx("button", { type: "button", "data-comment-reply": true, onClick: () => setReplying(true), className: "mt-2 text-xs underline", children: "Reply" })) : null] }));
381
434
  }
382
- function Entry({ item, onRemove, onEdit }) {
435
+ /** Where a comment's recording plays from: its signed URL, or this browser's copy of a device note's. */
436
+ function useAudioSrc(item, audioDb) {
437
+ const [local, setLocal] = useState(null);
438
+ const key = item.where === 'device' && isDeviceAudio(item.value.audio) ? item.value.audio : null;
439
+ useEffect(() => {
440
+ if (!key || typeof URL.createObjectURL !== 'function')
441
+ return;
442
+ let url = null;
443
+ let live = true;
444
+ void audioDb.get(key).then((b) => { if (live && b) {
445
+ url = URL.createObjectURL(b);
446
+ setLocal(url);
447
+ } });
448
+ return () => { live = false; if (url)
449
+ URL.revokeObjectURL(url); };
450
+ }, [key, audioDb]);
451
+ return item.audioUrl ?? local;
452
+ }
453
+ function Entry({ item, accent, audioDb, onRemove, onEdit }) {
383
454
  const [editing, setEditing] = useState(false);
455
+ const src = useAudioSrc(item, audioDb);
384
456
  if (editing)
385
457
  return _jsx(InlineForm, { label: "Save", initial: item.value.body, onCancel: () => setEditing(false), onSubmit: async (b) => { const e = await onEdit(item, b); if (!e)
386
458
  setEditing(false); return e; } });
387
- return (_jsxs("div", { children: [_jsx("p", { className: "m-0 whitespace-pre-wrap text-[15px]", children: item.value.body }), _jsxs("p", { className: "m-0 text-xs opacity-60", children: [item.kind === 'personal' ? (item.where === 'device' ? 'only you, on this device' : 'only you') : item.name, item.mine ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", "data-comment-edit": true, onClick: () => setEditing(true), className: "ml-2 underline", children: "edit" }), _jsx("button", { type: "button", "data-comment-delete": true, onClick: () => void onRemove(item), className: "ml-2 underline", children: "delete" })] })) : null] })] }));
459
+ return (_jsxs("div", { children: [src ? _jsx(AudioPlay, { src: src, accent: accent }) : null, item.value.body ? _jsx("p", { className: "m-0 whitespace-pre-wrap text-[15px]", children: item.value.body }) : null, _jsxs("p", { className: "m-0 text-xs opacity-60", children: [item.kind === 'personal' ? (item.where === 'device' ? 'only you, on this device' : 'only you') : item.name, item.mine ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", "data-comment-edit": true, onClick: () => setEditing(true), className: "ml-2 underline", children: "edit" }), _jsx("button", { type: "button", "data-comment-delete": true, onClick: () => void onRemove(item), className: "ml-2 underline", children: "delete" })] })) : null] })] }));
388
460
  }
389
461
  function InlineForm({ label, initial = '', accent = 'currentColor', onSubmit, onCancel }) {
390
462
  const [text, setText] = useState(initial);
@@ -0,0 +1,7 @@
1
+ export declare const isDeviceAudio: (p: unknown) => p is string;
2
+ export type DeviceAudio = {
3
+ put(key: string, blob: Blob): Promise<boolean>;
4
+ get(key: string): Promise<Blob | null>;
5
+ remove(key: string): Promise<void>;
6
+ };
7
+ export declare const deviceAudio: DeviceAudio;
@@ -0,0 +1,43 @@
1
+ // Recordings on personal notes for a reader who is not signed in: kept in this browser's IndexedDB
2
+ // and nowhere else, like the notes themselves (device-notes.ts). A note names its recording as
3
+ // `device/<memo>.<ext>`; on sign-in the comment layer uploads it to the reader's account and
4
+ // deletes it here. A browser without IndexedDB (or a private window that refuses it) keeps no
5
+ // recording, and nothing breaks: every call answers null or false.
6
+ const DB = 'artifact-voice';
7
+ const STORE = 'memos';
8
+ export const isDeviceAudio = (p) => typeof p === 'string' && p.startsWith('device/');
9
+ function open() {
10
+ return new Promise((resolve) => {
11
+ try {
12
+ if (typeof indexedDB === 'undefined')
13
+ return resolve(null);
14
+ const req = indexedDB.open(DB, 1);
15
+ req.onupgradeneeded = () => { req.result.createObjectStore(STORE); };
16
+ req.onsuccess = () => resolve(req.result);
17
+ req.onerror = () => resolve(null);
18
+ }
19
+ catch {
20
+ resolve(null);
21
+ }
22
+ });
23
+ }
24
+ async function run(mode, fn) {
25
+ const db = await open();
26
+ if (!db)
27
+ return null;
28
+ return new Promise((resolve) => {
29
+ try {
30
+ const req = fn(db.transaction(STORE, mode).objectStore(STORE));
31
+ req.onsuccess = () => resolve(req.result ?? null);
32
+ req.onerror = () => resolve(null);
33
+ }
34
+ catch {
35
+ resolve(null);
36
+ }
37
+ });
38
+ }
39
+ export const deviceAudio = {
40
+ put: async (key, blob) => (await run('readwrite', (s) => s.put(blob, key))) !== null,
41
+ get: async (key) => { const b = await run('readonly', (s) => s.get(key)); return b instanceof Blob ? b : null; },
42
+ remove: async (key) => { await run('readwrite', (s) => s.delete(key)); },
43
+ };
@@ -6,3 +6,6 @@ export { CommentLayer } from './comment-layer.js';
6
6
  export { CommentCard, warningLine, DEVICE_LINE } from './comment-card.js';
7
7
  export { deviceNotes, deviceNotesKey, addDeviceNote, replaceDeviceNote, removeDeviceNote } from './device-notes.js';
8
8
  export { indexText, offsetAt, rangeOf, headingAt, placeAnchor, boxOf, regionFrom, type TextIndex, type Placement } from './comment-place.js';
9
+ export { VoiceRecorder, AudioPlay, hostVoice, pickRecordingType, newMemoId, recognitionCtor, LIVE_LINE, type VoiceHost, type VoiceMemo, type VoiceScope } from './voice-recorder.js';
10
+ export { deviceAudio, isDeviceAudio, type DeviceAudio } from './device-audio.js';
11
+ export { type SavedMemo } from './comment-card.js';
@@ -8,3 +8,5 @@ export { CommentLayer } from './comment-layer.js';
8
8
  export { CommentCard, warningLine, DEVICE_LINE } from './comment-card.js';
9
9
  export { deviceNotes, deviceNotesKey, addDeviceNote, replaceDeviceNote, removeDeviceNote } from './device-notes.js';
10
10
  export { indexText, offsetAt, rangeOf, headingAt, placeAnchor, boxOf, regionFrom } from './comment-place.js';
11
+ export { VoiceRecorder, AudioPlay, hostVoice, pickRecordingType, newMemoId, recognitionCtor, LIVE_LINE } from './voice-recorder.js';
12
+ export { deviceAudio, isDeviceAudio } from './device-audio.js';
@@ -0,0 +1,52 @@
1
+ export type VoiceMemo = {
2
+ blob: Blob;
3
+ mime: string;
4
+ };
5
+ export type VoiceScope = 'comment' | 'personal';
6
+ /** The page's own voice routes, from the reader's browser. */
7
+ export type VoiceHost = {
8
+ /** Upload a memo; the stored path, or null when the page could not keep it. */
9
+ upload(memo: VoiceMemo, scope: VoiceScope): Promise<string | null>;
10
+ /** The host's transcript of an uploaded memo; null when it has no transcriber (404) or failed. */
11
+ transcribe(path: string): Promise<string | null>;
12
+ };
13
+ export declare const LIVE_LINE = "Your browser transcribes this as you speak.";
14
+ /** The recording type to ask for: the first this browser supports, else its default. */
15
+ export declare function pickRecordingType(isSupported?: (t: string) => boolean): string | undefined;
16
+ /** A memo's random file id: 22 url-safe characters, drawn in the browser. */
17
+ export declare function newMemoId(): string;
18
+ type Recognition = {
19
+ continuous: boolean;
20
+ interimResults: boolean;
21
+ lang: string;
22
+ onresult: ((e: {
23
+ results: ArrayLike<ArrayLike<{
24
+ transcript: string;
25
+ }> & {
26
+ isFinal?: boolean;
27
+ }>;
28
+ }) => void) | null;
29
+ onerror: (() => void) | null;
30
+ start(): void;
31
+ stop(): void;
32
+ };
33
+ type RecognitionCtor = new () => Recognition;
34
+ export declare function recognitionCtor(): RecognitionCtor | null;
35
+ /** The page's own routes as a VoiceHost. Every failure is a null, never a throw. */
36
+ export declare function hostVoice(artifactId: string): VoiceHost;
37
+ /** The mic button. Renders nothing where the browser cannot record. */
38
+ export declare function VoiceRecorder({ onStart, onLive, onStop, onError, accent, maxMs }: {
39
+ onStart?: () => void;
40
+ /** The live transcript so far, whole, each time it changes. */
41
+ onLive?: (text: string) => void;
42
+ onStop: (memo: VoiceMemo) => void;
43
+ onError?: (message: string) => void;
44
+ accent?: string;
45
+ maxMs?: number;
46
+ }): import("react").JSX.Element | null;
47
+ /** A small play control for a saved memo. `src` is a URL (signed, or a blob: URL on the device). */
48
+ export declare function AudioPlay({ src, accent }: {
49
+ src: string;
50
+ accent?: string;
51
+ }): import("react").JSX.Element;
52
+ export {};
@@ -0,0 +1,213 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // Say it instead of typing it. The microphone in the comment card: MediaRecorder for the memo
4
+ // itself, and, where the browser has it, speech recognition writing into the box while you speak.
5
+ //
6
+ // Chrome records audio/webm (opus), iPhone Safari audio/mp4; the first the browser supports is
7
+ // used, else the browser's own default, and a type the page cannot keep is refused before upload.
8
+ // A memo stops itself at three minutes. The host's transcription (if it has one) happens after the
9
+ // recording stops, in the card, through `hostVoice`, which never sees a key: it only calls this
10
+ // page's own routes.
11
+ //
12
+ // Everything here draws with the page's theme variables (--a-*) and the accent it is handed: the
13
+ // card is portalled onto document.body, and a hard-coded colour there is dark text on a dark page.
14
+ import { useEffect, useRef, useState } from 'react';
15
+ import { MAX_MEMO_MS, audioExtFor, baseMime } from '../artifacts/audio.js';
16
+ export const LIVE_LINE = 'Your browser transcribes this as you speak.';
17
+ const SEEN_KEY = 'artifact-voice-live-seen';
18
+ /** The recording type to ask for: the first this browser supports, else its default. */
19
+ export function pickRecordingType(isSupported = (t) => typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(t)) {
20
+ for (const t of ['audio/webm;codecs=opus', 'audio/mp4']) {
21
+ try {
22
+ if (isSupported(t))
23
+ return t;
24
+ }
25
+ catch { /* an engine that throws on the question supports neither */ }
26
+ }
27
+ return undefined;
28
+ }
29
+ /** A memo's random file id: 22 url-safe characters, drawn in the browser. */
30
+ export function newMemoId() {
31
+ const bytes = new Uint8Array(16);
32
+ crypto.getRandomValues(bytes);
33
+ let s = '';
34
+ for (const b of bytes)
35
+ s += 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'[b & 63];
36
+ return s + Date.now().toString(36).slice(-6);
37
+ }
38
+ export function recognitionCtor() {
39
+ if (typeof window === 'undefined')
40
+ return null;
41
+ const w = window;
42
+ return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
43
+ }
44
+ const canRecord = () => typeof window !== 'undefined' && typeof MediaRecorder !== 'undefined' && !!navigator.mediaDevices?.getUserMedia;
45
+ /** The page's own routes as a VoiceHost. Every failure is a null, never a throw. */
46
+ export function hostVoice(artifactId) {
47
+ const post = (url, body) => fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
48
+ return {
49
+ async upload(memo, scope) {
50
+ try {
51
+ const ext = audioExtFor(memo.mime);
52
+ if (!ext)
53
+ return null;
54
+ const url = `/api/artifacts/${artifactId}/uploads/${newMemoId()}.${ext}`;
55
+ const signed = await post(url, { audio: scope });
56
+ if (signed.status !== 201)
57
+ return null;
58
+ const { uploadUrl, headers } = (await signed.json());
59
+ const put = await fetch(uploadUrl, { method: 'PUT', headers, body: memo.blob });
60
+ if (!put.ok)
61
+ return null;
62
+ const done = await post(url, { audio: scope, done: true });
63
+ return done.status === 201 ? (await done.json()).path : null;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ },
69
+ async transcribe(path) {
70
+ try {
71
+ const r = await post(`/api/artifacts/${artifactId}/transcribe`, { asset: path });
72
+ if (r.status !== 200)
73
+ return null;
74
+ const b = (await r.json());
75
+ return typeof b.text === 'string' && b.text.trim() ? b.text.trim() : null;
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ },
81
+ };
82
+ }
83
+ const clock = (ms) => { const s = Math.floor(ms / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; };
84
+ /** The mic button. Renders nothing where the browser cannot record. */
85
+ export function VoiceRecorder({ onStart, onLive, onStop, onError, accent = 'currentColor', maxMs = MAX_MEMO_MS }) {
86
+ const [supported, setSupported] = useState(false);
87
+ const [live, setLive] = useState(false);
88
+ const [note, setNote] = useState(false);
89
+ const [recording, setRecording] = useState(false);
90
+ const [elapsed, setElapsed] = useState(0);
91
+ const rec = useRef(null);
92
+ useEffect(() => {
93
+ setSupported(canRecord());
94
+ const has = !!recognitionCtor();
95
+ setLive(has);
96
+ try {
97
+ setNote(has && !window.localStorage.getItem(SEEN_KEY));
98
+ }
99
+ catch {
100
+ setNote(has);
101
+ }
102
+ return () => { const r = rec.current; if (r) {
103
+ clearTimeout(r.timer);
104
+ clearInterval(r.tick);
105
+ r.recog?.stop();
106
+ r.stream.getTracks().forEach((t) => t.stop());
107
+ } };
108
+ }, []);
109
+ const stop = () => {
110
+ const r = rec.current;
111
+ if (!r)
112
+ return;
113
+ clearTimeout(r.timer);
114
+ clearInterval(r.tick);
115
+ try {
116
+ r.recog?.stop();
117
+ }
118
+ catch { /* already stopped */ }
119
+ if (r.recorder.state !== 'inactive')
120
+ r.recorder.stop();
121
+ };
122
+ const start = async () => {
123
+ let stream;
124
+ try {
125
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true });
126
+ }
127
+ catch {
128
+ onError?.('The microphone is blocked. Allow it for this site in your browser, then try again.');
129
+ return;
130
+ }
131
+ const asked = pickRecordingType();
132
+ let recorder;
133
+ try {
134
+ recorder = asked ? new MediaRecorder(stream, { mimeType: asked }) : new MediaRecorder(stream);
135
+ }
136
+ catch {
137
+ stream.getTracks().forEach((t) => t.stop());
138
+ onError?.('This browser cannot record here.');
139
+ return;
140
+ }
141
+ const chunks = [];
142
+ recorder.ondataavailable = (e) => { if (e.data && e.data.size)
143
+ chunks.push(e.data); };
144
+ recorder.onstop = () => {
145
+ stream.getTracks().forEach((t) => t.stop());
146
+ rec.current = null;
147
+ setRecording(false);
148
+ const type = recorder.mimeType || asked || chunks[0]?.type || '';
149
+ if (!audioExtFor(type)) {
150
+ onError?.('This browser records in a format the page cannot keep.');
151
+ return;
152
+ }
153
+ onStop({ blob: new Blob(chunks, { type: baseMime(type) }), mime: baseMime(type) });
154
+ };
155
+ let recog = null;
156
+ const Ctor = recognitionCtor();
157
+ if (Ctor) {
158
+ try {
159
+ recog = new Ctor();
160
+ recog.continuous = true;
161
+ recog.interimResults = true;
162
+ recog.lang = document.documentElement.lang || navigator.language || 'en-US';
163
+ recog.onresult = (e) => {
164
+ let text = '';
165
+ for (let i = 0; i < e.results.length; i++)
166
+ text += e.results[i][0]?.transcript ?? '';
167
+ onLive?.(text.replace(/\s+/g, ' ').trim());
168
+ };
169
+ recog.onerror = () => { };
170
+ recog.start();
171
+ }
172
+ catch {
173
+ recog = null;
174
+ }
175
+ try {
176
+ window.localStorage.setItem(SEEN_KEY, '1');
177
+ }
178
+ catch { /* private window */ }
179
+ }
180
+ const began = Date.now();
181
+ rec.current = {
182
+ recorder, stream, recog,
183
+ timer: setTimeout(stop, maxMs),
184
+ tick: setInterval(() => setElapsed(Date.now() - began), 250),
185
+ };
186
+ setElapsed(0);
187
+ setRecording(true);
188
+ onStart?.();
189
+ recorder.start(1000);
190
+ };
191
+ if (!supported)
192
+ return null;
193
+ return (_jsxs("div", { "data-voice-recorder": true, className: "mt-2 text-[13px] text-[color:var(--a-strong)]", children: [_jsxs("button", { type: "button", "data-voice-mic": true, "aria-pressed": recording, onClick: () => (recording ? stop() : void start()), className: "inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium", style: recording ? { borderColor: accent, background: accent, color: 'var(--a-on-accent, currentColor)' } : { borderColor: accent, color: accent }, children: [_jsx("span", { "aria-hidden": true, children: recording ? '■' : '●' }), recording ? `Stop · ${clock(elapsed)}` : 'Record'] }), live && note ? _jsx("p", { "data-voice-live-note": true, className: "m-0 mt-1 text-xs opacity-70", children: LIVE_LINE }) : null] }));
194
+ }
195
+ /** A small play control for a saved memo. `src` is a URL (signed, or a blob: URL on the device). */
196
+ export function AudioPlay({ src, accent = 'currentColor' }) {
197
+ const [playing, setPlaying] = useState(false);
198
+ const el = useRef(null);
199
+ useEffect(() => () => { el.current?.pause(); }, []);
200
+ const toggle = () => {
201
+ if (!el.current) {
202
+ el.current = new Audio(src);
203
+ el.current.onended = () => setPlaying(false);
204
+ el.current.onpause = () => setPlaying(false);
205
+ el.current.onplay = () => setPlaying(true);
206
+ }
207
+ if (el.current.paused)
208
+ void el.current.play()?.catch?.(() => setPlaying(false));
209
+ else
210
+ el.current.pause();
211
+ };
212
+ return (_jsxs("button", { type: "button", "data-voice-play": true, onClick: toggle, "aria-label": playing ? 'Pause the recording' : 'Play the recording', className: "mr-2 inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs text-[color:var(--a-strong)]", style: { borderColor: accent }, children: [_jsx("span", { "aria-hidden": true, style: { color: accent }, children: playing ? '❚❚' : '▶' }), playing ? 'Pause' : 'Play'] }));
213
+ }
@@ -7,6 +7,7 @@ import type { BrandPack } from '../brand/pack.js';
7
7
  import { type Access } from '../artifacts/reader.js';
8
8
  import { type ReadersStore } from '../artifacts/readers-store.js';
9
9
  import type { PersonalStore } from '../artifacts/personal-store.js';
10
+ import type { Transcriber } from '../artifacts/transcribe.js';
10
11
  export type ArtifactRoutesConfig = {
11
12
  store: ArtifactStore;
12
13
  /** Where readers' answers live. Without it every state route answers 501. */
@@ -49,6 +50,11 @@ export type ArtifactRoutesConfig = {
49
50
  * its writer's name, whatever `comments_visible:` says. Without it nobody is the owner, and
50
51
  * the publisher reads comments through /responses. */
51
52
  ownerEmail?: string;
53
+ /** Turns a reader's voice memo into text on the server, so the service's key never reaches a
54
+ * browser: `deepgramTranscriber(key)` or `openaiTranscriber(key)` from `./artifacts`, or your
55
+ * own. Without it the transcribe route answers 404 and readers get the browser's live
56
+ * transcript, or an empty box to type into. */
57
+ transcribe?: Transcriber;
52
58
  };
53
59
  /** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
54
60
  export declare const ARTIFACT_ID: RegExp;
@@ -152,13 +158,7 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
152
158
  id: string;
153
159
  name: string;
154
160
  }>;
155
- }) => Promise<NextResponse<{
156
- error: string;
157
- }> | NextResponse<{
158
- id: string;
159
- name: string;
160
- url: string;
161
- }>>;
161
+ }) => Promise<NextResponse<unknown>>;
162
162
  SHARE_IMAGE: (_request: NextRequest, { params }: Params) => Promise<Response>;
163
163
  ENTER: (request: NextRequest) => Promise<NextResponse<unknown>>;
164
164
  LEAVE: (request: NextRequest) => Promise<NextResponse<unknown>>;
@@ -191,5 +191,10 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
191
191
  access: Access | null;
192
192
  views: number;
193
193
  }>>;
194
+ TRANSCRIBE: (req: NextRequest, { params }: {
195
+ params: Promise<{
196
+ id: string;
197
+ }>;
198
+ }) => Promise<NextResponse<unknown>>;
194
199
  };
195
200
  export {};
@@ -28,6 +28,7 @@ import { AckDoor, ConfidentialBanner, NO_PRINT_CSS, NotAllowedDoor, SignInDoor,
28
28
  import { ARTIFACT_ID_RE } from './ids.js';
29
29
  import { createStateRoutes } from './state-routes.js';
30
30
  import { createPersonalRoutes } from './personal-routes.js';
31
+ import { createVoiceRoutes } from './voice-routes.js';
31
32
  /** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
32
33
  export const ARTIFACT_ID = ARTIFACT_ID_RE;
33
34
  /** A version number as it appears in a URL: no zero, no leading zero, so each version has one address. */
@@ -53,11 +54,18 @@ export function createArtifactRoutes(config) {
53
54
  const readerSecret = () => config.readerSecret?.();
54
55
  const signOutUrl = (id) => `/api/reader/leave?to=${encodeURIComponent(pagePath(id))}`;
55
56
  const shareCardUrl = (id, updatedAt) => `${pageUrl(id)}/share.png?v=${encodeURIComponent(updatedAt)}`;
57
+ // Readers' recordings are played through short-lived signed URLs, issued only beside the comment
58
+ // or note they belong to; a host whose assets cannot sign them shows no play control.
59
+ const audioUrl = config.assets?.audioUrl ? (id, path) => config.assets.audioUrl(id, path) : undefined;
56
60
  const stateRoutes = createStateRoutes({
57
61
  store, state: config.state, readers: config.readers, readerSecret, publishKey: config.publishKey, pageUrl, signInOrigin, siteUrl,
58
- clientIp: config.clientIp, ownerEmail: config.ownerEmail,
62
+ clientIp: config.clientIp, ownerEmail: config.ownerEmail, audioUrl,
63
+ });
64
+ const personalRoutes = createPersonalRoutes({ store, personal: config.personal, readers: config.readers, readerSecret, pageUrl, signInOrigin, siteUrl, audioUrl });
65
+ const voiceRoutes = createVoiceRoutes({
66
+ store, state: config.state, personal: config.personal, readers: config.readers, assets: config.assets, transcribe: config.transcribe,
67
+ readerSecret, pageUrl, signInOrigin, siteUrl, clientIp: config.clientIp,
59
68
  });
60
- const personalRoutes = createPersonalRoutes({ store, personal: config.personal, readers: config.readers, readerSecret, pageUrl, signInOrigin, siteUrl });
61
69
  async function generateMetadata({ params }) {
62
70
  const { id } = await params;
63
71
  const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
@@ -439,6 +447,21 @@ export function createArtifactRoutes(config) {
439
447
  * bytes never arrived. 501 on a host whose assets cannot sign, so the publisher falls back.
440
448
  */
441
449
  async function UPLOAD(request, { params }) {
450
+ const raw = await request.text().catch(() => '');
451
+ let body = {};
452
+ try {
453
+ const parsed = raw.length <= 4096 ? JSON.parse(raw) : null;
454
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
455
+ body = parsed;
456
+ }
457
+ catch {
458
+ body = {};
459
+ }
460
+ // A reader's voice memo: the reader's own doors, never the publish key (voice-routes.ts).
461
+ if (body.audio !== undefined) {
462
+ const { id, name } = await params;
463
+ return voiceRoutes.readerUpload(request, id, name, body);
464
+ }
442
465
  if (!isPublishAuthed(request, config.publishKey()))
443
466
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
444
467
  const assets = config.assets;
@@ -454,7 +477,6 @@ export function createArtifactRoutes(config) {
454
477
  const type = contentTypeFor(name);
455
478
  if (!type)
456
479
  return NextResponse.json({ error: 'asset type not allowed; use webp, png, jpg, gif, mp3, mp4 or json' }, { status: 415 });
457
- const body = (await request.json().catch(() => ({})));
458
480
  const digest = typeof body.digest === 'string' ? body.digest : '';
459
481
  if (!ASSET_DIGEST.test(digest))
460
482
  return NextResponse.json({ error: 'digest must be the first 8 hex of the sha256 of the bytes' }, { status: 400 });
@@ -508,5 +530,5 @@ export function createArtifactRoutes(config) {
508
530
  revalidatePath(pagePath(id));
509
531
  return NextResponse.json({ deleted: true });
510
532
  }
511
- return { Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, ...stateRoutes, ...personalRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
533
+ return { Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, TRANSCRIBE: voiceRoutes.TRANSCRIBE, ...stateRoutes, ...personalRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
512
534
  }