@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.
- package/apps/meet/[token].tsx +204 -13
- package/apps/meet/_CallControls.tsx +75 -5
- package/apps/meet/_CallView.tsx +326 -7
- package/apps/meet/_DiagnosticsPanel.tsx +113 -0
- package/apps/meet/_ParticipantTile.tsx +10 -2
- package/apps/meet/_ParticipantsDrawer.tsx +397 -0
- package/apps/meet/_icons.tsx +12 -0
- package/apps/meet/_meetApi.ts +141 -1
- package/apps/shared/media/filters/ScreenTransform.ts +148 -0
- package/apps/shared/media/useLocalMedia.ts +7 -3
- package/apps/shared/webrtc/MeshConnectionManager.ts +115 -4
- package/apps/shared/webrtc/realPeerConnection.ts +47 -7
- package/apps/shared/webrtc/types.ts +67 -8
- package/dist/apps/meet/[token].d.ts +4 -1
- package/dist/apps/meet/[token].js +121 -9
- package/dist/apps/meet/_CallControls.d.ts +16 -2
- package/dist/apps/meet/_CallControls.js +9 -6
- package/dist/apps/meet/_CallView.d.ts +45 -2
- package/dist/apps/meet/_CallView.js +215 -6
- package/dist/apps/meet/_DiagnosticsPanel.d.ts +22 -0
- package/dist/apps/meet/_DiagnosticsPanel.js +32 -0
- package/dist/apps/meet/_ParticipantTile.d.ts +8 -0
- package/dist/apps/meet/_ParticipantTile.js +9 -2
- package/dist/apps/meet/_ParticipantsDrawer.d.ts +77 -0
- package/dist/apps/meet/_ParticipantsDrawer.js +91 -0
- package/dist/apps/meet/_icons.d.ts +3 -0
- package/dist/apps/meet/_icons.js +3 -0
- package/dist/apps/meet/_meetApi.d.ts +93 -1
- package/dist/apps/meet/_meetApi.js +82 -0
- package/dist/apps/shared/media/filters/ScreenTransform.d.ts +53 -0
- package/dist/apps/shared/media/filters/ScreenTransform.js +100 -0
- package/dist/apps/shared/media/useLocalMedia.d.ts +5 -1
- package/dist/apps/shared/media/useLocalMedia.js +3 -3
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +49 -1
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +103 -3
- package/dist/apps/shared/webrtc/realPeerConnection.d.ts +19 -6
- package/dist/apps/shared/webrtc/realPeerConnection.js +42 -5
- package/dist/apps/shared/webrtc/types.d.ts +69 -7
- package/dist/lib/models/mongo/VideoMeetingMongo.js +21 -0
- package/dist/lib/models/mongo/VideoMeetingMongo.js.map +1 -1
- package/dist/lib/models/sql/VideoMeetingSQL.js +21 -0
- package/dist/lib/models/sql/VideoMeetingSQL.js.map +1 -1
- package/dist/lib/routes/BaseVideoMeetingRoute.js +380 -51
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
- package/dist/lib/util/PasswordUtils.js +52 -0
- package/dist/lib/util/PasswordUtils.js.map +1 -0
- package/dist/types/models/mongo/VideoMeetingMongo.d.ts +3 -0
- package/dist/types/models/sql/VideoMeetingSQL.d.ts +3 -0
- package/dist/types/models/types.d.ts +23 -0
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +147 -5
- package/dist/types/util/PasswordUtils.d.ts +17 -0
- 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
|
+
}
|
package/apps/meet/_icons.tsx
CHANGED
|
@@ -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
|
+
);
|
package/apps/meet/_meetApi.ts
CHANGED
|
@@ -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<
|
|
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
|
+
}
|