@rapidmx/meet-plugin 0.1.0 → 0.3.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 +3 -3
- package/apps/meet/[token].tsx +51 -46
- package/apps/meet/_CallControls.tsx +255 -32
- package/apps/meet/_CallView.tsx +275 -115
- package/apps/meet/_MeetLobby.tsx +111 -142
- package/apps/meet/_ParticipantTile.tsx +51 -19
- package/apps/meet/_icons.tsx +54 -0
- package/apps/shared/media/chime.ts +74 -0
- package/apps/shared/media/deviceMedia.ts +3 -13
- package/apps/shared/media/useLocalMedia.ts +337 -0
- package/apps/shared/push/GuestSignalingClient.ts +2 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +212 -44
- package/apps/shared/webrtc/realPeerConnection.ts +16 -3
- package/apps/shared/webrtc/types.ts +52 -15
- package/dist/apps/meet/[token].js +31 -19
- package/dist/apps/meet/_CallControls.d.ts +20 -7
- package/dist/apps/meet/_CallControls.js +72 -3
- package/dist/apps/meet/_CallView.d.ts +23 -4
- package/dist/apps/meet/_CallView.js +141 -58
- package/dist/apps/meet/_MeetLobby.d.ts +16 -19
- package/dist/apps/meet/_MeetLobby.js +49 -99
- package/dist/apps/meet/_ParticipantTile.d.ts +12 -6
- package/dist/apps/meet/_ParticipantTile.js +12 -9
- package/dist/apps/meet/_icons.d.ts +15 -0
- package/dist/apps/meet/_icons.js +16 -0
- package/dist/apps/shared/media/chime.d.ts +31 -0
- package/dist/apps/shared/media/chime.js +49 -0
- package/dist/apps/shared/media/deviceMedia.d.ts +3 -7
- package/dist/apps/shared/media/deviceMedia.js +3 -12
- package/dist/apps/shared/media/useLocalMedia.d.ts +37 -0
- package/dist/apps/shared/media/useLocalMedia.js +259 -0
- package/dist/apps/shared/push/GuestSignalingClient.js +2 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +57 -17
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +212 -31
- package/dist/apps/shared/webrtc/realPeerConnection.js +16 -3
- package/dist/apps/shared/webrtc/types.d.ts +52 -15
- package/dist/apps/shared/webrtc/types.js +3 -1
- package/dist/lib/routes/BaseVideoMeetingRoute.js +58 -3
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
- package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js +2 -1
- package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js.map +1 -1
- package/dist/lib/routes/sql/VideoMeetingRouteSQL.js +2 -1
- package/dist/lib/routes/sql/VideoMeetingRouteSQL.js.map +1 -1
- package/dist/lib/util/BookingIntegrationUtils.js +1 -1
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +15 -0
- package/dist/types/routes/mongo/VideoMeetingRouteMongo.d.ts +1 -0
- package/dist/types/routes/sql/VideoMeetingRouteSQL.d.ts +1 -0
- package/dist/types/util/BookingIntegrationUtils.d.ts +1 -1
- package/package.json +181 -181
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# RapidMX: Video Conferencing
|
|
2
2
|
|
|
3
|
-
[](https://github.com/RapidMX/meet-plugin/actions/workflows/build.yml)
|
|
4
|
+
[](https://coveralls.io/github/RapidMX/meet-plugin?branch=main)
|
|
5
|
+
[](https://www.npmjs.com/package/@rapidmx/meet-plugin)
|
|
6
6
|
|
|
7
7
|
Private and public WebRTC video meetings for a [RapidMX server](https://github.com/RapidMX/server). A mailbox owner
|
|
8
8
|
turns on video conferencing for a calendar event; a private meeting mints one link per invitee (inserted into the
|
package/apps/meet/[token].tsx
CHANGED
|
@@ -31,10 +31,13 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import React, { useState } from "react";
|
|
33
33
|
import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
|
|
34
|
+
import { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
|
|
34
35
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
36
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
35
37
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
36
38
|
import { MeetCard, MeetPageShell } from "./_MeetChrome.js";
|
|
37
|
-
import {
|
|
39
|
+
import { useLocalMedia } from "../shared/media/useLocalMedia.js";
|
|
40
|
+
import MeetLobby from "./_MeetLobby.js";
|
|
38
41
|
import CallView from "./_CallView.js";
|
|
39
42
|
import { type VideoMeetingJoinResult, joinMeeting } from "./_meetApi.js";
|
|
40
43
|
|
|
@@ -42,18 +45,19 @@ type Phase = "loading" | "not-found" | "lobby" | "in-call" | "ended";
|
|
|
42
45
|
|
|
43
46
|
export default function MeetJoinPage({ params }: { params: { token: string } }) {
|
|
44
47
|
const { branding } = useBranding();
|
|
45
|
-
return
|
|
46
|
-
<MeetPageShell branding={branding}>
|
|
47
|
-
<MeetJoinContent token={params.token} />
|
|
48
|
-
</MeetPageShell>
|
|
49
|
-
);
|
|
48
|
+
return <MeetJoinContent token={params.token} branding={branding} />;
|
|
50
49
|
}
|
|
51
50
|
|
|
52
|
-
|
|
51
|
+
/** Owns the camera and microphone (`useLocalMedia()`) for the whole visit, so the tracks the lobby previews are the
|
|
52
|
+
* ones the call sends. Every phase but the call is drawn inside the branded page shell; the call fills the whole
|
|
53
|
+
* window instead (see `_CallView.tsx`). */
|
|
54
|
+
function MeetJoinContent({ token, branding }: { token: string; branding: Branding | null }) {
|
|
53
55
|
const [phase, setPhase] = useState<Phase>("loading");
|
|
54
56
|
const [joinResult, setJoinResult] = useState<VideoMeetingJoinResult | null>(null);
|
|
55
|
-
const [
|
|
57
|
+
const [name, setName] = useState("");
|
|
56
58
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
59
|
+
const media = useLocalMedia();
|
|
60
|
+
const { release } = media;
|
|
57
61
|
|
|
58
62
|
React.useEffect(() => {
|
|
59
63
|
let cancelled = false;
|
|
@@ -80,41 +84,17 @@ function MeetJoinContent({ token }: { token: string }) {
|
|
|
80
84
|
};
|
|
81
85
|
}, [token]);
|
|
82
86
|
|
|
83
|
-
function handleJoin(
|
|
84
|
-
|
|
87
|
+
function handleJoin(joinName: string) {
|
|
88
|
+
setName(joinName);
|
|
85
89
|
setPhase("in-call");
|
|
86
90
|
}
|
|
87
91
|
|
|
88
92
|
function handleLeave() {
|
|
93
|
+
release();
|
|
89
94
|
setPhase("ended");
|
|
90
95
|
}
|
|
91
96
|
|
|
92
|
-
if (phase === "
|
|
93
|
-
return (
|
|
94
|
-
<MeetCard>
|
|
95
|
-
<p className="text-base text-text-muted">Loading…</p>
|
|
96
|
-
</MeetCard>
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (phase === "not-found") {
|
|
101
|
-
return (
|
|
102
|
-
<MeetCard>
|
|
103
|
-
<Alert>{loadError ?? "This meeting link isn't valid."}</Alert>
|
|
104
|
-
</MeetCard>
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (phase === "ended") {
|
|
109
|
-
return (
|
|
110
|
-
<MeetCard>
|
|
111
|
-
<h1 className="text-xl font-bold tracking-tight mb-2">You left the meeting</h1>
|
|
112
|
-
<p className="text-base text-text-muted">You can close this page now.</p>
|
|
113
|
-
</MeetCard>
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (phase === "in-call" && joinResult && preferences) {
|
|
97
|
+
if (phase === "in-call" && joinResult) {
|
|
118
98
|
return (
|
|
119
99
|
<CallView
|
|
120
100
|
channel={joinResult.meeting.uid}
|
|
@@ -125,20 +105,45 @@ function MeetJoinContent({ token }: { token: string }) {
|
|
|
125
105
|
// `VideoMeetingJoinResult`'s doc comment in `_meetApi.ts`).
|
|
126
106
|
token={joinResult.token}
|
|
127
107
|
selfUid={joinResult.selfUid}
|
|
128
|
-
selfName={
|
|
108
|
+
selfName={name}
|
|
109
|
+
meetingTitle={joinResult.meeting.title}
|
|
129
110
|
iceServers={joinResult.iceServers}
|
|
130
|
-
|
|
131
|
-
initialMicOn={preferences.micOn}
|
|
132
|
-
initialCameraOn={preferences.cameraOn}
|
|
111
|
+
media={media}
|
|
133
112
|
onLeave={handleLeave}
|
|
134
113
|
/>
|
|
135
114
|
);
|
|
136
115
|
}
|
|
137
116
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
<
|
|
142
|
-
|
|
143
|
-
|
|
117
|
+
let content: React.ReactNode;
|
|
118
|
+
if (phase === "loading") {
|
|
119
|
+
content = (
|
|
120
|
+
<MeetCard>
|
|
121
|
+
<p className="text-base text-text-muted">Loading…</p>
|
|
122
|
+
</MeetCard>
|
|
123
|
+
);
|
|
124
|
+
} else if (phase === "not-found") {
|
|
125
|
+
content = (
|
|
126
|
+
<MeetCard>
|
|
127
|
+
<Alert>{loadError ?? "This meeting link isn't valid."}</Alert>
|
|
128
|
+
</MeetCard>
|
|
129
|
+
);
|
|
130
|
+
} else if (phase === "ended") {
|
|
131
|
+
content = (
|
|
132
|
+
<MeetCard>
|
|
133
|
+
<h1 className="text-xl font-bold tracking-tight mb-2">You left the meeting</h1>
|
|
134
|
+
<p className="text-base text-text-muted mb-4">You can close this page now, or rejoin.</p>
|
|
135
|
+
<Button type="button" className="!w-auto" onClick={() => setPhase("lobby")}>
|
|
136
|
+
Rejoin meeting
|
|
137
|
+
</Button>
|
|
138
|
+
</MeetCard>
|
|
139
|
+
);
|
|
140
|
+
} else {
|
|
141
|
+
// phase === "lobby" (joinResult is always set by then - see the effect above).
|
|
142
|
+
content = (
|
|
143
|
+
<MeetCard maxWidth="max-w-4xl">
|
|
144
|
+
<MeetLobby meeting={joinResult!.meeting} media={media} initialName={name} onJoin={handleJoin} />
|
|
145
|
+
</MeetCard>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return <MeetPageShell branding={branding}>{content}</MeetPageShell>;
|
|
144
149
|
}
|
|
@@ -2,68 +2,291 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
/**
|
|
6
|
-
*
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
/**
|
|
6
|
+
* The in-call control bar, fixed to the bottom of the call (`_CallView.tsx` lays it out below the tiles, never over
|
|
7
|
+
* them). Its menus open upward, centered on the whole bar on a phone (where the bar wraps onto two rows and a menu
|
|
8
|
+
* 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
|
|
9
|
+
* screen, reactions, raise hand, grid/focus and leave.
|
|
10
|
+
*
|
|
11
|
+
* The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
|
|
12
|
+
* picking up, so a participant can see their audio is being sent - and the camera button shows a green dot while a
|
|
13
|
+
* camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`).
|
|
14
|
+
*/
|
|
15
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
16
|
+
import type { LocalMedia, MediaKind } from "../shared/media/useLocalMedia.js";
|
|
17
|
+
import { REACTION_EMOJIS } from "../shared/webrtc/types.js";
|
|
18
|
+
import {
|
|
19
|
+
ChevronUpIcon,
|
|
20
|
+
EmojiIcon,
|
|
21
|
+
FocusIcon,
|
|
22
|
+
GridIcon,
|
|
23
|
+
HandIcon,
|
|
24
|
+
LeaveIcon,
|
|
25
|
+
MicIcon,
|
|
26
|
+
MicOffIcon,
|
|
27
|
+
ScreenShareIcon,
|
|
28
|
+
VideoIcon,
|
|
29
|
+
VideoOffIcon,
|
|
30
|
+
} from "./_icons.js";
|
|
9
31
|
|
|
10
32
|
export type CallViewMode = "grid" | "focus";
|
|
11
33
|
|
|
12
34
|
export interface CallControlsProps {
|
|
13
|
-
|
|
14
|
-
onToggleMic: () => void;
|
|
15
|
-
cameraOn: boolean;
|
|
16
|
-
onToggleCamera: () => void;
|
|
35
|
+
media: LocalMedia;
|
|
17
36
|
isPresenting: boolean;
|
|
18
37
|
/** Someone else is presenting - the share button is disabled and explains why. */
|
|
19
38
|
presentingElsewhereName?: string;
|
|
20
39
|
onToggleShare: () => void;
|
|
40
|
+
handRaised: boolean;
|
|
41
|
+
onToggleHand: () => void;
|
|
42
|
+
onReaction: (emoji: string) => void;
|
|
21
43
|
viewMode: CallViewMode;
|
|
22
44
|
onToggleViewMode: () => void;
|
|
23
45
|
onLeave: () => void;
|
|
24
46
|
}
|
|
25
47
|
|
|
48
|
+
type OpenMenu = MediaKind | "emoji" | null;
|
|
49
|
+
|
|
50
|
+
const BUTTON = "flex items-center justify-center h-12 rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80";
|
|
51
|
+
const NEUTRAL = "bg-[#3c4043] text-white hover:bg-[#4b4f53]";
|
|
52
|
+
const ALERT = "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]";
|
|
53
|
+
const ACTIVE = "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]";
|
|
54
|
+
|
|
55
|
+
/** Bars that rise and fall with the microphone's level (0-5) - a flat, dim set while there is no sound. */
|
|
56
|
+
export function LevelBars({ level }: { level: number }) {
|
|
57
|
+
return (
|
|
58
|
+
<span className="flex items-center gap-[2px] h-5" data-testid="mic-level" data-level={level} aria-hidden="true">
|
|
59
|
+
{[0.6, 1, 0.6].map((weight, i) => (
|
|
60
|
+
<span
|
|
61
|
+
key={i}
|
|
62
|
+
className={`w-[3px] rounded-full ${level > 0 ? "bg-[#8ab4f8]" : "bg-white/50"}`}
|
|
63
|
+
style={{ height: `${4 + weight * level * 3}px` }}
|
|
64
|
+
/>
|
|
65
|
+
))}
|
|
66
|
+
</span>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
26
70
|
export default function CallControls({
|
|
27
|
-
|
|
28
|
-
onToggleMic,
|
|
29
|
-
cameraOn,
|
|
30
|
-
onToggleCamera,
|
|
71
|
+
media,
|
|
31
72
|
isPresenting,
|
|
32
73
|
presentingElsewhereName,
|
|
33
74
|
onToggleShare,
|
|
75
|
+
handRaised,
|
|
76
|
+
onToggleHand,
|
|
77
|
+
onReaction,
|
|
34
78
|
viewMode,
|
|
35
79
|
onToggleViewMode,
|
|
36
80
|
onLeave,
|
|
37
81
|
}: CallControlsProps) {
|
|
82
|
+
const [openMenu, setOpenMenu] = useState<OpenMenu>(null);
|
|
83
|
+
const barRef = useRef<HTMLDivElement>(null);
|
|
38
84
|
const shareDisabled = !isPresenting && !!presentingElsewhereName;
|
|
85
|
+
|
|
86
|
+
// A menu closes on Escape or a press anywhere outside the bar.
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (!openMenu) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
92
|
+
if (!barRef.current?.contains(event.target as Node)) {
|
|
93
|
+
setOpenMenu(null);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
97
|
+
if (event.key === "Escape") {
|
|
98
|
+
setOpenMenu(null);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
102
|
+
document.addEventListener("keydown", onKeyDown);
|
|
103
|
+
return () => {
|
|
104
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
105
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
106
|
+
};
|
|
107
|
+
}, [openMenu]);
|
|
108
|
+
|
|
109
|
+
function toggleMenu(menu: Exclude<OpenMenu, null>) {
|
|
110
|
+
setOpenMenu((prev) => (prev === menu ? null : menu));
|
|
111
|
+
}
|
|
112
|
+
|
|
39
113
|
return (
|
|
40
|
-
<div className="flex flex-wrap items-center justify-center gap-2
|
|
41
|
-
<
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
114
|
+
<div ref={barRef} className="relative flex flex-wrap items-center justify-center gap-2 px-3" role="toolbar" aria-label="Call controls">
|
|
115
|
+
<div className="sm:relative flex items-stretch gap-px">
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
className={`${BUTTON} w-9 rounded-r-none ${media.micOn ? NEUTRAL : ALERT}`}
|
|
119
|
+
aria-label="Choose microphone"
|
|
120
|
+
aria-haspopup="menu"
|
|
121
|
+
aria-expanded={openMenu === "audio"}
|
|
122
|
+
onClick={() => toggleMenu("audio")}
|
|
123
|
+
>
|
|
124
|
+
<ChevronUpIcon />
|
|
125
|
+
</button>
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
className={`${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT}`}
|
|
129
|
+
aria-label={media.micOn ? "Mute microphone" : "Unmute microphone"}
|
|
130
|
+
aria-pressed={!media.micOn}
|
|
131
|
+
onClick={() => void media.toggleMic()}
|
|
132
|
+
>
|
|
133
|
+
{media.micOn && <LevelBars level={media.audioLevel} />}
|
|
134
|
+
{media.micOn ? <MicIcon /> : <MicOffIcon />}
|
|
135
|
+
</button>
|
|
136
|
+
{openMenu === "audio" && <DeviceMenu kind="audio" media={media} onClose={() => setOpenMenu(null)} />}
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<div className="sm:relative flex items-stretch gap-px">
|
|
140
|
+
<button
|
|
141
|
+
type="button"
|
|
142
|
+
className={`${BUTTON} w-9 rounded-r-none ${media.cameraOn ? NEUTRAL : ALERT}`}
|
|
143
|
+
aria-label="Choose camera"
|
|
144
|
+
aria-haspopup="menu"
|
|
145
|
+
aria-expanded={openMenu === "video"}
|
|
146
|
+
onClick={() => toggleMenu("video")}
|
|
147
|
+
>
|
|
148
|
+
<ChevronUpIcon />
|
|
149
|
+
</button>
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
className={`${BUTTON} relative min-w-14 px-3 rounded-l-none ${media.cameraOn ? NEUTRAL : ALERT}`}
|
|
153
|
+
aria-label={media.cameraOn ? "Turn off camera" : "Turn on camera"}
|
|
154
|
+
aria-pressed={!media.cameraOn}
|
|
155
|
+
onClick={() => void media.toggleCamera()}
|
|
156
|
+
>
|
|
157
|
+
{media.cameraOn ? <VideoIcon /> : <VideoOffIcon />}
|
|
158
|
+
{media.cameraOn && (
|
|
159
|
+
<span
|
|
160
|
+
className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34a853] animate-pulse"
|
|
161
|
+
data-testid="camera-live"
|
|
162
|
+
title="Your camera is sending"
|
|
163
|
+
/>
|
|
164
|
+
)}
|
|
165
|
+
</button>
|
|
166
|
+
{openMenu === "video" && <DeviceMenu kind="video" media={media} onClose={() => setOpenMenu(null)} />}
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<button
|
|
48
170
|
type="button"
|
|
49
|
-
|
|
50
|
-
|
|
171
|
+
className={`${BUTTON} w-12 ${isPresenting ? ACTIVE : NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`}
|
|
172
|
+
aria-label={isPresenting ? "Stop sharing" : "Share screen"}
|
|
173
|
+
aria-pressed={isPresenting}
|
|
51
174
|
disabled={shareDisabled}
|
|
52
175
|
title={shareDisabled ? `${presentingElsewhereName} is presenting - stop their share to present yourself.` : undefined}
|
|
53
176
|
onClick={onToggleShare}
|
|
54
177
|
>
|
|
55
|
-
|
|
56
|
-
</
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
178
|
+
<ScreenShareIcon />
|
|
179
|
+
</button>
|
|
180
|
+
|
|
181
|
+
<div className="sm:relative">
|
|
182
|
+
<button
|
|
183
|
+
type="button"
|
|
184
|
+
className={`${BUTTON} w-12 ${NEUTRAL}`}
|
|
185
|
+
aria-label="Send a reaction"
|
|
186
|
+
aria-haspopup="menu"
|
|
187
|
+
aria-expanded={openMenu === "emoji"}
|
|
188
|
+
onClick={() => toggleMenu("emoji")}
|
|
189
|
+
>
|
|
190
|
+
<EmojiIcon />
|
|
191
|
+
</button>
|
|
192
|
+
{openMenu === "emoji" && (
|
|
193
|
+
<div
|
|
194
|
+
role="menu"
|
|
195
|
+
aria-label="Reactions"
|
|
196
|
+
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"
|
|
197
|
+
>
|
|
198
|
+
{REACTION_EMOJIS.map((emoji) => (
|
|
199
|
+
<button
|
|
200
|
+
key={emoji}
|
|
201
|
+
type="button"
|
|
202
|
+
role="menuitem"
|
|
203
|
+
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"
|
|
204
|
+
aria-label={`Send ${emoji}`}
|
|
205
|
+
onClick={() => {
|
|
206
|
+
onReaction(emoji);
|
|
207
|
+
setOpenMenu(null);
|
|
208
|
+
}}
|
|
209
|
+
>
|
|
210
|
+
{emoji}
|
|
211
|
+
</button>
|
|
212
|
+
))}
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
className={`${BUTTON} w-12 ${handRaised ? ACTIVE : NEUTRAL}`}
|
|
220
|
+
aria-label={handRaised ? "Lower hand" : "Raise hand"}
|
|
221
|
+
aria-pressed={handRaised}
|
|
222
|
+
onClick={onToggleHand}
|
|
223
|
+
>
|
|
224
|
+
<HandIcon />
|
|
225
|
+
</button>
|
|
226
|
+
|
|
227
|
+
<button
|
|
61
228
|
type="button"
|
|
62
|
-
className=
|
|
63
|
-
|
|
229
|
+
className={`${BUTTON} w-12 ${NEUTRAL}`}
|
|
230
|
+
aria-label={viewMode === "grid" ? "Switch to focused view" : "Switch to grid view"}
|
|
231
|
+
onClick={onToggleViewMode}
|
|
64
232
|
>
|
|
65
|
-
|
|
66
|
-
</
|
|
233
|
+
{viewMode === "grid" ? <FocusIcon /> : <GridIcon />}
|
|
234
|
+
</button>
|
|
235
|
+
|
|
236
|
+
<button type="button" className={`${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`} aria-label="Leave call" onClick={onLeave}>
|
|
237
|
+
<LeaveIcon />
|
|
238
|
+
</button>
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The device picker opened from the chevron beside the microphone/camera button: the available devices with the
|
|
244
|
+
* one in use ticked, or - while there are none to list - why, with a way to ask for access again. */
|
|
245
|
+
function DeviceMenu({ kind, media, onClose }: { kind: MediaKind; media: LocalMedia; onClose: () => void }) {
|
|
246
|
+
const list = kind === "audio" ? media.devices.microphones : media.devices.cameras;
|
|
247
|
+
const noun = kind === "audio" ? "microphone" : "camera";
|
|
248
|
+
const selected = media.selectedDeviceIds[kind];
|
|
249
|
+
const denied = media.status[kind] === "denied";
|
|
250
|
+
return (
|
|
251
|
+
<div
|
|
252
|
+
role="menu"
|
|
253
|
+
aria-label={kind === "audio" ? "Microphones" : "Cameras"}
|
|
254
|
+
className="absolute bottom-full mb-3 left-1/2 -translate-x-1/2 sm:left-0 sm:translate-x-0 w-max min-w-64 max-w-[calc(100vw-1rem)] p-1 rounded-lg bg-[#2b2d30] text-white text-sm shadow-xl"
|
|
255
|
+
>
|
|
256
|
+
{list.map((device, index) => (
|
|
257
|
+
<button
|
|
258
|
+
key={device.deviceId}
|
|
259
|
+
type="button"
|
|
260
|
+
role="menuitemradio"
|
|
261
|
+
aria-checked={device.deviceId === selected}
|
|
262
|
+
className="flex w-full items-center gap-2 px-3 py-2 rounded-md text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
|
|
263
|
+
onClick={() => {
|
|
264
|
+
void media.selectDevice(kind, device.deviceId);
|
|
265
|
+
onClose();
|
|
266
|
+
}}
|
|
267
|
+
>
|
|
268
|
+
<span className="w-4 text-[#8ab4f8]" aria-hidden="true">
|
|
269
|
+
{device.deviceId === selected ? "✓" : ""}
|
|
270
|
+
</span>
|
|
271
|
+
<span className="truncate">{device.label || `${noun[0].toUpperCase()}${noun.slice(1)} ${index + 1}`}</span>
|
|
272
|
+
</button>
|
|
273
|
+
))}
|
|
274
|
+
{list.length === 0 && (
|
|
275
|
+
<p className="px-3 py-2 text-white/70">{denied ? `Access to your ${noun} was blocked.` : `No ${noun} found.`}</p>
|
|
276
|
+
)}
|
|
277
|
+
{(list.length === 0 || denied) && (
|
|
278
|
+
<button
|
|
279
|
+
type="button"
|
|
280
|
+
role="menuitem"
|
|
281
|
+
className="w-full px-3 py-2 rounded-md text-left text-[#8ab4f8] hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
|
|
282
|
+
onClick={() => {
|
|
283
|
+
void media.requestAccess(kind);
|
|
284
|
+
onClose();
|
|
285
|
+
}}
|
|
286
|
+
>
|
|
287
|
+
Allow access to {noun}
|
|
288
|
+
</button>
|
|
289
|
+
)}
|
|
67
290
|
</div>
|
|
68
291
|
);
|
|
69
292
|
}
|