@tribe-nest/forge 3.57.0 → 3.58.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 +4 -2
- package/src/_tests/workspaceAliases.ts +1 -0
- package/src/contexts/CartContext.tsx +88 -5
- package/src/data/queries/useCallBreakouts.ts +112 -0
- package/src/data/queries/useCheckouts.ts +15 -2
- package/src/data/queries/useEvents.ts +68 -0
- package/src/data/queries/useMyBookings.ts +16 -0
- package/src/data/queries/useNotifications.ts +1 -1
- package/src/i18n/de.json +79 -0
- package/src/i18n/en.json +79 -0
- package/src/index.ts +1 -0
- package/src/server/_tests/buildPwaHead.spec.ts +1 -1
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +1 -1
- package/src/server/index.ts +1 -1
- package/src/server/pwa.ts +1 -1
- package/src/types/models.ts +10 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +55 -1
- package/src/ui/analytics/_tests/ForgeAnalytics.engagement.spec.tsx +78 -0
- package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +3 -1
- package/src/ui/headless/checkout/_tests/cartResume.spec.ts +3 -10
- package/src/ui/headless/checkout/_tests/seatConflict.spec.ts +187 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +3 -11
- package/src/ui/headless/checkout/_tests/useCheckoutSeats.spec.tsx +313 -0
- package/src/ui/headless/checkout/seatConflict.ts +117 -0
- package/src/ui/headless/checkout/useCheckout.ts +178 -55
- package/src/ui/headless/event/_tests/seatingFixture.ts +111 -0
- package/src/ui/headless/event/_tests/useEventCheckoutSeats.spec.tsx +443 -0
- package/src/ui/headless/event/_tests/useEventSeating.spec.tsx +154 -0
- package/src/ui/headless/event/useEventCheckout.ts +362 -22
- package/src/ui/headless/event/useEventSeating.ts +233 -0
- package/src/ui/headless/forms/_tests/usePhoneInput.spec.ts +187 -0
- package/src/ui/headless/forms/useEmailListForm.ts +37 -6
- package/src/ui/headless/forms/usePhoneInput.ts +294 -0
- package/src/ui/headless/forms/useSectionedForm.ts +2 -2
- package/src/ui/headless/index.ts +25 -1
- package/src/ui/index.ts +7 -0
- package/src/ui/media/CallStage.tsx +107 -10
- package/src/ui/media/_tests/CallBreakoutsPanel.spec.tsx +271 -0
- package/src/ui/media/_tests/CallStageBreakouts.spec.tsx +212 -0
- package/src/ui/media/_tests/callState.spec.ts +103 -1
- package/src/ui/media/callBreakouts.ts +57 -0
- package/src/ui/media/callState.ts +83 -23
- package/src/ui/media/index.ts +11 -0
- package/src/ui/media/panels/CallBreakoutsPanel.tsx +395 -0
- package/src/ui/media/panels/CallSidePanel.tsx +37 -4
- package/src/ui/media/stage/CallCenterNotice.tsx +5 -3
- package/src/ui/media/stage/CallControlsBar.tsx +46 -0
- package/src/ui/media/stage/CallTopBar.tsx +1 -1
- package/src/ui/shell/TribeNestApp.tsx +1 -1
- package/src/ui/styled/AccountDashboard.tsx +5 -0
- package/src/ui/styled/BundleConfirmation.tsx +47 -2
- package/src/ui/styled/Cart.tsx +211 -135
- package/src/ui/styled/Checkout.tsx +163 -32
- package/src/ui/styled/EmailListForm.tsx +7 -9
- package/src/ui/styled/EventTickets.tsx +143 -4
- package/src/ui/styled/PhoneInput.tsx +127 -0
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -0
- package/src/ui/styled/_tests/CartSeats.spec.tsx +109 -0
- package/src/ui/styled/_tests/CheckoutSeatsConflict.spec.tsx +194 -0
- package/src/ui/styled/_tests/EventSeatPicker.spec.tsx +199 -0
- package/src/ui/styled/forge-utilities.css +67 -0
- package/src/ui/styled/seating/EventSeatPicker.tsx +190 -0
- package/src/ui/styled/seating/SeatLegend.tsx +73 -0
- package/src/ui/styled/seating/SeatMapCanvas.tsx +647 -0
- package/src/ui/styled/seating/paint.ts +79 -0
- package/src/ui/styled/seating/useSeatMapViewport.ts +248 -0
- package/src/ui/styled/seating/viewport.ts +86 -0
- package/src/utils/_tests/engagementTracker.spec.ts +111 -0
- package/src/utils/engagementTracker.ts +73 -0
- package/src/utils/landing.ts +1 -1
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { translateForge, type ForgeT } from "../../../i18n";
|
|
6
|
+
import type { CallStageBreakouts } from "../callBreakouts";
|
|
7
|
+
import { CallSidePanel, showsRoomsTab } from "../panels/CallSidePanel";
|
|
8
|
+
import type { StageTokens } from "../stage/stageTheme";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The rooms rail, rendered against a fake adapter.
|
|
12
|
+
*
|
|
13
|
+
* Three things are worth being sure about here, and none of them is "did it
|
|
14
|
+
* render": that a call WITHOUT rooms draws exactly the two tabs it always drew,
|
|
15
|
+
* that the host's controls reach the adapter with the right arguments (an
|
|
16
|
+
* assignment sent to the wrong room is silent), and that closing every room
|
|
17
|
+
* takes a second press.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
afterEach(cleanup);
|
|
21
|
+
|
|
22
|
+
const tokens: StageTokens = {
|
|
23
|
+
stageBg: "#0c0d10",
|
|
24
|
+
stageSurface: "#17181d",
|
|
25
|
+
stageChrome: "#1e2026",
|
|
26
|
+
stageText: "#f4f5f7",
|
|
27
|
+
stageMuted: "#9aa0ab",
|
|
28
|
+
stageBorder: "#2c2f37",
|
|
29
|
+
accent: "#0b6",
|
|
30
|
+
accentText: "#fff",
|
|
31
|
+
danger: "#c00",
|
|
32
|
+
cornerRadius: 8,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const t: ForgeT = (key, vars) => translateForge("en", key, vars);
|
|
36
|
+
|
|
37
|
+
const adapter = (over: Partial<CallStageBreakouts> = {}): CallStageBreakouts => ({
|
|
38
|
+
ready: true,
|
|
39
|
+
canManage: false,
|
|
40
|
+
active: false,
|
|
41
|
+
current: null,
|
|
42
|
+
main: { participantCount: 0, participants: [] },
|
|
43
|
+
rooms: [],
|
|
44
|
+
pending: false,
|
|
45
|
+
create: vi.fn(async () => undefined),
|
|
46
|
+
assign: vi.fn(async () => undefined),
|
|
47
|
+
moveSelf: vi.fn(async () => undefined),
|
|
48
|
+
closeAll: vi.fn(async () => undefined),
|
|
49
|
+
refresh: vi.fn(),
|
|
50
|
+
...over,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const ROOMS = [
|
|
54
|
+
{
|
|
55
|
+
publicId: "room-1",
|
|
56
|
+
name: "Room 1",
|
|
57
|
+
position: 1,
|
|
58
|
+
participantCount: 1,
|
|
59
|
+
participants: [{ identity: "anna", name: "Anna" }],
|
|
60
|
+
assigned: [
|
|
61
|
+
{ identity: "anna", name: "Anna" },
|
|
62
|
+
{ identity: "ben", name: "Ben" },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
{ publicId: "room-2", name: "Room 2", position: 2, participantCount: 0, participants: [], assigned: [] },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const mount = (
|
|
69
|
+
props: { breakouts?: CallStageBreakouts; tab?: "chat" | "people" | "rooms"; onSelfMove?: (id: string | null) => void } = {},
|
|
70
|
+
) => {
|
|
71
|
+
const onTab = vi.fn();
|
|
72
|
+
const utils = render(
|
|
73
|
+
<CallSidePanel
|
|
74
|
+
tab={props.tab ?? "rooms"}
|
|
75
|
+
onTab={onTab}
|
|
76
|
+
onClose={() => undefined}
|
|
77
|
+
tokens={tokens}
|
|
78
|
+
t={t}
|
|
79
|
+
tiles={[]}
|
|
80
|
+
hands={[]}
|
|
81
|
+
isHost={!!props.breakouts?.canManage}
|
|
82
|
+
myIdentity="host-1"
|
|
83
|
+
onLowerHand={() => undefined}
|
|
84
|
+
onLowerAll={() => undefined}
|
|
85
|
+
unread={0}
|
|
86
|
+
breakouts={props.breakouts}
|
|
87
|
+
onSelfMove={props.onSelfMove ?? (() => undefined)}
|
|
88
|
+
movePending={false}
|
|
89
|
+
moveError={null}
|
|
90
|
+
/>,
|
|
91
|
+
);
|
|
92
|
+
return { ...utils, onTab };
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
describe("the rooms tab exists only when there is something behind it", () => {
|
|
96
|
+
it("draws no rooms tab and no rooms panel when the page injected no adapter", () => {
|
|
97
|
+
const { queryByText, queryByTestId, getByTestId } = mount({ breakouts: undefined, tab: "rooms" });
|
|
98
|
+
expect(showsRoomsTab(undefined)).toBe(false);
|
|
99
|
+
expect(queryByText("Rooms")).toBeNull();
|
|
100
|
+
expect(queryByTestId("call-breakouts-panel")).toBeNull();
|
|
101
|
+
// A rooms tab asked for with nothing behind it falls back to the roster.
|
|
102
|
+
expect(getByTestId("call-participants-panel")).toBeTruthy();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("hides the tab from an attendee while no room is open, and shows it once one is", () => {
|
|
106
|
+
expect(showsRoomsTab(adapter({ canManage: false, active: false }))).toBe(false);
|
|
107
|
+
expect(showsRoomsTab(adapter({ canManage: false, active: true }))).toBe(true);
|
|
108
|
+
// The host always has it: that is where rooms get created.
|
|
109
|
+
expect(showsRoomsTab(adapter({ canManage: true, active: false }))).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("the host's view", () => {
|
|
114
|
+
it("offers the create form while no room is open and sends count and assignment", async () => {
|
|
115
|
+
const breakouts = adapter({ canManage: true });
|
|
116
|
+
const { getByLabelText, getByText, queryByTestId } = mount({ breakouts });
|
|
117
|
+
|
|
118
|
+
expect(queryByTestId("breakouts-close-all")).toBeNull();
|
|
119
|
+
fireEvent.change(getByLabelText("Number of rooms"), { target: { value: "3" } });
|
|
120
|
+
fireEvent.change(getByLabelText("Assign people"), { target: { value: "manual" } });
|
|
121
|
+
fireEvent.click(getByText("Create rooms"));
|
|
122
|
+
|
|
123
|
+
await waitFor(() => expect(breakouts.create).toHaveBeenCalledWith({ count: 3, assign: "manual" }));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("refuses a count outside 1..20 without asking the server", () => {
|
|
127
|
+
const breakouts = adapter({ canManage: true });
|
|
128
|
+
const { getByLabelText, getByText } = mount({ breakouts });
|
|
129
|
+
fireEvent.change(getByLabelText("Number of rooms"), { target: { value: "21" } });
|
|
130
|
+
expect((getByText("Create rooms") as HTMLButtonElement).disabled).toBe(true);
|
|
131
|
+
fireEvent.change(getByLabelText("Number of rooms"), { target: { value: "0" } });
|
|
132
|
+
expect((getByText("Create rooms") as HTMLButtonElement).disabled).toBe(true);
|
|
133
|
+
expect(breakouts.create).not.toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("lists the main room and every room with live counts, arrivals and the people on their way", () => {
|
|
137
|
+
const breakouts = adapter({
|
|
138
|
+
canManage: true,
|
|
139
|
+
active: true,
|
|
140
|
+
main: { participantCount: 2, participants: [{ identity: "host-1", name: "Host" }, { identity: "carla", name: "Carla" }] },
|
|
141
|
+
rooms: ROOMS,
|
|
142
|
+
});
|
|
143
|
+
const { getByTestId } = mount({ breakouts });
|
|
144
|
+
|
|
145
|
+
const main = getByTestId("breakouts-room-main");
|
|
146
|
+
expect(main.textContent).toContain("Main room");
|
|
147
|
+
expect(main.textContent).toContain("2 in this room");
|
|
148
|
+
expect(main.textContent).toContain("Host (you)");
|
|
149
|
+
expect(main.textContent).toContain("Carla");
|
|
150
|
+
|
|
151
|
+
const room1 = getByTestId("breakouts-room-room-1");
|
|
152
|
+
expect(room1.textContent).toContain("Room 1");
|
|
153
|
+
expect(room1.textContent).toContain("1 in this room");
|
|
154
|
+
expect(room1.textContent).toContain("Anna");
|
|
155
|
+
// Assigned, not yet arrived: listed where they are GOING, and said so.
|
|
156
|
+
expect(room1.textContent).toContain("Ben (on the way)");
|
|
157
|
+
|
|
158
|
+
expect(getByTestId("breakouts-room-room-2").textContent).toContain("Nobody here yet");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("assigns a person through the select beside their name, and never offers one for the host themselves", async () => {
|
|
162
|
+
const breakouts = adapter({
|
|
163
|
+
canManage: true,
|
|
164
|
+
active: true,
|
|
165
|
+
main: { participantCount: 2, participants: [{ identity: "host-1", name: "Host" }, { identity: "carla", name: "Carla" }] },
|
|
166
|
+
rooms: ROOMS,
|
|
167
|
+
});
|
|
168
|
+
const { getByLabelText, queryByLabelText } = mount({ breakouts });
|
|
169
|
+
|
|
170
|
+
fireEvent.change(getByLabelText("Move Carla to a room"), { target: { value: "room-2" } });
|
|
171
|
+
await waitFor(() => expect(breakouts.assign).toHaveBeenCalledWith("carla", "room-2"));
|
|
172
|
+
|
|
173
|
+
// Back to main is `null`, not the empty string the option carries.
|
|
174
|
+
fireEvent.change(getByLabelText("Move Anna to a room"), { target: { value: "" } });
|
|
175
|
+
await waitFor(() => expect(breakouts.assign).toHaveBeenCalledWith("anna", null));
|
|
176
|
+
|
|
177
|
+
// The host moves themselves with Join, never by reassigning their own row.
|
|
178
|
+
expect(queryByLabelText("Move Host to a room")).toBeNull();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("joins a room through the stage's self-move, and marks the room the host is in", () => {
|
|
182
|
+
const onSelfMove = vi.fn();
|
|
183
|
+
const breakouts = adapter({ canManage: true, active: true, current: { publicId: "room-1", name: "Room 1" }, rooms: ROOMS });
|
|
184
|
+
const { getByTestId, queryByTestId } = mount({ breakouts, onSelfMove });
|
|
185
|
+
|
|
186
|
+
// In Room 1: no Join for it, a marker instead; Join for the other room and
|
|
187
|
+
// Return to main for the main room.
|
|
188
|
+
expect(queryByTestId("breakouts-join-room-1")).toBeNull();
|
|
189
|
+
expect(getByTestId("breakouts-room-room-1").textContent).toContain("You are here");
|
|
190
|
+
|
|
191
|
+
fireEvent.click(getByTestId("breakouts-join-room-2"));
|
|
192
|
+
expect(onSelfMove).toHaveBeenCalledWith("room-2");
|
|
193
|
+
fireEvent.click(getByTestId("breakouts-join-main"));
|
|
194
|
+
expect(onSelfMove).toHaveBeenCalledWith(null);
|
|
195
|
+
// The panel does not touch the adapter for a self-move; the stage does.
|
|
196
|
+
expect(breakouts.moveSelf).not.toHaveBeenCalled();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("closes every room only after the warning has been confirmed", async () => {
|
|
200
|
+
const breakouts = adapter({ canManage: true, active: true, rooms: ROOMS });
|
|
201
|
+
const { getByTestId, getByText, queryByTestId, queryByText } = mount({ breakouts });
|
|
202
|
+
|
|
203
|
+
// The warning is not on screen until the control is pressed.
|
|
204
|
+
expect(queryByText(/Everyone comes back to the main room/)).toBeNull();
|
|
205
|
+
fireEvent.click(getByTestId("breakouts-close-all"));
|
|
206
|
+
expect(breakouts.closeAll).not.toHaveBeenCalled();
|
|
207
|
+
// Visible as content: a warning about what a control is about to do is
|
|
208
|
+
// never a `?` hint.
|
|
209
|
+
expect(getByText(/Everyone comes back to the main room/)).toBeTruthy();
|
|
210
|
+
|
|
211
|
+
fireEvent.click(getByText("Keep them open"));
|
|
212
|
+
expect(breakouts.closeAll).not.toHaveBeenCalled();
|
|
213
|
+
expect(queryByTestId("breakouts-close-confirm")).toBeNull();
|
|
214
|
+
|
|
215
|
+
fireEvent.click(getByTestId("breakouts-close-all"));
|
|
216
|
+
fireEvent.click(getByTestId("breakouts-close-confirm"));
|
|
217
|
+
await waitFor(() => expect(breakouts.closeAll).toHaveBeenCalledTimes(1));
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("shows the server's own sentence when an action is refused", async () => {
|
|
221
|
+
const breakouts = adapter({
|
|
222
|
+
canManage: true,
|
|
223
|
+
create: vi.fn(async () => {
|
|
224
|
+
throw new Error("Rooms are already open.");
|
|
225
|
+
}),
|
|
226
|
+
});
|
|
227
|
+
const { getByText, getByTestId } = mount({ breakouts });
|
|
228
|
+
fireEvent.click(getByText("Create rooms"));
|
|
229
|
+
await waitFor(() => expect(getByTestId("breakouts-error").textContent).toBe("Rooms are already open."));
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("keeps the explanation behind a `?` with a name that says what it explains", async () => {
|
|
233
|
+
const { getByLabelText, queryByText } = mount({ breakouts: adapter({ canManage: true }) });
|
|
234
|
+
expect(queryByText(/Each room is a separate call/)).toBeNull();
|
|
235
|
+
fireEvent.click(getByLabelText("What happens when I create rooms?"));
|
|
236
|
+
await waitFor(() => expect(queryByText(/Each room is a separate call/)).toBeTruthy());
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("the attendee's view", () => {
|
|
241
|
+
it("says where you are, lists the rooms, and offers no host control", () => {
|
|
242
|
+
const breakouts = adapter({ canManage: false, active: true, current: { publicId: "room-2", name: "Room 2" }, rooms: ROOMS });
|
|
243
|
+
const { getByTestId, queryByTestId, queryByLabelText, queryByText } = mount({ breakouts });
|
|
244
|
+
|
|
245
|
+
expect(getByTestId("breakouts-where").textContent).toBe("You are in Room 2");
|
|
246
|
+
expect(queryByText("Room 1")).toBeTruthy();
|
|
247
|
+
expect(queryByText("Main room")).toBeTruthy();
|
|
248
|
+
expect(queryByTestId("breakouts-close-all")).toBeNull();
|
|
249
|
+
expect(queryByTestId("breakouts-create")).toBeNull();
|
|
250
|
+
expect(queryByLabelText(/Move .* to a room/)).toBeNull();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("offers Return to main room only while in a breakout, and routes it through the stage", () => {
|
|
254
|
+
const onSelfMove = vi.fn();
|
|
255
|
+
const inRoom = adapter({ canManage: false, active: true, current: { publicId: "room-2", name: "Room 2" }, rooms: ROOMS });
|
|
256
|
+
const { getByTestId, unmount } = mount({ breakouts: inRoom, onSelfMove });
|
|
257
|
+
fireEvent.click(getByTestId("breakouts-return-main"));
|
|
258
|
+
expect(onSelfMove).toHaveBeenCalledWith(null);
|
|
259
|
+
unmount();
|
|
260
|
+
|
|
261
|
+
const inMain = adapter({ canManage: false, active: true, current: null, rooms: ROOMS });
|
|
262
|
+
const { queryByTestId, getByTestId: get } = mount({ breakouts: inMain });
|
|
263
|
+
expect(queryByTestId("breakouts-return-main")).toBeNull();
|
|
264
|
+
expect(get("breakouts-where").textContent).toBe("You are in the main room");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("says it is loading before the first describe has landed", () => {
|
|
268
|
+
const { getByText } = mount({ breakouts: adapter({ ready: false, active: true }) });
|
|
269
|
+
expect(getByText("Loading rooms…")).toBeTruthy();
|
|
270
|
+
});
|
|
271
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { MediaRoom, type MediaDevice, type MediaTransport } from "@tribe-nest/media-client";
|
|
7
|
+
import { MediaRoomProvider } from "@tribe-nest/media-client/react";
|
|
8
|
+
|
|
9
|
+
import { ForgeI18nProvider } from "../../../i18n";
|
|
10
|
+
import { ForgeThemeProvider, type ForgeTheme } from "../../theme/ForgeThemeProvider";
|
|
11
|
+
import type { CallStageBreakouts } from "../callBreakouts";
|
|
12
|
+
import { CallStage } from "../CallStage";
|
|
13
|
+
import { FakeNode, settle } from "./fakeNode";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Breakout rooms on the STAGE: the controls bar and the centre notice.
|
|
17
|
+
*
|
|
18
|
+
* `CallBreakoutsPanel.spec.tsx` covers the rail against a fake adapter; this
|
|
19
|
+
* file covers what the orchestrator adds around it, driven through a real
|
|
20
|
+
* `<MediaRoomProvider>` the way `CallStage.spec.tsx` is, because the two
|
|
21
|
+
* decisions worth pinning here (nothing drawn without the prop, and "waiting"
|
|
22
|
+
* rather than "everyone left" while rooms are open) live in the join between
|
|
23
|
+
* the room's state and the stage.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
cleanup();
|
|
28
|
+
delete (navigator as { mediaDevices?: unknown }).mediaDevices;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
beforeAll(() => {
|
|
32
|
+
if (!("MediaStream" in globalThis)) {
|
|
33
|
+
(globalThis as { MediaStream?: unknown }).MediaStream = class {
|
|
34
|
+
constructor(readonly tracks: unknown[] = []) {}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const fakeTransport = (): MediaTransport => ({
|
|
40
|
+
id: "t-1",
|
|
41
|
+
async produce() {
|
|
42
|
+
return { id: "p-local", kind: "audio", closed: false, replaceTrack: vi.fn(async () => undefined), pause: vi.fn(), resume: vi.fn(), close: vi.fn() };
|
|
43
|
+
},
|
|
44
|
+
async consume(input) {
|
|
45
|
+
return {
|
|
46
|
+
id: `c-${input.producerId}`,
|
|
47
|
+
producerId: input.producerId,
|
|
48
|
+
kind: input.kind,
|
|
49
|
+
track: { kind: input.kind, stop: vi.fn() } as unknown as MediaStreamTrack,
|
|
50
|
+
pause: vi.fn(),
|
|
51
|
+
resume: vi.fn(),
|
|
52
|
+
close: vi.fn(),
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
close: vi.fn(),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const fakeDevice = (): MediaDevice => ({
|
|
59
|
+
async load() {},
|
|
60
|
+
loaded: true,
|
|
61
|
+
rtpCapabilities: { codecs: [] } as never,
|
|
62
|
+
canProduce: () => true,
|
|
63
|
+
createSendTransport: () => fakeTransport(),
|
|
64
|
+
createRecvTransport: () => fakeTransport(),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const theme: ForgeTheme = { colors: { text: "#101010", background: "#fafafa", primary: "#0b6", surface: "#eeeeee" }, cornerRadius: 10 };
|
|
68
|
+
|
|
69
|
+
const mount = (node: FakeNode, children: ReactNode) =>
|
|
70
|
+
render(
|
|
71
|
+
<ForgeI18nProvider locale="en">
|
|
72
|
+
<ForgeThemeProvider theme={theme}>
|
|
73
|
+
<MediaRoomProvider
|
|
74
|
+
getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
|
|
75
|
+
device={fakeDevice}
|
|
76
|
+
webSocket={node.factory}
|
|
77
|
+
autoSubscribe={false}
|
|
78
|
+
reconnect={{ baseMs: 1, maxMs: 4, factor: 1, jitter: "none" }}
|
|
79
|
+
>
|
|
80
|
+
{children}
|
|
81
|
+
</MediaRoomProvider>
|
|
82
|
+
</ForgeThemeProvider>
|
|
83
|
+
</ForgeI18nProvider>,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const adapter = (over: Partial<CallStageBreakouts> = {}): CallStageBreakouts => ({
|
|
87
|
+
ready: true,
|
|
88
|
+
canManage: false,
|
|
89
|
+
active: false,
|
|
90
|
+
current: null,
|
|
91
|
+
main: { participantCount: 0, participants: [] },
|
|
92
|
+
rooms: [{ publicId: "room-1", name: "Room 1", position: 1, participantCount: 0, participants: [], assigned: [] }],
|
|
93
|
+
pending: false,
|
|
94
|
+
create: vi.fn(async () => undefined),
|
|
95
|
+
assign: vi.fn(async () => undefined),
|
|
96
|
+
moveSelf: vi.fn(async () => undefined),
|
|
97
|
+
closeAll: vi.fn(async () => undefined),
|
|
98
|
+
refresh: vi.fn(),
|
|
99
|
+
...over,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Layer B of the breakout contract ships `MediaRoom.moveTo()` and the
|
|
104
|
+
* `moveRequested` reducer case in `@tribe-nest/media-client`. The cases that
|
|
105
|
+
* need the SDK's half are skipped, not failed, until that build is in the
|
|
106
|
+
* tree; they light up on their own the moment it is.
|
|
107
|
+
*/
|
|
108
|
+
const sdkMoves = typeof (MediaRoom.prototype as unknown as { moveTo?: unknown }).moveTo === "function";
|
|
109
|
+
|
|
110
|
+
describe("without the prop, the stage is the stage it was", () => {
|
|
111
|
+
it("draws no Rooms button, no Return to main, and no rooms tab", async () => {
|
|
112
|
+
const node = new FakeNode({ peers: [{ identity: "anna", name: "Anna" }] });
|
|
113
|
+
const { getByTestId, queryByTestId, queryByText } = mount(node, <CallStage />);
|
|
114
|
+
await waitFor(() => expect(getByTestId("tile-anna")).toBeTruthy());
|
|
115
|
+
|
|
116
|
+
expect(queryByTestId("toggle-rooms")).toBeNull();
|
|
117
|
+
expect(queryByTestId("return-to-main")).toBeNull();
|
|
118
|
+
fireEvent.click(getByTestId("toggle-people"));
|
|
119
|
+
await waitFor(() => expect(getByTestId("call-side-panel")).toBeTruthy());
|
|
120
|
+
expect(queryByText("Rooms")).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("still says everyone left when the room empties", async () => {
|
|
124
|
+
const node = new FakeNode({ peers: [{ identity: "anna", name: "Anna" }] });
|
|
125
|
+
const { getByTestId } = mount(node, <CallStage />);
|
|
126
|
+
await waitFor(() => expect(getByTestId("tile-anna")).toBeTruthy());
|
|
127
|
+
node.event({ event: "peerLeft", identity: "anna" });
|
|
128
|
+
await waitFor(() => expect(getByTestId("stage-notice-everyone_left")).toBeTruthy());
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("the controls bar", () => {
|
|
133
|
+
it("offers Return to main only to somebody in a breakout room, and it writes the assignment first", async () => {
|
|
134
|
+
const breakouts = adapter({ active: true, current: { publicId: "room-1", name: "Room 1" } });
|
|
135
|
+
const node = new FakeNode({ peers: [{ identity: "anna", name: "Anna" }] });
|
|
136
|
+
const { getByTestId } = mount(node, <CallStage breakouts={breakouts} />);
|
|
137
|
+
await waitFor(() => expect(getByTestId("tile-anna")).toBeTruthy());
|
|
138
|
+
|
|
139
|
+
fireEvent.click(getByTestId("return-to-main"));
|
|
140
|
+
// The assignment is written BEFORE the reconnect: the next ticket is
|
|
141
|
+
// minted for whatever the assignment says, so this order is the move.
|
|
142
|
+
await waitFor(() => expect(breakouts.moveSelf).toHaveBeenCalledWith(null));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("offers no Return to main in the main room, and a Rooms button once rooms exist", async () => {
|
|
146
|
+
const breakouts = adapter({ active: true, current: null });
|
|
147
|
+
const node = new FakeNode({ peers: [{ identity: "anna", name: "Anna" }] });
|
|
148
|
+
const { getByTestId, queryByTestId } = mount(node, <CallStage breakouts={breakouts} />);
|
|
149
|
+
await waitFor(() => expect(getByTestId("tile-anna")).toBeTruthy());
|
|
150
|
+
|
|
151
|
+
expect(queryByTestId("return-to-main")).toBeNull();
|
|
152
|
+
fireEvent.click(getByTestId("toggle-rooms"));
|
|
153
|
+
await waitFor(() => expect(getByTestId("call-breakouts-panel")).toBeTruthy());
|
|
154
|
+
expect(getByTestId("breakouts-where").textContent).toBe("You are in the main room");
|
|
155
|
+
// An attendee's list is read when the tab opens; the adapter does not poll for them.
|
|
156
|
+
expect(breakouts.refresh).toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("gives the host the Rooms button before any room exists, and the create form behind it", async () => {
|
|
160
|
+
const breakouts = adapter({ canManage: true, active: false, rooms: [] });
|
|
161
|
+
const node = new FakeNode({ peers: [{ identity: "anna", name: "Anna" }] });
|
|
162
|
+
const { getByTestId } = mount(node, <CallStage breakouts={breakouts} isHost />);
|
|
163
|
+
await waitFor(() => expect(getByTestId("tile-anna")).toBeTruthy());
|
|
164
|
+
|
|
165
|
+
fireEvent.click(getByTestId("toggle-rooms"));
|
|
166
|
+
await waitFor(() => expect(getByTestId("breakouts-create")).toBeTruthy());
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("the centre of the stage while rooms are open", () => {
|
|
171
|
+
it("says WAITING, not everyone left, when the host finds an empty room", async () => {
|
|
172
|
+
const breakouts = adapter({ canManage: true, active: true, current: { publicId: "room-1", name: "Room 1" } });
|
|
173
|
+
const node = new FakeNode({ peers: [{ identity: "anna", name: "Anna" }] });
|
|
174
|
+
const { getByTestId, queryByTestId } = mount(node, <CallStage breakouts={breakouts} isHost />);
|
|
175
|
+
await waitFor(() => expect(getByTestId("tile-anna")).toBeTruthy());
|
|
176
|
+
|
|
177
|
+
node.event({ event: "peerLeft", identity: "anna" });
|
|
178
|
+
await waitFor(() => expect(getByTestId("stage-notice-waiting")).toBeTruthy());
|
|
179
|
+
expect(queryByTestId("stage-notice-everyone_left")).toBeNull();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it.skipIf(!sdkMoves)("says where you are being moved when the node asks you to change rooms", async () => {
|
|
183
|
+
const breakouts = adapter({ active: true });
|
|
184
|
+
const node = new FakeNode({ peers: [{ identity: "anna", name: "Anna" }] });
|
|
185
|
+
const { getByTestId } = mount(node, <CallStage breakouts={breakouts} />);
|
|
186
|
+
await waitFor(() => expect(getByTestId("tile-anna")).toBeTruthy());
|
|
187
|
+
|
|
188
|
+
node.event({ event: "moveRequested", room: "booking-1~b1", name: "Room 1" });
|
|
189
|
+
|
|
190
|
+
const notice = await waitFor(() => getByTestId("stage-notice-moving"));
|
|
191
|
+
expect(notice.textContent).toContain("Moving you to Room 1");
|
|
192
|
+
// The SDK leaves and reconnects with a fresh ticket: a SECOND socket.
|
|
193
|
+
await waitFor(() => expect(node.sockets.length).toBe(2));
|
|
194
|
+
await settle(30);
|
|
195
|
+
// Back in a room, the assignment view is re-read.
|
|
196
|
+
await waitFor(() => expect(breakouts.refresh).toHaveBeenCalled());
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it.skipIf(!sdkMoves)("moves you itself after Return to main, and names the destination while it does", async () => {
|
|
200
|
+
const breakouts = adapter({ active: true, current: { publicId: "room-1", name: "Room 1" } });
|
|
201
|
+
const node = new FakeNode({ peers: [{ identity: "anna", name: "Anna" }] });
|
|
202
|
+
const { getByTestId } = mount(node, <CallStage breakouts={breakouts} />);
|
|
203
|
+
await waitFor(() => expect(getByTestId("tile-anna")).toBeTruthy());
|
|
204
|
+
|
|
205
|
+
fireEvent.click(getByTestId("return-to-main"));
|
|
206
|
+
|
|
207
|
+
const notice = await waitFor(() => getByTestId("stage-notice-moving"));
|
|
208
|
+
expect(notice.textContent).toContain("Returning you to the main room");
|
|
209
|
+
await waitFor(() => expect(node.sockets.length).toBe(2));
|
|
210
|
+
await waitFor(() => expect(breakouts.refresh).toHaveBeenCalled());
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -4,7 +4,15 @@ import type { ProducerEntry } from "@tribe-nest/media-client";
|
|
|
4
4
|
import type { Peer } from "@tribe-nest/media-protocol";
|
|
5
5
|
|
|
6
6
|
import { FORGE_LOCALES, getForgeMessages, translateForge } from "../../../i18n";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
breakoutMoveHeadline,
|
|
9
|
+
callHeadCount,
|
|
10
|
+
callStatus,
|
|
11
|
+
callTiles,
|
|
12
|
+
canPublishSource,
|
|
13
|
+
remoteAudioProducerIds,
|
|
14
|
+
stageNotice,
|
|
15
|
+
} from "../callState";
|
|
8
16
|
|
|
9
17
|
/**
|
|
10
18
|
* The two decisions a call screen gets wrong.
|
|
@@ -298,6 +306,14 @@ describe("callStatus is honest about what is happening", () => {
|
|
|
298
306
|
{ connectionState: "reconnecting", error: { type: "room_closed", reason: "x" } },
|
|
299
307
|
{ connectionState: "reconnecting", error: { type: "draining", reconnectAfterMs: 1 }, recovering: true },
|
|
300
308
|
{ connectionState: "reconnecting", error: { type: "draining", reconnectAfterMs: 1 } },
|
|
309
|
+
// A breakout move rides the draining cause; both of its headlines must exist too.
|
|
310
|
+
{
|
|
311
|
+
connectionState: "reconnecting",
|
|
312
|
+
error: { type: "draining", reconnectAfterMs: 0 },
|
|
313
|
+
recovering: true,
|
|
314
|
+
moving: { room: "r~b2", name: "Room 2" },
|
|
315
|
+
},
|
|
316
|
+
{ connectionState: "reconnecting", error: { type: "draining", reconnectAfterMs: 0 }, recovering: true, moving: { room: "r", name: null } },
|
|
301
317
|
{ connectionState: "reconnecting", error: { type: "closed_by_client" } },
|
|
302
318
|
{ connectionState: "reconnecting", error: { type: "socket_closed" }, recovering: true },
|
|
303
319
|
{ connectionState: "reconnecting", error: { type: "socket_closed" } },
|
|
@@ -548,6 +564,92 @@ describe("stageNotice: the ONE decision for the centre of the stage", () => {
|
|
|
548
564
|
}),
|
|
549
565
|
).toBeNull();
|
|
550
566
|
});
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Breakout rooms.
|
|
570
|
+
*
|
|
571
|
+
* A move between rooms is a leave and a reconnect, and for the second it
|
|
572
|
+
* takes the stage is empty. Read without knowing about the move, that is
|
|
573
|
+
* "everyone else has left the call" over a call nobody left, at exactly the
|
|
574
|
+
* moment the person is being sent somewhere.
|
|
575
|
+
*/
|
|
576
|
+
describe("with breakout rooms", () => {
|
|
577
|
+
const reconnecting = status({ tone: "reconnecting", headlineKey: "forge.call_stage.status_moving" });
|
|
578
|
+
|
|
579
|
+
it("says where you are going, and that wins over the empty-stage notices", () => {
|
|
580
|
+
const toRoom = stageNotice({ status: reconnecting, remoteCount: 0, everSawPeers: true, moving: { room: "r~b2", name: "Room 2" } });
|
|
581
|
+
expect(toRoom).toMatchObject({
|
|
582
|
+
kind: "moving",
|
|
583
|
+
headlineKey: "forge.call_stage.breakouts.status_moving",
|
|
584
|
+
headlineVars: { room: "Room 2" },
|
|
585
|
+
canRetry: false,
|
|
586
|
+
});
|
|
587
|
+
expect(translateForge("en", toRoom!.headlineKey, toRoom!.headlineVars)).toBe("Moving you to Room 2");
|
|
588
|
+
|
|
589
|
+
// The same move while still connected (the frame landed, the leave has
|
|
590
|
+
// not finished) and while alone: the move is the truth in both.
|
|
591
|
+
expect(stageNotice({ status: status(), remoteCount: 3, everSawPeers: true, moving: { room: "r~b2", name: "Room 2" } })?.kind).toBe("moving");
|
|
592
|
+
expect(stageNotice({ status: status(), remoteCount: 0, everSawPeers: false, moving: { room: "r~b2", name: "Room 2" } })?.kind).toBe("moving");
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("says you are coming back when the move has no room name", () => {
|
|
596
|
+
const home = stageNotice({ status: reconnecting, remoteCount: 0, everSawPeers: true, moving: { room: "r", name: null } });
|
|
597
|
+
expect(home).toMatchObject({ kind: "moving", headlineKey: "forge.call_stage.breakouts.status_returning" });
|
|
598
|
+
expect(home!.headlineVars).toBeUndefined();
|
|
599
|
+
expect(translateForge("en", home!.headlineKey)).toBe("Returning you to the main room");
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("does NOT let a move hide a terminal state", () => {
|
|
603
|
+
// A move that failed (the rejoin was refused) is the thing to act on; a
|
|
604
|
+
// spinner saying "Moving you to Room 2" over it is the promise nobody
|
|
605
|
+
// is keeping.
|
|
606
|
+
const failed = stageNotice({
|
|
607
|
+
status: status({ tone: "failed", headlineKey: "forge.call_stage.status_move_failed", canRetry: true }),
|
|
608
|
+
remoteCount: 0,
|
|
609
|
+
everSawPeers: true,
|
|
610
|
+
moving: { room: "r~b2", name: "Room 2" },
|
|
611
|
+
});
|
|
612
|
+
expect(failed).toMatchObject({ kind: "failed", canRetry: true });
|
|
613
|
+
const ended = stageNotice({
|
|
614
|
+
status: status({ tone: "ended", headlineKey: "forge.call_stage.status_ended" }),
|
|
615
|
+
remoteCount: 0,
|
|
616
|
+
everSawPeers: true,
|
|
617
|
+
moving: { room: "r~b2", name: "Room 2" },
|
|
618
|
+
});
|
|
619
|
+
expect(ended?.kind).toBe("ended");
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("reads an empty stage as WAITING while rooms are open, never as everyone having left", () => {
|
|
623
|
+
// The host hopped into Room 3 before anyone arrived: the people they left
|
|
624
|
+
// in the main room are still on the call.
|
|
625
|
+
expect(stageNotice({ status: status(), remoteCount: 0, everSawPeers: true, breakoutsActive: true })?.kind).toBe("waiting");
|
|
626
|
+
// And without open rooms the old answer stands.
|
|
627
|
+
expect(stageNotice({ status: status(), remoteCount: 0, everSawPeers: true, breakoutsActive: false })?.kind).toBe("everyone_left");
|
|
628
|
+
expect(stageNotice({ status: status(), remoteCount: 0, everSawPeers: true })?.kind).toBe("everyone_left");
|
|
629
|
+
// People on stage: nothing to say, rooms or not.
|
|
630
|
+
expect(stageNotice({ status: status(), remoteCount: 1, everSawPeers: true, breakoutsActive: true })).toBeNull();
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it("gives the status pill the same destination as the centre", () => {
|
|
634
|
+
// The SDK reports a breakout move with the draining cause a server drain
|
|
635
|
+
// uses, so without `moving` the pill says "Moving you to another server"
|
|
636
|
+
// about a move to Room 2.
|
|
637
|
+
const drain = callStatus({ connectionState: "reconnecting", error: { type: "draining", reconnectAfterMs: 0 }, recovering: true });
|
|
638
|
+
const move = callStatus({
|
|
639
|
+
connectionState: "reconnecting",
|
|
640
|
+
error: { type: "draining", reconnectAfterMs: 0 },
|
|
641
|
+
recovering: true,
|
|
642
|
+
moving: { room: "r~b2", name: "Room 2" },
|
|
643
|
+
});
|
|
644
|
+
expect(drain.headlineKey).toBe("forge.call_stage.status_moving");
|
|
645
|
+
expect(move).toMatchObject({ tone: "reconnecting", canRetry: false, ...breakoutMoveHeadline({ room: "r~b2", name: "Room 2" }) });
|
|
646
|
+
expect(translateForge("de", move.headlineKey, move.headlineVars)).toBe("Du wirst in Room 2 verschoben");
|
|
647
|
+
// A move whose rejoin nothing is retrying is a failure like any other drain's.
|
|
648
|
+
expect(
|
|
649
|
+
callStatus({ connectionState: "reconnecting", error: { type: "draining", reconnectAfterMs: 0 }, moving: { room: "r", name: null } }).tone,
|
|
650
|
+
).toBe("failed");
|
|
651
|
+
});
|
|
652
|
+
});
|
|
551
653
|
});
|
|
552
654
|
|
|
553
655
|
describe("tiles carry their source, and a share never displaces the face", () => {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The breakout-rooms adapter the stage renders, injected like `chat`.
|
|
3
|
+
*
|
|
4
|
+
* A PROP rather than something the stage fetches, for the same reason the chat
|
|
5
|
+
* adapter is one: the three surfaces authenticate differently and the admin
|
|
6
|
+
* dashboard mounts no `ForgeProvider`, so nothing inside the stage may touch
|
|
7
|
+
* an HTTP client. The page composes its lane's hook into THIS shape;
|
|
8
|
+
* `useCallBreakouts` (public lane) is the reference implementation and admin
|
|
9
|
+
* wires `useAdminCallBreakouts` against the same shape.
|
|
10
|
+
*
|
|
11
|
+
* ## What a breakout is, from the stage's side
|
|
12
|
+
*
|
|
13
|
+
* A child room under the call's main room. The join address never changes:
|
|
14
|
+
* the SDK keeps minting on the parent's public id and the backend answers with
|
|
15
|
+
* a token for whichever room this seat is assigned to. So a move is nothing
|
|
16
|
+
* more than "write the assignment, then leave and reconnect", which is what
|
|
17
|
+
* `moveSelf` followed by `room.moveTo()` does, and why `moveSelf` resolves
|
|
18
|
+
* once the ASSIGNMENT is written rather than once the person has arrived.
|
|
19
|
+
*
|
|
20
|
+
* Nothing here decides who may do what: `canManage` is a rendering hint and
|
|
21
|
+
* the server resolves the seat again on every call. A non-host asking for a
|
|
22
|
+
* host action gets the same 404 every other refusal on the call address gives.
|
|
23
|
+
*/
|
|
24
|
+
export type BreakoutParticipant = { identity: string; name: string | null };
|
|
25
|
+
|
|
26
|
+
export type BreakoutRoomSummary = {
|
|
27
|
+
publicId: string;
|
|
28
|
+
name: string;
|
|
29
|
+
position: number;
|
|
30
|
+
/** Live, from the plane snapshot. Host only; attendees always read 0. */
|
|
31
|
+
participantCount: number;
|
|
32
|
+
/** Live. Host only; attendees always read an empty list. */
|
|
33
|
+
participants: readonly BreakoutParticipant[];
|
|
34
|
+
/** From the assignment table: who is MEANT to be here, arrived or not. */
|
|
35
|
+
assigned: readonly BreakoutParticipant[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type CallStageBreakouts = {
|
|
39
|
+
/** False until the first describe has landed; the panel says so. */
|
|
40
|
+
ready: boolean;
|
|
41
|
+
/** This seat is the host. A rendering hint; the server decides. */
|
|
42
|
+
canManage: boolean;
|
|
43
|
+
/** Any room is open right now. */
|
|
44
|
+
active: boolean;
|
|
45
|
+
/** The room this seat is assigned to. `null` is the main room. */
|
|
46
|
+
current: { publicId: string; name: string } | null;
|
|
47
|
+
main: { participantCount: number; participants: readonly BreakoutParticipant[] };
|
|
48
|
+
rooms: readonly BreakoutRoomSummary[];
|
|
49
|
+
/** A request is in flight. Holds the controls rather than letting one be pressed twice. */
|
|
50
|
+
pending: boolean;
|
|
51
|
+
create: (input: { count: number; assign: "auto" | "manual" }) => Promise<void>;
|
|
52
|
+
assign: (identity: string, roomPublicId: string | null) => Promise<void>;
|
|
53
|
+
/** Self-move. Resolves once the ASSIGNMENT is written; the stage then calls room.moveTo(). */
|
|
54
|
+
moveSelf: (roomPublicId: string | null) => Promise<void>;
|
|
55
|
+
closeAll: () => Promise<void>;
|
|
56
|
+
refresh: () => void;
|
|
57
|
+
};
|