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