@rapidmx/meet-plugin 0.11.0 → 0.12.1

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,6 +9,12 @@
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
20
  * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
@@ -322,7 +328,7 @@ export default function CallControls({
322
328
  <div
323
329
  role="dialog"
324
330
  aria-label="Call diagnostics"
325
- 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"
326
332
  >
327
333
  <DiagnosticsPanel selfName={selfName} micOn={media.micOn} cameraOn={media.cameraOn} participants={participants} />
328
334
  </div>
@@ -64,6 +64,7 @@ import type { MeshParticipant, RelayTransportLike } from "../shared/webrtc/types
64
64
  import { createRelayTransport } from "../shared/relay/RelayTransport.js";
65
65
  import { GuestSignalingClient } from "../shared/push/GuestSignalingClient.js";
66
66
  import CallControls, { type CallViewMode } from "./_CallControls.js";
67
+ import { BatonIcon } from "./_icons.js";
67
68
  import {
68
69
  admitParticipant,
69
70
  denyParticipant,
@@ -666,113 +667,117 @@ export default function CallView({
666
667
  }
667
668
 
668
669
  return (
669
- <div className="fixed inset-0 z-50 flex flex-col bg-[#202124] text-white overflow-hidden">
670
+ <div className="fixed inset-0 z-50 flex bg-[#202124] text-white overflow-hidden">
670
671
  <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>
671
- <header className="shrink-0 flex items-center justify-between gap-3 px-4 py-3">
672
- <h1 className="min-w-0 truncate text-base font-medium">{meetingTitle}</h1>
673
- <div className="flex items-center gap-2 shrink-0">
674
- {raisedNames.length > 0 && (
675
- <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">
676
- ✋ {raisedNames.join(", ")}
677
- </span>
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 && (
672
+ <div className="relative flex-1 min-w-0 flex flex-col overflow-hidden">
673
+ <header className="shrink-0 flex items-center justify-between gap-3 px-4 py-3">
674
+ <h1 className="min-w-0 truncate text-base font-medium">{meetingTitle}</h1>
675
+ <div className="flex items-center gap-2 shrink-0">
676
+ {raisedNames.length > 0 && (
677
+ <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">
678
+ ✋ {raisedNames.join(", ")}
679
+ </span>
680
+ )}
681
+ {talkingStickActive && (
682
+ <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">
683
+ 🎙️ {talkingStickHolderName ? `${talkingStickHolderName} ${talkingStickHolderName === "You" ? "have" : "has"} the floor` : "Waiting for the host to choose a speaker"}
684
+ </span>
685
+ )}
686
+ {isHost && (
687
+ <button
688
+ type="button"
689
+ className={`w-9 h-9 flex items-center justify-center rounded-full 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]"}`}
690
+ aria-label={talkingStickActive ? "End talking stick" : "Talking stick"}
691
+ aria-pressed={talkingStickActive}
692
+ title={talkingStickActive ? "End talking stick" : "Start talking stick mode"}
693
+ onClick={handleToggleTalkingStick}
694
+ >
695
+ <BatonIcon />
696
+ </button>
697
+ )}
685
698
  <button
686
699
  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}
700
+ 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"
701
+ aria-label={`${participants.length + 1} participants`}
702
+ aria-haspopup="dialog"
703
+ aria-expanded={drawerOpen}
704
+ onClick={() => setDrawerOpen((prev) => !prev)}
690
705
  >
691
- {talkingStickActive ? "End talking stick" : "Talking stick"}
706
+ {participants.length + 1}
692
707
  </button>
693
- )}
708
+ </div>
709
+ </header>
710
+ {connectError && (
711
+ <div role="alert" className="shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#601410] text-[#f9dedc] text-sm">
712
+ {connectError}
713
+ </div>
714
+ )}
715
+ {audioBlocked && (
694
716
  <button
695
717
  type="button"
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"
697
- aria-label={`${participants.length + 1} participants`}
698
- aria-haspopup="dialog"
699
- aria-expanded={drawerOpen}
700
- onClick={() => setDrawerOpen((prev) => !prev)}
718
+ className="shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#a8c7fa] text-[#062e6f] text-sm font-medium"
719
+ onClick={handleResumeAudio}
701
720
  >
702
- {participants.length + 1}
721
+ Click here to turn on sound
703
722
  </button>
704
- </div>
705
- </header>
706
- {connectError && (
707
- <div role="alert" className="shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#601410] text-[#f9dedc] text-sm">
708
- {connectError}
709
- </div>
710
- )}
711
- {audioBlocked && (
712
- <button
713
- type="button"
714
- className="shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#a8c7fa] text-[#062e6f] text-sm font-medium"
715
- onClick={handleResumeAudio}
716
- >
717
- Click here to turn on sound
718
- </button>
719
- )}
720
- <main className="relative flex-1 min-h-0 px-3 pb-3">
721
- {stage}
722
- {presenterName && presenterUid !== peerId && (
723
- <p className="absolute top-2 left-5 px-2 py-0.5 rounded bg-black/60 text-sm">{presenterName} is presenting</p>
724
723
  )}
725
- </main>
726
- <footer className="shrink-0 pt-1 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
727
- <CallControls
728
- media={media}
729
- selfName={selfName}
730
- participants={participants}
731
- isPresenting={isPresenting}
732
- presentingElsewhereName={presenterUid && presenterUid !== peerId ? presenterName : undefined}
733
- onToggleShare={() => void handleToggleShare()}
734
- screenTransform={screenTransform}
735
- onRotateScreen={handleRotateScreen}
736
- onFlipScreen={handleFlipScreen}
737
- handRaised={handRaised}
738
- onToggleHand={handleToggleHand}
739
- onReaction={handleReaction}
740
- viewMode={viewMode}
741
- onToggleViewMode={() => setViewMode((prev) => (prev === "grid" ? "focus" : "grid"))}
742
- micLocked={micLocked}
743
- onLeave={onLeave}
744
- />
745
- </footer>
746
-
747
- {!alone && (
748
- <div
749
- 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"
750
- data-testid="self-view"
751
- >
752
- {selfTile("h-full")}
753
- </div>
754
- )}
755
-
756
- <div className="pointer-events-none absolute left-4 bottom-28 w-40 h-[45vh]" aria-hidden="true">
757
- {reactions.map((reaction) => (
724
+ <main className="relative flex-1 min-h-0 px-3 pb-3">
725
+ {stage}
726
+ {presenterName && presenterUid !== peerId && (
727
+ <p className="absolute top-2 left-5 px-2 py-0.5 rounded bg-black/60 text-sm">{presenterName} is presenting</p>
728
+ )}
729
+ </main>
730
+ <footer className="shrink-0 pt-1 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
731
+ <CallControls
732
+ media={media}
733
+ selfName={selfName}
734
+ participants={participants}
735
+ isPresenting={isPresenting}
736
+ presentingElsewhereName={presenterUid && presenterUid !== peerId ? presenterName : undefined}
737
+ onToggleShare={() => void handleToggleShare()}
738
+ screenTransform={screenTransform}
739
+ onRotateScreen={handleRotateScreen}
740
+ onFlipScreen={handleFlipScreen}
741
+ handRaised={handRaised}
742
+ onToggleHand={handleToggleHand}
743
+ onReaction={handleReaction}
744
+ viewMode={viewMode}
745
+ onToggleViewMode={() => setViewMode((prev) => (prev === "grid" ? "focus" : "grid"))}
746
+ micLocked={micLocked}
747
+ onLeave={onLeave}
748
+ />
749
+ </footer>
750
+
751
+ {!alone && (
758
752
  <div
759
- key={reaction.id}
760
- className="absolute bottom-0 flex flex-col items-center"
761
- style={{ left: `${(reaction.id % 4) * 36}px`, animation: `meet-float ${REACTION_MS}ms ease-out forwards` }}
762
- data-testid="reaction"
753
+ 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"
754
+ data-testid="self-view"
763
755
  >
764
- <span className="text-4xl leading-none">{reaction.emoji}</span>
765
- <span className="mt-1 px-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-xs">{reaction.name}</span>
756
+ {selfTile("h-full")}
766
757
  </div>
767
- ))}
768
- </div>
758
+ )}
759
+
760
+ <div className="pointer-events-none absolute left-4 bottom-28 w-40 h-[45vh]" aria-hidden="true">
761
+ {reactions.map((reaction) => (
762
+ <div
763
+ key={reaction.id}
764
+ className="absolute bottom-0 flex flex-col items-center"
765
+ style={{ left: `${(reaction.id % 4) * 36}px`, animation: `meet-float ${REACTION_MS}ms ease-out forwards` }}
766
+ data-testid="reaction"
767
+ >
768
+ <span className="text-4xl leading-none">{reaction.emoji}</span>
769
+ <span className="mt-1 px-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-xs">{reaction.name}</span>
770
+ </div>
771
+ ))}
772
+ </div>
769
773
 
770
- {participants.map((p) => {
771
- const stream = remoteStreams[p.uid];
772
- return stream ? <RemoteAudio key={`${p.uid}:${audioNonce}`} stream={stream} onBlocked={handleAudioBlocked} /> : null;
773
- })}
774
- <div role="status" aria-live="polite" className="sr-only">
775
- {announcement}
774
+ {participants.map((p) => {
775
+ const stream = remoteStreams[p.uid];
776
+ return stream ? <RemoteAudio key={`${p.uid}:${audioNonce}`} stream={stream} onBlocked={handleAudioBlocked} /> : null;
777
+ })}
778
+ <div role="status" aria-live="polite" className="sr-only">
779
+ {announcement}
780
+ </div>
776
781
  </div>
777
782
 
778
783
  {drawerOpen && (
@@ -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"
@@ -328,70 +330,66 @@ export default function ParticipantsDrawer({
328
330
  }, [onClose]);
329
331
 
330
332
  return (
331
- <>
332
- <div className="absolute inset-0 z-20 bg-black/40" onClick={onClose} aria-hidden="true" />
333
- <aside
334
- role="dialog"
335
- aria-label="Participants"
336
- className="absolute z-20 inset-y-0 right-0 w-72 max-w-[85vw] flex flex-col bg-[#2b2d30] text-white shadow-xl"
337
- onClick={(event) => event.stopPropagation()}
338
- >
339
- <div className="flex items-center justify-between gap-2 px-3 py-3 border-b border-white/10">
340
- <h2 className="text-sm font-medium">Participants ({participants.length + 1})</h2>
341
- <button
342
- type="button"
343
- 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"
344
- aria-label="Close participants"
345
- onClick={onClose}
346
- >
347
- ✕
348
- </button>
349
- </div>
350
- {isSelfHost && (
351
- <label className="flex items-center gap-2 px-3 py-2 border-b border-white/10 text-sm">
352
- <input type="checkbox" checked={forceMuteOnJoin} onChange={onToggleForceMuteOnJoin} className="w-4 h-4" />
353
- Mute new participants on join
354
- </label>
355
- )}
356
- {isSelfHost && <PasswordSection hasPassword={hasPassword} onSetPassword={onSetPassword} />}
357
- {isSelfHost && (
358
- <label className="flex items-center gap-2 px-3 py-2 border-b border-white/10 text-sm">
359
- <input type="checkbox" checked={waitingRoomEnabled} onChange={onToggleWaitingRoomEnabled} className="w-4 h-4" />
360
- Require the host to admit participants
361
- </label>
362
- )}
363
- {isSelfHost && waitingRoomEnabled && (
364
- <WaitingSection waitingParticipants={waitingParticipants} onAdmit={onAdmit} onDeny={onDeny} />
365
- )}
366
- <ul className="flex-1 min-h-0 overflow-y-auto p-2 text-sm">
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) => (
367
377
  <Row
368
- name={selfName}
369
- isSelf
370
- isHost={isSelfHost}
371
- micMuted={!micOn}
372
- handRaised={handRaised}
373
- hasStick={talkingStickActive && talkingStickHolder === selfPeerId}
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}
374
388
  canGiveStick={isSelfHost && talkingStickActive}
375
- onGiveStick={() => onGiveTalkingStick(selfPeerId)}
389
+ onGiveStick={() => onGiveTalkingStick(p.uid)}
376
390
  />
377
- {participants.map((p) => (
378
- <Row
379
- key={p.uid}
380
- name={p.name}
381
- isHost={isParticipantHost(p.uid)}
382
- micMuted={!p.audioOn}
383
- handRaised={p.handRaised}
384
- transport={p.transport}
385
- canModerate={isSelfHost}
386
- onMute={() => onMute(p.uid)}
387
- onKick={() => onKick(p.uid)}
388
- hasStick={talkingStickActive && talkingStickHolder === p.uid}
389
- canGiveStick={isSelfHost && talkingStickActive}
390
- onGiveStick={() => onGiveTalkingStick(p.uid)}
391
- />
392
- ))}
393
- </ul>
394
- </aside>
395
- </>
391
+ ))}
392
+ </ul>
393
+ </aside>
396
394
  );
397
395
  }
@@ -68,3 +68,15 @@ export const FlipIcon = () => (
68
68
  export const DiagnosticsIcon = () => (
69
69
  <Icon path="M22 12h-4l-3 9L9 3l-3 9H2v2h5.6l1.4-4.2L13.4 21 17 12h5v-2z" />
70
70
  );
71
+
72
+ /** A talking stick: a rounded shaft with a grip knob at one end, drawn (not a Material path - there isn't one for
73
+ * this) as a diagonal bar with a circle, rather than reusing `HandIcon`'s emoji-glyph fallback, since this one
74
+ * needs to light up (`currentColor`) the same way every other control icon here does. */
75
+ export const BatonIcon = () => (
76
+ <svg viewBox="0 0 24 24" className="w-6 h-6" fill="currentColor" aria-hidden="true" focusable="false">
77
+ <g transform="rotate(45 12 12)">
78
+ <rect x="5" y="10.5" width="13" height="3" rx="1.5" />
79
+ <circle cx="19" cy="12" r="3" />
80
+ </g>
81
+ </svg>
82
+ );
@@ -5,6 +5,12 @@
5
5
  * screen, reactions, raise hand, grid/focus and leave. The effects button opens the video filter picker
6
6
  * (`_EffectsPanel.tsx`) and lights up while any filter is on.
7
7
  *
8
+ * The diagnostics button is the one exception to "opens near its own button": its panel is `fixed` to the
9
+ * viewport's top-left corner rather than anchored above the button, since it reads better as a HUD-style overlay
10
+ * sitting over the call than as a dropdown tucked into the bottom control bar. The outside-click/Escape handling
11
+ * (`barRef`) still closes it the same way, since it's still a DOM descendant of the bar - only its visual position
12
+ * moved, not its open/close wiring.
13
+ *
8
14
  * The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
9
15
  * picking up, so a participant can see their audio is being sent - and the camera button shows a green dot while a
10
16
  * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
@@ -10,6 +10,12 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
10
10
  * screen, reactions, raise hand, grid/focus and leave. The effects button opens the video filter picker
11
11
  * (`_EffectsPanel.tsx`) and lights up while any filter is on.
12
12
  *
13
+ * The diagnostics button is the one exception to "opens near its own button": its panel is `fixed` to the
14
+ * viewport's top-left corner rather than anchored above the button, since it reads better as a HUD-style overlay
15
+ * sitting over the call than as a dropdown tucked into the bottom control bar. The outside-click/Escape handling
16
+ * (`barRef`) still closes it the same way, since it's still a DOM descendant of the bar - only its visual position
17
+ * moved, not its open/close wiring.
18
+ *
13
19
  * The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
14
20
  * picking up, so a participant can see their audio is being sent - and the camera button shows a green dot while a
15
21
  * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
@@ -62,7 +68,7 @@ export default function CallControls({ media, selfName, participants, isPresenti
62
68
  return (_jsxs("div", { ref: barRef, className: "relative flex flex-wrap items-center justify-center gap-2 px-3", role: "toolbar", "aria-label": "Call controls", children: [_jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": "Choose microphone", "aria-haspopup": "menu", "aria-expanded": openMenu === "audio", onClick: () => toggleMenu("audio"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT} disabled:opacity-50 disabled:cursor-not-allowed`, "aria-label": media.micOn ? "Mute microphone" : "Unmute microphone", "aria-pressed": !media.micOn, disabled: micLocked, title: micLocked ? "Only the current talking-stick holder can unmute." : undefined, onClick: () => void media.toggleMic(), children: [media.micOn && _jsx(LevelBars, { level: media.audioLevel }), media.micOn ? _jsx(MicIcon, {}) : _jsx(MicOffIcon, {})] }), openMenu === "audio" && _jsx(DeviceMenu, { kind: "audio", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": "Choose camera", "aria-haspopup": "menu", "aria-expanded": openMenu === "video", onClick: () => toggleMenu("video"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} relative min-w-14 px-3 rounded-l-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": media.cameraOn ? "Turn off camera" : "Turn on camera", "aria-pressed": !media.cameraOn, onClick: () => void media.toggleCamera(), children: [media.cameraOn ? _jsx(VideoIcon, {}) : _jsx(VideoOffIcon, {}), media.cameraOn && (_jsx("span", { className: "absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34a853] animate-pulse", "data-testid": "camera-live", title: "Your camera is sending" }))] }), openMenu === "video" && _jsx(DeviceMenu, { kind: "video", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${filtersActive(media.filters) ? ACTIVE : NEUTRAL}`, "aria-label": "Video effects", "aria-haspopup": "dialog", "aria-expanded": openMenu === "effects", onClick: () => toggleMenu("effects"), children: _jsx(EffectsIcon, {}) }), openMenu === "effects" && (_jsx("div", { role: "dialog", "aria-label": "Video effects", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-96 max-w-[calc(100vw-1rem)] max-h-[60vh] overflow-y-auto p-3 rounded-2xl bg-[#2b2d30] text-white shadow-xl", children: _jsx(EffectsPanel, { media: media, tone: "dark" }) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${isPresenting ? ACTIVE : NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": isPresenting ? "Stop sharing" : "Share screen", "aria-pressed": isPresenting, disabled: shareDisabled, title: shareDisabled ? `${presentingElsewhereName} is presenting - stop their share to present yourself.` : undefined, onClick: onToggleShare, children: _jsx(ScreenShareIcon, {}) }), isPresenting && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": "Rotate shared screen", title: "If your shared window looks sideways or upside down to everyone, rotate it here - this is a correction for your own capture, not a browser bug this app can fix automatically.", onClick: onRotateScreen, children: _jsx(RotateIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${screenTransform.flipped ? ACTIVE : NEUTRAL}`, "aria-label": screenTransform.flipped ? "Unflip shared screen" : "Flip shared screen", "aria-pressed": screenTransform.flipped, onClick: onFlipScreen, children: _jsx(FlipIcon, {}) })] })), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": "Send a reaction", "aria-haspopup": "menu", "aria-expanded": openMenu === "emoji", onClick: () => toggleMenu("emoji"), children: _jsx(EmojiIcon, {}) }), openMenu === "emoji" && (_jsx("div", { role: "menu", "aria-label": "Reactions", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-max grid grid-cols-4 gap-1 p-2 rounded-2xl bg-[#2b2d30] shadow-xl", children: REACTION_EMOJIS.map((emoji) => (_jsx("button", { type: "button", role: "menuitem", className: "w-11 h-11 rounded-lg text-2xl hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Send ${emoji}`, onClick: () => {
63
69
  onReaction(emoji);
64
70
  setOpenMenu(null);
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, {}) })] }));
71
+ }, children: emoji }, emoji))) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${handRaised ? ACTIVE : NEUTRAL}`, "aria-label": handRaised ? "Lower hand" : "Raise hand", "aria-pressed": handRaised, onClick: onToggleHand, children: _jsx(HandIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": viewMode === "grid" ? "Switch to focused view" : "Switch to grid view", onClick: onToggleViewMode, children: viewMode === "grid" ? _jsx(FocusIcon, {}) : _jsx(GridIcon, {}) }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${openMenu === "diagnostics" ? ACTIVE : NEUTRAL}`, "aria-label": "Call diagnostics", "aria-haspopup": "dialog", "aria-expanded": openMenu === "diagnostics", onClick: () => toggleMenu("diagnostics"), children: _jsx(DiagnosticsIcon, {}) }), openMenu === "diagnostics" && (_jsx("div", { role: "dialog", "aria-label": "Call diagnostics", className: "fixed z-30 top-3 left-3 w-80 max-w-[calc(100vw-1rem)] max-h-[70vh] overflow-y-auto p-3 rounded-2xl bg-[#2b2d30]/95 text-white shadow-xl", children: _jsx(DiagnosticsPanel, { selfName: selfName, micOn: media.micOn, cameraOn: media.cameraOn, participants: participants }) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`, "aria-label": "Leave call", onClick: () => onLeave(), children: _jsx(LeaveIcon, {}) })] }));
66
72
  }
67
73
  /** The device picker opened from the chevron beside the microphone/camera button: the available devices with the
68
74
  * one in use ticked, or - while there are none to list - why, with a way to ask for access again. */
@@ -63,6 +63,7 @@ import { MeshConnectionManager } from "../shared/webrtc/MeshConnectionManager.js
63
63
  import { createRelayTransport } from "../shared/relay/RelayTransport.js";
64
64
  import { GuestSignalingClient } from "../shared/push/GuestSignalingClient.js";
65
65
  import CallControls from "./_CallControls.js";
66
+ import { BatonIcon } from "./_icons.js";
66
67
  import { admitParticipant, denyParticipant, kickParticipant, listWaitingParticipants, setForceMuteOnJoin as apiSetForceMuteOnJoin, setMeetingPassword, setWaitingRoomEnabled as apiSetWaitingRoomEnabled, } from "./_meetApi.js";
67
68
  import ParticipantTile from "./_ParticipantTile.js";
68
69
  import ParticipantsDrawer from "./_ParticipantsDrawer.js";
@@ -501,10 +502,10 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
501
502
  else {
502
503
  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")) }));
503
504
  }
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) => {
505
- const stream = remoteStreams[p.uid];
506
- return stream ? _jsx(RemoteAudio, { stream: stream, onBlocked: handleAudioBlocked }, `${p.uid}:${audioNonce}`) : null;
507
- }), _jsx("div", { role: "status", "aria-live": "polite", className: "sr-only", children: announcement }), drawerOpen && (_jsx(ParticipantsDrawer, { selfName: selfName, micOn: media.micOn, handRaised: handRaised, participants: participants, isSelfHost: isHost, isParticipantHost: isParticipantHost, onMute: handleMuteParticipant, onKick: handleKickParticipant, forceMuteOnJoin: forceMuteOnJoin, onToggleForceMuteOnJoin: handleToggleForceMuteOnJoin, hasPassword: hasPassword, onSetPassword: handleSetPassword, waitingRoomEnabled: waitingRoomEnabled, onToggleWaitingRoomEnabled: handleToggleWaitingRoomEnabled, waitingParticipants: waitingParticipants, onAdmit: handleAdmitParticipant, onDeny: handleDenyParticipant, talkingStickActive: talkingStickActive, selfPeerId: peerId, talkingStickHolder: talkingStickHolder, onGiveTalkingStick: handleGiveTalkingStick, onClose: () => setDrawerOpen(false) }))] }));
505
+ return (_jsxs("div", { className: "fixed inset-0 z-50 flex bg-[#202124] text-white overflow-hidden", children: [_jsx("style", { children: `@keyframes meet-float { 0% { transform: translateY(0) scale(.6); opacity: 0; } 12% { opacity: 1; transform: translateY(-4vh) scale(1); } 100% { transform: translateY(-45vh) scale(1); opacity: 0; } }` }), _jsxs("div", { className: "relative flex-1 min-w-0 flex flex-col overflow-hidden", children: [_jsxs("header", { className: "shrink-0 flex items-center justify-between gap-3 px-4 py-3", children: [_jsx("h1", { className: "min-w-0 truncate text-base font-medium", children: meetingTitle }), _jsxs("div", { className: "flex items-center gap-2 shrink-0", children: [raisedNames.length > 0 && (_jsxs("span", { className: "max-w-[45vw] truncate px-3 py-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-sm font-medium", "data-testid": "raised-hands", children: ["\u270B ", raisedNames.join(", ")] })), talkingStickActive && (_jsxs("span", { className: "max-w-[45vw] truncate px-3 py-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-sm font-medium", "data-testid": "talking-stick-status", children: ["\uD83C\uDF99\uFE0F ", talkingStickHolderName ? `${talkingStickHolderName} ${talkingStickHolderName === "You" ? "have" : "has"} the floor` : "Waiting for the host to choose a speaker"] })), isHost && (_jsx("button", { type: "button", className: `w-9 h-9 flex items-center justify-center rounded-full 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-label": talkingStickActive ? "End talking stick" : "Talking stick", "aria-pressed": talkingStickActive, title: talkingStickActive ? "End talking stick" : "Start talking stick mode", onClick: handleToggleTalkingStick, children: _jsx(BatonIcon, {}) })), _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) => {
506
+ const stream = remoteStreams[p.uid];
507
+ return stream ? _jsx(RemoteAudio, { stream: stream, onBlocked: handleAudioBlocked }, `${p.uid}:${audioNonce}`) : null;
508
+ }), _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) }))] }));
508
509
  }
509
510
  /** Plays one remote participant's stream through a hidden `<audio>` element. `onBlocked` fires if the browser
510
511
  * refuses to start it (an autoplay policy) - the view then asks for a click. */
@@ -4,8 +4,10 @@
4
4
  * shows on a tile, as plain rows instead (a list reads better than tiles once there's a reason to scan for one
5
5
  * name).
6
6
  *
7
- * Closes on Escape, on a click on the backdrop behind it, or its own close button - never on a click inside the
8
- * drawer itself, matching `_CallControls.tsx`'s menus.
7
+ * A docked sidebar, not an overlay: `_CallView.tsx` renders it as an ordinary flex sibling of the call's own
8
+ * content column, which shrinks to make room rather than being covered by a backdrop - meant to stay open and
9
+ * visible for as long as the participant wants, including while the call continues underneath. Closes on Escape or
10
+ * its own close button; there is no backdrop to click, on purpose.
9
11
  *
10
12
  * A "Host" tag marks the host's own row (`isSelfHost`/`isParticipantHost` - see `_CallView.tsx`'s doc comment on
11
13
  * `hostUid` for what that identity is and isn't). Only while `isSelfHost` does every *other* row also get a "Mute"
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  ///////////////////////////////////////////////////////////////////////////////
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
4
4
  // SPDX-License-Identifier: MPL-2.0
@@ -9,8 +9,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
9
9
  * shows on a tile, as plain rows instead (a list reads better than tiles once there's a reason to scan for one
10
10
  * name).
11
11
  *
12
- * Closes on Escape, on a click on the backdrop behind it, or its own close button - never on a click inside the
13
- * drawer itself, matching `_CallControls.tsx`'s menus.
12
+ * A docked sidebar, not an overlay: `_CallView.tsx` renders it as an ordinary flex sibling of the call's own
13
+ * content column, which shrinks to make room rather than being covered by a backdrop - meant to stay open and
14
+ * visible for as long as the participant wants, including while the call continues underneath. Closes on Escape or
15
+ * its own close button; there is no backdrop to click, on purpose.
14
16
  *
15
17
  * A "Host" tag marks the host's own row (`isSelfHost`/`isParticipantHost` - see `_CallView.tsx`'s doc comment on
16
18
  * `hostUid` for what that identity is and isn't). Only while `isSelfHost` does every *other* row also get a "Mute"
@@ -87,5 +89,5 @@ export default function ParticipantsDrawer({ selfName, micOn, handRaised, partic
87
89
  document.addEventListener("keydown", onKeyDown);
88
90
  return () => document.removeEventListener("keydown", onKeyDown);
89
91
  }, [onClose]);
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)))] })] })] }));
92
+ return (_jsxs("aside", { role: "dialog", "aria-label": "Participants", className: "relative z-10 shrink-0 w-72 max-w-[85vw] h-full flex flex-col bg-[#2b2d30] text-white shadow-xl border-l border-white/10", children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-3 border-b border-white/10", children: [_jsxs("h2", { className: "text-sm font-medium", children: ["Participants (", participants.length + 1, ")"] }), _jsx("button", { type: "button", className: "w-8 h-8 flex items-center justify-center rounded-full hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": "Close participants", onClick: onClose, children: "\u2715" })] }), isSelfHost && (_jsxs("label", { className: "flex items-center gap-2 px-3 py-2 border-b border-white/10 text-sm", children: [_jsx("input", { type: "checkbox", checked: forceMuteOnJoin, onChange: onToggleForceMuteOnJoin, className: "w-4 h-4" }), "Mute new participants on join"] })), isSelfHost && _jsx(PasswordSection, { hasPassword: hasPassword, onSetPassword: onSetPassword }), isSelfHost && (_jsxs("label", { className: "flex items-center gap-2 px-3 py-2 border-b border-white/10 text-sm", children: [_jsx("input", { type: "checkbox", checked: waitingRoomEnabled, onChange: onToggleWaitingRoomEnabled, className: "w-4 h-4" }), "Require the host to admit participants"] })), isSelfHost && waitingRoomEnabled && (_jsx(WaitingSection, { waitingParticipants: waitingParticipants, onAdmit: onAdmit, onDeny: onDeny })), _jsxs("ul", { className: "flex-1 min-h-0 overflow-y-auto p-2 text-sm", children: [_jsx(Row, { name: selfName, isSelf: true, isHost: isSelfHost, micMuted: !micOn, handRaised: handRaised, hasStick: talkingStickActive && talkingStickHolder === selfPeerId, canGiveStick: isSelfHost && talkingStickActive, onGiveStick: () => onGiveTalkingStick(selfPeerId) }), participants.map((p) => (_jsx(Row, { name: p.name, isHost: isParticipantHost(p.uid), micMuted: !p.audioOn, handRaised: p.handRaised, transport: p.transport, canModerate: isSelfHost, onMute: () => onMute(p.uid), onKick: () => onKick(p.uid), hasStick: talkingStickActive && talkingStickHolder === p.uid, canGiveStick: isSelfHost && talkingStickActive, onGiveStick: () => onGiveTalkingStick(p.uid) }, p.uid)))] })] }));
91
93
  }
@@ -17,3 +17,7 @@ export declare const EffectsIcon: () => React.JSX.Element;
17
17
  export declare const RotateIcon: () => React.JSX.Element;
18
18
  export declare const FlipIcon: () => React.JSX.Element;
19
19
  export declare const DiagnosticsIcon: () => React.JSX.Element;
20
+ /** A talking stick: a rounded shaft with a grip knob at one end, drawn (not a Material path - there isn't one for
21
+ * this) as a diagonal bar with a circle, rather than reusing `HandIcon`'s emoji-glyph fallback, since this one
22
+ * needs to light up (`currentColor`) the same way every other control icon here does. */
23
+ export declare const BatonIcon: () => React.JSX.Element;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  function Icon({ path, className = "w-6 h-6" }) {
3
3
  return (_jsx("svg", { viewBox: "0 0 24 24", className: className, fill: "currentColor", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: path }) }));
4
4
  }
@@ -18,3 +18,7 @@ export const EffectsIcon = () => (_jsx(Icon, { path: "M19 9l1.25-2.75L23 5l-2.75
18
18
  export const RotateIcon = () => (_jsx(Icon, { path: "M12 6V2L7 7l5 5V8c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z" }));
19
19
  export const FlipIcon = () => (_jsx(Icon, { path: "M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8 20h2V1h-2v22zm8-6h2v-2h-2v2zM15 5h2V3h-2v2zm4 8h2v-2h-2v2zm0 8c1.1 0 2-.9 2-2h-2v2z" }));
20
20
  export const DiagnosticsIcon = () => (_jsx(Icon, { path: "M22 12h-4l-3 9L9 3l-3 9H2v2h5.6l1.4-4.2L13.4 21 17 12h5v-2z" }));
21
+ /** A talking stick: a rounded shaft with a grip knob at one end, drawn (not a Material path - there isn't one for
22
+ * this) as a diagonal bar with a circle, rather than reusing `HandIcon`'s emoji-glyph fallback, since this one
23
+ * needs to light up (`currentColor`) the same way every other control icon here does. */
24
+ export const BatonIcon = () => (_jsx("svg", { viewBox: "0 0 24 24", className: "w-6 h-6", fill: "currentColor", "aria-hidden": "true", focusable: "false", children: _jsxs("g", { transform: "rotate(45 12 12)", children: [_jsx("rect", { x: "5", y: "10.5", width: "13", height: "3", rx: "1.5" }), _jsx("circle", { cx: "19", cy: "12", r: "3" })] }) }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rapidmx/meet-plugin",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
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>",