@tribe-nest/forge 3.31.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.
@@ -23,8 +23,10 @@ import {
23
23
  useRemoteTrack,
24
24
  useRoomState,
25
25
  useVisibleProducers,
26
+ type LocalSource,
26
27
  } from "@tribe-nest/media-client/react";
27
28
 
29
+ import { useForgeT, type ForgeT } from "../../i18n";
28
30
  import { useThemeTokens, type ResolvedThemeTokens } from "../theme/ForgeThemeProvider";
29
31
  import { CallHelpHint } from "./CallHelpHint";
30
32
  import {
@@ -57,6 +59,17 @@ import {
57
59
  * screen that looks like somebody else's product on every site that installs
58
60
  * it.
59
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
+ *
60
73
  * ## What it does NOT decide
61
74
  *
62
75
  * Whether a control may be used. `useGrants()` says what the node minted this
@@ -85,6 +98,7 @@ export type CallStageProps = {
85
98
 
86
99
  export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStageProps) {
87
100
  const tokens = useThemeTokens();
101
+ const t = useForgeT();
88
102
  const { connectionState, error, recovering, retry } = useMediaRoom();
89
103
  const state = useRoomState();
90
104
  const peers = useParticipants();
@@ -132,21 +146,26 @@ export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStagePro
132
146
  fontFamily: tokens.fontFamily,
133
147
  }}
134
148
  >
135
- <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "0.75rem", flexWrap: "wrap" }}>
149
+ <div
150
+ style={{
151
+ display: "flex",
152
+ alignItems: "center",
153
+ justifyContent: "space-between",
154
+ gap: "0.75rem",
155
+ flexWrap: "wrap",
156
+ }}
157
+ >
136
158
  {title ? <div style={{ fontWeight: 600, fontFamily: tokens.headingFontFamily }}>{title}</div> : <span />}
137
159
  <div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
138
- {recording && <RecordingIndicator tokens={tokens} />}
139
- <ConnectionStatus status={status} tokens={tokens} onRetry={retry} />
160
+ {recording && <RecordingIndicator tokens={tokens} t={t} />}
161
+ <ConnectionStatus status={status} tokens={tokens} t={t} onRetry={retry} />
140
162
  </div>
141
163
  </div>
142
164
 
143
165
  <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.
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")}
150
169
  </CallHelpHint>
151
170
  </div>
152
171
 
@@ -157,9 +176,9 @@ export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStagePro
157
176
  gap: "0.75rem",
158
177
  }}
159
178
  >
160
- <LocalPreview tokens={tokens} aspectRatio={aspectRatio} />
179
+ <LocalPreview tokens={tokens} aspectRatio={aspectRatio} t={t} />
161
180
  {tiles.map((tile) => (
162
- <ParticipantTile key={tile.key} tile={tile} tokens={tokens} aspectRatio={aspectRatio} />
181
+ <ParticipantTile key={tile.key} tile={tile} tokens={tokens} aspectRatio={aspectRatio} t={t} />
163
182
  ))}
164
183
  </div>
165
184
 
@@ -167,7 +186,7 @@ export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStagePro
167
186
  <RemoteAudio key={producerId} producerId={producerId} />
168
187
  ))}
169
188
 
170
- <CallControls tokens={tokens} onLeave={onLeave} />
189
+ <CallControls tokens={tokens} t={t} connected={connectionState === "connected"} onLeave={onLeave} />
171
190
  </div>
172
191
  );
173
192
  }
@@ -182,14 +201,18 @@ export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStagePro
182
201
  function ConnectionStatus({
183
202
  status,
184
203
  tokens,
204
+ t,
185
205
  onRetry,
186
206
  }: {
187
207
  status: CallStatus;
188
208
  tokens: ResolvedThemeTokens;
209
+ t: ForgeT;
189
210
  onRetry: () => void;
190
211
  }) {
191
212
  const busy = status.tone === "connecting" || status.tone === "reconnecting";
192
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;
193
216
 
194
217
  return (
195
218
  <div
@@ -211,8 +234,8 @@ function ConnectionStatus({
211
234
  style={{ width: 8, height: 8, borderRadius: 9999, backgroundColor: tokens.primary, display: "inline-block" }}
212
235
  />
213
236
  )}
214
- <span style={{ fontWeight: alarming ? 600 : 400 }}>{status.headline}</span>
215
- {status.detail && <span style={{ color: tokens.muted }}>{status.detail}</span>}
237
+ <span style={{ fontWeight: alarming ? 600 : 400 }}>{t(status.headlineKey)}</span>
238
+ {detail && <span style={{ color: tokens.muted }}>{detail}</span>}
216
239
  {status.canRetry && (
217
240
  <button
218
241
  type="button"
@@ -232,7 +255,7 @@ function ConnectionStatus({
232
255
  }}
233
256
  >
234
257
  <RefreshCw width={12} height={12} aria-hidden="true" />
235
- Try again
258
+ {t("forge.call_stage.retry")}
236
259
  </button>
237
260
  )}
238
261
  </div>
@@ -240,7 +263,7 @@ function ConnectionStatus({
240
263
  }
241
264
 
242
265
  /** Somebody is recording. A consent signal, so it is content and always shown. */
243
- function RecordingIndicator({ tokens }: { tokens: ResolvedThemeTokens }) {
266
+ function RecordingIndicator({ tokens, t }: { tokens: ResolvedThemeTokens; t: ForgeT }) {
244
267
  return (
245
268
  <span
246
269
  style={{
@@ -259,7 +282,7 @@ function RecordingIndicator({ tokens }: { tokens: ResolvedThemeTokens }) {
259
282
  aria-hidden="true"
260
283
  style={{ width: 8, height: 8, borderRadius: 9999, backgroundColor: tokens.secondary, display: "inline-block" }}
261
284
  />
262
- This call is being recorded
285
+ {t("forge.call_stage.recording")}
263
286
  </span>
264
287
  );
265
288
  }
@@ -306,10 +329,12 @@ function ParticipantTile({
306
329
  tile,
307
330
  tokens,
308
331
  aspectRatio,
332
+ t,
309
333
  }: {
310
334
  tile: CallTile;
311
335
  tokens: ResolvedThemeTokens;
312
336
  aspectRatio: number;
337
+ t: ForgeT;
313
338
  }) {
314
339
  // `useRemoteTrack` takes a producer id unconditionally so the hook order is
315
340
  // the same for a tile with video and a tile without. An id that matches
@@ -327,21 +352,23 @@ function ParticipantTile({
327
352
  data-speaking={tile.isSpeaking ? "true" : "false"}
328
353
  >
329
354
  {showVideo ? (
330
- <video
331
- ref={attach}
332
- autoPlay
333
- playsInline
334
- muted
335
- style={{ width: "100%", height: "100%", objectFit: "cover" }}
336
- />
355
+ <video ref={attach} autoPlay playsInline muted style={{ width: "100%", height: "100%", objectFit: "cover" }} />
337
356
  ) : (
338
- <span style={{ fontSize: "0.9375rem", fontWeight: 600, color: tokens.text, padding: "0 0.75rem", textAlign: "center" }}>
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
+ >
339
366
  {tile.name}
340
367
  </span>
341
368
  )}
342
369
  <span style={tileName(tokens)}>
343
370
  {tile.name}
344
- {!tile.hasAudio && <span style={{ color: tokens.muted }}> (listening)</span>}
371
+ {!tile.hasAudio && <span style={{ color: tokens.muted }}> {t("forge.call_stage.listening")}</span>}
345
372
  </span>
346
373
  </div>
347
374
  );
@@ -361,13 +388,15 @@ function RemoteAudio({ producerId }: { producerId: string }) {
361
388
  }
362
389
 
363
390
  /** This viewer, as they are being sent. Muted, or it is a feedback loop. */
364
- function LocalPreview({ tokens, aspectRatio }: { tokens: ResolvedThemeTokens; aspectRatio: number }) {
391
+ function LocalPreview({ tokens, aspectRatio, t }: { tokens: ResolvedThemeTokens; aspectRatio: number; t: ForgeT }) {
365
392
  const state = useRoomState();
366
393
  const publications = useLocalPublications();
367
394
  const videoRef = useRef<HTMLVideoElement | null>(null);
368
395
 
369
396
  const camera = useMemo(() => publications.find((p) => p.source === "camera"), [publications]);
370
- const track = camera?.track;
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;
371
400
 
372
401
  useEffect(() => {
373
402
  const element = videoRef.current;
@@ -381,7 +410,7 @@ function LocalPreview({ tokens, aspectRatio }: { tokens: ResolvedThemeTokens; as
381
410
  };
382
411
  }, [track]);
383
412
 
384
- const name = state.identity ? "You" : "You (not connected yet)";
413
+ const name = state.identity ? t("forge.call_stage.you") : t("forge.call_stage.you_not_connected");
385
414
 
386
415
  return (
387
416
  <div style={tileFrame(tokens, aspectRatio, false)} data-testid="tile-local">
@@ -401,6 +430,13 @@ function LocalPreview({ tokens, aspectRatio }: { tokens: ResolvedThemeTokens; as
401
430
  );
402
431
  }
403
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
+
404
440
  const controlButton = (tokens: ResolvedThemeTokens, active: boolean, busy = false): CSSProperties => ({
405
441
  display: "inline-flex",
406
442
  alignItems: "center",
@@ -423,31 +459,66 @@ const controlButton = (tokens: ResolvedThemeTokens, active: boolean, busy = fals
423
459
  * a listen-only seat is not offered a microphone it would be refused. That is a
424
460
  * rendering decision; the node refuses the publish itself either way.
425
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
+ *
426
474
  * ## Why every toggle is disabled while it is working
427
475
  *
428
476
  * 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.
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.
437
486
  */
438
- function CallControls({ tokens, onLeave }: { tokens: ResolvedThemeTokens; onLeave?: () => void }) {
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
+ }) {
439
498
  const grants = useGrants();
440
499
  const { room } = useMediaRoom();
441
500
  const media = useLocalMedia();
442
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
+
443
506
  // Plain handlers, deliberately not `useCallback`. `useLocalMedia()` returns a
444
507
  // fresh object every render, so memoising on it would rebuild the callback
445
508
  // every render anyway while looking as though it did not.
446
509
  const toggleMicrophone = () => {
447
- void (media.isMicrophoneEnabled ? media.unpublish("microphone") : media.publishMicrophone());
510
+ if (!media.isMicrophoneEnabled) {
511
+ void media.publishMicrophone();
512
+ return;
513
+ }
514
+ void media.setPaused("microphone", microphoneLive);
448
515
  };
449
516
  const toggleCamera = () => {
450
- void (media.isCameraEnabled ? media.unpublish("camera") : media.publishCamera());
517
+ if (!media.isCameraEnabled) {
518
+ void media.publishCamera();
519
+ return;
520
+ }
521
+ void media.setPaused("camera", cameraLive);
451
522
  };
452
523
  const toggleScreen = () => {
453
524
  void (media.screenSharing ? media.unpublish("screen") : media.publishScreen());
@@ -469,32 +540,37 @@ function CallControls({ tokens, onLeave }: { tokens: ResolvedThemeTokens; onLeav
469
540
  onClick={toggleMicrophone}
470
541
  disabled={media.pending.microphone}
471
542
  aria-busy={media.pending.microphone}
472
- style={controlButton(tokens, media.isMicrophoneEnabled, media.pending.microphone)}
543
+ style={controlButton(tokens, microphoneLive, media.pending.microphone)}
473
544
  >
474
- {media.isMicrophoneEnabled ? (
545
+ {microphoneLive ? (
475
546
  <Mic width={16} height={16} aria-hidden="true" />
476
547
  ) : (
477
548
  <MicOff width={16} height={16} aria-hidden="true" />
478
549
  )}
479
- {media.isMicrophoneEnabled ? "Mute" : "Unmute"}
550
+ {microphoneLive ? t("forge.call_stage.mute") : t("forge.call_stage.unmute")}
480
551
  </button>
481
552
  )}
482
553
 
483
554
  {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>
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>
498
574
  )}
499
575
 
500
576
  {canPublishSource(grants, "screen") && (
@@ -511,11 +587,10 @@ function CallControls({ tokens, onLeave }: { tokens: ResolvedThemeTokens; onLeav
511
587
  ) : (
512
588
  <MonitorUp width={16} height={16} aria-hidden="true" />
513
589
  )}
514
- {media.screenSharing ? "Stop sharing" : "Share screen"}
590
+ {media.screenSharing ? t("forge.call_stage.screen_stop") : t("forge.call_stage.screen_start")}
515
591
  </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.
592
+ <CallHelpHint label={t("forge.call_stage.help_screen_label")}>
593
+ {t("forge.call_stage.help_screen")}
519
594
  </CallHelpHint>
520
595
  </span>
521
596
  )}
@@ -526,7 +601,7 @@ function CallControls({ tokens, onLeave }: { tokens: ResolvedThemeTokens; onLeav
526
601
  style={{ ...controlButton(tokens, false), marginLeft: "auto", borderColor: tokens.secondary }}
527
602
  >
528
603
  <PhoneOff width={16} height={16} aria-hidden="true" />
529
- Leave
604
+ {t("forge.call_stage.leave")}
530
605
  </button>
531
606
 
532
607
  {/* A denied permission prompt is something the person has to be told
@@ -537,6 +612,22 @@ function CallControls({ tokens, onLeave }: { tokens: ResolvedThemeTokens; onLeav
537
612
  {media.error.message}
538
613
  </span>
539
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
+ )}
540
631
  </div>
541
632
  );
542
633
  }