@tribe-nest/forge 3.42.1 → 3.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +2 -2
  2. package/src/data/queries/useCallChat.ts +162 -0
  3. package/src/data/queries/useCallRecording.ts +80 -0
  4. package/src/data/queries/useMembershipCalls.ts +162 -0
  5. package/src/i18n/de.json +73 -1
  6. package/src/i18n/en.json +73 -1
  7. package/src/index.ts +7 -0
  8. package/src/ui/headless/memberHome/index.ts +4 -0
  9. package/src/ui/headless/memberHome/useMemberCalls.ts +137 -0
  10. package/src/ui/headless/memberHome/useMemberHomeNav.ts +29 -5
  11. package/src/ui/media/CallStage.tsx +691 -553
  12. package/src/ui/media/PreJoin.tsx +269 -0
  13. package/src/ui/media/_tests/CallStage.spec.tsx +338 -5
  14. package/src/ui/media/_tests/callLayout.spec.ts +182 -0
  15. package/src/ui/media/_tests/callSignals.spec.ts +87 -0
  16. package/src/ui/media/_tests/callState.spec.ts +93 -1
  17. package/src/ui/media/_tests/fakeNode.ts +4 -1
  18. package/src/ui/media/callChat.ts +33 -0
  19. package/src/ui/media/callState.ts +125 -9
  20. package/src/ui/media/hooks/useCallSignals.ts +135 -0
  21. package/src/ui/media/hooks/useMeasuredWidth.ts +39 -0
  22. package/src/ui/media/index.ts +41 -1
  23. package/src/ui/media/panels/CallChatPanel.tsx +200 -0
  24. package/src/ui/media/panels/CallParticipantsPanel.tsx +125 -0
  25. package/src/ui/media/panels/CallSidePanel.tsx +152 -0
  26. package/src/ui/media/stage/CallCenterNotice.tsx +89 -0
  27. package/src/ui/media/stage/CallControlsBar.tsx +547 -0
  28. package/src/ui/media/stage/CallHealthPanel.tsx +132 -0
  29. package/src/ui/media/stage/CallToasts.tsx +49 -0
  30. package/src/ui/media/stage/CallTopBar.tsx +257 -0
  31. package/src/ui/media/stage/DeviceMenu.tsx +142 -0
  32. package/src/ui/media/stage/ReactionOverlay.tsx +38 -0
  33. package/src/ui/media/stage/SelfView.tsx +146 -0
  34. package/src/ui/media/stage/VideoTile.tsx +232 -0
  35. package/src/ui/media/stage/callLayout.ts +253 -0
  36. package/src/ui/media/stage/callSignals.ts +91 -0
  37. package/src/ui/media/stage/callSounds.ts +78 -0
  38. package/src/ui/media/stage/stageTheme.ts +50 -0
  39. package/src/ui/styled/forge-utilities.css +13 -0
  40. package/src/ui/styled/members/MemberCallsCalendar.tsx +571 -0
  41. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +135 -0
  42. package/src/ui/styled/members/index.ts +1 -0
@@ -0,0 +1,182 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { CallTile } from "../callState";
4
+ import { gridShape, paginate, pickDominant, presentingTile, resolveLayout, gridShapeNarrow } from "../stage/callLayout";
5
+
6
+ /**
7
+ * The layout engine, exhaustively. The layout decides what the viewport
8
+ * reports, and the viewport decides what the room subscribes to, so a wrong
9
+ * answer here is not a cosmetic bug: it is bandwidth spent on tiles nobody
10
+ * sees or a face that silently never arrives.
11
+ */
12
+
13
+ const tile = (identity: string, over: Partial<CallTile> = {}): CallTile => ({
14
+ key: `${identity}#camera`,
15
+ identity,
16
+ name: identity,
17
+ kind: "human",
18
+ source: "camera",
19
+ isSpeaking: false,
20
+ videoPaused: false,
21
+ hasAudio: true,
22
+ audioMuted: false,
23
+ videoProducerId: `v-${identity}`,
24
+ ...over,
25
+ });
26
+
27
+ const people = (count: number): CallTile[] => Array.from({ length: count }, (_, i) => tile(`p-${String(i).padStart(2, "0")}`));
28
+
29
+ const layoutOf = (tiles: CallTile[], over: Partial<Parameters<typeof resolveLayout>[0]> = {}) =>
30
+ resolveLayout({
31
+ tiles,
32
+ view: "gallery",
33
+ page: 0,
34
+ pinnedKey: null,
35
+ dominantIdentity: null,
36
+ localPresenting: false,
37
+ ...over,
38
+ });
39
+
40
+ describe("gridShape", () => {
41
+ it("keeps grids even: 2 across, then 2x2, 3x2, 4x2, never a ragged square", () => {
42
+ expect(gridShape(1)).toEqual({ columns: 1, rows: 1 });
43
+ expect(gridShape(2)).toEqual({ columns: 2, rows: 1 });
44
+ expect(gridShape(3)).toEqual({ columns: 2, rows: 2 });
45
+ expect(gridShape(4)).toEqual({ columns: 2, rows: 2 });
46
+ expect(gridShape(6)).toEqual({ columns: 3, rows: 2 });
47
+ expect(gridShape(8)).toEqual({ columns: 4, rows: 2 });
48
+ });
49
+
50
+ it("stacks into PORTRAITS on a phone: one column up to two people, two columns above", () => {
51
+ expect(gridShapeNarrow(1)).toEqual({ columns: 1, rows: 1 });
52
+ // The case the whole change is for: two people are ONE column, a row each,
53
+ // not side by side.
54
+ expect(gridShapeNarrow(2)).toEqual({ columns: 1, rows: 2 });
55
+ expect(gridShapeNarrow(3)).toEqual({ columns: 2, rows: 2 });
56
+ expect(gridShapeNarrow(4)).toEqual({ columns: 2, rows: 2 });
57
+ expect(gridShapeNarrow(5)).toEqual({ columns: 2, rows: 3 });
58
+ });
59
+ });
60
+
61
+ describe("paginate", () => {
62
+ it("pages at the size and clamps a stranded page when people leave", () => {
63
+ const items = ["a", "b", "c", "d", "e"];
64
+ expect(paginate(items, 0, 2)).toEqual({ items: ["a", "b"], page: 0, pageCount: 3 });
65
+ expect(paginate(items, 2, 2)).toEqual({ items: ["e"], page: 2, pageCount: 3 });
66
+ // The room shrank under a viewer parked on page 5: they land on the last
67
+ // real page rather than an empty one.
68
+ expect(paginate(items, 5, 2)).toEqual({ items: ["e"], page: 2, pageCount: 3 });
69
+ expect(paginate([], 3, 2)).toEqual({ items: [], page: 0, pageCount: 1 });
70
+ });
71
+ });
72
+
73
+ describe("pickDominant", () => {
74
+ it("holds the incumbent through the hold window, then yields", () => {
75
+ const speakers = [{ identity: "b", producerId: "a-b", volumeDb: -20 }];
76
+ // A new leader inside the hold does not take over...
77
+ expect(pickDominant({ speakers, previous: "a", previousSince: 1000, now: 2000, holdMs: 1500 })).toEqual({
78
+ identity: "a",
79
+ since: 1000,
80
+ });
81
+ // ...and does once the hold has run out.
82
+ expect(pickDominant({ speakers, previous: "a", previousSince: 1000, now: 2600, holdMs: 1500 })).toEqual({
83
+ identity: "b",
84
+ since: 2600,
85
+ });
86
+ // Silence keeps whoever was last dominant rather than emptying the stage.
87
+ expect(pickDominant({ speakers: [], previous: "a", previousSince: 1000, now: 9000 })).toEqual({
88
+ identity: "a",
89
+ since: 1000,
90
+ });
91
+ });
92
+ });
93
+
94
+ describe("resolveLayout", () => {
95
+ it("renders exactly one remote person as the duo, whatever the toggle says", () => {
96
+ const layout = layoutOf([tile("anna")], { view: "speaker" });
97
+ expect(layout.mode).toBe("duo");
98
+ expect(layout.pageTiles.map((t) => t.identity)).toEqual(["anna"]);
99
+ expect(layout.columns).toBe(2);
100
+ });
101
+
102
+ it("STACKS the duo on a phone: one column, a row each, never side by side", () => {
103
+ const layout = layoutOf([tile("anna")], { narrow: true });
104
+ expect(layout.mode).toBe("duo");
105
+ // One remote + the local self-view = two cells, stacked as portraits.
106
+ expect(layout.columns).toBe(1);
107
+ expect(layout.rows).toBe(2);
108
+ });
109
+
110
+ it("narrows the gallery to two columns on a phone", () => {
111
+ const layout = layoutOf(people(6), { narrow: true });
112
+ expect(layout.mode).toBe("gallery");
113
+ expect(layout.columns).toBe(2);
114
+ expect(layout.rows).toBe(3);
115
+ });
116
+
117
+ it("pages the gallery at 8 and reports 12 people as two pages", () => {
118
+ const layout = layoutOf(people(12));
119
+ expect(layout.mode).toBe("gallery");
120
+ expect(layout.pageTiles).toHaveLength(8);
121
+ expect(layout.pageCount).toBe(2);
122
+ expect(layout.columns).toBe(4);
123
+
124
+ const second = layoutOf(people(12), { page: 1 });
125
+ expect(second.pageTiles).toHaveLength(4);
126
+ expect(second.page).toBe(1);
127
+ });
128
+
129
+ it("forces the stage the moment ANY screen share exists, presenter named", () => {
130
+ const tiles = [...people(6), tile("anna", { key: "anna#screen", source: "screen", videoProducerId: "s-anna", name: "Anna" })];
131
+ const layout = layoutOf(tiles, { view: "gallery" });
132
+ expect(layout.mode).toBe("presenting");
133
+ expect(layout.main?.videoProducerId).toBe("s-anna");
134
+ expect(layout.presenter).toEqual({ identity: "anna", name: "Anna" });
135
+ // The rail shows 4 faces, not 8: the share suppresses camera demand,
136
+ // which is where the capacity saving actually comes from.
137
+ expect(layout.pageTiles).toHaveLength(4);
138
+ });
139
+
140
+ it("keeps the presenting mode for the viewer's OWN share, with no main tile", () => {
141
+ const layout = layoutOf(people(3), { localPresenting: true });
142
+ expect(layout.mode).toBe("presenting");
143
+ expect(layout.main).toBeUndefined();
144
+ });
145
+
146
+ it("puts a pinned tile on the stage ahead of the view toggle", () => {
147
+ const layout = layoutOf(people(5), { pinnedKey: "p-03#camera", view: "gallery" });
148
+ expect(layout.mode).toBe("speaker");
149
+ expect(layout.main?.identity).toBe("p-03");
150
+ expect(layout.pageTiles.some((t) => t.identity === "p-03")).toBe(false);
151
+ });
152
+
153
+ it("orders the speaker rail dominant-first without touching gallery order", () => {
154
+ const speaker = layoutOf(people(6), { view: "speaker", dominantIdentity: "p-04" });
155
+ expect(speaker.mode).toBe("speaker");
156
+ expect(speaker.main?.identity).toBe("p-04");
157
+
158
+ // The gallery does NOT reshuffle on dominance: a grid that reorders
159
+ // itself every time somebody talks moves people out from under the eye.
160
+ const gallery = layoutOf(people(6), { dominantIdentity: "p-04" });
161
+ expect(gallery.pageTiles.map((t) => t.identity)).toEqual(people(6).map((t) => t.identity));
162
+ });
163
+
164
+ it("names speakers whose tile the current page does not show", () => {
165
+ const tiles = people(12).map((t, i) => (i === 10 ? { ...t, isSpeaking: true } : t));
166
+ const layout = layoutOf(tiles, { page: 0 });
167
+ expect(layout.offPageSpeakerNames).toEqual(["p-10"]);
168
+ // On their own page they are shown, so nothing is announced.
169
+ expect(layoutOf(tiles, { page: 1 }).offPageSpeakerNames).toEqual([]);
170
+ });
171
+ });
172
+
173
+ describe("presentingTile", () => {
174
+ it("finds a share only when it carries video, deterministically by key", () => {
175
+ expect(presentingTile(people(3))).toBeUndefined();
176
+ const twoShares = [
177
+ tile("b", { key: "b#screen", source: "screen", videoProducerId: "s-b" }),
178
+ tile("a", { key: "a#screen", source: "screen", videoProducerId: "s-a" }),
179
+ ];
180
+ expect(presentingTile(twoShares)?.videoProducerId).toBe("s-a");
181
+ });
182
+ });
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ cullReactions,
5
+ diffPeers,
6
+ isKnownReaction,
7
+ laneFor,
8
+ orderHands,
9
+ reactionGate,
10
+ unreadCount,
11
+ type FlyingReaction,
12
+ } from "../stage/callSignals";
13
+
14
+ describe("reactions", () => {
15
+ it("whitelists what may fly: a hostile peer cannot animate arbitrary text", () => {
16
+ expect(isKnownReaction("👏")).toBe(true);
17
+ expect(isKnownReaction("<script>")).toBe(false);
18
+ expect(isKnownReaction(42)).toBe(false);
19
+ });
20
+
21
+ it("gates our own sends to one per half second", () => {
22
+ expect(reactionGate(null, 1000)).toBe(true);
23
+ expect(reactionGate(1000, 1200)).toBe(false);
24
+ expect(reactionGate(1000, 1600)).toBe(true);
25
+ });
26
+
27
+ it("culls by age and caps the flood, keeping the newest", () => {
28
+ const flying = (id: number, at: number): FlyingReaction => ({ id: String(id), emoji: "👏", identity: "x", lane: 0, at });
29
+ const aged = [flying(1, 0), flying(2, 3000)];
30
+ expect(cullReactions(aged, 5000).map((r) => r.id)).toEqual(["2"]);
31
+
32
+ const flood = Array.from({ length: 40 }, (_, i) => flying(i, 1000));
33
+ const culled = cullReactions(flood, 1001, 4000, 30);
34
+ expect(culled).toHaveLength(30);
35
+ expect(culled[0]?.id).toBe("10");
36
+ });
37
+
38
+ it("derives a stable lane from the id, in range", () => {
39
+ expect(laneFor("abc")).toBe(laneFor("abc"));
40
+ for (const id of ["a", "bb", "ccc"]) {
41
+ const lane = laneFor(id);
42
+ expect(lane).toBeGreaterThanOrEqual(0);
43
+ expect(lane).toBeLessThan(1);
44
+ }
45
+ });
46
+ });
47
+
48
+ describe("orderHands", () => {
49
+ it("is the queue: oldest first, identity as the tiebreak", () => {
50
+ const ordered = orderHands([
51
+ { identity: "b", raisedAt: 200 },
52
+ { identity: "c", raisedAt: 100 },
53
+ { identity: "a", raisedAt: 200 },
54
+ ]);
55
+ expect(ordered.map((h) => h.identity)).toEqual(["c", "a", "b"]);
56
+ });
57
+ });
58
+
59
+ describe("diffPeers", () => {
60
+ it("reports arrivals and departures, and never the recorder", () => {
61
+ const before = [
62
+ { identity: "a", name: "Ada", kind: "human" as const },
63
+ { identity: "gone", kind: "human" as const },
64
+ ];
65
+ const after = [
66
+ { identity: "a", name: "Ada", kind: "human" as const },
67
+ { identity: "new", name: "Newt", kind: "human" as const },
68
+ { identity: "rec-1", kind: "egress" as const },
69
+ ];
70
+ expect(diffPeers(before, after)).toEqual([
71
+ { identity: "new", name: "Newt", kind: "joined" },
72
+ { identity: "gone", name: "gone", kind: "left" },
73
+ ]);
74
+ });
75
+ });
76
+
77
+ describe("unreadCount", () => {
78
+ it("counts by position after the watermark, whole list when never read", () => {
79
+ const ids = ["m1", "m2", "m3"];
80
+ expect(unreadCount(ids, null)).toBe(3);
81
+ expect(unreadCount(ids, "m2")).toBe(1);
82
+ expect(unreadCount(ids, "m3")).toBe(0);
83
+ // A watermark for a message that was deleted counts everything: honest,
84
+ // and self-corrects the moment the panel is opened.
85
+ expect(unreadCount(ids, "gone")).toBe(3);
86
+ });
87
+ });
@@ -4,7 +4,7 @@ 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 { callHeadCount, callStatus, callTiles, canPublishSource, remoteAudioProducerIds } from "../callState";
7
+ import { callHeadCount, callStatus, callTiles, canPublishSource, remoteAudioProducerIds, stageNotice } from "../callState";
8
8
 
9
9
  /**
10
10
  * The two decisions a call screen gets wrong.
@@ -497,3 +497,95 @@ describe("canPublishSource", () => {
497
497
  expect(canPublishSource({ ...noPresenting, publishKinds: ["audio", "video"] }, "screen")).toBe(false);
498
498
  });
499
499
  });
500
+
501
+ describe("stageNotice: the ONE decision for the centre of the stage", () => {
502
+ const status = (over: Partial<import("../callState").CallStatus> = {}): import("../callState").CallStatus => ({
503
+ tone: "live",
504
+ headlineKey: "forge.call_stage.status_connected",
505
+ canRetry: false,
506
+ ...over,
507
+ });
508
+
509
+ it("shows JOINING while connecting, never a not-connected error", () => {
510
+ // The user-reported bug: the joining state used to read as an error.
511
+ const notice = stageNotice({
512
+ status: status({ tone: "connecting", headlineKey: "forge.call_stage.status_connecting" }),
513
+ remoteCount: 0,
514
+ everSawPeers: false,
515
+ });
516
+ expect(notice?.kind).toBe("joining");
517
+ expect(notice?.canRetry).toBe(false);
518
+ });
519
+
520
+ it("separates the room that has not started from the one that just emptied", () => {
521
+ expect(stageNotice({ status: status(), remoteCount: 0, everSawPeers: false })?.kind).toBe("waiting");
522
+ expect(stageNotice({ status: status(), remoteCount: 0, everSawPeers: true })?.kind).toBe("everyone_left");
523
+ expect(stageNotice({ status: status(), remoteCount: 2, everSawPeers: true })).toBeNull();
524
+ });
525
+
526
+ it("moves terminal states to the centre, carrying the server's sentence verbatim", () => {
527
+ const failed = stageNotice({
528
+ status: status({ tone: "failed", headlineKey: "forge.call_stage.status_refused", detailText: "unauthorized", canRetry: true }),
529
+ remoteCount: 3,
530
+ everSawPeers: true,
531
+ });
532
+ expect(failed).toMatchObject({ kind: "failed", detailText: "unauthorized", canRetry: true });
533
+
534
+ const ended = stageNotice({
535
+ status: status({ tone: "ended", headlineKey: "forge.call_stage.status_ended", detailText: "the host ended it" }),
536
+ remoteCount: 3,
537
+ everSawPeers: true,
538
+ });
539
+ expect(ended).toMatchObject({ kind: "ended", detailText: "the host ended it", canRetry: false });
540
+ });
541
+
542
+ it("keeps the stage for a RECONNECT with people on it: the pill handles it", () => {
543
+ expect(
544
+ stageNotice({
545
+ status: status({ tone: "reconnecting", headlineKey: "forge.call_stage.status_reconnecting" }),
546
+ remoteCount: 2,
547
+ everSawPeers: true,
548
+ }),
549
+ ).toBeNull();
550
+ });
551
+ });
552
+
553
+ describe("tiles carry their source, and a share never displaces the face", () => {
554
+ it("gives a screen sharer a camera slot AND a screen slot", () => {
555
+ const tiles = callTiles({
556
+ identity: "me",
557
+ peers: [{ identity: "anna", name: "Anna", kind: "human" }],
558
+ producers: [
559
+ { producerId: "v-1", identity: "anna", kind: "video", paused: false, source: "camera" },
560
+ { producerId: "s-1", identity: "anna", kind: "video", paused: false, source: "screen" },
561
+ { producerId: "a-1", identity: "anna", kind: "audio", paused: false },
562
+ ],
563
+ activeSpeakers: [],
564
+ });
565
+ expect(tiles.map((t) => ({ key: t.key, source: t.source }))).toEqual([
566
+ { key: "anna#camera", source: "camera" },
567
+ { key: "anna#screen", source: "screen" },
568
+ ]);
569
+ });
570
+
571
+ it("keeps the camera SLOT when only a share is up, so the person still has a face tile", () => {
572
+ const tiles = callTiles({
573
+ identity: "me",
574
+ peers: [{ identity: "anna", name: "Anna", kind: "human" }],
575
+ producers: [{ producerId: "s-1", identity: "anna", kind: "video", paused: false, source: "screen" }],
576
+ activeSpeakers: [],
577
+ });
578
+ expect(tiles.map((t) => t.key)).toEqual(["anna#camera", "anna#screen"]);
579
+ expect(tiles[0]?.videoProducerId).toBeUndefined();
580
+ });
581
+
582
+ it("reports a mute honestly: audio published, every producer paused", () => {
583
+ const tiles = callTiles({
584
+ identity: "me",
585
+ peers: [{ identity: "anna", name: "Anna", kind: "human" }],
586
+ producers: [{ producerId: "a-1", identity: "anna", kind: "audio", paused: true }],
587
+ activeSpeakers: [],
588
+ });
589
+ expect(tiles[0]).toMatchObject({ hasAudio: true, audioMuted: true });
590
+ });
591
+ });
@@ -83,7 +83,7 @@ export class FakeSocket {
83
83
  }
84
84
  }
85
85
 
86
- export type FakeProducer = { producerId: string; identity: string; kind: "audio" | "video" };
86
+ export type FakeProducer = { producerId: string; identity: string; kind: "audio" | "video"; source?: string };
87
87
 
88
88
  export type FakeNodeOptions = {
89
89
  identity?: string;
@@ -91,6 +91,8 @@ export type FakeNodeOptions = {
91
91
  producers?: FakeProducer[];
92
92
  recording?: boolean;
93
93
  grants?: Record<string, unknown>;
94
+ /** Hands already up in the join snapshot: a reconnect or a late join. */
95
+ raisedHands?: { identity: string; raisedAt: number }[];
94
96
  /** Refuse the join with this code instead of accepting it. */
95
97
  refuseWith?: string;
96
98
  };
@@ -163,6 +165,7 @@ export class FakeNode {
163
165
  iceServers: [],
164
166
  recording: this.options.recording ?? false,
165
167
  grants: this.options.grants ?? { canPublish: true, canSubscribe: true, canPublishData: false },
168
+ ...(this.options.raisedHands ? { raisedHands: this.options.raisedHands } : {}),
166
169
  };
167
170
  }
168
171
 
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The chat adapter the stage renders, injected like `recordingControl`.
3
+ *
4
+ * A PROP rather than something the stage fetches, and the seam matters more
5
+ * here than anywhere: the three surfaces authenticate the platform socket
6
+ * differently (admin's dashboard token, the portal's public token), and the
7
+ * admin dashboard mounts no `ForgeProvider` at all, so nothing inside the
8
+ * stage may touch a socket or an HTTP client. The page composes its lane's
9
+ * hooks into THIS shape; `useCallChat` (public lane) is the reference
10
+ * implementation, and admin wires its own against the same shape.
11
+ */
12
+ export type CallChatMessage = {
13
+ id: string;
14
+ content: string | null;
15
+ senderName: string;
16
+ /** Compared against `currentSenderKey` to right-align "mine". */
17
+ senderKey: string;
18
+ at: string;
19
+ reactions: readonly { emoji: string; count: number; reactedByMe: boolean }[];
20
+ };
21
+
22
+ export type CallStageChat = {
23
+ /** False while the channel is still being resolved; the panel says so. */
24
+ ready: boolean;
25
+ /** Oldest first, as a panel renders. */
26
+ messages: readonly CallChatMessage[];
27
+ sendMessage: (text: string) => void;
28
+ /** Toggles. The aggregate comes back over the socket for everyone. */
29
+ reactToMessage: (messageId: string, emoji: string) => void;
30
+ typingNames: readonly string[];
31
+ onTyping?: () => void;
32
+ currentSenderKey: string | null;
33
+ };
@@ -37,9 +37,10 @@ import {
37
37
  /** One rendered tile. A tile with no `videoProducerId` is NORMAL, not broken. */
38
38
  export type CallTile = {
39
39
  /**
40
- * React key. Deliberately the participant plus an ORDINAL rather than the
40
+ * React key. Deliberately the participant plus a SLOT rather than the
41
41
  * producer id: a camera going on and off must change what a tile shows, not
42
- * unmount and remount the tile.
42
+ * unmount and remount the tile. The camera slot exists whether or not a
43
+ * camera producer does; the screen slot appears with the share.
43
44
  */
44
45
  key: string;
45
46
  identity: string;
@@ -47,6 +48,13 @@ export type CallTile = {
47
48
  * at least tells two unnamed participants apart. */
48
49
  name: string;
49
50
  kind: ParticipantKind;
51
+ /**
52
+ * What this tile IS: the person, or what they are presenting. From the
53
+ * producer's declared `source` label, which the wire now carries; the two
54
+ * are laid out completely differently (a share takes the stage, a face
55
+ * takes a grid cell) and nothing else tells them apart.
56
+ */
57
+ source: "camera" | "screen";
50
58
  /** The node put one of this participant's audio producers in the active set. */
51
59
  isSpeaking: boolean;
52
60
  /** Attach this with `useRemoteTrack`. Absent means "no camera", not "error". */
@@ -55,6 +63,8 @@ export type CallTile = {
55
63
  videoPaused: boolean;
56
64
  /** They are publishing audio at all. A room can be joined listen-only. */
57
65
  hasAudio: boolean;
66
+ /** Audio published but paused at source: muted, and shown as such. */
67
+ audioMuted: boolean;
58
68
  };
59
69
 
60
70
  export type CallTileInput = {
@@ -100,23 +110,60 @@ export function callTiles(input: CallTileInput): readonly CallTile[] {
100
110
  const push = (identity: string, name: string, kind: ParticipantKind) => {
101
111
  const mine = input.producers.filter((p) => p.identity === identity);
102
112
  const videos = mine.filter((p) => p.kind === "video");
103
- const hasAudio = mine.some((p) => p.kind === "audio");
104
- const isSpeaking = mine.some((p) => p.kind === "audio" && speaking.has(p.producerId));
113
+ const audios = mine.filter((p) => p.kind === "audio");
114
+ const hasAudio = audios.length > 0;
115
+ const audioMuted = hasAudio && audios.every((p) => p.paused);
116
+ const isSpeaking = audios.some((p) => speaking.has(p.producerId));
117
+
118
+ // The label decides the slot. An unlabeled video (an older publisher) is
119
+ // read as a camera, which is what it almost always is and the harmless
120
+ // reading when it is not.
121
+ const cameras = videos.filter((p) => p.source !== "screen");
122
+ const screens = videos.filter((p) => p.source === "screen");
105
123
 
106
- if (videos.length === 0) {
107
- tiles.push({ key: `${identity}#0`, identity, name, kind, isSpeaking, videoPaused: false, hasAudio });
108
- return;
124
+ // The camera slot ALWAYS exists, video or not, so a share never takes the
125
+ // place of the face talking over it and a camera toggle never remounts
126
+ // the tile. Extra cameras (rare, but the wire allows them) get ordinals.
127
+ cameras.forEach((video, index) => {
128
+ tiles.push({
129
+ key: index === 0 ? `${identity}#camera` : `${identity}#camera${index + 1}`,
130
+ identity,
131
+ name,
132
+ kind,
133
+ source: "camera",
134
+ isSpeaking,
135
+ videoProducerId: video.producerId,
136
+ videoPaused: video.paused,
137
+ hasAudio,
138
+ audioMuted,
139
+ });
140
+ });
141
+ if (cameras.length === 0) {
142
+ tiles.push({
143
+ key: `${identity}#camera`,
144
+ identity,
145
+ name,
146
+ kind,
147
+ source: "camera",
148
+ isSpeaking,
149
+ videoPaused: false,
150
+ hasAudio,
151
+ audioMuted,
152
+ });
109
153
  }
110
- videos.forEach((video, index) => {
154
+
155
+ screens.forEach((video, index) => {
111
156
  tiles.push({
112
- key: `${identity}#${index}`,
157
+ key: index === 0 ? `${identity}#screen` : `${identity}#screen${index + 1}`,
113
158
  identity,
114
159
  name,
115
160
  kind,
161
+ source: "screen",
116
162
  isSpeaking,
117
163
  videoProducerId: video.producerId,
118
164
  videoPaused: video.paused,
119
165
  hasAudio,
166
+ audioMuted,
120
167
  });
121
168
  });
122
169
  };
@@ -358,3 +405,72 @@ export type { PublishSource };
358
405
  export function canPublishSource(grants: MediaGrants | undefined, source: PublishSource): boolean {
359
406
  return mayPublish(grants, { source, trackKind: source === "microphone" ? "audio" : "video" });
360
407
  }
408
+
409
+ export type StageNoticeKind = "joining" | "waiting" | "everyone_left" | "ended" | "failed";
410
+
411
+ export type StageNotice = {
412
+ kind: StageNoticeKind;
413
+ /** `forge.call_stage.*` keys. The status pill's copy is reused where the
414
+ * meaning is the same, so the two can never say different things. */
415
+ headlineKey: string;
416
+ detailKey?: string;
417
+ /** The server's own sentence, verbatim, exactly as the status line shows it. */
418
+ detailText?: string;
419
+ canRetry: boolean;
420
+ };
421
+
422
+ /**
423
+ * The ONE decision for what the centre of the stage says.
424
+ *
425
+ * The user-reported bugs this consolidates: the joining state used to show
426
+ * "not connected" (the local preview's caption leaking into an empty room),
427
+ * and errors rendered in the top corner where a person staring at a dead
428
+ * stage never looks. Terminal states (ended, failed) belong in the CENTRE;
429
+ * transient ones (connecting, reconnecting) keep the top pill so the stage
430
+ * behind them stays visible; and an empty live room says "waiting", which is
431
+ * a state, not an error.
432
+ */
433
+ export function stageNotice(input: {
434
+ status: CallStatus;
435
+ /** Remote PEOPLE on stage (identities, not tiles). */
436
+ remoteCount: number;
437
+ /** Has anyone else ever been here this session? Separates the room that has
438
+ * not started from the room that just emptied. */
439
+ everSawPeers: boolean;
440
+ }): StageNotice | null {
441
+ const { status } = input;
442
+
443
+ if (status.tone === "connecting") {
444
+ return { kind: "joining", headlineKey: "forge.call_stage.status_connecting", canRetry: false };
445
+ }
446
+ if (status.tone === "ended") {
447
+ return {
448
+ kind: "ended",
449
+ headlineKey: status.headlineKey,
450
+ ...(status.detailKey ? { detailKey: status.detailKey } : {}),
451
+ ...(status.detailText ? { detailText: status.detailText } : {}),
452
+ canRetry: false,
453
+ };
454
+ }
455
+ if (status.tone === "failed") {
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
+ };
463
+ }
464
+ // Live or reconnecting: the stage stands. Only an EMPTY one says anything.
465
+ if (input.remoteCount === 0) {
466
+ return input.everSawPeers
467
+ ? { kind: "everyone_left", headlineKey: "forge.call_stage.everyone_left", canRetry: false }
468
+ : {
469
+ kind: "waiting",
470
+ headlineKey: "forge.call_stage.waiting_for_others",
471
+ detailKey: "forge.call_stage.waiting_for_others_detail",
472
+ canRetry: false,
473
+ };
474
+ }
475
+ return null;
476
+ }