@marktiderman/genesis-chat 0.1.1 → 0.2.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.
Files changed (162) hide show
  1. package/README.md +304 -13
  2. package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
  3. package/dist/chunk-5D5A7I4Q.js.map +1 -0
  4. package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
  5. package/dist/chunk-7S6HYNWW.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-PZTOY2LY.js +576 -0
  9. package/dist/chunk-PZTOY2LY.js.map +1 -0
  10. package/dist/chunk-VEPZROQB.js +187 -0
  11. package/dist/chunk-VEPZROQB.js.map +1 -0
  12. package/dist/{client-BtdALFCx.d.ts → client-DMa2j7kz.d.ts} +152 -11
  13. package/dist/index.d.ts +78 -163
  14. package/dist/index.js +3 -2
  15. package/dist/native.d.ts +1910 -1
  16. package/dist/native.js +4900 -0
  17. package/dist/native.js.map +1 -1
  18. package/dist/outbox-B0y3phE0.d.ts +151 -0
  19. package/dist/push.d.ts +206 -0
  20. package/dist/push.js +3 -0
  21. package/dist/push.js.map +1 -0
  22. package/dist/react.d.ts +149 -7
  23. package/dist/react.js +3 -3
  24. package/dist/server.d.ts +510 -21
  25. package/dist/server.js +863 -21
  26. package/dist/server.js.map +1 -1
  27. package/dist/thread-DWXHKPdw.d.ts +57 -0
  28. package/dist/web.d.ts +313 -10
  29. package/dist/web.js +1731 -60
  30. package/dist/web.js.map +1 -1
  31. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  32. package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
  33. package/package.json +74 -6
  34. package/src/__tests__/coaching-core.test.ts +332 -0
  35. package/src/__tests__/coaching-react.test.tsx +248 -0
  36. package/src/__tests__/coaching-web.test.tsx +449 -0
  37. package/src/__tests__/core.test.ts +91 -0
  38. package/src/__tests__/outbox.test.ts +122 -0
  39. package/src/__tests__/realtime.test.ts +70 -0
  40. package/src/__tests__/review-fixes-core.test.ts +193 -0
  41. package/src/__tests__/review-fixes-web.test.tsx +402 -0
  42. package/src/__tests__/server-effects.test.ts +158 -0
  43. package/src/__tests__/server-fakes.ts +90 -0
  44. package/src/__tests__/server-integrations.test.ts +247 -0
  45. package/src/__tests__/server-providers.test.ts +246 -0
  46. package/src/__tests__/server-runner.test.ts +277 -0
  47. package/src/__tests__/server-safety.test.ts +290 -0
  48. package/src/__tests__/web-markdown.test.tsx +88 -0
  49. package/src/__tests__/web.test.tsx +333 -0
  50. package/src/core/availability.ts +223 -0
  51. package/src/core/client.ts +757 -0
  52. package/src/core/extras.ts +106 -0
  53. package/src/core/media.ts +144 -0
  54. package/src/core/outbox.ts +429 -0
  55. package/src/core/thread.ts +171 -0
  56. package/src/core/types.ts +311 -0
  57. package/src/index.ts +13 -0
  58. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  59. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  60. package/src/native/__tests__/composer-state.test.ts +188 -0
  61. package/src/native/__tests__/composer.test.tsx +133 -0
  62. package/src/native/__tests__/emoji.test.tsx +238 -0
  63. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  64. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  65. package/src/native/__tests__/organise-logic.test.ts +164 -0
  66. package/src/native/__tests__/organise-screens.test.tsx +344 -0
  67. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  68. package/src/native/__tests__/push-payload.test.ts +66 -0
  69. package/src/native/__tests__/push.test.ts +209 -0
  70. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  71. package/src/native/__tests__/rows-actions.test.ts +76 -0
  72. package/src/native/__tests__/thread-layout.test.ts +41 -0
  73. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  74. package/src/native/__tests__/tus.test.ts +154 -0
  75. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  76. package/src/native/__tests__/video-media.test.tsx +421 -0
  77. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  78. package/src/native/__tests__/video-upload.test.ts +227 -0
  79. package/src/native/access-error.tsx +31 -0
  80. package/src/native/actions.ts +42 -0
  81. package/src/native/attachment-cards.tsx +116 -0
  82. package/src/native/auto-retry.ts +69 -0
  83. package/src/native/availability.tsx +376 -0
  84. package/src/native/broadcast-sheet.tsx +193 -0
  85. package/src/native/composer-state.ts +302 -0
  86. package/src/native/composer.tsx +453 -0
  87. package/src/native/context.tsx +70 -0
  88. package/src/native/copy.ts +296 -0
  89. package/src/native/emoji-data.ts +21 -0
  90. package/src/native/emoji-picker.tsx +229 -0
  91. package/src/native/emoji.ts +118 -0
  92. package/src/native/icon-button.tsx +40 -0
  93. package/src/native/inbox-screen.tsx +116 -0
  94. package/src/native/index.ts +184 -0
  95. package/src/native/link-preview.tsx +104 -0
  96. package/src/native/media-colors.ts +15 -0
  97. package/src/native/media.tsx +187 -0
  98. package/src/native/message-actions-sheet.tsx +121 -0
  99. package/src/native/message-bubble.tsx +374 -0
  100. package/src/native/moderation.tsx +42 -0
  101. package/src/native/modules.ts +275 -0
  102. package/src/native/organise-copy.ts +195 -0
  103. package/src/native/photo-viewer.tsx +531 -0
  104. package/src/native/push.ts +375 -0
  105. package/src/native/recorder-machine.ts +176 -0
  106. package/src/native/rows.ts +81 -0
  107. package/src/native/schedule-sheet.tsx +314 -0
  108. package/src/native/search-screen.tsx +168 -0
  109. package/src/native/stack-options.ts +36 -0
  110. package/src/native/start-sheet.tsx +87 -0
  111. package/src/native/thread-screen.tsx +620 -0
  112. package/src/native/topics.tsx +179 -0
  113. package/src/native/tus.ts +282 -0
  114. package/src/native/use-composer.ts +71 -0
  115. package/src/native/use-jump.ts +91 -0
  116. package/src/native/use-voice-recorder.ts +234 -0
  117. package/src/native/video-player.tsx +370 -0
  118. package/src/native/video-recorder-state.ts +140 -0
  119. package/src/native/video-recorder.tsx +271 -0
  120. package/src/native/video-upload-tray.tsx +77 -0
  121. package/src/native/video-upload.ts +432 -0
  122. package/src/native/voice-player.tsx +283 -0
  123. package/src/react/index.tsx +810 -0
  124. package/src/server/assistant.ts +235 -0
  125. package/src/server/crypto.ts +86 -0
  126. package/src/server/expo.ts +119 -0
  127. package/src/server/handler.ts +32 -0
  128. package/src/server/index.ts +57 -0
  129. package/src/server/moderation.ts +155 -0
  130. package/src/server/mux.ts +290 -0
  131. package/src/server/notifications.ts +130 -0
  132. package/src/server/runner.ts +468 -0
  133. package/src/server/transcribers.ts +111 -0
  134. package/src/server/types.ts +171 -0
  135. package/src/server/unfurl.ts +480 -0
  136. package/src/server/upload.ts +158 -0
  137. package/src/web/attachments.tsx +379 -0
  138. package/src/web/availability.tsx +299 -0
  139. package/src/web/broadcast.tsx +166 -0
  140. package/src/web/bubble.tsx +563 -0
  141. package/src/web/cards.tsx +136 -0
  142. package/src/web/composer.tsx +571 -0
  143. package/src/web/emoji-picker.tsx +84 -0
  144. package/src/web/error-boundary.tsx +33 -0
  145. package/src/web/format.ts +55 -0
  146. package/src/web/inbox.tsx +195 -0
  147. package/src/web/index.ts +75 -0
  148. package/src/web/markdown.tsx +245 -0
  149. package/src/web/memory-client.ts +706 -0
  150. package/src/web/panel.tsx +135 -0
  151. package/src/web/schedule.tsx +192 -0
  152. package/src/web/search.tsx +129 -0
  153. package/src/web/thread-context.ts +64 -0
  154. package/src/web/thread.tsx +802 -0
  155. package/src/web/topics.tsx +160 -0
  156. package/src/web/use-voice-recorder.ts +264 -0
  157. package/src/web/video.tsx +522 -0
  158. package/src/web/voice-note.tsx +197 -0
  159. package/dist/chunk-27UTR5FW.js +0 -322
  160. package/dist/chunk-27UTR5FW.js.map +0 -1
  161. package/dist/chunk-JQXZUEEL.js.map +0 -1
  162. package/dist/chunk-SAY24E6V.js.map +0 -1
@@ -0,0 +1,522 @@
1
+ /**
2
+ * @feat:chat
3
+ * Video on the web. Recording: `useVideoRecorder` (camera and microphone through
4
+ * MediaRecorder, a live preview, a limit by role, retake) and `ChatVideoRecorder`, the
5
+ * composer's dialog around it, which sends through the provider's `videoUpload` (a Mux direct
6
+ * upload: PUT the recording, then send a pending video attachment) or, without it, as a file
7
+ * to storage. Playback: `ChatVideoPlayer`, Mux HLS with native HLS where the browser has it
8
+ * (Safari) and `hls.js`, loaded only then, elsewhere; "Processing…" while Mux works.
9
+ */
10
+ import { useCallback, useEffect, useRef, useState } from "react";
11
+ import { Circle, RotateCcw, SendHorizontal, Square, Video, VideoOff } from "lucide-react";
12
+ import {
13
+ Alert,
14
+ AlertDescription,
15
+ Button,
16
+ Dialog,
17
+ DialogContent,
18
+ DialogDescription,
19
+ DialogHeader,
20
+ DialogTitle,
21
+ Spinner,
22
+ cn,
23
+ } from "@marktiderman/genesis-ui";
24
+ import { fileExtension, formatDuration } from "../core/media";
25
+ import type { LocalFile } from "../core/outbox";
26
+ import type { AttachmentInput, SendInput } from "../core/types";
27
+ import type { ChatVideoUpload } from "../react/index";
28
+
29
+ // ── Recording ─────────────────────────────────────────────────────────────────
30
+
31
+ export type VideoRecorderState = "idle" | "requesting" | "previewing" | "recording" | "recorded" | "denied" | "unsupported" | "error";
32
+
33
+ export interface VideoRecording {
34
+ blob: Blob;
35
+ /** Without codec parameters: `video/webm` or `video/mp4`. */
36
+ contentType: string;
37
+ name: string;
38
+ durationMs: number;
39
+ /** An object URL for playback before sending. */
40
+ url: string | null;
41
+ }
42
+
43
+ /** MP4 where the browser records it (Safari, recent Chrome), else WebM. */
44
+ export function pickVideoMimeType(): string | null {
45
+ if (typeof MediaRecorder === "undefined") return null;
46
+ for (const type of ["video/mp4;codecs=avc1,mp4a", "video/mp4", "video/webm;codecs=vp9,opus", "video/webm;codecs=vp8,opus", "video/webm"]) {
47
+ try {
48
+ if (MediaRecorder.isTypeSupported(type)) return type;
49
+ } catch {
50
+ // Older browsers throw on unknown types.
51
+ }
52
+ }
53
+ return "";
54
+ }
55
+
56
+ export interface UseVideoRecorderOptions {
57
+ /** It stops itself here (`recordingLimitMs(role)`). */
58
+ maxMs: number;
59
+ /** About every second while recording (to tell the others). */
60
+ onTick?: () => void;
61
+ }
62
+
63
+ export function useVideoRecorder({ maxMs, onTick }: UseVideoRecorderOptions) {
64
+ const [state, setState] = useState<VideoRecorderState>("idle");
65
+ const [error, setError] = useState<string | null>(null);
66
+ const [elapsedMs, setElapsedMs] = useState(0);
67
+ const [stream, setStream] = useState<MediaStream | null>(null);
68
+ const [recording, setRecording] = useState<VideoRecording | null>(null);
69
+ const recorder = useRef<MediaRecorder | null>(null);
70
+ const chunks = useRef<Blob[]>([]);
71
+ const startedAt = useRef(0);
72
+ const timer = useRef<ReturnType<typeof setInterval> | null>(null);
73
+ const streamRef = useRef<MediaStream | null>(null);
74
+ const urlRef = useRef<string | null>(null);
75
+ // Bumped by reset (closing, unmounting): a camera that answers after that is turned off at once.
76
+ const session = useRef(0);
77
+ const tick = useRef(onTick);
78
+ tick.current = onTick;
79
+
80
+ const stopTimer = () => {
81
+ if (timer.current) clearInterval(timer.current);
82
+ timer.current = null;
83
+ };
84
+ const releaseUrl = () => {
85
+ if (urlRef.current && typeof URL !== "undefined" && URL.revokeObjectURL) URL.revokeObjectURL(urlRef.current);
86
+ urlRef.current = null;
87
+ };
88
+ const stopStream = useCallback(() => {
89
+ streamRef.current?.getTracks().forEach((t) => t.stop());
90
+ streamRef.current = null;
91
+ setStream(null);
92
+ }, []);
93
+
94
+ /** Asks for the camera and microphone and shows the preview. */
95
+ const open = useCallback(async () => {
96
+ if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia || pickVideoMimeType() === null) {
97
+ setState("unsupported");
98
+ setError("This browser can't record video.");
99
+ return false;
100
+ }
101
+ const token = session.current;
102
+ setState("requesting");
103
+ setError(null);
104
+ try {
105
+ const s = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user", width: { ideal: 1280 }, height: { ideal: 720 } }, audio: true });
106
+ if (token !== session.current) {
107
+ // Closed (or unmounted, or opened again) while the browser asked: never keep this camera on.
108
+ s.getTracks().forEach((t) => t.stop());
109
+ return false;
110
+ }
111
+ if (streamRef.current && streamRef.current !== s) streamRef.current.getTracks().forEach((t) => t.stop());
112
+ streamRef.current = s;
113
+ setStream(s);
114
+ setState("previewing");
115
+ return true;
116
+ } catch (e) {
117
+ if (token !== session.current) return false;
118
+ const denied = e instanceof Error && (e.name === "NotAllowedError" || e.name === "SecurityError");
119
+ setState(denied ? "denied" : "error");
120
+ setError(denied ? "Allow the camera and microphone in your browser's settings to record a video." : "The camera couldn't start.");
121
+ return false;
122
+ }
123
+ }, []);
124
+
125
+ const stop = useCallback(() => {
126
+ const r = recorder.current;
127
+ if (r && r.state !== "inactive") r.stop();
128
+ }, []);
129
+
130
+ const start = useCallback(async () => {
131
+ if (!streamRef.current && !(await open())) return;
132
+ const s = streamRef.current!;
133
+ const mime = pickVideoMimeType() ?? "";
134
+ let r: MediaRecorder;
135
+ try {
136
+ r = mime ? new MediaRecorder(s, { mimeType: mime, videoBitsPerSecond: 2_500_000 }) : new MediaRecorder(s);
137
+ } catch {
138
+ stopStream();
139
+ setState("error");
140
+ setError("This browser can't record video.");
141
+ return;
142
+ }
143
+ r.onerror = () => {
144
+ stopTimer();
145
+ r.onstop = null;
146
+ recorder.current = null;
147
+ stopStream();
148
+ setState("error");
149
+ setError("The recording stopped. Try again.");
150
+ };
151
+ releaseUrl();
152
+ setRecording(null);
153
+ chunks.current = [];
154
+ r.ondataavailable = (ev: BlobEvent) => {
155
+ if (ev.data && ev.data.size > 0) chunks.current.push(ev.data);
156
+ };
157
+ r.onstop = () => {
158
+ stopTimer();
159
+ const durationMs = Date.now() - startedAt.current;
160
+ const contentType = (r.mimeType || mime || "video/webm").split(";")[0]!.trim() || "video/webm";
161
+ const blob = new Blob(chunks.current, { type: contentType });
162
+ const url = typeof URL !== "undefined" && URL.createObjectURL ? URL.createObjectURL(blob) : null;
163
+ urlRef.current = url;
164
+ setRecording({ blob, contentType, name: `video-${Date.now()}.${fileExtension("", contentType) || "webm"}`, durationMs, url });
165
+ setState("recorded");
166
+ // The camera light goes off while you review it.
167
+ stopStream();
168
+ };
169
+ recorder.current = r;
170
+ startedAt.current = Date.now();
171
+ setElapsedMs(0);
172
+ r.start(1000);
173
+ setState("recording");
174
+ timer.current = setInterval(() => {
175
+ const ms = Date.now() - startedAt.current;
176
+ setElapsedMs(ms);
177
+ tick.current?.();
178
+ if (ms >= maxMs) stop();
179
+ }, 250);
180
+ }, [maxMs, open, stop, stopStream]);
181
+
182
+ /** Throws the take away and opens the camera again. */
183
+ const retake = useCallback(async () => {
184
+ releaseUrl();
185
+ setRecording(null);
186
+ setElapsedMs(0);
187
+ await open();
188
+ }, [open]);
189
+
190
+ /**
191
+ * The take's object URL now belongs to whoever it was handed to (the outbox frees it once the
192
+ * message lands): reset no longer revokes it.
193
+ */
194
+ const handOff = useCallback(() => {
195
+ urlRef.current = null;
196
+ }, []);
197
+
198
+ /** Stops everything and forgets the take. */
199
+ const reset = useCallback(() => {
200
+ session.current += 1;
201
+ stopTimer();
202
+ const r = recorder.current;
203
+ if (r && r.state !== "inactive") {
204
+ r.onstop = null;
205
+ r.stop();
206
+ }
207
+ recorder.current = null;
208
+ stopStream();
209
+ releaseUrl();
210
+ setRecording(null);
211
+ setElapsedMs(0);
212
+ setError(null);
213
+ setState("idle");
214
+ }, [stopStream]);
215
+
216
+ useEffect(() => () => reset(), [reset]);
217
+
218
+ return { state, error, elapsedMs, maxMs, stream, recording, open, start, stop, retake, reset, handOff };
219
+ }
220
+
221
+ export type VideoRecorder = ReturnType<typeof useVideoRecorder>;
222
+
223
+ /** A recording as a file the outbox uploads to storage (the fallback without `videoUpload`). */
224
+ export function videoFile(rec: VideoRecording): LocalFile {
225
+ return {
226
+ data: rec.blob,
227
+ name: rec.name,
228
+ contentType: rec.contentType,
229
+ previewUrl: rec.url ?? undefined,
230
+ meta: { kind: "video", durationMs: rec.durationMs, bytes: rec.blob.size },
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Uploads a recording through the app's `videoUpload` (a Mux direct upload: PUT the bytes to
236
+ * the URL it returns) and gives back the attachment to send, pending until Mux's webhook marks
237
+ * it ready.
238
+ */
239
+ export async function uploadVideo(
240
+ videoUpload: ChatVideoUpload,
241
+ conversationId: string,
242
+ rec: Pick<VideoRecording, "blob" | "contentType" | "durationMs" | "name">,
243
+ fetchImpl: typeof fetch = fetch,
244
+ signal?: AbortSignal,
245
+ ): Promise<AttachmentInput> {
246
+ const upload = await videoUpload.create(conversationId);
247
+ signal?.throwIfAborted?.();
248
+ const res = await fetchImpl(upload.url, { method: "PUT", body: rec.blob, headers: { "Content-Type": rec.contentType }, signal });
249
+ if (!res.ok) throw new Error(`The video didn't upload (${res.status})`);
250
+ return {
251
+ kind: "video",
252
+ provider: upload.provider,
253
+ providerId: upload.providerId,
254
+ status: "pending",
255
+ name: rec.name,
256
+ mime: rec.contentType,
257
+ bytes: rec.blob.size,
258
+ durationMs: rec.durationMs,
259
+ };
260
+ }
261
+
262
+ export interface ChatVideoRecorderProps {
263
+ open: boolean;
264
+ onOpenChange: (open: boolean) => void;
265
+ conversationId: string;
266
+ maxMs: number;
267
+ /** From useConversation: sends through the outbox. */
268
+ send: (input: Omit<SendInput, "conversationId" | "clientId">, files?: LocalFile[]) => Promise<unknown>;
269
+ /** Merged into what's sent (a reply, a topic, an internal note). */
270
+ extra?: Omit<SendInput, "conversationId" | "clientId" | "attachments" | "body">;
271
+ videoUpload?: ChatVideoUpload;
272
+ onTick?: () => void;
273
+ onSent?: () => void;
274
+ /** For tests. */
275
+ fetchImpl?: typeof fetch;
276
+ }
277
+
278
+ function Preview({ stream }: { stream: MediaStream | null }) {
279
+ const ref = useRef<HTMLVideoElement>(null);
280
+ useEffect(() => {
281
+ const el = ref.current;
282
+ if (!el) return;
283
+ try {
284
+ (el as HTMLVideoElement & { srcObject: MediaStream | null }).srcObject = stream;
285
+ } catch {
286
+ // happy-dom and old engines
287
+ }
288
+ }, [stream]);
289
+ return <video ref={ref} autoPlay muted playsInline className="h-full w-full -scale-x-100 object-cover" aria-label="Camera preview" data-testid="genesis-chat-video-preview" />;
290
+ }
291
+
292
+ /** The composer's video dialog: preview, record (to the role's limit), review, retake, send. */
293
+ export function ChatVideoRecorder({ open, onOpenChange, conversationId, maxMs, send, extra, videoUpload, onTick, onSent, fetchImpl }: ChatVideoRecorderProps) {
294
+ const rec = useVideoRecorder({ maxMs, onTick });
295
+ const [sending, setSending] = useState(false);
296
+ const [sendError, setSendError] = useState<string | null>(null);
297
+ const upload = useRef<AbortController | null>(null);
298
+ const { open: openCamera, reset } = rec;
299
+
300
+ useEffect(() => {
301
+ if (open) void openCamera();
302
+ else reset();
303
+ }, [open, openCamera, reset]);
304
+ // Closing or unmounting stops an upload still going.
305
+ useEffect(() => () => upload.current?.abort(), []);
306
+
307
+ const submit = async () => {
308
+ const take = rec.recording;
309
+ if (!take || upload.current) return;
310
+ const controller = new AbortController();
311
+ upload.current = controller;
312
+ setSending(true);
313
+ setSendError(null);
314
+ try {
315
+ if (videoUpload) {
316
+ const attachment = await uploadVideo(videoUpload, conversationId, take, fetchImpl, controller.signal);
317
+ if (controller.signal.aborted) return;
318
+ await send({ ...extra, body: "", attachments: [attachment] });
319
+ } else {
320
+ // The outbox shows the take while it uploads and frees its URL once it lands.
321
+ await send({ ...extra, body: "" }, [videoFile(take)]);
322
+ rec.handOff();
323
+ }
324
+ onSent?.();
325
+ onOpenChange(false);
326
+ } catch (e) {
327
+ if (!controller.signal.aborted) setSendError(e instanceof Error ? e.message : "The video didn't send");
328
+ } finally {
329
+ upload.current = null;
330
+ setSending(false);
331
+ }
332
+ };
333
+
334
+ /** Closing while it uploads cancels the upload. */
335
+ const close = (o: boolean) => {
336
+ if (!o) upload.current?.abort();
337
+ onOpenChange(o);
338
+ };
339
+
340
+ const recording = rec.state === "recording";
341
+ return (
342
+ <Dialog open={open} onOpenChange={close}>
343
+ <DialogContent className="sm:max-w-lg" data-testid="genesis-chat-video-recorder">
344
+ <DialogHeader>
345
+ <DialogTitle>Record a video</DialogTitle>
346
+ <DialogDescription>Up to {formatDuration(maxMs)}. You can watch it before you send it.</DialogDescription>
347
+ </DialogHeader>
348
+ {rec.error ? (
349
+ <Alert variant="destructive">
350
+ <VideoOff className="size-4" aria-hidden />
351
+ <AlertDescription>{rec.error}</AlertDescription>
352
+ </Alert>
353
+ ) : null}
354
+ <div className="relative aspect-video w-full overflow-hidden rounded-lg bg-muted">
355
+ {rec.state === "recorded" && rec.recording?.url ? (
356
+ // eslint-disable-next-line jsx-a11y/media-has-caption -- your own recording, reviewed before sending
357
+ <video src={rec.recording.url} controls playsInline className="h-full w-full object-contain" aria-label="Your recording" data-testid="genesis-chat-video-review" />
358
+ ) : rec.stream ? (
359
+ <Preview stream={rec.stream} />
360
+ ) : (
361
+ <span className="absolute inset-0 flex items-center justify-center">
362
+ {rec.state === "requesting" ? <Spinner aria-label="Starting the camera" /> : <Video className="size-8 text-muted-foreground" aria-hidden />}
363
+ </span>
364
+ )}
365
+ {recording ? (
366
+ <span className="absolute left-2 top-2 flex items-center gap-1.5 rounded-full bg-background/85 px-2 py-0.5 text-xs font-medium tabular-nums" role="timer" aria-live="off">
367
+ <span className="size-2 animate-pulse rounded-full bg-destructive" aria-hidden />
368
+ {formatDuration(rec.elapsedMs)} / {formatDuration(maxMs)}
369
+ </span>
370
+ ) : null}
371
+ {sending ? (
372
+ <span className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-background/70 text-sm font-medium" role="status">
373
+ <Spinner /> Uploading…
374
+ {videoUpload ? (
375
+ <Button type="button" variant="outline" size="sm" onClick={() => upload.current?.abort()} data-testid="genesis-chat-video-cancel-upload">
376
+ Cancel upload
377
+ </Button>
378
+ ) : null}
379
+ </span>
380
+ ) : null}
381
+ </div>
382
+ <div className="sr-only" role="status">
383
+ {recording ? "Recording" : rec.state === "recorded" ? "Recorded. Watch it, retake it or send it." : ""}
384
+ </div>
385
+ {sendError ? (
386
+ <p className="text-sm text-destructive" role="alert">
387
+ {sendError}
388
+ </p>
389
+ ) : null}
390
+ <div className="flex items-center justify-center gap-3">
391
+ {rec.state === "recorded" ? (
392
+ <>
393
+ <Button type="button" variant="outline" onClick={() => void rec.retake()} disabled={sending} data-testid="genesis-chat-video-retake">
394
+ <RotateCcw /> Retake
395
+ </Button>
396
+ <Button type="button" onClick={() => void submit()} loading={sending} data-testid="genesis-chat-video-send">
397
+ <SendHorizontal /> Send video
398
+ </Button>
399
+ </>
400
+ ) : recording ? (
401
+ <Button type="button" variant="destructive" className="rounded-full" onClick={rec.stop} aria-label="Stop recording" data-testid="genesis-chat-video-stop">
402
+ <Square className="fill-current" /> Stop
403
+ </Button>
404
+ ) : (
405
+ <Button
406
+ type="button"
407
+ className="rounded-full"
408
+ onClick={() => void rec.start()}
409
+ disabled={rec.state === "requesting" || rec.state === "unsupported"}
410
+ aria-label="Start recording"
411
+ data-testid="genesis-chat-video-record"
412
+ >
413
+ <Circle className="fill-destructive text-destructive" /> Record
414
+ </Button>
415
+ )}
416
+ </div>
417
+ </DialogContent>
418
+ </Dialog>
419
+ );
420
+ }
421
+
422
+ // ── Playback ──────────────────────────────────────────────────────────────────
423
+
424
+ export const muxStreamUrl = (playbackId: string) => `https://stream.mux.com/${encodeURIComponent(playbackId)}.m3u8`;
425
+ export const muxPosterUrl = (playbackId: string) => `https://image.mux.com/${encodeURIComponent(playbackId)}/thumbnail.jpg`;
426
+
427
+ export interface ChatVideoPlayerProps {
428
+ /** A file URL (storage), when there is no playback id. */
429
+ src?: string | null;
430
+ /** Mux's playback id: streamed as HLS. */
431
+ playbackId?: string | null;
432
+ poster?: string | null;
433
+ /** Mux is still working on it. */
434
+ processing?: boolean;
435
+ failed?: boolean;
436
+ /** Still uploading from this device. */
437
+ uploading?: boolean;
438
+ label?: string;
439
+ className?: string;
440
+ }
441
+
442
+ type HlsLike = { loadSource(url: string): void; attachMedia(el: HTMLMediaElement): void; destroy(): void };
443
+
444
+ /** Plays a chat video: Mux HLS (native, else hls.js loaded on demand), or a file. */
445
+ export function ChatVideoPlayer({ src, playbackId, poster, processing, failed, uploading, label, className }: ChatVideoPlayerProps) {
446
+ const ref = useRef<HTMLVideoElement>(null);
447
+ const [hlsFailed, setHlsFailed] = useState(false);
448
+ const stream = playbackId ? muxStreamUrl(playbackId) : null;
449
+
450
+ useEffect(() => {
451
+ const el = ref.current;
452
+ if (!el || !stream) return;
453
+ if (typeof el.canPlayType === "function" && el.canPlayType("application/vnd.apple.mpegurl")) {
454
+ el.src = stream;
455
+ return;
456
+ }
457
+ let hls: HlsLike | null = null;
458
+ let cancelled = false;
459
+ import("hls.js")
460
+ .then((mod) => {
461
+ const Hls = (mod as unknown as { default: { isSupported(): boolean; new (): HlsLike } }).default;
462
+ if (cancelled) return;
463
+ if (!Hls?.isSupported?.()) {
464
+ setHlsFailed(true);
465
+ return;
466
+ }
467
+ hls = new Hls();
468
+ hls.loadSource(stream);
469
+ hls.attachMedia(el);
470
+ })
471
+ .catch(() => {
472
+ // hls.js is an optional peer: without it, browsers lacking native HLS can't stream.
473
+ if (!cancelled) setHlsFailed(true);
474
+ });
475
+ return () => {
476
+ cancelled = true;
477
+ hls?.destroy();
478
+ };
479
+ }, [stream]);
480
+
481
+ const posterUrl = poster ?? (playbackId ? muxPosterUrl(playbackId) : null);
482
+ if (failed) {
483
+ return (
484
+ <div className={cn("flex aspect-video w-[min(20rem,100%)] items-center justify-center gap-2 rounded-xl bg-muted text-sm text-muted-foreground", className)} data-testid="genesis-chat-video-failed">
485
+ <VideoOff className="size-4" aria-hidden /> This video couldn't be processed
486
+ </div>
487
+ );
488
+ }
489
+ if (processing && !playbackId) {
490
+ return (
491
+ <div className={cn("flex aspect-video w-[min(20rem,100%)] flex-col items-center justify-center gap-2 rounded-xl bg-muted text-sm text-muted-foreground", className)} role="status" data-testid="genesis-chat-video-processing">
492
+ <Spinner size="sm" /> Processing…
493
+ </div>
494
+ );
495
+ }
496
+ return (
497
+ <div className={cn("relative w-[min(20rem,100%)] overflow-hidden rounded-xl bg-muted", className)} data-testid="genesis-chat-video">
498
+ {stream || src ? (
499
+ // eslint-disable-next-line jsx-a11y/media-has-caption -- transcripts come from the provider's captions
500
+ <video
501
+ ref={ref}
502
+ controls
503
+ playsInline
504
+ preload="metadata"
505
+ poster={posterUrl ?? undefined}
506
+ src={stream ? undefined : (src ?? undefined)}
507
+ className="block max-h-80 w-full"
508
+ aria-label={label ?? "Video"}
509
+ data-testid={stream ? "genesis-chat-video-hls" : undefined}
510
+ />
511
+ ) : (
512
+ <span className="block aspect-video w-full animate-pulse bg-muted" />
513
+ )}
514
+ {hlsFailed ? <span className="absolute inset-x-0 bottom-0 bg-background/85 px-2 py-1 text-xs">This browser can't stream this video.</span> : null}
515
+ {uploading ? (
516
+ <span className="absolute inset-0 flex items-center justify-center bg-background/40" role="status" aria-label="Uploading">
517
+ <Spinner size="sm" className="text-foreground" />
518
+ </span>
519
+ ) : null}
520
+ </div>
521
+ );
522
+ }