@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.
- package/README.md +304 -13
- package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
- package/dist/chunk-5D5A7I4Q.js.map +1 -0
- package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
- package/dist/chunk-7S6HYNWW.js.map +1 -0
- package/dist/chunk-BJCE5O7V.js +70 -0
- package/dist/chunk-BJCE5O7V.js.map +1 -0
- package/dist/chunk-PZTOY2LY.js +576 -0
- package/dist/chunk-PZTOY2LY.js.map +1 -0
- package/dist/chunk-VEPZROQB.js +187 -0
- package/dist/chunk-VEPZROQB.js.map +1 -0
- package/dist/{client-BtdALFCx.d.ts → client-DMa2j7kz.d.ts} +152 -11
- package/dist/index.d.ts +78 -163
- package/dist/index.js +3 -2
- package/dist/native.d.ts +1910 -1
- package/dist/native.js +4900 -0
- package/dist/native.js.map +1 -1
- package/dist/outbox-B0y3phE0.d.ts +151 -0
- package/dist/push.d.ts +206 -0
- package/dist/push.js +3 -0
- package/dist/push.js.map +1 -0
- package/dist/react.d.ts +149 -7
- package/dist/react.js +3 -3
- package/dist/server.d.ts +510 -21
- package/dist/server.js +863 -21
- package/dist/server.js.map +1 -1
- package/dist/thread-DWXHKPdw.d.ts +57 -0
- package/dist/web.d.ts +313 -10
- package/dist/web.js +1731 -60
- package/dist/web.js.map +1 -1
- package/migrations/0002_genesis_chat_coaching.sql +934 -0
- package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
- package/package.json +74 -6
- package/src/__tests__/coaching-core.test.ts +332 -0
- package/src/__tests__/coaching-react.test.tsx +248 -0
- package/src/__tests__/coaching-web.test.tsx +449 -0
- package/src/__tests__/core.test.ts +91 -0
- package/src/__tests__/outbox.test.ts +122 -0
- package/src/__tests__/realtime.test.ts +70 -0
- package/src/__tests__/review-fixes-core.test.ts +193 -0
- package/src/__tests__/review-fixes-web.test.tsx +402 -0
- package/src/__tests__/server-effects.test.ts +158 -0
- package/src/__tests__/server-fakes.ts +90 -0
- package/src/__tests__/server-integrations.test.ts +247 -0
- package/src/__tests__/server-providers.test.ts +246 -0
- package/src/__tests__/server-runner.test.ts +277 -0
- package/src/__tests__/server-safety.test.ts +290 -0
- package/src/__tests__/web-markdown.test.tsx +88 -0
- package/src/__tests__/web.test.tsx +333 -0
- package/src/core/availability.ts +223 -0
- package/src/core/client.ts +757 -0
- package/src/core/extras.ts +106 -0
- package/src/core/media.ts +144 -0
- package/src/core/outbox.ts +429 -0
- package/src/core/thread.ts +171 -0
- package/src/core/types.ts +311 -0
- package/src/index.ts +13 -0
- package/src/native/__tests__/auto-retry.test.tsx +128 -0
- package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
- package/src/native/__tests__/composer-state.test.ts +188 -0
- package/src/native/__tests__/composer.test.tsx +133 -0
- package/src/native/__tests__/emoji.test.tsx +238 -0
- package/src/native/__tests__/inbox-screen.test.tsx +48 -0
- package/src/native/__tests__/message-bubble.test.tsx +127 -0
- package/src/native/__tests__/organise-logic.test.ts +164 -0
- package/src/native/__tests__/organise-screens.test.tsx +344 -0
- package/src/native/__tests__/photo-viewer.test.tsx +226 -0
- package/src/native/__tests__/push-payload.test.ts +66 -0
- package/src/native/__tests__/push.test.ts +209 -0
- package/src/native/__tests__/recorder-machine.test.ts +96 -0
- package/src/native/__tests__/rows-actions.test.ts +76 -0
- package/src/native/__tests__/thread-layout.test.ts +41 -0
- package/src/native/__tests__/thread-screen.test.tsx +116 -0
- package/src/native/__tests__/tus.test.ts +154 -0
- package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
- package/src/native/__tests__/video-media.test.tsx +421 -0
- package/src/native/__tests__/video-recorder-state.test.ts +87 -0
- package/src/native/__tests__/video-upload.test.ts +227 -0
- package/src/native/access-error.tsx +31 -0
- package/src/native/actions.ts +42 -0
- package/src/native/attachment-cards.tsx +116 -0
- package/src/native/auto-retry.ts +69 -0
- package/src/native/availability.tsx +376 -0
- package/src/native/broadcast-sheet.tsx +193 -0
- package/src/native/composer-state.ts +302 -0
- package/src/native/composer.tsx +453 -0
- package/src/native/context.tsx +70 -0
- package/src/native/copy.ts +296 -0
- package/src/native/emoji-data.ts +21 -0
- package/src/native/emoji-picker.tsx +229 -0
- package/src/native/emoji.ts +118 -0
- package/src/native/icon-button.tsx +40 -0
- package/src/native/inbox-screen.tsx +116 -0
- package/src/native/index.ts +184 -0
- package/src/native/link-preview.tsx +104 -0
- package/src/native/media-colors.ts +15 -0
- package/src/native/media.tsx +187 -0
- package/src/native/message-actions-sheet.tsx +121 -0
- package/src/native/message-bubble.tsx +374 -0
- package/src/native/moderation.tsx +42 -0
- package/src/native/modules.ts +275 -0
- package/src/native/organise-copy.ts +195 -0
- package/src/native/photo-viewer.tsx +531 -0
- package/src/native/push.ts +375 -0
- package/src/native/recorder-machine.ts +176 -0
- package/src/native/rows.ts +81 -0
- package/src/native/schedule-sheet.tsx +314 -0
- package/src/native/search-screen.tsx +168 -0
- package/src/native/stack-options.ts +36 -0
- package/src/native/start-sheet.tsx +87 -0
- package/src/native/thread-screen.tsx +620 -0
- package/src/native/topics.tsx +179 -0
- package/src/native/tus.ts +282 -0
- package/src/native/use-composer.ts +71 -0
- package/src/native/use-jump.ts +91 -0
- package/src/native/use-voice-recorder.ts +234 -0
- package/src/native/video-player.tsx +370 -0
- package/src/native/video-recorder-state.ts +140 -0
- package/src/native/video-recorder.tsx +271 -0
- package/src/native/video-upload-tray.tsx +77 -0
- package/src/native/video-upload.ts +432 -0
- package/src/native/voice-player.tsx +283 -0
- package/src/react/index.tsx +810 -0
- package/src/server/assistant.ts +235 -0
- package/src/server/crypto.ts +86 -0
- package/src/server/expo.ts +119 -0
- package/src/server/handler.ts +32 -0
- package/src/server/index.ts +57 -0
- package/src/server/moderation.ts +155 -0
- package/src/server/mux.ts +290 -0
- package/src/server/notifications.ts +130 -0
- package/src/server/runner.ts +468 -0
- package/src/server/transcribers.ts +111 -0
- package/src/server/types.ts +171 -0
- package/src/server/unfurl.ts +480 -0
- package/src/server/upload.ts +158 -0
- package/src/web/attachments.tsx +379 -0
- package/src/web/availability.tsx +299 -0
- package/src/web/broadcast.tsx +166 -0
- package/src/web/bubble.tsx +563 -0
- package/src/web/cards.tsx +136 -0
- package/src/web/composer.tsx +571 -0
- package/src/web/emoji-picker.tsx +84 -0
- package/src/web/error-boundary.tsx +33 -0
- package/src/web/format.ts +55 -0
- package/src/web/inbox.tsx +195 -0
- package/src/web/index.ts +75 -0
- package/src/web/markdown.tsx +245 -0
- package/src/web/memory-client.ts +706 -0
- package/src/web/panel.tsx +135 -0
- package/src/web/schedule.tsx +192 -0
- package/src/web/search.tsx +129 -0
- package/src/web/thread-context.ts +64 -0
- package/src/web/thread.tsx +802 -0
- package/src/web/topics.tsx +160 -0
- package/src/web/use-voice-recorder.ts +264 -0
- package/src/web/video.tsx +522 -0
- package/src/web/voice-note.tsx +197 -0
- package/dist/chunk-27UTR5FW.js +0 -322
- package/dist/chunk-27UTR5FW.js.map +0 -1
- package/dist/chunk-JQXZUEEL.js.map +0 -1
- package/dist/chunk-SAY24E6V.js.map +0 -1
|
@@ -0,0 +1,344 @@
|
|
|
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
|
+
|
|
17
|
+
vi.mock("react-native", async (original) => ({ ...(await original<object>()), useWindowDimensions: () => ({ width: 390, height: 844, scale: 3, fontScale: 1 }) }));
|
|
18
|
+
|
|
19
|
+
afterEach(cleanup);
|
|
20
|
+
|
|
21
|
+
const ME = "11111111-1111-4111-8111-111111111111";
|
|
22
|
+
const SAM = "22222222-2222-4222-8222-222222222222";
|
|
23
|
+
const LEE = "33333333-3333-4333-8333-333333333333";
|
|
24
|
+
const NAMES: Record<string, string> = { [ME]: "Me", [SAM]: "Sam", [LEE]: "Lee" };
|
|
25
|
+
const getPerson = (id: string) => (NAMES[id] ? { name: NAMES[id]! } : undefined);
|
|
26
|
+
|
|
27
|
+
function wrap(client: MemoryChatClient, children: ReactNode) {
|
|
28
|
+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
29
|
+
return (
|
|
30
|
+
<QueryClientProvider client={qc}>
|
|
31
|
+
<ChatProvider client={client} userId={ME}>
|
|
32
|
+
<NativeToastProvider>
|
|
33
|
+
<ChatNativeProvider getPerson={getPerson}>{children}</ChatNativeProvider>
|
|
34
|
+
</NativeToastProvider>
|
|
35
|
+
</ChatProvider>
|
|
36
|
+
</QueryClientProvider>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function memory(conversations: MemoryConversationSeed[], extra: Partial<Parameters<typeof createMemoryChatClient>[0]> = {}) {
|
|
41
|
+
return createMemoryChatClient({ me: ME, conversations, ...extra });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const called = (client: MemoryChatClient, method: string) => client.calls.filter((c) => c.method === method);
|
|
45
|
+
|
|
46
|
+
describe("ChatThreadScreen topics", () => {
|
|
47
|
+
const seed = (myRole: "owner" | "member", closed = false): MemoryConversationSeed => ({
|
|
48
|
+
id: "cohort",
|
|
49
|
+
kind: "cohort",
|
|
50
|
+
title: "Cohort",
|
|
51
|
+
members: [{ userId: ME, role: myRole }, { userId: SAM }, { userId: LEE }],
|
|
52
|
+
topics: [{ topic: "week-1", title: "Week 1" }, { topic: "week-2", title: "Week 2", closed }],
|
|
53
|
+
messages: [
|
|
54
|
+
{ id: "a", senderId: SAM, body: "general hello" },
|
|
55
|
+
{ id: "b", senderId: SAM, body: "habits talk", topic: "week-1" },
|
|
56
|
+
{ id: "c", senderId: LEE, body: "week two talk", topic: "week-2" },
|
|
57
|
+
],
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("shows the topics as chips and lists only the topic chosen, with its name in the field", async () => {
|
|
61
|
+
const client = memory([seed("member")]);
|
|
62
|
+
render(wrap(client, <ChatThreadScreen conversationId="cohort" testID="t" />));
|
|
63
|
+
await waitFor(() => expect(screen.getByTestId("t-topics-week-1")).toBeTruthy());
|
|
64
|
+
expect(screen.getByTestId("t-topics-all").getAttribute("aria-selected")).toBe("true");
|
|
65
|
+
await waitFor(() => expect(screen.getByTestId("t-message-a")).toBeTruthy());
|
|
66
|
+
expect(screen.queryByTestId("t-topics-menu")).toBeNull(); // members don't manage topics
|
|
67
|
+
|
|
68
|
+
fireEvent.click(screen.getByTestId("t-topics-week-1"));
|
|
69
|
+
await screen.findByTestId("t-message-b");
|
|
70
|
+
expect(screen.queryByTestId("t-message-a")).toBeNull();
|
|
71
|
+
expect(screen.queryByTestId("t-message-c")).toBeNull();
|
|
72
|
+
expect(called(client, "messages").some((c) => (c.args[1] as { topic?: string }).topic === "week-1")).toBe(true);
|
|
73
|
+
expect((screen.getByTestId("t-composer-input") as HTMLInputElement).placeholder).toBe("Message #Week 1");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("a closed topic takes no posts from a member, and says so", async () => {
|
|
77
|
+
const client = memory([seed("member", true)]);
|
|
78
|
+
render(wrap(client, <ChatThreadScreen conversationId="cohort" testID="t" />));
|
|
79
|
+
await waitFor(() => expect(screen.getByTestId("t-topics-week-2")).toBeTruthy());
|
|
80
|
+
fireEvent.click(screen.getByTestId("t-topics-week-2"));
|
|
81
|
+
await waitFor(() => expect(screen.getByTestId("t-composer-disabled").textContent).toBe("This topic is closed."));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("lets an owner add a topic, which is then shown", async () => {
|
|
85
|
+
const client = memory([seed("owner")]);
|
|
86
|
+
render(wrap(client, <ChatThreadScreen conversationId="cohort" testID="t" />));
|
|
87
|
+
await waitFor(() => expect(screen.getByTestId("t-topics-menu")).toBeTruthy());
|
|
88
|
+
fireEvent.click(screen.getByTestId("t-topics-menu"));
|
|
89
|
+
fireEvent.click(await screen.findByTestId("t-topics-new"));
|
|
90
|
+
const name = await screen.findByTestId("t-topics-name");
|
|
91
|
+
fireEvent.change(name, { target: { value: "Week 3: Sleep" } });
|
|
92
|
+
fireEvent.click(screen.getByTestId("t-topics-save"));
|
|
93
|
+
await waitFor(() => expect(called(client, "setTopic")[0]?.args).toEqual(["cohort", "week-3-sleep", { title: "Week 3: Sleep", position: 2 }]));
|
|
94
|
+
await waitFor(() => expect(screen.getByTestId("t-topics-week-3-sleep").getAttribute("aria-selected")).toBe("true"));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("lets an owner close the topic shown", async () => {
|
|
98
|
+
const client = memory([seed("owner")]);
|
|
99
|
+
render(wrap(client, <ChatThreadScreen conversationId="cohort" testID="t" />));
|
|
100
|
+
fireEvent.click(await screen.findByTestId("t-topics-week-1"));
|
|
101
|
+
fireEvent.click(screen.getByTestId("t-topics-menu"));
|
|
102
|
+
fireEvent.click(await screen.findByTestId("t-topics-close"));
|
|
103
|
+
await waitFor(() => expect(called(client, "setTopic")[0]?.args).toEqual(["cohort", "week-1", { closed: true }]));
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("ChatThreadScreen topics across conversations", () => {
|
|
108
|
+
it("starts another conversation in All, not in the last one's topic", async () => {
|
|
109
|
+
const client = memory([
|
|
110
|
+
{ 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" }] },
|
|
111
|
+
{ id: "two", kind: "cohort", members: [{ userId: ME }, { userId: SAM }, { userId: LEE }], messages: [{ id: "b", senderId: SAM, body: "y" }] },
|
|
112
|
+
]);
|
|
113
|
+
const view = render(wrap(client, <ChatThreadScreen conversationId="one" testID="t" />));
|
|
114
|
+
fireEvent.click(await screen.findByTestId("t-topics-week-1"));
|
|
115
|
+
await waitFor(() => expect(screen.getByTestId("t-topics-week-1").getAttribute("aria-selected")).toBe("true"));
|
|
116
|
+
view.rerender(wrap(client, <ChatThreadScreen conversationId="two" testID="t" />));
|
|
117
|
+
await screen.findByTestId("t-message-b");
|
|
118
|
+
const last = called(client, "messages").filter((c) => c.args[0] === "two").at(-1)!;
|
|
119
|
+
expect((last.args[1] as { topic?: string }).topic).toBeUndefined();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("ChatThreadScreen scheduled send", () => {
|
|
124
|
+
const direct: MemoryConversationSeed = { id: "dm", members: [{ userId: ME }, { userId: SAM }], messages: [{ id: "a", senderId: SAM, body: "hi" }] };
|
|
125
|
+
|
|
126
|
+
it("schedules what's written when Send is held, empties the field, and lists it with Cancel", async () => {
|
|
127
|
+
const client = memory([direct]);
|
|
128
|
+
render(wrap(client, <ChatThreadScreen conversationId="dm" testID="t" />));
|
|
129
|
+
const input = await screen.findByTestId("t-composer-input");
|
|
130
|
+
await waitFor(() => expect(input.hasAttribute("disabled")).toBe(false));
|
|
131
|
+
fireEvent.change(input, { target: { value: "see you tomorrow" } });
|
|
132
|
+
const send = screen.getByTestId("t-composer-send");
|
|
133
|
+
expect(send.getAttribute("data-a11y-actions")).toBe("longpress"); // VoiceOver's way to it
|
|
134
|
+
fireEvent.contextMenu(send);
|
|
135
|
+
fireEvent.click(await screen.findByTestId("t-schedule-pick-0"));
|
|
136
|
+
await waitFor(() => expect(called(client, "schedule")).toHaveLength(1));
|
|
137
|
+
expect(called(client, "schedule")[0]!.args[0]).toMatchObject({ conversationId: "dm", body: "see you tomorrow", topic: null });
|
|
138
|
+
await waitFor(() => expect((screen.getByTestId("t-composer-input") as HTMLInputElement).value).toBe(""));
|
|
139
|
+
expect(called(client, "send")).toHaveLength(0);
|
|
140
|
+
|
|
141
|
+
fireEvent.click(await screen.findByTestId("t-scheduled"));
|
|
142
|
+
expect(screen.getByTestId("t-scheduled").getAttribute("aria-label")).toBe("1 scheduled message");
|
|
143
|
+
const list = await screen.findByTestId("t-scheduled-sheet");
|
|
144
|
+
const cancel = within(list).getByText("Cancel");
|
|
145
|
+
fireEvent.click(cancel.closest("button")!);
|
|
146
|
+
await waitFor(() => expect(called(client, "cancelScheduled")).toHaveLength(1));
|
|
147
|
+
await waitFor(() => expect(screen.queryByTestId("t-scheduled")).toBeNull());
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("picks a custom time in the sheet, refusing one too soon", async () => {
|
|
151
|
+
const client = memory([direct]);
|
|
152
|
+
render(wrap(client, <ChatThreadScreen conversationId="dm" testID="t" />));
|
|
153
|
+
const input = await screen.findByTestId("t-composer-input");
|
|
154
|
+
await waitFor(() => expect(input.hasAttribute("disabled")).toBe(false));
|
|
155
|
+
fireEvent.change(input, { target: { value: "later" } });
|
|
156
|
+
fireEvent.contextMenu(screen.getByTestId("t-composer-send"));
|
|
157
|
+
fireEvent.click(await screen.findByTestId("t-schedule-pick-custom"));
|
|
158
|
+
expect(screen.getByTestId("t-schedule-custom")).toBeTruthy();
|
|
159
|
+
// An hour ahead by default: schedulable.
|
|
160
|
+
expect(screen.getByTestId("t-schedule-confirm").getAttribute("aria-disabled")).not.toBe("true");
|
|
161
|
+
fireEvent.click(screen.getByTestId("t-schedule-confirm"));
|
|
162
|
+
await waitFor(() => expect(called(client, "schedule")).toHaveLength(1));
|
|
163
|
+
const at = new Date((called(client, "schedule")[0]!.args[0] as { sendAt: Date }).sendAt);
|
|
164
|
+
expect(at.getTime()).toBeGreaterThan(Date.now() + 50 * 60_000);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("ChatScheduleSheet and ChatScheduledList", () => {
|
|
169
|
+
it("starts again at the quick picks each time it opens", async () => {
|
|
170
|
+
const client = memory([]);
|
|
171
|
+
const view = render(wrap(client, <ChatScheduleSheet open onOpenChange={vi.fn()} onSchedule={vi.fn()} testID="s" />));
|
|
172
|
+
fireEvent.click(await screen.findByTestId("s-pick-custom"));
|
|
173
|
+
expect(screen.getByTestId("s-custom")).toBeTruthy();
|
|
174
|
+
view.rerender(wrap(client, <ChatScheduleSheet open={false} onOpenChange={vi.fn()} onSchedule={vi.fn()} testID="s" />));
|
|
175
|
+
view.rerender(wrap(client, <ChatScheduleSheet open onOpenChange={vi.fn()} onSchedule={vi.fn()} testID="s" />));
|
|
176
|
+
expect(await screen.findByTestId("s-pick-0")).toBeTruthy();
|
|
177
|
+
expect(screen.queryByTestId("s-custom")).toBeNull();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("offers Remove on one that couldn't be sent", async () => {
|
|
181
|
+
const client = memory([{ id: "dm", members: [{ userId: ME }, { userId: SAM }] }]);
|
|
182
|
+
await client.schedule({ conversationId: "dm", clientId: "c1", body: "later", sendAt: new Date(Date.now() + 3600_000) });
|
|
183
|
+
client.failNextSends(1);
|
|
184
|
+
await client.sendDue(new Date(Date.now() + 2 * 3600_000));
|
|
185
|
+
render(wrap(client, <ChatScheduledList conversationId="dm" testID="l" />));
|
|
186
|
+
const remove = await screen.findByText("Remove");
|
|
187
|
+
expect(screen.getByText("Couldn't send")).toBeTruthy();
|
|
188
|
+
fireEvent.click(remove.closest("button")!);
|
|
189
|
+
await waitFor(() => expect(called(client, "cancelScheduled")).toHaveLength(1));
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("ChatThreadScreen availability and moderation", () => {
|
|
194
|
+
it("shows the other person's away line under the header", async () => {
|
|
195
|
+
const client = memory([{ id: "dm", members: [{ userId: ME }, { userId: SAM }] }], { availability: { [SAM]: { awayMessage: "On a retreat" } } });
|
|
196
|
+
render(wrap(client, <ChatThreadScreen conversationId="dm" testID="t" />));
|
|
197
|
+
await waitFor(() => expect(screen.getByTestId("t-away").getAttribute("aria-label")).toBe("Sam is away: On a retreat"));
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("shows nothing when they're around", async () => {
|
|
201
|
+
const client = memory([{ id: "dm", members: [{ userId: ME }, { userId: SAM }] }], { availability: { [SAM]: {} } });
|
|
202
|
+
render(wrap(client, <ChatThreadScreen conversationId="dm" testID="t" />));
|
|
203
|
+
await waitFor(() => expect(called(client, "availabilityFor")).toHaveLength(1));
|
|
204
|
+
expect(screen.queryByTestId("t-away")).toBeNull();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("tells the sender a hidden message is theirs alone, and marks flagged ones for observers", async () => {
|
|
208
|
+
const client = memory([
|
|
209
|
+
{ id: "dm", members: [{ userId: ME }, { userId: SAM }], messages: [{ id: "h", senderId: ME, body: "oops", moderation: "hidden" }, { id: "f", senderId: SAM, body: "hmm", moderation: "flagged" }] },
|
|
210
|
+
]);
|
|
211
|
+
render(wrap(client, <ChatThreadScreen conversationId="dm" testID="t" />));
|
|
212
|
+
await waitFor(() => expect(screen.getByTestId("t-message-h-moderation").textContent).toBe("Hidden by moderation. Only you can see it."));
|
|
213
|
+
expect(screen.queryByTestId("t-message-f-moderation")).toBeNull();
|
|
214
|
+
|
|
215
|
+
cleanup();
|
|
216
|
+
const observed = memory([
|
|
217
|
+
{ 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" }] },
|
|
218
|
+
]);
|
|
219
|
+
render(wrap(observed, <ChatThreadScreen conversationId="g" testID="t" />));
|
|
220
|
+
await waitFor(() => expect(screen.getByTestId("t-message-h-moderation").textContent).toBe("Hidden by moderation"));
|
|
221
|
+
expect(screen.getByTestId("t-message-f-moderation").textContent).toBe("Flagged");
|
|
222
|
+
expect(screen.getByTestId("t-message-f-bubble").getAttribute("aria-label")).toContain("Flagged");
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("ChatThreadScreen jumpTo", () => {
|
|
227
|
+
it("loads earlier pages until the message is held, then highlights it", async () => {
|
|
228
|
+
const messages = Array.from({ length: 120 }, (_, i) => ({ id: `m${i + 1}`, senderId: i % 2 ? SAM : ME, body: `message ${i + 1}` }));
|
|
229
|
+
const client = memory([{ id: "dm", members: [{ userId: ME }, { userId: SAM }], messages }]);
|
|
230
|
+
render(wrap(client, <ChatThreadScreen conversationId="dm" jumpTo={{ messageId: "m3", seq: 3 }} testID="t" />));
|
|
231
|
+
await waitFor(() => expect(screen.getByTestId("t-message-m3")).toBeTruthy());
|
|
232
|
+
expect(called(client, "messages").some((c) => (c.args[1] as { beforeSeq?: number }).beforeSeq !== undefined)).toBe(true);
|
|
233
|
+
await waitFor(() => expect(JSON.parse(screen.getByTestId("t-message-m3").dataset.style!).backgroundColor).toBeTruthy());
|
|
234
|
+
expect(JSON.parse(screen.getByTestId("t-message-m4").dataset.style!).backgroundColor).toBeUndefined();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("says so when the message isn't there any more", async () => {
|
|
238
|
+
const client = memory([{ id: "dm", members: [{ userId: ME }, { userId: SAM }], messages: [{ id: "a", senderId: SAM, body: "hi" }] }]);
|
|
239
|
+
render(wrap(client, <ChatThreadScreen conversationId="dm" jumpTo={{ messageId: "gone" }} testID="t" />));
|
|
240
|
+
await waitFor(() => expect(screen.getByText("That message isn't here any more")).toBeTruthy());
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("ChatSearchScreen", () => {
|
|
245
|
+
it("finds messages across conversations with the match highlighted and transcripts marked, and opens one", async () => {
|
|
246
|
+
const client = memory([
|
|
247
|
+
{ id: "dm", members: [{ userId: ME }, { userId: SAM }], messages: [{ id: "a", senderId: SAM, body: "the habit tracker works" }] },
|
|
248
|
+
{
|
|
249
|
+
id: "dm2",
|
|
250
|
+
members: [{ userId: ME }, { userId: LEE }],
|
|
251
|
+
messages: [{ id: "v", senderId: LEE, body: "", attachments: [{ kind: "voice", transcript: "my habit this week" }] }],
|
|
252
|
+
},
|
|
253
|
+
]);
|
|
254
|
+
const onOpen = vi.fn();
|
|
255
|
+
render(wrap(client, <ChatSearchScreen onOpen={onOpen} debounceMs={0} testID="s" />));
|
|
256
|
+
expect(screen.getByTestId("s-hint")).toBeTruthy();
|
|
257
|
+
fireEvent.change(screen.getByTestId("s-field-input"), { target: { value: "habit" } });
|
|
258
|
+
await waitFor(() => expect(screen.getByTestId("s-hit-a")).toBeTruthy());
|
|
259
|
+
const hit = screen.getByTestId("s-hit-a");
|
|
260
|
+
expect(hit.getAttribute("aria-label")).toMatch(/^Sam, in Sam, .*the habit tracker works$/);
|
|
261
|
+
expect(within(hit).getByText("habit")).toBeTruthy();
|
|
262
|
+
expect(screen.getByTestId("s-transcript-v")).toBeTruthy();
|
|
263
|
+
expect(screen.getByTestId("s-hit-v").getAttribute("aria-label")).toContain("Transcript");
|
|
264
|
+
fireEvent.click(hit);
|
|
265
|
+
expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ messageId: "a", conversationId: "dm", seq: 1 }));
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("searches one conversation, and says when nothing matches", async () => {
|
|
269
|
+
const client = memory([{ id: "dm", members: [{ userId: ME }, { userId: SAM }], messages: [{ id: "a", senderId: SAM, body: "hello" }] }]);
|
|
270
|
+
render(wrap(client, <ChatSearchScreen conversationId="dm" onOpen={vi.fn()} debounceMs={0} testID="s" />));
|
|
271
|
+
expect((screen.getByTestId("s-field-input") as HTMLInputElement).placeholder).toBe("Search this conversation");
|
|
272
|
+
fireEvent.change(screen.getByTestId("s-field-input"), { target: { value: "zebra" } });
|
|
273
|
+
await waitFor(() => expect(screen.getByTestId("s-empty")).toBeTruthy());
|
|
274
|
+
expect(called(client, "search").at(-1)!.args).toEqual(["zebra", "dm"]);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe("ChatBroadcastSheet", () => {
|
|
279
|
+
it("sends one message into each conversation picked", async () => {
|
|
280
|
+
const client = memory([
|
|
281
|
+
{ id: "dm1", members: [{ userId: ME }, { userId: SAM }] },
|
|
282
|
+
{ id: "dm2", members: [{ userId: ME }, { userId: LEE }] },
|
|
283
|
+
{ id: "dm3", members: [{ userId: ME }, { userId: "44444444-4444-4444-8444-444444444444" }], canPost: false },
|
|
284
|
+
]);
|
|
285
|
+
const onSent = vi.fn();
|
|
286
|
+
const onOpenChange = vi.fn();
|
|
287
|
+
render(wrap(client, <ChatBroadcastSheet open onOpenChange={onOpenChange} kind="direct" onSent={onSent} testID="b" />));
|
|
288
|
+
await waitFor(() => expect(screen.getByTestId("b-row-dm1")).toBeTruthy());
|
|
289
|
+
expect(screen.getByTestId("b-send").getAttribute("aria-disabled")).toBe("true");
|
|
290
|
+
fireEvent.change(screen.getByTestId("b-text"), { target: { value: "Group call moved to 6" } });
|
|
291
|
+
fireEvent.click(screen.getByTestId("b-all"));
|
|
292
|
+
expect(screen.getByTestId("b-row-dm1").getAttribute("aria-checked")).toBe("true");
|
|
293
|
+
expect(screen.getByTestId("b-count").textContent).toBe("3 conversations");
|
|
294
|
+
fireEvent.click(screen.getByTestId("b-send"));
|
|
295
|
+
await waitFor(() => expect(onSent).toHaveBeenCalledWith(2, 3));
|
|
296
|
+
expect(called(client, "broadcast")[0]!.args.slice(0, 1)).toEqual([expect.arrayContaining(["dm1", "dm2", "dm3"])]);
|
|
297
|
+
expect(called(client, "broadcast")[0]!.args[2]).toBe("Group call moved to 6");
|
|
298
|
+
await waitFor(() => expect(screen.getByText("Sent to 2 of 3. You can't post in the others.")).toBeTruthy());
|
|
299
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("filters the list by name", async () => {
|
|
303
|
+
const client = memory([
|
|
304
|
+
{ id: "dm1", members: [{ userId: ME }, { userId: SAM }] },
|
|
305
|
+
{ id: "dm2", members: [{ userId: ME }, { userId: LEE }] },
|
|
306
|
+
]);
|
|
307
|
+
render(wrap(client, <ChatBroadcastSheet open onOpenChange={vi.fn()} testID="b" />));
|
|
308
|
+
await waitFor(() => expect(screen.getByTestId("b-row-dm2")).toBeTruthy());
|
|
309
|
+
fireEvent.change(screen.getByTestId("b-filter-input"), { target: { value: "sa" } });
|
|
310
|
+
expect(screen.queryByTestId("b-row-dm2")).toBeNull();
|
|
311
|
+
fireEvent.click(screen.getByTestId("b-row-dm1"));
|
|
312
|
+
expect(screen.getByTestId("b-count").textContent).toBe("1 conversation");
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe("ChatAvailabilityScreen", () => {
|
|
317
|
+
it("loads what's saved, and saves an away message with working hours", async () => {
|
|
318
|
+
const client = memory([], { availability: { [ME]: { awayMessage: "Back soon", timeZone: "Europe/London" } } });
|
|
319
|
+
const onSaved = vi.fn();
|
|
320
|
+
render(wrap(client, <ChatAvailabilityScreen onSaved={onSaved} testID="a" />));
|
|
321
|
+
await waitFor(() => expect((screen.getByTestId("a-message") as HTMLTextAreaElement).value).toBe("Back soon"));
|
|
322
|
+
fireEvent.click(screen.getByTestId("a-hours-on"));
|
|
323
|
+
await waitFor(() => expect(screen.getByTestId("a-day-mon")).toBeTruthy());
|
|
324
|
+
// Mondays off.
|
|
325
|
+
fireEvent.click(within(screen.getByTestId("a-day-mon")).getByRole("switch"));
|
|
326
|
+
fireEvent.click(screen.getByTestId("a-save"));
|
|
327
|
+
await waitFor(() => expect(onSaved).toHaveBeenCalled());
|
|
328
|
+
const saved = called(client, "setAvailability")[0]!.args[0] as { awayMessage: string; timeZone: string; hours: Record<string, unknown> };
|
|
329
|
+
expect(saved.awayMessage).toBe("Back soon");
|
|
330
|
+
expect(saved.timeZone).toBe("Europe/London");
|
|
331
|
+
expect(Object.keys(saved.hours)).toEqual(["tue", "wed", "thu", "fri"]);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("clears the away message with I'm back", async () => {
|
|
335
|
+
const client = memory([], { availability: { [ME]: { awayMessage: "Away", timeZone: "UTC" } } });
|
|
336
|
+
render(wrap(client, <ChatAvailabilityScreen testID="a" />));
|
|
337
|
+
await waitFor(() => expect(screen.getByTestId("a-back")).toBeTruthy());
|
|
338
|
+
act(() => {
|
|
339
|
+
fireEvent.click(screen.getByTestId("a-back"));
|
|
340
|
+
});
|
|
341
|
+
fireEvent.click(screen.getByTestId("a-save"));
|
|
342
|
+
await waitFor(() => expect(called(client, "setAvailability")[0]!.args[0]).toMatchObject({ awayMessage: null, awayUntil: null, hours: null }));
|
|
343
|
+
});
|
|
344
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// @feat:chat
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { chatPushPayload, conversationIdFromData } from "../push";
|
|
4
|
+
|
|
5
|
+
const CONV = "6F1D2C3B-4A59-4E8F-9A7B-1C2D3E4F5A6B";
|
|
6
|
+
const conv = CONV.toLowerCase();
|
|
7
|
+
const sender = { id: "user-1", name: "Ana Lima", avatarUrl: "https://cdn.example.com/a.jpg" };
|
|
8
|
+
|
|
9
|
+
describe("chatPushPayload", () => {
|
|
10
|
+
it("is a messages push the service extension runs on, with the sender, group and photo", () => {
|
|
11
|
+
const p = chatPushPayload({ conversationId: CONV, messageId: "m1", seq: 7, sender, groupName: "Cohort 3", imageUrl: "https://cdn.example.com/p.png" });
|
|
12
|
+
expect(p).toEqual({
|
|
13
|
+
categoryId: "messages",
|
|
14
|
+
threadId: conv,
|
|
15
|
+
mutableContent: true,
|
|
16
|
+
data: {
|
|
17
|
+
url: `/messages/${conv}`,
|
|
18
|
+
conversationId: conv,
|
|
19
|
+
messageId: "m1",
|
|
20
|
+
seq: 7,
|
|
21
|
+
senderId: "user-1",
|
|
22
|
+
senderName: "Ana Lima",
|
|
23
|
+
senderAvatar: "https://cdn.example.com/a.jpg",
|
|
24
|
+
groupName: "Cohort 3",
|
|
25
|
+
image: "https://cdn.example.com/p.png",
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
// The tap still routes to the thread.
|
|
29
|
+
expect(conversationIdFromData(p.data)).toBe(conv);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("a direct conversation has no group name", () => {
|
|
33
|
+
const p = chatPushPayload({ conversationId: CONV, messageId: "m1", sender });
|
|
34
|
+
expect(p.data).not.toHaveProperty("groupName");
|
|
35
|
+
expect(p.data).not.toHaveProperty("seq");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("hidden previews carry the link and ids only, and the extension doesn't run", () => {
|
|
39
|
+
const p = chatPushPayload({ conversationId: CONV, messageId: "m1", seq: 2, sender, groupName: "Cohort 3", imageUrl: "https://cdn.example.com/p.png", hidePreviews: true });
|
|
40
|
+
expect(p).toEqual({ categoryId: "messages", threadId: conv, mutableContent: false, data: { url: `/messages/${conv}`, conversationId: conv, messageId: "m1", seq: 2 } });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it.each(["http://cdn.example.com/a.jpg", "file:///etc/passwd", "https://user:pw@cdn.example.com/a.jpg", "not a url", `https://cdn.example.com/${"a".repeat(1010)}`])(
|
|
44
|
+
"leaves out an image that isn't a plain https URL: %s",
|
|
45
|
+
(url) => {
|
|
46
|
+
const p = chatPushPayload({ conversationId: CONV, messageId: "m1", sender: { ...sender, avatarUrl: url }, imageUrl: url });
|
|
47
|
+
expect(p.data).not.toHaveProperty("senderAvatar");
|
|
48
|
+
expect(p.data).not.toHaveProperty("image");
|
|
49
|
+
expect(p.data.senderName).toBe("Ana Lima");
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
it("leaves the sender out without both an id and a name", () => {
|
|
54
|
+
expect(chatPushPayload({ conversationId: CONV, messageId: "m1", sender: { id: "u", name: " " } }).data).not.toHaveProperty("senderId");
|
|
55
|
+
expect(chatPushPayload({ conversationId: CONV, messageId: "m1", sender: { id: "", name: "Ana" } }).data).not.toHaveProperty("senderName");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("data is flat strings and numbers (what genesis-notifications' PushMessage.data takes)", () => {
|
|
59
|
+
const p = chatPushPayload({ conversationId: CONV, messageId: "m1", seq: 1, sender, groupName: "G", imageUrl: "https://x.example/i.jpg" });
|
|
60
|
+
expect(Object.values(p.data).every((v) => typeof v === "string" || typeof v === "number")).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("refuses a conversation id that isn't a uuid", () => {
|
|
64
|
+
expect(() => chatPushPayload({ conversationId: "../etc", messageId: "m1" })).toThrow(/uuid/);
|
|
65
|
+
});
|
|
66
|
+
});
|