@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.
Files changed (49) hide show
  1. package/package.json +2 -2
  2. package/src/data/queries/useCallChat.ts +162 -0
  3. package/src/data/queries/useCallRecording.ts +80 -0
  4. package/src/data/queries/useMembershipCalls.ts +212 -0
  5. package/src/i18n/de.json +74 -1
  6. package/src/i18n/en.json +74 -1
  7. package/src/index.ts +7 -0
  8. package/src/types/models.ts +58 -5
  9. package/src/ui/headless/memberHome/index.ts +4 -0
  10. package/src/ui/headless/memberHome/useMemberCalls.ts +150 -0
  11. package/src/ui/headless/memberHome/useMemberHomeNav.ts +29 -5
  12. package/src/ui/index.ts +3 -0
  13. package/src/ui/media/CallStage.tsx +691 -553
  14. package/src/ui/media/PreJoin.tsx +269 -0
  15. package/src/ui/media/_tests/CallStage.spec.tsx +338 -5
  16. package/src/ui/media/_tests/callLayout.spec.ts +182 -0
  17. package/src/ui/media/_tests/callSignals.spec.ts +87 -0
  18. package/src/ui/media/_tests/callState.spec.ts +93 -1
  19. package/src/ui/media/_tests/fakeNode.ts +4 -1
  20. package/src/ui/media/callChat.ts +33 -0
  21. package/src/ui/media/callState.ts +125 -9
  22. package/src/ui/media/hooks/useCallSignals.ts +135 -0
  23. package/src/ui/media/hooks/useMeasuredWidth.ts +39 -0
  24. package/src/ui/media/index.ts +41 -1
  25. package/src/ui/media/panels/CallChatPanel.tsx +200 -0
  26. package/src/ui/media/panels/CallParticipantsPanel.tsx +125 -0
  27. package/src/ui/media/panels/CallSidePanel.tsx +152 -0
  28. package/src/ui/media/stage/CallCenterNotice.tsx +89 -0
  29. package/src/ui/media/stage/CallControlsBar.tsx +547 -0
  30. package/src/ui/media/stage/CallHealthPanel.tsx +132 -0
  31. package/src/ui/media/stage/CallToasts.tsx +49 -0
  32. package/src/ui/media/stage/CallTopBar.tsx +257 -0
  33. package/src/ui/media/stage/DeviceMenu.tsx +142 -0
  34. package/src/ui/media/stage/ReactionOverlay.tsx +38 -0
  35. package/src/ui/media/stage/SelfView.tsx +146 -0
  36. package/src/ui/media/stage/VideoTile.tsx +232 -0
  37. package/src/ui/media/stage/callLayout.ts +253 -0
  38. package/src/ui/media/stage/callSignals.ts +91 -0
  39. package/src/ui/media/stage/callSounds.ts +78 -0
  40. package/src/ui/media/stage/stageTheme.ts +50 -0
  41. package/src/ui/styled/CohortPage.tsx +15 -1
  42. package/src/ui/styled/CourseAccess.tsx +19 -6
  43. package/src/ui/styled/CourseCallsSection.tsx +99 -0
  44. package/src/ui/styled/FormRenderer.tsx +14 -4
  45. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +163 -0
  46. package/src/ui/styled/forge-utilities.css +13 -0
  47. package/src/ui/styled/members/MemberCallsCalendar.tsx +589 -0
  48. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +135 -0
  49. 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
+ }