@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.
- package/apps/meet/_CallControls.tsx +18 -3
- package/apps/meet/_CallView.tsx +165 -81
- package/apps/meet/_ParticipantsDrawer.tsx +109 -55
- package/apps/shared/webrtc/MeshConnectionManager.ts +26 -0
- package/apps/shared/webrtc/types.ts +23 -12
- package/dist/apps/meet/_CallControls.d.ts +14 -2
- package/dist/apps/meet/_CallControls.js +12 -4
- package/dist/apps/meet/_CallView.d.ts +17 -0
- package/dist/apps/meet/_CallView.js +64 -4
- package/dist/apps/meet/_ParticipantsDrawer.d.ts +21 -3
- package/dist/apps/meet/_ParticipantsDrawer.js +14 -7
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +19 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +25 -0
- package/dist/apps/shared/webrtc/types.d.ts +25 -11
- package/package.json +1 -1
|
@@ -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="
|
|
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>
|
package/apps/meet/_CallView.tsx
CHANGED
|
@@ -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
|
|
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
|
-
<
|
|
610
|
-
<
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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-
|
|
620
|
-
|
|
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
|
-
|
|
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
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
>
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
682
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
{
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
}
|