@rapidmx/meet-plugin 0.15.0 → 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 -483
- package/apps/meet/_CallView.tsx +1060 -924
- package/apps/meet/_ChatPanel.tsx +194 -0
- package/apps/meet/_ColorWheel.tsx +136 -0
- package/apps/meet/_MeetLobby.tsx +224 -224
- package/apps/meet/_ParticipantTile.tsx +243 -242
- package/apps/meet/_Whiteboard.tsx +577 -0
- package/apps/meet/_icons.tsx +135 -90
- 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/RelayClient.ts +2 -1
- package/apps/shared/relay/RelayTransport.ts +2 -1
- package/apps/shared/relay/relayEnv.ts +1 -1
- package/apps/shared/signaling/SignalingClient.ts +335 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +971 -971
- package/apps/shared/webrtc/types.ts +335 -316
- package/dist/apps/meet/[token].js +1 -1
- package/dist/apps/meet/_CallControls.d.ts +12 -4
- package/dist/apps/meet/_CallControls.js +5 -5
- package/dist/apps/meet/_CallView.d.ts +19 -4
- package/dist/apps/meet/_CallView.js +100 -11
- 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/_ParticipantTile.d.ts +2 -1
- package/dist/apps/meet/_ParticipantTile.js +1 -1
- package/dist/apps/meet/_Whiteboard.d.ts +64 -0
- package/dist/apps/meet/_Whiteboard.js +312 -0
- package/dist/apps/meet/_icons.d.ts +12 -0
- package/dist/apps/meet/_icons.js +12 -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/RelayClient.js +2 -1
- package/dist/apps/shared/relay/RelayTransport.d.ts +2 -1
- package/dist/apps/shared/relay/RelayTransport.js +2 -1
- 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 +8 -8
- package/dist/apps/shared/webrtc/types.d.ts +38 -24
- 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/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/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
|
|
@@ -43,9 +43,17 @@ export interface CallControlsProps {
|
|
|
43
43
|
onToggleHand: () => void;
|
|
44
44
|
onReaction: (emoji: string) => void;
|
|
45
45
|
viewMode: CallViewMode;
|
|
46
|
-
/** Disabled
|
|
47
|
-
*
|
|
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. */
|
|
48
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;
|
|
49
57
|
/** True while talking-stick mode is on and this participant doesn't currently hold it - see `_CallView.tsx`'s
|
|
50
58
|
* doc comment on talking-stick mode. Disables the microphone toggle (not the device-picker chevron, which
|
|
51
59
|
* doesn't change whether anyone can hear them) rather than merely nudging it, unlike a `mute-request`. */
|
|
@@ -66,4 +74,4 @@ export interface CallControlsProps {
|
|
|
66
74
|
export declare function LevelBars({ level }: {
|
|
67
75
|
level: number;
|
|
68
76
|
}): React.JSX.Element;
|
|
69
|
-
export default function CallControls({ media, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, isHost, transportMode, onSetTransportMode, onOpenDiagnostics, onOpenSettings, selfHidden, onToggleSelfHidden, 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
|
|
@@ -32,7 +32,7 @@ import { useEffect, useRef, useState } from "react";
|
|
|
32
32
|
import { filtersActive } from "../shared/media/filters/filterTypes.js";
|
|
33
33
|
import { REACTION_EMOJIS } from "../shared/webrtc/types.js";
|
|
34
34
|
import EffectsPanel from "./_EffectsPanel.js";
|
|
35
|
-
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";
|
|
36
36
|
/** The "…" menu's "Connection method" options, in display order - see `TransportMode`'s own doc comment. */
|
|
37
37
|
const TRANSPORT_MODE_OPTIONS = [
|
|
38
38
|
{ mode: "auto", label: "Auto" },
|
|
@@ -48,7 +48,7 @@ const ACTIVE = "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]";
|
|
|
48
48
|
export function LevelBars({ level }) {
|
|
49
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))) }));
|
|
50
50
|
}
|
|
51
|
-
export default function CallControls({ media, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, micLocked, isHost, transportMode, onSetTransportMode, onOpenDiagnostics, onOpenSettings, selfHidden, onToggleSelfHidden, 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, }) {
|
|
52
52
|
const [openMenu, setOpenMenu] = useState(null);
|
|
53
53
|
const barRef = useRef(null);
|
|
54
54
|
const shareDisabled = !isPresenting && !!presentingElsewhereName;
|
|
@@ -77,10 +77,10 @@ export default function CallControls({ media, participants, isPresenting, presen
|
|
|
77
77
|
function toggleMenu(menu) {
|
|
78
78
|
setOpenMenu((prev) => (prev === menu ? null : menu));
|
|
79
79
|
}
|
|
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("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: () => {
|
|
81
81
|
onReaction(emoji);
|
|
82
82
|
setOpenMenu(null);
|
|
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
|
|
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: () => {
|
|
84
84
|
onOpenDiagnostics();
|
|
85
85
|
setOpenMenu(null);
|
|
86
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: () => {
|
|
@@ -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,7 +11,10 @@
|
|
|
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.
|
|
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.
|
|
15
18
|
*
|
|
16
19
|
* ## Where the local tile appears
|
|
17
20
|
*
|
|
@@ -61,6 +64,18 @@
|
|
|
61
64
|
* `MeshConnectionManager`'s own doc comment on why that needs no extra code - the header chip then says nobody has
|
|
62
65
|
* the floor, until the host picks someone (or themselves) again.
|
|
63
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
|
+
*
|
|
64
79
|
* ## Diagnostics, connection method and settings
|
|
65
80
|
*
|
|
66
81
|
* All three are opened from `_CallControls.tsx`'s own "…" menu (its doc comment covers the menu itself) via
|
|
@@ -78,8 +93,8 @@ import type { LocalMedia } from "../shared/media/useLocalMedia.js";
|
|
|
78
93
|
export interface CallViewProps {
|
|
79
94
|
channel: string;
|
|
80
95
|
/** Omitted when the caller already authenticated as a real RapidMX identity in `join()` - there is then no
|
|
81
|
-
* guest token to hand `
|
|
82
|
-
*
|
|
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). */
|
|
83
98
|
token?: string;
|
|
84
99
|
selfUid: string;
|
|
85
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,7 +16,10 @@ 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.
|
|
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.
|
|
20
23
|
*
|
|
21
24
|
* ## Where the local tile appears
|
|
22
25
|
*
|
|
@@ -66,6 +69,18 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
66
69
|
* `MeshConnectionManager`'s own doc comment on why that needs no extra code - the header chip then says nobody has
|
|
67
70
|
* the floor, until the host picks someone (or themselves) again.
|
|
68
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
|
+
*
|
|
69
84
|
* ## Diagnostics, connection method and settings
|
|
70
85
|
*
|
|
71
86
|
* All three are opened from `_CallControls.tsx`'s own "…" menu (its doc comment covers the menu itself) via
|
|
@@ -87,15 +102,18 @@ import { NO_SCREEN_TRANSFORM, ScreenTransformProcessor, rotateClockwise } from "
|
|
|
87
102
|
import { createBrowserPeerConnection } from "../shared/webrtc/realPeerConnection.js";
|
|
88
103
|
import { MeshConnectionManager } from "../shared/webrtc/MeshConnectionManager.js";
|
|
89
104
|
import { createRelayTransport } from "../shared/relay/RelayTransport.js";
|
|
90
|
-
import {
|
|
105
|
+
import { SIGNALING_EVICTED_REASON, SignalingClient } from "../shared/signaling/SignalingClient.js";
|
|
106
|
+
import { CollabSession } from "../shared/collab/CollabSession.js";
|
|
91
107
|
import CallControls from "./_CallControls.js";
|
|
92
|
-
import
|
|
108
|
+
import ChatPanel from "./_ChatPanel.js";
|
|
109
|
+
import { BatonIcon, ChatIcon } from "./_icons.js";
|
|
93
110
|
import { admitParticipant, denyParticipant, kickParticipant, listWaitingParticipants, setForceMuteOnJoin as apiSetForceMuteOnJoin, setMeetingPassword, setWaitingRoomEnabled as apiSetWaitingRoomEnabled, } from "./_meetApi.js";
|
|
94
111
|
import DiagnosticsPanel from "./_DiagnosticsPanel.js";
|
|
95
112
|
import DiagnosticsWindow from "./_DiagnosticsWindow.js";
|
|
96
113
|
import ParticipantTile from "./_ParticipantTile.js";
|
|
97
114
|
import ParticipantsDrawer from "./_ParticipantsDrawer.js";
|
|
98
115
|
import SettingsModal from "./_SettingsModal.js";
|
|
116
|
+
import Whiteboard from "./_Whiteboard.js";
|
|
99
117
|
/** How long a reaction floats on screen. */
|
|
100
118
|
const REACTION_MS = 4000;
|
|
101
119
|
/** The most reactions shown at once - a burst beyond this drops the oldest. */
|
|
@@ -160,7 +178,22 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
160
178
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
161
179
|
const [transportMode, setTransportModeState] = useState("auto");
|
|
162
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([]);
|
|
163
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]);
|
|
164
197
|
/** The raw capture from `getDisplayMedia()` - only ever used to stop it (releasing the OS's own share
|
|
165
198
|
* indicator) and to notice the participant ending the share from the browser's own UI (`track.onended`). What's
|
|
166
199
|
* actually shown and sent is `screenTransformRef`'s output (`screenStream` state) - see `handleToggleShare()`. */
|
|
@@ -187,7 +220,14 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
187
220
|
}, []);
|
|
188
221
|
useEffect(() => {
|
|
189
222
|
let cancelled = false;
|
|
190
|
-
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
|
+
});
|
|
191
231
|
const manager = new MeshConnectionManager({
|
|
192
232
|
selfUid,
|
|
193
233
|
peerId,
|
|
@@ -201,6 +241,27 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
201
241
|
localState: { audioOn: media.micOn, videoOn: media.cameraOn },
|
|
202
242
|
});
|
|
203
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
|
+
});
|
|
204
265
|
const unsubscribe = manager.onEvent((event) => {
|
|
205
266
|
switch (event.type) {
|
|
206
267
|
case "participant-joined":
|
|
@@ -259,6 +320,8 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
259
320
|
.connect()
|
|
260
321
|
.then(() => {
|
|
261
322
|
if (!cancelled) {
|
|
323
|
+
// Listening before the mesh says hello, so no answering hello is missed - see `CollabSession`.
|
|
324
|
+
collab.start();
|
|
262
325
|
manager.start();
|
|
263
326
|
setSignalingReady(true);
|
|
264
327
|
}
|
|
@@ -277,6 +340,8 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
277
340
|
cancelled = true;
|
|
278
341
|
window.removeEventListener("pagehide", onPageHide);
|
|
279
342
|
unsubscribe();
|
|
343
|
+
unsubscribeCollab();
|
|
344
|
+
collab.stop();
|
|
280
345
|
manager.stop();
|
|
281
346
|
client.close();
|
|
282
347
|
for (const handle of Object.values(levelMetersRef.current)) {
|
|
@@ -324,6 +389,10 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
324
389
|
void mediaRef.current.toggleMic();
|
|
325
390
|
}
|
|
326
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]);
|
|
327
396
|
useEffect(() => {
|
|
328
397
|
if (!presenterUid) {
|
|
329
398
|
setActiveSpeakerUid((prev) => pickActiveSpeaker(levels, prev));
|
|
@@ -404,6 +473,19 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
404
473
|
showReaction(emoji, "You");
|
|
405
474
|
}
|
|
406
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
|
+
}
|
|
407
489
|
function togglePin(uid) {
|
|
408
490
|
setPinnedUid((prev) => (prev === uid ? undefined : uid));
|
|
409
491
|
}
|
|
@@ -515,7 +597,7 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
515
597
|
const otherUids = participants.map((p) => p.uid);
|
|
516
598
|
// A pin or an active-speaker pick can name someone who has just left, until the state catches up.
|
|
517
599
|
const stillHere = (uid) => (uid && otherUids.includes(uid) ? uid : undefined);
|
|
518
|
-
const showFocusLayout = !!presenterUid || viewMode === "focus";
|
|
600
|
+
const showFocusLayout = !!presenterUid || viewMode === "focus" || whiteboardOpen;
|
|
519
601
|
const mainUid = showFocusLayout
|
|
520
602
|
? computeMainUid({ presenterUid, pinnedUid: stillHere(pinnedUid), activeSpeakerUid: stillHere(activeSpeakerUid), otherUids })
|
|
521
603
|
: undefined;
|
|
@@ -541,7 +623,11 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
541
623
|
const mainParticipant = participants.find((p) => p.uid === mainUid);
|
|
542
624
|
const thumbnails = participants.filter((p) => p.uid !== mainUid);
|
|
543
625
|
let stage;
|
|
544
|
-
if (
|
|
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) {
|
|
545
631
|
// Nobody else yet: the local participant fills the tile area, presenting or not - and is never hideable,
|
|
546
632
|
// since there'd be nothing left to look at (see this module's doc comment on hiding the local tile).
|
|
547
633
|
stage = isPresenting ? (_jsx(ParticipantTile, { name: selfName, stream: screenStream, isLocal: true, contain: true, isFocused: true, className: "h-full" })) : (selfTile("h-full"));
|
|
@@ -551,13 +637,16 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
551
637
|
}
|
|
552
638
|
else {
|
|
553
639
|
// Plain grid, nobody presenting: the local tile joins the grid as an ordinary tile (unless hidden) instead
|
|
554
|
-
// of floating in a corner - see this module's doc comment.
|
|
555
|
-
|
|
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"))] }));
|
|
556
645
|
}
|
|
557
|
-
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) => {
|
|
558
647
|
const stream = remoteStreams[p.uid];
|
|
559
648
|
return stream ? _jsx(RemoteAudio, { stream: stream, onBlocked: handleAudioBlocked }, `${p.uid}:${audioNonce}`) : null;
|
|
560
|
-
}), _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) }))] }));
|
|
561
650
|
}
|
|
562
651
|
/** Plays one remote participant's stream through a hidden `<audio>` element. `onBlocked` fires if the browser
|
|
563
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;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
/**
|
|
7
|
+
* The in-call chat drawer, beside the call like the participants drawer (`_ParticipantsDrawer.tsx`) - `_CallView.tsx`
|
|
8
|
+
* shows one or the other. The messages come from `CollabSession` (`apps/shared/collab/CollabSession.ts`) and are
|
|
9
|
+
* rendered as Markdown by `ChatMarkdown` (bare links become links, image links become images).
|
|
10
|
+
*
|
|
11
|
+
* The composer is a textarea: Enter sends, Shift+Enter starts a new line. Its emoji button opens the web client's own
|
|
12
|
+
* emoji picker and inserts at the cursor; its GIF button opens the web client's own Giphy picker, and picking a GIF
|
|
13
|
+
* sends it at once as a message holding just its address - which every participant's chat then shows as the GIF
|
|
14
|
+
* itself. Both pickers are the very components the mail composer uses (`@rapidmx/web-client`'s `EmojiPicker` and
|
|
15
|
+
* `GifPicker`); GIF search goes through the server's own Giphy proxy, which a guest's token authenticates like a
|
|
16
|
+
* signed-in user's session.
|
|
17
|
+
*
|
|
18
|
+
* The list follows new messages only while it is already scrolled to the bottom, so reading back through the history
|
|
19
|
+
* isn't interrupted by someone else's message.
|
|
20
|
+
*/
|
|
21
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
22
|
+
import EmojiPicker from "@rapidmx/web-client/shared/components/mail/compose/EmojiPicker.js";
|
|
23
|
+
import GifPicker from "@rapidmx/web-client/shared/components/mail/compose/GifPicker.js";
|
|
24
|
+
import ChatMarkdown from "../shared/collab/chatMarkdown.js";
|
|
25
|
+
import { MAX_CHAT_LENGTH } from "../shared/collab/protocol.js";
|
|
26
|
+
import { EmojiIcon, GifIcon, SendIcon } from "./_icons.js";
|
|
27
|
+
/** How close to the bottom (px) still counts as "at the bottom" for following new messages. */
|
|
28
|
+
const STICK_THRESHOLD_PX = 48;
|
|
29
|
+
/** Within this many characters of `MAX_CHAT_LENGTH`, the composer shows how many are left. */
|
|
30
|
+
const COUNTER_THRESHOLD = 200;
|
|
31
|
+
/** Two messages from the same person this close together (ms) share one name heading. */
|
|
32
|
+
const GROUP_WINDOW_MS = 2 * 60000;
|
|
33
|
+
const ICON_BUTTON = "w-9 h-9 shrink-0 flex items-center justify-center rounded-full hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 disabled:opacity-40 disabled:cursor-not-allowed";
|
|
34
|
+
function formatTime(ms) {
|
|
35
|
+
return new Date(ms).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
36
|
+
}
|
|
37
|
+
export default function ChatPanel({ entries, onSend, onClose }) {
|
|
38
|
+
const [draft, setDraft] = useState("");
|
|
39
|
+
const [picker, setPicker] = useState(null);
|
|
40
|
+
const panelRef = useRef(null);
|
|
41
|
+
const listRef = useRef(null);
|
|
42
|
+
const inputRef = useRef(null);
|
|
43
|
+
const emojiButtonRef = useRef(null);
|
|
44
|
+
const gifButtonRef = useRef(null);
|
|
45
|
+
const stickRef = useRef(true);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
inputRef.current?.focus();
|
|
48
|
+
}, []);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
// Only an Escape meant for the chat - with focus in it - closes it, not one closing a menu elsewhere.
|
|
51
|
+
const onKeyDown = (event) => {
|
|
52
|
+
if (event.key === "Escape" && !picker && panelRef.current?.contains(document.activeElement)) {
|
|
53
|
+
onClose();
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
document.addEventListener("keydown", onKeyDown);
|
|
57
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
58
|
+
}, [onClose, picker]);
|
|
59
|
+
useLayoutEffect(() => {
|
|
60
|
+
const list = listRef.current;
|
|
61
|
+
if (list && stickRef.current) {
|
|
62
|
+
list.scrollTop = list.scrollHeight;
|
|
63
|
+
}
|
|
64
|
+
}, [entries]);
|
|
65
|
+
function handleScroll() {
|
|
66
|
+
const list = listRef.current;
|
|
67
|
+
stickRef.current = list.scrollHeight - list.scrollTop - list.clientHeight <= STICK_THRESHOLD_PX;
|
|
68
|
+
}
|
|
69
|
+
function send(text) {
|
|
70
|
+
if (onSend(text)) {
|
|
71
|
+
stickRef.current = true;
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
function handleSubmit(event) {
|
|
77
|
+
event?.preventDefault();
|
|
78
|
+
if (send(draft)) {
|
|
79
|
+
setDraft("");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function handleKeyDown(event) {
|
|
83
|
+
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
handleSubmit();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function insertEmoji(native) {
|
|
89
|
+
const input = inputRef.current;
|
|
90
|
+
const start = input.selectionStart ?? draft.length;
|
|
91
|
+
const end = input.selectionEnd ?? draft.length;
|
|
92
|
+
setPicker(null);
|
|
93
|
+
const next = draft.slice(0, start) + native + draft.slice(end);
|
|
94
|
+
// Past the limit the emoji is left out whole, never cut in half.
|
|
95
|
+
if (next.length > MAX_CHAT_LENGTH) {
|
|
96
|
+
input.focus();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
setDraft(next);
|
|
100
|
+
requestAnimationFrame(() => {
|
|
101
|
+
input.focus();
|
|
102
|
+
input.setSelectionRange(start + native.length, start + native.length);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function sendGif(url) {
|
|
106
|
+
setPicker(null);
|
|
107
|
+
send(url);
|
|
108
|
+
inputRef.current?.focus();
|
|
109
|
+
}
|
|
110
|
+
const remaining = MAX_CHAT_LENGTH - draft.length;
|
|
111
|
+
return (_jsxs("aside", { ref: panelRef, role: "dialog", "aria-label": "Chat", className: "relative z-10 shrink-0 w-80 max-w-[85vw] h-full flex flex-col bg-[#2b2d30] text-white shadow-xl border-l border-white/10", children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-3 border-b border-white/10", children: [_jsx("h2", { className: "text-sm font-medium", children: "Chat" }), _jsx("button", { type: "button", className: ICON_BUTTON, "aria-label": "Close chat", onClick: onClose, children: "\u2715" })] }), _jsxs("div", { ref: listRef, onScroll: handleScroll, className: "flex-1 min-h-0 overflow-y-auto px-3 py-2", role: "log", "aria-label": "Messages", "aria-live": "polite", children: [entries.length === 0 && _jsx("p", { className: "mt-6 text-center text-sm text-white/60", children: "Messages are visible to everyone in the call and disappear when it ends." }), entries.map((entry, index) => {
|
|
112
|
+
const previous = entries[index - 1];
|
|
113
|
+
const grouped = !!previous && previous.peer === entry.peer && entry.sentAt - previous.sentAt < GROUP_WINDOW_MS && entry.sentAt >= previous.sentAt;
|
|
114
|
+
return (_jsxs("div", { className: grouped ? "mt-1" : "mt-3", "data-testid": "chat-message", children: [!grouped && (_jsxs("p", { className: "flex items-baseline gap-2 text-xs", children: [_jsx("span", { className: "font-semibold text-white", children: entry.self ? "You" : entry.name }), _jsx("span", { className: "text-white/50", children: formatTime(entry.sentAt) })] })), _jsx("div", { className: "mt-0.5 text-white/90", children: _jsx(ChatMarkdown, { text: entry.text }) })] }, entry.key));
|
|
115
|
+
})] }), _jsxs("form", { onSubmit: handleSubmit, className: "shrink-0 border-t border-white/10 p-2", children: [_jsxs("div", { className: "flex items-end gap-1 rounded-2xl bg-[#3c4043] px-1 py-1", children: [_jsx("button", { ref: emojiButtonRef, type: "button", className: ICON_BUTTON, "aria-label": "Insert emoji", "aria-expanded": picker === "emoji", onClick: () => setPicker((prev) => (prev === "emoji" ? null : "emoji")), children: _jsx(EmojiIcon, {}) }), _jsx("button", { ref: gifButtonRef, type: "button", className: ICON_BUTTON, "aria-label": "Send a GIF", "aria-expanded": picker === "gif", onClick: () => setPicker((prev) => (prev === "gif" ? null : "gif")), children: _jsx(GifIcon, {}) }), _jsx("textarea", { ref: inputRef, rows: 1, value: draft, maxLength: MAX_CHAT_LENGTH, "aria-label": "Message", placeholder: "Send a message", className: "flex-1 min-w-0 max-h-32 resize-none bg-transparent py-2 px-1 text-sm placeholder:text-white/50 focus:outline-none [field-sizing:content]", onChange: (event) => setDraft(event.target.value), onKeyDown: handleKeyDown }), _jsx("button", { type: "submit", className: ICON_BUTTON, "aria-label": "Send message", disabled: !draft.trim(), children: _jsx(SendIcon, {}) })] }), _jsxs("p", { className: "mt-1 px-2 flex justify-between gap-2 text-[11px] text-white/50", children: [_jsx("span", { children: "**bold** *italic* `code` - Shift+Enter for a new line" }), remaining <= COUNTER_THRESHOLD && _jsxs("span", { "aria-live": "polite", children: [remaining, " left"] })] })] }), picker === "emoji" && _jsx(EmojiPicker, { anchorRef: emojiButtonRef, onSelect: insertEmoji, onClose: () => setPicker(null) }), picker === "gif" && _jsx(GifPicker, { anchorRef: gifButtonRef, onSelect: sendGif, onClose: () => setPicker(null) })] }));
|
|
116
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whiteboard's custom color picker: a hue/saturation wheel (hue around it, saturation out from its center) and a
|
|
3
|
+
* brightness slider below it, for any color the swatches don't offer. Dragging on the wheel, or using the arrow keys
|
|
4
|
+
* on it (left/right turn the hue, up/down change the saturation), picks a color; `onChange` gets every pick as
|
|
5
|
+
* `#rrggbb`.
|
|
6
|
+
*/
|
|
7
|
+
import React from "react";
|
|
8
|
+
export interface ColorWheelProps {
|
|
9
|
+
color: string;
|
|
10
|
+
onChange: (color: string) => void;
|
|
11
|
+
/** The wheel's diameter, in CSS pixels. */
|
|
12
|
+
size?: number;
|
|
13
|
+
}
|
|
14
|
+
export default function ColorWheel({ color, onChange, size }: ColorWheelProps): React.JSX.Element;
|