@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,254 @@
1
+ // @vitest-environment happy-dom
2
+ // @feat:chat
3
+ /// <reference types="vite/client" />
4
+ import pickerSource from "../emoji-picker.tsx?raw";
5
+ import dataSource from "../emoji-data.ts?raw";
6
+ import emojiSource from "../emoji.ts?raw";
7
+ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
8
+ import { afterEach, describe, expect, it, vi } from "vitest";
9
+ import type { ChatMessage } from "../../core/types";
10
+ import { ChatComposer } from "../composer";
11
+ import { clearDrafts, memoryDraftStorage } from "../composer-state";
12
+ import { ChatNativeProvider } from "../context";
13
+ import { ChatEmojiPicker } from "../emoji-picker";
14
+ import { EMOJI_CATEGORIES, emojiEntry, emojiInCategory, insertAtSelection, loadRecentEmoji, pushRecentEmoji, recentEmojiKey, RECENT_EMOJI_MAX, saveRecentEmoji, searchEmoji } from "../emoji";
15
+ import { QUICK_REACTIONS } from "../actions";
16
+ import { MessageActionsSheet } from "../message-actions-sheet";
17
+ import { useChatComposer } from "../use-composer";
18
+
19
+ // The grids' FlatList props, as the picker gave them.
20
+ const lists = vi.hoisted(() => [] as Record<string, unknown>[]);
21
+ vi.mock("react-native", async (original) => {
22
+ const rn = await original<Record<string, unknown> & { FlatList: (p: Record<string, unknown>) => unknown }>();
23
+ return {
24
+ ...rn,
25
+ useWindowDimensions: () => ({ width: 390, height: 844, scale: 3, fontScale: 1 }),
26
+ FlatList: (props: Record<string, unknown>) => {
27
+ lists.push(props);
28
+ return rn.FlatList(props);
29
+ },
30
+ };
31
+ });
32
+
33
+ afterEach(cleanup);
34
+
35
+ describe("the bundled emoji", () => {
36
+ it("ship in the app: nothing in the picker or its data fetches or names a URL", () => {
37
+ for (const src of [pickerSource, emojiSource, dataSource.split("\n").slice(9).join("\n")]) {
38
+ expect(src).not.toMatch(/https?:\/\//);
39
+ expect(src).not.toMatch(/\bfetch\(|emojibaseUrl|jsdelivr|frimousse/);
40
+ }
41
+ });
42
+
43
+ it("are every category's base emoji, kept modest", () => {
44
+ const all = EMOJI_CATEGORIES.flatMap((c) => emojiInCategory(c.id));
45
+ expect(all.length).toBeGreaterThan(1500);
46
+ expect(all.length).toBeLessThan(2200);
47
+ expect(dataSource.length).toBeLessThan(100_000);
48
+ expect(new Set(all.map((e) => e.emoji)).size).toBe(all.length);
49
+ expect(emojiInCategory("smileys")[0]).toMatchObject({ emoji: "😀", label: "grinning face" });
50
+ // No skin-tone variants: those are the base emoji's, chosen elsewhere.
51
+ expect(all.some((e) => /\u{1F3FB}|\u{1F3FF}/u.test(e.emoji))).toBe(false);
52
+ });
53
+
54
+ it("are found by words of their label or tags", () => {
55
+ expect(searchEmoji("thumbs up")[0]?.emoji).toBe("👍");
56
+ expect(searchEmoji("gri").map((e) => e.emoji)).toContain("😀");
57
+ expect(searchEmoji("red heart").map((e) => e.emoji)).toContain("❤️");
58
+ expect(searchEmoji(" ")).toEqual([]);
59
+ expect(searchEmoji("zzzqqq")).toEqual([]);
60
+ expect(emojiEntry("👍").label).toBe("thumbs up");
61
+ });
62
+
63
+ it("are written as keyboards type them, so a picked reaction counts with the same quick one", () => {
64
+ for (const quick of QUICK_REACTIONS) expect(emojiEntry(quick).label, quick).not.toBe(quick);
65
+ const all = EMOJI_CATEGORIES.flatMap((c) => emojiInCategory(c.id));
66
+ expect(all.some((e) => /\p{Emoji_Presentation}\uFE0F/u.test(e.emoji))).toBe(false);
67
+ expect(all.map((e) => e.emoji)).toContain("❤️");
68
+ });
69
+ });
70
+
71
+ describe("recent emoji", () => {
72
+ it("put the newest first, once, and keep at most the limit", () => {
73
+ expect(pushRecentEmoji(["😀", "👍"], "👍")).toEqual(["👍", "😀"]);
74
+ const many = Array.from({ length: RECENT_EMOJI_MAX }, (_, i) => String(i));
75
+ expect(pushRecentEmoji(many, "x")).toHaveLength(RECENT_EMOJI_MAX);
76
+ expect(pushRecentEmoji(many, "x")[0]).toBe("x");
77
+ });
78
+
79
+ it("are kept per person with their drafts, so signing out clears them", async () => {
80
+ const storage = memoryDraftStorage();
81
+ await saveRecentEmoji(storage, "u1", ["🎉"]);
82
+ await saveRecentEmoji(storage, "u2", ["🙂"]);
83
+ expect(await loadRecentEmoji(storage, "u1")).toEqual(["🎉"]);
84
+ await clearDrafts(storage, "u1");
85
+ expect(await storage.getItem(recentEmojiKey("u1"))).toBeNull();
86
+ expect(await loadRecentEmoji(storage, "u2")).toEqual(["🙂"]);
87
+ });
88
+
89
+ it("survive a corrupt entry as none", async () => {
90
+ const storage = memoryDraftStorage();
91
+ await storage.setItem(recentEmojiKey("u1"), "{not json");
92
+ expect(await loadRecentEmoji(storage, "u1")).toEqual([]);
93
+ });
94
+ });
95
+
96
+ describe("insertAtSelection", () => {
97
+ it("types at the cursor, over a selection, or at the end", () => {
98
+ expect(insertAtSelection("hello world", { start: 5, end: 5 }, "🙂")).toEqual({ text: "hello🙂 world", cursor: 7 });
99
+ expect(insertAtSelection("hello world", { start: 6, end: 11 }, "🌍")).toEqual({ text: "hello 🌍", cursor: 8 });
100
+ expect(insertAtSelection("hi", null, "👋")).toEqual({ text: "hi👋", cursor: 4 });
101
+ expect(insertAtSelection("hi", { start: 40, end: 50 }, "!")).toEqual({ text: "hi!", cursor: 3 });
102
+ });
103
+ });
104
+
105
+ const ME = "u1";
106
+ function textMessage(): ChatMessage {
107
+ return {
108
+ id: "m1", conversationId: "c1", seq: 1, clientId: "c", senderId: "u2", senderRole: "member", kind: "text", body: "hi",
109
+ mentions: [], replyToId: null, threadRootId: null, threadCount: 0, threadLastAt: null, topic: null, visibility: "everyone",
110
+ silent: false, meta: {}, createdAt: "2026-10-25T10:00:00Z", editedAt: null, deletedAt: null, attachments: [], reactions: [],
111
+ } as ChatMessage;
112
+ }
113
+
114
+ describe("the long-press sheet's +", () => {
115
+ const context = { me: ME, role: "member" as const, canPost: true, pinned: false, canPin: false, canCopy: false };
116
+
117
+ it("opens every emoji, searchable, and reacts with the one picked", async () => {
118
+ const onReact = vi.fn();
119
+ const onClose = vi.fn();
120
+ const drafts = memoryDraftStorage();
121
+ render(
122
+ <ChatNativeProvider modules={{ drafts }}>
123
+ <MessageActionsSheet message={textMessage()} context={context} onClose={onClose} onReact={onReact} onAction={vi.fn()} testID="acts" />
124
+ </ChatNativeProvider>,
125
+ );
126
+ const more = await screen.findByTestId("acts-react-more");
127
+ expect(more.getAttribute("aria-label")).toBe("More reactions");
128
+ fireEvent.click(more);
129
+ expect(screen.getByTestId("acts-picker")).toBeTruthy();
130
+ // Categories are tabs, each named for screen readers.
131
+ expect(screen.getByTestId("acts-picker-tab-food").getAttribute("aria-label")).toBe("Food and drink");
132
+ fireEvent.change(screen.getByTestId("acts-picker-search").querySelector("input")!, { target: { value: "party popper" } });
133
+ const party = await screen.findByTestId("acts-picker-emoji-🎉");
134
+ expect(party.getAttribute("aria-label")).toBe("party popper");
135
+ fireEvent.click(party);
136
+ expect(onReact).toHaveBeenCalledWith(expect.objectContaining({ id: "m1" }), "🎉", true);
137
+ expect(onClose).toHaveBeenCalled();
138
+ // Kept as a recent one for this person.
139
+ await waitFor(async () => expect(await loadRecentEmoji(drafts, ME)).toEqual(["🎉"]));
140
+ });
141
+
142
+ it("shows the person's recent emoji first, under Recent", async () => {
143
+ const drafts = memoryDraftStorage();
144
+ await saveRecentEmoji(drafts, "u-recent", ["🦄"]);
145
+ render(
146
+ <ChatNativeProvider modules={{ drafts }}>
147
+ <MessageActionsSheet message={textMessage()} context={{ ...context, me: "u-recent" }} onClose={vi.fn()} onReact={vi.fn()} onAction={vi.fn()} testID="acts" />
148
+ </ChatNativeProvider>,
149
+ );
150
+ fireEvent.click(await screen.findByTestId("acts-react-more"));
151
+ await screen.findByTestId("acts-picker-tab-recent");
152
+ expect(screen.getByTestId("acts-picker-tab-recent").getAttribute("aria-selected")).toBe("true");
153
+ expect(screen.getByTestId("acts-picker-emoji-🦄")).toBeTruthy();
154
+ });
155
+ });
156
+
157
+ describe("the long-press sheet's caption", () => {
158
+ const context = { me: "u1", role: "member" as const, canPost: true, pinned: false, canPin: false, canCopy: false };
159
+ it("has room under the sheet's top edge, and names an app card by its kind's title", async () => {
160
+ const message = { ...textMessage(), body: "", attachments: [{ id: "a1", kind: "check_in", ref: {} }] } as unknown as ChatMessage;
161
+ render(
162
+ <ChatNativeProvider>
163
+ <MessageActionsSheet message={message} context={context} onClose={vi.fn()} onReact={vi.fn()} onAction={vi.fn()} attachmentKinds={{ check_in: { title: () => "Check-in" } }} testID="acts" />
164
+ </ChatNativeProvider>,
165
+ );
166
+ const caption = await screen.findByTestId("acts-caption");
167
+ expect(caption.textContent).toBe("Check-in");
168
+ const body = JSON.parse(caption.parentElement!.getAttribute("data-style")!) as { paddingTop?: number };
169
+ expect(body.paddingTop).toBeGreaterThanOrEqual(16);
170
+ });
171
+ });
172
+
173
+ function ComposerHarness() {
174
+ const composer = useChatComposer("c1", null, ME);
175
+ return <ChatComposer composer={composer} canPost disabledReason={null} role="member" onSend={vi.fn()} userId="u-composer" testID="cmp" />;
176
+ }
177
+
178
+ describe("the composer's emoji button", () => {
179
+ it("opens the picker, which types at the cursor", async () => {
180
+ render(
181
+ <ChatNativeProvider>
182
+ <ComposerHarness />
183
+ </ChatNativeProvider>,
184
+ );
185
+ const input = screen.getByTestId("cmp-input") as HTMLInputElement;
186
+ fireEvent.change(input, { target: { value: "see you" } });
187
+ const button = screen.getByTestId("cmp-emoji");
188
+ expect(button.getAttribute("aria-label")).toBe("Add emoji");
189
+ await act(async () => fireEvent.click(button));
190
+ fireEvent.click(await screen.findByTestId("cmp-emoji-sheet-picker-emoji-😀"));
191
+ await waitFor(() => expect(input.value).toBe("see you😀"));
192
+ fireEvent.click(screen.getByTestId("cmp-emoji-sheet-done"));
193
+ await waitFor(() => expect(screen.queryByTestId("cmp-emoji-sheet-picker")).toBeNull());
194
+ });
195
+ });
196
+
197
+ describe("the picker's grid", () => {
198
+ it("lays out rows by row index (FlatList with numColumns asks per row), so every row scrolls into place", () => {
199
+ lists.length = 0;
200
+ render(
201
+ <ChatNativeProvider>
202
+ <ChatEmojiPicker onSelect={vi.fn()} testID="pk" />
203
+ </ChatNativeProvider>,
204
+ );
205
+ const grid = lists.find((p) => p.testID === "pk-grid")!;
206
+ const columns = grid.numColumns as number;
207
+ expect(columns).toBe(8);
208
+ const layout = grid.getItemLayout as (d: unknown, i: number) => { length: number; offset: number };
209
+ expect(layout(null, 5)).toEqual({ length: 44, offset: 5 * 44, index: 5 });
210
+ const rows = Math.ceil((grid.data as unknown[]).length / columns);
211
+ expect(layout(null, rows - 1).offset).toBe((rows - 1) * 44);
212
+ });
213
+
214
+ it("keeps an emoji picked while the stored recents were still loading", async () => {
215
+ const drafts = memoryDraftStorage();
216
+ await saveRecentEmoji(drafts, "u-race", ["🍕", "🎸"]);
217
+ let release!: () => void;
218
+ const gate = new Promise<void>((r) => (release = r));
219
+ // Read when asked, answered late: as AsyncStorage does with a write queued behind the read.
220
+ const slow = {
221
+ ...drafts,
222
+ getItem: async (k: string) => {
223
+ const value = await drafts.getItem(k);
224
+ await gate;
225
+ return value;
226
+ },
227
+ };
228
+ render(
229
+ <ChatNativeProvider modules={{ drafts: slow }}>
230
+ <ChatEmojiPicker onSelect={vi.fn()} userId="u-race" testID="pk" />
231
+ </ChatNativeProvider>,
232
+ );
233
+ fireEvent.click(screen.getByTestId("pk-emoji-😀"));
234
+ await act(async () => release());
235
+ await waitFor(async () => expect(await loadRecentEmoji(drafts, "u-race")).toEqual(["😀", "🍕", "🎸"]));
236
+ });
237
+ });
238
+
239
+ describe("the composer's emoji button, picked twice quickly", () => {
240
+ it("types both", async () => {
241
+ render(
242
+ <ChatNativeProvider modules={{ drafts: memoryDraftStorage() }}>
243
+ <ComposerHarness />
244
+ </ChatNativeProvider>,
245
+ );
246
+ await act(async () => fireEvent.click(screen.getByTestId("cmp-emoji")));
247
+ const smile = await screen.findByTestId("cmp-emoji-sheet-picker-emoji-😀");
248
+ act(() => {
249
+ fireEvent.click(smile);
250
+ fireEvent.click(smile);
251
+ });
252
+ await waitFor(() => expect((screen.getByTestId("cmp-input") as HTMLInputElement).value).toBe("😀😀"));
253
+ });
254
+ });
@@ -0,0 +1,48 @@
1
+ // @vitest-environment happy-dom
2
+ // @feat:chat
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import { cleanup, render, waitFor } from "@testing-library/react";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { GenesisModulesProvider, NativeToastProvider } from "@marktiderman/genesis-ui-native/components";
7
+ import type { ChatClient } from "../../core/client";
8
+ import { ChatProvider } from "../../react/index";
9
+ import { ChatNativeProvider } from "../context";
10
+ import { ChatInboxScreen } from "../inbox-screen";
11
+
12
+ vi.mock("react-native", async (original) => ({ ...(await original<object>()), useWindowDimensions: () => ({ width: 390, height: 844, scale: 3, fontScale: 1 }) }));
13
+
14
+ afterEach(cleanup);
15
+
16
+ const ME = "11111111-1111-4111-8111-111111111111";
17
+
18
+ function fakeClient() {
19
+ const inbox = vi.fn(async (_opts: Record<string, unknown>) => []);
20
+ const client = {
21
+ inbox,
22
+ subscribeInbox: vi.fn(() => ({ unsubscribe: vi.fn() })),
23
+ unreadTotal: vi.fn(async () => 0),
24
+ } as unknown as ChatClient;
25
+ return { client, inbox };
26
+ }
27
+
28
+ describe("ChatInboxScreen", () => {
29
+ it("passes kinds and excludeKinds to the inbox, so an app can keep support tickets out of Messages", async () => {
30
+ const { client, inbox } = fakeClient();
31
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
32
+ render(
33
+ <QueryClientProvider client={qc}>
34
+ <GenesisModulesProvider modules={{}}>
35
+ <ChatProvider client={client} userId={ME}>
36
+ <NativeToastProvider>
37
+ <ChatNativeProvider>
38
+ <ChatInboxScreen onOpen={() => {}} excludeKinds={["support"]} kinds={["direct", "group"]} />
39
+ </ChatNativeProvider>
40
+ </NativeToastProvider>
41
+ </ChatProvider>
42
+ </GenesisModulesProvider>
43
+ </QueryClientProvider>,
44
+ );
45
+ await waitFor(() => expect(inbox).toHaveBeenCalled());
46
+ expect(inbox.mock.calls[0]![0]).toMatchObject({ kinds: ["direct", "group"], excludeKinds: ["support"] });
47
+ });
48
+ });
@@ -0,0 +1,127 @@
1
+ // @vitest-environment happy-dom
2
+ // @feat:chat
3
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import type { ChatAttachment, ChatMessage, PendingMessage } from "../../core/types";
6
+ import { ChatNativeProvider } from "../context";
7
+ import { MessageBubble } from "../message-bubble";
8
+ import type { ChatNativeModules } from "../modules";
9
+ import { act } from "@testing-library/react";
10
+ import { invertedRows, type ThreadRow } from "../rows";
11
+
12
+ // The shared React Native shim has no useWindowDimensions; attachments size pictures by it.
13
+ vi.mock("react-native", async (original) => ({ ...(await original<object>()), useWindowDimensions: () => ({ width: 390, height: 844, scale: 3, fontScale: 1 }) }));
14
+
15
+ afterEach(cleanup);
16
+
17
+ const ME = "11111111-1111-4111-8111-111111111111";
18
+
19
+ function failedRow(files: PendingMessage["files"]): Extract<ThreadRow, { type: "message" }> {
20
+ const pending: PendingMessage = {
21
+ clientId: "c1",
22
+ input: { conversationId: "conv", clientId: "c1", body: "" },
23
+ senderId: ME,
24
+ createdAt: "2026-10-25T10:00:00Z",
25
+ status: "failed",
26
+ error: "Attach the file again to send",
27
+ files,
28
+ };
29
+ return invertedRows([{ type: "pending", pending }], { me: ME, group: false }).find((r) => r.type === "message") as Extract<ThreadRow, { type: "message" }>;
30
+ }
31
+
32
+ function show(row: Extract<ThreadRow, { type: "message" }>, handlers: Partial<Parameters<typeof MessageBubble>[0]> = {}) {
33
+ render(<MessageBubble row={row} me={ME} urls={{}} group={false} reducedMotion testID="b" {...handlers} />);
34
+ }
35
+
36
+ describe("a failed message", () => {
37
+ it("whose file was lost offers Attach again, not Retry, with the files it lost", () => {
38
+ const onReattach = vi.fn();
39
+ const files = [{ name: "photo.png", contentType: "image/png", kind: "image", lost: true }];
40
+ show(failedRow(files), { onReattach, onRetry: vi.fn(), onDiscard: vi.fn() });
41
+ expect(screen.getByTestId("b-failed").textContent).toBe("Attach the file again to send");
42
+ expect(screen.queryByTestId("b-retry")).toBeNull();
43
+ fireEvent.click(screen.getByTestId("b-reattach"));
44
+ expect(onReattach).toHaveBeenCalledWith("c1", files);
45
+ expect(screen.getByTestId("b-discard")).toBeTruthy();
46
+ });
47
+
48
+ it("whose files are all still there offers Retry", () => {
49
+ const onRetry = vi.fn();
50
+ show(failedRow([{ name: "photo.jpg", contentType: "image/jpeg", kind: "image" }]), { onRetry, onReattach: vi.fn() });
51
+ expect(screen.queryByTestId("b-reattach")).toBeNull();
52
+ fireEvent.click(screen.getByTestId("b-retry"));
53
+ expect(onRetry).toHaveBeenCalledWith("c1");
54
+ });
55
+
56
+ it("draws a pending voice note from PendingMessage.files, with its duration", () => {
57
+ show(failedRow([{ name: "voice.m4a", contentType: "audio/mp4", kind: "voice", meta: { durationMs: 9000, waveform: [10, 50] } }]));
58
+ expect(screen.getByTestId("b-voice-0")).toBeTruthy();
59
+ });
60
+ });
61
+
62
+ function attachment(id: string, kind: "image" | "voice", path: string): ChatAttachment {
63
+ return {
64
+ id, messageId: "m1", position: 0, kind, ref: {}, provider: "storage", path, providerId: null, playbackId: null, url: null, status: "ready",
65
+ name: kind === "image" ? `${id}.jpg` : "voice.m4a", mime: null, bytes: null, width: 10, height: 10, durationMs: 4000, waveform: [10, 60],
66
+ posterPath: null, thumbhash: null, transcript: null, transcriptStatus: null,
67
+ } as ChatAttachment;
68
+ }
69
+
70
+ function messageRow(attachments: ChatAttachment[]): Extract<ThreadRow, { type: "message" }> {
71
+ const message = {
72
+ id: "m1", conversationId: "conv", seq: 1, clientId: "cm1", senderId: "22222222-2222-4222-8222-222222222222", senderRole: "member", kind: "text",
73
+ body: "look", mentions: [], replyToId: null, threadRootId: null, threadCount: 0, threadLastAt: null, topic: null, visibility: "everyone",
74
+ silent: false, meta: {}, createdAt: "2026-10-25T10:00:00Z", editedAt: null, deletedAt: null, attachments, reactions: [],
75
+ } as unknown as ChatMessage;
76
+ return invertedRows([{ type: "message", message }], { me: ME, group: false }).find((r) => r.type === "message") as Extract<ThreadRow, { type: "message" }>;
77
+ }
78
+
79
+ function fakeAudio() {
80
+ const player = { play: vi.fn(), pause: vi.fn(), seekTo: vi.fn(async () => undefined), setPlaybackRate: vi.fn() };
81
+ const audio = {
82
+ useAudioRecorder: vi.fn(),
83
+ useAudioPlayer: vi.fn(() => player),
84
+ useAudioPlayerStatus: vi.fn(() => ({ currentTime: 0, duration: 4, playing: false, didJustFinish: false, isLoaded: true, playbackRate: 1 })),
85
+ RecordingPresets: { HIGH_QUALITY: {} },
86
+ requestRecordingPermissionsAsync: vi.fn(),
87
+ setAudioModeAsync: vi.fn(),
88
+ };
89
+ return { audio: audio as unknown as ChatNativeModules["audio"], player };
90
+ }
91
+
92
+ const fireA11y = (el: HTMLElement, name: string) => act(() => void el.dispatchEvent(new CustomEvent("accessibilityaction", { detail: name })));
93
+
94
+ describe("a bubble with media, for VoiceOver", () => {
95
+ it("carries the hidden controls as its own actions: play and speed for a voice note, open for each photo", () => {
96
+ const { audio, player } = fakeAudio();
97
+ const onOpenImage = vi.fn();
98
+ const urls = { "p/voice.m4a": "https://x/voice.m4a", "p/a.jpg": "https://x/a.jpg", "p/b.jpg": "https://x/b.jpg" };
99
+ render(
100
+ <ChatNativeProvider modules={{ audio }}>
101
+ <MessageBubble
102
+ row={messageRow([attachment("v", "voice", "p/voice.m4a"), attachment("a", "image", "p/a.jpg"), attachment("b", "image", "p/b.jpg")])}
103
+ me={ME}
104
+ urls={urls}
105
+ group={false}
106
+ reducedMotion
107
+ onOpenImage={onOpenImage}
108
+ onReply={vi.fn()}
109
+ testID="b"
110
+ />
111
+ </ChatNativeProvider>,
112
+ );
113
+ const bubble = screen.getByTestId("b-bubble");
114
+ expect(bubble.getAttribute("data-a11y-actions")).toBe("voice-0,voice-rate-0,photo-0,photo-1,reply");
115
+ fireA11y(bubble, "photo-1");
116
+ expect(onOpenImage).toHaveBeenCalledWith("https://x/b.jpg", "Photo, b.jpg");
117
+ fireA11y(bubble, "voice-0");
118
+ expect(player.play).toHaveBeenCalledTimes(1);
119
+ fireA11y(bubble, "voice-rate-0");
120
+ expect(player.setPlaybackRate).toHaveBeenCalledWith(1.5);
121
+ });
122
+
123
+ it("offers no play action without expo-audio, where the note can't play", () => {
124
+ render(<MessageBubble row={messageRow([attachment("v", "voice", "p/voice.m4a")])} me={ME} urls={{ "p/voice.m4a": "https://x/v" }} group={false} reducedMotion testID="b" />);
125
+ expect(screen.getByTestId("b-bubble").getAttribute("data-a11y-actions") ?? "").not.toContain("voice");
126
+ });
127
+ });
@@ -0,0 +1,164 @@
1
+ // @feat:chat
2
+ import { describe, expect, it } from "vitest";
3
+ import type { ChatInboxItem, ChatMessage, ChatTopic } from "../../core/types";
4
+ import { availabilityInput, awayLineFor, clockToDate, dateToClock, defaultWeek, formFrom, hoursFromWeek, sameTimeDays, weekFromHours } from "../availability";
5
+ import { broadcastOutcome, broadcastRows } from "../broadcast-sheet";
6
+ import { emptyComposer, type ComposerState } from "../composer-state";
7
+ import { chatNativeCopy } from "../copy";
8
+ import { moderationNote } from "../moderation";
9
+ import { defaultScheduleAt, scheduleInputFrom, withDay, withTime } from "../schedule-sheet";
10
+ import { snippetText } from "../search-screen";
11
+ import { canManageTopics, checkTopicName, topicClosedFor, topicMenuFor } from "../topics";
12
+ import { rowIndexOf } from "../use-jump";
13
+ import type { ThreadRow } from "../rows";
14
+
15
+ const topic = (key: string, closed = false): ChatTopic => ({
16
+ conversationId: "c", topic: key, title: key, position: 0, closedAt: closed ? "2026-10-01T00:00:00Z" : null, createdBy: null, createdAt: "2026-10-01T00:00:00Z",
17
+ });
18
+
19
+ describe("topics", () => {
20
+ it("are managed by owners and admins only", () => {
21
+ expect(canManageTopics("owner")).toBe(true);
22
+ expect(canManageTopics("admin")).toBe(true);
23
+ expect(canManageTopics("member")).toBe(false);
24
+ expect(canManageTopics("observer")).toBe(false);
25
+ expect(canManageTopics(null)).toBe(false);
26
+ });
27
+
28
+ it("closed, take posts only from owners, admins and observers", () => {
29
+ expect(topicClosedFor(topic("a", true), "member")).toBe(true);
30
+ expect(topicClosedFor(topic("a", true), "owner")).toBe(false);
31
+ expect(topicClosedFor(topic("a", true), "admin")).toBe(false);
32
+ expect(topicClosedFor(topic("a", true), "observer")).toBe(false);
33
+ expect(topicClosedFor(topic("a"), "member")).toBe(false);
34
+ expect(topicClosedFor(null, "member")).toBe(false);
35
+ });
36
+
37
+ it("offer New always, and Rename with Close or Reopen for the topic shown", () => {
38
+ expect(topicMenuFor(null)).toEqual(["new"]);
39
+ expect(topicMenuFor(topic("a"))).toEqual(["new", "rename", "close"]);
40
+ expect(topicMenuFor(topic("a", true))).toEqual(["new", "rename", "reopen"]);
41
+ });
42
+
43
+ it("key a new topic from its name, refuse a clash or an empty name, and keep the key on a rename", () => {
44
+ expect(checkTopicName("Week 1: Habits", [])).toEqual({ key: "week-1-habits" });
45
+ expect(checkTopicName(" ", [])).toEqual({ error: "empty" });
46
+ expect(checkTopicName("Week 1: habits!", [topic("week-1-habits")])).toEqual({ error: "exists" });
47
+ expect(checkTopicName("New name", [topic("week-1-habits")], "week-1-habits")).toEqual({ key: "week-1-habits" });
48
+ });
49
+ });
50
+
51
+ describe("scheduled send", () => {
52
+ const composer = (text: string, files = 0): ComposerState => ({
53
+ ...emptyComposer,
54
+ text,
55
+ attachments: Array.from({ length: files }, (_, i) => ({ key: `k${i}`, file: { data: "file:///a.jpg", name: "a.jpg", contentType: "image/jpeg" } })),
56
+ }) as ComposerState;
57
+ const at = new Date("2026-10-26T09:00:00");
58
+
59
+ it("schedules what's written, in the topic shown, with its reply and mentions", () => {
60
+ const state = { ...composer("hi @[Sam](user:22222222-2222-4222-8222-222222222222)"), replyTo: { messageId: "m1", name: "Sam", preview: "x" } } as ComposerState;
61
+ const made = scheduleInputFrom(state, { conversationId: "c", topic: "week-1", at });
62
+ expect(made).toEqual({
63
+ input: { conversationId: "c", body: "hi @[Sam](user:22222222-2222-4222-8222-222222222222)", mentions: ["22222222-2222-4222-8222-222222222222"], replyToId: "m1", topic: "week-1", sendAt: at },
64
+ });
65
+ });
66
+
67
+ it("won't schedule nothing, or files (native files are URIs; scheduling uploads bytes only)", () => {
68
+ expect(scheduleInputFrom(composer(" "), { conversationId: "c", at })).toEqual({ error: "empty" });
69
+ expect(scheduleInputFrom(composer("with a photo", 1), { conversationId: "c", at })).toEqual({ error: "files" });
70
+ });
71
+
72
+ it("starts the custom time an hour ahead on the next five minutes, and sets day and time apart", () => {
73
+ const d = defaultScheduleAt(new Date("2026-10-25T10:02:30"));
74
+ expect([d.getHours(), d.getMinutes(), d.getSeconds()]).toEqual([11, 5, 0]);
75
+ const base = new Date("2026-10-25T14:30:00");
76
+ const day = withDay(base, new Date("2026-12-01T03:00:00"));
77
+ expect([day.getMonth(), day.getDate(), day.getHours(), day.getMinutes()]).toEqual([11, 1, 14, 30]);
78
+ const time = withTime(base, new Date("2020-01-01T08:15:00"));
79
+ expect([time.getDate(), time.getHours(), time.getMinutes()]).toEqual([25, 8, 15]);
80
+ });
81
+ });
82
+
83
+ describe("availability", () => {
84
+ it("reads saved hours as a week and writes the week back, keeping extra ranges", () => {
85
+ const saved = { mon: [["09:00", "12:00"], ["13:00", "17:00"]] as [string, string][], wed: [["10:00", "14:00"]] as [string, string][] };
86
+ const week = weekFromHours(saved);
87
+ expect(week.mon).toEqual({ on: true, from: "09:00", to: "12:00" });
88
+ expect(week.tue.on).toBe(false);
89
+ expect(hoursFromWeek({ ...week, mon: { ...week.mon, from: "08:00" } }, saved)).toEqual({ mon: [["08:00", "12:00"], ["13:00", "17:00"]], wed: [["10:00", "14:00"]] });
90
+ expect(Object.keys(hoursFromWeek(defaultWeek(), null))).toEqual(["mon", "tue", "wed", "thu", "fri"]);
91
+ });
92
+
93
+ it("refuses a day that starts and ends at the same time", () => {
94
+ const week = defaultWeek();
95
+ expect(sameTimeDays(week)).toEqual([]);
96
+ expect(sameTimeDays({ ...week, tue: { on: true, from: "09:00", to: "09:00" }, sat: { on: false, from: "09:00", to: "09:00" } })).toEqual(["tue"]);
97
+ });
98
+
99
+ it("saves the form as setAvailability expects (blank message and no hours as null)", () => {
100
+ const until = new Date("2026-11-02T09:00:00Z");
101
+ const form = { ...formFrom(null, "Europe/London"), message: " Back Monday ", until, useHours: false };
102
+ expect(availabilityInput(form, null)).toEqual({ awayMessage: "Back Monday", awayUntil: until.toISOString(), timeZone: "Europe/London", hours: null });
103
+ expect(availabilityInput({ ...form, message: " ", until: null, useHours: true }, null)).toMatchObject({ awayMessage: null, awayUntil: null, hours: { mon: [["09:00", "17:00"]] } });
104
+ });
105
+
106
+ it("round-trips a clock time through a picker's Date", () => {
107
+ expect(dateToClock(clockToDate("07:45"))).toBe("07:45");
108
+ expect(dateToClock(clockToDate("bad"))).toBe("09:00");
109
+ });
110
+
111
+ it("names the other person when away, everyone away in a group, and skips you and observers", () => {
112
+ const now = new Date("2026-10-25T12:00:00Z");
113
+ const away = { userId: "sam", awayMessage: "On a retreat", awayUntil: null, timeZone: "UTC", hours: null };
114
+ const names: Record<string, string> = { me: "Me", sam: "Sam", lee: "Lee", obs: "Obs" };
115
+ const forUser = (id: string) => (id === "sam" || id === "lee" || id === "obs" || id === "me" ? { ...away, userId: id } : null);
116
+ expect(awayLineFor([{ userId: "me", role: "member" }, { userId: "sam", role: "member" }], "me", forUser, (id) => names[id]!, now)).toBe("Sam is away: On a retreat");
117
+ expect(awayLineFor([{ userId: "me", role: "member" }, { userId: "sam", role: "member" }, { userId: "lee", role: "member" }, { userId: "obs", role: "observer" }], "me", forUser, (id) => names[id]!, now)).toBe("Sam and Lee are away");
118
+ expect(awayLineFor([{ userId: "me", role: "member" }, { userId: "x", role: "member" }], "me", forUser, (id) => id, now)).toBeNull();
119
+ });
120
+ });
121
+
122
+ describe("broadcast", () => {
123
+ const item = (id: string, title: string | null, other: string | null, archived = false) => ({ conversationId: id, title, otherUserId: other, archived }) as ChatInboxItem;
124
+ it("lists the inbox by name, without archived ones, filtered by the query", () => {
125
+ const items = [item("1", null, "zoe"), item("2", "Coaching group", null), item("3", null, "amy"), item("4", null, "old", true)];
126
+ const person = (id: string | null) => (id ? { name: id === "zoe" ? "Zoe" : id === "amy" ? "Amy" : "Old" } : undefined);
127
+ expect(broadcastRows(items, { person, untitled: "Conversation" }).map((r) => r.title)).toEqual(["Amy", "Coaching group", "Zoe"]);
128
+ expect(broadcastRows(items, { person, untitled: "Conversation", query: "zo" }).map((r) => r.title)).toEqual(["Zoe"]);
129
+ expect(broadcastRows(items, { person, untitled: "Conversation", filter: (i) => i.otherUserId !== null }).map((r) => r.title)).toEqual(["Amy", "Zoe"]);
130
+ });
131
+
132
+ it("says how many it reached", () => {
133
+ expect(broadcastOutcome(3, 3, chatNativeCopy)).toEqual({ title: "Sent to 3 conversations", partial: false });
134
+ expect(broadcastOutcome(1, 1, chatNativeCopy)).toEqual({ title: "Sent to 1 conversation", partial: false });
135
+ expect(broadcastOutcome(2, 3, chatNativeCopy)).toEqual({ title: "Sent to 2 of 3. You can't post in the others.", partial: true });
136
+ });
137
+ });
138
+
139
+ describe("moderation", () => {
140
+ const m = (moderation: ChatMessage["moderation"], deletedAt: string | null = null) => ({ moderation, deletedAt });
141
+ it("tells a hidden message's sender only they see it, and marks hidden and flagged ones for observers", () => {
142
+ expect(moderationNote(m("hidden"), { mine: true, observer: false })).toBe("hidden-mine");
143
+ expect(moderationNote(m("hidden"), { mine: false, observer: true })).toBe("hidden");
144
+ expect(moderationNote(m("flagged"), { mine: false, observer: true })).toBe("flagged");
145
+ expect(moderationNote(m("flagged"), { mine: true, observer: false })).toBeNull();
146
+ expect(moderationNote(m(null), { mine: false, observer: true })).toBeNull();
147
+ expect(moderationNote(m("hidden", "2026-10-01T00:00:00Z"), { mine: true, observer: false })).toBeNull();
148
+ });
149
+ });
150
+
151
+ describe("search and jumping", () => {
152
+ it("reads a snippet without its marks", () => {
153
+ expect(snippetText("…a «word» here")).toBe("…a word here");
154
+ });
155
+ it("finds a message's row past day and unread rows", () => {
156
+ const rows = [
157
+ { type: "day", key: "d", label: "Today" },
158
+ { type: "unread", key: "u" },
159
+ { type: "message", key: "c1", item: { type: "message", message: { id: "m1" } } },
160
+ ] as unknown as ThreadRow[];
161
+ expect(rowIndexOf(rows, "m1")).toBe(2);
162
+ expect(rowIndexOf(rows, "m9")).toBe(-1);
163
+ });
164
+ });