@tribe-nest/forge 3.42.0 → 3.48.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 (46) hide show
  1. package/package.json +2 -2
  2. package/src/data/queries/useCallChat.ts +162 -0
  3. package/src/data/queries/useCallRecording.ts +80 -0
  4. package/src/data/queries/useMembershipCalls.ts +162 -0
  5. package/src/i18n/de.json +73 -1
  6. package/src/i18n/en.json +73 -1
  7. package/src/index.ts +7 -0
  8. package/src/ui/headless/memberHome/index.ts +4 -0
  9. package/src/ui/headless/memberHome/useMemberCalls.ts +137 -0
  10. package/src/ui/headless/memberHome/useMemberHomeNav.ts +29 -5
  11. package/src/ui/media/CallStage.tsx +691 -553
  12. package/src/ui/media/PreJoin.tsx +269 -0
  13. package/src/ui/media/_tests/CallStage.spec.tsx +338 -5
  14. package/src/ui/media/_tests/callLayout.spec.ts +182 -0
  15. package/src/ui/media/_tests/callSignals.spec.ts +87 -0
  16. package/src/ui/media/_tests/callState.spec.ts +93 -1
  17. package/src/ui/media/_tests/fakeNode.ts +4 -1
  18. package/src/ui/media/callChat.ts +33 -0
  19. package/src/ui/media/callState.ts +125 -9
  20. package/src/ui/media/hooks/useCallSignals.ts +135 -0
  21. package/src/ui/media/hooks/useMeasuredWidth.ts +39 -0
  22. package/src/ui/media/index.ts +41 -1
  23. package/src/ui/media/panels/CallChatPanel.tsx +200 -0
  24. package/src/ui/media/panels/CallParticipantsPanel.tsx +125 -0
  25. package/src/ui/media/panels/CallSidePanel.tsx +152 -0
  26. package/src/ui/media/stage/CallCenterNotice.tsx +89 -0
  27. package/src/ui/media/stage/CallControlsBar.tsx +547 -0
  28. package/src/ui/media/stage/CallHealthPanel.tsx +132 -0
  29. package/src/ui/media/stage/CallToasts.tsx +49 -0
  30. package/src/ui/media/stage/CallTopBar.tsx +257 -0
  31. package/src/ui/media/stage/DeviceMenu.tsx +142 -0
  32. package/src/ui/media/stage/ReactionOverlay.tsx +38 -0
  33. package/src/ui/media/stage/SelfView.tsx +146 -0
  34. package/src/ui/media/stage/VideoTile.tsx +232 -0
  35. package/src/ui/media/stage/callLayout.ts +253 -0
  36. package/src/ui/media/stage/callSignals.ts +91 -0
  37. package/src/ui/media/stage/callSounds.ts +78 -0
  38. package/src/ui/media/stage/stageTheme.ts +50 -0
  39. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +84 -7
  40. package/src/ui/styled/broadcast/broadcastStage.spec.ts +32 -0
  41. package/src/ui/styled/broadcast/relayReachable.spec.ts +42 -0
  42. package/src/ui/styled/broadcast/relayReachable.ts +47 -0
  43. package/src/ui/styled/forge-utilities.css +13 -0
  44. package/src/ui/styled/members/MemberCallsCalendar.tsx +571 -0
  45. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +135 -0
  46. package/src/ui/styled/members/index.ts +1 -0
@@ -0,0 +1,50 @@
1
+ import type { ResolvedThemeTokens } from "../../theme/ForgeThemeProvider";
2
+
3
+ /**
4
+ * The call stage's palette: a dark stage, with the site's accents.
5
+ *
6
+ * THE one sanctioned exception to "colors only from theme tokens" (a locked
7
+ * product decision): the call screen commits to a near-black chrome on every
8
+ * surface, the way every video product does, because faces read against dark
9
+ * and a light-themed site would otherwise produce a white call screen. The
10
+ * neutral constants live in THIS file and nowhere else; every accent (the
11
+ * speaking ring, active buttons, badges, danger) still flows from the theme,
12
+ * so the stage is recognisably the site's.
13
+ */
14
+ export type StageTokens = {
15
+ /** Near-black stage floor. */
16
+ stageBg: string;
17
+ /** Tiles, panels, dialogs: one step up from the floor. */
18
+ stageSurface: string;
19
+ /** The control bar and top bar: one more step. */
20
+ stageChrome: string;
21
+ stageText: string;
22
+ stageMuted: string;
23
+ stageBorder: string;
24
+ /** The site's primary: speaking ring, active controls, badges. */
25
+ accent: string;
26
+ /** Text on the accent. */
27
+ accentText: string;
28
+ /** The site's alarming color: leave, recording dot, errors. */
29
+ danger: string;
30
+ cornerRadius: number;
31
+ fontFamily?: string | undefined;
32
+ headingFontFamily?: string | undefined;
33
+ };
34
+
35
+ export function stageTokens(tokens: ResolvedThemeTokens): StageTokens {
36
+ return {
37
+ stageBg: "#0c0d10",
38
+ stageSurface: "#17181d",
39
+ stageChrome: "#1e2026",
40
+ stageText: "#f4f5f7",
41
+ stageMuted: "#9aa0ab",
42
+ stageBorder: "#2c2f37",
43
+ accent: tokens.primary,
44
+ accentText: tokens.textPrimary,
45
+ danger: tokens.secondary,
46
+ cornerRadius: tokens.cornerRadius,
47
+ fontFamily: tokens.fontFamily,
48
+ headingFontFamily: tokens.headingFontFamily,
49
+ };
50
+ }
@@ -159,6 +159,14 @@ export interface BroadcastPlayerProps {
159
159
  * carry. The picker below stays on the v1 Cloudflare path, which is where it
160
160
  * was built and where it is wired.
161
161
  */
162
+ /**
163
+ * How long the v1 Cloudflare lane may deliver nothing before HLS takes over.
164
+ *
165
+ * Long enough that a slow negotiation is not cut off, short enough that a
166
+ * viewer is not staring at a spinner while the broadcast is being published.
167
+ */
168
+ const LEGACY_REALTIME_DEADLINE_MS = 12_000;
169
+
162
170
  export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: BroadcastPlayerProps) {
163
171
  const t = useForgeT();
164
172
  const theme = useForgeTheme();
@@ -175,6 +183,21 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
175
183
  const videoRef = useRef<HTMLVideoElement>(null);
176
184
  const audioRef = useRef<HTMLAudioElement>(null);
177
185
  const videoContainerRef = useRef<HTMLDivElement>(null);
186
+ /**
187
+ * The v1 Cloudflare lane gave up, so HLS may have the viewer.
188
+ *
189
+ * The plane and the relay both fall back: every way they can fail ends at
190
+ * HLS, and `chooseBroadcastStage` holds that rule. The v1 lane had no such
191
+ * exit. `isLegacyRealtime` SUPPRESSED the HLS stage outright, so a broadcast
192
+ * whose Cloudflare room was gone - retired, expired, never created for a v2
193
+ * broadcast that still carries a stale `realtimeConfig` - showed a spinner
194
+ * and nothing else, for ever, with no way for the viewer to reach a stream
195
+ * that was being published the whole time.
196
+ *
197
+ * Reported from production as a live HLS broadcast showing nothing on the
198
+ * site.
199
+ */
200
+ const [legacyFailed, setLegacyFailed] = useState(false);
178
201
  const [isAudioLoaded, setIsAudioLoaded] = useState(false);
179
202
  const [isVideoLoaded, setIsVideoLoaded] = useState(false);
180
203
  const [isPlaying, setIsPlaying] = useState(false);
@@ -344,7 +367,7 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
344
367
  // hls.js instance on mount and starts fetching immediately, so rendering the
345
368
  // HLS stage "meanwhile" made every realtime viewer download the broadcast a
346
369
  // second time, over the CDN, for a stage that was about to be replaced.
347
- const showHls = stage === "hls" && !isLegacyRealtime;
370
+ const showHls = stage === "hls" && (!isLegacyRealtime || legacyFailed);
348
371
  const fellBack = broadcastFellBack(stageInput);
349
372
 
350
373
  const { data: audienceData } = useBroadcastAudience(broadcast.id, !broadcast.endedAt);
@@ -363,6 +386,24 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
363
386
  });
364
387
  }, [comments]);
365
388
 
389
+ /**
390
+ * The v1 lane also fails by never arriving, which no `catch` will report.
391
+ *
392
+ * A peer connection that negotiates and then delivers no track raises
393
+ * nothing at all: `subscribe()` resolves, and the viewer watches a spinner.
394
+ * So the deadline is the backstop, and it is what makes the fallback
395
+ * guaranteed rather than dependent on the failure being the polite kind.
396
+ *
397
+ * Twelve seconds: long enough that a slow negotiation is not cut off, short
398
+ * enough that a viewer is not staring at nothing while the broadcast runs.
399
+ */
400
+ useEffect(() => {
401
+ if (!isLegacyRealtime || legacyFailed) return;
402
+ if (isVideoLoaded || isAudioLoaded) return;
403
+ const timer = setTimeout(() => setLegacyFailed(true), LEGACY_REALTIME_DEADLINE_MS);
404
+ return () => clearTimeout(timer);
405
+ }, [isLegacyRealtime, legacyFailed, isVideoLoaded, isAudioLoaded]);
406
+
366
407
  // ── WebRTC subscription, v1 (Cloudflare Calls) ─────────────────────────────
367
408
  useEffect(() => {
368
409
  const config = broadcast.realtimeConfig;
@@ -413,9 +454,10 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
413
454
  };
414
455
 
415
456
  subscribe().catch(() => {
416
- // Nothing useful to say to a viewer here: the spinner over the stage is
417
- // already the honest report that no track arrived.
457
+ // A spinner is not an honest report when there is a stream the viewer
458
+ // could be watching. Falling back is.
418
459
  isSubscribingRef.current = false;
460
+ setLegacyFailed(true);
419
461
  });
420
462
  // `sessionApi` is rebuilt each render and is deliberately not a dependency:
421
463
  // including it would re-negotiate the peer connection on every render.
@@ -537,10 +579,45 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
537
579
  return (
538
580
  <div
539
581
  style={{
582
+ /**
583
+ * The video dominates, and the chat takes what is left.
584
+ *
585
+ * `flexWrap` wraps rather than shrinking, so the two panels stacked
586
+ * the moment their BASES no longer fit on a line - and with both
587
+ * growing at the same rate, the chat took half of every extra pixel,
588
+ * so the video was never big even side by side. On an ordinary
589
+ * desktop that read as a small video sitting on top of a chat.
590
+ *
591
+ * The bases below stay on one line down to about 800px, and the video
592
+ * grows three times faster than the chat, so the space a wider screen
593
+ * provides goes almost entirely to the picture. Stacking is then what
594
+ * happens on a genuinely narrow screen, which is where it is right.
595
+ */
540
596
  display: "flex",
541
597
  flexWrap: "wrap",
542
598
  gap: 32,
543
- padding: 16,
599
+ // No padding at the TOP: the shell and the close button above already
600
+ // put the player well down the page, and on a phone that was most of
601
+ // the first screen spent before any video.
602
+ padding: "0 16px 16px",
603
+ /**
604
+ * FILL the parent rather than being sized by what is inside.
605
+ *
606
+ * Without this the container was shrink-to-fit, so its width came from
607
+ * its content - and the HLS stage puts its player in an absolutely
608
+ * positioned `inset: 0` box (which is what stops the player resizing
609
+ * its own container on a rendition change). Absolutely positioned
610
+ * children contribute NO intrinsic width, so the content measured as
611
+ * almost nothing and the whole player collapsed to about 400px inside a
612
+ * 1300px column, then wrapped.
613
+ *
614
+ * The realtime stages hid it: they render an in-flow `<video>` with a
615
+ * real intrinsic width, so the same container came out wide enough. It
616
+ * looked like "HLS stacks and the SFU does not", and it was neither -
617
+ * it was a container taking its width from whatever happened to be
618
+ * inside it.
619
+ */
620
+ width: "100%",
544
621
  maxWidth: 1700,
545
622
  marginInline: "auto",
546
623
  flex: 1,
@@ -548,7 +625,7 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
548
625
  fontFamily: theme.fontFamily,
549
626
  }}
550
627
  >
551
- <div style={{ flex: "1 1 520px", minWidth: 0, ...panel }}>
628
+ <div style={{ flex: "3 1 420px", minWidth: 0, ...panel }}>
552
629
  {showMoq && moqStage && moqCredentials && (
553
630
  <MoqStageRenderer
554
631
  Stage={moqStage}
@@ -643,7 +720,7 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
643
720
  </div>
644
721
  )}
645
722
 
646
- {isLegacyRealtime && (
723
+ {isLegacyRealtime && !legacyFailed && (
647
724
  <div
648
725
  ref={videoContainerRef}
649
726
  data-testid="broadcast-realtime-stage"
@@ -815,7 +892,7 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
815
892
  </div>
816
893
  </div>
817
894
 
818
- <div style={{ flex: "1 1 340px", maxWidth: 400, height: 600, position: "relative", overflow: "hidden", ...panel }}>
895
+ <div style={{ flex: "1 1 300px", maxWidth: 400, height: 600, position: "relative", overflow: "hidden", ...panel }}>
819
896
  <p style={{ fontSize: 18, padding: 16, borderBottom: `1px solid ${theme.colors.primary}30` }}>
820
897
  {t("forge.broadcast_player.chat")}
821
898
  </p>
@@ -0,0 +1,32 @@
1
+
2
+ /**
3
+ * The v1 Cloudflare lane must end at HLS like the other two.
4
+ *
5
+ * `isLegacyRealtime` SUPPRESSED the HLS stage outright, so a broadcast whose
6
+ * Cloudflare room was gone - retired, expired, or never created for a v2
7
+ * broadcast still carrying a stale `realtimeConfig` - showed a spinner and
8
+ * nothing else, for ever, while HLS was being published the whole time.
9
+ * Reported from production as a live broadcast showing nothing on the site.
10
+ *
11
+ * The rule lives in the component (it needs the lane's load state, which this
12
+ * pure chooser has no business knowing), so this pins the SHAPE of it: three
13
+ * lanes, and every one of them has an exit to HLS.
14
+ */
15
+ describe("every realtime lane has an exit to HLS", () => {
16
+ const showHls = (input: { stage: string; isLegacyRealtime: boolean; legacyFailed: boolean }) =>
17
+ input.stage === "hls" && (!input.isLegacyRealtime || input.legacyFailed);
18
+
19
+ it("keeps HLS hidden while the v1 lane is still trying", () => {
20
+ // Not a regression: mounting an HLS engine beside a working realtime lane
21
+ // makes the viewer download the broadcast twice.
22
+ expect(showHls({ stage: "hls", isLegacyRealtime: true, legacyFailed: false })).toBe(false);
23
+ });
24
+
25
+ it("shows HLS once the v1 lane has given up", () => {
26
+ expect(showHls({ stage: "hls", isLegacyRealtime: true, legacyFailed: true })).toBe(true);
27
+ });
28
+
29
+ it("shows HLS normally when there is no v1 lane at all", () => {
30
+ expect(showHls({ stage: "hls", isLegacyRealtime: false, legacyFailed: false })).toBe(true);
31
+ });
32
+ });
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { relayIsReachable } from "./relayReachable";
4
+
5
+ /**
6
+ * The lane must be able to give up. `<moq-watch>` retries a refused connection
7
+ * for ever and never raises an `error` event, so without this the viewer sits
8
+ * on a dead stage while HLS is being published.
9
+ */
10
+ describe("probing the MoQ relay before handing a viewer to it", () => {
11
+ it("is reachable when the fingerprint responds", async () => {
12
+ expect(await relayIsReachable("https://relay.example/room/name", vi.fn(async () => new Response("")))).toBe(true);
13
+ });
14
+
15
+ it("is NOT reachable when the connection is refused", async () => {
16
+ // The dev case: no relay running on localhost:4444.
17
+ const refused = vi.fn(async () => {
18
+ throw new TypeError("Failed to fetch");
19
+ });
20
+ expect(await relayIsReachable("http://localhost:4444/room/name", refused)).toBe(false);
21
+ });
22
+
23
+ it("asks the relay ORIGIN, not the ticketed path", async () => {
24
+ const fetchImpl = vi.fn(async () => new Response(""));
25
+ await relayIsReachable("https://relay.example/room/name?jwt=abc", fetchImpl);
26
+ expect(fetchImpl.mock.calls[0]?.[0]).toBe("https://relay.example/certificate.sha256");
27
+ });
28
+
29
+ it("gives up rather than hanging when nothing answers", async () => {
30
+ const hangs = vi.fn(
31
+ (_url: string, init?: { signal?: AbortSignal }) =>
32
+ new Promise<Response>((_resolve, reject) => {
33
+ init?.signal?.addEventListener("abort", () => reject(new Error("aborted")));
34
+ }),
35
+ );
36
+ expect(await relayIsReachable("https://relay.example/x", hangs as unknown as typeof fetch, 10)).toBe(false);
37
+ });
38
+
39
+ it("treats an unparseable URL as unreachable rather than throwing", async () => {
40
+ expect(await relayIsReachable("not a url", vi.fn())).toBe(false);
41
+ });
42
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Is the MoQ relay actually there, before a viewer is handed to it?
3
+ *
4
+ * `<moq-watch>` owns its own reconnect loop: a refused connection produces a
5
+ * console error and a retry, for ever, and never an `error` event. So the
6
+ * lane's `onFailed` never fires, `chooseBroadcastStage` keeps answering "moq",
7
+ * and the HLS stage is never mounted - the viewer watches nothing while the
8
+ * broadcast is being published the whole time.
9
+ *
10
+ * A blind timeout would be the wrong fix: it cannot tell a slow relay from a
11
+ * dead one and would cut off a stream that was about to work. This asks the
12
+ * question that actually has an answer. The element fetches
13
+ * `/certificate.sha256` from the relay origin before it connects; if that is
14
+ * unreachable, so is the relay, and the lane is over before it began.
15
+ *
16
+ * The common case is not an outage. It is a developer with no relay running
17
+ * locally, where the URL is `http://localhost:4444` and correct.
18
+ */
19
+ export const RELAY_PROBE_TIMEOUT_MS = 3000;
20
+
21
+ export async function relayIsReachable(
22
+ url: string,
23
+ fetchImpl: typeof fetch = fetch,
24
+ timeoutMs: number = RELAY_PROBE_TIMEOUT_MS,
25
+ ): Promise<boolean> {
26
+ let origin: string;
27
+ try {
28
+ origin = new URL("/certificate.sha256", url).toString();
29
+ } catch {
30
+ // An unparseable relay URL is not reachable by any definition.
31
+ return false;
32
+ }
33
+
34
+ const controller = new AbortController();
35
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
36
+ try {
37
+ // `no-cors` on purpose: the answer wanted is "did the connection happen",
38
+ // not what the body says. An opaque response is a reachable relay, and it
39
+ // keeps this probe from needing CORS the element itself may not need.
40
+ await fetchImpl(origin, { mode: "no-cors", signal: controller.signal });
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ } finally {
45
+ clearTimeout(timer);
46
+ }
47
+ }
@@ -331,6 +331,9 @@
331
331
  .p-6 {
332
332
  padding: calc(var(--spacing) * 6);
333
333
  }
334
+ .p-10 {
335
+ padding: calc(var(--spacing) * 10);
336
+ }
334
337
  .px-4 {
335
338
  padding-inline: calc(var(--spacing) * 4);
336
339
  }
@@ -465,6 +468,16 @@
465
468
  --tw-ease: var(--ease-out);
466
469
  transition-timing-function: var(--ease-out);
467
470
  }
471
+ .sm\:block {
472
+ @media (width >= 40rem) {
473
+ display: block;
474
+ }
475
+ }
476
+ .sm\:hidden {
477
+ @media (width >= 40rem) {
478
+ display: none;
479
+ }
480
+ }
468
481
  .sm\:grid-cols-2 {
469
482
  @media (width >= 40rem) {
470
483
  grid-template-columns: repeat(2, minmax(0, 1fr));