@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,424 @@
1
+ // @vitest-environment happy-dom
2
+ // @feat:chat
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
5
+ import type { ReactNode } from "react";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { NativeToastProvider } from "@marktiderman/genesis-ui-native/components";
8
+ import { ChatProvider } from "../../react/index";
9
+ import { createMemoryChatClient, type MemoryChatClient, type MemoryConversationSeed } from "../../web/memory-client";
10
+ import { ChatAvailabilityScreen } from "../availability";
11
+ import { ChatBroadcastSheet } from "../broadcast-sheet";
12
+ import { ChatNativeProvider } from "../context";
13
+ import { ChatScheduledList, ChatScheduleSheet } from "../schedule-sheet";
14
+ import { ChatSearchScreen } from "../search-screen";
15
+ import { ChatThreadScreen } from "../thread-screen";
16
+ import { scrollToShow } from "../topics";
17
+ import { activePlaybackId, claimPlayback } from "../voice-player";
18
+
19
+ vi.mock("react-native", async (original) => ({ ...(await original<object>()), useWindowDimensions: () => ({ width: 390, height: 844, scale: 3, fontScale: 1 }) }));
20
+
21
+ afterEach(cleanup);
22
+
23
+ const ME = "11111111-1111-4111-8111-111111111111";
24
+ const SAM = "22222222-2222-4222-8222-222222222222";
25
+ const LEE = "33333333-3333-4333-8333-333333333333";
26
+ const NAMES: Record<string, string> = { [ME]: "Me", [SAM]: "Sam", [LEE]: "Lee" };
27
+ const getPerson = (id: string) => (NAMES[id] ? { name: NAMES[id]! } : undefined);
28
+
29
+ function wrap(client: MemoryChatClient, children: ReactNode) {
30
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
31
+ return (
32
+ <QueryClientProvider client={qc}>
33
+ <ChatProvider client={client} userId={ME}>
34
+ <NativeToastProvider>
35
+ <ChatNativeProvider getPerson={getPerson}>{children}</ChatNativeProvider>
36
+ </NativeToastProvider>
37
+ </ChatProvider>
38
+ </QueryClientProvider>
39
+ );
40
+ }
41
+
42
+ function memory(conversations: MemoryConversationSeed[], extra: Partial<Parameters<typeof createMemoryChatClient>[0]> = {}) {
43
+ return createMemoryChatClient({ me: ME, conversations, ...extra });
44
+ }
45
+
46
+ const called = (client: MemoryChatClient, method: string) => client.calls.filter((c) => c.method === method);
47
+
48
+ describe("ChatThreadScreen topics", () => {
49
+ const seed = (myRole: "owner" | "member", closed = false): MemoryConversationSeed => ({
50
+ id: "cohort",
51
+ kind: "cohort",
52
+ title: "Cohort",
53
+ members: [{ userId: ME, role: myRole }, { userId: SAM }, { userId: LEE }],
54
+ topics: [{ topic: "week-1", title: "Week 1" }, { topic: "week-2", title: "Week 2", closed }],
55
+ messages: [
56
+ { id: "a", senderId: SAM, body: "general hello" },
57
+ { id: "b", senderId: SAM, body: "habits talk", topic: "week-1" },
58
+ { id: "c", senderId: LEE, body: "week two talk", topic: "week-2" },
59
+ ],
60
+ });
61
+
62
+ it("shows the topics as chips and lists only the topic chosen, with its name in the field", async () => {
63
+ const client = memory([seed("member")]);
64
+ render(wrap(client, <ChatThreadScreen conversationId="cohort" testID="t" />));
65
+ await waitFor(() => expect(screen.getByTestId("t-topics-week-1")).toBeTruthy());
66
+ expect(screen.getByTestId("t-topics-all").getAttribute("aria-selected")).toBe("true");
67
+ await waitFor(() => expect(screen.getByTestId("t-message-a")).toBeTruthy());
68
+ expect(screen.queryByTestId("t-topics-menu")).toBeNull(); // members don't manage topics
69
+
70
+ fireEvent.click(screen.getByTestId("t-topics-week-1"));
71
+ await screen.findByTestId("t-message-b");
72
+ expect(screen.queryByTestId("t-message-a")).toBeNull();
73
+ expect(screen.queryByTestId("t-message-c")).toBeNull();
74
+ expect(called(client, "messages").some((c) => (c.args[1] as { topic?: string }).topic === "week-1")).toBe(true);
75
+ expect((screen.getByTestId("t-composer-input") as HTMLInputElement).placeholder).toBe("Message #Week 1");
76
+ });
77
+
78
+ it("a closed topic takes no posts from a member, and says so", async () => {
79
+ const client = memory([seed("member", true)]);
80
+ render(wrap(client, <ChatThreadScreen conversationId="cohort" testID="t" />));
81
+ await waitFor(() => expect(screen.getByTestId("t-topics-week-2")).toBeTruthy());
82
+ fireEvent.click(screen.getByTestId("t-topics-week-2"));
83
+ await waitFor(() => expect(screen.getByTestId("t-composer-disabled").textContent).toBe("This topic is closed."));
84
+ });
85
+
86
+ it("lets an owner add a topic, which is then shown", async () => {
87
+ const client = memory([seed("owner")]);
88
+ render(wrap(client, <ChatThreadScreen conversationId="cohort" testID="t" />));
89
+ await waitFor(() => expect(screen.getByTestId("t-topics-menu")).toBeTruthy());
90
+ fireEvent.click(screen.getByTestId("t-topics-menu"));
91
+ fireEvent.click(await screen.findByTestId("t-topics-new"));
92
+ const name = await screen.findByTestId("t-topics-name");
93
+ fireEvent.change(name, { target: { value: "Week 3: Sleep" } });
94
+ fireEvent.click(screen.getByTestId("t-topics-save"));
95
+ await waitFor(() => expect(called(client, "setTopic")[0]?.args).toEqual(["cohort", "week-3-sleep", { title: "Week 3: Sleep", position: 2 }]));
96
+ await waitFor(() => expect(screen.getByTestId("t-topics-week-3-sleep").getAttribute("aria-selected")).toBe("true"));
97
+ });
98
+
99
+ it("lets an owner close the topic shown", async () => {
100
+ const client = memory([seed("owner")]);
101
+ render(wrap(client, <ChatThreadScreen conversationId="cohort" testID="t" />));
102
+ fireEvent.click(await screen.findByTestId("t-topics-week-1"));
103
+ fireEvent.click(screen.getByTestId("t-topics-menu"));
104
+ fireEvent.click(await screen.findByTestId("t-topics-close"));
105
+ await waitFor(() => expect(called(client, "setTopic")[0]?.args).toEqual(["cohort", "week-1", { closed: true }]));
106
+ });
107
+ });
108
+
109
+ describe("ChatThreadScreen topics across conversations", () => {
110
+ it("starts another conversation in All, not in the last one's topic", async () => {
111
+ const client = memory([
112
+ { id: "one", kind: "cohort", members: [{ userId: ME }, { userId: SAM }, { userId: LEE }], topics: [{ topic: "week-1", title: "Week 1" }], messages: [{ id: "a", senderId: SAM, body: "x", topic: "week-1" }] },
113
+ { id: "two", kind: "cohort", members: [{ userId: ME }, { userId: SAM }, { userId: LEE }], messages: [{ id: "b", senderId: SAM, body: "y" }] },
114
+ ]);
115
+ const view = render(wrap(client, <ChatThreadScreen conversationId="one" testID="t" />));
116
+ fireEvent.click(await screen.findByTestId("t-topics-week-1"));
117
+ await waitFor(() => expect(screen.getByTestId("t-topics-week-1").getAttribute("aria-selected")).toBe("true"));
118
+ view.rerender(wrap(client, <ChatThreadScreen conversationId="two" testID="t" />));
119
+ await screen.findByTestId("t-message-b");
120
+ const last = called(client, "messages").filter((c) => c.args[0] === "two").at(-1)!;
121
+ expect((last.args[1] as { topic?: string }).topic).toBeUndefined();
122
+ });
123
+ });
124
+
125
+ describe("ChatThreadScreen scheduled send", () => {
126
+ const direct: MemoryConversationSeed = { id: "dm", members: [{ userId: ME }, { userId: SAM }], messages: [{ id: "a", senderId: SAM, body: "hi" }] };
127
+
128
+ it("schedules what's written when Send is held, empties the field, and lists it with Cancel", async () => {
129
+ const client = memory([direct]);
130
+ render(wrap(client, <ChatThreadScreen conversationId="dm" testID="t" />));
131
+ const input = await screen.findByTestId("t-composer-input");
132
+ await waitFor(() => expect(input.hasAttribute("disabled")).toBe(false));
133
+ fireEvent.change(input, { target: { value: "see you tomorrow" } });
134
+ const send = screen.getByTestId("t-composer-send");
135
+ expect(send.getAttribute("data-a11y-actions")).toBe("longpress"); // VoiceOver's way to it
136
+ fireEvent.contextMenu(send);
137
+ fireEvent.click(await screen.findByTestId("t-schedule-pick-0"));
138
+ await waitFor(() => expect(called(client, "schedule")).toHaveLength(1));
139
+ expect(called(client, "schedule")[0]!.args[0]).toMatchObject({ conversationId: "dm", body: "see you tomorrow", topic: null });
140
+ await waitFor(() => expect((screen.getByTestId("t-composer-input") as HTMLInputElement).value).toBe(""));
141
+ expect(called(client, "send")).toHaveLength(0);
142
+
143
+ fireEvent.click(await screen.findByTestId("t-scheduled"));
144
+ expect(screen.getByTestId("t-scheduled").getAttribute("aria-label")).toBe("1 scheduled message");
145
+ const list = await screen.findByTestId("t-scheduled-sheet");
146
+ const cancel = within(list).getByText("Cancel");
147
+ fireEvent.click(cancel.closest("button")!);
148
+ await waitFor(() => expect(called(client, "cancelScheduled")).toHaveLength(1));
149
+ await waitFor(() => expect(screen.queryByTestId("t-scheduled")).toBeNull());
150
+ });
151
+
152
+ it("picks a custom time in the sheet, refusing one too soon", async () => {
153
+ const client = memory([direct]);
154
+ render(wrap(client, <ChatThreadScreen conversationId="dm" testID="t" />));
155
+ const input = await screen.findByTestId("t-composer-input");
156
+ await waitFor(() => expect(input.hasAttribute("disabled")).toBe(false));
157
+ fireEvent.change(input, { target: { value: "later" } });
158
+ fireEvent.contextMenu(screen.getByTestId("t-composer-send"));
159
+ fireEvent.click(await screen.findByTestId("t-schedule-pick-custom"));
160
+ expect(screen.getByTestId("t-schedule-custom")).toBeTruthy();
161
+ // An hour ahead by default: schedulable.
162
+ expect(screen.getByTestId("t-schedule-confirm").getAttribute("aria-disabled")).not.toBe("true");
163
+ fireEvent.click(screen.getByTestId("t-schedule-confirm"));
164
+ await waitFor(() => expect(called(client, "schedule")).toHaveLength(1));
165
+ const at = new Date((called(client, "schedule")[0]!.args[0] as { sendAt: Date }).sendAt);
166
+ expect(at.getTime()).toBeGreaterThan(Date.now() + 50 * 60_000);
167
+ });
168
+ });
169
+
170
+ describe("ChatScheduleSheet and ChatScheduledList", () => {
171
+ it("starts again at the quick picks each time it opens", async () => {
172
+ const client = memory([]);
173
+ const view = render(wrap(client, <ChatScheduleSheet open onOpenChange={vi.fn()} onSchedule={vi.fn()} testID="s" />));
174
+ fireEvent.click(await screen.findByTestId("s-pick-custom"));
175
+ expect(screen.getByTestId("s-custom")).toBeTruthy();
176
+ view.rerender(wrap(client, <ChatScheduleSheet open={false} onOpenChange={vi.fn()} onSchedule={vi.fn()} testID="s" />));
177
+ view.rerender(wrap(client, <ChatScheduleSheet open onOpenChange={vi.fn()} onSchedule={vi.fn()} testID="s" />));
178
+ expect(await screen.findByTestId("s-pick-0")).toBeTruthy();
179
+ expect(screen.queryByTestId("s-custom")).toBeNull();
180
+ });
181
+
182
+ it("offers Remove on one that couldn't be sent", async () => {
183
+ const client = memory([{ id: "dm", members: [{ userId: ME }, { userId: SAM }] }]);
184
+ await client.schedule({ conversationId: "dm", clientId: "c1", body: "later", sendAt: new Date(Date.now() + 3600_000) });
185
+ client.failNextSends(1);
186
+ await client.sendDue(new Date(Date.now() + 2 * 3600_000));
187
+ render(wrap(client, <ChatScheduledList conversationId="dm" testID="l" />));
188
+ const remove = await screen.findByText("Remove");
189
+ expect(screen.getByText("Couldn't send")).toBeTruthy();
190
+ fireEvent.click(remove.closest("button")!);
191
+ await waitFor(() => expect(called(client, "cancelScheduled")).toHaveLength(1));
192
+ });
193
+ });
194
+
195
+ describe("ChatThreadScreen availability and moderation", () => {
196
+ it("shows the other person's away line under the header", async () => {
197
+ const client = memory([{ id: "dm", members: [{ userId: ME }, { userId: SAM }] }], { availability: { [SAM]: { awayMessage: "On a retreat" } } });
198
+ render(wrap(client, <ChatThreadScreen conversationId="dm" testID="t" />));
199
+ await waitFor(() => expect(screen.getByTestId("t-away").getAttribute("aria-label")).toBe("Sam is away: On a retreat"));
200
+ });
201
+
202
+ it("shows nothing when they're around", async () => {
203
+ const client = memory([{ id: "dm", members: [{ userId: ME }, { userId: SAM }] }], { availability: { [SAM]: {} } });
204
+ render(wrap(client, <ChatThreadScreen conversationId="dm" testID="t" />));
205
+ await waitFor(() => expect(called(client, "availabilityFor")).toHaveLength(1));
206
+ expect(screen.queryByTestId("t-away")).toBeNull();
207
+ });
208
+
209
+ it("tells the sender a hidden message is theirs alone, and marks flagged ones for observers", async () => {
210
+ const client = memory([
211
+ { id: "dm", members: [{ userId: ME }, { userId: SAM }], messages: [{ id: "h", senderId: ME, body: "oops", moderation: "hidden" }, { id: "f", senderId: SAM, body: "hmm", moderation: "flagged" }] },
212
+ ]);
213
+ render(wrap(client, <ChatThreadScreen conversationId="dm" testID="t" />));
214
+ await waitFor(() => expect(screen.getByTestId("t-message-h-moderation").textContent).toBe("Hidden by moderation. Only you can see it."));
215
+ expect(screen.queryByTestId("t-message-f-moderation")).toBeNull();
216
+
217
+ cleanup();
218
+ const observed = memory([
219
+ { id: "g", members: [{ userId: SAM }, { userId: LEE }], myRole: "observer", messages: [{ id: "h", senderId: SAM, body: "oops", moderation: "hidden" }, { id: "f", senderId: LEE, body: "hmm", moderation: "flagged" }] },
220
+ ]);
221
+ render(wrap(observed, <ChatThreadScreen conversationId="g" testID="t" />));
222
+ await waitFor(() => expect(screen.getByTestId("t-message-h-moderation").textContent).toBe("Hidden by moderation"));
223
+ expect(screen.getByTestId("t-message-f-moderation").textContent).toBe("Flagged");
224
+ expect(screen.getByTestId("t-message-f-bubble").getAttribute("aria-label")).toContain("Flagged");
225
+ });
226
+ });
227
+
228
+ describe("ChatThreadScreen jumpTo", () => {
229
+ it("loads earlier pages until the message is held, then highlights it", async () => {
230
+ const messages = Array.from({ length: 120 }, (_, i) => ({ id: `m${i + 1}`, senderId: i % 2 ? SAM : ME, body: `message ${i + 1}` }));
231
+ const client = memory([{ id: "dm", members: [{ userId: ME }, { userId: SAM }], messages }]);
232
+ render(wrap(client, <ChatThreadScreen conversationId="dm" jumpTo={{ messageId: "m3", seq: 3 }} testID="t" />));
233
+ await waitFor(() => expect(screen.getByTestId("t-message-m3")).toBeTruthy());
234
+ expect(called(client, "messages").some((c) => (c.args[1] as { beforeSeq?: number }).beforeSeq !== undefined)).toBe(true);
235
+ await waitFor(() => expect(JSON.parse(screen.getByTestId("t-message-m3").dataset.style!).backgroundColor).toBeTruthy());
236
+ expect(JSON.parse(screen.getByTestId("t-message-m4").dataset.style!).backgroundColor).toBeUndefined();
237
+ });
238
+
239
+ it("says so when the message isn't there any more", async () => {
240
+ const client = memory([{ id: "dm", members: [{ userId: ME }, { userId: SAM }], messages: [{ id: "a", senderId: SAM, body: "hi" }] }]);
241
+ render(wrap(client, <ChatThreadScreen conversationId="dm" jumpTo={{ messageId: "gone" }} testID="t" />));
242
+ await waitFor(() => expect(screen.getByText("That message isn't here any more")).toBeTruthy());
243
+ });
244
+ });
245
+
246
+ describe("ChatSearchScreen", () => {
247
+ it("finds messages across conversations with the match highlighted and transcripts marked, and opens one", async () => {
248
+ const client = memory([
249
+ { id: "dm", members: [{ userId: ME }, { userId: SAM }], messages: [{ id: "a", senderId: SAM, body: "the habit tracker works" }] },
250
+ {
251
+ id: "dm2",
252
+ members: [{ userId: ME }, { userId: LEE }],
253
+ messages: [{ id: "v", senderId: LEE, body: "", attachments: [{ kind: "voice", transcript: "my habit this week" }] }],
254
+ },
255
+ ]);
256
+ const onOpen = vi.fn();
257
+ render(wrap(client, <ChatSearchScreen onOpen={onOpen} debounceMs={0} testID="s" />));
258
+ expect(screen.getByTestId("s-hint")).toBeTruthy();
259
+ fireEvent.change(screen.getByTestId("s-field-input"), { target: { value: "habit" } });
260
+ await waitFor(() => expect(screen.getByTestId("s-hit-a")).toBeTruthy());
261
+ const hit = screen.getByTestId("s-hit-a");
262
+ expect(hit.getAttribute("aria-label")).toMatch(/^Sam, in Sam, .*the habit tracker works$/);
263
+ expect(within(hit).getByText("habit")).toBeTruthy();
264
+ expect(screen.getByTestId("s-transcript-v")).toBeTruthy();
265
+ expect(screen.getByTestId("s-hit-v").getAttribute("aria-label")).toContain("Transcript");
266
+ fireEvent.click(hit);
267
+ expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ messageId: "a", conversationId: "dm", seq: 1 }));
268
+ });
269
+
270
+ it("searches one conversation, and says when nothing matches", async () => {
271
+ const client = memory([{ id: "dm", members: [{ userId: ME }, { userId: SAM }], messages: [{ id: "a", senderId: SAM, body: "hello" }] }]);
272
+ render(wrap(client, <ChatSearchScreen conversationId="dm" onOpen={vi.fn()} debounceMs={0} testID="s" />));
273
+ expect((screen.getByTestId("s-field-input") as HTMLInputElement).placeholder).toBe("Search this conversation");
274
+ fireEvent.change(screen.getByTestId("s-field-input"), { target: { value: "zebra" } });
275
+ await waitFor(() => expect(screen.getByTestId("s-empty")).toBeTruthy());
276
+ expect(called(client, "search").at(-1)!.args).toEqual(["zebra", "dm"]);
277
+ });
278
+ });
279
+
280
+ describe("ChatBroadcastSheet", () => {
281
+ it("sends one message into each conversation picked", async () => {
282
+ const client = memory([
283
+ { id: "dm1", members: [{ userId: ME }, { userId: SAM }] },
284
+ { id: "dm2", members: [{ userId: ME }, { userId: LEE }] },
285
+ { id: "dm3", members: [{ userId: ME }, { userId: "44444444-4444-4444-8444-444444444444" }], canPost: false },
286
+ ]);
287
+ const onSent = vi.fn();
288
+ const onOpenChange = vi.fn();
289
+ render(wrap(client, <ChatBroadcastSheet open onOpenChange={onOpenChange} kind="direct" onSent={onSent} testID="b" />));
290
+ await waitFor(() => expect(screen.getByTestId("b-row-dm1")).toBeTruthy());
291
+ expect(screen.getByTestId("b-send").getAttribute("aria-disabled")).toBe("true");
292
+ fireEvent.change(screen.getByTestId("b-text"), { target: { value: "Group call moved to 6" } });
293
+ fireEvent.click(screen.getByTestId("b-all"));
294
+ expect(screen.getByTestId("b-row-dm1").getAttribute("aria-checked")).toBe("true");
295
+ expect(screen.getByTestId("b-count").textContent).toBe("3 conversations");
296
+ fireEvent.click(screen.getByTestId("b-send"));
297
+ await waitFor(() => expect(onSent).toHaveBeenCalledWith(2, 3));
298
+ expect(called(client, "broadcast")[0]!.args.slice(0, 1)).toEqual([expect.arrayContaining(["dm1", "dm2", "dm3"])]);
299
+ expect(called(client, "broadcast")[0]!.args[2]).toBe("Group call moved to 6");
300
+ await waitFor(() => expect(screen.getByText("Sent to 2 of 3. You can't post in the others.")).toBeTruthy());
301
+ expect(onOpenChange).toHaveBeenCalledWith(false);
302
+ });
303
+
304
+ it("filters the list by name", async () => {
305
+ const client = memory([
306
+ { id: "dm1", members: [{ userId: ME }, { userId: SAM }] },
307
+ { id: "dm2", members: [{ userId: ME }, { userId: LEE }] },
308
+ ]);
309
+ render(wrap(client, <ChatBroadcastSheet open onOpenChange={vi.fn()} testID="b" />));
310
+ await waitFor(() => expect(screen.getByTestId("b-row-dm2")).toBeTruthy());
311
+ fireEvent.change(screen.getByTestId("b-filter-input"), { target: { value: "sa" } });
312
+ expect(screen.queryByTestId("b-row-dm2")).toBeNull();
313
+ fireEvent.click(screen.getByTestId("b-row-dm1"));
314
+ expect(screen.getByTestId("b-count").textContent).toBe("1 conversation");
315
+ });
316
+ });
317
+
318
+ describe("ChatAvailabilityScreen", () => {
319
+ it("loads what's saved, and saves an away message with working hours", async () => {
320
+ const client = memory([], { availability: { [ME]: { awayMessage: "Back soon", timeZone: "Europe/London" } } });
321
+ const onSaved = vi.fn();
322
+ render(wrap(client, <ChatAvailabilityScreen onSaved={onSaved} testID="a" />));
323
+ await waitFor(() => expect((screen.getByTestId("a-message") as HTMLTextAreaElement).value).toBe("Back soon"));
324
+ fireEvent.click(screen.getByTestId("a-hours-on"));
325
+ await waitFor(() => expect(screen.getByTestId("a-day-mon")).toBeTruthy());
326
+ // Mondays off.
327
+ fireEvent.click(within(screen.getByTestId("a-day-mon")).getByRole("switch"));
328
+ fireEvent.click(screen.getByTestId("a-save"));
329
+ await waitFor(() => expect(onSaved).toHaveBeenCalled());
330
+ const saved = called(client, "setAvailability")[0]!.args[0] as { awayMessage: string; timeZone: string; hours: Record<string, unknown> };
331
+ expect(saved.awayMessage).toBe("Back soon");
332
+ expect(saved.timeZone).toBe("Europe/London");
333
+ expect(Object.keys(saved.hours)).toEqual(["tue", "wed", "thu", "fri"]);
334
+ });
335
+
336
+ it("clears the away message with I'm back", async () => {
337
+ const client = memory([], { availability: { [ME]: { awayMessage: "Away", timeZone: "UTC" } } });
338
+ render(wrap(client, <ChatAvailabilityScreen testID="a" />));
339
+ await waitFor(() => expect(screen.getByTestId("a-back")).toBeTruthy());
340
+ act(() => {
341
+ fireEvent.click(screen.getByTestId("a-back"));
342
+ });
343
+ fireEvent.click(screen.getByTestId("a-save"));
344
+ await waitFor(() => expect(called(client, "setAvailability")[0]!.args[0]).toMatchObject({ awayMessage: null, awayUntil: null, hours: null }));
345
+ });
346
+ });
347
+
348
+ describe("simulator check leftovers", () => {
349
+ const cohort = (closed: boolean, messages: MemoryConversationSeed["messages"] = []): MemoryConversationSeed => ({
350
+ id: "cohort",
351
+ kind: "cohort",
352
+ members: [{ userId: ME, role: "member" }, { userId: SAM }, { userId: LEE }],
353
+ topics: [{ topic: "week-1", title: "Week 1", closed }],
354
+ messages,
355
+ });
356
+
357
+ it("says a closed topic is closed when it has no messages, instead of inviting a hello", async () => {
358
+ const client = memory([cohort(true)]);
359
+ render(wrap(client, <ChatThreadScreen conversationId="cohort" testID="t" />));
360
+ fireEvent.click(await screen.findByTestId("t-topics-week-1"));
361
+ await waitFor(() => expect(screen.getByTestId("t-empty").textContent).toContain("This topic is closed."));
362
+ expect(screen.getByTestId("t-empty").textContent).not.toContain("Say hello below.");
363
+ });
364
+
365
+ it("still invites a hello in an open topic with no messages", async () => {
366
+ const client = memory([cohort(false)]);
367
+ render(wrap(client, <ChatThreadScreen conversationId="cohort" testID="t" />));
368
+ fireEvent.click(await screen.findByTestId("t-topics-week-1"));
369
+ await waitFor(() => expect(screen.getByTestId("t-empty").textContent).toContain("Say hello below."));
370
+ });
371
+
372
+ it("pauses what this thread is playing when the screen loses focus, and nothing of another's", async () => {
373
+ const client = memory([{ id: "dm", members: [{ userId: ME }, { userId: SAM }], messages: [{ id: "m1", senderId: SAM, body: "", attachments: [{ id: "att-1", kind: "voice", path: "dm/x/a.m4a" }] }] }]);
374
+ const view = render(wrap(client, <ChatThreadScreen conversationId="dm" testID="t" />));
375
+ await screen.findByTestId("t-message-m1");
376
+ const other = vi.fn();
377
+ claimPlayback("elsewhere", other);
378
+ view.rerender(wrap(client, <ChatThreadScreen conversationId="dm" focused={false} testID="t" />));
379
+ expect(other).not.toHaveBeenCalled();
380
+
381
+ view.rerender(wrap(client, <ChatThreadScreen conversationId="dm" testID="t" />));
382
+ const mine = vi.fn();
383
+ claimPlayback("att-1", mine);
384
+ view.rerender(wrap(client, <ChatThreadScreen conversationId="dm" focused={false} testID="t" />));
385
+ expect(mine).toHaveBeenCalledTimes(1);
386
+ expect(activePlaybackId()).toBeNull();
387
+ });
388
+
389
+ it("names the message in each scheduled row's Cancel, so two at the same time differ", async () => {
390
+ const client = memory([{ id: "dm", members: [{ userId: ME }, { userId: SAM }] }]);
391
+ const at = new Date(Date.now() + 3600_000);
392
+ await client.schedule({ conversationId: "dm", clientId: "c1", body: "first one", sendAt: at });
393
+ await client.schedule({ conversationId: "dm", clientId: "c2", body: "second one", sendAt: at });
394
+ render(wrap(client, <ChatScheduledList conversationId="dm" testID="l" />));
395
+ const cancels = await screen.findAllByText("Cancel");
396
+ const labels = cancels.map((c) => c.closest("button")!.getAttribute("aria-label"));
397
+ expect(new Set(labels).size).toBe(2);
398
+ expect(labels.some((l) => l?.startsWith("Cancel, first one, Scheduled for"))).toBe(true);
399
+ expect(labels.some((l) => l?.startsWith("Cancel, second one, Scheduled for"))).toBe(true);
400
+ });
401
+
402
+ it("sizes the schedule and topic sheets to their content", async () => {
403
+ const client = memory([{ ...cohort(false), members: [{ userId: ME, role: "owner" }, { userId: SAM }, { userId: LEE }] }]);
404
+ render(wrap(client, <ChatScheduleSheet open onOpenChange={vi.fn()} onSchedule={vi.fn()} testID="s" />));
405
+ const schedule = (await screen.findByTestId("s-pick-0")).closest("[role=dialog]")!;
406
+ expect(schedule.getAttribute("data-presentation-style")).toBeNull();
407
+ expect(schedule.getAttribute("data-transparent")).toBe("true");
408
+ cleanup();
409
+
410
+ render(wrap(client, <ChatThreadScreen conversationId="cohort" testID="t" />));
411
+ fireEvent.click(await screen.findByTestId("t-topics-menu"));
412
+ const topics = (await screen.findByTestId("t-topics-new")).closest("[role=dialog]")!;
413
+ expect(topics.getAttribute("data-transparent")).toBe("true");
414
+ });
415
+ });
416
+
417
+ describe("scrollToShow", () => {
418
+ it("brings a chip past either edge into view, and leaves one in view alone", () => {
419
+ expect(scrollToShow({ x: 400, width: 80 }, { offset: 0, width: 300 })).toBe(400 + 80 + 8 - 300);
420
+ expect(scrollToShow({ x: 20, width: 60 }, { offset: 100, width: 300 })).toBe(12);
421
+ expect(scrollToShow({ x: 120, width: 60 }, { offset: 100, width: 300 })).toBeNull();
422
+ expect(scrollToShow({ x: 120, width: 60 }, { offset: 0, width: 0 })).toBeNull();
423
+ });
424
+ });
@@ -0,0 +1,226 @@
1
+ // @vitest-environment happy-dom
2
+ // @feat:chat
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
5
+ import type { ReactNode } from "react";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { NativeToastProvider } from "@marktiderman/genesis-ui-native/components";
8
+ import type { ChatClient } from "../../core/client";
9
+ import type { ChatAttachment, ChatMessage } from "../../core/types";
10
+ import { ChatProvider } from "../../react/index";
11
+ import { ChatNativeProvider } from "../context";
12
+ import type { ChatNativeModules } from "../modules";
13
+ import { ChatMediaViewer, ChatPhotoViewer, DISMISS_DISTANCE, DOUBLE_TAP_MS, galleryFor, isLocalUri, panLimit, pinchScale, shouldDismiss } from "../photo-viewer";
14
+ import { ChatThreadScreen } from "../thread-screen";
15
+
16
+ vi.mock("react-native", async (original) => ({ ...(await original<object>()), useWindowDimensions: () => ({ width: 390, height: 844, scale: 3, fontScale: 1 }) }));
17
+
18
+ afterEach(cleanup);
19
+
20
+ const ME = "11111111-1111-4111-8111-111111111111";
21
+ const SAM = "22222222-2222-4222-8222-222222222222";
22
+ const CONV = "33333333-3333-4333-8333-333333333333";
23
+
24
+ function image(id: string, name: string): ChatAttachment {
25
+ return {
26
+ id, messageId: "m1", position: 0, kind: "image", ref: {}, provider: "storage", path: `${CONV}/${SAM}/${name}`, providerId: null, playbackId: null,
27
+ url: null, status: "ready", name, mime: "image/jpeg", bytes: 10, width: 800, height: 600, durationMs: null, waveform: null, posterPath: null,
28
+ thumbhash: null, transcript: null, transcriptStatus: null,
29
+ };
30
+ }
31
+
32
+ function photoMessage(id: string, attachments: ChatAttachment[]): ChatMessage {
33
+ return {
34
+ id, conversationId: CONV, seq: 1, clientId: `c-${id}`, senderId: SAM, senderRole: "member", kind: "text", body: "",
35
+ mentions: [], replyToId: null, threadRootId: null, threadCount: 0, threadLastAt: null, topic: null, visibility: "everyone",
36
+ silent: false, meta: {}, createdAt: "2026-10-25T10:00:00Z", editedAt: null, deletedAt: null, attachments, reactions: [],
37
+ } as ChatMessage;
38
+ }
39
+
40
+ const signed = (path: string) => `https://storage.example.test/${path}`;
41
+
42
+ describe("galleryFor", () => {
43
+ it("is the tapped photo's message's photos, opened at the one tapped", () => {
44
+ const m = photoMessage("m1", [image("a", "a.jpg"), image("b", "b.jpg"), image("c", "c.jpg")]);
45
+ const other = photoMessage("m2", [image("d", "d.jpg")]);
46
+ const urls = Object.fromEntries([...m.attachments, ...other.attachments].map((a) => [a.path!, signed(a.path!)]));
47
+ const g = galleryFor([other, m], urls, signed(`${CONV}/${SAM}/b.jpg`), "Photo", (a) => `Photo, ${a.name}`);
48
+ expect(g.photos.map((p) => p.alt)).toEqual(["Photo, a.jpg", "Photo, b.jpg", "Photo, c.jpg"]);
49
+ expect(g.index).toBe(1);
50
+ });
51
+
52
+ it("opens a photo in no message (still sending) alone", () => {
53
+ expect(galleryFor([], {}, "file:///tmp/x.jpg", "Photo", () => "")).toEqual({ photos: [{ key: "file:///tmp/x.jpg", uri: "file:///tmp/x.jpg", alt: "Photo" }], index: 0 });
54
+ });
55
+ });
56
+
57
+ describe("the viewer's gestures", () => {
58
+ it("close on a long enough pull down or a quick flick, not a nudge", () => {
59
+ expect(shouldDismiss(DISMISS_DISTANCE + 1, 0)).toBe(true);
60
+ expect(shouldDismiss(40, 2)).toBe(true);
61
+ expect(shouldDismiss(40, 0.2)).toBe(false);
62
+ expect(shouldDismiss(10, 3)).toBe(false);
63
+ expect(shouldDismiss(-200, 0)).toBe(false);
64
+ });
65
+
66
+ it("pinch between 0.8 and 4 times, and pan only as far as the zoomed photo reaches", () => {
67
+ expect(pinchScale(1, 100, 200)).toBe(2);
68
+ expect(pinchScale(2, 100, 1000)).toBe(4);
69
+ expect(pinchScale(1, 100, 10)).toBe(0.8);
70
+ expect(pinchScale(1.5, 0, 50)).toBe(1.5);
71
+ expect(panLimit(1, 390)).toBe(0);
72
+ expect(panLimit(2, 390)).toBe(195);
73
+ });
74
+
75
+ it("count two taps 330ms apart as a double tap: that's how far apart a quick one's onPress events land on iOS", () => {
76
+ expect(DOUBLE_TAP_MS).toBeGreaterThan(330);
77
+ });
78
+
79
+ it("know a file on the phone from a link", () => {
80
+ expect(isLocalUri("file:///a.jpg")).toBe(true);
81
+ expect(isLocalUri("https://x.test/a.jpg")).toBe(false);
82
+ });
83
+ });
84
+
85
+ function viewer(modules: ChatNativeModules, photos = [{ key: "a", uri: "https://x.test/a.jpg", alt: "Photo, a.jpg" }, { key: "b", uri: "https://x.test/b.jpg", alt: "Photo, b.jpg" }], onClose = vi.fn()) {
86
+ render(
87
+ <ChatNativeProvider modules={modules}>
88
+ <ChatPhotoViewer photos={photos} initialIndex={1} onClose={onClose} testID="v" />
89
+ </ChatNativeProvider>,
90
+ );
91
+ return onClose;
92
+ }
93
+
94
+ describe("ChatPhotoViewer", () => {
95
+ it("is full screen and dark, not a page sheet, and says which photo of how many", () => {
96
+ viewer({});
97
+ const modal = screen.getByTestId("v");
98
+ expect(modal.getAttribute("data-presentation-style")).toBe("overFullScreen");
99
+ expect(modal.getAttribute("data-presentation-style")).not.toBe("pageSheet");
100
+ expect(screen.getByTestId("v-counter").textContent).toBe("Photo 2 of 2");
101
+ const pager = screen.getByTestId("v-pager");
102
+ expect(pager.getAttribute("aria-label")).toBe("Photo, b.jpg");
103
+ expect(screen.getByTestId("v-photo-0")).toBeTruthy();
104
+ expect(screen.getByTestId("v-photo-1")).toBeTruthy();
105
+ });
106
+
107
+ it("closes from its Close button", () => {
108
+ const onClose = viewer({});
109
+ fireEvent.click(screen.getByTestId("v-close"));
110
+ expect(onClose).toHaveBeenCalled();
111
+ });
112
+
113
+ it("offers Share and Save only when the app passed expo-sharing and expo-media-library", () => {
114
+ viewer({});
115
+ expect(screen.queryByTestId("v-share")).toBeNull();
116
+ expect(screen.queryByTestId("v-save")).toBeNull();
117
+ cleanup();
118
+ // A link with no way to copy it to the phone can't be shared either.
119
+ viewer({ sharing: { isAvailableAsync: async () => true, shareAsync: vi.fn() } });
120
+ expect(screen.queryByTestId("v-share")).toBeNull();
121
+ });
122
+
123
+ it("shares the photo on screen, copied to the phone first", async () => {
124
+ const shareAsync = vi.fn(async () => undefined);
125
+ const downloadFile = vi.fn(async (url: string) => `file:///cache/${url.split("/").pop()}`);
126
+ viewer({ sharing: { isAvailableAsync: async () => true, shareAsync }, downloadFile });
127
+ await act(async () => fireEvent.click(screen.getByTestId("v-share")));
128
+ expect(downloadFile).toHaveBeenCalledWith("https://x.test/b.jpg");
129
+ expect(shareAsync).toHaveBeenCalledWith("file:///cache/b.jpg", { dialogTitle: "Share photo" });
130
+ });
131
+
132
+ it("saves to Photos after asking for add-only access, and says so", async () => {
133
+ const saveToLibraryAsync = vi.fn(async () => undefined);
134
+ const requestPermissionsAsync = vi.fn(async () => ({ granted: true }));
135
+ viewer({ mediaLibrary: { requestPermissionsAsync, saveToLibraryAsync }, downloadFile: async () => "file:///cache/b.jpg" });
136
+ await act(async () => fireEvent.click(screen.getByTestId("v-save")));
137
+ expect(requestPermissionsAsync).toHaveBeenCalledWith(true);
138
+ expect(saveToLibraryAsync).toHaveBeenCalledWith("file:///cache/b.jpg");
139
+ expect(screen.getByTestId("v-notice").textContent).toBe("Saved to Photos");
140
+ });
141
+
142
+ it("says how to allow saving when Photos access is refused, and saves nothing", async () => {
143
+ const saveToLibraryAsync = vi.fn(async () => undefined);
144
+ viewer({ mediaLibrary: { requestPermissionsAsync: async () => ({ granted: false }), saveToLibraryAsync }, downloadFile: async () => "file:///x.jpg" });
145
+ await act(async () => fireEvent.click(screen.getByTestId("v-save")));
146
+ expect(saveToLibraryAsync).not.toHaveBeenCalled();
147
+ expect(screen.getByTestId("v-notice").textContent).toMatch(/Settings/);
148
+ });
149
+
150
+ it("ChatMediaViewer, for one picture, is the same full-screen viewer", () => {
151
+ render(
152
+ <ChatNativeProvider>
153
+ <ChatMediaViewer image={{ uri: "https://x.test/a.jpg", alt: "Photo" }} onClose={vi.fn()} testID="one" />
154
+ </ChatNativeProvider>,
155
+ );
156
+ expect(screen.getByTestId("one").getAttribute("data-presentation-style")).toBe("overFullScreen");
157
+ expect(screen.queryByTestId("one-counter")).toBeNull();
158
+ });
159
+ });
160
+
161
+ function fakeClient(messages: ChatMessage[], access: { myRole: () => Promise<string | null> }): ChatClient {
162
+ const sub = { unsubscribe: vi.fn(), typing: vi.fn(), recording: vi.fn(), send: vi.fn() };
163
+ return {
164
+ conversation: vi.fn(async () => ({ id: CONV, kind: "direct", title: null, settings: {}, status: "open", archivedAt: null })),
165
+ members: vi.fn(async () => [
166
+ { conversationId: CONV, userId: ME, role: "member", lastReadSeq: 1, joinedAt: "2026-10-01T00:00:00Z" },
167
+ { conversationId: CONV, userId: SAM, role: "member", lastReadSeq: 1, joinedAt: "2026-10-01T00:00:00Z" },
168
+ ]),
169
+ kind: vi.fn(async () => ({ kind: "direct", label: "Direct", maxMembers: 2 })),
170
+ myRole: vi.fn(access.myRole),
171
+ canPost: vi.fn(async () => true),
172
+ messages: vi.fn(async () => ({ messages, hasEarlier: false })),
173
+ message: vi.fn(async () => null),
174
+ subscribeConversation: vi.fn(() => sub),
175
+ subscribeInbox: vi.fn(() => ({ unsubscribe: vi.fn() })),
176
+ markRead: vi.fn(async () => 0),
177
+ pins: vi.fn(async () => []),
178
+ fileUrls: vi.fn(async (paths: string[]) => Object.fromEntries(paths.map((p) => [p, signed(p)]))),
179
+ } as unknown as ChatClient;
180
+ }
181
+
182
+ function wrap(client: ChatClient, children: ReactNode) {
183
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
184
+ return (
185
+ <QueryClientProvider client={qc}>
186
+ <ChatProvider client={client} userId={ME}>
187
+ <NativeToastProvider>
188
+ <ChatNativeProvider>{children}</ChatNativeProvider>
189
+ </NativeToastProvider>
190
+ </ChatProvider>
191
+ </QueryClientProvider>
192
+ );
193
+ }
194
+
195
+ describe("ChatThreadScreen", () => {
196
+ it("opens a tapped photo in the full-screen viewer with the rest of its message's photos", async () => {
197
+ const m = photoMessage("m1", [image("a", "a.jpg"), image("b", "b.jpg"), image("c", "c.jpg")]);
198
+ render(wrap(fakeClient([m], { myRole: async () => "member" }), <ChatThreadScreen conversationId={CONV} testID="t" />));
199
+ const second = await screen.findByLabelText("Photo, b.jpg");
200
+ await waitFor(() => expect(second.getAttribute("aria-disabled")).not.toBe("true"));
201
+ fireEvent.click(second);
202
+ const modal = await screen.findByTestId("t-viewer");
203
+ expect(modal.getAttribute("data-presentation-style")).toBe("overFullScreen");
204
+ expect(screen.getByTestId("t-viewer-counter").textContent).toBe("Photo 2 of 3");
205
+ fireEvent.click(screen.getByTestId("t-viewer-close"));
206
+ await waitFor(() => expect(screen.queryByTestId("t-viewer")).toBeNull());
207
+ });
208
+
209
+ it("offers Retry in the composer's place when your role couldn't be read, and recovers", async () => {
210
+ let fail = true;
211
+ const client = fakeClient([], {
212
+ myRole: async () => {
213
+ if (fail) throw new Error("offline");
214
+ return "member";
215
+ },
216
+ });
217
+ render(wrap(client, <ChatThreadScreen conversationId={CONV} testID="t" />));
218
+ const retry = await screen.findByTestId("t-access-error-retry");
219
+ expect(screen.getByTestId("t-access-error-alert").textContent).toMatch(/couldn't be loaded/);
220
+ expect(screen.queryByTestId("t-composer-disabled")).toBeNull();
221
+ fail = false;
222
+ await act(async () => fireEvent.click(retry));
223
+ await screen.findByTestId("t-composer-input");
224
+ expect(screen.queryByTestId("t-access-error")).toBeNull();
225
+ });
226
+ });