@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.
- package/package.json +2 -2
- package/src/data/queries/useCallChat.ts +162 -0
- package/src/data/queries/useCallRecording.ts +80 -0
- package/src/data/queries/useMembershipCalls.ts +162 -0
- package/src/i18n/de.json +73 -1
- package/src/i18n/en.json +73 -1
- package/src/index.ts +7 -0
- package/src/ui/headless/memberHome/index.ts +4 -0
- package/src/ui/headless/memberHome/useMemberCalls.ts +137 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +29 -5
- package/src/ui/media/CallStage.tsx +691 -553
- package/src/ui/media/PreJoin.tsx +269 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +338 -5
- package/src/ui/media/_tests/callLayout.spec.ts +182 -0
- package/src/ui/media/_tests/callSignals.spec.ts +87 -0
- package/src/ui/media/_tests/callState.spec.ts +93 -1
- package/src/ui/media/_tests/fakeNode.ts +4 -1
- package/src/ui/media/callChat.ts +33 -0
- package/src/ui/media/callState.ts +125 -9
- package/src/ui/media/hooks/useCallSignals.ts +135 -0
- package/src/ui/media/hooks/useMeasuredWidth.ts +39 -0
- package/src/ui/media/index.ts +41 -1
- package/src/ui/media/panels/CallChatPanel.tsx +200 -0
- package/src/ui/media/panels/CallParticipantsPanel.tsx +125 -0
- package/src/ui/media/panels/CallSidePanel.tsx +152 -0
- package/src/ui/media/stage/CallCenterNotice.tsx +89 -0
- package/src/ui/media/stage/CallControlsBar.tsx +547 -0
- package/src/ui/media/stage/CallHealthPanel.tsx +132 -0
- package/src/ui/media/stage/CallToasts.tsx +49 -0
- package/src/ui/media/stage/CallTopBar.tsx +257 -0
- package/src/ui/media/stage/DeviceMenu.tsx +142 -0
- package/src/ui/media/stage/ReactionOverlay.tsx +38 -0
- package/src/ui/media/stage/SelfView.tsx +146 -0
- package/src/ui/media/stage/VideoTile.tsx +232 -0
- package/src/ui/media/stage/callLayout.ts +253 -0
- package/src/ui/media/stage/callSignals.ts +91 -0
- package/src/ui/media/stage/callSounds.ts +78 -0
- package/src/ui/media/stage/stageTheme.ts +50 -0
- package/src/ui/styled/forge-utilities.css +13 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +571 -0
- package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +135 -0
- 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
|
+
}
|