@rapidmx/meet-plugin 0.9.3 → 0.11.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 (52) hide show
  1. package/apps/meet/[token].tsx +204 -13
  2. package/apps/meet/_CallControls.tsx +75 -5
  3. package/apps/meet/_CallView.tsx +326 -7
  4. package/apps/meet/_DiagnosticsPanel.tsx +113 -0
  5. package/apps/meet/_ParticipantTile.tsx +10 -2
  6. package/apps/meet/_ParticipantsDrawer.tsx +397 -0
  7. package/apps/meet/_icons.tsx +12 -0
  8. package/apps/meet/_meetApi.ts +141 -1
  9. package/apps/shared/media/filters/ScreenTransform.ts +148 -0
  10. package/apps/shared/media/useLocalMedia.ts +7 -3
  11. package/apps/shared/webrtc/MeshConnectionManager.ts +115 -4
  12. package/apps/shared/webrtc/realPeerConnection.ts +47 -7
  13. package/apps/shared/webrtc/types.ts +67 -8
  14. package/dist/apps/meet/[token].d.ts +4 -1
  15. package/dist/apps/meet/[token].js +121 -9
  16. package/dist/apps/meet/_CallControls.d.ts +16 -2
  17. package/dist/apps/meet/_CallControls.js +9 -6
  18. package/dist/apps/meet/_CallView.d.ts +45 -2
  19. package/dist/apps/meet/_CallView.js +215 -6
  20. package/dist/apps/meet/_DiagnosticsPanel.d.ts +22 -0
  21. package/dist/apps/meet/_DiagnosticsPanel.js +32 -0
  22. package/dist/apps/meet/_ParticipantTile.d.ts +8 -0
  23. package/dist/apps/meet/_ParticipantTile.js +9 -2
  24. package/dist/apps/meet/_ParticipantsDrawer.d.ts +77 -0
  25. package/dist/apps/meet/_ParticipantsDrawer.js +91 -0
  26. package/dist/apps/meet/_icons.d.ts +3 -0
  27. package/dist/apps/meet/_icons.js +3 -0
  28. package/dist/apps/meet/_meetApi.d.ts +93 -1
  29. package/dist/apps/meet/_meetApi.js +82 -0
  30. package/dist/apps/shared/media/filters/ScreenTransform.d.ts +53 -0
  31. package/dist/apps/shared/media/filters/ScreenTransform.js +100 -0
  32. package/dist/apps/shared/media/useLocalMedia.d.ts +5 -1
  33. package/dist/apps/shared/media/useLocalMedia.js +3 -3
  34. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +49 -1
  35. package/dist/apps/shared/webrtc/MeshConnectionManager.js +103 -3
  36. package/dist/apps/shared/webrtc/realPeerConnection.d.ts +19 -6
  37. package/dist/apps/shared/webrtc/realPeerConnection.js +42 -5
  38. package/dist/apps/shared/webrtc/types.d.ts +69 -7
  39. package/dist/lib/models/mongo/VideoMeetingMongo.js +21 -0
  40. package/dist/lib/models/mongo/VideoMeetingMongo.js.map +1 -1
  41. package/dist/lib/models/sql/VideoMeetingSQL.js +21 -0
  42. package/dist/lib/models/sql/VideoMeetingSQL.js.map +1 -1
  43. package/dist/lib/routes/BaseVideoMeetingRoute.js +380 -51
  44. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  45. package/dist/lib/util/PasswordUtils.js +52 -0
  46. package/dist/lib/util/PasswordUtils.js.map +1 -0
  47. package/dist/types/models/mongo/VideoMeetingMongo.d.ts +3 -0
  48. package/dist/types/models/sql/VideoMeetingSQL.d.ts +3 -0
  49. package/dist/types/models/types.d.ts +23 -0
  50. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +147 -5
  51. package/dist/types/util/PasswordUtils.d.ts +17 -0
  52. package/package.json +1 -1
@@ -5,7 +5,10 @@
5
5
  /**
6
6
  * The public join/lobby/in-call page, `GET /meet/:token` - resolves an invitee's join token or a public meeting's
7
7
  * slug (`BaseVideoMeetingRoute.join()`, both shapes accepted identically - see `_meetApi.ts`), then walks through
8
- * `loading` -> `lobby` -> `in-call` -> `ended`.
8
+ * `loading` -> (`password`, only when a password is required) -> (`admission-request` then `waiting`, only when
9
+ * the meeting has a waiting room - see that phase's own render branch) -> `lobby` -> `in-call` -> `ended`. A
10
+ * waiting room takes over the entire gate (asking for a password too, on the same form) rather than stacking
11
+ * after a separate password screen - see `BaseVideoMeetingRoute.join()`'s own doc comment for why.
9
12
  *
10
13
  * ## Session-based name prefill - NOT implemented, and why
11
14
  *
@@ -35,13 +38,26 @@ import { Branding } from "@rapidmx/web-client/lib/branding/brandingApi.js";
35
38
  import { ApiRequestError } from "@rapidmx/web-client/lib/util/api.js";
36
39
  import Button from "@rapidmx/web-client/lib/components/buttons/Button.js";
37
40
  import Alert from "@rapidmx/web-client/lib/components/feedback/Alert.js";
41
+ import FormField from "@rapidmx/web-client/lib/components/forms/FormField.js";
38
42
  import { MeetCard, MeetPageShell } from "./_MeetChrome.js";
39
43
  import { useLocalMedia } from "../shared/media/useLocalMedia.js";
40
44
  import MeetLobby from "./_MeetLobby.js";
41
45
  import CallView from "./_CallView.js";
42
- import { type VideoMeetingJoinResult, joinMeeting } from "./_meetApi.js";
46
+ import {
47
+ type PublicVideoMeeting,
48
+ type VideoMeetingJoinResult,
49
+ joinMeeting,
50
+ pollAdmission,
51
+ requestAdmission,
52
+ verifyMeetingPassword,
53
+ } from "./_meetApi.js";
43
54
 
44
- type Phase = "loading" | "not-found" | "lobby" | "in-call" | "ended";
55
+ type Phase = "loading" | "not-found" | "password" | "admission-request" | "waiting" | "denied" | "lobby" | "in-call" | "ended";
56
+
57
+ const INPUT_CLASS = "w-full text-base py-2.5 px-3.5 border border-border rounded-md bg-surface text-text focus:outline-none focus:border-primary";
58
+
59
+ /** How often `MeetJoinContent` polls while in the `"waiting"` phase. */
60
+ const ADMISSION_POLL_MS = 3_000;
45
61
 
46
62
  export default function MeetJoinPage({ params }: { params: { token: string } }) {
47
63
  const { branding } = useBranding();
@@ -56,17 +72,49 @@ function MeetJoinContent({ token, branding }: { token: string; branding: Brandin
56
72
  const [name, setName] = useState("");
57
73
  const [loadError, setLoadError] = useState<string | null>(null);
58
74
  const [joinResult, setJoinResult] = useState<VideoMeetingJoinResult | null>(null);
59
- const media = useLocalMedia({ effectsAssetsUrl: joinResult?.effectsAssetsUrl });
75
+ /** The restricted `PublicVideoMeeting` `joinMeeting()` returned while this meeting required a password - just
76
+ * enough to show the password prompt's own title/host, since `joinResult` itself stays unset until one is
77
+ * verified (see `handleSubmitPassword()`). Only read while `phase === "password"`. */
78
+ const [passwordMeeting, setPasswordMeeting] = useState<PublicVideoMeeting | null>(null);
79
+ const [passwordInput, setPasswordInput] = useState("");
80
+ const [passwordError, setPasswordError] = useState<string | null>(null);
81
+ /** The restricted `PublicVideoMeeting` `joinMeeting()`/`requestAdmission()` returned while this meeting has a
82
+ * waiting room - read on both the `"admission-request"` and `"waiting"` screens. */
83
+ const [admissionMeeting, setAdmissionMeeting] = useState<PublicVideoMeeting | null>(null);
84
+ const [admissionName, setAdmissionName] = useState("");
85
+ const [admissionPassword, setAdmissionPassword] = useState("");
86
+ const [admissionError, setAdmissionError] = useState<string | null>(null);
87
+ /** The guest token `requestAdmission()` minted, to poll with (`Authorization` header - a guest has no session
88
+ * cookie of their own). `undefined` for an already-authenticated real caller, whose existing session cookie
89
+ * already identifies them to `pollAdmission()` with no header needed. */
90
+ const [pendingGuestToken, setPendingGuestToken] = useState<string | undefined>(undefined);
91
+ /** Set only when the call ended without the participant's own action (currently: kicked by the host) - see
92
+ * `CallView`'s `onLeave` doc comment. Shown instead of the ordinary "you left" message; also hides "Rejoin
93
+ * meeting", since a kicked participant's server-side channel grant has just been revoked and a rejoin attempt
94
+ * would only fail. */
95
+ const [endedReason, setEndedReason] = useState<string | undefined>(undefined);
96
+ const media = useLocalMedia({ effectsAssetsUrl: joinResult?.effectsAssetsUrl, forceMuteOnJoin: joinResult?.meeting.forceMuteOnJoin });
60
97
  const { release } = media;
61
98
 
62
99
  React.useEffect(() => {
63
100
  let cancelled = false;
64
101
  joinMeeting(token)
65
102
  .then((result) => {
66
- if (!cancelled) {
67
- setJoinResult(result);
68
- setPhase("lobby");
103
+ if (cancelled) {
104
+ return;
105
+ }
106
+ if ("requiresAdmission" in result) {
107
+ setAdmissionMeeting(result.meeting);
108
+ setPhase("admission-request");
109
+ return;
110
+ }
111
+ if ("requiresPassword" in result) {
112
+ setPasswordMeeting(result.meeting);
113
+ setPhase("password");
114
+ return;
69
115
  }
116
+ setJoinResult(result);
117
+ setPhase("lobby");
70
118
  })
71
119
  .catch((err) => {
72
120
  if (cancelled) {
@@ -89,8 +137,69 @@ function MeetJoinContent({ token, branding }: { token: string; branding: Brandin
89
137
  setPhase("in-call");
90
138
  }
91
139
 
92
- function handleLeave() {
140
+ async function handleSubmitPassword(e: React.FormEvent) {
141
+ e.preventDefault();
142
+ setPasswordError(null);
143
+ try {
144
+ const result = await verifyMeetingPassword(token, passwordInput);
145
+ setJoinResult(result);
146
+ setPhase("lobby");
147
+ } catch (err) {
148
+ setPasswordError(err instanceof ApiRequestError && err.status === 403 ? "Incorrect password." : "Something went wrong. Try again.");
149
+ }
150
+ }
151
+
152
+ async function handleRequestAdmission(e: React.FormEvent) {
153
+ e.preventDefault();
154
+ setAdmissionError(null);
155
+ try {
156
+ const result = await requestAdmission(token, {
157
+ name: admissionName.trim(),
158
+ ...(admissionMeeting?.hasPassword && { password: admissionPassword }),
159
+ });
160
+ setName(admissionName.trim());
161
+ setPendingGuestToken(result.token);
162
+ setPhase("waiting");
163
+ } catch (err) {
164
+ setAdmissionError(err instanceof ApiRequestError && err.status === 403 ? "Incorrect password." : "Something went wrong. Try again.");
165
+ }
166
+ }
167
+
168
+ // Polls while waiting for the host to respond - stops as soon as this phase is left, one way or the other.
169
+ React.useEffect(() => {
170
+ if (phase !== "waiting") {
171
+ return;
172
+ }
173
+ let cancelled = false;
174
+ const poll = async () => {
175
+ try {
176
+ const result = await pollAdmission(token, pendingGuestToken);
177
+ if (cancelled || "requiresAdmission" in result) {
178
+ return;
179
+ }
180
+ setJoinResult(result);
181
+ setPhase("lobby");
182
+ } catch (err) {
183
+ if (cancelled || !(err instanceof ApiRequestError) || err.status !== 403) {
184
+ // A transient failure (network blip, server hiccup) - stay on this screen and try again next
185
+ // tick, rather than giving up on what might just be one bad request.
186
+ return;
187
+ }
188
+ setAdmissionError(err.message || "The host did not admit you to this meeting.");
189
+ setPhase("denied");
190
+ }
191
+ };
192
+ void poll();
193
+ const interval = setInterval(() => void poll(), ADMISSION_POLL_MS);
194
+ return () => {
195
+ cancelled = true;
196
+ clearInterval(interval);
197
+ };
198
+ }, [phase, token, pendingGuestToken]);
199
+
200
+ function handleLeave(reason?: string) {
93
201
  release();
202
+ setEndedReason(reason);
94
203
  setPhase("ended");
95
204
  }
96
205
 
@@ -109,6 +218,10 @@ function MeetJoinContent({ token, branding }: { token: string; branding: Brandin
109
218
  meetingTitle={joinResult.meeting.title}
110
219
  iceServers={joinResult.iceServers}
111
220
  relayEnabled={joinResult.relayEnabled}
221
+ hostUid={joinResult.meeting.hostUid}
222
+ initialForceMuteOnJoin={!!joinResult.meeting.forceMuteOnJoin}
223
+ initialHasPassword={!!joinResult.meeting.hasPassword}
224
+ initialWaitingRoomEnabled={!!joinResult.meeting.waitingRoomEnabled}
112
225
  media={media}
113
226
  onLeave={handleLeave}
114
227
  />
@@ -128,14 +241,92 @@ function MeetJoinContent({ token, branding }: { token: string; branding: Brandin
128
241
  <Alert>{loadError ?? "This meeting link isn't valid."}</Alert>
129
242
  </MeetCard>
130
243
  );
244
+ } else if (phase === "password") {
245
+ content = (
246
+ <MeetCard>
247
+ <h1 className="text-2xl font-bold tracking-tight">{passwordMeeting?.title}</h1>
248
+ {passwordMeeting?.hostDisplayName && <p className="text-sm text-text-muted mt-1">Hosted by {passwordMeeting.hostDisplayName}</p>}
249
+ <p className="text-base text-text-muted mt-4 mb-3">This meeting requires a password to join.</p>
250
+ <form onSubmit={(e) => void handleSubmitPassword(e)}>
251
+ <FormField label="Password" htmlFor="meet-password">
252
+ <input
253
+ id="meet-password"
254
+ type="password"
255
+ autoFocus
256
+ className={INPUT_CLASS}
257
+ value={passwordInput}
258
+ onChange={(e) => setPasswordInput(e.target.value)}
259
+ />
260
+ </FormField>
261
+ {passwordError && <Alert>{passwordError}</Alert>}
262
+ <Button type="submit" className="!w-auto mt-3" disabled={!passwordInput}>
263
+ Join meeting
264
+ </Button>
265
+ </form>
266
+ </MeetCard>
267
+ );
268
+ } else if (phase === "admission-request") {
269
+ content = (
270
+ <MeetCard>
271
+ <h1 className="text-2xl font-bold tracking-tight">{admissionMeeting?.title}</h1>
272
+ {admissionMeeting?.hostDisplayName && <p className="text-sm text-text-muted mt-1">Hosted by {admissionMeeting.hostDisplayName}</p>}
273
+ <p className="text-base text-text-muted mt-4 mb-3">The host must let you in before you can join.</p>
274
+ <form onSubmit={(e) => void handleRequestAdmission(e)}>
275
+ <FormField label="Your name" htmlFor="admission-name">
276
+ <input
277
+ id="admission-name"
278
+ type="text"
279
+ autoFocus
280
+ className={INPUT_CLASS}
281
+ value={admissionName}
282
+ onChange={(e) => setAdmissionName(e.target.value)}
283
+ />
284
+ </FormField>
285
+ {admissionMeeting?.hasPassword && (
286
+ <FormField label="Password" htmlFor="admission-password">
287
+ <input
288
+ id="admission-password"
289
+ type="password"
290
+ className={`${INPUT_CLASS} mt-3`}
291
+ value={admissionPassword}
292
+ onChange={(e) => setAdmissionPassword(e.target.value)}
293
+ />
294
+ </FormField>
295
+ )}
296
+ {admissionError && <Alert>{admissionError}</Alert>}
297
+ <Button
298
+ type="submit"
299
+ className="!w-auto mt-3"
300
+ disabled={!admissionName.trim() || (!!admissionMeeting?.hasPassword && !admissionPassword)}
301
+ >
302
+ Ask to join
303
+ </Button>
304
+ </form>
305
+ </MeetCard>
306
+ );
307
+ } else if (phase === "waiting") {
308
+ content = (
309
+ <MeetCard>
310
+ <h1 className="text-2xl font-bold tracking-tight">{admissionMeeting?.title}</h1>
311
+ <p className="text-base text-text-muted mt-4">Waiting for the host to let you in&hellip;</p>
312
+ </MeetCard>
313
+ );
314
+ } else if (phase === "denied") {
315
+ content = (
316
+ <MeetCard>
317
+ <Alert>{admissionError ?? "The host did not admit you to this meeting."}</Alert>
318
+ </MeetCard>
319
+ );
131
320
  } else if (phase === "ended") {
132
321
  content = (
133
322
  <MeetCard>
134
- <h1 className="text-xl font-bold tracking-tight mb-2">You left the meeting</h1>
135
- <p className="text-base text-text-muted mb-4">You can close this page now, or rejoin.</p>
136
- <Button type="button" className="!w-auto" onClick={() => setPhase("lobby")}>
137
- Rejoin meeting
138
- </Button>
323
+ <h1 className="text-xl font-bold tracking-tight mb-2">{endedReason ? "Removed from the meeting" : "You left the meeting"}</h1>
324
+ <p className="text-base text-text-muted mb-4">{endedReason ?? "You can close this page now, or rejoin."}</p>
325
+ {!endedReason && (
326
+ <Button type="button" className="!w-auto" onClick={() => setPhase("lobby")}>
327
+ Rejoin meeting
328
+ </Button>
329
+ )}
139
330
  </MeetCard>
140
331
  );
141
332
  } else {
@@ -11,23 +11,30 @@
11
11
  *
12
12
  * The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
13
13
  * picking up, so a participant can see their audio is being sent - and the camera button shows a green dot while a
14
- * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`).
14
+ * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`). The microphone button
15
+ * is disabled (`micLocked`) while talking-stick mode is on and this participant isn't the current holder - see
16
+ * `_CallView.tsx`'s doc comment.
15
17
  */
16
18
  import React, { useEffect, useRef, useState } from "react";
17
19
  import { filtersActive } from "../shared/media/filters/filterTypes.js";
20
+ import type { ScreenTransformState } from "../shared/media/filters/ScreenTransform.js";
18
21
  import type { LocalMedia, MediaKind } from "../shared/media/useLocalMedia.js";
19
- import { REACTION_EMOJIS } from "../shared/webrtc/types.js";
22
+ import { REACTION_EMOJIS, type MeshParticipant } from "../shared/webrtc/types.js";
23
+ import DiagnosticsPanel from "./_DiagnosticsPanel.js";
20
24
  import EffectsPanel from "./_EffectsPanel.js";
21
25
  import {
22
26
  ChevronUpIcon,
27
+ DiagnosticsIcon,
23
28
  EffectsIcon,
24
29
  EmojiIcon,
30
+ FlipIcon,
25
31
  FocusIcon,
26
32
  GridIcon,
27
33
  HandIcon,
28
34
  LeaveIcon,
29
35
  MicIcon,
30
36
  MicOffIcon,
37
+ RotateIcon,
31
38
  ScreenShareIcon,
32
39
  VideoIcon,
33
40
  VideoOffIcon,
@@ -37,19 +44,29 @@ export type CallViewMode = "grid" | "focus";
37
44
 
38
45
  export interface CallControlsProps {
39
46
  media: LocalMedia;
47
+ selfName: string;
48
+ participants: MeshParticipant[];
40
49
  isPresenting: boolean;
41
50
  /** Someone else is presenting - the share button is disabled and explains why. */
42
51
  presentingElsewhereName?: string;
43
52
  onToggleShare: () => void;
53
+ /** The current presenter's own rotate/flip correction - shown (and changeable) only while `isPresenting`. */
54
+ screenTransform: ScreenTransformState;
55
+ onRotateScreen: () => void;
56
+ onFlipScreen: () => void;
44
57
  handRaised: boolean;
45
58
  onToggleHand: () => void;
46
59
  onReaction: (emoji: string) => void;
47
60
  viewMode: CallViewMode;
48
61
  onToggleViewMode: () => void;
62
+ /** True while talking-stick mode is on and this participant doesn't currently hold it - see `_CallView.tsx`'s
63
+ * doc comment on talking-stick mode. Disables the microphone toggle (not the device-picker chevron, which
64
+ * doesn't change whether anyone can hear them) rather than merely nudging it, unlike a `mute-request`. */
65
+ micLocked?: boolean;
49
66
  onLeave: () => void;
50
67
  }
51
68
 
52
- type OpenMenu = MediaKind | "emoji" | "effects" | null;
69
+ type OpenMenu = MediaKind | "emoji" | "effects" | "diagnostics" | null;
53
70
 
54
71
  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";
55
72
  const NEUTRAL = "bg-[#3c4043] text-white hover:bg-[#4b4f53]";
@@ -73,14 +90,20 @@ export function LevelBars({ level }: { level: number }) {
73
90
 
74
91
  export default function CallControls({
75
92
  media,
93
+ selfName,
94
+ participants,
76
95
  isPresenting,
77
96
  presentingElsewhereName,
78
97
  onToggleShare,
98
+ screenTransform,
99
+ onRotateScreen,
100
+ onFlipScreen,
79
101
  handRaised,
80
102
  onToggleHand,
81
103
  onReaction,
82
104
  viewMode,
83
105
  onToggleViewMode,
106
+ micLocked,
84
107
  onLeave,
85
108
  }: CallControlsProps) {
86
109
  const [openMenu, setOpenMenu] = useState<OpenMenu>(null);
@@ -129,9 +152,11 @@ export default function CallControls({
129
152
  </button>
130
153
  <button
131
154
  type="button"
132
- className={`${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT}`}
155
+ className={`${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT} disabled:opacity-50 disabled:cursor-not-allowed`}
133
156
  aria-label={media.micOn ? "Mute microphone" : "Unmute microphone"}
134
157
  aria-pressed={!media.micOn}
158
+ disabled={micLocked}
159
+ title={micLocked ? "Only the current talking-stick holder can unmute." : undefined}
135
160
  onClick={() => void media.toggleMic()}
136
161
  >
137
162
  {media.micOn && <LevelBars level={media.audioLevel} />}
@@ -204,6 +229,29 @@ export default function CallControls({
204
229
  <ScreenShareIcon />
205
230
  </button>
206
231
 
232
+ {isPresenting && (
233
+ <>
234
+ <button
235
+ type="button"
236
+ className={`${BUTTON} w-12 ${NEUTRAL}`}
237
+ aria-label="Rotate shared screen"
238
+ 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."
239
+ onClick={onRotateScreen}
240
+ >
241
+ <RotateIcon />
242
+ </button>
243
+ <button
244
+ type="button"
245
+ className={`${BUTTON} w-12 ${screenTransform.flipped ? ACTIVE : NEUTRAL}`}
246
+ aria-label={screenTransform.flipped ? "Unflip shared screen" : "Flip shared screen"}
247
+ aria-pressed={screenTransform.flipped}
248
+ onClick={onFlipScreen}
249
+ >
250
+ <FlipIcon />
251
+ </button>
252
+ </>
253
+ )}
254
+
207
255
  <div className="sm:relative">
208
256
  <button
209
257
  type="button"
@@ -259,7 +307,29 @@ export default function CallControls({
259
307
  {viewMode === "grid" ? <FocusIcon /> : <GridIcon />}
260
308
  </button>
261
309
 
262
- <button type="button" className={`${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`} aria-label="Leave call" onClick={onLeave}>
310
+ <div className="sm:relative">
311
+ <button
312
+ type="button"
313
+ className={`${BUTTON} w-12 ${openMenu === "diagnostics" ? ACTIVE : NEUTRAL}`}
314
+ aria-label="Call diagnostics"
315
+ aria-haspopup="dialog"
316
+ aria-expanded={openMenu === "diagnostics"}
317
+ onClick={() => toggleMenu("diagnostics")}
318
+ >
319
+ <DiagnosticsIcon />
320
+ </button>
321
+ {openMenu === "diagnostics" && (
322
+ <div
323
+ role="dialog"
324
+ aria-label="Call diagnostics"
325
+ className="absolute bottom-full mb-3 right-0 w-80 max-w-[calc(100vw-1rem)] max-h-[60vh] overflow-y-auto p-3 rounded-2xl bg-[#2b2d30] text-white shadow-xl"
326
+ >
327
+ <DiagnosticsPanel selfName={selfName} micOn={media.micOn} cameraOn={media.cameraOn} participants={participants} />
328
+ </div>
329
+ )}
330
+ </div>
331
+
332
+ <button type="button" className={`${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`} aria-label="Leave call" onClick={() => onLeave()}>
263
333
  <LeaveIcon />
264
334
  </button>
265
335
  </div>