@marktiderman/genesis-chat 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/README.md +379 -13
  2. package/dist/chunk-A4YJX7KT.js +544 -0
  3. package/dist/chunk-A4YJX7KT.js.map +1 -0
  4. package/dist/chunk-AZS3HNLM.js +30 -0
  5. package/dist/chunk-AZS3HNLM.js.map +1 -0
  6. package/dist/chunk-BJCE5O7V.js +70 -0
  7. package/dist/chunk-BJCE5O7V.js.map +1 -0
  8. package/dist/chunk-GBJ3YI4E.js +966 -0
  9. package/dist/chunk-GBJ3YI4E.js.map +1 -0
  10. package/dist/chunk-JU2FAPBI.js +609 -0
  11. package/dist/chunk-JU2FAPBI.js.map +1 -0
  12. package/dist/chunk-VEPZROQB.js +187 -0
  13. package/dist/chunk-VEPZROQB.js.map +1 -0
  14. package/dist/{client-BtdALFCx.d.ts → client-DUIG1hum.d.ts} +187 -11
  15. package/dist/index.d.ts +78 -163
  16. package/dist/index.js +3 -2
  17. package/dist/native.d.ts +1939 -1
  18. package/dist/native.js +5023 -0
  19. package/dist/native.js.map +1 -1
  20. package/dist/outbox-B9NAWQ58.d.ts +219 -0
  21. package/dist/push.d.ts +206 -0
  22. package/dist/push.js +3 -0
  23. package/dist/push.js.map +1 -0
  24. package/dist/react.d.ts +176 -7
  25. package/dist/react.js +3 -3
  26. package/dist/server.d.ts +551 -21
  27. package/dist/server.js +976 -34
  28. package/dist/server.js.map +1 -1
  29. package/dist/thread-oPra7pkW.d.ts +57 -0
  30. package/dist/web.d.ts +335 -12
  31. package/dist/web.js +1784 -64
  32. package/dist/web.js.map +1 -1
  33. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  34. package/migrations/0003_genesis_chat_search_threads.sql +85 -0
  35. package/migrations/tests/0002_genesis_chat_coaching.test.sql +269 -0
  36. package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
  37. package/package.json +78 -6
  38. package/src/__tests__/coaching-core.test.ts +334 -0
  39. package/src/__tests__/coaching-react.test.tsx +319 -0
  40. package/src/__tests__/coaching-web.test.tsx +521 -0
  41. package/src/__tests__/core.test.ts +91 -0
  42. package/src/__tests__/fake-xhr.ts +54 -0
  43. package/src/__tests__/outbox-hosted.test.ts +269 -0
  44. package/src/__tests__/outbox.test.ts +122 -0
  45. package/src/__tests__/realtime.test.ts +70 -0
  46. package/src/__tests__/review-279.test.ts +41 -0
  47. package/src/__tests__/review-fixes-core.test.ts +193 -0
  48. package/src/__tests__/review-fixes-web.test.tsx +405 -0
  49. package/src/__tests__/server-effects.test.ts +158 -0
  50. package/src/__tests__/server-fake-video.test.ts +81 -0
  51. package/src/__tests__/server-fakes.ts +96 -0
  52. package/src/__tests__/server-integrations.test.ts +247 -0
  53. package/src/__tests__/server-providers.test.ts +246 -0
  54. package/src/__tests__/server-runner.test.ts +277 -0
  55. package/src/__tests__/server-safety.test.ts +303 -0
  56. package/src/__tests__/web-markdown.test.tsx +88 -0
  57. package/src/__tests__/web.test.tsx +349 -0
  58. package/src/core/availability.ts +223 -0
  59. package/src/core/client.ts +783 -0
  60. package/src/core/extras.ts +106 -0
  61. package/src/core/media.ts +144 -0
  62. package/src/core/outbox.ts +635 -0
  63. package/src/core/thread.ts +171 -0
  64. package/src/core/transfer.ts +71 -0
  65. package/src/core/types.ts +328 -0
  66. package/src/index.ts +14 -0
  67. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  68. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  69. package/src/native/__tests__/composer-state.test.ts +188 -0
  70. package/src/native/__tests__/composer.test.tsx +133 -0
  71. package/src/native/__tests__/emoji.test.tsx +254 -0
  72. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  73. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  74. package/src/native/__tests__/organise-logic.test.ts +164 -0
  75. package/src/native/__tests__/organise-screens.test.tsx +424 -0
  76. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  77. package/src/native/__tests__/push-payload.test.ts +66 -0
  78. package/src/native/__tests__/push.test.ts +209 -0
  79. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  80. package/src/native/__tests__/rows-actions.test.ts +76 -0
  81. package/src/native/__tests__/thread-layout.test.ts +41 -0
  82. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  83. package/src/native/__tests__/tus.test.ts +154 -0
  84. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  85. package/src/native/__tests__/video-media.test.tsx +498 -0
  86. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  87. package/src/native/__tests__/video-upload.test.ts +227 -0
  88. package/src/native/access-error.tsx +31 -0
  89. package/src/native/actions.ts +42 -0
  90. package/src/native/attachment-cards.tsx +132 -0
  91. package/src/native/auto-retry.ts +69 -0
  92. package/src/native/availability.tsx +376 -0
  93. package/src/native/broadcast-sheet.tsx +193 -0
  94. package/src/native/composer-state.ts +302 -0
  95. package/src/native/composer.tsx +453 -0
  96. package/src/native/context.tsx +70 -0
  97. package/src/native/copy.ts +296 -0
  98. package/src/native/emoji-data.ts +21 -0
  99. package/src/native/emoji-picker.tsx +229 -0
  100. package/src/native/emoji.ts +118 -0
  101. package/src/native/icon-button.tsx +40 -0
  102. package/src/native/inbox-screen.tsx +122 -0
  103. package/src/native/index.ts +184 -0
  104. package/src/native/link-preview.tsx +104 -0
  105. package/src/native/media-colors.ts +15 -0
  106. package/src/native/media.tsx +187 -0
  107. package/src/native/message-actions-sheet.tsx +126 -0
  108. package/src/native/message-bubble.tsx +366 -0
  109. package/src/native/moderation.tsx +42 -0
  110. package/src/native/modules.ts +277 -0
  111. package/src/native/organise-copy.ts +195 -0
  112. package/src/native/photo-viewer.tsx +531 -0
  113. package/src/native/push.ts +375 -0
  114. package/src/native/recorder-machine.ts +176 -0
  115. package/src/native/rows.ts +81 -0
  116. package/src/native/schedule-sheet.tsx +328 -0
  117. package/src/native/search-screen.tsx +189 -0
  118. package/src/native/stack-options.ts +36 -0
  119. package/src/native/start-sheet.tsx +87 -0
  120. package/src/native/thread-screen.tsx +647 -0
  121. package/src/native/topics.tsx +234 -0
  122. package/src/native/tus.ts +282 -0
  123. package/src/native/use-composer.ts +71 -0
  124. package/src/native/use-jump.ts +91 -0
  125. package/src/native/use-voice-recorder.ts +234 -0
  126. package/src/native/video-player.tsx +410 -0
  127. package/src/native/video-recorder-state.ts +140 -0
  128. package/src/native/video-recorder.tsx +274 -0
  129. package/src/native/video-upload-tray.tsx +77 -0
  130. package/src/native/video-upload.ts +432 -0
  131. package/src/native/voice-player.tsx +301 -0
  132. package/src/react/index.tsx +894 -0
  133. package/src/react/previews.ts +51 -0
  134. package/src/server/assistant.ts +235 -0
  135. package/src/server/crypto.ts +86 -0
  136. package/src/server/expo.ts +119 -0
  137. package/src/server/fake-video.ts +171 -0
  138. package/src/server/handler.ts +32 -0
  139. package/src/server/index.ts +58 -0
  140. package/src/server/moderation.ts +155 -0
  141. package/src/server/mux.ts +290 -0
  142. package/src/server/notifications.ts +130 -0
  143. package/src/server/runner.ts +468 -0
  144. package/src/server/transcribers.ts +111 -0
  145. package/src/server/types.ts +171 -0
  146. package/src/server/unfurl.ts +480 -0
  147. package/src/server/upload.ts +158 -0
  148. package/src/web/attachments.tsx +379 -0
  149. package/src/web/availability.tsx +299 -0
  150. package/src/web/broadcast.tsx +166 -0
  151. package/src/web/bubble.tsx +585 -0
  152. package/src/web/cards.tsx +136 -0
  153. package/src/web/composer.tsx +571 -0
  154. package/src/web/emoji-picker.tsx +84 -0
  155. package/src/web/error-boundary.tsx +33 -0
  156. package/src/web/format.ts +55 -0
  157. package/src/web/inbox.tsx +196 -0
  158. package/src/web/index.ts +75 -0
  159. package/src/web/markdown.tsx +245 -0
  160. package/src/web/memory-client.ts +714 -0
  161. package/src/web/panel.tsx +135 -0
  162. package/src/web/schedule.tsx +204 -0
  163. package/src/web/search.tsx +129 -0
  164. package/src/web/thread-context.ts +64 -0
  165. package/src/web/thread.tsx +830 -0
  166. package/src/web/topics.tsx +160 -0
  167. package/src/web/use-voice-recorder.ts +264 -0
  168. package/src/web/video.tsx +516 -0
  169. package/src/web/voice-note.tsx +197 -0
  170. package/dist/chunk-27UTR5FW.js +0 -322
  171. package/dist/chunk-27UTR5FW.js.map +0 -1
  172. package/dist/chunk-JQXZUEEL.js +0 -525
  173. package/dist/chunk-JQXZUEEL.js.map +0 -1
  174. package/dist/chunk-SAY24E6V.js +0 -309
  175. package/dist/chunk-SAY24E6V.js.map +0 -1
@@ -0,0 +1,227 @@
1
+ // @feat:chat
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { chunkedPut, createVideoUploads, nextOffset, type ByteSource } from "../video-upload";
4
+
5
+ const KIB = 1024;
6
+ const bytes = (n: number): ByteSource => {
7
+ const buf = new Uint8Array(n).map((_, i) => i % 251).buffer;
8
+ return { size: n, read: async (a, b) => buf.slice(a, b), close: vi.fn() };
9
+ };
10
+ const res = (status: number, headers: Record<string, string> = {}) => new Response(status === 204 ? null : "", { status, headers });
11
+ const noSleep = async () => undefined;
12
+ const flush = () => new Promise((r) => setTimeout(r, 0));
13
+
14
+ /** A resumable upload server (the protocol Mux direct uploads speak): keeps what it receives. */
15
+ function server(total: number, opts: { dropAt?: number[]; refuse?: number } = {}) {
16
+ let have = 0;
17
+ const calls: { range: string; size: number }[] = [];
18
+ let n = 0;
19
+ const fetch = vi.fn(async (_url: string, init: RequestInit) => {
20
+ const range = String((init.headers as Record<string, string>)["content-range"]);
21
+ const size = init.body ? (init.body as ArrayBuffer).byteLength : 0;
22
+ calls.push({ range, size });
23
+ const call = n++;
24
+ if (opts.refuse !== undefined) return res(opts.refuse);
25
+ if (range.startsWith("bytes */")) return have >= total ? res(200) : res(308, have ? { range: `bytes=0-${have - 1}` } : {});
26
+ if (opts.dropAt?.includes(call)) throw new TypeError("Network request failed");
27
+ const [, from, to] = /bytes (\d+)-(\d+)\//.exec(range)!.map(Number);
28
+ if (from !== have) return res(308, have ? { range: `bytes=0-${have - 1}` } : {});
29
+ have = to! + 1;
30
+ return have >= total ? res(201) : res(308, { range: `bytes=0-${have - 1}` });
31
+ });
32
+ return { fetch: fetch as unknown as typeof globalThis.fetch, calls, have: () => have };
33
+ }
34
+
35
+ describe("chunkedPut (resumable PUT to a direct upload URL)", () => {
36
+ it("sends 256 KiB-multiple chunks with Content-Range and reports progress", async () => {
37
+ const s = server(600 * KIB);
38
+ const progress: number[] = [];
39
+ await chunkedPut({ url: "https://up", contentType: "video/quicktime", source: bytes(600 * KIB), fetch: s.fetch, chunkBytes: 256 * KIB, onProgress: (sent) => progress.push(sent), sleep: noSleep });
40
+ expect(s.calls.map((c) => c.range)).toEqual([`bytes 0-262143/614400`, `bytes 262144-524287/614400`, `bytes 524288-614399/614400`]);
41
+ expect(progress).toEqual([0, 262144, 524288, 614400]);
42
+ expect(s.have()).toBe(614400);
43
+ });
44
+
45
+ it("after a dropped connection asks where the server is and carries on from there", async () => {
46
+ const s = server(600 * KIB, { dropAt: [1] });
47
+ await chunkedPut({ url: "https://up", contentType: "video/mp4", source: bytes(600 * KIB), fetch: s.fetch, chunkBytes: 256 * KIB, sleep: noSleep });
48
+ expect(s.calls.map((c) => c.range)).toEqual([`bytes 0-262143/614400`, `bytes 262144-524287/614400`, `bytes */614400`, `bytes 262144-524287/614400`, `bytes 524288-614399/614400`]);
49
+ expect(s.have()).toBe(614400);
50
+ });
51
+
52
+ it("resumes an upload that got partway (a Retry) without sending the start again", async () => {
53
+ const first = server(512 * KIB);
54
+ await chunkedPut({ url: "u", contentType: "video/mp4", source: bytes(512 * KIB), fetch: first.fetch, chunkBytes: 256 * KIB, sleep: noSleep, isCancelled: () => first.have() >= 256 * KIB }).catch(() => undefined);
55
+ expect(first.have()).toBe(256 * KIB);
56
+ first.calls.length = 0;
57
+ await chunkedPut({ url: "u", contentType: "video/mp4", source: bytes(512 * KIB), fetch: first.fetch, chunkBytes: 256 * KIB, sleep: noSleep, resume: true });
58
+ expect(first.calls.map((c) => c.range)).toEqual([`bytes */524288`, `bytes 262144-524287/524288`]);
59
+ });
60
+
61
+ it("gives up on a refusal at once, and marks an expired upload URL so Retry mints another", async () => {
62
+ const refused = server(10, { refuse: 403 });
63
+ await expect(chunkedPut({ url: "u", contentType: "video/mp4", source: bytes(10), fetch: refused.fetch, sleep: noSleep })).rejects.toMatchObject({ code: "42501" });
64
+ expect(refused.calls).toHaveLength(1);
65
+ const gone = server(10, { refuse: 410 });
66
+ await expect(chunkedPut({ url: "u", contentType: "video/mp4", source: bytes(10), fetch: gone.fetch, sleep: noSleep })).rejects.toMatchObject({ code: "P0002" });
67
+ });
68
+
69
+ it("fails after its retries when the network stays down", async () => {
70
+ const down = vi.fn(async () => {
71
+ throw new TypeError("Network request failed");
72
+ }) as unknown as typeof fetch;
73
+ await expect(chunkedPut({ url: "u", contentType: "video/mp4", source: bytes(10), fetch: down, retries: 2, sleep: noSleep })).rejects.toThrow(/connection/);
74
+ });
75
+
76
+ it("ends in an error, not a loop, when the server answers 308 without moving forward", async () => {
77
+ const stuck = vi.fn(async () => res(308)) as unknown as typeof fetch;
78
+ await expect(chunkedPut({ url: "u", contentType: "video/mp4", source: bytes(10), fetch: stuck, retries: 3, sleep: noSleep })).rejects.toThrow(/out of step/);
79
+ expect((stuck as unknown as { mock: { calls: unknown[] } }).mock.calls.length).toBe(4);
80
+ });
81
+
82
+ it("refuses a server that calls a middle chunk the whole upload (it kept only part)", async () => {
83
+ const plain = vi.fn(async () => res(200)) as unknown as typeof fetch;
84
+ await expect(chunkedPut({ url: "u", contentType: "video/mp4", source: bytes(512 * KIB), fetch: plain, chunkBytes: 256 * KIB, sleep: noSleep })).rejects.toThrow(/only part/);
85
+ });
86
+
87
+ it("reads Range headers", () => {
88
+ expect(nextOffset("bytes=0-262143")).toBe(262144);
89
+ expect(nextOffset(null)).toBe(0);
90
+ });
91
+ });
92
+
93
+ describe("video uploads in flight", () => {
94
+ const video = { uri: "file:///take.mov", name: "video-1.mov", contentType: "video/quicktime", durationMs: 4200 };
95
+ const read = async () => new Response(new Uint8Array(100));
96
+
97
+ it("with videoUpload: uploads to the minted URL, then sends the pending Mux attachment through the outbox", async () => {
98
+ const s = server(100);
99
+ const fetch = vi.fn(async (url: string, init?: RequestInit) => (url.startsWith("file:") ? read() : s.fetch(url, init!))) as unknown as typeof globalThis.fetch;
100
+ const send = vi.fn(async () => "client-1");
101
+ const create = vi.fn(async () => ({ url: "https://storage.googleapis.com/up", providerId: "upl_1", provider: "mux" as const }));
102
+ const uploads = createVideoUploads(() => "t1");
103
+ const seen: number[] = [];
104
+ uploads.subscribe(() => seen.push(uploads.list("c")[0]?.progress ?? -1));
105
+ uploads.start({ conversationId: "c", video, extra: { replyToId: "m9", threadRootId: null }, send, videoUpload: { create }, fetch, sleep: noSleep });
106
+ await vi.waitFor(() => expect(send).toHaveBeenCalled());
107
+ expect(create).toHaveBeenCalledWith("c");
108
+ expect(send).toHaveBeenCalledWith({
109
+ conversationId: "c",
110
+ replyToId: "m9",
111
+ threadRootId: null,
112
+ body: "",
113
+ attachments: [{ kind: "video", provider: "mux", providerId: "upl_1", status: "pending", name: "video-1.mov", mime: "video/quicktime", bytes: 100, durationMs: 4200 }],
114
+ });
115
+ expect(uploads.list("c")).toEqual([]);
116
+ expect(seen).toContain(1);
117
+ });
118
+
119
+ it("uses the upload minted when recording started", async () => {
120
+ const s = server(100);
121
+ const fetch = vi.fn(async (url: string, init?: RequestInit) => (url.startsWith("file:") ? read() : s.fetch(url, init!))) as unknown as typeof globalThis.fetch;
122
+ const create = vi.fn();
123
+ const send = vi.fn(async () => "x");
124
+ createVideoUploads().start({ conversationId: "c", video, send, videoUpload: { create }, prepared: Promise.resolve({ url: "https://up", providerId: "upl_p", provider: "mux" }), fetch, sleep: noSleep });
125
+ await vi.waitFor(() => expect(send).toHaveBeenCalled());
126
+ expect(create).not.toHaveBeenCalled();
127
+ expect((send.mock.calls[0] as unknown[])[0]).toMatchObject({ attachments: [{ providerId: "upl_p" }] });
128
+ });
129
+
130
+ it("a failed upload shows as failed, and Retry resumes it on the same upload", async () => {
131
+ let up = true;
132
+ const s = server(100);
133
+ const fetch = vi.fn(async (url: string, init?: RequestInit) => {
134
+ if (url.startsWith("file:")) return read();
135
+ if (!up) throw new TypeError("Network request failed");
136
+ return s.fetch(url, init!);
137
+ }) as unknown as typeof globalThis.fetch;
138
+ up = false;
139
+ const send = vi.fn(async () => "x");
140
+ const create = vi.fn(async () => ({ url: "https://up", providerId: "upl_1", provider: "mux" as const }));
141
+ const uploads = createVideoUploads(() => "t1");
142
+ uploads.start({ conversationId: "c", video, send, videoUpload: { create }, fetch, sleep: noSleep });
143
+ await vi.waitFor(() => expect(uploads.list("c")[0]?.status).toBe("failed"));
144
+ expect(uploads.list("c")[0]?.error).toBe("offline");
145
+ up = true;
146
+ uploads.retry("t1");
147
+ await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1));
148
+ expect(create).toHaveBeenCalledTimes(1);
149
+ expect(s.calls[0]?.range).toBe("bytes */100");
150
+ });
151
+
152
+ it("an outbox that won't take the message leaves it failed, and Retry sends it without uploading again", async () => {
153
+ const s = server(100);
154
+ const fetch = vi.fn(async (url: string, init?: RequestInit) => (url.startsWith("file:") ? read() : s.fetch(url, init!))) as unknown as typeof globalThis.fetch;
155
+ const send = vi.fn().mockRejectedValueOnce(new Error("storage full")).mockResolvedValue("x");
156
+ const uploads = createVideoUploads(() => "t1");
157
+ uploads.start({ conversationId: "c", video, send, videoUpload: { create: async () => ({ url: "https://up", providerId: "p", provider: "mux" }) }, fetch, sleep: noSleep });
158
+ await vi.waitFor(() => expect(uploads.list("c")[0]?.status).toBe("failed"));
159
+ const puts = s.calls.length;
160
+ uploads.retry("t1");
161
+ await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(2));
162
+ expect(s.calls.length).toBe(puts);
163
+ expect(uploads.list("c")).toEqual([]);
164
+ });
165
+
166
+ it("mints a fresh upload, once and by itself, when the one minted at the start has expired", async () => {
167
+ const live = server(100);
168
+ const fetch = vi.fn(async (url: string, init?: RequestInit) => {
169
+ if (url.startsWith("file:")) return read();
170
+ if (url === "https://expired") return res(410);
171
+ return live.fetch(url, init!);
172
+ }) as unknown as typeof globalThis.fetch;
173
+ const create = vi.fn(async () => ({ url: "https://fresh", providerId: "upl_2", provider: "mux" as const }));
174
+ const send = vi.fn(async () => "x");
175
+ createVideoUploads().start({ conversationId: "c", video, send, videoUpload: { create }, prepared: Promise.resolve({ url: "https://expired", providerId: "upl_1", provider: "mux" }), fetch, sleep: noSleep });
176
+ await vi.waitFor(() => expect(send).toHaveBeenCalled());
177
+ expect(create).toHaveBeenCalledTimes(1);
178
+ expect((send.mock.calls[0] as unknown[])[0]).toMatchObject({ attachments: [{ providerId: "upl_2" }] });
179
+ });
180
+
181
+ it("cancelAll (sign-out) stops every upload, and none is sent", async () => {
182
+ let release!: () => void;
183
+ const gate = new Promise<void>((r) => (release = r));
184
+ const fetch = vi.fn(async (url: string) => (url.startsWith("file:") ? read() : (await gate, res(201)))) as unknown as typeof globalThis.fetch;
185
+ const send = vi.fn();
186
+ const uploads = createVideoUploads();
187
+ const upload = { create: async () => ({ url: "https://up", providerId: "p", provider: "mux" as const }) };
188
+ uploads.start({ conversationId: "c", userId: "u1", video, send, videoUpload: upload, fetch, sleep: noSleep });
189
+ uploads.start({ conversationId: "d", userId: "u1", video, send, videoUpload: upload, fetch, sleep: noSleep });
190
+ await flush();
191
+ uploads.cancelAll();
192
+ release();
193
+ await flush();
194
+ await flush();
195
+ expect(uploads.list()).toEqual([]);
196
+ expect(send).not.toHaveBeenCalled();
197
+ });
198
+
199
+ it("Cancel stops it and nothing is sent", async () => {
200
+ let release!: () => void;
201
+ const gate = new Promise<void>((r) => (release = r));
202
+ const fetch = vi.fn(async (url: string) => {
203
+ if (url.startsWith("file:")) return read();
204
+ await gate;
205
+ return res(201);
206
+ }) as unknown as typeof globalThis.fetch;
207
+ const send = vi.fn();
208
+ const uploads = createVideoUploads(() => "t1");
209
+ uploads.start({ conversationId: "c", video, send, videoUpload: { create: async () => ({ url: "https://up", providerId: "p", provider: "mux" }) }, fetch, sleep: noSleep });
210
+ await flush();
211
+ uploads.cancel("t1");
212
+ release();
213
+ await flush();
214
+ await flush();
215
+ expect(uploads.list("c")).toEqual([]);
216
+ expect(send).not.toHaveBeenCalled();
217
+ });
218
+
219
+ it("without videoUpload: goes through the outbox as a file to storage", async () => {
220
+ const send = vi.fn(async () => "x");
221
+ createVideoUploads().start({ conversationId: "c", video, extra: { threadRootId: "root" }, send });
222
+ await vi.waitFor(() => expect(send).toHaveBeenCalled());
223
+ expect(send).toHaveBeenCalledWith({ conversationId: "c", threadRootId: "root", body: "" }, [
224
+ { data: "file:///take.mov", name: "video-1.mov", contentType: "video/quicktime", previewUrl: "file:///take.mov", meta: { kind: "video", durationMs: 4200 } },
225
+ ]);
226
+ });
227
+ });
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @feat:chat
3
+ * In the composer's place when your role in a conversation couldn't be read
4
+ * (offline, a server hiccup): what happened and Retry
5
+ * (`useConversation().refetchAccess`), instead of waiting quietly forever or
6
+ * saying you can't post.
7
+ */
8
+ import { View } from "react-native";
9
+ import { NativeButton, NativeInlineAlert } from "@marktiderman/genesis-ui-native/components";
10
+ import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
11
+ import { useChatNative } from "./context";
12
+
13
+ export interface ChatAccessErrorProps {
14
+ onRetry: () => void;
15
+ /** A retry is under way. */
16
+ retrying?: boolean;
17
+ testID?: string;
18
+ }
19
+
20
+ export function ChatAccessError({ onRetry, retrying = false, testID = "chat-access-error" }: ChatAccessErrorProps) {
21
+ const { copy } = useChatNative();
22
+ const colors = useThemeRoles();
23
+ return (
24
+ <View testID={testID} style={{ padding: 12, gap: 10, backgroundColor: colors.groupedBackground }}>
25
+ <NativeInlineAlert variant="warning" description={copy.accessError} testID={`${testID}-alert`} />
26
+ <NativeButton variant="outline" size="lg" onPress={onRetry} loading={retrying} testID={`${testID}-retry`}>
27
+ {copy.retry}
28
+ </NativeButton>
29
+ </View>
30
+ );
31
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @feat:chat
3
+ * Which actions a message offers this person, from the same rules migration
4
+ * 0001 enforces (react and reply need can_post; pin follows
5
+ * useConversation().canPin: an owner or admin, or a member of a two-person
6
+ * kind; delete needs the sender, an owner or
7
+ * admin, or an observer who may post). The server stays the judge: an action
8
+ * it refuses shows a toast.
9
+ */
10
+ import type { ChatMessage, ChatRole } from "../core/types";
11
+
12
+ export type MessageActionId = "reply" | "copy" | "pin" | "unpin" | "delete";
13
+
14
+ /** The quick reactions on the long-press sheet. */
15
+ export const QUICK_REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"] as const;
16
+
17
+ export interface MessageActionContext {
18
+ me: string;
19
+ role: ChatRole | null;
20
+ canPost: boolean;
21
+ pinned: boolean;
22
+ /** May pin here (`useConversation().canPin`: an owner or admin, or a member of a two-person kind). */
23
+ canPin: boolean;
24
+ /** Copy needs expo-clipboard. */
25
+ canCopy: boolean;
26
+ }
27
+
28
+ export function canReactTo(message: ChatMessage, ctx: Pick<MessageActionContext, "canPost">): boolean {
29
+ return ctx.canPost && !message.deletedAt && message.kind === "text";
30
+ }
31
+
32
+ export function messageActionsFor(message: ChatMessage, ctx: MessageActionContext): MessageActionId[] {
33
+ if (message.deletedAt || message.kind === "system") return [];
34
+ const out: MessageActionId[] = [];
35
+ if (ctx.canPost) out.push("reply");
36
+ if (ctx.canCopy && message.body.trim()) out.push("copy");
37
+ const admin = ctx.role === "owner" || ctx.role === "admin";
38
+ if (message.visibility === "everyone" && ctx.canPin) out.push(ctx.pinned ? "unpin" : "pin");
39
+ const own = message.senderId === ctx.me && ctx.role !== null;
40
+ if (own || admin || (ctx.role === "observer" && ctx.canPost)) out.push("delete");
41
+ return out;
42
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * @feat:chat
3
+ * Cards for the app's own attachment kinds (`ChatProvider attachmentKinds`: a form to fill, a
4
+ * check-in to answer): an icon, the title and subtitle the kind gives, and one button. The
5
+ * button runs the kind's `onPress`; else the app's `onOpenAttachment` (open the form in the
6
+ * app's own screen); else its `href`, when it is https. The bubble offers the same button as
7
+ * a VoiceOver action (`cardAction`), since an accessible bubble hides what is inside it.
8
+ *
9
+ * The kind's `title`, `subtitle` and `action` get a second argument, `{ mine }` (`ChatCardContext`):
10
+ * true on the sender's own card, so a kind can drop its button there (you don't answer your own
11
+ * check-in). Previews (the inbox, the long-press sheet) name a card by its kind's `label`, else
12
+ * its title, not "Attachment".
13
+ */
14
+ import { Linking, View } from "react-native";
15
+ import { NativeButton, NativeIcon, NativeText } from "@marktiderman/genesis-ui-native/components";
16
+ import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
17
+ import type { ChatAttachment, ChatMessage } from "../core/types";
18
+ import type { ChatAttachmentKind, ChatCardContext } from "../react/index";
19
+ import { attachmentPreview, callAttachmentKind as call, cardTitle, messagePreview } from "../react/previews";
20
+
21
+ export type { ChatCardContext };
22
+ export { attachmentPreview, cardTitle, messagePreview };
23
+ import { fill } from "./copy";
24
+ import { useChatNative } from "./context";
25
+ import { ON_TINT_FILL, ON_TINT_FILL_STRONG } from "./media-colors";
26
+
27
+ export type OpenAttachment = (attachment: ChatAttachment, message: ChatMessage) => void;
28
+
29
+ /** A message's attachments split into the app's cards and the rest (photos, voice, video, files). */
30
+ export function splitCardAttachments<T extends { kind: string }>(attachments: readonly T[], kinds: Record<string, ChatAttachmentKind> | undefined): { cards: T[]; rest: T[] } {
31
+ if (!kinds) return { cards: [], rest: [...attachments] };
32
+ const cards: T[] = [];
33
+ const rest: T[] = [];
34
+ for (const a of attachments) (Object.prototype.hasOwnProperty.call(kinds, a.kind) ? cards : rest).push(a);
35
+ return { cards, rest };
36
+ }
37
+
38
+ /**
39
+ * A link a card may open by itself: https only. A kind's href is often built from what another
40
+ * person sent, so any other scheme (tel:, sms:, another app's links) goes through the app's own
41
+ * `onOpenAttachment` or the kind's `onPress` instead.
42
+ */
43
+ export function isOpenableHref(href: string | null | undefined): href is string {
44
+ if (!href) return false;
45
+ try {
46
+ const u = new URL(href);
47
+ return u.protocol === "https:" && Boolean(u.hostname);
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ /** What a card does: the kind's own handler, else the app's, else an https href; null without a button. */
54
+ export function cardAction(
55
+ kind: ChatAttachmentKind | undefined,
56
+ attachment: ChatAttachment,
57
+ message: ChatMessage,
58
+ onOpenAttachment?: OpenAttachment,
59
+ ctx: ChatCardContext = { mine: false },
60
+ ): { label: string; run: () => void } | null {
61
+ let action: ReturnType<NonNullable<ChatAttachmentKind["action"]>> = null;
62
+ try {
63
+ action = call(kind, "action", attachment, ctx);
64
+ } catch {
65
+ return null;
66
+ }
67
+ if (!action?.label) return null;
68
+ const href = isOpenableHref(action.href) ? action.href : null;
69
+ const onPress = action.onPress;
70
+ const run = onPress ? onPress : onOpenAttachment ? () => onOpenAttachment(attachment, message) : href ? () => void Linking.openURL(href).catch(() => undefined) : null;
71
+ return run ? { label: action.label, run } : null;
72
+ }
73
+
74
+ export interface ChatAttachmentCardProps {
75
+ attachment: ChatAttachment;
76
+ message: ChatMessage;
77
+ kind: ChatAttachmentKind;
78
+ mine?: boolean;
79
+ onOpenAttachment?: OpenAttachment;
80
+ testID?: string;
81
+ }
82
+
83
+ export function ChatAttachmentCard({ attachment, message, kind, mine = false, onOpenAttachment, testID = "chat-attachment-card" }: ChatAttachmentCardProps) {
84
+ const { copy } = useChatNative();
85
+ const colors = useThemeRoles();
86
+ let title = "";
87
+ let subtitle: string | null | undefined = null;
88
+ const ctx: ChatCardContext = { mine };
89
+ try {
90
+ title = call(kind, "title", attachment, ctx) ?? "";
91
+ subtitle = call(kind, "subtitle", attachment, ctx);
92
+ } catch (e) {
93
+ // An app's kind that throws shows as a plain card, and says so in development.
94
+ if (typeof __DEV__ !== "undefined" && __DEV__) console.warn(`[genesis-chat] attachment kind "${attachment.kind}" threw:`, e);
95
+ }
96
+ title = title || attachment.name || attachment.kind;
97
+ const action = cardAction(kind, attachment, message, onOpenAttachment, ctx);
98
+ const fg = mine ? "text-primary-foreground" : undefined;
99
+ return (
100
+ <View
101
+ testID={testID}
102
+ style={{ flexDirection: "row", alignItems: "center", gap: 10, padding: 10, borderRadius: 12, minWidth: 220, backgroundColor: mine ? ON_TINT_FILL : colors.groupedBackground }}
103
+ >
104
+ <View
105
+ accessible
106
+ accessibilityLabel={subtitle ? fill(copy.attachmentCard, { title, subtitle }) : title}
107
+ style={{ flexDirection: "row", alignItems: "center", gap: 10, flex: 1 }}
108
+ >
109
+ <View style={{ width: 40, height: 40, borderRadius: 10, alignItems: "center", justifyContent: "center", backgroundColor: mine ? ON_TINT_FILL_STRONG : colors.fill }}>
110
+ <NativeIcon name="file" size={20} color={mine ? colors.surface : colors.tint} />
111
+ </View>
112
+ <View style={{ flex: 1, minWidth: 0 }}>
113
+ <NativeText numberOfLines={1} className={fg} style={mine ? { fontWeight: "600", fontSize: 15 } : { fontWeight: "600", fontSize: 15, color: colors.label }}>
114
+ {title}
115
+ </NativeText>
116
+ {subtitle ? (
117
+ <NativeText preset="caption" numberOfLines={1} className={fg} style={mine ? { opacity: 0.85 } : { color: colors.secondaryLabel }}>
118
+ {subtitle}
119
+ </NativeText>
120
+ ) : null}
121
+ </View>
122
+ </View>
123
+ {action ? (
124
+ <NativeButton testID={`${testID}-action`} variant={mine ? "secondary" : "default"} onPress={action.run}>
125
+ {action.label}
126
+ </NativeButton>
127
+ ) : null}
128
+ </View>
129
+ );
130
+ }
131
+
132
+ declare const __DEV__: boolean | undefined;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @feat:chat
3
+ * Retries unsent messages when there's a chance they'll go now: the app
4
+ * coming back to the front (AppState "active") and, with NetInfo
5
+ * (`modules.netInfo`), the connection coming back, through
6
+ * `useChat().retryAll()`, which resends what failed for a reason that may
7
+ * pass and leaves alone what the server refused (`permanent`: not allowed,
8
+ * bad data) and messages whose files are gone. Mount it once under
9
+ * ChatProvider and ChatNativeProvider: `<ChatAutoRetry />`.
10
+ */
11
+ import { useEffect, useRef } from "react";
12
+ import { AppState } from "react-native";
13
+ import { useChat } from "../react/index";
14
+ import { useChatNative } from "./context";
15
+ import type { NetInfoLike } from "./modules";
16
+
17
+ /** React Native's `AppState`, as the hook uses it. */
18
+ export interface AppStateLike {
19
+ currentState: string | null;
20
+ addEventListener(type: "change", listener: (state: string) => void): { remove(): void };
21
+ }
22
+
23
+ export interface ChatAutoRetryOptions {
24
+ /** Default `modules.netInfo`. */
25
+ netInfo?: NetInfoLike;
26
+ /** Default React Native's AppState. */
27
+ appState?: AppStateLike;
28
+ }
29
+
30
+ /** Whether a NetInfo state is online: unknown counts as online, so only a real "offline" waits. */
31
+ export function isOnline(state: { isConnected: boolean | null; isInternetReachable?: boolean | null }): boolean {
32
+ return state.isConnected !== false && state.isInternetReachable !== false;
33
+ }
34
+
35
+ export function useChatAutoRetry({ netInfo, appState }: ChatAutoRetryOptions = {}): void {
36
+ const { retryAll } = useChat();
37
+ const { modules } = useChatNative();
38
+ const net = netInfo ?? modules.netInfo;
39
+ const app = appState ?? (AppState as unknown as AppStateLike | undefined);
40
+ // The latest retryAll, so a changing queue doesn't resubscribe.
41
+ const retry = useRef(retryAll);
42
+ retry.current = retryAll;
43
+
44
+ useEffect(() => {
45
+ const go = () => void retry.current().catch(() => undefined);
46
+ let last = app?.currentState ?? null;
47
+ const sub = app?.addEventListener?.("change", (next) => {
48
+ if (next === "active" && last !== "active") go();
49
+ last = next;
50
+ });
51
+ // Only a change from offline to online: NetInfo also reports the current state on subscribing.
52
+ let wasOnline: boolean | null = null;
53
+ const unsubscribe = net?.addEventListener((state) => {
54
+ const online = isOnline(state);
55
+ if (online && wasOnline === false) go();
56
+ wasOnline = online;
57
+ });
58
+ return () => {
59
+ sub?.remove();
60
+ unsubscribe?.();
61
+ };
62
+ }, [net, app]);
63
+ }
64
+
65
+ /** `useChatAutoRetry` as a component, for a provider tree. */
66
+ export function ChatAutoRetry(props: ChatAutoRetryOptions) {
67
+ useChatAutoRetry(props);
68
+ return null;
69
+ }