@tribe-nest/forge 3.42.1 → 3.50.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 +212 -0
- package/src/i18n/de.json +74 -1
- package/src/i18n/en.json +74 -1
- package/src/index.ts +7 -0
- package/src/types/models.ts +58 -5
- package/src/ui/headless/memberHome/index.ts +4 -0
- package/src/ui/headless/memberHome/useMemberCalls.ts +150 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +29 -5
- package/src/ui/index.ts +3 -0
- 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/CohortPage.tsx +15 -1
- package/src/ui/styled/CourseAccess.tsx +19 -6
- package/src/ui/styled/CourseCallsSection.tsx +99 -0
- package/src/ui/styled/FormRenderer.tsx +14 -4
- package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +163 -0
- package/src/ui/styled/forge-utilities.css +13 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +589 -0
- package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +135 -0
- package/src/ui/styled/members/index.ts +1 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Circle,
|
|
4
|
+
Hand,
|
|
5
|
+
MessageSquare,
|
|
6
|
+
Mic,
|
|
7
|
+
MicOff,
|
|
8
|
+
MonitorUp,
|
|
9
|
+
MonitorX,
|
|
10
|
+
PhoneOff,
|
|
11
|
+
SmilePlus,
|
|
12
|
+
Square,
|
|
13
|
+
Users,
|
|
14
|
+
Video,
|
|
15
|
+
VideoOff,
|
|
16
|
+
} from "lucide-react";
|
|
17
|
+
import { useGrants, useLocalMedia, useLocalPublications, useMediaRoom, type LocalSource } from "@tribe-nest/media-client/react";
|
|
18
|
+
|
|
19
|
+
import { useForgeT, type ForgeT } from "../../../i18n";
|
|
20
|
+
import { CallHelpHint } from "../CallHelpHint";
|
|
21
|
+
import { canPublishSource } from "../callState";
|
|
22
|
+
import type { CallStageRecording } from "../CallStage";
|
|
23
|
+
import { REACTION_EMOJI, type ReactionEmoji } from "./callSignals";
|
|
24
|
+
import { DeviceMenu } from "./DeviceMenu";
|
|
25
|
+
import type { StageTokens } from "./stageTheme";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The dark bottom bar: every control the call has, pinned where Leave lives.
|
|
29
|
+
*
|
|
30
|
+
* Mute stays a PAUSE, not a republish (Safari re-prompts on every
|
|
31
|
+
* `getUserMedia`), every toggle is disabled while its operation is in
|
|
32
|
+
* flight (`useLocalMedia` holds the real lock; this only says so), and every
|
|
33
|
+
* grant-gated control is drawn only when the token was minted for it. All of
|
|
34
|
+
* that is the previous stage's hard-won behavior, kept verbatim.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const LOST_SOURCE_KEYS: Record<LocalSource, string> = {
|
|
38
|
+
microphone: "forge.call_stage.lost_microphone",
|
|
39
|
+
camera: "forge.call_stage.lost_camera",
|
|
40
|
+
screen: "forge.call_stage.lost_screen",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const barButton = (tokens: StageTokens, active: boolean, busy = false, danger = false): CSSProperties => ({
|
|
44
|
+
display: "inline-flex",
|
|
45
|
+
alignItems: "center",
|
|
46
|
+
gap: "0.375rem",
|
|
47
|
+
padding: "0.5rem 0.7rem",
|
|
48
|
+
borderRadius: tokens.cornerRadius,
|
|
49
|
+
border: `1px solid ${danger ? tokens.danger : active ? tokens.accent : tokens.stageBorder}`,
|
|
50
|
+
backgroundColor: active ? tokens.accent : "transparent",
|
|
51
|
+
color: active ? tokens.accentText : tokens.stageText,
|
|
52
|
+
cursor: busy ? "progress" : "pointer",
|
|
53
|
+
opacity: busy ? 0.6 : 1,
|
|
54
|
+
font: "inherit",
|
|
55
|
+
fontSize: "0.8125rem",
|
|
56
|
+
whiteSpace: "nowrap",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export type CallControlsBarProps = {
|
|
60
|
+
tokens: StageTokens;
|
|
61
|
+
connected: boolean;
|
|
62
|
+
onLeave?: (() => void) | undefined;
|
|
63
|
+
recordingControl?: CallStageRecording | undefined;
|
|
64
|
+
/** Group calls opt the camera into simulcast so viewers can pick layers. */
|
|
65
|
+
cameraSimulcast?: boolean | undefined;
|
|
66
|
+
myHandRaised: boolean;
|
|
67
|
+
onRaiseHand: () => void;
|
|
68
|
+
onLowerHand: () => void;
|
|
69
|
+
onSendReaction: (emoji: ReactionEmoji) => void;
|
|
70
|
+
chatAvailable: boolean;
|
|
71
|
+
chatUnread: number;
|
|
72
|
+
onToggleChat: () => void;
|
|
73
|
+
onTogglePeople: () => void;
|
|
74
|
+
peopleOpen: boolean;
|
|
75
|
+
chatOpen: boolean;
|
|
76
|
+
/** Presses of any control unlock the chime AudioContext. */
|
|
77
|
+
onGesture?: (() => void) | undefined;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export function CallControlsBar(props: CallControlsBarProps) {
|
|
81
|
+
const { tokens } = props;
|
|
82
|
+
const t = useForgeT();
|
|
83
|
+
const grants = useGrants();
|
|
84
|
+
const { room } = useMediaRoom();
|
|
85
|
+
const media = useLocalMedia();
|
|
86
|
+
const publications = useLocalPublications();
|
|
87
|
+
|
|
88
|
+
const [reactionPickerOpen, setReactionPickerOpen] = useState(false);
|
|
89
|
+
const [confirmingLeave, setConfirmingLeave] = useState(false);
|
|
90
|
+
const [micDeviceId, setMicDeviceId] = useState<string | undefined>(undefined);
|
|
91
|
+
const [cameraDeviceId, setCameraDeviceId] = useState<string | undefined>(undefined);
|
|
92
|
+
const [mutedTalking, setMutedTalking] = useState(false);
|
|
93
|
+
const [mutedTalkingDismissed, setMutedTalkingDismissed] = useState(false);
|
|
94
|
+
|
|
95
|
+
const microphoneLive = media.isMicrophoneEnabled && !media.paused.microphone;
|
|
96
|
+
const cameraLive = media.isCameraEnabled && !media.paused.camera;
|
|
97
|
+
const localPresenting = media.screenSharing;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* "You appear to be muted." An analyser on the PAUSED microphone track: the
|
|
101
|
+
* capture stays open across a mute by design, so the track still carries
|
|
102
|
+
* the room's side of the person even though nothing is sent. Sustained
|
|
103
|
+
* level while muted lights a dismissible bar; a beat of silence clears the
|
|
104
|
+
* sustain so a cough does not.
|
|
105
|
+
*/
|
|
106
|
+
const micPublication = publications.find((p) => p.source === "microphone");
|
|
107
|
+
const mutedTrack = media.paused.microphone ? micPublication?.track : undefined;
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
setMutedTalking(false);
|
|
110
|
+
if (!mutedTrack || typeof AudioContext === "undefined") return undefined;
|
|
111
|
+
let context: AudioContext | undefined;
|
|
112
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
113
|
+
try {
|
|
114
|
+
context = new AudioContext();
|
|
115
|
+
const source = context.createMediaStreamSource(new MediaStream([mutedTrack]));
|
|
116
|
+
const analyser = context.createAnalyser();
|
|
117
|
+
analyser.fftSize = 512;
|
|
118
|
+
source.connect(analyser);
|
|
119
|
+
const samples = new Uint8Array(analyser.frequencyBinCount);
|
|
120
|
+
let loudSince: number | null = null;
|
|
121
|
+
timer = setInterval(() => {
|
|
122
|
+
analyser.getByteTimeDomainData(samples);
|
|
123
|
+
let sum = 0;
|
|
124
|
+
for (const value of samples) sum += (value - 128) * (value - 128);
|
|
125
|
+
const rms = Math.sqrt(sum / samples.length) / 128;
|
|
126
|
+
if (rms > 0.06) {
|
|
127
|
+
loudSince = loudSince ?? Date.now();
|
|
128
|
+
if (Date.now() - loudSince > 1500) setMutedTalking(true);
|
|
129
|
+
} else {
|
|
130
|
+
loudSince = null;
|
|
131
|
+
}
|
|
132
|
+
}, 300);
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
return () => {
|
|
137
|
+
if (timer) clearInterval(timer);
|
|
138
|
+
void context?.close().catch(() => undefined);
|
|
139
|
+
};
|
|
140
|
+
}, [mutedTrack]);
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
// A fresh mute gets a fresh chance to warn.
|
|
143
|
+
if (!media.paused.microphone) setMutedTalkingDismissed(false);
|
|
144
|
+
}, [media.paused.microphone]);
|
|
145
|
+
|
|
146
|
+
const gesture = () => props.onGesture?.();
|
|
147
|
+
|
|
148
|
+
const toggleMicrophone = () => {
|
|
149
|
+
gesture();
|
|
150
|
+
if (!media.isMicrophoneEnabled) {
|
|
151
|
+
void media.publishMicrophone(micDeviceId ? { deviceId: micDeviceId } : undefined);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
void media.setPaused("microphone", microphoneLive);
|
|
155
|
+
};
|
|
156
|
+
const toggleCamera = () => {
|
|
157
|
+
gesture();
|
|
158
|
+
if (!media.isCameraEnabled) {
|
|
159
|
+
void media.publishCamera({
|
|
160
|
+
...(cameraDeviceId ? { deviceId: cameraDeviceId } : {}),
|
|
161
|
+
...(props.cameraSimulcast ? { simulcast: true } : {}),
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
void media.setPaused("camera", cameraLive);
|
|
166
|
+
};
|
|
167
|
+
const toggleScreen = () => {
|
|
168
|
+
gesture();
|
|
169
|
+
void (localPresenting ? media.unpublish("screen") : media.publishScreen());
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const leave = useCallback(() => {
|
|
173
|
+
void room?.close().catch(() => undefined);
|
|
174
|
+
props.onLeave?.();
|
|
175
|
+
}, [room, props.onLeave]);
|
|
176
|
+
|
|
177
|
+
const recordingIsMine = !!props.recordingControl?.isRecording && !!props.recordingControl?.canControl;
|
|
178
|
+
const requestLeave = () => {
|
|
179
|
+
if (recordingIsMine) {
|
|
180
|
+
setConfirmingLeave(true);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
leave();
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const pickerRef = useRef<HTMLDivElement | null>(null);
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (!reactionPickerOpen) return undefined;
|
|
189
|
+
const close = (event: PointerEvent) => {
|
|
190
|
+
if (!pickerRef.current?.contains(event.target as Node)) setReactionPickerOpen(false);
|
|
191
|
+
};
|
|
192
|
+
window.addEventListener("pointerdown", close);
|
|
193
|
+
return () => window.removeEventListener("pointerdown", close);
|
|
194
|
+
}, [reactionPickerOpen]);
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<div
|
|
198
|
+
style={{
|
|
199
|
+
display: "flex",
|
|
200
|
+
flexWrap: "wrap",
|
|
201
|
+
alignItems: "center",
|
|
202
|
+
gap: "0.5rem",
|
|
203
|
+
padding: "0.5rem 0.75rem",
|
|
204
|
+
borderRadius: tokens.cornerRadius,
|
|
205
|
+
backgroundColor: tokens.stageChrome,
|
|
206
|
+
color: tokens.stageText,
|
|
207
|
+
marginTop: "auto",
|
|
208
|
+
flexShrink: 0,
|
|
209
|
+
}}
|
|
210
|
+
>
|
|
211
|
+
{canPublishSource(grants, "microphone") && (
|
|
212
|
+
<span style={{ display: "inline-flex" }}>
|
|
213
|
+
<button
|
|
214
|
+
type="button"
|
|
215
|
+
onClick={toggleMicrophone}
|
|
216
|
+
disabled={media.pending.microphone}
|
|
217
|
+
aria-busy={media.pending.microphone}
|
|
218
|
+
style={{
|
|
219
|
+
...barButton(tokens, microphoneLive, media.pending.microphone),
|
|
220
|
+
...(media.isMicrophoneEnabled ? { borderRadius: `${tokens.cornerRadius}px 0 0 ${tokens.cornerRadius}px` } : {}),
|
|
221
|
+
}}
|
|
222
|
+
>
|
|
223
|
+
{microphoneLive ? <Mic width={16} height={16} aria-hidden="true" /> : <MicOff width={16} height={16} aria-hidden="true" />}
|
|
224
|
+
{microphoneLive ? t("forge.call_stage.mute") : t("forge.call_stage.unmute")}
|
|
225
|
+
</button>
|
|
226
|
+
{media.isMicrophoneEnabled && (
|
|
227
|
+
<DeviceMenu
|
|
228
|
+
kind="audioinput"
|
|
229
|
+
currentDeviceId={micDeviceId}
|
|
230
|
+
onPick={(deviceId) => {
|
|
231
|
+
setMicDeviceId(deviceId);
|
|
232
|
+
void media.switchDevice("microphone", deviceId);
|
|
233
|
+
}}
|
|
234
|
+
tokens={tokens}
|
|
235
|
+
t={t}
|
|
236
|
+
label={t("forge.call_stage.pick_microphone")}
|
|
237
|
+
/>
|
|
238
|
+
)}
|
|
239
|
+
</span>
|
|
240
|
+
)}
|
|
241
|
+
|
|
242
|
+
{canPublishSource(grants, "camera") && (
|
|
243
|
+
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.25rem" }}>
|
|
244
|
+
<span style={{ display: "inline-flex" }}>
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
onClick={toggleCamera}
|
|
248
|
+
disabled={media.pending.camera}
|
|
249
|
+
aria-busy={media.pending.camera}
|
|
250
|
+
style={{
|
|
251
|
+
...barButton(tokens, cameraLive, media.pending.camera),
|
|
252
|
+
...(media.isCameraEnabled ? { borderRadius: `${tokens.cornerRadius}px 0 0 ${tokens.cornerRadius}px` } : {}),
|
|
253
|
+
}}
|
|
254
|
+
>
|
|
255
|
+
{cameraLive ? <Video width={16} height={16} aria-hidden="true" /> : <VideoOff width={16} height={16} aria-hidden="true" />}
|
|
256
|
+
{cameraLive ? t("forge.call_stage.camera_stop") : t("forge.call_stage.camera_start")}
|
|
257
|
+
</button>
|
|
258
|
+
{media.isCameraEnabled && (
|
|
259
|
+
<DeviceMenu
|
|
260
|
+
kind="videoinput"
|
|
261
|
+
currentDeviceId={cameraDeviceId}
|
|
262
|
+
onPick={(deviceId) => {
|
|
263
|
+
setCameraDeviceId(deviceId);
|
|
264
|
+
void media.switchDevice("camera", deviceId);
|
|
265
|
+
}}
|
|
266
|
+
tokens={tokens}
|
|
267
|
+
t={t}
|
|
268
|
+
label={t("forge.call_stage.pick_camera")}
|
|
269
|
+
/>
|
|
270
|
+
)}
|
|
271
|
+
</span>
|
|
272
|
+
<CallHelpHint label={t("forge.call_stage.help_camera_stop_label")}>{t("forge.call_stage.help_camera_stop")}</CallHelpHint>
|
|
273
|
+
</span>
|
|
274
|
+
)}
|
|
275
|
+
|
|
276
|
+
{canPublishSource(grants, "screen") && (
|
|
277
|
+
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.25rem" }}>
|
|
278
|
+
<button
|
|
279
|
+
type="button"
|
|
280
|
+
onClick={toggleScreen}
|
|
281
|
+
disabled={media.pending.screen}
|
|
282
|
+
aria-busy={media.pending.screen}
|
|
283
|
+
style={barButton(tokens, localPresenting, media.pending.screen)}
|
|
284
|
+
>
|
|
285
|
+
{localPresenting ? <MonitorX width={16} height={16} aria-hidden="true" /> : <MonitorUp width={16} height={16} aria-hidden="true" />}
|
|
286
|
+
{localPresenting ? t("forge.call_stage.screen_stop") : t("forge.call_stage.screen_start")}
|
|
287
|
+
</button>
|
|
288
|
+
<CallHelpHint label={t("forge.call_stage.help_screen_label")}>{t("forge.call_stage.help_screen")}</CallHelpHint>
|
|
289
|
+
</span>
|
|
290
|
+
)}
|
|
291
|
+
|
|
292
|
+
{props.recordingControl?.canControl && (
|
|
293
|
+
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.25rem" }}>
|
|
294
|
+
<button
|
|
295
|
+
type="button"
|
|
296
|
+
onClick={props.recordingControl.isRecording ? props.recordingControl.onStop : props.recordingControl.onStart}
|
|
297
|
+
disabled={props.recordingControl.pending}
|
|
298
|
+
aria-busy={props.recordingControl.pending}
|
|
299
|
+
style={barButton(tokens, !!props.recordingControl.isRecording, props.recordingControl.pending)}
|
|
300
|
+
>
|
|
301
|
+
{props.recordingControl.isRecording ? (
|
|
302
|
+
<Square width={16} height={16} aria-hidden="true" />
|
|
303
|
+
) : (
|
|
304
|
+
<Circle width={16} height={16} aria-hidden="true" />
|
|
305
|
+
)}
|
|
306
|
+
{props.recordingControl.isRecording ? t("forge.call_stage.record_stop") : t("forge.call_stage.record_start")}
|
|
307
|
+
</button>
|
|
308
|
+
<CallHelpHint label={t("forge.call_stage.help_record_label")}>{t("forge.call_stage.help_record")}</CallHelpHint>
|
|
309
|
+
</span>
|
|
310
|
+
)}
|
|
311
|
+
|
|
312
|
+
{/* Raise hand: participation data, grant-gated like every publish. */}
|
|
313
|
+
<button
|
|
314
|
+
type="button"
|
|
315
|
+
data-testid="raise-hand"
|
|
316
|
+
onClick={() => {
|
|
317
|
+
gesture();
|
|
318
|
+
void (props.myHandRaised ? props.onLowerHand() : props.onRaiseHand());
|
|
319
|
+
}}
|
|
320
|
+
aria-pressed={props.myHandRaised}
|
|
321
|
+
style={barButton(tokens, props.myHandRaised)}
|
|
322
|
+
>
|
|
323
|
+
<Hand width={16} height={16} aria-hidden="true" />
|
|
324
|
+
{props.myHandRaised ? t("forge.call_stage.lower_hand") : t("forge.call_stage.raise_hand")}
|
|
325
|
+
</button>
|
|
326
|
+
|
|
327
|
+
<div ref={pickerRef} style={{ position: "relative", display: "inline-flex" }}>
|
|
328
|
+
<button
|
|
329
|
+
type="button"
|
|
330
|
+
data-testid="reaction-picker"
|
|
331
|
+
onClick={() => {
|
|
332
|
+
gesture();
|
|
333
|
+
setReactionPickerOpen((current) => !current);
|
|
334
|
+
}}
|
|
335
|
+
aria-expanded={reactionPickerOpen}
|
|
336
|
+
aria-label={t("forge.call_stage.reactions")}
|
|
337
|
+
style={barButton(tokens, reactionPickerOpen)}
|
|
338
|
+
>
|
|
339
|
+
<SmilePlus width={16} height={16} aria-hidden="true" />
|
|
340
|
+
</button>
|
|
341
|
+
{reactionPickerOpen && (
|
|
342
|
+
<div
|
|
343
|
+
style={{
|
|
344
|
+
position: "absolute",
|
|
345
|
+
bottom: "calc(100% + 0.375rem)",
|
|
346
|
+
left: "50%",
|
|
347
|
+
transform: "translateX(-50%)",
|
|
348
|
+
display: "flex",
|
|
349
|
+
gap: "0.25rem",
|
|
350
|
+
padding: "0.375rem 0.5rem",
|
|
351
|
+
borderRadius: 9999,
|
|
352
|
+
backgroundColor: tokens.stageSurface,
|
|
353
|
+
border: `1px solid ${tokens.stageBorder}`,
|
|
354
|
+
boxShadow: "0 6px 24px rgba(0,0,0,0.5)",
|
|
355
|
+
zIndex: 10,
|
|
356
|
+
}}
|
|
357
|
+
>
|
|
358
|
+
{REACTION_EMOJI.map((emoji) => (
|
|
359
|
+
<button
|
|
360
|
+
key={emoji}
|
|
361
|
+
type="button"
|
|
362
|
+
onClick={() => {
|
|
363
|
+
props.onSendReaction(emoji);
|
|
364
|
+
setReactionPickerOpen(false);
|
|
365
|
+
}}
|
|
366
|
+
style={{ border: "none", background: "transparent", cursor: "pointer", fontSize: "1.25rem", padding: "0 0.125rem" }}
|
|
367
|
+
>
|
|
368
|
+
{emoji}
|
|
369
|
+
</button>
|
|
370
|
+
))}
|
|
371
|
+
</div>
|
|
372
|
+
)}
|
|
373
|
+
</div>
|
|
374
|
+
|
|
375
|
+
<span style={{ marginLeft: "auto", display: "inline-flex", gap: "0.5rem" }}>
|
|
376
|
+
{props.chatAvailable && (
|
|
377
|
+
<button
|
|
378
|
+
type="button"
|
|
379
|
+
data-testid="toggle-chat"
|
|
380
|
+
onClick={() => {
|
|
381
|
+
gesture();
|
|
382
|
+
props.onToggleChat();
|
|
383
|
+
}}
|
|
384
|
+
aria-pressed={props.chatOpen}
|
|
385
|
+
style={{ ...barButton(tokens, props.chatOpen), position: "relative" }}
|
|
386
|
+
>
|
|
387
|
+
<MessageSquare width={16} height={16} aria-hidden="true" />
|
|
388
|
+
{t("forge.call_stage.chat")}
|
|
389
|
+
{props.chatUnread > 0 && !props.chatOpen && (
|
|
390
|
+
<span
|
|
391
|
+
data-testid="chat-unread"
|
|
392
|
+
style={{
|
|
393
|
+
position: "absolute",
|
|
394
|
+
top: -6,
|
|
395
|
+
right: -6,
|
|
396
|
+
minWidth: 18,
|
|
397
|
+
height: 18,
|
|
398
|
+
borderRadius: 9999,
|
|
399
|
+
backgroundColor: tokens.danger,
|
|
400
|
+
color: "#ffffff",
|
|
401
|
+
fontSize: "0.6875rem",
|
|
402
|
+
display: "inline-flex",
|
|
403
|
+
alignItems: "center",
|
|
404
|
+
justifyContent: "center",
|
|
405
|
+
padding: "0 0.25rem",
|
|
406
|
+
}}
|
|
407
|
+
>
|
|
408
|
+
{props.chatUnread > 99 ? "99+" : props.chatUnread}
|
|
409
|
+
</span>
|
|
410
|
+
)}
|
|
411
|
+
</button>
|
|
412
|
+
)}
|
|
413
|
+
<button
|
|
414
|
+
type="button"
|
|
415
|
+
data-testid="toggle-people"
|
|
416
|
+
onClick={() => {
|
|
417
|
+
gesture();
|
|
418
|
+
props.onTogglePeople();
|
|
419
|
+
}}
|
|
420
|
+
aria-pressed={props.peopleOpen}
|
|
421
|
+
style={barButton(tokens, props.peopleOpen)}
|
|
422
|
+
>
|
|
423
|
+
<Users width={16} height={16} aria-hidden="true" />
|
|
424
|
+
{t("forge.call_stage.people")}
|
|
425
|
+
</button>
|
|
426
|
+
<button type="button" onClick={requestLeave} style={barButton(tokens, false, false, true)}>
|
|
427
|
+
<PhoneOff width={16} height={16} aria-hidden="true" color={tokens.danger} />
|
|
428
|
+
{t("forge.call_stage.leave")}
|
|
429
|
+
</button>
|
|
430
|
+
</span>
|
|
431
|
+
|
|
432
|
+
{confirmingLeave && (
|
|
433
|
+
<LeaveRecordingDialog
|
|
434
|
+
tokens={tokens}
|
|
435
|
+
t={t}
|
|
436
|
+
onStopAndLeave={() => {
|
|
437
|
+
// Stop FIRST, then leave: the other order closes the connection
|
|
438
|
+
// the stop request rides on.
|
|
439
|
+
props.recordingControl?.onStop();
|
|
440
|
+
setConfirmingLeave(false);
|
|
441
|
+
leave();
|
|
442
|
+
}}
|
|
443
|
+
onLeaveKeep={() => {
|
|
444
|
+
setConfirmingLeave(false);
|
|
445
|
+
leave();
|
|
446
|
+
}}
|
|
447
|
+
onStay={() => setConfirmingLeave(false)}
|
|
448
|
+
/>
|
|
449
|
+
)}
|
|
450
|
+
|
|
451
|
+
{media.error && (
|
|
452
|
+
<span role="alert" style={{ width: "100%", fontSize: "0.8125rem", color: tokens.stageText }}>
|
|
453
|
+
{media.error.message}
|
|
454
|
+
</span>
|
|
455
|
+
)}
|
|
456
|
+
|
|
457
|
+
{mutedTalking && !mutedTalkingDismissed && media.paused.microphone && (
|
|
458
|
+
<span
|
|
459
|
+
role="status"
|
|
460
|
+
data-testid="muted-talking"
|
|
461
|
+
style={{ width: "100%", display: "flex", alignItems: "center", gap: "0.5rem", fontSize: "0.8125rem", color: tokens.stageText }}
|
|
462
|
+
>
|
|
463
|
+
{t("forge.call_stage.muted_talking")}
|
|
464
|
+
<button type="button" onClick={toggleMicrophone} style={barButton(tokens, true)}>
|
|
465
|
+
{t("forge.call_stage.unmute")}
|
|
466
|
+
</button>
|
|
467
|
+
<button type="button" onClick={() => setMutedTalkingDismissed(true)} style={barButton(tokens, false)}>
|
|
468
|
+
{t("forge.call_stage.dismiss")}
|
|
469
|
+
</button>
|
|
470
|
+
</span>
|
|
471
|
+
)}
|
|
472
|
+
|
|
473
|
+
{props.connected && media.lostSources.length > 0 && (
|
|
474
|
+
<span role="alert" style={{ width: "100%", fontSize: "0.8125rem", color: tokens.stageText }}>
|
|
475
|
+
{media.lostSources.map((source) => (
|
|
476
|
+
<span key={source} style={{ display: "block" }}>
|
|
477
|
+
{t(LOST_SOURCE_KEYS[source])}
|
|
478
|
+
</span>
|
|
479
|
+
))}
|
|
480
|
+
</span>
|
|
481
|
+
)}
|
|
482
|
+
</div>
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function LeaveRecordingDialog({
|
|
487
|
+
tokens,
|
|
488
|
+
t,
|
|
489
|
+
onStopAndLeave,
|
|
490
|
+
onLeaveKeep,
|
|
491
|
+
onStay,
|
|
492
|
+
}: {
|
|
493
|
+
tokens: StageTokens;
|
|
494
|
+
t: ForgeT;
|
|
495
|
+
onStopAndLeave: () => void;
|
|
496
|
+
onLeaveKeep: () => void;
|
|
497
|
+
onStay: () => void;
|
|
498
|
+
}) {
|
|
499
|
+
return (
|
|
500
|
+
<div
|
|
501
|
+
role="dialog"
|
|
502
|
+
aria-modal="true"
|
|
503
|
+
aria-label={t("forge.call_stage.leave_recording_title")}
|
|
504
|
+
style={{
|
|
505
|
+
position: "fixed",
|
|
506
|
+
inset: 0,
|
|
507
|
+
zIndex: 60,
|
|
508
|
+
display: "flex",
|
|
509
|
+
alignItems: "center",
|
|
510
|
+
justifyContent: "center",
|
|
511
|
+
padding: "1rem",
|
|
512
|
+
background: "rgba(0,0,0,0.6)",
|
|
513
|
+
}}
|
|
514
|
+
>
|
|
515
|
+
<div
|
|
516
|
+
style={{
|
|
517
|
+
maxWidth: 420,
|
|
518
|
+
width: "100%",
|
|
519
|
+
display: "flex",
|
|
520
|
+
flexDirection: "column",
|
|
521
|
+
gap: "0.75rem",
|
|
522
|
+
padding: "1.25rem",
|
|
523
|
+
borderRadius: tokens.cornerRadius,
|
|
524
|
+
background: tokens.stageSurface,
|
|
525
|
+
border: `1px solid ${tokens.stageBorder}`,
|
|
526
|
+
color: tokens.stageText,
|
|
527
|
+
}}
|
|
528
|
+
>
|
|
529
|
+
<strong style={{ fontSize: "1rem" }}>{t("forge.call_stage.leave_recording_title")}</strong>
|
|
530
|
+
{/* A warning about what a control is about to do must be read, so it
|
|
531
|
+
is content in the dialog, never a `?` hint. */}
|
|
532
|
+
<p style={{ margin: 0, fontSize: "0.875rem", opacity: 0.85 }}>{t("forge.call_stage.leave_recording_body")}</p>
|
|
533
|
+
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
|
|
534
|
+
<button type="button" onClick={onStopAndLeave} style={barButton(tokens, true)}>
|
|
535
|
+
{t("forge.call_stage.leave_recording_stop")}
|
|
536
|
+
</button>
|
|
537
|
+
<button type="button" onClick={onLeaveKeep} style={barButton(tokens, false)}>
|
|
538
|
+
{t("forge.call_stage.leave_recording_keep")}
|
|
539
|
+
</button>
|
|
540
|
+
<button type="button" onClick={onStay} style={{ ...barButton(tokens, false), marginLeft: "auto" }}>
|
|
541
|
+
{t("forge.call_stage.leave_recording_cancel")}
|
|
542
|
+
</button>
|
|
543
|
+
</div>
|
|
544
|
+
</div>
|
|
545
|
+
</div>
|
|
546
|
+
);
|
|
547
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
import { useCallDiagnostics, type TileDiagnostics } from "@tribe-nest/media-client/react";
|
|
4
|
+
|
|
5
|
+
import type { ForgeT } from "../../../i18n";
|
|
6
|
+
import { CallHelpHint } from "../CallHelpHint";
|
|
7
|
+
import type { StageTokens } from "./stageTheme";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Call health, in the call, replacing an hour in webrtc-internals.
|
|
11
|
+
*
|
|
12
|
+
* Per video tile: the simulcast layer this client ASKED for, the layer the
|
|
13
|
+
* node is GIVING, and the picture actually being decoded. Above them, the
|
|
14
|
+
* node's bandwidth estimate (the number that pins everyone when it sits at
|
|
15
|
+
* the floor) and this side's own publish health (a layer scored 0 is a layer
|
|
16
|
+
* the node never receives). Together they answer "why is this blurry" with
|
|
17
|
+
* WHICH of the three culprits it is, live, once a second while open.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** 0/1/2 to the rid names every WebRTC log uses. */
|
|
21
|
+
const LAYER_NAMES = ["q", "h", "f"] as const;
|
|
22
|
+
const layerName = (layer: number | undefined): string =>
|
|
23
|
+
layer === undefined ? "-" : (LAYER_NAMES[layer] ?? String(layer));
|
|
24
|
+
|
|
25
|
+
export function CallHealthPanel({
|
|
26
|
+
tokens,
|
|
27
|
+
t,
|
|
28
|
+
names,
|
|
29
|
+
onClose,
|
|
30
|
+
}: {
|
|
31
|
+
tokens: StageTokens;
|
|
32
|
+
t: ForgeT;
|
|
33
|
+
/** Identity to display name, from the participants the stage already holds. */
|
|
34
|
+
names: ReadonlyMap<string, string>;
|
|
35
|
+
onClose: () => void;
|
|
36
|
+
}) {
|
|
37
|
+
const diagnostics = useCallDiagnostics(true);
|
|
38
|
+
|
|
39
|
+
const row = (label: string, value: string, warn = false) => (
|
|
40
|
+
<div style={{ display: "flex", justifyContent: "space-between", gap: "0.75rem" }}>
|
|
41
|
+
<span style={{ color: tokens.stageMuted, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label}</span>
|
|
42
|
+
<span style={{ color: warn ? tokens.danger : tokens.stageText, whiteSpace: "nowrap" }}>{value}</span>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const videoTiles = (diagnostics?.tiles ?? []).filter((tile) => tile.kind === "video");
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
role="region"
|
|
51
|
+
aria-label={t("forge.call_stage.health_title")}
|
|
52
|
+
style={{
|
|
53
|
+
position: "absolute",
|
|
54
|
+
top: "0.5rem",
|
|
55
|
+
right: "0.5rem",
|
|
56
|
+
width: "min(20rem, calc(100% - 1rem))",
|
|
57
|
+
maxHeight: "calc(100% - 1rem)",
|
|
58
|
+
overflowY: "auto",
|
|
59
|
+
zIndex: 20,
|
|
60
|
+
display: "flex",
|
|
61
|
+
flexDirection: "column",
|
|
62
|
+
gap: "0.375rem",
|
|
63
|
+
padding: "0.625rem 0.75rem",
|
|
64
|
+
backgroundColor: tokens.stageChrome,
|
|
65
|
+
border: `1px solid ${tokens.stageBorder}`,
|
|
66
|
+
borderRadius: tokens.cornerRadius,
|
|
67
|
+
fontSize: "0.72rem",
|
|
68
|
+
fontVariantNumeric: "tabular-nums",
|
|
69
|
+
color: tokens.stageText,
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.375rem" }}>
|
|
73
|
+
<span style={{ fontWeight: 600 }}>{t("forge.call_stage.health_title")}</span>
|
|
74
|
+
<CallHelpHint label={t("forge.call_stage.help_health_label")}>{t("forge.call_stage.help_health")}</CallHelpHint>
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
onClick={onClose}
|
|
78
|
+
aria-label={t("forge.call_stage.health_close")}
|
|
79
|
+
style={{ marginLeft: "auto", background: "none", border: "none", cursor: "pointer", color: tokens.stageMuted, display: "inline-flex", padding: 2 }}
|
|
80
|
+
>
|
|
81
|
+
<X width={14} height={14} aria-hidden="true" />
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
{!diagnostics && <div style={{ color: tokens.stageMuted }}>{t("forge.call_stage.health_measuring")}</div>}
|
|
86
|
+
|
|
87
|
+
{diagnostics && (
|
|
88
|
+
<>
|
|
89
|
+
{diagnostics.availableOutgoingBitrate !== undefined &&
|
|
90
|
+
row(
|
|
91
|
+
t("forge.call_stage.health_estimate"),
|
|
92
|
+
`${Math.round(diagnostics.availableOutgoingBitrate / 1000)} kbps`,
|
|
93
|
+
// The floor mediasoup starts at; an estimate still there with
|
|
94
|
+
// several tiles is the estimator pinning everyone low.
|
|
95
|
+
diagnostics.availableOutgoingBitrate <= 700_000 && videoTiles.length > 1,
|
|
96
|
+
)}
|
|
97
|
+
{(diagnostics.localProducers ?? [])
|
|
98
|
+
.filter((producer) => producer.kind === "video")
|
|
99
|
+
.map((producer) =>
|
|
100
|
+
row(
|
|
101
|
+
`${t("forge.call_stage.health_sending")}${producer.source ? ` (${producer.source})` : ""}`,
|
|
102
|
+
producer.layers.map((layer) => `${layer.rid ?? "?"}:${layer.score}`).join(" "),
|
|
103
|
+
producer.layers.some((layer) => layer.score === 0),
|
|
104
|
+
),
|
|
105
|
+
)}
|
|
106
|
+
{videoTiles.map((tile) => row(tileLabel(tile, names), tileValue(tile), isDowngraded(tile)))}
|
|
107
|
+
{videoTiles.length === 0 && <div style={{ color: tokens.stageMuted }}>{t("forge.call_stage.health_no_video")}</div>}
|
|
108
|
+
</>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function tileLabel(tile: TileDiagnostics, names: ReadonlyMap<string, string>): string {
|
|
115
|
+
const name = names.get(tile.identity) ?? tile.identity;
|
|
116
|
+
return tile.source === "screen" ? `${name} (screen)` : name;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function tileValue(tile: TileDiagnostics): string {
|
|
120
|
+
const layers = `${layerName(tile.requestedLayer)}>${layerName(tile.currentLayer)}`;
|
|
121
|
+
const picture =
|
|
122
|
+
tile.frameWidth !== undefined && tile.frameHeight !== undefined
|
|
123
|
+
? ` ${tile.frameWidth}x${tile.frameHeight}${tile.framesPerSecond !== undefined ? `@${Math.round(tile.framesPerSecond)}` : ""}`
|
|
124
|
+
: "";
|
|
125
|
+
const rate = tile.bitrateKbps !== undefined ? ` ${tile.bitrateKbps}kbps` : "";
|
|
126
|
+
return `${layers}${picture}${rate}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Getting less than asked is the line worth coloring. */
|
|
130
|
+
function isDowngraded(tile: TileDiagnostics): boolean {
|
|
131
|
+
return tile.requestedLayer !== undefined && tile.currentLayer !== undefined && tile.currentLayer < tile.requestedLayer;
|
|
132
|
+
}
|