@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
|
@@ -34,22 +34,44 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
34
34
|
* auto-detected active speaker (silently ignored while presenting, so it never fights the presenter for the main
|
|
35
35
|
* slot); else, in focus mode with nobody yet speaking, the first other participant - so focus mode never shows an
|
|
36
36
|
* empty main slot once someone else has joined.
|
|
37
|
+
*
|
|
38
|
+
* ## Talking stick mode
|
|
39
|
+
*
|
|
40
|
+
* A host-only navbar toggle (`handleToggleTalkingStick()`) that, once on, lets at most one participant's microphone
|
|
41
|
+
* be unmuted at a time - the host assigns who from the participants drawer (`handleGiveTalkingStick()`), everyone
|
|
42
|
+
* else sees a header chip naming who currently holds it. Purely an in-call runtime state signaled peer-to-peer over
|
|
43
|
+
* the mesh (`MeshConnectionManager.setTalkingStick()`/`"talking-stick-changed"`), the same shape as `mute-request`/
|
|
44
|
+
* `kicked`: no `VideoMeeting` field, no backend route, and only as "enforced" as a cooperating client makes it (see
|
|
45
|
+
* `types.ts`'s `SignalMessage` doc comment on why the signaling layer has no host identity to check against). What
|
|
46
|
+
* *is* new here versus `mute-request`'s one-time nudge: becoming or ceasing to be the holder force-toggles this
|
|
47
|
+
* tab's own mic to match (`selfHasTalkingStick`'s effect, below), and the mic button itself is disabled
|
|
48
|
+
* (`micLocked`, passed to `CallControls`) the whole time a non-holder would otherwise be able to tap it straight
|
|
49
|
+
* back on - a real restriction for as long as this tab's own UI is the one in front of the participant, not just a
|
|
50
|
+
* suggestion. Turning the mode off touches nobody's mic state, matching every other host toggle's "never
|
|
51
|
+
* retroactive" posture. A holder who leaves the call is not specially reassigned - see
|
|
52
|
+
* `MeshConnectionManager`'s own doc comment on why that needs no extra code - the header chip then says nobody has
|
|
53
|
+
* the floor, until the host picks someone (or themselves) again.
|
|
37
54
|
*/
|
|
38
55
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
39
56
|
import { playRaisedHandChime } from "../shared/media/chime.js";
|
|
40
57
|
import { pickActiveSpeaker } from "../shared/media/activeSpeaker.js";
|
|
41
58
|
import { startLevelMeter } from "../shared/media/levelMeter.js";
|
|
42
59
|
import { requestDisplayMedia, stopStream } from "../shared/media/deviceMedia.js";
|
|
60
|
+
import { NO_SCREEN_TRANSFORM, ScreenTransformProcessor, rotateClockwise } from "../shared/media/filters/ScreenTransform.js";
|
|
43
61
|
import { createBrowserPeerConnection } from "../shared/webrtc/realPeerConnection.js";
|
|
44
62
|
import { MeshConnectionManager } from "../shared/webrtc/MeshConnectionManager.js";
|
|
45
63
|
import { createRelayTransport } from "../shared/relay/RelayTransport.js";
|
|
46
64
|
import { GuestSignalingClient } from "../shared/push/GuestSignalingClient.js";
|
|
47
65
|
import CallControls from "./_CallControls.js";
|
|
66
|
+
import { admitParticipant, denyParticipant, kickParticipant, listWaitingParticipants, setForceMuteOnJoin as apiSetForceMuteOnJoin, setMeetingPassword, setWaitingRoomEnabled as apiSetWaitingRoomEnabled, } from "./_meetApi.js";
|
|
48
67
|
import ParticipantTile from "./_ParticipantTile.js";
|
|
68
|
+
import ParticipantsDrawer from "./_ParticipantsDrawer.js";
|
|
49
69
|
/** How long a reaction floats on screen. */
|
|
50
70
|
const REACTION_MS = 4000;
|
|
51
71
|
/** The most reactions shown at once - a burst beyond this drops the oldest. */
|
|
52
72
|
const MAX_REACTIONS = 12;
|
|
73
|
+
/** How often the host's drawer refreshes its own waiting-room list while open. */
|
|
74
|
+
const DEFAULT_WAITING_POLL_MS = 3000;
|
|
53
75
|
/** Who is shown large - see this module's doc comment. `undefined` when nobody but the local participant has
|
|
54
76
|
* joined yet (the grid/focus toggle still works, there's just nobody else to focus on). */
|
|
55
77
|
export function computeMainUid(options) {
|
|
@@ -70,7 +92,16 @@ export function computeMainUid(options) {
|
|
|
70
92
|
export function newPeerId(selfUid) {
|
|
71
93
|
return `${selfUid}~${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
|
|
72
94
|
}
|
|
73
|
-
|
|
95
|
+
/** The real, server-enforced account uid behind a participant's tab-scoped `MeshParticipant.uid` (`newPeerId()`'s
|
|
96
|
+
* `<account uid>~<random>` shape) - what the kick route's ACL grant is actually keyed by, unlike the tab-scoped id
|
|
97
|
+
* this view otherwise addresses a specific tab with. Neither a real account uid nor a minted guest uid (see
|
|
98
|
+
* `GUEST_UID_PREFIX`/`mintGuestToken()` on the backend) can itself contain "~", so the last one found is always
|
|
99
|
+
* exactly this suffix's own separator. */
|
|
100
|
+
export function accountUidOf(peerUid) {
|
|
101
|
+
const i = peerUid.lastIndexOf("~");
|
|
102
|
+
return i === -1 ? peerUid : peerUid.slice(0, i);
|
|
103
|
+
}
|
|
104
|
+
export default function CallView({ channel, token, selfUid, selfName, meetingTitle, iceServers, relayEnabled, hostUid, initialForceMuteOnJoin, initialHasPassword, initialWaitingRoomEnabled, media, onLeave, }) {
|
|
74
105
|
const [peerId] = useState(() => newPeerId(selfUid));
|
|
75
106
|
const [connectError, setConnectError] = useState(null);
|
|
76
107
|
const [participants, setParticipants] = useState([]);
|
|
@@ -78,6 +109,7 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
78
109
|
const [levels, setLevels] = useState({});
|
|
79
110
|
const [presenterUid, setPresenterUid] = useState(undefined);
|
|
80
111
|
const [screenStream, setScreenStream] = useState(null);
|
|
112
|
+
const [screenTransform, setScreenTransform] = useState(NO_SCREEN_TRANSFORM);
|
|
81
113
|
const [handRaised, setHandRaised] = useState(false);
|
|
82
114
|
const [viewMode, setViewMode] = useState("grid");
|
|
83
115
|
const [pinnedUid, setPinnedUid] = useState(undefined);
|
|
@@ -87,11 +119,28 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
87
119
|
const [audioBlocked, setAudioBlocked] = useState(false);
|
|
88
120
|
const [audioNonce, setAudioNonce] = useState(0);
|
|
89
121
|
const [signalingReady, setSignalingReady] = useState(false);
|
|
122
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
123
|
+
const [forceMuteOnJoin, setForceMuteOnJoinState] = useState(!!initialForceMuteOnJoin);
|
|
124
|
+
const [hasPassword, setHasPassword] = useState(!!initialHasPassword);
|
|
125
|
+
const [waitingRoomEnabled, setWaitingRoomEnabledState] = useState(!!initialWaitingRoomEnabled);
|
|
126
|
+
const [waitingParticipants, setWaitingParticipants] = useState([]);
|
|
127
|
+
const [talkingStickActive, setTalkingStickActive] = useState(false);
|
|
128
|
+
const [talkingStickHolder, setTalkingStickHolder] = useState(undefined);
|
|
90
129
|
const managerRef = useRef(null);
|
|
130
|
+
/** The raw capture from `getDisplayMedia()` - only ever used to stop it (releasing the OS's own share
|
|
131
|
+
* indicator) and to notice the participant ending the share from the browser's own UI (`track.onended`). What's
|
|
132
|
+
* actually shown and sent is `screenTransformRef`'s output (`screenStream` state) - see `handleToggleShare()`. */
|
|
91
133
|
const screenStreamRef = useRef(null);
|
|
134
|
+
const screenTransformRef = useRef(null);
|
|
92
135
|
const levelMetersRef = useRef({});
|
|
93
136
|
const reactionSeqRef = useRef(0);
|
|
94
137
|
const reactionTimersRef = useRef(new Set());
|
|
138
|
+
/** The latest `media`, for the mesh-event handler below - that effect runs once at mount (see its own comment),
|
|
139
|
+
* so it would otherwise see only the mic state/toggle function from that first render. */
|
|
140
|
+
const mediaRef = useRef(media);
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
mediaRef.current = media;
|
|
143
|
+
}, [media]);
|
|
95
144
|
const isPresenting = !!screenStream;
|
|
96
145
|
const showReaction = useCallback((emoji, name) => {
|
|
97
146
|
const id = ++reactionSeqRef.current;
|
|
@@ -158,6 +207,18 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
158
207
|
stopLocalPresentation(false);
|
|
159
208
|
}
|
|
160
209
|
return;
|
|
210
|
+
case "mute-requested":
|
|
211
|
+
if (mediaRef.current.micOn) {
|
|
212
|
+
void mediaRef.current.toggleMic();
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
case "kicked":
|
|
216
|
+
onLeave("The host removed you from this call.");
|
|
217
|
+
return;
|
|
218
|
+
case "talking-stick-changed":
|
|
219
|
+
setTalkingStickActive(event.active);
|
|
220
|
+
setTalkingStickHolder(event.holder);
|
|
221
|
+
return;
|
|
161
222
|
}
|
|
162
223
|
});
|
|
163
224
|
client
|
|
@@ -191,6 +252,7 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
191
252
|
// Only the screen capture is this view's to stop - the camera and microphone belong to the page, which
|
|
192
253
|
// releases them when the participant leaves.
|
|
193
254
|
stopStream(screenStreamRef.current);
|
|
255
|
+
screenTransformRef.current?.stop();
|
|
194
256
|
for (const timer of timers) {
|
|
195
257
|
clearTimeout(timer);
|
|
196
258
|
}
|
|
@@ -209,6 +271,25 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
209
271
|
useEffect(() => {
|
|
210
272
|
managerRef.current?.setLocalState({ audioOn: media.micOn, videoOn: media.cameraOn || isPresenting, handRaised });
|
|
211
273
|
}, [media.micOn, media.cameraOn, isPresenting, handRaised]);
|
|
274
|
+
// Talking-stick mode forces exactly one microphone on at a time: whoever just became the holder (including the
|
|
275
|
+
// host, on activation) is unmuted; everyone else is muted. Unlike `mute-requested`'s one-time nudge, this is
|
|
276
|
+
// paired with disabling the mic button itself (`micLocked`, passed to `CallControls` below) while it applies,
|
|
277
|
+
// so it isn't merely a suggestion - though still cooperative at the signaling layer, like every other
|
|
278
|
+
// moderation feature here (see `MeshConnectionManager`'s doc comment). Turning the mode off touches nobody's
|
|
279
|
+
// mic - states are left exactly as they are, the same "never retroactive" posture as every other host toggle.
|
|
280
|
+
const selfHasTalkingStick = talkingStickActive && talkingStickHolder === peerId;
|
|
281
|
+
const micLocked = talkingStickActive && !selfHasTalkingStick;
|
|
282
|
+
useEffect(() => {
|
|
283
|
+
if (!talkingStickActive) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (selfHasTalkingStick && !mediaRef.current.micOn) {
|
|
287
|
+
void mediaRef.current.toggleMic();
|
|
288
|
+
}
|
|
289
|
+
else if (!selfHasTalkingStick && mediaRef.current.micOn) {
|
|
290
|
+
void mediaRef.current.toggleMic();
|
|
291
|
+
}
|
|
292
|
+
}, [talkingStickActive, selfHasTalkingStick]);
|
|
212
293
|
useEffect(() => {
|
|
213
294
|
if (!presenterUid) {
|
|
214
295
|
setActiveSpeakerUid((prev) => pickActiveSpeaker(levels, prev));
|
|
@@ -220,8 +301,12 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
220
301
|
}
|
|
221
302
|
stopStream(screenStreamRef.current);
|
|
222
303
|
screenStreamRef.current = null;
|
|
223
|
-
|
|
304
|
+
screenTransformRef.current?.stop();
|
|
305
|
+
screenTransformRef.current = null;
|
|
306
|
+
// The sync effect above swaps the camera back in as the outgoing video track. The rotation/flip is reset
|
|
307
|
+
// for the next share - each share starts from the capture the browser hands back, not the last one's fix.
|
|
224
308
|
setScreenStream(null);
|
|
309
|
+
setScreenTransform(NO_SCREEN_TRANSFORM);
|
|
225
310
|
}
|
|
226
311
|
async function handleToggleShare() {
|
|
227
312
|
if (screenStreamRef.current) {
|
|
@@ -239,8 +324,34 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
239
324
|
return;
|
|
240
325
|
}
|
|
241
326
|
screenStreamRef.current = result.value;
|
|
242
|
-
|
|
243
|
-
|
|
327
|
+
const rawTrack = result.value.getVideoTracks()[0];
|
|
328
|
+
rawTrack.onended = () => stopLocalPresentation(true);
|
|
329
|
+
try {
|
|
330
|
+
// Runs the capture through the rotate/flip processor before anyone (presenter included) sees it - see
|
|
331
|
+
// ScreenTransform.ts's doc comment on why window capture sometimes needs this correction.
|
|
332
|
+
const processor = new ScreenTransformProcessor({ source: rawTrack, state: screenTransform });
|
|
333
|
+
screenTransformRef.current = processor;
|
|
334
|
+
setScreenStream(new MediaStream([processor.track]));
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
// This browser can't draw the correction - share the raw capture rather than not sharing at all; the
|
|
338
|
+
// rotate/flip buttons simply won't do anything (screenTransformRef stays null).
|
|
339
|
+
setScreenStream(result.value);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function handleRotateScreen() {
|
|
343
|
+
setScreenTransform((prev) => {
|
|
344
|
+
const next = rotateClockwise(prev);
|
|
345
|
+
screenTransformRef.current?.setState(next);
|
|
346
|
+
return next;
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
function handleFlipScreen() {
|
|
350
|
+
setScreenTransform((prev) => {
|
|
351
|
+
const next = { ...prev, flipped: !prev.flipped };
|
|
352
|
+
screenTransformRef.current?.setState(next);
|
|
353
|
+
return next;
|
|
354
|
+
});
|
|
244
355
|
}
|
|
245
356
|
function handleToggleHand() {
|
|
246
357
|
setHandRaised((prev) => !prev);
|
|
@@ -253,6 +364,68 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
253
364
|
function togglePin(uid) {
|
|
254
365
|
setPinnedUid((prev) => (prev === uid ? undefined : uid));
|
|
255
366
|
}
|
|
367
|
+
/** Asks `peerUid`'s participant to mute - the host-only button in the participants drawer calls this
|
|
368
|
+
* directly, with no confirmation (unlike removing someone, muting is easily undone by the participant
|
|
369
|
+
* themselves). */
|
|
370
|
+
function handleMuteParticipant(peerUid) {
|
|
371
|
+
managerRef.current?.sendMuteRequest(peerUid);
|
|
372
|
+
}
|
|
373
|
+
/** Removes `peerUid`'s participant from the call: the cooperative signal (immediate, so a cooperating client
|
|
374
|
+
* leaves without waiting on the network round trip below) and the enforced server-side revoke together - see
|
|
375
|
+
* `kickParticipant()`'s own doc comment for why both matter. The REST call's failure is swallowed rather than
|
|
376
|
+
* surfaced: the host already asked them to leave, and there is no useful recovery action to offer from here
|
|
377
|
+
* for what is almost always a transient network issue. */
|
|
378
|
+
function handleKickParticipant(peerUid) {
|
|
379
|
+
managerRef.current?.sendKick(peerUid);
|
|
380
|
+
kickParticipant(channel, accountUidOf(peerUid)).catch(() => undefined);
|
|
381
|
+
}
|
|
382
|
+
/** Flips the host's "mute new participants on join" setting: applied optimistically (the toggle itself is the
|
|
383
|
+
* only feedback this small a control needs) and reverted if the save fails, so the drawer never keeps claiming
|
|
384
|
+
* a setting that isn't actually persisted. Takes effect for whoever joins next - never retroactive, see
|
|
385
|
+
* `setForceMuteOnJoin()`'s own doc comment. */
|
|
386
|
+
function handleToggleForceMuteOnJoin() {
|
|
387
|
+
const next = !forceMuteOnJoin;
|
|
388
|
+
setForceMuteOnJoinState(next);
|
|
389
|
+
apiSetForceMuteOnJoin(channel, next).catch(() => setForceMuteOnJoinState(!next));
|
|
390
|
+
}
|
|
391
|
+
/** Sets, changes, or removes (`password: null`) the host's join password - unlike the force-mute toggle, this
|
|
392
|
+
* is not applied optimistically: the drawer's own `PasswordSection` awaits the result itself (clearing its
|
|
393
|
+
* input on success, showing an inline error on failure), so there is nothing here to revert. `hasPassword`
|
|
394
|
+
* updates only once the save actually succeeds. */
|
|
395
|
+
async function handleSetPassword(password) {
|
|
396
|
+
await setMeetingPassword(channel, password);
|
|
397
|
+
setHasPassword(!!password);
|
|
398
|
+
}
|
|
399
|
+
/** Flips the host's waiting-room setting - same optimistic-and-revert shape as `handleToggleForceMuteOnJoin()`,
|
|
400
|
+
* for the same reason. Takes effect for whoever joins next; nobody already admitted is retroactively gated. */
|
|
401
|
+
function handleToggleWaitingRoomEnabled() {
|
|
402
|
+
const next = !waitingRoomEnabled;
|
|
403
|
+
setWaitingRoomEnabledState(next);
|
|
404
|
+
apiSetWaitingRoomEnabled(channel, next).catch(() => setWaitingRoomEnabledState(!next));
|
|
405
|
+
}
|
|
406
|
+
/** Starts or stops talking-stick mode - host-only, see this module's doc comment. Starting makes the host the
|
|
407
|
+
* initial holder; stopping clears the holder too, leaving mic states exactly as they are rather than
|
|
408
|
+
* retroactively unmuting anyone. */
|
|
409
|
+
function handleToggleTalkingStick() {
|
|
410
|
+
managerRef.current?.setTalkingStick(!talkingStickActive, talkingStickActive ? undefined : peerId);
|
|
411
|
+
}
|
|
412
|
+
/** Hands the talking stick to `uid` (a participant's `MeshParticipant.uid`, or this tab's own `peerId` for the
|
|
413
|
+
* host taking it back) - host-only, called from the participants drawer's per-row "Give stick" button. */
|
|
414
|
+
function handleGiveTalkingStick(uid) {
|
|
415
|
+
managerRef.current?.setTalkingStick(true, uid);
|
|
416
|
+
}
|
|
417
|
+
/** Admits one pending request - optimistically removed from the drawer's own list (the next poll would drop it
|
|
418
|
+
* anyway, once the requester's own next poll completes their join; removing it here just avoids the visible
|
|
419
|
+
* delay). Re-added if the save itself fails, so a failure doesn't silently lose the request from the list. */
|
|
420
|
+
function handleAdmitParticipant(uid) {
|
|
421
|
+
setWaitingParticipants((prev) => prev.filter((p) => p.uid !== uid));
|
|
422
|
+
admitParticipant(channel, uid).catch(() => void listWaitingParticipants(channel).then(setWaitingParticipants, () => undefined));
|
|
423
|
+
}
|
|
424
|
+
/** Denies one pending request - same optimistic-removal shape as `handleAdmitParticipant()`. */
|
|
425
|
+
function handleDenyParticipant(uid) {
|
|
426
|
+
setWaitingParticipants((prev) => prev.filter((p) => p.uid !== uid));
|
|
427
|
+
denyParticipant(channel, uid).catch(() => void listWaitingParticipants(channel).then(setWaitingParticipants, () => undefined));
|
|
428
|
+
}
|
|
256
429
|
function handleAudioBlocked() {
|
|
257
430
|
setAudioBlocked(true);
|
|
258
431
|
}
|
|
@@ -260,6 +433,35 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
260
433
|
setAudioBlocked(false);
|
|
261
434
|
setAudioNonce((prev) => prev + 1);
|
|
262
435
|
}
|
|
436
|
+
// A participant's `uid` is their tab-scoped peer id (`<account uid>~<random>` - see `newPeerId()`), while
|
|
437
|
+
// `hostUid` is the plain account uid `PublicVideoMeeting.hostUid` names - so telling the host's own tab(s) apart
|
|
438
|
+
// needs the prefix match, not equality. Purely a client-side check (see `hostUid`'s own doc comment on this
|
|
439
|
+
// prop): nobody's tab is prevented from claiming it, only from being shown host controls for it.
|
|
440
|
+
const isHost = !!hostUid && selfUid === hostUid;
|
|
441
|
+
const isParticipantHost = (uid) => !!hostUid && uid.startsWith(`${hostUid}~`);
|
|
442
|
+
// Refreshes the drawer's own waiting-room list while the host has the drawer open - there is no push signal
|
|
443
|
+
// for a newly filed admission request, so polling is the only way the list stays current without the host
|
|
444
|
+
// having to close and reopen the drawer. Stops (and the list is dropped) the moment either condition ends.
|
|
445
|
+
useEffect(() => {
|
|
446
|
+
if (!isHost || !drawerOpen) {
|
|
447
|
+
setWaitingParticipants([]);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
let cancelled = false;
|
|
451
|
+
const poll = () => {
|
|
452
|
+
listWaitingParticipants(channel).then((list) => {
|
|
453
|
+
if (!cancelled) {
|
|
454
|
+
setWaitingParticipants(list);
|
|
455
|
+
}
|
|
456
|
+
}, () => undefined);
|
|
457
|
+
};
|
|
458
|
+
poll();
|
|
459
|
+
const interval = setInterval(poll, DEFAULT_WAITING_POLL_MS);
|
|
460
|
+
return () => {
|
|
461
|
+
cancelled = true;
|
|
462
|
+
clearInterval(interval);
|
|
463
|
+
};
|
|
464
|
+
}, [isHost, drawerOpen, channel]);
|
|
263
465
|
const otherUids = participants.map((p) => p.uid);
|
|
264
466
|
// A pin or an active-speaker pick can name someone who has just left, until the state catches up.
|
|
265
467
|
const stillHere = (uid) => (uid && otherUids.includes(uid) ? uid : undefined);
|
|
@@ -269,6 +471,13 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
269
471
|
: undefined;
|
|
270
472
|
const presenterName = presenterUid ? (presenterUid === peerId ? selfName : (participants.find((p) => p.uid === presenterUid)?.name ?? "Someone")) : undefined;
|
|
271
473
|
const raisedNames = [...(handRaised ? ["You"] : []), ...participants.filter((p) => p.handRaised).map((p) => p.name)];
|
|
474
|
+
// Absent (not "Someone") once the holder has left the call - see `MeshConnectionManager`'s doc comment on why
|
|
475
|
+
// nothing reassigns it automatically; the header then says so rather than naming someone no longer here.
|
|
476
|
+
const talkingStickHolderName = !talkingStickActive
|
|
477
|
+
? undefined
|
|
478
|
+
: selfHasTalkingStick
|
|
479
|
+
? "You"
|
|
480
|
+
: participants.find((p) => p.uid === talkingStickHolder)?.name;
|
|
272
481
|
const alone = participants.length === 0;
|
|
273
482
|
// This participant has no working link yet: the signaling channel is still opening, or there is someone in the call
|
|
274
483
|
// and every connection to them is still being made. Once any one is up (or nobody else is here) it is not shown,
|
|
@@ -292,10 +501,10 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
|
|
|
292
501
|
else {
|
|
293
502
|
stage = (_jsx("div", { className: "h-full grid gap-2 auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr))]", children: participants.map((p) => remoteTile(p, "h-full")) }));
|
|
294
503
|
}
|
|
295
|
-
return (_jsxs("div", { className: "fixed inset-0 z-50 flex flex-col bg-[#202124] text-white overflow-hidden", children: [_jsx("style", { children: `@keyframes meet-float { 0% { transform: translateY(0) scale(.6); opacity: 0; } 12% { opacity: 1; transform: translateY(-4vh) scale(1); } 100% { transform: translateY(-45vh) scale(1); opacity: 0; } }` }), _jsxs("header", { className: "shrink-0 flex items-center justify-between gap-3 px-4 py-3", children: [_jsx("h1", { className: "min-w-0 truncate text-base font-medium", children: meetingTitle }), _jsxs("div", { className: "flex items-center gap-2 shrink-0", children: [raisedNames.length > 0 && (_jsxs("span", { className: "max-w-[45vw] truncate px-3 py-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-sm font-medium", "data-testid": "raised-hands", children: ["\u270B ", raisedNames.join(", ")] })),
|
|
504
|
+
return (_jsxs("div", { className: "fixed inset-0 z-50 flex flex-col bg-[#202124] text-white overflow-hidden", children: [_jsx("style", { children: `@keyframes meet-float { 0% { transform: translateY(0) scale(.6); opacity: 0; } 12% { opacity: 1; transform: translateY(-4vh) scale(1); } 100% { transform: translateY(-45vh) scale(1); opacity: 0; } }` }), _jsxs("header", { className: "shrink-0 flex items-center justify-between gap-3 px-4 py-3", children: [_jsx("h1", { className: "min-w-0 truncate text-base font-medium", children: meetingTitle }), _jsxs("div", { className: "flex items-center gap-2 shrink-0", children: [raisedNames.length > 0 && (_jsxs("span", { className: "max-w-[45vw] truncate px-3 py-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-sm font-medium", "data-testid": "raised-hands", children: ["\u270B ", raisedNames.join(", ")] })), talkingStickActive && (_jsxs("span", { className: "max-w-[45vw] truncate px-3 py-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-sm font-medium", "data-testid": "talking-stick-status", children: ["\uD83C\uDF99\uFE0F ", talkingStickHolderName ? `${talkingStickHolderName} ${talkingStickHolderName === "You" ? "have" : "has"} the floor` : "Waiting for the host to choose a speaker"] })), isHost && (_jsx("button", { type: "button", className: `px-3 py-1.5 rounded-full text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 ${talkingStickActive ? "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]" : "bg-[#3c4043] hover:bg-[#4b4f53]"}`, "aria-pressed": talkingStickActive, onClick: handleToggleTalkingStick, children: talkingStickActive ? "End talking stick" : "Talking stick" })), _jsx("button", { type: "button", className: "px-3 py-1.5 rounded-full bg-[#3c4043] hover:bg-[#4b4f53] text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `${participants.length + 1} participants`, "aria-haspopup": "dialog", "aria-expanded": drawerOpen, onClick: () => setDrawerOpen((prev) => !prev), children: participants.length + 1 })] })] }), connectError && (_jsx("div", { role: "alert", className: "shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#601410] text-[#f9dedc] text-sm", children: connectError })), audioBlocked && (_jsx("button", { type: "button", className: "shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#a8c7fa] text-[#062e6f] text-sm font-medium", onClick: handleResumeAudio, children: "Click here to turn on sound" })), _jsxs("main", { className: "relative flex-1 min-h-0 px-3 pb-3", children: [stage, presenterName && presenterUid !== peerId && (_jsxs("p", { className: "absolute top-2 left-5 px-2 py-0.5 rounded bg-black/60 text-sm", children: [presenterName, " is presenting"] }))] }), _jsx("footer", { className: "shrink-0 pt-1 pb-[max(0.75rem,env(safe-area-inset-bottom))]", children: _jsx(CallControls, { media: media, selfName: selfName, participants: participants, isPresenting: isPresenting, presentingElsewhereName: presenterUid && presenterUid !== peerId ? presenterName : undefined, onToggleShare: () => void handleToggleShare(), screenTransform: screenTransform, onRotateScreen: handleRotateScreen, onFlipScreen: handleFlipScreen, handRaised: handRaised, onToggleHand: handleToggleHand, onReaction: handleReaction, viewMode: viewMode, onToggleViewMode: () => setViewMode((prev) => (prev === "grid" ? "focus" : "grid")), micLocked: micLocked, onLeave: onLeave }) }), !alone && (_jsx("div", { className: "absolute z-10 right-3 top-14 w-28 sm:top-auto sm:bottom-24 sm:w-52 aspect-video shadow-xl xl:right-4 xl:bottom-4", "data-testid": "self-view", children: selfTile("h-full") })), _jsx("div", { className: "pointer-events-none absolute left-4 bottom-28 w-40 h-[45vh]", "aria-hidden": "true", children: reactions.map((reaction) => (_jsxs("div", { className: "absolute bottom-0 flex flex-col items-center", style: { left: `${(reaction.id % 4) * 36}px`, animation: `meet-float ${REACTION_MS}ms ease-out forwards` }, "data-testid": "reaction", children: [_jsx("span", { className: "text-4xl leading-none", children: reaction.emoji }), _jsx("span", { className: "mt-1 px-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-xs", children: reaction.name })] }, reaction.id))) }), participants.map((p) => {
|
|
296
505
|
const stream = remoteStreams[p.uid];
|
|
297
506
|
return stream ? _jsx(RemoteAudio, { stream: stream, onBlocked: handleAudioBlocked }, `${p.uid}:${audioNonce}`) : null;
|
|
298
|
-
}), _jsx("div", { role: "status", "aria-live": "polite", className: "sr-only", children: announcement })] }));
|
|
507
|
+
}), _jsx("div", { role: "status", "aria-live": "polite", className: "sr-only", children: announcement }), drawerOpen && (_jsx(ParticipantsDrawer, { selfName: selfName, micOn: media.micOn, handRaised: handRaised, participants: participants, isSelfHost: isHost, isParticipantHost: isParticipantHost, onMute: handleMuteParticipant, onKick: handleKickParticipant, forceMuteOnJoin: forceMuteOnJoin, onToggleForceMuteOnJoin: handleToggleForceMuteOnJoin, hasPassword: hasPassword, onSetPassword: handleSetPassword, waitingRoomEnabled: waitingRoomEnabled, onToggleWaitingRoomEnabled: handleToggleWaitingRoomEnabled, waitingParticipants: waitingParticipants, onAdmit: handleAdmitParticipant, onDeny: handleDenyParticipant, talkingStickActive: talkingStickActive, selfPeerId: peerId, talkingStickHolder: talkingStickHolder, onGiveTalkingStick: handleGiveTalkingStick, onClose: () => setDrawerOpen(false) }))] }));
|
|
299
508
|
}
|
|
300
509
|
/** Plays one remote participant's stream through a hidden `<audio>` element. `onBlocked` fires if the browser
|
|
301
510
|
* refuses to start it (an autoplay policy) - the view then asks for a click. */
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The diagnostics panel, opened from the navbar for troubleshooting a call in progress - this browser's own
|
|
3
|
+
* capabilities, then each participant's connection: how their media reaches this one (the same badge
|
|
4
|
+
* `_ParticipantTile.tsx` shows, reused via `TRANSPORT_BADGES` rather than described in different words), and,
|
|
5
|
+
* once available, round-trip time and each kind's packet loss/jitter/bytes transferred
|
|
6
|
+
* (`MeshParticipant.diagnostics`, refreshed every few seconds by `MeshConnectionManager`'s own poller - see its doc
|
|
7
|
+
* comment). Purely informational and entirely local: nothing here is sent anywhere.
|
|
8
|
+
*
|
|
9
|
+
* `diagnostics` is `undefined` until the first poll completes (shortly after a connection comes up) and always
|
|
10
|
+
* absent for a `"websocket"`-relayed participant (there is no `RTCPeerConnection` to poll there) - both cases show
|
|
11
|
+
* "Not available" rather than a blank space, so it never looks like the panel forgot to load.
|
|
12
|
+
*/
|
|
13
|
+
import React from "react";
|
|
14
|
+
import type { MeshParticipant } from "../shared/webrtc/types.js";
|
|
15
|
+
export interface DiagnosticsPanelProps {
|
|
16
|
+
selfName: string;
|
|
17
|
+
/** Whether the local microphone/camera are actually sending - `LocalMedia.micOn`/`cameraOn`. */
|
|
18
|
+
micOn: boolean;
|
|
19
|
+
cameraOn: boolean;
|
|
20
|
+
participants: MeshParticipant[];
|
|
21
|
+
}
|
|
22
|
+
export default function DiagnosticsPanel({ selfName, micOn, cameraOn, participants }: DiagnosticsPanelProps): React.JSX.Element;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { TRANSPORT_BADGES } from "./_ParticipantTile.js";
|
|
3
|
+
/** This browser's support for what a call needs - checked once, since it cannot change mid-session. */
|
|
4
|
+
function browserCapabilities() {
|
|
5
|
+
const has = (name) => typeof window !== "undefined" && name in window;
|
|
6
|
+
return [
|
|
7
|
+
{ label: "WebRTC", supported: has("RTCPeerConnection") },
|
|
8
|
+
{ label: "Screen sharing", supported: typeof navigator !== "undefined" && !!navigator.mediaDevices?.getDisplayMedia },
|
|
9
|
+
{ label: "WebCodecs (server relay fallback)", supported: has("VideoEncoder") && has("AudioEncoder") },
|
|
10
|
+
];
|
|
11
|
+
}
|
|
12
|
+
function formatMs(seconds) {
|
|
13
|
+
return seconds === undefined ? "—" : `${Math.round(seconds * 1000)} ms`;
|
|
14
|
+
}
|
|
15
|
+
function formatBytes(bytes) {
|
|
16
|
+
return bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
17
|
+
}
|
|
18
|
+
const ROW = "flex justify-between gap-3 py-0.5";
|
|
19
|
+
const LABEL = "text-white/60";
|
|
20
|
+
function StreamRow({ kind, packetsLost, jitter, bytesSent, bytesReceived }) {
|
|
21
|
+
if (packetsLost === undefined && jitter === undefined && bytesSent === undefined && bytesReceived === undefined) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return (_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: kind }), _jsxs("span", { children: [packetsLost !== undefined && `${packetsLost} lost`, jitter !== undefined && ` · ${formatMs(jitter)} jitter`, (bytesSent !== undefined || bytesReceived !== undefined) &&
|
|
25
|
+
` · ${[bytesSent !== undefined && `↑${formatBytes(bytesSent)}`, bytesReceived !== undefined && `↓${formatBytes(bytesReceived)}`].filter(Boolean).join(" ")}`] })] }));
|
|
26
|
+
}
|
|
27
|
+
export default function DiagnosticsPanel({ selfName, micOn, cameraOn, participants }) {
|
|
28
|
+
return (_jsxs("div", { className: "text-sm", children: [_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-white/60 mb-1.5", children: "This browser" }), browserCapabilities().map(({ label, supported }) => (_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: label }), _jsx("span", { className: supported ? "text-[#8ab4f8]" : "text-[#f2b8b5]", children: supported ? "Supported" : "Not supported" })] }, label))), _jsxs("p", { className: "text-xs font-semibold uppercase tracking-wide text-white/60 mt-3 mb-1.5", children: [selfName, " ", _jsx("span", { className: "text-white/60", children: "(you)" })] }), _jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Sending" }), _jsx("span", { children: [micOn && "audio", cameraOn && "video"].filter(Boolean).join(" + ") || "Nothing" })] }), participants.length === 0 && _jsx("p", { className: "text-white/60 mt-3", children: "Nobody else is in the call yet." }), participants.map((p) => {
|
|
29
|
+
const badge = p.transport !== "connecting" && p.transport !== "p2p" ? TRANSPORT_BADGES[p.transport] : undefined;
|
|
30
|
+
return (_jsxs("div", { children: [_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-white/60 mt-3 mb-1.5", children: p.name }), _jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Connection" }), _jsx("span", { title: badge?.title, children: badge?.label ?? (p.transport === "connecting" ? "Connecting…" : "Direct") })] }), p.transport === "websocket" || !p.diagnostics ? (_jsxs("p", { className: `${LABEL} py-0.5`, children: ["Not available", p.transport !== "websocket" && " yet", "."] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: ROW, children: [_jsx("span", { className: LABEL, children: "Round-trip time" }), _jsx("span", { children: formatMs(p.diagnostics.roundTripTimeSeconds) })] }), _jsx(StreamRow, { kind: "Audio", ...p.diagnostics.audio }), _jsx(StreamRow, { kind: "Video", ...p.diagnostics.video })] }))] }, p.uid));
|
|
31
|
+
})] }));
|
|
32
|
+
}
|
|
@@ -13,6 +13,14 @@
|
|
|
13
13
|
* reflection, and mirroring it would show it backwards to no one but themselves. */
|
|
14
14
|
import React from "react";
|
|
15
15
|
import type { MediaTransport } from "../shared/webrtc/types.js";
|
|
16
|
+
/** What a tile says about how its participant's media is arriving, for the paths that are not the ordinary direct one
|
|
17
|
+
* (which needs no comment). Text rather than only an icon, so the reason a picture is degraded is never a guess.
|
|
18
|
+
* Exported so `_DiagnosticsPanel.tsx` shows the identical label/title rather than describing the same paths in
|
|
19
|
+
* slightly different words. */
|
|
20
|
+
export declare const TRANSPORT_BADGES: Partial<Record<MediaTransport, {
|
|
21
|
+
label: string;
|
|
22
|
+
title: string;
|
|
23
|
+
}>>;
|
|
16
24
|
export interface ParticipantTileProps {
|
|
17
25
|
name: string;
|
|
18
26
|
stream?: MediaStream | null;
|
|
@@ -19,9 +19,16 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
19
19
|
import { useEffect, useRef } from "react";
|
|
20
20
|
import { MicOffIcon } from "./_icons.js";
|
|
21
21
|
/** What a tile says about how its participant's media is arriving, for the paths that are not the ordinary direct one
|
|
22
|
-
* (which needs no comment). Text rather than only an icon, so the reason a picture is degraded is never a guess.
|
|
23
|
-
|
|
22
|
+
* (which needs no comment). Text rather than only an icon, so the reason a picture is degraded is never a guess.
|
|
23
|
+
* Exported so `_DiagnosticsPanel.tsx` shows the identical label/title rather than describing the same paths in
|
|
24
|
+
* slightly different words. */
|
|
25
|
+
export const TRANSPORT_BADGES = {
|
|
24
26
|
turn: { label: "Relayed", title: "Connected through the relay server, because a direct connection was not possible." },
|
|
27
|
+
"turn-tcp": {
|
|
28
|
+
label: "Relayed (TCP)",
|
|
29
|
+
title: "Connected through the relay server over TCP, because this network blocks the relay's usual UDP connection too. " +
|
|
30
|
+
"Audio or video may pause briefly under network strain rather than just glitching - a property of TCP, not a dropped connection.",
|
|
31
|
+
},
|
|
25
32
|
websocket: {
|
|
26
33
|
label: "Server relay",
|
|
27
34
|
title: "Sent through the server as a last resort, because no other connection was possible. Video is lower quality and sound may lag.",
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The participants drawer, opened from the header's participant-count chip: a right-side panel listing everyone in
|
|
3
|
+
* the call, "you" first. The same mic-muted/hand-raised/transport-badge information `_ParticipantTile.tsx` already
|
|
4
|
+
* shows on a tile, as plain rows instead (a list reads better than tiles once there's a reason to scan for one
|
|
5
|
+
* name).
|
|
6
|
+
*
|
|
7
|
+
* Closes on Escape, on a click on the backdrop behind it, or its own close button - never on a click inside the
|
|
8
|
+
* drawer itself, matching `_CallControls.tsx`'s menus.
|
|
9
|
+
*
|
|
10
|
+
* A "Host" tag marks the host's own row (`isSelfHost`/`isParticipantHost` - see `_CallView.tsx`'s doc comment on
|
|
11
|
+
* `hostUid` for what that identity is and isn't). Only while `isSelfHost` does every *other* row also get a "Mute"
|
|
12
|
+
* button (hidden once that participant is already muted - there is no useful "unmute someone else" action to
|
|
13
|
+
* offer) and a "Remove" button (a native `confirm()` first, since removal can't be undone by the participant the
|
|
14
|
+
* way a mute request can) - never on the local participant's own row, and never shown to anyone but the host.
|
|
15
|
+
*
|
|
16
|
+
* The header also gets a host-only "Mute on join" checkbox (`forceMuteOnJoin`/`onToggleForceMuteOnJoin`) - a
|
|
17
|
+
* setting for whoever joins *next*, not an action on anyone already here (muting someone already in the call is
|
|
18
|
+
* what each row's own "Mute" button is for) - a host-only password section (`hasPassword`/`onSetPassword`), the
|
|
19
|
+
* same "next joiner" scope: setting or changing it never affects anyone already in the call - and a host-only
|
|
20
|
+
* waiting-room toggle plus list (`waitingRoomEnabled`/`onToggleWaitingRoomEnabled`,
|
|
21
|
+
* `waitingParticipants`/`onAdmit`/`onDeny`): `_CallView.tsx` polls the list while this drawer is open and the
|
|
22
|
+
* caller is the host (there is no push signal for a newly filed admission request), so it stays current without
|
|
23
|
+
* the host needing to close and reopen the drawer.
|
|
24
|
+
*
|
|
25
|
+
* While talking-stick mode is on (`talkingStickActive`), every row - including the host's own - shows a "Give
|
|
26
|
+
* stick" button, host-only, hidden on whichever row currently holds it (`talkingStickHolder`); everyone (not just
|
|
27
|
+
* the host) sees a badge on that row instead, same visibility as the "Host" tag. See `_CallView.tsx`'s doc comment
|
|
28
|
+
* on talking-stick mode for what granting it actually does to the recipient's microphone.
|
|
29
|
+
*/
|
|
30
|
+
import React from "react";
|
|
31
|
+
import type { MeshParticipant } from "../shared/webrtc/types.js";
|
|
32
|
+
import type { WaitingParticipant } from "./_meetApi.js";
|
|
33
|
+
export interface ParticipantsDrawerProps {
|
|
34
|
+
selfName: string;
|
|
35
|
+
micOn: boolean;
|
|
36
|
+
handRaised: boolean;
|
|
37
|
+
participants: MeshParticipant[];
|
|
38
|
+
isSelfHost: boolean;
|
|
39
|
+
isParticipantHost: (uid: string) => boolean;
|
|
40
|
+
/** Sends a mute request to the given participant's `uid` - a no-op from the recipient's end if they're already
|
|
41
|
+
* muted (see `Row`'s own "already muted" guard, which never even shows the button then). */
|
|
42
|
+
onMute: (uid: string) => void;
|
|
43
|
+
/** Removes the given participant's `uid` - see `_CallView.tsx`'s `handleKickParticipant()` for what this
|
|
44
|
+
* actually does (the cooperative signal plus the enforced server-side revoke). */
|
|
45
|
+
onKick: (uid: string) => void;
|
|
46
|
+
/** Whether a newly joining participant currently starts muted - the header checkbox's own state, host-only. */
|
|
47
|
+
forceMuteOnJoin: boolean;
|
|
48
|
+
onToggleForceMuteOnJoin: () => void;
|
|
49
|
+
/** Whether this meeting currently requires a password - never the password itself, which the server never
|
|
50
|
+
* sends back (see `PublicVideoMeeting.hasPassword`'s own doc comment). */
|
|
51
|
+
hasPassword: boolean;
|
|
52
|
+
/** Sets (a non-empty string), replaces, or removes (`null`) the join password. Rejecting lets
|
|
53
|
+
* `PasswordSection` show its own inline error without this drawer needing to know anything about it. */
|
|
54
|
+
onSetPassword: (password: string | null) => Promise<void>;
|
|
55
|
+
/** Whether the meeting currently requires the host to admit each participant - the header checkbox's own
|
|
56
|
+
* state, host-only. */
|
|
57
|
+
waitingRoomEnabled: boolean;
|
|
58
|
+
onToggleWaitingRoomEnabled: () => void;
|
|
59
|
+
/** Everyone currently waiting to be admitted - `_CallView.tsx`'s own polled, always-current list; empty
|
|
60
|
+
* whenever this drawer isn't open and host, by construction. */
|
|
61
|
+
waitingParticipants: WaitingParticipant[];
|
|
62
|
+
onAdmit: (uid: string) => void;
|
|
63
|
+
onDeny: (uid: string) => void;
|
|
64
|
+
/** Whether talking-stick mode is currently on - the host-only navbar toggle's own state (see `_CallView.tsx`'s
|
|
65
|
+
* doc comment on talking-stick mode). */
|
|
66
|
+
talkingStickActive: boolean;
|
|
67
|
+
/** This tab's own peer id - needed only so the self row's "Give stick" button can name itself when the host
|
|
68
|
+
* wants to take the stick back. */
|
|
69
|
+
selfPeerId: string;
|
|
70
|
+
/** Whichever peer id (`selfPeerId`, or a participant's `MeshParticipant.uid`) currently holds the stick;
|
|
71
|
+
* undefined while `talkingStickActive` is `false`, or (transiently) once its holder has left the call. */
|
|
72
|
+
talkingStickHolder?: string;
|
|
73
|
+
/** Hands the stick to `uid` - host-only, see `_CallView.tsx`'s `handleGiveTalkingStick()`. */
|
|
74
|
+
onGiveTalkingStick: (uid: string) => void;
|
|
75
|
+
onClose: () => void;
|
|
76
|
+
}
|
|
77
|
+
export default function ParticipantsDrawer({ selfName, micOn, handRaised, participants, isSelfHost, isParticipantHost, onMute, onKick, forceMuteOnJoin, onToggleForceMuteOnJoin, hasPassword, onSetPassword, waitingRoomEnabled, onToggleWaitingRoomEnabled, waitingParticipants, onAdmit, onDeny, talkingStickActive, selfPeerId, talkingStickHolder, onGiveTalkingStick, onClose, }: ParticipantsDrawerProps): React.JSX.Element;
|