@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,498 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// @feat:chat
|
|
3
|
+
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
4
|
+
import { forwardRef, useImperativeHandle, type ReactNode } from "react";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { ChatAttachment, ChatLinkPreview, ChatMessage } from "../../core/types";
|
|
7
|
+
import { ChatComposer } from "../composer";
|
|
8
|
+
import { ChatNativeProvider } from "../context";
|
|
9
|
+
import { ChatLinkPreviewCard, linkPreviewIds, openChatLink } from "../link-preview";
|
|
10
|
+
import { MessageBubble } from "../message-bubble";
|
|
11
|
+
import type { ChatNativeModules } from "../modules";
|
|
12
|
+
import { invertedRows, type ThreadRow } from "../rows";
|
|
13
|
+
import { ChatVideoPlayer, videoState } from "../video-player";
|
|
14
|
+
import { ChatVideoRecorder } from "../video-recorder";
|
|
15
|
+
import { claimPlayback, activePlaybackId, pauseChatPlayback, releasePlayback } from "../voice-player";
|
|
16
|
+
import { AppState } from "react-native";
|
|
17
|
+
import { inboxRow } from "../inbox-screen";
|
|
18
|
+
import { chatNativeCopy } from "../copy";
|
|
19
|
+
import { attachmentPreview, ChatAttachmentCard, isOpenableHref, messagePreview, splitCardAttachments, type ChatCardContext } from "../attachment-cards";
|
|
20
|
+
import { useChatComposer } from "../use-composer";
|
|
21
|
+
import { createVideoUploads } from "../video-upload";
|
|
22
|
+
import { VideoUploadTray } from "../video-upload-tray";
|
|
23
|
+
|
|
24
|
+
const linking = vi.hoisted(() => ({ openURL: vi.fn(async () => true), openSettings: vi.fn(async () => undefined) }));
|
|
25
|
+
vi.mock("react-native", async (original) => ({
|
|
26
|
+
...(await original<object>()),
|
|
27
|
+
Linking: linking,
|
|
28
|
+
useWindowDimensions: () => ({ width: 390, height: 844, scale: 3, fontScale: 1 }),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
cleanup();
|
|
33
|
+
vi.clearAllMocks();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const fireA11y = (el: HTMLElement, name: string) => act(() => void el.dispatchEvent(new CustomEvent("accessibilityaction", { detail: name })));
|
|
37
|
+
const ME = "11111111-1111-4111-8111-111111111111";
|
|
38
|
+
const SAM = "22222222-2222-4222-8222-222222222222";
|
|
39
|
+
|
|
40
|
+
// ── Fakes for expo-video and expo-camera ──────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
function fakeVideo() {
|
|
43
|
+
const listeners = new Map<string, (p: unknown) => void>();
|
|
44
|
+
const player = {
|
|
45
|
+
play: vi.fn(() => listeners.get("playingChange")?.({ isPlaying: true })),
|
|
46
|
+
pause: vi.fn(() => listeners.get("playingChange")?.({ isPlaying: false })),
|
|
47
|
+
muted: false,
|
|
48
|
+
playing: false,
|
|
49
|
+
playbackRate: 1,
|
|
50
|
+
currentTime: 0,
|
|
51
|
+
addListener: vi.fn((event: string, l: (p: unknown) => void) => {
|
|
52
|
+
listeners.set(event, l);
|
|
53
|
+
return { remove: () => listeners.delete(event) };
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
56
|
+
const enterFullscreen = vi.fn(async () => undefined);
|
|
57
|
+
const view: { props: { onFullscreenEnter?: () => void; onFullscreenExit?: () => void } | null } = { props: null };
|
|
58
|
+
const VideoView = forwardRef<unknown, { testID?: string; onFullscreenEnter?: () => void; onFullscreenExit?: () => void }>(function VideoView(props, ref) {
|
|
59
|
+
const { testID } = props;
|
|
60
|
+
view.props = props;
|
|
61
|
+
useImperativeHandle(ref, () => ({ enterFullscreen }));
|
|
62
|
+
return <div data-testid={testID} />;
|
|
63
|
+
});
|
|
64
|
+
const sources: unknown[] = [];
|
|
65
|
+
const video = {
|
|
66
|
+
useVideoPlayer: vi.fn((source: unknown, setup?: (p: typeof player) => void) => {
|
|
67
|
+
// Like the real hook: the player is made, and set up, once.
|
|
68
|
+
if (!sources.length) setup?.(player);
|
|
69
|
+
sources.push(source);
|
|
70
|
+
return player;
|
|
71
|
+
}),
|
|
72
|
+
VideoView,
|
|
73
|
+
};
|
|
74
|
+
return { video: video as unknown as ChatNativeModules["video"], player, enterFullscreen, sources, view };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function fakeCamera(granted = true) {
|
|
78
|
+
let resolve: ((v: { uri: string } | undefined) => void) | null = null;
|
|
79
|
+
const handle = {
|
|
80
|
+
recordAsync: vi.fn(() => new Promise<{ uri: string } | undefined>((r) => (resolve = r))),
|
|
81
|
+
stopRecording: vi.fn(() => resolve?.({ uri: "file:///cache/take.mov" })),
|
|
82
|
+
};
|
|
83
|
+
const CameraView = forwardRef<unknown, { testID?: string; onCameraReady?: () => void; facing?: string }>(function CameraView({ testID, onCameraReady, facing }, ref) {
|
|
84
|
+
useImperativeHandle(ref, () => handle);
|
|
85
|
+
// The real one says it's ready once the preview runs.
|
|
86
|
+
setTimeout(() => onCameraReady?.(), 0);
|
|
87
|
+
return <div data-testid={testID} data-facing={facing} />;
|
|
88
|
+
});
|
|
89
|
+
const camera = {
|
|
90
|
+
CameraView,
|
|
91
|
+
Camera: {
|
|
92
|
+
requestCameraPermissionsAsync: vi.fn(async () => ({ granted })),
|
|
93
|
+
requestMicrophonePermissionsAsync: vi.fn(async () => ({ granted: true })),
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
return { camera: camera as unknown as ChatNativeModules["camera"], handle };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const withModules = (modules: ChatNativeModules, children: ReactNode) => <ChatNativeProvider modules={modules}>{children}</ChatNativeProvider>;
|
|
100
|
+
|
|
101
|
+
// ── Player ──────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
describe("ChatVideoPlayer", () => {
|
|
104
|
+
it("says Processing until Mux has a playback id, and says so when it failed", () => {
|
|
105
|
+
expect(videoState({ uri: null, playbackId: null, provider: "mux", status: "pending" })).toBe("processing");
|
|
106
|
+
expect(videoState({ uri: null, playbackId: "abc", provider: "mux", status: "ready" })).toBe("playable");
|
|
107
|
+
expect(videoState({ uri: null, playbackId: null, provider: "mux", status: "failed" })).toBe("failed");
|
|
108
|
+
expect(videoState({ uri: null, playbackId: null, provider: "storage", status: "ready" })).toBe("loading");
|
|
109
|
+
const { video } = fakeVideo();
|
|
110
|
+
render(withModules({ video }, <ChatVideoPlayer id="a1" uri={null} provider="mux" status="pending" durationMs={4000} testID="v" />));
|
|
111
|
+
expect(screen.getByTestId("v-processing").getAttribute("aria-label")).toBe("Processing video");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("shows Mux's thumbnail and duration, and streams HLS from the playback id on tap", () => {
|
|
115
|
+
const { video, player, sources } = fakeVideo();
|
|
116
|
+
render(withModules({ video }, <ChatVideoPlayer id="a1" uri={null} playbackId="PB1" provider="mux" status="ready" durationMs={42_000} testID="v" />));
|
|
117
|
+
const poster = screen.getByTestId("v-poster");
|
|
118
|
+
expect(poster.getAttribute("aria-label")).toBe("Video, 0:42");
|
|
119
|
+
expect(video!.useVideoPlayer).not.toHaveBeenCalled();
|
|
120
|
+
fireEvent.click(poster);
|
|
121
|
+
expect(sources[0]).toEqual({ uri: "https://stream.mux.com/PB1.m3u8", contentType: "hls" });
|
|
122
|
+
expect(player.play).toHaveBeenCalled();
|
|
123
|
+
expect(screen.getByTestId("v-active")).toBeTruthy();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("pauses, mutes, changes speed and goes full screen", () => {
|
|
127
|
+
const { video, player, enterFullscreen } = fakeVideo();
|
|
128
|
+
render(withModules({ video }, <ChatVideoPlayer id="a2" uri="https://x/v.mp4" provider="storage" status="ready" durationMs={3000} testID="v" />));
|
|
129
|
+
fireEvent.click(screen.getByTestId("v-poster"));
|
|
130
|
+
fireEvent.click(screen.getByTestId("v-play"));
|
|
131
|
+
expect(player.pause).toHaveBeenCalled();
|
|
132
|
+
expect(screen.getByTestId("v-play").getAttribute("aria-label")).toBe("Play video");
|
|
133
|
+
fireEvent.click(screen.getByTestId("v-mute"));
|
|
134
|
+
expect(player.muted).toBe(true);
|
|
135
|
+
expect(screen.getByTestId("v-mute").getAttribute("aria-label")).toBe("Unmute");
|
|
136
|
+
fireEvent.click(screen.getByTestId("v-rate"));
|
|
137
|
+
expect(player.playbackRate).toBe(1.5);
|
|
138
|
+
fireEvent.click(screen.getByTestId("v-fullscreen"));
|
|
139
|
+
expect(enterFullscreen).toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("plays one at a time with voice notes: a voice note starting pauses it, and it pauses a voice note", () => {
|
|
143
|
+
const { video, player } = fakeVideo();
|
|
144
|
+
const voicePause = vi.fn();
|
|
145
|
+
claimPlayback("voice-1", voicePause);
|
|
146
|
+
render(withModules({ video }, <ChatVideoPlayer id="a3" uri="https://x/v.mp4" provider="storage" status="ready" durationMs={3000} testID="v" />));
|
|
147
|
+
fireEvent.click(screen.getByTestId("v-poster"));
|
|
148
|
+
expect(voicePause).toHaveBeenCalled();
|
|
149
|
+
expect(activePlaybackId()).toBe("a3");
|
|
150
|
+
act(() => claimPlayback("voice-2", vi.fn()));
|
|
151
|
+
expect(player.pause).toHaveBeenCalled();
|
|
152
|
+
releasePlayback("voice-2");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("says a stream that won't load can't play, instead of a black box", () => {
|
|
156
|
+
const { video, player } = fakeVideo();
|
|
157
|
+
render(withModules({ video }, <ChatVideoPlayer id="a4" uri={null} playbackId="GONE" provider="mux" status="ready" durationMs={3000} testID="v" />));
|
|
158
|
+
fireEvent.click(screen.getByTestId("v-poster"));
|
|
159
|
+
const status = player.addListener.mock.calls.find((c) => c[0] === "statusChange")![1];
|
|
160
|
+
act(() => status({ status: "error" }));
|
|
161
|
+
expect(screen.getByTestId("v-broken").getAttribute("aria-label")).toBe("This video can't play right now.");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("pauses when the app goes to the background, and when it unmounts", () => {
|
|
165
|
+
const { video, player } = fakeVideo();
|
|
166
|
+
const view = render(withModules({ video }, <ChatVideoPlayer id="a5" uri="https://x/v.mp4" provider="storage" status="ready" durationMs={3000} testID="v" />));
|
|
167
|
+
fireEvent.click(screen.getByTestId("v-poster"));
|
|
168
|
+
expect(player.pause).not.toHaveBeenCalled();
|
|
169
|
+
act(() => (AppState as unknown as { __emit: (s: string) => void }).__emit("inactive"));
|
|
170
|
+
expect(player.pause).not.toHaveBeenCalled(); // Control Center, a system alert: it plays on
|
|
171
|
+
act(() => (AppState as unknown as { __emit: (s: string) => void }).__emit("background"));
|
|
172
|
+
expect(player.pause).toHaveBeenCalledTimes(1);
|
|
173
|
+
act(() => (AppState as unknown as { __emit: (s: string) => void }).__emit("active"));
|
|
174
|
+
view.unmount();
|
|
175
|
+
expect(player.pause).toHaveBeenCalledTimes(2);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("plays on in full screen, which Android reports as the app going to the background", () => {
|
|
179
|
+
const { video, player, view } = fakeVideo();
|
|
180
|
+
render(withModules({ video }, <ChatVideoPlayer id="a7" uri="https://x/v.mp4" provider="storage" status="ready" durationMs={3000} testID="v" />));
|
|
181
|
+
fireEvent.click(screen.getByTestId("v-poster"));
|
|
182
|
+
act(() => view.props?.onFullscreenEnter?.());
|
|
183
|
+
act(() => (AppState as unknown as { __emit: (s: string) => void }).__emit("background"));
|
|
184
|
+
expect(player.pause).not.toHaveBeenCalled();
|
|
185
|
+
act(() => view.props?.onFullscreenExit?.());
|
|
186
|
+
act(() => (AppState as unknown as { __emit: (s: string) => void }).__emit("background"));
|
|
187
|
+
expect(player.pause).toHaveBeenCalledTimes(1);
|
|
188
|
+
act(() => (AppState as unknown as { __emit: (s: string) => void }).__emit("active"));
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("is paused by pauseChatPlayback, which leaves another screen's alone", () => {
|
|
192
|
+
const { video, player } = fakeVideo();
|
|
193
|
+
render(withModules({ video }, <ChatVideoPlayer id="a6" uri="https://x/v.mp4" provider="storage" status="ready" durationMs={3000} testID="v" />));
|
|
194
|
+
fireEvent.click(screen.getByTestId("v-poster"));
|
|
195
|
+
act(() => void pauseChatPlayback(["other"]));
|
|
196
|
+
expect(player.pause).not.toHaveBeenCalled();
|
|
197
|
+
act(() => void pauseChatPlayback((id) => id === "a6"));
|
|
198
|
+
expect(player.pause).toHaveBeenCalledTimes(1);
|
|
199
|
+
expect(screen.getByTestId("v-play").getAttribute("aria-label")).toBe("Play video");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("renders nothing without expo-video (the bubble keeps its row)", () => {
|
|
203
|
+
const { container } = render(<ChatVideoPlayer id="a" uri="https://x" durationMs={1} />);
|
|
204
|
+
expect(container.innerHTML).toBe("");
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ── Recorder ────────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
describe("ChatVideoRecorder", () => {
|
|
211
|
+
it("asks for the camera and microphone, and says how to turn them on when refused", async () => {
|
|
212
|
+
const { camera } = fakeCamera(false);
|
|
213
|
+
render(withModules({ camera }, <ChatVideoRecorder open onClose={vi.fn()} limitMs={120_000} onRecorded={vi.fn()} testID="r" />));
|
|
214
|
+
await waitFor(() => expect(screen.getByTestId("r-denied")).toBeTruthy());
|
|
215
|
+
expect(screen.getByTestId("r-denied").textContent).toContain("Turn on the camera and microphone");
|
|
216
|
+
fireEvent.click(screen.getByTestId("r-settings"));
|
|
217
|
+
expect(linking.openSettings).toHaveBeenCalled();
|
|
218
|
+
expect(screen.queryByTestId("r-record")).toBeNull();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("records hands-free with a screen reader's double taps, mints the upload at the start, and sends on stop", async () => {
|
|
222
|
+
const { camera, handle } = fakeCamera();
|
|
223
|
+
const onRecorded = vi.fn();
|
|
224
|
+
const onStart = vi.fn();
|
|
225
|
+
const onClose = vi.fn();
|
|
226
|
+
let now = 1_000;
|
|
227
|
+
render(withModules({ camera }, <ChatVideoRecorder open onClose={onClose} limitMs={120_000} onStart={onStart} onRecorded={onRecorded} now={() => now} testID="r" />));
|
|
228
|
+
await waitFor(() => expect(screen.getByTestId("r-record").getAttribute("aria-disabled")).toBe("false"));
|
|
229
|
+
const record = screen.getByTestId("r-record");
|
|
230
|
+
expect(record.getAttribute("aria-label")).toBe("Start recording");
|
|
231
|
+
fireA11y(record, "activate");
|
|
232
|
+
expect(handle.recordAsync).toHaveBeenCalledWith({ maxDuration: 120 });
|
|
233
|
+
expect(onStart).toHaveBeenCalled();
|
|
234
|
+
expect(screen.getByTestId("r-record").getAttribute("aria-label")).toBe("Stop and send");
|
|
235
|
+
expect(screen.getByTestId("r-flip").getAttribute("aria-disabled")).toBe("true");
|
|
236
|
+
now = 6_000;
|
|
237
|
+
fireA11y(screen.getByTestId("r-record"), "activate");
|
|
238
|
+
expect(handle.stopRecording).toHaveBeenCalled();
|
|
239
|
+
await waitFor(() => expect(onRecorded).toHaveBeenCalled());
|
|
240
|
+
expect(onRecorded).toHaveBeenCalledWith({ uri: "file:///cache/take.mov", durationMs: 5000, contentType: "video/quicktime", name: "video-6000.mov" });
|
|
241
|
+
expect(onClose).toHaveBeenCalled();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("flips the camera between takes, and Close mid-take throws it away", async () => {
|
|
245
|
+
const { camera, handle } = fakeCamera();
|
|
246
|
+
const onRecorded = vi.fn();
|
|
247
|
+
const onClose = vi.fn();
|
|
248
|
+
render(withModules({ camera }, <ChatVideoRecorder open onClose={onClose} limitMs={60_000} onRecorded={onRecorded} testID="r" />));
|
|
249
|
+
await waitFor(() => expect(screen.getByTestId("r-record").getAttribute("aria-disabled")).toBe("false"));
|
|
250
|
+
expect(screen.getByTestId("r-camera").getAttribute("data-facing")).toBe("front");
|
|
251
|
+
fireEvent.click(screen.getByTestId("r-flip"));
|
|
252
|
+
expect(screen.getByTestId("r-camera").getAttribute("data-facing")).toBe("back");
|
|
253
|
+
// The other camera starts before it records.
|
|
254
|
+
await waitFor(() => expect(screen.getByTestId("r-record").getAttribute("aria-disabled")).toBe("false"));
|
|
255
|
+
fireA11y(screen.getByTestId("r-record"), "activate");
|
|
256
|
+
fireEvent.click(screen.getByTestId("r-close"));
|
|
257
|
+
expect(handle.stopRecording).toHaveBeenCalled();
|
|
258
|
+
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
|
259
|
+
expect(onRecorded).not.toHaveBeenCalled();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("is absent without expo-camera", () => {
|
|
263
|
+
const { container } = render(<ChatVideoRecorder open onClose={vi.fn()} limitMs={1} onRecorded={vi.fn()} />);
|
|
264
|
+
expect(container.innerHTML).toBe("");
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ── Link previews ───────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
const preview: ChatLinkPreview = { url: "https://example.com/post", status: "ok", title: "A post", description: "About it", imageUrl: "https://example.com/og.png", siteName: "Example", fetchedAt: "2026-10-25T10:00:00Z" };
|
|
271
|
+
|
|
272
|
+
describe("link previews", () => {
|
|
273
|
+
it("draw no image by default, so the phone never fetches og:image", () => {
|
|
274
|
+
render(withModules({}, <ChatLinkPreviewCard preview={preview} testID="p" />));
|
|
275
|
+
expect(screen.queryByTestId("p-image")).toBeNull();
|
|
276
|
+
expect(screen.getByTestId("p").getAttribute("aria-label")).toBe("A post, Example");
|
|
277
|
+
expect(screen.getByTestId("p").getAttribute("role")).toBe("link");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("draw the image only when the app turns linkPreviewImages on", () => {
|
|
281
|
+
render(withModules({}, <ChatLinkPreviewCard preview={preview} images testID="p" />));
|
|
282
|
+
expect(screen.getByTestId("p-image")).toBeTruthy();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("open in the in-app browser when there is one, else the system's, https only", async () => {
|
|
286
|
+
const webBrowser = { openBrowserAsync: vi.fn(async () => undefined) };
|
|
287
|
+
render(withModules({ webBrowser }, <ChatLinkPreviewCard preview={preview} testID="p" />));
|
|
288
|
+
fireEvent.click(screen.getByTestId("p"));
|
|
289
|
+
expect(webBrowser.openBrowserAsync).toHaveBeenCalledWith("https://example.com/post");
|
|
290
|
+
expect(await openChatLink("https://example.com")).toBe(true);
|
|
291
|
+
expect(linking.openURL).toHaveBeenCalledWith("https://example.com");
|
|
292
|
+
expect(await openChatLink("javascript:alert(1)")).toBe(false);
|
|
293
|
+
expect(await openChatLink("http://example.com")).toBe(false);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("aren't asked for on deleted or hidden messages, or ones without a link", () => {
|
|
297
|
+
const base = { body: "see https://example.com/a", deletedAt: null, moderation: null };
|
|
298
|
+
const ids = linkPreviewIds([
|
|
299
|
+
{ ...base, id: "a" },
|
|
300
|
+
{ ...base, id: "b", moderation: "hidden" },
|
|
301
|
+
{ ...base, id: "c", deletedAt: "2026-10-25T10:00:00Z" },
|
|
302
|
+
{ ...base, id: "d", body: "no link" },
|
|
303
|
+
] as unknown as ChatMessage[]);
|
|
304
|
+
expect(ids).toEqual(["a"]);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ── In the bubble ───────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
function attachment(over: Partial<ChatAttachment>): ChatAttachment {
|
|
311
|
+
return {
|
|
312
|
+
id: "att1", messageId: "m1", position: 0, kind: "video", ref: {}, provider: "mux", path: null, providerId: "upl", playbackId: null, url: null, status: "pending",
|
|
313
|
+
name: "video.mov", mime: "video/quicktime", bytes: 10, width: null, height: null, durationMs: 5000, waveform: null, posterPath: null, thumbhash: null, transcript: null, transcriptStatus: null,
|
|
314
|
+
...over,
|
|
315
|
+
} as ChatAttachment;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function row(over: Partial<ChatMessage>): Extract<ThreadRow, { type: "message" }> {
|
|
319
|
+
const message = {
|
|
320
|
+
id: "m1", conversationId: "conv", seq: 1, clientId: "cm1", senderId: SAM, senderRole: "member", kind: "text", body: "", mentions: [], replyToId: null,
|
|
321
|
+
threadRootId: null, threadCount: 0, threadLastAt: null, topic: null, visibility: "everyone", silent: false, meta: {}, createdAt: "2026-10-25T10:00:00Z",
|
|
322
|
+
editedAt: null, deletedAt: null, attachments: [], reactions: [], moderation: null, ...over,
|
|
323
|
+
} as unknown as ChatMessage;
|
|
324
|
+
return invertedRows([{ type: "message", message }], { me: ME, group: false }).find((r) => r.type === "message") as Extract<ThreadRow, { type: "message" }>;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
describe("the bubble", () => {
|
|
328
|
+
it("draws a video with its player, and carries play and full screen as VoiceOver actions", () => {
|
|
329
|
+
const { video, player, enterFullscreen } = fakeVideo();
|
|
330
|
+
render(withModules({ video }, <MessageBubble row={row({ attachments: [attachment({ playbackId: "PB", status: "ready" })] })} me={ME} urls={{}} group={false} reducedMotion testID="b" />));
|
|
331
|
+
const bubble = screen.getByTestId("b-bubble");
|
|
332
|
+
expect(bubble.getAttribute("aria-label")).toContain("Video");
|
|
333
|
+
expect(bubble.getAttribute("data-a11y-actions")).toBe("video-0,video-full-0");
|
|
334
|
+
fireA11y(bubble, "video-0");
|
|
335
|
+
expect(player.play).toHaveBeenCalled();
|
|
336
|
+
fireA11y(bubble, "video-full-0");
|
|
337
|
+
expect(enterFullscreen).toHaveBeenCalled();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("shows a link's preview under the text, and none on a hidden message", () => {
|
|
341
|
+
const r = row({ body: "look https://example.com/post" });
|
|
342
|
+
render(withModules({}, <MessageBubble row={r} me={ME} urls={{}} group={false} reducedMotion linkPreview={preview} testID="b" />));
|
|
343
|
+
expect(screen.getByTestId("b-preview")).toBeTruthy();
|
|
344
|
+
expect(screen.getByTestId("b-bubble").getAttribute("data-a11y-actions")).toContain("open-link");
|
|
345
|
+
cleanup();
|
|
346
|
+
render(withModules({}, <MessageBubble row={row({ body: "look https://example.com/post", moderation: "hidden" })} me={ME} urls={{}} group={false} reducedMotion linkPreview={preview} testID="b" />));
|
|
347
|
+
expect(screen.queryByTestId("b-preview")).toBeNull();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("draws the app's attachment kinds as cards whose button the app handles", () => {
|
|
351
|
+
const onOpenAttachment = vi.fn();
|
|
352
|
+
const form = attachment({ id: "f1", kind: "form", provider: "none", providerId: null, status: "ready", ref: { formId: "intake" } });
|
|
353
|
+
const kinds = { form: { title: () => "Intake form", subtitle: () => "5 questions", action: () => ({ label: "Fill it in" }) } };
|
|
354
|
+
render(withModules({}, <MessageBubble row={row({ attachments: [form] })} me={ME} urls={{}} group={false} reducedMotion attachmentKinds={kinds} onOpenAttachment={onOpenAttachment} testID="b" />));
|
|
355
|
+
expect(screen.getByTestId("b-bubble").getAttribute("aria-label")).toContain("Intake form");
|
|
356
|
+
expect(screen.queryByTestId("b-file-0")).toBeNull();
|
|
357
|
+
fireEvent.click(screen.getByTestId("b-card-0-action"));
|
|
358
|
+
expect(onOpenAttachment).toHaveBeenCalledWith(form, expect.objectContaining({ id: "m1" }));
|
|
359
|
+
// The accessible bubble hides the button from VoiceOver, so it carries it as an action.
|
|
360
|
+
const bubble = screen.getByTestId("b-bubble");
|
|
361
|
+
expect(bubble.getAttribute("data-a11y-actions")).toContain("card-0");
|
|
362
|
+
fireA11y(bubble, "card-0");
|
|
363
|
+
expect(onOpenAttachment).toHaveBeenCalledTimes(2);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("tells a kind the card is the sender's own, so it can drop its button there", () => {
|
|
367
|
+
const checkIn = attachment({ id: "c1", kind: "check_in", provider: "none", providerId: null, status: "ready", ref: {} });
|
|
368
|
+
const action = vi.fn((_a: ChatAttachment, ctx?: ChatCardContext) => (ctx?.mine ? null : { label: "Answer" }));
|
|
369
|
+
const subtitle = vi.fn((_a: ChatAttachment, ctx?: ChatCardContext) => (ctx?.mine ? "Sent" : null));
|
|
370
|
+
const kinds = { check_in: { title: () => "Check-in", subtitle, action } };
|
|
371
|
+
const theirs = render(withModules({}, <MessageBubble row={row({ attachments: [checkIn] })} me={ME} urls={{}} group={false} reducedMotion attachmentKinds={kinds} onOpenAttachment={vi.fn()} testID="b" />));
|
|
372
|
+
expect(screen.getByTestId("b-card-0-action").textContent).toBe("Answer");
|
|
373
|
+
theirs.unmount();
|
|
374
|
+
render(withModules({}, <MessageBubble row={row({ senderId: ME, attachments: [checkIn] })} me={ME} urls={{}} group={false} reducedMotion attachmentKinds={kinds} onOpenAttachment={vi.fn()} testID="b" />));
|
|
375
|
+
expect(screen.queryByTestId("b-card-0-action")).toBeNull();
|
|
376
|
+
expect(screen.getByTestId("b-card-0").textContent).toContain("Sent");
|
|
377
|
+
expect(screen.getByTestId("b-bubble").getAttribute("data-a11y-actions") ?? "").not.toContain("card-0");
|
|
378
|
+
expect(action).toHaveBeenLastCalledWith(checkIn, { mine: true });
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("previews an app kind by its title, not as Attachment (inbox and the long-press sheet)", () => {
|
|
382
|
+
const kinds = { check_in: { title: () => "Check-in" } };
|
|
383
|
+
expect(attachmentPreview("", "check_in", kinds)).toBe("Check-in");
|
|
384
|
+
expect(attachmentPreview("", "check_in", undefined)).toBe("Attachment");
|
|
385
|
+
expect(attachmentPreview("how did it go?", "check_in", kinds)).toBe("how did it go?");
|
|
386
|
+
expect(attachmentPreview("**", "check_in", kinds)).toBe("Check-in"); // only markdown marks: no text
|
|
387
|
+
expect(attachmentPreview("", "check_in", kinds, true)).toBe("Message deleted");
|
|
388
|
+
expect(attachmentPreview("", "check_in", { check_in: { title: (a) => `Check-in ${String(a.ref.week)}`, label: "Check-in" } })).toBe("Check-in");
|
|
389
|
+
expect(attachmentPreview("", "check_in", { check_in: { title: () => { throw new Error("x"); } } })).toBe("Attachment");
|
|
390
|
+
const m = row({ attachments: [attachment({ id: "c1", kind: "check_in", provider: "none", ref: {} })] }).item;
|
|
391
|
+
expect(messagePreview((m as { message: ChatMessage }).message, kinds)).toBe("Check-in");
|
|
392
|
+
const inbox = inboxRow(
|
|
393
|
+
{ conversationId: "c", kind: "direct", title: "Sam", otherUserId: null, unread: 0, mentioned: false, lastMessageAt: null, lastMessage: { id: "m", seq: 1, senderId: SAM, kind: "text", body: "", deleted: false, createdAt: "", attachment: "check_in" } } as never,
|
|
394
|
+
{ me: ME, copy: chatNativeCopy, person: () => undefined, kinds },
|
|
395
|
+
);
|
|
396
|
+
expect(inbox.preview).toBe("Check-in");
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("shows a hosted video still on its way as Processing, not an empty bubble", () => {
|
|
400
|
+
const pending = {
|
|
401
|
+
clientId: "c9", senderId: ME, createdAt: "2026-10-25T10:00:00Z", status: "sending" as const, files: [],
|
|
402
|
+
input: { conversationId: "conv", clientId: "c9", body: "", attachments: [{ kind: "video", provider: "mux" as const, providerId: "upl", status: "pending" as const, durationMs: 5000 }] },
|
|
403
|
+
};
|
|
404
|
+
const r = invertedRows([{ type: "pending", pending }], { me: ME, group: false }).find((x) => x.type === "message") as Extract<ThreadRow, { type: "message" }>;
|
|
405
|
+
const { video } = fakeVideo();
|
|
406
|
+
render(withModules({ video }, <MessageBubble row={r} me={ME} urls={{}} group={false} reducedMotion testID="b" />));
|
|
407
|
+
expect(screen.getByTestId("b-video-0-processing")).toBeTruthy();
|
|
408
|
+
expect(screen.getByTestId("b-bubble").getAttribute("aria-label")).toContain("Video");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("offers mute and speed to VoiceOver while its video plays", () => {
|
|
412
|
+
const { video, player } = fakeVideo();
|
|
413
|
+
render(withModules({ video }, <MessageBubble row={row({ attachments: [attachment({ id: "vv", playbackId: "PB", status: "ready" })] })} me={ME} urls={{}} group={false} reducedMotion testID="b" />));
|
|
414
|
+
const bubble = () => screen.getByTestId("b-bubble");
|
|
415
|
+
fireA11y(bubble(), "video-0");
|
|
416
|
+
expect(bubble().getAttribute("data-a11y-actions")).toBe("video-0,video-mute-0,video-rate-0,video-full-0");
|
|
417
|
+
fireA11y(bubble(), "video-mute-0");
|
|
418
|
+
expect(player.muted).toBe(true);
|
|
419
|
+
fireA11y(bubble(), "video-rate-0");
|
|
420
|
+
expect(player.playbackRate).toBe(1.5);
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
describe("attachment cards", () => {
|
|
425
|
+
const checkIn = attachment({ id: "c1", kind: "check_in", provider: "none", status: "ready" });
|
|
426
|
+
const msg = row({}).item.type === "message" ? (row({}).item as { message: ChatMessage }).message : (null as never);
|
|
427
|
+
|
|
428
|
+
it("run the kind's own onPress before the app's handler, and the href last", () => {
|
|
429
|
+
const own = vi.fn();
|
|
430
|
+
const app = vi.fn();
|
|
431
|
+
render(withModules({}, <ChatAttachmentCard attachment={checkIn} message={msg} kind={{ title: () => "Weekly check-in", action: () => ({ label: "Answer", onPress: own }) }} onOpenAttachment={app} testID="k" />));
|
|
432
|
+
fireEvent.click(screen.getByTestId("k-action"));
|
|
433
|
+
expect(own).toHaveBeenCalled();
|
|
434
|
+
expect(app).not.toHaveBeenCalled();
|
|
435
|
+
cleanup();
|
|
436
|
+
render(withModules({}, <ChatAttachmentCard attachment={checkIn} message={msg} kind={{ title: () => "Weekly check-in", action: () => ({ label: "Answer", href: "https://forms.example.com/1" }) }} testID="k" />));
|
|
437
|
+
fireEvent.click(screen.getByTestId("k-action"));
|
|
438
|
+
expect(linking.openURL).toHaveBeenCalledWith("https://forms.example.com/1");
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("open only https by themselves (other links go through the app), and have no button without an action", () => {
|
|
442
|
+
expect(isOpenableHref("javascript:alert(1)")).toBe(false);
|
|
443
|
+
expect(isOpenableHref("http://x.test")).toBe(false);
|
|
444
|
+
expect(isOpenableHref("tel:+15555550100")).toBe(false);
|
|
445
|
+
expect(isOpenableHref("myapp://form/1")).toBe(false);
|
|
446
|
+
expect(isOpenableHref("https://x.test")).toBe(true);
|
|
447
|
+
render(withModules({}, <ChatAttachmentCard attachment={checkIn} message={msg} kind={{ title: () => "T", action: () => ({ label: "Go", href: "javascript:alert(1)" }) }} testID="k" />));
|
|
448
|
+
expect(screen.queryByTestId("k-action")).toBeNull();
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("split from the message's other attachments by registered kind", () => {
|
|
452
|
+
const { cards, rest } = splitCardAttachments([{ kind: "form" }, { kind: "image" }], { form: { title: () => "" } });
|
|
453
|
+
expect(cards).toEqual([{ kind: "form" }]);
|
|
454
|
+
expect(rest).toEqual([{ kind: "image" }]);
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// ── Composer ────────────────────────────────────────────────────────────────
|
|
459
|
+
|
|
460
|
+
function Composer({ onRecordVideo }: { onRecordVideo?: () => void }) {
|
|
461
|
+
const composer = useChatComposer("conv", null, ME);
|
|
462
|
+
return <ChatComposer composer={composer} canPost disabledReason={null} role="member" onSend={vi.fn()} onRecordVideo={onRecordVideo} testID="c" />;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
describe("the composer's video button", () => {
|
|
466
|
+
it("opens the recorder, and is absent without one", () => {
|
|
467
|
+
const onRecordVideo = vi.fn();
|
|
468
|
+
render(withModules({}, <Composer onRecordVideo={onRecordVideo} />));
|
|
469
|
+
const button = screen.getByTestId("c-video");
|
|
470
|
+
expect(button.getAttribute("aria-label")).toBe("Record a video");
|
|
471
|
+
fireEvent.click(button);
|
|
472
|
+
expect(onRecordVideo).toHaveBeenCalled();
|
|
473
|
+
cleanup();
|
|
474
|
+
render(withModules({}, <Composer />));
|
|
475
|
+
expect(screen.queryByTestId("c-video")).toBeNull();
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
describe("the upload tray", () => {
|
|
480
|
+
it("shows progress, then a failure in words with Retry and Cancel, for the signed-in person only", async () => {
|
|
481
|
+
const uploads = createVideoUploads(() => "t1");
|
|
482
|
+
const fetch = vi.fn(async (url: string) => {
|
|
483
|
+
if (url.startsWith("file:")) return new Response(new Uint8Array(10));
|
|
484
|
+
throw new TypeError("Network request failed");
|
|
485
|
+
}) as unknown as typeof globalThis.fetch;
|
|
486
|
+
const video = { uri: "file:///a.mov", name: "a.mov", contentType: "video/quicktime", durationMs: 3000 };
|
|
487
|
+
render(withModules({}, <VideoUploadTray conversationId="c" userId={ME} uploads={uploads} testID="t" />));
|
|
488
|
+
act(() => void uploads.start({ conversationId: "c", userId: ME, video, send: vi.fn(), videoUpload: { create: async () => ({ url: "https://up", providerId: "p", provider: "mux" }) }, fetch, sleep: async () => undefined }));
|
|
489
|
+
await waitFor(() => expect(screen.getByTestId("t-t1-text").textContent).toBe("The video didn't send. Check your connection and retry."));
|
|
490
|
+
expect(screen.getByTestId("t-t1-retry")).toBeTruthy();
|
|
491
|
+
fireEvent.click(screen.getByTestId("t-t1-cancel"));
|
|
492
|
+
expect(screen.queryByTestId("t")).toBeNull();
|
|
493
|
+
cleanup();
|
|
494
|
+
act(() => void uploads.start({ conversationId: "c", userId: SAM, video, send: vi.fn(), fetch }));
|
|
495
|
+
render(withModules({}, <VideoUploadTray conversationId="c" userId={ME} uploads={uploads} testID="t" />));
|
|
496
|
+
expect(screen.queryByTestId("t")).toBeNull();
|
|
497
|
+
});
|
|
498
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// @feat:chat
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { initialVideoRecorder, recordingType, videoRecorderStep, type VideoRecorderEvent, type VideoRecorderState } from "../video-recorder-state";
|
|
4
|
+
|
|
5
|
+
const config = { limitMs: 120_000 };
|
|
6
|
+
function run(events: VideoRecorderEvent[], from: VideoRecorderState = initialVideoRecorder()) {
|
|
7
|
+
let state = from;
|
|
8
|
+
const effects: string[] = [];
|
|
9
|
+
for (const e of events) {
|
|
10
|
+
const step = videoRecorderStep(state, e, config);
|
|
11
|
+
state = step.state;
|
|
12
|
+
effects.push(...step.effects.map((x) => (x.type === "haptic" ? `haptic:${x.event}` : x.type === "send" ? `send:${x.durationMs}` : x.type)));
|
|
13
|
+
}
|
|
14
|
+
return { state, effects };
|
|
15
|
+
}
|
|
16
|
+
const ready = run([{ type: "permissions", granted: true }, { type: "camera-ready" }]).state;
|
|
17
|
+
|
|
18
|
+
describe("the video recorder", () => {
|
|
19
|
+
it("waits for both permissions and the camera before it records", () => {
|
|
20
|
+
expect(run([{ type: "permissions", granted: false }]).state.phase).toBe("denied");
|
|
21
|
+
expect(run([{ type: "permissions", granted: true }]).state.phase).toBe("starting");
|
|
22
|
+
expect(run([{ type: "permissions", granted: true }, { type: "press", at: 0 }]).effects).toEqual([]);
|
|
23
|
+
expect(ready.phase).toBe("ready");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("hold: records while held and sends on release", () => {
|
|
27
|
+
const r = run([{ type: "press", at: 1000 }, { type: "release", at: 4000 }, { type: "recorded", at: 4100, uri: "file:///a.mov" }], ready);
|
|
28
|
+
expect(r.effects).toEqual(["start", "haptic:record-start", "stop", "send:3100", "haptic:send", "close"]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("tap: starts hands-free, and the next tap stops and sends", () => {
|
|
32
|
+
const tapped = run([{ type: "press", at: 0 }, { type: "release", at: 100 }], ready);
|
|
33
|
+
expect(tapped.state).toMatchObject({ phase: "recording", gesture: "tap" });
|
|
34
|
+
expect(tapped.effects).toEqual(["start", "haptic:record-start"]);
|
|
35
|
+
const r = run([{ type: "press", at: 5000 }, { type: "release", at: 5100 }, { type: "recorded", at: 5150, uri: "file:///a.mov" }], tapped.state);
|
|
36
|
+
expect(r.effects).toEqual(["stop", "send:5150", "haptic:send", "close"]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("doesn't send a take under a second, and says why", () => {
|
|
40
|
+
const r = run([{ type: "press", at: 0 }, { type: "release", at: 400 }, { type: "recorded", at: 600, uri: "file:///a.mov" }], ready);
|
|
41
|
+
expect(r.effects).not.toContain("send:600");
|
|
42
|
+
expect(r.state).toMatchObject({ phase: "ready", notice: "too-short" });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("stops itself at the role's limit", () => {
|
|
46
|
+
const r = run([{ type: "press", at: 0 }, { type: "release", at: 50 }, { type: "tick", at: 60_000 }, { type: "tick", at: 120_000 }], ready);
|
|
47
|
+
expect(r.state.phase).toBe("stopping");
|
|
48
|
+
expect(r.effects.filter((e) => e === "stop")).toHaveLength(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("Cancel during a take throws it away and closes", () => {
|
|
52
|
+
const r = run([{ type: "press", at: 0 }, { type: "release", at: 50 }, { type: "cancel" }, { type: "recorded", at: 3000, uri: "file:///a.mov" }], ready);
|
|
53
|
+
expect(r.effects).toEqual(["start", "haptic:record-start", "stop", "haptic:record-cancel", "close"]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("flips only between takes", () => {
|
|
57
|
+
expect(run([{ type: "flip" }], ready).state.facing).toBe("back");
|
|
58
|
+
const recording = run([{ type: "press", at: 0 }], ready).state;
|
|
59
|
+
expect(run([{ type: "flip" }], recording).state.facing).toBe("front");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("a take with no file is lost on its own: nothing is sent and the camera stays ready", () => {
|
|
63
|
+
const r = run([{ type: "press", at: 0 }, { type: "release", at: 2000 }, { type: "recorded", at: 2000, uri: null }], ready);
|
|
64
|
+
expect(r.state).toMatchObject({ phase: "ready", notice: "take-failed" });
|
|
65
|
+
expect(r.effects.some((e) => e.startsWith("send"))).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("a very short take with no file says too short, not failed", () => {
|
|
69
|
+
const r = run([{ type: "press", at: 0 }, { type: "release", at: 400 }, { type: "recorded", at: 450, uri: null }], ready);
|
|
70
|
+
expect(r.state.notice).toBe("too-short");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("waits for the other camera after a flip, and Try again restarts a camera that failed", () => {
|
|
74
|
+
const flipped = run([{ type: "flip" }], ready).state;
|
|
75
|
+
expect(flipped.phase).toBe("starting");
|
|
76
|
+
expect(run([{ type: "press", at: 0 }], flipped).effects).toEqual([]);
|
|
77
|
+
expect(run([{ type: "camera-ready" }], flipped).state.phase).toBe("ready");
|
|
78
|
+
const failed = run([{ type: "camera-failed" }], ready).state;
|
|
79
|
+
expect(failed.phase).toBe("failed");
|
|
80
|
+
expect(run([{ type: "retry" }], failed).state).toMatchObject({ phase: "starting", cameraKey: 1 });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("names the recording's type from its file", () => {
|
|
84
|
+
expect(recordingType("file:///cache/Camera/abc.mov")).toEqual({ contentType: "video/quicktime", ext: "mov" });
|
|
85
|
+
expect(recordingType("file:///data/abc.mp4")).toEqual({ contentType: "video/mp4", ext: "mp4" });
|
|
86
|
+
});
|
|
87
|
+
});
|