@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.
- package/README.md +379 -13
- package/dist/chunk-A4YJX7KT.js +544 -0
- package/dist/chunk-A4YJX7KT.js.map +1 -0
- package/dist/chunk-AZS3HNLM.js +30 -0
- package/dist/chunk-AZS3HNLM.js.map +1 -0
- package/dist/chunk-BJCE5O7V.js +70 -0
- package/dist/chunk-BJCE5O7V.js.map +1 -0
- package/dist/chunk-GBJ3YI4E.js +966 -0
- package/dist/chunk-GBJ3YI4E.js.map +1 -0
- package/dist/chunk-JU2FAPBI.js +609 -0
- package/dist/chunk-JU2FAPBI.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-DUIG1hum.d.ts} +187 -11
- package/dist/index.d.ts +78 -163
- package/dist/index.js +3 -2
- package/dist/native.d.ts +1939 -1
- package/dist/native.js +5023 -0
- package/dist/native.js.map +1 -1
- package/dist/outbox-B9NAWQ58.d.ts +219 -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 +176 -7
- package/dist/react.js +3 -3
- package/dist/server.d.ts +551 -21
- package/dist/server.js +976 -34
- package/dist/server.js.map +1 -1
- package/dist/thread-oPra7pkW.d.ts +57 -0
- package/dist/web.d.ts +335 -12
- package/dist/web.js +1784 -64
- package/dist/web.js.map +1 -1
- package/migrations/0002_genesis_chat_coaching.sql +934 -0
- package/migrations/0003_genesis_chat_search_threads.sql +85 -0
- package/migrations/tests/0002_genesis_chat_coaching.test.sql +269 -0
- package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
- package/package.json +78 -6
- package/src/__tests__/coaching-core.test.ts +334 -0
- package/src/__tests__/coaching-react.test.tsx +319 -0
- package/src/__tests__/coaching-web.test.tsx +521 -0
- package/src/__tests__/core.test.ts +91 -0
- package/src/__tests__/fake-xhr.ts +54 -0
- package/src/__tests__/outbox-hosted.test.ts +269 -0
- package/src/__tests__/outbox.test.ts +122 -0
- package/src/__tests__/realtime.test.ts +70 -0
- package/src/__tests__/review-279.test.ts +41 -0
- package/src/__tests__/review-fixes-core.test.ts +193 -0
- package/src/__tests__/review-fixes-web.test.tsx +405 -0
- package/src/__tests__/server-effects.test.ts +158 -0
- package/src/__tests__/server-fake-video.test.ts +81 -0
- package/src/__tests__/server-fakes.ts +96 -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 +303 -0
- package/src/__tests__/web-markdown.test.tsx +88 -0
- package/src/__tests__/web.test.tsx +349 -0
- package/src/core/availability.ts +223 -0
- package/src/core/client.ts +783 -0
- package/src/core/extras.ts +106 -0
- package/src/core/media.ts +144 -0
- package/src/core/outbox.ts +635 -0
- package/src/core/thread.ts +171 -0
- package/src/core/transfer.ts +71 -0
- package/src/core/types.ts +328 -0
- package/src/index.ts +14 -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 +254 -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 +424 -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 +498 -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 +132 -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 +122 -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 +126 -0
- package/src/native/message-bubble.tsx +366 -0
- package/src/native/moderation.tsx +42 -0
- package/src/native/modules.ts +277 -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 +328 -0
- package/src/native/search-screen.tsx +189 -0
- package/src/native/stack-options.ts +36 -0
- package/src/native/start-sheet.tsx +87 -0
- package/src/native/thread-screen.tsx +647 -0
- package/src/native/topics.tsx +234 -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 +410 -0
- package/src/native/video-recorder-state.ts +140 -0
- package/src/native/video-recorder.tsx +274 -0
- package/src/native/video-upload-tray.tsx +77 -0
- package/src/native/video-upload.ts +432 -0
- package/src/native/voice-player.tsx +301 -0
- package/src/react/index.tsx +894 -0
- package/src/react/previews.ts +51 -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/fake-video.ts +171 -0
- package/src/server/handler.ts +32 -0
- package/src/server/index.ts +58 -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 +585 -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 +196 -0
- package/src/web/index.ts +75 -0
- package/src/web/markdown.tsx +245 -0
- package/src/web/memory-client.ts +714 -0
- package/src/web/panel.tsx +135 -0
- package/src/web/schedule.tsx +204 -0
- package/src/web/search.tsx +129 -0
- package/src/web/thread-context.ts +64 -0
- package/src/web/thread.tsx +830 -0
- package/src/web/topics.tsx +160 -0
- package/src/web/use-voice-recorder.ts +264 -0
- package/src/web/video.tsx +516 -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 +0 -525
- package/dist/chunk-JQXZUEEL.js.map +0 -1
- package/dist/chunk-SAY24E6V.js +0 -309
- package/dist/chunk-SAY24E6V.js.map +0 -1
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* Fixes from the review of the phases 2–4 client: malformed working hours, the outbox on
|
|
4
|
+
* sign-out, retry order and refusals, and who owns a preview URL.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { awayLine, awaySummary, hoursSummary, isWithinWorkingHours, normalizeHours } from "../core/availability";
|
|
8
|
+
import { ChatError, toAvailability, type ChatClient } from "../core/client";
|
|
9
|
+
import { createOutbox, type QueuedMessage } from "../core/outbox";
|
|
10
|
+
import { createMemoryChatClient } from "../web/memory-client";
|
|
11
|
+
|
|
12
|
+
const ME = "00000000-0000-4000-8000-0000000000a1";
|
|
13
|
+
const SAM = "00000000-0000-4000-8000-0000000000b2";
|
|
14
|
+
const tick = (ms = 0) => new Promise((r) => setTimeout(r, ms));
|
|
15
|
+
|
|
16
|
+
function deferred<T = void>() {
|
|
17
|
+
let resolve!: (v: T) => void;
|
|
18
|
+
const promise = new Promise<T>((r) => (resolve = r));
|
|
19
|
+
return { promise, resolve };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("someone else's malformed working hours", () => {
|
|
23
|
+
// What a member could store before the database checked it (or the server could still write).
|
|
24
|
+
const bad = {
|
|
25
|
+
mon: [[9, 17]],
|
|
26
|
+
tue: 5,
|
|
27
|
+
wed: [["09:00", "17:00"], ["x"], null, ["1:00", "2:00"], ["3:00", "4:00"], ["5:00", "6:00"], ["7:00", "8:00"]],
|
|
28
|
+
thu: [["9am", "5pm"]],
|
|
29
|
+
funday: [["09:00", "10:00"]],
|
|
30
|
+
constructor: [["09:00", "10:00"]],
|
|
31
|
+
} as never;
|
|
32
|
+
|
|
33
|
+
it("keeps only mon..sun days of HH:MM string pairs, at most four a day", () => {
|
|
34
|
+
expect(toAvailability({ user_id: SAM, hours: bad }).hours).toEqual({ wed: [["09:00", "17:00"], ["1:00", "2:00"], ["3:00", "4:00"], ["5:00", "6:00"]] });
|
|
35
|
+
expect(normalizeHours("nope")).toBeNull();
|
|
36
|
+
expect(normalizeHours([["09:00", "17:00"]])).toBeNull();
|
|
37
|
+
expect(normalizeHours({ mon: [[9, 17]] })).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("never makes the helpers throw", () => {
|
|
41
|
+
const a = { userId: SAM, awayMessage: null, awayUntil: null, timeZone: "UTC", hours: bad };
|
|
42
|
+
const now = new Date("2026-10-28T12:00:00Z"); // a Wednesday
|
|
43
|
+
expect(() => isWithinWorkingHours(bad, "UTC", now)).not.toThrow();
|
|
44
|
+
expect(isWithinWorkingHours(bad, "UTC", now)).toBe(true);
|
|
45
|
+
expect(hoursSummary(bad)).toBe("Wed, 9–5, 1–2, 3–4, 5–6");
|
|
46
|
+
expect(() => awayLine(a, "Sam", { now, always: true })).not.toThrow();
|
|
47
|
+
expect(() => awaySummary([{ availability: a, name: "Sam" }, { availability: { ...a, hours: { tue: 5 } as never }, name: "Lee" }], { now })).not.toThrow();
|
|
48
|
+
// An away date read in a zone Intl doesn't know falls back to the device's.
|
|
49
|
+
const away = { ...a, awayMessage: "Off", awayUntil: new Date(now.getTime() + 86_400_000).toISOString(), timeZone: "Mars/Olympus" };
|
|
50
|
+
expect(awayLine(away, "Sam", { now })).toMatch(/^Away until .+: Off$/);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("clearing the outbox (sign-out) mid-send", () => {
|
|
55
|
+
it("stops a send in flight: nothing posts after clear()", async () => {
|
|
56
|
+
const gate = deferred();
|
|
57
|
+
const sent: string[] = [];
|
|
58
|
+
const client = { send: vi.fn(async (i: { body: string }) => (await gate.promise, sent.push(i.body), { id: "x" })), upload: vi.fn() } as unknown as ChatClient;
|
|
59
|
+
const store = { load: async () => [], save: vi.fn(async (_q: QueuedMessage[]) => undefined) };
|
|
60
|
+
const ob = createOutbox({ client, userId: ME, store });
|
|
61
|
+
await ob.send({ conversationId: "c1", body: "from user A" });
|
|
62
|
+
await tick();
|
|
63
|
+
await ob.clear();
|
|
64
|
+
const saves = store.save.mock.calls.length;
|
|
65
|
+
gate.resolve();
|
|
66
|
+
await tick(10);
|
|
67
|
+
// The request had already gone, but the outbox records and reports nothing of it.
|
|
68
|
+
expect(store.save.mock.calls.length).toBe(saves);
|
|
69
|
+
expect(ob.pending()).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("never sends a message whose upload was in flight, and aborts that upload", async () => {
|
|
73
|
+
const gate = deferred<string>();
|
|
74
|
+
let signal: AbortSignal | undefined;
|
|
75
|
+
const client = { send: vi.fn(async () => ({ id: "x" })) } as unknown as ChatClient;
|
|
76
|
+
const upload = vi.fn(async (_c: string, _f: unknown, o?: { signal?: AbortSignal }) => ((signal = o?.signal), gate.promise));
|
|
77
|
+
const ob = createOutbox({ client, userId: ME, upload });
|
|
78
|
+
await ob.send({ conversationId: "c1", body: "with a file" }, [{ data: new Blob(["x"]), name: "a.png", contentType: "image/png" }]);
|
|
79
|
+
await tick();
|
|
80
|
+
await ob.clear();
|
|
81
|
+
expect(signal?.aborted).toBe(true);
|
|
82
|
+
gate.resolve("c1/me/a.png");
|
|
83
|
+
await tick(10);
|
|
84
|
+
expect(client.send).not.toHaveBeenCalled();
|
|
85
|
+
|
|
86
|
+
// The next session's messages to the same conversation still go.
|
|
87
|
+
await ob.send({ conversationId: "c1", body: "user B" });
|
|
88
|
+
await tick(10);
|
|
89
|
+
expect(client.send).toHaveBeenCalledTimes(1);
|
|
90
|
+
expect((client.send as ReturnType<typeof vi.fn>).mock.calls[0]![0]).toMatchObject({ body: "user B" });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("aborts the upload of a discarded message and never sends it", async () => {
|
|
94
|
+
const gate = deferred<string>();
|
|
95
|
+
let signal: AbortSignal | undefined;
|
|
96
|
+
const client = { send: vi.fn(async () => ({ id: "x" })) } as unknown as ChatClient;
|
|
97
|
+
const ob = createOutbox({ client, userId: ME, upload: async (_c, _f, o) => ((signal = o?.signal), gate.promise) });
|
|
98
|
+
const id = await ob.send({ conversationId: "c1", body: "never mind" }, [{ data: new Blob(["x"]), name: "a.png", contentType: "image/png" }]);
|
|
99
|
+
await tick();
|
|
100
|
+
await ob.discard(id);
|
|
101
|
+
expect(signal?.aborted).toBe(true);
|
|
102
|
+
gate.resolve("p");
|
|
103
|
+
await tick(10);
|
|
104
|
+
expect(client.send).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("retry order and refusals", () => {
|
|
109
|
+
const queued = (clientId: string, body: string, extra: Partial<QueuedMessage> = {}): QueuedMessage => ({
|
|
110
|
+
clientId,
|
|
111
|
+
input: { conversationId: "c1", clientId, body },
|
|
112
|
+
senderId: ME,
|
|
113
|
+
createdAt: "",
|
|
114
|
+
status: "failed",
|
|
115
|
+
previews: {},
|
|
116
|
+
files: [],
|
|
117
|
+
attempts: 1,
|
|
118
|
+
...extra,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("retryAll never lets a later message overtake one waiting for a lost file", async () => {
|
|
122
|
+
const order: string[] = [];
|
|
123
|
+
const lost = { name: "x.jpg", contentType: "image/jpeg", kind: "image" as const, lost: true };
|
|
124
|
+
const store = { load: async () => [queued("a", "first", { pendingFiles: [lost] }), queued("b", "second")], save: async () => undefined };
|
|
125
|
+
const client = { send: async (i: { body: string; clientId: string }) => (order.push(i.body), { id: i.clientId }), upload: async () => "c1/me/x.jpg" } as unknown as ChatClient;
|
|
126
|
+
const ob = createOutbox({ client, userId: ME, store });
|
|
127
|
+
await ob.retryAll();
|
|
128
|
+
await tick(10);
|
|
129
|
+
expect(order).toEqual([]);
|
|
130
|
+
expect(ob.pending("c1").map((p) => p.status)).toEqual(["failed", "failed"]);
|
|
131
|
+
|
|
132
|
+
await ob.reattach("a", [{ data: new Blob(["jpg"]), name: "x.jpg", contentType: "image/jpeg" }]);
|
|
133
|
+
await tick(10);
|
|
134
|
+
await ob.retryAll();
|
|
135
|
+
await tick(10);
|
|
136
|
+
expect(order).toEqual(["first", "second"]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("marks a refusal permanent: retryAll skips it (and what follows); a manual retry sends it", async () => {
|
|
140
|
+
const send = vi.fn(async (_i: { body: string }) => {
|
|
141
|
+
throw new ChatError("You can't post here", "42501", "send");
|
|
142
|
+
});
|
|
143
|
+
const ob = createOutbox({ client: { send } as unknown as ChatClient, userId: ME });
|
|
144
|
+
const id = await ob.send({ conversationId: "c1", body: "one" });
|
|
145
|
+
await tick(10);
|
|
146
|
+
expect(ob.pending("c1")[0]).toMatchObject({ status: "failed", permanent: true, error: "You can't post here" });
|
|
147
|
+
await ob.retryAll();
|
|
148
|
+
await ob.retryAll();
|
|
149
|
+
await tick(10);
|
|
150
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
151
|
+
|
|
152
|
+
send.mockImplementationOnce(async () => ({ id: "m" }) as never);
|
|
153
|
+
await ob.retry(id);
|
|
154
|
+
await tick(10);
|
|
155
|
+
expect(send).toHaveBeenCalledTimes(2);
|
|
156
|
+
expect(ob.pending("c1")).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("classes database refusals as permanent and network trouble as not", () => {
|
|
160
|
+
for (const code of ["42501", "22023", "23505", "P0001"]) expect(new ChatError("x", code, "send").permanent).toBe(true);
|
|
161
|
+
for (const code of [undefined, "08006", "PGRST000", "GS429", "GSFILE"]) expect(new ChatError("x", code, "send").permanent).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("preview URLs", () => {
|
|
166
|
+
it("are let go only once the message has landed (or was discarded)", async () => {
|
|
167
|
+
const gate = deferred();
|
|
168
|
+
const releasePreview = vi.fn();
|
|
169
|
+
const client = { send: async () => (await gate.promise, { id: "m" }) } as unknown as ChatClient;
|
|
170
|
+
const ob = createOutbox({ client, userId: ME, releasePreview, upload: async () => "c1/me/v.webm" });
|
|
171
|
+
await ob.send({ conversationId: "c1" }, [{ data: new Blob(["v"]), name: "v.webm", contentType: "video/webm", previewUrl: "blob:take" }]);
|
|
172
|
+
await tick(10);
|
|
173
|
+
expect(releasePreview).not.toHaveBeenCalled();
|
|
174
|
+
gate.resolve();
|
|
175
|
+
await tick(10);
|
|
176
|
+
expect(releasePreview).toHaveBeenCalledWith("blob:take");
|
|
177
|
+
|
|
178
|
+
const id = await ob.send({ conversationId: "c2" }, [{ data: new Blob(["p"]), name: "p.png", contentType: "image/png", previewUrl: "blob:photo" }]);
|
|
179
|
+
await ob.discard(id);
|
|
180
|
+
expect(releasePreview).toHaveBeenCalledWith("blob:photo");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("search snippets", () => {
|
|
185
|
+
it("can't be faked by a message's own « and »", async () => {
|
|
186
|
+
const client = createMemoryChatClient({
|
|
187
|
+
me: ME,
|
|
188
|
+
conversations: [{ id: "c1", members: [{ userId: ME }, { userId: SAM }], messages: [{ id: "m", senderId: SAM, body: "a «fake» mark near quilt", createdAt: new Date().toISOString() }] }],
|
|
189
|
+
});
|
|
190
|
+
const [hit] = await client.search("quilt");
|
|
191
|
+
expect(hit!.snippet).toBe("a ‹fake› mark near «quilt»");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment happy-dom
|
|
3
|
+
* @feat:chat
|
|
4
|
+
* Fixes from the review of the phases 2–4 client, as people see them: a thread that one bad
|
|
5
|
+
* message can't crash, catch-up after a long time away, read places in topics, link previews,
|
|
6
|
+
* the emoji picker, broadcast and schedule retries, and the camera.
|
|
7
|
+
*/
|
|
8
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
9
|
+
import { act, cleanup, configure, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react";
|
|
10
|
+
import type { ReactNode } from "react";
|
|
11
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
12
|
+
import { ChatError } from "../core/client";
|
|
13
|
+
import type { ChatAttachment } from "../core/types";
|
|
14
|
+
import { CATCH_UP_MAX_PAGES, CATCH_UP_PAGE, ChatProvider, chatKeys, useConversation, useLinkPreviews, type ChatProviderProps } from "../react";
|
|
15
|
+
import {
|
|
16
|
+
ChatBroadcast,
|
|
17
|
+
ChatEmojiPicker,
|
|
18
|
+
ChatErrorBoundary,
|
|
19
|
+
ChatLinkPreview,
|
|
20
|
+
ChatThread,
|
|
21
|
+
createMemoryChatClient,
|
|
22
|
+
useVideoRecorder,
|
|
23
|
+
type MemoryChatClient,
|
|
24
|
+
type MemoryChatOptions,
|
|
25
|
+
type MemoryConversationSeed,
|
|
26
|
+
} from "../web";
|
|
27
|
+
import { installFakeXHR } from "./fake-xhr";
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
cleanup();
|
|
31
|
+
vi.restoreAllMocks();
|
|
32
|
+
vi.unstubAllGlobals();
|
|
33
|
+
vi.useRealTimers();
|
|
34
|
+
});
|
|
35
|
+
configure({ asyncUtilTimeout: 5000 });
|
|
36
|
+
|
|
37
|
+
const ME = "00000000-0000-4000-8000-0000000000a1";
|
|
38
|
+
const SAM = "00000000-0000-4000-8000-0000000000b2";
|
|
39
|
+
const LEE = "00000000-0000-4000-8000-0000000000c3";
|
|
40
|
+
const names: Record<string, string> = { [ME]: "Me", [SAM]: "Sam", [LEE]: "Lee" };
|
|
41
|
+
const resolveUser = (id: string) => ({ name: names[id] ?? "Someone" });
|
|
42
|
+
const at = (min: number) => new Date(Date.now() - (600 - min) * 60_000).toISOString();
|
|
43
|
+
|
|
44
|
+
function providers(client: MemoryChatClient, extra: Partial<ChatProviderProps> = {}, queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })) {
|
|
45
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
46
|
+
<QueryClientProvider client={queryClient}>
|
|
47
|
+
<ChatProvider client={client} userId={ME} {...extra}>
|
|
48
|
+
{children}
|
|
49
|
+
</ChatProvider>
|
|
50
|
+
</QueryClientProvider>
|
|
51
|
+
);
|
|
52
|
+
return { wrapper, queryClient };
|
|
53
|
+
}
|
|
54
|
+
const mount = (client: MemoryChatClient, ui: ReactNode, extra: Partial<ChatProviderProps> = {}) => render(<>{ui}</>, { wrapper: providers(client, extra).wrapper });
|
|
55
|
+
|
|
56
|
+
const dm = (over: Partial<MemoryConversationSeed> = {}): MemoryConversationSeed => ({
|
|
57
|
+
id: "c1",
|
|
58
|
+
members: [{ userId: ME }, { userId: SAM }],
|
|
59
|
+
messages: [{ id: "m1", senderId: SAM, body: "Hello", createdAt: at(1) }],
|
|
60
|
+
...over,
|
|
61
|
+
});
|
|
62
|
+
const make = (seed: MemoryConversationSeed, over: Partial<MemoryChatOptions> = {}) => createMemoryChatClient({ me: ME, conversations: [seed], ...over });
|
|
63
|
+
const openMenu = (el: HTMLElement) => fireEvent.keyDown(el, { key: "Enter" });
|
|
64
|
+
|
|
65
|
+
describe("one bad message can't take the thread down", () => {
|
|
66
|
+
it("draws a conversation whose other member stored malformed hours", async () => {
|
|
67
|
+
const client = make(dm(), { availability: { [SAM]: { timeZone: "UTC", hours: { mon: [[9, 17]], tue: 5 } as never } } });
|
|
68
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
|
|
69
|
+
await screen.findByText("Hello");
|
|
70
|
+
await waitFor(() => expect(client.calls.some((c) => c.method === "availabilityFor")).toBe(true));
|
|
71
|
+
expect(screen.getByText("Hello")).toBeTruthy();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("an attachment kind named after a prototype key (constructor) gets the generic card", async () => {
|
|
75
|
+
const client = make(dm({ messages: [{ id: "a", senderId: SAM, body: "see this", createdAt: at(1), attachments: [{ kind: "constructor", ref: {} }] }] }));
|
|
76
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />, { attachmentKinds: { form: { title: () => "A form" } } });
|
|
77
|
+
await screen.findByText("see this");
|
|
78
|
+
expect(screen.getByText("Constructor")).toBeTruthy();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("an app card whose functions throw or return junk falls back to the generic card", async () => {
|
|
82
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
83
|
+
const client = make(
|
|
84
|
+
dm({
|
|
85
|
+
messages: [
|
|
86
|
+
{ id: "a", senderId: SAM, body: "first", createdAt: at(1), attachments: [{ kind: "form", ref: {} }] },
|
|
87
|
+
{ id: "b", senderId: SAM, body: "second", createdAt: at(2), attachments: [{ kind: "lesson", ref: {} }] },
|
|
88
|
+
],
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />, {
|
|
92
|
+
attachmentKinds: {
|
|
93
|
+
form: {
|
|
94
|
+
title: (a: ChatAttachment) => {
|
|
95
|
+
throw new Error(`bad ref ${String(a.kind)}`);
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
lesson: { title: () => 42 as unknown as string },
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
await screen.findByText("second");
|
|
102
|
+
expect(screen.getByText("Form")).toBeTruthy();
|
|
103
|
+
expect(screen.getByText("Lesson")).toBeTruthy();
|
|
104
|
+
expect(error).toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("an attachment slot that throws shows the generic card instead", async () => {
|
|
108
|
+
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
109
|
+
const client = make(dm({ messages: [{ id: "a", senderId: SAM, body: "with a form", createdAt: at(1), attachments: [{ kind: "form", name: "Check-in", ref: {} }] }] }));
|
|
110
|
+
const Boom = () => {
|
|
111
|
+
throw new Error("the app's renderer broke");
|
|
112
|
+
};
|
|
113
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} slots={{ attachment: Boom }} />);
|
|
114
|
+
await screen.findByText("with a form");
|
|
115
|
+
expect(screen.getByText("Check-in")).toBeTruthy();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("ChatErrorBoundary shows its fallback and reports the error", () => {
|
|
119
|
+
const onError = vi.fn();
|
|
120
|
+
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
121
|
+
const Boom = () => {
|
|
122
|
+
throw new Error("row broke");
|
|
123
|
+
};
|
|
124
|
+
render(
|
|
125
|
+
<ChatErrorBoundary fallback={<span>could not draw</span>} onError={onError}>
|
|
126
|
+
<Boom />
|
|
127
|
+
</ChatErrorBoundary>,
|
|
128
|
+
);
|
|
129
|
+
expect(screen.getByText("could not draw")).toBeTruthy();
|
|
130
|
+
expect(onError).toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("catch-up after a long time away", () => {
|
|
135
|
+
const seedMessages = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `m${i + 1}`, senderId: SAM, body: `message ${i + 1}`, createdAt: new Date(Date.UTC(2026, 0, 1, 0, 0, i)).toISOString() }));
|
|
136
|
+
const key = [...chatKeys.conversation("c1"), "messages", null, null] as const;
|
|
137
|
+
|
|
138
|
+
it("pages until it has everything", async () => {
|
|
139
|
+
const client = make(dm({ messages: seedMessages(450) }));
|
|
140
|
+
const first = (await client.messages("c1", { beforeSeq: 2, limit: 1 })).messages;
|
|
141
|
+
const { wrapper, queryClient } = providers(client);
|
|
142
|
+
queryClient.setQueryData(key, { messages: first, hasEarlier: false });
|
|
143
|
+
const { result } = renderHook(() => useConversation("c1", { markRead: false }), { wrapper });
|
|
144
|
+
await waitFor(() => expect(result.current.messages).toHaveLength(450));
|
|
145
|
+
expect(client.calls.filter((c) => c.method === "messages" && (c.args[1] as { afterSeq?: number })?.afterSeq != null)).toHaveLength(3);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it(`after ${CATCH_UP_MAX_PAGES} full pages it shows the newest page, with the rest behind "load earlier"`, async () => {
|
|
149
|
+
const total = CATCH_UP_PAGE * CATCH_UP_MAX_PAGES + 300;
|
|
150
|
+
const client = make(dm({ messages: seedMessages(total) }));
|
|
151
|
+
const first = (await client.messages("c1", { beforeSeq: 2, limit: 1 })).messages;
|
|
152
|
+
const { wrapper, queryClient } = providers(client);
|
|
153
|
+
queryClient.setQueryData(key, { messages: first, hasEarlier: false });
|
|
154
|
+
const { result } = renderHook(() => useConversation("c1", { markRead: false }), { wrapper });
|
|
155
|
+
await waitFor(() => expect(result.current.messages.at(-1)?.body).toBe(`message ${total}`));
|
|
156
|
+
await waitFor(() => expect(result.current.messages).toHaveLength(50));
|
|
157
|
+
expect(result.current.hasEarlier).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("read places", () => {
|
|
162
|
+
const cohort = make({
|
|
163
|
+
id: "c1",
|
|
164
|
+
members: [{ userId: ME }, { userId: SAM }, { userId: LEE }],
|
|
165
|
+
topics: [{ topic: "w1", title: "Week 1" }],
|
|
166
|
+
messages: [
|
|
167
|
+
{ id: "t1", senderId: SAM, body: "in week 1", topic: "w1", createdAt: at(1) },
|
|
168
|
+
{ id: "g1", senderId: LEE, body: "general, unseen", createdAt: at(2) },
|
|
169
|
+
{ id: "t2", senderId: SAM, body: "more week 1", topic: "w1", createdAt: at(3) },
|
|
170
|
+
],
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("a topic view doesn't move your read place (it would pass unseen messages in other topics)", async () => {
|
|
174
|
+
const { wrapper } = providers(cohort);
|
|
175
|
+
const { result } = renderHook(() => useConversation("c1", { topic: "w1" }), { wrapper });
|
|
176
|
+
await waitFor(() => expect(result.current.messages).toHaveLength(2));
|
|
177
|
+
await act(() => new Promise((r) => setTimeout(r, 50)));
|
|
178
|
+
const mine = async () => (await cohort.members("c1")).find((m) => m.userId === ME)!.lastReadSeq;
|
|
179
|
+
expect(await mine()).toBe(0);
|
|
180
|
+
// The whole timeline does.
|
|
181
|
+
renderHook(() => useConversation("c1"), { wrapper });
|
|
182
|
+
await waitFor(async () => expect(await mine()).toBe(3));
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("link previews", () => {
|
|
187
|
+
const preview = { url: "https://example.test/a", status: "ok" as const, title: "A post", description: null, imageUrl: "https://tracker.test/pixel.png", siteName: "Example", fetchedAt: "" };
|
|
188
|
+
|
|
189
|
+
it("show no image by default: the linked site never learns who read it", () => {
|
|
190
|
+
render(<ChatLinkPreview preview={preview} />);
|
|
191
|
+
expect(screen.getByTestId("genesis-chat-link-preview")).toBeTruthy();
|
|
192
|
+
expect(screen.queryByTestId("genesis-chat-link-preview-image")).toBeNull();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("load the image through the app's proxy when asked, and never without one", () => {
|
|
196
|
+
const { rerender } = render(<ChatLinkPreview preview={preview} images="proxy" proxyUrl={(u) => `/img-proxy?u=${encodeURIComponent(u)}`} />);
|
|
197
|
+
expect(screen.getByTestId("genesis-chat-link-preview-image").getAttribute("src")).toBe("/img-proxy?u=https%3A%2F%2Ftracker.test%2Fpixel.png");
|
|
198
|
+
rerender(<ChatLinkPreview preview={preview} images="proxy" />);
|
|
199
|
+
expect(screen.queryByTestId("genesis-chat-link-preview-image")).toBeNull();
|
|
200
|
+
rerender(<ChatLinkPreview preview={preview} images="direct" />);
|
|
201
|
+
expect(screen.getByTestId("genesis-chat-link-preview-image").getAttribute("src")).toBe(preview.imageUrl);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("ask once per message: a new message with a link doesn't refetch the others", async () => {
|
|
205
|
+
const client = make(dm({ messages: [{ id: "m1", senderId: SAM, body: "https://example.test/a", createdAt: at(1) }, { id: "m2", senderId: SAM, body: "https://example.test/b", createdAt: at(2) }] }), { linkPreviews: [preview] });
|
|
206
|
+
const { wrapper } = providers(client);
|
|
207
|
+
const { result, rerender } = renderHook(({ ids }) => useLinkPreviews(ids), { wrapper, initialProps: { ids: ["m1"] } });
|
|
208
|
+
await waitFor(() => expect(result.current.m1?.[0]?.title).toBe("A post"));
|
|
209
|
+
rerender({ ids: ["m1", "m2"] });
|
|
210
|
+
await waitFor(() => expect(result.current.m2).toBeDefined());
|
|
211
|
+
const asked = client.calls.filter((c) => c.method === "linkPreviewsFor").flatMap((c) => c.args[0] as string[]);
|
|
212
|
+
expect(asked.filter((id) => id === "m1")).toHaveLength(1);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("the emoji picker", () => {
|
|
217
|
+
it("fetches nothing from a CDN: without emojibaseUrl it isn't offered", async () => {
|
|
218
|
+
const fetchMock = vi.fn();
|
|
219
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
220
|
+
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
221
|
+
render(<ChatEmojiPicker onSelect={() => undefined} />);
|
|
222
|
+
expect(screen.getByTestId("genesis-chat-emoji-picker-unset")).toBeTruthy();
|
|
223
|
+
const client = make(dm());
|
|
224
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
|
|
225
|
+
await screen.findByTestId("genesis-chat-input");
|
|
226
|
+
expect(screen.queryByTestId("genesis-chat-emoji-button")).toBeNull();
|
|
227
|
+
expect(screen.queryByTestId("genesis-chat-more-reactions")).toBeNull();
|
|
228
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("is offered once the app hosts the data", async () => {
|
|
232
|
+
const client = make(dm());
|
|
233
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} emojibaseUrl="/emojibase" />);
|
|
234
|
+
expect(await screen.findByTestId("genesis-chat-emoji-button")).toBeTruthy();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("retrying a broadcast or a schedule", () => {
|
|
239
|
+
it("a broadcast sent again after a timeout keeps its clientId, and a double click sends once", async () => {
|
|
240
|
+
const client = createMemoryChatClient({ me: ME, conversations: [dm(), { id: "c2", members: [{ userId: ME }, { userId: LEE }] }] });
|
|
241
|
+
const real = client.broadcast.bind(client);
|
|
242
|
+
const ids: string[] = [];
|
|
243
|
+
let fail = true;
|
|
244
|
+
client.broadcast = vi.fn(async (convs: string[], clientId: string, body: string) => {
|
|
245
|
+
ids.push(clientId);
|
|
246
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
247
|
+
if (fail) {
|
|
248
|
+
fail = false;
|
|
249
|
+
throw new ChatError("timeout", undefined, "broadcast_message");
|
|
250
|
+
}
|
|
251
|
+
return real(convs, clientId, body);
|
|
252
|
+
});
|
|
253
|
+
mount(client, <ChatBroadcast resolveUser={resolveUser} />);
|
|
254
|
+
fireEvent.change(screen.getByLabelText("Message"), { target: { value: "Session moved" } });
|
|
255
|
+
await screen.findByText("Lee");
|
|
256
|
+
fireEvent.click(screen.getByRole("button", { name: "Select all" }));
|
|
257
|
+
const send = screen.getByTestId("genesis-chat-broadcast-send");
|
|
258
|
+
fireEvent.click(send);
|
|
259
|
+
fireEvent.click(send);
|
|
260
|
+
await waitFor(() => expect(ids).toHaveLength(1));
|
|
261
|
+
await act(() => new Promise((r) => setTimeout(r, 40)));
|
|
262
|
+
expect(ids).toHaveLength(1);
|
|
263
|
+
fireEvent.click(screen.getByTestId("genesis-chat-broadcast-send"));
|
|
264
|
+
await waitFor(() => expect(ids).toHaveLength(2));
|
|
265
|
+
expect(ids[1]).toBe(ids[0]);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("a schedule tried again after a failure keeps its clientId", async () => {
|
|
269
|
+
const client = make(dm());
|
|
270
|
+
const real = client.schedule.bind(client);
|
|
271
|
+
const ids: string[] = [];
|
|
272
|
+
let fail = true;
|
|
273
|
+
client.schedule = vi.fn(async (input) => {
|
|
274
|
+
ids.push(input.clientId);
|
|
275
|
+
if (fail) throw new ChatError("timeout", undefined, "schedule_message");
|
|
276
|
+
return real(input);
|
|
277
|
+
});
|
|
278
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
|
|
279
|
+
const box = (await screen.findByTestId("genesis-chat-input")) as HTMLTextAreaElement;
|
|
280
|
+
fireEvent.change(box, { target: { value: "Later" } });
|
|
281
|
+
openMenu(await screen.findByTestId("genesis-chat-send-menu"));
|
|
282
|
+
fireEvent.click((await screen.findAllByTestId("genesis-chat-schedule-pick"))[0]!);
|
|
283
|
+
await waitFor(() => expect(ids.length).toBeGreaterThan(0));
|
|
284
|
+
await waitFor(() => expect((screen.getByTestId("genesis-chat-send-menu") as HTMLButtonElement).disabled).toBe(false));
|
|
285
|
+
expect(box.value).toBe("Later");
|
|
286
|
+
fail = false;
|
|
287
|
+
const before = ids.length;
|
|
288
|
+
openMenu(screen.getByTestId("genesis-chat-send-menu"));
|
|
289
|
+
fireEvent.click((await screen.findAllByTestId("genesis-chat-schedule-pick"))[0]!);
|
|
290
|
+
await waitFor(() => expect(box.value).toBe(""));
|
|
291
|
+
expect(ids.length).toBeGreaterThan(before);
|
|
292
|
+
expect(new Set(ids).size).toBe(1);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("the custom time defaults to an hour from when the dialog opens", async () => {
|
|
296
|
+
vi.useFakeTimers({ toFake: ["Date"] });
|
|
297
|
+
const t0 = new Date(2026, 9, 26, 8, 0, 0);
|
|
298
|
+
vi.setSystemTime(t0);
|
|
299
|
+
const client = make(dm());
|
|
300
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
|
|
301
|
+
fireEvent.change(await screen.findByTestId("genesis-chat-input"), { target: { value: "Later" } });
|
|
302
|
+
// The menu (and its dialog) are drawn now, three hours before the dialog opens.
|
|
303
|
+
const menu = await screen.findByTestId("genesis-chat-send-menu");
|
|
304
|
+
vi.setSystemTime(new Date(2026, 9, 26, 11, 0, 0));
|
|
305
|
+
openMenu(menu);
|
|
306
|
+
fireEvent.click(await screen.findByTestId("genesis-chat-schedule-custom"));
|
|
307
|
+
const input = (await screen.findByTestId("genesis-chat-schedule-input")) as HTMLInputElement;
|
|
308
|
+
await waitFor(() => expect(input.value).toBe("2026-10-26T12:00"));
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe("the camera", () => {
|
|
313
|
+
const realMedia = Object.getOwnPropertyDescriptor(navigator, "mediaDevices");
|
|
314
|
+
afterEach(() => {
|
|
315
|
+
if (realMedia) Object.defineProperty(navigator, "mediaDevices", realMedia);
|
|
316
|
+
else delete (navigator as { mediaDevices?: unknown }).mediaDevices;
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
class FakeRecorder {
|
|
320
|
+
static isTypeSupported = (t: string) => t === "video/webm";
|
|
321
|
+
state: "inactive" | "recording" = "inactive";
|
|
322
|
+
mimeType = "video/webm";
|
|
323
|
+
ondataavailable: ((e: { data: Blob }) => void) | null = null;
|
|
324
|
+
onstop: (() => void) | null = null;
|
|
325
|
+
start() {
|
|
326
|
+
this.state = "recording";
|
|
327
|
+
}
|
|
328
|
+
stop() {
|
|
329
|
+
this.state = "inactive";
|
|
330
|
+
this.ondataavailable?.({ data: new Blob(["frames"], { type: "video/webm" }) });
|
|
331
|
+
this.onstop?.();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
async function recordTake() {
|
|
335
|
+
vi.stubGlobal("MediaRecorder", FakeRecorder);
|
|
336
|
+
Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: { getUserMedia: async () => ({ getTracks: () => [{ stop: () => undefined }] }) } });
|
|
337
|
+
fireEvent.click(await screen.findByTestId("genesis-chat-video-button"));
|
|
338
|
+
const record = await screen.findByTestId("genesis-chat-video-record");
|
|
339
|
+
await waitFor(() => expect((record as HTMLButtonElement).disabled).toBe(false));
|
|
340
|
+
await act(async () => {
|
|
341
|
+
fireEvent.click(record);
|
|
342
|
+
});
|
|
343
|
+
fireEvent.click(await screen.findByTestId("genesis-chat-video-stop"));
|
|
344
|
+
await screen.findByTestId("genesis-chat-video-retake");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
it("a hosted video keeps its place: text typed after it lands after it, while its bytes still go up", async () => {
|
|
348
|
+
const XHR = installFakeXHR();
|
|
349
|
+
const client = make(dm());
|
|
350
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />, { videoUpload: { create: async () => ({ url: "https://up.test/1", providerId: "u1", provider: "mux" }) } });
|
|
351
|
+
await recordTake();
|
|
352
|
+
await act(async () => {
|
|
353
|
+
fireEvent.click(screen.getByTestId("genesis-chat-video-send"));
|
|
354
|
+
});
|
|
355
|
+
// The dialog closes at once; the upload carries on.
|
|
356
|
+
await waitFor(() => expect(screen.queryByTestId("genesis-chat-video-recorder")).toBeNull());
|
|
357
|
+
fireEvent.change(await screen.findByTestId("genesis-chat-input"), { target: { value: "Watch this" } });
|
|
358
|
+
fireEvent.keyDown(screen.getByTestId("genesis-chat-input"), { key: "Enter" });
|
|
359
|
+
await waitFor(() => expect(client.calls.filter((c) => c.method === "send").length).toBe(2));
|
|
360
|
+
const sends = client.calls.filter((c) => c.method === "send").map((c) => c.args[0] as { body: string; attachments?: { provider?: string; status?: string }[] });
|
|
361
|
+
expect(sends[0]!.attachments?.[0]).toMatchObject({ provider: "mux", status: "pending" });
|
|
362
|
+
expect(sends[1]!.body).toBe("Watch this");
|
|
363
|
+
expect(XHR.all[0]!.status).toBe(0);
|
|
364
|
+
act(() => XHR.all[0]!.respond(200));
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("keeps the take's preview while it uploads, and frees it once the message lands", async () => {
|
|
368
|
+
const revoke = vi.fn();
|
|
369
|
+
vi.stubGlobal("URL", Object.assign(class extends URL {}, { createObjectURL: () => "blob:take", revokeObjectURL: revoke, canParse: URL.canParse }));
|
|
370
|
+
const client = make(dm());
|
|
371
|
+
let land!: () => void;
|
|
372
|
+
const upload = client.upload.bind(client);
|
|
373
|
+
client.upload = vi.fn(async (...args: Parameters<typeof upload>) => {
|
|
374
|
+
await new Promise<void>((r) => (land = r));
|
|
375
|
+
return upload(...args);
|
|
376
|
+
});
|
|
377
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
|
|
378
|
+
await recordTake();
|
|
379
|
+
await act(async () => {
|
|
380
|
+
fireEvent.click(screen.getByTestId("genesis-chat-video-send"));
|
|
381
|
+
});
|
|
382
|
+
await waitFor(() => expect(screen.queryByTestId("genesis-chat-video-recorder")).toBeNull());
|
|
383
|
+
expect(revoke).not.toHaveBeenCalledWith("blob:take");
|
|
384
|
+
await waitFor(() => expect(client.upload).toHaveBeenCalled());
|
|
385
|
+
land();
|
|
386
|
+
await waitFor(() => expect(client.calls.some((c) => c.method === "send")).toBe(true));
|
|
387
|
+
await waitFor(() => expect(revoke).toHaveBeenCalledWith("blob:take"));
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("turns off a camera that answers after the recorder closed", async () => {
|
|
391
|
+
vi.stubGlobal("MediaRecorder", class { static isTypeSupported = () => true; });
|
|
392
|
+
let answer!: (s: unknown) => void;
|
|
393
|
+
const stop = vi.fn();
|
|
394
|
+
Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: { getUserMedia: () => new Promise((r) => (answer = r)) } });
|
|
395
|
+
const { result, unmount } = renderHook(() => useVideoRecorder({ maxMs: 60_000 }));
|
|
396
|
+
let opened!: Promise<boolean>;
|
|
397
|
+
act(() => {
|
|
398
|
+
opened = result.current.open();
|
|
399
|
+
});
|
|
400
|
+
unmount();
|
|
401
|
+
answer({ getTracks: () => [{ stop }, { stop }] });
|
|
402
|
+
expect(await opened).toBe(false);
|
|
403
|
+
expect(stop).toHaveBeenCalledTimes(2);
|
|
404
|
+
});
|
|
405
|
+
});
|