@rapidmx/meet-plugin 0.9.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/meet/[token].tsx +204 -13
- package/apps/meet/_CallControls.tsx +64 -3
- package/apps/meet/_CallView.tsx +244 -7
- package/apps/meet/_DiagnosticsPanel.tsx +113 -0
- package/apps/meet/_ParticipantTile.tsx +10 -2
- package/apps/meet/_ParticipantsDrawer.tsx +341 -0
- package/apps/meet/_icons.tsx +12 -0
- package/apps/meet/_meetApi.ts +141 -1
- package/apps/shared/media/deviceMedia.ts +22 -4
- package/apps/shared/media/filters/ScreenTransform.ts +148 -0
- package/apps/shared/media/useLocalMedia.ts +38 -10
- package/apps/shared/relay/AudioPlayer.ts +30 -4
- package/apps/shared/relay/relayEnv.ts +8 -1
- package/apps/shared/webrtc/MeshConnectionManager.ts +89 -4
- package/apps/shared/webrtc/realPeerConnection.ts +47 -7
- package/apps/shared/webrtc/types.ts +55 -7
- package/dist/apps/meet/[token].d.ts +4 -1
- package/dist/apps/meet/[token].js +121 -9
- package/dist/apps/meet/_CallControls.d.ts +9 -1
- package/dist/apps/meet/_CallControls.js +6 -5
- package/dist/apps/meet/_CallView.d.ts +28 -2
- package/dist/apps/meet/_CallView.js +155 -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 +61 -0
- package/dist/apps/meet/_ParticipantsDrawer.js +86 -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/deviceMedia.d.ts +11 -2
- package/dist/apps/shared/media/deviceMedia.js +20 -4
- 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 +32 -10
- package/dist/apps/shared/relay/AudioPlayer.d.ts +14 -2
- package/dist/apps/shared/relay/AudioPlayer.js +31 -4
- package/dist/apps/shared/relay/relayEnv.d.ts +5 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +30 -1
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +78 -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 +54 -6
- 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
|
@@ -29,9 +29,16 @@ export const REACTION_EMOJIS = ["👍", "👏", "❤️", "🎉", "😂", "😮"
|
|
|
29
29
|
* grows other push consumers) can cheaply recognize and skip anything that isn't one of these.
|
|
30
30
|
*
|
|
31
31
|
* `hello`/`bye`/`presenter-claim`/`presenter-release`/`state`/`reaction` are broadcast (`to` left unset - every
|
|
32
|
-
* participant is relevant); `offer`/`answer`/`ice-candidate` are point-to-point (`to` is the
|
|
33
|
-
* a recipient that isn't `to` (when set) ignores the message, since a channel's pub/sub fans
|
|
34
|
-
* every subscriber, sender included.
|
|
32
|
+
* participant is relevant); `offer`/`answer`/`ice-candidate`/`mute-request`/`kicked` are point-to-point (`to` is the
|
|
33
|
+
* intended peer's uid) - a recipient that isn't `to` (when set) ignores the message, since a channel's pub/sub fans
|
|
34
|
+
* every message out to every subscriber, sender included.
|
|
35
|
+
*
|
|
36
|
+
* `mute-request`/`kicked` are host moderation signals (see `MeshConnectionManager`'s doc comment) - purely
|
|
37
|
+
* cooperative, like every other message here: the recipient's own client decides whether to honor one, and nothing
|
|
38
|
+
* on the wire distinguishes a genuine host's message from any other participant's, since this client protocol has
|
|
39
|
+
* no host identity of its own to check against server-side. `kicked` is paired with the sender also revoking the
|
|
40
|
+
* recipient's server-side channel grant (`BaseVideoMeetingRoute.revokeChannelGrant()`), which is enforced and does
|
|
41
|
+
* not depend on the recipient's client cooperating.
|
|
35
42
|
*/
|
|
36
43
|
export interface SignalMessage {
|
|
37
44
|
type: "video-meeting-signal";
|
|
@@ -45,7 +52,9 @@ export interface SignalMessage {
|
|
|
45
52
|
| "presenter-release"
|
|
46
53
|
| "state"
|
|
47
54
|
| "reaction"
|
|
48
|
-
| "relay-fallback"
|
|
55
|
+
| "relay-fallback"
|
|
56
|
+
| "mute-request"
|
|
57
|
+
| "kicked";
|
|
49
58
|
/** The sender's authenticated uid - the real caller's own uid when `join()` returned `authenticated: true`, else
|
|
50
59
|
* the `guest:<random>` uid `BaseVideoMeetingRoute.join()` minted. The server refuses a published message whose
|
|
51
60
|
* `from` isn't the authenticated caller's uid (it stops one participant speaking as another), so this must be
|
|
@@ -81,6 +90,28 @@ export interface SignalingChannel {
|
|
|
81
90
|
onMessage(handler: (message: SignalMessage) => void): () => void;
|
|
82
91
|
}
|
|
83
92
|
|
|
93
|
+
/** One kind's RTP stream stats, as of one sample - cumulative counters (`bytesSent`/`bytesReceived`), not a rate,
|
|
94
|
+
* since a single `getStats()` snapshot has no "since when" to divide by; a caller wanting a bitrate keeps the
|
|
95
|
+
* previous sample itself and divides the difference by the elapsed time (see `MeshConnectionManager`'s poller).
|
|
96
|
+
* `undefined` for anything this browser (or this stream's direction - an audio-only connection reports nothing for
|
|
97
|
+
* video, and vice versa) doesn't report. */
|
|
98
|
+
export interface RtpStreamDiagnostics {
|
|
99
|
+
packetsLost?: number;
|
|
100
|
+
/** Seconds. */
|
|
101
|
+
jitter?: number;
|
|
102
|
+
bytesSent?: number;
|
|
103
|
+
bytesReceived?: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One point-in-time sample of a connection's own quality stats, for the diagnostics panel. */
|
|
107
|
+
export interface ConnectionDiagnostics {
|
|
108
|
+
/** Seconds - the selected candidate pair's own current round-trip time. `undefined` when this browser doesn't
|
|
109
|
+
* report one (a connection still connecting, or simply not supported). */
|
|
110
|
+
roundTripTimeSeconds?: number;
|
|
111
|
+
audio: RtpStreamDiagnostics;
|
|
112
|
+
video: RtpStreamDiagnostics;
|
|
113
|
+
}
|
|
114
|
+
|
|
84
115
|
/** The subset of `RTCRtpSender` `MeshConnectionManager.setLocalTrack()` uses. Every connection carries one audio and
|
|
85
116
|
* one video sender for its whole life (see `MeshConnectionManager`'s doc comment), so turning a camera on or off,
|
|
86
117
|
* switching a device or sharing a screen only ever replaces the track a sender sends - no renegotiation. A real
|
|
@@ -115,17 +146,24 @@ export interface RTCPeerConnectionLike {
|
|
|
115
146
|
connectionState: string;
|
|
116
147
|
/** Which path the connected pair is using - read from the selected candidate pair once `connectionState` is
|
|
117
148
|
* `connected`. `"unknown"` when the browser does not report one (treated as direct by the caller). */
|
|
118
|
-
connectionType(): Promise<"p2p" | "turn" | "unknown">;
|
|
149
|
+
connectionType(): Promise<"p2p" | "turn" | "turn-tcp" | "unknown">;
|
|
150
|
+
/** One point-in-time sample of this connection's own quality stats, for the diagnostics panel - polled
|
|
151
|
+
* periodically and independently of `connectionType()` (see `MeshConnectionManager`'s doc comment). */
|
|
152
|
+
collectDiagnostics(): Promise<ConnectionDiagnostics>;
|
|
119
153
|
}
|
|
120
154
|
|
|
121
155
|
/** Which of the three media paths a participant's audio and video currently take, in the order they are tried:
|
|
122
156
|
*
|
|
123
157
|
* - `"p2p"`: a direct peer-to-peer WebRTC connection, the preferred path.
|
|
124
158
|
* - `"turn"`: still WebRTC, but relayed through the TURN server, for a participant a direct path cannot reach.
|
|
159
|
+
* - `"turn-tcp"`: the same, but the relay hop itself is TCP (or `turns:`, TLS being TCP-based too) rather than UDP -
|
|
160
|
+
* a network that blocks UDP outright. TCP's reliable, ordered delivery means one lost or delayed packet stalls
|
|
161
|
+
* everything queued behind it instead of just being dropped, so this can sound like pauses rather than the clicks
|
|
162
|
+
* plain packet loss would - worth telling apart from ordinary `"turn"` for exactly that reason.
|
|
125
163
|
* - `"websocket"`: not WebRTC at all - encoded frames proxied by this server over a WebSocket (see
|
|
126
164
|
* `apps/shared/relay/`), the last resort when neither of the above connects.
|
|
127
165
|
* - `"connecting"`: no path established yet. `"failed"`: every path this browser can try has failed. */
|
|
128
|
-
export type MediaTransport = "connecting" | "p2p" | "turn" | "websocket" | "failed";
|
|
166
|
+
export type MediaTransport = "connecting" | "p2p" | "turn" | "turn-tcp" | "websocket" | "failed";
|
|
129
167
|
|
|
130
168
|
/** What `MeshConnectionManager` needs from the WebSocket media relay (`apps/shared/relay/RelayTransport.ts` implements
|
|
131
169
|
* it) - declared here so the mesh, and its tests, depend on the shape alone. */
|
|
@@ -151,6 +189,10 @@ export interface MeshParticipant extends ParticipantState {
|
|
|
151
189
|
name: string;
|
|
152
190
|
/** How this participant's media currently reaches the local one - see `MediaTransport`. */
|
|
153
191
|
transport: MediaTransport;
|
|
192
|
+
/** This connection's most recently polled quality stats, for the diagnostics panel - `undefined` until the
|
|
193
|
+
* first poll completes (shortly after `transport` first leaves `"connecting"`), and never present at all for
|
|
194
|
+
* a `"websocket"`-relayed participant (there is no `RTCPeerConnection` to poll). */
|
|
195
|
+
diagnostics?: ConnectionDiagnostics;
|
|
154
196
|
}
|
|
155
197
|
|
|
156
198
|
export type MeshEvent =
|
|
@@ -160,4 +202,10 @@ export type MeshEvent =
|
|
|
160
202
|
| { type: "remote-stream"; uid: string; stream: MediaStream }
|
|
161
203
|
| { type: "hand-raised"; uid: string; name: string }
|
|
162
204
|
| { type: "reaction"; uid: string; name: string; emoji: string }
|
|
163
|
-
| { type: "presenter-changed"; uid: string | undefined }
|
|
205
|
+
| { type: "presenter-changed"; uid: string | undefined }
|
|
206
|
+
/** Someone (implicitly the host, on the client's own say-so - see `SignalMessage`'s doc comment) asked the local
|
|
207
|
+
* participant to mute. The caller (`_CallView.tsx`) mutes only if currently unmuted; this never unmutes anyone. */
|
|
208
|
+
| { type: "mute-requested" }
|
|
209
|
+
/** The local participant was removed from the call. The caller tears down the mesh and shows a distinct
|
|
210
|
+
* "removed by the host" state rather than the ordinary "you left" one. */
|
|
211
|
+
| { type: "kicked" };
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The public join/lobby/in-call page, `GET /meet/:token` - resolves an invitee's join token or a public meeting's
|
|
3
3
|
* slug (`BaseVideoMeetingRoute.join()`, both shapes accepted identically - see `_meetApi.ts`), then walks through
|
|
4
|
-
* `loading` -> `
|
|
4
|
+
* `loading` -> (`password`, only when a password is required) -> (`admission-request` then `waiting`, only when
|
|
5
|
+
* the meeting has a waiting room - see that phase's own render branch) -> `lobby` -> `in-call` -> `ended`. A
|
|
6
|
+
* waiting room takes over the entire gate (asking for a password too, on the same form) rather than stacking
|
|
7
|
+
* after a separate password screen - see `BaseVideoMeetingRoute.join()`'s own doc comment for why.
|
|
5
8
|
*
|
|
6
9
|
* ## Session-based name prefill - NOT implemented, and why
|
|
7
10
|
*
|
|
@@ -6,7 +6,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
/**
|
|
7
7
|
* The public join/lobby/in-call page, `GET /meet/:token` - resolves an invitee's join token or a public meeting's
|
|
8
8
|
* slug (`BaseVideoMeetingRoute.join()`, both shapes accepted identically - see `_meetApi.ts`), then walks through
|
|
9
|
-
* `loading` -> `
|
|
9
|
+
* `loading` -> (`password`, only when a password is required) -> (`admission-request` then `waiting`, only when
|
|
10
|
+
* the meeting has a waiting room - see that phase's own render branch) -> `lobby` -> `in-call` -> `ended`. A
|
|
11
|
+
* waiting room takes over the entire gate (asking for a password too, on the same form) rather than stacking
|
|
12
|
+
* after a separate password screen - see `BaseVideoMeetingRoute.join()`'s own doc comment for why.
|
|
10
13
|
*
|
|
11
14
|
* ## Session-based name prefill - NOT implemented, and why
|
|
12
15
|
*
|
|
@@ -35,11 +38,15 @@ import useBranding from "@rapidmx/web-client/lib/branding/useBranding.js";
|
|
|
35
38
|
import { ApiRequestError } from "@rapidmx/web-client/lib/util/api.js";
|
|
36
39
|
import Button from "@rapidmx/web-client/lib/components/buttons/Button.js";
|
|
37
40
|
import Alert from "@rapidmx/web-client/lib/components/feedback/Alert.js";
|
|
41
|
+
import FormField from "@rapidmx/web-client/lib/components/forms/FormField.js";
|
|
38
42
|
import { MeetCard, MeetPageShell } from "./_MeetChrome.js";
|
|
39
43
|
import { useLocalMedia } from "../shared/media/useLocalMedia.js";
|
|
40
44
|
import MeetLobby from "./_MeetLobby.js";
|
|
41
45
|
import CallView from "./_CallView.js";
|
|
42
|
-
import { joinMeeting } from "./_meetApi.js";
|
|
46
|
+
import { joinMeeting, pollAdmission, requestAdmission, verifyMeetingPassword, } from "./_meetApi.js";
|
|
47
|
+
const INPUT_CLASS = "w-full text-base py-2.5 px-3.5 border border-border rounded-md bg-surface text-text focus:outline-none focus:border-primary";
|
|
48
|
+
/** How often `MeetJoinContent` polls while in the `"waiting"` phase. */
|
|
49
|
+
const ADMISSION_POLL_MS = 3000;
|
|
43
50
|
export default function MeetJoinPage({ params }) {
|
|
44
51
|
const { branding } = useBranding();
|
|
45
52
|
return _jsx(MeetJoinContent, { token: params.token, branding: branding });
|
|
@@ -52,16 +59,48 @@ function MeetJoinContent({ token, branding }) {
|
|
|
52
59
|
const [name, setName] = useState("");
|
|
53
60
|
const [loadError, setLoadError] = useState(null);
|
|
54
61
|
const [joinResult, setJoinResult] = useState(null);
|
|
55
|
-
|
|
62
|
+
/** The restricted `PublicVideoMeeting` `joinMeeting()` returned while this meeting required a password - just
|
|
63
|
+
* enough to show the password prompt's own title/host, since `joinResult` itself stays unset until one is
|
|
64
|
+
* verified (see `handleSubmitPassword()`). Only read while `phase === "password"`. */
|
|
65
|
+
const [passwordMeeting, setPasswordMeeting] = useState(null);
|
|
66
|
+
const [passwordInput, setPasswordInput] = useState("");
|
|
67
|
+
const [passwordError, setPasswordError] = useState(null);
|
|
68
|
+
/** The restricted `PublicVideoMeeting` `joinMeeting()`/`requestAdmission()` returned while this meeting has a
|
|
69
|
+
* waiting room - read on both the `"admission-request"` and `"waiting"` screens. */
|
|
70
|
+
const [admissionMeeting, setAdmissionMeeting] = useState(null);
|
|
71
|
+
const [admissionName, setAdmissionName] = useState("");
|
|
72
|
+
const [admissionPassword, setAdmissionPassword] = useState("");
|
|
73
|
+
const [admissionError, setAdmissionError] = useState(null);
|
|
74
|
+
/** The guest token `requestAdmission()` minted, to poll with (`Authorization` header - a guest has no session
|
|
75
|
+
* cookie of their own). `undefined` for an already-authenticated real caller, whose existing session cookie
|
|
76
|
+
* already identifies them to `pollAdmission()` with no header needed. */
|
|
77
|
+
const [pendingGuestToken, setPendingGuestToken] = useState(undefined);
|
|
78
|
+
/** Set only when the call ended without the participant's own action (currently: kicked by the host) - see
|
|
79
|
+
* `CallView`'s `onLeave` doc comment. Shown instead of the ordinary "you left" message; also hides "Rejoin
|
|
80
|
+
* meeting", since a kicked participant's server-side channel grant has just been revoked and a rejoin attempt
|
|
81
|
+
* would only fail. */
|
|
82
|
+
const [endedReason, setEndedReason] = useState(undefined);
|
|
83
|
+
const media = useLocalMedia({ effectsAssetsUrl: joinResult?.effectsAssetsUrl, forceMuteOnJoin: joinResult?.meeting.forceMuteOnJoin });
|
|
56
84
|
const { release } = media;
|
|
57
85
|
React.useEffect(() => {
|
|
58
86
|
let cancelled = false;
|
|
59
87
|
joinMeeting(token)
|
|
60
88
|
.then((result) => {
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
|
|
89
|
+
if (cancelled) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if ("requiresAdmission" in result) {
|
|
93
|
+
setAdmissionMeeting(result.meeting);
|
|
94
|
+
setPhase("admission-request");
|
|
95
|
+
return;
|
|
64
96
|
}
|
|
97
|
+
if ("requiresPassword" in result) {
|
|
98
|
+
setPasswordMeeting(result.meeting);
|
|
99
|
+
setPhase("password");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
setJoinResult(result);
|
|
103
|
+
setPhase("lobby");
|
|
65
104
|
})
|
|
66
105
|
.catch((err) => {
|
|
67
106
|
if (cancelled) {
|
|
@@ -82,8 +121,69 @@ function MeetJoinContent({ token, branding }) {
|
|
|
82
121
|
setName(joinName);
|
|
83
122
|
setPhase("in-call");
|
|
84
123
|
}
|
|
85
|
-
function
|
|
124
|
+
async function handleSubmitPassword(e) {
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
setPasswordError(null);
|
|
127
|
+
try {
|
|
128
|
+
const result = await verifyMeetingPassword(token, passwordInput);
|
|
129
|
+
setJoinResult(result);
|
|
130
|
+
setPhase("lobby");
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
setPasswordError(err instanceof ApiRequestError && err.status === 403 ? "Incorrect password." : "Something went wrong. Try again.");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async function handleRequestAdmission(e) {
|
|
137
|
+
e.preventDefault();
|
|
138
|
+
setAdmissionError(null);
|
|
139
|
+
try {
|
|
140
|
+
const result = await requestAdmission(token, {
|
|
141
|
+
name: admissionName.trim(),
|
|
142
|
+
...(admissionMeeting?.hasPassword && { password: admissionPassword }),
|
|
143
|
+
});
|
|
144
|
+
setName(admissionName.trim());
|
|
145
|
+
setPendingGuestToken(result.token);
|
|
146
|
+
setPhase("waiting");
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
setAdmissionError(err instanceof ApiRequestError && err.status === 403 ? "Incorrect password." : "Something went wrong. Try again.");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Polls while waiting for the host to respond - stops as soon as this phase is left, one way or the other.
|
|
153
|
+
React.useEffect(() => {
|
|
154
|
+
if (phase !== "waiting") {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
let cancelled = false;
|
|
158
|
+
const poll = async () => {
|
|
159
|
+
try {
|
|
160
|
+
const result = await pollAdmission(token, pendingGuestToken);
|
|
161
|
+
if (cancelled || "requiresAdmission" in result) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
setJoinResult(result);
|
|
165
|
+
setPhase("lobby");
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
if (cancelled || !(err instanceof ApiRequestError) || err.status !== 403) {
|
|
169
|
+
// A transient failure (network blip, server hiccup) - stay on this screen and try again next
|
|
170
|
+
// tick, rather than giving up on what might just be one bad request.
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
setAdmissionError(err.message || "The host did not admit you to this meeting.");
|
|
174
|
+
setPhase("denied");
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
void poll();
|
|
178
|
+
const interval = setInterval(() => void poll(), ADMISSION_POLL_MS);
|
|
179
|
+
return () => {
|
|
180
|
+
cancelled = true;
|
|
181
|
+
clearInterval(interval);
|
|
182
|
+
};
|
|
183
|
+
}, [phase, token, pendingGuestToken]);
|
|
184
|
+
function handleLeave(reason) {
|
|
86
185
|
release();
|
|
186
|
+
setEndedReason(reason);
|
|
87
187
|
setPhase("ended");
|
|
88
188
|
}
|
|
89
189
|
if (phase === "in-call" && joinResult) {
|
|
@@ -93,7 +193,7 @@ function MeetJoinContent({ token, branding }) {
|
|
|
93
193
|
// guest token to apply as a cookie at all, and `GuestSignalingClient` relies entirely on the
|
|
94
194
|
// browser's own already-existing `jwt` session cookie instead (see its own doc comment and
|
|
95
195
|
// `VideoMeetingJoinResult`'s doc comment in `_meetApi.ts`).
|
|
96
|
-
token: joinResult.token, selfUid: joinResult.selfUid, selfName: name, meetingTitle: joinResult.meeting.title, iceServers: joinResult.iceServers, relayEnabled: joinResult.relayEnabled, media: media, onLeave: handleLeave }));
|
|
196
|
+
token: joinResult.token, selfUid: joinResult.selfUid, selfName: name, meetingTitle: joinResult.meeting.title, iceServers: joinResult.iceServers, relayEnabled: joinResult.relayEnabled, hostUid: joinResult.meeting.hostUid, initialForceMuteOnJoin: !!joinResult.meeting.forceMuteOnJoin, initialHasPassword: !!joinResult.meeting.hasPassword, initialWaitingRoomEnabled: !!joinResult.meeting.waitingRoomEnabled, media: media, onLeave: handleLeave }));
|
|
97
197
|
}
|
|
98
198
|
let content;
|
|
99
199
|
if (phase === "loading") {
|
|
@@ -102,8 +202,20 @@ function MeetJoinContent({ token, branding }) {
|
|
|
102
202
|
else if (phase === "not-found") {
|
|
103
203
|
content = (_jsx(MeetCard, { children: _jsx(Alert, { children: loadError ?? "This meeting link isn't valid." }) }));
|
|
104
204
|
}
|
|
205
|
+
else if (phase === "password") {
|
|
206
|
+
content = (_jsxs(MeetCard, { children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight", children: passwordMeeting?.title }), passwordMeeting?.hostDisplayName && _jsxs("p", { className: "text-sm text-text-muted mt-1", children: ["Hosted by ", passwordMeeting.hostDisplayName] }), _jsx("p", { className: "text-base text-text-muted mt-4 mb-3", children: "This meeting requires a password to join." }), _jsxs("form", { onSubmit: (e) => void handleSubmitPassword(e), children: [_jsx(FormField, { label: "Password", htmlFor: "meet-password", children: _jsx("input", { id: "meet-password", type: "password", autoFocus: true, className: INPUT_CLASS, value: passwordInput, onChange: (e) => setPasswordInput(e.target.value) }) }), passwordError && _jsx(Alert, { children: passwordError }), _jsx(Button, { type: "submit", className: "!w-auto mt-3", disabled: !passwordInput, children: "Join meeting" })] })] }));
|
|
207
|
+
}
|
|
208
|
+
else if (phase === "admission-request") {
|
|
209
|
+
content = (_jsxs(MeetCard, { children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight", children: admissionMeeting?.title }), admissionMeeting?.hostDisplayName && _jsxs("p", { className: "text-sm text-text-muted mt-1", children: ["Hosted by ", admissionMeeting.hostDisplayName] }), _jsx("p", { className: "text-base text-text-muted mt-4 mb-3", children: "The host must let you in before you can join." }), _jsxs("form", { onSubmit: (e) => void handleRequestAdmission(e), children: [_jsx(FormField, { label: "Your name", htmlFor: "admission-name", children: _jsx("input", { id: "admission-name", type: "text", autoFocus: true, className: INPUT_CLASS, value: admissionName, onChange: (e) => setAdmissionName(e.target.value) }) }), admissionMeeting?.hasPassword && (_jsx(FormField, { label: "Password", htmlFor: "admission-password", children: _jsx("input", { id: "admission-password", type: "password", className: `${INPUT_CLASS} mt-3`, value: admissionPassword, onChange: (e) => setAdmissionPassword(e.target.value) }) })), admissionError && _jsx(Alert, { children: admissionError }), _jsx(Button, { type: "submit", className: "!w-auto mt-3", disabled: !admissionName.trim() || (!!admissionMeeting?.hasPassword && !admissionPassword), children: "Ask to join" })] })] }));
|
|
210
|
+
}
|
|
211
|
+
else if (phase === "waiting") {
|
|
212
|
+
content = (_jsxs(MeetCard, { children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight", children: admissionMeeting?.title }), _jsx("p", { className: "text-base text-text-muted mt-4", children: "Waiting for the host to let you in\u2026" })] }));
|
|
213
|
+
}
|
|
214
|
+
else if (phase === "denied") {
|
|
215
|
+
content = (_jsx(MeetCard, { children: _jsx(Alert, { children: admissionError ?? "The host did not admit you to this meeting." }) }));
|
|
216
|
+
}
|
|
105
217
|
else if (phase === "ended") {
|
|
106
|
-
content = (_jsxs(MeetCard, { children: [_jsx("h1", { className: "text-xl font-bold tracking-tight mb-2", children: "You left the meeting" }), _jsx("p", { className: "text-base text-text-muted mb-4", children: "You can close this page now, or rejoin." }), _jsx(Button, { type: "button", className: "!w-auto", onClick: () => setPhase("lobby"), children: "Rejoin meeting" })] }));
|
|
218
|
+
content = (_jsxs(MeetCard, { children: [_jsx("h1", { className: "text-xl font-bold tracking-tight mb-2", children: endedReason ? "Removed from the meeting" : "You left the meeting" }), _jsx("p", { className: "text-base text-text-muted mb-4", children: endedReason ?? "You can close this page now, or rejoin." }), !endedReason && (_jsx(Button, { type: "button", className: "!w-auto", onClick: () => setPhase("lobby"), children: "Rejoin meeting" }))] }));
|
|
107
219
|
}
|
|
108
220
|
else {
|
|
109
221
|
// phase === "lobby" (joinResult is always set by then - see the effect above).
|
|
@@ -10,14 +10,22 @@
|
|
|
10
10
|
* camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`).
|
|
11
11
|
*/
|
|
12
12
|
import React from "react";
|
|
13
|
+
import type { ScreenTransformState } from "../shared/media/filters/ScreenTransform.js";
|
|
13
14
|
import type { LocalMedia } from "../shared/media/useLocalMedia.js";
|
|
15
|
+
import { type MeshParticipant } from "../shared/webrtc/types.js";
|
|
14
16
|
export type CallViewMode = "grid" | "focus";
|
|
15
17
|
export interface CallControlsProps {
|
|
16
18
|
media: LocalMedia;
|
|
19
|
+
selfName: string;
|
|
20
|
+
participants: MeshParticipant[];
|
|
17
21
|
isPresenting: boolean;
|
|
18
22
|
/** Someone else is presenting - the share button is disabled and explains why. */
|
|
19
23
|
presentingElsewhereName?: string;
|
|
20
24
|
onToggleShare: () => void;
|
|
25
|
+
/** The current presenter's own rotate/flip correction - shown (and changeable) only while `isPresenting`. */
|
|
26
|
+
screenTransform: ScreenTransformState;
|
|
27
|
+
onRotateScreen: () => void;
|
|
28
|
+
onFlipScreen: () => void;
|
|
21
29
|
handRaised: boolean;
|
|
22
30
|
onToggleHand: () => void;
|
|
23
31
|
onReaction: (emoji: string) => void;
|
|
@@ -29,4 +37,4 @@ export interface CallControlsProps {
|
|
|
29
37
|
export declare function LevelBars({ level }: {
|
|
30
38
|
level: number;
|
|
31
39
|
}): React.JSX.Element;
|
|
32
|
-
export default function CallControls({ media, isPresenting, presentingElsewhereName, onToggleShare, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, onLeave, }: CallControlsProps): React.JSX.Element;
|
|
40
|
+
export default function CallControls({ media, selfName, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, onLeave, }: CallControlsProps): React.JSX.Element;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
///////////////////////////////////////////////////////////////////////////////
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
@@ -17,8 +17,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
17
17
|
import { useEffect, useRef, useState } from "react";
|
|
18
18
|
import { filtersActive } from "../shared/media/filters/filterTypes.js";
|
|
19
19
|
import { REACTION_EMOJIS } from "../shared/webrtc/types.js";
|
|
20
|
+
import DiagnosticsPanel from "./_DiagnosticsPanel.js";
|
|
20
21
|
import EffectsPanel from "./_EffectsPanel.js";
|
|
21
|
-
import { ChevronUpIcon, EffectsIcon, EmojiIcon, FocusIcon, GridIcon, HandIcon, LeaveIcon, MicIcon, MicOffIcon, ScreenShareIcon, VideoIcon, VideoOffIcon, } from "./_icons.js";
|
|
22
|
+
import { ChevronUpIcon, DiagnosticsIcon, EffectsIcon, EmojiIcon, FlipIcon, FocusIcon, GridIcon, HandIcon, LeaveIcon, MicIcon, MicOffIcon, RotateIcon, ScreenShareIcon, VideoIcon, VideoOffIcon, } from "./_icons.js";
|
|
22
23
|
const BUTTON = "flex items-center justify-center h-12 rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80";
|
|
23
24
|
const NEUTRAL = "bg-[#3c4043] text-white hover:bg-[#4b4f53]";
|
|
24
25
|
const ALERT = "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]";
|
|
@@ -27,7 +28,7 @@ const ACTIVE = "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]";
|
|
|
27
28
|
export function LevelBars({ level }) {
|
|
28
29
|
return (_jsx("span", { className: "flex items-center gap-[2px] h-5", "data-testid": "mic-level", "data-level": level, "aria-hidden": "true", children: [0.6, 1, 0.6].map((weight, i) => (_jsx("span", { className: `w-[3px] rounded-full ${level > 0 ? "bg-[#8ab4f8]" : "bg-white/50"}`, style: { height: `${4 + weight * level * 3}px` } }, i))) }));
|
|
29
30
|
}
|
|
30
|
-
export default function CallControls({ media, isPresenting, presentingElsewhereName, onToggleShare, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, onLeave, }) {
|
|
31
|
+
export default function CallControls({ media, selfName, participants, isPresenting, presentingElsewhereName, onToggleShare, screenTransform, onRotateScreen, onFlipScreen, handRaised, onToggleHand, onReaction, viewMode, onToggleViewMode, onLeave, }) {
|
|
31
32
|
const [openMenu, setOpenMenu] = useState(null);
|
|
32
33
|
const barRef = useRef(null);
|
|
33
34
|
const shareDisabled = !isPresenting && !!presentingElsewhereName;
|
|
@@ -56,10 +57,10 @@ export default function CallControls({ media, isPresenting, presentingElsewhereN
|
|
|
56
57
|
function toggleMenu(menu) {
|
|
57
58
|
setOpenMenu((prev) => (prev === menu ? null : menu));
|
|
58
59
|
}
|
|
59
|
-
return (_jsxs("div", { ref: barRef, className: "relative flex flex-wrap items-center justify-center gap-2 px-3", role: "toolbar", "aria-label": "Call controls", children: [_jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": "Choose microphone", "aria-haspopup": "menu", "aria-expanded": openMenu === "audio", onClick: () => toggleMenu("audio"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": media.micOn ? "Mute microphone" : "Unmute microphone", "aria-pressed": !media.micOn, onClick: () => void media.toggleMic(), children: [media.micOn && _jsx(LevelBars, { level: media.audioLevel }), media.micOn ? _jsx(MicIcon, {}) : _jsx(MicOffIcon, {})] }), openMenu === "audio" && _jsx(DeviceMenu, { kind: "audio", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": "Choose camera", "aria-haspopup": "menu", "aria-expanded": openMenu === "video", onClick: () => toggleMenu("video"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} relative min-w-14 px-3 rounded-l-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": media.cameraOn ? "Turn off camera" : "Turn on camera", "aria-pressed": !media.cameraOn, onClick: () => void media.toggleCamera(), children: [media.cameraOn ? _jsx(VideoIcon, {}) : _jsx(VideoOffIcon, {}), media.cameraOn && (_jsx("span", { className: "absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34a853] animate-pulse", "data-testid": "camera-live", title: "Your camera is sending" }))] }), openMenu === "video" && _jsx(DeviceMenu, { kind: "video", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${filtersActive(media.filters) ? ACTIVE : NEUTRAL}`, "aria-label": "Video effects", "aria-haspopup": "dialog", "aria-expanded": openMenu === "effects", onClick: () => toggleMenu("effects"), children: _jsx(EffectsIcon, {}) }), openMenu === "effects" && (_jsx("div", { role: "dialog", "aria-label": "Video effects", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-96 max-w-[calc(100vw-1rem)] max-h-[60vh] overflow-y-auto p-3 rounded-2xl bg-[#2b2d30] text-white shadow-xl", children: _jsx(EffectsPanel, { media: media, tone: "dark" }) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${isPresenting ? ACTIVE : NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": isPresenting ? "Stop sharing" : "Share screen", "aria-pressed": isPresenting, disabled: shareDisabled, title: shareDisabled ? `${presentingElsewhereName} is presenting - stop their share to present yourself.` : undefined, onClick: onToggleShare, children: _jsx(ScreenShareIcon, {}) }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": "Send a reaction", "aria-haspopup": "menu", "aria-expanded": openMenu === "emoji", onClick: () => toggleMenu("emoji"), children: _jsx(EmojiIcon, {}) }), openMenu === "emoji" && (_jsx("div", { role: "menu", "aria-label": "Reactions", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-max grid grid-cols-4 gap-1 p-2 rounded-2xl bg-[#2b2d30] shadow-xl", children: REACTION_EMOJIS.map((emoji) => (_jsx("button", { type: "button", role: "menuitem", className: "w-11 h-11 rounded-lg text-2xl hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Send ${emoji}`, onClick: () => {
|
|
60
|
+
return (_jsxs("div", { ref: barRef, className: "relative flex flex-wrap items-center justify-center gap-2 px-3", role: "toolbar", "aria-label": "Call controls", children: [_jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": "Choose microphone", "aria-haspopup": "menu", "aria-expanded": openMenu === "audio", onClick: () => toggleMenu("audio"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": media.micOn ? "Mute microphone" : "Unmute microphone", "aria-pressed": !media.micOn, onClick: () => void media.toggleMic(), children: [media.micOn && _jsx(LevelBars, { level: media.audioLevel }), media.micOn ? _jsx(MicIcon, {}) : _jsx(MicOffIcon, {})] }), openMenu === "audio" && _jsx(DeviceMenu, { kind: "audio", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": "Choose camera", "aria-haspopup": "menu", "aria-expanded": openMenu === "video", onClick: () => toggleMenu("video"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} relative min-w-14 px-3 rounded-l-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": media.cameraOn ? "Turn off camera" : "Turn on camera", "aria-pressed": !media.cameraOn, onClick: () => void media.toggleCamera(), children: [media.cameraOn ? _jsx(VideoIcon, {}) : _jsx(VideoOffIcon, {}), media.cameraOn && (_jsx("span", { className: "absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34a853] animate-pulse", "data-testid": "camera-live", title: "Your camera is sending" }))] }), openMenu === "video" && _jsx(DeviceMenu, { kind: "video", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${filtersActive(media.filters) ? ACTIVE : NEUTRAL}`, "aria-label": "Video effects", "aria-haspopup": "dialog", "aria-expanded": openMenu === "effects", onClick: () => toggleMenu("effects"), children: _jsx(EffectsIcon, {}) }), openMenu === "effects" && (_jsx("div", { role: "dialog", "aria-label": "Video effects", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-96 max-w-[calc(100vw-1rem)] max-h-[60vh] overflow-y-auto p-3 rounded-2xl bg-[#2b2d30] text-white shadow-xl", children: _jsx(EffectsPanel, { media: media, tone: "dark" }) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${isPresenting ? ACTIVE : NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": isPresenting ? "Stop sharing" : "Share screen", "aria-pressed": isPresenting, disabled: shareDisabled, title: shareDisabled ? `${presentingElsewhereName} is presenting - stop their share to present yourself.` : undefined, onClick: onToggleShare, children: _jsx(ScreenShareIcon, {}) }), isPresenting && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": "Rotate shared screen", title: "If your shared window looks sideways or upside down to everyone, rotate it here - this is a correction for your own capture, not a browser bug this app can fix automatically.", onClick: onRotateScreen, children: _jsx(RotateIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${screenTransform.flipped ? ACTIVE : NEUTRAL}`, "aria-label": screenTransform.flipped ? "Unflip shared screen" : "Flip shared screen", "aria-pressed": screenTransform.flipped, onClick: onFlipScreen, children: _jsx(FlipIcon, {}) })] })), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": "Send a reaction", "aria-haspopup": "menu", "aria-expanded": openMenu === "emoji", onClick: () => toggleMenu("emoji"), children: _jsx(EmojiIcon, {}) }), openMenu === "emoji" && (_jsx("div", { role: "menu", "aria-label": "Reactions", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-max grid grid-cols-4 gap-1 p-2 rounded-2xl bg-[#2b2d30] shadow-xl", children: REACTION_EMOJIS.map((emoji) => (_jsx("button", { type: "button", role: "menuitem", className: "w-11 h-11 rounded-lg text-2xl hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Send ${emoji}`, onClick: () => {
|
|
60
61
|
onReaction(emoji);
|
|
61
62
|
setOpenMenu(null);
|
|
62
|
-
}, children: emoji }, emoji))) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${handRaised ? ACTIVE : NEUTRAL}`, "aria-label": handRaised ? "Lower hand" : "Raise hand", "aria-pressed": handRaised, onClick: onToggleHand, children: _jsx(HandIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": viewMode === "grid" ? "Switch to focused view" : "Switch to grid view", onClick: onToggleViewMode, children: viewMode === "grid" ? _jsx(FocusIcon, {}) : _jsx(GridIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`, "aria-label": "Leave call", onClick: onLeave, children: _jsx(LeaveIcon, {}) })] }));
|
|
63
|
+
}, children: emoji }, emoji))) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${handRaised ? ACTIVE : NEUTRAL}`, "aria-label": handRaised ? "Lower hand" : "Raise hand", "aria-pressed": handRaised, onClick: onToggleHand, children: _jsx(HandIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": viewMode === "grid" ? "Switch to focused view" : "Switch to grid view", onClick: onToggleViewMode, children: viewMode === "grid" ? _jsx(FocusIcon, {}) : _jsx(GridIcon, {}) }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${openMenu === "diagnostics" ? ACTIVE : NEUTRAL}`, "aria-label": "Call diagnostics", "aria-haspopup": "dialog", "aria-expanded": openMenu === "diagnostics", onClick: () => toggleMenu("diagnostics"), children: _jsx(DiagnosticsIcon, {}) }), openMenu === "diagnostics" && (_jsx("div", { role: "dialog", "aria-label": "Call diagnostics", className: "absolute bottom-full mb-3 right-0 w-80 max-w-[calc(100vw-1rem)] max-h-[60vh] overflow-y-auto p-3 rounded-2xl bg-[#2b2d30] text-white shadow-xl", children: _jsx(DiagnosticsPanel, { selfName: selfName, micOn: media.micOn, cameraOn: media.cameraOn, participants: participants }) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`, "aria-label": "Leave call", onClick: () => onLeave(), children: _jsx(LeaveIcon, {}) })] }));
|
|
63
64
|
}
|
|
64
65
|
/** The device picker opened from the chevron beside the microphone/camera button: the available devices with the
|
|
65
66
|
* one in use ticked, or - while there are none to list - why, with a way to ask for access again. */
|
|
@@ -45,9 +45,29 @@ export interface CallViewProps {
|
|
|
45
45
|
/** Whether the server offers the WebSocket media relay, the last-resort path for a participant neither a direct
|
|
46
46
|
* connection nor the TURN server can reach - `VideoMeetingJoinResult.relayEnabled`. Off when absent. */
|
|
47
47
|
relayEnabled?: boolean;
|
|
48
|
+
/** The meeting's host account uid (`PublicVideoMeeting.hostUid`) - this client's only source of a "host"
|
|
49
|
+
* identity, purely for deciding which controls to show (see `BaseVideoMeetingRoute`'s class doc comment on why
|
|
50
|
+
* this is a client-side-only check, not a server-enforced one for every moderation action). Absent when the
|
|
51
|
+
* server couldn't resolve one, in which case nobody sees host controls. */
|
|
52
|
+
hostUid?: string;
|
|
53
|
+
/** `PublicVideoMeeting.forceMuteOnJoin` as of this call's own `join()` - the starting value for the host's
|
|
54
|
+
* toggle in the participants drawer, which this view then owns and keeps current itself (see
|
|
55
|
+
* `handleToggleForceMuteOnJoin()`). Does not update if changed elsewhere while this view is mounted - there is
|
|
56
|
+
* no signal for that today, matching every other meeting-settings field's lack of live sync. */
|
|
57
|
+
initialForceMuteOnJoin?: boolean;
|
|
58
|
+
/** `PublicVideoMeeting.hasPassword` as of this call's own `join()` - the starting value for the host's password
|
|
59
|
+
* section in the participants drawer, same "owned and kept current by this view" shape as
|
|
60
|
+
* `initialForceMuteOnJoin`. */
|
|
61
|
+
initialHasPassword?: boolean;
|
|
62
|
+
/** `PublicVideoMeeting.waitingRoomEnabled` as of this call's own `join()` - the starting value for the host's
|
|
63
|
+
* waiting-room toggle, same "owned and kept current by this view" shape as `initialForceMuteOnJoin`. */
|
|
64
|
+
initialWaitingRoomEnabled?: boolean;
|
|
48
65
|
/** The camera and microphone, owned by the page (`[token].tsx`) - the lobby's tracks carried into the call. */
|
|
49
66
|
media: LocalMedia;
|
|
50
|
-
|
|
67
|
+
/** Called once the participant leaves, for any reason. `reason` is set only when the call ended without the
|
|
68
|
+
* participant's own action - currently, being kicked by the host - so the caller can show a distinct message
|
|
69
|
+
* instead of the ordinary "you left" one; omitted for an ordinary voluntary leave. */
|
|
70
|
+
onLeave: (reason?: string) => void;
|
|
51
71
|
}
|
|
52
72
|
/** Who is shown large - see this module's doc comment. `undefined` when nobody but the local participant has
|
|
53
73
|
* joined yet (the grid/focus toggle still works, there's just nobody else to focus on). */
|
|
@@ -61,4 +81,10 @@ export declare function computeMainUid(options: {
|
|
|
61
81
|
* from two devices - or two tabs - is two participants rather than one that ignores itself. It travels as the
|
|
62
82
|
* `peer` of each message; `from` stays the exact authenticated uid, which the server requires. */
|
|
63
83
|
export declare function newPeerId(selfUid: string): string;
|
|
64
|
-
|
|
84
|
+
/** The real, server-enforced account uid behind a participant's tab-scoped `MeshParticipant.uid` (`newPeerId()`'s
|
|
85
|
+
* `<account uid>~<random>` shape) - what the kick route's ACL grant is actually keyed by, unlike the tab-scoped id
|
|
86
|
+
* this view otherwise addresses a specific tab with. Neither a real account uid nor a minted guest uid (see
|
|
87
|
+
* `GUEST_UID_PREFIX`/`mintGuestToken()` on the backend) can itself contain "~", so the last one found is always
|
|
88
|
+
* exactly this suffix's own separator. */
|
|
89
|
+
export declare function accountUidOf(peerUid: string): string;
|
|
90
|
+
export default function CallView({ channel, token, selfUid, selfName, meetingTitle, iceServers, relayEnabled, hostUid, initialForceMuteOnJoin, initialHasPassword, initialWaitingRoomEnabled, media, onLeave, }: CallViewProps): React.JSX.Element;
|