@rapidmx/meet-plugin 0.13.1 → 0.14.1

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 (41) hide show
  1. package/README.md +55 -55
  2. package/apps/meet/[token].tsx +341 -341
  3. package/apps/meet/_CallControls.tsx +463 -394
  4. package/apps/meet/_CallView.tsx +891 -848
  5. package/apps/meet/_DiagnosticsWindow.tsx +80 -0
  6. package/apps/meet/_MeetLobby.tsx +224 -224
  7. package/apps/meet/_ParticipantTile.tsx +169 -169
  8. package/apps/meet/_ParticipantsDrawer.tsx +10 -104
  9. package/apps/meet/_SettingsModal.tsx +152 -0
  10. package/apps/meet/_icons.tsx +80 -82
  11. package/apps/meet/_meetApi.ts +212 -212
  12. package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
  13. package/apps/shared/media/filters/mlModels.ts +174 -174
  14. package/apps/shared/push/GuestSignalingClient.ts +359 -359
  15. package/apps/shared/relay/RelayClient.ts +7 -2
  16. package/apps/shared/webrtc/MeshConnectionManager.ts +949 -795
  17. package/apps/shared/webrtc/types.ts +247 -222
  18. package/dist/apps/meet/_CallControls.d.ts +19 -9
  19. package/dist/apps/meet/_CallControls.js +28 -10
  20. package/dist/apps/meet/_CallView.d.ts +14 -2
  21. package/dist/apps/meet/_CallView.js +27 -2
  22. package/dist/apps/meet/_DiagnosticsWindow.d.ts +25 -0
  23. package/dist/apps/meet/_DiagnosticsWindow.js +45 -0
  24. package/dist/apps/meet/_ParticipantsDrawer.d.ts +10 -21
  25. package/dist/apps/meet/_ParticipantsDrawer.js +9 -39
  26. package/dist/apps/meet/_SettingsModal.d.ts +28 -0
  27. package/dist/apps/meet/_SettingsModal.js +59 -0
  28. package/dist/apps/meet/_icons.d.ts +1 -1
  29. package/dist/apps/meet/_icons.js +1 -1
  30. package/dist/apps/shared/relay/RelayClient.d.ts +6 -1
  31. package/dist/apps/shared/relay/RelayClient.js +7 -2
  32. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +55 -2
  33. package/dist/apps/shared/webrtc/MeshConnectionManager.js +153 -9
  34. package/dist/apps/shared/webrtc/types.d.ts +25 -1
  35. package/dist/lib/util/RedisRelayBus.js +61 -5
  36. package/dist/lib/util/RedisRelayBus.js.map +1 -1
  37. package/dist/lib/util/RelayHub.js +10 -3
  38. package/dist/lib/util/RelayHub.js.map +1 -1
  39. package/dist/types/util/RedisRelayBus.d.ts +45 -6
  40. package/dist/types/util/RelayHub.d.ts +9 -2
  41. package/package.json +208 -208
@@ -1,341 +1,341 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- /**
6
- * The public join/lobby/in-call page, `GET /meet/:token` - resolves an invitee's join token or a public meeting's
7
- * slug (`BaseVideoMeetingRoute.join()`, both shapes accepted identically - see `_meetApi.ts`), then walks through
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.
12
- *
13
- * ## Session-based name prefill - NOT implemented, and why
14
- *
15
- * The spec asks this page to prefill the name field from `Profile.givenName` when the visiting browser already
16
- * holds a RapidMX session (investigating `@rapidmx/web-client`'s `lib/profileApi.ts`/`lib/session.ts` was part of
17
- * this plugin's Phase 2 work). That turned out not to be wireable from here: `profileApi.ts`'s `getMyProfile()` needs
18
- * an `authServerUrl` to call auth-server (a *different* origin) with, and `session.ts`'s own doc comment confirms
19
- * `userUid`/`authServerUrl` are supplied only via server-side `fetchProps` on the `www`/admin console hosts
20
- * (`wwwRoute`/`AdminConsoleRoute` in `@rapidmx/server`) - never on the `public` host this page is mounted on.
21
- * `PublicPageRoute` (`@rapidmx/server`, this page's actual host route) returns only branding props; it has no
22
- * concept of the caller's session at all. There is, today, no public-host plugin page anywhere in this codebase
23
- * that does this, so there was no convention to extend - only two, cross-repo (`@rapidmx/server`/`web-client`)
24
- * ways to add one (flagged in this plugin's Phase 2 report rather than built here, both out of this frontend-only
25
- * phase's scope):
26
- *
27
- * 1. Have `PublicPageRoute.fetchProps()` check the incoming `jwt` cookie itself (the way `wwwRoute` already does)
28
- * and pass `userUid`/`authServerUrl` through like the `www`/admin hosts do.
29
- * 2. Add a small authenticated "who am I" endpoint on this server's own origin (no `authServerUrl` needed at all),
30
- * which this page could call with `apiFetch()` the same way any other same-origin authenticated call works.
31
- *
32
- * Per the spec, the name field is always left empty and editable instead - correct for the common case (most
33
- * invitees have no RapidMX account at all), just never prefilled even for a mailbox owner testing their own link.
34
- */
35
- import React, { useState } from "react";
36
- import useBranding from "@rapidmx/web-client/lib/branding/useBranding.js";
37
- import { Branding } from "@rapidmx/web-client/lib/branding/brandingApi.js";
38
- import { ApiRequestError } from "@rapidmx/web-client/lib/util/api.js";
39
- import Button from "@rapidmx/web-client/lib/components/buttons/Button.js";
40
- import Alert from "@rapidmx/web-client/lib/components/feedback/Alert.js";
41
- import FormField from "@rapidmx/web-client/lib/components/forms/FormField.js";
42
- import { MeetCard, MeetPageShell } from "./_MeetChrome.js";
43
- import { useLocalMedia } from "../shared/media/useLocalMedia.js";
44
- import MeetLobby from "./_MeetLobby.js";
45
- import CallView from "./_CallView.js";
46
- import {
47
- type PublicVideoMeeting,
48
- type VideoMeetingJoinResult,
49
- joinMeeting,
50
- pollAdmission,
51
- requestAdmission,
52
- verifyMeetingPassword,
53
- } from "./_meetApi.js";
54
-
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;
61
-
62
- export default function MeetJoinPage({ params }: { params: { token: string } }) {
63
- const { branding } = useBranding();
64
- return <MeetJoinContent token={params.token} branding={branding} />;
65
- }
66
-
67
- /** Owns the camera and microphone (`useLocalMedia()`) for the whole visit, so the tracks the lobby previews are the
68
- * ones the call sends. Every phase but the call is drawn inside the branded page shell; the call fills the whole
69
- * window instead (see `_CallView.tsx`). */
70
- function MeetJoinContent({ token, branding }: { token: string; branding: Branding | null }) {
71
- const [phase, setPhase] = useState<Phase>("loading");
72
- const [name, setName] = useState("");
73
- const [loadError, setLoadError] = useState<string | null>(null);
74
- const [joinResult, setJoinResult] = useState<VideoMeetingJoinResult | null>(null);
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 });
97
- const { release } = media;
98
-
99
- React.useEffect(() => {
100
- let cancelled = false;
101
- joinMeeting(token)
102
- .then((result) => {
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;
115
- }
116
- setJoinResult(result);
117
- setPhase("lobby");
118
- })
119
- .catch((err) => {
120
- if (cancelled) {
121
- return;
122
- }
123
- if (err instanceof ApiRequestError && err.status === 404) {
124
- setPhase("not-found");
125
- return;
126
- }
127
- setLoadError(err instanceof ApiRequestError ? err.message : "Could not load this meeting.");
128
- setPhase("not-found");
129
- });
130
- return () => {
131
- cancelled = true;
132
- };
133
- }, [token]);
134
-
135
- function handleJoin(joinName: string) {
136
- setName(joinName);
137
- setPhase("in-call");
138
- }
139
-
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) {
201
- release();
202
- setEndedReason(reason);
203
- setPhase("ended");
204
- }
205
-
206
- if (phase === "in-call" && joinResult) {
207
- return (
208
- <CallView
209
- channel={joinResult.meeting.uid}
210
- // `token` is present only for the common anonymous case (`authenticated: false`) - when the
211
- // caller's own existing session already authenticated them (`authenticated: true`), there is no
212
- // guest token to apply as a cookie at all, and `GuestSignalingClient` relies entirely on the
213
- // browser's own already-existing `jwt` session cookie instead (see its own doc comment and
214
- // `VideoMeetingJoinResult`'s doc comment in `_meetApi.ts`).
215
- token={joinResult.token}
216
- selfUid={joinResult.selfUid}
217
- selfName={name}
218
- meetingTitle={joinResult.meeting.title}
219
- iceServers={joinResult.iceServers}
220
- relayEnabled={joinResult.relayEnabled}
221
- hostUid={joinResult.meeting.hostUid}
222
- initialForceMuteOnJoin={!!joinResult.meeting.forceMuteOnJoin}
223
- initialHasPassword={!!joinResult.meeting.hasPassword}
224
- initialWaitingRoomEnabled={!!joinResult.meeting.waitingRoomEnabled}
225
- media={media}
226
- onLeave={handleLeave}
227
- />
228
- );
229
- }
230
-
231
- let content: React.ReactNode;
232
- if (phase === "loading") {
233
- content = (
234
- <MeetCard>
235
- <p className="text-base text-text-muted">Loading&hellip;</p>
236
- </MeetCard>
237
- );
238
- } else if (phase === "not-found") {
239
- content = (
240
- <MeetCard>
241
- <Alert>{loadError ?? "This meeting link isn't valid."}</Alert>
242
- </MeetCard>
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
- );
320
- } else if (phase === "ended") {
321
- content = (
322
- <MeetCard>
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
- )}
330
- </MeetCard>
331
- );
332
- } else {
333
- // phase === "lobby" (joinResult is always set by then - see the effect above).
334
- content = (
335
- <MeetCard maxWidth="max-w-4xl">
336
- <MeetLobby meeting={joinResult!.meeting} media={media} initialName={name} onJoin={handleJoin} />
337
- </MeetCard>
338
- );
339
- }
340
- return <MeetPageShell branding={branding}>{content}</MeetPageShell>;
341
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The public join/lobby/in-call page, `GET /meet/:token` - resolves an invitee's join token or a public meeting's
7
+ * slug (`BaseVideoMeetingRoute.join()`, both shapes accepted identically - see `_meetApi.ts`), then walks through
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.
12
+ *
13
+ * ## Session-based name prefill - NOT implemented, and why
14
+ *
15
+ * The spec asks this page to prefill the name field from `Profile.givenName` when the visiting browser already
16
+ * holds a RapidMX session (investigating `@rapidmx/web-client`'s `lib/profileApi.ts`/`lib/session.ts` was part of
17
+ * this plugin's Phase 2 work). That turned out not to be wireable from here: `profileApi.ts`'s `getMyProfile()` needs
18
+ * an `authServerUrl` to call auth-server (a *different* origin) with, and `session.ts`'s own doc comment confirms
19
+ * `userUid`/`authServerUrl` are supplied only via server-side `fetchProps` on the `www`/admin console hosts
20
+ * (`wwwRoute`/`AdminConsoleRoute` in `@rapidmx/server`) - never on the `public` host this page is mounted on.
21
+ * `PublicPageRoute` (`@rapidmx/server`, this page's actual host route) returns only branding props; it has no
22
+ * concept of the caller's session at all. There is, today, no public-host plugin page anywhere in this codebase
23
+ * that does this, so there was no convention to extend - only two, cross-repo (`@rapidmx/server`/`web-client`)
24
+ * ways to add one (flagged in this plugin's Phase 2 report rather than built here, both out of this frontend-only
25
+ * phase's scope):
26
+ *
27
+ * 1. Have `PublicPageRoute.fetchProps()` check the incoming `jwt` cookie itself (the way `wwwRoute` already does)
28
+ * and pass `userUid`/`authServerUrl` through like the `www`/admin hosts do.
29
+ * 2. Add a small authenticated "who am I" endpoint on this server's own origin (no `authServerUrl` needed at all),
30
+ * which this page could call with `apiFetch()` the same way any other same-origin authenticated call works.
31
+ *
32
+ * Per the spec, the name field is always left empty and editable instead - correct for the common case (most
33
+ * invitees have no RapidMX account at all), just never prefilled even for a mailbox owner testing their own link.
34
+ */
35
+ import React, { useState } from "react";
36
+ import useBranding from "@rapidmx/web-client/lib/branding/useBranding.js";
37
+ import { Branding } from "@rapidmx/web-client/lib/branding/brandingApi.js";
38
+ import { ApiRequestError } from "@rapidmx/web-client/lib/util/api.js";
39
+ import Button from "@rapidmx/web-client/lib/components/buttons/Button.js";
40
+ import Alert from "@rapidmx/web-client/lib/components/feedback/Alert.js";
41
+ import FormField from "@rapidmx/web-client/lib/components/forms/FormField.js";
42
+ import { MeetCard, MeetPageShell } from "./_MeetChrome.js";
43
+ import { useLocalMedia } from "../shared/media/useLocalMedia.js";
44
+ import MeetLobby from "./_MeetLobby.js";
45
+ import CallView from "./_CallView.js";
46
+ import {
47
+ type PublicVideoMeeting,
48
+ type VideoMeetingJoinResult,
49
+ joinMeeting,
50
+ pollAdmission,
51
+ requestAdmission,
52
+ verifyMeetingPassword,
53
+ } from "./_meetApi.js";
54
+
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;
61
+
62
+ export default function MeetJoinPage({ params }: { params: { token: string } }) {
63
+ const { branding } = useBranding();
64
+ return <MeetJoinContent token={params.token} branding={branding} />;
65
+ }
66
+
67
+ /** Owns the camera and microphone (`useLocalMedia()`) for the whole visit, so the tracks the lobby previews are the
68
+ * ones the call sends. Every phase but the call is drawn inside the branded page shell; the call fills the whole
69
+ * window instead (see `_CallView.tsx`). */
70
+ function MeetJoinContent({ token, branding }: { token: string; branding: Branding | null }) {
71
+ const [phase, setPhase] = useState<Phase>("loading");
72
+ const [name, setName] = useState("");
73
+ const [loadError, setLoadError] = useState<string | null>(null);
74
+ const [joinResult, setJoinResult] = useState<VideoMeetingJoinResult | null>(null);
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 });
97
+ const { release } = media;
98
+
99
+ React.useEffect(() => {
100
+ let cancelled = false;
101
+ joinMeeting(token)
102
+ .then((result) => {
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;
115
+ }
116
+ setJoinResult(result);
117
+ setPhase("lobby");
118
+ })
119
+ .catch((err) => {
120
+ if (cancelled) {
121
+ return;
122
+ }
123
+ if (err instanceof ApiRequestError && err.status === 404) {
124
+ setPhase("not-found");
125
+ return;
126
+ }
127
+ setLoadError(err instanceof ApiRequestError ? err.message : "Could not load this meeting.");
128
+ setPhase("not-found");
129
+ });
130
+ return () => {
131
+ cancelled = true;
132
+ };
133
+ }, [token]);
134
+
135
+ function handleJoin(joinName: string) {
136
+ setName(joinName);
137
+ setPhase("in-call");
138
+ }
139
+
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) {
201
+ release();
202
+ setEndedReason(reason);
203
+ setPhase("ended");
204
+ }
205
+
206
+ if (phase === "in-call" && joinResult) {
207
+ return (
208
+ <CallView
209
+ channel={joinResult.meeting.uid}
210
+ // `token` is present only for the common anonymous case (`authenticated: false`) - when the
211
+ // caller's own existing session already authenticated them (`authenticated: true`), there is no
212
+ // guest token to apply as a cookie at all, and `GuestSignalingClient` relies entirely on the
213
+ // browser's own already-existing `jwt` session cookie instead (see its own doc comment and
214
+ // `VideoMeetingJoinResult`'s doc comment in `_meetApi.ts`).
215
+ token={joinResult.token}
216
+ selfUid={joinResult.selfUid}
217
+ selfName={name}
218
+ meetingTitle={joinResult.meeting.title}
219
+ iceServers={joinResult.iceServers}
220
+ relayEnabled={joinResult.relayEnabled}
221
+ hostUid={joinResult.meeting.hostUid}
222
+ initialForceMuteOnJoin={!!joinResult.meeting.forceMuteOnJoin}
223
+ initialHasPassword={!!joinResult.meeting.hasPassword}
224
+ initialWaitingRoomEnabled={!!joinResult.meeting.waitingRoomEnabled}
225
+ media={media}
226
+ onLeave={handleLeave}
227
+ />
228
+ );
229
+ }
230
+
231
+ let content: React.ReactNode;
232
+ if (phase === "loading") {
233
+ content = (
234
+ <MeetCard>
235
+ <p className="text-base text-text-muted">Loading&hellip;</p>
236
+ </MeetCard>
237
+ );
238
+ } else if (phase === "not-found") {
239
+ content = (
240
+ <MeetCard>
241
+ <Alert>{loadError ?? "This meeting link isn't valid."}</Alert>
242
+ </MeetCard>
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
+ );
320
+ } else if (phase === "ended") {
321
+ content = (
322
+ <MeetCard>
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
+ )}
330
+ </MeetCard>
331
+ );
332
+ } else {
333
+ // phase === "lobby" (joinResult is always set by then - see the effect above).
334
+ content = (
335
+ <MeetCard maxWidth="max-w-4xl">
336
+ <MeetLobby meeting={joinResult!.meeting} media={media} initialName={name} onJoin={handleJoin} />
337
+ </MeetCard>
338
+ );
339
+ }
340
+ return <MeetPageShell branding={branding}>{content}</MeetPageShell>;
341
+ }