@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.
- package/package.json +7 -2
- package/src/contexts/CartContext.tsx +17 -1
- package/src/contexts/_tests/CartContext.spec.tsx +36 -0
- package/src/data/queries/useMyBookings.ts +9 -0
- package/src/i18n/_tests/translationKeys.spec.ts +15 -0
- package/src/i18n/de.json +40 -2
- package/src/i18n/en.json +40 -2
- package/src/ui/index.ts +25 -0
- package/src/ui/media/BookingCallScreen.tsx +33 -0
- package/src/ui/media/CallStage.tsx +153 -62
- package/src/ui/media/_tests/CallStage.spec.tsx +265 -19
- package/src/ui/media/_tests/bookingSession.spec.tsx +48 -0
- package/src/ui/media/_tests/callState.spec.ts +63 -16
- package/src/ui/media/bookingSession.tsx +45 -57
- package/src/ui/media/bookingWindow.ts +81 -0
- package/src/ui/media/callState.ts +42 -23
- package/src/ui/styled/AccountDashboard.tsx +106 -6
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +166 -0
- package/src/ui/styled/forge-utilities.css +832 -0
|
@@ -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 {
|
|
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
|
-
<
|
|
112
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
/**
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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 = () =>
|
|
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
|
|
27
|
-
identity
|
|
28
|
-
kind
|
|
29
|
-
paused
|
|
30
|
-
|
|
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.
|
|
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
|
-
|
|
264
|
-
|
|
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
|
-
}).
|
|
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.
|
|
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.
|
|
334
|
-
expect(givenUp.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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";
|