@rapidmx/meet-plugin 0.9.2 → 0.10.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 (60) hide show
  1. package/apps/meet/[token].tsx +204 -13
  2. package/apps/meet/_CallControls.tsx +64 -3
  3. package/apps/meet/_CallView.tsx +244 -7
  4. package/apps/meet/_DiagnosticsPanel.tsx +113 -0
  5. package/apps/meet/_ParticipantTile.tsx +10 -2
  6. package/apps/meet/_ParticipantsDrawer.tsx +341 -0
  7. package/apps/meet/_icons.tsx +12 -0
  8. package/apps/meet/_meetApi.ts +141 -1
  9. package/apps/shared/media/deviceMedia.ts +22 -4
  10. package/apps/shared/media/filters/ScreenTransform.ts +148 -0
  11. package/apps/shared/media/useLocalMedia.ts +38 -10
  12. package/apps/shared/relay/AudioPlayer.ts +30 -4
  13. package/apps/shared/relay/relayEnv.ts +8 -1
  14. package/apps/shared/webrtc/MeshConnectionManager.ts +89 -4
  15. package/apps/shared/webrtc/realPeerConnection.ts +47 -7
  16. package/apps/shared/webrtc/types.ts +55 -7
  17. package/dist/apps/meet/[token].d.ts +4 -1
  18. package/dist/apps/meet/[token].js +121 -9
  19. package/dist/apps/meet/_CallControls.d.ts +9 -1
  20. package/dist/apps/meet/_CallControls.js +6 -5
  21. package/dist/apps/meet/_CallView.d.ts +28 -2
  22. package/dist/apps/meet/_CallView.js +155 -6
  23. package/dist/apps/meet/_DiagnosticsPanel.d.ts +22 -0
  24. package/dist/apps/meet/_DiagnosticsPanel.js +32 -0
  25. package/dist/apps/meet/_ParticipantTile.d.ts +8 -0
  26. package/dist/apps/meet/_ParticipantTile.js +9 -2
  27. package/dist/apps/meet/_ParticipantsDrawer.d.ts +61 -0
  28. package/dist/apps/meet/_ParticipantsDrawer.js +86 -0
  29. package/dist/apps/meet/_icons.d.ts +3 -0
  30. package/dist/apps/meet/_icons.js +3 -0
  31. package/dist/apps/meet/_meetApi.d.ts +93 -1
  32. package/dist/apps/meet/_meetApi.js +82 -0
  33. package/dist/apps/shared/media/deviceMedia.d.ts +11 -2
  34. package/dist/apps/shared/media/deviceMedia.js +20 -4
  35. package/dist/apps/shared/media/filters/ScreenTransform.d.ts +53 -0
  36. package/dist/apps/shared/media/filters/ScreenTransform.js +100 -0
  37. package/dist/apps/shared/media/useLocalMedia.d.ts +5 -1
  38. package/dist/apps/shared/media/useLocalMedia.js +32 -10
  39. package/dist/apps/shared/relay/AudioPlayer.d.ts +14 -2
  40. package/dist/apps/shared/relay/AudioPlayer.js +31 -4
  41. package/dist/apps/shared/relay/relayEnv.d.ts +5 -0
  42. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +30 -1
  43. package/dist/apps/shared/webrtc/MeshConnectionManager.js +78 -3
  44. package/dist/apps/shared/webrtc/realPeerConnection.d.ts +19 -6
  45. package/dist/apps/shared/webrtc/realPeerConnection.js +42 -5
  46. package/dist/apps/shared/webrtc/types.d.ts +54 -6
  47. package/dist/lib/models/mongo/VideoMeetingMongo.js +21 -0
  48. package/dist/lib/models/mongo/VideoMeetingMongo.js.map +1 -1
  49. package/dist/lib/models/sql/VideoMeetingSQL.js +21 -0
  50. package/dist/lib/models/sql/VideoMeetingSQL.js.map +1 -1
  51. package/dist/lib/routes/BaseVideoMeetingRoute.js +380 -51
  52. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  53. package/dist/lib/util/PasswordUtils.js +52 -0
  54. package/dist/lib/util/PasswordUtils.js.map +1 -0
  55. package/dist/types/models/mongo/VideoMeetingMongo.d.ts +3 -0
  56. package/dist/types/models/sql/VideoMeetingSQL.d.ts +3 -0
  57. package/dist/types/models/types.d.ts +23 -0
  58. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +147 -5
  59. package/dist/types/util/PasswordUtils.d.ts +17 -0
  60. package/package.json +1 -1
@@ -0,0 +1,341 @@
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
+ import React, { useEffect, useState } from "react";
30
+ import type { MeshParticipant } from "../shared/webrtc/types.js";
31
+ import type { WaitingParticipant } from "./_meetApi.js";
32
+ import { TRANSPORT_BADGES } from "./_ParticipantTile.js";
33
+ import { MicOffIcon } from "./_icons.js";
34
+
35
+ export interface ParticipantsDrawerProps {
36
+ selfName: string;
37
+ micOn: boolean;
38
+ handRaised: boolean;
39
+ participants: MeshParticipant[];
40
+ isSelfHost: boolean;
41
+ isParticipantHost: (uid: string) => boolean;
42
+ /** Sends a mute request to the given participant's `uid` - a no-op from the recipient's end if they're already
43
+ * muted (see `Row`'s own "already muted" guard, which never even shows the button then). */
44
+ onMute: (uid: string) => void;
45
+ /** Removes the given participant's `uid` - see `_CallView.tsx`'s `handleKickParticipant()` for what this
46
+ * actually does (the cooperative signal plus the enforced server-side revoke). */
47
+ onKick: (uid: string) => void;
48
+ /** Whether a newly joining participant currently starts muted - the header checkbox's own state, host-only. */
49
+ forceMuteOnJoin: boolean;
50
+ onToggleForceMuteOnJoin: () => void;
51
+ /** Whether this meeting currently requires a password - never the password itself, which the server never
52
+ * sends back (see `PublicVideoMeeting.hasPassword`'s own doc comment). */
53
+ hasPassword: boolean;
54
+ /** Sets (a non-empty string), replaces, or removes (`null`) the join password. Rejecting lets
55
+ * `PasswordSection` show its own inline error without this drawer needing to know anything about it. */
56
+ onSetPassword: (password: string | null) => Promise<void>;
57
+ /** Whether the meeting currently requires the host to admit each participant - the header checkbox's own
58
+ * state, host-only. */
59
+ waitingRoomEnabled: boolean;
60
+ onToggleWaitingRoomEnabled: () => void;
61
+ /** Everyone currently waiting to be admitted - `_CallView.tsx`'s own polled, always-current list; empty
62
+ * whenever this drawer isn't open and host, by construction. */
63
+ waitingParticipants: WaitingParticipant[];
64
+ onAdmit: (uid: string) => void;
65
+ onDeny: (uid: string) => void;
66
+ onClose: () => void;
67
+ }
68
+
69
+ /** The host-only password form: one text input plus a Set/Change button, and a Remove button once one is set.
70
+ * Owns its own input/error/busy state locally - `_CallView.tsx` only needs to know whether a password exists, not
71
+ * what a half-typed one currently says. */
72
+ function PasswordSection({ hasPassword, onSetPassword }: { hasPassword: boolean; onSetPassword: (password: string | null) => Promise<void> }) {
73
+ const [input, setInput] = useState("");
74
+ const [error, setError] = useState<string | null>(null);
75
+ const [busy, setBusy] = useState(false);
76
+
77
+ async function submit(password: string | null) {
78
+ setBusy(true);
79
+ setError(null);
80
+ try {
81
+ await onSetPassword(password);
82
+ setInput("");
83
+ } catch {
84
+ setError("Could not save - try again.");
85
+ } finally {
86
+ setBusy(false);
87
+ }
88
+ }
89
+
90
+ return (
91
+ <div className="px-3 py-2 border-b border-white/10 text-sm">
92
+ <p className="text-white/70 mb-1.5">{hasPassword ? "Password protection is on." : "No password required to join."}</p>
93
+ <form
94
+ className="flex gap-2"
95
+ onSubmit={(event) => {
96
+ event.preventDefault();
97
+ if (input) {
98
+ void submit(input);
99
+ }
100
+ }}
101
+ >
102
+ <input
103
+ type="password"
104
+ aria-label={hasPassword ? "New password" : "Set a password"}
105
+ placeholder={hasPassword ? "New password" : "Set a password"}
106
+ 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"
107
+ value={input}
108
+ onChange={(event) => setInput(event.target.value)}
109
+ disabled={busy}
110
+ />
111
+ <button
112
+ type="submit"
113
+ 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"
114
+ disabled={busy || !input}
115
+ >
116
+ {hasPassword ? "Change" : "Set"}
117
+ </button>
118
+ </form>
119
+ {hasPassword && (
120
+ <button
121
+ type="button"
122
+ className="mt-1.5 text-xs text-[#f2b8b5] underline focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 disabled:opacity-50"
123
+ disabled={busy}
124
+ onClick={() => void submit(null)}
125
+ >
126
+ Remove password
127
+ </button>
128
+ )}
129
+ {error && <p className="text-[#f2b8b5] text-xs mt-1.5">{error}</p>}
130
+ </div>
131
+ );
132
+ }
133
+
134
+ /** The host-only "who's waiting" list - shown only while `waitingRoomEnabled` is on, since there is otherwise never
135
+ * anyone to list. Says so explicitly when the list is empty, rather than rendering nothing (silence here would be
136
+ * indistinguishable from the list just not having loaded yet). */
137
+ function WaitingSection({
138
+ waitingParticipants,
139
+ onAdmit,
140
+ onDeny,
141
+ }: {
142
+ waitingParticipants: WaitingParticipant[];
143
+ onAdmit: (uid: string) => void;
144
+ onDeny: (uid: string) => void;
145
+ }) {
146
+ return (
147
+ <div className="px-3 py-2 border-b border-white/10 text-sm">
148
+ <p className="text-xs font-semibold uppercase tracking-wide text-white/60 mb-1.5">Waiting to join</p>
149
+ {waitingParticipants.length === 0 ? (
150
+ <p className="text-white/70">Nobody is waiting right now.</p>
151
+ ) : (
152
+ <ul>
153
+ {waitingParticipants.map((p) => (
154
+ <li key={p.uid} className="flex items-center gap-2 py-1">
155
+ <span className="flex-1 min-w-0 truncate">{p.name}</span>
156
+ <button
157
+ type="button"
158
+ 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"
159
+ aria-label={`Admit ${p.name}`}
160
+ onClick={() => onAdmit(p.uid)}
161
+ >
162
+ Admit
163
+ </button>
164
+ <button
165
+ type="button"
166
+ 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"
167
+ aria-label={`Deny ${p.name}`}
168
+ onClick={() => onDeny(p.uid)}
169
+ >
170
+ Deny
171
+ </button>
172
+ </li>
173
+ ))}
174
+ </ul>
175
+ )}
176
+ </div>
177
+ );
178
+ }
179
+
180
+ function Row({
181
+ name,
182
+ isSelf,
183
+ isHost,
184
+ micMuted,
185
+ handRaised,
186
+ transport,
187
+ canModerate,
188
+ onMute,
189
+ onKick,
190
+ }: {
191
+ name: string;
192
+ isSelf?: boolean;
193
+ isHost: boolean;
194
+ micMuted: boolean;
195
+ handRaised: boolean;
196
+ transport?: MeshParticipant["transport"];
197
+ /** Whether to show this row's Mute/Remove buttons - `isSelfHost && !isSelf`, decided by the caller. */
198
+ canModerate?: boolean;
199
+ onMute?: () => void;
200
+ onKick?: () => void;
201
+ }) {
202
+ const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
203
+ return (
204
+ <li className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-white/5">
205
+ <span className="flex-1 min-w-0 truncate">
206
+ {name}
207
+ {isSelf ? " (you)" : ""}
208
+ </span>
209
+ {isHost && <span className="px-2 py-0.5 rounded text-xs whitespace-nowrap bg-[#a8c7fa] text-[#062e6f]">Host</span>}
210
+ {handRaised && (
211
+ <span role="img" aria-label="Hand raised">
212
+ ✋
213
+ </span>
214
+ )}
215
+ {micMuted && (
216
+ <span className="w-4 h-4 text-white/70 [&>svg]:w-4 [&>svg]:h-4" role="img" aria-label="Muted">
217
+ <MicOffIcon />
218
+ </span>
219
+ )}
220
+ {badge && (
221
+ <span
222
+ className={`px-2 py-0.5 rounded text-xs whitespace-nowrap ${transport === "failed" ? "bg-[#601410] text-[#f9dedc]" : "bg-black/40 text-white"}`}
223
+ title={badge.title}
224
+ >
225
+ {badge.label}
226
+ </span>
227
+ )}
228
+ {canModerate && !micMuted && (
229
+ <button
230
+ type="button"
231
+ 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"
232
+ aria-label={`Mute ${name}`}
233
+ onClick={onMute}
234
+ >
235
+ Mute
236
+ </button>
237
+ )}
238
+ {canModerate && (
239
+ <button
240
+ type="button"
241
+ 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"
242
+ aria-label={`Remove ${name}`}
243
+ onClick={() => {
244
+ if (window.confirm(`Remove ${name} from the call?`)) {
245
+ onKick?.();
246
+ }
247
+ }}
248
+ >
249
+ Remove
250
+ </button>
251
+ )}
252
+ </li>
253
+ );
254
+ }
255
+
256
+ export default function ParticipantsDrawer({
257
+ selfName,
258
+ micOn,
259
+ handRaised,
260
+ participants,
261
+ isSelfHost,
262
+ isParticipantHost,
263
+ onMute,
264
+ onKick,
265
+ forceMuteOnJoin,
266
+ onToggleForceMuteOnJoin,
267
+ hasPassword,
268
+ onSetPassword,
269
+ waitingRoomEnabled,
270
+ onToggleWaitingRoomEnabled,
271
+ waitingParticipants,
272
+ onAdmit,
273
+ onDeny,
274
+ onClose,
275
+ }: ParticipantsDrawerProps) {
276
+ useEffect(() => {
277
+ const onKeyDown = (event: KeyboardEvent) => {
278
+ if (event.key === "Escape") {
279
+ onClose();
280
+ }
281
+ };
282
+ document.addEventListener("keydown", onKeyDown);
283
+ return () => document.removeEventListener("keydown", onKeyDown);
284
+ }, [onClose]);
285
+
286
+ return (
287
+ <>
288
+ <div className="absolute inset-0 z-20 bg-black/40" onClick={onClose} aria-hidden="true" />
289
+ <aside
290
+ role="dialog"
291
+ aria-label="Participants"
292
+ className="absolute z-20 inset-y-0 right-0 w-72 max-w-[85vw] flex flex-col bg-[#2b2d30] text-white shadow-xl"
293
+ onClick={(event) => event.stopPropagation()}
294
+ >
295
+ <div className="flex items-center justify-between gap-2 px-3 py-3 border-b border-white/10">
296
+ <h2 className="text-sm font-medium">Participants ({participants.length + 1})</h2>
297
+ <button
298
+ type="button"
299
+ 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"
300
+ aria-label="Close participants"
301
+ onClick={onClose}
302
+ >
303
+ ✕
304
+ </button>
305
+ </div>
306
+ {isSelfHost && (
307
+ <label className="flex items-center gap-2 px-3 py-2 border-b border-white/10 text-sm">
308
+ <input type="checkbox" checked={forceMuteOnJoin} onChange={onToggleForceMuteOnJoin} className="w-4 h-4" />
309
+ Mute new participants on join
310
+ </label>
311
+ )}
312
+ {isSelfHost && <PasswordSection hasPassword={hasPassword} onSetPassword={onSetPassword} />}
313
+ {isSelfHost && (
314
+ <label className="flex items-center gap-2 px-3 py-2 border-b border-white/10 text-sm">
315
+ <input type="checkbox" checked={waitingRoomEnabled} onChange={onToggleWaitingRoomEnabled} className="w-4 h-4" />
316
+ Require the host to admit participants
317
+ </label>
318
+ )}
319
+ {isSelfHost && waitingRoomEnabled && (
320
+ <WaitingSection waitingParticipants={waitingParticipants} onAdmit={onAdmit} onDeny={onDeny} />
321
+ )}
322
+ <ul className="flex-1 min-h-0 overflow-y-auto p-2 text-sm">
323
+ <Row name={selfName} isSelf isHost={isSelfHost} micMuted={!micOn} handRaised={handRaised} />
324
+ {participants.map((p) => (
325
+ <Row
326
+ key={p.uid}
327
+ name={p.name}
328
+ isHost={isParticipantHost(p.uid)}
329
+ micMuted={!p.audioOn}
330
+ handRaised={p.handRaised}
331
+ transport={p.transport}
332
+ canModerate={isSelfHost}
333
+ onMute={() => onMute(p.uid)}
334
+ onKick={() => onKick(p.uid)}
335
+ />
336
+ ))}
337
+ </ul>
338
+ </aside>
339
+ </>
340
+ );
341
+ }
@@ -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
+ }
@@ -50,10 +50,28 @@ const UNSUPPORTED_ERROR: MediaAccessError = {
50
50
  message: "This browser (or this page's connection) doesn't support camera/microphone access. Try a modern browser over HTTPS.",
51
51
  };
52
52
 
53
+ /** A "no such device" error naming exactly what was missing: just the microphone, just the camera, or - when
54
+ * both were asked for (or a caller doesn't say, e.g. `listDevices()`/`requestDisplayMedia()`'s own failures, which
55
+ * have no single "kind" to name) - both. Exported so `useLocalMedia.ts`'s `acquireBoth()` can build the same
56
+ * "both missing" message after finding out separately that neither device exists, rather than only being able to
57
+ * report whichever one it happened to ask about last. */
58
+ export function notFoundError(wantsAudio: boolean, wantsVideo: boolean): MediaAccessError {
59
+ const message =
60
+ wantsAudio && !wantsVideo
61
+ ? "No microphone was found on this device."
62
+ : wantsVideo && !wantsAudio
63
+ ? "No camera was found on this device."
64
+ : "No camera or microphone was found on this device.";
65
+ return { kind: "not-found", message };
66
+ }
67
+
53
68
  /** Maps a `getUserMedia()`/`getDisplayMedia()`/`enumerateDevices()` rejection to a friendly, already-complete
54
69
  * message - the handful of `DOMException` names a browser actually raises for these calls, matched by name rather
55
- * than `instanceof DOMException` (a test's fake rejection need not be a real `DOMException`). */
56
- export function classifyMediaError(err: unknown): MediaAccessError {
70
+ * than `instanceof DOMException` (a test's fake rejection need not be a real `DOMException`). `requested` is the
71
+ * constraints the failed call actually asked for, so a "not found" error can name just the camera or just the
72
+ * microphone rather than always blaming both - omitted (by `listDevices()`/`requestDisplayMedia()`, neither of
73
+ * which asks for one kind specifically) it's treated as having asked for both. */
74
+ export function classifyMediaError(err: unknown, requested?: MediaStreamConstraints): MediaAccessError {
57
75
  const name: string = err instanceof Error ? err.name : "";
58
76
  switch (name) {
59
77
  case "NotAllowedError":
@@ -61,7 +79,7 @@ export function classifyMediaError(err: unknown): MediaAccessError {
61
79
  return { kind: "permission-denied", message: "Camera/microphone access was denied. Allow access in your browser and try again." };
62
80
  case "NotFoundError":
63
81
  case "OverconstrainedError":
64
- return { kind: "not-found", message: "No camera or microphone was found on this device." };
82
+ return notFoundError(requested ? !!requested.audio : true, requested ? !!requested.video : true);
65
83
  default:
66
84
  return { kind: "unknown", message: "Could not access your camera or microphone. Please try again." };
67
85
  }
@@ -107,7 +125,7 @@ export async function requestUserMedia(
107
125
  try {
108
126
  return { ok: true, value: await devices.getUserMedia(constraints) };
109
127
  } catch (err) {
110
- return { ok: false, error: classifyMediaError(err) };
128
+ return { ok: false, error: classifyMediaError(err, constraints) };
111
129
  }
112
130
  }
113
131