@rapidmx/meet-plugin 0.14.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +35 -55
  2. package/apps/meet/[token].tsx +341 -341
  3. package/apps/meet/_CallControls.tsx +508 -463
  4. package/apps/meet/_CallView.tsx +1060 -891
  5. package/apps/meet/_ChatPanel.tsx +194 -0
  6. package/apps/meet/_ColorWheel.tsx +136 -0
  7. package/apps/meet/_DiagnosticsPanel.tsx +65 -7
  8. package/apps/meet/_MeetLobby.tsx +224 -224
  9. package/apps/meet/_ParticipantTile.tsx +243 -169
  10. package/apps/meet/_ParticipantsDrawer.tsx +10 -7
  11. package/apps/meet/_Whiteboard.tsx +577 -0
  12. package/apps/meet/_icons.tsx +135 -80
  13. package/apps/meet/_meetApi.ts +213 -212
  14. package/apps/shared/collab/CollabSession.ts +675 -0
  15. package/apps/shared/collab/WhiteboardModel.ts +216 -0
  16. package/apps/shared/collab/chatMarkdown.tsx +375 -0
  17. package/apps/shared/collab/color.ts +67 -0
  18. package/apps/shared/collab/protocol.ts +321 -0
  19. package/apps/shared/collab/whiteboardRender.ts +311 -0
  20. package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
  21. package/apps/shared/media/filters/mlModels.ts +174 -174
  22. package/apps/shared/relay/AudioPlayer.ts +29 -0
  23. package/apps/shared/relay/AudioSender.ts +23 -0
  24. package/apps/shared/relay/RelayClient.ts +54 -2
  25. package/apps/shared/relay/RelayReceiver.ts +43 -2
  26. package/apps/shared/relay/RelaySender.ts +23 -1
  27. package/apps/shared/relay/RelayTransport.ts +21 -1
  28. package/apps/shared/relay/SilenceMeter.ts +59 -0
  29. package/apps/shared/relay/relayEnv.ts +1 -1
  30. package/apps/shared/signaling/SignalingClient.ts +335 -0
  31. package/apps/shared/webrtc/MeshConnectionManager.ts +971 -949
  32. package/apps/shared/webrtc/types.ts +335 -247
  33. package/dist/apps/meet/[token].js +1 -1
  34. package/dist/apps/meet/_CallControls.d.ts +21 -6
  35. package/dist/apps/meet/_CallControls.js +14 -8
  36. package/dist/apps/meet/_CallView.d.ts +36 -7
  37. package/dist/apps/meet/_CallView.js +132 -16
  38. package/dist/apps/meet/_ChatPanel.d.ts +24 -0
  39. package/dist/apps/meet/_ChatPanel.js +116 -0
  40. package/dist/apps/meet/_ColorWheel.d.ts +14 -0
  41. package/dist/apps/meet/_ColorWheel.js +82 -0
  42. package/dist/apps/meet/_DiagnosticsPanel.d.ts +5 -3
  43. package/dist/apps/meet/_DiagnosticsPanel.js +17 -1
  44. package/dist/apps/meet/_ParticipantTile.d.ts +11 -3
  45. package/dist/apps/meet/_ParticipantTile.js +36 -5
  46. package/dist/apps/meet/_ParticipantsDrawer.js +3 -3
  47. package/dist/apps/meet/_Whiteboard.d.ts +64 -0
  48. package/dist/apps/meet/_Whiteboard.js +312 -0
  49. package/dist/apps/meet/_icons.d.ts +16 -0
  50. package/dist/apps/meet/_icons.js +16 -0
  51. package/dist/apps/meet/_meetApi.d.ts +8 -7
  52. package/dist/apps/meet/_meetApi.js +2 -2
  53. package/dist/apps/shared/collab/CollabSession.d.ts +208 -0
  54. package/dist/apps/shared/collab/CollabSession.js +559 -0
  55. package/dist/apps/shared/collab/WhiteboardModel.d.ts +54 -0
  56. package/dist/apps/shared/collab/WhiteboardModel.js +197 -0
  57. package/dist/apps/shared/collab/chatMarkdown.d.ts +65 -0
  58. package/dist/apps/shared/collab/chatMarkdown.js +311 -0
  59. package/dist/apps/shared/collab/color.d.ts +18 -0
  60. package/dist/apps/shared/collab/color.js +59 -0
  61. package/dist/apps/shared/collab/protocol.d.ts +157 -0
  62. package/dist/apps/shared/collab/protocol.js +222 -0
  63. package/dist/apps/shared/collab/whiteboardRender.d.ts +31 -0
  64. package/dist/apps/shared/collab/whiteboardRender.js +268 -0
  65. package/dist/apps/shared/relay/AudioPlayer.d.ts +9 -0
  66. package/dist/apps/shared/relay/AudioPlayer.js +24 -4
  67. package/dist/apps/shared/relay/AudioSender.d.ts +8 -0
  68. package/dist/apps/shared/relay/AudioSender.js +20 -4
  69. package/dist/apps/shared/relay/RelayClient.d.ts +16 -0
  70. package/dist/apps/shared/relay/RelayClient.js +46 -2
  71. package/dist/apps/shared/relay/RelayReceiver.d.ts +5 -0
  72. package/dist/apps/shared/relay/RelayReceiver.js +38 -6
  73. package/dist/apps/shared/relay/RelaySender.d.ts +7 -0
  74. package/dist/apps/shared/relay/RelaySender.js +20 -5
  75. package/dist/apps/shared/relay/RelayTransport.d.ts +6 -1
  76. package/dist/apps/shared/relay/RelayTransport.js +16 -1
  77. package/dist/apps/shared/relay/SilenceMeter.d.ts +25 -0
  78. package/dist/apps/shared/relay/SilenceMeter.js +55 -0
  79. package/dist/apps/shared/relay/relayEnv.d.ts +1 -1
  80. package/dist/apps/shared/signaling/SignalingClient.d.ts +95 -0
  81. package/dist/apps/shared/signaling/SignalingClient.js +278 -0
  82. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +7 -7
  83. package/dist/apps/shared/webrtc/MeshConnectionManager.js +28 -9
  84. package/dist/apps/shared/webrtc/types.d.ts +104 -26
  85. package/dist/apps/shared/webrtc/types.js +3 -4
  86. package/dist/lib/routes/BaseVideoMeetingRoute.js +171 -69
  87. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  88. package/dist/lib/util/RelayHub.js +15 -2
  89. package/dist/lib/util/RelayHub.js.map +1 -1
  90. package/dist/lib/util/SignalingHub.js +339 -0
  91. package/dist/lib/util/SignalingHub.js.map +1 -0
  92. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +68 -46
  93. package/dist/types/util/RelayHub.d.ts +5 -2
  94. package/dist/types/util/SignalingHub.d.ts +112 -0
  95. package/package.json +208 -208
  96. package/apps/shared/push/GuestSignalingClient.ts +0 -359
  97. package/dist/apps/shared/push/GuestSignalingClient.d.ts +0 -98
  98. package/dist/apps/shared/push/GuestSignalingClient.js +0 -312
@@ -190,7 +190,7 @@ function MeetJoinContent({ token, branding }) {
190
190
  return (_jsx(CallView, { channel: joinResult.meeting.uid,
191
191
  // `token` is present only for the common anonymous case (`authenticated: false`) - when the
192
192
  // caller's own existing session already authenticated them (`authenticated: true`), there is no
193
- // guest token to apply as a cookie at all, and `GuestSignalingClient` relies entirely on the
193
+ // guest token to apply as a cookie at all, and `SignalingClient` relies entirely on the
194
194
  // browser's own already-existing `jwt` session cookie instead (see its own doc comment and
195
195
  // `VideoMeetingJoinResult`'s doc comment in `_meetApi.ts`).
196
196
  token: joinResult.token, selfUid: joinResult.selfUid, selfName: name, meetingTitle: joinResult.meeting.title, iceServers: joinResult.iceServers, relayEnabled: joinResult.relayEnabled, hostUid: joinResult.meeting.hostUid, initialForceMuteOnJoin: !!joinResult.meeting.forceMuteOnJoin, initialHasPassword: !!joinResult.meeting.hasPassword, initialWaitingRoomEnabled: !!joinResult.meeting.waitingRoomEnabled, media: media, onLeave: handleLeave }));
@@ -2,7 +2,7 @@
2
2
  * The in-call control bar, fixed to the bottom of the call (`_CallView.tsx` lays it out below the tiles, never over
3
3
  * them). Its menus open upward, centered on the whole bar on a phone (where the bar wraps onto two rows and a menu
4
4
  * hung from its own button would run off the edge) and from their own button on a wider window: microphone and camera (each a mute/unmute or on/off button next to a menu that picks the device), share
5
- * screen, reactions, raise hand, grid/focus and leave. The effects button opens the video filter picker
5
+ * screen, whiteboard, 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
8
  * The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
@@ -17,8 +17,11 @@
17
17
  * their own: "Diagnostics" (`onOpenDiagnostics`) and "Settings" (`onOpenSettings`, host-only) each open their own
18
18
  * persistent window/modal in `_CallView.tsx` and close this menu; "Connection method" is a four-way
19
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).
20
+ * forcing how *this tab's* media reaches everyone else, independent of what anyone else has chosen; "Hide self"/
21
+ * "Show self" (`selfHidden`/`onToggleSelfHidden`) is the only way to bring the local tile back once hidden from
22
+ * grid view, where there's no tile left to offer its own "show" control - see `_CallView.tsx`'s doc comment on
23
+ * where the local tile appears. Otherwise an ordinary menu of this bar's own shape (`openMenu`, closes on
24
+ * Escape/outside click like every other one here).
22
25
  */
23
26
  import React from "react";
24
27
  import type { ScreenTransformState } from "../shared/media/filters/ScreenTransform.js";
@@ -40,9 +43,17 @@ export interface CallControlsProps {
40
43
  onToggleHand: () => void;
41
44
  onReaction: (emoji: string) => void;
42
45
  viewMode: CallViewMode;
43
- /** Disabled whenever `participants.length < 2` - grid and focused view render the exact same single tile with
44
- * only one other participant (or none), so there is nothing for this to visibly toggle yet. */
46
+ /** Disabled while alone (`participants.length === 0`) - with nobody else here, grid and focused view both just
47
+ * show the local tile filling the stage, so there is nothing for this to visibly toggle yet. Enabled from the
48
+ * very first other participant: focus view corners the local tile and gives the other participant the main
49
+ * slot, which already looks different from grid view's two equal-sized tiles - see `_CallView.tsx`'s doc
50
+ * comment on where the local tile appears. */
45
51
  onToggleViewMode: () => void;
52
+ /** Whether this participant has the shared whiteboard open - see `_CallView.tsx`'s doc comment. */
53
+ whiteboardOpen: boolean;
54
+ /** Someone drew on the whiteboard while it was closed here - its button shows a dot. */
55
+ whiteboardActivity?: boolean;
56
+ onToggleWhiteboard: () => void;
46
57
  /** True while talking-stick mode is on and this participant doesn't currently hold it - see `_CallView.tsx`'s
47
58
  * doc comment on talking-stick mode. Disables the microphone toggle (not the device-picker chevron, which
48
59
  * doesn't change whether anyone can hear them) rather than merely nudging it, unlike a `mute-request`. */
@@ -53,10 +64,14 @@ export interface CallControlsProps {
53
64
  onSetTransportMode: (mode: TransportMode) => void;
54
65
  onOpenDiagnostics: () => void;
55
66
  onOpenSettings: () => void;
67
+ /** Whether the local tile is currently hidden - the "…" menu's "Hide self"/"Show self" item reflects and
68
+ * toggles this; see `_CallView.tsx`'s doc comment on where the local tile appears. */
69
+ selfHidden: boolean;
70
+ onToggleSelfHidden: () => void;
56
71
  onLeave: () => void;
57
72
  }
58
73
  /** Bars that rise and fall with the microphone's level (0-5) - a flat, dim set while there is no sound. */
59
74
  export declare function LevelBars({ level }: {
60
75
  level: number;
61
76
  }): 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;
77
+ export default function CallControls({ media, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, whiteboardOpen, whiteboardActivity, onToggleWhiteboard, micLocked, isHost, transportMode, onSetTransportMode, onOpenDiagnostics, onOpenSettings, selfHidden, onToggleSelfHidden, onLeave, }: CallControlsProps): React.JSX.Element;
@@ -7,7 +7,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
7
7
  * The in-call control bar, fixed to the bottom of the call (`_CallView.tsx` lays it out below the tiles, never over
8
8
  * them). Its menus open upward, centered on the whole bar on a phone (where the bar wraps onto two rows and a menu
9
9
  * hung from its own button would run off the edge) and from their own button on a wider window: microphone and camera (each a mute/unmute or on/off button next to a menu that picks the device), share
10
- * screen, reactions, raise hand, grid/focus and leave. The effects button opens the video filter picker
10
+ * screen, whiteboard, 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
13
  * The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
@@ -22,14 +22,17 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
22
22
  * their own: "Diagnostics" (`onOpenDiagnostics`) and "Settings" (`onOpenSettings`, host-only) each open their own
23
23
  * persistent window/modal in `_CallView.tsx` and close this menu; "Connection method" is a four-way
24
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).
25
+ * forcing how *this tab's* media reaches everyone else, independent of what anyone else has chosen; "Hide self"/
26
+ * "Show self" (`selfHidden`/`onToggleSelfHidden`) is the only way to bring the local tile back once hidden from
27
+ * grid view, where there's no tile left to offer its own "show" control - see `_CallView.tsx`'s doc comment on
28
+ * where the local tile appears. Otherwise an ordinary menu of this bar's own shape (`openMenu`, closes on
29
+ * Escape/outside click like every other one here).
27
30
  */
28
31
  import { useEffect, useRef, useState } from "react";
29
32
  import { filtersActive } from "../shared/media/filters/filterTypes.js";
30
33
  import { REACTION_EMOJIS } from "../shared/webrtc/types.js";
31
34
  import EffectsPanel from "./_EffectsPanel.js";
32
- import { ChevronUpIcon, EffectsIcon, EmojiIcon, FlipIcon, FocusIcon, GridIcon, HandIcon, LeaveIcon, MicIcon, MicOffIcon, OverflowIcon, RotateIcon, ScreenShareIcon, VideoIcon, VideoOffIcon, } from "./_icons.js";
35
+ import { ChevronUpIcon, EffectsIcon, EmojiIcon, FlipIcon, FocusIcon, GridIcon, HandIcon, LeaveIcon, MicIcon, MicOffIcon, OverflowIcon, RotateIcon, ScreenShareIcon, VideoIcon, VideoOffIcon, WhiteboardIcon, } from "./_icons.js";
33
36
  /** The "…" menu's "Connection method" options, in display order - see `TransportMode`'s own doc comment. */
34
37
  const TRANSPORT_MODE_OPTIONS = [
35
38
  { mode: "auto", label: "Auto" },
@@ -45,7 +48,7 @@ const ACTIVE = "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]";
45
48
  export function LevelBars({ level }) {
46
49
  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))) }));
47
50
  }
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, }) {
51
+ export default function CallControls({ media, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, whiteboardOpen, whiteboardActivity, onToggleWhiteboard, micLocked, isHost, transportMode, onSetTransportMode, onOpenDiagnostics, onOpenSettings, selfHidden, onToggleSelfHidden, onLeave, }) {
49
52
  const [openMenu, setOpenMenu] = useState(null);
50
53
  const barRef = useRef(null);
51
54
  const shareDisabled = !isPresenting && !!presentingElsewhereName;
@@ -74,13 +77,16 @@ export default function CallControls({ media, participants, isPresenting, presen
74
77
  function toggleMenu(menu) {
75
78
  setOpenMenu((prev) => (prev === menu ? null : menu));
76
79
  }
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: () => {
80
+ 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("button", { type: "button", className: `${BUTTON} relative w-12 ${whiteboardOpen ? ACTIVE : NEUTRAL}`, "aria-label": whiteboardOpen ? "Close whiteboard" : whiteboardActivity ? "Open whiteboard, new drawing" : "Open whiteboard", "aria-pressed": whiteboardOpen, onClick: onToggleWhiteboard, children: [_jsx(WhiteboardIcon, {}), whiteboardActivity && !whiteboardOpen && (_jsx("span", { className: "absolute top-2 right-2 w-2 h-2 rounded-full bg-[#fbbc04]", "data-testid": "whiteboard-activity" }))] }), _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: () => {
78
81
  onReaction(emoji);
79
82
  setOpenMenu(null);
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: () => {
83
+ }, 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 === 0, title: participants.length === 0 ? "Grid and focused view look the same while you're alone." : 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
84
  onOpenDiagnostics();
82
85
  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: () => {
86
+ }, children: "Diagnostics" }), _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
+ onToggleSelfHidden();
88
+ setOpenMenu(null);
89
+ }, children: selfHidden ? "Show self" : "Hide self" }), _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
90
  onSetTransportMode(mode);
85
91
  setOpenMenu(null);
86
92
  }, 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: () => {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The in-call view: connects the full-mesh `MeshConnectionManager` to the meeting's signaling channel
3
- * (`GuestSignalingClient`), mirrors its events into React state, and renders the grid/focused/presentation
3
+ * (`SignalingClient`, this plugin's own WebSocket), mirrors its events into React state, and renders the grid/focused/presentation
4
4
  * layouts - see `apps/shared/webrtc/MeshConnectionManager.ts`'s doc comment for the who-calls-whom and
5
5
  * single-presenter rules this view relies on rather than re-deciding. These reusable, non-UI modules live under
6
6
  * `apps/shared/` rather than this plugin's backend `src/` - `tsconfig.apps.json` builds `apps/` as its own
@@ -11,10 +11,27 @@
11
11
  *
12
12
  * The call fills the viewport (`fixed inset-0`) as three rows - a slim header, the tiles (which take whatever room
13
13
  * is left and never scroll the page), and the control bar pinned to the bottom - instead of being laid out inside
14
- * the branded page shell, whose header, footer and padding pushed it off the screen. The local participant's own
15
- * tile is a small tile in the bottom-right corner while anyone else is in the call (above the control bar on a narrower
16
- * window, and at the top on a phone, where the bar wraps onto a second row), and fills the tile area while they are
17
- * alone.
14
+ * the branded page shell, whose header, footer and padding pushed it off the screen. In plain grid view, each tile
15
+ * keeps its capture device's aspect ratio (`aspect-video`) instead of being stretched to fill an equal share of the
16
+ * available height - the grid area itself may scroll (vertically, within that middle row) once enough
17
+ * aspect-ratio-sized tiles no longer all fit, rather than cropping or distorting any of them to force a fit.
18
+ *
19
+ * ## Where the local tile appears
20
+ *
21
+ * In plain grid view (not focus, not presenting), the local tile is an ordinary member of the grid, same size as
22
+ * everyone else's, rather than a floating corner overlay - so with exactly two participants, the local tile is
23
+ * genuinely one of the two grid cells, not a small picture-in-picture over the other person's. The floating corner
24
+ * tile (above the control bar on a narrower window, at the top on a phone, where the bar wraps onto a second row)
25
+ * is reserved for focus/presentation layouts, where there's already a single large tile the local one would
26
+ * otherwise compete with for space. Either way, alone in the call, the local tile simply fills the whole stage
27
+ * (see the `alone` branch of the `stage` computation).
28
+ *
29
+ * `selfHidden` (`handleHideSelf()`/`handleToggleSelfHidden()`) removes the local tile from view entirely, wherever
30
+ * it would otherwise appear - set from the tile's own corner "…" menu (`_ParticipantTile.tsx`'s `onHideSelf`, one
31
+ * directional: there's no tile left to click "show" on once it's gone) or toggled back on from the navbar's own
32
+ * "…" menu (`_CallControls.tsx`), which is the only way to re-show it from grid view, where there'd otherwise be
33
+ * nothing left bearing a "show self" control. Never honored while `alone`: hiding the only tile on screen would
34
+ * just blank it, so the local tile ignores `selfHidden` there and always shows.
18
35
  *
19
36
  * ## Who is heard
20
37
  *
@@ -47,6 +64,18 @@
47
64
  * `MeshConnectionManager`'s own doc comment on why that needs no extra code - the header chip then says nobody has
48
65
  * the floor, until the host picks someone (or themselves) again.
49
66
  *
67
+ * ## Chat and whiteboard
68
+ *
69
+ * Both run on `CollabSession` (`apps/shared/collab/CollabSession.ts`), over the same signaling channel as the mesh,
70
+ * and last as long as the call. The chat opens in a drawer (`_ChatPanel.tsx`) from the header's chat button - in
71
+ * place of the participants drawer, since one side drawer at a time is all a narrow window has room for - which
72
+ * counts unread messages while it is closed. The whiteboard (`_Whiteboard.tsx`) takes the stage when opened from
73
+ * the control bar, with everyone's video in a strip below it and the local tile in its corner, like presenting; it
74
+ * is a per-participant view of one shared board, so opening or closing it changes nobody else's screen. Its button
75
+ * shows a dot when someone draws while it is closed. Everyone's pointer shows on everyone's board with their name;
76
+ * while talking-stick mode is on only the holder may draw (`CollabSession.setDrawPolicy()`), the same rule as the
77
+ * microphone. History a late joiner receives is shown but not counted as unread.
78
+ *
50
79
  * ## Diagnostics, connection method and settings
51
80
  *
52
81
  * All three are opened from `_CallControls.tsx`'s own "…" menu (its doc comment covers the menu itself) via
@@ -64,8 +93,8 @@ import type { LocalMedia } from "../shared/media/useLocalMedia.js";
64
93
  export interface CallViewProps {
65
94
  channel: string;
66
95
  /** Omitted when the caller already authenticated as a real RapidMX identity in `join()` - there is then no
67
- * guest token to hand `GuestSignalingClient`, which relies entirely on the browser's own already-existing
68
- * `jwt` session cookie instead (see its own doc comment). */
96
+ * guest token to hand `SignalingClient`, which relies entirely on the browser's own already-existing `jwt`
97
+ * session cookie instead (see its own doc comment). */
69
98
  token?: string;
70
99
  selfUid: string;
71
100
  selfName: string;
@@ -5,7 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  /**
7
7
  * The in-call view: connects the full-mesh `MeshConnectionManager` to the meeting's signaling channel
8
- * (`GuestSignalingClient`), mirrors its events into React state, and renders the grid/focused/presentation
8
+ * (`SignalingClient`, this plugin's own WebSocket), mirrors its events into React state, and renders the grid/focused/presentation
9
9
  * layouts - see `apps/shared/webrtc/MeshConnectionManager.ts`'s doc comment for the who-calls-whom and
10
10
  * single-presenter rules this view relies on rather than re-deciding. These reusable, non-UI modules live under
11
11
  * `apps/shared/` rather than this plugin's backend `src/` - `tsconfig.apps.json` builds `apps/` as its own
@@ -16,10 +16,27 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
16
16
  *
17
17
  * The call fills the viewport (`fixed inset-0`) as three rows - a slim header, the tiles (which take whatever room
18
18
  * is left and never scroll the page), and the control bar pinned to the bottom - instead of being laid out inside
19
- * the branded page shell, whose header, footer and padding pushed it off the screen. The local participant's own
20
- * tile is a small tile in the bottom-right corner while anyone else is in the call (above the control bar on a narrower
21
- * window, and at the top on a phone, where the bar wraps onto a second row), and fills the tile area while they are
22
- * alone.
19
+ * the branded page shell, whose header, footer and padding pushed it off the screen. In plain grid view, each tile
20
+ * keeps its capture device's aspect ratio (`aspect-video`) instead of being stretched to fill an equal share of the
21
+ * available height - the grid area itself may scroll (vertically, within that middle row) once enough
22
+ * aspect-ratio-sized tiles no longer all fit, rather than cropping or distorting any of them to force a fit.
23
+ *
24
+ * ## Where the local tile appears
25
+ *
26
+ * In plain grid view (not focus, not presenting), the local tile is an ordinary member of the grid, same size as
27
+ * everyone else's, rather than a floating corner overlay - so with exactly two participants, the local tile is
28
+ * genuinely one of the two grid cells, not a small picture-in-picture over the other person's. The floating corner
29
+ * tile (above the control bar on a narrower window, at the top on a phone, where the bar wraps onto a second row)
30
+ * is reserved for focus/presentation layouts, where there's already a single large tile the local one would
31
+ * otherwise compete with for space. Either way, alone in the call, the local tile simply fills the whole stage
32
+ * (see the `alone` branch of the `stage` computation).
33
+ *
34
+ * `selfHidden` (`handleHideSelf()`/`handleToggleSelfHidden()`) removes the local tile from view entirely, wherever
35
+ * it would otherwise appear - set from the tile's own corner "…" menu (`_ParticipantTile.tsx`'s `onHideSelf`, one
36
+ * directional: there's no tile left to click "show" on once it's gone) or toggled back on from the navbar's own
37
+ * "…" menu (`_CallControls.tsx`), which is the only way to re-show it from grid view, where there'd otherwise be
38
+ * nothing left bearing a "show self" control. Never honored while `alone`: hiding the only tile on screen would
39
+ * just blank it, so the local tile ignores `selfHidden` there and always shows.
23
40
  *
24
41
  * ## Who is heard
25
42
  *
@@ -52,6 +69,18 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
52
69
  * `MeshConnectionManager`'s own doc comment on why that needs no extra code - the header chip then says nobody has
53
70
  * the floor, until the host picks someone (or themselves) again.
54
71
  *
72
+ * ## Chat and whiteboard
73
+ *
74
+ * Both run on `CollabSession` (`apps/shared/collab/CollabSession.ts`), over the same signaling channel as the mesh,
75
+ * and last as long as the call. The chat opens in a drawer (`_ChatPanel.tsx`) from the header's chat button - in
76
+ * place of the participants drawer, since one side drawer at a time is all a narrow window has room for - which
77
+ * counts unread messages while it is closed. The whiteboard (`_Whiteboard.tsx`) takes the stage when opened from
78
+ * the control bar, with everyone's video in a strip below it and the local tile in its corner, like presenting; it
79
+ * is a per-participant view of one shared board, so opening or closing it changes nobody else's screen. Its button
80
+ * shows a dot when someone draws while it is closed. Everyone's pointer shows on everyone's board with their name;
81
+ * while talking-stick mode is on only the holder may draw (`CollabSession.setDrawPolicy()`), the same rule as the
82
+ * microphone. History a late joiner receives is shown but not counted as unread.
83
+ *
55
84
  * ## Diagnostics, connection method and settings
56
85
  *
57
86
  * All three are opened from `_CallControls.tsx`'s own "…" menu (its doc comment covers the menu itself) via
@@ -73,15 +102,18 @@ import { NO_SCREEN_TRANSFORM, ScreenTransformProcessor, rotateClockwise } from "
73
102
  import { createBrowserPeerConnection } from "../shared/webrtc/realPeerConnection.js";
74
103
  import { MeshConnectionManager } from "../shared/webrtc/MeshConnectionManager.js";
75
104
  import { createRelayTransport } from "../shared/relay/RelayTransport.js";
76
- import { GuestSignalingClient } from "../shared/push/GuestSignalingClient.js";
105
+ import { SIGNALING_EVICTED_REASON, SignalingClient } from "../shared/signaling/SignalingClient.js";
106
+ import { CollabSession } from "../shared/collab/CollabSession.js";
77
107
  import CallControls from "./_CallControls.js";
78
- import { BatonIcon } from "./_icons.js";
108
+ import ChatPanel from "./_ChatPanel.js";
109
+ import { BatonIcon, ChatIcon } from "./_icons.js";
79
110
  import { admitParticipant, denyParticipant, kickParticipant, listWaitingParticipants, setForceMuteOnJoin as apiSetForceMuteOnJoin, setMeetingPassword, setWaitingRoomEnabled as apiSetWaitingRoomEnabled, } from "./_meetApi.js";
80
111
  import DiagnosticsPanel from "./_DiagnosticsPanel.js";
81
112
  import DiagnosticsWindow from "./_DiagnosticsWindow.js";
82
113
  import ParticipantTile from "./_ParticipantTile.js";
83
114
  import ParticipantsDrawer from "./_ParticipantsDrawer.js";
84
115
  import SettingsModal from "./_SettingsModal.js";
116
+ import Whiteboard from "./_Whiteboard.js";
85
117
  /** How long a reaction floats on screen. */
86
118
  const REACTION_MS = 4000;
87
119
  /** The most reactions shown at once - a burst beyond this drops the oldest. */
@@ -145,7 +177,23 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
145
177
  const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
146
178
  const [settingsOpen, setSettingsOpen] = useState(false);
147
179
  const [transportMode, setTransportModeState] = useState("auto");
180
+ const [selfHidden, setSelfHidden] = useState(false);
181
+ const [chatOpen, setChatOpen] = useState(false);
182
+ const [chatEntries, setChatEntries] = useState([]);
183
+ const [unreadChat, setUnreadChat] = useState(0);
184
+ const [whiteboardOpen, setWhiteboardOpen] = useState(false);
185
+ const [boardRevision, setBoardRevision] = useState(0);
186
+ const [whiteboardActivity, setWhiteboardActivity] = useState(false);
187
+ const [boardCursors, setBoardCursors] = useState([]);
148
188
  const managerRef = useRef(null);
189
+ const collabRef = useRef(null);
190
+ /** Whether the chat drawer / whiteboard is open, for the collab-event handler - set up once, like the mesh's. */
191
+ const chatOpenRef = useRef(chatOpen);
192
+ const whiteboardOpenRef = useRef(whiteboardOpen);
193
+ useEffect(() => {
194
+ chatOpenRef.current = chatOpen;
195
+ whiteboardOpenRef.current = whiteboardOpen;
196
+ }, [chatOpen, whiteboardOpen]);
149
197
  /** The raw capture from `getDisplayMedia()` - only ever used to stop it (releasing the OS's own share
150
198
  * indicator) and to notice the participant ending the share from the browser's own UI (`track.onended`). What's
151
199
  * actually shown and sent is `screenTransformRef`'s output (`screenStream` state) - see `handleToggleShare()`. */
@@ -172,7 +220,14 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
172
220
  }, []);
173
221
  useEffect(() => {
174
222
  let cancelled = false;
175
- const client = new GuestSignalingClient({ channel, token });
223
+ const client = new SignalingClient({
224
+ meetingUid: channel,
225
+ peerId,
226
+ token,
227
+ // The server closed the socket for good: being removed reads as the host's doing (the cooperative `kicked`
228
+ // usually got here first), anything else - the meeting ended, the grant is gone - as a connection error.
229
+ onClosed: (reason) => (reason === SIGNALING_EVICTED_REASON ? onLeave("The host removed you from this call.") : setConnectError(reason)),
230
+ });
176
231
  const manager = new MeshConnectionManager({
177
232
  selfUid,
178
233
  peerId,
@@ -186,6 +241,27 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
186
241
  localState: { audioOn: media.micOn, videoOn: media.cameraOn },
187
242
  });
188
243
  managerRef.current = manager;
244
+ const collab = new CollabSession({ channel: client, selfUid, peerId, selfName });
245
+ collabRef.current = collab;
246
+ const unsubscribeCollab = collab.onEvent((event) => {
247
+ if (event.type === "board") {
248
+ setBoardRevision(collab.board.revision);
249
+ if (!whiteboardOpenRef.current) {
250
+ setWhiteboardActivity(true);
251
+ }
252
+ return;
253
+ }
254
+ if (event.type === "cursors") {
255
+ setBoardCursors(collab.cursors);
256
+ return;
257
+ }
258
+ setChatEntries(collab.chat);
259
+ // What was said before this participant arrived (a catch-up), or a message only renamed, is not news to them.
260
+ if (!event.entry.self && !event.history && !event.renamed && !chatOpenRef.current) {
261
+ setUnreadChat((prev) => prev + 1);
262
+ setAnnouncement(`${event.entry.name}: ${event.entry.text.slice(0, 140)}`);
263
+ }
264
+ });
189
265
  const unsubscribe = manager.onEvent((event) => {
190
266
  switch (event.type) {
191
267
  case "participant-joined":
@@ -244,6 +320,8 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
244
320
  .connect()
245
321
  .then(() => {
246
322
  if (!cancelled) {
323
+ // Listening before the mesh says hello, so no answering hello is missed - see `CollabSession`.
324
+ collab.start();
247
325
  manager.start();
248
326
  setSignalingReady(true);
249
327
  }
@@ -262,6 +340,8 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
262
340
  cancelled = true;
263
341
  window.removeEventListener("pagehide", onPageHide);
264
342
  unsubscribe();
343
+ unsubscribeCollab();
344
+ collab.stop();
265
345
  manager.stop();
266
346
  client.close();
267
347
  for (const handle of Object.values(levelMetersRef.current)) {
@@ -309,6 +389,10 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
309
389
  void mediaRef.current.toggleMic();
310
390
  }
311
391
  }, [talkingStickActive, selfHasTalkingStick]);
392
+ // Talking stick also decides who may draw on the whiteboard: only the holder, while it is on - see `CollabSession`.
393
+ useEffect(() => {
394
+ collabRef.current?.setDrawPolicy(talkingStickActive ? (peer) => peer === talkingStickHolder : undefined);
395
+ }, [talkingStickActive, talkingStickHolder]);
312
396
  useEffect(() => {
313
397
  if (!presenterUid) {
314
398
  setActiveSpeakerUid((prev) => pickActiveSpeaker(levels, prev));
@@ -375,11 +459,33 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
375
459
  function handleToggleHand() {
376
460
  setHandRaised((prev) => !prev);
377
461
  }
462
+ /** Hides the local tile - the tile's own "…" menu calls this directly (one-directional: there is no "show
463
+ * self" from in-grid, since a hidden tile isn't there to click on). Re-showing it is the navbar "…" menu's
464
+ * `handleToggleSelfHidden()` instead. */
465
+ function handleHideSelf() {
466
+ setSelfHidden(true);
467
+ }
468
+ function handleToggleSelfHidden() {
469
+ setSelfHidden((prev) => !prev);
470
+ }
378
471
  function handleReaction(emoji) {
379
472
  if (managerRef.current?.sendReaction(emoji)) {
380
473
  showReaction(emoji, "You");
381
474
  }
382
475
  }
476
+ function handleToggleChat() {
477
+ setChatOpen((prev) => !prev);
478
+ setDrawerOpen(false);
479
+ setUnreadChat(0);
480
+ }
481
+ function handleToggleParticipants() {
482
+ setDrawerOpen((prev) => !prev);
483
+ setChatOpen(false);
484
+ }
485
+ function handleToggleWhiteboard() {
486
+ setWhiteboardOpen((prev) => !prev);
487
+ setWhiteboardActivity(false);
488
+ }
383
489
  function togglePin(uid) {
384
490
  setPinnedUid((prev) => (prev === uid ? undefined : uid));
385
491
  }
@@ -491,7 +597,7 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
491
597
  const otherUids = participants.map((p) => p.uid);
492
598
  // A pin or an active-speaker pick can name someone who has just left, until the state catches up.
493
599
  const stillHere = (uid) => (uid && otherUids.includes(uid) ? uid : undefined);
494
- const showFocusLayout = !!presenterUid || viewMode === "focus";
600
+ const showFocusLayout = !!presenterUid || viewMode === "focus" || whiteboardOpen;
495
601
  const mainUid = showFocusLayout
496
602
  ? computeMainUid({ presenterUid, pinnedUid: stillHere(pinnedUid), activeSpeakerUid: stillHere(activeSpeakerUid), otherUids })
497
603
  : undefined;
@@ -510,27 +616,37 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
510
616
  // so someone already in the call is not told they are "connecting" each time a newcomer arrives.
511
617
  const selfConnecting = !connectError && (!signalingReady || (!alone && participants.every((p) => p.transport === "connecting")));
512
618
  const remoteTile = (p, className) => (_jsx(ParticipantTile, { name: p.name, stream: remoteStreams[p.uid] ?? null, cameraOff: !p.videoOn, micMuted: !p.audioOn, handRaised: p.handRaised, transport: p.transport, status: p.transport === "connecting" ? "Awaiting connection…" : undefined, isFocused: mainUid === p.uid, contain: presenterUid === p.uid, className: className, onClick: () => togglePin(p.uid) }, p.uid));
513
- const selfTile = (className) => (_jsx(ParticipantTile, { name: selfName, stream: media.videoStream, isLocal: true, cameraOff: !media.cameraOn, micMuted: !media.micOn, handRaised: handRaised, status: selfConnecting ? "Connecting…" : undefined,
619
+ const selfTile = (className, canHide) => (_jsx(ParticipantTile, { name: selfName, stream: media.videoStream, isLocal: true, cameraOff: !media.cameraOn, micMuted: !media.micOn, handRaised: handRaised, status: selfConnecting ? "Connecting…" : undefined,
514
620
  // A custom background is a fixed picture, not a live reflection - mirroring it would show it backwards
515
621
  // to no one but the participant themselves (see `_ParticipantTile.tsx`'s doc comment).
516
- mirrored: media.filters.background !== "image", className: className }));
622
+ mirrored: media.filters.background !== "image", onHideSelf: canHide ? handleHideSelf : undefined, className: className }));
517
623
  const mainParticipant = participants.find((p) => p.uid === mainUid);
518
624
  const thumbnails = participants.filter((p) => p.uid !== mainUid);
519
625
  let stage;
520
- if (alone) {
521
- // Nobody else yet: the local participant fills the tile area, presenting or not.
626
+ if (whiteboardOpen && collabRef.current) {
627
+ // The board takes the main slot, everyone else's video the strip below it - see this module's doc comment.
628
+ stage = (_jsxs("div", { className: "h-full flex flex-col gap-2", children: [_jsx("div", { className: "flex-1 min-h-0", children: _jsx(Whiteboard, { session: collabRef.current, revision: boardRevision, cursors: boardCursors, canDraw: !talkingStickActive || selfHasTalkingStick, drawLockedTo: talkingStickHolderName, onClose: handleToggleWhiteboard }) }), participants.length > 0 && (_jsx("div", { className: "flex gap-2 overflow-x-auto h-24 shrink-0", "data-testid": "thumbnails", children: participants.map((p) => (_jsx("div", { className: "w-36 shrink-0", children: remoteTile(p, "h-full") }, p.uid))) }))] }));
629
+ }
630
+ else if (alone) {
631
+ // Nobody else yet: the local participant fills the tile area, presenting or not - and is never hideable,
632
+ // since there'd be nothing left to look at (see this module's doc comment on hiding the local tile).
522
633
  stage = isPresenting ? (_jsx(ParticipantTile, { name: selfName, stream: screenStream, isLocal: true, contain: true, isFocused: true, className: "h-full" })) : (selfTile("h-full"));
523
634
  }
524
635
  else if (showFocusLayout && (mainParticipant || isPresenting)) {
525
636
  stage = (_jsxs("div", { className: "h-full flex flex-col gap-2", children: [_jsx("div", { className: "flex-1 min-h-0", "data-testid": "main-tile", children: mainParticipant ? (remoteTile(mainParticipant, "h-full")) : (_jsx(ParticipantTile, { name: selfName, stream: screenStream, isLocal: true, contain: true, isFocused: true, className: "h-full" })) }), thumbnails.length > 0 && (_jsx("div", { className: "flex gap-2 overflow-x-auto h-24 shrink-0", "data-testid": "thumbnails", children: thumbnails.map((p) => (_jsx("div", { className: "w-36 shrink-0", children: remoteTile(p, "h-full") }, p.uid))) }))] }));
526
637
  }
527
638
  else {
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")) }));
639
+ // Plain grid, nobody presenting: the local tile joins the grid as an ordinary tile (unless hidden) instead
640
+ // of floating in a corner - see this module's doc comment. Each tile keeps a video aspect ratio
641
+ // (`aspect-video`) rather than being stretched to fill whatever height an equal-row grid would otherwise
642
+ // force on it, so rows size to their own content (`content-start`, with a scrollbar if that overflows)
643
+ // instead of a fixed `auto-rows-fr` share of the stage.
644
+ stage = (_jsxs("div", { className: "h-full content-start overflow-y-auto grid gap-2 [grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr))]", children: [!selfHidden && selfTile("aspect-video", true), participants.map((p) => remoteTile(p, "aspect-video"))] }));
529
645
  }
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) => {
646
+ 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, {}) })), _jsxs("button", { type: "button", className: `relative w-9 h-9 flex items-center justify-center rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 ${chatOpen ? "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]" : "bg-[#3c4043] hover:bg-[#4b4f53]"}`, "aria-label": unreadChat > 0 ? `Chat, ${unreadChat} unread` : "Chat", "aria-haspopup": "dialog", "aria-expanded": chatOpen, onClick: handleToggleChat, children: [_jsx(ChatIcon, {}), unreadChat > 0 && (_jsx("span", { className: "absolute -top-1 -right-1 min-w-5 h-5 px-1 flex items-center justify-center rounded-full bg-[#d93025] text-white text-[11px] font-semibold", "data-testid": "chat-unread", children: unreadChat > 99 ? "99+" : unreadChat }))] }), _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: handleToggleParticipants, 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")), whiteboardOpen: whiteboardOpen, whiteboardActivity: whiteboardActivity, onToggleWhiteboard: handleToggleWhiteboard, micLocked: micLocked, selfHidden: selfHidden, onToggleSelfHidden: handleToggleSelfHidden, isHost: isHost, transportMode: transportMode, onSetTransportMode: handleSetTransportMode, onOpenDiagnostics: () => setDiagnosticsOpen(true), onOpenSettings: () => setSettingsOpen(true), onLeave: onLeave }) }), !alone && !selfHidden && showFocusLayout && (_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", true) })), _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) => {
531
647
  const stream = remoteStreams[p.uid];
532
648
  return stream ? _jsx(RemoteAudio, { stream: stream, onBlocked: handleAudioBlocked }, `${p.uid}:${audioNonce}`) : null;
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) }))] }));
649
+ }), _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) })), chatOpen && collabRef.current && (_jsx(ChatPanel, { entries: chatEntries, onSend: (text) => collabRef.current.sendChat(text), onClose: handleToggleChat })), 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) }))] }));
534
650
  }
535
651
  /** Plays one remote participant's stream through a hidden `<audio>` element. `onBlocked` fires if the browser
536
652
  * refuses to start it (an autoplay policy) - the view then asks for a click. */
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The in-call chat drawer, beside the call like the participants drawer (`_ParticipantsDrawer.tsx`) - `_CallView.tsx`
3
+ * shows one or the other. The messages come from `CollabSession` (`apps/shared/collab/CollabSession.ts`) and are
4
+ * rendered as Markdown by `ChatMarkdown` (bare links become links, image links become images).
5
+ *
6
+ * The composer is a textarea: Enter sends, Shift+Enter starts a new line. Its emoji button opens the web client's own
7
+ * emoji picker and inserts at the cursor; its GIF button opens the web client's own Giphy picker, and picking a GIF
8
+ * sends it at once as a message holding just its address - which every participant's chat then shows as the GIF
9
+ * itself. Both pickers are the very components the mail composer uses (`@rapidmx/web-client`'s `EmojiPicker` and
10
+ * `GifPicker`); GIF search goes through the server's own Giphy proxy, which a guest's token authenticates like a
11
+ * signed-in user's session.
12
+ *
13
+ * The list follows new messages only while it is already scrolled to the bottom, so reading back through the history
14
+ * isn't interrupted by someone else's message.
15
+ */
16
+ import React from "react";
17
+ import type { ChatEntry } from "../shared/collab/CollabSession.js";
18
+ export interface ChatPanelProps {
19
+ entries: readonly ChatEntry[];
20
+ /** Sends a message; `false` when it could not be sent (blank or too long). */
21
+ onSend: (text: string) => boolean;
22
+ onClose: () => void;
23
+ }
24
+ export default function ChatPanel({ entries, onSend, onClose }: ChatPanelProps): React.JSX.Element;