@tribe-nest/forge 3.29.0 → 3.31.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.
Files changed (51) hide show
  1. package/package.json +6 -3
  2. package/src/_tests/publishedResolvability.spec.ts +184 -0
  3. package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
  4. package/src/_tests/workspaceAliases.ts +40 -0
  5. package/src/contexts/PublicAuthContext.tsx +34 -5
  6. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  7. package/src/data/queries/useBroadcasts.ts +151 -0
  8. package/src/data/queries/useMyBookings.ts +9 -1
  9. package/src/i18n/de.json +59 -0
  10. package/src/i18n/en.json +59 -0
  11. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  12. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  13. package/src/ui/format/membershipPwyw.ts +164 -0
  14. package/src/ui/format/pwyw.ts +37 -0
  15. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  16. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  17. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  18. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  19. package/src/ui/headless/index.ts +14 -0
  20. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  21. package/src/ui/index.ts +36 -0
  22. package/src/ui/media/CallHelpHint.tsx +87 -0
  23. package/src/ui/media/CallStage.tsx +542 -0
  24. package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
  25. package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
  26. package/src/ui/media/_tests/callState.spec.ts +452 -0
  27. package/src/ui/media/_tests/fakeNode.ts +178 -0
  28. package/src/ui/media/bookingSession.tsx +194 -0
  29. package/src/ui/media/callState.ts +341 -0
  30. package/src/ui/media/index.ts +135 -0
  31. package/src/ui/styled/AccountDashboard.tsx +92 -3
  32. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  33. package/src/ui/styled/ForgotPasswordForm.tsx +5 -0
  34. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  35. package/src/ui/styled/LoginForm.tsx +10 -0
  36. package/src/ui/styled/MembershipCheckout.tsx +318 -45
  37. package/src/ui/styled/MembershipTiers.tsx +10 -3
  38. package/src/ui/styled/ResetPasswordForm.tsx +5 -0
  39. package/src/ui/styled/SignupForm.tsx +5 -0
  40. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  41. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  42. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  43. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  44. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  45. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  46. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  47. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  48. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  49. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  50. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  51. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
@@ -0,0 +1,87 @@
1
+ import { useId, useState, type ReactNode } from "react";
2
+ import { HelpCircle } from "lucide-react";
3
+
4
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
5
+
6
+ /**
7
+ * The `?` that carries an explanation, instead of a paragraph that always shows.
8
+ *
9
+ * House design law: if a piece of text exists to EXPLAIN something, it goes
10
+ * behind a `?`. A call screen is the worst possible place to break that rule,
11
+ * because it is looked at while something is happening and every sentence that
12
+ * is not about right now is a sentence in the way.
13
+ *
14
+ * ## What is NOT help, and stays visible on this surface
15
+ *
16
+ * - the connection state, and what went wrong when it went wrong
17
+ * - a denied camera or microphone permission
18
+ * - the recording indicator, which is a consent signal
19
+ *
20
+ * ## Why Forge grows its own rather than importing `HelpHint`
21
+ *
22
+ * `@tribe-nest/frontend-shared` is not a dependency of this package and cannot
23
+ * become one: Forge publishes raw source and a creator site installs only
24
+ * Forge's declared dependencies, so an import of it would resolve here (npm
25
+ * workspaces symlink everything) and fail on every real site. See
26
+ * `_tests/publishedResolvability.spec.ts`. This is a disclosure built from a
27
+ * button and a span, which needs nothing.
28
+ */
29
+ export type CallHelpHintProps = {
30
+ /** Accessible name. Say what it explains ("What does sharing send?"), never "help". */
31
+ label: string;
32
+ children: ReactNode;
33
+ };
34
+
35
+ export function CallHelpHint({ label, children }: CallHelpHintProps) {
36
+ const tokens = useThemeTokens();
37
+ const [open, setOpen] = useState(false);
38
+ const panelId = useId();
39
+
40
+ return (
41
+ <span style={{ position: "relative", display: "inline-flex", verticalAlign: "middle" }}>
42
+ <button
43
+ type="button"
44
+ aria-label={label}
45
+ aria-expanded={open}
46
+ aria-controls={panelId}
47
+ onClick={() => setOpen((was) => !was)}
48
+ style={{
49
+ display: "inline-flex",
50
+ alignItems: "center",
51
+ justifyContent: "center",
52
+ background: "transparent",
53
+ border: "none",
54
+ padding: 0,
55
+ cursor: "pointer",
56
+ color: tokens.muted,
57
+ }}
58
+ >
59
+ <HelpCircle width={14} height={14} aria-hidden="true" />
60
+ </button>
61
+ {open && (
62
+ <span
63
+ id={panelId}
64
+ role="note"
65
+ style={{
66
+ position: "absolute",
67
+ bottom: "calc(100% + 6px)",
68
+ left: 0,
69
+ zIndex: 20,
70
+ width: "16rem",
71
+ padding: "0.5rem 0.625rem",
72
+ borderRadius: tokens.cornerRadius,
73
+ border: `1px solid ${tokens.border}`,
74
+ backgroundColor: tokens.surface,
75
+ color: tokens.text,
76
+ fontSize: "0.75rem",
77
+ lineHeight: 1.5,
78
+ textAlign: "left",
79
+ fontWeight: 400,
80
+ }}
81
+ >
82
+ {children}
83
+ </span>
84
+ )}
85
+ </span>
86
+ );
87
+ }
@@ -0,0 +1,542 @@
1
+ import { useCallback, useEffect, useMemo, useRef, type CSSProperties, type ReactNode } from "react";
2
+ import {
3
+ AlertTriangle,
4
+ Loader2,
5
+ Mic,
6
+ MicOff,
7
+ MonitorUp,
8
+ MonitorX,
9
+ PhoneOff,
10
+ RefreshCw,
11
+ Video,
12
+ VideoOff,
13
+ } from "lucide-react";
14
+
15
+ import {
16
+ useActiveSpeakers,
17
+ useGrants,
18
+ useLocalMedia,
19
+ useLocalPublications,
20
+ useMediaRoom,
21
+ useParticipants,
22
+ useRecording,
23
+ useRemoteTrack,
24
+ useRoomState,
25
+ useVisibleProducers,
26
+ } from "@tribe-nest/media-client/react";
27
+
28
+ import { useThemeTokens, type ResolvedThemeTokens } from "../theme/ForgeThemeProvider";
29
+ import { CallHelpHint } from "./CallHelpHint";
30
+ import {
31
+ callHeadCount,
32
+ callStatus,
33
+ callTiles,
34
+ canPublishSource,
35
+ remoteAudioProducerIds,
36
+ type CallStatus,
37
+ type CallTile,
38
+ } from "./callState";
39
+
40
+ /**
41
+ * The call, as a screen. One implementation, both surfaces.
42
+ *
43
+ * Everything above this file was reachable only from a test: the room, the
44
+ * hooks and `BookingCallProvider` all existed and nothing rendered a call, so
45
+ * no human could join one. This is the part that makes it reachable, and it is
46
+ * built ONCE here because there are two rendering surfaces (admin, and code
47
+ * websites through Forge) and a second implementation would mean every fix to
48
+ * the ordering rules had to be made twice. The second copy is always the one
49
+ * that drifts.
50
+ *
51
+ * ## Where the colours come from
52
+ *
53
+ * `useThemeTokens()`, which on a code website is the creator's own
54
+ * `themeSettings` fed through `<ForgeThemeProvider>`, and in admin is the
55
+ * dashboard's own tokens fed through the same provider. Not one colour is
56
+ * written down in this file. A call screen with a hard-coded palette is a call
57
+ * screen that looks like somebody else's product on every site that installs
58
+ * it.
59
+ *
60
+ * ## What it does NOT decide
61
+ *
62
+ * Whether a control may be used. `useGrants()` says what the node minted this
63
+ * token for, and it is read here so a button that would be refused is not
64
+ * offered; the node checks the same thing on the publish itself and its answer
65
+ * is the only one that counts. Hiding a control is courtesy, never enforcement.
66
+ *
67
+ * ## Mount it behind a Join control
68
+ *
69
+ * The provider connects on mount and closes on unmount. Rendering the whole
70
+ * thing inside a list of sessions would open a media connection for every row.
71
+ */
72
+
73
+ export type CallStageProps = {
74
+ /**
75
+ * Called once the local participant has left. The host is expected to unmount
76
+ * the provider: this component closes the room, but a provider left mounted
77
+ * would reconnect on the next render.
78
+ */
79
+ onLeave?: () => void;
80
+ /** Whose call this is. Rendered above the grid. */
81
+ title?: ReactNode;
82
+ /** Ratio for each tile. Defaults to 16 / 9. */
83
+ aspectRatio?: number;
84
+ };
85
+
86
+ export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStageProps) {
87
+ const tokens = useThemeTokens();
88
+ const { connectionState, error, recovering, retry } = useMediaRoom();
89
+ const state = useRoomState();
90
+ const peers = useParticipants();
91
+ const activeSpeakers = useActiveSpeakers();
92
+ const recording = useRecording();
93
+ /**
94
+ * The barrier, applied. NOT `state.producers`.
95
+ *
96
+ * The reducer keeps every producer the node has ever announced so a subscribe
97
+ * rule that narrows and later widens is reversible, which means the raw list
98
+ * can hold people this seat has been barred from seeing. On a sealed room
99
+ * their presence is most of what the barrier was hiding, and a tile carries
100
+ * their identity.
101
+ */
102
+ const producers = useVisibleProducers();
103
+
104
+ const status = useMemo(
105
+ () => callStatus({ connectionState, error, recovering, phase: state.phase, closedReason: state.closedReason }),
106
+ [connectionState, error, recovering, state.phase, state.closedReason],
107
+ );
108
+
109
+ // Memoised on the room's own snapshots, every one of which is a stable
110
+ // reference while nothing changed. Rebuilding these per render is what turns
111
+ // a call screen into a component that re-renders per FRAME.
112
+ const tiles = useMemo(
113
+ () => callTiles({ identity: state.identity, peers, producers, activeSpeakers }),
114
+ [state.identity, peers, producers, activeSpeakers],
115
+ );
116
+ const audioProducerIds = useMemo(
117
+ () => remoteAudioProducerIds({ identity: state.identity, producers }),
118
+ [state.identity, producers],
119
+ );
120
+
121
+ return (
122
+ <div
123
+ style={{
124
+ display: "flex",
125
+ flexDirection: "column",
126
+ gap: "0.75rem",
127
+ padding: "1rem",
128
+ borderRadius: tokens.cornerRadius,
129
+ border: `1px solid ${tokens.border}`,
130
+ backgroundColor: tokens.background,
131
+ color: tokens.text,
132
+ fontFamily: tokens.fontFamily,
133
+ }}
134
+ >
135
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "0.75rem", flexWrap: "wrap" }}>
136
+ {title ? <div style={{ fontWeight: 600, fontFamily: tokens.headingFontFamily }}>{title}</div> : <span />}
137
+ <div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
138
+ {recording && <RecordingIndicator tokens={tokens} />}
139
+ <ConnectionStatus status={status} tokens={tokens} onRetry={retry} />
140
+ </div>
141
+ </div>
142
+
143
+ <div style={{ display: "flex", alignItems: "center", gap: "0.375rem", fontSize: "0.75rem", color: tokens.muted }}>
144
+ <span>
145
+ In this call: {callHeadCount(tiles)}
146
+ </span>
147
+ <CallHelpHint label="Why can I hear someone but not see them?">
148
+ A tile with a name and no picture means that person has their camera off, or the call is busy enough that only
149
+ the people currently speaking are sent in full. Both are normal, and their audio is unaffected.
150
+ </CallHelpHint>
151
+ </div>
152
+
153
+ <div
154
+ style={{
155
+ display: "grid",
156
+ gridTemplateColumns: "repeat(auto-fit, minmax(12rem, 1fr))",
157
+ gap: "0.75rem",
158
+ }}
159
+ >
160
+ <LocalPreview tokens={tokens} aspectRatio={aspectRatio} />
161
+ {tiles.map((tile) => (
162
+ <ParticipantTile key={tile.key} tile={tile} tokens={tokens} aspectRatio={aspectRatio} />
163
+ ))}
164
+ </div>
165
+
166
+ {audioProducerIds.map((producerId) => (
167
+ <RemoteAudio key={producerId} producerId={producerId} />
168
+ ))}
169
+
170
+ <CallControls tokens={tokens} onLeave={onLeave} />
171
+ </div>
172
+ );
173
+ }
174
+
175
+ /**
176
+ * The connection, said plainly.
177
+ *
178
+ * Four different words for four different situations, because "connecting",
179
+ * "reconnecting" and "we were refused" call for three different responses and a
180
+ * single spinner tells you which of them you are in exactly never.
181
+ */
182
+ function ConnectionStatus({
183
+ status,
184
+ tokens,
185
+ onRetry,
186
+ }: {
187
+ status: CallStatus;
188
+ tokens: ResolvedThemeTokens;
189
+ onRetry: () => void;
190
+ }) {
191
+ const busy = status.tone === "connecting" || status.tone === "reconnecting";
192
+ const alarming = status.tone === "failed";
193
+
194
+ return (
195
+ <div
196
+ role="status"
197
+ aria-live="polite"
198
+ style={{
199
+ display: "flex",
200
+ alignItems: "center",
201
+ gap: "0.5rem",
202
+ fontSize: "0.8125rem",
203
+ color: alarming ? tokens.text : tokens.muted,
204
+ }}
205
+ >
206
+ {busy && <Loader2 width={14} height={14} aria-hidden="true" />}
207
+ {alarming && <AlertTriangle width={14} height={14} aria-hidden="true" color={tokens.secondary} />}
208
+ {status.tone === "live" && (
209
+ <span
210
+ aria-hidden="true"
211
+ style={{ width: 8, height: 8, borderRadius: 9999, backgroundColor: tokens.primary, display: "inline-block" }}
212
+ />
213
+ )}
214
+ <span style={{ fontWeight: alarming ? 600 : 400 }}>{status.headline}</span>
215
+ {status.detail && <span style={{ color: tokens.muted }}>{status.detail}</span>}
216
+ {status.canRetry && (
217
+ <button
218
+ type="button"
219
+ onClick={onRetry}
220
+ style={{
221
+ display: "inline-flex",
222
+ alignItems: "center",
223
+ gap: "0.25rem",
224
+ border: `1px solid ${tokens.border}`,
225
+ borderRadius: tokens.cornerRadius,
226
+ backgroundColor: "transparent",
227
+ color: tokens.text,
228
+ padding: "0.125rem 0.5rem",
229
+ cursor: "pointer",
230
+ font: "inherit",
231
+ fontSize: "0.75rem",
232
+ }}
233
+ >
234
+ <RefreshCw width={12} height={12} aria-hidden="true" />
235
+ Try again
236
+ </button>
237
+ )}
238
+ </div>
239
+ );
240
+ }
241
+
242
+ /** Somebody is recording. A consent signal, so it is content and always shown. */
243
+ function RecordingIndicator({ tokens }: { tokens: ResolvedThemeTokens }) {
244
+ return (
245
+ <span
246
+ style={{
247
+ display: "inline-flex",
248
+ alignItems: "center",
249
+ gap: "0.375rem",
250
+ fontSize: "0.75rem",
251
+ fontWeight: 600,
252
+ color: tokens.text,
253
+ border: `1px solid ${tokens.border}`,
254
+ borderRadius: tokens.cornerRadius,
255
+ padding: "0.125rem 0.5rem",
256
+ }}
257
+ >
258
+ <span
259
+ aria-hidden="true"
260
+ style={{ width: 8, height: 8, borderRadius: 9999, backgroundColor: tokens.secondary, display: "inline-block" }}
261
+ />
262
+ This call is being recorded
263
+ </span>
264
+ );
265
+ }
266
+
267
+ const tileFrame = (tokens: ResolvedThemeTokens, aspectRatio: number, speaking: boolean): CSSProperties => ({
268
+ position: "relative",
269
+ overflow: "hidden",
270
+ aspectRatio: String(aspectRatio),
271
+ borderRadius: tokens.cornerRadius,
272
+ backgroundColor: tokens.surface,
273
+ // The active speaker is shown by the FRAME rather than by re-ordering the
274
+ // grid: a grid that reshuffles itself every time somebody says "mm" is
275
+ // unusable, and the person you were looking at moves out from under the eye.
276
+ border: `2px solid ${speaking ? tokens.primary : tokens.border}`,
277
+ display: "flex",
278
+ alignItems: "center",
279
+ justifyContent: "center",
280
+ });
281
+
282
+ const tileName = (tokens: ResolvedThemeTokens): CSSProperties => ({
283
+ position: "absolute",
284
+ left: "0.5rem",
285
+ bottom: "0.5rem",
286
+ maxWidth: "calc(100% - 1rem)",
287
+ overflow: "hidden",
288
+ textOverflow: "ellipsis",
289
+ whiteSpace: "nowrap",
290
+ fontSize: "0.75rem",
291
+ padding: "0.125rem 0.375rem",
292
+ borderRadius: tokens.cornerRadius,
293
+ backgroundColor: tokens.background,
294
+ color: tokens.text,
295
+ });
296
+
297
+ /**
298
+ * One participant.
299
+ *
300
+ * A tile with no video renders the NAME, at size, and keeps its place in the
301
+ * grid. That is the whole point: somebody with the camera off has not left, and
302
+ * a grid that drops them re-flows under everyone else's eyes the moment one
303
+ * person turns a camera off.
304
+ */
305
+ function ParticipantTile({
306
+ tile,
307
+ tokens,
308
+ aspectRatio,
309
+ }: {
310
+ tile: CallTile;
311
+ tokens: ResolvedThemeTokens;
312
+ aspectRatio: number;
313
+ }) {
314
+ // `useRemoteTrack` takes a producer id unconditionally so the hook order is
315
+ // the same for a tile with video and a tile without. An id that matches
316
+ // nothing simply returns no track, which is the state we want anyway.
317
+ const { track, attach } = useRemoteTrack(tile.videoProducerId ?? "");
318
+ const showVideo = !!tile.videoProducerId && !!track && !tile.videoPaused;
319
+
320
+ return (
321
+ <div
322
+ style={tileFrame(tokens, aspectRatio, tile.isSpeaking)}
323
+ data-testid={`tile-${tile.identity}`}
324
+ // The active set as an attribute as well as a border: a site restyling
325
+ // the grid needs the fact, and a colour is not something a test can
326
+ // assert without pinning a palette this component deliberately has none of.
327
+ data-speaking={tile.isSpeaking ? "true" : "false"}
328
+ >
329
+ {showVideo ? (
330
+ <video
331
+ ref={attach}
332
+ autoPlay
333
+ playsInline
334
+ muted
335
+ style={{ width: "100%", height: "100%", objectFit: "cover" }}
336
+ />
337
+ ) : (
338
+ <span style={{ fontSize: "0.9375rem", fontWeight: 600, color: tokens.text, padding: "0 0.75rem", textAlign: "center" }}>
339
+ {tile.name}
340
+ </span>
341
+ )}
342
+ <span style={tileName(tokens)}>
343
+ {tile.name}
344
+ {!tile.hasAudio && <span style={{ color: tokens.muted }}> (listening)</span>}
345
+ </span>
346
+ </div>
347
+ );
348
+ }
349
+
350
+ /**
351
+ * Remote audio, attached and never drawn.
352
+ *
353
+ * Audio has to live on an element or the call is silent. It is deliberately NOT
354
+ * attached to the participant's `<video>`: a tile whose camera is off has no
355
+ * video element, and hanging the audio off one would mute whoever turned their
356
+ * camera off.
357
+ */
358
+ function RemoteAudio({ producerId }: { producerId: string }) {
359
+ const { attach } = useRemoteTrack(producerId);
360
+ return <audio ref={attach} autoPlay style={{ display: "none" }} />;
361
+ }
362
+
363
+ /** This viewer, as they are being sent. Muted, or it is a feedback loop. */
364
+ function LocalPreview({ tokens, aspectRatio }: { tokens: ResolvedThemeTokens; aspectRatio: number }) {
365
+ const state = useRoomState();
366
+ const publications = useLocalPublications();
367
+ const videoRef = useRef<HTMLVideoElement | null>(null);
368
+
369
+ const camera = useMemo(() => publications.find((p) => p.source === "camera"), [publications]);
370
+ const track = camera?.track;
371
+
372
+ useEffect(() => {
373
+ const element = videoRef.current;
374
+ if (!element) return;
375
+ // Rebuilt only when the TRACK changes. Doing this in a ref callback would
376
+ // build a new `MediaStream` on every render, which is the per-frame churn
377
+ // this component exists to avoid.
378
+ element.srcObject = track ? new MediaStream([track]) : null;
379
+ return () => {
380
+ element.srcObject = null;
381
+ };
382
+ }, [track]);
383
+
384
+ const name = state.identity ? "You" : "You (not connected yet)";
385
+
386
+ return (
387
+ <div style={tileFrame(tokens, aspectRatio, false)} data-testid="tile-local">
388
+ {track ? (
389
+ <video
390
+ ref={videoRef}
391
+ autoPlay
392
+ playsInline
393
+ muted
394
+ style={{ width: "100%", height: "100%", objectFit: "cover" }}
395
+ />
396
+ ) : (
397
+ <span style={{ fontSize: "0.9375rem", fontWeight: 600, color: tokens.text }}>{name}</span>
398
+ )}
399
+ <span style={tileName(tokens)}>{name}</span>
400
+ </div>
401
+ );
402
+ }
403
+
404
+ const controlButton = (tokens: ResolvedThemeTokens, active: boolean, busy = false): CSSProperties => ({
405
+ display: "inline-flex",
406
+ alignItems: "center",
407
+ gap: "0.375rem",
408
+ padding: "0.5rem 0.75rem",
409
+ borderRadius: tokens.cornerRadius,
410
+ border: `1px solid ${active ? tokens.primary : tokens.border}`,
411
+ backgroundColor: active ? tokens.primary : "transparent",
412
+ color: active ? tokens.textPrimary : tokens.text,
413
+ cursor: busy ? "progress" : "pointer",
414
+ opacity: busy ? 0.6 : 1,
415
+ font: "inherit",
416
+ fontSize: "0.8125rem",
417
+ });
418
+
419
+ /**
420
+ * Microphone, camera, screen and leave.
421
+ *
422
+ * Each toggle is drawn only when the token was minted with the grant for it, so
423
+ * a listen-only seat is not offered a microphone it would be refused. That is a
424
+ * rendering decision; the node refuses the publish itself either way.
425
+ *
426
+ * ## Why every toggle is disabled while it is working
427
+ *
428
+ * Each of these is a read-then-write: it reads whether the source is live and
429
+ * publishes or unpublishes accordingly. The flag it reads does not move until
430
+ * the publish has resolved, and a publish starts with a permission prompt that
431
+ * can sit on screen for seconds - so a button that looks untouched is a button
432
+ * a person presses again, and two presses used to mean two producers from one
433
+ * identity and everyone else hearing them doubled. `useLocalMedia` holds the
434
+ * real lock (a synchronous ref, since both clicks land before React re-renders
435
+ * anything); this is the part that says so on screen, because a control that
436
+ * silently ignores you is only marginally better than one that misbehaves.
437
+ */
438
+ function CallControls({ tokens, onLeave }: { tokens: ResolvedThemeTokens; onLeave?: () => void }) {
439
+ const grants = useGrants();
440
+ const { room } = useMediaRoom();
441
+ const media = useLocalMedia();
442
+
443
+ // Plain handlers, deliberately not `useCallback`. `useLocalMedia()` returns a
444
+ // fresh object every render, so memoising on it would rebuild the callback
445
+ // every render anyway while looking as though it did not.
446
+ const toggleMicrophone = () => {
447
+ void (media.isMicrophoneEnabled ? media.unpublish("microphone") : media.publishMicrophone());
448
+ };
449
+ const toggleCamera = () => {
450
+ void (media.isCameraEnabled ? media.unpublish("camera") : media.publishCamera());
451
+ };
452
+ const toggleScreen = () => {
453
+ void (media.screenSharing ? media.unpublish("screen") : media.publishScreen());
454
+ };
455
+
456
+ const leave = useCallback(() => {
457
+ // Closed here AND unmounted by the host. Closing alone would leave a
458
+ // provider mounted that reconnects on its next render; unmounting alone
459
+ // relies on a cleanup running before the person walks away from the laptop.
460
+ void room?.close().catch(() => undefined);
461
+ onLeave?.();
462
+ }, [room, onLeave]);
463
+
464
+ return (
465
+ <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "0.5rem" }}>
466
+ {canPublishSource(grants, "microphone") && (
467
+ <button
468
+ type="button"
469
+ onClick={toggleMicrophone}
470
+ disabled={media.pending.microphone}
471
+ aria-busy={media.pending.microphone}
472
+ style={controlButton(tokens, media.isMicrophoneEnabled, media.pending.microphone)}
473
+ >
474
+ {media.isMicrophoneEnabled ? (
475
+ <Mic width={16} height={16} aria-hidden="true" />
476
+ ) : (
477
+ <MicOff width={16} height={16} aria-hidden="true" />
478
+ )}
479
+ {media.isMicrophoneEnabled ? "Mute" : "Unmute"}
480
+ </button>
481
+ )}
482
+
483
+ {canPublishSource(grants, "camera") && (
484
+ <button
485
+ type="button"
486
+ onClick={toggleCamera}
487
+ disabled={media.pending.camera}
488
+ aria-busy={media.pending.camera}
489
+ style={controlButton(tokens, media.isCameraEnabled, media.pending.camera)}
490
+ >
491
+ {media.isCameraEnabled ? (
492
+ <Video width={16} height={16} aria-hidden="true" />
493
+ ) : (
494
+ <VideoOff width={16} height={16} aria-hidden="true" />
495
+ )}
496
+ {media.isCameraEnabled ? "Stop camera" : "Start camera"}
497
+ </button>
498
+ )}
499
+
500
+ {canPublishSource(grants, "screen") && (
501
+ <span style={{ display: "inline-flex", alignItems: "center", gap: "0.25rem" }}>
502
+ <button
503
+ type="button"
504
+ onClick={toggleScreen}
505
+ disabled={media.pending.screen}
506
+ aria-busy={media.pending.screen}
507
+ style={controlButton(tokens, media.screenSharing, media.pending.screen)}
508
+ >
509
+ {media.screenSharing ? (
510
+ <MonitorX width={16} height={16} aria-hidden="true" />
511
+ ) : (
512
+ <MonitorUp width={16} height={16} aria-hidden="true" />
513
+ )}
514
+ {media.screenSharing ? "Stop sharing" : "Share screen"}
515
+ </button>
516
+ <CallHelpHint label="What does sharing my screen send?">
517
+ Whatever you pick in the browser prompt, live, to everyone in the call. Your camera keeps running alongside
518
+ it rather than being replaced, so people can still see you while you present.
519
+ </CallHelpHint>
520
+ </span>
521
+ )}
522
+
523
+ <button
524
+ type="button"
525
+ onClick={leave}
526
+ style={{ ...controlButton(tokens, false), marginLeft: "auto", borderColor: tokens.secondary }}
527
+ >
528
+ <PhoneOff width={16} height={16} aria-hidden="true" />
529
+ Leave
530
+ </button>
531
+
532
+ {/* A denied permission prompt is something the person has to be told
533
+ about without asking, so it stays visible rather than going behind
534
+ the `?`. */}
535
+ {media.error && (
536
+ <span role="alert" style={{ width: "100%", fontSize: "0.8125rem", color: tokens.text }}>
537
+ {media.error.message}
538
+ </span>
539
+ )}
540
+ </div>
541
+ );
542
+ }