@marktiderman/genesis-chat 0.1.0 → 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,449 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ * @feat:chat
4
+ */
5
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
6
+ import { act, cleanup, configure, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
7
+ import type { ReactNode } from "react";
8
+ import { afterEach, describe, expect, it, vi } from "vitest";
9
+ import { ChatProvider, type ChatProviderProps } from "../react";
10
+ import {
11
+ ChatAvailabilitySettings,
12
+ ChatBroadcast,
13
+ ChatLinkPreview,
14
+ ChatThread,
15
+ createMemoryChatClient,
16
+ type MemoryChatClient,
17
+ type MemoryChatOptions,
18
+ type MemoryConversationSeed,
19
+ } from "../web";
20
+
21
+ afterEach(() => {
22
+ cleanup();
23
+ vi.restoreAllMocks();
24
+ vi.unstubAllGlobals();
25
+ });
26
+ configure({ asyncUtilTimeout: 4000 });
27
+
28
+ const ME = "00000000-0000-4000-8000-0000000000a1";
29
+ const SAM = "00000000-0000-4000-8000-0000000000b2";
30
+ const LEE = "00000000-0000-4000-8000-0000000000c3";
31
+ const names: Record<string, string> = { [ME]: "Me", [SAM]: "Sam", [LEE]: "Lee" };
32
+ const resolveUser = (id: string) => ({ name: names[id] ?? "Someone" });
33
+ const at = (min: number) => new Date(Date.now() - (600 - min) * 60_000).toISOString();
34
+
35
+ function mount(client: MemoryChatClient, ui: ReactNode, extra: Partial<ChatProviderProps> = {}) {
36
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
37
+ return render(
38
+ <QueryClientProvider client={queryClient}>
39
+ <ChatProvider client={client} userId={ME} {...extra}>
40
+ {ui}
41
+ </ChatProvider>
42
+ </QueryClientProvider>,
43
+ );
44
+ }
45
+
46
+ const dm = (over: Partial<MemoryConversationSeed> = {}): MemoryConversationSeed => ({
47
+ id: "c1",
48
+ members: [{ userId: ME }, { userId: SAM }],
49
+ messages: [
50
+ { id: "m1", senderId: SAM, body: "Hello", createdAt: at(1) },
51
+ { id: "m2", senderId: ME, body: "Hi Sam", createdAt: at(2) },
52
+ ],
53
+ ...over,
54
+ });
55
+
56
+ const make = (seed: MemoryConversationSeed, over: Partial<MemoryChatOptions> = {}) => createMemoryChatClient({ me: ME, conversations: [seed], ...over });
57
+
58
+ /** Radix menus open from the keyboard in happy-dom (it has no pointer capture). */
59
+ const openMenu = (el: HTMLElement) => fireEvent.keyDown(el, { key: "Enter" });
60
+
61
+ describe("topics", () => {
62
+ const cohort = (role: "owner" | "member", closed = false): MemoryConversationSeed => ({
63
+ id: "c1",
64
+ title: "Cohort",
65
+ members: [{ userId: ME, role }, { userId: SAM, role: role === "owner" ? "member" : "owner" }, { userId: LEE }],
66
+ topics: [
67
+ { topic: "w1", title: "Week 1", closed },
68
+ { topic: "w2", title: "Week 2" },
69
+ ],
70
+ messages: [
71
+ { id: "g1", senderId: SAM, body: "Welcome, everyone", createdAt: at(1) },
72
+ { id: "t1", senderId: LEE, body: "Week one question", topic: "w1", createdAt: at(2) },
73
+ { id: "t2", senderId: SAM, body: "Week two notes", topic: "w2", createdAt: at(3) },
74
+ ],
75
+ });
76
+
77
+ it("shows All and each topic, filters to one, and posts into it", async () => {
78
+ const client = make(cohort("member"));
79
+ mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
80
+ const bar = await screen.findByTestId("genesis-chat-topic-bar");
81
+ expect(within(bar).getByText("All")).toBeTruthy();
82
+ await screen.findByText("Week two notes");
83
+ fireEvent.click(within(bar).getByTestId("genesis-chat-topic-w1"));
84
+ expect(await screen.findByText("Week one question")).toBeTruthy();
85
+ expect(screen.queryByText("Week two notes")).toBeNull();
86
+ const input = await screen.findByTestId("genesis-chat-input");
87
+ expect(input.getAttribute("placeholder")).toBe("Message #Week 1");
88
+ fireEvent.change(input, { target: { value: "My answer" } });
89
+ fireEvent.keyDown(input, { key: "Enter" });
90
+ await waitFor(() => expect(client.calls.some((c) => c.method === "send")).toBe(true));
91
+ expect(client.calls.find((c) => c.method === "send")!.args[0]).toMatchObject({ body: "My answer", topic: "w1" });
92
+ // No owner controls for a member.
93
+ expect(screen.queryByTestId("genesis-chat-topic-menu")).toBeNull();
94
+ });
95
+
96
+ it("says a closed topic is closed instead of offering the box", async () => {
97
+ const client = make(cohort("member", true));
98
+ mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
99
+ const closed = await screen.findByTestId("genesis-chat-topic-w1");
100
+ expect(closed.getAttribute("aria-label")).toBe("Week 1 (closed)");
101
+ fireEvent.click(closed);
102
+ expect((await screen.findByTestId("genesis-chat-composer-disabled")).textContent).toContain("This topic is closed");
103
+ expect(screen.queryByTestId("genesis-chat-input")).toBeNull();
104
+ });
105
+
106
+ it("lets an owner start a topic and close one", async () => {
107
+ const client = make(cohort("owner"));
108
+ mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
109
+ openMenu(await screen.findByTestId("genesis-chat-topic-menu"));
110
+ fireEvent.click(await screen.findByTestId("genesis-chat-topic-new"));
111
+ fireEvent.change(await screen.findByLabelText("Name"), { target: { value: "Week 3: Habits" } });
112
+ fireEvent.click(screen.getByTestId("genesis-chat-topic-create"));
113
+ await screen.findByTestId("genesis-chat-topic-week-3-habits");
114
+ expect(client.calls.find((c) => c.method === "setTopic")!.args).toEqual(["c1", "week-3-habits", { title: "Week 3: Habits", position: 2 }]);
115
+ // The new topic is chosen; close it.
116
+ openMenu(screen.getByTestId("genesis-chat-topic-menu"));
117
+ fireEvent.click(await screen.findByTestId("genesis-chat-topic-close"));
118
+ await waitFor(() => expect(screen.getByTestId("genesis-chat-topic-week-3-habits").getAttribute("aria-label")).toBe("Week 3: Habits (closed)"));
119
+ // An owner can still post in a closed topic.
120
+ expect(screen.getByTestId("genesis-chat-input")).toBeTruthy();
121
+ });
122
+ });
123
+
124
+ describe("scheduled send", () => {
125
+ it("schedules from the send menu, lists it, and cancels it", async () => {
126
+ const client = make(dm());
127
+ mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
128
+ const input = await screen.findByTestId("genesis-chat-input");
129
+ fireEvent.change(input, { target: { value: "See you tomorrow" } });
130
+ openMenu(await screen.findByTestId("genesis-chat-send-menu"));
131
+ const picks = await screen.findAllByTestId("genesis-chat-schedule-pick");
132
+ expect(picks[0]!.textContent).toMatch(/Tomorrow/);
133
+ fireEvent.click(picks[0]!);
134
+ await waitFor(() => expect(client.calls.some((c) => c.method === "schedule")).toBe(true));
135
+ const scheduled = client.calls.find((c) => c.method === "schedule")!.args[0] as { body: string; sendAt: Date };
136
+ expect(scheduled.body).toBe("See you tomorrow");
137
+ expect(new Date(scheduled.sendAt).getHours()).toBe(9);
138
+ // Not sent now, and the box is clear.
139
+ expect(client.calls.some((c) => c.method === "send")).toBe(false);
140
+ await waitFor(() => expect((input as HTMLTextAreaElement).value).toBe(""));
141
+
142
+ const count = await screen.findByTestId("genesis-chat-scheduled-count");
143
+ expect(count.textContent).toContain("1 scheduled message");
144
+ fireEvent.click(count);
145
+ fireEvent.click(await screen.findByTestId("genesis-chat-scheduled-cancel"));
146
+ await waitFor(() => expect(client.calls.some((c) => c.method === "cancelScheduled")).toBe(true));
147
+ await waitFor(() => expect(screen.queryByTestId("genesis-chat-scheduled-count")).toBeNull());
148
+ });
149
+
150
+ it("schedules at a date and time you pick", async () => {
151
+ const client = make(dm());
152
+ mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
153
+ fireEvent.change(await screen.findByTestId("genesis-chat-input"), { target: { value: "Custom" } });
154
+ openMenu(await screen.findByTestId("genesis-chat-send-menu"));
155
+ fireEvent.click(await screen.findByTestId("genesis-chat-schedule-custom"));
156
+ const when = new Date(Date.now() + 2 * 86_400_000);
157
+ when.setHours(14, 30, 0, 0);
158
+ const p = (n: number) => String(n).padStart(2, "0");
159
+ fireEvent.change(await screen.findByTestId("genesis-chat-schedule-input"), {
160
+ target: { value: `${when.getFullYear()}-${p(when.getMonth() + 1)}-${p(when.getDate())}T14:30` },
161
+ });
162
+ fireEvent.click(screen.getByTestId("genesis-chat-schedule-confirm"));
163
+ await waitFor(() => expect(client.calls.some((c) => c.method === "schedule")).toBe(true));
164
+ expect(new Date((client.calls.find((c) => c.method === "schedule")!.args[0] as { sendAt: Date }).sendAt).getTime()).toBe(when.getTime());
165
+ });
166
+ });
167
+
168
+ describe("availability", () => {
169
+ it("shows a banner when the other person is away", async () => {
170
+ const client = make(dm(), { availability: { [SAM]: { awayMessage: "Back Monday", awayUntil: new Date(Date.now() + 3 * 86_400_000).toISOString() } } });
171
+ mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
172
+ const banner = await screen.findByTestId("genesis-chat-away-banner");
173
+ expect(banner.textContent).toMatch(/^Away until .+: Back Monday$/);
174
+ expect(banner.getAttribute("role")).toBe("status");
175
+ });
176
+
177
+ it("names every member who is away in a group", async () => {
178
+ const ANN = "00000000-0000-4000-8000-0000000000e5";
179
+ const BO = "00000000-0000-4000-8000-0000000000f6";
180
+ names[ANN] = "Ann";
181
+ names[BO] = "Bo";
182
+ const soon = new Date(Date.now() + 86_400_000).toISOString();
183
+ const client = make(
184
+ { id: "c1", title: "Group", members: [{ userId: ME, role: "owner" }, { userId: SAM }, { userId: LEE }, { userId: ANN }, { userId: BO }], messages: [{ id: "x", senderId: SAM, body: "Hi", createdAt: at(1) }] },
185
+ { availability: { [SAM]: { awayMessage: "Off", awayUntil: soon }, [LEE]: { awayMessage: "Off", awayUntil: soon }, [ANN]: { awayMessage: "Off", awayUntil: soon } } },
186
+ );
187
+ mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
188
+ expect((await screen.findByTestId("genesis-chat-away-banner")).textContent).toBe("Sam, Lee and 1 other are away");
189
+ });
190
+
191
+ it("shows no banner inside working hours, and says when they reply outside them", async () => {
192
+ const all = { mon: [["00:00", "24:00"]], tue: [["00:00", "24:00"]], wed: [["00:00", "24:00"]], thu: [["00:00", "24:00"]], fri: [["00:00", "24:00"]], sat: [["00:00", "24:00"]], sun: [["00:00", "24:00"]] } as never;
193
+ const inside = make(dm(), { availability: { [SAM]: { timeZone: "UTC", hours: all } } });
194
+ const { unmount } = mount(inside, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
195
+ await screen.findByText("Hello");
196
+ await new Promise((r) => setTimeout(r, 20));
197
+ expect(screen.queryByTestId("genesis-chat-away-banner")).toBeNull();
198
+ unmount();
199
+ const never = make(dm(), { availability: { [SAM]: { timeZone: "America/New_York", hours: { mon: [["09:00", "09:01"]] } } } });
200
+ mount(never, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
201
+ expect((await screen.findByTestId("genesis-chat-away-banner")).textContent).toBe("Sam replies Mon, 9–9:01 (Eastern)");
202
+ });
203
+
204
+ it("saves your away message, hours and time zone", async () => {
205
+ const client = make(dm());
206
+ mount(client, <ChatAvailabilitySettings />);
207
+ fireEvent.change(await screen.findByLabelText("Away message"), { target: { value: "On holiday" } });
208
+ fireEvent.click(screen.getByRole("switch", { name: /Tell people when you usually reply/ }));
209
+ fireEvent.click(await screen.findByTestId("genesis-chat-availability-save"));
210
+ await waitFor(() => expect(client.calls.some((c) => c.method === "setAvailability")).toBe(true));
211
+ const saved = client.calls.find((c) => c.method === "setAvailability")!.args[0] as { awayMessage: string; hours: Record<string, unknown>; timeZone: string };
212
+ expect(saved.awayMessage).toBe("On holiday");
213
+ expect(Object.keys(saved.hours)).toEqual(["mon", "tue", "wed", "thu", "fri"]);
214
+ expect(saved.hours.mon).toEqual([["09:00", "17:00"]]);
215
+ expect(saved.timeZone).toBeTruthy();
216
+ });
217
+ });
218
+
219
+ describe("search", () => {
220
+ it("finds a message further up, loads to it and highlights it", async () => {
221
+ const messages = [
222
+ { id: "old", senderId: SAM, body: "The needle is here", createdAt: at(1) },
223
+ ...Array.from({ length: 60 }, (_, i) => ({ id: `f${i}`, senderId: i % 2 ? ME : SAM, body: `Filler ${i}`, createdAt: at(10 + i) })),
224
+ ];
225
+ const client = make(dm({ messages }));
226
+ mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
227
+ await screen.findByText("Filler 59");
228
+ expect(document.getElementById("genesis-chat-msg-old")).toBeNull();
229
+ fireEvent.click(screen.getByTestId("genesis-chat-thread-search"));
230
+ fireEvent.change(await screen.findByTestId("genesis-chat-search-input"), { target: { value: "needle" } });
231
+ const hit = await screen.findByTestId("genesis-chat-search-hit");
232
+ expect(hit.querySelector("mark")?.textContent).toBe("needle");
233
+ fireEvent.click(hit);
234
+ await waitFor(() => expect(document.querySelector("#genesis-chat-msg-old .ring-2")).not.toBeNull());
235
+ });
236
+
237
+ it("marks hits in a transcript", async () => {
238
+ const client = make(dm({ messages: [{ id: "v", senderId: SAM, body: "", createdAt: at(1), attachments: [{ kind: "voice", transcript: "call the dentist" }] }] }));
239
+ mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
240
+ fireEvent.click(await screen.findByTestId("genesis-chat-thread-search"));
241
+ fireEvent.change(await screen.findByTestId("genesis-chat-search-input"), { target: { value: "dentist" } });
242
+ expect((await screen.findByTestId("genesis-chat-search-transcript")).textContent).toContain("Transcript");
243
+ });
244
+ });
245
+
246
+ describe("link previews", () => {
247
+ const preview = { url: "https://example.test/a", status: "ok" as const, title: "A title", description: "<b>not html</b>", siteName: "Example", fetchedAt: "t" };
248
+
249
+ it("draws an https image, and never a non-https one, and never HTML", () => {
250
+ const { rerender } = render(<ChatLinkPreview images="direct" preview={{ ...preview, imageUrl: "https://example.test/i.png" }} />);
251
+ expect(screen.getByTestId("genesis-chat-link-preview-image").getAttribute("src")).toBe("https://example.test/i.png");
252
+ expect(screen.getByText("<b>not html</b>")).toBeTruthy();
253
+ expect(screen.getByTestId("genesis-chat-link-preview").querySelector("b")).toBeNull();
254
+ for (const bad of ["http://example.test/i.png", "javascript:alert(1)", "data:image/png;base64,AAAA", "//example.test/i.png"]) {
255
+ rerender(<ChatLinkPreview images="direct" preview={{ ...preview, imageUrl: bad }} />);
256
+ expect(screen.queryByTestId("genesis-chat-link-preview-image")).toBeNull();
257
+ }
258
+ rerender(<ChatLinkPreview preview={{ ...preview, url: "javascript:alert(1)", imageUrl: null }} />);
259
+ expect(screen.queryByTestId("genesis-chat-link-preview")).toBeNull();
260
+ });
261
+
262
+ it("puts a card under a message with a link", async () => {
263
+ const client = make(dm({ messages: [{ id: "l", senderId: SAM, body: "Read https://example.test/a", createdAt: at(1) }] }), {
264
+ linkPreviews: [{ ...preview, imageUrl: null }],
265
+ });
266
+ mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
267
+ expect((await screen.findByTestId("genesis-chat-link-preview")).textContent).toContain("A title");
268
+ });
269
+ });
270
+
271
+ describe("moderation", () => {
272
+ const seed = (myRole?: "observer"): MemoryConversationSeed => ({
273
+ id: "c1",
274
+ members: [{ userId: ME }, { userId: SAM }, { userId: LEE }],
275
+ ...(myRole ? { myRole, members: [{ userId: SAM }, { userId: LEE }, { userId: "00000000-0000-4000-8000-0000000000d4" }] } : {}),
276
+ messages: [
277
+ { id: "h1", senderId: ME, body: "Something rude", moderation: "hidden", createdAt: at(1) },
278
+ { id: "h2", senderId: SAM, body: "Hidden from you", moderation: "hidden", createdAt: at(2) },
279
+ { id: "f1", senderId: LEE, body: "Borderline", moderation: "flagged", createdAt: at(3) },
280
+ ],
281
+ });
282
+
283
+ it("tells the sender their message is hidden, and hides others' hidden messages", async () => {
284
+ const client = make(seed());
285
+ mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
286
+ expect((await screen.findByTestId("genesis-chat-hidden-notice")).textContent).toContain("Hidden by moderation");
287
+ expect(screen.queryByText("Hidden from you")).toBeNull();
288
+ // A flagged message reads as normal to members.
289
+ expect(screen.getByText("Borderline")).toBeTruthy();
290
+ expect(screen.queryByTestId("genesis-chat-moderation-flagged")).toBeNull();
291
+ });
292
+
293
+ it("shows observers hidden and flagged messages with a badge", async () => {
294
+ const client = make(seed("observer"));
295
+ mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
296
+ await screen.findByText("Hidden from you");
297
+ expect(screen.getAllByTestId("genesis-chat-moderation-hidden")).toHaveLength(2);
298
+ expect(screen.getByTestId("genesis-chat-moderation-flagged").textContent).toContain("Flagged");
299
+ });
300
+ });
301
+
302
+ describe("app attachment kinds", () => {
303
+ it("draws a registered kind as a card with its action", async () => {
304
+ const client = make(dm({ messages: [{ id: "a", senderId: SAM, body: "", createdAt: at(1), attachments: [{ kind: "form", ref: { formId: "f1", title: "Weekly check-in" } }] }] }));
305
+ mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />, {
306
+ attachmentKinds: {
307
+ form: {
308
+ title: (a) => String(a.ref.title),
309
+ subtitle: () => "Takes 2 minutes",
310
+ action: (a) => ({ label: "Fill in", href: `https://app.test/forms/${String(a.ref.formId)}` }),
311
+ },
312
+ },
313
+ });
314
+ const card = await screen.findByTestId("genesis-chat-attachment-card");
315
+ expect(card.textContent).toContain("Weekly check-in");
316
+ expect(card.textContent).toContain("Takes 2 minutes");
317
+ expect(within(card).getByRole("link", { name: /Fill in/ }).getAttribute("href")).toBe("https://app.test/forms/f1");
318
+ });
319
+ });
320
+
321
+ describe("broadcast", () => {
322
+ it("sends one message into each picked conversation", async () => {
323
+ const client = createMemoryChatClient({
324
+ me: ME,
325
+ conversations: [dm(), { id: "c2", members: [{ userId: ME }, { userId: LEE }] }],
326
+ });
327
+ mount(client, <ChatBroadcast resolveUser={resolveUser} />);
328
+ expect(screen.getByText(/replies come back to you privately/)).toBeTruthy();
329
+ fireEvent.change(screen.getByLabelText("Message"), { target: { value: "Session moved to 10" } });
330
+ await screen.findByText("Lee");
331
+ fireEvent.click(screen.getByRole("button", { name: "Select all" }));
332
+ fireEvent.click(screen.getByTestId("genesis-chat-broadcast-send"));
333
+ await waitFor(() => expect(client.calls.some((c) => c.method === "broadcast")).toBe(true));
334
+ expect([...(client.calls.find((c) => c.method === "broadcast")!.args[0] as string[])].sort()).toEqual(["c1", "c2"]);
335
+ await waitFor(() => expect(client.calls.filter((c) => c.method === "send")).toHaveLength(2));
336
+ });
337
+ });
338
+
339
+ describe("access errors", () => {
340
+ it("offers a retry instead of saying you can't post", async () => {
341
+ const client = make(dm());
342
+ const real = client.myRole;
343
+ client.myRole = vi.fn(async () => {
344
+ throw new Error("Failed to fetch");
345
+ });
346
+ mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
347
+ await screen.findByTestId("genesis-chat-access-error");
348
+ expect(screen.queryByTestId("genesis-chat-cannot-post")).toBeNull();
349
+ client.myRole = real;
350
+ fireEvent.click(screen.getByTestId("genesis-chat-access-retry"));
351
+ await screen.findByTestId("genesis-chat-input");
352
+ });
353
+ });
354
+
355
+ describe("video in the composer", () => {
356
+ class FakeRecorder {
357
+ static isTypeSupported = (t: string) => t === "video/webm;codecs=vp8,opus";
358
+ state: "inactive" | "recording" = "inactive";
359
+ mimeType: string;
360
+ ondataavailable: ((e: { data: Blob }) => void) | null = null;
361
+ onstop: (() => void) | null = null;
362
+ constructor(_stream: unknown, opts?: { mimeType?: string }) {
363
+ this.mimeType = opts?.mimeType ?? "";
364
+ }
365
+ start() {
366
+ this.state = "recording";
367
+ }
368
+ stop() {
369
+ this.state = "inactive";
370
+ this.ondataavailable?.({ data: new Blob(["frames"], { type: "video/webm" }) });
371
+ this.onstop?.();
372
+ }
373
+ }
374
+ const realMedia = Object.getOwnPropertyDescriptor(navigator, "mediaDevices");
375
+ afterEach(() => {
376
+ if (realMedia) Object.defineProperty(navigator, "mediaDevices", realMedia);
377
+ });
378
+ const stubCamera = () => {
379
+ const stop = vi.fn();
380
+ const getUserMedia = vi.fn(async () => ({ getTracks: () => [{ stop }, { stop }] }));
381
+ Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: { getUserMedia } });
382
+ return { getUserMedia, stop };
383
+ };
384
+
385
+ async function recordAndSend(time: { now: number }) {
386
+ fireEvent.click(await screen.findByTestId("genesis-chat-video-button"));
387
+ const record = await screen.findByTestId("genesis-chat-video-record");
388
+ await waitFor(() => expect((record as HTMLButtonElement).disabled).toBe(false));
389
+ await act(async () => {
390
+ fireEvent.click(record);
391
+ });
392
+ time.now += 4_000;
393
+ fireEvent.click(await screen.findByTestId("genesis-chat-video-stop"));
394
+ await screen.findByTestId("genesis-chat-video-retake");
395
+ await act(async () => {
396
+ fireEvent.click(screen.getByTestId("genesis-chat-video-send"));
397
+ });
398
+ }
399
+
400
+ it("records, uploads to the app's videoUpload with a PUT, and sends a pending Mux video", async () => {
401
+ const time = { now: 2_000_000 };
402
+ vi.spyOn(Date, "now").mockImplementation(() => time.now);
403
+ vi.stubGlobal("MediaRecorder", FakeRecorder);
404
+ const camera = stubCamera();
405
+ const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
406
+ vi.stubGlobal("fetch", fetchMock);
407
+ const create = vi.fn(async () => ({ url: "https://storage.mux.test/upload/1", providerId: "up_1", provider: "mux" as const }));
408
+ const client = make(dm());
409
+ mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />, { videoUpload: { create } });
410
+ await recordAndSend(time);
411
+ await waitFor(() => expect(client.calls.some((c) => c.method === "send")).toBe(true));
412
+ expect(camera.getUserMedia).toHaveBeenCalledWith(expect.objectContaining({ audio: true, video: expect.anything() }));
413
+ expect(create).toHaveBeenCalledWith("c1");
414
+ expect(fetchMock).toHaveBeenCalledWith("https://storage.mux.test/upload/1", expect.objectContaining({ method: "PUT", headers: { "Content-Type": "video/webm" } }));
415
+ const sent = client.calls.find((c) => c.method === "send")!.args[0] as { body: string; attachments: Record<string, unknown>[] };
416
+ expect(sent.attachments[0]).toMatchObject({ kind: "video", provider: "mux", providerId: "up_1", status: "pending", durationMs: 4000, mime: "video/webm" });
417
+ // Nothing went to storage, and the camera was released.
418
+ expect(client.calls.some((c) => c.method === "upload")).toBe(false);
419
+ expect(camera.stop).toHaveBeenCalled();
420
+ // The pending video says it's processing.
421
+ expect(await screen.findByTestId("genesis-chat-video-processing")).toBeTruthy();
422
+ });
423
+
424
+ it("falls back to a storage upload without videoUpload", async () => {
425
+ const time = { now: 3_000_000 };
426
+ vi.spyOn(Date, "now").mockImplementation(() => time.now);
427
+ vi.stubGlobal("MediaRecorder", FakeRecorder);
428
+ stubCamera();
429
+ const client = make(dm());
430
+ mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
431
+ await recordAndSend(time);
432
+ await waitFor(() => expect(client.calls.some((c) => c.method === "send")).toBe(true));
433
+ expect(client.calls.find((c) => c.method === "upload")!.args[1]).toMatch(/^video-.*\.webm$/);
434
+ const sent = client.calls.find((c) => c.method === "send")!.args[0] as { attachments: Record<string, unknown>[] };
435
+ expect(sent.attachments[0]).toMatchObject({ kind: "video", mime: "video/webm", durationMs: 4000 });
436
+ });
437
+
438
+ it("plays a ready Mux video from its playback id with a poster", async () => {
439
+ const client = make(
440
+ dm({ messages: [{ id: "v", senderId: SAM, body: "", createdAt: at(1), attachments: [{ kind: "video", provider: "mux", providerId: "up", playbackId: "pb123", status: "ready" }] }] }),
441
+ );
442
+ // Native HLS (Safari): the stream goes straight on the element.
443
+ vi.spyOn(HTMLMediaElement.prototype, "canPlayType").mockImplementation((t: string) => (t === "application/vnd.apple.mpegurl" ? "maybe" : ""));
444
+ mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
445
+ const video = (await screen.findByTestId("genesis-chat-video-hls")) as HTMLVideoElement;
446
+ await waitFor(() => expect(video.getAttribute("src") ?? video.src).toBe("https://stream.mux.com/pb123.m3u8"));
447
+ expect(video.getAttribute("poster")).toBe("https://image.mux.com/pb123/thumbnail.jpg");
448
+ });
449
+ });
@@ -0,0 +1,91 @@
1
+ /** @feat:chat */
2
+ import { describe, expect, it } from "vitest";
3
+ import { fileExtension, isAllowedExtension, waveformPeaks, waveformFromMetering, formatDuration, nextPlaybackRate } from "../core/media";
4
+ import { mergeThread, startsGroup, parseMentions, previewText, summarizeReactions, activityLine, currentActivity } from "../core/thread";
5
+ import type { ChatMessage, PendingMessage } from "../core/types";
6
+
7
+ const msg = (seq: number, over: Partial<ChatMessage> = {}): ChatMessage => ({
8
+ id: `m${seq}`, conversationId: "c", seq, clientId: `k${seq}`, senderId: "a", senderRole: "member", kind: "text",
9
+ body: `hi ${seq}`, mentions: [], replyToId: null, threadRootId: null, threadCount: 0, threadLastAt: null, topic: null,
10
+ visibility: "everyone", silent: false, meta: {}, createdAt: new Date(2026, 9, 25, 10, seq).toISOString(),
11
+ editedAt: null, deletedAt: null, attachments: [], reactions: [], ...over,
12
+ });
13
+ const pending = (clientId: string): PendingMessage => ({
14
+ clientId, input: { conversationId: "c", clientId, body: "x" }, senderId: "a", createdAt: new Date(2026, 9, 25, 11).toISOString(), status: "sending",
15
+ });
16
+
17
+ describe("media", () => {
18
+ it("stores files under a safe extension and refuses pages", () => {
19
+ expect(fileExtension("note", "audio/mp4")).toBe("m4a");
20
+ expect(fileExtension("photo.JPG", "")).toBe("jpg");
21
+ expect(isAllowedExtension("svg")).toBe(false);
22
+ expect(isAllowedExtension("html")).toBe(false);
23
+ expect(isAllowedExtension("webm")).toBe(true);
24
+ });
25
+ it("draws a waveform from samples or a level meter", () => {
26
+ const p = waveformPeaks([0, 0.5, -1, 0.25], 2);
27
+ expect(p).toEqual([50, 100]);
28
+ expect(waveformPeaks([], 4)).toEqual([0, 0, 0, 0]);
29
+ expect(waveformFromMetering([-160, -160], 2)).toEqual([0, 0]);
30
+ expect(waveformFromMetering([0, -6], 2)[0]).toBe(100);
31
+ });
32
+ it("formats durations and cycles playback speed", () => {
33
+ expect(formatDuration(42_000)).toBe("0:42");
34
+ expect(formatDuration(3_909_000)).toBe("1:05:09");
35
+ expect(nextPlaybackRate(1)).toBe(1.5);
36
+ expect(nextPlaybackRate(2)).toBe(1);
37
+ });
38
+ });
39
+
40
+ describe("thread", () => {
41
+ it("never shows a message twice once the server has it", () => {
42
+ const items = mergeThread([msg(2), msg(1)], [pending("k2"), pending("new")]);
43
+ expect(items.map((i) => (i.type === "message" ? i.message.id : i.pending.clientId))).toEqual(["m1", "m2", "new"]);
44
+ });
45
+ it("groups a run from one sender", () => {
46
+ const items = mergeThread([msg(1), msg(2), msg(3, { senderId: "b" })], []);
47
+ expect([0, 1, 2].map((i) => startsGroup(items, i))).toEqual([true, false, true]);
48
+ });
49
+ it("reads mentions and previews", () => {
50
+ const id = "00000000-0000-4000-8000-0000000000aa";
51
+ expect(parseMentions(`hey @[Sam](user:${id})`)).toEqual([id]);
52
+ expect(previewText(`hey @[Sam](user:${id}) **now**`, null)).toBe("hey @Sam now");
53
+ expect(previewText("", "voice")).toBe("Voice message");
54
+ expect(previewText("x", "image", true)).toBe("Message deleted");
55
+ });
56
+ it("counts reactions and says who is typing", () => {
57
+ const m = msg(1, { reactions: [{ emoji: "👍", userId: "a" }, { emoji: "🔥", userId: "b" }, { emoji: "🔥", userId: "c" }] });
58
+ expect(summarizeReactions(m, "a").map((r) => [r.emoji, r.count, r.mine])).toEqual([["🔥", 2, false], ["👍", 1, true]]);
59
+ const now = Date.now();
60
+ const act = currentActivity([{ userId: "b", activity: "typing", at: now - 10_000 }, { userId: "c", activity: "recording-voice", at: now }], now);
61
+ expect(activityLine(act, (u) => u.toUpperCase())).toBe("C is recording a voice message…");
62
+ });
63
+ });
64
+
65
+ import { belongsTo } from "../react";
66
+ describe("a sent message lands in the lists it belongs to", () => {
67
+ const m = msg(3, { conversationId: "c1", threadRootId: null, topic: "lesson-1" });
68
+ it("matches the main timeline and its topic, not another thread or conversation", () => {
69
+ expect(belongsTo(["genesis-chat", "conversation", "c1", "messages", null, null], m)).toBe(true);
70
+ expect(belongsTo(["genesis-chat", "conversation", "c1", "messages", null, "lesson-1"], m)).toBe(true);
71
+ expect(belongsTo(["genesis-chat", "conversation", "c1", "messages", null, "lesson-2"], m)).toBe(false);
72
+ expect(belongsTo(["genesis-chat", "conversation", "c1", "messages", "root-1", null], m)).toBe(false);
73
+ expect(belongsTo(["genesis-chat", "conversation", "c2", "messages", null, null], m)).toBe(false);
74
+ expect(belongsTo(["genesis-chat", "conversation", "c1"], m)).toBe(false);
75
+ });
76
+ });
77
+
78
+ import { createChatClient } from "../core/client";
79
+ describe("inbox kinds", () => {
80
+ it("passes kinds and excluded kinds to the inbox and the badge", async () => {
81
+ const calls: [string, Record<string, unknown>][] = [];
82
+ const supabase = { schema: () => ({ rpc: async (fn: string, args: Record<string, unknown>) => (calls.push([fn, args]), { data: fn === "inbox" ? [] : 0, error: null }) }) } as never;
83
+ const chat = createChatClient(supabase);
84
+ await chat.inbox({ excludeKinds: ["support"] });
85
+ await chat.unreadTotal({ excludeKinds: ["support"] });
86
+ await chat.inbox();
87
+ expect(calls[0]![1]).toMatchObject({ p_exclude_kinds: ["support"] });
88
+ expect(calls[1]).toEqual(["unread_total", { p_exclude_kinds: ["support"] }]);
89
+ expect(calls[2]![1]).not.toHaveProperty("p_exclude_kinds");
90
+ });
91
+ });