@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.
- package/README.md +55 -55
- package/apps/meet/[token].tsx +341 -341
- package/apps/meet/_CallControls.tsx +463 -394
- package/apps/meet/_CallView.tsx +891 -848
- package/apps/meet/_DiagnosticsWindow.tsx +80 -0
- package/apps/meet/_MeetLobby.tsx +224 -224
- package/apps/meet/_ParticipantTile.tsx +169 -169
- package/apps/meet/_ParticipantsDrawer.tsx +10 -104
- package/apps/meet/_SettingsModal.tsx +152 -0
- package/apps/meet/_icons.tsx +80 -82
- package/apps/meet/_meetApi.ts +212 -212
- package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
- package/apps/shared/media/filters/mlModels.ts +174 -174
- package/apps/shared/push/GuestSignalingClient.ts +359 -359
- package/apps/shared/relay/RelayClient.ts +7 -2
- package/apps/shared/webrtc/MeshConnectionManager.ts +949 -795
- package/apps/shared/webrtc/types.ts +247 -222
- package/dist/apps/meet/_CallControls.d.ts +19 -9
- package/dist/apps/meet/_CallControls.js +28 -10
- package/dist/apps/meet/_CallView.d.ts +14 -2
- package/dist/apps/meet/_CallView.js +27 -2
- package/dist/apps/meet/_DiagnosticsWindow.d.ts +25 -0
- package/dist/apps/meet/_DiagnosticsWindow.js +45 -0
- package/dist/apps/meet/_ParticipantsDrawer.d.ts +10 -21
- package/dist/apps/meet/_ParticipantsDrawer.js +9 -39
- package/dist/apps/meet/_SettingsModal.d.ts +28 -0
- package/dist/apps/meet/_SettingsModal.js +59 -0
- package/dist/apps/meet/_icons.d.ts +1 -1
- package/dist/apps/meet/_icons.js +1 -1
- package/dist/apps/shared/relay/RelayClient.d.ts +6 -1
- package/dist/apps/shared/relay/RelayClient.js +7 -2
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +55 -2
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +153 -9
- package/dist/apps/shared/webrtc/types.d.ts +25 -1
- package/dist/lib/util/RedisRelayBus.js +61 -5
- package/dist/lib/util/RedisRelayBus.js.map +1 -1
- package/dist/lib/util/RelayHub.js +10 -3
- package/dist/lib/util/RelayHub.js.map +1 -1
- package/dist/types/util/RedisRelayBus.d.ts +45 -6
- package/dist/types/util/RelayHub.d.ts +9 -2
- package/package.json +208 -208
|
@@ -1,169 +1,169 @@
|
|
|
1
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
-
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
-
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
/** One participant's video tile - used by the grid, the focused/thumbnail-strip layouts and the local participant's
|
|
6
|
-
* corner tile in `_CallView.tsx`. Renders a placeholder (the participant's initial) instead of a `<video>` element
|
|
7
|
-
* whenever there's no stream or their camera is off, rather than showing a black/frozen frame.
|
|
8
|
-
*
|
|
9
|
-
* A tile never plays sound: its `<video>` is always muted, and what a participant says is played by the call view's
|
|
10
|
-
* own `<audio>` elements (`_CallView.tsx`'s `RemoteAudio`), so a tile that isn't showing video - or isn't on screen
|
|
11
|
-
* in the current layout - can never silence anyone.
|
|
12
|
-
*
|
|
13
|
-
* The local tile is shown mirrored (`scaleX(-1)`), like a real mirror, so a participant's own movements feel
|
|
14
|
-
* natural - this is a local display transform only, never applied to the video actually sent to anyone, so
|
|
15
|
-
* everyone else always sees the participant the right way round. `mirrored={false}` (`_CallView.tsx`, when a custom
|
|
16
|
-
* background image is on) turns that off: the picture the participant chose is a fixed reference, not a live
|
|
17
|
-
* reflection, and mirroring it would show it backwards to no one but themselves. */
|
|
18
|
-
import React, { useEffect, useRef } from "react";
|
|
19
|
-
import type { MediaTransport } from "../shared/webrtc/types.js";
|
|
20
|
-
import { MicOffIcon } from "./_icons.js";
|
|
21
|
-
|
|
22
|
-
/** What a tile says about how its participant's media is arriving, for the paths that are not the ordinary direct one
|
|
23
|
-
* (which needs no comment). Text rather than only an icon, so the reason a picture is degraded is never a guess.
|
|
24
|
-
* Exported so `_DiagnosticsPanel.tsx` shows the identical label/title rather than describing the same paths in
|
|
25
|
-
* slightly different words. */
|
|
26
|
-
export const TRANSPORT_BADGES: Partial<Record<MediaTransport, { label: string; title: string }>> = {
|
|
27
|
-
turn: { label: "Relayed", title: "Connected through the relay server, because a direct connection was not possible." },
|
|
28
|
-
"turn-tcp": {
|
|
29
|
-
label: "Relayed (TCP)",
|
|
30
|
-
title:
|
|
31
|
-
"Connected through the relay server over TCP, because this network blocks the relay's usual UDP connection too. " +
|
|
32
|
-
"Audio or video may pause briefly under network strain rather than just glitching - a property of TCP, not a dropped connection.",
|
|
33
|
-
},
|
|
34
|
-
websocket: {
|
|
35
|
-
label: "Server relay",
|
|
36
|
-
title: "Sent through the server as a last resort, because no other connection was possible. Video is lower quality and sound may lag.",
|
|
37
|
-
},
|
|
38
|
-
failed: { label: "Can't connect", title: "This participant could not be reached from your network." },
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
/** A short line saying the connection is still being made, with a spinner - a polite live region (not `role="status"`,
|
|
42
|
-
* which the call view's own announcement region already is), so a participant who cannot see the tile still learns
|
|
43
|
-
* why nothing is happening yet. */
|
|
44
|
-
function StatusPill({ status, className }: { status: string; className?: string }) {
|
|
45
|
-
return (
|
|
46
|
-
<span
|
|
47
|
-
aria-live="polite"
|
|
48
|
-
data-testid="tile-status"
|
|
49
|
-
className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-black/60 text-white text-xs ${className ?? ""}`}
|
|
50
|
-
>
|
|
51
|
-
<span className="inline-block w-3 h-3 rounded-full border-2 border-white/30 border-t-white animate-spin" aria-hidden="true" />
|
|
52
|
-
{status}
|
|
53
|
-
</span>
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export interface ParticipantTileProps {
|
|
58
|
-
name: string;
|
|
59
|
-
stream?: MediaStream | null;
|
|
60
|
-
isLocal?: boolean;
|
|
61
|
-
/** Shows the initial instead of the video. */
|
|
62
|
-
cameraOff?: boolean;
|
|
63
|
-
micMuted?: boolean;
|
|
64
|
-
handRaised?: boolean;
|
|
65
|
-
/** How this participant's media reaches you - a badge is shown for everything but a direct or still-connecting one. */
|
|
66
|
-
transport?: MediaTransport;
|
|
67
|
-
/** Says the connection is still being made, e.g. "Connecting..." on the local tile and "Awaiting connection..." on a
|
|
68
|
-
* participant's - shown with a spinner until the caller clears it. */
|
|
69
|
-
status?: string;
|
|
70
|
-
/** Highlights this tile as the current presenter/focus. */
|
|
71
|
-
isFocused?: boolean;
|
|
72
|
-
/** Fits the whole picture inside the tile instead of filling it - a shared screen must not be cropped. */
|
|
73
|
-
contain?: boolean;
|
|
74
|
-
/** Whether the local tile is shown mirrored, like a real mirror - ignored for a remote tile, which is never
|
|
75
|
-
* mirrored. Default `true`; see this module's doc comment. */
|
|
76
|
-
mirrored?: boolean;
|
|
77
|
-
onClick?: () => void;
|
|
78
|
-
className?: string;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function initialOf(name: string): string {
|
|
82
|
-
return (Array.from(name.trim())[0] ?? "?").toUpperCase();
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export default function ParticipantTile({
|
|
86
|
-
name,
|
|
87
|
-
stream,
|
|
88
|
-
isLocal,
|
|
89
|
-
cameraOff,
|
|
90
|
-
micMuted,
|
|
91
|
-
handRaised,
|
|
92
|
-
transport,
|
|
93
|
-
status,
|
|
94
|
-
isFocused,
|
|
95
|
-
contain,
|
|
96
|
-
mirrored = true,
|
|
97
|
-
onClick,
|
|
98
|
-
className,
|
|
99
|
-
}: ParticipantTileProps) {
|
|
100
|
-
const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
|
|
101
|
-
const videoRef = useRef<HTMLVideoElement>(null);
|
|
102
|
-
const showVideo = !!stream && !cameraOff;
|
|
103
|
-
|
|
104
|
-
// The `<video>` element only exists while `showVideo`, so its stream is bound whenever it (re)appears too.
|
|
105
|
-
useEffect(() => {
|
|
106
|
-
if (videoRef.current) {
|
|
107
|
-
videoRef.current.srcObject = stream ?? null;
|
|
108
|
-
}
|
|
109
|
-
}, [stream, showVideo]);
|
|
110
|
-
|
|
111
|
-
return (
|
|
112
|
-
<div
|
|
113
|
-
className={`relative overflow-hidden rounded-xl bg-[#3c4043] flex items-center justify-center min-h-[90px] ${
|
|
114
|
-
isFocused ? "ring-2 ring-[#8ab4f8]" : ""
|
|
115
|
-
} ${className ?? ""}`}
|
|
116
|
-
onClick={onClick}
|
|
117
|
-
role={onClick ? "button" : undefined}
|
|
118
|
-
tabIndex={onClick ? 0 : undefined}
|
|
119
|
-
aria-label={onClick ? `${name}${isLocal ? " (you)" : ""}` : undefined}
|
|
120
|
-
>
|
|
121
|
-
{showVideo ? (
|
|
122
|
-
<>
|
|
123
|
-
<video
|
|
124
|
-
ref={videoRef}
|
|
125
|
-
autoPlay
|
|
126
|
-
playsInline
|
|
127
|
-
muted
|
|
128
|
-
className={`w-full h-full ${contain ? "object-contain bg-black" : "object-cover"} ${isLocal && !contain && mirrored ? "[transform:scaleX(-1)]" : ""}`}
|
|
129
|
-
/>
|
|
130
|
-
{status && <StatusPill status={status} className="absolute top-2 left-1/2 -translate-x-1/2 whitespace-nowrap" />}
|
|
131
|
-
</>
|
|
132
|
-
) : (
|
|
133
|
-
<div className="flex flex-col items-center gap-2 max-w-full px-1">
|
|
134
|
-
<div className="w-16 h-16 rounded-full bg-primary text-white flex items-center justify-center text-2xl font-bold" aria-hidden="true">
|
|
135
|
-
{initialOf(name)}
|
|
136
|
-
</div>
|
|
137
|
-
{status && <StatusPill status={status} />}
|
|
138
|
-
</div>
|
|
139
|
-
)}
|
|
140
|
-
{handRaised && (
|
|
141
|
-
<span className="absolute top-2 left-2 flex items-center justify-center w-8 h-8 rounded-full bg-[#a8c7fa] text-base" role="img" aria-label="Hand raised">
|
|
142
|
-
✋
|
|
143
|
-
</span>
|
|
144
|
-
)}
|
|
145
|
-
{micMuted && (
|
|
146
|
-
<span className="absolute top-2 right-2 flex items-center justify-center w-7 h-7 rounded-full bg-black/60 text-white" role="img" aria-label="Muted">
|
|
147
|
-
<span className="w-4 h-4 [&>svg]:w-4 [&>svg]:h-4">
|
|
148
|
-
<MicOffIcon />
|
|
149
|
-
</span>
|
|
150
|
-
</span>
|
|
151
|
-
)}
|
|
152
|
-
{badge && (
|
|
153
|
-
<span
|
|
154
|
-
className={`absolute bottom-2 right-2 px-2 py-0.5 rounded text-xs ${
|
|
155
|
-
transport === "failed" ? "bg-[#601410] text-[#f9dedc]" : "bg-black/60 text-white"
|
|
156
|
-
}`}
|
|
157
|
-
title={badge.title}
|
|
158
|
-
data-testid="transport-badge"
|
|
159
|
-
>
|
|
160
|
-
{badge.label}
|
|
161
|
-
</span>
|
|
162
|
-
)}
|
|
163
|
-
<div className="absolute bottom-2 left-2 max-w-[calc(100%-1rem)] truncate px-2 py-0.5 rounded bg-black/60 text-white text-sm">
|
|
164
|
-
{name}
|
|
165
|
-
{isLocal ? " (you)" : ""}
|
|
166
|
-
</div>
|
|
167
|
-
</div>
|
|
168
|
-
);
|
|
169
|
-
}
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/** One participant's video tile - used by the grid, the focused/thumbnail-strip layouts and the local participant's
|
|
6
|
+
* corner tile in `_CallView.tsx`. Renders a placeholder (the participant's initial) instead of a `<video>` element
|
|
7
|
+
* whenever there's no stream or their camera is off, rather than showing a black/frozen frame.
|
|
8
|
+
*
|
|
9
|
+
* A tile never plays sound: its `<video>` is always muted, and what a participant says is played by the call view's
|
|
10
|
+
* own `<audio>` elements (`_CallView.tsx`'s `RemoteAudio`), so a tile that isn't showing video - or isn't on screen
|
|
11
|
+
* in the current layout - can never silence anyone.
|
|
12
|
+
*
|
|
13
|
+
* The local tile is shown mirrored (`scaleX(-1)`), like a real mirror, so a participant's own movements feel
|
|
14
|
+
* natural - this is a local display transform only, never applied to the video actually sent to anyone, so
|
|
15
|
+
* everyone else always sees the participant the right way round. `mirrored={false}` (`_CallView.tsx`, when a custom
|
|
16
|
+
* background image is on) turns that off: the picture the participant chose is a fixed reference, not a live
|
|
17
|
+
* reflection, and mirroring it would show it backwards to no one but themselves. */
|
|
18
|
+
import React, { useEffect, useRef } from "react";
|
|
19
|
+
import type { MediaTransport } from "../shared/webrtc/types.js";
|
|
20
|
+
import { MicOffIcon } from "./_icons.js";
|
|
21
|
+
|
|
22
|
+
/** What a tile says about how its participant's media is arriving, for the paths that are not the ordinary direct one
|
|
23
|
+
* (which needs no comment). Text rather than only an icon, so the reason a picture is degraded is never a guess.
|
|
24
|
+
* Exported so `_DiagnosticsPanel.tsx` shows the identical label/title rather than describing the same paths in
|
|
25
|
+
* slightly different words. */
|
|
26
|
+
export const TRANSPORT_BADGES: Partial<Record<MediaTransport, { label: string; title: string }>> = {
|
|
27
|
+
turn: { label: "Relayed", title: "Connected through the relay server, because a direct connection was not possible." },
|
|
28
|
+
"turn-tcp": {
|
|
29
|
+
label: "Relayed (TCP)",
|
|
30
|
+
title:
|
|
31
|
+
"Connected through the relay server over TCP, because this network blocks the relay's usual UDP connection too. " +
|
|
32
|
+
"Audio or video may pause briefly under network strain rather than just glitching - a property of TCP, not a dropped connection.",
|
|
33
|
+
},
|
|
34
|
+
websocket: {
|
|
35
|
+
label: "Server relay",
|
|
36
|
+
title: "Sent through the server as a last resort, because no other connection was possible. Video is lower quality and sound may lag.",
|
|
37
|
+
},
|
|
38
|
+
failed: { label: "Can't connect", title: "This participant could not be reached from your network." },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** A short line saying the connection is still being made, with a spinner - a polite live region (not `role="status"`,
|
|
42
|
+
* which the call view's own announcement region already is), so a participant who cannot see the tile still learns
|
|
43
|
+
* why nothing is happening yet. */
|
|
44
|
+
function StatusPill({ status, className }: { status: string; className?: string }) {
|
|
45
|
+
return (
|
|
46
|
+
<span
|
|
47
|
+
aria-live="polite"
|
|
48
|
+
data-testid="tile-status"
|
|
49
|
+
className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-black/60 text-white text-xs ${className ?? ""}`}
|
|
50
|
+
>
|
|
51
|
+
<span className="inline-block w-3 h-3 rounded-full border-2 border-white/30 border-t-white animate-spin" aria-hidden="true" />
|
|
52
|
+
{status}
|
|
53
|
+
</span>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ParticipantTileProps {
|
|
58
|
+
name: string;
|
|
59
|
+
stream?: MediaStream | null;
|
|
60
|
+
isLocal?: boolean;
|
|
61
|
+
/** Shows the initial instead of the video. */
|
|
62
|
+
cameraOff?: boolean;
|
|
63
|
+
micMuted?: boolean;
|
|
64
|
+
handRaised?: boolean;
|
|
65
|
+
/** How this participant's media reaches you - a badge is shown for everything but a direct or still-connecting one. */
|
|
66
|
+
transport?: MediaTransport;
|
|
67
|
+
/** Says the connection is still being made, e.g. "Connecting..." on the local tile and "Awaiting connection..." on a
|
|
68
|
+
* participant's - shown with a spinner until the caller clears it. */
|
|
69
|
+
status?: string;
|
|
70
|
+
/** Highlights this tile as the current presenter/focus. */
|
|
71
|
+
isFocused?: boolean;
|
|
72
|
+
/** Fits the whole picture inside the tile instead of filling it - a shared screen must not be cropped. */
|
|
73
|
+
contain?: boolean;
|
|
74
|
+
/** Whether the local tile is shown mirrored, like a real mirror - ignored for a remote tile, which is never
|
|
75
|
+
* mirrored. Default `true`; see this module's doc comment. */
|
|
76
|
+
mirrored?: boolean;
|
|
77
|
+
onClick?: () => void;
|
|
78
|
+
className?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function initialOf(name: string): string {
|
|
82
|
+
return (Array.from(name.trim())[0] ?? "?").toUpperCase();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export default function ParticipantTile({
|
|
86
|
+
name,
|
|
87
|
+
stream,
|
|
88
|
+
isLocal,
|
|
89
|
+
cameraOff,
|
|
90
|
+
micMuted,
|
|
91
|
+
handRaised,
|
|
92
|
+
transport,
|
|
93
|
+
status,
|
|
94
|
+
isFocused,
|
|
95
|
+
contain,
|
|
96
|
+
mirrored = true,
|
|
97
|
+
onClick,
|
|
98
|
+
className,
|
|
99
|
+
}: ParticipantTileProps) {
|
|
100
|
+
const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
|
|
101
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
102
|
+
const showVideo = !!stream && !cameraOff;
|
|
103
|
+
|
|
104
|
+
// The `<video>` element only exists while `showVideo`, so its stream is bound whenever it (re)appears too.
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (videoRef.current) {
|
|
107
|
+
videoRef.current.srcObject = stream ?? null;
|
|
108
|
+
}
|
|
109
|
+
}, [stream, showVideo]);
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div
|
|
113
|
+
className={`relative overflow-hidden rounded-xl bg-[#3c4043] flex items-center justify-center min-h-[90px] ${
|
|
114
|
+
isFocused ? "ring-2 ring-[#8ab4f8]" : ""
|
|
115
|
+
} ${className ?? ""}`}
|
|
116
|
+
onClick={onClick}
|
|
117
|
+
role={onClick ? "button" : undefined}
|
|
118
|
+
tabIndex={onClick ? 0 : undefined}
|
|
119
|
+
aria-label={onClick ? `${name}${isLocal ? " (you)" : ""}` : undefined}
|
|
120
|
+
>
|
|
121
|
+
{showVideo ? (
|
|
122
|
+
<>
|
|
123
|
+
<video
|
|
124
|
+
ref={videoRef}
|
|
125
|
+
autoPlay
|
|
126
|
+
playsInline
|
|
127
|
+
muted
|
|
128
|
+
className={`w-full h-full ${contain ? "object-contain bg-black" : "object-cover"} ${isLocal && !contain && mirrored ? "[transform:scaleX(-1)]" : ""}`}
|
|
129
|
+
/>
|
|
130
|
+
{status && <StatusPill status={status} className="absolute top-2 left-1/2 -translate-x-1/2 whitespace-nowrap" />}
|
|
131
|
+
</>
|
|
132
|
+
) : (
|
|
133
|
+
<div className="flex flex-col items-center gap-2 max-w-full px-1">
|
|
134
|
+
<div className="w-16 h-16 rounded-full bg-primary text-white flex items-center justify-center text-2xl font-bold" aria-hidden="true">
|
|
135
|
+
{initialOf(name)}
|
|
136
|
+
</div>
|
|
137
|
+
{status && <StatusPill status={status} />}
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
{handRaised && (
|
|
141
|
+
<span className="absolute top-2 left-2 flex items-center justify-center w-8 h-8 rounded-full bg-[#a8c7fa] text-base" role="img" aria-label="Hand raised">
|
|
142
|
+
✋
|
|
143
|
+
</span>
|
|
144
|
+
)}
|
|
145
|
+
{micMuted && (
|
|
146
|
+
<span className="absolute top-2 right-2 flex items-center justify-center w-7 h-7 rounded-full bg-black/60 text-white" role="img" aria-label="Muted">
|
|
147
|
+
<span className="w-4 h-4 [&>svg]:w-4 [&>svg]:h-4">
|
|
148
|
+
<MicOffIcon />
|
|
149
|
+
</span>
|
|
150
|
+
</span>
|
|
151
|
+
)}
|
|
152
|
+
{badge && (
|
|
153
|
+
<span
|
|
154
|
+
className={`absolute bottom-2 right-2 px-2 py-0.5 rounded text-xs ${
|
|
155
|
+
transport === "failed" ? "bg-[#601410] text-[#f9dedc]" : "bg-black/60 text-white"
|
|
156
|
+
}`}
|
|
157
|
+
title={badge.title}
|
|
158
|
+
data-testid="transport-badge"
|
|
159
|
+
>
|
|
160
|
+
{badge.label}
|
|
161
|
+
</span>
|
|
162
|
+
)}
|
|
163
|
+
<div className="absolute bottom-2 left-2 max-w-[calc(100%-1rem)] truncate px-2 py-0.5 rounded bg-black/60 text-white text-sm">
|
|
164
|
+
{name}
|
|
165
|
+
{isLocal ? " (you)" : ""}
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
@@ -19,21 +19,19 @@
|
|
|
19
19
|
* offer) and a "Remove" button (a native `confirm()` first, since removal can't be undone by the participant the
|
|
20
20
|
* way a mute request can) - never on the local participant's own row, and never shown to anyone but the host.
|
|
21
21
|
*
|
|
22
|
-
* The header also gets a host-only "
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* caller is the host (there is no push signal for a newly filed admission request), so it stays current without
|
|
29
|
-
* the host needing to close and reopen the drawer.
|
|
22
|
+
* The header also gets a host-only "who's waiting" list (`waitingParticipants`/`onAdmit`/`onDeny`), shown whenever
|
|
23
|
+
* `waitingRoomEnabled` is on - `_CallView.tsx` polls the list while this drawer is open and the caller is the host
|
|
24
|
+
* (there is no push signal for a newly filed admission request), so it stays current without the host needing to
|
|
25
|
+
* close and reopen the drawer. The waiting-room toggle itself, along with "mute new participants on join" and the
|
|
26
|
+
* join password, lives in `_SettingsModal.tsx` instead (opened from the navbar's "…" menu) - this drawer only
|
|
27
|
+
* reads `waitingRoomEnabled` to decide whether the list has anywhere to come from, it doesn't set it.
|
|
30
28
|
*
|
|
31
29
|
* While talking-stick mode is on (`talkingStickActive`), every row - including the host's own - shows a "Give
|
|
32
30
|
* stick" button, host-only, hidden on whichever row currently holds it (`talkingStickHolder`); everyone (not just
|
|
33
31
|
* the host) sees a badge on that row instead, same visibility as the "Host" tag. See `_CallView.tsx`'s doc comment
|
|
34
32
|
* on talking-stick mode for what granting it actually does to the recipient's microphone.
|
|
35
33
|
*/
|
|
36
|
-
import React, { useEffect
|
|
34
|
+
import React, { useEffect } from "react";
|
|
37
35
|
import type { MeshParticipant } from "../shared/webrtc/types.js";
|
|
38
36
|
import type { WaitingParticipant } from "./_meetApi.js";
|
|
39
37
|
import { TRANSPORT_BADGES } from "./_ParticipantTile.js";
|
|
@@ -52,19 +50,10 @@ export interface ParticipantsDrawerProps {
|
|
|
52
50
|
/** Removes the given participant's `uid` - see `_CallView.tsx`'s `handleKickParticipant()` for what this
|
|
53
51
|
* actually does (the cooperative signal plus the enforced server-side revoke). */
|
|
54
52
|
onKick: (uid: string) => void;
|
|
55
|
-
/** Whether
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
/** Whether this meeting currently requires a password - never the password itself, which the server never
|
|
59
|
-
* sends back (see `PublicVideoMeeting.hasPassword`'s own doc comment). */
|
|
60
|
-
hasPassword: boolean;
|
|
61
|
-
/** Sets (a non-empty string), replaces, or removes (`null`) the join password. Rejecting lets
|
|
62
|
-
* `PasswordSection` show its own inline error without this drawer needing to know anything about it. */
|
|
63
|
-
onSetPassword: (password: string | null) => Promise<void>;
|
|
64
|
-
/** Whether the meeting currently requires the host to admit each participant - the header checkbox's own
|
|
65
|
-
* state, host-only. */
|
|
53
|
+
/** Whether the meeting currently requires the host to admit each participant - read-only here, set from
|
|
54
|
+
* `_SettingsModal.tsx` instead; this drawer only uses it to decide whether the waiting list has anywhere to
|
|
55
|
+
* come from. */
|
|
66
56
|
waitingRoomEnabled: boolean;
|
|
67
|
-
onToggleWaitingRoomEnabled: () => void;
|
|
68
57
|
/** Everyone currently waiting to be admitted - `_CallView.tsx`'s own polled, always-current list; empty
|
|
69
58
|
* whenever this drawer isn't open and host, by construction. */
|
|
70
59
|
waitingParticipants: WaitingParticipant[];
|
|
@@ -84,71 +73,6 @@ export interface ParticipantsDrawerProps {
|
|
|
84
73
|
onClose: () => void;
|
|
85
74
|
}
|
|
86
75
|
|
|
87
|
-
/** The host-only password form: one text input plus a Set/Change button, and a Remove button once one is set.
|
|
88
|
-
* Owns its own input/error/busy state locally - `_CallView.tsx` only needs to know whether a password exists, not
|
|
89
|
-
* what a half-typed one currently says. */
|
|
90
|
-
function PasswordSection({ hasPassword, onSetPassword }: { hasPassword: boolean; onSetPassword: (password: string | null) => Promise<void> }) {
|
|
91
|
-
const [input, setInput] = useState("");
|
|
92
|
-
const [error, setError] = useState<string | null>(null);
|
|
93
|
-
const [busy, setBusy] = useState(false);
|
|
94
|
-
|
|
95
|
-
async function submit(password: string | null) {
|
|
96
|
-
setBusy(true);
|
|
97
|
-
setError(null);
|
|
98
|
-
try {
|
|
99
|
-
await onSetPassword(password);
|
|
100
|
-
setInput("");
|
|
101
|
-
} catch {
|
|
102
|
-
setError("Could not save - try again.");
|
|
103
|
-
} finally {
|
|
104
|
-
setBusy(false);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
return (
|
|
109
|
-
<div className="px-3 py-2 border-b border-white/10 text-sm">
|
|
110
|
-
<p className="text-white/70 mb-1.5">{hasPassword ? "Password protection is on." : "No password required to join."}</p>
|
|
111
|
-
<form
|
|
112
|
-
className="flex gap-2"
|
|
113
|
-
onSubmit={(event) => {
|
|
114
|
-
event.preventDefault();
|
|
115
|
-
if (input) {
|
|
116
|
-
void submit(input);
|
|
117
|
-
}
|
|
118
|
-
}}
|
|
119
|
-
>
|
|
120
|
-
<input
|
|
121
|
-
type="password"
|
|
122
|
-
aria-label={hasPassword ? "New password" : "Set a password"}
|
|
123
|
-
placeholder={hasPassword ? "New password" : "Set a password"}
|
|
124
|
-
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"
|
|
125
|
-
value={input}
|
|
126
|
-
onChange={(event) => setInput(event.target.value)}
|
|
127
|
-
disabled={busy}
|
|
128
|
-
/>
|
|
129
|
-
<button
|
|
130
|
-
type="submit"
|
|
131
|
-
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"
|
|
132
|
-
disabled={busy || !input}
|
|
133
|
-
>
|
|
134
|
-
{hasPassword ? "Change" : "Set"}
|
|
135
|
-
</button>
|
|
136
|
-
</form>
|
|
137
|
-
{hasPassword && (
|
|
138
|
-
<button
|
|
139
|
-
type="button"
|
|
140
|
-
className="mt-1.5 text-xs text-[#f2b8b5] underline focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 disabled:opacity-50"
|
|
141
|
-
disabled={busy}
|
|
142
|
-
onClick={() => void submit(null)}
|
|
143
|
-
>
|
|
144
|
-
Remove password
|
|
145
|
-
</button>
|
|
146
|
-
)}
|
|
147
|
-
{error && <p className="text-[#f2b8b5] text-xs mt-1.5">{error}</p>}
|
|
148
|
-
</div>
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
76
|
/** The host-only "who's waiting" list - shown only while `waitingRoomEnabled` is on, since there is otherwise never
|
|
153
77
|
* anyone to list. Says so explicitly when the list is empty, rather than rendering nothing (silence here would be
|
|
154
78
|
* indistinguishable from the list just not having loaded yet). */
|
|
@@ -304,12 +228,7 @@ export default function ParticipantsDrawer({
|
|
|
304
228
|
isParticipantHost,
|
|
305
229
|
onMute,
|
|
306
230
|
onKick,
|
|
307
|
-
forceMuteOnJoin,
|
|
308
|
-
onToggleForceMuteOnJoin,
|
|
309
|
-
hasPassword,
|
|
310
|
-
onSetPassword,
|
|
311
231
|
waitingRoomEnabled,
|
|
312
|
-
onToggleWaitingRoomEnabled,
|
|
313
232
|
waitingParticipants,
|
|
314
233
|
onAdmit,
|
|
315
234
|
onDeny,
|
|
@@ -346,19 +265,6 @@ export default function ParticipantsDrawer({
|
|
|
346
265
|
✕
|
|
347
266
|
</button>
|
|
348
267
|
</div>
|
|
349
|
-
{isSelfHost && (
|
|
350
|
-
<label className="flex items-center gap-2 px-3 py-2 border-b border-white/10 text-sm">
|
|
351
|
-
<input type="checkbox" checked={forceMuteOnJoin} onChange={onToggleForceMuteOnJoin} className="w-4 h-4" />
|
|
352
|
-
Mute new participants on join
|
|
353
|
-
</label>
|
|
354
|
-
)}
|
|
355
|
-
{isSelfHost && <PasswordSection hasPassword={hasPassword} onSetPassword={onSetPassword} />}
|
|
356
|
-
{isSelfHost && (
|
|
357
|
-
<label className="flex items-center gap-2 px-3 py-2 border-b border-white/10 text-sm">
|
|
358
|
-
<input type="checkbox" checked={waitingRoomEnabled} onChange={onToggleWaitingRoomEnabled} className="w-4 h-4" />
|
|
359
|
-
Require the host to admit participants
|
|
360
|
-
</label>
|
|
361
|
-
)}
|
|
362
268
|
{isSelfHost && waitingRoomEnabled && (
|
|
363
269
|
<WaitingSection waitingParticipants={waitingParticipants} onAdmit={onAdmit} onDeny={onDeny} />
|
|
364
270
|
)}
|