@tribe-nest/forge 3.42.0 → 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 (46) 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/broadcast/BroadcastPlayer.tsx +84 -7
  40. package/src/ui/styled/broadcast/broadcastStage.spec.ts +32 -0
  41. package/src/ui/styled/broadcast/relayReachable.spec.ts +42 -0
  42. package/src/ui/styled/broadcast/relayReachable.ts +47 -0
  43. package/src/ui/styled/forge-utilities.css +13 -0
  44. package/src/ui/styled/members/MemberCallsCalendar.tsx +571 -0
  45. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +135 -0
  46. package/src/ui/styled/members/index.ts +1 -0
@@ -0,0 +1,49 @@
1
+ import type { ForgeT } from "../../../i18n";
2
+ import type { StageTokens } from "./stageTheme";
3
+
4
+ export type CallToast = {
5
+ id: string;
6
+ /** Already-translated text: the stage composes it where the facts are. */
7
+ text: string;
8
+ };
9
+
10
+ /**
11
+ * Join/leave/hand notices, top-centre, `aria-live="polite"` so a screen
12
+ * reader hears an arrival without being interrupted by it. The stage owns
13
+ * the list (capped, collapsed under bursts, timed out); this only draws it.
14
+ */
15
+ export function CallToasts({ toasts, tokens }: { toasts: readonly CallToast[]; tokens: StageTokens; t: ForgeT }) {
16
+ return (
17
+ <div
18
+ aria-live="polite"
19
+ style={{
20
+ position: "absolute",
21
+ top: "0.75rem",
22
+ left: "50%",
23
+ transform: "translateX(-50%)",
24
+ display: "flex",
25
+ flexDirection: "column",
26
+ alignItems: "center",
27
+ gap: "0.375rem",
28
+ pointerEvents: "none",
29
+ zIndex: 5,
30
+ }}
31
+ >
32
+ {toasts.map((toast) => (
33
+ <span
34
+ key={toast.id}
35
+ data-testid="call-toast"
36
+ style={{
37
+ fontSize: "0.8125rem",
38
+ padding: "0.3rem 0.75rem",
39
+ borderRadius: 9999,
40
+ backgroundColor: "rgba(0,0,0,0.65)",
41
+ color: tokens.stageText,
42
+ }}
43
+ >
44
+ {toast.text}
45
+ </span>
46
+ ))}
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,257 @@
1
+ import { useEffect, useState, type ReactNode } from "react";
2
+ import { Activity, AlertTriangle, Grid3x3, Hand, Loader2, User } from "lucide-react";
3
+
4
+ import type { ForgeT } from "../../../i18n";
5
+ import { CallHelpHint } from "../CallHelpHint";
6
+ import type { CallStatus } from "../callState";
7
+ import type { ViewChoice } from "./callLayout";
8
+ import type { StageTokens } from "./stageTheme";
9
+
10
+ /**
11
+ * The top bar: title, elapsed time, the recording consent pill, the
12
+ * connection pill and the view toggle. Terminal states moved to the CENTRE
13
+ * of the stage (`CallCenterNotice`); this pill keeps the transient ones,
14
+ * where covering the stage would hide the tiles a person is watching.
15
+ */
16
+ export function CallTopBar({
17
+ title,
18
+ tokens,
19
+ t,
20
+ status,
21
+ recording,
22
+ connectedAt,
23
+ view,
24
+ onView,
25
+ headCount,
26
+ showViewToggle,
27
+ healthOpen,
28
+ onToggleHealth,
29
+ raisedHands,
30
+ onOpenHands,
31
+ compact = false,
32
+ }: {
33
+ title?: ReactNode;
34
+ tokens: StageTokens;
35
+ t: ForgeT;
36
+ status: CallStatus;
37
+ recording: boolean;
38
+ /** Epoch ms of the first successful connect, for the timer. Null before. */
39
+ connectedAt: number | null;
40
+ view: ViewChoice;
41
+ onView: (view: ViewChoice) => void;
42
+ headCount: number;
43
+ showViewToggle: boolean;
44
+ healthOpen: boolean;
45
+ onToggleHealth: () => void;
46
+ /** Raised hands, oldest first: the same queue order the panel shows. */
47
+ raisedHands: readonly { identity: string; name: string }[];
48
+ onOpenHands: () => void;
49
+ /** Phone width: drop the title and the head-count hint so the controls and
50
+ * the connection state stay on one row. */
51
+ compact?: boolean;
52
+ }) {
53
+ return (
54
+ <div
55
+ style={{
56
+ display: "flex",
57
+ alignItems: "center",
58
+ gap: "0.75rem",
59
+ flexWrap: "wrap",
60
+ padding: "0.5rem 0.75rem",
61
+ backgroundColor: tokens.stageChrome,
62
+ borderRadius: tokens.cornerRadius,
63
+ color: tokens.stageText,
64
+ }}
65
+ >
66
+ {title && !compact ? (
67
+ <div style={{ fontWeight: 600, fontFamily: tokens.headingFontFamily, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
68
+ {title}
69
+ </div>
70
+ ) : (
71
+ <span />
72
+ )}
73
+ <CallTimer connectedAt={connectedAt} tokens={tokens} />
74
+ <span style={{ display: "inline-flex", alignItems: "center", gap: "0.25rem", fontSize: "0.75rem", color: tokens.stageMuted }}>
75
+ {t("forge.call_stage.head_count", { count: headCount })}
76
+ {!compact && (
77
+ <CallHelpHint label={t("forge.call_stage.help_hear_not_see_label")}>
78
+ {t("forge.call_stage.help_hear_not_see")}
79
+ </CallHelpHint>
80
+ )}
81
+ </span>
82
+
83
+ {raisedHands.length > 0 && (
84
+ <button
85
+ type="button"
86
+ onClick={onOpenHands}
87
+ aria-label={t("forge.call_stage.hands_raised_open", { count: raisedHands.length })}
88
+ title={raisedHands.map((h) => h.name).join(", ")}
89
+ style={{
90
+ display: "inline-flex",
91
+ alignItems: "center",
92
+ gap: "0.375rem",
93
+ fontSize: "0.75rem",
94
+ fontWeight: 600,
95
+ border: `1px solid ${tokens.accent}`,
96
+ borderRadius: 9999,
97
+ padding: "0.125rem 0.6rem",
98
+ background: "none",
99
+ color: tokens.accent,
100
+ cursor: "pointer",
101
+ }}
102
+ >
103
+ <Hand width={13} height={13} aria-hidden="true" />
104
+ {/* The first raised hand IS the one a host takes next, so name it;
105
+ the rest are a count, and the full list is the button's title. */}
106
+ {raisedHands.length === 1
107
+ ? raisedHands[0]!.name
108
+ : t("forge.call_stage.hands_raised_named", { name: raisedHands[0]!.name, count: raisedHands.length - 1 })}
109
+ </button>
110
+ )}
111
+
112
+ <span style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: "0.75rem" }}>
113
+ {recording && (
114
+ <span
115
+ style={{
116
+ display: "inline-flex",
117
+ alignItems: "center",
118
+ gap: "0.375rem",
119
+ fontSize: "0.75rem",
120
+ fontWeight: 600,
121
+ border: `1px solid ${tokens.stageBorder}`,
122
+ borderRadius: 9999,
123
+ padding: "0.125rem 0.6rem",
124
+ }}
125
+ >
126
+ <span aria-hidden="true" style={{ width: 8, height: 8, borderRadius: 9999, backgroundColor: tokens.danger }} />
127
+ {t("forge.call_stage.recording")}
128
+ </span>
129
+ )}
130
+ <button
131
+ type="button"
132
+ onClick={onToggleHealth}
133
+ aria-pressed={healthOpen}
134
+ aria-label={t("forge.call_stage.health_toggle")}
135
+ title={t("forge.call_stage.health_toggle")}
136
+ style={{
137
+ display: "inline-flex",
138
+ alignItems: "center",
139
+ padding: "0.25rem",
140
+ background: "none",
141
+ border: `1px solid ${healthOpen ? tokens.accent : tokens.stageBorder}`,
142
+ borderRadius: tokens.cornerRadius,
143
+ color: healthOpen ? tokens.accent : tokens.stageMuted,
144
+ cursor: "pointer",
145
+ }}
146
+ >
147
+ <Activity width={14} height={14} aria-hidden="true" />
148
+ </button>
149
+ <ConnectionPill status={status} tokens={tokens} t={t} />
150
+ {showViewToggle && (
151
+ <span style={{ display: "inline-flex", borderRadius: tokens.cornerRadius, overflow: "hidden", border: `1px solid ${tokens.stageBorder}` }}>
152
+ <ToggleHalf
153
+ active={view === "gallery"}
154
+ label={t("forge.call_stage.view_gallery")}
155
+ onClick={() => onView("gallery")}
156
+ tokens={tokens}
157
+ >
158
+ <Grid3x3 width={14} height={14} aria-hidden="true" />
159
+ </ToggleHalf>
160
+ <ToggleHalf
161
+ active={view === "speaker"}
162
+ label={t("forge.call_stage.view_speaker")}
163
+ onClick={() => onView("speaker")}
164
+ tokens={tokens}
165
+ >
166
+ <User width={14} height={14} aria-hidden="true" />
167
+ </ToggleHalf>
168
+ </span>
169
+ )}
170
+ </span>
171
+ </div>
172
+ );
173
+ }
174
+
175
+ function ToggleHalf({
176
+ active,
177
+ label,
178
+ onClick,
179
+ tokens,
180
+ children,
181
+ }: {
182
+ active: boolean;
183
+ label: string;
184
+ onClick: () => void;
185
+ tokens: StageTokens;
186
+ children: ReactNode;
187
+ }) {
188
+ return (
189
+ <button
190
+ type="button"
191
+ onClick={onClick}
192
+ aria-pressed={active}
193
+ aria-label={label}
194
+ title={label}
195
+ style={{
196
+ display: "inline-flex",
197
+ alignItems: "center",
198
+ gap: "0.25rem",
199
+ padding: "0.3rem 0.55rem",
200
+ border: "none",
201
+ cursor: "pointer",
202
+ backgroundColor: active ? tokens.accent : "transparent",
203
+ color: active ? tokens.accentText : tokens.stageText,
204
+ font: "inherit",
205
+ fontSize: "0.75rem",
206
+ }}
207
+ >
208
+ {children}
209
+ </button>
210
+ );
211
+ }
212
+
213
+ /**
214
+ * Elapsed time, ticking in ISOLATION. The one component on the stage allowed
215
+ * a per-second interval, so nothing else re-renders with the clock.
216
+ */
217
+ export function CallTimer({ connectedAt, tokens }: { connectedAt: number | null; tokens: StageTokens }) {
218
+ const [now, setNow] = useState(() => Date.now());
219
+ useEffect(() => {
220
+ if (connectedAt === null) return undefined;
221
+ const timer = setInterval(() => setNow(Date.now()), 1000);
222
+ return () => clearInterval(timer);
223
+ }, [connectedAt]);
224
+ if (connectedAt === null) return null;
225
+
226
+ const seconds = Math.max(0, Math.floor((now - connectedAt) / 1000));
227
+ const hh = Math.floor(seconds / 3600);
228
+ const mm = Math.floor((seconds % 3600) / 60);
229
+ const ss = seconds % 60;
230
+ const pad = (value: number) => String(value).padStart(2, "0");
231
+ return (
232
+ <span data-testid="call-timer" style={{ fontSize: "0.75rem", color: tokens.stageMuted, fontVariantNumeric: "tabular-nums" }}>
233
+ {hh > 0 ? `${hh}:${pad(mm)}:${pad(ss)}` : `${mm}:${pad(ss)}`}
234
+ </span>
235
+ );
236
+ }
237
+
238
+ /** Transient connection states only; terminal ones own the stage centre. */
239
+ function ConnectionPill({ status, tokens, t }: { status: CallStatus; tokens: StageTokens; t: ForgeT }) {
240
+ const busy = status.tone === "connecting" || status.tone === "reconnecting";
241
+ const detail = status.detailKey ? t(status.detailKey) : status.detailText;
242
+ return (
243
+ <span
244
+ role="status"
245
+ aria-live="polite"
246
+ style={{ display: "inline-flex", alignItems: "center", gap: "0.4rem", fontSize: "0.75rem", color: tokens.stageMuted }}
247
+ >
248
+ {busy && <Loader2 width={13} height={13} aria-hidden="true" style={{ animation: "forge-call-spin 1.2s linear infinite" }} />}
249
+ {status.tone === "failed" && <AlertTriangle width={13} height={13} aria-hidden="true" color={tokens.danger} />}
250
+ {status.tone === "live" && (
251
+ <span aria-hidden="true" style={{ width: 7, height: 7, borderRadius: 9999, backgroundColor: tokens.accent, display: "inline-block" }} />
252
+ )}
253
+ <span style={{ fontWeight: status.tone === "failed" ? 600 : 400 }}>{t(status.headlineKey)}</span>
254
+ {detail && status.tone !== "live" && <span>{detail}</span>}
255
+ </span>
256
+ );
257
+ }
@@ -0,0 +1,142 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { ChevronUp } from "lucide-react";
3
+
4
+ import type { ForgeT } from "../../../i18n";
5
+ import type { StageTokens } from "./stageTheme";
6
+
7
+ export type DeviceKind = "audioinput" | "videoinput" | "audiooutput";
8
+
9
+ /**
10
+ * The device picker behind a control's chevron.
11
+ *
12
+ * `enumerateDevices` yields labels only after a capture permission was
13
+ * granted, so the list is read when the menu OPENS, not at mount: by then
14
+ * the person has usually published something. A device with no label is
15
+ * shown by its ordinal, which is unhelpful but honest.
16
+ *
17
+ * The speaker entry only exists where `setSinkId` does (not Safari); the
18
+ * caller simply does not render an output menu there.
19
+ */
20
+ export function DeviceMenu({
21
+ kind,
22
+ currentDeviceId,
23
+ onPick,
24
+ tokens,
25
+ t,
26
+ label,
27
+ }: {
28
+ kind: DeviceKind;
29
+ currentDeviceId?: string;
30
+ onPick: (deviceId: string) => void;
31
+ tokens: StageTokens;
32
+ t: ForgeT;
33
+ label: string;
34
+ }) {
35
+ const [open, setOpen] = useState(false);
36
+ const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
37
+ const menuRef = useRef<HTMLDivElement | null>(null);
38
+
39
+ useEffect(() => {
40
+ if (!open) return undefined;
41
+ let cancelled = false;
42
+ void navigator.mediaDevices
43
+ ?.enumerateDevices()
44
+ .then((all) => {
45
+ if (!cancelled) setDevices(all.filter((d) => d.kind === kind));
46
+ })
47
+ .catch(() => undefined);
48
+ const close = (event: PointerEvent) => {
49
+ if (!menuRef.current?.contains(event.target as Node)) setOpen(false);
50
+ };
51
+ window.addEventListener("pointerdown", close);
52
+ return () => {
53
+ cancelled = true;
54
+ window.removeEventListener("pointerdown", close);
55
+ };
56
+ }, [open, kind]);
57
+
58
+ return (
59
+ <div ref={menuRef} style={{ position: "relative", display: "inline-flex" }}>
60
+ <button
61
+ type="button"
62
+ onClick={() => setOpen((current) => !current)}
63
+ aria-haspopup="listbox"
64
+ aria-expanded={open}
65
+ aria-label={label}
66
+ title={label}
67
+ style={{
68
+ display: "inline-flex",
69
+ alignItems: "center",
70
+ padding: "0.5rem 0.25rem",
71
+ border: `1px solid ${tokens.stageBorder}`,
72
+ borderLeft: "none",
73
+ borderRadius: `0 ${tokens.cornerRadius}px ${tokens.cornerRadius}px 0`,
74
+ backgroundColor: "transparent",
75
+ color: tokens.stageText,
76
+ cursor: "pointer",
77
+ }}
78
+ >
79
+ <ChevronUp width={12} height={12} aria-hidden="true" />
80
+ </button>
81
+ {open && (
82
+ <div
83
+ role="listbox"
84
+ aria-label={label}
85
+ style={{
86
+ position: "absolute",
87
+ bottom: "calc(100% + 0.375rem)",
88
+ right: 0,
89
+ minWidth: "14rem",
90
+ maxHeight: "14rem",
91
+ overflowY: "auto",
92
+ display: "flex",
93
+ flexDirection: "column",
94
+ padding: "0.25rem",
95
+ borderRadius: tokens.cornerRadius,
96
+ backgroundColor: tokens.stageChrome,
97
+ border: `1px solid ${tokens.stageBorder}`,
98
+ boxShadow: "0 6px 24px rgba(0,0,0,0.5)",
99
+ zIndex: 10,
100
+ }}
101
+ >
102
+ {devices.length === 0 && (
103
+ <span style={{ padding: "0.4rem 0.6rem", fontSize: "0.8125rem", color: tokens.stageMuted }}>
104
+ {t("forge.call_stage.no_devices")}
105
+ </span>
106
+ )}
107
+ {devices.map((device, index) => {
108
+ const active = device.deviceId === currentDeviceId;
109
+ return (
110
+ <button
111
+ key={device.deviceId || index}
112
+ type="button"
113
+ role="option"
114
+ aria-selected={active}
115
+ onClick={() => {
116
+ setOpen(false);
117
+ onPick(device.deviceId);
118
+ }}
119
+ style={{
120
+ textAlign: "left",
121
+ padding: "0.4rem 0.6rem",
122
+ border: "none",
123
+ borderRadius: tokens.cornerRadius,
124
+ cursor: "pointer",
125
+ backgroundColor: active ? tokens.accent : "transparent",
126
+ color: active ? tokens.accentText : tokens.stageText,
127
+ font: "inherit",
128
+ fontSize: "0.8125rem",
129
+ whiteSpace: "nowrap",
130
+ overflow: "hidden",
131
+ textOverflow: "ellipsis",
132
+ }}
133
+ >
134
+ {device.label || t("forge.call_stage.unnamed_device", { number: index + 1 })}
135
+ </button>
136
+ );
137
+ })}
138
+ </div>
139
+ )}
140
+ </div>
141
+ );
142
+ }
@@ -0,0 +1,38 @@
1
+ import type { FlyingReaction } from "./callSignals";
2
+
3
+ /**
4
+ * The flying emoji layer. Pointer-events none, so a reaction never blocks a
5
+ * click on the tile under it; the list itself is owned by the stage (culled
6
+ * and capped in `callSignals`), this only animates what it is given.
7
+ */
8
+ export function ReactionOverlay({ reactions }: { reactions: readonly FlyingReaction[] }) {
9
+ if (reactions.length === 0) return null;
10
+ return (
11
+ <div aria-hidden="true" style={{ position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none" }}>
12
+ {reactions.map((reaction) => (
13
+ <span
14
+ key={reaction.id}
15
+ data-testid="flying-reaction"
16
+ style={{
17
+ position: "absolute",
18
+ bottom: 0,
19
+ // Lanes spread across the middle 80% so nothing rises under the
20
+ // controls at the edges.
21
+ left: `${10 + reaction.lane * 80}%`,
22
+ fontSize: "1.75rem",
23
+ animation: "forge-call-reaction-rise 3.5s ease-out forwards",
24
+ }}
25
+ >
26
+ {reaction.emoji}
27
+ </span>
28
+ ))}
29
+ <style>{`
30
+ @keyframes forge-call-reaction-rise {
31
+ 0% { transform: translateY(0) scale(0.8); opacity: 0; }
32
+ 10% { opacity: 1; }
33
+ 100% { transform: translateY(-60vh) scale(1.15); opacity: 0; }
34
+ }
35
+ `}</style>
36
+ </div>
37
+ );
38
+ }
@@ -0,0 +1,146 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { EyeOff } from "lucide-react";
3
+ import { useLocalPublications, useRoomState } from "@tribe-nest/media-client/react";
4
+
5
+ import type { ForgeT } from "../../../i18n";
6
+ import type { StageTokens } from "./stageTheme";
7
+
8
+ export type SelfViewCorner = "br" | "bl" | "tr" | "tl";
9
+
10
+ /**
11
+ * The floating self-view: small, corner-snapped, hideable.
12
+ *
13
+ * Dragging snaps to the NEAREST corner on release rather than free-floating,
14
+ * because a preview parked over somebody's face mid-call is the thing people
15
+ * actually complain about, and four corners are four predictable homes.
16
+ * Muted, or it is a feedback loop.
17
+ */
18
+ export function SelfView({
19
+ tokens,
20
+ t,
21
+ corner,
22
+ onCorner,
23
+ onHide,
24
+ }: {
25
+ tokens: StageTokens;
26
+ t: ForgeT;
27
+ corner: SelfViewCorner;
28
+ onCorner: (corner: SelfViewCorner) => void;
29
+ onHide: () => void;
30
+ }) {
31
+ const state = useRoomState();
32
+ const publications = useLocalPublications();
33
+ const videoRef = useRef<HTMLVideoElement | null>(null);
34
+ const [dragging, setDragging] = useState<{ x: number; y: number } | null>(null);
35
+
36
+ const camera = useMemo(() => publications.find((p) => p.source === "camera"), [publications]);
37
+ const track = camera && !camera.paused ? camera.track : undefined;
38
+
39
+ useEffect(() => {
40
+ const element = videoRef.current;
41
+ if (!element) return;
42
+ element.srcObject = track ? new MediaStream([track]) : null;
43
+ return () => {
44
+ element.srcObject = null;
45
+ };
46
+ }, [track]);
47
+
48
+ const place: Record<SelfViewCorner, React.CSSProperties> = {
49
+ br: { right: "0.75rem", bottom: "0.75rem" },
50
+ bl: { left: "0.75rem", bottom: "0.75rem" },
51
+ tr: { right: "0.75rem", top: "0.75rem" },
52
+ tl: { left: "0.75rem", top: "0.75rem" },
53
+ };
54
+
55
+ const name = state.identity ? t("forge.call_stage.you") : t("forge.call_stage.you_joining");
56
+
57
+ return (
58
+ <div
59
+ data-testid="tile-local"
60
+ style={{
61
+ position: "absolute",
62
+ ...place[corner],
63
+ width: "clamp(7rem, 18%, 13rem)",
64
+ aspectRatio: "16 / 9",
65
+ borderRadius: tokens.cornerRadius,
66
+ overflow: "hidden",
67
+ backgroundColor: tokens.stageSurface,
68
+ border: `1px solid ${tokens.stageBorder}`,
69
+ boxShadow: "0 4px 16px rgba(0,0,0,0.45)",
70
+ display: "flex",
71
+ alignItems: "center",
72
+ justifyContent: "center",
73
+ zIndex: 4,
74
+ cursor: "grab",
75
+ touchAction: "none",
76
+ ...(dragging ? { transform: `translate(${dragging.x}px, ${dragging.y}px)`, transition: "none" } : { transition: "transform 120ms" }),
77
+ }}
78
+ onPointerDown={(event) => {
79
+ // The element is read NOW: React's currentTarget is not valid inside
80
+ // listeners that fire after the handler returns.
81
+ const element = event.currentTarget as HTMLElement;
82
+ // Pointer capture, so a touch drag does not lose the element the
83
+ // moment the finger leaves it.
84
+ element.setPointerCapture?.(event.pointerId);
85
+ const startX = event.clientX;
86
+ const startY = event.clientY;
87
+ setDragging({ x: 0, y: 0 });
88
+ const move = (moveEvent: PointerEvent) => setDragging({ x: moveEvent.clientX - startX, y: moveEvent.clientY - startY });
89
+ const up = (upEvent: PointerEvent) => {
90
+ window.removeEventListener("pointermove", move);
91
+ window.removeEventListener("pointerup", up);
92
+ setDragging(null);
93
+ const box = element.parentElement?.getBoundingClientRect();
94
+ if (!box) return;
95
+ const horizontal = upEvent.clientX - box.left < box.width / 2 ? "l" : "r";
96
+ const vertical = upEvent.clientY - box.top < box.height / 2 ? "t" : "b";
97
+ onCorner(`${vertical}${horizontal}` as SelfViewCorner);
98
+ };
99
+ window.addEventListener("pointermove", move);
100
+ window.addEventListener("pointerup", up);
101
+ }}
102
+ >
103
+ {track ? (
104
+ <video ref={videoRef} autoPlay playsInline muted style={{ width: "100%", height: "100%", objectFit: "cover" }} />
105
+ ) : (
106
+ <span style={{ fontSize: "0.8125rem", fontWeight: 600, color: tokens.stageText }}>{name}</span>
107
+ )}
108
+ <span
109
+ style={{
110
+ position: "absolute",
111
+ left: "0.375rem",
112
+ bottom: "0.375rem",
113
+ fontSize: "0.6875rem",
114
+ padding: "0.1rem 0.375rem",
115
+ borderRadius: tokens.cornerRadius,
116
+ backgroundColor: "rgba(0,0,0,0.55)",
117
+ color: tokens.stageText,
118
+ }}
119
+ >
120
+ {name}
121
+ </span>
122
+ <button
123
+ type="button"
124
+ onClick={onHide}
125
+ aria-label={t("forge.call_stage.hide_self_view")}
126
+ style={{
127
+ position: "absolute",
128
+ top: "0.25rem",
129
+ right: "0.25rem",
130
+ display: "inline-flex",
131
+ alignItems: "center",
132
+ justifyContent: "center",
133
+ width: 22,
134
+ height: 22,
135
+ borderRadius: 9999,
136
+ border: "none",
137
+ cursor: "pointer",
138
+ backgroundColor: "rgba(0,0,0,0.5)",
139
+ color: tokens.stageText,
140
+ }}
141
+ >
142
+ <EyeOff width={12} height={12} aria-hidden="true" />
143
+ </button>
144
+ </div>
145
+ );
146
+ }