@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,432 @@
1
+ /**
2
+ * @feat:chat
3
+ * Sending a recorded video. With `ChatProvider videoUpload` (Mux direct uploads) the
4
+ * recording goes to the URL the server mints, in chunks, resumably: each chunk is a PUT with
5
+ * `Content-Range`, the server answers 308 with how much it has, and after a dropped
6
+ * connection the next attempt asks (`Content-Range: bytes * /total`) and carries on from there.
7
+ * That is the resumable protocol Mux direct uploads speak (Google Cloud Storage's, the one
8
+ * Mux's UpChunk uses); it is not TUS, so `createTusUpload` doesn't apply. Once the bytes are
9
+ * there, the message is sent through the outbox with `{ kind: "video", provider, providerId,
10
+ * status: "pending" }` (the server minted the passthrough; the client sends no attachment id),
11
+ * and Mux's webhook marks it ready. Without `videoUpload`, the recording goes through the
12
+ * outbox as a file, to storage, like a picked video.
13
+ *
14
+ * The upload is minted when recording starts (`prepare`), so the bytes can go the moment the
15
+ * recording stops: expo-camera hands over the file only then, so it can't stream while it
16
+ * records. Uploads in flight live here, outside any screen, keyed by conversation: leaving the
17
+ * thread doesn't stop one, and coming back shows it.
18
+ */
19
+ import { useSyncExternalStore } from "react";
20
+ import { ChatError } from "../core/client";
21
+ import type { LocalFile } from "../core/outbox";
22
+ import type { AttachmentInput, SendInput } from "../core/types";
23
+ import type { ChatVideoUpload } from "../react/index";
24
+ import type { ChunkFileLike } from "./modules";
25
+
26
+ /** 8 MiB: a multiple of 256 KiB, as resumable uploads require for every chunk but the last. */
27
+ export const VIDEO_CHUNK_BYTES = 8 * 1024 * 1024;
28
+
29
+ /** What an upload reads: a file a chunk at a time, or bytes in memory. */
30
+ export interface ByteSource {
31
+ size: number;
32
+ read(from: number, to: number): Promise<ArrayBuffer>;
33
+ close(): void;
34
+ }
35
+
36
+ const exact = (bytes: ArrayBuffer | Uint8Array): ArrayBuffer =>
37
+ bytes instanceof ArrayBuffer ? bytes : (bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer);
38
+
39
+ /** Opens a file URI for upload: in chunks with `openFile` (expo-file-system), else read whole through fetch. */
40
+ export async function fileSource(uri: string, openFile?: (uri: string) => Promise<ChunkFileLike> | ChunkFileLike, doFetch: typeof fetch = fetch): Promise<ByteSource> {
41
+ if (openFile) {
42
+ const handle = await openFile(uri);
43
+ if (!Number.isFinite(handle.size) || handle.size <= 0) {
44
+ try {
45
+ handle.close?.();
46
+ } catch {
47
+ // Already closed.
48
+ }
49
+ throw new ChatError("The recording is empty", "22023", "upload");
50
+ }
51
+ return {
52
+ size: handle.size,
53
+ read: async (from, to) => {
54
+ const bytes = exact(await handle.read(from, to - from));
55
+ if (bytes.byteLength !== to - from) throw new ChatError("The recording changed while it was sent", "22023", "upload");
56
+ return bytes;
57
+ },
58
+ close: () => {
59
+ try {
60
+ handle.close?.();
61
+ } catch {
62
+ // Already closed.
63
+ }
64
+ },
65
+ };
66
+ }
67
+ const buffer = await (await doFetch(uri)).arrayBuffer();
68
+ if (buffer.byteLength === 0) throw new ChatError("The recording is empty", "22023", "upload");
69
+ return { size: buffer.byteLength, read: async (from, to) => buffer.slice(from, to), close: () => undefined };
70
+ }
71
+
72
+ export interface ChunkedPutOptions {
73
+ url: string;
74
+ contentType: string;
75
+ source: ByteSource;
76
+ fetch?: typeof fetch;
77
+ chunkBytes?: number;
78
+ /** Network failures retried per chunk before it fails (the person can Retry). Default 4. */
79
+ retries?: number;
80
+ /** Bytes the server has, after each chunk. */
81
+ onProgress?: (sent: number, total: number) => void;
82
+ /** Stops between requests (Cancel). */
83
+ isCancelled?: () => boolean;
84
+ /** Ask the server where it is before sending anything (a Retry of an upload that got partway). */
85
+ resume?: boolean;
86
+ /** For tests. */
87
+ sleep?: (ms: number) => Promise<void>;
88
+ }
89
+
90
+ const DONE = new Set([200, 201, 202, 204]);
91
+ const transient = (status: number) => status === 0 || status === 408 || status === 429 || status >= 500;
92
+
93
+ /** The next byte the server wants, from a 308's `Range: bytes=0-N` (none: it has nothing yet). */
94
+ export function nextOffset(range: string | null | undefined): number {
95
+ const m = /bytes=(\d+)-(\d+)/.exec(range ?? "");
96
+ return m ? Number(m[2]) + 1 : 0;
97
+ }
98
+
99
+ /** Uploads `source` to a resumable upload URL, a chunk at a time. Resolves once the server has every byte. */
100
+ export async function chunkedPut(options: ChunkedPutOptions): Promise<void> {
101
+ const doFetch = options.fetch ?? fetch;
102
+ const chunk = options.chunkBytes ?? VIDEO_CHUNK_BYTES;
103
+ const retries = options.retries ?? 4;
104
+ const sleep = options.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
105
+ const total = options.source.size;
106
+ if (chunk % (256 * 1024) !== 0 && chunk < total) throw new ChatError("Chunks must be a multiple of 256 KiB", "22023", "upload");
107
+ const cancelled = () => {
108
+ if (options.isCancelled?.()) throw new ChatError("Cancelled", "57014", "upload");
109
+ };
110
+
111
+ /** Where the server is: a status query (an empty PUT). null when it has everything. */
112
+ const ask = async (): Promise<number | null> => {
113
+ const res = await doFetch(options.url, { method: "PUT", headers: { "content-range": `bytes */${total}` } });
114
+ if (DONE.has(res.status)) return null;
115
+ if (res.status === 308) return nextOffset(res.headers.get("range"));
116
+ return refuse(res);
117
+ };
118
+
119
+ let offset = 0;
120
+ if (options.resume) {
121
+ cancelled();
122
+ const at = await withRetry(ask);
123
+ if (at === null) return void options.onProgress?.(total, total);
124
+ offset = at;
125
+ }
126
+ options.onProgress?.(offset, total);
127
+ // Failures since the server last moved forward: a 308 that keeps nothing new counts too, so a
128
+ // server that never advances ends in an error, not a loop.
129
+ let stalls = 0;
130
+ const stall = async (message: string) => {
131
+ stalls++;
132
+ if (stalls > retries) throw new ChatError(message, undefined, "upload");
133
+ await sleep(Math.min(8000, 500 * 2 ** (stalls - 1)));
134
+ cancelled();
135
+ };
136
+ while (offset < total) {
137
+ cancelled();
138
+ const from = offset;
139
+ const to = Math.min(total, from + chunk);
140
+ const body = await options.source.read(from, to);
141
+ let res: Response | null = null;
142
+ try {
143
+ res = await doFetch(options.url, {
144
+ method: "PUT",
145
+ headers: { "content-type": options.contentType, "content-range": `bytes ${from}-${to - 1}/${total}` },
146
+ body,
147
+ });
148
+ } catch {
149
+ res = null;
150
+ }
151
+ if (res && DONE.has(res.status)) {
152
+ // "Complete" before the last byte: the server took a part as the whole (a plain PUT URL).
153
+ if (to < total) throw new ChatError("The upload server kept only part of the video", "22023", "upload");
154
+ offset = total;
155
+ } else if (res?.status === 308) {
156
+ const next = nextOffset(res.headers.get("range"));
157
+ if (next > from) {
158
+ offset = next;
159
+ stalls = 0;
160
+ } else {
161
+ await stall("The video upload is out of step");
162
+ }
163
+ } else if (res && !transient(res.status)) {
164
+ return refuse(res);
165
+ } else {
166
+ // A dropped connection or a server hiccup: wait, ask where it is, and go on from there.
167
+ await stall("Not sent. Check your connection.");
168
+ try {
169
+ const at = await ask();
170
+ if (at === null) offset = total;
171
+ else if (at > from) {
172
+ offset = at;
173
+ stalls = 0;
174
+ }
175
+ } catch (e) {
176
+ if (e instanceof ChatError && !e.transient) throw e;
177
+ }
178
+ }
179
+ options.onProgress?.(offset, total);
180
+ }
181
+
182
+ async function withRetry<T>(run: () => Promise<T>): Promise<T> {
183
+ let last: unknown;
184
+ for (let attempt = 0; attempt <= retries; attempt++) {
185
+ try {
186
+ return await run();
187
+ } catch (e) {
188
+ last = e;
189
+ if (e instanceof ChatError && !e.transient) throw e;
190
+ if (attempt < retries) await sleep(Math.min(8000, 500 * 2 ** attempt));
191
+ cancelled();
192
+ }
193
+ }
194
+ throw last instanceof ChatError ? last : new ChatError("Not sent. Check your connection.", undefined, "upload");
195
+ }
196
+ }
197
+
198
+ async function refuse(res: Response): Promise<never> {
199
+ const text = await res.text().catch(() => "");
200
+ // 404 / 410: the upload URL expired or was used up; a new one is minted on Retry.
201
+ const code = res.status === 404 || res.status === 410 ? "P0002" : res.status === 401 || res.status === 403 ? "42501" : transient(res.status) ? undefined : "22023";
202
+ throw new ChatError(text.slice(0, 200) || `The video didn't upload (${res.status})`, code, "upload");
203
+ }
204
+
205
+ // ── Uploads in flight ─────────────────────────────────────────────────────────
206
+
207
+ /** A recording as the recorder hands it over. */
208
+ export interface RecordedVideo {
209
+ uri: string;
210
+ name: string;
211
+ contentType: string;
212
+ durationMs: number;
213
+ }
214
+
215
+ export type VideoUploadStatus = "uploading" | "failed";
216
+
217
+ /** Why a video didn't go: the tray words it (`copy`); `detail` is the error, for logs. */
218
+ export type VideoUploadError = "offline" | "forbidden" | "failed";
219
+
220
+ /** One video on its way, shown in the thread above the composer until its message is queued. */
221
+ export interface VideoUploadTask {
222
+ id: string;
223
+ conversationId: string;
224
+ /** Whose it is: the tray shows only the signed-in person's. */
225
+ userId: string | null;
226
+ video: RecordedVideo;
227
+ status: VideoUploadStatus;
228
+ /** 0 to 1. */
229
+ progress: number;
230
+ error: VideoUploadError | null;
231
+ detail: string | null;
232
+ }
233
+
234
+ /** The outbox's send (`useChat().outbox.send`): the message goes in the conversation's order. */
235
+ export type OutboxSend = (input: Omit<SendInput, "clientId"> & { clientId?: string }, files?: LocalFile[]) => Promise<string>;
236
+
237
+ export type MintedUpload = { url: string; providerId: string; provider: "mux" };
238
+
239
+ export interface StartVideoInput {
240
+ conversationId: string;
241
+ userId?: string | null;
242
+ video: RecordedVideo;
243
+ /** What goes with it: a reply, a thread, a topic. */
244
+ extra?: Omit<SendInput, "conversationId" | "clientId" | "attachments" | "body">;
245
+ send: OutboxSend;
246
+ videoUpload?: ChatVideoUpload;
247
+ /** The upload minted when recording started. */
248
+ prepared?: Promise<MintedUpload> | null;
249
+ openFile?: (uri: string) => Promise<ChunkFileLike> | ChunkFileLike;
250
+ fetch?: typeof fetch;
251
+ chunkBytes?: number;
252
+ sleep?: (ms: number) => Promise<void>;
253
+ }
254
+
255
+ /** The video as a file the outbox uploads to storage (without `videoUpload`). */
256
+ export function recordedVideoFile(video: RecordedVideo): LocalFile {
257
+ return { data: video.uri, name: video.name, contentType: video.contentType, previewUrl: video.uri, meta: { kind: "video", durationMs: video.durationMs } };
258
+ }
259
+
260
+ interface Internal {
261
+ task: VideoUploadTask;
262
+ input: StartVideoInput;
263
+ upload: MintedUpload | null;
264
+ bytes: number | null;
265
+ /** Bytes went to `upload`: a Retry asks the server where it is first. */
266
+ started: boolean;
267
+ /** Every byte is there: a Retry only sends the message. */
268
+ uploaded: boolean;
269
+ cancelled: boolean;
270
+ }
271
+
272
+ function errorKind(e: unknown): VideoUploadError {
273
+ if (e instanceof ChatError) return e.forbidden ? "forbidden" : e.transient ? "offline" : "failed";
274
+ return e instanceof TypeError ? "offline" : "failed";
275
+ }
276
+
277
+ export function createVideoUploads(newId: () => string = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`) {
278
+ const tasks = new Map<string, Internal>();
279
+ const listeners = new Set<() => void>();
280
+ let snapshot: VideoUploadTask[] = [];
281
+ const emit = () => {
282
+ snapshot = [...tasks.values()].map((t) => t.task);
283
+ listeners.forEach((l) => l());
284
+ };
285
+ const update = (t: Internal, patch: Partial<VideoUploadTask>) => {
286
+ if (!tasks.has(t.task.id)) return;
287
+ t.task = { ...t.task, ...patch };
288
+ emit();
289
+ };
290
+ const finish = (t: Internal) => {
291
+ tasks.delete(t.task.id);
292
+ emit();
293
+ };
294
+
295
+ async function upload(t: Internal, videoUpload: ChatVideoUpload) {
296
+ const { input } = t;
297
+ if (!t.upload) {
298
+ const prepared = input.prepared;
299
+ // A minted upload is used once, whatever happens to it.
300
+ t.input = { ...input, prepared: null };
301
+ t.upload = await (prepared ?? videoUpload.create(input.conversationId));
302
+ }
303
+ if (t.cancelled) return;
304
+ const source = await fileSource(input.video.uri, input.openFile, input.fetch);
305
+ try {
306
+ t.bytes = source.size;
307
+ const resume = t.started;
308
+ t.started = true;
309
+ await chunkedPut({
310
+ url: t.upload.url,
311
+ contentType: input.video.contentType,
312
+ source,
313
+ fetch: input.fetch,
314
+ chunkBytes: input.chunkBytes,
315
+ sleep: input.sleep,
316
+ resume,
317
+ isCancelled: () => t.cancelled,
318
+ onProgress: (sent, total) => update(t, { progress: total ? sent / total : 0 }),
319
+ });
320
+ } finally {
321
+ source.close();
322
+ }
323
+ t.uploaded = true;
324
+ }
325
+
326
+ async function run(t: Internal) {
327
+ const { input } = t;
328
+ const { video } = input;
329
+ if (!input.videoUpload) {
330
+ // To storage, through the outbox (its pending bubble shows the upload).
331
+ await input.send({ ...input.extra, conversationId: input.conversationId, body: "" }, [recordedVideoFile(video)]);
332
+ return finish(t);
333
+ }
334
+ if (!t.uploaded) {
335
+ try {
336
+ await upload(t, input.videoUpload);
337
+ } catch (e) {
338
+ // A minted upload that expired before it was used: mint a fresh one, once, without asking.
339
+ if (!(e instanceof ChatError && e.code === "P0002") || t.cancelled) throw e;
340
+ t.upload = null;
341
+ t.started = false;
342
+ await upload(t, input.videoUpload);
343
+ }
344
+ }
345
+ if (t.cancelled || !t.upload) return;
346
+ const attachment: AttachmentInput = {
347
+ kind: "video",
348
+ provider: t.upload.provider,
349
+ providerId: t.upload.providerId,
350
+ status: "pending",
351
+ name: video.name,
352
+ mime: video.contentType,
353
+ bytes: t.bytes ?? undefined,
354
+ durationMs: Math.round(video.durationMs),
355
+ };
356
+ // Queued in the outbox, it shows in the thread as a pending video; then the tray lets it go.
357
+ await input.send({ ...input.extra, conversationId: input.conversationId, body: "", attachments: [attachment] });
358
+ finish(t);
359
+ }
360
+
361
+ function go(t: Internal) {
362
+ update(t, { status: "uploading", error: null, detail: null });
363
+ run(t).catch((e: unknown) => {
364
+ if (t.cancelled) return;
365
+ // An upload URL that is gone: Retry mints a new one and starts over.
366
+ if (e instanceof ChatError && e.code === "P0002") {
367
+ t.upload = null;
368
+ t.started = false;
369
+ }
370
+ update(t, { status: "failed", error: errorKind(e), detail: e instanceof Error ? e.message : String(e) });
371
+ });
372
+ }
373
+
374
+ return {
375
+ /** Starts sending a recording. Returns the task's id. */
376
+ start(input: StartVideoInput): string {
377
+ const id = newId();
378
+ const t: Internal = {
379
+ task: { id, conversationId: input.conversationId, userId: input.userId ?? null, video: input.video, status: "uploading", progress: 0, error: null, detail: null },
380
+ input,
381
+ upload: null,
382
+ bytes: null,
383
+ started: false,
384
+ uploaded: false,
385
+ cancelled: false,
386
+ };
387
+ tasks.set(id, t);
388
+ emit();
389
+ go(t);
390
+ return id;
391
+ },
392
+ retry(id: string) {
393
+ const t = tasks.get(id);
394
+ if (t && t.task.status === "failed") go(t);
395
+ },
396
+ /** Stops it and forgets it (an upload Mux never receives expires on its own). */
397
+ cancel(id: string) {
398
+ const t = tasks.get(id);
399
+ if (!t) return;
400
+ t.cancelled = true;
401
+ finish(t);
402
+ },
403
+ /** Stops and forgets every one (call it on sign-out, beside `clearOutbox`). */
404
+ cancelAll() {
405
+ for (const t of tasks.values()) t.cancelled = true;
406
+ tasks.clear();
407
+ emit();
408
+ },
409
+ list(conversationId?: string): VideoUploadTask[] {
410
+ return conversationId ? snapshot.filter((t) => t.conversationId === conversationId) : snapshot;
411
+ },
412
+ subscribe(listener: () => void) {
413
+ listeners.add(listener);
414
+ return () => void listeners.delete(listener);
415
+ },
416
+ snapshot: () => snapshot,
417
+ };
418
+ }
419
+
420
+ export type VideoUploads = ReturnType<typeof createVideoUploads>;
421
+
422
+ /** The app's uploads in flight (one for the app; the thread screen reads it). */
423
+ export const videoUploads: VideoUploads = createVideoUploads();
424
+
425
+ const EMPTY: VideoUploadTask[] = [];
426
+
427
+ /** The signed-in person's videos on their way to this conversation, live. */
428
+ export function useVideoUploads(conversationId: string, userId: string | null = null, store: VideoUploads = videoUploads): VideoUploadTask[] {
429
+ const all = useSyncExternalStore(store.subscribe, store.snapshot, () => EMPTY);
430
+ const mine = (t: VideoUploadTask) => t.conversationId === conversationId && (userId === null || t.userId === null || t.userId === userId);
431
+ return all.some(mine) ? all.filter(mine) : EMPTY;
432
+ }