@rapidmx/meet-plugin 0.10.0 → 0.12.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.
@@ -71,6 +71,19 @@
71
71
  * manager takes no action of its own beyond that - it doesn't mute a track or tear itself down, since a kicked
72
72
  * participant's actual departure is `stop()`, which the caller decides to call.
73
73
  *
74
+ * ## Talking stick (`talking-stick`)
75
+ *
76
+ * One exclusive "floor" - at most one participant may be unmuted at a time, host-assigned - but unlike presenter,
77
+ * there is no claim race to resolve: only the host's own client ever calls `setTalkingStick()` (same trust level as
78
+ * `sendMuteRequest()`/`sendKick()` - nothing here checks that), so the manager carries no state of its own and
79
+ * simply relays the latest message it sees as `talking-stick-changed`, applying it to itself too via an immediate
80
+ * local emit (the same "optimistic, no round trip" shape `claimPresenter()`/`releasePresenter()` use). A holder who
81
+ * leaves the call is not specially reassigned - every other participant's existing `participant-left` handling
82
+ * already drops them from the roster, and "the stick's holder is no longer in the call" is simply "nobody is holder
83
+ * right now" without any manager needing to say so, until the host picks someone (or themselves) again. There is no
84
+ * backend route or persisted field for this at all: like `mute-request`/`kicked`, it only matters while people are
85
+ * actually in the call together.
86
+ *
74
87
  * ## Three media paths, tried in order
75
88
  *
76
89
  * Each pair of participants reaches for the best path that works, and `MeshParticipant.transport` says which one it
@@ -319,6 +332,16 @@ export class MeshConnectionManager {
319
332
  this.send({ kind: "kicked", to: peerUid });
320
333
  }
321
334
 
335
+ /** Turns talking-stick mode on (naming `holder`, the initial speaker) or off (`active: false`, `holder`
336
+ * omitted), or hands an already-active stick to a different `holder` - see this module's doc comment on why
337
+ * there is no collision to resolve here, unlike presenter. Broadcasts and applies locally at once, trusting
338
+ * there is only ever one sender; the caller (`_CallView.tsx`) is responsible for only offering this to the
339
+ * host. */
340
+ setTalkingStick(active: boolean, holder?: string): void {
341
+ this.send({ kind: "talking-stick", active, holder });
342
+ this.emit({ type: "talking-stick-changed", active, holder });
343
+ }
344
+
322
345
  private sendHello(): void {
323
346
  this.send({ kind: "hello", name: this.options.selfName, state: this.localState });
324
347
  }
@@ -385,6 +408,9 @@ export class MeshConnectionManager {
385
408
  case "kicked":
386
409
  this.emit({ type: "kicked" });
387
410
  return;
411
+ case "talking-stick":
412
+ this.emit({ type: "talking-stick-changed", active: message.active === true, holder: typeof message.holder === "string" ? message.holder : undefined });
413
+ return;
388
414
  }
389
415
  }
390
416
 
@@ -28,17 +28,18 @@ export const REACTION_EMOJIS = ["👍", "👏", "❤️", "🎉", "😂", "😮"
28
28
  * literal so a listener on the meeting's channel (which will also see other frame shapes if this server ever
29
29
  * grows other push consumers) can cheaply recognize and skip anything that isn't one of these.
30
30
  *
31
- * `hello`/`bye`/`presenter-claim`/`presenter-release`/`state`/`reaction` are broadcast (`to` left unset - every
32
- * participant is relevant); `offer`/`answer`/`ice-candidate`/`mute-request`/`kicked` are point-to-point (`to` is the
33
- * intended peer's uid) - a recipient that isn't `to` (when set) ignores the message, since a channel's pub/sub fans
34
- * every message out to every subscriber, sender included.
31
+ * `hello`/`bye`/`presenter-claim`/`presenter-release`/`state`/`reaction`/`talking-stick` are broadcast (`to` left
32
+ * unset - every participant is relevant); `offer`/`answer`/`ice-candidate`/`mute-request`/`kicked` are
33
+ * point-to-point (`to` is the intended peer's uid) - a recipient that isn't `to` (when set) ignores the message,
34
+ * since a channel's pub/sub fans every message out to every subscriber, sender included.
35
35
  *
36
- * `mute-request`/`kicked` are host moderation signals (see `MeshConnectionManager`'s doc comment) - purely
37
- * cooperative, like every other message here: the recipient's own client decides whether to honor one, and nothing
38
- * on the wire distinguishes a genuine host's message from any other participant's, since this client protocol has
39
- * no host identity of its own to check against server-side. `kicked` is paired with the sender also revoking the
40
- * recipient's server-side channel grant (`BaseVideoMeetingRoute.revokeChannelGrant()`), which is enforced and does
41
- * not depend on the recipient's client cooperating.
36
+ * `mute-request`/`kicked`/`talking-stick` are host moderation signals (see `MeshConnectionManager`'s doc comment) -
37
+ * purely cooperative, like every other message here: the recipient's own client decides whether to honor one, and
38
+ * nothing on the wire distinguishes a genuine host's message from any other participant's, since this client
39
+ * protocol has no host identity of its own to check against server-side. `kicked` is paired with the sender also
40
+ * revoking the recipient's server-side channel grant (`BaseVideoMeetingRoute.revokeChannelGrant()`), which is
41
+ * enforced and does not depend on the recipient's client cooperating; `talking-stick` has no server-side
42
+ * counterpart at all (see this module's doc comment on why it is purely an in-call runtime signal).
42
43
  */
43
44
  export interface SignalMessage {
44
45
  type: "video-meeting-signal";
@@ -54,7 +55,8 @@ export interface SignalMessage {
54
55
  | "reaction"
55
56
  | "relay-fallback"
56
57
  | "mute-request"
57
- | "kicked";
58
+ | "kicked"
59
+ | "talking-stick";
58
60
  /** The sender's authenticated uid - the real caller's own uid when `join()` returned `authenticated: true`, else
59
61
  * the `guest:<random>` uid `BaseVideoMeetingRoute.join()` minted. The server refuses a published message whose
60
62
  * `from` isn't the authenticated caller's uid (it stops one participant speaking as another), so this must be
@@ -73,6 +75,11 @@ export interface SignalMessage {
73
75
  state?: ParticipantState;
74
76
  /** `reaction` only - one of `REACTION_EMOJIS`. */
75
77
  emoji?: string;
78
+ /** `talking-stick` only - whether talking-stick mode is currently on. */
79
+ active?: boolean;
80
+ /** `talking-stick` only - the peer id (`MeshParticipant.uid`/this tab's own id) currently holding the stick.
81
+ * Absent when `active` is `false`, or when nobody holds it yet. */
82
+ holder?: string;
76
83
  /** `offer`/`answer` only. */
77
84
  sdp?: RTCSessionDescriptionInit;
78
85
  /** `ice-candidate` only. */
@@ -208,4 +215,8 @@ export type MeshEvent =
208
215
  | { type: "mute-requested" }
209
216
  /** The local participant was removed from the call. The caller tears down the mesh and shows a distinct
210
217
  * "removed by the host" state rather than the ordinary "you left" one. */
211
- | { type: "kicked" };
218
+ | { type: "kicked" }
219
+ /** Talking-stick mode turned on or off, or the stick changed hands - see this module's doc comment and
220
+ * `MeshConnectionManager.setTalkingStick()`. `holder` is the peer id now holding it; absent while `active` is
221
+ * `false`, or (transiently) if whoever held it has since left the call. */
222
+ | { type: "talking-stick-changed"; active: boolean; holder?: string };
@@ -5,9 +5,17 @@
5
5
  * screen, reactions, raise hand, grid/focus and leave. The effects button opens the video filter picker
6
6
  * (`_EffectsPanel.tsx`) and lights up while any filter is on.
7
7
  *
8
+ * The diagnostics button is the one exception to "opens near its own button": its panel is `fixed` to the
9
+ * viewport's top-left corner rather than anchored above the button, since it reads better as a HUD-style overlay
10
+ * sitting over the call than as a dropdown tucked into the bottom control bar. The outside-click/Escape handling
11
+ * (`barRef`) still closes it the same way, since it's still a DOM descendant of the bar - only its visual position
12
+ * moved, not its open/close wiring.
13
+ *
8
14
  * The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
9
15
  * 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`).
16
+ * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
17
+ * is disabled (`micLocked`) while talking-stick mode is on and this participant isn't the current holder - see
18
+ * `_CallView.tsx`'s doc comment.
11
19
  */
12
20
  import React from "react";
13
21
  import type { ScreenTransformState } from "../shared/media/filters/ScreenTransform.js";
@@ -31,10 +39,14 @@ export interface CallControlsProps {
31
39
  onReaction: (emoji: string) => void;
32
40
  viewMode: CallViewMode;
33
41
  onToggleViewMode: () => void;
42
+ /** True while talking-stick mode is on and this participant doesn't currently hold it - see `_CallView.tsx`'s
43
+ * doc comment on talking-stick mode. Disables the microphone toggle (not the device-picker chevron, which
44
+ * doesn't change whether anyone can hear them) rather than merely nudging it, unlike a `mute-request`. */
45
+ micLocked?: boolean;
34
46
  onLeave: () => void;
35
47
  }
36
48
  /** Bars that rise and fall with the microphone's level (0-5) - a flat, dim set while there is no sound. */
37
49
  export declare function LevelBars({ level }: {
38
50
  level: number;
39
51
  }): React.JSX.Element;
40
- export default function CallControls({ media, selfName, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, onLeave, }: CallControlsProps): React.JSX.Element;
52
+ export default function CallControls({ media, selfName, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, onLeave, }: CallControlsProps): React.JSX.Element;
@@ -10,9 +10,17 @@ 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
+ *
13
19
  * The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
14
20
  * 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`).
21
+ * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
22
+ * is disabled (`micLocked`) while talking-stick mode is on and this participant isn't the current holder - see
23
+ * `_CallView.tsx`'s doc comment.
16
24
  */
17
25
  import { useEffect, useRef, useState } from "react";
18
26
  import { filtersActive } from "../shared/media/filters/filterTypes.js";
@@ -28,7 +36,7 @@ const ACTIVE = "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]";
28
36
  export function LevelBars({ level }) {
29
37
  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))) }));
30
38
  }
31
- export default function CallControls({ media, selfName, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, onLeave, }) {
39
+ export default function CallControls({ media, selfName, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, onLeave, }) {
32
40
  const [openMenu, setOpenMenu] = useState(null);
33
41
  const barRef = useRef(null);
34
42
  const shareDisabled = !isPresenting && !!presentingElsewhereName;
@@ -57,10 +65,10 @@ export default function CallControls({ media, selfName, participants, isPresenti
57
65
  function toggleMenu(menu) {
58
66
  setOpenMenu((prev) => (prev === menu ? null : menu));
59
67
  }
60
- return (_jsxs("div", { ref: barRef, className: "relative flex flex-wrap items-center justify-center gap-2 px-3", role: "toolbar", "aria-label": "Call controls", children: [_jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": "Choose microphone", "aria-haspopup": "menu", "aria-expanded": openMenu === "audio", onClick: () => toggleMenu("audio"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": media.micOn ? "Mute microphone" : "Unmute microphone", "aria-pressed": !media.micOn, onClick: () => void media.toggleMic(), children: [media.micOn && _jsx(LevelBars, { level: media.audioLevel }), media.micOn ? _jsx(MicIcon, {}) : _jsx(MicOffIcon, {})] }), openMenu === "audio" && _jsx(DeviceMenu, { kind: "audio", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": "Choose camera", "aria-haspopup": "menu", "aria-expanded": openMenu === "video", onClick: () => toggleMenu("video"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} relative min-w-14 px-3 rounded-l-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": media.cameraOn ? "Turn off camera" : "Turn on camera", "aria-pressed": !media.cameraOn, onClick: () => void media.toggleCamera(), children: [media.cameraOn ? _jsx(VideoIcon, {}) : _jsx(VideoOffIcon, {}), media.cameraOn && (_jsx("span", { className: "absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34a853] animate-pulse", "data-testid": "camera-live", title: "Your camera is sending" }))] }), openMenu === "video" && _jsx(DeviceMenu, { kind: "video", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${filtersActive(media.filters) ? ACTIVE : NEUTRAL}`, "aria-label": "Video effects", "aria-haspopup": "dialog", "aria-expanded": openMenu === "effects", onClick: () => toggleMenu("effects"), children: _jsx(EffectsIcon, {}) }), openMenu === "effects" && (_jsx("div", { role: "dialog", "aria-label": "Video effects", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-96 max-w-[calc(100vw-1rem)] max-h-[60vh] overflow-y-auto p-3 rounded-2xl bg-[#2b2d30] text-white shadow-xl", children: _jsx(EffectsPanel, { media: media, tone: "dark" }) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${isPresenting ? ACTIVE : NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": isPresenting ? "Stop sharing" : "Share screen", "aria-pressed": isPresenting, disabled: shareDisabled, title: shareDisabled ? `${presentingElsewhereName} is presenting - stop their share to present yourself.` : undefined, onClick: onToggleShare, children: _jsx(ScreenShareIcon, {}) }), isPresenting && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": "Rotate shared screen", title: "If your shared window looks sideways or upside down to everyone, rotate it here - this is a correction for your own capture, not a browser bug this app can fix automatically.", onClick: onRotateScreen, children: _jsx(RotateIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${screenTransform.flipped ? ACTIVE : NEUTRAL}`, "aria-label": screenTransform.flipped ? "Unflip shared screen" : "Flip shared screen", "aria-pressed": screenTransform.flipped, onClick: onFlipScreen, children: _jsx(FlipIcon, {}) })] })), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": "Send a reaction", "aria-haspopup": "menu", "aria-expanded": openMenu === "emoji", onClick: () => toggleMenu("emoji"), children: _jsx(EmojiIcon, {}) }), openMenu === "emoji" && (_jsx("div", { role: "menu", "aria-label": "Reactions", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-max grid grid-cols-4 gap-1 p-2 rounded-2xl bg-[#2b2d30] shadow-xl", children: REACTION_EMOJIS.map((emoji) => (_jsx("button", { type: "button", role: "menuitem", className: "w-11 h-11 rounded-lg text-2xl hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Send ${emoji}`, onClick: () => {
68
+ 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: () => {
61
69
  onReaction(emoji);
62
70
  setOpenMenu(null);
63
- }, children: emoji }, emoji))) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${handRaised ? ACTIVE : NEUTRAL}`, "aria-label": handRaised ? "Lower hand" : "Raise hand", "aria-pressed": handRaised, onClick: onToggleHand, children: _jsx(HandIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": viewMode === "grid" ? "Switch to focused view" : "Switch to grid view", onClick: onToggleViewMode, children: viewMode === "grid" ? _jsx(FocusIcon, {}) : _jsx(GridIcon, {}) }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${openMenu === "diagnostics" ? ACTIVE : NEUTRAL}`, "aria-label": "Call diagnostics", "aria-haspopup": "dialog", "aria-expanded": openMenu === "diagnostics", onClick: () => toggleMenu("diagnostics"), children: _jsx(DiagnosticsIcon, {}) }), openMenu === "diagnostics" && (_jsx("div", { role: "dialog", "aria-label": "Call diagnostics", className: "absolute bottom-full mb-3 right-0 w-80 max-w-[calc(100vw-1rem)] max-h-[60vh] overflow-y-auto p-3 rounded-2xl bg-[#2b2d30] text-white shadow-xl", children: _jsx(DiagnosticsPanel, { selfName: selfName, micOn: media.micOn, cameraOn: media.cameraOn, participants: participants }) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`, "aria-label": "Leave call", onClick: () => onLeave(), children: _jsx(LeaveIcon, {}) })] }));
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 === "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: "fixed z-30 top-3 left-3 w-80 max-w-[calc(100vw-1rem)] max-h-[70vh] overflow-y-auto p-3 rounded-2xl bg-[#2b2d30]/95 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, {}) })] }));
64
72
  }
65
73
  /** The device picker opened from the chevron beside the microphone/camera button: the available devices with the
66
74
  * 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";
@@ -34,6 +34,23 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
34
34
  * auto-detected active speaker (silently ignored while presenting, so it never fights the presenter for the main
35
35
  * slot); else, in focus mode with nobody yet speaking, the first other participant - so focus mode never shows an
36
36
  * empty main slot once someone else has joined.
37
+ *
38
+ * ## Talking stick mode
39
+ *
40
+ * A host-only navbar toggle (`handleToggleTalkingStick()`) that, once on, lets at most one participant's microphone
41
+ * be unmuted at a time - the host assigns who from the participants drawer (`handleGiveTalkingStick()`), everyone
42
+ * else sees a header chip naming who currently holds it. Purely an in-call runtime state signaled peer-to-peer over
43
+ * the mesh (`MeshConnectionManager.setTalkingStick()`/`"talking-stick-changed"`), the same shape as `mute-request`/
44
+ * `kicked`: no `VideoMeeting` field, no backend route, and only as "enforced" as a cooperating client makes it (see
45
+ * `types.ts`'s `SignalMessage` doc comment on why the signaling layer has no host identity to check against). What
46
+ * *is* new here versus `mute-request`'s one-time nudge: becoming or ceasing to be the holder force-toggles this
47
+ * tab's own mic to match (`selfHasTalkingStick`'s effect, below), and the mic button itself is disabled
48
+ * (`micLocked`, passed to `CallControls`) the whole time a non-holder would otherwise be able to tap it straight
49
+ * 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
50
+ * suggestion. Turning the mode off touches nobody's mic state, matching every other host toggle's "never
51
+ * retroactive" posture. A holder who leaves the call is not specially reassigned - see
52
+ * `MeshConnectionManager`'s own doc comment on why that needs no extra code - the header chip then says nobody has
53
+ * the floor, until the host picks someone (or themselves) again.
37
54
  */
38
55
  import { useCallback, useEffect, useRef, useState } from "react";
39
56
  import { playRaisedHandChime } from "../shared/media/chime.js";
@@ -107,6 +124,8 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
107
124
  const [hasPassword, setHasPassword] = useState(!!initialHasPassword);
108
125
  const [waitingRoomEnabled, setWaitingRoomEnabledState] = useState(!!initialWaitingRoomEnabled);
109
126
  const [waitingParticipants, setWaitingParticipants] = useState([]);
127
+ const [talkingStickActive, setTalkingStickActive] = useState(false);
128
+ const [talkingStickHolder, setTalkingStickHolder] = useState(undefined);
110
129
  const managerRef = useRef(null);
111
130
  /** The raw capture from `getDisplayMedia()` - only ever used to stop it (releasing the OS's own share
112
131
  * indicator) and to notice the participant ending the share from the browser's own UI (`track.onended`). What's
@@ -196,6 +215,10 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
196
215
  case "kicked":
197
216
  onLeave("The host removed you from this call.");
198
217
  return;
218
+ case "talking-stick-changed":
219
+ setTalkingStickActive(event.active);
220
+ setTalkingStickHolder(event.holder);
221
+ return;
199
222
  }
200
223
  });
201
224
  client
@@ -248,6 +271,25 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
248
271
  useEffect(() => {
249
272
  managerRef.current?.setLocalState({ audioOn: media.micOn, videoOn: media.cameraOn || isPresenting, handRaised });
250
273
  }, [media.micOn, media.cameraOn, isPresenting, handRaised]);
274
+ // Talking-stick mode forces exactly one microphone on at a time: whoever just became the holder (including the
275
+ // host, on activation) is unmuted; everyone else is muted. Unlike `mute-requested`'s one-time nudge, this is
276
+ // paired with disabling the mic button itself (`micLocked`, passed to `CallControls` below) while it applies,
277
+ // so it isn't merely a suggestion - though still cooperative at the signaling layer, like every other
278
+ // moderation feature here (see `MeshConnectionManager`'s doc comment). Turning the mode off touches nobody's
279
+ // mic - states are left exactly as they are, the same "never retroactive" posture as every other host toggle.
280
+ const selfHasTalkingStick = talkingStickActive && talkingStickHolder === peerId;
281
+ const micLocked = talkingStickActive && !selfHasTalkingStick;
282
+ useEffect(() => {
283
+ if (!talkingStickActive) {
284
+ return;
285
+ }
286
+ if (selfHasTalkingStick && !mediaRef.current.micOn) {
287
+ void mediaRef.current.toggleMic();
288
+ }
289
+ else if (!selfHasTalkingStick && mediaRef.current.micOn) {
290
+ void mediaRef.current.toggleMic();
291
+ }
292
+ }, [talkingStickActive, selfHasTalkingStick]);
251
293
  useEffect(() => {
252
294
  if (!presenterUid) {
253
295
  setActiveSpeakerUid((prev) => pickActiveSpeaker(levels, prev));
@@ -361,6 +403,17 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
361
403
  setWaitingRoomEnabledState(next);
362
404
  apiSetWaitingRoomEnabled(channel, next).catch(() => setWaitingRoomEnabledState(!next));
363
405
  }
406
+ /** Starts or stops talking-stick mode - host-only, see this module's doc comment. Starting makes the host the
407
+ * initial holder; stopping clears the holder too, leaving mic states exactly as they are rather than
408
+ * retroactively unmuting anyone. */
409
+ function handleToggleTalkingStick() {
410
+ managerRef.current?.setTalkingStick(!talkingStickActive, talkingStickActive ? undefined : peerId);
411
+ }
412
+ /** Hands the talking stick to `uid` (a participant's `MeshParticipant.uid`, or this tab's own `peerId` for the
413
+ * host taking it back) - host-only, called from the participants drawer's per-row "Give stick" button. */
414
+ function handleGiveTalkingStick(uid) {
415
+ managerRef.current?.setTalkingStick(true, uid);
416
+ }
364
417
  /** Admits one pending request - optimistically removed from the drawer's own list (the next poll would drop it
365
418
  * anyway, once the requester's own next poll completes their join; removing it here just avoids the visible
366
419
  * delay). Re-added if the save itself fails, so a failure doesn't silently lose the request from the list. */
@@ -418,6 +471,13 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
418
471
  : undefined;
419
472
  const presenterName = presenterUid ? (presenterUid === peerId ? selfName : (participants.find((p) => p.uid === presenterUid)?.name ?? "Someone")) : undefined;
420
473
  const raisedNames = [...(handRaised ? ["You"] : []), ...participants.filter((p) => p.handRaised).map((p) => p.name)];
474
+ // Absent (not "Someone") once the holder has left the call - see `MeshConnectionManager`'s doc comment on why
475
+ // nothing reassigns it automatically; the header then says so rather than naming someone no longer here.
476
+ const talkingStickHolderName = !talkingStickActive
477
+ ? undefined
478
+ : selfHasTalkingStick
479
+ ? "You"
480
+ : participants.find((p) => p.uid === talkingStickHolder)?.name;
421
481
  const alone = participants.length === 0;
422
482
  // This participant has no working link yet: the signaling channel is still opening, or there is someone in the call
423
483
  // and every connection to them is still being made. Once any one is up (or nobody else is here) it is not shown,
@@ -441,10 +501,10 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
441
501
  else {
442
502
  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")) }));
443
503
  }
444
- return (_jsxs("div", { className: "fixed inset-0 z-50 flex flex-col bg-[#202124] text-white overflow-hidden", children: [_jsx("style", { children: `@keyframes meet-float { 0% { transform: translateY(0) scale(.6); opacity: 0; } 12% { opacity: 1; transform: translateY(-4vh) scale(1); } 100% { transform: translateY(-45vh) scale(1); opacity: 0; } }` }), _jsxs("header", { className: "shrink-0 flex items-center justify-between gap-3 px-4 py-3", children: [_jsx("h1", { className: "min-w-0 truncate text-base font-medium", children: meetingTitle }), _jsxs("div", { className: "flex items-center gap-2 shrink-0", children: [raisedNames.length > 0 && (_jsxs("span", { className: "max-w-[45vw] truncate px-3 py-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-sm font-medium", "data-testid": "raised-hands", children: ["\u270B ", raisedNames.join(", ")] })), _jsx("button", { type: "button", className: "px-3 py-1.5 rounded-full bg-[#3c4043] hover:bg-[#4b4f53] text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `${participants.length + 1} participants`, "aria-haspopup": "dialog", "aria-expanded": drawerOpen, onClick: () => setDrawerOpen((prev) => !prev), children: participants.length + 1 })] })] }), connectError && (_jsx("div", { role: "alert", className: "shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#601410] text-[#f9dedc] text-sm", children: connectError })), audioBlocked && (_jsx("button", { type: "button", className: "shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#a8c7fa] text-[#062e6f] text-sm font-medium", onClick: handleResumeAudio, children: "Click here to turn on sound" })), _jsxs("main", { className: "relative flex-1 min-h-0 px-3 pb-3", children: [stage, presenterName && presenterUid !== peerId && (_jsxs("p", { className: "absolute top-2 left-5 px-2 py-0.5 rounded bg-black/60 text-sm", children: [presenterName, " is presenting"] }))] }), _jsx("footer", { className: "shrink-0 pt-1 pb-[max(0.75rem,env(safe-area-inset-bottom))]", children: _jsx(CallControls, { media: media, selfName: selfName, participants: participants, isPresenting: isPresenting, presentingElsewhereName: presenterUid && presenterUid !== peerId ? presenterName : undefined, onToggleShare: () => void handleToggleShare(), screenTransform: screenTransform, onRotateScreen: handleRotateScreen, onFlipScreen: handleFlipScreen, handRaised: handRaised, onToggleHand: handleToggleHand, onReaction: handleReaction, viewMode: viewMode, onToggleViewMode: () => setViewMode((prev) => (prev === "grid" ? "focus" : "grid")), onLeave: onLeave }) }), !alone && (_jsx("div", { className: "absolute z-10 right-3 top-14 w-28 sm:top-auto sm:bottom-24 sm:w-52 aspect-video shadow-xl xl:right-4 xl:bottom-4", "data-testid": "self-view", children: selfTile("h-full") })), _jsx("div", { className: "pointer-events-none absolute left-4 bottom-28 w-40 h-[45vh]", "aria-hidden": "true", children: reactions.map((reaction) => (_jsxs("div", { className: "absolute bottom-0 flex flex-col items-center", style: { left: `${(reaction.id % 4) * 36}px`, animation: `meet-float ${REACTION_MS}ms ease-out forwards` }, "data-testid": "reaction", children: [_jsx("span", { className: "text-4xl leading-none", children: reaction.emoji }), _jsx("span", { className: "mt-1 px-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-xs", children: reaction.name })] }, reaction.id))) }), participants.map((p) => {
445
- const stream = remoteStreams[p.uid];
446
- return stream ? _jsx(RemoteAudio, { stream: stream, onBlocked: handleAudioBlocked }, `${p.uid}:${audioNonce}`) : null;
447
- }), _jsx("div", { role: "status", "aria-live": "polite", className: "sr-only", children: announcement }), drawerOpen && (_jsx(ParticipantsDrawer, { selfName: selfName, micOn: media.micOn, handRaised: handRaised, participants: participants, isSelfHost: isHost, isParticipantHost: isParticipantHost, onMute: handleMuteParticipant, onKick: handleKickParticipant, forceMuteOnJoin: forceMuteOnJoin, onToggleForceMuteOnJoin: handleToggleForceMuteOnJoin, hasPassword: hasPassword, onSetPassword: handleSetPassword, waitingRoomEnabled: waitingRoomEnabled, onToggleWaitingRoomEnabled: handleToggleWaitingRoomEnabled, waitingParticipants: waitingParticipants, onAdmit: handleAdmitParticipant, onDeny: handleDenyParticipant, onClose: () => setDrawerOpen(false) }))] }));
504
+ 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: `px-3 py-1.5 rounded-full text-sm font-medium 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-pressed": talkingStickActive, onClick: handleToggleTalkingStick, children: talkingStickActive ? "End talking stick" : "Talking stick" })), _jsx("button", { type: "button", className: "px-3 py-1.5 rounded-full bg-[#3c4043] hover:bg-[#4b4f53] text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `${participants.length + 1} participants`, "aria-haspopup": "dialog", "aria-expanded": drawerOpen, onClick: () => setDrawerOpen((prev) => !prev), children: participants.length + 1 })] })] }), connectError && (_jsx("div", { role: "alert", className: "shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#601410] text-[#f9dedc] text-sm", children: connectError })), audioBlocked && (_jsx("button", { type: "button", className: "shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#a8c7fa] text-[#062e6f] text-sm font-medium", onClick: handleResumeAudio, children: "Click here to turn on sound" })), _jsxs("main", { className: "relative flex-1 min-h-0 px-3 pb-3", children: [stage, presenterName && presenterUid !== peerId && (_jsxs("p", { className: "absolute top-2 left-5 px-2 py-0.5 rounded bg-black/60 text-sm", children: [presenterName, " is presenting"] }))] }), _jsx("footer", { className: "shrink-0 pt-1 pb-[max(0.75rem,env(safe-area-inset-bottom))]", children: _jsx(CallControls, { media: media, selfName: selfName, participants: participants, isPresenting: isPresenting, presentingElsewhereName: presenterUid && presenterUid !== peerId ? presenterName : undefined, onToggleShare: () => void handleToggleShare(), screenTransform: screenTransform, onRotateScreen: handleRotateScreen, onFlipScreen: handleFlipScreen, handRaised: handRaised, onToggleHand: handleToggleHand, onReaction: handleReaction, viewMode: viewMode, onToggleViewMode: () => setViewMode((prev) => (prev === "grid" ? "focus" : "grid")), micLocked: micLocked, 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) => {
505
+ const stream = remoteStreams[p.uid];
506
+ return stream ? _jsx(RemoteAudio, { stream: stream, onBlocked: handleAudioBlocked }, `${p.uid}:${audioNonce}`) : null;
507
+ }), _jsx("div", { role: "status", "aria-live": "polite", className: "sr-only", children: announcement })] }), drawerOpen && (_jsx(ParticipantsDrawer, { selfName: selfName, micOn: media.micOn, handRaised: handRaised, participants: participants, isSelfHost: isHost, isParticipantHost: isParticipantHost, onMute: handleMuteParticipant, onKick: handleKickParticipant, forceMuteOnJoin: forceMuteOnJoin, onToggleForceMuteOnJoin: handleToggleForceMuteOnJoin, hasPassword: hasPassword, onSetPassword: handleSetPassword, waitingRoomEnabled: waitingRoomEnabled, onToggleWaitingRoomEnabled: handleToggleWaitingRoomEnabled, waitingParticipants: waitingParticipants, onAdmit: handleAdmitParticipant, onDeny: handleDenyParticipant, talkingStickActive: talkingStickActive, selfPeerId: peerId, talkingStickHolder: talkingStickHolder, onGiveTalkingStick: handleGiveTalkingStick, onClose: () => setDrawerOpen(false) }))] }));
448
508
  }
449
509
  /** Plays one remote participant's stream through a hidden `<audio>` element. `onBlocked` fires if the browser
450
510
  * refuses to start it (an autoplay policy) - the view then asks for a click. */
@@ -4,8 +4,10 @@
4
4
  * shows on a tile, as plain rows instead (a list reads better than tiles once there's a reason to scan for one
5
5
  * name).
6
6
  *
7
- * Closes on Escape, on a click on the backdrop behind it, or its own close button - never on a click inside the
8
- * drawer itself, matching `_CallControls.tsx`'s menus.
7
+ * A docked sidebar, not an overlay: `_CallView.tsx` renders it as an ordinary flex sibling of the call's own
8
+ * content column, which shrinks to make room rather than being covered by a backdrop - meant to stay open and
9
+ * visible for as long as the participant wants, including while the call continues underneath. Closes on Escape or
10
+ * its own close button; there is no backdrop to click, on purpose.
9
11
  *
10
12
  * A "Host" tag marks the host's own row (`isSelfHost`/`isParticipantHost` - see `_CallView.tsx`'s doc comment on
11
13
  * `hostUid` for what that identity is and isn't). Only while `isSelfHost` does every *other* row also get a "Mute"
@@ -21,6 +23,11 @@
21
23
  * `waitingParticipants`/`onAdmit`/`onDeny`): `_CallView.tsx` polls the list while this drawer is open and the
22
24
  * caller is the host (there is no push signal for a newly filed admission request), so it stays current without
23
25
  * the host needing to close and reopen the drawer.
26
+ *
27
+ * While talking-stick mode is on (`talkingStickActive`), every row - including the host's own - shows a "Give
28
+ * stick" button, host-only, hidden on whichever row currently holds it (`talkingStickHolder`); everyone (not just
29
+ * the host) sees a badge on that row instead, same visibility as the "Host" tag. See `_CallView.tsx`'s doc comment
30
+ * on talking-stick mode for what granting it actually does to the recipient's microphone.
24
31
  */
25
32
  import React from "react";
26
33
  import type { MeshParticipant } from "../shared/webrtc/types.js";
@@ -56,6 +63,17 @@ export interface ParticipantsDrawerProps {
56
63
  waitingParticipants: WaitingParticipant[];
57
64
  onAdmit: (uid: string) => void;
58
65
  onDeny: (uid: string) => void;
66
+ /** Whether talking-stick mode is currently on - the host-only navbar toggle's own state (see `_CallView.tsx`'s
67
+ * doc comment on talking-stick mode). */
68
+ talkingStickActive: boolean;
69
+ /** This tab's own peer id - needed only so the self row's "Give stick" button can name itself when the host
70
+ * wants to take the stick back. */
71
+ selfPeerId: string;
72
+ /** Whichever peer id (`selfPeerId`, or a participant's `MeshParticipant.uid`) currently holds the stick;
73
+ * undefined while `talkingStickActive` is `false`, or (transiently) once its holder has left the call. */
74
+ talkingStickHolder?: string;
75
+ /** Hands the stick to `uid` - host-only, see `_CallView.tsx`'s `handleGiveTalkingStick()`. */
76
+ onGiveTalkingStick: (uid: string) => void;
59
77
  onClose: () => void;
60
78
  }
61
- export default function ParticipantsDrawer({ selfName, micOn, handRaised, participants, isSelfHost, isParticipantHost, onMute, onKick, forceMuteOnJoin, onToggleForceMuteOnJoin, hasPassword, onSetPassword, waitingRoomEnabled, onToggleWaitingRoomEnabled, waitingParticipants, onAdmit, onDeny, onClose, }: ParticipantsDrawerProps): React.JSX.Element;
79
+ export default function ParticipantsDrawer({ selfName, micOn, handRaised, participants, isSelfHost, isParticipantHost, onMute, onKick, forceMuteOnJoin, onToggleForceMuteOnJoin, hasPassword, onSetPassword, waitingRoomEnabled, onToggleWaitingRoomEnabled, waitingParticipants, onAdmit, onDeny, talkingStickActive, selfPeerId, talkingStickHolder, onGiveTalkingStick, onClose, }: ParticipantsDrawerProps): React.JSX.Element;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } 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
@@ -9,8 +9,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
9
9
  * shows on a tile, as plain rows instead (a list reads better than tiles once there's a reason to scan for one
10
10
  * name).
11
11
  *
12
- * Closes on Escape, on a click on the backdrop behind it, or its own close button - never on a click inside the
13
- * drawer itself, matching `_CallControls.tsx`'s menus.
12
+ * A docked sidebar, not an overlay: `_CallView.tsx` renders it as an ordinary flex sibling of the call's own
13
+ * content column, which shrinks to make room rather than being covered by a backdrop - meant to stay open and
14
+ * visible for as long as the participant wants, including while the call continues underneath. Closes on Escape or
15
+ * its own close button; there is no backdrop to click, on purpose.
14
16
  *
15
17
  * A "Host" tag marks the host's own row (`isSelfHost`/`isParticipantHost` - see `_CallView.tsx`'s doc comment on
16
18
  * `hostUid` for what that identity is and isn't). Only while `isSelfHost` does every *other* row also get a "Mute"
@@ -26,6 +28,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
26
28
  * `waitingParticipants`/`onAdmit`/`onDeny`): `_CallView.tsx` polls the list while this drawer is open and the
27
29
  * caller is the host (there is no push signal for a newly filed admission request), so it stays current without
28
30
  * the host needing to close and reopen the drawer.
31
+ *
32
+ * While talking-stick mode is on (`talkingStickActive`), every row - including the host's own - shows a "Give
33
+ * stick" button, host-only, hidden on whichever row currently holds it (`talkingStickHolder`); everyone (not just
34
+ * the host) sees a badge on that row instead, same visibility as the "Host" tag. See `_CallView.tsx`'s doc comment
35
+ * on talking-stick mode for what granting it actually does to the recipient's microphone.
29
36
  */
30
37
  import { useEffect, useState } from "react";
31
38
  import { TRANSPORT_BADGES } from "./_ParticipantTile.js";
@@ -64,15 +71,15 @@ function PasswordSection({ hasPassword, onSetPassword }) {
64
71
  function WaitingSection({ waitingParticipants, onAdmit, onDeny, }) {
65
72
  return (_jsxs("div", { className: "px-3 py-2 border-b border-white/10 text-sm", children: [_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-white/60 mb-1.5", children: "Waiting to join" }), waitingParticipants.length === 0 ? (_jsx("p", { className: "text-white/70", children: "Nobody is waiting right now." })) : (_jsx("ul", { children: waitingParticipants.map((p) => (_jsxs("li", { className: "flex items-center gap-2 py-1", children: [_jsx("span", { className: "flex-1 min-w-0 truncate", children: p.name }), _jsx("button", { type: "button", className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Admit ${p.name}`, onClick: () => onAdmit(p.uid), children: "Admit" }), _jsx("button", { type: "button", className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Deny ${p.name}`, onClick: () => onDeny(p.uid), children: "Deny" })] }, p.uid))) }))] }));
66
73
  }
67
- function Row({ name, isSelf, isHost, micMuted, handRaised, transport, canModerate, onMute, onKick, }) {
74
+ function Row({ name, isSelf, isHost, micMuted, handRaised, transport, canModerate, onMute, onKick, hasStick, canGiveStick, onGiveStick, }) {
68
75
  const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
69
- return (_jsxs("li", { className: "flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-white/5", children: [_jsxs("span", { className: "flex-1 min-w-0 truncate", children: [name, isSelf ? " (you)" : ""] }), isHost && _jsx("span", { className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f]", children: "Host" }), handRaised && (_jsx("span", { role: "img", "aria-label": "Hand raised", children: "\u270B" })), micMuted && (_jsx("span", { className: "w-4 h-4 text-white/70 [&>svg]:w-4 [&>svg]:h-4", role: "img", "aria-label": "Muted", children: _jsx(MicOffIcon, {}) })), badge && (_jsx("span", { className: `px-2 py-0.5 rounded text-xs whitespace-nowrap ${transport === "failed" ? "bg-[#601410] text-[#f9dedc]" : "bg-black/40 text-white"}`, title: badge.title, children: badge.label })), canModerate && !micMuted && (_jsx("button", { type: "button", className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Mute ${name}`, onClick: onMute, children: "Mute" })), canModerate && (_jsx("button", { type: "button", className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#601410] text-[#f9dedc] hover:bg-[#7a1b16] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Remove ${name}`, onClick: () => {
76
+ return (_jsxs("li", { className: "flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-white/5", children: [_jsxs("span", { className: "flex-1 min-w-0 truncate", children: [name, isSelf ? " (you)" : ""] }), isHost && _jsx("span", { className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f]", children: "Host" }), hasStick && (_jsx("span", { className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f]", role: "img", "aria-label": "Holding the talking stick", children: "\uD83C\uDF99\uFE0F" })), handRaised && (_jsx("span", { role: "img", "aria-label": "Hand raised", children: "\u270B" })), micMuted && (_jsx("span", { className: "w-4 h-4 text-white/70 [&>svg]:w-4 [&>svg]:h-4", role: "img", "aria-label": "Muted", children: _jsx(MicOffIcon, {}) })), badge && (_jsx("span", { className: `px-2 py-0.5 rounded text-xs whitespace-nowrap ${transport === "failed" ? "bg-[#601410] text-[#f9dedc]" : "bg-black/40 text-white"}`, title: badge.title, children: badge.label })), canGiveStick && !hasStick && (_jsx("button", { type: "button", className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Give the talking stick to ${name}`, onClick: onGiveStick, children: "Give stick" })), canModerate && !micMuted && (_jsx("button", { type: "button", className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Mute ${name}`, onClick: onMute, children: "Mute" })), canModerate && (_jsx("button", { type: "button", className: "px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#601410] text-[#f9dedc] hover:bg-[#7a1b16] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Remove ${name}`, onClick: () => {
70
77
  if (window.confirm(`Remove ${name} from the call?`)) {
71
78
  onKick?.();
72
79
  }
73
80
  }, children: "Remove" }))] }));
74
81
  }
75
- export default function ParticipantsDrawer({ selfName, micOn, handRaised, participants, isSelfHost, isParticipantHost, onMute, onKick, forceMuteOnJoin, onToggleForceMuteOnJoin, hasPassword, onSetPassword, waitingRoomEnabled, onToggleWaitingRoomEnabled, waitingParticipants, onAdmit, onDeny, onClose, }) {
82
+ export default function ParticipantsDrawer({ selfName, micOn, handRaised, participants, isSelfHost, isParticipantHost, onMute, onKick, forceMuteOnJoin, onToggleForceMuteOnJoin, hasPassword, onSetPassword, waitingRoomEnabled, onToggleWaitingRoomEnabled, waitingParticipants, onAdmit, onDeny, talkingStickActive, selfPeerId, talkingStickHolder, onGiveTalkingStick, onClose, }) {
76
83
  useEffect(() => {
77
84
  const onKeyDown = (event) => {
78
85
  if (event.key === "Escape") {
@@ -82,5 +89,5 @@ export default function ParticipantsDrawer({ selfName, micOn, handRaised, partic
82
89
  document.addEventListener("keydown", onKeyDown);
83
90
  return () => document.removeEventListener("keydown", onKeyDown);
84
91
  }, [onClose]);
85
- return (_jsxs(_Fragment, { children: [_jsx("div", { className: "absolute inset-0 z-20 bg-black/40", onClick: onClose, "aria-hidden": "true" }), _jsxs("aside", { role: "dialog", "aria-label": "Participants", className: "absolute z-20 inset-y-0 right-0 w-72 max-w-[85vw] flex flex-col bg-[#2b2d30] text-white shadow-xl", onClick: (event) => event.stopPropagation(), 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 && (_jsxs("label", { className: "flex items-center gap-2 px-3 py-2 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"] })), isSelfHost && _jsx(PasswordSection, { hasPassword: hasPassword, onSetPassword: onSetPassword }), isSelfHost && (_jsxs("label", { className: "flex items-center gap-2 px-3 py-2 border-b border-white/10 text-sm", children: [_jsx("input", { type: "checkbox", checked: waitingRoomEnabled, onChange: onToggleWaitingRoomEnabled, className: "w-4 h-4" }), "Require the host to admit participants"] })), 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 }), 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) }, p.uid)))] })] })] }));
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 && (_jsxs("label", { className: "flex items-center gap-2 px-3 py-2 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"] })), isSelfHost && _jsx(PasswordSection, { hasPassword: hasPassword, onSetPassword: onSetPassword }), isSelfHost && (_jsxs("label", { className: "flex items-center gap-2 px-3 py-2 border-b border-white/10 text-sm", children: [_jsx("input", { type: "checkbox", checked: waitingRoomEnabled, onChange: onToggleWaitingRoomEnabled, className: "w-4 h-4" }), "Require the host to admit participants"] })), 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)))] })] }));
86
93
  }
@@ -67,6 +67,19 @@
67
67
  * manager takes no action of its own beyond that - it doesn't mute a track or tear itself down, since a kicked
68
68
  * participant's actual departure is `stop()`, which the caller decides to call.
69
69
  *
70
+ * ## Talking stick (`talking-stick`)
71
+ *
72
+ * One exclusive "floor" - at most one participant may be unmuted at a time, host-assigned - but unlike presenter,
73
+ * there is no claim race to resolve: only the host's own client ever calls `setTalkingStick()` (same trust level as
74
+ * `sendMuteRequest()`/`sendKick()` - nothing here checks that), so the manager carries no state of its own and
75
+ * simply relays the latest message it sees as `talking-stick-changed`, applying it to itself too via an immediate
76
+ * local emit (the same "optimistic, no round trip" shape `claimPresenter()`/`releasePresenter()` use). A holder who
77
+ * leaves the call is not specially reassigned - every other participant's existing `participant-left` handling
78
+ * already drops them from the roster, and "the stick's holder is no longer in the call" is simply "nobody is holder
79
+ * right now" without any manager needing to say so, until the host picks someone (or themselves) again. There is no
80
+ * backend route or persisted field for this at all: like `mute-request`/`kicked`, it only matters while people are
81
+ * actually in the call together.
82
+ *
70
83
  * ## Three media paths, tried in order
71
84
  *
72
85
  * Each pair of participants reaches for the best path that works, and `MeshParticipant.transport` says which one it
@@ -176,6 +189,12 @@ export declare class MeshConnectionManager {
176
189
  * does not itself revoke `peerUid`'s server-side channel grant (see `BaseVideoMeetingRoute.revokeChannelGrant()`,
177
190
  * which the caller is expected to call alongside this for an enforced removal). */
178
191
  sendKick(peerUid: string): void;
192
+ /** Turns talking-stick mode on (naming `holder`, the initial speaker) or off (`active: false`, `holder`
193
+ * omitted), or hands an already-active stick to a different `holder` - see this module's doc comment on why
194
+ * there is no collision to resolve here, unlike presenter. Broadcasts and applies locally at once, trusting
195
+ * there is only ever one sender; the caller (`_CallView.tsx`) is responsible for only offering this to the
196
+ * host. */
197
+ setTalkingStick(active: boolean, holder?: string): void;
179
198
  private sendHello;
180
199
  private send;
181
200
  private emit;
@@ -71,6 +71,19 @@
71
71
  * manager takes no action of its own beyond that - it doesn't mute a track or tear itself down, since a kicked
72
72
  * participant's actual departure is `stop()`, which the caller decides to call.
73
73
  *
74
+ * ## Talking stick (`talking-stick`)
75
+ *
76
+ * One exclusive "floor" - at most one participant may be unmuted at a time, host-assigned - but unlike presenter,
77
+ * there is no claim race to resolve: only the host's own client ever calls `setTalkingStick()` (same trust level as
78
+ * `sendMuteRequest()`/`sendKick()` - nothing here checks that), so the manager carries no state of its own and
79
+ * simply relays the latest message it sees as `talking-stick-changed`, applying it to itself too via an immediate
80
+ * local emit (the same "optimistic, no round trip" shape `claimPresenter()`/`releasePresenter()` use). A holder who
81
+ * leaves the call is not specially reassigned - every other participant's existing `participant-left` handling
82
+ * already drops them from the roster, and "the stick's holder is no longer in the call" is simply "nobody is holder
83
+ * right now" without any manager needing to say so, until the host picks someone (or themselves) again. There is no
84
+ * backend route or persisted field for this at all: like `mute-request`/`kicked`, it only matters while people are
85
+ * actually in the call together.
86
+ *
74
87
  * ## Three media paths, tried in order
75
88
  *
76
89
  * Each pair of participants reaches for the best path that works, and `MeshParticipant.transport` says which one it
@@ -235,6 +248,15 @@ export class MeshConnectionManager {
235
248
  sendKick(peerUid) {
236
249
  this.send({ kind: "kicked", to: peerUid });
237
250
  }
251
+ /** Turns talking-stick mode on (naming `holder`, the initial speaker) or off (`active: false`, `holder`
252
+ * omitted), or hands an already-active stick to a different `holder` - see this module's doc comment on why
253
+ * there is no collision to resolve here, unlike presenter. Broadcasts and applies locally at once, trusting
254
+ * there is only ever one sender; the caller (`_CallView.tsx`) is responsible for only offering this to the
255
+ * host. */
256
+ setTalkingStick(active, holder) {
257
+ this.send({ kind: "talking-stick", active, holder });
258
+ this.emit({ type: "talking-stick-changed", active, holder });
259
+ }
238
260
  sendHello() {
239
261
  this.send({ kind: "hello", name: this.options.selfName, state: this.localState });
240
262
  }
@@ -298,6 +320,9 @@ export class MeshConnectionManager {
298
320
  case "kicked":
299
321
  this.emit({ type: "kicked" });
300
322
  return;
323
+ case "talking-stick":
324
+ this.emit({ type: "talking-stick-changed", active: message.active === true, holder: typeof message.holder === "string" ? message.holder : undefined });
325
+ return;
301
326
  }
302
327
  }
303
328
  handleHello(message) {