@marktiderman/genesis-chat 0.1.1 → 0.2.1

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.
Files changed (175) hide show
  1. package/README.md +379 -13
  2. package/dist/chunk-A4YJX7KT.js +544 -0
  3. package/dist/chunk-A4YJX7KT.js.map +1 -0
  4. package/dist/chunk-AZS3HNLM.js +30 -0
  5. package/dist/chunk-AZS3HNLM.js.map +1 -0
  6. package/dist/chunk-BJCE5O7V.js +70 -0
  7. package/dist/chunk-BJCE5O7V.js.map +1 -0
  8. package/dist/chunk-GBJ3YI4E.js +966 -0
  9. package/dist/chunk-GBJ3YI4E.js.map +1 -0
  10. package/dist/chunk-JU2FAPBI.js +609 -0
  11. package/dist/chunk-JU2FAPBI.js.map +1 -0
  12. package/dist/chunk-VEPZROQB.js +187 -0
  13. package/dist/chunk-VEPZROQB.js.map +1 -0
  14. package/dist/{client-BtdALFCx.d.ts → client-DUIG1hum.d.ts} +187 -11
  15. package/dist/index.d.ts +78 -163
  16. package/dist/index.js +3 -2
  17. package/dist/native.d.ts +1939 -1
  18. package/dist/native.js +5023 -0
  19. package/dist/native.js.map +1 -1
  20. package/dist/outbox-B9NAWQ58.d.ts +219 -0
  21. package/dist/push.d.ts +206 -0
  22. package/dist/push.js +3 -0
  23. package/dist/push.js.map +1 -0
  24. package/dist/react.d.ts +176 -7
  25. package/dist/react.js +3 -3
  26. package/dist/server.d.ts +551 -21
  27. package/dist/server.js +976 -34
  28. package/dist/server.js.map +1 -1
  29. package/dist/thread-oPra7pkW.d.ts +57 -0
  30. package/dist/web.d.ts +335 -12
  31. package/dist/web.js +1784 -64
  32. package/dist/web.js.map +1 -1
  33. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  34. package/migrations/0003_genesis_chat_search_threads.sql +85 -0
  35. package/migrations/tests/0002_genesis_chat_coaching.test.sql +269 -0
  36. package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
  37. package/package.json +78 -6
  38. package/src/__tests__/coaching-core.test.ts +334 -0
  39. package/src/__tests__/coaching-react.test.tsx +319 -0
  40. package/src/__tests__/coaching-web.test.tsx +521 -0
  41. package/src/__tests__/core.test.ts +91 -0
  42. package/src/__tests__/fake-xhr.ts +54 -0
  43. package/src/__tests__/outbox-hosted.test.ts +269 -0
  44. package/src/__tests__/outbox.test.ts +122 -0
  45. package/src/__tests__/realtime.test.ts +70 -0
  46. package/src/__tests__/review-279.test.ts +41 -0
  47. package/src/__tests__/review-fixes-core.test.ts +193 -0
  48. package/src/__tests__/review-fixes-web.test.tsx +405 -0
  49. package/src/__tests__/server-effects.test.ts +158 -0
  50. package/src/__tests__/server-fake-video.test.ts +81 -0
  51. package/src/__tests__/server-fakes.ts +96 -0
  52. package/src/__tests__/server-integrations.test.ts +247 -0
  53. package/src/__tests__/server-providers.test.ts +246 -0
  54. package/src/__tests__/server-runner.test.ts +277 -0
  55. package/src/__tests__/server-safety.test.ts +303 -0
  56. package/src/__tests__/web-markdown.test.tsx +88 -0
  57. package/src/__tests__/web.test.tsx +349 -0
  58. package/src/core/availability.ts +223 -0
  59. package/src/core/client.ts +783 -0
  60. package/src/core/extras.ts +106 -0
  61. package/src/core/media.ts +144 -0
  62. package/src/core/outbox.ts +635 -0
  63. package/src/core/thread.ts +171 -0
  64. package/src/core/transfer.ts +71 -0
  65. package/src/core/types.ts +328 -0
  66. package/src/index.ts +14 -0
  67. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  68. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  69. package/src/native/__tests__/composer-state.test.ts +188 -0
  70. package/src/native/__tests__/composer.test.tsx +133 -0
  71. package/src/native/__tests__/emoji.test.tsx +254 -0
  72. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  73. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  74. package/src/native/__tests__/organise-logic.test.ts +164 -0
  75. package/src/native/__tests__/organise-screens.test.tsx +424 -0
  76. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  77. package/src/native/__tests__/push-payload.test.ts +66 -0
  78. package/src/native/__tests__/push.test.ts +209 -0
  79. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  80. package/src/native/__tests__/rows-actions.test.ts +76 -0
  81. package/src/native/__tests__/thread-layout.test.ts +41 -0
  82. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  83. package/src/native/__tests__/tus.test.ts +154 -0
  84. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  85. package/src/native/__tests__/video-media.test.tsx +498 -0
  86. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  87. package/src/native/__tests__/video-upload.test.ts +227 -0
  88. package/src/native/access-error.tsx +31 -0
  89. package/src/native/actions.ts +42 -0
  90. package/src/native/attachment-cards.tsx +132 -0
  91. package/src/native/auto-retry.ts +69 -0
  92. package/src/native/availability.tsx +376 -0
  93. package/src/native/broadcast-sheet.tsx +193 -0
  94. package/src/native/composer-state.ts +302 -0
  95. package/src/native/composer.tsx +453 -0
  96. package/src/native/context.tsx +70 -0
  97. package/src/native/copy.ts +296 -0
  98. package/src/native/emoji-data.ts +21 -0
  99. package/src/native/emoji-picker.tsx +229 -0
  100. package/src/native/emoji.ts +118 -0
  101. package/src/native/icon-button.tsx +40 -0
  102. package/src/native/inbox-screen.tsx +122 -0
  103. package/src/native/index.ts +184 -0
  104. package/src/native/link-preview.tsx +104 -0
  105. package/src/native/media-colors.ts +15 -0
  106. package/src/native/media.tsx +187 -0
  107. package/src/native/message-actions-sheet.tsx +126 -0
  108. package/src/native/message-bubble.tsx +366 -0
  109. package/src/native/moderation.tsx +42 -0
  110. package/src/native/modules.ts +277 -0
  111. package/src/native/organise-copy.ts +195 -0
  112. package/src/native/photo-viewer.tsx +531 -0
  113. package/src/native/push.ts +375 -0
  114. package/src/native/recorder-machine.ts +176 -0
  115. package/src/native/rows.ts +81 -0
  116. package/src/native/schedule-sheet.tsx +328 -0
  117. package/src/native/search-screen.tsx +189 -0
  118. package/src/native/stack-options.ts +36 -0
  119. package/src/native/start-sheet.tsx +87 -0
  120. package/src/native/thread-screen.tsx +647 -0
  121. package/src/native/topics.tsx +234 -0
  122. package/src/native/tus.ts +282 -0
  123. package/src/native/use-composer.ts +71 -0
  124. package/src/native/use-jump.ts +91 -0
  125. package/src/native/use-voice-recorder.ts +234 -0
  126. package/src/native/video-player.tsx +410 -0
  127. package/src/native/video-recorder-state.ts +140 -0
  128. package/src/native/video-recorder.tsx +274 -0
  129. package/src/native/video-upload-tray.tsx +77 -0
  130. package/src/native/video-upload.ts +432 -0
  131. package/src/native/voice-player.tsx +301 -0
  132. package/src/react/index.tsx +894 -0
  133. package/src/react/previews.ts +51 -0
  134. package/src/server/assistant.ts +235 -0
  135. package/src/server/crypto.ts +86 -0
  136. package/src/server/expo.ts +119 -0
  137. package/src/server/fake-video.ts +171 -0
  138. package/src/server/handler.ts +32 -0
  139. package/src/server/index.ts +58 -0
  140. package/src/server/moderation.ts +155 -0
  141. package/src/server/mux.ts +290 -0
  142. package/src/server/notifications.ts +130 -0
  143. package/src/server/runner.ts +468 -0
  144. package/src/server/transcribers.ts +111 -0
  145. package/src/server/types.ts +171 -0
  146. package/src/server/unfurl.ts +480 -0
  147. package/src/server/upload.ts +158 -0
  148. package/src/web/attachments.tsx +379 -0
  149. package/src/web/availability.tsx +299 -0
  150. package/src/web/broadcast.tsx +166 -0
  151. package/src/web/bubble.tsx +585 -0
  152. package/src/web/cards.tsx +136 -0
  153. package/src/web/composer.tsx +571 -0
  154. package/src/web/emoji-picker.tsx +84 -0
  155. package/src/web/error-boundary.tsx +33 -0
  156. package/src/web/format.ts +55 -0
  157. package/src/web/inbox.tsx +196 -0
  158. package/src/web/index.ts +75 -0
  159. package/src/web/markdown.tsx +245 -0
  160. package/src/web/memory-client.ts +714 -0
  161. package/src/web/panel.tsx +135 -0
  162. package/src/web/schedule.tsx +204 -0
  163. package/src/web/search.tsx +129 -0
  164. package/src/web/thread-context.ts +64 -0
  165. package/src/web/thread.tsx +830 -0
  166. package/src/web/topics.tsx +160 -0
  167. package/src/web/use-voice-recorder.ts +264 -0
  168. package/src/web/video.tsx +516 -0
  169. package/src/web/voice-note.tsx +197 -0
  170. package/dist/chunk-27UTR5FW.js +0 -322
  171. package/dist/chunk-27UTR5FW.js.map +0 -1
  172. package/dist/chunk-JQXZUEEL.js +0 -525
  173. package/dist/chunk-JQXZUEEL.js.map +0 -1
  174. package/dist/chunk-SAY24E6V.js +0 -309
  175. package/dist/chunk-SAY24E6V.js.map +0 -1
@@ -0,0 +1,160 @@
1
+ /**
2
+ * @feat:chat
3
+ * The topic bar: "All" and each of a conversation's topics as a single-choice toggle group
4
+ * (Radix: arrow keys move between them), a lock on closed ones, and, for owners and admins, a
5
+ * menu to start a topic and to close or reopen the one open. ChatThread shows it when the
6
+ * conversation has topics.
7
+ */
8
+ import { useState } from "react";
9
+ import { Hash, Lock, LockOpen, MoreHorizontal, Plus } from "lucide-react";
10
+ import {
11
+ Button,
12
+ Dialog,
13
+ DialogContent,
14
+ DialogDescription,
15
+ DialogFooter,
16
+ DialogHeader,
17
+ DialogTitle,
18
+ DropdownMenu,
19
+ DropdownMenuContent,
20
+ DropdownMenuItem,
21
+ DropdownMenuTrigger,
22
+ Input,
23
+ Label,
24
+ ToggleGroup,
25
+ ToggleGroupItem,
26
+ cn,
27
+ toast,
28
+ } from "@marktiderman/genesis-ui";
29
+ import { topicKey } from "../core/extras";
30
+ import type { ChatTopic, TopicInput } from "../core/types";
31
+
32
+ export interface ChatTopicBarProps {
33
+ topics: ChatTopic[];
34
+ /** The topic shown; null for "All". */
35
+ value: string | null;
36
+ onValueChange: (topic: string | null) => void;
37
+ /** Owners and admins: "New topic", and close or reopen. */
38
+ canManage?: boolean;
39
+ /** Saves a topic (from useTopics). */
40
+ onSetTopic?: (topic: string, input: TopicInput) => Promise<void>;
41
+ className?: string;
42
+ }
43
+
44
+ const ALL = "__all__";
45
+
46
+ export function ChatTopicBar({ topics, value, onValueChange, canManage = false, onSetTopic, className }: ChatTopicBarProps) {
47
+ const [creating, setCreating] = useState(false);
48
+ const [title, setTitle] = useState("");
49
+ const [busy, setBusy] = useState(false);
50
+ const current = value ? topics.find((t) => t.topic === value) : undefined;
51
+
52
+ const create = async () => {
53
+ const name = title.trim();
54
+ if (!name || !onSetTopic) return;
55
+ const key = topicKey(name);
56
+ if (topics.some((t) => t.topic === key)) {
57
+ toast.error("There's already a topic with that name");
58
+ return;
59
+ }
60
+ setBusy(true);
61
+ try {
62
+ await onSetTopic(key, { title: name, position: topics.length });
63
+ setCreating(false);
64
+ setTitle("");
65
+ onValueChange(key);
66
+ } catch (e) {
67
+ toast.error(e instanceof Error ? e.message : "Couldn't add the topic");
68
+ } finally {
69
+ setBusy(false);
70
+ }
71
+ };
72
+
73
+ const toggleClosed = async (t: ChatTopic) => {
74
+ try {
75
+ await onSetTopic?.(t.topic, { closed: !t.closedAt });
76
+ toast.success(t.closedAt ? `Reopened ${t.title}` : `Closed ${t.title}`);
77
+ } catch (e) {
78
+ toast.error(e instanceof Error ? e.message : "Couldn't change the topic");
79
+ }
80
+ };
81
+
82
+ return (
83
+ <div className={cn("flex items-center gap-1 border-b px-2 py-1.5", className)} data-testid="genesis-chat-topic-bar">
84
+ <ToggleGroup
85
+ type="single"
86
+ value={value ?? ALL}
87
+ onValueChange={(v) => {
88
+ // A single toggle group clears on a second click; keep a choice instead.
89
+ if (v) onValueChange(v === ALL ? null : v);
90
+ }}
91
+ aria-label="Topics"
92
+ className="flex min-w-0 flex-1 justify-start gap-1 overflow-x-auto [scrollbar-width:none]"
93
+ >
94
+ <ToggleGroupItem value={ALL} size="sm" className="h-7 shrink-0 rounded-full px-3 text-xs" data-testid="genesis-chat-topic-all">
95
+ All
96
+ </ToggleGroupItem>
97
+ {topics.map((t) => (
98
+ <ToggleGroupItem
99
+ key={t.topic}
100
+ value={t.topic}
101
+ size="sm"
102
+ className="h-7 shrink-0 gap-1 rounded-full px-3 text-xs"
103
+ aria-label={t.closedAt ? `${t.title} (closed)` : t.title}
104
+ data-testid={`genesis-chat-topic-${t.topic}`}
105
+ >
106
+ {t.closedAt ? <Lock className="size-3" aria-hidden /> : <Hash className="size-3 opacity-60" aria-hidden />}
107
+ <span className="max-w-[10rem] truncate">{t.title}</span>
108
+ </ToggleGroupItem>
109
+ ))}
110
+ </ToggleGroup>
111
+ {canManage && onSetTopic ? (
112
+ <DropdownMenu>
113
+ <DropdownMenuTrigger asChild>
114
+ <Button type="button" variant="ghost" size="icon" className="size-7 shrink-0 rounded-full" aria-label="Topic options" data-testid="genesis-chat-topic-menu">
115
+ <MoreHorizontal />
116
+ </Button>
117
+ </DropdownMenuTrigger>
118
+ <DropdownMenuContent align="end" className="w-48">
119
+ <DropdownMenuItem className="gap-2" onSelect={() => setCreating(true)} data-testid="genesis-chat-topic-new">
120
+ <Plus className="size-4" aria-hidden /> New topic
121
+ </DropdownMenuItem>
122
+ {current ? (
123
+ <DropdownMenuItem className="gap-2" onSelect={() => void toggleClosed(current)} data-testid="genesis-chat-topic-close">
124
+ {current.closedAt ? <LockOpen className="size-4" aria-hidden /> : <Lock className="size-4" aria-hidden />}
125
+ {current.closedAt ? "Reopen topic" : "Close topic"}
126
+ </DropdownMenuItem>
127
+ ) : null}
128
+ </DropdownMenuContent>
129
+ </DropdownMenu>
130
+ ) : null}
131
+
132
+ <Dialog open={creating} onOpenChange={(o) => !busy && setCreating(o)}>
133
+ <DialogContent className="sm:max-w-sm">
134
+ <DialogHeader>
135
+ <DialogTitle>New topic</DialogTitle>
136
+ <DialogDescription>Messages posted in a topic stay together, under its name.</DialogDescription>
137
+ </DialogHeader>
138
+ <form
139
+ className="flex flex-col gap-2"
140
+ onSubmit={(e) => {
141
+ e.preventDefault();
142
+ void create();
143
+ }}
144
+ >
145
+ <Label htmlFor="genesis-chat-topic-title">Name</Label>
146
+ <Input id="genesis-chat-topic-title" value={title} onChange={(e) => setTitle(e.target.value)} maxLength={160} autoFocus placeholder="Week 1: Habits" />
147
+ <DialogFooter className="mt-2">
148
+ <Button type="button" variant="outline" onClick={() => setCreating(false)}>
149
+ Cancel
150
+ </Button>
151
+ <Button type="submit" loading={busy} disabled={!title.trim()} data-testid="genesis-chat-topic-create">
152
+ Add topic
153
+ </Button>
154
+ </DialogFooter>
155
+ </form>
156
+ </DialogContent>
157
+ </Dialog>
158
+ </div>
159
+ );
160
+ }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * @feat:chat
3
+ * Voice notes on the web: MediaRecorder (Opus in WebM, else MP4/AAC for
4
+ * Safari), with an AnalyserNode read every 100 ms for a live level meter and,
5
+ * at the end, the waveform the bubble draws before the audio loads.
6
+ */
7
+ import { useCallback, useEffect, useRef, useState } from "react";
8
+ import { CHAT_LIMITS, fileExtension, waveformPeaks } from "../core/media";
9
+ import type { LocalFile } from "../core/outbox";
10
+
11
+ export type VoiceRecorderState = "idle" | "requesting" | "recording" | "denied" | "unsupported" | "error";
12
+
13
+ export interface VoiceRecording {
14
+ blob: Blob;
15
+ /** The container's type without codec parameters, e.g. `audio/webm`. */
16
+ contentType: string;
17
+ name: string;
18
+ durationMs: number;
19
+ /** 48 peaks, 0–100. */
20
+ waveform: number[];
21
+ }
22
+
23
+ export interface UseVoiceRecorderOptions {
24
+ /** The longest recording; it stops itself there (`recordingLimitMs(role)`). Default five minutes. */
25
+ maxMs?: number;
26
+ /** Called about every 100 ms while recording (e.g. to tell the others). */
27
+ onTick?: () => void;
28
+ /** Called with the recording when it stopped itself at `maxMs`. */
29
+ onLimit?: (recording: VoiceRecording) => void;
30
+ }
31
+
32
+ const SAMPLE_MS = 100;
33
+ const METER_BARS = 32;
34
+
35
+ /** The type to record in: Opus in WebM where the browser has it, else MP4 (Safari). */
36
+ export function pickVoiceMimeType(): string | null {
37
+ if (typeof MediaRecorder === "undefined") return null;
38
+ for (const type of ["audio/webm;codecs=opus", "audio/mp4", "audio/webm"]) {
39
+ try {
40
+ if (MediaRecorder.isTypeSupported(type)) return type;
41
+ } catch {
42
+ // Older browsers throw on unknown types.
43
+ }
44
+ }
45
+ return "";
46
+ }
47
+
48
+ /** A recording as a file the outbox sends: a voice attachment with its duration and waveform. */
49
+ export function voiceFile(recording: VoiceRecording): LocalFile {
50
+ return {
51
+ data: recording.blob,
52
+ name: recording.name,
53
+ contentType: recording.contentType,
54
+ previewUrl: typeof URL !== "undefined" && URL.createObjectURL ? URL.createObjectURL(recording.blob) : undefined,
55
+ meta: { kind: "voice", durationMs: recording.durationMs, waveform: recording.waveform, bytes: recording.blob.size },
56
+ };
57
+ }
58
+
59
+ export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}) {
60
+ const maxMs = options.maxMs ?? CHAT_LIMITS.recordingMs;
61
+ const [state, setState] = useState<VoiceRecorderState>("idle");
62
+ const [elapsedMs, setElapsedMs] = useState(0);
63
+ const [levels, setLevels] = useState<number[]>([]);
64
+ const [error, setError] = useState<string | null>(null);
65
+
66
+ const opts = useRef(options);
67
+ opts.current = options;
68
+ const session = useRef<{
69
+ recorder: MediaRecorder;
70
+ stream: MediaStream;
71
+ context: AudioContext | null;
72
+ timer: ReturnType<typeof setInterval>;
73
+ chunks: Blob[];
74
+ samples: number[];
75
+ startedAt: number;
76
+ mimeType: string;
77
+ discard: boolean;
78
+ done: Promise<VoiceRecording | null>;
79
+ } | null>(null);
80
+
81
+ const cleanup = useCallback(() => {
82
+ const s = session.current;
83
+ if (!s) return;
84
+ clearInterval(s.timer);
85
+ for (const t of s.stream.getTracks()) t.stop();
86
+ void s.context?.close().catch(() => undefined);
87
+ session.current = null;
88
+ }, []);
89
+
90
+ const stop = useCallback(async (): Promise<VoiceRecording | null> => {
91
+ const s = session.current;
92
+ if (!s) return null;
93
+ if (s.recorder.state !== "inactive") s.recorder.stop();
94
+ return s.done;
95
+ }, []);
96
+
97
+ // Cancel while the browser still asks for the microphone: the answer is dropped.
98
+ const abandoned = useRef(false);
99
+
100
+ const cancel = useCallback(() => {
101
+ const s = session.current;
102
+ if (!s) {
103
+ abandoned.current = true;
104
+ setState((st) => (st === "requesting" ? "idle" : st));
105
+ return;
106
+ }
107
+ s.discard = true;
108
+ if (s.recorder.state !== "inactive") s.recorder.stop();
109
+ else cleanup();
110
+ }, [cleanup]);
111
+
112
+ const start = useCallback(async () => {
113
+ if (session.current) return;
114
+ const mimeType = pickVoiceMimeType();
115
+ if (mimeType === null || typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) {
116
+ setState("unsupported");
117
+ setError("This browser can't record voice messages.");
118
+ return;
119
+ }
120
+ setState("requesting");
121
+ setError(null);
122
+ abandoned.current = false;
123
+ let stream: MediaStream;
124
+ try {
125
+ stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
126
+ } catch (e) {
127
+ const name = (e as { name?: string })?.name;
128
+ if (name === "NotAllowedError" || name === "SecurityError") {
129
+ setState("denied");
130
+ setError("Microphone access is blocked. Allow it for this site in your browser's settings to record voice messages.");
131
+ } else if (name === "NotFoundError") {
132
+ setState("error");
133
+ setError("No microphone was found.");
134
+ } else {
135
+ setState("error");
136
+ setError("The microphone couldn't be started.");
137
+ }
138
+ return;
139
+ }
140
+ if (abandoned.current) {
141
+ for (const t of stream.getTracks()) t.stop();
142
+ return;
143
+ }
144
+
145
+ let recorder: MediaRecorder;
146
+ try {
147
+ recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
148
+ } catch {
149
+ for (const t of stream.getTracks()) t.stop();
150
+ setState("unsupported");
151
+ setError("This browser can't record voice messages.");
152
+ return;
153
+ }
154
+
155
+ let context: AudioContext | null = null;
156
+ let analyser: AnalyserNode | null = null;
157
+ try {
158
+ const Ctx = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
159
+ if (Ctx) {
160
+ context = new Ctx();
161
+ analyser = context.createAnalyser();
162
+ analyser.fftSize = 1024;
163
+ context.createMediaStreamSource(stream).connect(analyser);
164
+ }
165
+ } catch {
166
+ context = null;
167
+ analyser = null;
168
+ }
169
+ const buffer = analyser ? new Float32Array(analyser.fftSize) : null;
170
+
171
+ const chunks: Blob[] = [];
172
+ const samples: number[] = [];
173
+ const startedAt = Date.now();
174
+ let resolveDone!: (r: VoiceRecording | null) => void;
175
+ const done = new Promise<VoiceRecording | null>((r) => (resolveDone = r));
176
+
177
+ const timer = setInterval(() => {
178
+ const s = session.current;
179
+ if (!s) return;
180
+ let level = 0;
181
+ if (analyser && buffer) {
182
+ analyser.getFloatTimeDomainData(buffer);
183
+ for (let i = 0; i < buffer.length; i++) level = Math.max(level, Math.abs(buffer[i]!));
184
+ }
185
+ s.samples.push(level);
186
+ setLevels((prev) => [...prev, level].slice(-METER_BARS));
187
+ const elapsed = Date.now() - s.startedAt;
188
+ setElapsedMs(elapsed);
189
+ opts.current.onTick?.();
190
+ if (elapsed >= maxMs && s.recorder.state === "recording") {
191
+ void stop().then((r) => r && opts.current.onLimit?.(r));
192
+ }
193
+ }, SAMPLE_MS);
194
+
195
+ recorder.ondataavailable = (e) => {
196
+ if (e.data && e.data.size > 0) chunks.push(e.data);
197
+ };
198
+ recorder.onstop = () => {
199
+ const s = session.current;
200
+ const discard = s?.discard ?? true;
201
+ const durationMs = Math.min(maxMs, Date.now() - startedAt);
202
+ const contentType = (recorder.mimeType || mimeType || "audio/webm").split(";")[0]!.trim();
203
+ cleanup();
204
+ setState("idle");
205
+ setElapsedMs(0);
206
+ setLevels([]);
207
+ if (discard || chunks.length === 0) {
208
+ resolveDone(null);
209
+ return;
210
+ }
211
+ const blob = new Blob(chunks, { type: contentType });
212
+ const ext = fileExtension("", contentType) || "webm";
213
+ resolveDone({
214
+ blob,
215
+ contentType,
216
+ name: `voice-${new Date(startedAt).toISOString().replace(/[:.]/g, "-")}.${ext}`,
217
+ durationMs,
218
+ waveform: waveformPeaks(samples, 48),
219
+ });
220
+ };
221
+
222
+ session.current = { recorder, stream, context, timer, chunks, samples, startedAt, mimeType: mimeType ?? "", discard: false, done };
223
+ recorder.start(250);
224
+ setElapsedMs(0);
225
+ setLevels([]);
226
+ setState("recording");
227
+ }, [cleanup, maxMs, stop]);
228
+
229
+ // Leaving the screen mid-recording throws the recording away.
230
+ useEffect(() => () => {
231
+ const s = session.current;
232
+ if (s) {
233
+ s.discard = true;
234
+ try {
235
+ if (s.recorder.state !== "inactive") s.recorder.stop();
236
+ } catch {
237
+ // already stopped
238
+ }
239
+ clearInterval(s.timer);
240
+ for (const t of s.stream.getTracks()) t.stop();
241
+ void s.context?.close().catch(() => undefined);
242
+ session.current = null;
243
+ }
244
+ }, []);
245
+
246
+ return {
247
+ state,
248
+ recording: state === "recording",
249
+ elapsedMs,
250
+ maxMs,
251
+ /** The latest levels, 0–1, newest last: a live meter. */
252
+ levels,
253
+ error,
254
+ clearError: () => {
255
+ setError(null);
256
+ setState((s) => (s === "denied" || s === "error" || s === "unsupported" ? "idle" : s));
257
+ },
258
+ start,
259
+ stop,
260
+ cancel,
261
+ };
262
+ }
263
+
264
+ export type VoiceRecorder = ReturnType<typeof useVoiceRecorder>;