@rapidmx/meet-plugin 0.2.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.
Files changed (46) hide show
  1. package/apps/meet/[token].tsx +51 -46
  2. package/apps/meet/_CallControls.tsx +255 -32
  3. package/apps/meet/_CallView.tsx +275 -115
  4. package/apps/meet/_MeetLobby.tsx +111 -142
  5. package/apps/meet/_ParticipantTile.tsx +51 -19
  6. package/apps/meet/_icons.tsx +54 -0
  7. package/apps/shared/media/chime.ts +74 -0
  8. package/apps/shared/media/deviceMedia.ts +3 -13
  9. package/apps/shared/media/useLocalMedia.ts +337 -0
  10. package/apps/shared/push/GuestSignalingClient.ts +2 -0
  11. package/apps/shared/webrtc/MeshConnectionManager.ts +212 -44
  12. package/apps/shared/webrtc/realPeerConnection.ts +16 -3
  13. package/apps/shared/webrtc/types.ts +52 -15
  14. package/dist/apps/meet/[token].js +31 -19
  15. package/dist/apps/meet/_CallControls.d.ts +20 -7
  16. package/dist/apps/meet/_CallControls.js +72 -3
  17. package/dist/apps/meet/_CallView.d.ts +23 -4
  18. package/dist/apps/meet/_CallView.js +141 -58
  19. package/dist/apps/meet/_MeetLobby.d.ts +16 -19
  20. package/dist/apps/meet/_MeetLobby.js +49 -99
  21. package/dist/apps/meet/_ParticipantTile.d.ts +12 -6
  22. package/dist/apps/meet/_ParticipantTile.js +12 -9
  23. package/dist/apps/meet/_icons.d.ts +15 -0
  24. package/dist/apps/meet/_icons.js +16 -0
  25. package/dist/apps/shared/media/chime.d.ts +31 -0
  26. package/dist/apps/shared/media/chime.js +49 -0
  27. package/dist/apps/shared/media/deviceMedia.d.ts +3 -7
  28. package/dist/apps/shared/media/deviceMedia.js +3 -12
  29. package/dist/apps/shared/media/useLocalMedia.d.ts +37 -0
  30. package/dist/apps/shared/media/useLocalMedia.js +259 -0
  31. package/dist/apps/shared/push/GuestSignalingClient.js +2 -0
  32. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +57 -17
  33. package/dist/apps/shared/webrtc/MeshConnectionManager.js +212 -31
  34. package/dist/apps/shared/webrtc/realPeerConnection.js +16 -3
  35. package/dist/apps/shared/webrtc/types.d.ts +52 -15
  36. package/dist/apps/shared/webrtc/types.js +3 -1
  37. package/dist/lib/routes/BaseVideoMeetingRoute.js +58 -3
  38. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  39. package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js +2 -1
  40. package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js.map +1 -1
  41. package/dist/lib/routes/sql/VideoMeetingRouteSQL.js +2 -1
  42. package/dist/lib/routes/sql/VideoMeetingRouteSQL.js.map +1 -1
  43. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +15 -0
  44. package/dist/types/routes/mongo/VideoMeetingRouteMongo.d.ts +1 -0
  45. package/dist/types/routes/sql/VideoMeetingRouteSQL.d.ts +1 -0
  46. package/package.json +181 -181
@@ -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 { type JoinPreferences, default as MeetLobby } from "./_MeetLobby.js";
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
- function MeetJoinContent({ token }: { token: string }) {
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 [preferences, setPreferences] = useState<JoinPreferences | null>(null);
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(prefs: JoinPreferences) {
84
- setPreferences(prefs);
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 === "loading") {
93
- return (
94
- <MeetCard>
95
- <p className="text-base text-text-muted">Loading&hellip;</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={preferences.name}
108
+ selfName={name}
109
+ meetingTitle={joinResult.meeting.title}
129
110
  iceServers={joinResult.iceServers}
130
- initialStream={preferences.stream}
131
- initialMicOn={preferences.micOn}
132
- initialCameraOn={preferences.cameraOn}
111
+ media={media}
133
112
  onLeave={handleLeave}
134
113
  />
135
114
  );
136
115
  }
137
116
 
138
- // phase === "lobby" (join Result is always set by then - see the effect above).
139
- return (
140
- <MeetCard maxWidth="max-w-4xl">
141
- <MeetLobby meeting={joinResult!.meeting} onJoin={handleJoin} />
142
- </MeetCard>
143
- );
117
+ let content: React.ReactNode;
118
+ if (phase === "loading") {
119
+ content = (
120
+ <MeetCard>
121
+ <p className="text-base text-text-muted">Loading&hellip;</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
- /** The in-call bottom control bar: mute/unmute, camera on/off, share screen, grid/focus toggle, leave. Deliberately
6
- * minimal per this plugin's Phase 2 spec ("no chat, no recording, no reactions"). */
7
- import React from "react";
8
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
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
- micOn: boolean;
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
- micOn,
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 py-3 px-4 bg-surface border-t border-border">
41
- <Button type="button" variant="secondary" className="!w-auto" onClick={onToggleMic}>
42
- {micOn ? "Mute" : "Unmute"}
43
- </Button>
44
- <Button type="button" variant="secondary" className="!w-auto" onClick={onToggleCamera}>
45
- {cameraOn ? "Turn camera off" : "Turn camera on"}
46
- </Button>
47
- <Button
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
- variant="secondary"
50
- className="!w-auto"
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
- {isPresenting ? "Stop sharing" : "Share screen"}
56
- </Button>
57
- <Button type="button" variant="secondary" className="!w-auto" onClick={onToggleViewMode}>
58
- {viewMode === "grid" ? "Focused view" : "Grid view"}
59
- </Button>
60
- <Button
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="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger"
63
- onClick={onLeave}
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
- Leave
66
- </Button>
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
  }