@marktiderman/genesis-chat 0.2.0 → 0.2.2

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 +13 -9
  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
@@ -0,0 +1,269 @@
1
+ /** @feat:chat Hosted uploads joining the outbox at start, and upload progress. */
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { createChatClient, type ChatClient, type ChatSupabase } from "../core/client";
4
+ import { createOutbox, type HostedUpload, type HostedUploadStart, type OutboxStore, type QueuedMessage } from "../core/outbox";
5
+ import { sendBytes } from "../core/transfer";
6
+ import { installFakeXHR } from "./fake-xhr";
7
+
8
+ afterEach(() => {
9
+ vi.unstubAllGlobals();
10
+ });
11
+
12
+ const tick = () => new Promise((r) => setTimeout(r, 0));
13
+
14
+ /** Saves through JSON, as localStorage or MMKV would: nothing is shared with the live queue. */
15
+ function jsonStore(): OutboxStore & { raw: () => QueuedMessage[] } {
16
+ let saved = "[]";
17
+ return {
18
+ load: async () => JSON.parse(saved) as QueuedMessage[],
19
+ save: async (q) => void (saved = JSON.stringify(q)),
20
+ raw: () => JSON.parse(saved) as QueuedMessage[],
21
+ };
22
+ }
23
+
24
+ /** A client whose send is idempotent by clientId, like send_message. */
25
+ function fakeClient() {
26
+ const posted = new Map<string, { id: string; input: Record<string, unknown> }>();
27
+ const sends: Record<string, unknown>[] = [];
28
+ const client = {
29
+ upload: vi.fn(async (_c: string, _u: string, _d: unknown, o: { name: string; onProgress?: (s: number, t: number) => void }) => {
30
+ o.onProgress?.(5, 10);
31
+ return `c1/me/${o.name}`;
32
+ }),
33
+ send: vi.fn(async (input: Record<string, unknown>) => {
34
+ sends.push(input);
35
+ const clientId = String(input.clientId);
36
+ if (!posted.has(clientId)) posted.set(clientId, { id: `m-${posted.size + 1}`, input });
37
+ const p = posted.get(clientId)!;
38
+ return { id: p.id, conversationId: input.conversationId, clientId, seq: 1 } as never;
39
+ }),
40
+ } as unknown as ChatClient;
41
+ return { client, sends, posted };
42
+ }
43
+
44
+ /** A hosted upload whose bytes finish when the test says. */
45
+ function hostedHook() {
46
+ const calls: { file: string; resume?: unknown }[] = [];
47
+ const finishers: { resolve: () => void; reject: (e: unknown) => void }[] = [];
48
+ let n = 0;
49
+ const hook: HostedUpload = vi.fn(async (_conversationId, file, opts) => {
50
+ calls.push({ file: file.name, resume: opts.resume });
51
+ const done = new Promise<void>((resolve, reject) => finishers.push({ resolve, reject }));
52
+ const start: HostedUploadStart = {
53
+ provider: "mux",
54
+ providerId: opts.resume?.providerId ?? `upl_${++n}`,
55
+ status: "pending",
56
+ url: opts.resume?.url ?? `https://up.test/${n}`,
57
+ done,
58
+ };
59
+ return start;
60
+ });
61
+ return { hook, calls, finishers };
62
+ }
63
+
64
+ const video = (name = "clip.mp4") => ({ data: `file:///cache/${name}`, name, contentType: "video/mp4", hosted: true, meta: { kind: "video" as const, durationMs: 3000 } });
65
+
66
+ describe("a hosted video joins the outbox at start", () => {
67
+ it("is sent in its place, pending, before its bytes are up; text written after it lands after it", async () => {
68
+ const { client, sends } = fakeClient();
69
+ const h = hostedHook();
70
+ const outbox = createOutbox({ client, userId: "me", hostedUpload: h.hook, readFile: async () => new Blob(["v"]) });
71
+ const videoId = await outbox.send({ conversationId: "c1", body: "" }, [video()]);
72
+ const textId = await outbox.send({ conversationId: "c1", body: "Watch this" });
73
+ await tick();
74
+ await tick();
75
+ expect(sends.map((s) => s.clientId)).toEqual([videoId, textId]);
76
+ expect(sends[0]!.attachments).toEqual([
77
+ expect.objectContaining({ kind: "video", provider: "mux", providerId: "upl_1", status: "pending", mime: "video/mp4", durationMs: 3000 }),
78
+ ]);
79
+ // Nothing went to storage.
80
+ expect(client.upload).not.toHaveBeenCalled();
81
+ // Posted, it waits only for its bytes.
82
+ expect(outbox.pending("c1")).toEqual([expect.objectContaining({ clientId: videoId, sentMessageId: "m-1", status: "sending" })]);
83
+ h.finishers[0]!.resolve();
84
+ await tick();
85
+ expect(outbox.pending("c1")).toEqual([]);
86
+ });
87
+
88
+ it("carries the upload on after a relaunch and sends the message again with the same clientId and upload id", async () => {
89
+ const store = jsonStore();
90
+ const first = fakeClient();
91
+ const h1 = hostedHook();
92
+ const before = createOutbox({ client: first.client, userId: "me", store, hostedUpload: h1.hook });
93
+ const id = await before.send({ conversationId: "c1", body: "" }, [video()]);
94
+ await tick();
95
+ await tick();
96
+ expect(first.sends).toHaveLength(1);
97
+ expect(store.raw()[0]).toMatchObject({ clientId: id, hosted: { 0: { provider: "mux", providerId: "upl_1", url: "https://up.test/1" } } });
98
+
99
+ // The app closes with the bytes half up; it opens again.
100
+ const h2 = hostedHook();
101
+ const after = createOutbox({ client: first.client, userId: "me", store, hostedUpload: h2.hook });
102
+ await after.load();
103
+ await after.retryAll();
104
+ await tick();
105
+ await tick();
106
+ expect(h2.calls).toEqual([{ file: "clip.mp4", resume: expect.objectContaining({ provider: "mux", providerId: "upl_1", url: "https://up.test/1" }) }]);
107
+ expect(first.sends).toHaveLength(2);
108
+ expect(first.sends[1]).toMatchObject({ clientId: id, attachments: [expect.objectContaining({ providerId: "upl_1", status: "pending" })] });
109
+ // Posted once.
110
+ expect(first.posted.size).toBe(1);
111
+ h2.finishers[0]!.resolve();
112
+ await tick();
113
+ expect(after.pending()).toEqual([]);
114
+ expect(store.raw()).toEqual([]);
115
+ });
116
+
117
+ it("a failed transfer marks the posted message failed without holding later ones; Retry carries the same upload on", async () => {
118
+ const { client, sends } = fakeClient();
119
+ const h = hostedHook();
120
+ const outbox = createOutbox({ client, userId: "me", hostedUpload: h.hook });
121
+ const videoId = await outbox.send({ conversationId: "c1", body: "" }, [video()]);
122
+ await tick();
123
+ h.finishers[0]!.reject(new Error("network"));
124
+ await tick();
125
+ await tick();
126
+ expect(outbox.pending("c1")[0]).toMatchObject({ clientId: videoId, status: "failed", error: "The video didn't finish uploading" });
127
+ await outbox.send({ conversationId: "c1", body: "Still here" });
128
+ await tick();
129
+ expect(sends.at(-1)).toMatchObject({ body: "Still here" });
130
+
131
+ await outbox.retry(videoId);
132
+ await tick();
133
+ await tick();
134
+ expect(h.calls.at(-1)).toEqual({ file: "clip.mp4", resume: expect.objectContaining({ providerId: "upl_1" }) });
135
+ h.finishers.at(-1)!.resolve();
136
+ await tick();
137
+ expect(outbox.pending("c1")).toEqual([]);
138
+ });
139
+
140
+ it("goes to storage, as before, without a hostedUpload", async () => {
141
+ const { client, sends } = fakeClient();
142
+ const outbox = createOutbox({ client, userId: "me", readFile: async () => new Blob(["v"]) });
143
+ await outbox.send({ conversationId: "c1", body: "" }, [video()]);
144
+ await tick();
145
+ await tick();
146
+ expect(client.upload).toHaveBeenCalledTimes(1);
147
+ expect(sends[0]!.attachments).toEqual([expect.objectContaining({ kind: "video", path: "c1/me/clip.mp4" })]);
148
+ });
149
+
150
+ it("discarding the message stops its bytes", async () => {
151
+ const { client } = fakeClient();
152
+ let signal: AbortSignal | undefined;
153
+ const hook: HostedUpload = async (_c, _f, opts) => {
154
+ signal = opts.signal;
155
+ return { provider: "mux", providerId: "upl_x", status: "pending", done: new Promise<void>(() => undefined) };
156
+ };
157
+ const outbox = createOutbox({ client, userId: "me", hostedUpload: hook });
158
+ const id = await outbox.send({ conversationId: "c1", body: "" }, [video()]);
159
+ await tick();
160
+ await outbox.discard(id);
161
+ expect(signal?.aborted).toBe(true);
162
+ expect(outbox.pending()).toEqual([]);
163
+ });
164
+ });
165
+
166
+ describe("upload progress", () => {
167
+ it("shows each storage file's progress on the pending message", async () => {
168
+ const { client } = fakeClient();
169
+ let finish!: () => void;
170
+ let report!: (s: number, t: number) => void;
171
+ (client.upload as ReturnType<typeof vi.fn>).mockImplementationOnce(
172
+ (_c: string, _u: string, _d: unknown, o: { name: string; onProgress?: (s: number, t: number) => void }) =>
173
+ new Promise((r) => {
174
+ report = o.onProgress!;
175
+ finish = () => r(`c1/me/${o.name}`);
176
+ }),
177
+ );
178
+ const changes: QueuedMessage[][] = [];
179
+ const outbox = createOutbox({ client, userId: "me", onChange: (q) => changes.push(q) });
180
+ await outbox.send({ conversationId: "c1" }, [{ data: new Blob(["0123456789"]), name: "a.png", contentType: "image/png" }]);
181
+ await tick();
182
+ report(4, 10);
183
+ expect(outbox.pending("c1")[0]!.files?.[0]?.progress).toBe(0.4);
184
+ report(10, 10);
185
+ expect(outbox.pending("c1")[0]!.files?.[0]?.progress).toBe(1);
186
+ finish();
187
+ await tick();
188
+ expect(outbox.pending("c1")).toEqual([]);
189
+ });
190
+
191
+ it("passes onProgress to the app's upload hook too", async () => {
192
+ const { client } = fakeClient();
193
+ let seen = 0;
194
+ const outbox = createOutbox({
195
+ client,
196
+ userId: "me",
197
+ upload: async (_c, file, o) => {
198
+ o?.onProgress?.(1, 2);
199
+ seen = outbox.pending("c1")[0]!.files![0]!.progress!;
200
+ return `c1/me/${file.name}`;
201
+ },
202
+ });
203
+ await outbox.send({ conversationId: "c1" }, [{ data: "file:///a.jpg", name: "a.jpg", contentType: "image/jpeg" }]);
204
+ await tick();
205
+ expect(seen).toBe(0.5);
206
+ });
207
+ });
208
+
209
+ describe("client.upload with onProgress", () => {
210
+ function storageFake() {
211
+ const signedFor: string[] = [];
212
+ const plain: string[] = [];
213
+ const supabase = {
214
+ schema: () => ({}),
215
+ storage: {
216
+ from: () => ({
217
+ createSignedUploadUrl: async (path: string) => {
218
+ signedFor.push(path);
219
+ return { data: { signedUrl: `https://sb.test/storage/v1/object/upload/sign/genesis-chat/${path}?token=t`, path, token: "t" }, error: null };
220
+ },
221
+ upload: async (path: string) => {
222
+ plain.push(path);
223
+ return { data: { path }, error: null };
224
+ },
225
+ }),
226
+ },
227
+ } as unknown as ChatSupabase;
228
+ return { supabase, signedFor, plain };
229
+ }
230
+
231
+ it("sends the bytes to a signed upload URL through XMLHttpRequest and reports progress", async () => {
232
+ const XHR = installFakeXHR();
233
+ const s = storageFake();
234
+ const client = createChatClient(s.supabase);
235
+ const progress: number[][] = [];
236
+ const going = client.upload("c1", "me", new Blob(["0123456789"]), { name: "a.png", contentType: "image/png", id: "f1", onProgress: (a, b) => progress.push([a, b]) });
237
+ await tick();
238
+ const xhr = XHR.all[0]!;
239
+ expect(xhr).toMatchObject({ method: "PUT", url: "https://sb.test/storage/v1/object/upload/sign/genesis-chat/c1/me/f1.png?token=t", sent: true });
240
+ expect(xhr.headers).toMatchObject({ "content-type": "image/png", "x-upsert": "false" });
241
+ xhr.progress(3, 10);
242
+ xhr.respond(200, "{}");
243
+ expect(await going).toBe("c1/me/f1.png");
244
+ expect(progress).toEqual([
245
+ [3, 10],
246
+ [10, 10],
247
+ ]);
248
+ expect(s.plain).toEqual([]);
249
+ });
250
+
251
+ it("uses the plain upload without onProgress", async () => {
252
+ installFakeXHR();
253
+ const s = storageFake();
254
+ const client = createChatClient(s.supabase);
255
+ expect(await client.upload("c1", "me", new Blob(["x"]), { name: "a.png", contentType: "image/png", id: "f2" })).toBe("c1/me/f2.png");
256
+ expect(s.plain).toEqual(["c1/me/f2.png"]);
257
+ expect(s.signedFor).toEqual([]);
258
+ });
259
+
260
+ it("a refused PUT is a permanent ChatError; a dropped connection is transient", async () => {
261
+ const XHR = installFakeXHR();
262
+ const refused = sendBytes("https://x.test/a", new Blob(["x"]));
263
+ XHR.all[0]!.respond(403);
264
+ await expect(refused).rejects.toMatchObject({ name: "ChatError", permanent: true });
265
+ const dropped = sendBytes("https://x.test/b", new Blob(["x"]));
266
+ XHR.all[1]!.fail();
267
+ await expect(dropped).rejects.toMatchObject({ name: "ChatError", transient: true });
268
+ });
269
+ });
@@ -0,0 +1,41 @@
1
+ /** @feat:chat Fixes from the review of release 279: upload refusals, and what app cards are told. */
2
+ import { describe, expect, it } from "vitest";
3
+ import { ChatError } from "../core/client";
4
+ import { sendBytes } from "../core/transfer";
5
+ import type { ChatAttachment } from "../core/types";
6
+ import type { ChatAttachmentKind, ChatCardContext } from "../react/index";
7
+ import { attachmentCardOf } from "../web/attachments";
8
+
9
+ const answering = (status: number) => (async () => new Response("", { status })) as unknown as typeof fetch;
10
+ const failure = (status: number) => sendBytes("https://x.test/a", new Blob(["x"]), { fetch: answering(status) }).then(
11
+ () => { throw new Error("expected a failure"); },
12
+ (e: ChatError) => e,
13
+ );
14
+
15
+ describe("an upload's failed answer", () => {
16
+ it("is retried on its own after a timeout or a server error", async () => {
17
+ expect((await failure(408)).transient).toBe(true);
18
+ expect((await failure(503)).transient).toBe(true);
19
+ });
20
+ it("waits when rate limited, without blocking the conversation as a refusal would", async () => {
21
+ const e = await failure(429);
22
+ expect(e.rateLimited).toBe(true);
23
+ expect(e.permanent).toBe(false);
24
+ });
25
+ it("is a refusal for any other 4xx", async () => {
26
+ expect((await failure(403)).permanent).toBe(true);
27
+ });
28
+ });
29
+
30
+ describe("an app card on the web", () => {
31
+ const checkIn: ChatAttachmentKind = {
32
+ title: () => "Check-in",
33
+ action: (_a, ctx?: ChatCardContext) => (ctx?.mine ? null : { label: "Answer", href: "https://app.test/check-in" }),
34
+ };
35
+ const a = { kind: "check_in", ref: {} } as unknown as ChatAttachment;
36
+
37
+ it("is told whether it is on your own message", () => {
38
+ expect(attachmentCardOf(checkIn, a, { mine: false })?.action?.label).toBe("Answer");
39
+ expect(attachmentCardOf(checkIn, a, { mine: true })?.action).toBeNull();
40
+ });
41
+ });
@@ -24,6 +24,7 @@ import {
24
24
  type MemoryChatOptions,
25
25
  type MemoryConversationSeed,
26
26
  } from "../web";
27
+ import { installFakeXHR } from "./fake-xhr";
27
28
 
28
29
  afterEach(() => {
29
30
  cleanup();
@@ -343,22 +344,24 @@ describe("the camera", () => {
343
344
  await screen.findByTestId("genesis-chat-video-retake");
344
345
  }
345
346
 
346
- it("cancels a hosted video upload, sending nothing", async () => {
347
- let signal: AbortSignal | undefined;
348
- vi.stubGlobal("fetch", vi.fn((_url: string, init: RequestInit) => new Promise((_r, reject) => {
349
- signal = init.signal ?? undefined;
350
- signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
351
- })));
347
+ it("a hosted video keeps its place: text typed after it lands after it, while its bytes still go up", async () => {
348
+ const XHR = installFakeXHR();
352
349
  const client = make(dm());
353
350
  mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />, { videoUpload: { create: async () => ({ url: "https://up.test/1", providerId: "u1", provider: "mux" }) } });
354
351
  await recordTake();
355
352
  await act(async () => {
356
353
  fireEvent.click(screen.getByTestId("genesis-chat-video-send"));
357
354
  });
358
- fireEvent.click(await screen.findByTestId("genesis-chat-video-cancel-upload"));
359
- await waitFor(() => expect(signal?.aborted).toBe(true));
360
- await screen.findByTestId("genesis-chat-video-send");
361
- expect(client.calls.some((c) => c.method === "send")).toBe(false);
355
+ // The dialog closes at once; the upload carries on.
356
+ await waitFor(() => expect(screen.queryByTestId("genesis-chat-video-recorder")).toBeNull());
357
+ fireEvent.change(await screen.findByTestId("genesis-chat-input"), { target: { value: "Watch this" } });
358
+ fireEvent.keyDown(screen.getByTestId("genesis-chat-input"), { key: "Enter" });
359
+ await waitFor(() => expect(client.calls.filter((c) => c.method === "send").length).toBe(2));
360
+ const sends = client.calls.filter((c) => c.method === "send").map((c) => c.args[0] as { body: string; attachments?: { provider?: string; status?: string }[] });
361
+ expect(sends[0]!.attachments?.[0]).toMatchObject({ provider: "mux", status: "pending" });
362
+ expect(sends[1]!.body).toBe("Watch this");
363
+ expect(XHR.all[0]!.status).toBe(0);
364
+ act(() => XHR.all[0]!.respond(200));
362
365
  });
363
366
 
364
367
  it("keeps the take's preview while it uploads, and frees it once the message lands", async () => {
@@ -0,0 +1,81 @@
1
+ /** @feat:chat The dev-only fake hosted video provider. */
2
+ import { describe, expect, it } from "vitest";
3
+ import { chatUploadPassthrough, createVideoUploadHandler, fakeVideoProvider } from "../server";
4
+ import { fakeSupabase, type Row } from "./server-fakes";
5
+
6
+ const CONV = "11111111-1111-4111-8111-111111111111";
7
+ const USER = "22222222-2222-4222-8222-222222222222";
8
+
9
+ function setup(attachments: Row[] = [], over: { readyWaitMs?: number } = {}) {
10
+ const tables = { attachments };
11
+ const sb = fakeSupabase({ tables, rpc: { update_attachment: () => null } });
12
+ const video = fakeVideoProvider({ supabase: sb.client, uploadUrl: "http://localhost:3000/dev/fake-video", env: "development", newId: () => "abc", sleep: async () => undefined, ...over });
13
+ return { sb, video, tables };
14
+ }
15
+
16
+ describe("fakeVideoProvider", () => {
17
+ it("refuses to run in production unless forced", () => {
18
+ const sb = fakeSupabase({});
19
+ expect(() => fakeVideoProvider({ supabase: sb.client, uploadUrl: "http://localhost/x", env: "production" })).toThrow(/production/);
20
+ expect(() => fakeVideoProvider({ supabase: sb.client, uploadUrl: "http://localhost/x", env: "production", force: true })).not.toThrow();
21
+ });
22
+
23
+ it("mints a local upload URL and id through the usual upload handler", async () => {
24
+ const { video } = setup();
25
+ const handler = createVideoUploadHandler({ video, authorize: () => ({ userId: USER }), corsOrigin: "http://localhost:3000" });
26
+ const res = await handler(new Request("http://localhost:3000/api/video-upload", { method: "POST", body: JSON.stringify({ conversationId: CONV }) }));
27
+ expect(await res.json()).toEqual({ url: "http://localhost:3000/dev/fake-video?id=fake_abc", providerId: "fake_abc", provider: "mux" });
28
+ });
29
+
30
+ it("markReady does what Mux's ready webhook does: ready, a playback id and a fake HLS URL", async () => {
31
+ const { sb, video } = setup([{ id: "att1", provider: "mux", provider_id: "fake_abc" }]);
32
+ await video.markReady("fake_abc", { durationMs: 4200.4, posterPath: `${CONV}/${USER}/p.jpg` });
33
+ expect(sb.rpcCalls).toEqual([
34
+ {
35
+ fn: "update_attachment",
36
+ args: {
37
+ p_attachment_id: "att1",
38
+ p_patch: {
39
+ status: "ready",
40
+ playback_id: "fake-fake_abc",
41
+ url: "https://fake-video.invalid/fake_abc.m3u8",
42
+ transcript_status: "off",
43
+ duration_ms: 4200,
44
+ poster_path: `${CONV}/${USER}/p.jpg`,
45
+ },
46
+ },
47
+ },
48
+ ]);
49
+ });
50
+
51
+ it("says so when no message carries the upload yet", async () => {
52
+ const { video } = setup();
53
+ await expect(video.markReady("fake_nope")).rejects.toThrow(/is its message sent/);
54
+ });
55
+
56
+ it("its handler stores the PUT body in the genesis-chat bucket, then marks the attachment ready", async () => {
57
+ const { sb, video } = setup([{ id: "att1", provider: "mux", provider_id: "fake_abc" }]);
58
+ const { url } = await video.createUpload({ corsOrigin: "http://localhost:3000", passthrough: chatUploadPassthrough({ conversationId: CONV, userId: USER }) });
59
+ const res = await video.handler(new Request(url, { method: "PUT", body: new Uint8Array([1, 2, 3]), headers: { "content-type": "video/webm" } }));
60
+ expect(res.status).toBe(200);
61
+ expect(res.headers.get("access-control-allow-origin")).toBe("http://localhost:3000");
62
+ expect(sb.uploaded).toEqual([{ bucket: "genesis-chat", path: `${CONV}/${USER}/fake_abc.webm`, bytes: 3, contentType: "video/webm" }]);
63
+ expect(sb.rpcCalls.map((c) => c.fn)).toEqual(["update_attachment"]);
64
+ expect(await video.playbackUrl("fake-fake_abc")).toBe(`https://files.test/${CONV}/${USER}/fake_abc.webm?token=x`);
65
+ });
66
+
67
+ it("stores the body and answers 202 when the message hasn't arrived in time", async () => {
68
+ const { sb, video } = setup([], { readyWaitMs: 0 });
69
+ const { url } = await video.createUpload({ corsOrigin: "http://localhost:3000", passthrough: chatUploadPassthrough({ conversationId: CONV }) });
70
+ const res = await video.handler(new Request(url, { method: "PUT", body: new Uint8Array([1]) }));
71
+ expect(res.status).toBe(202);
72
+ expect(await res.json()).toMatchObject({ stored: true, ready: false, providerId: "fake_abc" });
73
+ expect(sb.uploaded).toHaveLength(1);
74
+ });
75
+
76
+ it("answers a preflight, and refuses an unknown upload", async () => {
77
+ const { video } = setup();
78
+ expect((await video.handler(new Request("http://localhost:3000/dev/fake-video?id=x", { method: "OPTIONS" }))).status).toBe(204);
79
+ expect((await video.handler(new Request("http://localhost:3000/dev/fake-video?id=x", { method: "PUT", body: "x" }))).status).toBe(404);
80
+ });
81
+ });
@@ -10,6 +10,7 @@ export interface FakeSupabase {
10
10
  rpcCalls: { fn: string; args: Record<string, unknown> }[];
11
11
  removed: { bucket: string; paths: string[] }[];
12
12
  signed: string[];
13
+ uploaded: { bucket: string; path: string; bytes: number; contentType?: string }[];
13
14
  }
14
15
 
15
16
  /** rpc results by function name; tables as arrays of rows, filtered by eq(). */
@@ -22,6 +23,7 @@ export function fakeSupabase(opts: {
22
23
  const rpcCalls: FakeSupabase["rpcCalls"] = [];
23
24
  const removed: FakeSupabase["removed"] = [];
24
25
  const signed: string[] = [];
26
+ const uploaded: FakeSupabase["uploaded"] = [];
25
27
  const builder = (table: string) => {
26
28
  const filters: [string, unknown][] = [];
27
29
  let orderBy: string | null = null;
@@ -58,6 +60,10 @@ export function fakeSupabase(opts: {
58
60
  removed.push({ bucket, paths });
59
61
  return { data: opts.removeError ? null : paths.map((name) => ({ name })), error: opts.removeError ?? null };
60
62
  },
63
+ upload: async (path: string, body: ArrayBuffer, o?: { contentType?: string }) => {
64
+ uploaded.push({ bucket, path, bytes: body.byteLength, contentType: o?.contentType });
65
+ return { data: { path }, error: null };
66
+ },
61
67
  createSignedUrl: async (path: string, seconds: number) => {
62
68
  signed.push(`${path}@${seconds}`);
63
69
  return opts.signedUrlError
@@ -67,7 +73,7 @@ export function fakeSupabase(opts: {
67
73
  }),
68
74
  },
69
75
  };
70
- return { client: client as unknown as ChatSupabase, rpcCalls, removed, signed };
76
+ return { client: client as unknown as ChatSupabase, rpcCalls, removed, signed, uploaded };
71
77
  }
72
78
 
73
79
  export interface FakeFetch {
@@ -142,10 +142,23 @@ describe("createLinkUnfurler", () => {
142
142
  ["a long attribute name", "a"],
143
143
  ["unclosed tags in a title", "<title>" + "<".repeat(1000)],
144
144
  ])("parses a 512 KiB page of %s in linear time", (_, unit) => {
145
- const page = unit.repeat(Math.ceil((512 * 1024) / unit.length));
146
- const started = performance.now();
147
- parseOpenGraph(unit === "a" ? `<meta ${page}>` : page, "https://x.example/");
148
- expect(performance.now() - started).toBeLessThan(500);
145
+ // Time grows with the page, not its square: four times the bytes may take about four times as
146
+ // long (quadratic would take sixteen). Compared against the same machine's smaller page, so a
147
+ // slow CI runner can't fail it; the best of three runs keeps a pause from counting.
148
+ const timeFor = (bytes: number) => {
149
+ const page = unit.repeat(Math.ceil(bytes / unit.length));
150
+ const html = unit === "a" ? `<meta ${page}>` : page;
151
+ let best = Infinity;
152
+ for (let i = 0; i < 3; i++) {
153
+ const started = performance.now();
154
+ parseOpenGraph(html, "https://x.example/");
155
+ best = Math.min(best, performance.now() - started);
156
+ }
157
+ return best;
158
+ };
159
+ const small = timeFor(128 * 1024);
160
+ const large = timeFor(512 * 1024);
161
+ expect(large).toBeLessThan(small * 10 + 50);
149
162
  });
150
163
 
151
164
  it("still reads a title and an unclosed script runs to the end", () => {
@@ -237,6 +237,22 @@ describe("ChatInbox", () => {
237
237
  expect(document.activeElement).toBe(groupRow);
238
238
  });
239
239
 
240
+ it("previews a message that is only an app card by its kind's title", async () => {
241
+ const client = createMemoryChatClient({
242
+ me: ME,
243
+ conversations: [{ id: "d1", members: [{ userId: ME }, { userId: SAM }], messages: [{ senderId: SAM, body: "", createdAt: at(10), attachments: [{ kind: "check_in" }] }] }],
244
+ });
245
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
246
+ render(
247
+ <QueryClientProvider client={queryClient}>
248
+ <ChatProvider client={client} userId={ME} attachmentKinds={{ check_in: { title: () => "Check-in" } }}>
249
+ <ChatInbox resolveUser={resolveUser} onSelect={() => undefined} />
250
+ </ChatProvider>
251
+ </QueryClientProvider>,
252
+ );
253
+ expect((await screen.findByTestId("genesis-chat-inbox-row-d1")).textContent).toContain("Check-in");
254
+ });
255
+
240
256
  it("shows an empty state when there are none", async () => {
241
257
  const client = createMemoryChatClient({ me: ME, conversations: [] });
242
258
  mount(client, <ChatInbox resolveUser={resolveUser} onSelect={() => undefined} />);
@@ -29,6 +29,7 @@ import type {
29
29
  } from "./types";
30
30
  import { normalizeHours } from "./availability";
31
31
  import { fileExtension, isAllowedExtension, randomId } from "./media";
32
+ import { sendBytes, type UploadProgress } from "./transfer";
32
33
 
33
34
  /** Any supabase-js client; the app's generated Database types don't need to list genesis_chat. */
34
35
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -254,6 +255,7 @@ export function toSearchHit(r: Row): ChatSearchHit {
254
255
  createdAt: String(r.created_at),
255
256
  snippet: String(r.snippet ?? ""),
256
257
  inTranscript: Boolean(r.in_transcript),
258
+ threadRootId: str(r.thread_root_id),
257
259
  };
258
260
  }
259
261
 
@@ -643,13 +645,37 @@ export function createChatClient(supabase: ChatSupabase, options: ChatClientOpti
643
645
  * Uploads a file into the conversation's folder for the signed-in person and
644
646
  * returns its path, ready to attach. Resumable for big files is the native
645
647
  * renderer's job (Supabase TUS); this is the plain upload.
648
+ *
649
+ * With `onProgress(sent, total)`, where XMLHttpRequest exists (browsers, React Native), the
650
+ * bytes go to a signed upload URL (`createSignedUploadUrl`, which checks the same storage
651
+ * policy as a plain upload) through XMLHttpRequest, which reports progress; elsewhere the
652
+ * plain upload runs and reports once, at the end. `signal` cancels it.
646
653
  */
647
- async upload(conversationId: string, userId: string, file: Blob | ArrayBuffer, opts: { name: string; contentType: string; id?: string }) {
654
+ async upload(
655
+ conversationId: string,
656
+ userId: string,
657
+ file: Blob | ArrayBuffer,
658
+ opts: { name: string; contentType: string; id?: string; onProgress?: UploadProgress; signal?: AbortSignal },
659
+ ) {
648
660
  const ext = fileExtension(opts.name, opts.contentType);
649
661
  if (!isAllowedExtension(ext)) throw new ChatError(`Files of type .${ext} can't be sent`, "22023", "upload");
650
662
  const id = opts.id ?? randomId();
651
663
  const path = `${conversationId}/${userId}/${id}.${ext}`;
652
- must("upload", await supabase.storage.from(CHAT_BUCKET).upload(path, file, { contentType: opts.contentType, upsert: false }));
664
+ const bucket = supabase.storage.from(CHAT_BUCKET);
665
+ if (opts.onProgress && typeof XMLHttpRequest !== "undefined" && typeof bucket.createSignedUploadUrl === "function") {
666
+ const signed = must<{ signedUrl: string } | null>("upload", await bucket.createSignedUploadUrl(path));
667
+ if (!signed?.signedUrl) throw new ChatError("No upload URL came back", undefined, "upload");
668
+ await sendBytes(signed.signedUrl, file, {
669
+ method: "PUT",
670
+ headers: { "content-type": opts.contentType, "x-upsert": "false", "cache-control": "max-age=3600" },
671
+ signal: opts.signal,
672
+ onProgress: opts.onProgress,
673
+ });
674
+ return path;
675
+ }
676
+ must("upload", await bucket.upload(path, file, { contentType: opts.contentType, upsert: false }));
677
+ const total = typeof Blob !== "undefined" && file instanceof Blob ? file.size : (file as ArrayBuffer).byteLength;
678
+ opts.onProgress?.(total, total);
653
679
  return path;
654
680
  },
655
681