@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.
@@ -9,9 +9,17 @@
9
9
  * screen, reactions, raise hand, grid/focus and leave. The effects button opens the video filter picker
10
10
  * (`_EffectsPanel.tsx`) and lights up while any filter is on.
11
11
  *
12
+ * The diagnostics button is the one exception to "opens near its own button": its panel is `fixed` to the
13
+ * viewport's top-left corner rather than anchored above the button, since it reads better as a HUD-style overlay
14
+ * sitting over the call than as a dropdown tucked into the bottom control bar. The outside-click/Escape handling
15
+ * (`barRef`) still closes it the same way, since it's still a DOM descendant of the bar - only its visual position
16
+ * moved, not its open/close wiring.
17
+ *
12
18
  * The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
13
19
  * 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`).
20
+ * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
21
+ * is disabled (`micLocked`) while talking-stick mode is on and this participant isn't the current holder - see
22
+ * `_CallView.tsx`'s doc comment.
15
23
  */
16
24
  import React, { useEffect, useRef, useState } from "react";
17
25
  import { filtersActive } from "../shared/media/filters/filterTypes.js";
@@ -57,6 +65,10 @@ export interface CallControlsProps {
57
65
  onReaction: (emoji: string) => void;
58
66
  viewMode: CallViewMode;
59
67
  onToggleViewMode: () => void;
68
+ /** True while talking-stick mode is on and this participant doesn't currently hold it - see `_CallView.tsx`'s
69
+ * doc comment on talking-stick mode. Disables the microphone toggle (not the device-picker chevron, which
70
+ * doesn't change whether anyone can hear them) rather than merely nudging it, unlike a `mute-request`. */
71
+ micLocked?: boolean;
60
72
  onLeave: () => void;
61
73
  }
62
74
 
@@ -97,6 +109,7 @@ export default function CallControls({
97
109
  onReaction,
98
110
  viewMode,
99
111
  onToggleViewMode,
112
+ micLocked,
100
113
  onLeave,
101
114
  }: CallControlsProps) {
102
115
  const [openMenu, setOpenMenu] = useState<OpenMenu>(null);
@@ -145,9 +158,11 @@ export default function CallControls({
145
158
  </button>
146
159
  <button
147
160
  type="button"
148
- className={`${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT}`}
161
+ 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
162
  aria-label={media.micOn ? "Mute microphone" : "Unmute microphone"}
150
163
  aria-pressed={!media.micOn}
164
+ disabled={micLocked}
165
+ title={micLocked ? "Only the current talking-stick holder can unmute." : undefined}
151
166
  onClick={() => void media.toggleMic()}
152
167
  >
153
168
  {media.micOn && <LevelBars level={media.audioLevel} />}
@@ -313,7 +328,7 @@ export default function CallControls({
313
328
  <div
314
329
  role="dialog"
315
330
  aria-label="Call diagnostics"
316
- 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"
331
+ 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"
317
332
  >
318
333
  <DiagnosticsPanel selfName={selfName} micOn={media.micOn} cameraOn={media.cameraOn} participants={participants} />
319
334
  </div>
@@ -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,
@@ -604,97 +666,115 @@ export default function CallView({
604
666
  }
605
667
 
606
668
  return (
607
- <div className="fixed inset-0 z-50 flex flex-col bg-[#202124] text-white overflow-hidden">
669
+ <div className="fixed inset-0 z-50 flex bg-[#202124] text-white overflow-hidden">
608
670
  <style>{`@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; } }`}</style>
609
- <header className="shrink-0 flex items-center justify-between gap-3 px-4 py-3">
610
- <h1 className="min-w-0 truncate text-base font-medium">{meetingTitle}</h1>
611
- <div className="flex items-center gap-2 shrink-0">
612
- {raisedNames.length > 0 && (
613
- <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">
614
- ✋ {raisedNames.join(", ")}
615
- </span>
616
- )}
671
+ <div className="relative flex-1 min-w-0 flex flex-col overflow-hidden">
672
+ <header className="shrink-0 flex items-center justify-between gap-3 px-4 py-3">
673
+ <h1 className="min-w-0 truncate text-base font-medium">{meetingTitle}</h1>
674
+ <div className="flex items-center gap-2 shrink-0">
675
+ {raisedNames.length > 0 && (
676
+ <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">
677
+ ✋ {raisedNames.join(", ")}
678
+ </span>
679
+ )}
680
+ {talkingStickActive && (
681
+ <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">
682
+ 🎙️ {talkingStickHolderName ? `${talkingStickHolderName} ${talkingStickHolderName === "You" ? "have" : "has"} the floor` : "Waiting for the host to choose a speaker"}
683
+ </span>
684
+ )}
685
+ {isHost && (
686
+ <button
687
+ type="button"
688
+ 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]"}`}
689
+ aria-pressed={talkingStickActive}
690
+ onClick={handleToggleTalkingStick}
691
+ >
692
+ {talkingStickActive ? "End talking stick" : "Talking stick"}
693
+ </button>
694
+ )}
695
+ <button
696
+ type="button"
697
+ 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"
698
+ aria-label={`${participants.length + 1} participants`}
699
+ aria-haspopup="dialog"
700
+ aria-expanded={drawerOpen}
701
+ onClick={() => setDrawerOpen((prev) => !prev)}
702
+ >
703
+ {participants.length + 1}
704
+ </button>
705
+ </div>
706
+ </header>
707
+ {connectError && (
708
+ <div role="alert" className="shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#601410] text-[#f9dedc] text-sm">
709
+ {connectError}
710
+ </div>
711
+ )}
712
+ {audioBlocked && (
617
713
  <button
618
714
  type="button"
619
- 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"
620
- aria-label={`${participants.length + 1} participants`}
621
- aria-haspopup="dialog"
622
- aria-expanded={drawerOpen}
623
- onClick={() => setDrawerOpen((prev) => !prev)}
715
+ className="shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#a8c7fa] text-[#062e6f] text-sm font-medium"
716
+ onClick={handleResumeAudio}
624
717
  >
625
- {participants.length + 1}
718
+ Click here to turn on sound
626
719
  </button>
627
- </div>
628
- </header>
629
- {connectError && (
630
- <div role="alert" className="shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#601410] text-[#f9dedc] text-sm">
631
- {connectError}
632
- </div>
633
- )}
634
- {audioBlocked && (
635
- <button
636
- type="button"
637
- className="shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#a8c7fa] text-[#062e6f] text-sm font-medium"
638
- onClick={handleResumeAudio}
639
- >
640
- Click here to turn on sound
641
- </button>
642
- )}
643
- <main className="relative flex-1 min-h-0 px-3 pb-3">
644
- {stage}
645
- {presenterName && presenterUid !== peerId && (
646
- <p className="absolute top-2 left-5 px-2 py-0.5 rounded bg-black/60 text-sm">{presenterName} is presenting</p>
647
720
  )}
648
- </main>
649
- <footer className="shrink-0 pt-1 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
650
- <CallControls
651
- media={media}
652
- selfName={selfName}
653
- participants={participants}
654
- isPresenting={isPresenting}
655
- presentingElsewhereName={presenterUid && presenterUid !== peerId ? presenterName : undefined}
656
- onToggleShare={() => void handleToggleShare()}
657
- screenTransform={screenTransform}
658
- onRotateScreen={handleRotateScreen}
659
- onFlipScreen={handleFlipScreen}
660
- handRaised={handRaised}
661
- onToggleHand={handleToggleHand}
662
- onReaction={handleReaction}
663
- viewMode={viewMode}
664
- onToggleViewMode={() => setViewMode((prev) => (prev === "grid" ? "focus" : "grid"))}
665
- onLeave={onLeave}
666
- />
667
- </footer>
668
-
669
- {!alone && (
670
- <div
671
- 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"
672
- data-testid="self-view"
673
- >
674
- {selfTile("h-full")}
675
- </div>
676
- )}
677
-
678
- <div className="pointer-events-none absolute left-4 bottom-28 w-40 h-[45vh]" aria-hidden="true">
679
- {reactions.map((reaction) => (
721
+ <main className="relative flex-1 min-h-0 px-3 pb-3">
722
+ {stage}
723
+ {presenterName && presenterUid !== peerId && (
724
+ <p className="absolute top-2 left-5 px-2 py-0.5 rounded bg-black/60 text-sm">{presenterName} is presenting</p>
725
+ )}
726
+ </main>
727
+ <footer className="shrink-0 pt-1 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
728
+ <CallControls
729
+ media={media}
730
+ selfName={selfName}
731
+ participants={participants}
732
+ isPresenting={isPresenting}
733
+ presentingElsewhereName={presenterUid && presenterUid !== peerId ? presenterName : undefined}
734
+ onToggleShare={() => void handleToggleShare()}
735
+ screenTransform={screenTransform}
736
+ onRotateScreen={handleRotateScreen}
737
+ onFlipScreen={handleFlipScreen}
738
+ handRaised={handRaised}
739
+ onToggleHand={handleToggleHand}
740
+ onReaction={handleReaction}
741
+ viewMode={viewMode}
742
+ onToggleViewMode={() => setViewMode((prev) => (prev === "grid" ? "focus" : "grid"))}
743
+ micLocked={micLocked}
744
+ onLeave={onLeave}
745
+ />
746
+ </footer>
747
+
748
+ {!alone && (
680
749
  <div
681
- key={reaction.id}
682
- className="absolute bottom-0 flex flex-col items-center"
683
- style={{ left: `${(reaction.id % 4) * 36}px`, animation: `meet-float ${REACTION_MS}ms ease-out forwards` }}
684
- data-testid="reaction"
750
+ 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"
751
+ data-testid="self-view"
685
752
  >
686
- <span className="text-4xl leading-none">{reaction.emoji}</span>
687
- <span className="mt-1 px-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-xs">{reaction.name}</span>
753
+ {selfTile("h-full")}
688
754
  </div>
689
- ))}
690
- </div>
755
+ )}
756
+
757
+ <div className="pointer-events-none absolute left-4 bottom-28 w-40 h-[45vh]" aria-hidden="true">
758
+ {reactions.map((reaction) => (
759
+ <div
760
+ key={reaction.id}
761
+ className="absolute bottom-0 flex flex-col items-center"
762
+ style={{ left: `${(reaction.id % 4) * 36}px`, animation: `meet-float ${REACTION_MS}ms ease-out forwards` }}
763
+ data-testid="reaction"
764
+ >
765
+ <span className="text-4xl leading-none">{reaction.emoji}</span>
766
+ <span className="mt-1 px-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-xs">{reaction.name}</span>
767
+ </div>
768
+ ))}
769
+ </div>
691
770
 
692
- {participants.map((p) => {
693
- const stream = remoteStreams[p.uid];
694
- return stream ? <RemoteAudio key={`${p.uid}:${audioNonce}`} stream={stream} onBlocked={handleAudioBlocked} /> : null;
695
- })}
696
- <div role="status" aria-live="polite" className="sr-only">
697
- {announcement}
771
+ {participants.map((p) => {
772
+ const stream = remoteStreams[p.uid];
773
+ return stream ? <RemoteAudio key={`${p.uid}:${audioNonce}`} stream={stream} onBlocked={handleAudioBlocked} /> : null;
774
+ })}
775
+ <div role="status" aria-live="polite" className="sr-only">
776
+ {announcement}
777
+ </div>
698
778
  </div>
699
779
 
700
780
  {drawerOpen && (
@@ -716,6 +796,10 @@ export default function CallView({
716
796
  waitingParticipants={waitingParticipants}
717
797
  onAdmit={handleAdmitParticipant}
718
798
  onDeny={handleDenyParticipant}
799
+ talkingStickActive={talkingStickActive}
800
+ selfPeerId={peerId}
801
+ talkingStickHolder={talkingStickHolder}
802
+ onGiveTalkingStick={handleGiveTalkingStick}
719
803
  onClose={() => setDrawerOpen(false)}
720
804
  />
721
805
  )}
@@ -8,8 +8,10 @@
8
8
  * shows on a tile, as plain rows instead (a list reads better than tiles once there's a reason to scan for one
9
9
  * name).
10
10
  *
11
- * Closes on Escape, on a click on the backdrop behind it, or its own close button - never on a click inside the
12
- * drawer itself, matching `_CallControls.tsx`'s menus.
11
+ * A docked sidebar, not an overlay: `_CallView.tsx` renders it as an ordinary flex sibling of the call's own
12
+ * content column, which shrinks to make room rather than being covered by a backdrop - meant to stay open and
13
+ * visible for as long as the participant wants, including while the call continues underneath. Closes on Escape or
14
+ * its own close button; there is no backdrop to click, on purpose.
13
15
  *
14
16
  * A "Host" tag marks the host's own row (`isSelfHost`/`isParticipantHost` - see `_CallView.tsx`'s doc comment on
15
17
  * `hostUid` for what that identity is and isn't). Only while `isSelfHost` does every *other* row also get a "Mute"
@@ -25,6 +27,11 @@
25
27
  * `waitingParticipants`/`onAdmit`/`onDeny`): `_CallView.tsx` polls the list while this drawer is open and the
26
28
  * caller is the host (there is no push signal for a newly filed admission request), so it stays current without
27
29
  * the host needing to close and reopen the drawer.
30
+ *
31
+ * While talking-stick mode is on (`talkingStickActive`), every row - including the host's own - shows a "Give
32
+ * stick" button, host-only, hidden on whichever row currently holds it (`talkingStickHolder`); everyone (not just
33
+ * the host) sees a badge on that row instead, same visibility as the "Host" tag. See `_CallView.tsx`'s doc comment
34
+ * on talking-stick mode for what granting it actually does to the recipient's microphone.
28
35
  */
29
36
  import React, { useEffect, useState } from "react";
30
37
  import type { MeshParticipant } from "../shared/webrtc/types.js";
@@ -63,6 +70,17 @@ export interface ParticipantsDrawerProps {
63
70
  waitingParticipants: WaitingParticipant[];
64
71
  onAdmit: (uid: string) => void;
65
72
  onDeny: (uid: string) => void;
73
+ /** Whether talking-stick mode is currently on - the host-only navbar toggle's own state (see `_CallView.tsx`'s
74
+ * doc comment on talking-stick mode). */
75
+ talkingStickActive: boolean;
76
+ /** This tab's own peer id - needed only so the self row's "Give stick" button can name itself when the host
77
+ * wants to take the stick back. */
78
+ selfPeerId: string;
79
+ /** Whichever peer id (`selfPeerId`, or a participant's `MeshParticipant.uid`) currently holds the stick;
80
+ * undefined while `talkingStickActive` is `false`, or (transiently) once its holder has left the call. */
81
+ talkingStickHolder?: string;
82
+ /** Hands the stick to `uid` - host-only, see `_CallView.tsx`'s `handleGiveTalkingStick()`. */
83
+ onGiveTalkingStick: (uid: string) => void;
66
84
  onClose: () => void;
67
85
  }
68
86
 
@@ -187,6 +205,9 @@ function Row({
187
205
  canModerate,
188
206
  onMute,
189
207
  onKick,
208
+ hasStick,
209
+ canGiveStick,
210
+ onGiveStick,
190
211
  }: {
191
212
  name: string;
192
213
  isSelf?: boolean;
@@ -198,6 +219,12 @@ function Row({
198
219
  canModerate?: boolean;
199
220
  onMute?: () => void;
200
221
  onKick?: () => void;
222
+ /** Whether this row currently holds the talking stick - shown to every viewer, not just the host. */
223
+ hasStick?: boolean;
224
+ /** Whether to show this row's "Give stick" button - `isSelfHost && talkingStickActive`, decided by the caller;
225
+ * never shown on the row that already holds it. */
226
+ canGiveStick?: boolean;
227
+ onGiveStick?: () => void;
201
228
  }) {
202
229
  const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
203
230
  return (
@@ -207,6 +234,11 @@ function Row({
207
234
  {isSelf ? " (you)" : ""}
208
235
  </span>
209
236
  {isHost && <span className="px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f]">Host</span>}
237
+ {hasStick && (
238
+ <span className="px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f]" role="img" aria-label="Holding the talking stick">
239
+ 🎙️
240
+ </span>
241
+ )}
210
242
  {handRaised && (
211
243
  <span role="img" aria-label="Hand raised">
212
244
  ✋
@@ -225,6 +257,16 @@ function Row({
225
257
  {badge.label}
226
258
  </span>
227
259
  )}
260
+ {canGiveStick && !hasStick && (
261
+ <button
262
+ type="button"
263
+ 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"
264
+ aria-label={`Give the talking stick to ${name}`}
265
+ onClick={onGiveStick}
266
+ >
267
+ Give stick
268
+ </button>
269
+ )}
228
270
  {canModerate && !micMuted && (
229
271
  <button
230
272
  type="button"
@@ -271,6 +313,10 @@ export default function ParticipantsDrawer({
271
313
  waitingParticipants,
272
314
  onAdmit,
273
315
  onDeny,
316
+ talkingStickActive,
317
+ selfPeerId,
318
+ talkingStickHolder,
319
+ onGiveTalkingStick,
274
320
  onClose,
275
321
  }: ParticipantsDrawerProps) {
276
322
  useEffect(() => {
@@ -284,58 +330,66 @@ export default function ParticipantsDrawer({
284
330
  }, [onClose]);
285
331
 
286
332
  return (
287
- <>
288
- <div className="absolute inset-0 z-20 bg-black/40" onClick={onClose} aria-hidden="true" />
289
- <aside
290
- role="dialog"
291
- aria-label="Participants"
292
- className="absolute z-20 inset-y-0 right-0 w-72 max-w-[85vw] flex flex-col bg-[#2b2d30] text-white shadow-xl"
293
- onClick={(event) => event.stopPropagation()}
294
- >
295
- <div className="flex items-center justify-between gap-2 px-3 py-3 border-b border-white/10">
296
- <h2 className="text-sm font-medium">Participants ({participants.length + 1})</h2>
297
- <button
298
- type="button"
299
- 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"
300
- aria-label="Close participants"
301
- onClick={onClose}
302
- >
303
- ✕
304
- </button>
305
- </div>
306
- {isSelfHost && (
307
- <label className="flex items-center gap-2 px-3 py-2 border-b border-white/10 text-sm">
308
- <input type="checkbox" checked={forceMuteOnJoin} onChange={onToggleForceMuteOnJoin} className="w-4 h-4" />
309
- Mute new participants on join
310
- </label>
311
- )}
312
- {isSelfHost && <PasswordSection hasPassword={hasPassword} onSetPassword={onSetPassword} />}
313
- {isSelfHost && (
314
- <label className="flex items-center gap-2 px-3 py-2 border-b border-white/10 text-sm">
315
- <input type="checkbox" checked={waitingRoomEnabled} onChange={onToggleWaitingRoomEnabled} className="w-4 h-4" />
316
- Require the host to admit participants
317
- </label>
318
- )}
319
- {isSelfHost && waitingRoomEnabled && (
320
- <WaitingSection waitingParticipants={waitingParticipants} onAdmit={onAdmit} onDeny={onDeny} />
321
- )}
322
- <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} />
324
- {participants.map((p) => (
325
- <Row
326
- key={p.uid}
327
- name={p.name}
328
- isHost={isParticipantHost(p.uid)}
329
- micMuted={!p.audioOn}
330
- handRaised={p.handRaised}
331
- transport={p.transport}
332
- canModerate={isSelfHost}
333
- onMute={() => onMute(p.uid)}
334
- onKick={() => onKick(p.uid)}
335
- />
336
- ))}
337
- </ul>
338
- </aside>
339
- </>
333
+ <aside
334
+ role="dialog"
335
+ aria-label="Participants"
336
+ 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"
337
+ >
338
+ <div className="flex items-center justify-between gap-2 px-3 py-3 border-b border-white/10">
339
+ <h2 className="text-sm font-medium">Participants ({participants.length + 1})</h2>
340
+ <button
341
+ type="button"
342
+ 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"
343
+ aria-label="Close participants"
344
+ onClick={onClose}
345
+ >
346
+ ✕
347
+ </button>
348
+ </div>
349
+ {isSelfHost && (
350
+ <label className="flex items-center gap-2 px-3 py-2 border-b border-white/10 text-sm">
351
+ <input type="checkbox" checked={forceMuteOnJoin} onChange={onToggleForceMuteOnJoin} className="w-4 h-4" />
352
+ Mute new participants on join
353
+ </label>
354
+ )}
355
+ {isSelfHost && <PasswordSection hasPassword={hasPassword} onSetPassword={onSetPassword} />}
356
+ {isSelfHost && (
357
+ <label className="flex items-center gap-2 px-3 py-2 border-b border-white/10 text-sm">
358
+ <input type="checkbox" checked={waitingRoomEnabled} onChange={onToggleWaitingRoomEnabled} className="w-4 h-4" />
359
+ Require the host to admit participants
360
+ </label>
361
+ )}
362
+ {isSelfHost && waitingRoomEnabled && (
363
+ <WaitingSection waitingParticipants={waitingParticipants} onAdmit={onAdmit} onDeny={onDeny} />
364
+ )}
365
+ <ul className="flex-1 min-h-0 overflow-y-auto p-2 text-sm">
366
+ <Row
367
+ name={selfName}
368
+ isSelf
369
+ isHost={isSelfHost}
370
+ micMuted={!micOn}
371
+ handRaised={handRaised}
372
+ hasStick={talkingStickActive && talkingStickHolder === selfPeerId}
373
+ canGiveStick={isSelfHost && talkingStickActive}
374
+ onGiveStick={() => onGiveTalkingStick(selfPeerId)}
375
+ />
376
+ {participants.map((p) => (
377
+ <Row
378
+ key={p.uid}
379
+ name={p.name}
380
+ isHost={isParticipantHost(p.uid)}
381
+ micMuted={!p.audioOn}
382
+ handRaised={p.handRaised}
383
+ transport={p.transport}
384
+ canModerate={isSelfHost}
385
+ onMute={() => onMute(p.uid)}
386
+ onKick={() => onKick(p.uid)}
387
+ hasStick={talkingStickActive && talkingStickHolder === p.uid}
388
+ canGiveStick={isSelfHost && talkingStickActive}
389
+ onGiveStick={() => onGiveTalkingStick(p.uid)}
390
+ />
391
+ ))}
392
+ </ul>
393
+ </aside>
340
394
  );
341
395
  }