@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
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  ///////////////////////////////////////////////////////////////////////////////
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
4
4
  // SPDX-License-Identifier: MPL-2.0
@@ -17,8 +17,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
17
17
  import { useEffect, useRef, useState } from "react";
18
18
  import { filtersActive } from "../shared/media/filters/filterTypes.js";
19
19
  import { REACTION_EMOJIS } from "../shared/webrtc/types.js";
20
+ import DiagnosticsPanel from "./_DiagnosticsPanel.js";
20
21
  import EffectsPanel from "./_EffectsPanel.js";
21
- import { ChevronUpIcon, EffectsIcon, EmojiIcon, FocusIcon, GridIcon, HandIcon, LeaveIcon, MicIcon, MicOffIcon, ScreenShareIcon, VideoIcon, VideoOffIcon, } from "./_icons.js";
22
+ import { ChevronUpIcon, DiagnosticsIcon, EffectsIcon, EmojiIcon, FlipIcon, FocusIcon, GridIcon, HandIcon, LeaveIcon, MicIcon, MicOffIcon, RotateIcon, ScreenShareIcon, VideoIcon, VideoOffIcon, } from "./_icons.js";
22
23
  const BUTTON = "flex items-center justify-center h-12 rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80";
23
24
  const NEUTRAL = "bg-[#3c4043] text-white hover:bg-[#4b4f53]";
24
25
  const ALERT = "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]";
@@ -27,7 +28,7 @@ const ACTIVE = "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]";
27
28
  export function LevelBars({ level }) {
28
29
  return (_jsx("span", { className: "flex items-center gap-[2px] h-5", "data-testid": "mic-level", "data-level": level, "aria-hidden": "true", children: [0.6, 1, 0.6].map((weight, i) => (_jsx("span", { className: `w-[3px] rounded-full ${level > 0 ? "bg-[#8ab4f8]" : "bg-white/50"}`, style: { height: `${4 + weight * level * 3}px` } }, i))) }));
29
30
  }
30
- export default function CallControls({ media, isPresenting, presentingElsewhereName, onToggleShare, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, onLeave, }) {
31
+ export default function CallControls({ media, selfName, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, onLeave, }) {
31
32
  const [openMenu, setOpenMenu] = useState(null);
32
33
  const barRef = useRef(null);
33
34
  const shareDisabled = !isPresenting && !!presentingElsewhereName;
@@ -56,10 +57,10 @@ export default function CallControls({ media, isPresenting, presentingElsewhereN
56
57
  function toggleMenu(menu) {
57
58
  setOpenMenu((prev) => (prev === menu ? null : menu));
58
59
  }
59
- return (_jsxs("div", { ref: barRef, className: "relative flex flex-wrap items-center justify-center gap-2 px-3", role: "toolbar", "aria-label": "Call controls", children: [_jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": "Choose microphone", "aria-haspopup": "menu", "aria-expanded": openMenu === "audio", onClick: () => toggleMenu("audio"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": media.micOn ? "Mute microphone" : "Unmute microphone", "aria-pressed": !media.micOn, onClick: () => void media.toggleMic(), children: [media.micOn && _jsx(LevelBars, { level: media.audioLevel }), media.micOn ? _jsx(MicIcon, {}) : _jsx(MicOffIcon, {})] }), openMenu === "audio" && _jsx(DeviceMenu, { kind: "audio", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": "Choose camera", "aria-haspopup": "menu", "aria-expanded": openMenu === "video", onClick: () => toggleMenu("video"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} relative min-w-14 px-3 rounded-l-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": media.cameraOn ? "Turn off camera" : "Turn on camera", "aria-pressed": !media.cameraOn, onClick: () => void media.toggleCamera(), children: [media.cameraOn ? _jsx(VideoIcon, {}) : _jsx(VideoOffIcon, {}), media.cameraOn && (_jsx("span", { className: "absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34a853] animate-pulse", "data-testid": "camera-live", title: "Your camera is sending" }))] }), openMenu === "video" && _jsx(DeviceMenu, { kind: "video", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${filtersActive(media.filters) ? ACTIVE : NEUTRAL}`, "aria-label": "Video effects", "aria-haspopup": "dialog", "aria-expanded": openMenu === "effects", onClick: () => toggleMenu("effects"), children: _jsx(EffectsIcon, {}) }), openMenu === "effects" && (_jsx("div", { role: "dialog", "aria-label": "Video effects", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-96 max-w-[calc(100vw-1rem)] max-h-[60vh] overflow-y-auto p-3 rounded-2xl bg-[#2b2d30] text-white shadow-xl", children: _jsx(EffectsPanel, { media: media, tone: "dark" }) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${isPresenting ? ACTIVE : NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": isPresenting ? "Stop sharing" : "Share screen", "aria-pressed": isPresenting, disabled: shareDisabled, title: shareDisabled ? `${presentingElsewhereName} is presenting - stop their share to present yourself.` : undefined, onClick: onToggleShare, children: _jsx(ScreenShareIcon, {}) }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": "Send a reaction", "aria-haspopup": "menu", "aria-expanded": openMenu === "emoji", onClick: () => toggleMenu("emoji"), children: _jsx(EmojiIcon, {}) }), openMenu === "emoji" && (_jsx("div", { role: "menu", "aria-label": "Reactions", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-max grid grid-cols-4 gap-1 p-2 rounded-2xl bg-[#2b2d30] shadow-xl", children: REACTION_EMOJIS.map((emoji) => (_jsx("button", { type: "button", role: "menuitem", className: "w-11 h-11 rounded-lg text-2xl hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Send ${emoji}`, onClick: () => {
60
+ return (_jsxs("div", { ref: barRef, className: "relative flex flex-wrap items-center justify-center gap-2 px-3", role: "toolbar", "aria-label": "Call controls", children: [_jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": "Choose microphone", "aria-haspopup": "menu", "aria-expanded": openMenu === "audio", onClick: () => toggleMenu("audio"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": media.micOn ? "Mute microphone" : "Unmute microphone", "aria-pressed": !media.micOn, onClick: () => void media.toggleMic(), children: [media.micOn && _jsx(LevelBars, { level: media.audioLevel }), media.micOn ? _jsx(MicIcon, {}) : _jsx(MicOffIcon, {})] }), openMenu === "audio" && _jsx(DeviceMenu, { kind: "audio", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": "Choose camera", "aria-haspopup": "menu", "aria-expanded": openMenu === "video", onClick: () => toggleMenu("video"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} relative min-w-14 px-3 rounded-l-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": media.cameraOn ? "Turn off camera" : "Turn on camera", "aria-pressed": !media.cameraOn, onClick: () => void media.toggleCamera(), children: [media.cameraOn ? _jsx(VideoIcon, {}) : _jsx(VideoOffIcon, {}), media.cameraOn && (_jsx("span", { className: "absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34a853] animate-pulse", "data-testid": "camera-live", title: "Your camera is sending" }))] }), openMenu === "video" && _jsx(DeviceMenu, { kind: "video", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${filtersActive(media.filters) ? ACTIVE : NEUTRAL}`, "aria-label": "Video effects", "aria-haspopup": "dialog", "aria-expanded": openMenu === "effects", onClick: () => toggleMenu("effects"), children: _jsx(EffectsIcon, {}) }), openMenu === "effects" && (_jsx("div", { role: "dialog", "aria-label": "Video effects", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-96 max-w-[calc(100vw-1rem)] max-h-[60vh] overflow-y-auto p-3 rounded-2xl bg-[#2b2d30] text-white shadow-xl", children: _jsx(EffectsPanel, { media: media, tone: "dark" }) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${isPresenting ? ACTIVE : NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": isPresenting ? "Stop sharing" : "Share screen", "aria-pressed": isPresenting, disabled: shareDisabled, title: shareDisabled ? `${presentingElsewhereName} is presenting - stop their share to present yourself.` : undefined, onClick: onToggleShare, children: _jsx(ScreenShareIcon, {}) }), isPresenting && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": "Rotate shared screen", title: "If your shared window looks sideways or upside down to everyone, rotate it here - this is a correction for your own capture, not a browser bug this app can fix automatically.", onClick: onRotateScreen, children: _jsx(RotateIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${screenTransform.flipped ? ACTIVE : NEUTRAL}`, "aria-label": screenTransform.flipped ? "Unflip shared screen" : "Flip shared screen", "aria-pressed": screenTransform.flipped, onClick: onFlipScreen, children: _jsx(FlipIcon, {}) })] })), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": "Send a reaction", "aria-haspopup": "menu", "aria-expanded": openMenu === "emoji", onClick: () => toggleMenu("emoji"), children: _jsx(EmojiIcon, {}) }), openMenu === "emoji" && (_jsx("div", { role: "menu", "aria-label": "Reactions", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-max grid grid-cols-4 gap-1 p-2 rounded-2xl bg-[#2b2d30] shadow-xl", children: REACTION_EMOJIS.map((emoji) => (_jsx("button", { type: "button", role: "menuitem", className: "w-11 h-11 rounded-lg text-2xl hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Send ${emoji}`, onClick: () => {
60
61
  onReaction(emoji);
61
62
  setOpenMenu(null);
62
- }, children: emoji }, emoji))) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${handRaised ? ACTIVE : NEUTRAL}`, "aria-label": handRaised ? "Lower hand" : "Raise hand", "aria-pressed": handRaised, onClick: onToggleHand, children: _jsx(HandIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": viewMode === "grid" ? "Switch to focused view" : "Switch to grid view", onClick: onToggleViewMode, children: viewMode === "grid" ? _jsx(FocusIcon, {}) : _jsx(GridIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`, "aria-label": "Leave call", onClick: onLeave, children: _jsx(LeaveIcon, {}) })] }));
63
+ }, children: emoji }, emoji))) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${handRaised ? ACTIVE : NEUTRAL}`, "aria-label": handRaised ? "Lower hand" : "Raise hand", "aria-pressed": handRaised, onClick: onToggleHand, children: _jsx(HandIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": viewMode === "grid" ? "Switch to focused view" : "Switch to grid view", onClick: onToggleViewMode, children: viewMode === "grid" ? _jsx(FocusIcon, {}) : _jsx(GridIcon, {}) }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${openMenu === "diagnostics" ? ACTIVE : NEUTRAL}`, "aria-label": "Call diagnostics", "aria-haspopup": "dialog", "aria-expanded": openMenu === "diagnostics", onClick: () => toggleMenu("diagnostics"), children: _jsx(DiagnosticsIcon, {}) }), openMenu === "diagnostics" && (_jsx("div", { role: "dialog", "aria-label": "Call diagnostics", className: "absolute bottom-full mb-3 right-0 w-80 max-w-[calc(100vw-1rem)] max-h-[60vh] overflow-y-auto p-3 rounded-2xl bg-[#2b2d30] text-white shadow-xl", children: _jsx(DiagnosticsPanel, { selfName: selfName, micOn: media.micOn, cameraOn: media.cameraOn, participants: participants }) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`, "aria-label": "Leave call", onClick: () => onLeave(), children: _jsx(LeaveIcon, {}) })] }));
63
64
  }
64
65
  /** The device picker opened from the chevron beside the microphone/camera button: the available devices with the
65
66
  * one in use ticked, or - while there are none to list - why, with a way to ask for access again. */
@@ -45,9 +45,29 @@ export interface CallViewProps {
45
45
  /** Whether the server offers the WebSocket media relay, the last-resort path for a participant neither a direct
46
46
  * connection nor the TURN server can reach - `VideoMeetingJoinResult.relayEnabled`. Off when absent. */
47
47
  relayEnabled?: boolean;
48
+ /** The meeting's host account uid (`PublicVideoMeeting.hostUid`) - this client's only source of a "host"
49
+ * identity, purely for deciding which controls to show (see `BaseVideoMeetingRoute`'s class doc comment on why
50
+ * this is a client-side-only check, not a server-enforced one for every moderation action). Absent when the
51
+ * server couldn't resolve one, in which case nobody sees host controls. */
52
+ hostUid?: string;
53
+ /** `PublicVideoMeeting.forceMuteOnJoin` as of this call's own `join()` - the starting value for the host's
54
+ * toggle in the participants drawer, which this view then owns and keeps current itself (see
55
+ * `handleToggleForceMuteOnJoin()`). Does not update if changed elsewhere while this view is mounted - there is
56
+ * no signal for that today, matching every other meeting-settings field's lack of live sync. */
57
+ initialForceMuteOnJoin?: boolean;
58
+ /** `PublicVideoMeeting.hasPassword` as of this call's own `join()` - the starting value for the host's password
59
+ * section in the participants drawer, same "owned and kept current by this view" shape as
60
+ * `initialForceMuteOnJoin`. */
61
+ initialHasPassword?: boolean;
62
+ /** `PublicVideoMeeting.waitingRoomEnabled` as of this call's own `join()` - the starting value for the host's
63
+ * waiting-room toggle, same "owned and kept current by this view" shape as `initialForceMuteOnJoin`. */
64
+ initialWaitingRoomEnabled?: boolean;
48
65
  /** The camera and microphone, owned by the page (`[token].tsx`) - the lobby's tracks carried into the call. */
49
66
  media: LocalMedia;
50
- onLeave: () => void;
67
+ /** Called once the participant leaves, for any reason. `reason` is set only when the call ended without the
68
+ * participant's own action - currently, being kicked by the host - so the caller can show a distinct message
69
+ * instead of the ordinary "you left" one; omitted for an ordinary voluntary leave. */
70
+ onLeave: (reason?: string) => void;
51
71
  }
52
72
  /** Who is shown large - see this module's doc comment. `undefined` when nobody but the local participant has
53
73
  * joined yet (the grid/focus toggle still works, there's just nobody else to focus on). */
@@ -61,4 +81,10 @@ export declare function computeMainUid(options: {
61
81
  * from two devices - or two tabs - is two participants rather than one that ignores itself. It travels as the
62
82
  * `peer` of each message; `from` stays the exact authenticated uid, which the server requires. */
63
83
  export declare function newPeerId(selfUid: string): string;
64
- export default function CallView({ channel, token, selfUid, selfName, meetingTitle, iceServers, relayEnabled, media, onLeave }: CallViewProps): React.JSX.Element;
84
+ /** The real, server-enforced account uid behind a participant's tab-scoped `MeshParticipant.uid` (`newPeerId()`'s
85
+ * `<account uid>~<random>` shape) - what the kick route's ACL grant is actually keyed by, unlike the tab-scoped id
86
+ * this view otherwise addresses a specific tab with. Neither a real account uid nor a minted guest uid (see
87
+ * `GUEST_UID_PREFIX`/`mintGuestToken()` on the backend) can itself contain "~", so the last one found is always
88
+ * exactly this suffix's own separator. */
89
+ export declare function accountUidOf(peerUid: string): string;
90
+ export default function CallView({ channel, token, selfUid, selfName, meetingTitle, iceServers, relayEnabled, hostUid, initialForceMuteOnJoin, initialHasPassword, initialWaitingRoomEnabled, media, onLeave, }: CallViewProps): React.JSX.Element;
@@ -40,16 +40,21 @@ import { playRaisedHandChime } from "../shared/media/chime.js";
40
40
  import { pickActiveSpeaker } from "../shared/media/activeSpeaker.js";
41
41
  import { startLevelMeter } from "../shared/media/levelMeter.js";
42
42
  import { requestDisplayMedia, stopStream } from "../shared/media/deviceMedia.js";
43
+ import { NO_SCREEN_TRANSFORM, ScreenTransformProcessor, rotateClockwise } from "../shared/media/filters/ScreenTransform.js";
43
44
  import { createBrowserPeerConnection } from "../shared/webrtc/realPeerConnection.js";
44
45
  import { MeshConnectionManager } from "../shared/webrtc/MeshConnectionManager.js";
45
46
  import { createRelayTransport } from "../shared/relay/RelayTransport.js";
46
47
  import { GuestSignalingClient } from "../shared/push/GuestSignalingClient.js";
47
48
  import CallControls from "./_CallControls.js";
49
+ import { admitParticipant, denyParticipant, kickParticipant, listWaitingParticipants, setForceMuteOnJoin as apiSetForceMuteOnJoin, setMeetingPassword, setWaitingRoomEnabled as apiSetWaitingRoomEnabled, } from "./_meetApi.js";
48
50
  import ParticipantTile from "./_ParticipantTile.js";
51
+ import ParticipantsDrawer from "./_ParticipantsDrawer.js";
49
52
  /** How long a reaction floats on screen. */
50
53
  const REACTION_MS = 4000;
51
54
  /** The most reactions shown at once - a burst beyond this drops the oldest. */
52
55
  const MAX_REACTIONS = 12;
56
+ /** How often the host's drawer refreshes its own waiting-room list while open. */
57
+ const DEFAULT_WAITING_POLL_MS = 3000;
53
58
  /** Who is shown large - see this module's doc comment. `undefined` when nobody but the local participant has
54
59
  * joined yet (the grid/focus toggle still works, there's just nobody else to focus on). */
55
60
  export function computeMainUid(options) {
@@ -70,7 +75,16 @@ export function computeMainUid(options) {
70
75
  export function newPeerId(selfUid) {
71
76
  return `${selfUid}~${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
72
77
  }
73
- export default function CallView({ channel, token, selfUid, selfName, meetingTitle, iceServers, relayEnabled, media, onLeave }) {
78
+ /** The real, server-enforced account uid behind a participant's tab-scoped `MeshParticipant.uid` (`newPeerId()`'s
79
+ * `<account uid>~<random>` shape) - what the kick route's ACL grant is actually keyed by, unlike the tab-scoped id
80
+ * this view otherwise addresses a specific tab with. Neither a real account uid nor a minted guest uid (see
81
+ * `GUEST_UID_PREFIX`/`mintGuestToken()` on the backend) can itself contain "~", so the last one found is always
82
+ * exactly this suffix's own separator. */
83
+ export function accountUidOf(peerUid) {
84
+ const i = peerUid.lastIndexOf("~");
85
+ return i === -1 ? peerUid : peerUid.slice(0, i);
86
+ }
87
+ export default function CallView({ channel, token, selfUid, selfName, meetingTitle, iceServers, relayEnabled, hostUid, initialForceMuteOnJoin, initialHasPassword, initialWaitingRoomEnabled, media, onLeave, }) {
74
88
  const [peerId] = useState(() => newPeerId(selfUid));
75
89
  const [connectError, setConnectError] = useState(null);
76
90
  const [participants, setParticipants] = useState([]);
@@ -78,6 +92,7 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
78
92
  const [levels, setLevels] = useState({});
79
93
  const [presenterUid, setPresenterUid] = useState(undefined);
80
94
  const [screenStream, setScreenStream] = useState(null);
95
+ const [screenTransform, setScreenTransform] = useState(NO_SCREEN_TRANSFORM);
81
96
  const [handRaised, setHandRaised] = useState(false);
82
97
  const [viewMode, setViewMode] = useState("grid");
83
98
  const [pinnedUid, setPinnedUid] = useState(undefined);
@@ -87,11 +102,26 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
87
102
  const [audioBlocked, setAudioBlocked] = useState(false);
88
103
  const [audioNonce, setAudioNonce] = useState(0);
89
104
  const [signalingReady, setSignalingReady] = useState(false);
105
+ const [drawerOpen, setDrawerOpen] = useState(false);
106
+ const [forceMuteOnJoin, setForceMuteOnJoinState] = useState(!!initialForceMuteOnJoin);
107
+ const [hasPassword, setHasPassword] = useState(!!initialHasPassword);
108
+ const [waitingRoomEnabled, setWaitingRoomEnabledState] = useState(!!initialWaitingRoomEnabled);
109
+ const [waitingParticipants, setWaitingParticipants] = useState([]);
90
110
  const managerRef = useRef(null);
111
+ /** The raw capture from `getDisplayMedia()` - only ever used to stop it (releasing the OS's own share
112
+ * indicator) and to notice the participant ending the share from the browser's own UI (`track.onended`). What's
113
+ * actually shown and sent is `screenTransformRef`'s output (`screenStream` state) - see `handleToggleShare()`. */
91
114
  const screenStreamRef = useRef(null);
115
+ const screenTransformRef = useRef(null);
92
116
  const levelMetersRef = useRef({});
93
117
  const reactionSeqRef = useRef(0);
94
118
  const reactionTimersRef = useRef(new Set());
119
+ /** The latest `media`, for the mesh-event handler below - that effect runs once at mount (see its own comment),
120
+ * so it would otherwise see only the mic state/toggle function from that first render. */
121
+ const mediaRef = useRef(media);
122
+ useEffect(() => {
123
+ mediaRef.current = media;
124
+ }, [media]);
95
125
  const isPresenting = !!screenStream;
96
126
  const showReaction = useCallback((emoji, name) => {
97
127
  const id = ++reactionSeqRef.current;
@@ -158,6 +188,14 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
158
188
  stopLocalPresentation(false);
159
189
  }
160
190
  return;
191
+ case "mute-requested":
192
+ if (mediaRef.current.micOn) {
193
+ void mediaRef.current.toggleMic();
194
+ }
195
+ return;
196
+ case "kicked":
197
+ onLeave("The host removed you from this call.");
198
+ return;
161
199
  }
162
200
  });
163
201
  client
@@ -191,6 +229,7 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
191
229
  // Only the screen capture is this view's to stop - the camera and microphone belong to the page, which
192
230
  // releases them when the participant leaves.
193
231
  stopStream(screenStreamRef.current);
232
+ screenTransformRef.current?.stop();
194
233
  for (const timer of timers) {
195
234
  clearTimeout(timer);
196
235
  }
@@ -220,8 +259,12 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
220
259
  }
221
260
  stopStream(screenStreamRef.current);
222
261
  screenStreamRef.current = null;
223
- // The sync effect above swaps the camera back in as the outgoing video track.
262
+ screenTransformRef.current?.stop();
263
+ screenTransformRef.current = null;
264
+ // The sync effect above swaps the camera back in as the outgoing video track. The rotation/flip is reset
265
+ // for the next share - each share starts from the capture the browser hands back, not the last one's fix.
224
266
  setScreenStream(null);
267
+ setScreenTransform(NO_SCREEN_TRANSFORM);
225
268
  }
226
269
  async function handleToggleShare() {
227
270
  if (screenStreamRef.current) {
@@ -239,8 +282,34 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
239
282
  return;
240
283
  }
241
284
  screenStreamRef.current = result.value;
242
- setScreenStream(result.value);
243
- result.value.getVideoTracks()[0].onended = () => stopLocalPresentation(true);
285
+ const rawTrack = result.value.getVideoTracks()[0];
286
+ rawTrack.onended = () => stopLocalPresentation(true);
287
+ try {
288
+ // Runs the capture through the rotate/flip processor before anyone (presenter included) sees it - see
289
+ // ScreenTransform.ts's doc comment on why window capture sometimes needs this correction.
290
+ const processor = new ScreenTransformProcessor({ source: rawTrack, state: screenTransform });
291
+ screenTransformRef.current = processor;
292
+ setScreenStream(new MediaStream([processor.track]));
293
+ }
294
+ catch {
295
+ // This browser can't draw the correction - share the raw capture rather than not sharing at all; the
296
+ // rotate/flip buttons simply won't do anything (screenTransformRef stays null).
297
+ setScreenStream(result.value);
298
+ }
299
+ }
300
+ function handleRotateScreen() {
301
+ setScreenTransform((prev) => {
302
+ const next = rotateClockwise(prev);
303
+ screenTransformRef.current?.setState(next);
304
+ return next;
305
+ });
306
+ }
307
+ function handleFlipScreen() {
308
+ setScreenTransform((prev) => {
309
+ const next = { ...prev, flipped: !prev.flipped };
310
+ screenTransformRef.current?.setState(next);
311
+ return next;
312
+ });
244
313
  }
245
314
  function handleToggleHand() {
246
315
  setHandRaised((prev) => !prev);
@@ -253,6 +322,57 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
253
322
  function togglePin(uid) {
254
323
  setPinnedUid((prev) => (prev === uid ? undefined : uid));
255
324
  }
325
+ /** Asks `peerUid`'s participant to mute - the host-only button in the participants drawer calls this
326
+ * directly, with no confirmation (unlike removing someone, muting is easily undone by the participant
327
+ * themselves). */
328
+ function handleMuteParticipant(peerUid) {
329
+ managerRef.current?.sendMuteRequest(peerUid);
330
+ }
331
+ /** Removes `peerUid`'s participant from the call: the cooperative signal (immediate, so a cooperating client
332
+ * leaves without waiting on the network round trip below) and the enforced server-side revoke together - see
333
+ * `kickParticipant()`'s own doc comment for why both matter. The REST call's failure is swallowed rather than
334
+ * surfaced: the host already asked them to leave, and there is no useful recovery action to offer from here
335
+ * for what is almost always a transient network issue. */
336
+ function handleKickParticipant(peerUid) {
337
+ managerRef.current?.sendKick(peerUid);
338
+ kickParticipant(channel, accountUidOf(peerUid)).catch(() => undefined);
339
+ }
340
+ /** Flips the host's "mute new participants on join" setting: applied optimistically (the toggle itself is the
341
+ * only feedback this small a control needs) and reverted if the save fails, so the drawer never keeps claiming
342
+ * a setting that isn't actually persisted. Takes effect for whoever joins next - never retroactive, see
343
+ * `setForceMuteOnJoin()`'s own doc comment. */
344
+ function handleToggleForceMuteOnJoin() {
345
+ const next = !forceMuteOnJoin;
346
+ setForceMuteOnJoinState(next);
347
+ apiSetForceMuteOnJoin(channel, next).catch(() => setForceMuteOnJoinState(!next));
348
+ }
349
+ /** Sets, changes, or removes (`password: null`) the host's join password - unlike the force-mute toggle, this
350
+ * is not applied optimistically: the drawer's own `PasswordSection` awaits the result itself (clearing its
351
+ * input on success, showing an inline error on failure), so there is nothing here to revert. `hasPassword`
352
+ * updates only once the save actually succeeds. */
353
+ async function handleSetPassword(password) {
354
+ await setMeetingPassword(channel, password);
355
+ setHasPassword(!!password);
356
+ }
357
+ /** Flips the host's waiting-room setting - same optimistic-and-revert shape as `handleToggleForceMuteOnJoin()`,
358
+ * for the same reason. Takes effect for whoever joins next; nobody already admitted is retroactively gated. */
359
+ function handleToggleWaitingRoomEnabled() {
360
+ const next = !waitingRoomEnabled;
361
+ setWaitingRoomEnabledState(next);
362
+ apiSetWaitingRoomEnabled(channel, next).catch(() => setWaitingRoomEnabledState(!next));
363
+ }
364
+ /** Admits one pending request - optimistically removed from the drawer's own list (the next poll would drop it
365
+ * anyway, once the requester's own next poll completes their join; removing it here just avoids the visible
366
+ * delay). Re-added if the save itself fails, so a failure doesn't silently lose the request from the list. */
367
+ function handleAdmitParticipant(uid) {
368
+ setWaitingParticipants((prev) => prev.filter((p) => p.uid !== uid));
369
+ admitParticipant(channel, uid).catch(() => void listWaitingParticipants(channel).then(setWaitingParticipants, () => undefined));
370
+ }
371
+ /** Denies one pending request - same optimistic-removal shape as `handleAdmitParticipant()`. */
372
+ function handleDenyParticipant(uid) {
373
+ setWaitingParticipants((prev) => prev.filter((p) => p.uid !== uid));
374
+ denyParticipant(channel, uid).catch(() => void listWaitingParticipants(channel).then(setWaitingParticipants, () => undefined));
375
+ }
256
376
  function handleAudioBlocked() {
257
377
  setAudioBlocked(true);
258
378
  }
@@ -260,6 +380,35 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
260
380
  setAudioBlocked(false);
261
381
  setAudioNonce((prev) => prev + 1);
262
382
  }
383
+ // A participant's `uid` is their tab-scoped peer id (`<account uid>~<random>` - see `newPeerId()`), while
384
+ // `hostUid` is the plain account uid `PublicVideoMeeting.hostUid` names - so telling the host's own tab(s) apart
385
+ // needs the prefix match, not equality. Purely a client-side check (see `hostUid`'s own doc comment on this
386
+ // prop): nobody's tab is prevented from claiming it, only from being shown host controls for it.
387
+ const isHost = !!hostUid && selfUid === hostUid;
388
+ const isParticipantHost = (uid) => !!hostUid && uid.startsWith(`${hostUid}~`);
389
+ // Refreshes the drawer's own waiting-room list while the host has the drawer open - there is no push signal
390
+ // for a newly filed admission request, so polling is the only way the list stays current without the host
391
+ // having to close and reopen the drawer. Stops (and the list is dropped) the moment either condition ends.
392
+ useEffect(() => {
393
+ if (!isHost || !drawerOpen) {
394
+ setWaitingParticipants([]);
395
+ return;
396
+ }
397
+ let cancelled = false;
398
+ const poll = () => {
399
+ listWaitingParticipants(channel).then((list) => {
400
+ if (!cancelled) {
401
+ setWaitingParticipants(list);
402
+ }
403
+ }, () => undefined);
404
+ };
405
+ poll();
406
+ const interval = setInterval(poll, DEFAULT_WAITING_POLL_MS);
407
+ return () => {
408
+ cancelled = true;
409
+ clearInterval(interval);
410
+ };
411
+ }, [isHost, drawerOpen, channel]);
263
412
  const otherUids = participants.map((p) => p.uid);
264
413
  // A pin or an active-speaker pick can name someone who has just left, until the state catches up.
265
414
  const stillHere = (uid) => (uid && otherUids.includes(uid) ? uid : undefined);
@@ -292,10 +441,10 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
292
441
  else {
293
442
  stage = (_jsx("div", { className: "h-full grid gap-2 auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr))]", children: participants.map((p) => remoteTile(p, "h-full")) }));
294
443
  }
295
- return (_jsxs("div", { className: "fixed inset-0 z-50 flex flex-col bg-[#202124] text-white overflow-hidden", children: [_jsx("style", { children: `@keyframes meet-float { 0% { transform: translateY(0) scale(.6); opacity: 0; } 12% { opacity: 1; transform: translateY(-4vh) scale(1); } 100% { transform: translateY(-45vh) scale(1); opacity: 0; } }` }), _jsxs("header", { className: "shrink-0 flex items-center justify-between gap-3 px-4 py-3", children: [_jsx("h1", { className: "min-w-0 truncate text-base font-medium", children: meetingTitle }), _jsxs("div", { className: "flex items-center gap-2 shrink-0", children: [raisedNames.length > 0 && (_jsxs("span", { className: "max-w-[45vw] truncate px-3 py-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-sm font-medium", "data-testid": "raised-hands", children: ["\u270B ", raisedNames.join(", ")] })), _jsx("span", { className: "px-3 py-1.5 rounded-full bg-[#3c4043] text-sm", "aria-label": `${participants.length + 1} participants`, children: participants.length + 1 })] })] }), connectError && (_jsx("div", { role: "alert", className: "shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#601410] text-[#f9dedc] text-sm", children: connectError })), audioBlocked && (_jsx("button", { type: "button", className: "shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#a8c7fa] text-[#062e6f] text-sm font-medium", onClick: handleResumeAudio, children: "Click here to turn on sound" })), _jsxs("main", { className: "relative flex-1 min-h-0 px-3 pb-3", children: [stage, presenterName && presenterUid !== peerId && (_jsxs("p", { className: "absolute top-2 left-5 px-2 py-0.5 rounded bg-black/60 text-sm", children: [presenterName, " is presenting"] }))] }), _jsx("footer", { className: "shrink-0 pt-1 pb-[max(0.75rem,env(safe-area-inset-bottom))]", children: _jsx(CallControls, { media: media, isPresenting: isPresenting, presentingElsewhereName: presenterUid && presenterUid !== peerId ? presenterName : undefined, onToggleShare: () => void handleToggleShare(), handRaised: handRaised, onToggleHand: handleToggleHand, onReaction: handleReaction, viewMode: viewMode, onToggleViewMode: () => setViewMode((prev) => (prev === "grid" ? "focus" : "grid")), onLeave: onLeave }) }), !alone && (_jsx("div", { className: "absolute z-10 right-3 top-14 w-28 sm:top-auto sm:bottom-24 sm:w-52 aspect-video shadow-xl xl:right-4 xl:bottom-4", "data-testid": "self-view", children: selfTile("h-full") })), _jsx("div", { className: "pointer-events-none absolute left-4 bottom-28 w-40 h-[45vh]", "aria-hidden": "true", children: reactions.map((reaction) => (_jsxs("div", { className: "absolute bottom-0 flex flex-col items-center", style: { left: `${(reaction.id % 4) * 36}px`, animation: `meet-float ${REACTION_MS}ms ease-out forwards` }, "data-testid": "reaction", children: [_jsx("span", { className: "text-4xl leading-none", children: reaction.emoji }), _jsx("span", { className: "mt-1 px-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-xs", children: reaction.name })] }, reaction.id))) }), participants.map((p) => {
444
+ return (_jsxs("div", { className: "fixed inset-0 z-50 flex flex-col bg-[#202124] text-white overflow-hidden", children: [_jsx("style", { children: `@keyframes meet-float { 0% { transform: translateY(0) scale(.6); opacity: 0; } 12% { opacity: 1; transform: translateY(-4vh) scale(1); } 100% { transform: translateY(-45vh) scale(1); opacity: 0; } }` }), _jsxs("header", { className: "shrink-0 flex items-center justify-between gap-3 px-4 py-3", children: [_jsx("h1", { className: "min-w-0 truncate text-base font-medium", children: meetingTitle }), _jsxs("div", { className: "flex items-center gap-2 shrink-0", children: [raisedNames.length > 0 && (_jsxs("span", { className: "max-w-[45vw] truncate px-3 py-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-sm font-medium", "data-testid": "raised-hands", children: ["\u270B ", raisedNames.join(", ")] })), _jsx("button", { type: "button", 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", "aria-label": `${participants.length + 1} participants`, "aria-haspopup": "dialog", "aria-expanded": drawerOpen, onClick: () => setDrawerOpen((prev) => !prev), children: participants.length + 1 })] })] }), connectError && (_jsx("div", { role: "alert", className: "shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#601410] text-[#f9dedc] text-sm", children: connectError })), audioBlocked && (_jsx("button", { type: "button", className: "shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#a8c7fa] text-[#062e6f] text-sm font-medium", onClick: handleResumeAudio, children: "Click here to turn on sound" })), _jsxs("main", { className: "relative flex-1 min-h-0 px-3 pb-3", children: [stage, presenterName && presenterUid !== peerId && (_jsxs("p", { className: "absolute top-2 left-5 px-2 py-0.5 rounded bg-black/60 text-sm", children: [presenterName, " is presenting"] }))] }), _jsx("footer", { className: "shrink-0 pt-1 pb-[max(0.75rem,env(safe-area-inset-bottom))]", children: _jsx(CallControls, { media: media, selfName: selfName, participants: participants, isPresenting: isPresenting, presentingElsewhereName: presenterUid && presenterUid !== peerId ? presenterName : undefined, onToggleShare: () => void handleToggleShare(), screenTransform: screenTransform, onRotateScreen: handleRotateScreen, onFlipScreen: handleFlipScreen, handRaised: handRaised, onToggleHand: handleToggleHand, onReaction: handleReaction, viewMode: viewMode, onToggleViewMode: () => setViewMode((prev) => (prev === "grid" ? "focus" : "grid")), onLeave: onLeave }) }), !alone && (_jsx("div", { className: "absolute z-10 right-3 top-14 w-28 sm:top-auto sm:bottom-24 sm:w-52 aspect-video shadow-xl xl:right-4 xl:bottom-4", "data-testid": "self-view", children: selfTile("h-full") })), _jsx("div", { className: "pointer-events-none absolute left-4 bottom-28 w-40 h-[45vh]", "aria-hidden": "true", children: reactions.map((reaction) => (_jsxs("div", { className: "absolute bottom-0 flex flex-col items-center", style: { left: `${(reaction.id % 4) * 36}px`, animation: `meet-float ${REACTION_MS}ms ease-out forwards` }, "data-testid": "reaction", children: [_jsx("span", { className: "text-4xl leading-none", children: reaction.emoji }), _jsx("span", { className: "mt-1 px-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-xs", children: reaction.name })] }, reaction.id))) }), participants.map((p) => {
296
445
  const stream = remoteStreams[p.uid];
297
446
  return stream ? _jsx(RemoteAudio, { stream: stream, onBlocked: handleAudioBlocked }, `${p.uid}:${audioNonce}`) : null;
298
- }), _jsx("div", { role: "status", "aria-live": "polite", className: "sr-only", children: announcement })] }));
447
+ }), _jsx("div", { role: "status", "aria-live": "polite", className: "sr-only", children: announcement }), drawerOpen && (_jsx(ParticipantsDrawer, { selfName: selfName, micOn: media.micOn, handRaised: handRaised, participants: participants, isSelfHost: isHost, isParticipantHost: isParticipantHost, onMute: handleMuteParticipant, onKick: handleKickParticipant, forceMuteOnJoin: forceMuteOnJoin, onToggleForceMuteOnJoin: handleToggleForceMuteOnJoin, hasPassword: hasPassword, onSetPassword: handleSetPassword, waitingRoomEnabled: waitingRoomEnabled, onToggleWaitingRoomEnabled: handleToggleWaitingRoomEnabled, waitingParticipants: waitingParticipants, onAdmit: handleAdmitParticipant, onDeny: handleDenyParticipant, onClose: () => setDrawerOpen(false) }))] }));
299
448
  }
300
449
  /** Plays one remote participant's stream through a hidden `<audio>` element. `onBlocked` fires if the browser
301
450
  * refuses to start it (an autoplay policy) - the view then asks for a click. */
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The diagnostics panel, opened from the navbar for troubleshooting a call in progress - this browser's own
3
+ * capabilities, then each participant's connection: how their media reaches this one (the same badge
4
+ * `_ParticipantTile.tsx` shows, reused via `TRANSPORT_BADGES` rather than described in different words), and,
5
+ * once available, round-trip time and each kind's packet loss/jitter/bytes transferred
6
+ * (`MeshParticipant.diagnostics`, refreshed every few seconds by `MeshConnectionManager`'s own poller - see its doc
7
+ * comment). Purely informational and entirely local: nothing here is sent anywhere.
8
+ *
9
+ * `diagnostics` is `undefined` until the first poll completes (shortly after a connection comes up) and always
10
+ * absent for a `"websocket"`-relayed participant (there is no `RTCPeerConnection` to poll there) - both cases show
11
+ * "Not available" rather than a blank space, so it never looks like the panel forgot to load.
12
+ */
13
+ import React from "react";
14
+ import type { MeshParticipant } from "../shared/webrtc/types.js";
15
+ export interface DiagnosticsPanelProps {
16
+ selfName: string;
17
+ /** Whether the local microphone/camera are actually sending - `LocalMedia.micOn`/`cameraOn`. */
18
+ micOn: boolean;
19
+ cameraOn: boolean;
20
+ participants: MeshParticipant[];
21
+ }
22
+ export default function DiagnosticsPanel({ selfName, micOn, cameraOn, participants }: DiagnosticsPanelProps): React.JSX.Element;
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { TRANSPORT_BADGES } from "./_ParticipantTile.js";
3
+ /** This browser's support for what a call needs - checked once, since it cannot change mid-session. */
4
+ function browserCapabilities() {
5
+ const has = (name) => typeof window !== "undefined" && name in window;
6
+ return [
7
+ { label: "WebRTC", supported: has("RTCPeerConnection") },
8
+ { label: "Screen sharing", supported: typeof navigator !== "undefined" && !!navigator.mediaDevices?.getDisplayMedia },
9
+ { label: "WebCodecs (server relay fallback)", supported: has("VideoEncoder") && has("AudioEncoder") },
10
+ ];
11
+ }
12
+ function formatMs(seconds) {
13
+ return seconds === undefined ? "—" : `${Math.round(seconds * 1000)} ms`;
14
+ }
15
+ function formatBytes(bytes) {
16
+ return bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
17
+ }
18
+ const ROW = "flex justify-between gap-3 py-0.5";
19
+ const LABEL = "text-white/60";
20
+ function StreamRow({ kind, packetsLost, jitter, bytesSent, bytesReceived }) {
21
+ if (packetsLost === undefined && jitter === undefined && bytesSent === undefined && bytesReceived === undefined) {
22
+ return null;
23
+ }
24
+ return (_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: kind }), _jsxs("span", { children: [packetsLost !== undefined && `${packetsLost} lost`, jitter !== undefined && ` · ${formatMs(jitter)} jitter`, (bytesSent !== undefined || bytesReceived !== undefined) &&
25
+ ` · ${[bytesSent !== undefined && `↑${formatBytes(bytesSent)}`, bytesReceived !== undefined && `↓${formatBytes(bytesReceived)}`].filter(Boolean).join(" ")}`] })] }));
26
+ }
27
+ export default function DiagnosticsPanel({ selfName, micOn, cameraOn, participants }) {
28
+ return (_jsxs("div", { className: "text-sm", children: [_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-white/60 mb-1.5", children: "This browser" }), browserCapabilities().map(({ label, supported }) => (_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: label }), _jsx("span", { className: supported ? "text-[#8ab4f8]" : "text-[#f2b8b5]", children: supported ? "Supported" : "Not supported" })] }, label))), _jsxs("p", { className: "text-xs font-semibold uppercase tracking-wide text-white/60 mt-3 mb-1.5", children: [selfName, " ", _jsx("span", { className: "text-white/60", children: "(you)" })] }), _jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Sending" }), _jsx("span", { children: [micOn && "audio", cameraOn && "video"].filter(Boolean).join(" + ") || "Nothing" })] }), participants.length === 0 && _jsx("p", { className: "text-white/60 mt-3", children: "Nobody else is in the call yet." }), participants.map((p) => {
29
+ const badge = p.transport !== "connecting" && p.transport !== "p2p" ? TRANSPORT_BADGES[p.transport] : undefined;
30
+ return (_jsxs("div", { children: [_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-white/60 mt-3 mb-1.5", children: p.name }), _jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Connection" }), _jsx("span", { title: badge?.title, children: badge?.label ?? (p.transport === "connecting" ? "Connecting…" : "Direct") })] }), p.transport === "websocket" || !p.diagnostics ? (_jsxs("p", { className: `${LABEL} py-0.5`, children: ["Not available", p.transport !== "websocket" && " yet", "."] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Round-trip time" }), _jsx("span", { children: formatMs(p.diagnostics.roundTripTimeSeconds) })] }), _jsx(StreamRow, { kind: "Audio", ...p.diagnostics.audio }), _jsx(StreamRow, { kind: "Video", ...p.diagnostics.video })] }))] }, p.uid));
31
+ })] }));
32
+ }
@@ -13,6 +13,14 @@
13
13
  * reflection, and mirroring it would show it backwards to no one but themselves. */
14
14
  import React from "react";
15
15
  import type { MediaTransport } from "../shared/webrtc/types.js";
16
+ /** What a tile says about how its participant's media is arriving, for the paths that are not the ordinary direct one
17
+ * (which needs no comment). Text rather than only an icon, so the reason a picture is degraded is never a guess.
18
+ * Exported so `_DiagnosticsPanel.tsx` shows the identical label/title rather than describing the same paths in
19
+ * slightly different words. */
20
+ export declare const TRANSPORT_BADGES: Partial<Record<MediaTransport, {
21
+ label: string;
22
+ title: string;
23
+ }>>;
16
24
  export interface ParticipantTileProps {
17
25
  name: string;
18
26
  stream?: MediaStream | null;
@@ -19,9 +19,16 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
19
19
  import { useEffect, useRef } from "react";
20
20
  import { MicOffIcon } from "./_icons.js";
21
21
  /** What a tile says about how its participant's media is arriving, for the paths that are not the ordinary direct one
22
- * (which needs no comment). Text rather than only an icon, so the reason a picture is degraded is never a guess. */
23
- const TRANSPORT_BADGES = {
22
+ * (which needs no comment). Text rather than only an icon, so the reason a picture is degraded is never a guess.
23
+ * Exported so `_DiagnosticsPanel.tsx` shows the identical label/title rather than describing the same paths in
24
+ * slightly different words. */
25
+ export const TRANSPORT_BADGES = {
24
26
  turn: { label: "Relayed", title: "Connected through the relay server, because a direct connection was not possible." },
27
+ "turn-tcp": {
28
+ label: "Relayed (TCP)",
29
+ title: "Connected through the relay server over TCP, because this network blocks the relay's usual UDP connection too. " +
30
+ "Audio or video may pause briefly under network strain rather than just glitching - a property of TCP, not a dropped connection.",
31
+ },
25
32
  websocket: {
26
33
  label: "Server relay",
27
34
  title: "Sent through the server as a last resort, because no other connection was possible. Video is lower quality and sound may lag.",
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The participants drawer, opened from the header's participant-count chip: a right-side panel listing everyone in
3
+ * the call, "you" first. The same mic-muted/hand-raised/transport-badge information `_ParticipantTile.tsx` already
4
+ * shows on a tile, as plain rows instead (a list reads better than tiles once there's a reason to scan for one
5
+ * name).
6
+ *
7
+ * Closes on Escape, on a click on the backdrop behind it, or its own close button - never on a click inside the
8
+ * drawer itself, matching `_CallControls.tsx`'s menus.
9
+ *
10
+ * A "Host" tag marks the host's own row (`isSelfHost`/`isParticipantHost` - see `_CallView.tsx`'s doc comment on
11
+ * `hostUid` for what that identity is and isn't). Only while `isSelfHost` does every *other* row also get a "Mute"
12
+ * button (hidden once that participant is already muted - there is no useful "unmute someone else" action to
13
+ * offer) and a "Remove" button (a native `confirm()` first, since removal can't be undone by the participant the
14
+ * way a mute request can) - never on the local participant's own row, and never shown to anyone but the host.
15
+ *
16
+ * The header also gets a host-only "Mute on join" checkbox (`forceMuteOnJoin`/`onToggleForceMuteOnJoin`) - a
17
+ * setting for whoever joins *next*, not an action on anyone already here (muting someone already in the call is
18
+ * what each row's own "Mute" button is for) - a host-only password section (`hasPassword`/`onSetPassword`), the
19
+ * same "next joiner" scope: setting or changing it never affects anyone already in the call - and a host-only
20
+ * waiting-room toggle plus list (`waitingRoomEnabled`/`onToggleWaitingRoomEnabled`,
21
+ * `waitingParticipants`/`onAdmit`/`onDeny`): `_CallView.tsx` polls the list while this drawer is open and the
22
+ * caller is the host (there is no push signal for a newly filed admission request), so it stays current without
23
+ * the host needing to close and reopen the drawer.
24
+ */
25
+ import React from "react";
26
+ import type { MeshParticipant } from "../shared/webrtc/types.js";
27
+ import type { WaitingParticipant } from "./_meetApi.js";
28
+ export interface ParticipantsDrawerProps {
29
+ selfName: string;
30
+ micOn: boolean;
31
+ handRaised: boolean;
32
+ participants: MeshParticipant[];
33
+ isSelfHost: boolean;
34
+ isParticipantHost: (uid: string) => boolean;
35
+ /** Sends a mute request to the given participant's `uid` - a no-op from the recipient's end if they're already
36
+ * muted (see `Row`'s own "already muted" guard, which never even shows the button then). */
37
+ onMute: (uid: string) => void;
38
+ /** Removes the given participant's `uid` - see `_CallView.tsx`'s `handleKickParticipant()` for what this
39
+ * actually does (the cooperative signal plus the enforced server-side revoke). */
40
+ onKick: (uid: string) => void;
41
+ /** Whether a newly joining participant currently starts muted - the header checkbox's own state, host-only. */
42
+ forceMuteOnJoin: boolean;
43
+ onToggleForceMuteOnJoin: () => void;
44
+ /** Whether this meeting currently requires a password - never the password itself, which the server never
45
+ * sends back (see `PublicVideoMeeting.hasPassword`'s own doc comment). */
46
+ hasPassword: boolean;
47
+ /** Sets (a non-empty string), replaces, or removes (`null`) the join password. Rejecting lets
48
+ * `PasswordSection` show its own inline error without this drawer needing to know anything about it. */
49
+ onSetPassword: (password: string | null) => Promise<void>;
50
+ /** Whether the meeting currently requires the host to admit each participant - the header checkbox's own
51
+ * state, host-only. */
52
+ waitingRoomEnabled: boolean;
53
+ onToggleWaitingRoomEnabled: () => void;
54
+ /** Everyone currently waiting to be admitted - `_CallView.tsx`'s own polled, always-current list; empty
55
+ * whenever this drawer isn't open and host, by construction. */
56
+ waitingParticipants: WaitingParticipant[];
57
+ onAdmit: (uid: string) => void;
58
+ onDeny: (uid: string) => void;
59
+ onClose: () => void;
60
+ }
61
+ export default function ParticipantsDrawer({ selfName, micOn, handRaised, participants, isSelfHost, isParticipantHost, onMute, onKick, forceMuteOnJoin, onToggleForceMuteOnJoin, hasPassword, onSetPassword, waitingRoomEnabled, onToggleWaitingRoomEnabled, waitingParticipants, onAdmit, onDeny, onClose, }: ParticipantsDrawerProps): React.JSX.Element;