@marktiderman/genesis-chat 0.2.2 → 0.3.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 +119 -6
- package/dist/{chunk-VEPZROQB.js → chunk-BTG4JWLF.js} +5 -4
- package/dist/chunk-BTG4JWLF.js.map +1 -0
- package/dist/{chunk-AZS3HNLM.js → chunk-IEFULAC2.js} +25 -3
- package/dist/chunk-IEFULAC2.js.map +1 -0
- package/dist/{chunk-JU2FAPBI.js → chunk-XQ2ULNHV.js} +2 -2
- package/dist/{chunk-JU2FAPBI.js.map → chunk-XQ2ULNHV.js.map} +1 -1
- package/dist/native.d.ts +158 -6
- package/dist/native.js +404 -181
- package/dist/native.js.map +1 -1
- package/dist/push.d.ts +2 -1
- package/dist/push.js +1 -1
- package/dist/react.d.ts +6 -2
- package/dist/react.js +1 -1
- package/dist/server.js +1 -1
- package/dist/web.d.ts +6 -2
- package/dist/web.js +6 -18
- package/dist/web.js.map +1 -1
- package/package.json +9 -9
- package/src/native/__tests__/attachment-slot.test.tsx +353 -0
- package/src/native/__tests__/organise-screens.test.tsx +12 -0
- package/src/native/__tests__/push-ids.test.ts +18 -0
- package/src/native/__tests__/push.test.ts +10 -4
- package/src/native/attachment-cards.tsx +249 -11
- package/src/native/copy.ts +15 -0
- package/src/native/index.ts +28 -4
- package/src/native/media.tsx +89 -2
- package/src/native/message-bubble.tsx +78 -12
- package/src/native/push.ts +5 -2
- package/src/native/search-screen.tsx +77 -69
- package/src/native/thread-screen.tsx +23 -6
- package/src/react/error-boundary.tsx +46 -0
- package/src/react/index.tsx +6 -2
- package/src/web/cards.tsx +2 -2
- package/src/web/error-boundary.tsx +2 -30
- package/dist/chunk-AZS3HNLM.js.map +0 -1
- package/dist/chunk-VEPZROQB.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marktiderman/genesis-chat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -61,13 +61,13 @@
|
|
|
61
61
|
"README.md"
|
|
62
62
|
],
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@marktiderman/genesis-features": "^0.4.
|
|
64
|
+
"@marktiderman/genesis-features": "^0.4.1",
|
|
65
65
|
"frimousse": "0.4.0"
|
|
66
66
|
},
|
|
67
67
|
"peerDependencies": {
|
|
68
|
-
"@marktiderman/genesis-core": "^1.
|
|
69
|
-
"@marktiderman/genesis-ui": "^2.
|
|
70
|
-
"@marktiderman/genesis-ui-native": "^2.
|
|
68
|
+
"@marktiderman/genesis-core": "^1.10.0",
|
|
69
|
+
"@marktiderman/genesis-ui": "^2.17.0",
|
|
70
|
+
"@marktiderman/genesis-ui-native": "^2.5.0",
|
|
71
71
|
"@supabase/supabase-js": "^2.118.0",
|
|
72
72
|
"@tanstack/react-query": "^5.0.0",
|
|
73
73
|
"hls.js": "^1.5.0",
|
|
@@ -140,10 +140,10 @@
|
|
|
140
140
|
}
|
|
141
141
|
},
|
|
142
142
|
"devDependencies": {
|
|
143
|
-
"@marktiderman/genesis-core": "^1.
|
|
144
|
-
"@marktiderman/genesis-notifications": "^0.
|
|
145
|
-
"@marktiderman/genesis-ui": "^2.
|
|
146
|
-
"@marktiderman/genesis-ui-native": "^2.
|
|
143
|
+
"@marktiderman/genesis-core": "^1.10.0",
|
|
144
|
+
"@marktiderman/genesis-notifications": "^0.2.0",
|
|
145
|
+
"@marktiderman/genesis-ui": "^2.17.0",
|
|
146
|
+
"@marktiderman/genesis-ui-native": "^2.5.0",
|
|
147
147
|
"@supabase/supabase-js": "^2.118.0",
|
|
148
148
|
"@tanstack/react-query": "^5.75.0",
|
|
149
149
|
"@testing-library/react": "^16.3.2",
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// @feat:chat
|
|
3
|
+
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
4
|
+
import { createElement, useLayoutEffect, type ReactNode } from "react";
|
|
5
|
+
import { Pressable, Text, View } from "react-native";
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import type { ChatAttachment, ChatMessage } from "../../core/types";
|
|
8
|
+
import type { ChatAttachmentKind } from "../../react/index";
|
|
9
|
+
import { attachmentPreview, slotUrl, splitCardAttachments, type ChatAttachmentSlotProps } from "../attachment-cards";
|
|
10
|
+
import { chatNativeCopy } from "../copy";
|
|
11
|
+
import { inboxRow } from "../inbox-screen";
|
|
12
|
+
import { MessageBubble } from "../message-bubble";
|
|
13
|
+
import { transcriptIsLong } from "../media";
|
|
14
|
+
import { invertedRows, type ThreadRow } from "../rows";
|
|
15
|
+
|
|
16
|
+
// Text lays out to `layout.lines` lines when a test sets it (the shim lays nothing out).
|
|
17
|
+
const layout = vi.hoisted(() => ({ lines: null as number | null }));
|
|
18
|
+
vi.mock("react-native", async (original) => {
|
|
19
|
+
const rn = await original<{ Text: (p: object) => ReactNode }>();
|
|
20
|
+
function Text(props: { onTextLayout?: (e: { nativeEvent: { lines: unknown[] } }) => void }) {
|
|
21
|
+
const { onTextLayout, ...rest } = props;
|
|
22
|
+
useLayoutEffect(() => {
|
|
23
|
+
if (onTextLayout && layout.lines !== null) onTextLayout({ nativeEvent: { lines: Array.from({ length: layout.lines }) } });
|
|
24
|
+
}, [onTextLayout]);
|
|
25
|
+
return createElement(rn.Text as never, rest);
|
|
26
|
+
}
|
|
27
|
+
return { ...rn, Text, useWindowDimensions: () => ({ width: 390, height: 844, scale: 3, fontScale: 1 }) };
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
cleanup();
|
|
32
|
+
vi.restoreAllMocks();
|
|
33
|
+
vi.useRealTimers();
|
|
34
|
+
layout.lines = null;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const ME = "11111111-1111-4111-8111-111111111111";
|
|
38
|
+
const SAM = "22222222-2222-4222-8222-222222222222";
|
|
39
|
+
const fireA11y = (el: HTMLElement, name: string) => act(() => void el.dispatchEvent(new CustomEvent("accessibilityaction", { detail: name })));
|
|
40
|
+
|
|
41
|
+
function attachment(over: Partial<ChatAttachment>): ChatAttachment {
|
|
42
|
+
return {
|
|
43
|
+
id: "a1", messageId: "m1", position: 0, kind: "task", ref: {}, provider: "none", path: null, providerId: null, playbackId: null, url: null, status: "ready",
|
|
44
|
+
name: null, mime: null, bytes: null, width: null, height: null, durationMs: null, waveform: null, posterPath: null, thumbhash: null,
|
|
45
|
+
transcript: null, transcriptStatus: null, ...over,
|
|
46
|
+
} as ChatAttachment;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function message(over: Partial<ChatMessage>): ChatMessage {
|
|
50
|
+
return {
|
|
51
|
+
id: "m1", conversationId: "conv", seq: 1, clientId: "cm1", senderId: SAM, senderRole: "member", kind: "text", body: "", mentions: [], replyToId: null,
|
|
52
|
+
threadRootId: null, threadCount: 0, threadLastAt: null, topic: null, visibility: "everyone", silent: false, meta: {}, createdAt: "2026-10-25T10:00:00Z",
|
|
53
|
+
editedAt: null, deletedAt: null, attachments: [], reactions: [], moderation: null, ...over,
|
|
54
|
+
} as unknown as ChatMessage;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function row(m: ChatMessage): Extract<ThreadRow, { type: "message" }> {
|
|
58
|
+
return invertedRows([{ type: "message", message: m }], { me: ME, group: false }).find((r) => r.type === "message") as Extract<ThreadRow, { type: "message" }>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A coaching app's task: a checkbox that ticks it, live state from the app (here a variable). */
|
|
62
|
+
function taskKind(state: { done: boolean }, toggle: () => void | Promise<unknown>): ChatAttachmentKind {
|
|
63
|
+
return {
|
|
64
|
+
label: "Task",
|
|
65
|
+
title: (a) => String(a.ref.title ?? "Task"),
|
|
66
|
+
subtitle: () => (state.done ? "Done" : "To do"),
|
|
67
|
+
action: () => ({ label: state.done ? "Mark not done" : "Mark done", onPress: toggle }),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function TaskCard({ attachment, mine, action, pending, error, testID }: ChatAttachmentSlotProps) {
|
|
72
|
+
return (
|
|
73
|
+
<View testID={`${testID}-task`}>
|
|
74
|
+
<Pressable testID={`${testID}-check`} accessibilityRole="checkbox" accessibilityState={{ busy: pending }} disabled={pending || !action} onPress={action?.run} style={{ minHeight: 44, minWidth: 44 }}>
|
|
75
|
+
<Text>{String(attachment.ref.title)}</Text>
|
|
76
|
+
</Pressable>
|
|
77
|
+
{mine ? <Text testID={`${testID}-mine`}>mine</Text> : null}
|
|
78
|
+
{error ? <Text testID={`${testID}-slot-error`}>{error}</Text> : null}
|
|
79
|
+
</View>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe("splitCardAttachments", () => {
|
|
84
|
+
const kinds = { form: { title: () => "Form" } };
|
|
85
|
+
const list = [{ kind: "image" }, { kind: "form" }, { kind: "task" }, { kind: "voice" }];
|
|
86
|
+
it("puts registered kinds on cards, and with a slot every kind the package doesn't draw", () => {
|
|
87
|
+
expect(splitCardAttachments(list, kinds).cards.map((a) => a.kind)).toEqual(["form"]);
|
|
88
|
+
expect(splitCardAttachments(list, kinds, true).cards.map((a) => a.kind)).toEqual(["form", "task"]);
|
|
89
|
+
expect(splitCardAttachments(list, undefined, true).rest.map((a) => a.kind)).toEqual(["image", "voice"]);
|
|
90
|
+
});
|
|
91
|
+
it("never treats an inherited key as a kind", () => {
|
|
92
|
+
expect(splitCardAttachments([{ kind: "constructor" }], {}).cards).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("the attachment slot", () => {
|
|
97
|
+
it("draws the app's kind with the web slot's props, and its kind's action", () => {
|
|
98
|
+
const toggle = vi.fn();
|
|
99
|
+
const task = attachment({ ref: { title: "Walk 20 minutes" }, url: "https://example.com/t" });
|
|
100
|
+
const seen: ChatAttachmentSlotProps[] = [];
|
|
101
|
+
const Slot = (p: ChatAttachmentSlotProps) => {
|
|
102
|
+
seen.push(p);
|
|
103
|
+
return <TaskCard {...p} />;
|
|
104
|
+
};
|
|
105
|
+
render(<MessageBubble row={row(message({ attachments: [task] }))} me={ME} urls={{}} group={false} reducedMotion attachmentKinds={{ task: taskKind({ done: false }, toggle) }} attachmentSlot={Slot} testID="b" />);
|
|
106
|
+
expect(screen.getByTestId("b-card-0-task")).toBeTruthy();
|
|
107
|
+
expect(screen.queryByTestId("b-card-0-action")).toBeNull();
|
|
108
|
+
const p = seen[seen.length - 1]!;
|
|
109
|
+
expect(p.attachment).toBe(task);
|
|
110
|
+
expect(p.message.id).toBe("m1");
|
|
111
|
+
expect(p.url).toBe("https://example.com/t");
|
|
112
|
+
expect(p.mine).toBe(false);
|
|
113
|
+
expect(p.kind?.label).toBe("Task");
|
|
114
|
+
expect(p.action?.label).toBe("Mark done");
|
|
115
|
+
expect(p.pending).toBe(false);
|
|
116
|
+
expect(p.error).toBeNull();
|
|
117
|
+
fireEvent.click(screen.getByTestId("b-card-0-check"));
|
|
118
|
+
expect(toggle).toHaveBeenCalledTimes(1);
|
|
119
|
+
// VoiceOver reaches the checkbox through the bubble's own action, with the same handler.
|
|
120
|
+
const bubble = screen.getByTestId("b-bubble");
|
|
121
|
+
expect(bubble.getAttribute("aria-label")).toContain("Walk 20 minutes");
|
|
122
|
+
expect(bubble.getAttribute("data-a11y-actions")).toContain("card-0");
|
|
123
|
+
fireA11y(bubble, "card-0");
|
|
124
|
+
expect(toggle).toHaveBeenCalledTimes(2);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("shows pending while an async action runs, then an error when it rejects", async () => {
|
|
128
|
+
let reject!: (e: Error) => void;
|
|
129
|
+
const toggle = vi.fn(() => new Promise((_, r) => (reject = r)));
|
|
130
|
+
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
131
|
+
const task = attachment({ ref: { title: "Journal" } });
|
|
132
|
+
render(<MessageBubble row={row(message({ attachments: [task] }))} me={ME} urls={{}} group={false} reducedMotion attachmentKinds={{ task: taskKind({ done: false }, toggle) }} attachmentSlot={TaskCard} testID="b" />);
|
|
133
|
+
fireEvent.click(screen.getByTestId("b-card-0-check"));
|
|
134
|
+
expect(screen.getByTestId("b-card-0-check").getAttribute("aria-busy")).toBe("true");
|
|
135
|
+
// A second tap while it runs is ignored.
|
|
136
|
+
fireEvent.click(screen.getByTestId("b-card-0-check"));
|
|
137
|
+
fireA11y(screen.getByTestId("b-bubble"), "card-0");
|
|
138
|
+
expect(toggle).toHaveBeenCalledTimes(1);
|
|
139
|
+
await act(async () => reject(new Error("offline")));
|
|
140
|
+
expect(screen.getByTestId("b-card-0-slot-error").textContent).toBe(chatNativeCopy.cardActionFailed);
|
|
141
|
+
expect(screen.getByTestId("b-bubble").getAttribute("aria-label")).toContain(chatNativeCopy.cardActionFailed);
|
|
142
|
+
expect(screen.getByTestId("b-card-0-check").getAttribute("aria-busy")).toBe("false");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("gives a slot `run` for the app's other actions", async () => {
|
|
146
|
+
const save = vi.fn(async () => undefined);
|
|
147
|
+
const Slot = ({ run, pending, testID }: ChatAttachmentSlotProps) => (
|
|
148
|
+
<Pressable testID={`${testID}-save`} accessibilityState={{ busy: pending }} onPress={() => run(save)}>
|
|
149
|
+
<Text>Save</Text>
|
|
150
|
+
</Pressable>
|
|
151
|
+
);
|
|
152
|
+
render(<MessageBubble row={row(message({ attachments: [attachment({ kind: "worksheet" })] }))} me={ME} urls={{}} group={false} reducedMotion attachmentSlot={Slot} testID="b" />);
|
|
153
|
+
await act(async () => void fireEvent.click(screen.getByTestId("b-card-0-save")));
|
|
154
|
+
expect(save).toHaveBeenCalledTimes(1);
|
|
155
|
+
expect(screen.getByTestId("b-card-0-save").getAttribute("aria-busy")).toBe("false");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("returns the fallback card for kinds it doesn't draw", () => {
|
|
159
|
+
const Slot = (p: ChatAttachmentSlotProps) => (p.attachment.kind === "task" ? <TaskCard {...p} /> : p.fallback);
|
|
160
|
+
const form = attachment({ id: "f1", kind: "form", ref: {} });
|
|
161
|
+
const kinds = { form: { title: () => "Intake form", action: () => ({ label: "Fill it in", onPress: vi.fn() }) } };
|
|
162
|
+
render(<MessageBubble row={row(message({ attachments: [form] }))} me={ME} urls={{}} group={false} reducedMotion attachmentKinds={kinds} attachmentSlot={Slot} testID="b" />);
|
|
163
|
+
expect(screen.getByTestId("b-card-0-action")).toBeTruthy();
|
|
164
|
+
expect(screen.getByTestId("b-card-0").textContent).toContain("Intake form");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("shows the card when the slot throws", () => {
|
|
168
|
+
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
169
|
+
const Slot = (): never => {
|
|
170
|
+
throw new Error("bad ref");
|
|
171
|
+
};
|
|
172
|
+
const kinds = { task: { title: () => "A task" } };
|
|
173
|
+
render(<MessageBubble row={row(message({ attachments: [attachment({})] }))} me={ME} urls={{}} group={false} reducedMotion attachmentKinds={kinds} attachmentSlot={Slot} testID="b" />);
|
|
174
|
+
expect(screen.getByTestId("b-card-0").textContent).toContain("A task");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("is told `mine` on your own message", () => {
|
|
178
|
+
render(<MessageBubble row={row(message({ senderId: ME, attachments: [attachment({ ref: { title: "x" } })] }))} me={ME} urls={{}} group={false} reducedMotion attachmentSlot={TaskCard} testID="b" />);
|
|
179
|
+
expect(screen.getByTestId("b-card-0-mine")).toBeTruthy();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("without a slot keeps the card, and `null` hides only unregistered kinds", () => {
|
|
183
|
+
const kinds = { task: { title: () => "A task" } };
|
|
184
|
+
const list = [attachment({}), attachment({ id: "a2", kind: "lesson", name: "lesson.pdf" })];
|
|
185
|
+
render(<MessageBubble row={row(message({ attachments: list }))} me={ME} urls={{}} group={false} reducedMotion attachmentKinds={kinds} testID="b" />);
|
|
186
|
+
expect(screen.getByTestId("b-card-0").textContent).toContain("A task");
|
|
187
|
+
// An unregistered kind without a slot stays a file row, as before.
|
|
188
|
+
expect(screen.getByTestId("b-file-0")).toBeTruthy();
|
|
189
|
+
cleanup();
|
|
190
|
+
render(<MessageBubble row={row(message({ attachments: list }))} me={ME} urls={{}} group={false} reducedMotion attachmentKinds={kinds} attachmentSlot={null} testID="b" />);
|
|
191
|
+
expect(screen.getByTestId("b-card-0").textContent).toContain("A task");
|
|
192
|
+
expect(screen.queryByTestId("b-card-1")).toBeNull();
|
|
193
|
+
expect(screen.queryByTestId("b-file-0")).toBeNull();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("the default card's button is busy while an async action runs and is at least 44pt tall", async () => {
|
|
197
|
+
let resolve!: () => void;
|
|
198
|
+
const onPress = vi.fn(() => new Promise<void>((r) => (resolve = r)));
|
|
199
|
+
const kinds = { form: { title: () => "Intake form", action: () => ({ label: "Fill it in", onPress }) } };
|
|
200
|
+
render(<MessageBubble row={row(message({ attachments: [attachment({ kind: "form" })] }))} me={ME} urls={{}} group={false} reducedMotion attachmentKinds={kinds} testID="b" />);
|
|
201
|
+
const button = screen.getByTestId("b-card-0-action");
|
|
202
|
+
expect(button.className).toContain("h-11");
|
|
203
|
+
fireEvent.click(button);
|
|
204
|
+
expect(screen.getByTestId("b-card-0-action").getAttribute("aria-busy")).toBe("true");
|
|
205
|
+
await act(async () => resolve());
|
|
206
|
+
expect(screen.getByTestId("b-card-0-action").getAttribute("aria-busy")).toBe("false");
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("the attachment slot, after review", () => {
|
|
211
|
+
it("tries the slot again once what it draws changes, after it threw", () => {
|
|
212
|
+
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
213
|
+
const Slot = (p: ChatAttachmentSlotProps) => {
|
|
214
|
+
if (!p.kind?.label) throw new Error("data not loaded");
|
|
215
|
+
return <TaskCard {...p} />;
|
|
216
|
+
};
|
|
217
|
+
const task = attachment({ ref: { title: "Walk" } });
|
|
218
|
+
const m = message({ attachments: [task] });
|
|
219
|
+
const bubble = (kinds: Record<string, ChatAttachmentKind>) => <MessageBubble row={row(m)} me={ME} urls={{}} group={false} reducedMotion attachmentKinds={kinds} attachmentSlot={Slot} testID="b" />;
|
|
220
|
+
const { rerender } = render(bubble({ task: { title: () => "Loading" } }));
|
|
221
|
+
expect(screen.queryByTestId("b-card-0-task")).toBeNull();
|
|
222
|
+
expect(screen.getByTestId("b-card-0").textContent).toContain("Loading");
|
|
223
|
+
rerender(bubble({ task: taskKind({ done: false }, vi.fn()) }));
|
|
224
|
+
expect(screen.getByTestId("b-card-0-task")).toBeTruthy();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("draws nothing for a kind the slot returns null for, and leaves out a message with nothing else", () => {
|
|
228
|
+
const Slot = (p: ChatAttachmentSlotProps) => (p.attachment.kind === "worksheet" ? <TaskCard {...p} /> : null);
|
|
229
|
+
const kinds = { task: taskKind({ done: false }, vi.fn()) };
|
|
230
|
+
render(<MessageBubble row={row(message({ attachments: [attachment({ ref: { title: "Walk" } })] }))} me={ME} urls={{}} group={false} reducedMotion attachmentKinds={kinds} attachmentSlot={Slot} testID="b" />);
|
|
231
|
+
expect(screen.queryByTestId("b-card-0")).toBeNull();
|
|
232
|
+
expect(screen.getByTestId("b").getAttribute("aria-hidden")).toBe("true");
|
|
233
|
+
expect(screen.getByTestId("b-bubble").getAttribute("data-a11y-actions") ?? "").not.toContain("card-0");
|
|
234
|
+
cleanup();
|
|
235
|
+
// With text it stays, and reads without the card.
|
|
236
|
+
render(<MessageBubble row={row(message({ body: "for today", attachments: [attachment({ ref: { title: "Walk" } })] }))} me={ME} urls={{}} group={false} reducedMotion attachmentKinds={kinds} attachmentSlot={Slot} testID="b" />);
|
|
237
|
+
expect(screen.getByTestId("b").getAttribute("aria-hidden")).toBeNull();
|
|
238
|
+
const label = screen.getByTestId("b-bubble").getAttribute("aria-label") ?? "";
|
|
239
|
+
expect(label).toContain("for today");
|
|
240
|
+
expect(label).not.toContain("Walk");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("with `null`, leaves out a message that is only an unregistered kind", () => {
|
|
244
|
+
render(<MessageBubble row={row(message({ attachments: [attachment({ kind: "poll" })] }))} me={ME} urls={{}} group={false} reducedMotion attachmentSlot={null} testID="b" />);
|
|
245
|
+
expect(screen.getByTestId("b").getAttribute("aria-hidden")).toBe("true");
|
|
246
|
+
expect(screen.getByTestId("b-bubble").getAttribute("aria-label") ?? "").not.toContain("poll");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("gives the slot only an http(s) url", () => {
|
|
250
|
+
expect(slotUrl("javascript:alert(1)")).toBeNull();
|
|
251
|
+
expect(slotUrl("file:///etc/passwd")).toBeNull();
|
|
252
|
+
expect(slotUrl("myapp://x")).toBeNull();
|
|
253
|
+
expect(slotUrl("https://example.com/a")).toBe("https://example.com/a");
|
|
254
|
+
expect(slotUrl("http://127.0.0.1:54321/a")).toBe("http://127.0.0.1:54321/a");
|
|
255
|
+
const seen: (string | null)[] = [];
|
|
256
|
+
const Slot = (p: ChatAttachmentSlotProps) => {
|
|
257
|
+
seen.push(p.url);
|
|
258
|
+
return null;
|
|
259
|
+
};
|
|
260
|
+
render(<MessageBubble row={row(message({ attachments: [attachment({ url: "javascript:alert(1)" })] }))} me={ME} urls={{}} group={false} reducedMotion attachmentSlot={Slot} testID="b" />);
|
|
261
|
+
expect(seen.every((u) => u === null)).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("stops being busy when an action runs too long, and ignores what it settles to later", async () => {
|
|
265
|
+
vi.useFakeTimers();
|
|
266
|
+
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
267
|
+
let resolve!: () => void;
|
|
268
|
+
const toggle = vi.fn(() => new Promise<void>((r) => (resolve = r)));
|
|
269
|
+
const kinds = { task: taskKind({ done: false }, toggle) };
|
|
270
|
+
render(<MessageBubble row={row(message({ attachments: [attachment({ ref: { title: "Walk" } })] }))} me={ME} urls={{}} group={false} reducedMotion attachmentKinds={kinds} attachmentSlot={TaskCard} cardActionTimeoutMs={5000} testID="b" />);
|
|
271
|
+
fireEvent.click(screen.getByTestId("b-card-0-check"));
|
|
272
|
+
expect(screen.getByTestId("b-card-0-check").getAttribute("aria-busy")).toBe("true");
|
|
273
|
+
await act(async () => void vi.advanceTimersByTime(5000));
|
|
274
|
+
expect(screen.getByTestId("b-card-0-check").getAttribute("aria-busy")).toBe("false");
|
|
275
|
+
expect(screen.getByTestId("b-card-0-slot-error").textContent).toBe(chatNativeCopy.cardActionTimedOut);
|
|
276
|
+
await act(async () => resolve());
|
|
277
|
+
expect(screen.getByTestId("b-card-0-slot-error").textContent).toBe(chatNativeCopy.cardActionTimedOut);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("times out after 30 seconds by default", async () => {
|
|
281
|
+
vi.useFakeTimers();
|
|
282
|
+
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
283
|
+
const kinds = { task: taskKind({ done: false }, () => new Promise<void>(() => undefined)) };
|
|
284
|
+
render(<MessageBubble row={row(message({ attachments: [attachment({ ref: { title: "Walk" } })] }))} me={ME} urls={{}} group={false} reducedMotion attachmentKinds={kinds} attachmentSlot={TaskCard} testID="b" />);
|
|
285
|
+
fireEvent.click(screen.getByTestId("b-card-0-check"));
|
|
286
|
+
await act(async () => void vi.advanceTimersByTime(29_999));
|
|
287
|
+
expect(screen.getByTestId("b-card-0-check").getAttribute("aria-busy")).toBe("true");
|
|
288
|
+
await act(async () => void vi.advanceTimersByTime(1));
|
|
289
|
+
expect(screen.getByTestId("b-card-0-check").getAttribute("aria-busy")).toBe("false");
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("previews of an app kind", () => {
|
|
294
|
+
const kinds = { task: { label: "Task", title: (a: ChatAttachment) => String(a.ref.title) } };
|
|
295
|
+
it("a reply's quote names it by its label", () => {
|
|
296
|
+
const quoted = message({ id: "q1", attachments: [attachment({ messageId: "q1", ref: { title: "Walk" } })] });
|
|
297
|
+
const reply = message({ id: "m2", body: "done!", replyToId: "q1" });
|
|
298
|
+
render(<MessageBubble row={row(reply)} me={ME} urls={{}} repliedTo={quoted} group={false} reducedMotion attachmentKinds={kinds} testID="b" />);
|
|
299
|
+
expect(screen.getByTestId("b-quote").textContent).toContain("Task");
|
|
300
|
+
});
|
|
301
|
+
it("the inbox's last message reads as the label", () => {
|
|
302
|
+
expect(attachmentPreview("", "task", kinds)).toBe("Task");
|
|
303
|
+
const r = inboxRow(
|
|
304
|
+
{ id: "c1", kind: "direct", title: "Sam", lastMessage: { body: "", attachment: "task", senderId: SAM, kind: "text", deleted: false, createdAt: "2026-10-25T10:00:00Z" } } as never,
|
|
305
|
+
{ me: ME, copy: chatNativeCopy, person: () => undefined, kinds },
|
|
306
|
+
);
|
|
307
|
+
expect(r.preview).toBe("Task");
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe("a voice note's transcript", () => {
|
|
312
|
+
const voice = (over: Partial<ChatAttachment>) => attachment({ kind: "voice", provider: "storage", path: "v.m4a", durationMs: 4000, waveform: [10, 60], ...over });
|
|
313
|
+
|
|
314
|
+
it("shows under the player and is part of the bubble's label", () => {
|
|
315
|
+
render(<MessageBubble row={row(message({ attachments: [voice({ transcript: "Felt strong today.", transcriptStatus: "ready" })] }))} me={ME} urls={{ "v.m4a": "https://x/v" }} group={false} reducedMotion testID="b" />);
|
|
316
|
+
expect(screen.getByTestId("b-voice-0-transcript-text").textContent).toBe("Felt strong today.");
|
|
317
|
+
expect(screen.queryByTestId("b-voice-0-transcript-toggle")).toBeNull();
|
|
318
|
+
expect(screen.getByTestId("b-bubble").getAttribute("aria-label")).toContain("Transcript: Felt strong today.");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("folds a long one behind Show more", () => {
|
|
322
|
+
const long = "word ".repeat(60).trim();
|
|
323
|
+
expect(transcriptIsLong(long)).toBe(true);
|
|
324
|
+
render(<MessageBubble row={row(message({ attachments: [voice({ transcript: long, transcriptStatus: "ready" })] }))} me={ME} urls={{}} group={false} reducedMotion testID="b" />);
|
|
325
|
+
const toggle = screen.getByTestId("b-voice-0-transcript-toggle");
|
|
326
|
+
expect(toggle.textContent).toBe(chatNativeCopy.showMore);
|
|
327
|
+
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
|
328
|
+
fireEvent.click(toggle);
|
|
329
|
+
expect(screen.getByTestId("b-voice-0-transcript-toggle").textContent).toBe(chatNativeCopy.showLess);
|
|
330
|
+
expect(screen.getByTestId("b-voice-0-transcript-toggle").getAttribute("aria-expanded")).toBe("true");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("folds by the measured line count once laid out, not by length", () => {
|
|
334
|
+
const longText = "word ".repeat(60).trim();
|
|
335
|
+
layout.lines = 2;
|
|
336
|
+
render(<MessageBubble row={row(message({ attachments: [voice({ transcript: longText, transcriptStatus: "ready" })] }))} me={ME} urls={{}} group={false} reducedMotion testID="b" />);
|
|
337
|
+
expect(screen.queryByTestId("b-voice-0-transcript-toggle")).toBeNull();
|
|
338
|
+
cleanup();
|
|
339
|
+
layout.lines = 5;
|
|
340
|
+
render(<MessageBubble row={row(message({ attachments: [voice({ transcript: "Short\nbut\non many lines", transcriptStatus: "ready" })] }))} me={ME} urls={{}} group={false} reducedMotion testID="b" />);
|
|
341
|
+
expect(screen.getByTestId("b-voice-0-transcript-toggle").textContent).toBe(chatNativeCopy.showMore);
|
|
342
|
+
expect(transcriptIsLong("x", 4)).toBe(true);
|
|
343
|
+
expect(transcriptIsLong("x".repeat(500), 3)).toBe(false);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("says Transcribing while it is made, and nothing when there is none", () => {
|
|
347
|
+
render(<MessageBubble row={row(message({ attachments: [voice({ transcriptStatus: "pending" })] }))} me={ME} urls={{}} group={false} reducedMotion testID="b" />);
|
|
348
|
+
expect(screen.getByTestId("b-voice-0-transcript").textContent).toBe(chatNativeCopy.transcribing);
|
|
349
|
+
cleanup();
|
|
350
|
+
render(<MessageBubble row={row(message({ attachments: [voice({ transcriptStatus: "failed" })] }))} me={ME} urls={{}} group={false} reducedMotion testID="b" />);
|
|
351
|
+
expect(screen.queryByTestId("b-voice-0-transcript")).toBeNull();
|
|
352
|
+
});
|
|
353
|
+
});
|
|
@@ -275,6 +275,18 @@ describe("ChatSearchScreen", () => {
|
|
|
275
275
|
await waitFor(() => expect(screen.getByTestId("s-empty")).toBeTruthy());
|
|
276
276
|
expect(called(client, "search").at(-1)!.args).toEqual(["zebra", "dm"]);
|
|
277
277
|
});
|
|
278
|
+
|
|
279
|
+
it("keeps the field and its states in a list that insets below a large-title or see-through header", async () => {
|
|
280
|
+
const client = memory([{ id: "dm", members: [{ userId: ME }, { userId: SAM }], messages: [{ id: "a", senderId: SAM, body: "hello" }] }]);
|
|
281
|
+
render(wrap(client, <ChatSearchScreen onOpen={vi.fn()} debounceMs={0} testID="s" />));
|
|
282
|
+
const list = screen.getByTestId("s-results");
|
|
283
|
+
expect(list.getAttribute("data-content-inset-adjustment-behavior")).toBe("automatic");
|
|
284
|
+
expect(within(list).getByTestId("s-field-input")).toBeTruthy();
|
|
285
|
+
expect(within(list).getByTestId("s-hint")).toBeTruthy();
|
|
286
|
+
fireEvent.change(screen.getByTestId("s-field-input"), { target: { value: "hello" } });
|
|
287
|
+
await waitFor(() => expect(within(list).getByTestId("s-hit-a")).toBeTruthy());
|
|
288
|
+
expect(within(list).getByTestId("s-field-input")).toBeTruthy();
|
|
289
|
+
});
|
|
278
290
|
});
|
|
279
291
|
|
|
280
292
|
describe("ChatBroadcastSheet", () => {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { MESSAGE_ACTIONS } from "@marktiderman/genesis-notifications";
|
|
3
|
+
import { CHAT_NOTIFICATION_CATEGORIES } from "../../server/notifications";
|
|
4
|
+
import { CHAT_PUSH_CATEGORY, chatNotificationActions } from "../push";
|
|
5
|
+
|
|
6
|
+
// A chat push sent through genesis-notifications must land on the category and
|
|
7
|
+
// action ids this package's push handler (and genesis-native's notification
|
|
8
|
+
// service extension, which looks for "messages") answers to.
|
|
9
|
+
describe("chat and genesis-notifications agree on push ids", () => {
|
|
10
|
+
it("the same action ids", () => {
|
|
11
|
+
expect(MESSAGE_ACTIONS.actions.map((a) => a.id)).toEqual(chatNotificationActions().map((a) => a.identifier));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("the same category id", () => {
|
|
15
|
+
expect(Object.keys(CHAT_NOTIFICATION_CATEGORIES)).toEqual([CHAT_PUSH_CATEGORY]);
|
|
16
|
+
expect(CHAT_PUSH_CATEGORY).toBe("messages");
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -31,7 +31,7 @@ describe("the messages category", () => {
|
|
|
31
31
|
it("is one preferences category and two actions, neither opening the app", async () => {
|
|
32
32
|
expect(chatPushCategory()).toMatchObject({ id: "messages", title: "Messages" });
|
|
33
33
|
const actions = chatNotificationActions();
|
|
34
|
-
expect(actions.map((a) => a.identifier)).toEqual(["reply", "
|
|
34
|
+
expect(actions.map((a) => a.identifier)).toEqual(["reply", "mark_read"]);
|
|
35
35
|
expect(actions[0]!.textInput).toEqual({ submitButtonTitle: "Send", placeholder: "Message" });
|
|
36
36
|
expect(actions.every((a) => a.options.opensAppToForeground === false)).toBe(true);
|
|
37
37
|
const setNotificationCategoryAsync = vi.fn(async () => undefined);
|
|
@@ -79,17 +79,23 @@ describe("handleChatPushResponse", () => {
|
|
|
79
79
|
|
|
80
80
|
it("Mark read marks up to the pushed message", async () => {
|
|
81
81
|
const client = fakeClient();
|
|
82
|
-
expect(await handleChatPushResponse(response("
|
|
82
|
+
expect(await handleChatPushResponse(response("mark_read"), { client })).toBe("marked-read");
|
|
83
83
|
expect(client.markRead).toHaveBeenCalledWith(CONV, 7);
|
|
84
84
|
expect(client.send).not.toHaveBeenCalled();
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
+
it("still marks read from a notification shown before the action id changed (mark-read)", async () => {
|
|
88
|
+
const client = fakeClient();
|
|
89
|
+
expect(await handleChatPushResponse(response("mark-read"), { client })).toBe("marked-read");
|
|
90
|
+
expect(client.markRead).toHaveBeenCalledWith(CONV, 7);
|
|
91
|
+
});
|
|
92
|
+
|
|
87
93
|
it("reads the data createChatRunner sends: conversationId and seq, and a seq that is null", async () => {
|
|
88
94
|
const client = fakeClient();
|
|
89
95
|
const data = { url: chatPath(CONV), conversationId: CONV, messageId: "m1", seq: 12 };
|
|
90
|
-
expect(await handleChatPushResponse(response("
|
|
96
|
+
expect(await handleChatPushResponse(response("mark_read", {}, data), { client })).toBe("marked-read");
|
|
91
97
|
expect(client.markRead).toHaveBeenLastCalledWith(CONV, 12);
|
|
92
|
-
await handleChatPushResponse(response("
|
|
98
|
+
await handleChatPushResponse(response("mark_read", {}, { conversationId: CONV, seq: null }), { client });
|
|
93
99
|
expect(client.markRead).toHaveBeenLastCalledWith(CONV, undefined);
|
|
94
100
|
});
|
|
95
101
|
|