@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.
- package/README.md +35 -55
- package/apps/meet/[token].tsx +341 -341
- package/apps/meet/_CallControls.tsx +508 -463
- package/apps/meet/_CallView.tsx +1060 -891
- package/apps/meet/_ChatPanel.tsx +194 -0
- package/apps/meet/_ColorWheel.tsx +136 -0
- package/apps/meet/_DiagnosticsPanel.tsx +65 -7
- package/apps/meet/_MeetLobby.tsx +224 -224
- package/apps/meet/_ParticipantTile.tsx +243 -169
- package/apps/meet/_ParticipantsDrawer.tsx +10 -7
- package/apps/meet/_Whiteboard.tsx +577 -0
- package/apps/meet/_icons.tsx +135 -80
- package/apps/meet/_meetApi.ts +213 -212
- package/apps/shared/collab/CollabSession.ts +675 -0
- package/apps/shared/collab/WhiteboardModel.ts +216 -0
- package/apps/shared/collab/chatMarkdown.tsx +375 -0
- package/apps/shared/collab/color.ts +67 -0
- package/apps/shared/collab/protocol.ts +321 -0
- package/apps/shared/collab/whiteboardRender.ts +311 -0
- package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
- package/apps/shared/media/filters/mlModels.ts +174 -174
- package/apps/shared/relay/AudioPlayer.ts +29 -0
- package/apps/shared/relay/AudioSender.ts +23 -0
- package/apps/shared/relay/RelayClient.ts +54 -2
- package/apps/shared/relay/RelayReceiver.ts +43 -2
- package/apps/shared/relay/RelaySender.ts +23 -1
- package/apps/shared/relay/RelayTransport.ts +21 -1
- package/apps/shared/relay/SilenceMeter.ts +59 -0
- package/apps/shared/relay/relayEnv.ts +1 -1
- package/apps/shared/signaling/SignalingClient.ts +335 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +971 -949
- package/apps/shared/webrtc/types.ts +335 -247
- package/dist/apps/meet/[token].js +1 -1
- package/dist/apps/meet/_CallControls.d.ts +21 -6
- package/dist/apps/meet/_CallControls.js +14 -8
- package/dist/apps/meet/_CallView.d.ts +36 -7
- package/dist/apps/meet/_CallView.js +132 -16
- package/dist/apps/meet/_ChatPanel.d.ts +24 -0
- package/dist/apps/meet/_ChatPanel.js +116 -0
- package/dist/apps/meet/_ColorWheel.d.ts +14 -0
- package/dist/apps/meet/_ColorWheel.js +82 -0
- package/dist/apps/meet/_DiagnosticsPanel.d.ts +5 -3
- package/dist/apps/meet/_DiagnosticsPanel.js +17 -1
- package/dist/apps/meet/_ParticipantTile.d.ts +11 -3
- package/dist/apps/meet/_ParticipantTile.js +36 -5
- package/dist/apps/meet/_ParticipantsDrawer.js +3 -3
- package/dist/apps/meet/_Whiteboard.d.ts +64 -0
- package/dist/apps/meet/_Whiteboard.js +312 -0
- package/dist/apps/meet/_icons.d.ts +16 -0
- package/dist/apps/meet/_icons.js +16 -0
- package/dist/apps/meet/_meetApi.d.ts +8 -7
- package/dist/apps/meet/_meetApi.js +2 -2
- package/dist/apps/shared/collab/CollabSession.d.ts +208 -0
- package/dist/apps/shared/collab/CollabSession.js +559 -0
- package/dist/apps/shared/collab/WhiteboardModel.d.ts +54 -0
- package/dist/apps/shared/collab/WhiteboardModel.js +197 -0
- package/dist/apps/shared/collab/chatMarkdown.d.ts +65 -0
- package/dist/apps/shared/collab/chatMarkdown.js +311 -0
- package/dist/apps/shared/collab/color.d.ts +18 -0
- package/dist/apps/shared/collab/color.js +59 -0
- package/dist/apps/shared/collab/protocol.d.ts +157 -0
- package/dist/apps/shared/collab/protocol.js +222 -0
- package/dist/apps/shared/collab/whiteboardRender.d.ts +31 -0
- package/dist/apps/shared/collab/whiteboardRender.js +268 -0
- package/dist/apps/shared/relay/AudioPlayer.d.ts +9 -0
- package/dist/apps/shared/relay/AudioPlayer.js +24 -4
- package/dist/apps/shared/relay/AudioSender.d.ts +8 -0
- package/dist/apps/shared/relay/AudioSender.js +20 -4
- package/dist/apps/shared/relay/RelayClient.d.ts +16 -0
- package/dist/apps/shared/relay/RelayClient.js +46 -2
- package/dist/apps/shared/relay/RelayReceiver.d.ts +5 -0
- package/dist/apps/shared/relay/RelayReceiver.js +38 -6
- package/dist/apps/shared/relay/RelaySender.d.ts +7 -0
- package/dist/apps/shared/relay/RelaySender.js +20 -5
- package/dist/apps/shared/relay/RelayTransport.d.ts +6 -1
- package/dist/apps/shared/relay/RelayTransport.js +16 -1
- package/dist/apps/shared/relay/SilenceMeter.d.ts +25 -0
- package/dist/apps/shared/relay/SilenceMeter.js +55 -0
- package/dist/apps/shared/relay/relayEnv.d.ts +1 -1
- package/dist/apps/shared/signaling/SignalingClient.d.ts +95 -0
- package/dist/apps/shared/signaling/SignalingClient.js +278 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +7 -7
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +28 -9
- package/dist/apps/shared/webrtc/types.d.ts +104 -26
- package/dist/apps/shared/webrtc/types.js +3 -4
- package/dist/lib/routes/BaseVideoMeetingRoute.js +171 -69
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
- package/dist/lib/util/RelayHub.js +15 -2
- package/dist/lib/util/RelayHub.js.map +1 -1
- package/dist/lib/util/SignalingHub.js +339 -0
- package/dist/lib/util/SignalingHub.js.map +1 -0
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +68 -46
- package/dist/types/util/RelayHub.d.ts +5 -2
- package/dist/types/util/SignalingHub.d.ts +112 -0
- package/package.json +208 -208
- package/apps/shared/push/GuestSignalingClient.ts +0 -359
- package/dist/apps/shared/push/GuestSignalingClient.d.ts +0 -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 `
|
|
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
|
|
21
|
-
*
|
|
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
|
|
44
|
-
*
|
|
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
|
|
26
|
-
*
|
|
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
|
|
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("
|
|
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
|
-
* (`
|
|
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.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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 `
|
|
68
|
-
*
|
|
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
|
-
* (`
|
|
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.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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 {
|
|
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
|
|
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
|
|
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 (
|
|
521
|
-
//
|
|
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
|
-
|
|
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:
|
|
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;
|