@tribe-nest/forge 3.42.1 → 3.48.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/package.json +2 -2
- package/src/data/queries/useCallChat.ts +162 -0
- package/src/data/queries/useCallRecording.ts +80 -0
- package/src/data/queries/useMembershipCalls.ts +162 -0
- package/src/i18n/de.json +73 -1
- package/src/i18n/en.json +73 -1
- package/src/index.ts +7 -0
- package/src/ui/headless/memberHome/index.ts +4 -0
- package/src/ui/headless/memberHome/useMemberCalls.ts +137 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +29 -5
- package/src/ui/media/CallStage.tsx +691 -553
- package/src/ui/media/PreJoin.tsx +269 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +338 -5
- package/src/ui/media/_tests/callLayout.spec.ts +182 -0
- package/src/ui/media/_tests/callSignals.spec.ts +87 -0
- package/src/ui/media/_tests/callState.spec.ts +93 -1
- package/src/ui/media/_tests/fakeNode.ts +4 -1
- package/src/ui/media/callChat.ts +33 -0
- package/src/ui/media/callState.ts +125 -9
- package/src/ui/media/hooks/useCallSignals.ts +135 -0
- package/src/ui/media/hooks/useMeasuredWidth.ts +39 -0
- package/src/ui/media/index.ts +41 -1
- package/src/ui/media/panels/CallChatPanel.tsx +200 -0
- package/src/ui/media/panels/CallParticipantsPanel.tsx +125 -0
- package/src/ui/media/panels/CallSidePanel.tsx +152 -0
- package/src/ui/media/stage/CallCenterNotice.tsx +89 -0
- package/src/ui/media/stage/CallControlsBar.tsx +547 -0
- package/src/ui/media/stage/CallHealthPanel.tsx +132 -0
- package/src/ui/media/stage/CallToasts.tsx +49 -0
- package/src/ui/media/stage/CallTopBar.tsx +257 -0
- package/src/ui/media/stage/DeviceMenu.tsx +142 -0
- package/src/ui/media/stage/ReactionOverlay.tsx +38 -0
- package/src/ui/media/stage/SelfView.tsx +146 -0
- package/src/ui/media/stage/VideoTile.tsx +232 -0
- package/src/ui/media/stage/callLayout.ts +253 -0
- package/src/ui/media/stage/callSignals.ts +91 -0
- package/src/ui/media/stage/callSounds.ts +78 -0
- package/src/ui/media/stage/stageTheme.ts +50 -0
- package/src/ui/styled/forge-utilities.css +13 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +571 -0
- package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +135 -0
- package/src/ui/styled/members/index.ts +1 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import { Mic, MicOff, Video, VideoOff } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import { useForgeT } from "../../i18n";
|
|
5
|
+
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
6
|
+
import { stageTokens } from "./stage/stageTheme";
|
|
7
|
+
import { DeviceMenu } from "./stage/DeviceMenu";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The green room: check the camera and the microphone BEFORE connecting.
|
|
11
|
+
*
|
|
12
|
+
* Mounted by the page in place of `MediaRoomProvider`; only the Join click
|
|
13
|
+
* mounts the provider, so nobody arrives in a call with a camera they did not
|
|
14
|
+
* expect to be on. The captures opened here are STOPPED on join (the stage
|
|
15
|
+
* opens its own with the chosen devices), and the click doubles as the user
|
|
16
|
+
* gesture that unlocks the chime AudioContext.
|
|
17
|
+
*/
|
|
18
|
+
export type PreJoinSettings = {
|
|
19
|
+
audioDeviceId?: string;
|
|
20
|
+
videoDeviceId?: string;
|
|
21
|
+
joinWithMicOn: boolean;
|
|
22
|
+
joinWithCameraOn: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type PreJoinProps = {
|
|
26
|
+
title?: ReactNode;
|
|
27
|
+
onJoin: (settings: PreJoinSettings) => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function PreJoin({ title, onJoin }: PreJoinProps) {
|
|
31
|
+
const tokens = stageTokens(useThemeTokens());
|
|
32
|
+
const t = useForgeT();
|
|
33
|
+
const [micOn, setMicOn] = useState(true);
|
|
34
|
+
const [cameraOn, setCameraOn] = useState(true);
|
|
35
|
+
const [audioDeviceId, setAudioDeviceId] = useState<string | undefined>(undefined);
|
|
36
|
+
const [videoDeviceId, setVideoDeviceId] = useState<string | undefined>(undefined);
|
|
37
|
+
const [permissionError, setPermissionError] = useState<string | null>(null);
|
|
38
|
+
const [level, setLevel] = useState(0);
|
|
39
|
+
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
40
|
+
const streamRef = useRef<MediaStream | null>(null);
|
|
41
|
+
|
|
42
|
+
// One preview capture per (device, on/off) combination, always stopped on
|
|
43
|
+
// the way out: a green room must never leave a camera light on behind it.
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
let cancelled = false;
|
|
46
|
+
let context: AudioContext | undefined;
|
|
47
|
+
let meterTimer: ReturnType<typeof setInterval> | undefined;
|
|
48
|
+
|
|
49
|
+
const stop = () => {
|
|
50
|
+
streamRef.current?.getTracks().forEach((track) => track.stop());
|
|
51
|
+
streamRef.current = null;
|
|
52
|
+
if (videoRef.current) videoRef.current.srcObject = null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
if (!micOn && !cameraOn) {
|
|
56
|
+
stop();
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
void navigator.mediaDevices
|
|
61
|
+
?.getUserMedia({
|
|
62
|
+
audio: micOn ? (audioDeviceId ? { deviceId: { exact: audioDeviceId } } : true) : false,
|
|
63
|
+
video: cameraOn ? (videoDeviceId ? { deviceId: { exact: videoDeviceId } } : true) : false,
|
|
64
|
+
})
|
|
65
|
+
.then((stream) => {
|
|
66
|
+
if (cancelled) {
|
|
67
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
stop();
|
|
71
|
+
streamRef.current = stream;
|
|
72
|
+
setPermissionError(null);
|
|
73
|
+
if (videoRef.current && cameraOn) videoRef.current.srcObject = stream;
|
|
74
|
+
|
|
75
|
+
const audioTrack = stream.getAudioTracks()[0];
|
|
76
|
+
if (audioTrack && typeof AudioContext !== "undefined") {
|
|
77
|
+
try {
|
|
78
|
+
context = new AudioContext();
|
|
79
|
+
const analyser = context.createAnalyser();
|
|
80
|
+
analyser.fftSize = 512;
|
|
81
|
+
context.createMediaStreamSource(new MediaStream([audioTrack])).connect(analyser);
|
|
82
|
+
const samples = new Uint8Array(analyser.frequencyBinCount);
|
|
83
|
+
meterTimer = setInterval(() => {
|
|
84
|
+
analyser.getByteTimeDomainData(samples);
|
|
85
|
+
let sum = 0;
|
|
86
|
+
for (const value of samples) sum += (value - 128) * (value - 128);
|
|
87
|
+
setLevel(Math.min(1, Math.sqrt(sum / samples.length) / 32));
|
|
88
|
+
}, 120);
|
|
89
|
+
} catch {
|
|
90
|
+
// No meter is a cosmetic loss, never a blocked join.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
.catch((err: Error) => {
|
|
95
|
+
if (!cancelled) setPermissionError(err.message);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return () => {
|
|
99
|
+
cancelled = true;
|
|
100
|
+
if (meterTimer) clearInterval(meterTimer);
|
|
101
|
+
void context?.close().catch(() => undefined);
|
|
102
|
+
stop();
|
|
103
|
+
};
|
|
104
|
+
}, [micOn, cameraOn, audioDeviceId, videoDeviceId]);
|
|
105
|
+
|
|
106
|
+
const join = () => {
|
|
107
|
+
// The stage captures its own tracks against the chosen device ids;
|
|
108
|
+
// handing live tracks across the provider boundary would tie the green
|
|
109
|
+
// room's lifetime to the call's.
|
|
110
|
+
streamRef.current?.getTracks().forEach((track) => track.stop());
|
|
111
|
+
streamRef.current = null;
|
|
112
|
+
onJoin({
|
|
113
|
+
...(audioDeviceId ? { audioDeviceId } : {}),
|
|
114
|
+
...(videoDeviceId ? { videoDeviceId } : {}),
|
|
115
|
+
joinWithMicOn: micOn,
|
|
116
|
+
joinWithCameraOn: cameraOn,
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const toggleStyle = (active: boolean) => ({
|
|
121
|
+
display: "inline-flex",
|
|
122
|
+
alignItems: "center",
|
|
123
|
+
gap: "0.375rem",
|
|
124
|
+
padding: "0.5rem 0.75rem",
|
|
125
|
+
borderRadius: tokens.cornerRadius,
|
|
126
|
+
border: `1px solid ${active ? tokens.accent : tokens.stageBorder}`,
|
|
127
|
+
backgroundColor: active ? tokens.accent : "transparent",
|
|
128
|
+
color: active ? tokens.accentText : tokens.stageText,
|
|
129
|
+
cursor: "pointer",
|
|
130
|
+
font: "inherit",
|
|
131
|
+
fontSize: "0.8125rem",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div
|
|
136
|
+
data-testid="prejoin"
|
|
137
|
+
style={{
|
|
138
|
+
display: "flex",
|
|
139
|
+
flexDirection: "column",
|
|
140
|
+
alignItems: "center",
|
|
141
|
+
justifyContent: "center",
|
|
142
|
+
gap: "1rem",
|
|
143
|
+
height: "100%",
|
|
144
|
+
minHeight: "24rem",
|
|
145
|
+
padding: "1.5rem",
|
|
146
|
+
borderRadius: tokens.cornerRadius,
|
|
147
|
+
backgroundColor: tokens.stageBg,
|
|
148
|
+
color: tokens.stageText,
|
|
149
|
+
fontFamily: tokens.fontFamily,
|
|
150
|
+
boxSizing: "border-box",
|
|
151
|
+
}}
|
|
152
|
+
>
|
|
153
|
+
{title && <strong style={{ fontSize: "1.1rem", fontFamily: tokens.headingFontFamily }}>{title}</strong>}
|
|
154
|
+
|
|
155
|
+
<div
|
|
156
|
+
style={{
|
|
157
|
+
position: "relative",
|
|
158
|
+
width: "min(28rem, 90%)",
|
|
159
|
+
aspectRatio: "16 / 9",
|
|
160
|
+
borderRadius: tokens.cornerRadius,
|
|
161
|
+
overflow: "hidden",
|
|
162
|
+
backgroundColor: tokens.stageSurface,
|
|
163
|
+
border: `1px solid ${tokens.stageBorder}`,
|
|
164
|
+
display: "flex",
|
|
165
|
+
alignItems: "center",
|
|
166
|
+
justifyContent: "center",
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
{cameraOn && !permissionError ? (
|
|
170
|
+
<video ref={videoRef} autoPlay playsInline muted style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
|
171
|
+
) : (
|
|
172
|
+
<span style={{ fontSize: "0.875rem", color: tokens.stageMuted }}>{t("forge.call_stage.prejoin_camera_off")}</span>
|
|
173
|
+
)}
|
|
174
|
+
{micOn && !permissionError && (
|
|
175
|
+
<span
|
|
176
|
+
aria-hidden="true"
|
|
177
|
+
style={{
|
|
178
|
+
position: "absolute",
|
|
179
|
+
left: "0.5rem",
|
|
180
|
+
bottom: "0.5rem",
|
|
181
|
+
width: "5rem",
|
|
182
|
+
height: 6,
|
|
183
|
+
borderRadius: 3,
|
|
184
|
+
backgroundColor: "rgba(255,255,255,0.2)",
|
|
185
|
+
overflow: "hidden",
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
<span
|
|
189
|
+
style={{
|
|
190
|
+
display: "block",
|
|
191
|
+
width: `${Math.round(level * 100)}%`,
|
|
192
|
+
height: "100%",
|
|
193
|
+
backgroundColor: tokens.accent,
|
|
194
|
+
transition: "width 120ms",
|
|
195
|
+
}}
|
|
196
|
+
/>
|
|
197
|
+
</span>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
{/* A denied permission is content the person must see without asking. */}
|
|
202
|
+
{permissionError && (
|
|
203
|
+
<span role="alert" style={{ fontSize: "0.8125rem", color: tokens.stageText, maxWidth: "28rem", textAlign: "center" }}>
|
|
204
|
+
{t("forge.call_stage.prejoin_permission")} {permissionError}
|
|
205
|
+
</span>
|
|
206
|
+
)}
|
|
207
|
+
|
|
208
|
+
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", justifyContent: "center" }}>
|
|
209
|
+
<span style={{ display: "inline-flex" }}>
|
|
210
|
+
<button
|
|
211
|
+
type="button"
|
|
212
|
+
onClick={() => setMicOn((current) => !current)}
|
|
213
|
+
aria-pressed={micOn}
|
|
214
|
+
style={{ ...toggleStyle(micOn), borderRadius: `${tokens.cornerRadius}px 0 0 ${tokens.cornerRadius}px` }}
|
|
215
|
+
>
|
|
216
|
+
{micOn ? <Mic width={16} height={16} aria-hidden="true" /> : <MicOff width={16} height={16} aria-hidden="true" />}
|
|
217
|
+
{micOn ? t("forge.call_stage.prejoin_mic_on") : t("forge.call_stage.prejoin_mic_off")}
|
|
218
|
+
</button>
|
|
219
|
+
<DeviceMenu
|
|
220
|
+
kind="audioinput"
|
|
221
|
+
currentDeviceId={audioDeviceId}
|
|
222
|
+
onPick={setAudioDeviceId}
|
|
223
|
+
tokens={tokens}
|
|
224
|
+
t={t}
|
|
225
|
+
label={t("forge.call_stage.pick_microphone")}
|
|
226
|
+
/>
|
|
227
|
+
</span>
|
|
228
|
+
<span style={{ display: "inline-flex" }}>
|
|
229
|
+
<button
|
|
230
|
+
type="button"
|
|
231
|
+
onClick={() => setCameraOn((current) => !current)}
|
|
232
|
+
aria-pressed={cameraOn}
|
|
233
|
+
style={{ ...toggleStyle(cameraOn), borderRadius: `${tokens.cornerRadius}px 0 0 ${tokens.cornerRadius}px` }}
|
|
234
|
+
>
|
|
235
|
+
{cameraOn ? <Video width={16} height={16} aria-hidden="true" /> : <VideoOff width={16} height={16} aria-hidden="true" />}
|
|
236
|
+
{cameraOn ? t("forge.call_stage.prejoin_camera_on") : t("forge.call_stage.prejoin_camera_off_toggle")}
|
|
237
|
+
</button>
|
|
238
|
+
<DeviceMenu
|
|
239
|
+
kind="videoinput"
|
|
240
|
+
currentDeviceId={videoDeviceId}
|
|
241
|
+
onPick={setVideoDeviceId}
|
|
242
|
+
tokens={tokens}
|
|
243
|
+
t={t}
|
|
244
|
+
label={t("forge.call_stage.pick_camera")}
|
|
245
|
+
/>
|
|
246
|
+
</span>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<button
|
|
250
|
+
type="button"
|
|
251
|
+
data-testid="prejoin-join"
|
|
252
|
+
onClick={join}
|
|
253
|
+
style={{
|
|
254
|
+
padding: "0.65rem 2.5rem",
|
|
255
|
+
borderRadius: tokens.cornerRadius,
|
|
256
|
+
border: "none",
|
|
257
|
+
cursor: "pointer",
|
|
258
|
+
backgroundColor: tokens.accent,
|
|
259
|
+
color: tokens.accentText,
|
|
260
|
+
font: "inherit",
|
|
261
|
+
fontSize: "0.9375rem",
|
|
262
|
+
fontWeight: 600,
|
|
263
|
+
}}
|
|
264
|
+
>
|
|
265
|
+
{t("forge.call_stage.prejoin_join")}
|
|
266
|
+
</button>
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
@@ -42,17 +42,45 @@ afterEach(() => {
|
|
|
42
42
|
delete (navigator as { mediaDevices?: unknown }).mediaDevices;
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The width `useMeasuredWidth` sees. jsdom has no layout, so a ResizeObserver
|
|
47
|
+
* stub reports this on observe, and `getBoundingClientRect` is stubbed to
|
|
48
|
+
* match. Default is a roomy desktop; a test sets it narrow to exercise the
|
|
49
|
+
* phone layout.
|
|
50
|
+
*/
|
|
51
|
+
let stageTestWidth = 1200;
|
|
52
|
+
const resizeCallbacks = new Set<(entries: { contentRect: { width: number } }[]) => void>();
|
|
53
|
+
const setStageWidth = (width: number) => {
|
|
54
|
+
stageTestWidth = width;
|
|
55
|
+
for (const cb of resizeCallbacks) cb([{ contentRect: { width } }]);
|
|
56
|
+
};
|
|
57
|
+
|
|
45
58
|
beforeAll(() => {
|
|
46
|
-
// jsdom has no `MediaStream`, and the local preview builds one to hand to a
|
|
47
|
-
// `<video>`. A two-line stand-in is enough: nothing here asserts on it, the
|
|
48
|
-
// point is that the component may construct one without throwing.
|
|
49
59
|
if (!("MediaStream" in globalThis)) {
|
|
50
60
|
(globalThis as { MediaStream?: unknown }).MediaStream = class {
|
|
51
61
|
constructor(readonly tracks: unknown[] = []) {}
|
|
52
62
|
};
|
|
53
63
|
}
|
|
64
|
+
(globalThis as { ResizeObserver?: unknown }).ResizeObserver = class {
|
|
65
|
+
constructor(private readonly cb: (entries: { contentRect: { width: number } }[]) => void) {
|
|
66
|
+
resizeCallbacks.add(this.cb);
|
|
67
|
+
}
|
|
68
|
+
observe() {
|
|
69
|
+
this.cb([{ contentRect: { width: stageTestWidth } }]);
|
|
70
|
+
}
|
|
71
|
+
unobserve() {}
|
|
72
|
+
disconnect() {
|
|
73
|
+
resizeCallbacks.delete(this.cb);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
// The hook also seeds from getBoundingClientRect on mount.
|
|
77
|
+
Element.prototype.getBoundingClientRect = function () {
|
|
78
|
+
return { width: stageTestWidth, height: 600, top: 0, left: 0, right: stageTestWidth, bottom: 600, x: 0, y: 0, toJSON: () => ({}) } as DOMRect;
|
|
79
|
+
};
|
|
54
80
|
});
|
|
55
81
|
|
|
82
|
+
afterEach(() => setStageWidth(1200));
|
|
83
|
+
|
|
56
84
|
/** Every `produce` this device is asked for, by source. Reset per test. */
|
|
57
85
|
const produced: string[] = [];
|
|
58
86
|
|
|
@@ -585,9 +613,12 @@ describe("the promise to come back is one somebody keeps", () => {
|
|
|
585
613
|
const { getByRole, getByText } = mount(node, <CallStage />, { reconnect: NO_RECOVERY });
|
|
586
614
|
await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
|
|
587
615
|
|
|
616
|
+
// The node asks to be left, and then the REJOIN is refused: the whole
|
|
617
|
+
// fleet is full, so the move genuinely has nowhere to go. (The SDK now
|
|
618
|
+
// moves the call itself on a plain drain, so refusing the second join is
|
|
619
|
+
// what "nowhere" means.)
|
|
620
|
+
(node as unknown as { options: { refuseWith?: string } }).options.refuseWith = "capacity";
|
|
588
621
|
node.event({ event: "draining", reconnectAfterMs: 1 });
|
|
589
|
-
await settle(20);
|
|
590
|
-
node.socket.drop();
|
|
591
622
|
|
|
592
623
|
await waitFor(() => expect(getByRole("status").textContent).toContain("Could not move you"));
|
|
593
624
|
// The control the drained case never had. Spinner plus no button is the
|
|
@@ -930,3 +961,305 @@ describe("what a drop took away is said, once the room is back", () => {
|
|
|
930
961
|
expect(queryByText(/Your microphone was turned off/)).toBeNull();
|
|
931
962
|
});
|
|
932
963
|
});
|
|
964
|
+
|
|
965
|
+
describe("the rebuilt stage: layout, signals and panels", () => {
|
|
966
|
+
const manyPeers = (count: number) =>
|
|
967
|
+
Array.from({ length: count }, (_, i) => ({ identity: `p-${String(i).padStart(2, "0")}`, name: `Person ${i}` }));
|
|
968
|
+
|
|
969
|
+
it("shows JOINING in the centre while connecting, never a not-connected error", () => {
|
|
970
|
+
// The user-reported bug this rebuild fixes: the empty joining screen used
|
|
971
|
+
// to read "not connected", which is an error message about a state that
|
|
972
|
+
// is not an error. Asserted SYNCHRONOUSLY, before the fake node's join
|
|
973
|
+
// lands.
|
|
974
|
+
const node = new FakeNode({});
|
|
975
|
+
const { getByTestId, queryByText } = mount(node, <CallStage />);
|
|
976
|
+
expect(getByTestId("stage-notice-joining")).toBeTruthy();
|
|
977
|
+
expect(queryByText(/not connected/i)).toBeNull();
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
it("renders a refusal in the CENTRE of the stage, with the retry", async () => {
|
|
981
|
+
const node = new FakeNode({ refuseWith: "unauthorized" });
|
|
982
|
+
const { getByTestId, getAllByText } = mount(node, <CallStage />);
|
|
983
|
+
|
|
984
|
+
await waitFor(() => expect(getByTestId("stage-notice-failed")).toBeTruthy());
|
|
985
|
+
expect(getByTestId("stage-notice-failed").textContent).toContain("Could not join this call");
|
|
986
|
+
// The pill and the notice may both offer the words; the CONTROL lives in
|
|
987
|
+
// the notice, where a person staring at a dead stage actually looks.
|
|
988
|
+
expect(getAllByText("Try again").length).toBeGreaterThan(0);
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
it("says it is waiting when alone, and that everyone left when they leave", async () => {
|
|
992
|
+
const node = new FakeNode({ peers: [{ identity: "anna", name: "Anna" }] });
|
|
993
|
+
const { getByTestId, queryByTestId } = mount(node, <CallStage />);
|
|
994
|
+
await settle(30);
|
|
995
|
+
expect(queryByTestId("stage-notice-waiting")).toBeNull();
|
|
996
|
+
|
|
997
|
+
node.event({ event: "peerLeft", identity: "anna" });
|
|
998
|
+
await waitFor(() => expect(getByTestId("stage-notice-everyone_left")).toBeTruthy());
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
it("lays out exactly one remote person as two large portraits, no floating self view", async () => {
|
|
1002
|
+
const node = new FakeNode({ peers: [{ identity: "anna", name: "Anna" }] });
|
|
1003
|
+
const { container, getByTestId } = mount(node, <CallStage />);
|
|
1004
|
+
await waitFor(() => expect(getByTestId("tile-anna")).toBeTruthy());
|
|
1005
|
+
// The local half of the duo IS the self view: one tile-local, at size.
|
|
1006
|
+
expect(container.querySelectorAll("[data-testid='tile-local']").length).toBe(1);
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
it("pages a 12-person call at 8 per page", async () => {
|
|
1010
|
+
const node = new FakeNode({ peers: manyPeers(12) });
|
|
1011
|
+
const { container, getByTestId, getByText } = mount(node, <CallStage />);
|
|
1012
|
+
|
|
1013
|
+
await waitFor(() => expect(getByText("1 / 2")).toBeTruthy());
|
|
1014
|
+
expect(container.querySelectorAll("[data-testid^='tile-p-']").length).toBe(8);
|
|
1015
|
+
|
|
1016
|
+
fireEvent.click(getByTestId("page-next"));
|
|
1017
|
+
await waitFor(() => expect(getByText("2 / 2")).toBeTruthy());
|
|
1018
|
+
expect(container.querySelectorAll("[data-testid^='tile-p-']").length).toBe(4);
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
it("puts a labeled screen share on the stage with a 4-face rail and the presenter's name", async () => {
|
|
1022
|
+
const node = new FakeNode({
|
|
1023
|
+
peers: [...manyPeers(6), { identity: "anna", name: "Anna" }],
|
|
1024
|
+
producers: [{ producerId: "s-1", identity: "anna", kind: "video", source: "screen" }],
|
|
1025
|
+
});
|
|
1026
|
+
const { container, getByTestId } = mount(node, <CallStage />);
|
|
1027
|
+
|
|
1028
|
+
await waitFor(() => expect(getByTestId("presenter-label").textContent).toContain("Anna is presenting"));
|
|
1029
|
+
const share = container.querySelector("[data-source='screen']");
|
|
1030
|
+
expect(share).toBeTruthy();
|
|
1031
|
+
// The rail suppresses camera demand: 4 faces beside the share, not 7.
|
|
1032
|
+
const faces = container.querySelectorAll("[data-source='camera']");
|
|
1033
|
+
expect(faces.length).toBe(4);
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
it("stacks the share on a PHONE: full-width on top, faces as a strip below, never a squished side rail", async () => {
|
|
1037
|
+
setStageWidth(390);
|
|
1038
|
+
const node = new FakeNode({
|
|
1039
|
+
peers: [{ identity: "anna", name: "Anna" }, { identity: "ben", name: "Ben" }],
|
|
1040
|
+
producers: [{ producerId: "s-1", identity: "anna", kind: "video", source: "screen" }],
|
|
1041
|
+
});
|
|
1042
|
+
const { getByTestId, container } = mount(node, <CallStage />);
|
|
1043
|
+
|
|
1044
|
+
await waitFor(() => expect(getByTestId("presenter-label").textContent).toContain("Anna is presenting"));
|
|
1045
|
+
// The presenting stage stacks vertically on a phone rather than splitting
|
|
1046
|
+
// the width with a side rail.
|
|
1047
|
+
expect(getByTestId("presenting-stage").style.flexDirection).toBe("column");
|
|
1048
|
+
expect(container.querySelector("[data-source='screen']")).toBeTruthy();
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
it("keeps the desktop presenting split (share beside a side rail) on a wide screen", async () => {
|
|
1052
|
+
setStageWidth(1200);
|
|
1053
|
+
const node = new FakeNode({
|
|
1054
|
+
peers: [{ identity: "anna", name: "Anna" }, { identity: "ben", name: "Ben" }],
|
|
1055
|
+
producers: [{ producerId: "s-1", identity: "anna", kind: "video", source: "screen" }],
|
|
1056
|
+
});
|
|
1057
|
+
const { getByTestId } = mount(node, <CallStage />);
|
|
1058
|
+
await waitFor(() => expect(getByTestId("presenter-label").textContent).toContain("Anna is presenting"));
|
|
1059
|
+
expect(getByTestId("presenting-stage").style.flexDirection).toBe("row");
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
it("raises a hand over the wire and shows everyone's hands from room state", async () => {
|
|
1063
|
+
const node = new FakeNode({ peers: manyPeers(3) });
|
|
1064
|
+
const { getByTestId, getByText } = mount(node, <CallStage />);
|
|
1065
|
+
await waitFor(() => expect(getByText("Raise hand")).toBeTruthy());
|
|
1066
|
+
|
|
1067
|
+
fireEvent.click(getByTestId("raise-hand"));
|
|
1068
|
+
await waitFor(() => expect(node.received.some((f) => f.method === "setHand" && f.raised === true)).toBe(true));
|
|
1069
|
+
|
|
1070
|
+
// A peer's hand arrives as room state and lands on their tile.
|
|
1071
|
+
node.event({ event: "handChanged", identity: "p-01", raisedAt: 100 });
|
|
1072
|
+
await waitFor(() => expect(getByTestId("hand-p-01")).toBeTruthy());
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
it("surfaces a raised hand in the TOP BAR too, named, and rings for everyone", async () => {
|
|
1076
|
+
// The sound is synthesized through WebAudio, absent in jsdom, so the chime
|
|
1077
|
+
// is a no-op here; what a test can hold is that the top-bar indicator
|
|
1078
|
+
// appears the moment the shared state gains a hand, with the raiser's name.
|
|
1079
|
+
const node = new FakeNode({ peers: manyPeers(3) });
|
|
1080
|
+
const { getByText, queryByRole, getByRole } = mount(node, <CallStage />);
|
|
1081
|
+
await waitFor(() => expect(getByText("Raise hand")).toBeTruthy());
|
|
1082
|
+
|
|
1083
|
+
// Nothing up: no top-bar hand control (the name appears on the tile, so
|
|
1084
|
+
// the control, not the name, is what proves the indicator's absence).
|
|
1085
|
+
expect(queryByRole("button", { name: /Raised hands:/ })).toBeNull();
|
|
1086
|
+
|
|
1087
|
+
node.event({ event: "handChanged", identity: "p-01", raisedAt: 100 });
|
|
1088
|
+
// The first (and only) hand shows the person's name, and the control opens
|
|
1089
|
+
// the people panel where the queue lives.
|
|
1090
|
+
const chip = await waitFor(() => getByRole("button", { name: /Raised hands: 1/ }));
|
|
1091
|
+
expect(chip.textContent).toContain("Person 1");
|
|
1092
|
+
|
|
1093
|
+
// A second hand collapses to "name +1", the first in the queue named.
|
|
1094
|
+
node.event({ event: "handChanged", identity: "p-02", raisedAt: 200 });
|
|
1095
|
+
await waitFor(() => expect(getByRole("button", { name: /Raised hands: 2/ }).textContent).toContain("+1"));
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
it("names YOUR OWN raised hand \"You\", not your raw identity", async () => {
|
|
1099
|
+
// The peer list is other people, so a self hand had no name to resolve
|
|
1100
|
+
// and fell back to the uuid - correct on everyone else's screen, wrong on
|
|
1101
|
+
// your own. Self is now in the name map as "You".
|
|
1102
|
+
const node = new FakeNode({ identity: "host-uuid-123", peers: manyPeers(2) });
|
|
1103
|
+
const { getByTestId, getByRole, getByText } = mount(node, <CallStage />);
|
|
1104
|
+
await waitFor(() => expect(getByText("Raise hand")).toBeTruthy());
|
|
1105
|
+
|
|
1106
|
+
fireEvent.click(getByTestId("raise-hand"));
|
|
1107
|
+
await waitFor(() => expect(node.received.some((f) => f.method === "setHand" && f.raised === true)).toBe(true));
|
|
1108
|
+
// The node echoes the hand back as room state, addressed to self.
|
|
1109
|
+
node.event({ event: "handChanged", identity: "host-uuid-123", raisedAt: 100 });
|
|
1110
|
+
|
|
1111
|
+
const chip = await waitFor(() => getByRole("button", { name: /Raised hands: 1/ }));
|
|
1112
|
+
expect(chip.textContent).toContain("You");
|
|
1113
|
+
expect(chip.textContent).not.toContain("host-uuid-123");
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
it("does not ring or badge for hands that were ALREADY up when you joined", async () => {
|
|
1117
|
+
// A reconnect (or a late joiner) receives a snapshot with hands already
|
|
1118
|
+
// raised; that is not N new hands going up, and must not blast the chime
|
|
1119
|
+
// or read as fresh events. Seeded from the first snapshot.
|
|
1120
|
+
const node = new FakeNode({
|
|
1121
|
+
peers: manyPeers(3),
|
|
1122
|
+
raisedHands: [{ identity: "p-01", raisedAt: 10 }],
|
|
1123
|
+
});
|
|
1124
|
+
const { getByRole, getByText } = mount(node, <CallStage />);
|
|
1125
|
+
await waitFor(() => expect(getByText("Raise hand")).toBeTruthy());
|
|
1126
|
+
// The already-up hand still SHOWS (it is real state), it just did not chime.
|
|
1127
|
+
await waitFor(() => expect(getByRole("button", { name: /Raised hands: 1/ })).toBeTruthy());
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
it("sends a whitelisted reaction and flies an incoming one", async () => {
|
|
1131
|
+
const node = new FakeNode({ peers: manyPeers(3) });
|
|
1132
|
+
const { container, getByTestId, getByText } = mount(node, <CallStage />);
|
|
1133
|
+
await waitFor(() => expect(getByText("Raise hand")).toBeTruthy());
|
|
1134
|
+
|
|
1135
|
+
fireEvent.click(getByTestId("reaction-picker"));
|
|
1136
|
+
fireEvent.click(getByText("👏"));
|
|
1137
|
+
await waitFor(() =>
|
|
1138
|
+
expect(node.received.some((f) => f.method === "broadcast" && (f as { type?: string }).type === "reaction")).toBe(true),
|
|
1139
|
+
);
|
|
1140
|
+
// Our own copy flies immediately.
|
|
1141
|
+
expect(container.querySelectorAll("[data-testid='flying-reaction']").length).toBe(1);
|
|
1142
|
+
|
|
1143
|
+
node.event({ event: "broadcast", type: "reaction", identity: "p-01", data: { emoji: "❤️" }, at: 1 });
|
|
1144
|
+
await waitFor(() => expect(container.querySelectorAll("[data-testid='flying-reaction']").length).toBe(2));
|
|
1145
|
+
|
|
1146
|
+
// The whitelist holds on receipt: arbitrary strings never fly.
|
|
1147
|
+
node.event({ event: "broadcast", type: "reaction", identity: "p-01", data: { emoji: "<script>" }, at: 2 });
|
|
1148
|
+
await settle(30);
|
|
1149
|
+
expect(container.querySelectorAll("[data-testid='flying-reaction']").length).toBe(2);
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
it("counts unread chat on the badge until the panel is opened", async () => {
|
|
1153
|
+
const node = new FakeNode({ peers: manyPeers(3) });
|
|
1154
|
+
const chat = {
|
|
1155
|
+
ready: true,
|
|
1156
|
+
messages: [
|
|
1157
|
+
{ id: "m1", content: "hi", senderName: "Anna", senderKey: "anna", at: "", reactions: [] },
|
|
1158
|
+
{ id: "m2", content: "hello", senderName: "Ben", senderKey: "ben", at: "", reactions: [] },
|
|
1159
|
+
],
|
|
1160
|
+
sendMessage: vi.fn(),
|
|
1161
|
+
reactToMessage: vi.fn(),
|
|
1162
|
+
typingNames: [],
|
|
1163
|
+
currentSenderKey: "me",
|
|
1164
|
+
};
|
|
1165
|
+
const { getByTestId, queryByTestId } = mount(node, <CallStage chat={chat} />);
|
|
1166
|
+
|
|
1167
|
+
await waitFor(() => expect(getByTestId("chat-unread").textContent).toBe("2"));
|
|
1168
|
+
|
|
1169
|
+
fireEvent.click(getByTestId("toggle-chat"));
|
|
1170
|
+
await waitFor(() => expect(getByTestId("call-side-panel")).toBeTruthy());
|
|
1171
|
+
expect(queryByTestId("chat-unread")).toBeNull();
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
it("shows peers arriving as toasts that clear on their own", async () => {
|
|
1175
|
+
const node = new FakeNode({});
|
|
1176
|
+
const { getByText, queryAllByTestId } = mount(node, <CallStage />);
|
|
1177
|
+
await settle(30);
|
|
1178
|
+
|
|
1179
|
+
node.event({ event: "peerJoined", peer: { identity: "anna", name: "Anna", kind: "human" } });
|
|
1180
|
+
await waitFor(() => expect(getByText("Anna joined")).toBeTruthy());
|
|
1181
|
+
await waitFor(() => expect(queryAllByTestId("call-toast")).toHaveLength(0), { timeout: 6000 });
|
|
1182
|
+
});
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
describe("the call health panel", () => {
|
|
1186
|
+
it("opens from the top bar, reports, and closes: the diagnosis lives IN the call", async () => {
|
|
1187
|
+
const node = new FakeNode({
|
|
1188
|
+
peers: [{ identity: "anna", name: "Anna" }],
|
|
1189
|
+
producers: [{ producerId: "p-anna-video", identity: "anna", kind: "video" }],
|
|
1190
|
+
});
|
|
1191
|
+
const { getByRole, queryByRole } = mount(node, <CallStage />);
|
|
1192
|
+
await settle();
|
|
1193
|
+
|
|
1194
|
+
expect(queryByRole("region", { name: "Call health" })).toBeNull();
|
|
1195
|
+
fireEvent.click(getByRole("button", { name: "Call health" }));
|
|
1196
|
+
|
|
1197
|
+
const panel = await waitFor(() => getByRole("region", { name: "Call health" }));
|
|
1198
|
+
expect(panel).toBeTruthy();
|
|
1199
|
+
// The node's answer and the tiles take a poll cycle; what must be
|
|
1200
|
+
// immediate is that the panel exists and says it is measuring rather
|
|
1201
|
+
// than showing stale or invented numbers.
|
|
1202
|
+
expect(panel.textContent).toContain("Call health");
|
|
1203
|
+
|
|
1204
|
+
fireEvent.click(getByRole("button", { name: "Close call health" }));
|
|
1205
|
+
expect(queryByRole("region", { name: "Call health" })).toBeNull();
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
it("asks the node for diagnostics while open, once a second and not before", async () => {
|
|
1209
|
+
const node = new FakeNode({ peers: [] });
|
|
1210
|
+
const { getByRole } = mount(node, <CallStage />);
|
|
1211
|
+
await settle();
|
|
1212
|
+
|
|
1213
|
+
const asks = () => node.received.filter((frame) => frame.method === "diagnostics").length;
|
|
1214
|
+
expect(asks()).toBe(0);
|
|
1215
|
+
|
|
1216
|
+
fireEvent.click(getByRole("button", { name: "Call health" }));
|
|
1217
|
+
await waitFor(() => expect(asks()).toBeGreaterThanOrEqual(1));
|
|
1218
|
+
});
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
describe("the camera capture asks for a real resolution", () => {
|
|
1222
|
+
it("requests 720p ideal, never required: SD-by-default was a field finding, OverconstrainedError would be worse", async () => {
|
|
1223
|
+
const { getUserMedia } = stubCapture("video");
|
|
1224
|
+
const node = new FakeNode({});
|
|
1225
|
+
const { getByText } = mount(node, <CallStage />);
|
|
1226
|
+
await settle();
|
|
1227
|
+
|
|
1228
|
+
fireEvent.click(getByText("Start camera"));
|
|
1229
|
+
await waitFor(() => expect(getUserMedia).toHaveBeenCalledTimes(1));
|
|
1230
|
+
|
|
1231
|
+
expect(getUserMedia).toHaveBeenCalledWith({
|
|
1232
|
+
video: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } },
|
|
1233
|
+
});
|
|
1234
|
+
});
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
describe("the stage is responsive to its OWN width", () => {
|
|
1238
|
+
it("docks the panel beside the stage on a wide screen", async () => {
|
|
1239
|
+
setStageWidth(1200);
|
|
1240
|
+
const node = new FakeNode({ peers: [{ identity: "a", name: "Ann" }, { identity: "b", name: "Ben" }] });
|
|
1241
|
+
const { getByTestId, queryByTestId, getByText } = mount(node, <CallStage />);
|
|
1242
|
+
await waitFor(() => expect(getByText("Raise hand")).toBeTruthy());
|
|
1243
|
+
|
|
1244
|
+
fireEvent.click(getByTestId("toggle-people"));
|
|
1245
|
+
await waitFor(() => expect(getByTestId("call-side-panel")).toBeTruthy());
|
|
1246
|
+
// Wide: no full-screen overlay wrapper, the panel is a docked sibling.
|
|
1247
|
+
expect(queryByTestId("call-panel-overlay")).toBeNull();
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
it("folds the panel into a full-screen overlay on a phone, so the video is not crushed", async () => {
|
|
1251
|
+
setStageWidth(390);
|
|
1252
|
+
const node = new FakeNode({ peers: [{ identity: "a", name: "Ann" }, { identity: "b", name: "Ben" }] });
|
|
1253
|
+
const { getByTestId, getByText } = mount(node, <CallStage />);
|
|
1254
|
+
await waitFor(() => expect(getByText("Raise hand")).toBeTruthy());
|
|
1255
|
+
|
|
1256
|
+
fireEvent.click(getByTestId("toggle-people"));
|
|
1257
|
+
const overlay = await waitFor(() => getByTestId("call-panel-overlay"));
|
|
1258
|
+
// The panel COVERS the stage (absolute, full inset) rather than taking a
|
|
1259
|
+
// slice of the row.
|
|
1260
|
+
expect(overlay.style.position).toBe("absolute");
|
|
1261
|
+
expect(overlay.style.inset).toBe("0");
|
|
1262
|
+
const panel = getByTestId("call-side-panel");
|
|
1263
|
+
expect(panel.style.width).toBe("100%");
|
|
1264
|
+
});
|
|
1265
|
+
})
|