@rapidmx/meet-plugin 0.9.3 → 0.11.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 +75 -5
  3. package/apps/meet/_CallView.tsx +326 -7
  4. package/apps/meet/_DiagnosticsPanel.tsx +113 -0
  5. package/apps/meet/_ParticipantTile.tsx +10 -2
  6. package/apps/meet/_ParticipantsDrawer.tsx +397 -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 +115 -4
  12. package/apps/shared/webrtc/realPeerConnection.ts +47 -7
  13. package/apps/shared/webrtc/types.ts +67 -8
  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 +16 -2
  17. package/dist/apps/meet/_CallControls.js +9 -6
  18. package/dist/apps/meet/_CallView.d.ts +45 -2
  19. package/dist/apps/meet/_CallView.js +215 -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 +77 -0
  25. package/dist/apps/meet/_ParticipantsDrawer.js +91 -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 +49 -1
  35. package/dist/apps/shared/webrtc/MeshConnectionManager.js +103 -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 +69 -7
  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
@@ -6,7 +6,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  /**
7
7
  * The public join/lobby/in-call page, `GET /meet/:token` - resolves an invitee's join token or a public meeting's
8
8
  * slug (`BaseVideoMeetingRoute.join()`, both shapes accepted identically - see `_meetApi.ts`), then walks through
9
- * `loading` -> `lobby` -> `in-call` -> `ended`.
9
+ * `loading` -> (`password`, only when a password is required) -> (`admission-request` then `waiting`, only when
10
+ * the meeting has a waiting room - see that phase's own render branch) -> `lobby` -> `in-call` -> `ended`. A
11
+ * waiting room takes over the entire gate (asking for a password too, on the same form) rather than stacking
12
+ * after a separate password screen - see `BaseVideoMeetingRoute.join()`'s own doc comment for why.
10
13
  *
11
14
  * ## Session-based name prefill - NOT implemented, and why
12
15
  *
@@ -35,11 +38,15 @@ import useBranding from "@rapidmx/web-client/lib/branding/useBranding.js";
35
38
  import { ApiRequestError } from "@rapidmx/web-client/lib/util/api.js";
36
39
  import Button from "@rapidmx/web-client/lib/components/buttons/Button.js";
37
40
  import Alert from "@rapidmx/web-client/lib/components/feedback/Alert.js";
41
+ import FormField from "@rapidmx/web-client/lib/components/forms/FormField.js";
38
42
  import { MeetCard, MeetPageShell } from "./_MeetChrome.js";
39
43
  import { useLocalMedia } from "../shared/media/useLocalMedia.js";
40
44
  import MeetLobby from "./_MeetLobby.js";
41
45
  import CallView from "./_CallView.js";
42
- import { joinMeeting } from "./_meetApi.js";
46
+ import { joinMeeting, pollAdmission, requestAdmission, verifyMeetingPassword, } from "./_meetApi.js";
47
+ const INPUT_CLASS = "w-full text-base py-2.5 px-3.5 border border-border rounded-md bg-surface text-text focus:outline-none focus:border-primary";
48
+ /** How often `MeetJoinContent` polls while in the `"waiting"` phase. */
49
+ const ADMISSION_POLL_MS = 3000;
43
50
  export default function MeetJoinPage({ params }) {
44
51
  const { branding } = useBranding();
45
52
  return _jsx(MeetJoinContent, { token: params.token, branding: branding });
@@ -52,16 +59,48 @@ function MeetJoinContent({ token, branding }) {
52
59
  const [name, setName] = useState("");
53
60
  const [loadError, setLoadError] = useState(null);
54
61
  const [joinResult, setJoinResult] = useState(null);
55
- const media = useLocalMedia({ effectsAssetsUrl: joinResult?.effectsAssetsUrl });
62
+ /** The restricted `PublicVideoMeeting` `joinMeeting()` returned while this meeting required a password - just
63
+ * enough to show the password prompt's own title/host, since `joinResult` itself stays unset until one is
64
+ * verified (see `handleSubmitPassword()`). Only read while `phase === "password"`. */
65
+ const [passwordMeeting, setPasswordMeeting] = useState(null);
66
+ const [passwordInput, setPasswordInput] = useState("");
67
+ const [passwordError, setPasswordError] = useState(null);
68
+ /** The restricted `PublicVideoMeeting` `joinMeeting()`/`requestAdmission()` returned while this meeting has a
69
+ * waiting room - read on both the `"admission-request"` and `"waiting"` screens. */
70
+ const [admissionMeeting, setAdmissionMeeting] = useState(null);
71
+ const [admissionName, setAdmissionName] = useState("");
72
+ const [admissionPassword, setAdmissionPassword] = useState("");
73
+ const [admissionError, setAdmissionError] = useState(null);
74
+ /** The guest token `requestAdmission()` minted, to poll with (`Authorization` header - a guest has no session
75
+ * cookie of their own). `undefined` for an already-authenticated real caller, whose existing session cookie
76
+ * already identifies them to `pollAdmission()` with no header needed. */
77
+ const [pendingGuestToken, setPendingGuestToken] = useState(undefined);
78
+ /** Set only when the call ended without the participant's own action (currently: kicked by the host) - see
79
+ * `CallView`'s `onLeave` doc comment. Shown instead of the ordinary "you left" message; also hides "Rejoin
80
+ * meeting", since a kicked participant's server-side channel grant has just been revoked and a rejoin attempt
81
+ * would only fail. */
82
+ const [endedReason, setEndedReason] = useState(undefined);
83
+ const media = useLocalMedia({ effectsAssetsUrl: joinResult?.effectsAssetsUrl, forceMuteOnJoin: joinResult?.meeting.forceMuteOnJoin });
56
84
  const { release } = media;
57
85
  React.useEffect(() => {
58
86
  let cancelled = false;
59
87
  joinMeeting(token)
60
88
  .then((result) => {
61
- if (!cancelled) {
62
- setJoinResult(result);
63
- setPhase("lobby");
89
+ if (cancelled) {
90
+ return;
91
+ }
92
+ if ("requiresAdmission" in result) {
93
+ setAdmissionMeeting(result.meeting);
94
+ setPhase("admission-request");
95
+ return;
64
96
  }
97
+ if ("requiresPassword" in result) {
98
+ setPasswordMeeting(result.meeting);
99
+ setPhase("password");
100
+ return;
101
+ }
102
+ setJoinResult(result);
103
+ setPhase("lobby");
65
104
  })
66
105
  .catch((err) => {
67
106
  if (cancelled) {
@@ -82,8 +121,69 @@ function MeetJoinContent({ token, branding }) {
82
121
  setName(joinName);
83
122
  setPhase("in-call");
84
123
  }
85
- function handleLeave() {
124
+ async function handleSubmitPassword(e) {
125
+ e.preventDefault();
126
+ setPasswordError(null);
127
+ try {
128
+ const result = await verifyMeetingPassword(token, passwordInput);
129
+ setJoinResult(result);
130
+ setPhase("lobby");
131
+ }
132
+ catch (err) {
133
+ setPasswordError(err instanceof ApiRequestError && err.status === 403 ? "Incorrect password." : "Something went wrong. Try again.");
134
+ }
135
+ }
136
+ async function handleRequestAdmission(e) {
137
+ e.preventDefault();
138
+ setAdmissionError(null);
139
+ try {
140
+ const result = await requestAdmission(token, {
141
+ name: admissionName.trim(),
142
+ ...(admissionMeeting?.hasPassword && { password: admissionPassword }),
143
+ });
144
+ setName(admissionName.trim());
145
+ setPendingGuestToken(result.token);
146
+ setPhase("waiting");
147
+ }
148
+ catch (err) {
149
+ setAdmissionError(err instanceof ApiRequestError && err.status === 403 ? "Incorrect password." : "Something went wrong. Try again.");
150
+ }
151
+ }
152
+ // Polls while waiting for the host to respond - stops as soon as this phase is left, one way or the other.
153
+ React.useEffect(() => {
154
+ if (phase !== "waiting") {
155
+ return;
156
+ }
157
+ let cancelled = false;
158
+ const poll = async () => {
159
+ try {
160
+ const result = await pollAdmission(token, pendingGuestToken);
161
+ if (cancelled || "requiresAdmission" in result) {
162
+ return;
163
+ }
164
+ setJoinResult(result);
165
+ setPhase("lobby");
166
+ }
167
+ catch (err) {
168
+ if (cancelled || !(err instanceof ApiRequestError) || err.status !== 403) {
169
+ // A transient failure (network blip, server hiccup) - stay on this screen and try again next
170
+ // tick, rather than giving up on what might just be one bad request.
171
+ return;
172
+ }
173
+ setAdmissionError(err.message || "The host did not admit you to this meeting.");
174
+ setPhase("denied");
175
+ }
176
+ };
177
+ void poll();
178
+ const interval = setInterval(() => void poll(), ADMISSION_POLL_MS);
179
+ return () => {
180
+ cancelled = true;
181
+ clearInterval(interval);
182
+ };
183
+ }, [phase, token, pendingGuestToken]);
184
+ function handleLeave(reason) {
86
185
  release();
186
+ setEndedReason(reason);
87
187
  setPhase("ended");
88
188
  }
89
189
  if (phase === "in-call" && joinResult) {
@@ -93,7 +193,7 @@ function MeetJoinContent({ token, branding }) {
93
193
  // guest token to apply as a cookie at all, and `GuestSignalingClient` relies entirely on the
94
194
  // browser's own already-existing `jwt` session cookie instead (see its own doc comment and
95
195
  // `VideoMeetingJoinResult`'s doc comment in `_meetApi.ts`).
96
- token: joinResult.token, selfUid: joinResult.selfUid, selfName: name, meetingTitle: joinResult.meeting.title, iceServers: joinResult.iceServers, relayEnabled: joinResult.relayEnabled, media: media, onLeave: handleLeave }));
196
+ token: joinResult.token, selfUid: joinResult.selfUid, selfName: name, meetingTitle: joinResult.meeting.title, iceServers: joinResult.iceServers, relayEnabled: joinResult.relayEnabled, hostUid: joinResult.meeting.hostUid, initialForceMuteOnJoin: !!joinResult.meeting.forceMuteOnJoin, initialHasPassword: !!joinResult.meeting.hasPassword, initialWaitingRoomEnabled: !!joinResult.meeting.waitingRoomEnabled, media: media, onLeave: handleLeave }));
97
197
  }
98
198
  let content;
99
199
  if (phase === "loading") {
@@ -102,8 +202,20 @@ function MeetJoinContent({ token, branding }) {
102
202
  else if (phase === "not-found") {
103
203
  content = (_jsx(MeetCard, { children: _jsx(Alert, { children: loadError ?? "This meeting link isn't valid." }) }));
104
204
  }
205
+ else if (phase === "password") {
206
+ content = (_jsxs(MeetCard, { children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight", children: passwordMeeting?.title }), passwordMeeting?.hostDisplayName && _jsxs("p", { className: "text-sm text-text-muted mt-1", children: ["Hosted by ", passwordMeeting.hostDisplayName] }), _jsx("p", { className: "text-base text-text-muted mt-4 mb-3", children: "This meeting requires a password to join." }), _jsxs("form", { onSubmit: (e) => void handleSubmitPassword(e), children: [_jsx(FormField, { label: "Password", htmlFor: "meet-password", children: _jsx("input", { id: "meet-password", type: "password", autoFocus: true, className: INPUT_CLASS, value: passwordInput, onChange: (e) => setPasswordInput(e.target.value) }) }), passwordError && _jsx(Alert, { children: passwordError }), _jsx(Button, { type: "submit", className: "!w-auto mt-3", disabled: !passwordInput, children: "Join meeting" })] })] }));
207
+ }
208
+ else if (phase === "admission-request") {
209
+ content = (_jsxs(MeetCard, { children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight", children: admissionMeeting?.title }), admissionMeeting?.hostDisplayName && _jsxs("p", { className: "text-sm text-text-muted mt-1", children: ["Hosted by ", admissionMeeting.hostDisplayName] }), _jsx("p", { className: "text-base text-text-muted mt-4 mb-3", children: "The host must let you in before you can join." }), _jsxs("form", { onSubmit: (e) => void handleRequestAdmission(e), children: [_jsx(FormField, { label: "Your name", htmlFor: "admission-name", children: _jsx("input", { id: "admission-name", type: "text", autoFocus: true, className: INPUT_CLASS, value: admissionName, onChange: (e) => setAdmissionName(e.target.value) }) }), admissionMeeting?.hasPassword && (_jsx(FormField, { label: "Password", htmlFor: "admission-password", children: _jsx("input", { id: "admission-password", type: "password", className: `${INPUT_CLASS} mt-3`, value: admissionPassword, onChange: (e) => setAdmissionPassword(e.target.value) }) })), admissionError && _jsx(Alert, { children: admissionError }), _jsx(Button, { type: "submit", className: "!w-auto mt-3", disabled: !admissionName.trim() || (!!admissionMeeting?.hasPassword && !admissionPassword), children: "Ask to join" })] })] }));
210
+ }
211
+ else if (phase === "waiting") {
212
+ content = (_jsxs(MeetCard, { children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight", children: admissionMeeting?.title }), _jsx("p", { className: "text-base text-text-muted mt-4", children: "Waiting for the host to let you in\u2026" })] }));
213
+ }
214
+ else if (phase === "denied") {
215
+ content = (_jsx(MeetCard, { children: _jsx(Alert, { children: admissionError ?? "The host did not admit you to this meeting." }) }));
216
+ }
105
217
  else if (phase === "ended") {
106
- content = (_jsxs(MeetCard, { children: [_jsx("h1", { className: "text-xl font-bold tracking-tight mb-2", children: "You left the meeting" }), _jsx("p", { className: "text-base text-text-muted mb-4", children: "You can close this page now, or rejoin." }), _jsx(Button, { type: "button", className: "!w-auto", onClick: () => setPhase("lobby"), children: "Rejoin meeting" })] }));
218
+ content = (_jsxs(MeetCard, { children: [_jsx("h1", { className: "text-xl font-bold tracking-tight mb-2", children: endedReason ? "Removed from the meeting" : "You left the meeting" }), _jsx("p", { className: "text-base text-text-muted mb-4", children: endedReason ?? "You can close this page now, or rejoin." }), !endedReason && (_jsx(Button, { type: "button", className: "!w-auto", onClick: () => setPhase("lobby"), children: "Rejoin meeting" }))] }));
107
219
  }
108
220
  else {
109
221
  // phase === "lobby" (joinResult is always set by then - see the effect above).
@@ -7,26 +7,40 @@
7
7
  *
8
8
  * The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
9
9
  * picking up, so a participant can see their audio is being sent - and the camera button shows a green dot while a
10
- * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`).
10
+ * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
11
+ * is disabled (`micLocked`) while talking-stick mode is on and this participant isn't the current holder - see
12
+ * `_CallView.tsx`'s doc comment.
11
13
  */
12
14
  import React from "react";
15
+ import type { ScreenTransformState } from "../shared/media/filters/ScreenTransform.js";
13
16
  import type { LocalMedia } from "../shared/media/useLocalMedia.js";
17
+ import { type MeshParticipant } from "../shared/webrtc/types.js";
14
18
  export type CallViewMode = "grid" | "focus";
15
19
  export interface CallControlsProps {
16
20
  media: LocalMedia;
21
+ selfName: string;
22
+ participants: MeshParticipant[];
17
23
  isPresenting: boolean;
18
24
  /** Someone else is presenting - the share button is disabled and explains why. */
19
25
  presentingElsewhereName?: string;
20
26
  onToggleShare: () => void;
27
+ /** The current presenter's own rotate/flip correction - shown (and changeable) only while `isPresenting`. */
28
+ screenTransform: ScreenTransformState;
29
+ onRotateScreen: () => void;
30
+ onFlipScreen: () => void;
21
31
  handRaised: boolean;
22
32
  onToggleHand: () => void;
23
33
  onReaction: (emoji: string) => void;
24
34
  viewMode: CallViewMode;
25
35
  onToggleViewMode: () => void;
36
+ /** True while talking-stick mode is on and this participant doesn't currently hold it - see `_CallView.tsx`'s
37
+ * doc comment on talking-stick mode. Disables the microphone toggle (not the device-picker chevron, which
38
+ * doesn't change whether anyone can hear them) rather than merely nudging it, unlike a `mute-request`. */
39
+ micLocked?: boolean;
26
40
  onLeave: () => void;
27
41
  }
28
42
  /** Bars that rise and fall with the microphone's level (0-5) - a flat, dim set while there is no sound. */
29
43
  export declare function LevelBars({ level }: {
30
44
  level: number;
31
45
  }): React.JSX.Element;
32
- export default function CallControls({ media, isPresenting, presentingElsewhereName, onToggleShare, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, onLeave, }: CallControlsProps): React.JSX.Element;
46
+ export default function CallControls({ media, selfName, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, onLeave, }: CallControlsProps): React.JSX.Element;
@@ -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
@@ -12,13 +12,16 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  *
13
13
  * The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
14
14
  * picking up, so a participant can see their audio is being sent - and the camera button shows a green dot while a
15
- * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`).
15
+ * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
16
+ * is disabled (`micLocked`) while talking-stick mode is on and this participant isn't the current holder - see
17
+ * `_CallView.tsx`'s doc comment.
16
18
  */
17
19
  import { useEffect, useRef, useState } from "react";
18
20
  import { filtersActive } from "../shared/media/filters/filterTypes.js";
19
21
  import { REACTION_EMOJIS } from "../shared/webrtc/types.js";
22
+ import DiagnosticsPanel from "./_DiagnosticsPanel.js";
20
23
  import EffectsPanel from "./_EffectsPanel.js";
21
- import { ChevronUpIcon, EffectsIcon, EmojiIcon, FocusIcon, GridIcon, HandIcon, LeaveIcon, MicIcon, MicOffIcon, ScreenShareIcon, VideoIcon, VideoOffIcon, } from "./_icons.js";
24
+ import { ChevronUpIcon, DiagnosticsIcon, EffectsIcon, EmojiIcon, FlipIcon, FocusIcon, GridIcon, HandIcon, LeaveIcon, MicIcon, MicOffIcon, RotateIcon, ScreenShareIcon, VideoIcon, VideoOffIcon, } from "./_icons.js";
22
25
  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
26
  const NEUTRAL = "bg-[#3c4043] text-white hover:bg-[#4b4f53]";
24
27
  const ALERT = "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]";
@@ -27,7 +30,7 @@ const ACTIVE = "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]";
27
30
  export function LevelBars({ level }) {
28
31
  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
32
  }
30
- export default function CallControls({ media, isPresenting, presentingElsewhereName, onToggleShare, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, onLeave, }) {
33
+ export default function CallControls({ media, selfName, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, onLeave, }) {
31
34
  const [openMenu, setOpenMenu] = useState(null);
32
35
  const barRef = useRef(null);
33
36
  const shareDisabled = !isPresenting && !!presentingElsewhereName;
@@ -56,10 +59,10 @@ export default function CallControls({ media, isPresenting, presentingElsewhereN
56
59
  function toggleMenu(menu) {
57
60
  setOpenMenu((prev) => (prev === menu ? null : menu));
58
61
  }
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: () => {
62
+ 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: () => {
60
63
  onReaction(emoji);
61
64
  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, {}) })] }));
65
+ }, 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
66
  }
64
67
  /** The device picker opened from the chevron beside the microphone/camera button: the available devices with the
65
68
  * one in use ticked, or - while there are none to list - why, with a way to ask for access again. */
@@ -29,6 +29,23 @@
29
29
  * auto-detected active speaker (silently ignored while presenting, so it never fights the presenter for the main
30
30
  * slot); else, in focus mode with nobody yet speaking, the first other participant - so focus mode never shows an
31
31
  * empty main slot once someone else has joined.
32
+ *
33
+ * ## Talking stick mode
34
+ *
35
+ * A host-only navbar toggle (`handleToggleTalkingStick()`) that, once on, lets at most one participant's microphone
36
+ * be unmuted at a time - the host assigns who from the participants drawer (`handleGiveTalkingStick()`), everyone
37
+ * else sees a header chip naming who currently holds it. Purely an in-call runtime state signaled peer-to-peer over
38
+ * the mesh (`MeshConnectionManager.setTalkingStick()`/`"talking-stick-changed"`), the same shape as `mute-request`/
39
+ * `kicked`: no `VideoMeeting` field, no backend route, and only as "enforced" as a cooperating client makes it (see
40
+ * `types.ts`'s `SignalMessage` doc comment on why the signaling layer has no host identity to check against). What
41
+ * *is* new here versus `mute-request`'s one-time nudge: becoming or ceasing to be the holder force-toggles this
42
+ * tab's own mic to match (`selfHasTalkingStick`'s effect, below), and the mic button itself is disabled
43
+ * (`micLocked`, passed to `CallControls`) the whole time a non-holder would otherwise be able to tap it straight
44
+ * back on - a real restriction for as long as this tab's own UI is the one in front of the participant, not just a
45
+ * suggestion. Turning the mode off touches nobody's mic state, matching every other host toggle's "never
46
+ * retroactive" posture. A holder who leaves the call is not specially reassigned - see
47
+ * `MeshConnectionManager`'s own doc comment on why that needs no extra code - the header chip then says nobody has
48
+ * the floor, until the host picks someone (or themselves) again.
32
49
  */
33
50
  import React from "react";
34
51
  import type { LocalMedia } from "../shared/media/useLocalMedia.js";
@@ -45,9 +62,29 @@ export interface CallViewProps {
45
62
  /** Whether the server offers the WebSocket media relay, the last-resort path for a participant neither a direct
46
63
  * connection nor the TURN server can reach - `VideoMeetingJoinResult.relayEnabled`. Off when absent. */
47
64
  relayEnabled?: boolean;
65
+ /** The meeting's host account uid (`PublicVideoMeeting.hostUid`) - this client's only source of a "host"
66
+ * identity, purely for deciding which controls to show (see `BaseVideoMeetingRoute`'s class doc comment on why
67
+ * this is a client-side-only check, not a server-enforced one for every moderation action). Absent when the
68
+ * server couldn't resolve one, in which case nobody sees host controls. */
69
+ hostUid?: string;
70
+ /** `PublicVideoMeeting.forceMuteOnJoin` as of this call's own `join()` - the starting value for the host's
71
+ * toggle in the participants drawer, which this view then owns and keeps current itself (see
72
+ * `handleToggleForceMuteOnJoin()`). Does not update if changed elsewhere while this view is mounted - there is
73
+ * no signal for that today, matching every other meeting-settings field's lack of live sync. */
74
+ initialForceMuteOnJoin?: boolean;
75
+ /** `PublicVideoMeeting.hasPassword` as of this call's own `join()` - the starting value for the host's password
76
+ * section in the participants drawer, same "owned and kept current by this view" shape as
77
+ * `initialForceMuteOnJoin`. */
78
+ initialHasPassword?: boolean;
79
+ /** `PublicVideoMeeting.waitingRoomEnabled` as of this call's own `join()` - the starting value for the host's
80
+ * waiting-room toggle, same "owned and kept current by this view" shape as `initialForceMuteOnJoin`. */
81
+ initialWaitingRoomEnabled?: boolean;
48
82
  /** The camera and microphone, owned by the page (`[token].tsx`) - the lobby's tracks carried into the call. */
49
83
  media: LocalMedia;
50
- onLeave: () => void;
84
+ /** Called once the participant leaves, for any reason. `reason` is set only when the call ended without the
85
+ * participant's own action - currently, being kicked by the host - so the caller can show a distinct message
86
+ * instead of the ordinary "you left" one; omitted for an ordinary voluntary leave. */
87
+ onLeave: (reason?: string) => void;
51
88
  }
52
89
  /** Who is shown large - see this module's doc comment. `undefined` when nobody but the local participant has
53
90
  * joined yet (the grid/focus toggle still works, there's just nobody else to focus on). */
@@ -61,4 +98,10 @@ export declare function computeMainUid(options: {
61
98
  * from two devices - or two tabs - is two participants rather than one that ignores itself. It travels as the
62
99
  * `peer` of each message; `from` stays the exact authenticated uid, which the server requires. */
63
100
  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;
101
+ /** The real, server-enforced account uid behind a participant's tab-scoped `MeshParticipant.uid` (`newPeerId()`'s
102
+ * `<account uid>~<random>` shape) - what the kick route's ACL grant is actually keyed by, unlike the tab-scoped id
103
+ * this view otherwise addresses a specific tab with. Neither a real account uid nor a minted guest uid (see
104
+ * `GUEST_UID_PREFIX`/`mintGuestToken()` on the backend) can itself contain "~", so the last one found is always
105
+ * exactly this suffix's own separator. */
106
+ export declare function accountUidOf(peerUid: string): string;
107
+ export default function CallView({ channel, token, selfUid, selfName, meetingTitle, iceServers, relayEnabled, hostUid, initialForceMuteOnJoin, initialHasPassword, initialWaitingRoomEnabled, media, onLeave, }: CallViewProps): React.JSX.Element;