@supersuit/suggest-a-change 0.2.4 → 0.3.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 +11 -0
- package/README.md +2 -1
- package/dist/client/adapters.d.ts +1 -1
- package/dist/client/default-field.js +42 -8
- package/dist/client/mine.js +2 -0
- package/dist/client/sheet.js +1 -1
- package/dist/client/words.d.ts +1 -0
- package/dist/client/words.js +5 -5
- package/dist/github/compose.d.ts +7 -2
- package/dist/github/compose.js +4 -1
- package/dist/github/filer.d.ts +3 -0
- package/dist/github/index.d.ts +2 -2
- package/dist/github/index.js +1 -1
- package/dist/github/relay.d.ts +27 -0
- package/dist/github/relay.js +48 -3
- package/package.json +1 -1
- package/src/client/adapters.ts +1 -1
- package/src/client/default-field.tsx +42 -11
- package/src/client/mine.tsx +2 -0
- package/src/client/sheet.tsx +1 -1
- package/src/client/styles.css +14 -2
- package/src/client/words.ts +6 -5
- package/src/github/compose.ts +10 -3
- package/src/github/filer.ts +3 -1
- package/src/github/index.ts +2 -2
- package/src/github/relay.ts +57 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 (2026-11-01)
|
|
4
|
+
|
|
5
|
+
- **The relay transcribes voice notes.** `createSuggestionRelay({ transcribe })` takes a transcriber, `({ name, mimeType, bytes, staged? }, { signal }) => Promise<string>`, and calls it for each voice note a `file` or `follow-up` brings with no `transcript`, before the privacy catch, so the transcript is caught like the words. A report with only a voice note is now titled from the transcript's first sentence instead of "Suggestion from <app>", under "Voice note (transcribed automatically)" rather than a claim about the owner's Mac. A transcriber that fails, hears nothing, or takes longer than `transcribeTimeoutMs` (default 25 s) never stops the filing: the issue files with the recording and says in plain words why there is no transcript, never the transcriber's error. `capabilities` answers `transcribes`; `RelayCapabilities`, `ComposeOptions.transcriptBy` and `transcriptNote` are new. Earned when voice-only suggestions reached GitHub as "Not transcribed." after the receiving Mac stopped writing transcripts.
|
|
6
|
+
- **My suggestions sets nothing once it is gone.** A report marked seen starts a recount; if the screen unmounted while that recount was reading the seen record, its late answer still set state, which threw `window is not defined` inside React when the page had been torn down (seen 2026-10-29 as an unhandled rejection failing the Helm's vitest run under load). Unmounting now retires every load and recount still in flight, as switching account already did.
|
|
7
|
+
|
|
8
|
+
## 0.2.5 (2026-10-26)
|
|
9
|
+
|
|
10
|
+
- **A tap on the microphone records.** `DefaultField` was hold-only: on a tap the release ended the take before the microphone had answered, so nothing was recorded and nothing said to hold (seen on iPhone Safari). Now a press shorter than 350 ms starts a take that keeps recording until the next tap (or the time limit), and a longer press records until it is let go. Leaving the button, or a pointer the browser cancels (iOS Safari does when its permission prompt appears), never ends a tapped take. `startTake` is still called synchronously inside the press, with nothing awaited before it. While recording the dot turns to a stop square and pulses (under `prefers-reduced-motion: no-preference`).
|
|
11
|
+
- **The microphone sits inside its text box.** Bottom right of a text area, centred at the right of a one-line input, a 44px target, with the box's padding keeping text clear of it (`.sac-voice-box`, `.sac-voice-multi`, `.sac-voice-single`). The `[data-voice-field]` / `[data-voice-control]` contract is unchanged.
|
|
12
|
+
- **Words.** `holdToTalk` now defaults to "Tap to talk", `stopTalking` to "Tap to stop", and the placeholders say "tap the microphone and say it". The keys keep their names.
|
|
13
|
+
|
|
3
14
|
## 0.2.4 (2026-10-26)
|
|
4
15
|
|
|
5
16
|
- **The list of what a suggestion carries is folded by default.** With a destination, the sheet above Send says one line, "Files an issue on <repo> (private)" or, in `--sac-danger` and never folded, "(public: anyone can read it)", then a toggle "What it carries (N)" counting what will go (the always rows plus each ticked one). Tapping it opens the full list with its tick boxes, reasons and account line; unticking inside still leaves an item out and lowers the count. Without a destination the tick boxes fold under the one line as "Also shared (N)". The toggle is a `<button aria-expanded aria-controls>` with a 44px target (`.sac-fold`); the app's `destinationNote` stays visible outside the fold. New replaceable words: `filesOn`, `privateShort`, `publicShort`, `carriesToggle`, `alsoSharedToggle`, `carriesLead`. `destinationWords` is unchanged. Earned by the operator's read of the open list on a phone: "it's a little bit disturbing with all the checkboxes shown."
|
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ const adapters: Adapters = {
|
|
|
52
52
|
|
|
53
53
|
`SuggestEntry` also takes `className` and `role` (for example `role="menuitem"` when a host shows it as a menu row); no role is set by default. My suggestions and the detail view keep their last answer from the app on the phone (module-owned IndexedDB, keyed by app and account like a draft), draw it at once with a quiet "Updating" line, and replace it when the app answers; if the app cannot be reached they keep the saved rows and say so. An adapter tells a refusal from an unreachable app: `mine()` and `one()` throw an error with `refused: true` or a `status` of 401, 403 or 404 for a refusal (the saved copy is then deleted), and anything else thrown is unreachable. Saved reports the list no longer holds are pruned, and signing out clears them. A first open with nothing saved shows "Loading".
|
|
54
54
|
|
|
55
|
-
The entry also keeps the phone's outbox moving while it is mounted (a report sent with the Mac away goes when it is back, with the sheet closed); a host with no always-mounted entry renders `<KeepSending adapters={adapters} />` once, high in the app. The entry carries `data-suggest-entry` (the attribute the screen check looks for; see "The check" below). Signed out, it opens nothing and says why. The receipt says "Got it" only after the adapter has answered `done` for the report and every attachment; until then it says the report is on the phone. Every text field the module renders sits in a `[data-voice-field]` container with a `[data-voice-control]`; a host's `Field` slot must keep that shape (it receives `FieldProps`, which is the Helm's voice field props). A voice note alone is a whole report: the recording is kept and uploaded as a part, and
|
|
55
|
+
The entry also keeps the phone's outbox moving while it is mounted (a report sent with the Mac away goes when it is back, with the sheet closed); a host with no always-mounted entry renders `<KeepSending adapters={adapters} />` once, high in the app. The entry carries `data-suggest-entry` (the attribute the screen check looks for; see "The check" below). Signed out, it opens nothing and says why. The receipt says "Got it" only after the adapter has answered `done` for the report and every attachment; until then it says the report is on the phone. Every text field the module renders sits in a `[data-voice-field]` container with a `[data-voice-control]`; a host's `Field` slot must keep that shape (it receives `FieldProps`, which is the Helm's voice field props). A voice note alone is a whole report: the recording is kept and uploaded as a part, and whoever receives it writes the transcript (a relay given `transcribe`, see below, or the owner's Mac). `DefaultField` puts its microphone inside the text box (bottom right of a text area, centred at the right of a one-line input, a 44px target, with the text kept clear of it). A tap starts a take that records until the next tap or the time limit; a press held past a tap records until it is let go; leaving the button or a pointer the browser cancels (iOS Safari's microphone prompt) never ends a tapped take. The recorder is asked inside the press with nothing awaited first, so iOS Safari counts the microphone request as part of the gesture. A host's own `Field` should follow the same two rules.
|
|
56
56
|
|
|
57
57
|
Styling: structure only, class names prefixed `sac-`. `@supersuit/suggest-a-change/styles.css` is optional defaults (16px inputs so iOS does not zoom, 44px touch targets, safe-area padding on the sheet). Re-skin it with these custom properties:
|
|
58
58
|
|
|
@@ -385,6 +385,7 @@ await relay.status([{ repo: "acme/notes", number: got.number }]); // [{ repo, nu
|
|
|
385
385
|
|
|
386
386
|
- **`file`** `{ repo, app?, report, transcript?, files: [{ name, type, bytes (base64) } | { name, type, staged: name }], redacted? }` answers `{ number, url }`. The relay checks the report against the contract and each file against what the report declares, runs the privacy catch itself, saves the files to `suggestion-attachments`, then opens the issue **as the App**, filed by the name `identity()` gives (never the account id, and never a name in the request), with the role and the contact from `identity()` too and the app's name and `app:<label>` from the relay's `apps` table by the request's `app` id (an id not in the table is 400 "unknown app"; with no table no request names an app, and the issue says "in an app" with no app label); the contact only when the report carries `who.contact` (the person left it ticked). At most ten labels. A credential refuses with 422 naming the kind and the field, never the text, whatever `redacted` says. Any other hit refuses with 409 `{ held: true, hits: [{ kind, field }] }` until the filer sends again with `redacted: true`, saying they chose to file without the flagged part; the relay then redacts what it found itself, so raw text is never filed on the filer's word. The catch reads every string that would reach the issue (where, every diagnostic, the context, the trail, the names, the handle), after folding look-alike letters and removing invisible characters. A repeat of the same report answers the same issue; another identity using that report id is 409.
|
|
387
387
|
- **`follow-up`** `{ repo, number, reportId, words, transcript?, files?, redacted? }` adds a comment, only for the identity that filed that report through this relay (anyone else, 403). The same privacy catch runs.
|
|
388
|
+
- **Transcribing voice notes.** Pass `transcribe: async ({ name, mimeType, bytes, staged? }, { signal }) => string` and the relay writes the transcript of every voice note a `file` or `follow-up` brings with no `transcript` (a staged note's bytes are already read; `staged` is its key, for a host that would rather sign a read link). It runs before the privacy catch, so the transcript is caught and redacted like the words; the issue's heading says "Voice note (transcribed automatically)", and a report with no words takes its title from the transcript's first sentence instead of "Suggestion from <app>". All of a request's notes are transcribed at once within `transcribeTimeoutMs` (default `TRANSCRIBE_TIMEOUT_MS`, 25 s, inside a 60 s serverless limit); `signal` aborts when the relay stops waiting. A transcriber that throws, returns nothing, or is too slow never stops the filing: the issue files with the recording and one plain sentence of why (`TRANSCRIPT_MISSING`: the service did not answer in time, failed or refused the recording, or heard no speech), never the transcriber's own error text. A transcript the request carries is used as it is. `capabilities` answers `transcribes: true` when the relay has a transcriber, which is what a host sets the sheet's `Destination.transcribes` from, so the list promises a transcript only when one will be made.
|
|
388
389
|
- **`status`** `{ items: [{ repo, number }] }` answers `{ items: [{ repo, number, status, reason? }] }`, only for issues this identity filed (anything else, 403).
|
|
389
390
|
- **`attach`** is for a filer who opened the issue themselves but cannot push. Reading the issue is the proof they could file there: it must exist, be an issue, be open or closed within a week, and END with the composer's own `## Filed by` line for that report ("Report <id>" anywhere else proves nothing). And a report belongs to the first identity that files or attaches for it: anyone else attaching to it is 403. An issue the relay filed takes files only from the identity that filed it; an issue someone opened themselves takes them only when its author's login is the `githubLogin` that `identity()` vouches for, and nothing is claimed until that proof holds. A request lists each file once and no more of each kind than a report may carry; a follow-up is composed and measured before any file is written, and holds to the report's caps like `attach`.
|
|
390
391
|
|
|
@@ -42,7 +42,7 @@ export interface FieldProps {
|
|
|
42
42
|
placeholder?: string;
|
|
43
43
|
multiline?: boolean;
|
|
44
44
|
type?: 'text' | 'search';
|
|
45
|
-
/** Gets each finished take. The sheet keeps the recording;
|
|
45
|
+
/** Gets each finished take. The sheet keeps the recording; the transcript is written where it is received (a relay with `transcribe`, or the owner's Mac). */
|
|
46
46
|
upload?: HeardUpload;
|
|
47
47
|
onHeard?(h: {
|
|
48
48
|
audio: string;
|
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
// THE MODULE'S OWN VOICE FIELD, for hosted apps that have no golden one. A text area with
|
|
4
|
-
//
|
|
3
|
+
// THE MODULE'S OWN VOICE FIELD, for hosted apps that have no golden one. A text area with its microphone
|
|
4
|
+
// INSIDE the box (bottom right; centred at the right of a one-line input), in one [data-voice-field]
|
|
5
|
+
// container with nothing else typeable in it. A take is
|
|
5
6
|
// recorded with the module's recorder (written to the phone's database as it is made). Transcription is
|
|
6
|
-
// not done here: the take goes to `upload`, and the
|
|
7
|
+
// not done here: the take goes to `upload`, and whoever receives it (a relay with `transcribe`, or the owner's Mac) writes the transcript. A voice note
|
|
7
8
|
// alone is a whole report, so a field with no words and one take is a complete answer.
|
|
8
9
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
10
|
+
// A tap starts a take that keeps recording until the next tap (or the time limit); a press held longer
|
|
11
|
+
// than a tap records until it is let go, as hold-to-talk always did. Leaving the button, or a pointer the
|
|
12
|
+
// browser cancels (iOS Safari does when its microphone prompt appears), never ends a tapped take. The
|
|
13
|
+
// recorder is asked synchronously inside the press, with nothing awaited first, so iOS Safari treats the
|
|
14
|
+
// microphone request as part of the gesture. Space or Enter on the focused button starts and stops a take.
|
|
15
|
+
// (The operator, 2026-10-26: a tap on the hold-only button did nothing, because the release ended the take
|
|
16
|
+
// before the microphone had answered.)
|
|
11
17
|
import { useEffect, useRef, useState } from 'react';
|
|
12
18
|
import { startTake as defaultStartTake } from './recorder.js';
|
|
13
19
|
import { toast } from './toast.js';
|
|
14
20
|
import { DEFAULT_STRINGS } from './words.js';
|
|
21
|
+
/** A press shorter than this is a tap: it starts a take that a second tap stops. */
|
|
22
|
+
const TAP_MS = 350;
|
|
15
23
|
export function DefaultField(props) {
|
|
16
24
|
const { rt, id, label, value, onChange, placeholder, multiline = false, type = 'text', upload, onHeard, onKept, onHearing, disabled, startTake = defaultStartTake, maxMs, labels, scope } = props;
|
|
17
25
|
const L = labels ?? DEFAULT_STRINGS;
|
|
@@ -21,6 +29,8 @@ export function DefaultField(props) {
|
|
|
21
29
|
const take = useRef(null);
|
|
22
30
|
const wanted = useRef(false);
|
|
23
31
|
const timer = useRef(null);
|
|
32
|
+
// The press in progress that started a take: when it began. Null between presses, and for a press that stopped one.
|
|
33
|
+
const press = useRef(null);
|
|
24
34
|
const latest = useRef(value);
|
|
25
35
|
latest.current = value;
|
|
26
36
|
const cb = useRef({ onChange, upload, onHeard, onKept, onHearing });
|
|
@@ -85,8 +95,32 @@ export function DefaultField(props) {
|
|
|
85
95
|
}
|
|
86
96
|
useEffect(() => () => { wanted.current = false; if (timer.current)
|
|
87
97
|
clearTimeout(timer.current); void take.current?.cancel(); }, []);
|
|
88
|
-
const control = (_jsx("button", { type: "button", "data-voice-control": "", className: "sac-record", "aria-pressed": recording, "aria-label": canRecord ? `${recording ? L.stopTalking : L.holdToTalk}: ${label}` : `${L.voiceUnavailable}: ${label}`, disabled: disabled || !canRecord, onPointerDown: (e) => {
|
|
89
|
-
|
|
98
|
+
const control = (_jsx("button", { type: "button", "data-voice-control": "", className: "sac-record", "aria-pressed": recording, "aria-label": canRecord ? `${recording ? L.stopTalking : L.holdToTalk}: ${label}` : `${L.voiceUnavailable}: ${label}`, disabled: disabled || !canRecord, onPointerDown: (e) => {
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
// A take already going (a tapped one): this press stops it.
|
|
101
|
+
if (take.current || wanted.current) {
|
|
102
|
+
press.current = null;
|
|
103
|
+
void end();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
press.current = Date.now();
|
|
107
|
+
void begin(); // startTake is called before begin's first await: still inside the gesture
|
|
108
|
+
}, onPointerUp: () => {
|
|
109
|
+
const at = press.current;
|
|
110
|
+
press.current = null;
|
|
111
|
+
// A tap keeps recording until the next tap; a hold stops on release.
|
|
112
|
+
if (at !== null && Date.now() - at >= TAP_MS)
|
|
113
|
+
void end();
|
|
114
|
+
}, onPointerLeave: () => {
|
|
115
|
+
// Sliding off a held press stops it, as hold-to-talk did; a tapped take, or one still starting, goes on.
|
|
116
|
+
const at = press.current;
|
|
117
|
+
press.current = null;
|
|
118
|
+
if (at !== null && take.current && Date.now() - at >= TAP_MS)
|
|
119
|
+
void end();
|
|
120
|
+
}, onPointerCancel: () => {
|
|
121
|
+
// The browser took the pointer (a permission prompt, a scroll): the take carries on as a tapped one.
|
|
122
|
+
press.current = null;
|
|
123
|
+
}, onContextMenu: (e) => e.preventDefault(), onKeyDown: (e) => {
|
|
90
124
|
if (e.key !== ' ' && e.key !== 'Enter')
|
|
91
125
|
return;
|
|
92
126
|
e.preventDefault();
|
|
@@ -95,5 +129,5 @@ export function DefaultField(props) {
|
|
|
95
129
|
void (take.current || wanted.current ? end() : begin());
|
|
96
130
|
}, children: _jsx("span", { "aria-hidden": "true", className: "sac-record-dot" }) }));
|
|
97
131
|
const shared = { id: `sac-field-${id}`, value, placeholder, disabled, className: 'sac-input', onChange: (e) => onChange(e.target.value) };
|
|
98
|
-
return (_jsxs("div", { "data-voice-field": "", className: "sac-voice", "data-recording": recording ? '' : undefined, children: [
|
|
132
|
+
return (_jsxs("div", { "data-voice-field": "", className: "sac-voice", "data-recording": recording ? '' : undefined, children: [_jsx("label", { htmlFor: `sac-field-${id}`, className: "sac-sr", children: label }), _jsxs("div", { className: `sac-voice-box ${multiline ? 'sac-voice-multi' : 'sac-voice-single'}`, children: [multiline ? _jsx("textarea", { rows: 3, ...shared }) : _jsx("input", { type: type, ...shared }), control] })] }));
|
|
99
133
|
}
|
package/dist/client/mine.js
CHANGED
|
@@ -31,6 +31,8 @@ export function MySuggestions({ adapters, onOpen }) {
|
|
|
31
31
|
const answered = useRef(false);
|
|
32
32
|
useClearOnSignOut(rt, who, adapters.words.app);
|
|
33
33
|
useEffect(() => { loads.current++; known.current = null; answered.current = false; setRows(null); setOpen(null); setBad(''); setUpdating(false); setFresh(new Set()); }, [rt]);
|
|
34
|
+
// Leaving the screen retires every load and recount still running, as a new account does: none may set state after.
|
|
35
|
+
useEffect(() => () => { loads.current++; }, []);
|
|
34
36
|
const recount = useCallback(async () => {
|
|
35
37
|
if (!rt || !known.current)
|
|
36
38
|
return;
|
package/dist/client/sheet.js
CHANGED
|
@@ -203,7 +203,7 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
|
|
|
203
203
|
await chain.current;
|
|
204
204
|
return (await saveDraft(rt.store, key, {})).reportId;
|
|
205
205
|
}, [rt, key]);
|
|
206
|
-
// A finished take: kept as a copy under this report, and listed.
|
|
206
|
+
// A finished take: kept as a copy under this report, and listed. The transcript is written where it is received.
|
|
207
207
|
const keepTake = useCallback(async (fieldId, original, blob, ms) => {
|
|
208
208
|
if (!rt || !key)
|
|
209
209
|
return { refused: S.loading };
|
package/dist/client/words.d.ts
CHANGED
|
@@ -57,6 +57,7 @@ export interface Strings {
|
|
|
57
57
|
mineTitle: string;
|
|
58
58
|
mineEmpty: string;
|
|
59
59
|
back: string;
|
|
60
|
+
/** The microphone's name while idle. The key keeps its old name; a tap starts it, and holding works too. */
|
|
60
61
|
holdToTalk: string;
|
|
61
62
|
stopTalking: string;
|
|
62
63
|
micDenied: string;
|
package/dist/client/words.js
CHANGED
|
@@ -20,9 +20,9 @@ export const DEFAULT_STRINGS = {
|
|
|
20
20
|
optional: 'optional',
|
|
21
21
|
sayItLegend: 'Say it',
|
|
22
22
|
sayItLabel: 'Your suggestion',
|
|
23
|
-
sayItPlaceholder: 'What would you change? Type it, or
|
|
24
|
-
sayItPlaceholderBroken: 'What happened, what you expected, and how to make it happen again. Type it, or
|
|
25
|
-
sayItPlaceholderFeedback: 'Whatever is on your mind. Type it, or
|
|
23
|
+
sayItPlaceholder: 'What would you change? Type it, or tap the microphone and say it',
|
|
24
|
+
sayItPlaceholderBroken: 'What happened, what you expected, and how to make it happen again. Type it, or tap the microphone and say it',
|
|
25
|
+
sayItPlaceholderFeedback: 'Whatever is on your mind. Type it, or tap the microphone and say it',
|
|
26
26
|
showItLegend: 'Show it',
|
|
27
27
|
addImages: 'Add a screenshot or photo',
|
|
28
28
|
addVideo: 'Add a short video',
|
|
@@ -51,8 +51,8 @@ export const DEFAULT_STRINGS = {
|
|
|
51
51
|
mineTitle: 'My suggestions',
|
|
52
52
|
mineEmpty: 'You have not sent a suggestion yet.',
|
|
53
53
|
back: 'Back',
|
|
54
|
-
holdToTalk: '
|
|
55
|
-
stopTalking: '
|
|
54
|
+
holdToTalk: 'Tap to talk',
|
|
55
|
+
stopTalking: 'Tap to stop',
|
|
56
56
|
micDenied: 'The microphone could not be used. You can type it instead.',
|
|
57
57
|
voiceUnavailable: 'Voice notes cannot be added here',
|
|
58
58
|
loading: 'Loading',
|
package/dist/github/compose.d.ts
CHANGED
|
@@ -46,7 +46,8 @@ export interface Destination {
|
|
|
46
46
|
account: string;
|
|
47
47
|
/** False when files cannot reach this repo yet (no App installed and the filer cannot push): they wait. */
|
|
48
48
|
attaches: boolean;
|
|
49
|
-
/** False when nobody transcribes voice notes (
|
|
49
|
+
/** False when nobody transcribes voice notes (no relay `transcribe` and no Mac that writes one; a relay says which in
|
|
50
|
+
* its `capabilities`): the list does not promise a transcript. */
|
|
50
51
|
transcribes?: boolean;
|
|
51
52
|
/** The largest file this destination takes; a larger one is named in the list as staying on the phone. */
|
|
52
53
|
maxInlineBytes?: number;
|
|
@@ -104,8 +105,12 @@ export interface ComposeOptions {
|
|
|
104
105
|
filerRole?: string;
|
|
105
106
|
/** file name -> its link, from an AttachmentHost. A file with no link yet is written as still being added. */
|
|
106
107
|
attachmentUrls?: Record<string, string>;
|
|
107
|
-
/** The voice notes' transcript
|
|
108
|
+
/** The voice notes' transcript. */
|
|
108
109
|
transcript?: string;
|
|
110
|
+
/** Who made it: 'relay' when the relay transcribed it as it filed; otherwise it came with the request (the owner's Mac). */
|
|
111
|
+
transcriptBy?: 'relay' | 'mac';
|
|
112
|
+
/** A plain sentence saying a transcript, or part of one, could not be made and why; written under the voice note. */
|
|
113
|
+
transcriptNote?: string;
|
|
109
114
|
/** The first body, written before any file is saved: every attachment is "being added". */
|
|
110
115
|
pending?: boolean;
|
|
111
116
|
}
|
package/dist/github/compose.js
CHANGED
|
@@ -192,6 +192,7 @@ export function composeIssue(report, opts = {}) {
|
|
|
192
192
|
};
|
|
193
193
|
const words = scrub(text(report.words).trim());
|
|
194
194
|
const transcript = scrub(text(opts.transcript).trim());
|
|
195
|
+
const note = inline(text(opts.transcriptNote).trim());
|
|
195
196
|
// Only links the host produced, and only plain https ones, become images or links; anything else is still "being added".
|
|
196
197
|
const urls = opts.pending ? {} : Object.fromEntries(Object.entries(opts.attachmentUrls ?? {}).flatMap(([k, u]) => (safeUrl(u) ? [[k, u]] : [])));
|
|
197
198
|
const media = report.media ?? [];
|
|
@@ -217,7 +218,9 @@ export function composeIssue(report, opts = {}) {
|
|
|
217
218
|
if (has.has('words'))
|
|
218
219
|
out.push('## What they said', fence(said), '');
|
|
219
220
|
if (has.has('voice')) {
|
|
220
|
-
out.push(transcript ? "## Voice note (transcribed on the owner's Mac)" : '## Voice note', heard ? fence(heard) : 'Not transcribed.');
|
|
221
|
+
out.push(transcript ? (opts.transcriptBy === 'relay' ? '## Voice note (transcribed automatically)' : "## Voice note (transcribed on the owner's Mac)") : '## Voice note', heard ? fence(heard) : (note || 'Not transcribed.'));
|
|
222
|
+
if (heard && note)
|
|
223
|
+
out.push('', note);
|
|
221
224
|
const links = voice.filter((v) => urls[v.file]).map((v) => `[${inline(v.file)}](${urls[v.file]})`);
|
|
222
225
|
if (links.length)
|
|
223
226
|
out.push('', ...links);
|
package/dist/github/filer.d.ts
CHANGED
|
@@ -51,11 +51,14 @@ export interface RelayFiler {
|
|
|
51
51
|
}
|
|
52
52
|
/** Files larger than this go to the relay staged (one signed PUT each) rather than inside the request. */
|
|
53
53
|
export declare const STAGE_OVER_BYTES: number;
|
|
54
|
+
/** `transcribes`: the relay writes a voice note's transcript itself (a host passed `transcribe`); set the sheet's
|
|
55
|
+
* `Destination.transcribes` from it. */
|
|
54
56
|
export interface RelayCapabilities {
|
|
55
57
|
actions: string[];
|
|
56
58
|
staging: boolean;
|
|
57
59
|
inlineBytes: number;
|
|
58
60
|
maxBytes?: Record<string, number>;
|
|
61
|
+
transcribes?: boolean;
|
|
59
62
|
}
|
|
60
63
|
/** `url` is where the relay is mounted; each action is posted to `<url>/<action>`. */
|
|
61
64
|
export declare function relayFiler({ url, headers, fetch }: {
|
package/dist/github/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { fileIssue, updateIssue, addFollowUp, issueStates, issueStatus, fileSugg
|
|
|
6
6
|
export type { GitHubConfig, FilingConfig, IssueState, FileSuggestionInput, FiledSuggestion, Fetch } from './api.ts';
|
|
7
7
|
export { branchHost, relayHost, toBase64, attachmentUrl, ATTACHMENTS_BRANCH, CONTENTS_API_BYTES } from './hosts.ts';
|
|
8
8
|
export type { AttachmentHost, AttachmentTarget, AttachmentFile } from './hosts.ts';
|
|
9
|
-
export { createSuggestionRelay, createAttachRelay, memoryUsage, RELAY_LIMITS, RELAY_VOICE_BYTES, FILING_LEASE_MS } from './relay.ts';
|
|
10
|
-
export type { SuggestionRelayOptions, AttachRelayOptions, RelayLimits, RelayUsage, RelayIdentity, Reservation, FiledRecord, StagingAdapter } from './relay.ts';
|
|
9
|
+
export { createSuggestionRelay, createAttachRelay, memoryUsage, RELAY_LIMITS, RELAY_VOICE_BYTES, FILING_LEASE_MS, TRANSCRIBE_TIMEOUT_MS, TRANSCRIPT_MISSING } from './relay.ts';
|
|
10
|
+
export type { SuggestionRelayOptions, AttachRelayOptions, RelayLimits, RelayUsage, RelayIdentity, Reservation, FiledRecord, StagingAdapter, RelayVoice, RelayTranscriber } from './relay.ts';
|
|
11
11
|
export { relayFiler, canFileDirectly, STAGE_OVER_BYTES } from './filer.ts';
|
|
12
12
|
export type { RelayFiler, RelayFiling, RelayFile, RelayCapabilities } from './filer.ts';
|
package/dist/github/index.js
CHANGED
|
@@ -5,5 +5,5 @@ export { privacyCatch, redact, mayFileAsIs, normalize } from './privacy.js';
|
|
|
5
5
|
export { composeIssue, whatGoes, destinationWords, pieces, BODY_LIMIT, filedByLine, endsWithFiledBy, FILED_BY } from './compose.js';
|
|
6
6
|
export { fileIssue, updateIssue, addFollowUp, issueStates, issueStatus, fileSuggestion, attachPending, github, GitHubError } from './api.js';
|
|
7
7
|
export { branchHost, relayHost, toBase64, attachmentUrl, ATTACHMENTS_BRANCH, CONTENTS_API_BYTES } from './hosts.js';
|
|
8
|
-
export { createSuggestionRelay, createAttachRelay, memoryUsage, RELAY_LIMITS, RELAY_VOICE_BYTES, FILING_LEASE_MS } from './relay.js';
|
|
8
|
+
export { createSuggestionRelay, createAttachRelay, memoryUsage, RELAY_LIMITS, RELAY_VOICE_BYTES, FILING_LEASE_MS, TRANSCRIBE_TIMEOUT_MS, TRANSCRIPT_MISSING } from './relay.js';
|
|
9
9
|
export { relayFiler, canFileDirectly, STAGE_OVER_BYTES } from './filer.js';
|
package/dist/github/relay.d.ts
CHANGED
|
@@ -97,6 +97,28 @@ export interface RelayIdentity {
|
|
|
97
97
|
handle?: string;
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
|
+
/** One voice recording handed to the host's transcriber: its file name, its type as audio, and its bytes (read from
|
|
101
|
+
* staging already when it was staged; `staged` is then its staging key, for a host that would rather sign a read link). */
|
|
102
|
+
export interface RelayVoice {
|
|
103
|
+
name: string;
|
|
104
|
+
mimeType: string;
|
|
105
|
+
bytes: Uint8Array;
|
|
106
|
+
staged?: string;
|
|
107
|
+
}
|
|
108
|
+
/** Answers the words said in one recording. `signal` aborts when the relay stops waiting. */
|
|
109
|
+
export type RelayTranscriber = (voice: RelayVoice, o: {
|
|
110
|
+
signal: AbortSignal;
|
|
111
|
+
}) => Promise<string>;
|
|
112
|
+
/** How long the relay waits for all of a request's transcripts before it files without them. Serverless hosts cap a
|
|
113
|
+
* request (Vercel's default is 60 s), so this leaves room for GitHub. */
|
|
114
|
+
export declare const TRANSCRIBE_TIMEOUT_MS = 25000;
|
|
115
|
+
/** The plain words an issue uses for why a transcript is missing. Never the transcriber's own error, which can carry
|
|
116
|
+
* anything (a key, a URL, a provider's internals). */
|
|
117
|
+
export declare const TRANSCRIPT_MISSING: {
|
|
118
|
+
readonly timeout: 'the transcription service did not answer in time';
|
|
119
|
+
readonly failed: 'the transcription service failed or refused the recording';
|
|
120
|
+
readonly silent: 'no speech was heard in the recording';
|
|
121
|
+
};
|
|
100
122
|
export interface SuggestionRelayOptions {
|
|
101
123
|
/** The GitHub App the relay acts as: its numeric id and its private key (PEM, PKCS#1 as GitHub issues it, or PKCS#8). */
|
|
102
124
|
app: {
|
|
@@ -125,6 +147,11 @@ export interface SuggestionRelayOptions {
|
|
|
125
147
|
staging?: StagingAdapter;
|
|
126
148
|
/** May lower any limit, never raise one. */
|
|
127
149
|
limits?: Partial<RelayLimits>;
|
|
150
|
+
/** Writes the transcript of each voice note a `file` or `follow-up` brings with no `transcript`. Without it the issue
|
|
151
|
+
* says the note was not transcribed, and `capabilities` says `transcribes: false`. */
|
|
152
|
+
transcribe?: RelayTranscriber;
|
|
153
|
+
/** Default TRANSCRIBE_TIMEOUT_MS; all of a request's notes are transcribed at once inside it. */
|
|
154
|
+
transcribeTimeoutMs?: number;
|
|
128
155
|
usage?: RelayUsage;
|
|
129
156
|
fetch?: Fetch;
|
|
130
157
|
now?: () => number;
|
package/dist/github/relay.js
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
// - attach: for a filer who opened the issue themselves but cannot push; the relay proves they could file there by
|
|
10
10
|
// reading the issue and requiring it to carry the report.
|
|
11
11
|
//
|
|
12
|
+
// With `transcribe`, the relay also writes the transcript of each voice note a request brings without one, before the
|
|
13
|
+
// privacy catch, so the transcript is caught like the words. A transcriber that fails or is slow never stops a filing.
|
|
14
|
+
//
|
|
12
15
|
// Web-standard only: Request, Response, fetch, crypto.subtle.
|
|
13
16
|
import { CEILINGS, isRepoName, isReportId, validContact, validateReport } from '../contract.mjs';
|
|
14
17
|
import { cappedText, json } from '../server/http.js';
|
|
@@ -92,6 +95,16 @@ export function memoryUsage({ now = () => Date.now() } = {}) {
|
|
|
92
95
|
async filer(repo, num) { return issues.get(`${repo}#${num}`) ?? null; },
|
|
93
96
|
};
|
|
94
97
|
}
|
|
98
|
+
/** How long the relay waits for all of a request's transcripts before it files without them. Serverless hosts cap a
|
|
99
|
+
* request (Vercel's default is 60 s), so this leaves room for GitHub. */
|
|
100
|
+
export const TRANSCRIBE_TIMEOUT_MS = 25_000;
|
|
101
|
+
/** The plain words an issue uses for why a transcript is missing. Never the transcriber's own error, which can carry
|
|
102
|
+
* anything (a key, a URL, a provider's internals). */
|
|
103
|
+
export const TRANSCRIPT_MISSING = {
|
|
104
|
+
timeout: 'the transcription service did not answer in time',
|
|
105
|
+
failed: 'the transcription service failed or refused the recording',
|
|
106
|
+
silent: 'no speech was heard in the recording',
|
|
107
|
+
};
|
|
95
108
|
/** How long a filing holds its report before another try may take it over (its process is presumed dead). */
|
|
96
109
|
export const FILING_LEASE_MS = 5 * 60_000;
|
|
97
110
|
const FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
@@ -282,6 +295,32 @@ export function createSuggestionRelay(opts, only) {
|
|
|
282
295
|
return json(413, { error: `that file is larger than the ${max} bytes allowed` });
|
|
283
296
|
return { name: x.name, type: real, kind, bytes, ...(stagedKey ? { stagedKey } : {}) };
|
|
284
297
|
}
|
|
298
|
+
/** The transcript for a request's voice notes: the request's own when it carries one, else the relay's (with a plain
|
|
299
|
+
* note for any note it could not transcribe), else none. Never throws. */
|
|
300
|
+
async function transcriptFor(files, given) {
|
|
301
|
+
if (typeof given === 'string' && given.trim())
|
|
302
|
+
return { transcript: given };
|
|
303
|
+
const voices = files.filter((f) => f.kind === 'voice');
|
|
304
|
+
if (!opts.transcribe || !voices.length)
|
|
305
|
+
return {};
|
|
306
|
+
const ms = opts.transcribeTimeoutMs ?? TRANSCRIBE_TIMEOUT_MS;
|
|
307
|
+
const stop = new AbortController();
|
|
308
|
+
let timer;
|
|
309
|
+
const late = new Promise((res) => { timer = setTimeout(() => { stop.abort(); res('late'); }, ms); });
|
|
310
|
+
const one = (v) => Promise.resolve()
|
|
311
|
+
.then(() => opts.transcribe({ name: v.name, mimeType: v.type.replace(/^video\//, 'audio/'), bytes: v.bytes, ...(v.stagedKey ? { staged: v.stagedKey } : {}) }, { signal: stop.signal }))
|
|
312
|
+
.then((t) => (typeof t === 'string' && t.trim() ? { text: t.trim() } : { fail: TRANSCRIPT_MISSING.silent }), () => ({ fail: TRANSCRIPT_MISSING.failed }));
|
|
313
|
+
const got = await Promise.race([Promise.all(voices.map(one)), late]);
|
|
314
|
+
clearTimeout(timer);
|
|
315
|
+
const results = got === 'late' ? voices.map(() => ({ fail: TRANSCRIPT_MISSING.timeout })) : got;
|
|
316
|
+
const many = voices.length > 1;
|
|
317
|
+
const texts = results.flatMap((r, i) => ('text' in r ? [many ? `(${voices[i].name})\n${r.text}` : r.text] : []));
|
|
318
|
+
const fails = results.flatMap((r, i) => ('fail' in r ? [many ? `${voices[i].name}: ${r.fail}` : r.fail] : []));
|
|
319
|
+
const note = fails.length
|
|
320
|
+
? `The transcript${many ? ` of ${fails.length === voices.length ? 'the recordings' : `${fails.length} of ${voices.length} recordings`}` : ''} could not be made: ${fails.join('; ')}.${texts.length ? '' : ` The recording${many ? 's are' : ' is'} attached.`}`
|
|
321
|
+
: undefined;
|
|
322
|
+
return { ...(texts.length ? { transcript: texts.join('\n\n'), by: 'relay' } : {}), ...(note ? { note } : {}) };
|
|
323
|
+
}
|
|
285
324
|
/** The bytes a request still has to hold today: inline files only, since upload-url held a staged file's up front. */
|
|
286
325
|
const inlineBytesOf = (files) => files.reduce((n, f) => n + (f.stagedKey ? 0 : f.bytes.length), 0);
|
|
287
326
|
const discardStaged = async (files) => {
|
|
@@ -381,12 +420,14 @@ export function createSuggestionRelay(opts, only) {
|
|
|
381
420
|
if (appId !== undefined && opts.apps && (typeof appId !== 'string' || !Object.prototype.hasOwnProperty.call(opts.apps, appId)))
|
|
382
421
|
return bad('unknown app');
|
|
383
422
|
const app = opts.apps && typeof appId === 'string' ? opts.apps[appId] : undefined;
|
|
423
|
+
// The transcript is made before the catch reads anything, so it is caught like the words.
|
|
424
|
+
const heard = await transcriptFor(files, body.transcript);
|
|
384
425
|
// Who it is from comes from the host, never the request: the name, the role and the contact.
|
|
385
426
|
const contact = contactFor(me, report.who?.contact);
|
|
386
427
|
const appVersion = typeof report.diagnostics?.appVersion === 'string' ? report.diagnostics.appVersion : undefined;
|
|
387
428
|
const readable = {
|
|
388
429
|
words: report.words, followUps: report.followUps, where: report.where, diagnostics: report.diagnostics,
|
|
389
|
-
context: report.context, transcript:
|
|
430
|
+
context: report.context, transcript: heard.transcript,
|
|
390
431
|
filerName: me.name, appName: app?.name, who: { contact },
|
|
391
432
|
files: files.map((f) => ({ name: f.name })),
|
|
392
433
|
};
|
|
@@ -438,6 +479,8 @@ export function createSuggestionRelay(opts, only) {
|
|
|
438
479
|
filerName: typeof clean.filerName === 'string' && clean.filerName.trim() ? clean.filerName.trim().slice(0, 100) : 'Someone',
|
|
439
480
|
filerRole: role,
|
|
440
481
|
transcript: clean.transcript,
|
|
482
|
+
...(heard.by && clean.transcript ? { transcriptBy: heard.by } : {}),
|
|
483
|
+
...(heard.note ? { transcriptNote: heard.note } : {}),
|
|
441
484
|
attachmentUrls,
|
|
442
485
|
});
|
|
443
486
|
const made = await fileIssue({ repo, token, fetch: opts.fetch, api: opts.api }, issue);
|
|
@@ -488,7 +531,8 @@ export function createSuggestionRelay(opts, only) {
|
|
|
488
531
|
if (checked instanceof Response)
|
|
489
532
|
return checked;
|
|
490
533
|
const files = checked;
|
|
491
|
-
const
|
|
534
|
+
const heard = await transcriptFor(files, body.transcript);
|
|
535
|
+
const readable = { words: typeof body.words === 'string' ? body.words.slice(0, 20_000) : '', transcript: heard.transcript, files: files.map((f) => ({ name: f.name })) };
|
|
492
536
|
const caught = catchIt(readable, account, body.redacted === true);
|
|
493
537
|
if ('res' in caught)
|
|
494
538
|
return caught.res;
|
|
@@ -500,7 +544,7 @@ export function createSuggestionRelay(opts, only) {
|
|
|
500
544
|
const links = files.map((f) => { const url = attachmentUrl(repo, ATTACHMENTS_BRANCH, `${folder}/${f.name}`); return f.kind === 'image' ? `` : `[${f.name}](${url})`; });
|
|
501
545
|
// A follow-up is the person's own words, so it sits in a fence like the words of the report.
|
|
502
546
|
const fenced = (s) => { const run = Math.max(0, ...[...s.matchAll(/`+/g)].map((m) => m[0].length)); const f = '`'.repeat(Math.max(3, run + 1)); return `${f}\n${s}\n${f}`; };
|
|
503
|
-
const text = ['## Added later', words.trim() ? fenced(words.trim()) : '', transcript ? `\n### Voice note\n${fenced(transcript.trim())}` : '', links.length ? `\n${links.join('\n')}` : ''].filter(Boolean).join('\n');
|
|
547
|
+
const text = ['## Added later', words.trim() ? fenced(words.trim()) : '', transcript ? `\n### Voice note\n${fenced(transcript.trim())}` : '', heard.note ? `\n${heard.note}` : '', links.length ? `\n${links.join('\n')}` : ''].filter(Boolean).join('\n');
|
|
504
548
|
if (text.length > BODY_LIMIT)
|
|
505
549
|
return json(413, { error: `that follow-up is longer than GitHub takes in a comment (${BODY_LIMIT} characters)` });
|
|
506
550
|
const held = await reserve(account, reportId, inlineBytesOf(files));
|
|
@@ -707,6 +751,7 @@ export function createSuggestionRelay(opts, only) {
|
|
|
707
751
|
const capabilities = () => json(200, {
|
|
708
752
|
actions: ACTIONS.filter((a) => (only ? a === only : a !== 'upload-url' || Boolean(opts.staging))),
|
|
709
753
|
staging: Boolean(opts.staging),
|
|
754
|
+
transcribes: Boolean(opts.transcribe),
|
|
710
755
|
inlineBytes: limits.inlineBytes,
|
|
711
756
|
maxBytes: { image: limits.imageBytes, video: limits.videoBytes, voice: limits.voiceBytes },
|
|
712
757
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/suggest-a-change",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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/adapters.ts
CHANGED
|
@@ -33,7 +33,7 @@ export interface FieldProps {
|
|
|
33
33
|
placeholder?: string
|
|
34
34
|
multiline?: boolean
|
|
35
35
|
type?: 'text' | 'search'
|
|
36
|
-
/** Gets each finished take. The sheet keeps the recording;
|
|
36
|
+
/** Gets each finished take. The sheet keeps the recording; the transcript is written where it is received (a relay with `transcribe`, or the owner's Mac). */
|
|
37
37
|
upload?: HeardUpload
|
|
38
38
|
onHeard?(h: { audio: string; text: string }): void
|
|
39
39
|
onKept?(t: { key: string; ms: number }): void
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
'use client'
|
|
2
|
-
// THE MODULE'S OWN VOICE FIELD, for hosted apps that have no golden one. A text area with
|
|
3
|
-
//
|
|
2
|
+
// THE MODULE'S OWN VOICE FIELD, for hosted apps that have no golden one. A text area with its microphone
|
|
3
|
+
// INSIDE the box (bottom right; centred at the right of a one-line input), in one [data-voice-field]
|
|
4
|
+
// container with nothing else typeable in it. A take is
|
|
4
5
|
// recorded with the module's recorder (written to the phone's database as it is made). Transcription is
|
|
5
|
-
// not done here: the take goes to `upload`, and the
|
|
6
|
+
// not done here: the take goes to `upload`, and whoever receives it (a relay with `transcribe`, or the owner's Mac) writes the transcript. A voice note
|
|
6
7
|
// alone is a whole report, so a field with no words and one take is a complete answer.
|
|
7
8
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
9
|
+
// A tap starts a take that keeps recording until the next tap (or the time limit); a press held longer
|
|
10
|
+
// than a tap records until it is let go, as hold-to-talk always did. Leaving the button, or a pointer the
|
|
11
|
+
// browser cancels (iOS Safari does when its microphone prompt appears), never ends a tapped take. The
|
|
12
|
+
// recorder is asked synchronously inside the press, with nothing awaited first, so iOS Safari treats the
|
|
13
|
+
// microphone request as part of the gesture. Space or Enter on the focused button starts and stops a take.
|
|
14
|
+
// (The operator, 2026-10-26: a tap on the hold-only button did nothing, because the release ended the take
|
|
15
|
+
// before the microphone had answered.)
|
|
10
16
|
import { useEffect, useRef, useState } from 'react'
|
|
11
17
|
import type { FieldProps } from './adapters.ts'
|
|
12
18
|
import { startTake as defaultStartTake } from './recorder.ts'
|
|
@@ -15,6 +21,8 @@ import { DEFAULT_STRINGS } from './words.ts'
|
|
|
15
21
|
|
|
16
22
|
|
|
17
23
|
type Take = Awaited<ReturnType<typeof defaultStartTake>>
|
|
24
|
+
/** A press shorter than this is a tap: it starts a take that a second tap stops. */
|
|
25
|
+
const TAP_MS = 350
|
|
18
26
|
|
|
19
27
|
export function DefaultField(props: FieldProps) {
|
|
20
28
|
const { rt, id, label, value, onChange, placeholder, multiline = false, type = 'text', upload, onHeard, onKept, onHearing, disabled, startTake = defaultStartTake, maxMs, labels, scope } = props
|
|
@@ -25,6 +33,8 @@ export function DefaultField(props: FieldProps) {
|
|
|
25
33
|
const take = useRef<Take | null>(null)
|
|
26
34
|
const wanted = useRef(false)
|
|
27
35
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
36
|
+
// The press in progress that started a take: when it began. Null between presses, and for a press that stopped one.
|
|
37
|
+
const press = useRef<number | null>(null)
|
|
28
38
|
const latest = useRef(value)
|
|
29
39
|
latest.current = value
|
|
30
40
|
const cb = useRef({ onChange, upload, onHeard, onKept, onHearing })
|
|
@@ -75,10 +85,29 @@ export function DefaultField(props: FieldProps) {
|
|
|
75
85
|
aria-pressed={recording}
|
|
76
86
|
aria-label={canRecord ? `${recording ? L.stopTalking : L.holdToTalk}: ${label}` : `${L.voiceUnavailable}: ${label}`}
|
|
77
87
|
disabled={disabled || !canRecord}
|
|
78
|
-
onPointerDown={(e) => {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
88
|
+
onPointerDown={(e) => {
|
|
89
|
+
e.preventDefault()
|
|
90
|
+
// A take already going (a tapped one): this press stops it.
|
|
91
|
+
if (take.current || wanted.current) { press.current = null; void end(); return }
|
|
92
|
+
press.current = Date.now()
|
|
93
|
+
void begin() // startTake is called before begin's first await: still inside the gesture
|
|
94
|
+
}}
|
|
95
|
+
onPointerUp={() => {
|
|
96
|
+
const at = press.current
|
|
97
|
+
press.current = null
|
|
98
|
+
// A tap keeps recording until the next tap; a hold stops on release.
|
|
99
|
+
if (at !== null && Date.now() - at >= TAP_MS) void end()
|
|
100
|
+
}}
|
|
101
|
+
onPointerLeave={() => {
|
|
102
|
+
// Sliding off a held press stops it, as hold-to-talk did; a tapped take, or one still starting, goes on.
|
|
103
|
+
const at = press.current
|
|
104
|
+
press.current = null
|
|
105
|
+
if (at !== null && take.current && Date.now() - at >= TAP_MS) void end()
|
|
106
|
+
}}
|
|
107
|
+
onPointerCancel={() => {
|
|
108
|
+
// The browser took the pointer (a permission prompt, a scroll): the take carries on as a tapped one.
|
|
109
|
+
press.current = null
|
|
110
|
+
}}
|
|
82
111
|
onContextMenu={(e) => e.preventDefault()}
|
|
83
112
|
onKeyDown={(e) => {
|
|
84
113
|
if (e.key !== ' ' && e.key !== 'Enter') return
|
|
@@ -93,9 +122,11 @@ export function DefaultField(props: FieldProps) {
|
|
|
93
122
|
const shared = { id: `sac-field-${id}`, value, placeholder, disabled, className: 'sac-input', onChange: (e: { target: { value: string } }) => onChange(e.target.value) }
|
|
94
123
|
return (
|
|
95
124
|
<div data-voice-field="" className="sac-voice" data-recording={recording ? '' : undefined}>
|
|
96
|
-
{control}
|
|
97
125
|
<label htmlFor={`sac-field-${id}`} className="sac-sr">{label}</label>
|
|
98
|
-
|
|
126
|
+
<div className={`sac-voice-box ${multiline ? 'sac-voice-multi' : 'sac-voice-single'}`}>
|
|
127
|
+
{multiline ? <textarea rows={3} {...shared} /> : <input type={type} {...shared} />}
|
|
128
|
+
{control}
|
|
129
|
+
</div>
|
|
99
130
|
</div>
|
|
100
131
|
)
|
|
101
132
|
}
|
package/src/client/mine.tsx
CHANGED
|
@@ -36,6 +36,8 @@ export function MySuggestions({ adapters, onOpen }: { adapters: Adapters; onOpen
|
|
|
36
36
|
const answered = useRef(false)
|
|
37
37
|
useClearOnSignOut(rt, who, adapters.words.app)
|
|
38
38
|
useEffect(() => { loads.current++; known.current = null; answered.current = false; setRows(null); setOpen(null); setBad(''); setUpdating(false); setFresh(new Set()) }, [rt])
|
|
39
|
+
// Leaving the screen retires every load and recount still running, as a new account does: none may set state after.
|
|
40
|
+
useEffect(() => () => { loads.current++ }, [])
|
|
39
41
|
|
|
40
42
|
const recount = useCallback(async () => {
|
|
41
43
|
if (!rt || !known.current) return
|
package/src/client/sheet.tsx
CHANGED
|
@@ -198,7 +198,7 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
|
|
|
198
198
|
return (await saveDraft(rt!.store, key!, {})).reportId
|
|
199
199
|
}, [rt, key])
|
|
200
200
|
|
|
201
|
-
// A finished take: kept as a copy under this report, and listed.
|
|
201
|
+
// A finished take: kept as a copy under this report, and listed. The transcript is written where it is received.
|
|
202
202
|
const keepTake = useCallback(async (fieldId: string, original: string, blob: Blob, ms: number) => {
|
|
203
203
|
if (!rt || !key) return { refused: S.loading }
|
|
204
204
|
if (ms > ceilings.voiceMs) return { refused: S.voiceTooLong }
|
package/src/client/styles.css
CHANGED
|
@@ -37,11 +37,22 @@
|
|
|
37
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
38
|
/* Saying it is the sheet's biggest thing, whatever field the host puts in it. */
|
|
39
39
|
.sac-sayit textarea { min-height: 9rem; }
|
|
40
|
-
.sac-voice {
|
|
40
|
+
.sac-voice { min-width: 0; }
|
|
41
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; }
|
|
42
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; }
|
|
43
43
|
.sac-record-dot { width: 18px; height: 18px; border-radius: 50%; background: var(--sac-danger); }
|
|
44
44
|
.sac-voice[data-recording] .sac-record { border-color: var(--sac-danger); box-shadow: 0 0 0 4px color-mix(in srgb, var(--sac-danger) 25%, transparent); }
|
|
45
|
+
/* Recording: the dot becomes a stop square, so a tapped take reads as "tap to stop". */
|
|
46
|
+
.sac-voice[data-recording] .sac-record-dot { border-radius: 3px; width: 16px; height: 16px; }
|
|
47
|
+
/* The microphone sits inside its text box: bottom right of a text area, centred at the right of a one-line input.
|
|
48
|
+
The box keeps its text clear of it. */
|
|
49
|
+
.sac-voice-box { position: relative; display: flex; min-width: 0; }
|
|
50
|
+
.sac-voice-box > textarea, .sac-voice-box > input { padding-right: 60px; }
|
|
51
|
+
.sac-voice-multi > textarea { padding-bottom: 56px; }
|
|
52
|
+
.sac-voice-single > input { min-height: 56px; }
|
|
53
|
+
.sac-voice-box > .sac-record { position: absolute; right: 6px; }
|
|
54
|
+
.sac-voice-multi > .sac-record { bottom: 6px; }
|
|
55
|
+
.sac-voice-single > .sac-record { top: 50%; transform: translateY(-50%); }
|
|
45
56
|
.sac-button, .sac-entry, .sac-row-button { font: inherit; font-size: 16px; min-height: 44px; min-width: 44px; padding: 8px 14px; border-radius: var(--sac-radius); border: 1px solid var(--sac-border); background: var(--sac-bg); color: var(--sac-fg); cursor: pointer; }
|
|
46
57
|
.sac-primary { background: var(--sac-accent); color: var(--sac-accent-fg); border-color: var(--sac-accent); }
|
|
47
58
|
.sac-button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
@@ -86,4 +97,5 @@
|
|
|
86
97
|
.sac-entry-why { margin-left: 8px; color: var(--sac-muted); font-size: 0.9rem; }
|
|
87
98
|
.sac-sheet :focus-visible, .sac-entry:focus-visible, .sac-button:focus-visible, .sac-record:focus-visible, .sac-row-button:focus-visible { outline: 3px solid var(--sac-focus); outline-offset: 2px; }
|
|
88
99
|
@media (min-width: 640px) { .sac-backdrop { align-items: center; } .sac-sheet { border-radius: var(--sac-radius); } }
|
|
89
|
-
@
|
|
100
|
+
@keyframes sac-pulse { 0%, 100% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--sac-danger) 25%, transparent); } 50% { box-shadow: 0 0 0 8px color-mix(in srgb, var(--sac-danger) 10%, transparent); } }
|
|
101
|
+
@media (prefers-reduced-motion: no-preference) { .sac-voice[data-recording] .sac-record { transition: box-shadow 0.15s; animation: sac-pulse 1.4s ease-in-out infinite; } .sac-fold-mark { transition: transform 0.15s; } }
|
package/src/client/words.ts
CHANGED
|
@@ -63,6 +63,7 @@ export interface Strings {
|
|
|
63
63
|
mineTitle: string
|
|
64
64
|
mineEmpty: string
|
|
65
65
|
back: string
|
|
66
|
+
/** The microphone's name while idle. The key keeps its old name; a tap starts it, and holding works too. */
|
|
66
67
|
holdToTalk: string
|
|
67
68
|
stopTalking: string
|
|
68
69
|
micDenied: string
|
|
@@ -143,9 +144,9 @@ export const DEFAULT_STRINGS: Strings = {
|
|
|
143
144
|
optional: 'optional',
|
|
144
145
|
sayItLegend: 'Say it',
|
|
145
146
|
sayItLabel: 'Your suggestion',
|
|
146
|
-
sayItPlaceholder: 'What would you change? Type it, or
|
|
147
|
-
sayItPlaceholderBroken: 'What happened, what you expected, and how to make it happen again. Type it, or
|
|
148
|
-
sayItPlaceholderFeedback: 'Whatever is on your mind. Type it, or
|
|
147
|
+
sayItPlaceholder: 'What would you change? Type it, or tap the microphone and say it',
|
|
148
|
+
sayItPlaceholderBroken: 'What happened, what you expected, and how to make it happen again. Type it, or tap the microphone and say it',
|
|
149
|
+
sayItPlaceholderFeedback: 'Whatever is on your mind. Type it, or tap the microphone and say it',
|
|
149
150
|
showItLegend: 'Show it',
|
|
150
151
|
addImages: 'Add a screenshot or photo',
|
|
151
152
|
addVideo: 'Add a short video',
|
|
@@ -174,8 +175,8 @@ export const DEFAULT_STRINGS: Strings = {
|
|
|
174
175
|
mineTitle: 'My suggestions',
|
|
175
176
|
mineEmpty: 'You have not sent a suggestion yet.',
|
|
176
177
|
back: 'Back',
|
|
177
|
-
holdToTalk: '
|
|
178
|
-
stopTalking: '
|
|
178
|
+
holdToTalk: 'Tap to talk',
|
|
179
|
+
stopTalking: 'Tap to stop',
|
|
179
180
|
micDenied: 'The microphone could not be used. You can type it instead.',
|
|
180
181
|
voiceUnavailable: 'Voice notes cannot be added here',
|
|
181
182
|
loading: 'Loading',
|
package/src/github/compose.ts
CHANGED
|
@@ -24,7 +24,8 @@ export interface Destination {
|
|
|
24
24
|
account: string
|
|
25
25
|
/** False when files cannot reach this repo yet (no App installed and the filer cannot push): they wait. */
|
|
26
26
|
attaches: boolean
|
|
27
|
-
/** False when nobody transcribes voice notes (
|
|
27
|
+
/** False when nobody transcribes voice notes (no relay `transcribe` and no Mac that writes one; a relay says which in
|
|
28
|
+
* its `capabilities`): the list does not promise a transcript. */
|
|
28
29
|
transcribes?: boolean
|
|
29
30
|
/** The largest file this destination takes; a larger one is named in the list as staying on the phone. */
|
|
30
31
|
maxInlineBytes?: number
|
|
@@ -61,8 +62,12 @@ export interface ComposeOptions {
|
|
|
61
62
|
filerRole?: string
|
|
62
63
|
/** file name -> its link, from an AttachmentHost. A file with no link yet is written as still being added. */
|
|
63
64
|
attachmentUrls?: Record<string, string>
|
|
64
|
-
/** The voice notes' transcript
|
|
65
|
+
/** The voice notes' transcript. */
|
|
65
66
|
transcript?: string
|
|
67
|
+
/** Who made it: 'relay' when the relay transcribed it as it filed; otherwise it came with the request (the owner's Mac). */
|
|
68
|
+
transcriptBy?: 'relay' | 'mac'
|
|
69
|
+
/** A plain sentence saying a transcript, or part of one, could not be made and why; written under the voice note. */
|
|
70
|
+
transcriptNote?: string
|
|
66
71
|
/** The first body, written before any file is saved: every attachment is "being added". */
|
|
67
72
|
pending?: boolean
|
|
68
73
|
}
|
|
@@ -247,6 +252,7 @@ export function composeIssue(report: Draft, opts: ComposeOptions = {}): Composed
|
|
|
247
252
|
}
|
|
248
253
|
const words = scrub(text(report.words).trim())
|
|
249
254
|
const transcript = scrub(text(opts.transcript).trim())
|
|
255
|
+
const note = inline(text(opts.transcriptNote).trim())
|
|
250
256
|
// Only links the host produced, and only plain https ones, become images or links; anything else is still "being added".
|
|
251
257
|
const urls: Record<string, string> = opts.pending ? {} : Object.fromEntries(Object.entries(opts.attachmentUrls ?? {}).flatMap(([k, u]) => (safeUrl(u) ? [[k, u]] : [])))
|
|
252
258
|
const media = report.media ?? []
|
|
@@ -274,7 +280,8 @@ export function composeIssue(report: Draft, opts: ComposeOptions = {}): Composed
|
|
|
274
280
|
const out: string[] = []
|
|
275
281
|
if (has.has('words')) out.push('## What they said', fence(said), '')
|
|
276
282
|
if (has.has('voice')) {
|
|
277
|
-
out.push(transcript ? "## Voice note (transcribed on the owner's Mac)" : '## Voice note', heard ? fence(heard) : 'Not transcribed.')
|
|
283
|
+
out.push(transcript ? (opts.transcriptBy === 'relay' ? '## Voice note (transcribed automatically)' : "## Voice note (transcribed on the owner's Mac)") : '## Voice note', heard ? fence(heard) : (note || 'Not transcribed.'))
|
|
284
|
+
if (heard && note) out.push('', note)
|
|
278
285
|
const links = voice.filter((v) => urls[v.file]).map((v) => `[${inline(v.file)}](${urls[v.file]})`)
|
|
279
286
|
if (links.length) out.push('', ...links)
|
|
280
287
|
if (coming && !has.has('images') && !has.has('video')) out.push('', coming)
|
package/src/github/filer.ts
CHANGED
|
@@ -38,7 +38,9 @@ const raw = (b: RelayFile['bytes']): Uint8Array => {
|
|
|
38
38
|
if (b instanceof ArrayBuffer) return new Uint8Array(b)
|
|
39
39
|
const bin = atob(b); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
/** `transcribes`: the relay writes a voice note's transcript itself (a host passed `transcribe`); set the sheet's
|
|
42
|
+
* `Destination.transcribes` from it. */
|
|
43
|
+
export interface RelayCapabilities { actions: string[]; staging: boolean; inlineBytes: number; maxBytes?: Record<string, number>; transcribes?: boolean }
|
|
42
44
|
|
|
43
45
|
/** `url` is where the relay is mounted; each action is posted to `<url>/<action>`. */
|
|
44
46
|
export function relayFiler({ url, headers = {}, fetch }: { url: string; headers?: Record<string, string>; fetch?: Fetch }): RelayFiler {
|
package/src/github/index.ts
CHANGED
|
@@ -9,7 +9,7 @@ export { fileIssue, updateIssue, addFollowUp, issueStates, issueStatus, fileSugg
|
|
|
9
9
|
export type { GitHubConfig, FilingConfig, IssueState, FileSuggestionInput, FiledSuggestion, Fetch } from './api.ts'
|
|
10
10
|
export { branchHost, relayHost, toBase64, attachmentUrl, ATTACHMENTS_BRANCH, CONTENTS_API_BYTES } from './hosts.ts'
|
|
11
11
|
export type { AttachmentHost, AttachmentTarget, AttachmentFile } from './hosts.ts'
|
|
12
|
-
export { createSuggestionRelay, createAttachRelay, memoryUsage, RELAY_LIMITS, RELAY_VOICE_BYTES, FILING_LEASE_MS } from './relay.ts'
|
|
13
|
-
export type { SuggestionRelayOptions, AttachRelayOptions, RelayLimits, RelayUsage, RelayIdentity, Reservation, FiledRecord, StagingAdapter } from './relay.ts'
|
|
12
|
+
export { createSuggestionRelay, createAttachRelay, memoryUsage, RELAY_LIMITS, RELAY_VOICE_BYTES, FILING_LEASE_MS, TRANSCRIBE_TIMEOUT_MS, TRANSCRIPT_MISSING } from './relay.ts'
|
|
13
|
+
export type { SuggestionRelayOptions, AttachRelayOptions, RelayLimits, RelayUsage, RelayIdentity, Reservation, FiledRecord, StagingAdapter, RelayVoice, RelayTranscriber } from './relay.ts'
|
|
14
14
|
export { relayFiler, canFileDirectly, STAGE_OVER_BYTES } from './filer.ts'
|
|
15
15
|
export type { RelayFiler, RelayFiling, RelayFile, RelayCapabilities } from './filer.ts'
|
package/src/github/relay.ts
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
// - attach: for a filer who opened the issue themselves but cannot push; the relay proves they could file there by
|
|
10
10
|
// reading the issue and requiring it to carry the report.
|
|
11
11
|
//
|
|
12
|
+
// With `transcribe`, the relay also writes the transcript of each voice note a request brings without one, before the
|
|
13
|
+
// privacy catch, so the transcript is caught like the words. A transcriber that fails or is slow never stops a filing.
|
|
14
|
+
//
|
|
12
15
|
// Web-standard only: Request, Response, fetch, crypto.subtle.
|
|
13
16
|
import { CEILINGS, isRepoName, isReportId, validContact, validateReport } from '../contract.mjs'
|
|
14
17
|
import { cappedText, json } from '../server/http.ts'
|
|
@@ -145,6 +148,22 @@ export interface RelayIdentity {
|
|
|
145
148
|
contact?: { email?: string; phone?: string; handle?: string }
|
|
146
149
|
}
|
|
147
150
|
|
|
151
|
+
/** One voice recording handed to the host's transcriber: its file name, its type as audio, and its bytes (read from
|
|
152
|
+
* staging already when it was staged; `staged` is then its staging key, for a host that would rather sign a read link). */
|
|
153
|
+
export interface RelayVoice { name: string; mimeType: string; bytes: Uint8Array; staged?: string }
|
|
154
|
+
/** Answers the words said in one recording. `signal` aborts when the relay stops waiting. */
|
|
155
|
+
export type RelayTranscriber = (voice: RelayVoice, o: { signal: AbortSignal }) => Promise<string>
|
|
156
|
+
/** How long the relay waits for all of a request's transcripts before it files without them. Serverless hosts cap a
|
|
157
|
+
* request (Vercel's default is 60 s), so this leaves room for GitHub. */
|
|
158
|
+
export const TRANSCRIBE_TIMEOUT_MS = 25_000
|
|
159
|
+
/** The plain words an issue uses for why a transcript is missing. Never the transcriber's own error, which can carry
|
|
160
|
+
* anything (a key, a URL, a provider's internals). */
|
|
161
|
+
export const TRANSCRIPT_MISSING = {
|
|
162
|
+
timeout: 'the transcription service did not answer in time',
|
|
163
|
+
failed: 'the transcription service failed or refused the recording',
|
|
164
|
+
silent: 'no speech was heard in the recording',
|
|
165
|
+
} as const
|
|
166
|
+
|
|
148
167
|
export interface SuggestionRelayOptions {
|
|
149
168
|
/** The GitHub App the relay acts as: its numeric id and its private key (PEM, PKCS#1 as GitHub issues it, or PKCS#8). */
|
|
150
169
|
app: { id: number | string; privateKey: string }
|
|
@@ -164,6 +183,11 @@ export interface SuggestionRelayOptions {
|
|
|
164
183
|
staging?: StagingAdapter
|
|
165
184
|
/** May lower any limit, never raise one. */
|
|
166
185
|
limits?: Partial<RelayLimits>
|
|
186
|
+
/** Writes the transcript of each voice note a `file` or `follow-up` brings with no `transcript`. Without it the issue
|
|
187
|
+
* says the note was not transcribed, and `capabilities` says `transcribes: false`. */
|
|
188
|
+
transcribe?: RelayTranscriber
|
|
189
|
+
/** Default TRANSCRIBE_TIMEOUT_MS; all of a request's notes are transcribed at once inside it. */
|
|
190
|
+
transcribeTimeoutMs?: number
|
|
167
191
|
usage?: RelayUsage
|
|
168
192
|
fetch?: Fetch
|
|
169
193
|
now?: () => number
|
|
@@ -335,6 +359,30 @@ export function createSuggestionRelay(opts: SuggestionRelayOptions, only?: Actio
|
|
|
335
359
|
if (bytes.length > max) return json(413, { error: `that file is larger than the ${max} bytes allowed` })
|
|
336
360
|
return { name: x.name, type: real, kind, bytes, ...(stagedKey ? { stagedKey } : {}) }
|
|
337
361
|
}
|
|
362
|
+
/** The transcript for a request's voice notes: the request's own when it carries one, else the relay's (with a plain
|
|
363
|
+
* note for any note it could not transcribe), else none. Never throws. */
|
|
364
|
+
async function transcriptFor(files: Checked[], given: unknown): Promise<{ transcript?: string; by?: 'relay'; note?: string }> {
|
|
365
|
+
if (typeof given === 'string' && given.trim()) return { transcript: given }
|
|
366
|
+
const voices = files.filter((f) => f.kind === 'voice')
|
|
367
|
+
if (!opts.transcribe || !voices.length) return {}
|
|
368
|
+
const ms = opts.transcribeTimeoutMs ?? TRANSCRIBE_TIMEOUT_MS
|
|
369
|
+
const stop = new AbortController()
|
|
370
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
371
|
+
const late = new Promise<'late'>((res) => { timer = setTimeout(() => { stop.abort(); res('late') }, ms) })
|
|
372
|
+
const one = (v: Checked) => Promise.resolve()
|
|
373
|
+
.then(() => opts.transcribe!({ name: v.name, mimeType: v.type.replace(/^video\//, 'audio/'), bytes: v.bytes, ...(v.stagedKey ? { staged: v.stagedKey } : {}) }, { signal: stop.signal }))
|
|
374
|
+
.then((t) => (typeof t === 'string' && t.trim() ? { text: t.trim() } : { fail: TRANSCRIPT_MISSING.silent }), () => ({ fail: TRANSCRIPT_MISSING.failed }))
|
|
375
|
+
const got = await Promise.race([Promise.all(voices.map(one)), late])
|
|
376
|
+
clearTimeout(timer)
|
|
377
|
+
const results: ({ text: string } | { fail: string })[] = got === 'late' ? voices.map(() => ({ fail: TRANSCRIPT_MISSING.timeout })) : got
|
|
378
|
+
const many = voices.length > 1
|
|
379
|
+
const texts = results.flatMap((r, i) => ('text' in r ? [many ? `(${voices[i].name})\n${r.text}` : r.text] : []))
|
|
380
|
+
const fails = results.flatMap((r, i) => ('fail' in r ? [many ? `${voices[i].name}: ${r.fail}` : r.fail] : []))
|
|
381
|
+
const note = fails.length
|
|
382
|
+
? `The transcript${many ? ` of ${fails.length === voices.length ? 'the recordings' : `${fails.length} of ${voices.length} recordings`}` : ''} could not be made: ${fails.join('; ')}.${texts.length ? '' : ` The recording${many ? 's are' : ' is'} attached.`}`
|
|
383
|
+
: undefined
|
|
384
|
+
return { ...(texts.length ? { transcript: texts.join('\n\n'), by: 'relay' as const } : {}), ...(note ? { note } : {}) }
|
|
385
|
+
}
|
|
338
386
|
/** The bytes a request still has to hold today: inline files only, since upload-url held a staged file's up front. */
|
|
339
387
|
const inlineBytesOf = (files: Checked[]) => files.reduce((n, f) => n + (f.stagedKey ? 0 : f.bytes.length), 0)
|
|
340
388
|
const discardStaged = async (files: Checked[]) => {
|
|
@@ -409,12 +457,14 @@ export function createSuggestionRelay(opts: SuggestionRelayOptions, only?: Actio
|
|
|
409
457
|
const appId = body.app
|
|
410
458
|
if (appId !== undefined && opts.apps && (typeof appId !== 'string' || !Object.prototype.hasOwnProperty.call(opts.apps, appId))) return bad('unknown app')
|
|
411
459
|
const app = opts.apps && typeof appId === 'string' ? opts.apps[appId] : undefined
|
|
460
|
+
// The transcript is made before the catch reads anything, so it is caught like the words.
|
|
461
|
+
const heard = await transcriptFor(files, body.transcript)
|
|
412
462
|
// Who it is from comes from the host, never the request: the name, the role and the contact.
|
|
413
463
|
const contact = contactFor(me, (report.who as { contact?: unknown } | undefined)?.contact)
|
|
414
464
|
const appVersion = typeof (report.diagnostics as { appVersion?: unknown } | undefined)?.appVersion === 'string' ? (report.diagnostics as { appVersion: string }).appVersion : undefined
|
|
415
465
|
const readable = {
|
|
416
466
|
words: report.words, followUps: report.followUps, where: report.where, diagnostics: report.diagnostics,
|
|
417
|
-
context: report.context, transcript:
|
|
467
|
+
context: report.context, transcript: heard.transcript,
|
|
418
468
|
filerName: me.name, appName: app?.name, who: { contact },
|
|
419
469
|
files: files.map((f) => ({ name: f.name })),
|
|
420
470
|
}
|
|
@@ -450,6 +500,8 @@ export function createSuggestionRelay(opts: SuggestionRelayOptions, only?: Actio
|
|
|
450
500
|
filerName: typeof clean.filerName === 'string' && clean.filerName.trim() ? clean.filerName.trim().slice(0, 100) : 'Someone',
|
|
451
501
|
filerRole: role,
|
|
452
502
|
transcript: clean.transcript,
|
|
503
|
+
...(heard.by && clean.transcript ? { transcriptBy: heard.by } : {}),
|
|
504
|
+
...(heard.note ? { transcriptNote: heard.note } : {}),
|
|
453
505
|
attachmentUrls,
|
|
454
506
|
})
|
|
455
507
|
const made = await fileIssue({ repo, token, fetch: opts.fetch, api: opts.api }, issue)
|
|
@@ -477,7 +529,8 @@ export function createSuggestionRelay(opts: SuggestionRelayOptions, only?: Actio
|
|
|
477
529
|
const checked = await checkMany(Array.isArray(body.files) ? body.files : [], () => undefined, account, reportId)
|
|
478
530
|
if (checked instanceof Response) return checked
|
|
479
531
|
const files = checked
|
|
480
|
-
const
|
|
532
|
+
const heard = await transcriptFor(files, body.transcript)
|
|
533
|
+
const readable = { words: typeof body.words === 'string' ? body.words.slice(0, 20_000) : '', transcript: heard.transcript, files: files.map((f) => ({ name: f.name })) }
|
|
481
534
|
const caught = catchIt(readable, account, body.redacted === true); if ('res' in caught) return caught.res
|
|
482
535
|
const { words, transcript } = caught.clean
|
|
483
536
|
if (!words.trim() && !transcript && !files.length) return bad('a follow-up needs words or a file')
|
|
@@ -486,7 +539,7 @@ export function createSuggestionRelay(opts: SuggestionRelayOptions, only?: Actio
|
|
|
486
539
|
const links = files.map((f) => { const url = attachmentUrl(repo, ATTACHMENTS_BRANCH, `${folder}/${f.name}`); return f.kind === 'image' ? `` : `[${f.name}](${url})` })
|
|
487
540
|
// A follow-up is the person's own words, so it sits in a fence like the words of the report.
|
|
488
541
|
const fenced = (s: string) => { const run = Math.max(0, ...[...s.matchAll(/`+/g)].map((m) => m[0].length)); const f = '`'.repeat(Math.max(3, run + 1)); return `${f}\n${s}\n${f}` }
|
|
489
|
-
const text = ['## Added later', words.trim() ? fenced(words.trim()) : '', transcript ? `\n### Voice note\n${fenced(transcript.trim())}` : '', links.length ? `\n${links.join('\n')}` : ''].filter(Boolean).join('\n')
|
|
542
|
+
const text = ['## Added later', words.trim() ? fenced(words.trim()) : '', transcript ? `\n### Voice note\n${fenced(transcript.trim())}` : '', heard.note ? `\n${heard.note}` : '', links.length ? `\n${links.join('\n')}` : ''].filter(Boolean).join('\n')
|
|
490
543
|
if (text.length > BODY_LIMIT) return json(413, { error: `that follow-up is longer than GitHub takes in a comment (${BODY_LIMIT} characters)` })
|
|
491
544
|
const held = await reserve(account, reportId, inlineBytesOf(files))
|
|
492
545
|
if (held instanceof Response) return held
|
|
@@ -613,6 +666,7 @@ export function createSuggestionRelay(opts: SuggestionRelayOptions, only?: Actio
|
|
|
613
666
|
const capabilities = () => json(200, {
|
|
614
667
|
actions: ACTIONS.filter((a) => (only ? a === only : a !== 'upload-url' || Boolean(opts.staging))),
|
|
615
668
|
staging: Boolean(opts.staging),
|
|
669
|
+
transcribes: Boolean(opts.transcribe),
|
|
616
670
|
inlineBytes: limits.inlineBytes,
|
|
617
671
|
maxBytes: { image: limits.imageBytes, video: limits.videoBytes, voice: limits.voiceBytes },
|
|
618
672
|
})
|