@rapidmx/meet-plugin 0.9.3 → 0.10.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 (52) hide show
  1. package/apps/meet/[token].tsx +204 -13
  2. package/apps/meet/_CallControls.tsx +64 -3
  3. package/apps/meet/_CallView.tsx +244 -7
  4. package/apps/meet/_DiagnosticsPanel.tsx +113 -0
  5. package/apps/meet/_ParticipantTile.tsx +10 -2
  6. package/apps/meet/_ParticipantsDrawer.tsx +341 -0
  7. package/apps/meet/_icons.tsx +12 -0
  8. package/apps/meet/_meetApi.ts +141 -1
  9. package/apps/shared/media/filters/ScreenTransform.ts +148 -0
  10. package/apps/shared/media/useLocalMedia.ts +7 -3
  11. package/apps/shared/webrtc/MeshConnectionManager.ts +89 -4
  12. package/apps/shared/webrtc/realPeerConnection.ts +47 -7
  13. package/apps/shared/webrtc/types.ts +55 -7
  14. package/dist/apps/meet/[token].d.ts +4 -1
  15. package/dist/apps/meet/[token].js +121 -9
  16. package/dist/apps/meet/_CallControls.d.ts +9 -1
  17. package/dist/apps/meet/_CallControls.js +6 -5
  18. package/dist/apps/meet/_CallView.d.ts +28 -2
  19. package/dist/apps/meet/_CallView.js +155 -6
  20. package/dist/apps/meet/_DiagnosticsPanel.d.ts +22 -0
  21. package/dist/apps/meet/_DiagnosticsPanel.js +32 -0
  22. package/dist/apps/meet/_ParticipantTile.d.ts +8 -0
  23. package/dist/apps/meet/_ParticipantTile.js +9 -2
  24. package/dist/apps/meet/_ParticipantsDrawer.d.ts +61 -0
  25. package/dist/apps/meet/_ParticipantsDrawer.js +86 -0
  26. package/dist/apps/meet/_icons.d.ts +3 -0
  27. package/dist/apps/meet/_icons.js +3 -0
  28. package/dist/apps/meet/_meetApi.d.ts +93 -1
  29. package/dist/apps/meet/_meetApi.js +82 -0
  30. package/dist/apps/shared/media/filters/ScreenTransform.d.ts +53 -0
  31. package/dist/apps/shared/media/filters/ScreenTransform.js +100 -0
  32. package/dist/apps/shared/media/useLocalMedia.d.ts +5 -1
  33. package/dist/apps/shared/media/useLocalMedia.js +3 -3
  34. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +30 -1
  35. package/dist/apps/shared/webrtc/MeshConnectionManager.js +78 -3
  36. package/dist/apps/shared/webrtc/realPeerConnection.d.ts +19 -6
  37. package/dist/apps/shared/webrtc/realPeerConnection.js +42 -5
  38. package/dist/apps/shared/webrtc/types.d.ts +54 -6
  39. package/dist/lib/models/mongo/VideoMeetingMongo.js +21 -0
  40. package/dist/lib/models/mongo/VideoMeetingMongo.js.map +1 -1
  41. package/dist/lib/models/sql/VideoMeetingSQL.js +21 -0
  42. package/dist/lib/models/sql/VideoMeetingSQL.js.map +1 -1
  43. package/dist/lib/routes/BaseVideoMeetingRoute.js +380 -51
  44. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  45. package/dist/lib/util/PasswordUtils.js +52 -0
  46. package/dist/lib/util/PasswordUtils.js.map +1 -0
  47. package/dist/types/models/mongo/VideoMeetingMongo.d.ts +3 -0
  48. package/dist/types/models/sql/VideoMeetingSQL.d.ts +3 -0
  49. package/dist/types/models/types.d.ts +23 -0
  50. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +147 -5
  51. package/dist/types/util/PasswordUtils.d.ts +17 -0
  52. package/package.json +1 -1
@@ -39,6 +39,7 @@ import { playRaisedHandChime } from "../shared/media/chime.js";
39
39
  import { pickActiveSpeaker } from "../shared/media/activeSpeaker.js";
40
40
  import { startLevelMeter, type LevelMeterHandle } from "../shared/media/levelMeter.js";
41
41
  import { requestDisplayMedia, stopStream } from "../shared/media/deviceMedia.js";
42
+ import { NO_SCREEN_TRANSFORM, ScreenTransformProcessor, rotateClockwise, type ScreenTransformState } from "../shared/media/filters/ScreenTransform.js";
42
43
  import type { LocalMedia } from "../shared/media/useLocalMedia.js";
43
44
  import { createBrowserPeerConnection } from "../shared/webrtc/realPeerConnection.js";
44
45
  import { MeshConnectionManager } from "../shared/webrtc/MeshConnectionManager.js";
@@ -46,7 +47,18 @@ import type { MeshParticipant, RelayTransportLike } from "../shared/webrtc/types
46
47
  import { createRelayTransport } from "../shared/relay/RelayTransport.js";
47
48
  import { GuestSignalingClient } from "../shared/push/GuestSignalingClient.js";
48
49
  import CallControls, { type CallViewMode } from "./_CallControls.js";
50
+ import {
51
+ admitParticipant,
52
+ denyParticipant,
53
+ kickParticipant,
54
+ listWaitingParticipants,
55
+ setForceMuteOnJoin as apiSetForceMuteOnJoin,
56
+ setMeetingPassword,
57
+ setWaitingRoomEnabled as apiSetWaitingRoomEnabled,
58
+ type WaitingParticipant,
59
+ } from "./_meetApi.js";
49
60
  import ParticipantTile from "./_ParticipantTile.js";
61
+ import ParticipantsDrawer from "./_ParticipantsDrawer.js";
50
62
 
51
63
  export interface CallViewProps {
52
64
  channel: string;
@@ -61,15 +73,37 @@ export interface CallViewProps {
61
73
  /** Whether the server offers the WebSocket media relay, the last-resort path for a participant neither a direct
62
74
  * connection nor the TURN server can reach - `VideoMeetingJoinResult.relayEnabled`. Off when absent. */
63
75
  relayEnabled?: boolean;
76
+ /** The meeting's host account uid (`PublicVideoMeeting.hostUid`) - this client's only source of a "host"
77
+ * identity, purely for deciding which controls to show (see `BaseVideoMeetingRoute`'s class doc comment on why
78
+ * this is a client-side-only check, not a server-enforced one for every moderation action). Absent when the
79
+ * server couldn't resolve one, in which case nobody sees host controls. */
80
+ hostUid?: string;
81
+ /** `PublicVideoMeeting.forceMuteOnJoin` as of this call's own `join()` - the starting value for the host's
82
+ * toggle in the participants drawer, which this view then owns and keeps current itself (see
83
+ * `handleToggleForceMuteOnJoin()`). Does not update if changed elsewhere while this view is mounted - there is
84
+ * no signal for that today, matching every other meeting-settings field's lack of live sync. */
85
+ initialForceMuteOnJoin?: boolean;
86
+ /** `PublicVideoMeeting.hasPassword` as of this call's own `join()` - the starting value for the host's password
87
+ * section in the participants drawer, same "owned and kept current by this view" shape as
88
+ * `initialForceMuteOnJoin`. */
89
+ initialHasPassword?: boolean;
90
+ /** `PublicVideoMeeting.waitingRoomEnabled` as of this call's own `join()` - the starting value for the host's
91
+ * waiting-room toggle, same "owned and kept current by this view" shape as `initialForceMuteOnJoin`. */
92
+ initialWaitingRoomEnabled?: boolean;
64
93
  /** The camera and microphone, owned by the page (`[token].tsx`) - the lobby's tracks carried into the call. */
65
94
  media: LocalMedia;
66
- onLeave: () => void;
95
+ /** Called once the participant leaves, for any reason. `reason` is set only when the call ended without the
96
+ * participant's own action - currently, being kicked by the host - so the caller can show a distinct message
97
+ * instead of the ordinary "you left" one; omitted for an ordinary voluntary leave. */
98
+ onLeave: (reason?: string) => void;
67
99
  }
68
100
 
69
101
  /** How long a reaction floats on screen. */
70
102
  const REACTION_MS = 4_000;
71
103
  /** The most reactions shown at once - a burst beyond this drops the oldest. */
72
104
  const MAX_REACTIONS = 12;
105
+ /** How often the host's drawer refreshes its own waiting-room list while open. */
106
+ const DEFAULT_WAITING_POLL_MS = 3_000;
73
107
 
74
108
  interface Reaction {
75
109
  id: number;
@@ -104,7 +138,31 @@ export function newPeerId(selfUid: string): string {
104
138
  return `${selfUid}~${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
105
139
  }
106
140
 
107
- export default function CallView({ channel, token, selfUid, selfName, meetingTitle, iceServers, relayEnabled, media, onLeave }: CallViewProps) {
141
+ /** The real, server-enforced account uid behind a participant's tab-scoped `MeshParticipant.uid` (`newPeerId()`'s
142
+ * `<account uid>~<random>` shape) - what the kick route's ACL grant is actually keyed by, unlike the tab-scoped id
143
+ * this view otherwise addresses a specific tab with. Neither a real account uid nor a minted guest uid (see
144
+ * `GUEST_UID_PREFIX`/`mintGuestToken()` on the backend) can itself contain "~", so the last one found is always
145
+ * exactly this suffix's own separator. */
146
+ export function accountUidOf(peerUid: string): string {
147
+ const i = peerUid.lastIndexOf("~");
148
+ return i === -1 ? peerUid : peerUid.slice(0, i);
149
+ }
150
+
151
+ export default function CallView({
152
+ channel,
153
+ token,
154
+ selfUid,
155
+ selfName,
156
+ meetingTitle,
157
+ iceServers,
158
+ relayEnabled,
159
+ hostUid,
160
+ initialForceMuteOnJoin,
161
+ initialHasPassword,
162
+ initialWaitingRoomEnabled,
163
+ media,
164
+ onLeave,
165
+ }: CallViewProps) {
108
166
  const [peerId] = useState(() => newPeerId(selfUid));
109
167
  const [connectError, setConnectError] = useState<string | null>(null);
110
168
  const [participants, setParticipants] = useState<MeshParticipant[]>([]);
@@ -112,6 +170,7 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
112
170
  const [levels, setLevels] = useState<Record<string, number>>({});
113
171
  const [presenterUid, setPresenterUid] = useState<string | undefined>(undefined);
114
172
  const [screenStream, setScreenStream] = useState<MediaStream | null>(null);
173
+ const [screenTransform, setScreenTransform] = useState<ScreenTransformState>(NO_SCREEN_TRANSFORM);
115
174
  const [handRaised, setHandRaised] = useState(false);
116
175
  const [viewMode, setViewMode] = useState<CallViewMode>("grid");
117
176
  const [pinnedUid, setPinnedUid] = useState<string | undefined>(undefined);
@@ -121,12 +180,27 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
121
180
  const [audioBlocked, setAudioBlocked] = useState(false);
122
181
  const [audioNonce, setAudioNonce] = useState(0);
123
182
  const [signalingReady, setSignalingReady] = useState(false);
183
+ const [drawerOpen, setDrawerOpen] = useState(false);
184
+ const [forceMuteOnJoin, setForceMuteOnJoinState] = useState(!!initialForceMuteOnJoin);
185
+ const [hasPassword, setHasPassword] = useState(!!initialHasPassword);
186
+ const [waitingRoomEnabled, setWaitingRoomEnabledState] = useState(!!initialWaitingRoomEnabled);
187
+ const [waitingParticipants, setWaitingParticipants] = useState<WaitingParticipant[]>([]);
124
188
 
125
189
  const managerRef = useRef<MeshConnectionManager | null>(null);
190
+ /** The raw capture from `getDisplayMedia()` - only ever used to stop it (releasing the OS's own share
191
+ * indicator) and to notice the participant ending the share from the browser's own UI (`track.onended`). What's
192
+ * actually shown and sent is `screenTransformRef`'s output (`screenStream` state) - see `handleToggleShare()`. */
126
193
  const screenStreamRef = useRef<MediaStream | null>(null);
194
+ const screenTransformRef = useRef<ScreenTransformProcessor | null>(null);
127
195
  const levelMetersRef = useRef<Record<string, LevelMeterHandle>>({});
128
196
  const reactionSeqRef = useRef(0);
129
197
  const reactionTimersRef = useRef(new Set<ReturnType<typeof setTimeout>>());
198
+ /** The latest `media`, for the mesh-event handler below - that effect runs once at mount (see its own comment),
199
+ * so it would otherwise see only the mic state/toggle function from that first render. */
200
+ const mediaRef = useRef(media);
201
+ useEffect(() => {
202
+ mediaRef.current = media;
203
+ }, [media]);
130
204
  const isPresenting = !!screenStream;
131
205
 
132
206
  const showReaction = useCallback((emoji: string, name: string) => {
@@ -198,6 +272,14 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
198
272
  stopLocalPresentation(false);
199
273
  }
200
274
  return;
275
+ case "mute-requested":
276
+ if (mediaRef.current.micOn) {
277
+ void mediaRef.current.toggleMic();
278
+ }
279
+ return;
280
+ case "kicked":
281
+ onLeave("The host removed you from this call.");
282
+ return;
201
283
  }
202
284
  });
203
285
 
@@ -234,6 +316,7 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
234
316
  // Only the screen capture is this view's to stop - the camera and microphone belong to the page, which
235
317
  // releases them when the participant leaves.
236
318
  stopStream(screenStreamRef.current);
319
+ screenTransformRef.current?.stop();
237
320
  for (const timer of timers) {
238
321
  clearTimeout(timer);
239
322
  }
@@ -266,8 +349,12 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
266
349
  }
267
350
  stopStream(screenStreamRef.current);
268
351
  screenStreamRef.current = null;
269
- // The sync effect above swaps the camera back in as the outgoing video track.
352
+ screenTransformRef.current?.stop();
353
+ screenTransformRef.current = null;
354
+ // The sync effect above swaps the camera back in as the outgoing video track. The rotation/flip is reset
355
+ // for the next share - each share starts from the capture the browser hands back, not the last one's fix.
270
356
  setScreenStream(null);
357
+ setScreenTransform(NO_SCREEN_TRANSFORM);
271
358
  }
272
359
 
273
360
  async function handleToggleShare() {
@@ -286,8 +373,35 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
286
373
  return;
287
374
  }
288
375
  screenStreamRef.current = result.value;
289
- setScreenStream(result.value);
290
- result.value.getVideoTracks()[0].onended = () => stopLocalPresentation(true);
376
+ const rawTrack = result.value.getVideoTracks()[0];
377
+ rawTrack.onended = () => stopLocalPresentation(true);
378
+ try {
379
+ // Runs the capture through the rotate/flip processor before anyone (presenter included) sees it - see
380
+ // ScreenTransform.ts's doc comment on why window capture sometimes needs this correction.
381
+ const processor = new ScreenTransformProcessor({ source: rawTrack, state: screenTransform });
382
+ screenTransformRef.current = processor;
383
+ setScreenStream(new MediaStream([processor.track]));
384
+ } catch {
385
+ // This browser can't draw the correction - share the raw capture rather than not sharing at all; the
386
+ // rotate/flip buttons simply won't do anything (screenTransformRef stays null).
387
+ setScreenStream(result.value);
388
+ }
389
+ }
390
+
391
+ function handleRotateScreen() {
392
+ setScreenTransform((prev) => {
393
+ const next = rotateClockwise(prev);
394
+ screenTransformRef.current?.setState(next);
395
+ return next;
396
+ });
397
+ }
398
+
399
+ function handleFlipScreen() {
400
+ setScreenTransform((prev) => {
401
+ const next = { ...prev, flipped: !prev.flipped };
402
+ screenTransformRef.current?.setState(next);
403
+ return next;
404
+ });
291
405
  }
292
406
 
293
407
  function handleToggleHand() {
@@ -304,6 +418,64 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
304
418
  setPinnedUid((prev) => (prev === uid ? undefined : uid));
305
419
  }
306
420
 
421
+ /** Asks `peerUid`'s participant to mute - the host-only button in the participants drawer calls this
422
+ * directly, with no confirmation (unlike removing someone, muting is easily undone by the participant
423
+ * themselves). */
424
+ function handleMuteParticipant(peerUid: string) {
425
+ managerRef.current?.sendMuteRequest(peerUid);
426
+ }
427
+
428
+ /** Removes `peerUid`'s participant from the call: the cooperative signal (immediate, so a cooperating client
429
+ * leaves without waiting on the network round trip below) and the enforced server-side revoke together - see
430
+ * `kickParticipant()`'s own doc comment for why both matter. The REST call's failure is swallowed rather than
431
+ * surfaced: the host already asked them to leave, and there is no useful recovery action to offer from here
432
+ * for what is almost always a transient network issue. */
433
+ function handleKickParticipant(peerUid: string) {
434
+ managerRef.current?.sendKick(peerUid);
435
+ kickParticipant(channel, accountUidOf(peerUid)).catch(() => undefined);
436
+ }
437
+
438
+ /** Flips the host's "mute new participants on join" setting: applied optimistically (the toggle itself is the
439
+ * only feedback this small a control needs) and reverted if the save fails, so the drawer never keeps claiming
440
+ * a setting that isn't actually persisted. Takes effect for whoever joins next - never retroactive, see
441
+ * `setForceMuteOnJoin()`'s own doc comment. */
442
+ function handleToggleForceMuteOnJoin() {
443
+ const next = !forceMuteOnJoin;
444
+ setForceMuteOnJoinState(next);
445
+ apiSetForceMuteOnJoin(channel, next).catch(() => setForceMuteOnJoinState(!next));
446
+ }
447
+
448
+ /** Sets, changes, or removes (`password: null`) the host's join password - unlike the force-mute toggle, this
449
+ * is not applied optimistically: the drawer's own `PasswordSection` awaits the result itself (clearing its
450
+ * input on success, showing an inline error on failure), so there is nothing here to revert. `hasPassword`
451
+ * updates only once the save actually succeeds. */
452
+ async function handleSetPassword(password: string | null): Promise<void> {
453
+ await setMeetingPassword(channel, password);
454
+ setHasPassword(!!password);
455
+ }
456
+
457
+ /** Flips the host's waiting-room setting - same optimistic-and-revert shape as `handleToggleForceMuteOnJoin()`,
458
+ * for the same reason. Takes effect for whoever joins next; nobody already admitted is retroactively gated. */
459
+ function handleToggleWaitingRoomEnabled() {
460
+ const next = !waitingRoomEnabled;
461
+ setWaitingRoomEnabledState(next);
462
+ apiSetWaitingRoomEnabled(channel, next).catch(() => setWaitingRoomEnabledState(!next));
463
+ }
464
+
465
+ /** Admits one pending request - optimistically removed from the drawer's own list (the next poll would drop it
466
+ * anyway, once the requester's own next poll completes their join; removing it here just avoids the visible
467
+ * delay). Re-added if the save itself fails, so a failure doesn't silently lose the request from the list. */
468
+ function handleAdmitParticipant(uid: string) {
469
+ setWaitingParticipants((prev) => prev.filter((p) => p.uid !== uid));
470
+ admitParticipant(channel, uid).catch(() => void listWaitingParticipants(channel).then(setWaitingParticipants, () => undefined));
471
+ }
472
+
473
+ /** Denies one pending request - same optimistic-removal shape as `handleAdmitParticipant()`. */
474
+ function handleDenyParticipant(uid: string) {
475
+ setWaitingParticipants((prev) => prev.filter((p) => p.uid !== uid));
476
+ denyParticipant(channel, uid).catch(() => void listWaitingParticipants(channel).then(setWaitingParticipants, () => undefined));
477
+ }
478
+
307
479
  function handleAudioBlocked() {
308
480
  setAudioBlocked(true);
309
481
  }
@@ -313,6 +485,36 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
313
485
  setAudioNonce((prev) => prev + 1);
314
486
  }
315
487
 
488
+ // A participant's `uid` is their tab-scoped peer id (`<account uid>~<random>` - see `newPeerId()`), while
489
+ // `hostUid` is the plain account uid `PublicVideoMeeting.hostUid` names - so telling the host's own tab(s) apart
490
+ // needs the prefix match, not equality. Purely a client-side check (see `hostUid`'s own doc comment on this
491
+ // prop): nobody's tab is prevented from claiming it, only from being shown host controls for it.
492
+ const isHost = !!hostUid && selfUid === hostUid;
493
+ const isParticipantHost = (uid: string): boolean => !!hostUid && uid.startsWith(`${hostUid}~`);
494
+
495
+ // Refreshes the drawer's own waiting-room list while the host has the drawer open - there is no push signal
496
+ // for a newly filed admission request, so polling is the only way the list stays current without the host
497
+ // having to close and reopen the drawer. Stops (and the list is dropped) the moment either condition ends.
498
+ useEffect(() => {
499
+ if (!isHost || !drawerOpen) {
500
+ setWaitingParticipants([]);
501
+ return;
502
+ }
503
+ let cancelled = false;
504
+ const poll = () => {
505
+ listWaitingParticipants(channel).then((list) => {
506
+ if (!cancelled) {
507
+ setWaitingParticipants(list);
508
+ }
509
+ }, () => undefined);
510
+ };
511
+ poll();
512
+ const interval = setInterval(poll, DEFAULT_WAITING_POLL_MS);
513
+ return () => {
514
+ cancelled = true;
515
+ clearInterval(interval);
516
+ };
517
+ }, [isHost, drawerOpen, channel]);
316
518
  const otherUids = participants.map((p) => p.uid);
317
519
  // A pin or an active-speaker pick can name someone who has just left, until the state catches up.
318
520
  const stillHere = (uid: string | undefined) => (uid && otherUids.includes(uid) ? uid : undefined);
@@ -412,9 +614,16 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
412
614
  ✋ {raisedNames.join(", ")}
413
615
  </span>
414
616
  )}
415
- <span className="px-3 py-1.5 rounded-full bg-[#3c4043] text-sm" aria-label={`${participants.length + 1} participants`}>
617
+ <button
618
+ type="button"
619
+ className="px-3 py-1.5 rounded-full bg-[#3c4043] hover:bg-[#4b4f53] text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
620
+ aria-label={`${participants.length + 1} participants`}
621
+ aria-haspopup="dialog"
622
+ aria-expanded={drawerOpen}
623
+ onClick={() => setDrawerOpen((prev) => !prev)}
624
+ >
416
625
  {participants.length + 1}
417
- </span>
626
+ </button>
418
627
  </div>
419
628
  </header>
420
629
  {connectError && (
@@ -440,9 +649,14 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
440
649
  <footer className="shrink-0 pt-1 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
441
650
  <CallControls
442
651
  media={media}
652
+ selfName={selfName}
653
+ participants={participants}
443
654
  isPresenting={isPresenting}
444
655
  presentingElsewhereName={presenterUid && presenterUid !== peerId ? presenterName : undefined}
445
656
  onToggleShare={() => void handleToggleShare()}
657
+ screenTransform={screenTransform}
658
+ onRotateScreen={handleRotateScreen}
659
+ onFlipScreen={handleFlipScreen}
446
660
  handRaised={handRaised}
447
661
  onToggleHand={handleToggleHand}
448
662
  onReaction={handleReaction}
@@ -482,6 +696,29 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
482
696
  <div role="status" aria-live="polite" className="sr-only">
483
697
  {announcement}
484
698
  </div>
699
+
700
+ {drawerOpen && (
701
+ <ParticipantsDrawer
702
+ selfName={selfName}
703
+ micOn={media.micOn}
704
+ handRaised={handRaised}
705
+ participants={participants}
706
+ isSelfHost={isHost}
707
+ isParticipantHost={isParticipantHost}
708
+ onMute={handleMuteParticipant}
709
+ onKick={handleKickParticipant}
710
+ forceMuteOnJoin={forceMuteOnJoin}
711
+ onToggleForceMuteOnJoin={handleToggleForceMuteOnJoin}
712
+ hasPassword={hasPassword}
713
+ onSetPassword={handleSetPassword}
714
+ waitingRoomEnabled={waitingRoomEnabled}
715
+ onToggleWaitingRoomEnabled={handleToggleWaitingRoomEnabled}
716
+ waitingParticipants={waitingParticipants}
717
+ onAdmit={handleAdmitParticipant}
718
+ onDeny={handleDenyParticipant}
719
+ onClose={() => setDrawerOpen(false)}
720
+ />
721
+ )}
485
722
  </div>
486
723
  );
487
724
  }
@@ -0,0 +1,113 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The diagnostics panel, opened from the navbar for troubleshooting a call in progress - this browser's own
7
+ * capabilities, then each participant's connection: how their media reaches this one (the same badge
8
+ * `_ParticipantTile.tsx` shows, reused via `TRANSPORT_BADGES` rather than described in different words), and,
9
+ * once available, round-trip time and each kind's packet loss/jitter/bytes transferred
10
+ * (`MeshParticipant.diagnostics`, refreshed every few seconds by `MeshConnectionManager`'s own poller - see its doc
11
+ * comment). Purely informational and entirely local: nothing here is sent anywhere.
12
+ *
13
+ * `diagnostics` is `undefined` until the first poll completes (shortly after a connection comes up) and always
14
+ * absent for a `"websocket"`-relayed participant (there is no `RTCPeerConnection` to poll there) - both cases show
15
+ * "Not available" rather than a blank space, so it never looks like the panel forgot to load.
16
+ */
17
+ import React from "react";
18
+ import type { MeshParticipant } from "../shared/webrtc/types.js";
19
+ import { TRANSPORT_BADGES } from "./_ParticipantTile.js";
20
+
21
+ export interface DiagnosticsPanelProps {
22
+ selfName: string;
23
+ /** Whether the local microphone/camera are actually sending - `LocalMedia.micOn`/`cameraOn`. */
24
+ micOn: boolean;
25
+ cameraOn: boolean;
26
+ participants: MeshParticipant[];
27
+ }
28
+
29
+ /** This browser's support for what a call needs - checked once, since it cannot change mid-session. */
30
+ function browserCapabilities(): { label: string; supported: boolean }[] {
31
+ const has = (name: string): boolean => typeof window !== "undefined" && name in window;
32
+ return [
33
+ { label: "WebRTC", supported: has("RTCPeerConnection") },
34
+ { label: "Screen sharing", supported: typeof navigator !== "undefined" && !!navigator.mediaDevices?.getDisplayMedia },
35
+ { label: "WebCodecs (server relay fallback)", supported: has("VideoEncoder") && has("AudioEncoder") },
36
+ ];
37
+ }
38
+
39
+ function formatMs(seconds: number | undefined): string {
40
+ return seconds === undefined ? "—" : `${Math.round(seconds * 1000)} ms`;
41
+ }
42
+
43
+ function formatBytes(bytes: number): string {
44
+ return bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
45
+ }
46
+
47
+ const ROW = "flex justify-between gap-3 py-0.5";
48
+ const LABEL = "text-white/60";
49
+
50
+ function StreamRow({ kind, packetsLost, jitter, bytesSent, bytesReceived }: { kind: string; packetsLost?: number; jitter?: number; bytesSent?: number; bytesReceived?: number }) {
51
+ if (packetsLost === undefined && jitter === undefined && bytesSent === undefined && bytesReceived === undefined) {
52
+ return null;
53
+ }
54
+ return (
55
+ <div className={ROW}>
56
+ <span className={LABEL}>{kind}</span>
57
+ <span>
58
+ {packetsLost !== undefined && `${packetsLost} lost`}
59
+ {jitter !== undefined && ` · ${formatMs(jitter)} jitter`}
60
+ {(bytesSent !== undefined || bytesReceived !== undefined) &&
61
+ ` · ${[bytesSent !== undefined && `↑${formatBytes(bytesSent)}`, bytesReceived !== undefined && `↓${formatBytes(bytesReceived)}`].filter(Boolean).join(" ")}`}
62
+ </span>
63
+ </div>
64
+ );
65
+ }
66
+
67
+ export default function DiagnosticsPanel({ selfName, micOn, cameraOn, participants }: DiagnosticsPanelProps) {
68
+ return (
69
+ <div className="text-sm">
70
+ <p className="text-xs font-semibold uppercase tracking-wide text-white/60 mb-1.5">This browser</p>
71
+ {browserCapabilities().map(({ label, supported }) => (
72
+ <div key={label} className={ROW}>
73
+ <span className={LABEL}>{label}</span>
74
+ <span className={supported ? "text-[#8ab4f8]" : "text-[#f2b8b5]"}>{supported ? "Supported" : "Not supported"}</span>
75
+ </div>
76
+ ))}
77
+
78
+ <p className="text-xs font-semibold uppercase tracking-wide text-white/60 mt-3 mb-1.5">
79
+ {selfName} <span className="text-white/60">(you)</span>
80
+ </p>
81
+ <div className={ROW}>
82
+ <span className={LABEL}>Sending</span>
83
+ <span>{[micOn && "audio", cameraOn && "video"].filter(Boolean).join(" + ") || "Nothing"}</span>
84
+ </div>
85
+
86
+ {participants.length === 0 && <p className="text-white/60 mt-3">Nobody else is in the call yet.</p>}
87
+ {participants.map((p) => {
88
+ const badge = p.transport !== "connecting" && p.transport !== "p2p" ? TRANSPORT_BADGES[p.transport] : undefined;
89
+ return (
90
+ <div key={p.uid}>
91
+ <p className="text-xs font-semibold uppercase tracking-wide text-white/60 mt-3 mb-1.5">{p.name}</p>
92
+ <div className={ROW}>
93
+ <span className={LABEL}>Connection</span>
94
+ <span title={badge?.title}>{badge?.label ?? (p.transport === "connecting" ? "Connecting…" : "Direct")}</span>
95
+ </div>
96
+ {p.transport === "websocket" || !p.diagnostics ? (
97
+ <p className={`${LABEL} py-0.5`}>Not available{p.transport !== "websocket" && " yet"}.</p>
98
+ ) : (
99
+ <>
100
+ <div className={ROW}>
101
+ <span className={LABEL}>Round-trip time</span>
102
+ <span>{formatMs(p.diagnostics.roundTripTimeSeconds)}</span>
103
+ </div>
104
+ <StreamRow kind="Audio" {...p.diagnostics.audio} />
105
+ <StreamRow kind="Video" {...p.diagnostics.video} />
106
+ </>
107
+ )}
108
+ </div>
109
+ );
110
+ })}
111
+ </div>
112
+ );
113
+ }
@@ -20,9 +20,17 @@ import type { MediaTransport } from "../shared/webrtc/types.js";
20
20
  import { MicOffIcon } from "./_icons.js";
21
21
 
22
22
  /** What a tile says about how its participant's media is arriving, for the paths that are not the ordinary direct one
23
- * (which needs no comment). Text rather than only an icon, so the reason a picture is degraded is never a guess. */
24
- const TRANSPORT_BADGES: Partial<Record<MediaTransport, { label: string; title: string }>> = {
23
+ * (which needs no comment). Text rather than only an icon, so the reason a picture is degraded is never a guess.
24
+ * Exported so `_DiagnosticsPanel.tsx` shows the identical label/title rather than describing the same paths in
25
+ * slightly different words. */
26
+ export const TRANSPORT_BADGES: Partial<Record<MediaTransport, { label: string; title: string }>> = {
25
27
  turn: { label: "Relayed", title: "Connected through the relay server, because a direct connection was not possible." },
28
+ "turn-tcp": {
29
+ label: "Relayed (TCP)",
30
+ title:
31
+ "Connected through the relay server over TCP, because this network blocks the relay's usual UDP connection too. " +
32
+ "Audio or video may pause briefly under network strain rather than just glitching - a property of TCP, not a dropped connection.",
33
+ },
26
34
  websocket: {
27
35
  label: "Server relay",
28
36
  title: "Sent through the server as a last resort, because no other connection was possible. Video is lower quality and sound may lag.",