@rapidmx/meet-plugin 0.14.0 → 0.14.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.
@@ -14,12 +14,21 @@
14
14
  * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
15
15
  * is disabled (`micLocked`) while talking-stick mode is on and this participant isn't the current holder - see
16
16
  * `_CallView.tsx`'s doc comment.
17
+ *
18
+ * ## The "…" menu
19
+ *
20
+ * Sits between the grid/focus toggle and Leave, a catch-all for controls that don't need a dedicated button of
21
+ * their own: "Diagnostics" (`onOpenDiagnostics`) and "Settings" (`onOpenSettings`, host-only) each open their own
22
+ * persistent window/modal in `_CallView.tsx` and close this menu; "Connection method" is a four-way
23
+ * `menuitemradio` group (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment)
24
+ * forcing how *this tab's* media reaches everyone else, independent of what anyone else has chosen. Otherwise an
25
+ * ordinary menu of this bar's own shape (`openMenu`, closes on Escape/outside click like every other one here).
17
26
  */
18
27
  import React, { useEffect, useRef, useState } from "react";
19
28
  import { filtersActive } from "../shared/media/filters/filterTypes.js";
20
29
  import type { ScreenTransformState } from "../shared/media/filters/ScreenTransform.js";
21
30
  import type { LocalMedia, MediaKind } from "../shared/media/useLocalMedia.js";
22
- import { REACTION_EMOJIS, type MeshParticipant } from "../shared/webrtc/types.js";
31
+ import { REACTION_EMOJIS, type MeshParticipant, type TransportMode } from "../shared/webrtc/types.js";
23
32
  import EffectsPanel from "./_EffectsPanel.js";
24
33
  import {
25
34
  ChevronUpIcon,
@@ -32,12 +41,21 @@ import {
32
41
  LeaveIcon,
33
42
  MicIcon,
34
43
  MicOffIcon,
44
+ OverflowIcon,
35
45
  RotateIcon,
36
46
  ScreenShareIcon,
37
47
  VideoIcon,
38
48
  VideoOffIcon,
39
49
  } from "./_icons.js";
40
50
 
51
+ /** The "…" menu's "Connection method" options, in display order - see `TransportMode`'s own doc comment. */
52
+ const TRANSPORT_MODE_OPTIONS: { mode: TransportMode; label: string }[] = [
53
+ { mode: "auto", label: "Auto" },
54
+ { mode: "p2p", label: "P2P" },
55
+ { mode: "relay", label: "Relay" },
56
+ { mode: "websocket", label: "WebSocket Relay" },
57
+ ];
58
+
41
59
  export type CallViewMode = "grid" | "focus";
42
60
 
43
61
  export interface CallControlsProps {
@@ -62,10 +80,16 @@ export interface CallControlsProps {
62
80
  * doc comment on talking-stick mode. Disables the microphone toggle (not the device-picker chevron, which
63
81
  * doesn't change whether anyone can hear them) rather than merely nudging it, unlike a `mute-request`. */
64
82
  micLocked?: boolean;
83
+ /** Whether the "…" menu's "Settings" item is offered at all - see this module's doc comment on the menu. */
84
+ isHost: boolean;
85
+ transportMode: TransportMode;
86
+ onSetTransportMode: (mode: TransportMode) => void;
87
+ onOpenDiagnostics: () => void;
88
+ onOpenSettings: () => void;
65
89
  onLeave: () => void;
66
90
  }
67
91
 
68
- type OpenMenu = MediaKind | "emoji" | "effects" | null;
92
+ type OpenMenu = MediaKind | "emoji" | "effects" | "overflow" | null;
69
93
 
70
94
  const BUTTON = "flex items-center justify-center h-12 rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80";
71
95
  const NEUTRAL = "bg-[#3c4043] text-white hover:bg-[#4b4f53]";
@@ -102,6 +126,11 @@ export default function CallControls({
102
126
  viewMode,
103
127
  onToggleViewMode,
104
128
  micLocked,
129
+ isHost,
130
+ transportMode,
131
+ onSetTransportMode,
132
+ onOpenDiagnostics,
133
+ onOpenSettings,
105
134
  onLeave,
106
135
  }: CallControlsProps) {
107
136
  const [openMenu, setOpenMenu] = useState<OpenMenu>(null);
@@ -307,6 +336,74 @@ export default function CallControls({
307
336
  {viewMode === "grid" ? <FocusIcon /> : <GridIcon />}
308
337
  </button>
309
338
 
339
+ <div className="sm:relative">
340
+ <button
341
+ type="button"
342
+ className={`${BUTTON} w-12 ${openMenu === "overflow" ? ACTIVE : NEUTRAL}`}
343
+ aria-label="More options"
344
+ aria-haspopup="menu"
345
+ aria-expanded={openMenu === "overflow"}
346
+ onClick={() => toggleMenu("overflow")}
347
+ >
348
+ <OverflowIcon />
349
+ </button>
350
+ {openMenu === "overflow" && (
351
+ <div
352
+ role="menu"
353
+ aria-label="More options"
354
+ className="absolute bottom-full mb-3 right-0 w-56 max-w-[calc(100vw-1rem)] rounded-2xl bg-[#2b2d30] text-white shadow-xl py-1 text-sm"
355
+ >
356
+ <button
357
+ type="button"
358
+ role="menuitem"
359
+ className="w-full px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
360
+ onClick={() => {
361
+ onOpenDiagnostics();
362
+ setOpenMenu(null);
363
+ }}
364
+ >
365
+ Diagnostics
366
+ </button>
367
+ <div className="my-1 border-t border-white/10" />
368
+ <p className="px-3 pt-1 pb-0.5 text-xs font-semibold uppercase tracking-wide text-white/60">Connection method</p>
369
+ {TRANSPORT_MODE_OPTIONS.map(({ mode, label }) => (
370
+ <button
371
+ key={mode}
372
+ type="button"
373
+ role="menuitemradio"
374
+ aria-checked={transportMode === mode}
375
+ className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
376
+ onClick={() => {
377
+ onSetTransportMode(mode);
378
+ setOpenMenu(null);
379
+ }}
380
+ >
381
+ <span className="w-4 text-[#8ab4f8]" aria-hidden="true">
382
+ {transportMode === mode ? "✓" : ""}
383
+ </span>
384
+ {label}
385
+ </button>
386
+ ))}
387
+ {isHost && (
388
+ <>
389
+ <div className="my-1 border-t border-white/10" />
390
+ <button
391
+ type="button"
392
+ role="menuitem"
393
+ className="w-full px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
394
+ onClick={() => {
395
+ onOpenSettings();
396
+ setOpenMenu(null);
397
+ }}
398
+ >
399
+ Settings
400
+ </button>
401
+ </>
402
+ )}
403
+ </div>
404
+ )}
405
+ </div>
406
+
310
407
  <button type="button" className={`${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`} aria-label="Leave call" onClick={() => onLeave()}>
311
408
  <LeaveIcon />
312
409
  </button>
@@ -51,18 +51,17 @@
51
51
  * `MeshConnectionManager`'s own doc comment on why that needs no extra code - the header chip then says nobody has
52
52
  * the floor, until the host picks someone (or themselves) again.
53
53
  *
54
- * ## The navbar's "…" menu
54
+ * ## Diagnostics, connection method and settings
55
55
  *
56
- * A catch-all for controls that don't need a dedicated button of their own: "Diagnostics" opens
57
- * `_DiagnosticsWindow.tsx` (a persistent, draggable window - not a popover like everything else here, since
58
- * troubleshooting a connection is something a participant wants to keep open while they poke at the rest of the
59
- * call, not something that closes the moment they click elsewhere); "Connection method" is a four-way choice
60
- * (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment) for forcing how *this tab's*
61
- * media reaches everyone else, independent of what anyone else has chosen; "Settings" (host-only) opens
62
- * `_SettingsModal.tsx`, which used to be the participants drawer's own header (mute-on-join, the join password, the
63
- * waiting-room toggle) - moved out because none of those are about *who* is in the call, which is what the drawer
64
- * is for. The menu itself closes on Escape, a click outside it, or picking an item; the windows it opens do not
65
- * close along with it.
56
+ * All three are opened from `_CallControls.tsx`'s own "…" menu (its doc comment covers the menu itself) via
57
+ * callback props, but owned here: `diagnosticsOpen` renders `_DiagnosticsWindow.tsx` (a persistent, draggable
58
+ * window - not a popover like the menus in the control bar, since troubleshooting a connection is something a
59
+ * participant wants to keep open while they poke at the rest of the call, not something that closes the moment
60
+ * they click elsewhere); `transportMode`/`handleSetTransportMode()` forces how *this tab's* media reaches everyone
61
+ * else (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment), independent of what
62
+ * anyone else has chosen; `settingsOpen` renders `_SettingsModal.tsx`, which used to be the participants drawer's
63
+ * own header (mute-on-join, the join password, the waiting-room toggle) - moved out because none of those are
64
+ * about *who* is in the call, which is what the drawer is for.
66
65
  */
67
66
  import React, { useCallback, useEffect, useRef, useState } from "react";
68
67
  import { playRaisedHandChime } from "../shared/media/chime.js";
@@ -77,7 +76,7 @@ import type { MeshParticipant, RelayTransportLike, TransportMode } from "../shar
77
76
  import { createRelayTransport } from "../shared/relay/RelayTransport.js";
78
77
  import { GuestSignalingClient } from "../shared/push/GuestSignalingClient.js";
79
78
  import CallControls, { type CallViewMode } from "./_CallControls.js";
80
- import { BatonIcon, OverflowIcon } from "./_icons.js";
79
+ import { BatonIcon } from "./_icons.js";
81
80
  import {
82
81
  admitParticipant,
83
82
  denyParticipant,
@@ -139,14 +138,6 @@ const MAX_REACTIONS = 12;
139
138
  /** How often the host's drawer refreshes its own waiting-room list while open. */
140
139
  const DEFAULT_WAITING_POLL_MS = 3_000;
141
140
 
142
- /** The "…" menu's "Connection method" options, in display order - see `TransportMode`'s own doc comment. */
143
- const TRANSPORT_MODE_OPTIONS: { mode: TransportMode; label: string }[] = [
144
- { mode: "auto", label: "Auto" },
145
- { mode: "p2p", label: "P2P" },
146
- { mode: "relay", label: "Relay" },
147
- { mode: "websocket", label: "WebSocket Relay" },
148
- ];
149
-
150
141
  interface Reaction {
151
142
  id: number;
152
143
  emoji: string;
@@ -229,13 +220,11 @@ export default function CallView({
229
220
  const [waitingParticipants, setWaitingParticipants] = useState<WaitingParticipant[]>([]);
230
221
  const [talkingStickActive, setTalkingStickActive] = useState(false);
231
222
  const [talkingStickHolder, setTalkingStickHolder] = useState<string | undefined>(undefined);
232
- const [overflowMenuOpen, setOverflowMenuOpen] = useState(false);
233
223
  const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
234
224
  const [settingsOpen, setSettingsOpen] = useState(false);
235
225
  const [transportMode, setTransportModeState] = useState<TransportMode>("auto");
236
226
 
237
227
  const managerRef = useRef<MeshConnectionManager | null>(null);
238
- const overflowMenuRef = useRef<HTMLDivElement>(null);
239
228
  /** The raw capture from `getDisplayMedia()` - only ever used to stop it (releasing the OS's own share
240
229
  * indicator) and to notice the participant ending the share from the browser's own UI (`track.onended`). What's
241
230
  * actually shown and sent is `screenTransformRef`'s output (`screenStream` state) - see `handleToggleShare()`. */
@@ -409,31 +398,6 @@ export default function CallView({
409
398
  }
410
399
  }, [talkingStickActive, selfHasTalkingStick]);
411
400
 
412
- // The "…" menu closes on Escape or a press anywhere outside it - matching `_CallControls.tsx`'s own menus -
413
- // but never the windows it opens (diagnostics, settings), which stay open independently (see this module's
414
- // doc comment on the "…" menu).
415
- useEffect(() => {
416
- if (!overflowMenuOpen) {
417
- return;
418
- }
419
- const onPointerDown = (event: PointerEvent) => {
420
- if (!overflowMenuRef.current?.contains(event.target as Node)) {
421
- setOverflowMenuOpen(false);
422
- }
423
- };
424
- const onKeyDown = (event: KeyboardEvent) => {
425
- if (event.key === "Escape") {
426
- setOverflowMenuOpen(false);
427
- }
428
- };
429
- document.addEventListener("pointerdown", onPointerDown);
430
- document.addEventListener("keydown", onKeyDown);
431
- return () => {
432
- document.removeEventListener("pointerdown", onPointerDown);
433
- document.removeEventListener("keydown", onKeyDown);
434
- };
435
- }, [overflowMenuOpen]);
436
-
437
401
  useEffect(() => {
438
402
  if (!presenterUid) {
439
403
  setActiveSpeakerUid((prev) => pickActiveSpeaker(levels, prev));
@@ -573,12 +537,11 @@ export default function CallView({
573
537
  }
574
538
 
575
539
  /** Forces this tab's own transport policy - see `MeshConnectionManager.setTransportMode()`'s doc comment for
576
- * what each mode actually does, and this module's doc comment on the "…" menu for why this is a personal,
577
- * per-tab choice rather than a meeting setting. */
540
+ * what each mode actually does, and `_CallControls.tsx`'s doc comment on its "…" menu for why this is a
541
+ * personal, per-tab choice rather than a meeting setting. */
578
542
  function handleSetTransportMode(mode: TransportMode) {
579
543
  setTransportModeState(mode);
580
544
  managerRef.current?.setTransportMode(mode);
581
- setOverflowMenuOpen(false);
582
545
  }
583
546
 
584
547
  /** Admits one pending request - optimistically removed from the drawer's own list (the next poll would drop it
@@ -768,70 +731,6 @@ export default function CallView({
768
731
  >
769
732
  {participants.length + 1}
770
733
  </button>
771
- <div className="relative" ref={overflowMenuRef}>
772
- <button
773
- type="button"
774
- className={`w-9 h-9 flex items-center justify-center rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 ${overflowMenuOpen ? "bg-[#4b4f53]" : "bg-[#3c4043] hover:bg-[#4b4f53]"}`}
775
- aria-label="More options"
776
- aria-haspopup="menu"
777
- aria-expanded={overflowMenuOpen}
778
- onClick={() => setOverflowMenuOpen((prev) => !prev)}
779
- >
780
- <OverflowIcon />
781
- </button>
782
- {overflowMenuOpen && (
783
- <div
784
- role="menu"
785
- aria-label="More options"
786
- className="absolute top-full right-0 mt-2 w-56 rounded-2xl bg-[#2b2d30] text-white shadow-xl py-1 text-sm"
787
- >
788
- <button
789
- type="button"
790
- role="menuitem"
791
- className="w-full px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
792
- onClick={() => {
793
- setDiagnosticsOpen(true);
794
- setOverflowMenuOpen(false);
795
- }}
796
- >
797
- Diagnostics
798
- </button>
799
- <div className="my-1 border-t border-white/10" />
800
- <p className="px-3 pt-1 pb-0.5 text-xs font-semibold uppercase tracking-wide text-white/60">Connection method</p>
801
- {TRANSPORT_MODE_OPTIONS.map(({ mode, label }) => (
802
- <button
803
- key={mode}
804
- type="button"
805
- role="menuitemradio"
806
- aria-checked={transportMode === mode}
807
- className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
808
- onClick={() => handleSetTransportMode(mode)}
809
- >
810
- <span className="w-4 text-[#8ab4f8]" aria-hidden="true">
811
- {transportMode === mode ? "✓" : ""}
812
- </span>
813
- {label}
814
- </button>
815
- ))}
816
- {isHost && (
817
- <>
818
- <div className="my-1 border-t border-white/10" />
819
- <button
820
- type="button"
821
- role="menuitem"
822
- className="w-full px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
823
- onClick={() => {
824
- setSettingsOpen(true);
825
- setOverflowMenuOpen(false);
826
- }}
827
- >
828
- Settings
829
- </button>
830
- </>
831
- )}
832
- </div>
833
- )}
834
- </div>
835
734
  </div>
836
735
  </header>
837
736
  {connectError && (
@@ -870,6 +769,11 @@ export default function CallView({
870
769
  viewMode={viewMode}
871
770
  onToggleViewMode={() => setViewMode((prev) => (prev === "grid" ? "focus" : "grid"))}
872
771
  micLocked={micLocked}
772
+ isHost={isHost}
773
+ transportMode={transportMode}
774
+ onSetTransportMode={handleSetTransportMode}
775
+ onOpenDiagnostics={() => setDiagnosticsOpen(true)}
776
+ onOpenSettings={() => setSettingsOpen(true)}
873
777
  onLeave={onLeave}
874
778
  />
875
779
  </footer>
@@ -10,11 +10,20 @@
10
10
  * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
11
11
  * is disabled (`micLocked`) while talking-stick mode is on and this participant isn't the current holder - see
12
12
  * `_CallView.tsx`'s doc comment.
13
+ *
14
+ * ## The "…" menu
15
+ *
16
+ * Sits between the grid/focus toggle and Leave, a catch-all for controls that don't need a dedicated button of
17
+ * their own: "Diagnostics" (`onOpenDiagnostics`) and "Settings" (`onOpenSettings`, host-only) each open their own
18
+ * persistent window/modal in `_CallView.tsx` and close this menu; "Connection method" is a four-way
19
+ * `menuitemradio` group (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment)
20
+ * forcing how *this tab's* media reaches everyone else, independent of what anyone else has chosen. Otherwise an
21
+ * ordinary menu of this bar's own shape (`openMenu`, closes on Escape/outside click like every other one here).
13
22
  */
14
23
  import React from "react";
15
24
  import type { ScreenTransformState } from "../shared/media/filters/ScreenTransform.js";
16
25
  import type { LocalMedia } from "../shared/media/useLocalMedia.js";
17
- import { type MeshParticipant } from "../shared/webrtc/types.js";
26
+ import { type MeshParticipant, type TransportMode } from "../shared/webrtc/types.js";
18
27
  export type CallViewMode = "grid" | "focus";
19
28
  export interface CallControlsProps {
20
29
  media: LocalMedia;
@@ -38,10 +47,16 @@ export interface CallControlsProps {
38
47
  * doc comment on talking-stick mode. Disables the microphone toggle (not the device-picker chevron, which
39
48
  * doesn't change whether anyone can hear them) rather than merely nudging it, unlike a `mute-request`. */
40
49
  micLocked?: boolean;
50
+ /** Whether the "…" menu's "Settings" item is offered at all - see this module's doc comment on the menu. */
51
+ isHost: boolean;
52
+ transportMode: TransportMode;
53
+ onSetTransportMode: (mode: TransportMode) => void;
54
+ onOpenDiagnostics: () => void;
55
+ onOpenSettings: () => void;
41
56
  onLeave: () => void;
42
57
  }
43
58
  /** Bars that rise and fall with the microphone's level (0-5) - a flat, dim set while there is no sound. */
44
59
  export declare function LevelBars({ level }: {
45
60
  level: number;
46
61
  }): React.JSX.Element;
47
- export default function CallControls({ media, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, onLeave, }: CallControlsProps): React.JSX.Element;
62
+ export default function CallControls({ media, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, isHost, transportMode, onSetTransportMode, onOpenDiagnostics, onOpenSettings, onLeave, }: CallControlsProps): React.JSX.Element;
@@ -15,12 +15,28 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
15
15
  * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
16
16
  * is disabled (`micLocked`) while talking-stick mode is on and this participant isn't the current holder - see
17
17
  * `_CallView.tsx`'s doc comment.
18
+ *
19
+ * ## The "…" menu
20
+ *
21
+ * Sits between the grid/focus toggle and Leave, a catch-all for controls that don't need a dedicated button of
22
+ * their own: "Diagnostics" (`onOpenDiagnostics`) and "Settings" (`onOpenSettings`, host-only) each open their own
23
+ * persistent window/modal in `_CallView.tsx` and close this menu; "Connection method" is a four-way
24
+ * `menuitemradio` group (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment)
25
+ * forcing how *this tab's* media reaches everyone else, independent of what anyone else has chosen. Otherwise an
26
+ * ordinary menu of this bar's own shape (`openMenu`, closes on Escape/outside click like every other one here).
18
27
  */
19
28
  import { useEffect, useRef, useState } from "react";
20
29
  import { filtersActive } from "../shared/media/filters/filterTypes.js";
21
30
  import { REACTION_EMOJIS } from "../shared/webrtc/types.js";
22
31
  import EffectsPanel from "./_EffectsPanel.js";
23
- import { ChevronUpIcon, EffectsIcon, EmojiIcon, FlipIcon, FocusIcon, GridIcon, HandIcon, LeaveIcon, MicIcon, MicOffIcon, RotateIcon, ScreenShareIcon, VideoIcon, VideoOffIcon, } from "./_icons.js";
32
+ import { ChevronUpIcon, EffectsIcon, EmojiIcon, FlipIcon, FocusIcon, GridIcon, HandIcon, LeaveIcon, MicIcon, MicOffIcon, OverflowIcon, RotateIcon, ScreenShareIcon, VideoIcon, VideoOffIcon, } from "./_icons.js";
33
+ /** The "…" menu's "Connection method" options, in display order - see `TransportMode`'s own doc comment. */
34
+ const TRANSPORT_MODE_OPTIONS = [
35
+ { mode: "auto", label: "Auto" },
36
+ { mode: "p2p", label: "P2P" },
37
+ { mode: "relay", label: "Relay" },
38
+ { mode: "websocket", label: "WebSocket Relay" },
39
+ ];
24
40
  const BUTTON = "flex items-center justify-center h-12 rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80";
25
41
  const NEUTRAL = "bg-[#3c4043] text-white hover:bg-[#4b4f53]";
26
42
  const ALERT = "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]";
@@ -29,7 +45,7 @@ const ACTIVE = "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]";
29
45
  export function LevelBars({ level }) {
30
46
  return (_jsx("span", { className: "flex items-center gap-[2px] h-5", "data-testid": "mic-level", "data-level": level, "aria-hidden": "true", children: [0.6, 1, 0.6].map((weight, i) => (_jsx("span", { className: `w-[3px] rounded-full ${level > 0 ? "bg-[#8ab4f8]" : "bg-white/50"}`, style: { height: `${4 + weight * level * 3}px` } }, i))) }));
31
47
  }
32
- export default function CallControls({ media, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, onLeave, }) {
48
+ export default function CallControls({ media, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, isHost, transportMode, onSetTransportMode, onOpenDiagnostics, onOpenSettings, onLeave, }) {
33
49
  const [openMenu, setOpenMenu] = useState(null);
34
50
  const barRef = useRef(null);
35
51
  const shareDisabled = !isPresenting && !!presentingElsewhereName;
@@ -61,7 +77,16 @@ export default function CallControls({ media, participants, isPresenting, presen
61
77
  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: () => {
62
78
  onReaction(emoji);
63
79
  setOpenMenu(null);
64
- }, 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} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": viewMode === "grid" ? "Switch to focused view" : "Switch to grid view", disabled: participants.length < 2, title: participants.length < 2 ? "Grid and focused view look the same with only one other participant." : undefined, onClick: onToggleViewMode, children: viewMode === "grid" ? _jsx(FocusIcon, {}) : _jsx(GridIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`, "aria-label": "Leave call", onClick: () => onLeave(), children: _jsx(LeaveIcon, {}) })] }));
80
+ }, 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} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": viewMode === "grid" ? "Switch to focused view" : "Switch to grid view", disabled: participants.length < 2, title: participants.length < 2 ? "Grid and focused view look the same with only one other participant." : undefined, onClick: onToggleViewMode, children: viewMode === "grid" ? _jsx(FocusIcon, {}) : _jsx(GridIcon, {}) }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${openMenu === "overflow" ? ACTIVE : NEUTRAL}`, "aria-label": "More options", "aria-haspopup": "menu", "aria-expanded": openMenu === "overflow", onClick: () => toggleMenu("overflow"), children: _jsx(OverflowIcon, {}) }), openMenu === "overflow" && (_jsxs("div", { role: "menu", "aria-label": "More options", className: "absolute bottom-full mb-3 right-0 w-56 max-w-[calc(100vw-1rem)] rounded-2xl bg-[#2b2d30] text-white shadow-xl py-1 text-sm", children: [_jsx("button", { type: "button", role: "menuitem", className: "w-full px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
81
+ onOpenDiagnostics();
82
+ setOpenMenu(null);
83
+ }, children: "Diagnostics" }), _jsx("div", { className: "my-1 border-t border-white/10" }), _jsx("p", { className: "px-3 pt-1 pb-0.5 text-xs font-semibold uppercase tracking-wide text-white/60", children: "Connection method" }), TRANSPORT_MODE_OPTIONS.map(({ mode, label }) => (_jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": transportMode === mode, className: "flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
84
+ onSetTransportMode(mode);
85
+ setOpenMenu(null);
86
+ }, children: [_jsx("span", { className: "w-4 text-[#8ab4f8]", "aria-hidden": "true", children: transportMode === mode ? "✓" : "" }), label] }, mode))), isHost && (_jsxs(_Fragment, { children: [_jsx("div", { className: "my-1 border-t border-white/10" }), _jsx("button", { type: "button", role: "menuitem", className: "w-full px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
87
+ onOpenSettings();
88
+ setOpenMenu(null);
89
+ }, children: "Settings" })] }))] }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`, "aria-label": "Leave call", onClick: () => onLeave(), children: _jsx(LeaveIcon, {}) })] }));
65
90
  }
66
91
  /** The device picker opened from the chevron beside the microphone/camera button: the available devices with the
67
92
  * one in use ticked, or - while there are none to list - why, with a way to ask for access again. */
@@ -47,18 +47,17 @@
47
47
  * `MeshConnectionManager`'s own doc comment on why that needs no extra code - the header chip then says nobody has
48
48
  * the floor, until the host picks someone (or themselves) again.
49
49
  *
50
- * ## The navbar's "…" menu
50
+ * ## Diagnostics, connection method and settings
51
51
  *
52
- * A catch-all for controls that don't need a dedicated button of their own: "Diagnostics" opens
53
- * `_DiagnosticsWindow.tsx` (a persistent, draggable window - not a popover like everything else here, since
54
- * troubleshooting a connection is something a participant wants to keep open while they poke at the rest of the
55
- * call, not something that closes the moment they click elsewhere); "Connection method" is a four-way choice
56
- * (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment) for forcing how *this tab's*
57
- * media reaches everyone else, independent of what anyone else has chosen; "Settings" (host-only) opens
58
- * `_SettingsModal.tsx`, which used to be the participants drawer's own header (mute-on-join, the join password, the
59
- * waiting-room toggle) - moved out because none of those are about *who* is in the call, which is what the drawer
60
- * is for. The menu itself closes on Escape, a click outside it, or picking an item; the windows it opens do not
61
- * close along with it.
52
+ * All three are opened from `_CallControls.tsx`'s own "…" menu (its doc comment covers the menu itself) via
53
+ * callback props, but owned here: `diagnosticsOpen` renders `_DiagnosticsWindow.tsx` (a persistent, draggable
54
+ * window - not a popover like the menus in the control bar, since troubleshooting a connection is something a
55
+ * participant wants to keep open while they poke at the rest of the call, not something that closes the moment
56
+ * they click elsewhere); `transportMode`/`handleSetTransportMode()` forces how *this tab's* media reaches everyone
57
+ * else (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment), independent of what
58
+ * anyone else has chosen; `settingsOpen` renders `_SettingsModal.tsx`, which used to be the participants drawer's
59
+ * own header (mute-on-join, the join password, the waiting-room toggle) - moved out because none of those are
60
+ * about *who* is in the call, which is what the drawer is for.
62
61
  */
63
62
  import React from "react";
64
63
  import type { LocalMedia } from "../shared/media/useLocalMedia.js";
@@ -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
@@ -52,18 +52,17 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
52
52
  * `MeshConnectionManager`'s own doc comment on why that needs no extra code - the header chip then says nobody has
53
53
  * the floor, until the host picks someone (or themselves) again.
54
54
  *
55
- * ## The navbar's "…" menu
55
+ * ## Diagnostics, connection method and settings
56
56
  *
57
- * A catch-all for controls that don't need a dedicated button of their own: "Diagnostics" opens
58
- * `_DiagnosticsWindow.tsx` (a persistent, draggable window - not a popover like everything else here, since
59
- * troubleshooting a connection is something a participant wants to keep open while they poke at the rest of the
60
- * call, not something that closes the moment they click elsewhere); "Connection method" is a four-way choice
61
- * (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment) for forcing how *this tab's*
62
- * media reaches everyone else, independent of what anyone else has chosen; "Settings" (host-only) opens
63
- * `_SettingsModal.tsx`, which used to be the participants drawer's own header (mute-on-join, the join password, the
64
- * waiting-room toggle) - moved out because none of those are about *who* is in the call, which is what the drawer
65
- * is for. The menu itself closes on Escape, a click outside it, or picking an item; the windows it opens do not
66
- * close along with it.
57
+ * All three are opened from `_CallControls.tsx`'s own "…" menu (its doc comment covers the menu itself) via
58
+ * callback props, but owned here: `diagnosticsOpen` renders `_DiagnosticsWindow.tsx` (a persistent, draggable
59
+ * window - not a popover like the menus in the control bar, since troubleshooting a connection is something a
60
+ * participant wants to keep open while they poke at the rest of the call, not something that closes the moment
61
+ * they click elsewhere); `transportMode`/`handleSetTransportMode()` forces how *this tab's* media reaches everyone
62
+ * else (`TransportMode` - see `MeshConnectionManager.setTransportMode()`'s own doc comment), independent of what
63
+ * anyone else has chosen; `settingsOpen` renders `_SettingsModal.tsx`, which used to be the participants drawer's
64
+ * own header (mute-on-join, the join password, the waiting-room toggle) - moved out because none of those are
65
+ * about *who* is in the call, which is what the drawer is for.
67
66
  */
68
67
  import { useCallback, useEffect, useRef, useState } from "react";
69
68
  import { playRaisedHandChime } from "../shared/media/chime.js";
@@ -76,7 +75,7 @@ import { MeshConnectionManager } from "../shared/webrtc/MeshConnectionManager.js
76
75
  import { createRelayTransport } from "../shared/relay/RelayTransport.js";
77
76
  import { GuestSignalingClient } from "../shared/push/GuestSignalingClient.js";
78
77
  import CallControls from "./_CallControls.js";
79
- import { BatonIcon, OverflowIcon } from "./_icons.js";
78
+ import { BatonIcon } from "./_icons.js";
80
79
  import { admitParticipant, denyParticipant, kickParticipant, listWaitingParticipants, setForceMuteOnJoin as apiSetForceMuteOnJoin, setMeetingPassword, setWaitingRoomEnabled as apiSetWaitingRoomEnabled, } from "./_meetApi.js";
81
80
  import DiagnosticsPanel from "./_DiagnosticsPanel.js";
82
81
  import DiagnosticsWindow from "./_DiagnosticsWindow.js";
@@ -89,13 +88,6 @@ const REACTION_MS = 4000;
89
88
  const MAX_REACTIONS = 12;
90
89
  /** How often the host's drawer refreshes its own waiting-room list while open. */
91
90
  const DEFAULT_WAITING_POLL_MS = 3000;
92
- /** The "…" menu's "Connection method" options, in display order - see `TransportMode`'s own doc comment. */
93
- const TRANSPORT_MODE_OPTIONS = [
94
- { mode: "auto", label: "Auto" },
95
- { mode: "p2p", label: "P2P" },
96
- { mode: "relay", label: "Relay" },
97
- { mode: "websocket", label: "WebSocket Relay" },
98
- ];
99
91
  /** Who is shown large - see this module's doc comment. `undefined` when nobody but the local participant has
100
92
  * joined yet (the grid/focus toggle still works, there's just nobody else to focus on). */
101
93
  export function computeMainUid(options) {
@@ -150,12 +142,10 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
150
142
  const [waitingParticipants, setWaitingParticipants] = useState([]);
151
143
  const [talkingStickActive, setTalkingStickActive] = useState(false);
152
144
  const [talkingStickHolder, setTalkingStickHolder] = useState(undefined);
153
- const [overflowMenuOpen, setOverflowMenuOpen] = useState(false);
154
145
  const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
155
146
  const [settingsOpen, setSettingsOpen] = useState(false);
156
147
  const [transportMode, setTransportModeState] = useState("auto");
157
148
  const managerRef = useRef(null);
158
- const overflowMenuRef = useRef(null);
159
149
  /** The raw capture from `getDisplayMedia()` - only ever used to stop it (releasing the OS's own share
160
150
  * indicator) and to notice the participant ending the share from the browser's own UI (`track.onended`). What's
161
151
  * actually shown and sent is `screenTransformRef`'s output (`screenStream` state) - see `handleToggleShare()`. */
@@ -319,30 +309,6 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
319
309
  void mediaRef.current.toggleMic();
320
310
  }
321
311
  }, [talkingStickActive, selfHasTalkingStick]);
322
- // The "…" menu closes on Escape or a press anywhere outside it - matching `_CallControls.tsx`'s own menus -
323
- // but never the windows it opens (diagnostics, settings), which stay open independently (see this module's
324
- // doc comment on the "…" menu).
325
- useEffect(() => {
326
- if (!overflowMenuOpen) {
327
- return;
328
- }
329
- const onPointerDown = (event) => {
330
- if (!overflowMenuRef.current?.contains(event.target)) {
331
- setOverflowMenuOpen(false);
332
- }
333
- };
334
- const onKeyDown = (event) => {
335
- if (event.key === "Escape") {
336
- setOverflowMenuOpen(false);
337
- }
338
- };
339
- document.addEventListener("pointerdown", onPointerDown);
340
- document.addEventListener("keydown", onKeyDown);
341
- return () => {
342
- document.removeEventListener("pointerdown", onPointerDown);
343
- document.removeEventListener("keydown", onKeyDown);
344
- };
345
- }, [overflowMenuOpen]);
346
312
  useEffect(() => {
347
313
  if (!presenterUid) {
348
314
  setActiveSpeakerUid((prev) => pickActiveSpeaker(levels, prev));
@@ -468,12 +434,11 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
468
434
  managerRef.current?.setTalkingStick(true, uid);
469
435
  }
470
436
  /** Forces this tab's own transport policy - see `MeshConnectionManager.setTransportMode()`'s doc comment for
471
- * what each mode actually does, and this module's doc comment on the "…" menu for why this is a personal,
472
- * per-tab choice rather than a meeting setting. */
437
+ * what each mode actually does, and `_CallControls.tsx`'s doc comment on its "…" menu for why this is a
438
+ * personal, per-tab choice rather than a meeting setting. */
473
439
  function handleSetTransportMode(mode) {
474
440
  setTransportModeState(mode);
475
441
  managerRef.current?.setTransportMode(mode);
476
- setOverflowMenuOpen(false);
477
442
  }
478
443
  /** Admits one pending request - optimistically removed from the drawer's own list (the next poll would drop it
479
444
  * anyway, once the requester's own next poll completes their join; removing it here just avoids the visible
@@ -562,13 +527,7 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
562
527
  else {
563
528
  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")) }));
564
529
  }
565
- 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 }), _jsxs("div", { className: "relative", ref: overflowMenuRef, children: [_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 ${overflowMenuOpen ? "bg-[#4b4f53]" : "bg-[#3c4043] hover:bg-[#4b4f53]"}`, "aria-label": "More options", "aria-haspopup": "menu", "aria-expanded": overflowMenuOpen, onClick: () => setOverflowMenuOpen((prev) => !prev), children: _jsx(OverflowIcon, {}) }), overflowMenuOpen && (_jsxs("div", { role: "menu", "aria-label": "More options", className: "absolute top-full right-0 mt-2 w-56 rounded-2xl bg-[#2b2d30] text-white shadow-xl py-1 text-sm", children: [_jsx("button", { type: "button", role: "menuitem", className: "w-full px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
566
- setDiagnosticsOpen(true);
567
- setOverflowMenuOpen(false);
568
- }, children: "Diagnostics" }), _jsx("div", { className: "my-1 border-t border-white/10" }), _jsx("p", { className: "px-3 pt-1 pb-0.5 text-xs font-semibold uppercase tracking-wide text-white/60", children: "Connection method" }), TRANSPORT_MODE_OPTIONS.map(({ mode, label }) => (_jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": transportMode === mode, className: "flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => handleSetTransportMode(mode), children: [_jsx("span", { className: "w-4 text-[#8ab4f8]", "aria-hidden": "true", children: transportMode === mode ? "✓" : "" }), label] }, mode))), isHost && (_jsxs(_Fragment, { children: [_jsx("div", { className: "my-1 border-t border-white/10" }), _jsx("button", { type: "button", role: "menuitem", className: "w-full px-3 py-2 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
569
- setSettingsOpen(true);
570
- setOverflowMenuOpen(false);
571
- }, children: "Settings" })] }))] }))] })] })] }), 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, 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) => {
530
+ 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, 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, isHost: isHost, transportMode: transportMode, onSetTransportMode: handleSetTransportMode, onOpenDiagnostics: () => setDiagnosticsOpen(true), onOpenSettings: () => setSettingsOpen(true), 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) => {
572
531
  const stream = remoteStreams[p.uid];
573
532
  return stream ? _jsx(RemoteAudio, { stream: stream, onBlocked: handleAudioBlocked }, `${p.uid}:${audioNonce}`) : null;
574
533
  }), _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, waitingRoomEnabled: waitingRoomEnabled, waitingParticipants: waitingParticipants, onAdmit: handleAdmitParticipant, onDeny: handleDenyParticipant, talkingStickActive: talkingStickActive, selfPeerId: peerId, talkingStickHolder: talkingStickHolder, onGiveTalkingStick: handleGiveTalkingStick, onClose: () => setDrawerOpen(false) })), diagnosticsOpen && (_jsx(DiagnosticsWindow, { onClose: () => setDiagnosticsOpen(false), children: _jsx(DiagnosticsPanel, { selfName: selfName, micOn: media.micOn, cameraOn: media.cameraOn, participants: participants }) })), settingsOpen && isHost && (_jsx(SettingsModal, { forceMuteOnJoin: forceMuteOnJoin, onToggleForceMuteOnJoin: handleToggleForceMuteOnJoin, hasPassword: hasPassword, onSetPassword: handleSetPassword, waitingRoomEnabled: waitingRoomEnabled, onToggleWaitingRoomEnabled: handleToggleWaitingRoomEnabled, onClose: () => setSettingsOpen(false) }))] }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rapidmx/meet-plugin",
3
- "version": "0.14.0",
3
+ "version": "0.14.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>",