@marktiderman/genesis-chat 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +304 -13
  2. package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
  3. package/dist/chunk-5D5A7I4Q.js.map +1 -0
  4. package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
  5. package/dist/chunk-7S6HYNWW.js.map +1 -0
  6. package/dist/chunk-BJCE5O7V.js +70 -0
  7. package/dist/chunk-BJCE5O7V.js.map +1 -0
  8. package/dist/chunk-PZTOY2LY.js +576 -0
  9. package/dist/chunk-PZTOY2LY.js.map +1 -0
  10. package/dist/chunk-VEPZROQB.js +187 -0
  11. package/dist/chunk-VEPZROQB.js.map +1 -0
  12. package/dist/{client-BtdALFCx.d.ts → client-DMa2j7kz.d.ts} +152 -11
  13. package/dist/index.d.ts +78 -163
  14. package/dist/index.js +3 -2
  15. package/dist/native.d.ts +1910 -1
  16. package/dist/native.js +4900 -0
  17. package/dist/native.js.map +1 -1
  18. package/dist/outbox-B0y3phE0.d.ts +151 -0
  19. package/dist/push.d.ts +206 -0
  20. package/dist/push.js +3 -0
  21. package/dist/push.js.map +1 -0
  22. package/dist/react.d.ts +149 -7
  23. package/dist/react.js +3 -3
  24. package/dist/server.d.ts +510 -21
  25. package/dist/server.js +863 -21
  26. package/dist/server.js.map +1 -1
  27. package/dist/thread-DWXHKPdw.d.ts +57 -0
  28. package/dist/web.d.ts +313 -10
  29. package/dist/web.js +1731 -60
  30. package/dist/web.js.map +1 -1
  31. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  32. package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
  33. package/package.json +74 -6
  34. package/src/__tests__/coaching-core.test.ts +332 -0
  35. package/src/__tests__/coaching-react.test.tsx +248 -0
  36. package/src/__tests__/coaching-web.test.tsx +449 -0
  37. package/src/__tests__/core.test.ts +91 -0
  38. package/src/__tests__/outbox.test.ts +122 -0
  39. package/src/__tests__/realtime.test.ts +70 -0
  40. package/src/__tests__/review-fixes-core.test.ts +193 -0
  41. package/src/__tests__/review-fixes-web.test.tsx +402 -0
  42. package/src/__tests__/server-effects.test.ts +158 -0
  43. package/src/__tests__/server-fakes.ts +90 -0
  44. package/src/__tests__/server-integrations.test.ts +247 -0
  45. package/src/__tests__/server-providers.test.ts +246 -0
  46. package/src/__tests__/server-runner.test.ts +277 -0
  47. package/src/__tests__/server-safety.test.ts +290 -0
  48. package/src/__tests__/web-markdown.test.tsx +88 -0
  49. package/src/__tests__/web.test.tsx +333 -0
  50. package/src/core/availability.ts +223 -0
  51. package/src/core/client.ts +757 -0
  52. package/src/core/extras.ts +106 -0
  53. package/src/core/media.ts +144 -0
  54. package/src/core/outbox.ts +429 -0
  55. package/src/core/thread.ts +171 -0
  56. package/src/core/types.ts +311 -0
  57. package/src/index.ts +13 -0
  58. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  59. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  60. package/src/native/__tests__/composer-state.test.ts +188 -0
  61. package/src/native/__tests__/composer.test.tsx +133 -0
  62. package/src/native/__tests__/emoji.test.tsx +238 -0
  63. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  64. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  65. package/src/native/__tests__/organise-logic.test.ts +164 -0
  66. package/src/native/__tests__/organise-screens.test.tsx +344 -0
  67. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  68. package/src/native/__tests__/push-payload.test.ts +66 -0
  69. package/src/native/__tests__/push.test.ts +209 -0
  70. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  71. package/src/native/__tests__/rows-actions.test.ts +76 -0
  72. package/src/native/__tests__/thread-layout.test.ts +41 -0
  73. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  74. package/src/native/__tests__/tus.test.ts +154 -0
  75. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  76. package/src/native/__tests__/video-media.test.tsx +421 -0
  77. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  78. package/src/native/__tests__/video-upload.test.ts +227 -0
  79. package/src/native/access-error.tsx +31 -0
  80. package/src/native/actions.ts +42 -0
  81. package/src/native/attachment-cards.tsx +116 -0
  82. package/src/native/auto-retry.ts +69 -0
  83. package/src/native/availability.tsx +376 -0
  84. package/src/native/broadcast-sheet.tsx +193 -0
  85. package/src/native/composer-state.ts +302 -0
  86. package/src/native/composer.tsx +453 -0
  87. package/src/native/context.tsx +70 -0
  88. package/src/native/copy.ts +296 -0
  89. package/src/native/emoji-data.ts +21 -0
  90. package/src/native/emoji-picker.tsx +229 -0
  91. package/src/native/emoji.ts +118 -0
  92. package/src/native/icon-button.tsx +40 -0
  93. package/src/native/inbox-screen.tsx +116 -0
  94. package/src/native/index.ts +184 -0
  95. package/src/native/link-preview.tsx +104 -0
  96. package/src/native/media-colors.ts +15 -0
  97. package/src/native/media.tsx +187 -0
  98. package/src/native/message-actions-sheet.tsx +121 -0
  99. package/src/native/message-bubble.tsx +374 -0
  100. package/src/native/moderation.tsx +42 -0
  101. package/src/native/modules.ts +275 -0
  102. package/src/native/organise-copy.ts +195 -0
  103. package/src/native/photo-viewer.tsx +531 -0
  104. package/src/native/push.ts +375 -0
  105. package/src/native/recorder-machine.ts +176 -0
  106. package/src/native/rows.ts +81 -0
  107. package/src/native/schedule-sheet.tsx +314 -0
  108. package/src/native/search-screen.tsx +168 -0
  109. package/src/native/stack-options.ts +36 -0
  110. package/src/native/start-sheet.tsx +87 -0
  111. package/src/native/thread-screen.tsx +620 -0
  112. package/src/native/topics.tsx +179 -0
  113. package/src/native/tus.ts +282 -0
  114. package/src/native/use-composer.ts +71 -0
  115. package/src/native/use-jump.ts +91 -0
  116. package/src/native/use-voice-recorder.ts +234 -0
  117. package/src/native/video-player.tsx +370 -0
  118. package/src/native/video-recorder-state.ts +140 -0
  119. package/src/native/video-recorder.tsx +271 -0
  120. package/src/native/video-upload-tray.tsx +77 -0
  121. package/src/native/video-upload.ts +432 -0
  122. package/src/native/voice-player.tsx +283 -0
  123. package/src/react/index.tsx +810 -0
  124. package/src/server/assistant.ts +235 -0
  125. package/src/server/crypto.ts +86 -0
  126. package/src/server/expo.ts +119 -0
  127. package/src/server/handler.ts +32 -0
  128. package/src/server/index.ts +57 -0
  129. package/src/server/moderation.ts +155 -0
  130. package/src/server/mux.ts +290 -0
  131. package/src/server/notifications.ts +130 -0
  132. package/src/server/runner.ts +468 -0
  133. package/src/server/transcribers.ts +111 -0
  134. package/src/server/types.ts +171 -0
  135. package/src/server/unfurl.ts +480 -0
  136. package/src/server/upload.ts +158 -0
  137. package/src/web/attachments.tsx +379 -0
  138. package/src/web/availability.tsx +299 -0
  139. package/src/web/broadcast.tsx +166 -0
  140. package/src/web/bubble.tsx +563 -0
  141. package/src/web/cards.tsx +136 -0
  142. package/src/web/composer.tsx +571 -0
  143. package/src/web/emoji-picker.tsx +84 -0
  144. package/src/web/error-boundary.tsx +33 -0
  145. package/src/web/format.ts +55 -0
  146. package/src/web/inbox.tsx +195 -0
  147. package/src/web/index.ts +75 -0
  148. package/src/web/markdown.tsx +245 -0
  149. package/src/web/memory-client.ts +706 -0
  150. package/src/web/panel.tsx +135 -0
  151. package/src/web/schedule.tsx +192 -0
  152. package/src/web/search.tsx +129 -0
  153. package/src/web/thread-context.ts +64 -0
  154. package/src/web/thread.tsx +802 -0
  155. package/src/web/topics.tsx +160 -0
  156. package/src/web/use-voice-recorder.ts +264 -0
  157. package/src/web/video.tsx +522 -0
  158. package/src/web/voice-note.tsx +197 -0
  159. package/dist/chunk-27UTR5FW.js +0 -322
  160. package/dist/chunk-27UTR5FW.js.map +0 -1
  161. package/dist/chunk-JQXZUEEL.js.map +0 -1
  162. package/dist/chunk-SAY24E6V.js.map +0 -1
@@ -0,0 +1,122 @@
1
+ /** @feat:chat */
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import type { ChatClient } from "../core/client";
4
+ import { randomId } from "../core/media";
5
+ import { createOutbox, memoryOutboxStore, type QueuedMessage } from "../core/outbox";
6
+
7
+ const tick = () => new Promise((r) => setTimeout(r, 0));
8
+
9
+ function fakeClient() {
10
+ const sent: unknown[] = [];
11
+ const client = {
12
+ upload: vi.fn(async (_c: string, _u: string, _d: unknown, o: { name: string }) => `c1/me/${o.name}`),
13
+ send: vi.fn(async (input: { clientId: string; conversationId: string }) => {
14
+ sent.push(input);
15
+ return { id: `m-${input.clientId}`, conversationId: input.conversationId, clientId: input.clientId, seq: sent.length } as never;
16
+ }),
17
+ } as unknown as ChatClient;
18
+ return { client, sent };
19
+ }
20
+
21
+ describe("ids", () => {
22
+ const original = globalThis.crypto;
23
+ afterEach(() => Object.defineProperty(globalThis, "crypto", { value: original, configurable: true }));
24
+
25
+ it("makes UUIDs where crypto.randomUUID is missing (some React Native runtimes)", () => {
26
+ Object.defineProperty(globalThis, "crypto", { value: { getRandomValues: original.getRandomValues.bind(original) }, configurable: true });
27
+ const id = randomId();
28
+ expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
29
+ Object.defineProperty(globalThis, "crypto", { value: undefined, configurable: true });
30
+ expect(randomId()).not.toBe(randomId());
31
+ });
32
+
33
+ it("uses the app's id maker when given one", async () => {
34
+ const { client } = fakeClient();
35
+ const outbox = createOutbox({ client, userId: "me", newId: () => "fixed-id" });
36
+ expect(await outbox.send({ conversationId: "c1", body: "hi" })).toBe("fixed-id");
37
+ });
38
+ });
39
+
40
+ describe("files across a relaunch", () => {
41
+ it("says what each pending file is, so the bubble can draw it", async () => {
42
+ const { client } = fakeClient();
43
+ let release!: () => void;
44
+ (client.upload as ReturnType<typeof vi.fn>).mockImplementationOnce(() => new Promise((r) => (release = () => r("c1/me/v.m4a"))));
45
+ const outbox = createOutbox({ client, userId: "me" });
46
+ await outbox.send({ conversationId: "c1" }, [{ data: new Blob(["x"]), name: "v.m4a", contentType: "audio/mp4", meta: { kind: "voice", durationMs: 4200 } }]);
47
+ expect(outbox.pending("c1")[0]!.files).toEqual([{ name: "v.m4a", contentType: "audio/mp4", kind: "voice", meta: { kind: "voice", durationMs: 4200 } }]);
48
+ release();
49
+ });
50
+
51
+ it("never sends a message without the pasted image it lost on a relaunch; it asks for it again", async () => {
52
+ const store = memoryOutboxStore();
53
+ const first = fakeClient();
54
+ (first.client.upload as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("offline"));
55
+ const before = createOutbox({ client: first.client, userId: "me", store });
56
+ const id = await before.send({ conversationId: "c1", body: "look" }, [{ data: new Blob(["png"]), name: "paste.png", contentType: "image/png" }]);
57
+ await tick();
58
+
59
+ const again = fakeClient();
60
+ const after = createOutbox({ client: again.client, userId: "me", store });
61
+ await after.load();
62
+ await after.retryAll();
63
+ await tick();
64
+ expect(again.client.send).not.toHaveBeenCalled();
65
+ expect(after.pending("c1")[0]).toMatchObject({ status: "failed", error: "Attach the file again to send" });
66
+
67
+ await after.reattach(id, [{ data: new Blob(["png"]), name: "paste.png", contentType: "image/png" }]);
68
+ await tick();
69
+ expect(again.sent).toHaveLength(1);
70
+ expect(after.pending("c1")).toHaveLength(0);
71
+ });
72
+
73
+ it("keeps a pasted image across a relaunch when the app can save it", async () => {
74
+ const store = memoryOutboxStore();
75
+ const first = fakeClient();
76
+ (first.client.upload as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("offline"));
77
+ const persistFile = vi.fn(async () => "file:///cache/paste.png");
78
+ const before = createOutbox({ client: first.client, userId: "me", store, persistFile });
79
+ await before.send({ conversationId: "c1" }, [{ data: new Blob(["png"]), name: "paste.png", contentType: "image/png" }]);
80
+ await tick();
81
+ const saved = (await store.load()) as QueuedMessage[];
82
+ expect(saved[0]!.files[0]!.data).toBe("file:///cache/paste.png");
83
+
84
+ const again = fakeClient();
85
+ const readFile = vi.fn(async () => new Blob(["png"]));
86
+ const after = createOutbox({ client: again.client, userId: "me", store, readFile });
87
+ await after.load();
88
+ await after.retryAll();
89
+ await tick();
90
+ expect(readFile).toHaveBeenCalledWith("file:///cache/paste.png");
91
+ expect(again.sent).toHaveLength(1);
92
+ });
93
+ });
94
+
95
+ describe("reattaching only what was lost", () => {
96
+ it("fills the lost slots and keeps the files that survived", async () => {
97
+ const store = memoryOutboxStore();
98
+ const first = fakeClient();
99
+ (first.client.upload as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("offline"));
100
+ const before = createOutbox({ client: first.client, userId: "me", store });
101
+ const id = await before.send({ conversationId: "c1" }, [
102
+ { data: "file:///docs/plan.pdf", name: "plan.pdf", contentType: "application/pdf" },
103
+ { data: new Blob(["png"]), name: "paste.png", contentType: "image/png" },
104
+ ]);
105
+ await tick();
106
+
107
+ const again = fakeClient();
108
+ const after = createOutbox({ client: again.client, userId: "me", store, readFile: async () => new Blob(["pdf"]) });
109
+ await after.load();
110
+ expect(after.pending("c1")[0]!.files!.map((f) => [f.name, Boolean(f.lost)])).toEqual([["plan.pdf", false], ["paste.png", true]]);
111
+
112
+ // Saving again (any change) must not forget which slot was lost.
113
+ await after.retryAll();
114
+ expect(((await store.load()) as QueuedMessage[])[0]!.pendingFiles!.map((f) => Boolean(f.lost))).toEqual([false, true]);
115
+
116
+ await after.reattach(id, [{ data: new Blob(["png2"]), name: "paste-again.png", contentType: "image/png" }]);
117
+ await tick();
118
+ expect(again.client.upload).toHaveBeenCalledTimes(2);
119
+ expect((again.client.upload as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[3].name)).toEqual(["plan.pdf", "paste-again.png"]);
120
+ expect(again.sent).toHaveLength(1);
121
+ });
122
+ });
@@ -0,0 +1,70 @@
1
+ /** @feat:chat */
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { createChatClient, type ChatSupabase } from "../core/client";
4
+
5
+ function fakeRealtime() {
6
+ const order: string[] = [];
7
+ let release!: () => void;
8
+ const authed = new Promise<void>((r) => (release = r));
9
+ const channel = {
10
+ on: vi.fn(() => channel),
11
+ status: undefined as undefined | ((s: string) => void),
12
+ subscribe: vi.fn((cb?: (s: string) => void) => {
13
+ order.push("subscribe");
14
+ channel.status = cb;
15
+ return channel;
16
+ }),
17
+ send: vi.fn(),
18
+ };
19
+ const supabase = {
20
+ realtime: {
21
+ setAuth: vi.fn(async () => {
22
+ await authed;
23
+ order.push("setAuth");
24
+ }),
25
+ },
26
+ channel: vi.fn(() => channel),
27
+ removeChannel: vi.fn(),
28
+ schema: vi.fn(),
29
+ } as unknown as ChatSupabase;
30
+ return { supabase, channel, order, release };
31
+ }
32
+
33
+ const tick = () => new Promise((r) => setTimeout(r, 0));
34
+
35
+ describe("joining private topics", () => {
36
+ it("sets the user's token before joining, so a private join isn't refused", async () => {
37
+ const f = fakeRealtime();
38
+ createChatClient(f.supabase).subscribeConversation("c1", "me", { onSignal: () => {} });
39
+ await tick();
40
+ expect(f.channel.subscribe).not.toHaveBeenCalled();
41
+ f.release();
42
+ await tick();
43
+ expect(f.order).toEqual(["setAuth", "subscribe"]);
44
+ });
45
+
46
+ it("never joins a channel left before its token was set", async () => {
47
+ const f = fakeRealtime();
48
+ const sub = createChatClient(f.supabase).subscribeInbox("me", () => {});
49
+ sub.unsubscribe();
50
+ f.release();
51
+ await tick();
52
+ expect(f.channel.subscribe).not.toHaveBeenCalled();
53
+ expect(f.supabase.removeChannel).toHaveBeenCalled();
54
+ });
55
+
56
+ it("drops typing signals until the channel has joined (no REST fallback)", async () => {
57
+ const f = fakeRealtime();
58
+ const ch = createChatClient(f.supabase).subscribeConversation("c1", "me", { onSignal: () => {} });
59
+ f.release();
60
+ await tick();
61
+ ch.signal("typing");
62
+ expect(f.channel.send).not.toHaveBeenCalled();
63
+ f.channel.status?.("SUBSCRIBED");
64
+ ch.signal("typing");
65
+ expect(f.channel.send).toHaveBeenCalledTimes(1);
66
+ f.channel.status?.("CHANNEL_ERROR");
67
+ ch.signal("typing");
68
+ expect(f.channel.send).toHaveBeenCalledTimes(1);
69
+ });
70
+ });
@@ -0,0 +1,193 @@
1
+ /**
2
+ * @feat:chat
3
+ * Fixes from the review of the phases 2–4 client: malformed working hours, the outbox on
4
+ * sign-out, retry order and refusals, and who owns a preview URL.
5
+ */
6
+ import { describe, expect, it, vi } from "vitest";
7
+ import { awayLine, awaySummary, hoursSummary, isWithinWorkingHours, normalizeHours } from "../core/availability";
8
+ import { ChatError, toAvailability, type ChatClient } from "../core/client";
9
+ import { createOutbox, type QueuedMessage } from "../core/outbox";
10
+ import { createMemoryChatClient } from "../web/memory-client";
11
+
12
+ const ME = "00000000-0000-4000-8000-0000000000a1";
13
+ const SAM = "00000000-0000-4000-8000-0000000000b2";
14
+ const tick = (ms = 0) => new Promise((r) => setTimeout(r, ms));
15
+
16
+ function deferred<T = void>() {
17
+ let resolve!: (v: T) => void;
18
+ const promise = new Promise<T>((r) => (resolve = r));
19
+ return { promise, resolve };
20
+ }
21
+
22
+ describe("someone else's malformed working hours", () => {
23
+ // What a member could store before the database checked it (or the server could still write).
24
+ const bad = {
25
+ mon: [[9, 17]],
26
+ tue: 5,
27
+ wed: [["09:00", "17:00"], ["x"], null, ["1:00", "2:00"], ["3:00", "4:00"], ["5:00", "6:00"], ["7:00", "8:00"]],
28
+ thu: [["9am", "5pm"]],
29
+ funday: [["09:00", "10:00"]],
30
+ constructor: [["09:00", "10:00"]],
31
+ } as never;
32
+
33
+ it("keeps only mon..sun days of HH:MM string pairs, at most four a day", () => {
34
+ expect(toAvailability({ user_id: SAM, hours: bad }).hours).toEqual({ wed: [["09:00", "17:00"], ["1:00", "2:00"], ["3:00", "4:00"], ["5:00", "6:00"]] });
35
+ expect(normalizeHours("nope")).toBeNull();
36
+ expect(normalizeHours([["09:00", "17:00"]])).toBeNull();
37
+ expect(normalizeHours({ mon: [[9, 17]] })).toBeNull();
38
+ });
39
+
40
+ it("never makes the helpers throw", () => {
41
+ const a = { userId: SAM, awayMessage: null, awayUntil: null, timeZone: "UTC", hours: bad };
42
+ const now = new Date("2026-10-28T12:00:00Z"); // a Wednesday
43
+ expect(() => isWithinWorkingHours(bad, "UTC", now)).not.toThrow();
44
+ expect(isWithinWorkingHours(bad, "UTC", now)).toBe(true);
45
+ expect(hoursSummary(bad)).toBe("Wed, 9–5, 1–2, 3–4, 5–6");
46
+ expect(() => awayLine(a, "Sam", { now, always: true })).not.toThrow();
47
+ expect(() => awaySummary([{ availability: a, name: "Sam" }, { availability: { ...a, hours: { tue: 5 } as never }, name: "Lee" }], { now })).not.toThrow();
48
+ // An away date read in a zone Intl doesn't know falls back to the device's.
49
+ const away = { ...a, awayMessage: "Off", awayUntil: new Date(now.getTime() + 86_400_000).toISOString(), timeZone: "Mars/Olympus" };
50
+ expect(awayLine(away, "Sam", { now })).toMatch(/^Away until .+: Off$/);
51
+ });
52
+ });
53
+
54
+ describe("clearing the outbox (sign-out) mid-send", () => {
55
+ it("stops a send in flight: nothing posts after clear()", async () => {
56
+ const gate = deferred();
57
+ const sent: string[] = [];
58
+ const client = { send: vi.fn(async (i: { body: string }) => (await gate.promise, sent.push(i.body), { id: "x" })), upload: vi.fn() } as unknown as ChatClient;
59
+ const store = { load: async () => [], save: vi.fn(async (_q: QueuedMessage[]) => undefined) };
60
+ const ob = createOutbox({ client, userId: ME, store });
61
+ await ob.send({ conversationId: "c1", body: "from user A" });
62
+ await tick();
63
+ await ob.clear();
64
+ const saves = store.save.mock.calls.length;
65
+ gate.resolve();
66
+ await tick(10);
67
+ // The request had already gone, but the outbox records and reports nothing of it.
68
+ expect(store.save.mock.calls.length).toBe(saves);
69
+ expect(ob.pending()).toEqual([]);
70
+ });
71
+
72
+ it("never sends a message whose upload was in flight, and aborts that upload", async () => {
73
+ const gate = deferred<string>();
74
+ let signal: AbortSignal | undefined;
75
+ const client = { send: vi.fn(async () => ({ id: "x" })) } as unknown as ChatClient;
76
+ const upload = vi.fn(async (_c: string, _f: unknown, o?: { signal?: AbortSignal }) => ((signal = o?.signal), gate.promise));
77
+ const ob = createOutbox({ client, userId: ME, upload });
78
+ await ob.send({ conversationId: "c1", body: "with a file" }, [{ data: new Blob(["x"]), name: "a.png", contentType: "image/png" }]);
79
+ await tick();
80
+ await ob.clear();
81
+ expect(signal?.aborted).toBe(true);
82
+ gate.resolve("c1/me/a.png");
83
+ await tick(10);
84
+ expect(client.send).not.toHaveBeenCalled();
85
+
86
+ // The next session's messages to the same conversation still go.
87
+ await ob.send({ conversationId: "c1", body: "user B" });
88
+ await tick(10);
89
+ expect(client.send).toHaveBeenCalledTimes(1);
90
+ expect((client.send as ReturnType<typeof vi.fn>).mock.calls[0]![0]).toMatchObject({ body: "user B" });
91
+ });
92
+
93
+ it("aborts the upload of a discarded message and never sends it", async () => {
94
+ const gate = deferred<string>();
95
+ let signal: AbortSignal | undefined;
96
+ const client = { send: vi.fn(async () => ({ id: "x" })) } as unknown as ChatClient;
97
+ const ob = createOutbox({ client, userId: ME, upload: async (_c, _f, o) => ((signal = o?.signal), gate.promise) });
98
+ const id = await ob.send({ conversationId: "c1", body: "never mind" }, [{ data: new Blob(["x"]), name: "a.png", contentType: "image/png" }]);
99
+ await tick();
100
+ await ob.discard(id);
101
+ expect(signal?.aborted).toBe(true);
102
+ gate.resolve("p");
103
+ await tick(10);
104
+ expect(client.send).not.toHaveBeenCalled();
105
+ });
106
+ });
107
+
108
+ describe("retry order and refusals", () => {
109
+ const queued = (clientId: string, body: string, extra: Partial<QueuedMessage> = {}): QueuedMessage => ({
110
+ clientId,
111
+ input: { conversationId: "c1", clientId, body },
112
+ senderId: ME,
113
+ createdAt: "",
114
+ status: "failed",
115
+ previews: {},
116
+ files: [],
117
+ attempts: 1,
118
+ ...extra,
119
+ });
120
+
121
+ it("retryAll never lets a later message overtake one waiting for a lost file", async () => {
122
+ const order: string[] = [];
123
+ const lost = { name: "x.jpg", contentType: "image/jpeg", kind: "image" as const, lost: true };
124
+ const store = { load: async () => [queued("a", "first", { pendingFiles: [lost] }), queued("b", "second")], save: async () => undefined };
125
+ const client = { send: async (i: { body: string; clientId: string }) => (order.push(i.body), { id: i.clientId }), upload: async () => "c1/me/x.jpg" } as unknown as ChatClient;
126
+ const ob = createOutbox({ client, userId: ME, store });
127
+ await ob.retryAll();
128
+ await tick(10);
129
+ expect(order).toEqual([]);
130
+ expect(ob.pending("c1").map((p) => p.status)).toEqual(["failed", "failed"]);
131
+
132
+ await ob.reattach("a", [{ data: new Blob(["jpg"]), name: "x.jpg", contentType: "image/jpeg" }]);
133
+ await tick(10);
134
+ await ob.retryAll();
135
+ await tick(10);
136
+ expect(order).toEqual(["first", "second"]);
137
+ });
138
+
139
+ it("marks a refusal permanent: retryAll skips it (and what follows); a manual retry sends it", async () => {
140
+ const send = vi.fn(async (_i: { body: string }) => {
141
+ throw new ChatError("You can't post here", "42501", "send");
142
+ });
143
+ const ob = createOutbox({ client: { send } as unknown as ChatClient, userId: ME });
144
+ const id = await ob.send({ conversationId: "c1", body: "one" });
145
+ await tick(10);
146
+ expect(ob.pending("c1")[0]).toMatchObject({ status: "failed", permanent: true, error: "You can't post here" });
147
+ await ob.retryAll();
148
+ await ob.retryAll();
149
+ await tick(10);
150
+ expect(send).toHaveBeenCalledTimes(1);
151
+
152
+ send.mockImplementationOnce(async () => ({ id: "m" }) as never);
153
+ await ob.retry(id);
154
+ await tick(10);
155
+ expect(send).toHaveBeenCalledTimes(2);
156
+ expect(ob.pending("c1")).toEqual([]);
157
+ });
158
+
159
+ it("classes database refusals as permanent and network trouble as not", () => {
160
+ for (const code of ["42501", "22023", "23505", "P0001"]) expect(new ChatError("x", code, "send").permanent).toBe(true);
161
+ for (const code of [undefined, "08006", "PGRST000", "GS429", "GSFILE"]) expect(new ChatError("x", code, "send").permanent).toBe(false);
162
+ });
163
+ });
164
+
165
+ describe("preview URLs", () => {
166
+ it("are let go only once the message has landed (or was discarded)", async () => {
167
+ const gate = deferred();
168
+ const releasePreview = vi.fn();
169
+ const client = { send: async () => (await gate.promise, { id: "m" }) } as unknown as ChatClient;
170
+ const ob = createOutbox({ client, userId: ME, releasePreview, upload: async () => "c1/me/v.webm" });
171
+ await ob.send({ conversationId: "c1" }, [{ data: new Blob(["v"]), name: "v.webm", contentType: "video/webm", previewUrl: "blob:take" }]);
172
+ await tick(10);
173
+ expect(releasePreview).not.toHaveBeenCalled();
174
+ gate.resolve();
175
+ await tick(10);
176
+ expect(releasePreview).toHaveBeenCalledWith("blob:take");
177
+
178
+ const id = await ob.send({ conversationId: "c2" }, [{ data: new Blob(["p"]), name: "p.png", contentType: "image/png", previewUrl: "blob:photo" }]);
179
+ await ob.discard(id);
180
+ expect(releasePreview).toHaveBeenCalledWith("blob:photo");
181
+ });
182
+ });
183
+
184
+ describe("search snippets", () => {
185
+ it("can't be faked by a message's own « and »", async () => {
186
+ const client = createMemoryChatClient({
187
+ me: ME,
188
+ conversations: [{ id: "c1", members: [{ userId: ME }, { userId: SAM }], messages: [{ id: "m", senderId: SAM, body: "a «fake» mark near quilt", createdAt: new Date().toISOString() }] }],
189
+ });
190
+ const [hit] = await client.search("quilt");
191
+ expect(hit!.snippet).toBe("a ‹fake› mark near «quilt»");
192
+ });
193
+ });