@marktiderman/genesis-chat 0.2.0 → 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 (77) hide show
  1. package/README.md +82 -7
  2. package/dist/{chunk-5D5A7I4Q.js → chunk-A4YJX7KT.js} +130 -16
  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-7S6HYNWW.js → chunk-GBJ3YI4E.js} +72 -5
  7. package/dist/chunk-GBJ3YI4E.js.map +1 -0
  8. package/dist/{chunk-PZTOY2LY.js → chunk-JU2FAPBI.js} +46 -13
  9. package/dist/chunk-JU2FAPBI.js.map +1 -0
  10. package/dist/{client-DMa2j7kz.d.ts → client-DUIG1hum.d.ts} +36 -1
  11. package/dist/index.d.ts +4 -4
  12. package/dist/index.js +2 -2
  13. package/dist/native.d.ts +36 -7
  14. package/dist/native.js +239 -116
  15. package/dist/native.js.map +1 -1
  16. package/dist/{outbox-B0y3phE0.d.ts → outbox-B9NAWQ58.d.ts} +74 -6
  17. package/dist/push.d.ts +2 -2
  18. package/dist/react.d.ts +48 -21
  19. package/dist/react.js +3 -3
  20. package/dist/server.d.ts +43 -2
  21. package/dist/server.js +115 -15
  22. package/dist/server.js.map +1 -1
  23. package/dist/{thread-DWXHKPdw.d.ts → thread-oPra7pkW.d.ts} +1 -1
  24. package/dist/web.d.ts +31 -11
  25. package/dist/web.js +97 -48
  26. package/dist/web.js.map +1 -1
  27. package/migrations/0003_genesis_chat_search_threads.sql +85 -0
  28. package/migrations/tests/0002_genesis_chat_coaching.test.sql +7 -3
  29. package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
  30. package/package.json +7 -3
  31. package/src/__tests__/coaching-core.test.ts +3 -1
  32. package/src/__tests__/coaching-react.test.tsx +73 -2
  33. package/src/__tests__/coaching-web.test.tsx +78 -6
  34. package/src/__tests__/fake-xhr.ts +54 -0
  35. package/src/__tests__/outbox-hosted.test.ts +269 -0
  36. package/src/__tests__/review-279.test.ts +41 -0
  37. package/src/__tests__/review-fixes-web.test.tsx +13 -10
  38. package/src/__tests__/server-fake-video.test.ts +81 -0
  39. package/src/__tests__/server-fakes.ts +7 -1
  40. package/src/__tests__/server-safety.test.ts +17 -4
  41. package/src/__tests__/web.test.tsx +16 -0
  42. package/src/core/client.ts +28 -2
  43. package/src/core/outbox.ts +222 -16
  44. package/src/core/transfer.ts +71 -0
  45. package/src/core/types.ts +17 -0
  46. package/src/index.ts +1 -0
  47. package/src/native/__tests__/emoji.test.tsx +16 -0
  48. package/src/native/__tests__/organise-screens.test.tsx +80 -0
  49. package/src/native/__tests__/video-media.test.tsx +81 -4
  50. package/src/native/attachment-cards.tsx +22 -6
  51. package/src/native/inbox-screen.tsx +12 -6
  52. package/src/native/index.ts +2 -2
  53. package/src/native/message-actions-sheet.tsx +10 -5
  54. package/src/native/message-bubble.tsx +3 -11
  55. package/src/native/modules.ts +2 -0
  56. package/src/native/schedule-sheet.tsx +23 -9
  57. package/src/native/search-screen.tsx +26 -5
  58. package/src/native/thread-screen.tsx +30 -3
  59. package/src/native/topics.tsx +70 -15
  60. package/src/native/video-player.tsx +46 -6
  61. package/src/native/video-recorder.tsx +6 -3
  62. package/src/native/voice-player.tsx +18 -0
  63. package/src/react/index.tsx +103 -19
  64. package/src/react/previews.ts +51 -0
  65. package/src/server/fake-video.ts +171 -0
  66. package/src/server/index.ts +1 -0
  67. package/src/web/attachments.tsx +6 -6
  68. package/src/web/bubble.tsx +22 -0
  69. package/src/web/inbox.tsx +7 -6
  70. package/src/web/memory-client.ts +11 -3
  71. package/src/web/panel.tsx +2 -2
  72. package/src/web/schedule.tsx +14 -2
  73. package/src/web/thread.tsx +36 -8
  74. package/src/web/video.tsx +28 -34
  75. package/dist/chunk-5D5A7I4Q.js.map +0 -1
  76. package/dist/chunk-7S6HYNWW.js.map +0 -1
  77. package/dist/chunk-PZTOY2LY.js.map +0 -1
@@ -8,11 +8,19 @@
8
8
  * and only sent again by hand. `clear()` (sign-out) stops sends in flight: none
9
9
  * posts, or saves anything, afterwards. Storage is the platform's (memory by default;
10
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.
11
18
  */
12
19
  import type { ChatClient } from "./client";
13
20
  import { ChatError } from "./client";
14
21
  import { attachmentKindFor, randomId } from "./media";
15
- import type { AttachmentInput, ChatMessage, PendingFile, PendingMessage, SendInput } from "./types";
22
+ import type { UploadProgress } from "./transfer";
23
+ import type { AttachmentInput, ChatMessage, HostedAttachment, PendingFile, PendingMessage, SendInput } from "./types";
16
24
 
17
25
  /** A file still on the device, uploaded when its message is sent. */
18
26
  export interface LocalFile {
@@ -24,8 +32,50 @@ export interface LocalFile {
24
32
  previewUrl?: string;
25
33
  /** Known up front: a voice note's duration and waveform, a photo's size. */
26
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>;
27
59
  }
28
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
+
29
79
  export interface QueuedMessage extends Omit<PendingMessage, "files"> {
30
80
  files: LocalFile[];
31
81
  /** What each file is, kept with the saved copy (PendingMessage.files). */
@@ -33,10 +83,20 @@ export interface QueuedMessage extends Omit<PendingMessage, "files"> {
33
83
  /** Files lost on a relaunch (bytes that weren't persisted). */
34
84
  lostFiles?: number;
35
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;
36
90
  }
37
91
 
38
92
  function describe(f: LocalFile): PendingFile {
39
- return { name: f.name, contentType: f.contentType, kind: f.meta?.kind ?? attachmentKindFor(f.contentType), meta: f.meta };
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
+ };
40
100
  }
41
101
 
42
102
  export interface OutboxStore {
@@ -55,8 +115,18 @@ export interface OutboxOptions {
55
115
  store?: OutboxStore;
56
116
  /** Reads a native file URI into bytes for upload. */
57
117
  readFile?: (uri: string) => Promise<Blob | ArrayBuffer>;
58
- /** Upload hook, e.g. resumable uploads on native. Defaults to client.upload. `signal` aborts when the message is discarded or the outbox cleared. */
59
- upload?: (conversationId: string, file: LocalFile, opts?: { signal?: AbortSignal }) => Promise<string>;
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;
60
130
  /** Called with every change, for the UI. */
61
131
  onChange?: (queue: QueuedMessage[]) => void;
62
132
  /** Called when a message lands. */
@@ -95,6 +165,9 @@ export function createOutbox(opts: OutboxOptions) {
95
165
  const running = new Map<string, number>(); // conversationId -> the generation running it
96
166
  const aborts = new Map<string, AbortController>(); // clientId -> its uploads' controller
97
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>();
98
171
 
99
172
  const release = opts.releasePreview ?? releaseObjectUrl;
100
173
  const releasePreviews = (q: QueuedMessage) => {
@@ -128,6 +201,23 @@ export function createOutbox(opts: OutboxOptions) {
128
201
  void store.save(queue.map(saveable));
129
202
  };
130
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
+
131
221
  /** Swaps a byte file for a saved URI, so it survives a relaunch. */
132
222
  async function persist(q: QueuedMessage) {
133
223
  if (!opts.persistFile) return;
@@ -165,8 +255,12 @@ export function createOutbox(opts: OutboxOptions) {
165
255
  .filter((q) => !live.has(q.clientId))
166
256
  .map((q) => {
167
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;
168
262
  return {
169
- ...q,
263
+ ...rest,
170
264
  status: q.status === "sending" || lost > 0 ? "failed" : q.status,
171
265
  error: lost > 0 ? "Attach the file again to send" : (q.error ?? "Not sent"),
172
266
  files: q.files ?? [],
@@ -182,10 +276,74 @@ export function createOutbox(opts: OutboxOptions) {
182
276
  return loading;
183
277
  }
184
278
 
185
- async function uploadFile(conversationId: string, file: LocalFile, signal?: AbortSignal): Promise<string> {
186
- if (opts.upload) return opts.upload(conversationId, file, { signal });
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 });
187
281
  const data = typeof file.data === "string" ? await (opts.readFile ?? notReadable)(file.data) : file.data;
188
- return opts.client.upload(conversationId, opts.userId, data, { name: file.name, contentType: file.contentType });
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);
189
347
  }
190
348
 
191
349
  async function run(conversationId: string) {
@@ -199,7 +357,8 @@ export function createOutbox(opts: OutboxOptions) {
199
357
  if (gen !== generation) return;
200
358
  // In the order written: the first message still here decides. One that failed (a lost
201
359
  // file, a refusal, no connection) holds back the ones after it until it is retried.
202
- const next = queue.find((q) => q.input.conversationId === conversationId);
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);
203
362
  if (!next || next.status !== "sending") break;
204
363
  if ((next.lostFiles ?? 0) > 0) {
205
364
  next.status = "failed";
@@ -213,10 +372,25 @@ export function createOutbox(opts: OutboxOptions) {
213
372
  const attachments: AttachmentInput[] = [...(next.input.attachments ?? [])];
214
373
  for (let i = 0; i < next.files.length; i++) {
215
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
+ }
216
389
  const key = `${next.clientId}:${i}`;
217
390
  let path = uploaded.get(key);
218
391
  if (!path) {
219
- path = await uploadFile(conversationId, file, controller?.signal);
392
+ const index = i;
393
+ path = await uploadFile(conversationId, file, controller?.signal, (sent, total) => setProgress(next, index, sent, total));
220
394
  if (stale(next)) return;
221
395
  uploaded.set(key, path);
222
396
  }
@@ -232,8 +406,18 @@ export function createOutbox(opts: OutboxOptions) {
232
406
  const message = await opts.client.send({ ...next.input, attachments });
233
407
  // Posted, but the person signed out meanwhile: the next session must not hear of it.
234
408
  if (gen !== generation) return;
235
- queue = queue.filter((q) => q.clientId !== next.clientId);
236
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);
237
421
  emit();
238
422
  opts.onSent?.(message, next.clientId);
239
423
  // The bubble now draws the stored copy: the local preview can go.
@@ -259,7 +443,15 @@ export function createOutbox(opts: OutboxOptions) {
259
443
 
260
444
  /** Can this failed message go without the person's help (retryAll, coming back online)? */
261
445
  const canAutoRetry = (x: QueuedMessage) =>
262
- !x.permanent && !(x.lostFiles ?? 0) && x.files.every((f) => typeof f.data !== "string" || Boolean(opts.readFile || opts.upload));
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
+ };
263
455
 
264
456
  return {
265
457
  /** Queues a message; it shows at once and sends in order. Returns its clientId. */
@@ -301,7 +493,7 @@ export function createOutbox(opts: OutboxOptions) {
301
493
  for (const x of queue) {
302
494
  if (x.input.conversationId !== q.input.conversationId || x.status !== "failed") continue;
303
495
  if ((x.lostFiles ?? 0) > 0) break;
304
- x.status = "sending";
496
+ requeue(x);
305
497
  x.permanent = false;
306
498
  }
307
499
  emit();
@@ -354,7 +546,7 @@ export function createOutbox(opts: OutboxOptions) {
354
546
  const gen = generation;
355
547
  await persist(q);
356
548
  if (gen !== generation || !queue.includes(q)) return;
357
- q.status = "sending";
549
+ requeue(q);
358
550
  emit();
359
551
  void run(q.input.conversationId);
360
552
  },
@@ -372,10 +564,11 @@ export function createOutbox(opts: OutboxOptions) {
372
564
  const c = x.input.conversationId;
373
565
  if (held.has(c) || x.status !== "failed") continue;
374
566
  if (!canAutoRetry(x)) {
375
- held.add(c);
567
+ // A posted one (only its upload failed) holds nothing back.
568
+ if (!x.sentMessageId) held.add(c);
376
569
  continue;
377
570
  }
378
- x.status = "sending";
571
+ requeue(x);
379
572
  conversations.add(c);
380
573
  }
381
574
  emit();
@@ -388,6 +581,9 @@ export function createOutbox(opts: OutboxOptions) {
388
581
  generation += 1;
389
582
  for (const c of aborts.values()) c.abort();
390
583
  aborts.clear();
584
+ for (const c of hostedAborts.values()) c.abort();
585
+ hostedAborts.clear();
586
+ transfers.clear();
391
587
  running.clear();
392
588
  const gone = queue;
393
589
  queue = [];
@@ -404,10 +600,20 @@ export function createOutbox(opts: OutboxOptions) {
404
600
  queue = queue.filter((q) => q.clientId !== clientId);
405
601
  aborts.get(clientId)?.abort();
406
602
  aborts.delete(clientId);
603
+ stopHosted(clientId);
407
604
  if (gone) releasePreviews(gone);
408
605
  emit();
409
606
  },
410
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
+
411
617
  pending(conversationId?: string): PendingMessage[] {
412
618
  return queue
413
619
  .filter((q) => !conversationId || q.input.conversationId === conversationId)
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @feat:chat
3
+ * Sending bytes with progress, the same on every platform. fetch can't report how much of a
4
+ * request body has gone, so where XMLHttpRequest exists (browsers, React Native) the body goes
5
+ * through it and `onProgress(sent, total)` follows `upload.onprogress`; elsewhere it goes
6
+ * through fetch and reports once, at the end.
7
+ */
8
+ import { ChatError } from "./client";
9
+
10
+ export type UploadProgress = (sent: number, total: number) => void;
11
+
12
+ export interface SendBytesOptions {
13
+ method?: "PUT" | "POST";
14
+ headers?: Record<string, string>;
15
+ signal?: AbortSignal;
16
+ onProgress?: UploadProgress;
17
+ /** For tests and runtimes without XMLHttpRequest. */
18
+ fetch?: typeof fetch;
19
+ }
20
+
21
+ const sizeOf = (body: Blob | ArrayBuffer) => (typeof Blob !== "undefined" && body instanceof Blob ? body.size : (body as ArrayBuffer).byteLength);
22
+
23
+ /** A failed answer's code: a 5xx, 408 or no answer is worth retrying, 429 waits, any other 4xx is a refusal. */
24
+ const refusal = (status: number) => (status >= 500 || status === 0 || status === 408 ? undefined : status === 429 ? "GS429" : "22023");
25
+
26
+ const aborted = () => new ChatError("The upload was cancelled", "GSABORT", "upload");
27
+
28
+ /** Sends `body` to `url`; resolves with the response text, throws a ChatError on a non-2xx answer. */
29
+ export function sendBytes(url: string, body: Blob | ArrayBuffer, opts: SendBytesOptions = {}): Promise<string> {
30
+ const method = opts.method ?? "PUT";
31
+ const total = sizeOf(body);
32
+ if (opts.signal?.aborted) return Promise.reject(aborted());
33
+ if (typeof XMLHttpRequest === "undefined" || opts.fetch) {
34
+ const f = opts.fetch ?? fetch;
35
+ return f(url, { method, body, headers: opts.headers, signal: opts.signal }).then(async (res) => {
36
+ if (!res.ok) throw new ChatError(`The upload failed (${res.status})`, refusal(res.status), "upload");
37
+ opts.onProgress?.(total, total);
38
+ return res.text().catch(() => "");
39
+ });
40
+ }
41
+ return new Promise<string>((resolve, reject) => {
42
+ const xhr = new XMLHttpRequest();
43
+ const onAbort = () => xhr.abort();
44
+ const done = () => opts.signal?.removeEventListener("abort", onAbort);
45
+ xhr.open(method, url);
46
+ for (const [k, v] of Object.entries(opts.headers ?? {})) xhr.setRequestHeader(k, v);
47
+ if (opts.onProgress && xhr.upload) {
48
+ xhr.upload.onprogress = (e: ProgressEvent) => opts.onProgress?.(e.loaded, e.lengthComputable ? e.total : total);
49
+ }
50
+ xhr.onload = () => {
51
+ done();
52
+ if (xhr.status >= 200 && xhr.status < 300) {
53
+ opts.onProgress?.(total, total);
54
+ resolve(typeof xhr.responseText === "string" ? xhr.responseText : "");
55
+ } else {
56
+ reject(new ChatError(`The upload failed (${xhr.status})`, refusal(xhr.status), "upload"));
57
+ }
58
+ };
59
+ // No answer at all: the connection, worth retrying.
60
+ xhr.onerror = () => {
61
+ done();
62
+ reject(new ChatError("The upload didn't reach the server", undefined, "upload"));
63
+ };
64
+ xhr.onabort = () => {
65
+ done();
66
+ reject(aborted());
67
+ };
68
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
69
+ xhr.send(body);
70
+ });
71
+ }
package/src/core/types.ts CHANGED
@@ -204,6 +204,21 @@ export interface PendingFile {
204
204
  meta?: Partial<AttachmentInput>;
205
205
  /** Not kept between launches (bytes with nowhere to save them): attach it again. */
206
206
  lost?: boolean;
207
+ /** How much of it has uploaded, 0 to 1, once the upload reports progress. */
208
+ progress?: number;
209
+ /** Going to the hosted video provider (Mux, Cloudflare) rather than storage. */
210
+ hosted?: boolean;
211
+ }
212
+
213
+ /**
214
+ * A hosted video upload that has started: the provider's upload id, sent with the message as
215
+ * `{ provider, providerId, status: "pending" }` while the bytes still go up. The provider's
216
+ * webhook marks the attachment ready.
217
+ */
218
+ export interface HostedAttachment {
219
+ provider: "mux" | "cloudflare";
220
+ providerId: string;
221
+ status: "pending";
207
222
  }
208
223
 
209
224
  /** A live hint from Realtime: something changed; fetch to see what. */
@@ -296,6 +311,8 @@ export interface ChatSearchHit {
296
311
  snippet: string;
297
312
  /** Found in a voice note's or video's transcript, not the text. */
298
313
  inTranscript: boolean;
314
+ /** The thread the message is a reply in (open that thread to show it); null on the main timeline. */
315
+ threadRootId: string | null;
299
316
  }
300
317
 
301
318
  /** Open Graph data for a link (fetched by the server's runner). Plain text: never render it as HTML. */
package/src/index.ts CHANGED
@@ -9,5 +9,6 @@ export * from "./core/client";
9
9
  export * from "./core/media";
10
10
  export * from "./core/thread";
11
11
  export * from "./core/outbox";
12
+ export * from "./core/transfer";
12
13
  export * from "./core/availability";
13
14
  export * from "./core/extras";
@@ -154,6 +154,22 @@ describe("the long-press sheet's +", () => {
154
154
  });
155
155
  });
156
156
 
157
+ describe("the long-press sheet's caption", () => {
158
+ const context = { me: "u1", role: "member" as const, canPost: true, pinned: false, canPin: false, canCopy: false };
159
+ it("has room under the sheet's top edge, and names an app card by its kind's title", async () => {
160
+ const message = { ...textMessage(), body: "", attachments: [{ id: "a1", kind: "check_in", ref: {} }] } as unknown as ChatMessage;
161
+ render(
162
+ <ChatNativeProvider>
163
+ <MessageActionsSheet message={message} context={context} onClose={vi.fn()} onReact={vi.fn()} onAction={vi.fn()} attachmentKinds={{ check_in: { title: () => "Check-in" } }} testID="acts" />
164
+ </ChatNativeProvider>,
165
+ );
166
+ const caption = await screen.findByTestId("acts-caption");
167
+ expect(caption.textContent).toBe("Check-in");
168
+ const body = JSON.parse(caption.parentElement!.getAttribute("data-style")!) as { paddingTop?: number };
169
+ expect(body.paddingTop).toBeGreaterThanOrEqual(16);
170
+ });
171
+ });
172
+
157
173
  function ComposerHarness() {
158
174
  const composer = useChatComposer("c1", null, ME);
159
175
  return <ChatComposer composer={composer} canPost disabledReason={null} role="member" onSend={vi.fn()} userId="u-composer" testID="cmp" />;
@@ -13,6 +13,8 @@ import { ChatNativeProvider } from "../context";
13
13
  import { ChatScheduledList, ChatScheduleSheet } from "../schedule-sheet";
14
14
  import { ChatSearchScreen } from "../search-screen";
15
15
  import { ChatThreadScreen } from "../thread-screen";
16
+ import { scrollToShow } from "../topics";
17
+ import { activePlaybackId, claimPlayback } from "../voice-player";
16
18
 
17
19
  vi.mock("react-native", async (original) => ({ ...(await original<object>()), useWindowDimensions: () => ({ width: 390, height: 844, scale: 3, fontScale: 1 }) }));
18
20
 
@@ -342,3 +344,81 @@ describe("ChatAvailabilityScreen", () => {
342
344
  await waitFor(() => expect(called(client, "setAvailability")[0]!.args[0]).toMatchObject({ awayMessage: null, awayUntil: null, hours: null }));
343
345
  });
344
346
  });
347
+
348
+ describe("simulator check leftovers", () => {
349
+ const cohort = (closed: boolean, messages: MemoryConversationSeed["messages"] = []): MemoryConversationSeed => ({
350
+ id: "cohort",
351
+ kind: "cohort",
352
+ members: [{ userId: ME, role: "member" }, { userId: SAM }, { userId: LEE }],
353
+ topics: [{ topic: "week-1", title: "Week 1", closed }],
354
+ messages,
355
+ });
356
+
357
+ it("says a closed topic is closed when it has no messages, instead of inviting a hello", async () => {
358
+ const client = memory([cohort(true)]);
359
+ render(wrap(client, <ChatThreadScreen conversationId="cohort" testID="t" />));
360
+ fireEvent.click(await screen.findByTestId("t-topics-week-1"));
361
+ await waitFor(() => expect(screen.getByTestId("t-empty").textContent).toContain("This topic is closed."));
362
+ expect(screen.getByTestId("t-empty").textContent).not.toContain("Say hello below.");
363
+ });
364
+
365
+ it("still invites a hello in an open topic with no messages", async () => {
366
+ const client = memory([cohort(false)]);
367
+ render(wrap(client, <ChatThreadScreen conversationId="cohort" testID="t" />));
368
+ fireEvent.click(await screen.findByTestId("t-topics-week-1"));
369
+ await waitFor(() => expect(screen.getByTestId("t-empty").textContent).toContain("Say hello below."));
370
+ });
371
+
372
+ it("pauses what this thread is playing when the screen loses focus, and nothing of another's", async () => {
373
+ const client = memory([{ id: "dm", members: [{ userId: ME }, { userId: SAM }], messages: [{ id: "m1", senderId: SAM, body: "", attachments: [{ id: "att-1", kind: "voice", path: "dm/x/a.m4a" }] }] }]);
374
+ const view = render(wrap(client, <ChatThreadScreen conversationId="dm" testID="t" />));
375
+ await screen.findByTestId("t-message-m1");
376
+ const other = vi.fn();
377
+ claimPlayback("elsewhere", other);
378
+ view.rerender(wrap(client, <ChatThreadScreen conversationId="dm" focused={false} testID="t" />));
379
+ expect(other).not.toHaveBeenCalled();
380
+
381
+ view.rerender(wrap(client, <ChatThreadScreen conversationId="dm" testID="t" />));
382
+ const mine = vi.fn();
383
+ claimPlayback("att-1", mine);
384
+ view.rerender(wrap(client, <ChatThreadScreen conversationId="dm" focused={false} testID="t" />));
385
+ expect(mine).toHaveBeenCalledTimes(1);
386
+ expect(activePlaybackId()).toBeNull();
387
+ });
388
+
389
+ it("names the message in each scheduled row's Cancel, so two at the same time differ", async () => {
390
+ const client = memory([{ id: "dm", members: [{ userId: ME }, { userId: SAM }] }]);
391
+ const at = new Date(Date.now() + 3600_000);
392
+ await client.schedule({ conversationId: "dm", clientId: "c1", body: "first one", sendAt: at });
393
+ await client.schedule({ conversationId: "dm", clientId: "c2", body: "second one", sendAt: at });
394
+ render(wrap(client, <ChatScheduledList conversationId="dm" testID="l" />));
395
+ const cancels = await screen.findAllByText("Cancel");
396
+ const labels = cancels.map((c) => c.closest("button")!.getAttribute("aria-label"));
397
+ expect(new Set(labels).size).toBe(2);
398
+ expect(labels.some((l) => l?.startsWith("Cancel, first one, Scheduled for"))).toBe(true);
399
+ expect(labels.some((l) => l?.startsWith("Cancel, second one, Scheduled for"))).toBe(true);
400
+ });
401
+
402
+ it("sizes the schedule and topic sheets to their content", async () => {
403
+ const client = memory([{ ...cohort(false), members: [{ userId: ME, role: "owner" }, { userId: SAM }, { userId: LEE }] }]);
404
+ render(wrap(client, <ChatScheduleSheet open onOpenChange={vi.fn()} onSchedule={vi.fn()} testID="s" />));
405
+ const schedule = (await screen.findByTestId("s-pick-0")).closest("[role=dialog]")!;
406
+ expect(schedule.getAttribute("data-presentation-style")).toBeNull();
407
+ expect(schedule.getAttribute("data-transparent")).toBe("true");
408
+ cleanup();
409
+
410
+ render(wrap(client, <ChatThreadScreen conversationId="cohort" testID="t" />));
411
+ fireEvent.click(await screen.findByTestId("t-topics-menu"));
412
+ const topics = (await screen.findByTestId("t-topics-new")).closest("[role=dialog]")!;
413
+ expect(topics.getAttribute("data-transparent")).toBe("true");
414
+ });
415
+ });
416
+
417
+ describe("scrollToShow", () => {
418
+ it("brings a chip past either edge into view, and leaves one in view alone", () => {
419
+ expect(scrollToShow({ x: 400, width: 80 }, { offset: 0, width: 300 })).toBe(400 + 80 + 8 - 300);
420
+ expect(scrollToShow({ x: 20, width: 60 }, { offset: 100, width: 300 })).toBe(12);
421
+ expect(scrollToShow({ x: 120, width: 60 }, { offset: 100, width: 300 })).toBeNull();
422
+ expect(scrollToShow({ x: 120, width: 60 }, { offset: 0, width: 0 })).toBeNull();
423
+ });
424
+ });