@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,635 @@
1
+ /**
2
+ * @feat:chat
3
+ * The send queue: a message shows at once as pending, its files upload, then it
4
+ * is sent with the clientId made when it was written, so a retry (after a lost
5
+ * connection, a closed app) never posts twice. Messages to one conversation go
6
+ * in the order they were written: the first that can't go (a lost file, a refusal)
7
+ * holds back the ones after it. A refusal (not allowed, bad data) is `permanent`
8
+ * and only sent again by hand. `clear()` (sign-out) stops sends in flight: none
9
+ * posts, or saves anything, afterwards. Storage is the platform's (memory by default;
10
+ * localStorage on web, MMKV or SQLite on native), behind OutboxStore.
11
+ *
12
+ * A hosted video (`LocalFile.hosted`, with `hostedUpload`) doesn't wait for its bytes: the
13
+ * upload is started (the provider's upload id minted), the message is sent in its place in the
14
+ * order with the attachment `pending`, and the bytes carry on going up; the provider's webhook
15
+ * marks it ready. The message stays here (hidden from the thread once the server has it) until
16
+ * its bytes are up, so a relaunch carries the upload on (`resume`) and sends the message again
17
+ * with the same clientId, which posts nothing twice.
18
+ */
19
+ import type { ChatClient } from "./client";
20
+ import { ChatError } from "./client";
21
+ import { attachmentKindFor, randomId } from "./media";
22
+ import type { UploadProgress } from "./transfer";
23
+ import type { AttachmentInput, ChatMessage, HostedAttachment, PendingFile, PendingMessage, SendInput } from "./types";
24
+
25
+ /** A file still on the device, uploaded when its message is sent. */
26
+ export interface LocalFile {
27
+ /** The bytes, or (native) a file URI the platform reads in `readFile`. */
28
+ data: Blob | ArrayBuffer | string;
29
+ name: string;
30
+ contentType: string;
31
+ /** A preview to show while it uploads (an object URL, a file URI). The outbox owns it from here: see `releasePreview`. */
32
+ previewUrl?: string;
33
+ /** Known up front: a voice note's duration and waveform, a photo's size. */
34
+ meta?: Partial<Omit<AttachmentInput, "path" | "kind">> & { kind?: AttachmentInput["kind"] };
35
+ /**
36
+ * Send it to the hosted video provider (the outbox's `hostedUpload`) instead of storage: the
37
+ * message goes at once, in order, with the attachment pending, while the bytes go up. Without
38
+ * a `hostedUpload` it goes to storage like any file.
39
+ */
40
+ hosted?: boolean;
41
+ }
42
+
43
+ /** A hosted upload as the outbox keeps it (saved with the message), so it can be carried on. */
44
+ export interface HostedUploadRecord {
45
+ provider: HostedAttachment["provider"];
46
+ providerId: string;
47
+ /** Where the bytes go, when the hook returned it: what a resume needs. */
48
+ url?: string;
49
+ /** Every byte is there. */
50
+ uploaded?: boolean;
51
+ }
52
+
53
+ /** What `hostedUpload` resolves with as soon as the upload exists (before the bytes are up). */
54
+ export interface HostedUploadStart extends HostedAttachment {
55
+ /** Where the bytes go; kept with the message, handed back as `resume.url`. */
56
+ url?: string;
57
+ /** Settles when every byte is there (rejects when the transfer fails). Absent: already there. */
58
+ done?: Promise<void>;
59
+ }
60
+
61
+ export interface HostedUploadOptions {
62
+ /** Aborts when the message is discarded or the outbox cleared. */
63
+ signal?: AbortSignal;
64
+ onProgress?: UploadProgress;
65
+ /**
66
+ * The upload started before (a relaunch, a retry after the transfer failed): carry it on and
67
+ * resolve with the same `providerId`. A message may already be posted with it, so mint a new
68
+ * one only when the old upload is gone; that message's video then stays pending.
69
+ */
70
+ resume?: HostedUploadRecord;
71
+ }
72
+
73
+ /** Starts (or carries on) a hosted upload; resolves once the provider's upload id exists. */
74
+ export type HostedUpload = (conversationId: string, file: LocalFile, opts: HostedUploadOptions) => Promise<HostedUploadStart>;
75
+
76
+ /** Uploads a file to storage and resolves with its path. */
77
+ export type StorageUpload = (conversationId: string, file: LocalFile, opts?: { signal?: AbortSignal; onProgress?: UploadProgress }) => Promise<string>;
78
+
79
+ export interface QueuedMessage extends Omit<PendingMessage, "files"> {
80
+ files: LocalFile[];
81
+ /** What each file is, kept with the saved copy (PendingMessage.files). */
82
+ pendingFiles?: PendingFile[];
83
+ /** Files lost on a relaunch (bytes that weren't persisted). */
84
+ lostFiles?: number;
85
+ attempts: number;
86
+ /** Hosted uploads started, by file slot. */
87
+ hosted?: Record<number, HostedUploadRecord>;
88
+ /** Posted (this message's id): it waits here only for its hosted bytes to finish. */
89
+ sentMessageId?: string;
90
+ }
91
+
92
+ function describe(f: LocalFile): PendingFile {
93
+ return {
94
+ name: f.name,
95
+ contentType: f.contentType,
96
+ kind: f.meta?.kind ?? attachmentKindFor(f.contentType),
97
+ meta: f.meta,
98
+ ...(f.hosted ? { hosted: true } : {}),
99
+ };
100
+ }
101
+
102
+ export interface OutboxStore {
103
+ load(): Promise<QueuedMessage[]>;
104
+ save(queue: QueuedMessage[]): Promise<void>;
105
+ }
106
+
107
+ export const memoryOutboxStore = (): OutboxStore => {
108
+ let saved: QueuedMessage[] = [];
109
+ return { load: async () => saved, save: async (q) => void (saved = q) };
110
+ };
111
+
112
+ export interface OutboxOptions {
113
+ client: ChatClient;
114
+ userId: string;
115
+ store?: OutboxStore;
116
+ /** Reads a native file URI into bytes for upload. */
117
+ readFile?: (uri: string) => Promise<Blob | ArrayBuffer>;
118
+ /**
119
+ * Upload hook, e.g. resumable uploads on native. Defaults to client.upload. `signal` aborts
120
+ * when the message is discarded or the outbox cleared; call `onProgress(sent, total)` as bytes
121
+ * go, and the pending bubble shows it (`PendingMessage.files[i].progress`).
122
+ */
123
+ upload?: StorageUpload;
124
+ /**
125
+ * Starts a hosted video upload for a file marked `hosted` and resolves once the provider's
126
+ * upload id exists, with `done` for the bytes. See HostedUpload; without it, hosted files go
127
+ * to storage.
128
+ */
129
+ hostedUpload?: HostedUpload;
130
+ /** Called with every change, for the UI. */
131
+ onChange?: (queue: QueuedMessage[]) => void;
132
+ /** Called when a message lands. */
133
+ onSent?: (message: ChatMessage, clientId: string) => void;
134
+ /** Makes client ids. Default: a random UUID (works where crypto.randomUUID is missing). */
135
+ newId?: () => string;
136
+ /**
137
+ * Keeps a file held as bytes (a pasted image, a recording) between launches: write it
138
+ * somewhere and return a URI `readFile` can read (native: the app's cache directory).
139
+ * Without it, such a file isn't saved, and after a relaunch its message asks for it again.
140
+ */
141
+ persistFile?: (file: LocalFile) => Promise<string>;
142
+ /**
143
+ * Lets go of a file's `previewUrl` once its message has landed, been discarded or cleared.
144
+ * Default: revokes `blob:` object URLs (the web's), and leaves everything else alone.
145
+ */
146
+ releasePreview?: (url: string) => void;
147
+ }
148
+
149
+ /** Revokes a `blob:` object URL; anything else (a native file URI) is left alone. */
150
+ export function releaseObjectUrl(url: string) {
151
+ if (!url.startsWith("blob:") || typeof URL === "undefined" || typeof URL.revokeObjectURL !== "function") return;
152
+ try {
153
+ URL.revokeObjectURL(url);
154
+ } catch {
155
+ // Already gone, or a runtime without object URLs.
156
+ }
157
+ }
158
+
159
+
160
+ export function createOutbox(opts: OutboxOptions) {
161
+ const store = opts.store ?? memoryOutboxStore();
162
+ let queue: QueuedMessage[] = [];
163
+ // Bumped by clear(): work begun under an older generation stops at its next await.
164
+ let generation = 0;
165
+ const running = new Map<string, number>(); // conversationId -> the generation running it
166
+ const aborts = new Map<string, AbortController>(); // clientId -> its uploads' controller
167
+ const uploaded = new Map<string, string>(); // clientId:index -> path
168
+ // Hosted bytes still going up, by clientId:index, and the controller that stops them.
169
+ const transfers = new Map<string, Promise<void>>();
170
+ const hostedAborts = new Map<string, AbortController>();
171
+
172
+ const release = opts.releasePreview ?? releaseObjectUrl;
173
+ const releasePreviews = (q: QueuedMessage) => {
174
+ for (const url of new Set([...Object.values(q.previews ?? {}), ...q.files.map((f) => f.previewUrl)])) {
175
+ if (typeof url === "string" && url) release(url);
176
+ }
177
+ };
178
+
179
+ const newId = opts.newId ?? randomId;
180
+
181
+ // Saved copies: a file held as bytes is kept only as a URI (persistFile) or marked lost.
182
+ // `files` holds the files still here, in slot order; `pendingFiles` holds every slot.
183
+ const saveable = (q: QueuedMessage): QueuedMessage => {
184
+ const slots = q.pendingFiles?.length ? q.pendingFiles : q.files.map(describe);
185
+ const here = [...q.files];
186
+ const files: LocalFile[] = [];
187
+ const pendingFiles = slots.map((slot) => {
188
+ if (slot.lost) return slot;
189
+ const f = here.shift();
190
+ if (f && typeof f.data === "string") {
191
+ files.push(f);
192
+ return { ...slot, lost: false };
193
+ }
194
+ return { ...slot, lost: true };
195
+ });
196
+ return { ...q, files, pendingFiles };
197
+ };
198
+
199
+ const emit = () => {
200
+ opts.onChange?.([...queue]);
201
+ void store.save(queue.map(saveable));
202
+ };
203
+
204
+ /** A file's upload progress, for the UI only (not saved). */
205
+ const setProgress = (q: QueuedMessage, index: number, sent: number, total: number) => {
206
+ const slots = q.pendingFiles;
207
+ if (!slots?.[index] || !queue.includes(q)) return;
208
+ // Whole percents: the bar shows no finer, and each change re-renders every chat hook.
209
+ const progress = total > 0 ? Math.round(Math.min(1, Math.max(0, sent / total)) * 100) / 100 : 0;
210
+ if (slots[index]!.progress === progress) return;
211
+ q.pendingFiles = slots.map((slot, j) => (j === index ? { ...slot, progress } : slot));
212
+ opts.onChange?.([...queue]);
213
+ };
214
+
215
+ const stopHosted = (clientId: string) => {
216
+ hostedAborts.get(clientId)?.abort();
217
+ hostedAborts.delete(clientId);
218
+ for (const k of [...transfers.keys()]) if (k.startsWith(`${clientId}:`)) transfers.delete(k);
219
+ };
220
+
221
+ /** Swaps a byte file for a saved URI, so it survives a relaunch. */
222
+ async function persist(q: QueuedMessage) {
223
+ if (!opts.persistFile) return;
224
+ for (let i = 0; i < q.files.length; i++) {
225
+ const f = q.files[i]!;
226
+ if (typeof f.data === "string") continue;
227
+ try {
228
+ q.files[i] = { ...f, data: await opts.persistFile(f) };
229
+ } catch {
230
+ // Kept in memory only; a relaunch asks for it again.
231
+ }
232
+ }
233
+ }
234
+
235
+ /**
236
+ * One load for the outbox's life, which every method awaits. What was saved is merged into
237
+ * the live queue (a send made while the load was in flight stays), deduped by clientId.
238
+ */
239
+ let loading: Promise<void> | null = null;
240
+ function ensureLoaded(): Promise<void> {
241
+ loading ??= (async () => {
242
+ const gen = generation;
243
+ let saved: QueuedMessage[] = [];
244
+ try {
245
+ saved = (await store.load()) ?? [];
246
+ } catch {
247
+ saved = [];
248
+ }
249
+ // Cleared (signed out) while it loaded: what was saved belonged to that session.
250
+ if (gen !== generation) return;
251
+ // A file held as bytes that wasn't persisted doesn't survive a relaunch: its message waits,
252
+ // failed, for the file to be attached again (never sent without it) or discarded.
253
+ const live = new Set(queue.map((q) => q.clientId));
254
+ const restored: QueuedMessage[] = saved
255
+ .filter((q) => !live.has(q.clientId))
256
+ .map((q) => {
257
+ const lost = (q.pendingFiles ?? []).filter((f) => f.lost).length;
258
+ // Posted, its hosted bytes still going up when the app closed: it is sent again (the
259
+ // same clientId posts nothing twice) after its upload is carried on.
260
+ const rest: QueuedMessage = { ...q };
261
+ delete rest.sentMessageId;
262
+ return {
263
+ ...rest,
264
+ status: q.status === "sending" || lost > 0 ? "failed" : q.status,
265
+ error: lost > 0 ? "Attach the file again to send" : (q.error ?? "Not sent"),
266
+ files: q.files ?? [],
267
+ attempts: q.attempts ?? 0,
268
+ lostFiles: lost,
269
+ };
270
+ });
271
+ if (restored.length) {
272
+ queue = [...restored, ...queue];
273
+ opts.onChange?.([...queue]);
274
+ }
275
+ })();
276
+ return loading;
277
+ }
278
+
279
+ async function uploadFile(conversationId: string, file: LocalFile, signal?: AbortSignal, onProgress?: UploadProgress): Promise<string> {
280
+ if (opts.upload) return opts.upload(conversationId, file, { signal, onProgress });
281
+ const data = typeof file.data === "string" ? await (opts.readFile ?? notReadable)(file.data) : file.data;
282
+ return opts.client.upload(conversationId, opts.userId, data, { name: file.name, contentType: file.contentType, onProgress, signal });
283
+ }
284
+
285
+ const isHosted = (f: LocalFile) => Boolean(f.hosted && opts.hostedUpload);
286
+
287
+ /**
288
+ * The hosted upload for one file slot: the one going up, or a new (or carried-on) one. Resolves
289
+ * once the provider's upload id exists; its bytes go on in `transfers`.
290
+ */
291
+ async function startHosted(q: QueuedMessage, index: number, file: LocalFile): Promise<HostedUploadRecord> {
292
+ const key = `${q.clientId}:${index}`;
293
+ const known = q.hosted?.[index];
294
+ if (known && (known.uploaded || transfers.has(key))) return known;
295
+ let controller = hostedAborts.get(q.clientId);
296
+ if (!controller && typeof AbortController !== "undefined") {
297
+ controller = new AbortController();
298
+ hostedAborts.set(q.clientId, controller);
299
+ }
300
+ const started = await opts.hostedUpload!(q.input.conversationId, file, {
301
+ signal: controller?.signal,
302
+ onProgress: (sent, total) => setProgress(q, index, sent, total),
303
+ ...(known ? { resume: known } : {}),
304
+ });
305
+ const record: HostedUploadRecord = { provider: started.provider, providerId: started.providerId, ...(started.url ? { url: started.url } : {}) };
306
+ q.hosted = { ...q.hosted, [index]: record };
307
+ // Saved at once: a relaunch before the message goes carries this upload on, not a new one.
308
+ if (queue.includes(q)) emit();
309
+ const transfer = Promise.resolve(started.done).then(
310
+ () => {
311
+ record.uploaded = true;
312
+ if (transfers.get(key) === transfer) transfers.delete(key);
313
+ setProgress(q, index, 1, 1);
314
+ },
315
+ (e: unknown) => {
316
+ if (transfers.get(key) === transfer) transfers.delete(key);
317
+ throw e;
318
+ },
319
+ );
320
+ transfer.catch(() => undefined);
321
+ transfers.set(key, transfer);
322
+ return record;
323
+ }
324
+
325
+ /** A posted message's hosted bytes: once they are all up, it leaves; a failed transfer marks it failed (Retry carries it on). */
326
+ async function settleHosted(q: QueuedMessage, gen: number) {
327
+ const waiting = Object.entries(q.hosted ?? {})
328
+ .filter(([, r]) => !r.uploaded)
329
+ .map(([i]) => transfers.get(`${q.clientId}:${i}`) ?? Promise.reject(new ChatError("The upload stopped", undefined, "upload")));
330
+ try {
331
+ await Promise.all(waiting);
332
+ } catch (e) {
333
+ if (gen !== generation || !queue.includes(q) || q.status !== "sending") return;
334
+ q.attempts += 1;
335
+ q.status = "failed";
336
+ q.error = e instanceof ChatError && e.code === "GSABORT" ? "The upload was cancelled" : "The video didn't finish uploading";
337
+ emit();
338
+ return;
339
+ }
340
+ if (gen !== generation || !queue.includes(q)) return;
341
+ // A retry may have started more meanwhile: wait for those too.
342
+ if (Object.values(q.hosted ?? {}).some((r) => !r.uploaded)) return void settleHosted(q, gen);
343
+ queue = queue.filter((x) => x !== q);
344
+ hostedAborts.delete(q.clientId);
345
+ emit();
346
+ releasePreviews(q);
347
+ }
348
+
349
+ async function run(conversationId: string) {
350
+ const gen = generation;
351
+ if (running.get(conversationId) === gen) return;
352
+ running.set(conversationId, gen);
353
+ // Cleared (signed out) or discarded while we waited: stop, sending and saving nothing.
354
+ const stale = (q: QueuedMessage) => gen !== generation || !queue.includes(q);
355
+ try {
356
+ for (;;) {
357
+ if (gen !== generation) return;
358
+ // In the order written: the first message still here decides. One that failed (a lost
359
+ // file, a refusal, no connection) holds back the ones after it until it is retried.
360
+ // A posted message waiting only for its hosted bytes holds nothing back.
361
+ const next = queue.find((q) => q.input.conversationId === conversationId && !q.sentMessageId);
362
+ if (!next || next.status !== "sending") break;
363
+ if ((next.lostFiles ?? 0) > 0) {
364
+ next.status = "failed";
365
+ next.error = "Attach the file again to send";
366
+ emit();
367
+ break;
368
+ }
369
+ const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
370
+ if (controller) aborts.set(next.clientId, controller);
371
+ try {
372
+ const attachments: AttachmentInput[] = [...(next.input.attachments ?? [])];
373
+ for (let i = 0; i < next.files.length; i++) {
374
+ const file = next.files[i]!;
375
+ if (isHosted(file)) {
376
+ const hosted = await startHosted(next, i, file);
377
+ if (stale(next)) return;
378
+ attachments.push({
379
+ ...file.meta,
380
+ kind: file.meta?.kind ?? attachmentKindFor(file.contentType),
381
+ provider: hosted.provider,
382
+ providerId: hosted.providerId,
383
+ status: "pending",
384
+ name: file.meta?.name ?? file.name,
385
+ mime: file.contentType,
386
+ });
387
+ continue;
388
+ }
389
+ const key = `${next.clientId}:${i}`;
390
+ let path = uploaded.get(key);
391
+ if (!path) {
392
+ const index = i;
393
+ path = await uploadFile(conversationId, file, controller?.signal, (sent, total) => setProgress(next, index, sent, total));
394
+ if (stale(next)) return;
395
+ uploaded.set(key, path);
396
+ }
397
+ attachments.push({
398
+ ...file.meta,
399
+ kind: file.meta?.kind ?? attachmentKindFor(file.contentType),
400
+ path,
401
+ name: file.meta?.name ?? file.name,
402
+ mime: file.contentType,
403
+ });
404
+ }
405
+ if (stale(next)) return;
406
+ const message = await opts.client.send({ ...next.input, attachments });
407
+ // Posted, but the person signed out meanwhile: the next session must not hear of it.
408
+ if (gen !== generation) return;
409
+ for (let i = 0; i < next.files.length; i++) uploaded.delete(`${next.clientId}:${i}`);
410
+ if (Object.values(next.hosted ?? {}).some((r) => !r.uploaded)) {
411
+ // Posted with its video pending: it stays (out of the thread, which has the server's
412
+ // copy) until the bytes are up, so a relaunch can carry the upload on.
413
+ next.sentMessageId = message.id;
414
+ emit();
415
+ opts.onSent?.(message, next.clientId);
416
+ void settleHosted(next, gen);
417
+ continue;
418
+ }
419
+ queue = queue.filter((q) => q.clientId !== next.clientId);
420
+ hostedAborts.delete(next.clientId);
421
+ emit();
422
+ opts.onSent?.(message, next.clientId);
423
+ // The bubble now draws the stored copy: the local preview can go.
424
+ releasePreviews(next);
425
+ } catch (e) {
426
+ if (stale(next)) return;
427
+ const err = e instanceof ChatError ? e : new ChatError(e instanceof Error ? e.message : String(e), undefined, "send");
428
+ next.attempts += 1;
429
+ next.status = "failed";
430
+ next.permanent = err.permanent;
431
+ next.error = err.forbidden ? "You can't post here" : err.transient ? "Not sent. Check your connection." : err.message;
432
+ emit();
433
+ // Later messages to this conversation wait behind a failed one, so order holds.
434
+ break;
435
+ } finally {
436
+ if (controller && aborts.get(next.clientId) === controller) aborts.delete(next.clientId);
437
+ }
438
+ }
439
+ } finally {
440
+ if (running.get(conversationId) === gen) running.delete(conversationId);
441
+ }
442
+ }
443
+
444
+ /** Can this failed message go without the person's help (retryAll, coming back online)? */
445
+ const canAutoRetry = (x: QueuedMessage) =>
446
+ !x.permanent &&
447
+ !(x.lostFiles ?? 0) &&
448
+ x.files.every((f) => typeof f.data !== "string" || Boolean(isHosted(f) ? opts.hostedUpload : opts.readFile || opts.upload));
449
+
450
+ /** A failed message goes again: a posted one (its transfer failed) re-sends, idempotently, after carrying its upload on. */
451
+ const requeue = (x: QueuedMessage) => {
452
+ x.status = "sending";
453
+ delete x.sentMessageId;
454
+ };
455
+
456
+ return {
457
+ /** Queues a message; it shows at once and sends in order. Returns its clientId. */
458
+ async send(input: Omit<SendInput, "clientId"> & { clientId?: string }, files: LocalFile[] = []): Promise<string> {
459
+ await ensureLoaded();
460
+ const gen = generation;
461
+ const clientId = input.clientId ?? newId();
462
+ const previews: Record<number, string> = {};
463
+ files.forEach((f, i) => {
464
+ if (f.previewUrl) previews[i] = f.previewUrl;
465
+ });
466
+ const queued: QueuedMessage = {
467
+ clientId,
468
+ input: { ...input, clientId },
469
+ senderId: opts.userId,
470
+ createdAt: new Date().toISOString(),
471
+ status: "sending",
472
+ previews,
473
+ files: [...files],
474
+ pendingFiles: files.map(describe),
475
+ attempts: 0,
476
+ };
477
+ queue.push(queued);
478
+ opts.onChange?.([...queue]);
479
+ await persist(queued);
480
+ // Cleared or discarded while its files were saved: it goes nowhere.
481
+ if (gen !== generation || !queue.includes(queued)) return clientId;
482
+ emit();
483
+ void run(input.conversationId);
484
+ return clientId;
485
+ },
486
+
487
+ async retry(clientId: string) {
488
+ await ensureLoaded();
489
+ const q = queue.find((x) => x.clientId === clientId);
490
+ if (!q) return;
491
+ // Retrying one retries everything failed in that conversation, in order, refusals too
492
+ // (a person asked), up to one still waiting for a file.
493
+ for (const x of queue) {
494
+ if (x.input.conversationId !== q.input.conversationId || x.status !== "failed") continue;
495
+ if ((x.lostFiles ?? 0) > 0) break;
496
+ requeue(x);
497
+ x.permanent = false;
498
+ }
499
+ emit();
500
+ void run(q.input.conversationId);
501
+ },
502
+
503
+ /**
504
+ * Gives a message the files it lost on a relaunch and sends it. `files` fill the lost
505
+ * slots in order (`PendingMessage.files[i].lost`); the files that were kept stay. Pass
506
+ * one per lost slot; with fewer, the message keeps waiting for the rest.
507
+ */
508
+ async reattach(clientId: string, files: LocalFile[]) {
509
+ await ensureLoaded();
510
+ const q = queue.find((x) => x.clientId === clientId);
511
+ if (!q) return;
512
+ const slots = q.pendingFiles ?? [];
513
+ const kept = [...q.files];
514
+ const given = [...files];
515
+ const merged: LocalFile[] = [];
516
+ const described: PendingFile[] = [];
517
+ for (const slot of slots) {
518
+ if (slot.lost) {
519
+ const f = given.shift();
520
+ if (f) {
521
+ merged.push(f);
522
+ described.push(describe(f));
523
+ } else {
524
+ described.push(slot);
525
+ }
526
+ } else {
527
+ const f = kept.shift();
528
+ if (f) {
529
+ merged.push(f);
530
+ described.push(slot);
531
+ }
532
+ }
533
+ }
534
+ // A message saved before slots were recorded: the given files are all of them.
535
+ if (slots.length === 0) {
536
+ merged.push(...given);
537
+ described.push(...given.map(describe));
538
+ }
539
+ q.files = merged;
540
+ q.pendingFiles = described;
541
+ q.lostFiles = described.filter((d) => d.lost).length;
542
+ if (q.lostFiles > 0) {
543
+ emit();
544
+ return;
545
+ }
546
+ const gen = generation;
547
+ await persist(q);
548
+ if (gen !== generation || !queue.includes(q)) return;
549
+ requeue(q);
550
+ emit();
551
+ void run(q.input.conversationId);
552
+ },
553
+
554
+ /**
555
+ * Retries failed messages (after the connection comes back), in order per conversation,
556
+ * stopping at the first that can't go on its own: one waiting for a lost file, or one the
557
+ * server refused (`permanent`, retried only by hand). The ones after it keep waiting.
558
+ */
559
+ async retryAll() {
560
+ await ensureLoaded();
561
+ const conversations = new Set<string>();
562
+ const held = new Set<string>();
563
+ for (const x of queue) {
564
+ const c = x.input.conversationId;
565
+ if (held.has(c) || x.status !== "failed") continue;
566
+ if (!canAutoRetry(x)) {
567
+ // A posted one (only its upload failed) holds nothing back.
568
+ if (!x.sentMessageId) held.add(c);
569
+ continue;
570
+ }
571
+ requeue(x);
572
+ conversations.add(c);
573
+ }
574
+ emit();
575
+ for (const c of conversations) void run(c);
576
+ },
577
+
578
+ /** Forgets every queued message, here and in the store (call it on sign-out). */
579
+ async clear() {
580
+ // At once, before any await: a send in flight stops at its next step and posts nothing.
581
+ generation += 1;
582
+ for (const c of aborts.values()) c.abort();
583
+ aborts.clear();
584
+ for (const c of hostedAborts.values()) c.abort();
585
+ hostedAborts.clear();
586
+ transfers.clear();
587
+ running.clear();
588
+ const gone = queue;
589
+ queue = [];
590
+ uploaded.clear();
591
+ gone.forEach(releasePreviews);
592
+ opts.onChange?.([]);
593
+ await ensureLoaded();
594
+ await store.save([]);
595
+ },
596
+
597
+ async discard(clientId: string) {
598
+ await ensureLoaded();
599
+ const gone = queue.find((q) => q.clientId === clientId);
600
+ queue = queue.filter((q) => q.clientId !== clientId);
601
+ aborts.get(clientId)?.abort();
602
+ aborts.delete(clientId);
603
+ stopHosted(clientId);
604
+ if (gone) releasePreviews(gone);
605
+ emit();
606
+ },
607
+
608
+ /**
609
+ * Uploads one file to storage the way the outbox does (the `upload` hook, or `readFile` then
610
+ * the client's upload) and resolves with its path: for files sent outside the queue, such as
611
+ * a scheduled message's.
612
+ */
613
+ uploadFile(conversationId: string, file: LocalFile, o: { signal?: AbortSignal; onProgress?: UploadProgress } = {}): Promise<string> {
614
+ return uploadFile(conversationId, file, o.signal, o.onProgress);
615
+ },
616
+
617
+ pending(conversationId?: string): PendingMessage[] {
618
+ return queue
619
+ .filter((q) => !conversationId || q.input.conversationId === conversationId)
620
+ .map((q) => ({ ...q, files: q.pendingFiles }));
621
+ },
622
+
623
+ /** A new client id, made the way this outbox makes them. */
624
+ newId,
625
+
626
+ load: ensureLoaded,
627
+ };
628
+ }
629
+
630
+ export type Outbox = ReturnType<typeof createOutbox>;
631
+
632
+ async function notReadable(): Promise<never> {
633
+ // This device's set-up (no readFile), not a refusal: a relaunch that can read it sends it.
634
+ throw new ChatError("This file can't be read on this device", "GSFILE", "upload");
635
+ }