@rapidmx/meet-plugin 0.10.0 → 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.
@@ -11,7 +11,9 @@
11
11
  *
12
12
  * The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
13
13
  * picking up, so a participant can see their audio is being sent - and the camera button shows a green dot while a
14
- * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`).
14
+ * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
15
+ * is disabled (`micLocked`) while talking-stick mode is on and this participant isn't the current holder - see
16
+ * `_CallView.tsx`'s doc comment.
15
17
  */
16
18
  import React, { useEffect, useRef, useState } from "react";
17
19
  import { filtersActive } from "../shared/media/filters/filterTypes.js";
@@ -57,6 +59,10 @@ export interface CallControlsProps {
57
59
  onReaction: (emoji: string) => void;
58
60
  viewMode: CallViewMode;
59
61
  onToggleViewMode: () => void;
62
+ /** True while talking-stick mode is on and this participant doesn't currently hold it - see `_CallView.tsx`'s
63
+ * doc comment on talking-stick mode. Disables the microphone toggle (not the device-picker chevron, which
64
+ * doesn't change whether anyone can hear them) rather than merely nudging it, unlike a `mute-request`. */
65
+ micLocked?: boolean;
60
66
  onLeave: () => void;
61
67
  }
62
68
 
@@ -97,6 +103,7 @@ export default function CallControls({
97
103
  onReaction,
98
104
  viewMode,
99
105
  onToggleViewMode,
106
+ micLocked,
100
107
  onLeave,
101
108
  }: CallControlsProps) {
102
109
  const [openMenu, setOpenMenu] = useState<OpenMenu>(null);
@@ -145,9 +152,11 @@ export default function CallControls({
145
152
  </button>
146
153
  <button
147
154
  type="button"
148
- className={`${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT}`}
155
+ className={`${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT} disabled:opacity-50 disabled:cursor-not-allowed`}
149
156
  aria-label={media.micOn ? "Mute microphone" : "Unmute microphone"}
150
157
  aria-pressed={!media.micOn}
158
+ disabled={micLocked}
159
+ title={micLocked ? "Only the current talking-stick holder can unmute." : undefined}
151
160
  onClick={() => void media.toggleMic()}
152
161
  >
153
162
  {media.micOn && <LevelBars level={media.audioLevel} />}
@@ -33,6 +33,23 @@
33
33
  * auto-detected active speaker (silently ignored while presenting, so it never fights the presenter for the main
34
34
  * slot); else, in focus mode with nobody yet speaking, the first other participant - so focus mode never shows an
35
35
  * empty main slot once someone else has joined.
36
+ *
37
+ * ## Talking stick mode
38
+ *
39
+ * A host-only navbar toggle (`handleToggleTalkingStick()`) that, once on, lets at most one participant's microphone
40
+ * be unmuted at a time - the host assigns who from the participants drawer (`handleGiveTalkingStick()`), everyone
41
+ * else sees a header chip naming who currently holds it. Purely an in-call runtime state signaled peer-to-peer over
42
+ * the mesh (`MeshConnectionManager.setTalkingStick()`/`"talking-stick-changed"`), the same shape as `mute-request`/
43
+ * `kicked`: no `VideoMeeting` field, no backend route, and only as "enforced" as a cooperating client makes it (see
44
+ * `types.ts`'s `SignalMessage` doc comment on why the signaling layer has no host identity to check against). What
45
+ * *is* new here versus `mute-request`'s one-time nudge: becoming or ceasing to be the holder force-toggles this
46
+ * tab's own mic to match (`selfHasTalkingStick`'s effect, below), and the mic button itself is disabled
47
+ * (`micLocked`, passed to `CallControls`) the whole time a non-holder would otherwise be able to tap it straight
48
+ * 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
49
+ * suggestion. Turning the mode off touches nobody's mic state, matching every other host toggle's "never
50
+ * retroactive" posture. A holder who leaves the call is not specially reassigned - see
51
+ * `MeshConnectionManager`'s own doc comment on why that needs no extra code - the header chip then says nobody has
52
+ * the floor, until the host picks someone (or themselves) again.
36
53
  */
37
54
  import React, { useCallback, useEffect, useRef, useState } from "react";
38
55
  import { playRaisedHandChime } from "../shared/media/chime.js";
@@ -185,6 +202,8 @@ export default function CallView({
185
202
  const [hasPassword, setHasPassword] = useState(!!initialHasPassword);
186
203
  const [waitingRoomEnabled, setWaitingRoomEnabledState] = useState(!!initialWaitingRoomEnabled);
187
204
  const [waitingParticipants, setWaitingParticipants] = useState<WaitingParticipant[]>([]);
205
+ const [talkingStickActive, setTalkingStickActive] = useState(false);
206
+ const [talkingStickHolder, setTalkingStickHolder] = useState<string | undefined>(undefined);
188
207
 
189
208
  const managerRef = useRef<MeshConnectionManager | null>(null);
190
209
  /** The raw capture from `getDisplayMedia()` - only ever used to stop it (releasing the OS's own share
@@ -280,6 +299,10 @@ export default function CallView({
280
299
  case "kicked":
281
300
  onLeave("The host removed you from this call.");
282
301
  return;
302
+ case "talking-stick-changed":
303
+ setTalkingStickActive(event.active);
304
+ setTalkingStickHolder(event.holder);
305
+ return;
283
306
  }
284
307
  });
285
308
 
@@ -337,6 +360,25 @@ export default function CallView({
337
360
  managerRef.current?.setLocalState({ audioOn: media.micOn, videoOn: media.cameraOn || isPresenting, handRaised });
338
361
  }, [media.micOn, media.cameraOn, isPresenting, handRaised]);
339
362
 
363
+ // Talking-stick mode forces exactly one microphone on at a time: whoever just became the holder (including the
364
+ // host, on activation) is unmuted; everyone else is muted. Unlike `mute-requested`'s one-time nudge, this is
365
+ // paired with disabling the mic button itself (`micLocked`, passed to `CallControls` below) while it applies,
366
+ // so it isn't merely a suggestion - though still cooperative at the signaling layer, like every other
367
+ // moderation feature here (see `MeshConnectionManager`'s doc comment). Turning the mode off touches nobody's
368
+ // mic - states are left exactly as they are, the same "never retroactive" posture as every other host toggle.
369
+ const selfHasTalkingStick = talkingStickActive && talkingStickHolder === peerId;
370
+ const micLocked = talkingStickActive && !selfHasTalkingStick;
371
+ useEffect(() => {
372
+ if (!talkingStickActive) {
373
+ return;
374
+ }
375
+ if (selfHasTalkingStick && !mediaRef.current.micOn) {
376
+ void mediaRef.current.toggleMic();
377
+ } else if (!selfHasTalkingStick && mediaRef.current.micOn) {
378
+ void mediaRef.current.toggleMic();
379
+ }
380
+ }, [talkingStickActive, selfHasTalkingStick]);
381
+
340
382
  useEffect(() => {
341
383
  if (!presenterUid) {
342
384
  setActiveSpeakerUid((prev) => pickActiveSpeaker(levels, prev));
@@ -462,6 +504,19 @@ export default function CallView({
462
504
  apiSetWaitingRoomEnabled(channel, next).catch(() => setWaitingRoomEnabledState(!next));
463
505
  }
464
506
 
507
+ /** Starts or stops talking-stick mode - host-only, see this module's doc comment. Starting makes the host the
508
+ * initial holder; stopping clears the holder too, leaving mic states exactly as they are rather than
509
+ * retroactively unmuting anyone. */
510
+ function handleToggleTalkingStick() {
511
+ managerRef.current?.setTalkingStick(!talkingStickActive, talkingStickActive ? undefined : peerId);
512
+ }
513
+
514
+ /** Hands the talking stick to `uid` (a participant's `MeshParticipant.uid`, or this tab's own `peerId` for the
515
+ * host taking it back) - host-only, called from the participants drawer's per-row "Give stick" button. */
516
+ function handleGiveTalkingStick(uid: string) {
517
+ managerRef.current?.setTalkingStick(true, uid);
518
+ }
519
+
465
520
  /** Admits one pending request - optimistically removed from the drawer's own list (the next poll would drop it
466
521
  * anyway, once the requester's own next poll completes their join; removing it here just avoids the visible
467
522
  * delay). Re-added if the save itself fails, so a failure doesn't silently lose the request from the list. */
@@ -524,6 +579,13 @@ export default function CallView({
524
579
  : undefined;
525
580
  const presenterName = presenterUid ? (presenterUid === peerId ? selfName : (participants.find((p) => p.uid === presenterUid)?.name ?? "Someone")) : undefined;
526
581
  const raisedNames = [...(handRaised ? ["You"] : []), ...participants.filter((p) => p.handRaised).map((p) => p.name)];
582
+ // Absent (not "Someone") once the holder has left the call - see `MeshConnectionManager`'s doc comment on why
583
+ // nothing reassigns it automatically; the header then says so rather than naming someone no longer here.
584
+ const talkingStickHolderName = !talkingStickActive
585
+ ? undefined
586
+ : selfHasTalkingStick
587
+ ? "You"
588
+ : participants.find((p) => p.uid === talkingStickHolder)?.name;
527
589
  const alone = participants.length === 0;
528
590
  // This participant has no working link yet: the signaling channel is still opening, or there is someone in the call
529
591
  // and every connection to them is still being made. Once any one is up (or nobody else is here) it is not shown,
@@ -614,6 +676,21 @@ export default function CallView({
614
676
  ✋ {raisedNames.join(", ")}
615
677
  </span>
616
678
  )}
679
+ {talkingStickActive && (
680
+ <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">
681
+ 🎙️ {talkingStickHolderName ? `${talkingStickHolderName} ${talkingStickHolderName === "You" ? "have" : "has"} the floor` : "Waiting for the host to choose a speaker"}
682
+ </span>
683
+ )}
684
+ {isHost && (
685
+ <button
686
+ type="button"
687
+ 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]"}`}
688
+ aria-pressed={talkingStickActive}
689
+ onClick={handleToggleTalkingStick}
690
+ >
691
+ {talkingStickActive ? "End talking stick" : "Talking stick"}
692
+ </button>
693
+ )}
617
694
  <button
618
695
  type="button"
619
696
  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"
@@ -662,6 +739,7 @@ export default function CallView({
662
739
  onReaction={handleReaction}
663
740
  viewMode={viewMode}
664
741
  onToggleViewMode={() => setViewMode((prev) => (prev === "grid" ? "focus" : "grid"))}
742
+ micLocked={micLocked}
665
743
  onLeave={onLeave}
666
744
  />
667
745
  </footer>
@@ -716,6 +794,10 @@ export default function CallView({
716
794
  waitingParticipants={waitingParticipants}
717
795
  onAdmit={handleAdmitParticipant}
718
796
  onDeny={handleDenyParticipant}
797
+ talkingStickActive={talkingStickActive}
798
+ selfPeerId={peerId}
799
+ talkingStickHolder={talkingStickHolder}
800
+ onGiveTalkingStick={handleGiveTalkingStick}
719
801
  onClose={() => setDrawerOpen(false)}
720
802
  />
721
803
  )}
@@ -25,6 +25,11 @@
25
25
  * `waitingParticipants`/`onAdmit`/`onDeny`): `_CallView.tsx` polls the list while this drawer is open and the
26
26
  * caller is the host (there is no push signal for a newly filed admission request), so it stays current without
27
27
  * the host needing to close and reopen the drawer.
28
+ *
29
+ * While talking-stick mode is on (`talkingStickActive`), every row - including the host's own - shows a "Give
30
+ * stick" button, host-only, hidden on whichever row currently holds it (`talkingStickHolder`); everyone (not just
31
+ * the host) sees a badge on that row instead, same visibility as the "Host" tag. See `_CallView.tsx`'s doc comment
32
+ * on talking-stick mode for what granting it actually does to the recipient's microphone.
28
33
  */
29
34
  import React, { useEffect, useState } from "react";
30
35
  import type { MeshParticipant } from "../shared/webrtc/types.js";
@@ -63,6 +68,17 @@ export interface ParticipantsDrawerProps {
63
68
  waitingParticipants: WaitingParticipant[];
64
69
  onAdmit: (uid: string) => void;
65
70
  onDeny: (uid: string) => void;
71
+ /** Whether talking-stick mode is currently on - the host-only navbar toggle's own state (see `_CallView.tsx`'s
72
+ * doc comment on talking-stick mode). */
73
+ talkingStickActive: boolean;
74
+ /** This tab's own peer id - needed only so the self row's "Give stick" button can name itself when the host
75
+ * wants to take the stick back. */
76
+ selfPeerId: string;
77
+ /** Whichever peer id (`selfPeerId`, or a participant's `MeshParticipant.uid`) currently holds the stick;
78
+ * undefined while `talkingStickActive` is `false`, or (transiently) once its holder has left the call. */
79
+ talkingStickHolder?: string;
80
+ /** Hands the stick to `uid` - host-only, see `_CallView.tsx`'s `handleGiveTalkingStick()`. */
81
+ onGiveTalkingStick: (uid: string) => void;
66
82
  onClose: () => void;
67
83
  }
68
84
 
@@ -187,6 +203,9 @@ function Row({
187
203
  canModerate,
188
204
  onMute,
189
205
  onKick,
206
+ hasStick,
207
+ canGiveStick,
208
+ onGiveStick,
190
209
  }: {
191
210
  name: string;
192
211
  isSelf?: boolean;
@@ -198,6 +217,12 @@ function Row({
198
217
  canModerate?: boolean;
199
218
  onMute?: () => void;
200
219
  onKick?: () => void;
220
+ /** Whether this row currently holds the talking stick - shown to every viewer, not just the host. */
221
+ hasStick?: boolean;
222
+ /** Whether to show this row's "Give stick" button - `isSelfHost && talkingStickActive`, decided by the caller;
223
+ * never shown on the row that already holds it. */
224
+ canGiveStick?: boolean;
225
+ onGiveStick?: () => void;
201
226
  }) {
202
227
  const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
203
228
  return (
@@ -207,6 +232,11 @@ function Row({
207
232
  {isSelf ? " (you)" : ""}
208
233
  </span>
209
234
  {isHost && <span className="px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f]">Host</span>}
235
+ {hasStick && (
236
+ <span className="px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f]" role="img" aria-label="Holding the talking stick">
237
+ 🎙️
238
+ </span>
239
+ )}
210
240
  {handRaised && (
211
241
  <span role="img" aria-label="Hand raised">
212
242
  ✋
@@ -225,6 +255,16 @@ function Row({
225
255
  {badge.label}
226
256
  </span>
227
257
  )}
258
+ {canGiveStick && !hasStick && (
259
+ <button
260
+ type="button"
261
+ 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"
262
+ aria-label={`Give the talking stick to ${name}`}
263
+ onClick={onGiveStick}
264
+ >
265
+ Give stick
266
+ </button>
267
+ )}
228
268
  {canModerate && !micMuted && (
229
269
  <button
230
270
  type="button"
@@ -271,6 +311,10 @@ export default function ParticipantsDrawer({
271
311
  waitingParticipants,
272
312
  onAdmit,
273
313
  onDeny,
314
+ talkingStickActive,
315
+ selfPeerId,
316
+ talkingStickHolder,
317
+ onGiveTalkingStick,
274
318
  onClose,
275
319
  }: ParticipantsDrawerProps) {
276
320
  useEffect(() => {
@@ -320,7 +364,16 @@ export default function ParticipantsDrawer({
320
364
  <WaitingSection waitingParticipants={waitingParticipants} onAdmit={onAdmit} onDeny={onDeny} />
321
365
  )}
322
366
  <ul className="flex-1 min-h-0 overflow-y-auto p-2 text-sm">
323
- <Row name={selfName} isSelf isHost={isSelfHost} micMuted={!micOn} handRaised={handRaised} />
367
+ <Row
368
+ name={selfName}
369
+ isSelf
370
+ isHost={isSelfHost}
371
+ micMuted={!micOn}
372
+ handRaised={handRaised}
373
+ hasStick={talkingStickActive && talkingStickHolder === selfPeerId}
374
+ canGiveStick={isSelfHost && talkingStickActive}
375
+ onGiveStick={() => onGiveTalkingStick(selfPeerId)}
376
+ />
324
377
  {participants.map((p) => (
325
378
  <Row
326
379
  key={p.uid}
@@ -332,6 +385,9 @@ export default function ParticipantsDrawer({
332
385
  canModerate={isSelfHost}
333
386
  onMute={() => onMute(p.uid)}
334
387
  onKick={() => onKick(p.uid)}
388
+ hasStick={talkingStickActive && talkingStickHolder === p.uid}
389
+ canGiveStick={isSelfHost && talkingStickActive}
390
+ onGiveStick={() => onGiveTalkingStick(p.uid)}
335
391
  />
336
392
  ))}
337
393
  </ul>
@@ -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 };
@@ -7,7 +7,9 @@
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";
13
15
  import type { ScreenTransformState } from "../shared/media/filters/ScreenTransform.js";
@@ -31,10 +33,14 @@ export interface CallControlsProps {
31
33
  onReaction: (emoji: string) => void;
32
34
  viewMode: CallViewMode;
33
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;
34
40
  onLeave: () => void;
35
41
  }
36
42
  /** Bars that rise and fall with the microphone's level (0-5) - a flat, dim set while there is no sound. */
37
43
  export declare function LevelBars({ level }: {
38
44
  level: number;
39
45
  }): 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;
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;
@@ -12,7 +12,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
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";
@@ -28,7 +30,7 @@ const ACTIVE = "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]";
28
30
  export function LevelBars({ level }) {
29
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))) }));
30
32
  }
31
- export default function CallControls({ media, selfName, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, 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, }) {
32
34
  const [openMenu, setOpenMenu] = useState(null);
33
35
  const barRef = useRef(null);
34
36
  const shareDisabled = !isPresenting && !!presentingElsewhereName;
@@ -57,7 +59,7 @@ export default function CallControls({ media, selfName, participants, isPresenti
57
59
  function toggleMenu(menu) {
58
60
  setOpenMenu((prev) => (prev === menu ? null : menu));
59
61
  }
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: () => {
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: () => {
61
63
  onReaction(emoji);
62
64
  setOpenMenu(null);
63
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, {}) })] }));
@@ -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) => {
504
+ 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(", ")] })), 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) => {
445
505
  const stream = remoteStreams[p.uid];
446
506
  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) }))] }));
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. */
@@ -21,6 +21,11 @@
21
21
  * `waitingParticipants`/`onAdmit`/`onDeny`): `_CallView.tsx` polls the list while this drawer is open and the
22
22
  * caller is the host (there is no push signal for a newly filed admission request), so it stays current without
23
23
  * the host needing to close and reopen the drawer.
24
+ *
25
+ * While talking-stick mode is on (`talkingStickActive`), every row - including the host's own - shows a "Give
26
+ * stick" button, host-only, hidden on whichever row currently holds it (`talkingStickHolder`); everyone (not just
27
+ * the host) sees a badge on that row instead, same visibility as the "Host" tag. See `_CallView.tsx`'s doc comment
28
+ * on talking-stick mode for what granting it actually does to the recipient's microphone.
24
29
  */
25
30
  import React from "react";
26
31
  import type { MeshParticipant } from "../shared/webrtc/types.js";
@@ -56,6 +61,17 @@ export interface ParticipantsDrawerProps {
56
61
  waitingParticipants: WaitingParticipant[];
57
62
  onAdmit: (uid: string) => void;
58
63
  onDeny: (uid: string) => void;
64
+ /** Whether talking-stick mode is currently on - the host-only navbar toggle's own state (see `_CallView.tsx`'s
65
+ * doc comment on talking-stick mode). */
66
+ talkingStickActive: boolean;
67
+ /** This tab's own peer id - needed only so the self row's "Give stick" button can name itself when the host
68
+ * wants to take the stick back. */
69
+ selfPeerId: string;
70
+ /** Whichever peer id (`selfPeerId`, or a participant's `MeshParticipant.uid`) currently holds the stick;
71
+ * undefined while `talkingStickActive` is `false`, or (transiently) once its holder has left the call. */
72
+ talkingStickHolder?: string;
73
+ /** Hands the stick to `uid` - host-only, see `_CallView.tsx`'s `handleGiveTalkingStick()`. */
74
+ onGiveTalkingStick: (uid: string) => void;
59
75
  onClose: () => void;
60
76
  }
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;
77
+ 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;
@@ -26,6 +26,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
26
26
  * `waitingParticipants`/`onAdmit`/`onDeny`): `_CallView.tsx` polls the list while this drawer is open and the
27
27
  * caller is the host (there is no push signal for a newly filed admission request), so it stays current without
28
28
  * the host needing to close and reopen the drawer.
29
+ *
30
+ * While talking-stick mode is on (`talkingStickActive`), every row - including the host's own - shows a "Give
31
+ * stick" button, host-only, hidden on whichever row currently holds it (`talkingStickHolder`); everyone (not just
32
+ * the host) sees a badge on that row instead, same visibility as the "Host" tag. See `_CallView.tsx`'s doc comment
33
+ * on talking-stick mode for what granting it actually does to the recipient's microphone.
29
34
  */
30
35
  import { useEffect, useState } from "react";
31
36
  import { TRANSPORT_BADGES } from "./_ParticipantTile.js";
@@ -64,15 +69,15 @@ function PasswordSection({ hasPassword, onSetPassword }) {
64
69
  function WaitingSection({ waitingParticipants, onAdmit, onDeny, }) {
65
70
  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
71
  }
67
- function Row({ name, isSelf, isHost, micMuted, handRaised, transport, canModerate, onMute, onKick, }) {
72
+ function Row({ name, isSelf, isHost, micMuted, handRaised, transport, canModerate, onMute, onKick, hasStick, canGiveStick, onGiveStick, }) {
68
73
  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: () => {
74
+ 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
75
  if (window.confirm(`Remove ${name} from the call?`)) {
71
76
  onKick?.();
72
77
  }
73
78
  }, children: "Remove" }))] }));
74
79
  }
75
- export default function ParticipantsDrawer({ selfName, micOn, handRaised, participants, isSelfHost, isParticipantHost, onMute, onKick, forceMuteOnJoin, onToggleForceMuteOnJoin, hasPassword, onSetPassword, waitingRoomEnabled, onToggleWaitingRoomEnabled, waitingParticipants, onAdmit, onDeny, onClose, }) {
80
+ 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
81
  useEffect(() => {
77
82
  const onKeyDown = (event) => {
78
83
  if (event.key === "Escape") {
@@ -82,5 +87,5 @@ export default function ParticipantsDrawer({ selfName, micOn, handRaised, partic
82
87
  document.addEventListener("keydown", onKeyDown);
83
88
  return () => document.removeEventListener("keydown", onKeyDown);
84
89
  }, [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)))] })] })] }));
90
+ 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, 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
91
  }
@@ -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) {
@@ -21,21 +21,22 @@ export declare const REACTION_EMOJIS: readonly ["👍", "👏", "❤️", "🎉"
21
21
  * literal so a listener on the meeting's channel (which will also see other frame shapes if this server ever
22
22
  * grows other push consumers) can cheaply recognize and skip anything that isn't one of these.
23
23
  *
24
- * `hello`/`bye`/`presenter-claim`/`presenter-release`/`state`/`reaction` are broadcast (`to` left unset - every
25
- * participant is relevant); `offer`/`answer`/`ice-candidate`/`mute-request`/`kicked` are point-to-point (`to` is the
26
- * intended peer's uid) - a recipient that isn't `to` (when set) ignores the message, since a channel's pub/sub fans
27
- * every message out to every subscriber, sender included.
24
+ * `hello`/`bye`/`presenter-claim`/`presenter-release`/`state`/`reaction`/`talking-stick` are broadcast (`to` left
25
+ * unset - every participant is relevant); `offer`/`answer`/`ice-candidate`/`mute-request`/`kicked` are
26
+ * point-to-point (`to` is the intended peer's uid) - a recipient that isn't `to` (when set) ignores the message,
27
+ * since a channel's pub/sub fans every message out to every subscriber, sender included.
28
28
  *
29
- * `mute-request`/`kicked` are host moderation signals (see `MeshConnectionManager`'s doc comment) - purely
30
- * cooperative, like every other message here: the recipient's own client decides whether to honor one, and nothing
31
- * on the wire distinguishes a genuine host's message from any other participant's, since this client protocol has
32
- * no host identity of its own to check against server-side. `kicked` is paired with the sender also revoking the
33
- * recipient's server-side channel grant (`BaseVideoMeetingRoute.revokeChannelGrant()`), which is enforced and does
34
- * not depend on the recipient's client cooperating.
29
+ * `mute-request`/`kicked`/`talking-stick` are host moderation signals (see `MeshConnectionManager`'s doc comment) -
30
+ * purely cooperative, like every other message here: the recipient's own client decides whether to honor one, and
31
+ * nothing on the wire distinguishes a genuine host's message from any other participant's, since this client
32
+ * protocol has no host identity of its own to check against server-side. `kicked` is paired with the sender also
33
+ * revoking the recipient's server-side channel grant (`BaseVideoMeetingRoute.revokeChannelGrant()`), which is
34
+ * enforced and does not depend on the recipient's client cooperating; `talking-stick` has no server-side
35
+ * counterpart at all (see this module's doc comment on why it is purely an in-call runtime signal).
35
36
  */
36
37
  export interface SignalMessage {
37
38
  type: "video-meeting-signal";
38
- kind: "hello" | "bye" | "offer" | "answer" | "ice-candidate" | "presenter-claim" | "presenter-release" | "state" | "reaction" | "relay-fallback" | "mute-request" | "kicked";
39
+ kind: "hello" | "bye" | "offer" | "answer" | "ice-candidate" | "presenter-claim" | "presenter-release" | "state" | "reaction" | "relay-fallback" | "mute-request" | "kicked" | "talking-stick";
39
40
  /** The sender's authenticated uid - the real caller's own uid when `join()` returned `authenticated: true`, else
40
41
  * the `guest:<random>` uid `BaseVideoMeetingRoute.join()` minted. The server refuses a published message whose
41
42
  * `from` isn't the authenticated caller's uid (it stops one participant speaking as another), so this must be
@@ -54,6 +55,11 @@ export interface SignalMessage {
54
55
  state?: ParticipantState;
55
56
  /** `reaction` only - one of `REACTION_EMOJIS`. */
56
57
  emoji?: string;
58
+ /** `talking-stick` only - whether talking-stick mode is currently on. */
59
+ active?: boolean;
60
+ /** `talking-stick` only - the peer id (`MeshParticipant.uid`/this tab's own id) currently holding the stick.
61
+ * Absent when `active` is `false`, or when nobody holds it yet. */
62
+ holder?: string;
57
63
  /** `offer`/`answer` only. */
58
64
  sdp?: RTCSessionDescriptionInit;
59
65
  /** `ice-candidate` only. */
@@ -209,4 +215,12 @@ export type MeshEvent = {
209
215
  * "removed by the host" state rather than the ordinary "you left" one. */
210
216
  | {
211
217
  type: "kicked";
218
+ }
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
+ | {
223
+ type: "talking-stick-changed";
224
+ active: boolean;
225
+ holder?: string;
212
226
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rapidmx/meet-plugin",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "RapidMX plugin that provides WebRTC video conferencing, joinable from a calendar invite or a shared link.",
5
5
  "repository": "https://github.com/rapidmx/meet-plugin.git",
6
6
  "author": "RapidREST <rapidrests@gmail.com>",