@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
@@ -0,0 +1,397 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The participants drawer, opened from the header's participant-count chip: a right-side panel listing everyone in
7
+ * the call, "you" first. The same mic-muted/hand-raised/transport-badge information `_ParticipantTile.tsx` already
8
+ * shows on a tile, as plain rows instead (a list reads better than tiles once there's a reason to scan for one
9
+ * name).
10
+ *
11
+ * Closes on Escape, on a click on the backdrop behind it, or its own close button - never on a click inside the
12
+ * drawer itself, matching `_CallControls.tsx`'s menus.
13
+ *
14
+ * A "Host" tag marks the host's own row (`isSelfHost`/`isParticipantHost` - see `_CallView.tsx`'s doc comment on
15
+ * `hostUid` for what that identity is and isn't). Only while `isSelfHost` does every *other* row also get a "Mute"
16
+ * button (hidden once that participant is already muted - there is no useful "unmute someone else" action to
17
+ * offer) and a "Remove" button (a native `confirm()` first, since removal can't be undone by the participant the
18
+ * way a mute request can) - never on the local participant's own row, and never shown to anyone but the host.
19
+ *
20
+ * The header also gets a host-only "Mute on join" checkbox (`forceMuteOnJoin`/`onToggleForceMuteOnJoin`) - a
21
+ * setting for whoever joins *next*, not an action on anyone already here (muting someone already in the call is
22
+ * what each row's own "Mute" button is for) - a host-only password section (`hasPassword`/`onSetPassword`), the
23
+ * same "next joiner" scope: setting or changing it never affects anyone already in the call - and a host-only
24
+ * waiting-room toggle plus list (`waitingRoomEnabled`/`onToggleWaitingRoomEnabled`,
25
+ * `waitingParticipants`/`onAdmit`/`onDeny`): `_CallView.tsx` polls the list while this drawer is open and the
26
+ * caller is the host (there is no push signal for a newly filed admission request), so it stays current without
27
+ * the host needing to close and reopen the drawer.
28
+ *
29
+ * While talking-stick mode is on (`talkingStickActive`), every row - including the host's own - shows a "Give
30
+ * stick" button, host-only, hidden on whichever row currently holds it (`talkingStickHolder`); everyone (not just
31
+ * the host) sees a badge on that row instead, same visibility as the "Host" tag. See `_CallView.tsx`'s doc comment
32
+ * on talking-stick mode for what granting it actually does to the recipient's microphone.
33
+ */
34
+ import React, { useEffect, useState } from "react";
35
+ import type { MeshParticipant } from "../shared/webrtc/types.js";
36
+ import type { WaitingParticipant } from "./_meetApi.js";
37
+ import { TRANSPORT_BADGES } from "./_ParticipantTile.js";
38
+ import { MicOffIcon } from "./_icons.js";
39
+
40
+ export interface ParticipantsDrawerProps {
41
+ selfName: string;
42
+ micOn: boolean;
43
+ handRaised: boolean;
44
+ participants: MeshParticipant[];
45
+ isSelfHost: boolean;
46
+ isParticipantHost: (uid: string) => boolean;
47
+ /** Sends a mute request to the given participant's `uid` - a no-op from the recipient's end if they're already
48
+ * muted (see `Row`'s own "already muted" guard, which never even shows the button then). */
49
+ onMute: (uid: string) => void;
50
+ /** Removes the given participant's `uid` - see `_CallView.tsx`'s `handleKickParticipant()` for what this
51
+ * actually does (the cooperative signal plus the enforced server-side revoke). */
52
+ onKick: (uid: string) => void;
53
+ /** Whether a newly joining participant currently starts muted - the header checkbox's own state, host-only. */
54
+ forceMuteOnJoin: boolean;
55
+ onToggleForceMuteOnJoin: () => void;
56
+ /** Whether this meeting currently requires a password - never the password itself, which the server never
57
+ * sends back (see `PublicVideoMeeting.hasPassword`'s own doc comment). */
58
+ hasPassword: boolean;
59
+ /** Sets (a non-empty string), replaces, or removes (`null`) the join password. Rejecting lets
60
+ * `PasswordSection` show its own inline error without this drawer needing to know anything about it. */
61
+ onSetPassword: (password: string | null) => Promise<void>;
62
+ /** Whether the meeting currently requires the host to admit each participant - the header checkbox's own
63
+ * state, host-only. */
64
+ waitingRoomEnabled: boolean;
65
+ onToggleWaitingRoomEnabled: () => void;
66
+ /** Everyone currently waiting to be admitted - `_CallView.tsx`'s own polled, always-current list; empty
67
+ * whenever this drawer isn't open and host, by construction. */
68
+ waitingParticipants: WaitingParticipant[];
69
+ onAdmit: (uid: string) => void;
70
+ onDeny: (uid: string) => void;
71
+ /** Whether talking-stick mode is currently on - the host-only navbar toggle's own state (see `_CallView.tsx`'s
72
+ * doc comment on talking-stick mode). */
73
+ talkingStickActive: boolean;
74
+ /** This tab's own peer id - needed only so the self row's "Give stick" button can name itself when the host
75
+ * wants to take the stick back. */
76
+ selfPeerId: string;
77
+ /** Whichever peer id (`selfPeerId`, or a participant's `MeshParticipant.uid`) currently holds the stick;
78
+ * undefined while `talkingStickActive` is `false`, or (transiently) once its holder has left the call. */
79
+ talkingStickHolder?: string;
80
+ /** Hands the stick to `uid` - host-only, see `_CallView.tsx`'s `handleGiveTalkingStick()`. */
81
+ onGiveTalkingStick: (uid: string) => void;
82
+ onClose: () => void;
83
+ }
84
+
85
+ /** The host-only password form: one text input plus a Set/Change button, and a Remove button once one is set.
86
+ * Owns its own input/error/busy state locally - `_CallView.tsx` only needs to know whether a password exists, not
87
+ * what a half-typed one currently says. */
88
+ function PasswordSection({ hasPassword, onSetPassword }: { hasPassword: boolean; onSetPassword: (password: string | null) => Promise<void> }) {
89
+ const [input, setInput] = useState("");
90
+ const [error, setError] = useState<string | null>(null);
91
+ const [busy, setBusy] = useState(false);
92
+
93
+ async function submit(password: string | null) {
94
+ setBusy(true);
95
+ setError(null);
96
+ try {
97
+ await onSetPassword(password);
98
+ setInput("");
99
+ } catch {
100
+ setError("Could not save - try again.");
101
+ } finally {
102
+ setBusy(false);
103
+ }
104
+ }
105
+
106
+ return (
107
+ <div className="px-3 py-2 border-b border-white/10 text-sm">
108
+ <p className="text-white/70 mb-1.5">{hasPassword ? "Password protection is on." : "No password required to join."}</p>
109
+ <form
110
+ className="flex gap-2"
111
+ onSubmit={(event) => {
112
+ event.preventDefault();
113
+ if (input) {
114
+ void submit(input);
115
+ }
116
+ }}
117
+ >
118
+ <input
119
+ type="password"
120
+ aria-label={hasPassword ? "New password" : "Set a password"}
121
+ placeholder={hasPassword ? "New password" : "Set a password"}
122
+ className="flex-1 min-w-0 px-2 py-1 rounded bg-white/10 text-white placeholder:text-white/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
123
+ value={input}
124
+ onChange={(event) => setInput(event.target.value)}
125
+ disabled={busy}
126
+ />
127
+ <button
128
+ type="submit"
129
+ className="px-2 py-1 rounded text-xs whitespace-nowrap bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 disabled:opacity-50"
130
+ disabled={busy || !input}
131
+ >
132
+ {hasPassword ? "Change" : "Set"}
133
+ </button>
134
+ </form>
135
+ {hasPassword && (
136
+ <button
137
+ type="button"
138
+ className="mt-1.5 text-xs text-[#f2b8b5] underline focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 disabled:opacity-50"
139
+ disabled={busy}
140
+ onClick={() => void submit(null)}
141
+ >
142
+ Remove password
143
+ </button>
144
+ )}
145
+ {error && <p className="text-[#f2b8b5] text-xs mt-1.5">{error}</p>}
146
+ </div>
147
+ );
148
+ }
149
+
150
+ /** The host-only "who's waiting" list - shown only while `waitingRoomEnabled` is on, since there is otherwise never
151
+ * anyone to list. Says so explicitly when the list is empty, rather than rendering nothing (silence here would be
152
+ * indistinguishable from the list just not having loaded yet). */
153
+ function WaitingSection({
154
+ waitingParticipants,
155
+ onAdmit,
156
+ onDeny,
157
+ }: {
158
+ waitingParticipants: WaitingParticipant[];
159
+ onAdmit: (uid: string) => void;
160
+ onDeny: (uid: string) => void;
161
+ }) {
162
+ return (
163
+ <div className="px-3 py-2 border-b border-white/10 text-sm">
164
+ <p className="text-xs font-semibold uppercase tracking-wide text-white/60 mb-1.5">Waiting to join</p>
165
+ {waitingParticipants.length === 0 ? (
166
+ <p className="text-white/70">Nobody is waiting right now.</p>
167
+ ) : (
168
+ <ul>
169
+ {waitingParticipants.map((p) => (
170
+ <li key={p.uid} className="flex items-center gap-2 py-1">
171
+ <span className="flex-1 min-w-0 truncate">{p.name}</span>
172
+ <button
173
+ type="button"
174
+ className="px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
175
+ aria-label={`Admit ${p.name}`}
176
+ onClick={() => onAdmit(p.uid)}
177
+ >
178
+ Admit
179
+ </button>
180
+ <button
181
+ type="button"
182
+ className="px-2 py-0.5 rounded text-xs whitespace-nowrap bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
183
+ aria-label={`Deny ${p.name}`}
184
+ onClick={() => onDeny(p.uid)}
185
+ >
186
+ Deny
187
+ </button>
188
+ </li>
189
+ ))}
190
+ </ul>
191
+ )}
192
+ </div>
193
+ );
194
+ }
195
+
196
+ function Row({
197
+ name,
198
+ isSelf,
199
+ isHost,
200
+ micMuted,
201
+ handRaised,
202
+ transport,
203
+ canModerate,
204
+ onMute,
205
+ onKick,
206
+ hasStick,
207
+ canGiveStick,
208
+ onGiveStick,
209
+ }: {
210
+ name: string;
211
+ isSelf?: boolean;
212
+ isHost: boolean;
213
+ micMuted: boolean;
214
+ handRaised: boolean;
215
+ transport?: MeshParticipant["transport"];
216
+ /** Whether to show this row's Mute/Remove buttons - `isSelfHost && !isSelf`, decided by the caller. */
217
+ canModerate?: boolean;
218
+ onMute?: () => void;
219
+ onKick?: () => void;
220
+ /** Whether this row currently holds the talking stick - shown to every viewer, not just the host. */
221
+ hasStick?: boolean;
222
+ /** Whether to show this row's "Give stick" button - `isSelfHost && talkingStickActive`, decided by the caller;
223
+ * never shown on the row that already holds it. */
224
+ canGiveStick?: boolean;
225
+ onGiveStick?: () => void;
226
+ }) {
227
+ const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
228
+ return (
229
+ <li className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-white/5">
230
+ <span className="flex-1 min-w-0 truncate">
231
+ {name}
232
+ {isSelf ? " (you)" : ""}
233
+ </span>
234
+ {isHost && <span className="px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f]">Host</span>}
235
+ {hasStick && (
236
+ <span className="px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f]" role="img" aria-label="Holding the talking stick">
237
+ 🎙️
238
+ </span>
239
+ )}
240
+ {handRaised && (
241
+ <span role="img" aria-label="Hand raised">
242
+ ✋
243
+ </span>
244
+ )}
245
+ {micMuted && (
246
+ <span className="w-4 h-4 text-white/70 [&>svg]:w-4 [&>svg]:h-4" role="img" aria-label="Muted">
247
+ <MicOffIcon />
248
+ </span>
249
+ )}
250
+ {badge && (
251
+ <span
252
+ className={`px-2 py-0.5 rounded text-xs whitespace-nowrap ${transport === "failed" ? "bg-[#601410] text-[#f9dedc]" : "bg-black/40 text-white"}`}
253
+ title={badge.title}
254
+ >
255
+ {badge.label}
256
+ </span>
257
+ )}
258
+ {canGiveStick && !hasStick && (
259
+ <button
260
+ type="button"
261
+ className="px-2 py-0.5 rounded text-xs whitespace-nowrap bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
262
+ aria-label={`Give the talking stick to ${name}`}
263
+ onClick={onGiveStick}
264
+ >
265
+ Give stick
266
+ </button>
267
+ )}
268
+ {canModerate && !micMuted && (
269
+ <button
270
+ type="button"
271
+ className="px-2 py-0.5 rounded text-xs whitespace-nowrap bg-white/10 hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
272
+ aria-label={`Mute ${name}`}
273
+ onClick={onMute}
274
+ >
275
+ Mute
276
+ </button>
277
+ )}
278
+ {canModerate && (
279
+ <button
280
+ type="button"
281
+ className="px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#601410] text-[#f9dedc] hover:bg-[#7a1b16] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
282
+ aria-label={`Remove ${name}`}
283
+ onClick={() => {
284
+ if (window.confirm(`Remove ${name} from the call?`)) {
285
+ onKick?.();
286
+ }
287
+ }}
288
+ >
289
+ Remove
290
+ </button>
291
+ )}
292
+ </li>
293
+ );
294
+ }
295
+
296
+ export default function ParticipantsDrawer({
297
+ selfName,
298
+ micOn,
299
+ handRaised,
300
+ participants,
301
+ isSelfHost,
302
+ isParticipantHost,
303
+ onMute,
304
+ onKick,
305
+ forceMuteOnJoin,
306
+ onToggleForceMuteOnJoin,
307
+ hasPassword,
308
+ onSetPassword,
309
+ waitingRoomEnabled,
310
+ onToggleWaitingRoomEnabled,
311
+ waitingParticipants,
312
+ onAdmit,
313
+ onDeny,
314
+ talkingStickActive,
315
+ selfPeerId,
316
+ talkingStickHolder,
317
+ onGiveTalkingStick,
318
+ onClose,
319
+ }: ParticipantsDrawerProps) {
320
+ useEffect(() => {
321
+ const onKeyDown = (event: KeyboardEvent) => {
322
+ if (event.key === "Escape") {
323
+ onClose();
324
+ }
325
+ };
326
+ document.addEventListener("keydown", onKeyDown);
327
+ return () => document.removeEventListener("keydown", onKeyDown);
328
+ }, [onClose]);
329
+
330
+ return (
331
+ <>
332
+ <div className="absolute inset-0 z-20 bg-black/40" onClick={onClose} aria-hidden="true" />
333
+ <aside
334
+ role="dialog"
335
+ aria-label="Participants"
336
+ className="absolute z-20 inset-y-0 right-0 w-72 max-w-[85vw] flex flex-col bg-[#2b2d30] text-white shadow-xl"
337
+ onClick={(event) => event.stopPropagation()}
338
+ >
339
+ <div className="flex items-center justify-between gap-2 px-3 py-3 border-b border-white/10">
340
+ <h2 className="text-sm font-medium">Participants ({participants.length + 1})</h2>
341
+ <button
342
+ type="button"
343
+ className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
344
+ aria-label="Close participants"
345
+ onClick={onClose}
346
+ >
347
+ ✕
348
+ </button>
349
+ </div>
350
+ {isSelfHost && (
351
+ <label className="flex items-center gap-2 px-3 py-2 border-b border-white/10 text-sm">
352
+ <input type="checkbox" checked={forceMuteOnJoin} onChange={onToggleForceMuteOnJoin} className="w-4 h-4" />
353
+ Mute new participants on join
354
+ </label>
355
+ )}
356
+ {isSelfHost && <PasswordSection hasPassword={hasPassword} onSetPassword={onSetPassword} />}
357
+ {isSelfHost && (
358
+ <label className="flex items-center gap-2 px-3 py-2 border-b border-white/10 text-sm">
359
+ <input type="checkbox" checked={waitingRoomEnabled} onChange={onToggleWaitingRoomEnabled} className="w-4 h-4" />
360
+ Require the host to admit participants
361
+ </label>
362
+ )}
363
+ {isSelfHost && waitingRoomEnabled && (
364
+ <WaitingSection waitingParticipants={waitingParticipants} onAdmit={onAdmit} onDeny={onDeny} />
365
+ )}
366
+ <ul className="flex-1 min-h-0 overflow-y-auto p-2 text-sm">
367
+ <Row
368
+ name={selfName}
369
+ isSelf
370
+ isHost={isSelfHost}
371
+ micMuted={!micOn}
372
+ handRaised={handRaised}
373
+ hasStick={talkingStickActive && talkingStickHolder === selfPeerId}
374
+ canGiveStick={isSelfHost && talkingStickActive}
375
+ onGiveStick={() => onGiveTalkingStick(selfPeerId)}
376
+ />
377
+ {participants.map((p) => (
378
+ <Row
379
+ key={p.uid}
380
+ name={p.name}
381
+ isHost={isParticipantHost(p.uid)}
382
+ micMuted={!p.audioOn}
383
+ handRaised={p.handRaised}
384
+ transport={p.transport}
385
+ canModerate={isSelfHost}
386
+ onMute={() => onMute(p.uid)}
387
+ onKick={() => onKick(p.uid)}
388
+ hasStick={talkingStickActive && talkingStickHolder === p.uid}
389
+ canGiveStick={isSelfHost && talkingStickActive}
390
+ onGiveStick={() => onGiveTalkingStick(p.uid)}
391
+ />
392
+ ))}
393
+ </ul>
394
+ </aside>
395
+ </>
396
+ );
397
+ }
@@ -56,3 +56,15 @@ export const HandIcon = () => (
56
56
  export const EffectsIcon = () => (
57
57
  <Icon path="M19 9l1.25-2.75L23 5l-2.75-1.25L19 1l-1.25 2.75L15 5l2.75 1.25L19 9zm-7.5.5L9 4 6.5 9.5 1 12l5.5 2.5L9 20l2.5-5.5L17 12l-5.5-2.5zM19 15l-1.25 2.75L15 19l2.75 1.25L19 23l1.25-2.75L23 19l-2.75-1.25L19 15z" />
58
58
  );
59
+
60
+ export const RotateIcon = () => (
61
+ <Icon path="M12 6V2L7 7l5 5V8c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z" />
62
+ );
63
+
64
+ export const FlipIcon = () => (
65
+ <Icon path="M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8 20h2V1h-2v22zm8-6h2v-2h-2v2zM15 5h2V3h-2v2zm4 8h2v-2h-2v2zm0 8c1.1 0 2-.9 2-2h-2v2z" />
66
+ );
67
+
68
+ export const DiagnosticsIcon = () => (
69
+ <Icon path="M22 12h-4l-3 9L9 3l-3 9H2v2h5.6l1.4-4.2L13.4 21 17 12h5v-2z" />
70
+ );
@@ -29,6 +29,19 @@ export interface PublicVideoMeeting {
29
29
  visibility: VideoMeetingVisibility;
30
30
  status: VideoMeetingStatus;
31
31
  hostDisplayName?: string;
32
+ /** The account uid that owns the meeting - the sole source of a "host" identity on the client. Absent when the
33
+ * server can't resolve one (an orphaned mailbox, or a server that predates this field), in which case no host
34
+ * controls are shown to anyone. */
35
+ hostUid?: string;
36
+ /** When `true`, a joining participant's microphone starts muted - see `useLocalMedia`'s `forceMuteOnJoin`
37
+ * option, which this is threaded into. Absent reads as `false`. */
38
+ forceMuteOnJoin?: boolean;
39
+ /** Whether this meeting currently requires a password - never the password or its hash, just whether one is
40
+ * set. Absent reads as `false`. */
41
+ hasPassword?: boolean;
42
+ /** Whether this meeting currently requires the host to admit each participant before they join - see
43
+ * `requestAdmission()`. Absent reads as `false`. */
44
+ waitingRoomEnabled?: boolean;
32
45
  }
33
46
 
34
47
  /**
@@ -59,6 +72,34 @@ export interface VideoMeetingJoinResult {
59
72
  effectsAssetsUrl?: string;
60
73
  }
61
74
 
75
+ /** Mirrors `BaseVideoMeetingRoute.ts`'s `VideoMeetingPasswordRequiredResult` - returned by `joinMeeting()` in place
76
+ * of a `VideoMeetingJoinResult` when the meeting requires a password that hasn't been submitted yet. Carries only
77
+ * enough to show a password prompt (the meeting's title/host); nothing here grants anything. Narrow on
78
+ * `"requiresPassword" in result && result.requiresPassword`. */
79
+ export interface VideoMeetingPasswordRequiredResult {
80
+ meeting: PublicVideoMeeting;
81
+ requiresPassword: true;
82
+ }
83
+
84
+ /** Mirrors `BaseVideoMeetingRoute.ts`'s `VideoMeetingAdmissionRequiredResult` - returned by `requestAdmission()`
85
+ * (and re-returned by `pollAdmission()`) while a `waitingRoomEnabled` meeting's admission request is still
86
+ * pending. Unlike `VideoMeetingPasswordRequiredResult`, this carries `selfUid` (and, for a guest, `token`) - a
87
+ * caller needs a stable, authenticated identity to poll with. `joinMeeting()` itself also answers this shape, with
88
+ * none of those fields, when a waiting room applies and no request has been filed yet. Narrow on
89
+ * `"requiresAdmission" in result`. */
90
+ export interface VideoMeetingAdmissionRequiredResult {
91
+ meeting: PublicVideoMeeting;
92
+ requiresAdmission: true;
93
+ authenticated?: boolean;
94
+ selfUid?: string;
95
+ token?: string;
96
+ expiresAt?: string;
97
+ }
98
+
99
+ /** `joinMeeting()`'s actual return type - see `VideoMeetingPasswordRequiredResult`'s and
100
+ * `VideoMeetingAdmissionRequiredResult`'s own doc comments. */
101
+ export type VideoMeetingJoinResponse = VideoMeetingJoinResult | VideoMeetingPasswordRequiredResult | VideoMeetingAdmissionRequiredResult;
102
+
62
103
  /** Resolves a join token (an invitee's own link) or a public meeting's slug to its meeting info, ICE servers, and
63
104
  * either a short-lived guest signaling token (the common anonymous case) or confirmation that the caller's own
64
105
  * existing session already grants them signaling access (`authenticated: true` - see
@@ -67,6 +108,105 @@ export interface VideoMeetingJoinResult {
67
108
  * private/public token shape for what it's being used against - the caller (`[token].tsx`) shows a single,
68
109
  * friendly "this meeting link isn't valid" state for all of those rather than trying to distinguish them, matching
69
110
  * `BaseVideoMeetingRoute.requireMeetingByToken()`'s own explicit "never leak which almost-matched" posture. */
70
- export function joinMeeting(token: string): Promise<VideoMeetingJoinResult> {
111
+ export function joinMeeting(token: string): Promise<VideoMeetingJoinResponse> {
71
112
  return apiFetch(`/mail/video-meetings/join/${encodeURIComponent(token)}`);
72
113
  }
114
+
115
+ /** Submits a password for a meeting `joinMeeting()` reported `requiresPassword` for. On success, returns an
116
+ * ordinary granted `VideoMeetingJoinResult` - the same shape and same meaning as `joinMeeting()`'s own non-password
117
+ * case. Rejects with `ApiRequestError` (status `403`) for a wrong or missing password. */
118
+ export function verifyMeetingPassword(token: string, password: string): Promise<VideoMeetingJoinResult> {
119
+ return apiFetch(`/mail/video-meetings/join/${encodeURIComponent(token)}/verify`, {
120
+ method: "POST",
121
+ body: JSON.stringify({ password }),
122
+ });
123
+ }
124
+
125
+ /** Sets, replaces, or removes (`password: null`) the meeting's join password - host only, enforced the same way
126
+ * as every other owner-side video-meeting update. Hashed server-side; the plaintext is never stored or returned. */
127
+ export function setMeetingPassword(meetingUid: string, password: string | null): Promise<void> {
128
+ return apiFetch(`/mail/video-meetings/${encodeURIComponent(meetingUid)}`, {
129
+ method: "PUT",
130
+ body: JSON.stringify({ password }),
131
+ });
132
+ }
133
+
134
+ /** Files an admission request for a meeting `joinMeeting()` reported `requiresAdmission` for - `password` only
135
+ * when `meeting.hasPassword` is also set (both are checked together on this one call, never in two steps). On
136
+ * success returns `VideoMeetingAdmissionRequiredResult` with a `selfUid` (and, for a guest, `token`) to poll with
137
+ * via `pollAdmission()` - nothing is granted yet. Rejects with `ApiRequestError` (status `403`) for a wrong
138
+ * password, or `400` if `meeting.hasPassword` and no password was given. */
139
+ export function requestAdmission(token: string, body: { name: string; password?: string }): Promise<VideoMeetingAdmissionRequiredResult> {
140
+ return apiFetch(`/mail/video-meetings/join/${encodeURIComponent(token)}/verify`, {
141
+ method: "POST",
142
+ body: JSON.stringify(body),
143
+ });
144
+ }
145
+
146
+ /** Polls whether the host has responded to a pending admission request - authenticated as whichever identity
147
+ * `requestAdmission()` (or a previous poll) returned: `guestToken`, when given, is sent as an `Authorization`
148
+ * header (a guest has no session cookie of their own); omitted for an already-authenticated real caller, whose
149
+ * existing session cookie already identifies them. Resolves to a granted `VideoMeetingJoinResult` once admitted,
150
+ * or re-resolves to `VideoMeetingAdmissionRequiredResult` while still pending. Rejects with `ApiRequestError`
151
+ * (status `403`) once denied, or `404` if this identity never requested admission (or already polled past a final
152
+ * answer). */
153
+ export function pollAdmission(token: string, guestToken?: string): Promise<VideoMeetingJoinResult | VideoMeetingAdmissionRequiredResult> {
154
+ return apiFetch(`/mail/video-meetings/join/${encodeURIComponent(token)}/status`, {
155
+ headers: guestToken ? { Authorization: `jwt ${guestToken}` } : undefined,
156
+ });
157
+ }
158
+
159
+ /** One pending admission request, as `listWaitingParticipants()` returns it. */
160
+ export interface WaitingParticipant {
161
+ uid: string;
162
+ name: string;
163
+ requestedAt: string;
164
+ }
165
+
166
+ /** Lists a meeting's pending admission requests, oldest first - host only. */
167
+ export function listWaitingParticipants(meetingUid: string): Promise<WaitingParticipant[]> {
168
+ return apiFetch(`/mail/video-meetings/${encodeURIComponent(meetingUid)}/waiting`);
169
+ }
170
+
171
+ /** Admits a pending admission request - host only. The requester's own next `pollAdmission()` call completes
172
+ * their join; this alone does not notify them of anything. A no-op if `uid` has no pending request. */
173
+ export function admitParticipant(meetingUid: string, uid: string): Promise<void> {
174
+ return apiFetch(`/mail/video-meetings/${encodeURIComponent(meetingUid)}/admit/${encodeURIComponent(uid)}`, { method: "POST" });
175
+ }
176
+
177
+ /** Denies a pending admission request - host only. The requester's own next `pollAdmission()` call then rejects
178
+ * with a 403. A no-op if `uid` has no pending request. */
179
+ export function denyParticipant(meetingUid: string, uid: string): Promise<void> {
180
+ return apiFetch(`/mail/video-meetings/${encodeURIComponent(meetingUid)}/deny/${encodeURIComponent(uid)}`, { method: "POST" });
181
+ }
182
+
183
+ /** Sets whether the meeting requires the host to admit each participant (`PublicVideoMeeting.waitingRoomEnabled`) -
184
+ * host only, enforced the same way as every other owner-side video-meeting update. Takes effect for anyone who
185
+ * joins (or reloads the join link and joins again) after this call resolves. */
186
+ export function setWaitingRoomEnabled(meetingUid: string, waitingRoomEnabled: boolean): Promise<void> {
187
+ return apiFetch(`/mail/video-meetings/${encodeURIComponent(meetingUid)}`, {
188
+ method: "PUT",
189
+ body: JSON.stringify({ waitingRoomEnabled }),
190
+ });
191
+ }
192
+
193
+ /** Removes a participant from the meeting - host only, enforced server-side with the caller's own authenticated
194
+ * session (the same mailbox-ACL check every other owner-side video-meeting route uses), not by anything the
195
+ * client claims. Revokes `uid`'s grant on the meeting's own push channel, so they can no longer signal or relay
196
+ * media even if their client ignores the cooperative "kicked" signal a host's UI sends alongside this
197
+ * (`MeshConnectionManager.sendKick()`). `uid` is the participant's real account uid (`MeshParticipant.uid` minus
198
+ * its tab-scoped suffix - see `_CallView.tsx`'s `accountUidOf()`), not their tab-scoped peer id. */
199
+ export function kickParticipant(meetingUid: string, uid: string): Promise<void> {
200
+ return apiFetch(`/mail/video-meetings/${encodeURIComponent(meetingUid)}/kick/${encodeURIComponent(uid)}`, { method: "POST" });
201
+ }
202
+
203
+ /** Sets whether a newly joining participant starts muted (`PublicVideoMeeting.forceMuteOnJoin`) - host only,
204
+ * enforced the same way as every other owner-side video-meeting update. Takes effect for anyone who joins (or
205
+ * reloads the join link and joins again) after this call resolves; it does not retroactively mute anyone already
206
+ * in the call - the host's own "Mute" button (see `kickParticipant()`) is the tool for that. */
207
+ export function setForceMuteOnJoin(meetingUid: string, forceMuteOnJoin: boolean): Promise<void> {
208
+ return apiFetch(`/mail/video-meetings/${encodeURIComponent(meetingUid)}`, {
209
+ method: "PUT",
210
+ body: JSON.stringify({ forceMuteOnJoin }),
211
+ });
212
+ }