@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.
@@ -5,8 +5,10 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
5
5
 
6
6
  import { MediaRoomProvider, type MediaRoomProviderProps } from "@tribe-nest/media-client/react";
7
7
  import type { MediaDevice, MediaTransport } from "@tribe-nest/media-client";
8
+ import { MediaError } from "@tribe-nest/media-protocol";
8
9
 
9
10
  import { CallStage } from "../CallStage";
11
+ import { ForgeI18nProvider } from "../../../i18n";
10
12
  import { ForgeThemeProvider, type ForgeTheme } from "../../theme/ForgeThemeProvider";
11
13
  import { FakeNode, settle } from "./fakeNode";
12
14
 
@@ -67,7 +69,14 @@ const fakeTransport = (): MediaTransport => ({
67
69
  id: "t-1",
68
70
  async produce(input) {
69
71
  produced.push(String((input.appData as { source?: string } | undefined)?.source));
70
- return { id: `p-local-${produced.length}`, kind: "audio", closed: false, pause: vi.fn(), resume: vi.fn(), close: vi.fn() };
72
+ return {
73
+ id: `p-local-${produced.length}`,
74
+ kind: "audio",
75
+ closed: false,
76
+ pause: vi.fn(),
77
+ resume: vi.fn(),
78
+ close: vi.fn(),
79
+ };
71
80
  },
72
81
  async consume(input) {
73
82
  return {
@@ -105,27 +114,36 @@ const FAST_RECOVERY: Recovery = { baseMs: 1, maxMs: 4, factor: 1, jitter: "none"
105
114
  const mount = (
106
115
  node: FakeNode,
107
116
  children: ReactNode,
108
- options: { autoSubscribe?: boolean; reconnect?: Recovery } = {},
117
+ options: { autoSubscribe?: boolean; reconnect?: Recovery; locale?: string } = {},
109
118
  ) =>
110
119
  render(
111
- <ForgeThemeProvider theme={theme}>
112
- <MediaRoomProvider
113
- getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
114
- device={fakeDevice}
115
- webSocket={node.factory}
116
- autoSubscribe={options.autoSubscribe ?? false}
117
- reconnect={options.reconnect ?? NO_RECOVERY}
118
- >
119
- {children}
120
- </MediaRoomProvider>
121
- </ForgeThemeProvider>,
120
+ <ForgeI18nProvider locale={options.locale ?? "en"}>
121
+ <ForgeThemeProvider theme={theme}>
122
+ <MediaRoomProvider
123
+ getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
124
+ device={fakeDevice}
125
+ webSocket={node.factory}
126
+ autoSubscribe={options.autoSubscribe ?? false}
127
+ reconnect={options.reconnect ?? NO_RECOVERY}
128
+ >
129
+ {children}
130
+ </MediaRoomProvider>
131
+ </ForgeThemeProvider>
132
+ </ForgeI18nProvider>,
122
133
  );
123
134
 
124
- /** A permission prompt and a capture, as the browser would give them. */
125
- const stubCapture = () => {
126
- const track = { kind: "audio", stop: vi.fn(), enabled: true } as unknown as MediaStreamTrack & {
127
- stop: ReturnType<typeof vi.fn>;
128
- };
135
+ /**
136
+ * A permission prompt and a capture, as the browser would give them.
137
+ *
138
+ * The track is an `EventTarget`, as a real `MediaStreamTrack` is, so a spec can
139
+ * end it the way the browser's own "Stop sharing" bar does.
140
+ */
141
+ const stubCapture = (kind: "audio" | "video" = "audio") => {
142
+ const target = new EventTarget() as EventTarget & { kind: string; stop: ReturnType<typeof vi.fn>; enabled: boolean };
143
+ target.kind = kind;
144
+ target.stop = vi.fn();
145
+ target.enabled = true;
146
+ const track = target as unknown as MediaStreamTrack & { stop: ReturnType<typeof vi.fn> };
129
147
  const getUserMedia = vi.fn(async () => ({ getTracks: () => [track] }) as unknown as MediaStream);
130
148
  Object.defineProperty(navigator, "mediaDevices", {
131
149
  configurable: true,
@@ -381,7 +399,8 @@ describe("the grid", () => {
381
399
 
382
400
  const { container, getByTestId } = mount(node, <CallStage />);
383
401
  await waitFor(() => expect(getByTestId("tile-ben")).toBeTruthy());
384
- const order = () => [...container.querySelectorAll("[data-testid^='tile-']")].map((n) => n.getAttribute("data-testid"));
402
+ const order = () =>
403
+ [...container.querySelectorAll("[data-testid^='tile-']")].map((n) => n.getAttribute("data-testid"));
385
404
  const before = order();
386
405
 
387
406
  node.event({ event: "activeSpeakers", producerIds: ["p-ben-audio"] });
@@ -466,6 +485,47 @@ describe("the connection state, said honestly", () => {
466
485
  expect(getByText("Try again")).toBeTruthy();
467
486
  });
468
487
 
488
+ /**
489
+ * The ticket endpoint said no BEFORE any socket existed. This used to wedge
490
+ * the room on "Connecting" for ever (the SDK never ran its close path for a
491
+ * failure with no socket), and the docblocks promising the server's refusal
492
+ * would be shown verbatim could not be kept. Now it is a refusal like any
493
+ * other: the sentence, and a control.
494
+ */
495
+ it("shows the credential endpoint's refusal verbatim, with a retry, rather than a spinner", async () => {
496
+ const node = new FakeNode({});
497
+ let attempts = 0;
498
+ const { getByRole, getByText } = render(
499
+ <ForgeThemeProvider theme={theme}>
500
+ <MediaRoomProvider
501
+ getCredentials={() => {
502
+ attempts += 1;
503
+ if (attempts === 1) {
504
+ throw new MediaError("bad_request", "The room opens 15 minutes before the session.");
505
+ }
506
+ return { mediaUrl: "wss://media.example", token: "t" };
507
+ }}
508
+ device={fakeDevice}
509
+ webSocket={node.factory}
510
+ autoSubscribe={false}
511
+ reconnect={NO_RECOVERY}
512
+ >
513
+ <CallStage />
514
+ </MediaRoomProvider>
515
+ </ForgeThemeProvider>,
516
+ );
517
+
518
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Could not join this call"));
519
+ expect(getByRole("status").textContent).toContain("The room opens 15 minutes before the session.");
520
+ expect(getByRole("status").textContent).not.toContain("Connection lost");
521
+
522
+ // And Try again is not decoration: the next attempt fetches a ticket again
523
+ // and gets in.
524
+ fireEvent.click(getByText("Try again"));
525
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
526
+ expect(attempts).toBe(2);
527
+ });
528
+
469
529
  it("says the call ended when the node closes the room", async () => {
470
530
  const node = new FakeNode({});
471
531
  const { getByRole } = mount(node, <CallStage />);
@@ -683,3 +743,189 @@ describe("the things that must stay visible", () => {
683
743
  await waitFor(() => expect(queryByText(/means that person has their camera off/)).toBeTruthy());
684
744
  });
685
745
  });
746
+
747
+ /**
748
+ * The screen speaks the site's language.
749
+ *
750
+ * Every string on the call screen was English, written into the component,
751
+ * against Forge's own convention: a German site rendered a German account page
752
+ * with an English call in the middle of it, and `callStatus` returned sentences
753
+ * a translator could not reach. This pins that the SAME component, under the
754
+ * SAME provider every Forge component reads, is German when the site is.
755
+ */
756
+ describe("the screen is translated", () => {
757
+ it("renders the status and the controls in the site's language", async () => {
758
+ const node = new FakeNode({});
759
+ const { getByRole, getByText, queryByText } = mount(node, <CallStage />, { locale: "de" });
760
+
761
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Verbunden"));
762
+ expect(getByText("Stummschaltung aufheben")).toBeTruthy();
763
+ expect(getByText("Kamera starten")).toBeTruthy();
764
+ expect(getByText("Verlassen")).toBeTruthy();
765
+ expect(getByText(/In diesem Anruf:/).textContent).toBe("In diesem Anruf: 1");
766
+ expect(queryByText("Unmute")).toBeNull();
767
+ });
768
+
769
+ it("keeps the server's own sentence verbatim inside a translated status", async () => {
770
+ const node = new FakeNode({});
771
+ const { getByRole } = mount(node, <CallStage />, { locale: "de" });
772
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Verbunden"));
773
+
774
+ node.event({ event: "roomClosed", reason: "the host ended it" });
775
+
776
+ // The headline is ours and is German; the reason is the server's and is not
777
+ // put through a translator, because a friendlier version of a refusal is a
778
+ // lie about why.
779
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Der Anruf ist beendet"));
780
+ expect(getByRole("status").textContent).toContain("the host ended it");
781
+ });
782
+
783
+ it("falls back to English outside any i18n provider, so a site without one still gets a call", async () => {
784
+ const node = new FakeNode({});
785
+ const { getByRole } = render(
786
+ <ForgeThemeProvider theme={theme}>
787
+ <MediaRoomProvider
788
+ getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
789
+ device={fakeDevice}
790
+ webSocket={node.factory}
791
+ autoSubscribe={false}
792
+ reconnect={NO_RECOVERY}
793
+ >
794
+ <CallStage />
795
+ </MediaRoomProvider>
796
+ </ForgeThemeProvider>,
797
+ );
798
+
799
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
800
+ });
801
+ });
802
+
803
+ /**
804
+ * Mute is a pause, not a second permission prompt.
805
+ *
806
+ * The toggles were unpublish-then-publish, and every publish starts with
807
+ * `getUserMedia`. Safari asks permission on EVERY call to it, so a person on an
808
+ * iPad was asked whether the site may use the microphone each time they
809
+ * unmuted. Now the first press captures and every press after that pauses or
810
+ * resumes the publication the room already holds.
811
+ */
812
+ describe("mute and camera-off keep the capture", () => {
813
+ it("mutes and unmutes with ONE capture, and tells the node each time", async () => {
814
+ const { getUserMedia } = stubCapture();
815
+ const node = new FakeNode({});
816
+ const { getByText } = mount(node, <CallStage />);
817
+
818
+ await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
819
+ fireEvent.click(getByText("Unmute"));
820
+ await waitFor(() => expect(getByText("Mute")).toBeTruthy());
821
+
822
+ fireEvent.click(getByText("Mute"));
823
+ await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
824
+ expect(node.received.filter((f) => f.method === "pauseProducer")).toHaveLength(1);
825
+ // Paused, not closed: the microphone is still published, just silent.
826
+ expect(node.received.filter((f) => f.method === "closeProducer")).toHaveLength(0);
827
+
828
+ fireEvent.click(getByText("Unmute"));
829
+ await waitFor(() => expect(getByText("Mute")).toBeTruthy());
830
+ expect(node.received.filter((f) => f.method === "resumeProducer")).toHaveLength(1);
831
+ // The whole point: one prompt for the whole call.
832
+ expect(getUserMedia).toHaveBeenCalledTimes(1);
833
+ expect(produced).toEqual(["microphone"]);
834
+ });
835
+
836
+ it("shows the name in the local preview while the camera is paused, and explains the light behind a ?", async () => {
837
+ stubCapture("video");
838
+ const node = new FakeNode({});
839
+ const { getByText, getByTestId, getByLabelText, queryByText } = mount(node, <CallStage />);
840
+
841
+ await waitFor(() => expect(getByText("Start camera")).toBeTruthy());
842
+ fireEvent.click(getByText("Start camera"));
843
+ await waitFor(() => expect(getByText("Stop camera")).toBeTruthy());
844
+ await waitFor(() => expect(getByTestId("tile-local").querySelector("video")).toBeTruthy());
845
+
846
+ fireEvent.click(getByText("Stop camera"));
847
+
848
+ // What the others see is the name, so that is what the preview shows.
849
+ await waitFor(() => expect(getByText("Start camera")).toBeTruthy());
850
+ await waitFor(() => expect(getByTestId("tile-local").querySelector("video")).toBeNull());
851
+ expect(node.received.filter((f) => f.method === "pauseProducer")).toHaveLength(1);
852
+ // The camera stays open (the light can stay on), and that is an explanation,
853
+ // so it lives behind a ? with a name that says what it explains.
854
+ expect(queryByText(/keeps the camera open/)).toBeNull();
855
+ fireEvent.click(getByLabelText("What does Stop camera do?"));
856
+ await waitFor(() => expect(queryByText(/keeps the camera open/)).toBeTruthy());
857
+ });
858
+ });
859
+
860
+ /**
861
+ * The browser can end a share without asking us.
862
+ *
863
+ * Chrome and Firefox draw their own "Stop sharing" bar over a screen share.
864
+ * Nothing about it went near `unpublish`, so the node kept a producer nobody
865
+ * was feeding, everybody else kept a frozen tile, and this button read "Stop
866
+ * sharing" about a share that had ended.
867
+ */
868
+ describe("a screen share stopped from the browser's own bar", () => {
869
+ it("is unpublished, and the control follows", async () => {
870
+ const { track } = stubCapture("video");
871
+ const node = new FakeNode({});
872
+ const { getByText } = mount(node, <CallStage />);
873
+
874
+ await waitFor(() => expect(getByText("Share screen")).toBeTruthy());
875
+ fireEvent.click(getByText("Share screen"));
876
+ await waitFor(() => expect(getByText("Stop sharing")).toBeTruthy());
877
+
878
+ track.dispatchEvent(new Event("ended"));
879
+
880
+ await waitFor(() => expect(getByText("Share screen")).toBeTruthy());
881
+ expect(node.received.filter((f) => f.method === "closeProducer")).toHaveLength(1);
882
+ });
883
+ });
884
+
885
+ /**
886
+ * A recovered connection is not a recovered microphone, and the screen says so.
887
+ *
888
+ * The drop stops every local capture (right: a light left on over a dead
889
+ * session is retained capture) and the SDK does not turn it back on by itself
890
+ * (also right: capture is a permission, and it is the person's press). What
891
+ * was wrong is that nothing then told them. The room came back, the status
892
+ * read "Connected", and a coach who had been talking for ten minutes was
893
+ * talking to nobody until the client asked whether they were still there.
894
+ */
895
+ describe("what a drop took away is said, once the room is back", () => {
896
+ it("names the microphone, and stops once it is back on", async () => {
897
+ stubCapture();
898
+ const node = new FakeNode({});
899
+ const { getByText, getByRole, queryByText } = mount(node, <CallStage />, { reconnect: FAST_RECOVERY });
900
+
901
+ await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
902
+ fireEvent.click(getByText("Unmute"));
903
+ await waitFor(() => expect(getByText("Mute")).toBeTruthy());
904
+
905
+ node.socket.drop();
906
+
907
+ await waitFor(() => expect(node.sockets.length).toBe(2));
908
+ await waitFor(() => expect(getByRole("status").textContent).toContain("Connected"));
909
+ // Content, not a hint: this is the thing they have to act on.
910
+ await waitFor(() => expect(queryByText(/Your microphone was turned off/)).toBeTruthy());
911
+ expect(getByText("Unmute")).toBeTruthy();
912
+
913
+ fireEvent.click(getByText("Unmute"));
914
+ await waitFor(() => expect(getByText("Mute")).toBeTruthy());
915
+ await waitFor(() => expect(queryByText(/Your microphone was turned off/)).toBeNull());
916
+ });
917
+
918
+ it("says nothing after a deliberate leave", async () => {
919
+ stubCapture();
920
+ const node = new FakeNode({});
921
+ const { getByText, queryByText } = mount(node, <CallStage />, { reconnect: FAST_RECOVERY });
922
+ await waitFor(() => expect(getByText("Unmute")).toBeTruthy());
923
+ fireEvent.click(getByText("Unmute"));
924
+ await waitFor(() => expect(getByText("Mute")).toBeTruthy());
925
+
926
+ fireEvent.click(getByText("Leave"));
927
+
928
+ await settle(30);
929
+ expect(queryByText(/Your microphone was turned off/)).toBeNull();
930
+ });
931
+ });
@@ -1,6 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import { renderHook } from "@testing-library/react";
4
+ import { MediaError } from "@tribe-nest/media-protocol";
4
5
 
5
6
  import {
6
7
  BOOKING_CALL_CLOSES_MINUTES_AFTER,
@@ -167,6 +168,53 @@ describe("useBookingSessionCredentials", () => {
167
168
  expect(credentials.role).toBe("guest");
168
169
  });
169
170
 
171
+ /**
172
+ * The server's refusal must reach the screen as the server's SENTENCE. Left
173
+ * as an axios error, the SDK read it as a dropped socket and the screen said
174
+ * "Connection lost" about a call the person was never in.
175
+ */
176
+ it("turns the server's refusal into a MediaError carrying its message", async () => {
177
+ post.mockRejectedValueOnce({
178
+ response: { status: 409, data: { message: "The room opens 15 minutes before the session." } },
179
+ });
180
+ const getCredentials = renderHook(() => useBookingSessionCredentials("booking-1")).result.current;
181
+
182
+ const error: unknown = await getCredentials().then(
183
+ () => undefined,
184
+ (e: unknown) => e,
185
+ );
186
+
187
+ expect(error).toBeInstanceOf(MediaError);
188
+ expect((error as MediaError).message).toBe("The room opens 15 minutes before the session.");
189
+ expect((error as MediaError).code).toBe("bad_request");
190
+ expect((error as MediaError).retryable).toBe(false);
191
+ });
192
+
193
+ it("maps a 401 and a 404 to the SDK's own codes, and a 5xx to a retryable internal", async () => {
194
+ const getCredentials = renderHook(() => useBookingSessionCredentials("booking-1")).result.current;
195
+ const refusal = async (status: number) => {
196
+ post.mockRejectedValueOnce({ response: { status, data: { message: `status ${status}` } } });
197
+ return getCredentials().then(
198
+ () => undefined,
199
+ (e: unknown) => e as MediaError,
200
+ );
201
+ };
202
+
203
+ expect((await refusal(401))?.code).toBe("unauthorized");
204
+ expect((await refusal(404))?.code).toBe("no_such_room");
205
+ const internal = await refusal(503);
206
+ expect(internal?.code).toBe("internal");
207
+ expect(internal?.retryable).toBe(true);
208
+ });
209
+
210
+ it("leaves a network failure alone, because that one really is a connection problem", async () => {
211
+ const network = new Error("Network Error");
212
+ post.mockRejectedValueOnce(network);
213
+ const getCredentials = renderHook(() => useBookingSessionCredentials("booking-1")).result.current;
214
+
215
+ await expect(getCredentials()).rejects.toBe(network);
216
+ });
217
+
170
218
  it("keeps ONE callback identity per booking", () => {
171
219
  const { result, rerender } = renderHook(() => useBookingSessionCredentials("booking-1"));
172
220
  const first = result.current;
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
3
3
  import type { ProducerEntry } from "@tribe-nest/media-client";
4
4
  import type { Peer } from "@tribe-nest/media-protocol";
5
5
 
6
+ import { FORGE_LOCALES, getForgeMessages, translateForge } from "../../../i18n";
6
7
  import { callHeadCount, callStatus, callTiles, canPublishSource, remoteAudioProducerIds } from "../callState";
7
8
 
8
9
  /**
@@ -22,12 +23,12 @@ const peer = (identity: string, name?: string): Peer => ({
22
23
  /** The recording process, which joins the room as a real peer. */
23
24
  const recorder = (identity: string): Peer => ({ identity, kind: "egress" });
24
25
 
25
- const producer = (
26
- producerId: string,
27
- identity: string,
28
- kind: "audio" | "video",
29
- paused = false,
30
- ): ProducerEntry => ({ producerId, identity, kind, paused });
26
+ const producer = (producerId: string, identity: string, kind: "audio" | "video", paused = false): ProducerEntry => ({
27
+ producerId,
28
+ identity,
29
+ kind,
30
+ paused,
31
+ });
31
32
 
32
33
  describe("callTiles keeps the grid from collapsing", () => {
33
34
  it("draws a tile for somebody with NO video at all", () => {
@@ -256,21 +257,67 @@ describe("callStatus is honest about what is happening", () => {
256
257
  // other is worth pressing a button about.
257
258
  expect(dropped.tone).toBe("reconnecting");
258
259
  expect(refused.tone).toBe("failed");
259
- expect(dropped.headline).not.toBe(refused.headline);
260
+ expect(dropped.headlineKey).not.toBe(refused.headlineKey);
260
261
  });
261
262
 
262
263
  it("shows the server's own words for a refusal rather than a friendly lie", () => {
263
- expect(callStatus({ connectionState: "reconnecting", error: { type: "refused", code: "room_closed" } }).detail).toBe(
264
- "room_closed",
265
- );
264
+ // As TEXT, not as a key: a refusal is the one thing on the screen that must
265
+ // never be put through a translator, because the server's sentence is the
266
+ // reason and a friendlier version of it is a lie about why.
267
+ const refusedByCode = callStatus({
268
+ connectionState: "reconnecting",
269
+ error: { type: "refused", code: "room_closed" },
270
+ });
271
+ expect(refusedByCode.detailText).toBe("room_closed");
272
+ expect(refusedByCode.detailKey).toBeUndefined();
266
273
  expect(
267
274
  callStatus({
268
275
  connectionState: "reconnecting",
269
276
  error: { type: "refused", code: "unauthorized", message: "this session has ended" },
270
- }).detail,
277
+ }).detailText,
271
278
  ).toBe("this session has ended");
272
279
  });
273
280
 
281
+ /**
282
+ * Keys, not sentences. `callStatus` has no locale, and every English string
283
+ * it used to return was rendered as-is on sites whose every other word was
284
+ * German. What it names has to exist in every bundle, or a German visitor
285
+ * reads a raw dotted key at the one moment they are being told the call is
286
+ * over.
287
+ */
288
+ it("names copy by key, and every key it can produce exists in every bundle", () => {
289
+ const seen = new Set<string>();
290
+ const inputs: Parameters<typeof callStatus>[0][] = [
291
+ { connectionState: "idle", error: undefined },
292
+ { connectionState: "connecting", error: undefined },
293
+ { connectionState: "connected", error: undefined },
294
+ { connectionState: "connected", error: undefined, phase: "closed" },
295
+ { connectionState: "closed", error: { type: "closed_by_client" } },
296
+ { connectionState: "closed", error: { type: "room_closed", reason: "x" } },
297
+ { connectionState: "reconnecting", error: { type: "refused", code: "unauthorized" } },
298
+ { connectionState: "reconnecting", error: { type: "room_closed", reason: "x" } },
299
+ { connectionState: "reconnecting", error: { type: "draining", reconnectAfterMs: 1 }, recovering: true },
300
+ { connectionState: "reconnecting", error: { type: "draining", reconnectAfterMs: 1 } },
301
+ { connectionState: "reconnecting", error: { type: "closed_by_client" } },
302
+ { connectionState: "reconnecting", error: { type: "socket_closed" }, recovering: true },
303
+ { connectionState: "reconnecting", error: { type: "socket_closed" } },
304
+ ];
305
+ for (const input of inputs) {
306
+ const status = callStatus(input);
307
+ seen.add(status.headlineKey);
308
+ if (status.detailKey) seen.add(status.detailKey);
309
+ }
310
+ for (const locale of FORGE_LOCALES) {
311
+ const bundle = getForgeMessages(locale);
312
+ const missing = [...seen].filter((key) => !key.startsWith("forge.call_stage.") || bundle[key] === undefined);
313
+ expect(missing, `${locale} is missing: ${missing.join(", ")}`).toEqual([]);
314
+ }
315
+ // And translated English reads as the sentences the screen specs assert on.
316
+ expect(translateForge("en", callStatus({ connectionState: "connected", error: undefined }).headlineKey)).toBe(
317
+ "Connected",
318
+ );
319
+ });
320
+
274
321
  it("does not offer a retry for something retrying cannot fix", () => {
275
322
  expect(callStatus({ connectionState: "connected", error: undefined }).canRetry).toBe(false);
276
323
  expect(callStatus({ connectionState: "connecting", error: undefined }).canRetry).toBe(false);
@@ -319,7 +366,7 @@ describe("callStatus is honest about what is happening", () => {
319
366
  // ever opened.
320
367
  expect(stranded.tone).toBe("failed");
321
368
  expect(stranded.canRetry).toBe(true);
322
- expect(stranded.headline).not.toBe(moving.headline);
369
+ expect(stranded.headlineKey).not.toBe(moving.headlineKey);
323
370
  });
324
371
 
325
372
  it("stops saying `reconnecting` about a dropped socket nothing is retrying", () => {
@@ -330,8 +377,8 @@ describe("callStatus is honest about what is happening", () => {
330
377
  });
331
378
  const givenUp = callStatus({ connectionState: "reconnecting", error: { type: "socket_closed", code: 1006 } });
332
379
 
333
- expect(retrying.headline).toBe("Connection lost, reconnecting");
334
- expect(givenUp.headline).toBe("Connection lost");
380
+ expect(translateForge("en", retrying.headlineKey)).toBe("Connection lost, reconnecting");
381
+ expect(translateForge("en", givenUp.headlineKey)).toBe("Connection lost");
335
382
  expect(givenUp.tone).toBe("failed");
336
383
  expect(givenUp.canRetry).toBe(true);
337
384
  });
@@ -359,7 +406,7 @@ describe("callStatus is honest about what is happening", () => {
359
406
  for (const connectionState of ["closed", "reconnecting"] as const) {
360
407
  const status = callStatus({ connectionState, error: { type: "room_closed", reason: "the host ended it" } });
361
408
  expect(status.tone).toBe("ended");
362
- expect(status.detail).toBe("the host ended it");
409
+ expect(status.detailText).toBe("the host ended it");
363
410
  }
364
411
  });
365
412
 
@@ -376,7 +423,7 @@ describe("callStatus is honest about what is happening", () => {
376
423
  });
377
424
 
378
425
  expect(status.tone).toBe("ended");
379
- expect(status.detail).toBe("the host ended it");
426
+ expect(status.detailText).toBe("the host ended it");
380
427
  });
381
428
 
382
429
  it("is live only when the room says connected", () => {
@@ -1,7 +1,7 @@
1
1
  import { useCallback, type ReactNode } from "react";
2
2
 
3
3
  import { MediaRoomProvider } from "@tribe-nest/media-client/react";
4
- import type { MediaGrants } from "@tribe-nest/media-protocol";
4
+ import { MediaError, type MediaErrorCode, type MediaGrants } from "@tribe-nest/media-protocol";
5
5
 
6
6
  import { useForge } from "../../provider/ForgeProvider";
7
7
 
@@ -96,11 +96,42 @@ export function useBookingSessionCredentials(bookingId: string): () => Promise<B
96
96
  // function on every parent render, and `<MediaRoomProvider>` holds its
97
97
  // options in a ref precisely because that is the default mistake.
98
98
  return useCallback(async () => {
99
- const res = await client.post(`/public/coaching/bookings/${bookingId}/session/join`, {});
100
- return res.data as BookingSessionCredentials;
99
+ try {
100
+ const res = await client.post(`/public/coaching/bookings/${bookingId}/session/join`, {});
101
+ return res.data as BookingSessionCredentials;
102
+ } catch (error) {
103
+ throw credentialRefusal(error);
104
+ }
101
105
  }, [client, bookingId]);
102
106
  }
103
107
 
108
+ /**
109
+ * The server's refusal, in the SDK's vocabulary, so the screen shows it.
110
+ *
111
+ * The join endpoint answers a person who belongs in the call with the REAL
112
+ * reason when it says no: the room opens in ten minutes, the session is not
113
+ * confirmed, the window has closed. Left as an axios error, that sentence never
114
+ * reached the screen: the SDK's `causeFromError` reads a non-`MediaError` as a
115
+ * dropped socket ("Request failed with status code 409"), and `callStatus`
116
+ * then says "Connection lost" about a call the person was never in. As a
117
+ * `MediaError` the cause is `refused`, which the screen renders as "Could not
118
+ * join this call" with the server's own sentence and a Try again. That is what
119
+ * every docblock on this surface promised and what the wire could not deliver.
120
+ *
121
+ * Only a response with a status is translated. A network failure (no
122
+ * response) really is a connection problem and keeps its own reading.
123
+ */
124
+ function credentialRefusal(error: unknown): unknown {
125
+ const response = (error as { response?: { status?: number; data?: { message?: unknown } } } | null)?.response;
126
+ const status = response?.status;
127
+ if (typeof status !== "number") return error;
128
+
129
+ const message = typeof response?.data?.message === "string" ? response.data.message : undefined;
130
+ const code: MediaErrorCode =
131
+ status === 401 ? "unauthorized" : status === 404 ? "no_such_room" : status >= 500 ? "internal" : "bad_request";
132
+ return new MediaError(code, message ?? `${status}`, status >= 500);
133
+ }
134
+
104
135
  export type BookingCallProviderProps = {
105
136
  bookingId: string;
106
137
  /** Rendered while the first connection is in flight. */
@@ -136,59 +167,16 @@ export function BookingCallProvider({
136
167
  );
137
168
  }
138
169
 
139
- /** How early the room opens, and how long it stays open. Mirrors the server. */
140
- export const BOOKING_CALL_OPENS_MINUTES_BEFORE = 15;
141
- export const BOOKING_CALL_CLOSES_MINUTES_AFTER = 120;
142
-
143
- const MINUTE = 60_000;
144
-
145
- /** What a booking has to carry for the window to be computable. */
146
- export type BookingCallSubject = {
147
- status: string;
148
- sessionStartTime: string;
149
- sessionEndTime: string;
150
- location?: { type: string } | null;
151
- };
152
-
153
- export type BookingCallWindow = {
154
- /** Does this session happen on a platform video call at all? */
155
- isVideo: boolean;
156
- /** May a ticket be minted right now? Draw the Join control from this. */
157
- isOpen: boolean;
158
- /** Before the window, so the answer is "come back at". */
159
- opensAt: Date;
160
- /** After it the server refuses new tickets. */
161
- closesAt: Date;
162
- };
163
-
164
170
  /**
165
- * Is this session's room open, and when does it open?
166
- *
167
- * For DRAWING the control and nothing else. The server applies the same window
168
- * and its refusal is the decision: a clock that is ten minutes fast must not be
169
- * able to talk itself into a room, and a clock that is ten minutes slow must
170
- * not hide a session that has already started. Show the server's refusal
171
- * verbatim when a join fails rather than pre-judging it here.
172
- *
173
- * `now` is a parameter so a caller can drive it from a ticking value and get a
174
- * re-render at the moment the window opens; a hook reading the clock itself
175
- * would be stale until something else happened to re-render.
171
+ * The window logic lives in `bookingWindow.ts`, a module with NO media
172
+ * dependency, so `AccountDashboard` (in the `./ui` entry every site loads) can
173
+ * decide whether to DRAW a Join control without pulling the SDK into that
174
+ * bundle. Re-exported here so `@tribe-nest/forge/media` keeps its surface.
176
175
  */
177
- export function bookingCallWindow(booking: BookingCallSubject, now: Date = new Date()): BookingCallWindow {
178
- const startsAt = new Date(booking.sessionStartTime);
179
- const endsAt = new Date(booking.sessionEndTime);
180
- const opensAt = new Date(startsAt.getTime() - BOOKING_CALL_OPENS_MINUTES_BEFORE * MINUTE);
181
- const closesAt = new Date(endsAt.getTime() + BOOKING_CALL_CLOSES_MINUTES_AFTER * MINUTE);
182
-
183
- // A cancelled session has no room to enter, which is also why the server
184
- // withholds `location` for anything that is not confirmed - so this reads
185
- // `isVideo: false` for one, rather than offering a button that 409s.
186
- const isVideo = booking.status === "confirmed" && booking.location?.type === "video";
187
-
188
- return {
189
- isVideo,
190
- isOpen: isVideo && now.getTime() >= opensAt.getTime() && now.getTime() <= closesAt.getTime(),
191
- opensAt,
192
- closesAt,
193
- };
194
- }
176
+ export {
177
+ BOOKING_CALL_CLOSES_MINUTES_AFTER,
178
+ BOOKING_CALL_OPENS_MINUTES_BEFORE,
179
+ bookingCallWindow,
180
+ type BookingCallSubject,
181
+ type BookingCallWindow,
182
+ } from "./bookingWindow";