@rapidmx/meet-plugin 0.13.1 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -55
- package/apps/meet/[token].tsx +341 -341
- package/apps/meet/_CallControls.tsx +463 -394
- package/apps/meet/_CallView.tsx +891 -848
- package/apps/meet/_DiagnosticsWindow.tsx +80 -0
- package/apps/meet/_MeetLobby.tsx +224 -224
- package/apps/meet/_ParticipantTile.tsx +169 -169
- package/apps/meet/_ParticipantsDrawer.tsx +10 -104
- package/apps/meet/_SettingsModal.tsx +152 -0
- package/apps/meet/_icons.tsx +80 -82
- package/apps/meet/_meetApi.ts +212 -212
- package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
- package/apps/shared/media/filters/mlModels.ts +174 -174
- package/apps/shared/push/GuestSignalingClient.ts +359 -359
- package/apps/shared/relay/RelayClient.ts +7 -2
- package/apps/shared/webrtc/MeshConnectionManager.ts +949 -795
- package/apps/shared/webrtc/types.ts +247 -222
- package/dist/apps/meet/_CallControls.d.ts +19 -9
- package/dist/apps/meet/_CallControls.js +28 -10
- package/dist/apps/meet/_CallView.d.ts +14 -2
- package/dist/apps/meet/_CallView.js +27 -2
- package/dist/apps/meet/_DiagnosticsWindow.d.ts +25 -0
- package/dist/apps/meet/_DiagnosticsWindow.js +45 -0
- package/dist/apps/meet/_ParticipantsDrawer.d.ts +10 -21
- package/dist/apps/meet/_ParticipantsDrawer.js +9 -39
- package/dist/apps/meet/_SettingsModal.d.ts +28 -0
- package/dist/apps/meet/_SettingsModal.js +59 -0
- package/dist/apps/meet/_icons.d.ts +1 -1
- package/dist/apps/meet/_icons.js +1 -1
- package/dist/apps/shared/relay/RelayClient.d.ts +6 -1
- package/dist/apps/shared/relay/RelayClient.js +7 -2
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +55 -2
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +153 -9
- package/dist/apps/shared/webrtc/types.d.ts +25 -1
- package/dist/lib/util/RedisRelayBus.js +61 -5
- package/dist/lib/util/RedisRelayBus.js.map +1 -1
- package/dist/lib/util/RelayHub.js +10 -3
- package/dist/lib/util/RelayHub.js.map +1 -1
- package/dist/types/util/RedisRelayBus.d.ts +45 -6
- package/dist/types/util/RelayHub.d.ts +9 -2
- package/package.json +208 -208
|
@@ -10,24 +10,33 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
10
10
|
* screen, reactions, raise hand, grid/focus and leave. The effects button opens the video filter picker
|
|
11
11
|
* (`_EffectsPanel.tsx`) and lights up while any filter is on.
|
|
12
12
|
*
|
|
13
|
-
* The diagnostics button is the one exception to "opens near its own button": its panel is `fixed` to the
|
|
14
|
-
* viewport's top-left corner rather than anchored above the button, since it reads better as a HUD-style overlay
|
|
15
|
-
* sitting over the call than as a dropdown tucked into the bottom control bar. The outside-click/Escape handling
|
|
16
|
-
* (`barRef`) still closes it the same way, since it's still a DOM descendant of the bar - only its visual position
|
|
17
|
-
* moved, not its open/close wiring.
|
|
18
|
-
*
|
|
19
13
|
* The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
|
|
20
14
|
* picking up, so a participant can see their audio is being sent - and the camera button shows a green dot while a
|
|
21
15
|
* camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
|
|
22
16
|
* is disabled (`micLocked`) while talking-stick mode is on and this participant isn't the current holder - see
|
|
23
17
|
* `_CallView.tsx`'s doc comment.
|
|
18
|
+
*
|
|
19
|
+
* ## The "…" menu
|
|
20
|
+
*
|
|
21
|
+
* Sits between the grid/focus toggle and Leave, a catch-all for controls that don't need a dedicated button of
|
|
22
|
+
* their own: "Diagnostics" (`onOpenDiagnostics`) and "Settings" (`onOpenSettings`, host-only) each open their own
|
|
23
|
+
* persistent window/modal in `_CallView.tsx` and close this menu; "Connection method" is a four-way
|
|
24
|
+
* `menuitemradio` group (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment)
|
|
25
|
+
* forcing how *this tab's* media reaches everyone else, independent of what anyone else has chosen. Otherwise an
|
|
26
|
+
* ordinary menu of this bar's own shape (`openMenu`, closes on Escape/outside click like every other one here).
|
|
24
27
|
*/
|
|
25
28
|
import { useEffect, useRef, useState } from "react";
|
|
26
29
|
import { filtersActive } from "../shared/media/filters/filterTypes.js";
|
|
27
30
|
import { REACTION_EMOJIS } from "../shared/webrtc/types.js";
|
|
28
|
-
import DiagnosticsPanel from "./_DiagnosticsPanel.js";
|
|
29
31
|
import EffectsPanel from "./_EffectsPanel.js";
|
|
30
|
-
import { ChevronUpIcon,
|
|
32
|
+
import { ChevronUpIcon, EffectsIcon, EmojiIcon, FlipIcon, FocusIcon, GridIcon, HandIcon, LeaveIcon, MicIcon, MicOffIcon, OverflowIcon, RotateIcon, ScreenShareIcon, VideoIcon, VideoOffIcon, } from "./_icons.js";
|
|
33
|
+
/** The "…" menu's "Connection method" options, in display order - see `TransportMode`'s own doc comment. */
|
|
34
|
+
const TRANSPORT_MODE_OPTIONS = [
|
|
35
|
+
{ mode: "auto", label: "Auto" },
|
|
36
|
+
{ mode: "p2p", label: "P2P" },
|
|
37
|
+
{ mode: "relay", label: "Relay" },
|
|
38
|
+
{ mode: "websocket", label: "WebSocket Relay" },
|
|
39
|
+
];
|
|
31
40
|
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";
|
|
32
41
|
const NEUTRAL = "bg-[#3c4043] text-white hover:bg-[#4b4f53]";
|
|
33
42
|
const ALERT = "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]";
|
|
@@ -36,7 +45,7 @@ const ACTIVE = "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]";
|
|
|
36
45
|
export function LevelBars({ level }) {
|
|
37
46
|
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))) }));
|
|
38
47
|
}
|
|
39
|
-
export default function CallControls({ media,
|
|
48
|
+
export default function CallControls({ media, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, isHost, transportMode, onSetTransportMode, onOpenDiagnostics, onOpenSettings, onLeave, }) {
|
|
40
49
|
const [openMenu, setOpenMenu] = useState(null);
|
|
41
50
|
const barRef = useRef(null);
|
|
42
51
|
const shareDisabled = !isPresenting && !!presentingElsewhereName;
|
|
@@ -68,7 +77,16 @@ export default function CallControls({ media, selfName, participants, isPresenti
|
|
|
68
77
|
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} disabled:opacity-50 disabled:cursor-not-allowed`, "aria-label": media.micOn ? "Mute microphone" : "Unmute microphone", "aria-pressed": !media.micOn, disabled: micLocked, title: micLocked ? "Only the current talking-stick holder can unmute." : undefined, 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: () => {
|
|
69
78
|
onReaction(emoji);
|
|
70
79
|
setOpenMenu(null);
|
|
71
|
-
}, 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 === "
|
|
80
|
+
}, 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} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": viewMode === "grid" ? "Switch to focused view" : "Switch to grid view", disabled: participants.length < 2, title: participants.length < 2 ? "Grid and focused view look the same with only one other participant." : undefined, onClick: onToggleViewMode, children: viewMode === "grid" ? _jsx(FocusIcon, {}) : _jsx(GridIcon, {}) }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${openMenu === "overflow" ? ACTIVE : NEUTRAL}`, "aria-label": "More options", "aria-haspopup": "menu", "aria-expanded": openMenu === "overflow", onClick: () => toggleMenu("overflow"), children: _jsx(OverflowIcon, {}) }), openMenu === "overflow" && (_jsxs("div", { role: "menu", "aria-label": "More options", className: "absolute bottom-full mb-3 right-0 w-56 max-w-[calc(100vw-1rem)] rounded-2xl bg-[#2b2d30] text-white shadow-xl py-1 text-sm", children: [_jsx("button", { type: "button", role: "menuitem", className: "w-full px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
|
|
81
|
+
onOpenDiagnostics();
|
|
82
|
+
setOpenMenu(null);
|
|
83
|
+
}, children: "Diagnostics" }), _jsx("div", { className: "my-1 border-t border-white/10" }), _jsx("p", { className: "px-3 pt-1 pb-0.5 text-xs font-semibold uppercase tracking-wide text-white/60", children: "Connection method" }), TRANSPORT_MODE_OPTIONS.map(({ mode, label }) => (_jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": transportMode === mode, className: "flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
|
|
84
|
+
onSetTransportMode(mode);
|
|
85
|
+
setOpenMenu(null);
|
|
86
|
+
}, children: [_jsx("span", { className: "w-4 text-[#8ab4f8]", "aria-hidden": "true", children: transportMode === mode ? "✓" : "" }), label] }, mode))), isHost && (_jsxs(_Fragment, { children: [_jsx("div", { className: "my-1 border-t border-white/10" }), _jsx("button", { type: "button", role: "menuitem", className: "w-full px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
|
|
87
|
+
onOpenSettings();
|
|
88
|
+
setOpenMenu(null);
|
|
89
|
+
}, children: "Settings" })] }))] }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`, "aria-label": "Leave call", onClick: () => onLeave(), children: _jsx(LeaveIcon, {}) })] }));
|
|
72
90
|
}
|
|
73
91
|
/** The device picker opened from the chevron beside the microphone/camera button: the available devices with the
|
|
74
92
|
* one in use ticked, or - while there are none to list - why, with a way to ask for access again. */
|
|
@@ -46,6 +46,18 @@
|
|
|
46
46
|
* retroactive" posture. A holder who leaves the call is not specially reassigned - see
|
|
47
47
|
* `MeshConnectionManager`'s own doc comment on why that needs no extra code - the header chip then says nobody has
|
|
48
48
|
* the floor, until the host picks someone (or themselves) again.
|
|
49
|
+
*
|
|
50
|
+
* ## Diagnostics, connection method and settings
|
|
51
|
+
*
|
|
52
|
+
* All three are opened from `_CallControls.tsx`'s own "…" menu (its doc comment covers the menu itself) via
|
|
53
|
+
* callback props, but owned here: `diagnosticsOpen` renders `_DiagnosticsWindow.tsx` (a persistent, draggable
|
|
54
|
+
* window - not a popover like the menus in the control bar, since troubleshooting a connection is something a
|
|
55
|
+
* participant wants to keep open while they poke at the rest of the call, not something that closes the moment
|
|
56
|
+
* they click elsewhere); `transportMode`/`handleSetTransportMode()` forces how *this tab's* media reaches everyone
|
|
57
|
+
* else (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment), independent of what
|
|
58
|
+
* anyone else has chosen; `settingsOpen` renders `_SettingsModal.tsx`, which used to be the participants drawer's
|
|
59
|
+
* own header (mute-on-join, the join password, the waiting-room toggle) - moved out because none of those are
|
|
60
|
+
* about *who* is in the call, which is what the drawer is for.
|
|
49
61
|
*/
|
|
50
62
|
import React from "react";
|
|
51
63
|
import type { LocalMedia } from "../shared/media/useLocalMedia.js";
|
|
@@ -68,12 +80,12 @@ export interface CallViewProps {
|
|
|
68
80
|
* server couldn't resolve one, in which case nobody sees host controls. */
|
|
69
81
|
hostUid?: string;
|
|
70
82
|
/** `PublicVideoMeeting.forceMuteOnJoin` as of this call's own `join()` - the starting value for the host's
|
|
71
|
-
* toggle in the
|
|
83
|
+
* toggle in the settings modal, which this view then owns and keeps current itself (see
|
|
72
84
|
* `handleToggleForceMuteOnJoin()`). Does not update if changed elsewhere while this view is mounted - there is
|
|
73
85
|
* no signal for that today, matching every other meeting-settings field's lack of live sync. */
|
|
74
86
|
initialForceMuteOnJoin?: boolean;
|
|
75
87
|
/** `PublicVideoMeeting.hasPassword` as of this call's own `join()` - the starting value for the host's password
|
|
76
|
-
* section in the
|
|
88
|
+
* section in the settings modal, same "owned and kept current by this view" shape as
|
|
77
89
|
* `initialForceMuteOnJoin`. */
|
|
78
90
|
initialHasPassword?: boolean;
|
|
79
91
|
/** `PublicVideoMeeting.waitingRoomEnabled` as of this call's own `join()` - the starting value for the host's
|
|
@@ -51,6 +51,18 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
51
51
|
* retroactive" posture. A holder who leaves the call is not specially reassigned - see
|
|
52
52
|
* `MeshConnectionManager`'s own doc comment on why that needs no extra code - the header chip then says nobody has
|
|
53
53
|
* the floor, until the host picks someone (or themselves) again.
|
|
54
|
+
*
|
|
55
|
+
* ## Diagnostics, connection method and settings
|
|
56
|
+
*
|
|
57
|
+
* All three are opened from `_CallControls.tsx`'s own "…" menu (its doc comment covers the menu itself) via
|
|
58
|
+
* callback props, but owned here: `diagnosticsOpen` renders `_DiagnosticsWindow.tsx` (a persistent, draggable
|
|
59
|
+
* window - not a popover like the menus in the control bar, since troubleshooting a connection is something a
|
|
60
|
+
* participant wants to keep open while they poke at the rest of the call, not something that closes the moment
|
|
61
|
+
* they click elsewhere); `transportMode`/`handleSetTransportMode()` forces how *this tab's* media reaches everyone
|
|
62
|
+
* else (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment), independent of what
|
|
63
|
+
* anyone else has chosen; `settingsOpen` renders `_SettingsModal.tsx`, which used to be the participants drawer's
|
|
64
|
+
* own header (mute-on-join, the join password, the waiting-room toggle) - moved out because none of those are
|
|
65
|
+
* about *who* is in the call, which is what the drawer is for.
|
|
54
66
|
*/
|
|
55
67
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
56
68
|
import { playRaisedHandChime } from "../shared/media/chime.js";
|
|
@@ -65,8 +77,11 @@ import { GuestSignalingClient } from "../shared/push/GuestSignalingClient.js";
|
|
|
65
77
|
import CallControls from "./_CallControls.js";
|
|
66
78
|
import { BatonIcon } from "./_icons.js";
|
|
67
79
|
import { admitParticipant, denyParticipant, kickParticipant, listWaitingParticipants, setForceMuteOnJoin as apiSetForceMuteOnJoin, setMeetingPassword, setWaitingRoomEnabled as apiSetWaitingRoomEnabled, } from "./_meetApi.js";
|
|
80
|
+
import DiagnosticsPanel from "./_DiagnosticsPanel.js";
|
|
81
|
+
import DiagnosticsWindow from "./_DiagnosticsWindow.js";
|
|
68
82
|
import ParticipantTile from "./_ParticipantTile.js";
|
|
69
83
|
import ParticipantsDrawer from "./_ParticipantsDrawer.js";
|
|
84
|
+
import SettingsModal from "./_SettingsModal.js";
|
|
70
85
|
/** How long a reaction floats on screen. */
|
|
71
86
|
const REACTION_MS = 4000;
|
|
72
87
|
/** The most reactions shown at once - a burst beyond this drops the oldest. */
|
|
@@ -127,6 +142,9 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
127
142
|
const [waitingParticipants, setWaitingParticipants] = useState([]);
|
|
128
143
|
const [talkingStickActive, setTalkingStickActive] = useState(false);
|
|
129
144
|
const [talkingStickHolder, setTalkingStickHolder] = useState(undefined);
|
|
145
|
+
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
|
|
146
|
+
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
147
|
+
const [transportMode, setTransportModeState] = useState("auto");
|
|
130
148
|
const managerRef = useRef(null);
|
|
131
149
|
/** The raw capture from `getDisplayMedia()` - only ever used to stop it (releasing the OS's own share
|
|
132
150
|
* indicator) and to notice the participant ending the share from the browser's own UI (`track.onended`). What's
|
|
@@ -415,6 +433,13 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
415
433
|
function handleGiveTalkingStick(uid) {
|
|
416
434
|
managerRef.current?.setTalkingStick(true, uid);
|
|
417
435
|
}
|
|
436
|
+
/** Forces this tab's own transport policy - see `MeshConnectionManager.setTransportMode()`'s doc comment for
|
|
437
|
+
* what each mode actually does, and `_CallControls.tsx`'s doc comment on its "…" menu for why this is a
|
|
438
|
+
* personal, per-tab choice rather than a meeting setting. */
|
|
439
|
+
function handleSetTransportMode(mode) {
|
|
440
|
+
setTransportModeState(mode);
|
|
441
|
+
managerRef.current?.setTransportMode(mode);
|
|
442
|
+
}
|
|
418
443
|
/** Admits one pending request - optimistically removed from the drawer's own list (the next poll would drop it
|
|
419
444
|
* anyway, once the requester's own next poll completes their join; removing it here just avoids the visible
|
|
420
445
|
* delay). Re-added if the save itself fails, so a failure doesn't silently lose the request from the list. */
|
|
@@ -502,10 +527,10 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
502
527
|
else {
|
|
503
528
|
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")) }));
|
|
504
529
|
}
|
|
505
|
-
return (_jsxs("div", { className: "fixed inset-0 z-50 flex 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("div", { className: "relative flex-1 min-w-0 flex flex-col overflow-hidden", children: [_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(", ")] })), talkingStickActive && (_jsxs("span", { className: "max-w-[45vw] truncate px-3 py-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-sm font-medium", "data-testid": "talking-stick-status", children: ["\uD83C\uDF99\uFE0F ", talkingStickHolderName ? `${talkingStickHolderName} ${talkingStickHolderName === "You" ? "have" : "has"} the floor` : "Waiting for the host to choose a speaker"] })), isHost && (_jsx("button", { type: "button", className: `w-9 h-9 flex items-center justify-center rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 ${talkingStickActive ? "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]" : "bg-[#3c4043] hover:bg-[#4b4f53]"}`, "aria-label": talkingStickActive ? "End talking stick" : "Talking stick", "aria-pressed": talkingStickActive, title: talkingStickActive ? "End talking stick" : "Start talking stick mode", onClick: handleToggleTalkingStick, children: _jsx(BatonIcon, {}) })), _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,
|
|
530
|
+
return (_jsxs("div", { className: "fixed inset-0 z-50 flex 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("div", { className: "relative flex-1 min-w-0 flex flex-col overflow-hidden", children: [_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(", ")] })), talkingStickActive && (_jsxs("span", { className: "max-w-[45vw] truncate px-3 py-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-sm font-medium", "data-testid": "talking-stick-status", children: ["\uD83C\uDF99\uFE0F ", talkingStickHolderName ? `${talkingStickHolderName} ${talkingStickHolderName === "You" ? "have" : "has"} the floor` : "Waiting for the host to choose a speaker"] })), isHost && (_jsx("button", { type: "button", className: `w-9 h-9 flex items-center justify-center rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 ${talkingStickActive ? "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]" : "bg-[#3c4043] hover:bg-[#4b4f53]"}`, "aria-label": talkingStickActive ? "End talking stick" : "Talking stick", "aria-pressed": talkingStickActive, title: talkingStickActive ? "End talking stick" : "Start talking stick mode", onClick: handleToggleTalkingStick, children: _jsx(BatonIcon, {}) })), _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, 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")), micLocked: micLocked, isHost: isHost, transportMode: transportMode, onSetTransportMode: handleSetTransportMode, onOpenDiagnostics: () => setDiagnosticsOpen(true), onOpenSettings: () => setSettingsOpen(true), 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) => {
|
|
506
531
|
const stream = remoteStreams[p.uid];
|
|
507
532
|
return stream ? _jsx(RemoteAudio, { stream: stream, onBlocked: handleAudioBlocked }, `${p.uid}:${audioNonce}`) : null;
|
|
508
|
-
}), _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,
|
|
533
|
+
}), _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, waitingRoomEnabled: waitingRoomEnabled, waitingParticipants: waitingParticipants, onAdmit: handleAdmitParticipant, onDeny: handleDenyParticipant, talkingStickActive: talkingStickActive, selfPeerId: peerId, talkingStickHolder: talkingStickHolder, onGiveTalkingStick: handleGiveTalkingStick, onClose: () => setDrawerOpen(false) })), diagnosticsOpen && (_jsx(DiagnosticsWindow, { onClose: () => setDiagnosticsOpen(false), children: _jsx(DiagnosticsPanel, { selfName: selfName, micOn: media.micOn, cameraOn: media.cameraOn, participants: participants }) })), settingsOpen && isHost && (_jsx(SettingsModal, { forceMuteOnJoin: forceMuteOnJoin, onToggleForceMuteOnJoin: handleToggleForceMuteOnJoin, hasPassword: hasPassword, onSetPassword: handleSetPassword, waitingRoomEnabled: waitingRoomEnabled, onToggleWaitingRoomEnabled: handleToggleWaitingRoomEnabled, onClose: () => setSettingsOpen(false) }))] }));
|
|
509
534
|
}
|
|
510
535
|
/** Plays one remote participant's stream through a hidden `<audio>` element. `onBlocked` fires if the browser
|
|
511
536
|
* refuses to start it (an autoplay policy) - the view then asks for a click. */
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A draggable, persistent window around `_DiagnosticsPanel.tsx` - unlike every other popover in this call (which
|
|
3
|
+
* closes the moment focus moves elsewhere), this one stays open and visible over the call for as long as a
|
|
4
|
+
* participant is actively troubleshooting a connection, until they close it themselves (the title bar's own ✕).
|
|
5
|
+
* Opened from the navbar's "…" menu (`_CallView.tsx`).
|
|
6
|
+
*
|
|
7
|
+
* Dragging is tracked on `document`, not the title bar itself, so a fast drag that outruns the pointer never gets
|
|
8
|
+
* "stuck" the way listening only on the title bar's own element would - the standard way to implement a drag
|
|
9
|
+
* without relying on `setPointerCapture()` (unimplemented in the jsdom this is tested under). The window's position
|
|
10
|
+
* is owned by this component (`position` state), not the caller - closing and reopening it starts over at
|
|
11
|
+
* `initialPosition` rather than remembering where it was left, since there is nothing durable to anchor that to
|
|
12
|
+
* across the call ending and starting again.
|
|
13
|
+
*/
|
|
14
|
+
import React from "react";
|
|
15
|
+
export interface DiagnosticsWindowProps {
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
/** Where the window first appears - defaults to a spot near the top-left corner, clear of the header's own
|
|
18
|
+
* chips and buttons. */
|
|
19
|
+
initialPosition?: {
|
|
20
|
+
x: number;
|
|
21
|
+
y: number;
|
|
22
|
+
};
|
|
23
|
+
children: React.ReactNode;
|
|
24
|
+
}
|
|
25
|
+
export default function DiagnosticsWindow({ onClose, initialPosition, children }: DiagnosticsWindowProps): React.JSX.Element;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
/**
|
|
7
|
+
* A draggable, persistent window around `_DiagnosticsPanel.tsx` - unlike every other popover in this call (which
|
|
8
|
+
* closes the moment focus moves elsewhere), this one stays open and visible over the call for as long as a
|
|
9
|
+
* participant is actively troubleshooting a connection, until they close it themselves (the title bar's own ✕).
|
|
10
|
+
* Opened from the navbar's "…" menu (`_CallView.tsx`).
|
|
11
|
+
*
|
|
12
|
+
* Dragging is tracked on `document`, not the title bar itself, so a fast drag that outruns the pointer never gets
|
|
13
|
+
* "stuck" the way listening only on the title bar's own element would - the standard way to implement a drag
|
|
14
|
+
* without relying on `setPointerCapture()` (unimplemented in the jsdom this is tested under). The window's position
|
|
15
|
+
* is owned by this component (`position` state), not the caller - closing and reopening it starts over at
|
|
16
|
+
* `initialPosition` rather than remembering where it was left, since there is nothing durable to anchor that to
|
|
17
|
+
* across the call ending and starting again.
|
|
18
|
+
*/
|
|
19
|
+
import { useEffect, useRef, useState } from "react";
|
|
20
|
+
const DEFAULT_POSITION = { x: 12, y: 56 };
|
|
21
|
+
export default function DiagnosticsWindow({ onClose, initialPosition = DEFAULT_POSITION, children }) {
|
|
22
|
+
const [position, setPosition] = useState(initialPosition);
|
|
23
|
+
const [dragging, setDragging] = useState(false);
|
|
24
|
+
const dragOffsetRef = useRef({ x: 0, y: 0 });
|
|
25
|
+
function handleTitleBarPointerDown(event) {
|
|
26
|
+
dragOffsetRef.current = { x: event.clientX - position.x, y: event.clientY - position.y };
|
|
27
|
+
setDragging(true);
|
|
28
|
+
}
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!dragging) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const onMove = (event) => {
|
|
34
|
+
setPosition({ x: event.clientX - dragOffsetRef.current.x, y: event.clientY - dragOffsetRef.current.y });
|
|
35
|
+
};
|
|
36
|
+
const onUp = () => setDragging(false);
|
|
37
|
+
document.addEventListener("pointermove", onMove);
|
|
38
|
+
document.addEventListener("pointerup", onUp);
|
|
39
|
+
return () => {
|
|
40
|
+
document.removeEventListener("pointermove", onMove);
|
|
41
|
+
document.removeEventListener("pointerup", onUp);
|
|
42
|
+
};
|
|
43
|
+
}, [dragging]);
|
|
44
|
+
return (_jsxs("div", { role: "dialog", "aria-label": "Call diagnostics", className: "fixed z-30 w-80 max-w-[calc(100vw-1rem)] max-h-[70vh] flex flex-col rounded-2xl bg-[#2b2d30]/95 text-white shadow-xl overflow-hidden", style: { left: position.x, top: position.y }, children: [_jsxs("div", { className: "shrink-0 flex items-center justify-between gap-2 px-3 py-2 border-b border-white/10 cursor-move select-none", onPointerDown: handleTitleBarPointerDown, children: [_jsx("span", { className: "text-sm font-medium", children: "Call diagnostics" }), _jsx("button", { type: "button", className: "w-7 h-7 flex items-center justify-center rounded-full hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": "Close call diagnostics", onClick: onClose, children: "\u2715" })] }), _jsx("div", { className: "flex-1 min-h-0 overflow-y-auto p-3", children: children })] }));
|
|
45
|
+
}
|
|
@@ -15,14 +15,12 @@
|
|
|
15
15
|
* offer) and a "Remove" button (a native `confirm()` first, since removal can't be undone by the participant the
|
|
16
16
|
* way a mute request can) - never on the local participant's own row, and never shown to anyone but the host.
|
|
17
17
|
*
|
|
18
|
-
* The header also gets a host-only "
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* caller is the host (there is no push signal for a newly filed admission request), so it stays current without
|
|
25
|
-
* the host needing to close and reopen the drawer.
|
|
18
|
+
* The header also gets a host-only "who's waiting" list (`waitingParticipants`/`onAdmit`/`onDeny`), shown whenever
|
|
19
|
+
* `waitingRoomEnabled` is on - `_CallView.tsx` polls the list while this drawer is open and the caller is the host
|
|
20
|
+
* (there is no push signal for a newly filed admission request), so it stays current without the host needing to
|
|
21
|
+
* close and reopen the drawer. The waiting-room toggle itself, along with "mute new participants on join" and the
|
|
22
|
+
* join password, lives in `_SettingsModal.tsx` instead (opened from the navbar's "…" menu) - this drawer only
|
|
23
|
+
* reads `waitingRoomEnabled` to decide whether the list has anywhere to come from, it doesn't set it.
|
|
26
24
|
*
|
|
27
25
|
* While talking-stick mode is on (`talkingStickActive`), every row - including the host's own - shows a "Give
|
|
28
26
|
* stick" button, host-only, hidden on whichever row currently holds it (`talkingStickHolder`); everyone (not just
|
|
@@ -45,19 +43,10 @@ export interface ParticipantsDrawerProps {
|
|
|
45
43
|
/** Removes the given participant's `uid` - see `_CallView.tsx`'s `handleKickParticipant()` for what this
|
|
46
44
|
* actually does (the cooperative signal plus the enforced server-side revoke). */
|
|
47
45
|
onKick: (uid: string) => void;
|
|
48
|
-
/** Whether
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
/** Whether this meeting currently requires a password - never the password itself, which the server never
|
|
52
|
-
* sends back (see `PublicVideoMeeting.hasPassword`'s own doc comment). */
|
|
53
|
-
hasPassword: boolean;
|
|
54
|
-
/** Sets (a non-empty string), replaces, or removes (`null`) the join password. Rejecting lets
|
|
55
|
-
* `PasswordSection` show its own inline error without this drawer needing to know anything about it. */
|
|
56
|
-
onSetPassword: (password: string | null) => Promise<void>;
|
|
57
|
-
/** Whether the meeting currently requires the host to admit each participant - the header checkbox's own
|
|
58
|
-
* state, host-only. */
|
|
46
|
+
/** Whether the meeting currently requires the host to admit each participant - read-only here, set from
|
|
47
|
+
* `_SettingsModal.tsx` instead; this drawer only uses it to decide whether the waiting list has anywhere to
|
|
48
|
+
* come from. */
|
|
59
49
|
waitingRoomEnabled: boolean;
|
|
60
|
-
onToggleWaitingRoomEnabled: () => void;
|
|
61
50
|
/** Everyone currently waiting to be admitted - `_CallView.tsx`'s own polled, always-current list; empty
|
|
62
51
|
* whenever this drawer isn't open and host, by construction. */
|
|
63
52
|
waitingParticipants: WaitingParticipant[];
|
|
@@ -76,4 +65,4 @@ export interface ParticipantsDrawerProps {
|
|
|
76
65
|
onGiveTalkingStick: (uid: string) => void;
|
|
77
66
|
onClose: () => void;
|
|
78
67
|
}
|
|
79
|
-
export default function ParticipantsDrawer({ selfName, micOn, handRaised, participants, isSelfHost, isParticipantHost, onMute, onKick,
|
|
68
|
+
export default function ParticipantsDrawer({ selfName, micOn, handRaised, participants, isSelfHost, isParticipantHost, onMute, onKick, waitingRoomEnabled, waitingParticipants, onAdmit, onDeny, talkingStickActive, selfPeerId, talkingStickHolder, onGiveTalkingStick, onClose, }: ParticipantsDrawerProps): React.JSX.Element;
|
|
@@ -20,51 +20,21 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
20
20
|
* offer) and a "Remove" button (a native `confirm()` first, since removal can't be undone by the participant the
|
|
21
21
|
* way a mute request can) - never on the local participant's own row, and never shown to anyone but the host.
|
|
22
22
|
*
|
|
23
|
-
* The header also gets a host-only "
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* caller is the host (there is no push signal for a newly filed admission request), so it stays current without
|
|
30
|
-
* the host needing to close and reopen the drawer.
|
|
23
|
+
* The header also gets a host-only "who's waiting" list (`waitingParticipants`/`onAdmit`/`onDeny`), shown whenever
|
|
24
|
+
* `waitingRoomEnabled` is on - `_CallView.tsx` polls the list while this drawer is open and the caller is the host
|
|
25
|
+
* (there is no push signal for a newly filed admission request), so it stays current without the host needing to
|
|
26
|
+
* close and reopen the drawer. The waiting-room toggle itself, along with "mute new participants on join" and the
|
|
27
|
+
* join password, lives in `_SettingsModal.tsx` instead (opened from the navbar's "…" menu) - this drawer only
|
|
28
|
+
* reads `waitingRoomEnabled` to decide whether the list has anywhere to come from, it doesn't set it.
|
|
31
29
|
*
|
|
32
30
|
* While talking-stick mode is on (`talkingStickActive`), every row - including the host's own - shows a "Give
|
|
33
31
|
* stick" button, host-only, hidden on whichever row currently holds it (`talkingStickHolder`); everyone (not just
|
|
34
32
|
* the host) sees a badge on that row instead, same visibility as the "Host" tag. See `_CallView.tsx`'s doc comment
|
|
35
33
|
* on talking-stick mode for what granting it actually does to the recipient's microphone.
|
|
36
34
|
*/
|
|
37
|
-
import { useEffect
|
|
35
|
+
import { useEffect } from "react";
|
|
38
36
|
import { TRANSPORT_BADGES } from "./_ParticipantTile.js";
|
|
39
37
|
import { MicOffIcon } from "./_icons.js";
|
|
40
|
-
/** The host-only password form: one text input plus a Set/Change button, and a Remove button once one is set.
|
|
41
|
-
* Owns its own input/error/busy state locally - `_CallView.tsx` only needs to know whether a password exists, not
|
|
42
|
-
* what a half-typed one currently says. */
|
|
43
|
-
function PasswordSection({ hasPassword, onSetPassword }) {
|
|
44
|
-
const [input, setInput] = useState("");
|
|
45
|
-
const [error, setError] = useState(null);
|
|
46
|
-
const [busy, setBusy] = useState(false);
|
|
47
|
-
async function submit(password) {
|
|
48
|
-
setBusy(true);
|
|
49
|
-
setError(null);
|
|
50
|
-
try {
|
|
51
|
-
await onSetPassword(password);
|
|
52
|
-
setInput("");
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
setError("Could not save - try again.");
|
|
56
|
-
}
|
|
57
|
-
finally {
|
|
58
|
-
setBusy(false);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
return (_jsxs("div", { className: "px-3 py-2 border-b border-white/10 text-sm", children: [_jsx("p", { className: "text-white/70 mb-1.5", children: hasPassword ? "Password protection is on." : "No password required to join." }), _jsxs("form", { className: "flex gap-2", onSubmit: (event) => {
|
|
62
|
-
event.preventDefault();
|
|
63
|
-
if (input) {
|
|
64
|
-
void submit(input);
|
|
65
|
-
}
|
|
66
|
-
}, children: [_jsx("input", { type: "password", "aria-label": hasPassword ? "New password" : "Set a password", placeholder: hasPassword ? "New password" : "Set a password", className: "flex-1 min-w-0 px-2 py-1 rounded bg-white/10 text-white placeholder:text-white/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", value: input, onChange: (event) => setInput(event.target.value), disabled: busy }), _jsx("button", { type: "submit", className: "px-2 py-1 rounded text-xs whitespace-nowrap bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 disabled:opacity-50", disabled: busy || !input, children: hasPassword ? "Change" : "Set" })] }), hasPassword && (_jsx("button", { type: "button", className: "mt-1.5 text-xs text-[#f2b8b5] underline focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 disabled:opacity-50", disabled: busy, onClick: () => void submit(null), children: "Remove password" })), error && _jsx("p", { className: "text-[#f2b8b5] text-xs mt-1.5", children: error })] }));
|
|
67
|
-
}
|
|
68
38
|
/** The host-only "who's waiting" list - shown only while `waitingRoomEnabled` is on, since there is otherwise never
|
|
69
39
|
* anyone to list. Says so explicitly when the list is empty, rather than rendering nothing (silence here would be
|
|
70
40
|
* indistinguishable from the list just not having loaded yet). */
|
|
@@ -79,7 +49,7 @@ function Row({ name, isSelf, isHost, micMuted, handRaised, transport, canModerat
|
|
|
79
49
|
}
|
|
80
50
|
}, children: "Remove" }))] }));
|
|
81
51
|
}
|
|
82
|
-
export default function ParticipantsDrawer({ selfName, micOn, handRaised, participants, isSelfHost, isParticipantHost, onMute, onKick,
|
|
52
|
+
export default function ParticipantsDrawer({ selfName, micOn, handRaised, participants, isSelfHost, isParticipantHost, onMute, onKick, waitingRoomEnabled, waitingParticipants, onAdmit, onDeny, talkingStickActive, selfPeerId, talkingStickHolder, onGiveTalkingStick, onClose, }) {
|
|
83
53
|
useEffect(() => {
|
|
84
54
|
const onKeyDown = (event) => {
|
|
85
55
|
if (event.key === "Escape") {
|
|
@@ -89,5 +59,5 @@ export default function ParticipantsDrawer({ selfName, micOn, handRaised, partic
|
|
|
89
59
|
document.addEventListener("keydown", onKeyDown);
|
|
90
60
|
return () => document.removeEventListener("keydown", onKeyDown);
|
|
91
61
|
}, [onClose]);
|
|
92
|
-
return (_jsxs("aside", { role: "dialog", "aria-label": "Participants", className: "relative z-10 shrink-0 w-72 max-w-[85vw] h-full flex flex-col bg-[#2b2d30] text-white shadow-xl border-l border-white/10", children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-3 border-b border-white/10", children: [_jsxs("h2", { className: "text-sm font-medium", children: ["Participants (", participants.length + 1, ")"] }), _jsx("button", { type: "button", className: "w-8 h-8 flex items-center justify-center rounded-full hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": "Close participants", onClick: onClose, children: "\u2715" })] }), isSelfHost &&
|
|
62
|
+
return (_jsxs("aside", { role: "dialog", "aria-label": "Participants", className: "relative z-10 shrink-0 w-72 max-w-[85vw] h-full flex flex-col bg-[#2b2d30] text-white shadow-xl border-l border-white/10", children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-3 border-b border-white/10", children: [_jsxs("h2", { className: "text-sm font-medium", children: ["Participants (", participants.length + 1, ")"] }), _jsx("button", { type: "button", className: "w-8 h-8 flex items-center justify-center rounded-full hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": "Close participants", onClick: onClose, children: "\u2715" })] }), isSelfHost && waitingRoomEnabled && (_jsx(WaitingSection, { waitingParticipants: waitingParticipants, onAdmit: onAdmit, onDeny: onDeny })), _jsxs("ul", { className: "flex-1 min-h-0 overflow-y-auto p-2 text-sm", children: [_jsx(Row, { name: selfName, isSelf: true, isHost: isSelfHost, micMuted: !micOn, handRaised: handRaised, hasStick: talkingStickActive && talkingStickHolder === selfPeerId, canGiveStick: isSelfHost && talkingStickActive, onGiveStick: () => onGiveTalkingStick(selfPeerId) }), participants.map((p) => (_jsx(Row, { name: p.name, isHost: isParticipantHost(p.uid), micMuted: !p.audioOn, handRaised: p.handRaised, transport: p.transport, canModerate: isSelfHost, onMute: () => onMute(p.uid), onKick: () => onKick(p.uid), hasStick: talkingStickActive && talkingStickHolder === p.uid, canGiveStick: isSelfHost && talkingStickActive, onGiveStick: () => onGiveTalkingStick(p.uid) }, p.uid)))] })] }));
|
|
93
63
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host-only meeting settings modal, opened from the navbar's "…" menu (`_CallView.tsx`) - host-only because
|
|
3
|
+
* `_CallView.tsx` never renders this for anyone else. Holds the settings that used to live in the participants
|
|
4
|
+
* drawer's header: "Mute new participants on join" (`forceMuteOnJoin`), the join password
|
|
5
|
+
* (`hasPassword`/`onSetPassword`), and "Require the host to admit participants" (`waitingRoomEnabled`) - each a
|
|
6
|
+
* setting for whoever joins *next*, not an action on anyone already here (muting someone already in the call is
|
|
7
|
+
* the participants drawer's own "Mute" button; the drawer also keeps the live waiting-room list, since admitting
|
|
8
|
+
* someone is closer to "who's here" than a setting).
|
|
9
|
+
*
|
|
10
|
+
* An ordinary modal, not a docked panel like the drawer or a persistent window like diagnostics: closes on Escape,
|
|
11
|
+
* a click on the backdrop, or its own close button, matching `_CallControls.tsx`'s menus and this app's general
|
|
12
|
+
* "settings is a focused, modal task" convention.
|
|
13
|
+
*/
|
|
14
|
+
import React from "react";
|
|
15
|
+
export interface SettingsModalProps {
|
|
16
|
+
forceMuteOnJoin: boolean;
|
|
17
|
+
onToggleForceMuteOnJoin: () => void;
|
|
18
|
+
/** Whether this meeting currently requires a password - never the password itself (see
|
|
19
|
+
* `PublicVideoMeeting.hasPassword`'s own doc comment). */
|
|
20
|
+
hasPassword: boolean;
|
|
21
|
+
/** Sets (a non-empty string), replaces, or removes (`null`) the join password. Rejecting lets `PasswordSection`
|
|
22
|
+
* show its own inline error without this modal needing to know anything about it. */
|
|
23
|
+
onSetPassword: (password: string | null) => Promise<void>;
|
|
24
|
+
waitingRoomEnabled: boolean;
|
|
25
|
+
onToggleWaitingRoomEnabled: () => void;
|
|
26
|
+
onClose: () => void;
|
|
27
|
+
}
|
|
28
|
+
export default function SettingsModal({ forceMuteOnJoin, onToggleForceMuteOnJoin, hasPassword, onSetPassword, waitingRoomEnabled, onToggleWaitingRoomEnabled, onClose, }: SettingsModalProps): React.JSX.Element;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
/**
|
|
7
|
+
* The host-only meeting settings modal, opened from the navbar's "…" menu (`_CallView.tsx`) - host-only because
|
|
8
|
+
* `_CallView.tsx` never renders this for anyone else. Holds the settings that used to live in the participants
|
|
9
|
+
* drawer's header: "Mute new participants on join" (`forceMuteOnJoin`), the join password
|
|
10
|
+
* (`hasPassword`/`onSetPassword`), and "Require the host to admit participants" (`waitingRoomEnabled`) - each a
|
|
11
|
+
* setting for whoever joins *next*, not an action on anyone already here (muting someone already in the call is
|
|
12
|
+
* the participants drawer's own "Mute" button; the drawer also keeps the live waiting-room list, since admitting
|
|
13
|
+
* someone is closer to "who's here" than a setting).
|
|
14
|
+
*
|
|
15
|
+
* An ordinary modal, not a docked panel like the drawer or a persistent window like diagnostics: closes on Escape,
|
|
16
|
+
* a click on the backdrop, or its own close button, matching `_CallControls.tsx`'s menus and this app's general
|
|
17
|
+
* "settings is a focused, modal task" convention.
|
|
18
|
+
*/
|
|
19
|
+
import { useEffect, useState } from "react";
|
|
20
|
+
/** The password form: one text input plus a Set/Change button, and a Remove button once one is set. Owns its own
|
|
21
|
+
* input/error/busy state locally - the modal only needs to know whether a password exists, not what a half-typed
|
|
22
|
+
* one currently says. */
|
|
23
|
+
function PasswordSection({ hasPassword, onSetPassword }) {
|
|
24
|
+
const [input, setInput] = useState("");
|
|
25
|
+
const [error, setError] = useState(null);
|
|
26
|
+
const [busy, setBusy] = useState(false);
|
|
27
|
+
async function submit(password) {
|
|
28
|
+
setBusy(true);
|
|
29
|
+
setError(null);
|
|
30
|
+
try {
|
|
31
|
+
await onSetPassword(password);
|
|
32
|
+
setInput("");
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
setError("Could not save - try again.");
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
setBusy(false);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return (_jsxs("div", { className: "py-3 border-b border-white/10 text-sm", children: [_jsx("p", { className: "text-white/70 mb-1.5", children: hasPassword ? "Password protection is on." : "No password required to join." }), _jsxs("form", { className: "flex gap-2", onSubmit: (event) => {
|
|
42
|
+
event.preventDefault();
|
|
43
|
+
if (input) {
|
|
44
|
+
void submit(input);
|
|
45
|
+
}
|
|
46
|
+
}, children: [_jsx("input", { type: "password", "aria-label": hasPassword ? "New password" : "Set a password", placeholder: hasPassword ? "New password" : "Set a password", className: "flex-1 min-w-0 px-2 py-1 rounded bg-white/10 text-white placeholder:text-white/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", value: input, onChange: (event) => setInput(event.target.value), disabled: busy }), _jsx("button", { type: "submit", className: "px-2 py-1 rounded text-xs whitespace-nowrap bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 disabled:opacity-50", disabled: busy || !input, children: hasPassword ? "Change" : "Set" })] }), hasPassword && (_jsx("button", { type: "button", className: "mt-1.5 text-xs text-[#f2b8b5] underline focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 disabled:opacity-50", disabled: busy, onClick: () => void submit(null), children: "Remove password" })), error && _jsx("p", { className: "text-[#f2b8b5] text-xs mt-1.5", children: error })] }));
|
|
47
|
+
}
|
|
48
|
+
export default function SettingsModal({ forceMuteOnJoin, onToggleForceMuteOnJoin, hasPassword, onSetPassword, waitingRoomEnabled, onToggleWaitingRoomEnabled, onClose, }) {
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const onKeyDown = (event) => {
|
|
51
|
+
if (event.key === "Escape") {
|
|
52
|
+
onClose();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
document.addEventListener("keydown", onKeyDown);
|
|
56
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
57
|
+
}, [onClose]);
|
|
58
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-30 bg-black/40", onClick: onClose, "aria-hidden": "true" }), _jsxs("div", { role: "dialog", "aria-label": "Meeting settings", className: "fixed z-30 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 max-w-[calc(100vw-1.5rem)] max-h-[80vh] overflow-y-auto flex flex-col rounded-2xl bg-[#2b2d30] text-white shadow-xl", onClick: (event) => event.stopPropagation(), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-4 py-3 border-b border-white/10", children: [_jsx("h2", { className: "text-sm font-medium", children: "Meeting settings" }), _jsx("button", { type: "button", className: "w-8 h-8 flex items-center justify-center rounded-full hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": "Close meeting settings", onClick: onClose, children: "\u2715" })] }), _jsxs("div", { className: "px-4", children: [_jsxs("label", { className: "flex items-center gap-2 py-3 border-b border-white/10 text-sm", children: [_jsx("input", { type: "checkbox", checked: forceMuteOnJoin, onChange: onToggleForceMuteOnJoin, className: "w-4 h-4" }), "Mute new participants on join"] }), _jsx(PasswordSection, { hasPassword: hasPassword, onSetPassword: onSetPassword }), _jsxs("label", { className: "flex items-center gap-2 py-3 text-sm", children: [_jsx("input", { type: "checkbox", checked: waitingRoomEnabled, onChange: onToggleWaitingRoomEnabled, className: "w-4 h-4" }), "Require the host to admit participants"] })] })] })] }));
|
|
59
|
+
}
|
|
@@ -16,8 +16,8 @@ export declare const HandIcon: () => React.JSX.Element;
|
|
|
16
16
|
export declare const EffectsIcon: () => React.JSX.Element;
|
|
17
17
|
export declare const RotateIcon: () => React.JSX.Element;
|
|
18
18
|
export declare const FlipIcon: () => React.JSX.Element;
|
|
19
|
-
export declare const DiagnosticsIcon: () => React.JSX.Element;
|
|
20
19
|
/** A talking stick: a rounded shaft with a grip knob at one end, drawn (not a Material path - there isn't one for
|
|
21
20
|
* this) as a diagonal bar with a circle, rather than reusing `HandIcon`'s emoji-glyph fallback, since this one
|
|
22
21
|
* needs to light up (`currentColor`) the same way every other control icon here does. */
|
|
23
22
|
export declare const BatonIcon: () => React.JSX.Element;
|
|
23
|
+
export declare const OverflowIcon: () => React.JSX.Element;
|
package/dist/apps/meet/_icons.js
CHANGED
|
@@ -17,8 +17,8 @@ export const HandIcon = () => (_jsx("span", { className: "text-xl leading-none",
|
|
|
17
17
|
export const EffectsIcon = () => (_jsx(Icon, { path: "M19 9l1.25-2.75L23 5l-2.75-1.25L19 1l-1.25 2.75L15 5l2.75 1.25L19 9zm-7.5.5L9 4 6.5 9.5 1 12l5.5 2.5L9 20l2.5-5.5L17 12l-5.5-2.5zM19 15l-1.25 2.75L15 19l2.75 1.25L19 23l1.25-2.75L23 19l-2.75-1.25L19 15z" }));
|
|
18
18
|
export const RotateIcon = () => (_jsx(Icon, { path: "M12 6V2L7 7l5 5V8c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z" }));
|
|
19
19
|
export const FlipIcon = () => (_jsx(Icon, { path: "M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8 20h2V1h-2v22zm8-6h2v-2h-2v2zM15 5h2V3h-2v2zm4 8h2v-2h-2v2zm0 8c1.1 0 2-.9 2-2h-2v2z" }));
|
|
20
|
-
export const DiagnosticsIcon = () => (_jsx(Icon, { path: "M22 12h-4l-3 9L9 3l-3 9H2v2h5.6l1.4-4.2L13.4 21 17 12h5v-2z" }));
|
|
21
20
|
/** A talking stick: a rounded shaft with a grip knob at one end, drawn (not a Material path - there isn't one for
|
|
22
21
|
* this) as a diagonal bar with a circle, rather than reusing `HandIcon`'s emoji-glyph fallback, since this one
|
|
23
22
|
* needs to light up (`currentColor`) the same way every other control icon here does. */
|
|
24
23
|
export const BatonIcon = () => (_jsx("svg", { viewBox: "0 0 24 24", className: "w-6 h-6", fill: "currentColor", "aria-hidden": "true", focusable: "false", children: _jsxs("g", { transform: "rotate(45 12 12)", children: [_jsx("rect", { x: "5", y: "10.5", width: "13", height: "3", rx: "1.5" }), _jsx("circle", { cx: "19", cy: "12", r: "3" })] }) }));
|
|
24
|
+
export const OverflowIcon = () => _jsx(Icon, { path: "M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" });
|
|
@@ -9,7 +9,12 @@ export declare const MAX_NEGOTIATED_MESSAGE_BYTES: number;
|
|
|
9
9
|
/** The most senders the server lets one socket `want`. */
|
|
10
10
|
export declare const MAX_WANTED_PEERS = 32;
|
|
11
11
|
/** A media send is dropped, not queued, while more than this many bytes are still waiting to leave - the
|
|
12
|
-
* connection is slower than the media, and queueing would only add latency to the frames that are still fresh.
|
|
12
|
+
* connection is slower than the media, and queueing would only add latency to the frames that are still fresh.
|
|
13
|
+
* 64 KiB is a little under 1.4 seconds of `VideoSender.VIDEO_BITRATE` (350 kbps) plus `AudioSender.AUDIO_BITRATE`
|
|
14
|
+
* (24 kbps) combined (≈46.75 KB/s) - tighter than the server's own `RelayHub.RELAY_WS_MAX_BACKPRESSURE_BYTES`
|
|
15
|
+
* (128 KiB) on purpose, so a sender that is itself the slow end of the connection starts dropping its own stale
|
|
16
|
+
* frames before the server's larger budget would. This was previously 256 KiB (≈5.6 seconds), which defeated the
|
|
17
|
+
* "drop, don't queue" intent on exactly the constrained uplink that routes a call to this fallback tier at all. */
|
|
13
18
|
export declare const MAX_BUFFERED_BYTES: number;
|
|
14
19
|
/** Reconnect delay: about a second after the first failure, doubling to a ceiling, jittered so a server restart
|
|
15
20
|
* doesn't bring every client back in the same instant. */
|
|
@@ -32,8 +32,13 @@ export const MAX_NEGOTIATED_MESSAGE_BYTES = 1024 * 1024;
|
|
|
32
32
|
/** The most senders the server lets one socket `want`. */
|
|
33
33
|
export const MAX_WANTED_PEERS = 32;
|
|
34
34
|
/** A media send is dropped, not queued, while more than this many bytes are still waiting to leave - the
|
|
35
|
-
* connection is slower than the media, and queueing would only add latency to the frames that are still fresh.
|
|
36
|
-
|
|
35
|
+
* connection is slower than the media, and queueing would only add latency to the frames that are still fresh.
|
|
36
|
+
* 64 KiB is a little under 1.4 seconds of `VideoSender.VIDEO_BITRATE` (350 kbps) plus `AudioSender.AUDIO_BITRATE`
|
|
37
|
+
* (24 kbps) combined (≈46.75 KB/s) - tighter than the server's own `RelayHub.RELAY_WS_MAX_BACKPRESSURE_BYTES`
|
|
38
|
+
* (128 KiB) on purpose, so a sender that is itself the slow end of the connection starts dropping its own stale
|
|
39
|
+
* frames before the server's larger budget would. This was previously 256 KiB (≈5.6 seconds), which defeated the
|
|
40
|
+
* "drop, don't queue" intent on exactly the constrained uplink that routes a call to this fallback tier at all. */
|
|
41
|
+
export const MAX_BUFFERED_BYTES = 64 * 1024;
|
|
37
42
|
/** Reconnect delay: about a second after the first failure, doubling to a ceiling, jittered so a server restart
|
|
38
43
|
* doesn't bring every client back in the same instant. */
|
|
39
44
|
export const RELAY_BACKOFF_BASE_MS = 1000;
|