@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
|
@@ -241,6 +241,8 @@ export type CallStatus = {
|
|
|
241
241
|
tone: CallStatusTone;
|
|
242
242
|
/** Always shown. Connection state is CONTENT, never a help hint. `forge.call_stage.*`. */
|
|
243
243
|
headlineKey: string;
|
|
244
|
+
/** Placeholders for `headlineKey` (a breakout room's name). Absent for every other headline. */
|
|
245
|
+
headlineVars?: Record<string, string | number>;
|
|
244
246
|
/** Our own second line, when there is one. `forge.call_stage.*`. */
|
|
245
247
|
detailKey?: string;
|
|
246
248
|
/** The server's own words when it has any. Shown verbatim. */
|
|
@@ -264,11 +266,32 @@ export type CallStatusInput = {
|
|
|
264
266
|
* offers a control rather than a spinner.
|
|
265
267
|
*/
|
|
266
268
|
recovering?: boolean;
|
|
269
|
+
/**
|
|
270
|
+
* A breakout move in flight: the node asked this seat to change rooms
|
|
271
|
+
* (`RoomState.moving`), or the person pressed Join / Return themselves.
|
|
272
|
+
* The SDK reports the reconnect with the same `draining` cause a server
|
|
273
|
+
* drain uses, so without this the pill would say "Moving you to another
|
|
274
|
+
* server" about a move to Room 2. `name` null means back to the main room.
|
|
275
|
+
*/
|
|
276
|
+
moving?: BreakoutMove | null;
|
|
267
277
|
};
|
|
268
278
|
|
|
279
|
+
/** What the stage knows about a move: where to, by display name when it has one. */
|
|
280
|
+
export type BreakoutMove = { room: string; name: string | null };
|
|
281
|
+
|
|
269
282
|
const ENDED = "forge.call_stage.status_ended";
|
|
270
283
|
const LEFT = "forge.call_stage.status_left";
|
|
271
284
|
|
|
285
|
+
/**
|
|
286
|
+
* The headline for a breakout move, shared by the pill and the centre notice
|
|
287
|
+
* so the two can never disagree about where somebody is going.
|
|
288
|
+
*/
|
|
289
|
+
export function breakoutMoveHeadline(moving: BreakoutMove): Pick<CallStatus, "headlineKey" | "headlineVars"> {
|
|
290
|
+
return moving.name === null
|
|
291
|
+
? { headlineKey: "forge.call_stage.breakouts.status_returning" }
|
|
292
|
+
: { headlineKey: "forge.call_stage.breakouts.status_moving", headlineVars: { room: moving.name } };
|
|
293
|
+
}
|
|
294
|
+
|
|
272
295
|
/**
|
|
273
296
|
* The room's states, told honestly.
|
|
274
297
|
*
|
|
@@ -338,12 +361,19 @@ export function callStatus(input: CallStatusInput): CallStatus {
|
|
|
338
361
|
// ordinary deploy would otherwise end every call on that node under a
|
|
339
362
|
// spinner saying nobody was dropped.
|
|
340
363
|
return recovering
|
|
341
|
-
?
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
364
|
+
? input.moving
|
|
365
|
+
? {
|
|
366
|
+
tone: "reconnecting",
|
|
367
|
+
...breakoutMoveHeadline(input.moving),
|
|
368
|
+
detailKey: "forge.call_stage.breakouts.status_moving_detail",
|
|
369
|
+
canRetry: false,
|
|
370
|
+
}
|
|
371
|
+
: {
|
|
372
|
+
tone: "reconnecting",
|
|
373
|
+
headlineKey: "forge.call_stage.status_moving",
|
|
374
|
+
detailKey: "forge.call_stage.status_moving_detail",
|
|
375
|
+
canRetry: false,
|
|
376
|
+
}
|
|
347
377
|
: {
|
|
348
378
|
tone: "failed",
|
|
349
379
|
headlineKey: "forge.call_stage.status_move_failed",
|
|
@@ -406,13 +436,15 @@ export function canPublishSource(grants: MediaGrants | undefined, source: Publis
|
|
|
406
436
|
return mayPublish(grants, { source, trackKind: source === "microphone" ? "audio" : "video" });
|
|
407
437
|
}
|
|
408
438
|
|
|
409
|
-
export type StageNoticeKind = "joining" | "waiting" | "everyone_left" | "ended" | "failed";
|
|
439
|
+
export type StageNoticeKind = "joining" | "waiting" | "everyone_left" | "moving" | "ended" | "failed";
|
|
410
440
|
|
|
411
441
|
export type StageNotice = {
|
|
412
442
|
kind: StageNoticeKind;
|
|
413
443
|
/** `forge.call_stage.*` keys. The status pill's copy is reused where the
|
|
414
444
|
* meaning is the same, so the two can never say different things. */
|
|
415
445
|
headlineKey: string;
|
|
446
|
+
/** Placeholders for `headlineKey`. Only a breakout move has any. */
|
|
447
|
+
headlineVars?: Record<string, string | number>;
|
|
416
448
|
detailKey?: string;
|
|
417
449
|
/** The server's own sentence, verbatim, exactly as the status line shows it. */
|
|
418
450
|
detailText?: string;
|
|
@@ -429,6 +461,18 @@ export type StageNotice = {
|
|
|
429
461
|
* transient ones (connecting, reconnecting) keep the top pill so the stage
|
|
430
462
|
* behind them stays visible; and an empty live room says "waiting", which is
|
|
431
463
|
* a state, not an error.
|
|
464
|
+
*
|
|
465
|
+
* ## Breakout rooms change two answers
|
|
466
|
+
*
|
|
467
|
+
* A MOVE between rooms is a leave and a reconnect, so for a second or two the
|
|
468
|
+
* stage is empty and reconnecting, which without `moving` reads as "everyone
|
|
469
|
+
* left". The move wins over every non-terminal notice: "Moving you to Room 2"
|
|
470
|
+
* is the truth, and a terminal state (ended, failed) still wins over it,
|
|
471
|
+
* because a move that failed is the thing to act on.
|
|
472
|
+
*
|
|
473
|
+
* And while rooms are OPEN, an empty stage is "waiting", never "everyone
|
|
474
|
+
* left": a host who hopped into Room 3 and found nobody there yet did not end
|
|
475
|
+
* the call, and the people they left in the main room are still on it.
|
|
432
476
|
*/
|
|
433
477
|
export function stageNotice(input: {
|
|
434
478
|
status: CallStatus;
|
|
@@ -437,33 +481,30 @@ export function stageNotice(input: {
|
|
|
437
481
|
/** Has anyone else ever been here this session? Separates the room that has
|
|
438
482
|
* not started from the room that just emptied. */
|
|
439
483
|
everSawPeers: boolean;
|
|
484
|
+
/** A breakout move in flight (`RoomState.moving`, or the person's own press). */
|
|
485
|
+
moving?: BreakoutMove | null;
|
|
486
|
+
/** Breakout rooms are open somewhere in this call. */
|
|
487
|
+
breakoutsActive?: boolean;
|
|
440
488
|
}): StageNotice | null {
|
|
441
489
|
const { status } = input;
|
|
442
490
|
|
|
443
|
-
if (status.tone === "
|
|
444
|
-
return
|
|
491
|
+
if (status.tone === "ended" || status.tone === "failed") {
|
|
492
|
+
return terminalNotice(status);
|
|
445
493
|
}
|
|
446
|
-
if (
|
|
494
|
+
if (input.moving) {
|
|
447
495
|
return {
|
|
448
|
-
kind: "
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
...(status.detailText ? { detailText: status.detailText } : {}),
|
|
496
|
+
kind: "moving",
|
|
497
|
+
...breakoutMoveHeadline(input.moving),
|
|
498
|
+
detailKey: "forge.call_stage.breakouts.status_moving_detail",
|
|
452
499
|
canRetry: false,
|
|
453
500
|
};
|
|
454
501
|
}
|
|
455
|
-
if (status.tone === "
|
|
456
|
-
return {
|
|
457
|
-
kind: "failed",
|
|
458
|
-
headlineKey: status.headlineKey,
|
|
459
|
-
...(status.detailKey ? { detailKey: status.detailKey } : {}),
|
|
460
|
-
...(status.detailText ? { detailText: status.detailText } : {}),
|
|
461
|
-
canRetry: status.canRetry,
|
|
462
|
-
};
|
|
502
|
+
if (status.tone === "connecting") {
|
|
503
|
+
return { kind: "joining", headlineKey: "forge.call_stage.status_connecting", canRetry: false };
|
|
463
504
|
}
|
|
464
505
|
// Live or reconnecting: the stage stands. Only an EMPTY one says anything.
|
|
465
506
|
if (input.remoteCount === 0) {
|
|
466
|
-
return input.everSawPeers
|
|
507
|
+
return input.everSawPeers && !input.breakoutsActive
|
|
467
508
|
? { kind: "everyone_left", headlineKey: "forge.call_stage.everyone_left", canRetry: false }
|
|
468
509
|
: {
|
|
469
510
|
kind: "waiting",
|
|
@@ -474,3 +515,22 @@ export function stageNotice(input: {
|
|
|
474
515
|
}
|
|
475
516
|
return null;
|
|
476
517
|
}
|
|
518
|
+
|
|
519
|
+
function terminalNotice(status: CallStatus): StageNotice {
|
|
520
|
+
if (status.tone === "ended") {
|
|
521
|
+
return {
|
|
522
|
+
kind: "ended",
|
|
523
|
+
headlineKey: status.headlineKey,
|
|
524
|
+
...(status.detailKey ? { detailKey: status.detailKey } : {}),
|
|
525
|
+
...(status.detailText ? { detailText: status.detailText } : {}),
|
|
526
|
+
canRetry: false,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
return {
|
|
530
|
+
kind: "failed",
|
|
531
|
+
headlineKey: status.headlineKey,
|
|
532
|
+
...(status.detailKey ? { detailKey: status.detailKey } : {}),
|
|
533
|
+
...(status.detailText ? { detailText: status.detailText } : {}),
|
|
534
|
+
canRetry: status.canRetry,
|
|
535
|
+
};
|
|
536
|
+
}
|
package/src/ui/media/index.ts
CHANGED
|
@@ -49,6 +49,9 @@ export {
|
|
|
49
49
|
useLocalMedia,
|
|
50
50
|
useLocalPublications,
|
|
51
51
|
useMediaRoom,
|
|
52
|
+
// A breakout move the node asked for (`moveRequested`), until the next join.
|
|
53
|
+
// `CallStage` reads it; a site laying out its own stage wants it by name.
|
|
54
|
+
useMoving,
|
|
52
55
|
useParticipants,
|
|
53
56
|
useRecording,
|
|
54
57
|
useRemoteTrack,
|
|
@@ -131,6 +134,13 @@ export { PreJoin, type PreJoinProps, type PreJoinSettings } from "./PreJoin";
|
|
|
131
134
|
*/
|
|
132
135
|
export type { CallChatMessage, CallStageChat } from "./callChat";
|
|
133
136
|
|
|
137
|
+
/**
|
|
138
|
+
* The breakout-rooms adapter's shape, injected the same way as `chat`. The
|
|
139
|
+
* public lane's `useCallBreakouts` (from the root entry) produces it; admin
|
|
140
|
+
* composes its own against the dashboard's client.
|
|
141
|
+
*/
|
|
142
|
+
export type { BreakoutParticipant, BreakoutRoomSummary, CallStageBreakouts } from "./callBreakouts";
|
|
143
|
+
|
|
134
144
|
/** The reaction vocabulary, shared by the picker and the receive whitelist. */
|
|
135
145
|
export { REACTION_EMOJI, type ReactionEmoji } from "./stage/callSignals";
|
|
136
146
|
|
|
@@ -149,6 +159,7 @@ export {
|
|
|
149
159
|
callTiles,
|
|
150
160
|
canPublishSource,
|
|
151
161
|
remoteAudioProducerIds,
|
|
162
|
+
type BreakoutMove,
|
|
152
163
|
type CallStatus,
|
|
153
164
|
type CallStatusInput,
|
|
154
165
|
type CallStatusTone,
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { useState, type CSSProperties } from "react";
|
|
2
|
+
import { DoorOpen, Undo2 } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import type { ForgeT } from "../../../i18n";
|
|
5
|
+
import { CallHelpHint } from "../CallHelpHint";
|
|
6
|
+
import type { BreakoutParticipant, BreakoutRoomSummary, CallStageBreakouts } from "../callBreakouts";
|
|
7
|
+
import type { StageTokens } from "../stage/stageTheme";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The rooms rail: breakout rooms, as the host runs them and as everyone else
|
|
11
|
+
* sees them.
|
|
12
|
+
*
|
|
13
|
+
* Everything this panel touches arrives through the `CallStageBreakouts`
|
|
14
|
+
* adapter, never a client: the stage serves three differently-authenticated
|
|
15
|
+
* surfaces and the admin dashboard mounts no `ForgeProvider`. The one thing
|
|
16
|
+
* the panel does NOT do itself is move the viewer: a self-move is the stage's
|
|
17
|
+
* flow (write the assignment, then `room.moveTo()`), so the Join and Return
|
|
18
|
+
* buttons call `onSelfMove` and the stage owns the reconnect.
|
|
19
|
+
*
|
|
20
|
+
* Copy: explanations live behind a `?`; what stays visible is state (where you
|
|
21
|
+
* are, who is where), errors, and the one warning a host has to read before
|
|
22
|
+
* closing every room.
|
|
23
|
+
*/
|
|
24
|
+
export function CallBreakoutsPanel({
|
|
25
|
+
breakouts,
|
|
26
|
+
myIdentity,
|
|
27
|
+
onSelfMove,
|
|
28
|
+
movePending,
|
|
29
|
+
moveError,
|
|
30
|
+
tokens,
|
|
31
|
+
t,
|
|
32
|
+
}: {
|
|
33
|
+
breakouts: CallStageBreakouts;
|
|
34
|
+
myIdentity: string | null;
|
|
35
|
+
onSelfMove: (roomPublicId: string | null) => void;
|
|
36
|
+
/** The stage's own self-move is in flight. */
|
|
37
|
+
movePending: boolean;
|
|
38
|
+
/** The stage's own self-move failed, verbatim from the server when it said why. */
|
|
39
|
+
moveError: string | null;
|
|
40
|
+
tokens: StageTokens;
|
|
41
|
+
t: ForgeT;
|
|
42
|
+
}) {
|
|
43
|
+
const [error, setError] = useState<string | null>(null);
|
|
44
|
+
const busy = breakouts.pending || movePending;
|
|
45
|
+
|
|
46
|
+
// Every adapter call reports the same way: the server's sentence when it
|
|
47
|
+
// gave one, else our own. Visible, because an error is never help.
|
|
48
|
+
const run = (action: () => Promise<void>) => {
|
|
49
|
+
setError(null);
|
|
50
|
+
void action().catch((cause: unknown) => {
|
|
51
|
+
const message = cause instanceof Error && cause.message ? cause.message : t("forge.call_stage.breakouts.error");
|
|
52
|
+
setError(message);
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
data-testid="call-breakouts-panel"
|
|
59
|
+
style={{
|
|
60
|
+
flex: 1,
|
|
61
|
+
minHeight: 0,
|
|
62
|
+
overflowY: "auto",
|
|
63
|
+
display: "flex",
|
|
64
|
+
flexDirection: "column",
|
|
65
|
+
gap: "0.75rem",
|
|
66
|
+
padding: "0.75rem",
|
|
67
|
+
fontSize: "0.8125rem",
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
{!breakouts.ready && <span style={{ color: tokens.stageMuted }}>{t("forge.call_stage.breakouts.loading")}</span>}
|
|
71
|
+
|
|
72
|
+
{breakouts.ready && breakouts.canManage && (
|
|
73
|
+
<HostView breakouts={breakouts} myIdentity={myIdentity} onSelfMove={onSelfMove} busy={busy} run={run} tokens={tokens} t={t} />
|
|
74
|
+
)}
|
|
75
|
+
{breakouts.ready && !breakouts.canManage && (
|
|
76
|
+
<AttendeeView breakouts={breakouts} onSelfMove={onSelfMove} busy={busy} tokens={tokens} t={t} />
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
{(error || moveError) && (
|
|
80
|
+
<span role="alert" data-testid="breakouts-error" style={{ color: tokens.stageText }}>
|
|
81
|
+
{error ?? moveError}
|
|
82
|
+
</span>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const panelButton = (tokens: StageTokens, primary: boolean, busy: boolean, danger = false): CSSProperties => ({
|
|
89
|
+
display: "inline-flex",
|
|
90
|
+
alignItems: "center",
|
|
91
|
+
gap: "0.3rem",
|
|
92
|
+
padding: "0.3rem 0.65rem",
|
|
93
|
+
borderRadius: tokens.cornerRadius,
|
|
94
|
+
border: `1px solid ${danger ? tokens.danger : primary ? tokens.accent : tokens.stageBorder}`,
|
|
95
|
+
backgroundColor: primary ? tokens.accent : "transparent",
|
|
96
|
+
color: primary ? tokens.accentText : tokens.stageText,
|
|
97
|
+
cursor: busy ? "progress" : "pointer",
|
|
98
|
+
opacity: busy ? 0.6 : 1,
|
|
99
|
+
font: "inherit",
|
|
100
|
+
fontSize: "0.75rem",
|
|
101
|
+
whiteSpace: "nowrap",
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const fieldStyle = (tokens: StageTokens): CSSProperties => ({
|
|
105
|
+
padding: "0.3rem 0.45rem",
|
|
106
|
+
borderRadius: tokens.cornerRadius,
|
|
107
|
+
border: `1px solid ${tokens.stageBorder}`,
|
|
108
|
+
backgroundColor: tokens.stageBg,
|
|
109
|
+
color: tokens.stageText,
|
|
110
|
+
font: "inherit",
|
|
111
|
+
fontSize: "0.8125rem",
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const labelStyle: CSSProperties = { display: "flex", alignItems: "center", gap: "0.35rem", color: "inherit" };
|
|
115
|
+
|
|
116
|
+
const roomHeading = (tokens: StageTokens): CSSProperties => ({
|
|
117
|
+
display: "flex",
|
|
118
|
+
alignItems: "center",
|
|
119
|
+
gap: "0.5rem",
|
|
120
|
+
fontWeight: 600,
|
|
121
|
+
color: tokens.stageText,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const nameOf = (person: BreakoutParticipant): string => person.name?.trim() || person.identity;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Everyone who belongs to a room, arrived or not. Live presence first (the
|
|
128
|
+
* plane's own account), then anybody assigned who has not landed yet, so a
|
|
129
|
+
* host sees the person they just sent to Room 2 listed there before the
|
|
130
|
+
* reconnect completes rather than vanishing for a second.
|
|
131
|
+
*/
|
|
132
|
+
function roster(room: Pick<BreakoutRoomSummary, "participants" | "assigned">): (BreakoutParticipant & { arrived: boolean })[] {
|
|
133
|
+
const rows = room.participants.map((person) => ({ ...person, arrived: true }));
|
|
134
|
+
const seen = new Set(rows.map((person) => person.identity));
|
|
135
|
+
for (const person of room.assigned) {
|
|
136
|
+
if (seen.has(person.identity)) continue;
|
|
137
|
+
seen.add(person.identity);
|
|
138
|
+
rows.push({ ...person, arrived: false });
|
|
139
|
+
}
|
|
140
|
+
return rows;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function HostView({
|
|
144
|
+
breakouts,
|
|
145
|
+
myIdentity,
|
|
146
|
+
onSelfMove,
|
|
147
|
+
busy,
|
|
148
|
+
run,
|
|
149
|
+
tokens,
|
|
150
|
+
t,
|
|
151
|
+
}: {
|
|
152
|
+
breakouts: CallStageBreakouts;
|
|
153
|
+
myIdentity: string | null;
|
|
154
|
+
onSelfMove: (roomPublicId: string | null) => void;
|
|
155
|
+
busy: boolean;
|
|
156
|
+
run: (action: () => Promise<void>) => void;
|
|
157
|
+
tokens: StageTokens;
|
|
158
|
+
t: ForgeT;
|
|
159
|
+
}) {
|
|
160
|
+
const [count, setCount] = useState(2);
|
|
161
|
+
const [assign, setAssign] = useState<"auto" | "manual">("auto");
|
|
162
|
+
const [confirmingClose, setConfirmingClose] = useState(false);
|
|
163
|
+
|
|
164
|
+
if (!breakouts.active) {
|
|
165
|
+
const validCount = Number.isInteger(count) && count >= 1 && count <= 20;
|
|
166
|
+
return (
|
|
167
|
+
<form
|
|
168
|
+
data-testid="breakouts-create"
|
|
169
|
+
onSubmit={(event) => {
|
|
170
|
+
event.preventDefault();
|
|
171
|
+
if (!validCount) return;
|
|
172
|
+
run(() => breakouts.create({ count, assign }));
|
|
173
|
+
}}
|
|
174
|
+
style={{ display: "flex", flexDirection: "column", gap: "0.625rem" }}
|
|
175
|
+
>
|
|
176
|
+
<div style={roomHeading(tokens)}>
|
|
177
|
+
{t("forge.call_stage.breakouts.create_title")}
|
|
178
|
+
<CallHelpHint label={t("forge.call_stage.breakouts.help_create_label")}>{t("forge.call_stage.breakouts.help_create")}</CallHelpHint>
|
|
179
|
+
</div>
|
|
180
|
+
<label style={labelStyle}>
|
|
181
|
+
<span style={{ flex: 1 }}>{t("forge.call_stage.breakouts.count_label")}</span>
|
|
182
|
+
<input
|
|
183
|
+
type="number"
|
|
184
|
+
min={1}
|
|
185
|
+
max={20}
|
|
186
|
+
step={1}
|
|
187
|
+
value={count}
|
|
188
|
+
onChange={(event) => setCount(Number(event.target.value))}
|
|
189
|
+
style={{ ...fieldStyle(tokens), width: "4.5rem" }}
|
|
190
|
+
/>
|
|
191
|
+
</label>
|
|
192
|
+
<label style={labelStyle}>
|
|
193
|
+
<span style={{ flex: 1 }}>{t("forge.call_stage.breakouts.assign_label")}</span>
|
|
194
|
+
<select value={assign} onChange={(event) => setAssign(event.target.value as "auto" | "manual")} style={fieldStyle(tokens)}>
|
|
195
|
+
<option value="auto">{t("forge.call_stage.breakouts.assign_auto")}</option>
|
|
196
|
+
<option value="manual">{t("forge.call_stage.breakouts.assign_manual")}</option>
|
|
197
|
+
</select>
|
|
198
|
+
</label>
|
|
199
|
+
<button type="submit" disabled={busy || !validCount} aria-busy={busy} style={{ ...panelButton(tokens, true, busy), alignSelf: "flex-start" }}>
|
|
200
|
+
<DoorOpen width={14} height={14} aria-hidden="true" />
|
|
201
|
+
{t("forge.call_stage.breakouts.create")}
|
|
202
|
+
</button>
|
|
203
|
+
</form>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const roomOptions = [{ value: "", label: t("forge.call_stage.breakouts.main_room") }, ...breakouts.rooms.map((room) => ({ value: room.publicId, label: room.name }))];
|
|
208
|
+
|
|
209
|
+
const rows = (room: Pick<BreakoutRoomSummary, "participants" | "assigned">, here: string | null) =>
|
|
210
|
+
roster(room).map((person) => {
|
|
211
|
+
const mine = person.identity === myIdentity;
|
|
212
|
+
return (
|
|
213
|
+
<div key={person.identity} style={{ display: "flex", alignItems: "center", gap: "0.5rem", padding: "0.2rem 0" }}>
|
|
214
|
+
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
215
|
+
{nameOf(person)}
|
|
216
|
+
{mine && <span style={{ color: tokens.stageMuted }}> {t("forge.call_stage.you_suffix")}</span>}
|
|
217
|
+
{!person.arrived && <span style={{ color: tokens.stageMuted }}> {t("forge.call_stage.breakouts.assigned_not_arrived")}</span>}
|
|
218
|
+
</span>
|
|
219
|
+
{!mine && (
|
|
220
|
+
<select
|
|
221
|
+
aria-label={t("forge.call_stage.breakouts.move_to_label", { name: nameOf(person) })}
|
|
222
|
+
value={here ?? ""}
|
|
223
|
+
disabled={busy}
|
|
224
|
+
onChange={(event) => run(() => breakouts.assign(person.identity, event.target.value || null))}
|
|
225
|
+
style={{ ...fieldStyle(tokens), fontSize: "0.75rem", padding: "0.15rem 0.3rem" }}
|
|
226
|
+
>
|
|
227
|
+
{roomOptions.map((option) => (
|
|
228
|
+
<option key={option.value} value={option.value}>
|
|
229
|
+
{option.label}
|
|
230
|
+
</option>
|
|
231
|
+
))}
|
|
232
|
+
</select>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const joinButton = (roomPublicId: string | null) => {
|
|
239
|
+
const here = (breakouts.current?.publicId ?? null) === roomPublicId;
|
|
240
|
+
return here ? (
|
|
241
|
+
<span style={{ fontSize: "0.6875rem", color: tokens.stageMuted }}>{t("forge.call_stage.breakouts.current_marker")}</span>
|
|
242
|
+
) : (
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
data-testid={roomPublicId ? `breakouts-join-${roomPublicId}` : "breakouts-join-main"}
|
|
246
|
+
onClick={() => onSelfMove(roomPublicId)}
|
|
247
|
+
disabled={busy}
|
|
248
|
+
aria-busy={busy}
|
|
249
|
+
style={{ ...panelButton(tokens, false, busy), marginLeft: "auto" }}
|
|
250
|
+
>
|
|
251
|
+
{roomPublicId ? t("forge.call_stage.breakouts.join") : t("forge.call_stage.breakouts.return_to_main")}
|
|
252
|
+
</button>
|
|
253
|
+
);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
|
258
|
+
<div style={roomHeading(tokens)}>
|
|
259
|
+
{t("forge.call_stage.breakouts.create_title")}
|
|
260
|
+
<CallHelpHint label={t("forge.call_stage.breakouts.help_join_label")}>{t("forge.call_stage.breakouts.help_join")}</CallHelpHint>
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
<section data-testid="breakouts-room-main" style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
|
264
|
+
<div style={roomHeading(tokens)}>
|
|
265
|
+
<span>{t("forge.call_stage.breakouts.main_room")}</span>
|
|
266
|
+
<span style={{ fontWeight: 400, color: tokens.stageMuted }}>
|
|
267
|
+
{t("forge.call_stage.breakouts.participant_count", { count: breakouts.main.participantCount })}
|
|
268
|
+
</span>
|
|
269
|
+
{joinButton(null)}
|
|
270
|
+
</div>
|
|
271
|
+
{breakouts.main.participants.length === 0 && <span style={{ color: tokens.stageMuted }}>{t("forge.call_stage.breakouts.empty_room")}</span>}
|
|
272
|
+
{rows({ participants: breakouts.main.participants, assigned: [] }, null)}
|
|
273
|
+
</section>
|
|
274
|
+
|
|
275
|
+
{breakouts.rooms.map((room) => (
|
|
276
|
+
<section key={room.publicId} data-testid={`breakouts-room-${room.publicId}`} style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
|
277
|
+
<div style={roomHeading(tokens)}>
|
|
278
|
+
<span>{room.name}</span>
|
|
279
|
+
<span style={{ fontWeight: 400, color: tokens.stageMuted }}>
|
|
280
|
+
{t("forge.call_stage.breakouts.participant_count", { count: room.participantCount })}
|
|
281
|
+
</span>
|
|
282
|
+
{joinButton(room.publicId)}
|
|
283
|
+
</div>
|
|
284
|
+
{room.participants.length === 0 && room.assigned.length === 0 && (
|
|
285
|
+
<span style={{ color: tokens.stageMuted }}>{t("forge.call_stage.breakouts.empty_room")}</span>
|
|
286
|
+
)}
|
|
287
|
+
{rows(room, room.publicId)}
|
|
288
|
+
</section>
|
|
289
|
+
))}
|
|
290
|
+
|
|
291
|
+
{confirmingClose ? (
|
|
292
|
+
<div
|
|
293
|
+
role="group"
|
|
294
|
+
aria-label={t("forge.call_stage.breakouts.close_all")}
|
|
295
|
+
style={{
|
|
296
|
+
display: "flex",
|
|
297
|
+
flexDirection: "column",
|
|
298
|
+
gap: "0.5rem",
|
|
299
|
+
padding: "0.625rem",
|
|
300
|
+
borderRadius: tokens.cornerRadius,
|
|
301
|
+
border: `1px solid ${tokens.danger}`,
|
|
302
|
+
}}
|
|
303
|
+
>
|
|
304
|
+
{/* A warning about what a control is about to do must be read, so it
|
|
305
|
+
is content here, never a `?` hint. */}
|
|
306
|
+
<span>{t("forge.call_stage.breakouts.close_confirm_body")}</span>
|
|
307
|
+
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
308
|
+
<button
|
|
309
|
+
type="button"
|
|
310
|
+
data-testid="breakouts-close-confirm"
|
|
311
|
+
disabled={busy}
|
|
312
|
+
onClick={() => {
|
|
313
|
+
setConfirmingClose(false);
|
|
314
|
+
run(() => breakouts.closeAll());
|
|
315
|
+
}}
|
|
316
|
+
style={panelButton(tokens, false, busy, true)}
|
|
317
|
+
>
|
|
318
|
+
{t("forge.call_stage.breakouts.close_confirm")}
|
|
319
|
+
</button>
|
|
320
|
+
<button type="button" onClick={() => setConfirmingClose(false)} style={panelButton(tokens, false, false)}>
|
|
321
|
+
{t("forge.call_stage.breakouts.close_cancel")}
|
|
322
|
+
</button>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
) : (
|
|
326
|
+
<button
|
|
327
|
+
type="button"
|
|
328
|
+
data-testid="breakouts-close-all"
|
|
329
|
+
onClick={() => setConfirmingClose(true)}
|
|
330
|
+
disabled={busy}
|
|
331
|
+
style={{ ...panelButton(tokens, false, busy, true), alignSelf: "flex-start" }}
|
|
332
|
+
>
|
|
333
|
+
{t("forge.call_stage.breakouts.close_all")}
|
|
334
|
+
</button>
|
|
335
|
+
)}
|
|
336
|
+
</div>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function AttendeeView({
|
|
341
|
+
breakouts,
|
|
342
|
+
onSelfMove,
|
|
343
|
+
busy,
|
|
344
|
+
tokens,
|
|
345
|
+
t,
|
|
346
|
+
}: {
|
|
347
|
+
breakouts: CallStageBreakouts;
|
|
348
|
+
onSelfMove: (roomPublicId: string | null) => void;
|
|
349
|
+
busy: boolean;
|
|
350
|
+
tokens: StageTokens;
|
|
351
|
+
t: ForgeT;
|
|
352
|
+
}) {
|
|
353
|
+
return (
|
|
354
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
|
355
|
+
<div style={roomHeading(tokens)}>
|
|
356
|
+
{/* Where you are is STATE, and stays visible. */}
|
|
357
|
+
<span data-testid="breakouts-where">
|
|
358
|
+
{breakouts.current
|
|
359
|
+
? t("forge.call_stage.breakouts.you_are_in", { room: breakouts.current.name })
|
|
360
|
+
: t("forge.call_stage.breakouts.you_are_in_main")}
|
|
361
|
+
</span>
|
|
362
|
+
<CallHelpHint label={t("forge.call_stage.breakouts.help_attendee_label")}>{t("forge.call_stage.breakouts.help_attendee")}</CallHelpHint>
|
|
363
|
+
</div>
|
|
364
|
+
|
|
365
|
+
<ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
|
366
|
+
<li style={{ display: "flex", gap: "0.5rem" }}>
|
|
367
|
+
<span>{t("forge.call_stage.breakouts.main_room")}</span>
|
|
368
|
+
{!breakouts.current && <span style={{ color: tokens.stageMuted }}>{t("forge.call_stage.breakouts.current_marker")}</span>}
|
|
369
|
+
</li>
|
|
370
|
+
{breakouts.rooms.map((room) => (
|
|
371
|
+
<li key={room.publicId} style={{ display: "flex", gap: "0.5rem" }}>
|
|
372
|
+
<span>{room.name}</span>
|
|
373
|
+
{breakouts.current?.publicId === room.publicId && (
|
|
374
|
+
<span style={{ color: tokens.stageMuted }}>{t("forge.call_stage.breakouts.current_marker")}</span>
|
|
375
|
+
)}
|
|
376
|
+
</li>
|
|
377
|
+
))}
|
|
378
|
+
</ul>
|
|
379
|
+
|
|
380
|
+
{breakouts.current && (
|
|
381
|
+
<button
|
|
382
|
+
type="button"
|
|
383
|
+
data-testid="breakouts-return-main"
|
|
384
|
+
onClick={() => onSelfMove(null)}
|
|
385
|
+
disabled={busy}
|
|
386
|
+
aria-busy={busy}
|
|
387
|
+
style={{ ...panelButton(tokens, true, busy), alignSelf: "flex-start" }}
|
|
388
|
+
>
|
|
389
|
+
<Undo2 width={14} height={14} aria-hidden="true" />
|
|
390
|
+
{t("forge.call_stage.breakouts.return_to_main_room")}
|
|
391
|
+
</button>
|
|
392
|
+
)}
|
|
393
|
+
</div>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
@@ -2,18 +2,30 @@ import { X } from "lucide-react";
|
|
|
2
2
|
import type { RaisedHand } from "@tribe-nest/media-client";
|
|
3
3
|
|
|
4
4
|
import type { ForgeT } from "../../../i18n";
|
|
5
|
+
import type { CallStageBreakouts } from "../callBreakouts";
|
|
5
6
|
import type { CallStageChat } from "../callChat";
|
|
6
7
|
import type { CallTile } from "../callState";
|
|
7
8
|
import type { StageTokens } from "../stage/stageTheme";
|
|
9
|
+
import { CallBreakoutsPanel } from "./CallBreakoutsPanel";
|
|
8
10
|
import { CallChatPanel } from "./CallChatPanel";
|
|
9
11
|
import { CallParticipantsPanel } from "./CallParticipantsPanel";
|
|
10
12
|
|
|
11
|
-
export type SidePanelTab = "chat" | "people";
|
|
13
|
+
export type SidePanelTab = "chat" | "people" | "rooms";
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
16
|
+
* Is the rooms tab drawn at all? Only when the page injected an adapter AND
|
|
17
|
+
* there is something to show: a host can always open it (to create rooms), and
|
|
18
|
+
* everyone else sees it only while rooms are open. A call where nobody creates
|
|
19
|
+
* breakouts draws the two tabs it always drew.
|
|
20
|
+
*/
|
|
21
|
+
export const showsRoomsTab = (breakouts: CallStageBreakouts | undefined): breakouts is CallStageBreakouts =>
|
|
22
|
+
!!breakouts && (breakouts.canManage || breakouts.active);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The right rail: one width, two tabs (three with breakout rooms), the
|
|
26
|
+
* Zoom-familiar shape. Tabs rather than stacked panels because the rail's
|
|
27
|
+
* width is the stage's loss, and two rails would take a third of a laptop
|
|
28
|
+
* screen.
|
|
17
29
|
*/
|
|
18
30
|
export function CallSidePanel({
|
|
19
31
|
tab,
|
|
@@ -29,6 +41,10 @@ export function CallSidePanel({
|
|
|
29
41
|
onLowerHand,
|
|
30
42
|
onLowerAll,
|
|
31
43
|
unread,
|
|
44
|
+
breakouts,
|
|
45
|
+
onSelfMove,
|
|
46
|
+
movePending = false,
|
|
47
|
+
moveError = null,
|
|
32
48
|
fill = false,
|
|
33
49
|
}: {
|
|
34
50
|
tab: SidePanelTab;
|
|
@@ -44,6 +60,12 @@ export function CallSidePanel({
|
|
|
44
60
|
onLowerHand: (identity: string) => void;
|
|
45
61
|
onLowerAll: () => void;
|
|
46
62
|
unread: number;
|
|
63
|
+
/** The rooms adapter. Absent, or inactive for a non-host, draws no tab. */
|
|
64
|
+
breakouts?: CallStageBreakouts | undefined;
|
|
65
|
+
/** The stage's self-move flow (assignment, then `room.moveTo()`). */
|
|
66
|
+
onSelfMove?: ((roomPublicId: string | null) => void) | undefined;
|
|
67
|
+
movePending?: boolean | undefined;
|
|
68
|
+
moveError?: string | null | undefined;
|
|
47
69
|
/** Fill the parent (a full-screen overlay on a phone) instead of the fixed
|
|
48
70
|
* docked width. */
|
|
49
71
|
fill?: boolean;
|
|
@@ -111,6 +133,7 @@ export function CallSidePanel({
|
|
|
111
133
|
<div style={{ display: "flex", alignItems: "center", borderBottom: `1px solid ${tokens.stageBorder}` }}>
|
|
112
134
|
{chat && tabButton("chat", t("forge.call_stage.tab_chat"), tab === "chat" ? 0 : unread)}
|
|
113
135
|
{tabButton("people", t("forge.call_stage.tab_people"), hands.length)}
|
|
136
|
+
{showsRoomsTab(breakouts) && tabButton("rooms", t("forge.call_stage.breakouts.tab"))}
|
|
114
137
|
<button
|
|
115
138
|
type="button"
|
|
116
139
|
onClick={onClose}
|
|
@@ -135,6 +158,16 @@ export function CallSidePanel({
|
|
|
135
158
|
|
|
136
159
|
{tab === "chat" && chat ? (
|
|
137
160
|
<CallChatPanel chat={chat} tokens={tokens} t={t} />
|
|
161
|
+
) : tab === "rooms" && showsRoomsTab(breakouts) ? (
|
|
162
|
+
<CallBreakoutsPanel
|
|
163
|
+
breakouts={breakouts}
|
|
164
|
+
myIdentity={myIdentity}
|
|
165
|
+
onSelfMove={onSelfMove ?? (() => undefined)}
|
|
166
|
+
movePending={movePending}
|
|
167
|
+
moveError={moveError}
|
|
168
|
+
tokens={tokens}
|
|
169
|
+
t={t}
|
|
170
|
+
/>
|
|
138
171
|
) : (
|
|
139
172
|
<CallParticipantsPanel
|
|
140
173
|
tiles={tiles}
|