@tribe-nest/forge 3.29.0 → 3.34.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 +11 -3
- package/src/_tests/publishedResolvability.spec.ts +184 -0
- package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
- package/src/_tests/workspaceAliases.ts +40 -0
- package/src/contexts/CartContext.tsx +17 -1
- package/src/contexts/PublicAuthContext.tsx +34 -5
- package/src/contexts/_tests/CartContext.spec.tsx +36 -0
- package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
- package/src/data/queries/useBroadcasts.ts +151 -0
- package/src/data/queries/useMyBookings.ts +18 -1
- package/src/i18n/_tests/translationKeys.spec.ts +15 -0
- package/src/i18n/de.json +97 -0
- package/src/i18n/en.json +97 -0
- package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
- package/src/ui/format/_tests/pwyw.spec.ts +65 -8
- package/src/ui/format/membershipPwyw.ts +164 -0
- package/src/ui/format/pwyw.ts +37 -0
- package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
- package/src/ui/headless/broadcast/broadcastState.ts +158 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
- package/src/ui/headless/event/useEventCheckout.ts +8 -13
- package/src/ui/headless/index.ts +14 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
- package/src/ui/index.ts +61 -0
- package/src/ui/media/BookingCallScreen.tsx +33 -0
- package/src/ui/media/CallHelpHint.tsx +87 -0
- package/src/ui/media/CallStage.tsx +633 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +931 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +227 -0
- package/src/ui/media/_tests/callState.spec.ts +499 -0
- package/src/ui/media/_tests/fakeNode.ts +178 -0
- package/src/ui/media/bookingSession.tsx +182 -0
- package/src/ui/media/bookingWindow.ts +81 -0
- package/src/ui/media/callState.ts +360 -0
- package/src/ui/media/index.ts +135 -0
- package/src/ui/styled/AccountDashboard.tsx +193 -4
- package/src/ui/styled/BroadcastWatch.tsx +107 -0
- package/src/ui/styled/ForgotPasswordForm.tsx +5 -0
- package/src/ui/styled/LiveBroadcastList.tsx +171 -0
- package/src/ui/styled/LoginForm.tsx +10 -0
- package/src/ui/styled/MembershipCheckout.tsx +318 -45
- package/src/ui/styled/MembershipTiers.tsx +10 -3
- package/src/ui/styled/ResetPasswordForm.tsx +5 -0
- package/src/ui/styled/SignupForm.tsx +5 -0
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +166 -0
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
- package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
- package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
- package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
- package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
- package/src/ui/styled/community/CommunityComposer.tsx +182 -3
- package/src/ui/styled/community/CommunityFeed.tsx +36 -51
- package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
- package/src/ui/styled/forge-utilities.css +832 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
AlertTriangle,
|
|
4
|
+
Loader2,
|
|
5
|
+
Mic,
|
|
6
|
+
MicOff,
|
|
7
|
+
MonitorUp,
|
|
8
|
+
MonitorX,
|
|
9
|
+
PhoneOff,
|
|
10
|
+
RefreshCw,
|
|
11
|
+
Video,
|
|
12
|
+
VideoOff,
|
|
13
|
+
} from "lucide-react";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
useActiveSpeakers,
|
|
17
|
+
useGrants,
|
|
18
|
+
useLocalMedia,
|
|
19
|
+
useLocalPublications,
|
|
20
|
+
useMediaRoom,
|
|
21
|
+
useParticipants,
|
|
22
|
+
useRecording,
|
|
23
|
+
useRemoteTrack,
|
|
24
|
+
useRoomState,
|
|
25
|
+
useVisibleProducers,
|
|
26
|
+
type LocalSource,
|
|
27
|
+
} from "@tribe-nest/media-client/react";
|
|
28
|
+
|
|
29
|
+
import { useForgeT, type ForgeT } from "../../i18n";
|
|
30
|
+
import { useThemeTokens, type ResolvedThemeTokens } from "../theme/ForgeThemeProvider";
|
|
31
|
+
import { CallHelpHint } from "./CallHelpHint";
|
|
32
|
+
import {
|
|
33
|
+
callHeadCount,
|
|
34
|
+
callStatus,
|
|
35
|
+
callTiles,
|
|
36
|
+
canPublishSource,
|
|
37
|
+
remoteAudioProducerIds,
|
|
38
|
+
type CallStatus,
|
|
39
|
+
type CallTile,
|
|
40
|
+
} from "./callState";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The call, as a screen. One implementation, both surfaces.
|
|
44
|
+
*
|
|
45
|
+
* Everything above this file was reachable only from a test: the room, the
|
|
46
|
+
* hooks and `BookingCallProvider` all existed and nothing rendered a call, so
|
|
47
|
+
* no human could join one. This is the part that makes it reachable, and it is
|
|
48
|
+
* built ONCE here because there are two rendering surfaces (admin, and code
|
|
49
|
+
* websites through Forge) and a second implementation would mean every fix to
|
|
50
|
+
* the ordering rules had to be made twice. The second copy is always the one
|
|
51
|
+
* that drifts.
|
|
52
|
+
*
|
|
53
|
+
* ## Where the colours come from
|
|
54
|
+
*
|
|
55
|
+
* `useThemeTokens()`, which on a code website is the creator's own
|
|
56
|
+
* `themeSettings` fed through `<ForgeThemeProvider>`, and in admin is the
|
|
57
|
+
* dashboard's own tokens fed through the same provider. Not one colour is
|
|
58
|
+
* written down in this file. A call screen with a hard-coded palette is a call
|
|
59
|
+
* screen that looks like somebody else's product on every site that installs
|
|
60
|
+
* it.
|
|
61
|
+
*
|
|
62
|
+
* ## Where the words come from
|
|
63
|
+
*
|
|
64
|
+
* `useForgeT()`, like every other Forge component, with keys under
|
|
65
|
+
* `forge.call_stage.*`. `callStatus` returns KEYS for the same reason it is
|
|
66
|
+
* pure: it has no locale. The one thing rendered verbatim is the server's own
|
|
67
|
+
* sentence (a refusal, a close reason), because a friendlier translation of a
|
|
68
|
+
* refusal is a lie about why you were refused. Admin renders this same
|
|
69
|
+
* component inside `<ForgeI18nProvider>` with the dashboard's locale; outside
|
|
70
|
+
* any provider the hook answers in English, so a site that has not mounted one
|
|
71
|
+
* still gets a working call.
|
|
72
|
+
*
|
|
73
|
+
* ## What it does NOT decide
|
|
74
|
+
*
|
|
75
|
+
* Whether a control may be used. `useGrants()` says what the node minted this
|
|
76
|
+
* token for, and it is read here so a button that would be refused is not
|
|
77
|
+
* offered; the node checks the same thing on the publish itself and its answer
|
|
78
|
+
* is the only one that counts. Hiding a control is courtesy, never enforcement.
|
|
79
|
+
*
|
|
80
|
+
* ## Mount it behind a Join control
|
|
81
|
+
*
|
|
82
|
+
* The provider connects on mount and closes on unmount. Rendering the whole
|
|
83
|
+
* thing inside a list of sessions would open a media connection for every row.
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
export type CallStageProps = {
|
|
87
|
+
/**
|
|
88
|
+
* Called once the local participant has left. The host is expected to unmount
|
|
89
|
+
* the provider: this component closes the room, but a provider left mounted
|
|
90
|
+
* would reconnect on the next render.
|
|
91
|
+
*/
|
|
92
|
+
onLeave?: () => void;
|
|
93
|
+
/** Whose call this is. Rendered above the grid. */
|
|
94
|
+
title?: ReactNode;
|
|
95
|
+
/** Ratio for each tile. Defaults to 16 / 9. */
|
|
96
|
+
aspectRatio?: number;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStageProps) {
|
|
100
|
+
const tokens = useThemeTokens();
|
|
101
|
+
const t = useForgeT();
|
|
102
|
+
const { connectionState, error, recovering, retry } = useMediaRoom();
|
|
103
|
+
const state = useRoomState();
|
|
104
|
+
const peers = useParticipants();
|
|
105
|
+
const activeSpeakers = useActiveSpeakers();
|
|
106
|
+
const recording = useRecording();
|
|
107
|
+
/**
|
|
108
|
+
* The barrier, applied. NOT `state.producers`.
|
|
109
|
+
*
|
|
110
|
+
* The reducer keeps every producer the node has ever announced so a subscribe
|
|
111
|
+
* rule that narrows and later widens is reversible, which means the raw list
|
|
112
|
+
* can hold people this seat has been barred from seeing. On a sealed room
|
|
113
|
+
* their presence is most of what the barrier was hiding, and a tile carries
|
|
114
|
+
* their identity.
|
|
115
|
+
*/
|
|
116
|
+
const producers = useVisibleProducers();
|
|
117
|
+
|
|
118
|
+
const status = useMemo(
|
|
119
|
+
() => callStatus({ connectionState, error, recovering, phase: state.phase, closedReason: state.closedReason }),
|
|
120
|
+
[connectionState, error, recovering, state.phase, state.closedReason],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Memoised on the room's own snapshots, every one of which is a stable
|
|
124
|
+
// reference while nothing changed. Rebuilding these per render is what turns
|
|
125
|
+
// a call screen into a component that re-renders per FRAME.
|
|
126
|
+
const tiles = useMemo(
|
|
127
|
+
() => callTiles({ identity: state.identity, peers, producers, activeSpeakers }),
|
|
128
|
+
[state.identity, peers, producers, activeSpeakers],
|
|
129
|
+
);
|
|
130
|
+
const audioProducerIds = useMemo(
|
|
131
|
+
() => remoteAudioProducerIds({ identity: state.identity, producers }),
|
|
132
|
+
[state.identity, producers],
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div
|
|
137
|
+
style={{
|
|
138
|
+
display: "flex",
|
|
139
|
+
flexDirection: "column",
|
|
140
|
+
gap: "0.75rem",
|
|
141
|
+
padding: "1rem",
|
|
142
|
+
borderRadius: tokens.cornerRadius,
|
|
143
|
+
border: `1px solid ${tokens.border}`,
|
|
144
|
+
backgroundColor: tokens.background,
|
|
145
|
+
color: tokens.text,
|
|
146
|
+
fontFamily: tokens.fontFamily,
|
|
147
|
+
}}
|
|
148
|
+
>
|
|
149
|
+
<div
|
|
150
|
+
style={{
|
|
151
|
+
display: "flex",
|
|
152
|
+
alignItems: "center",
|
|
153
|
+
justifyContent: "space-between",
|
|
154
|
+
gap: "0.75rem",
|
|
155
|
+
flexWrap: "wrap",
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
{title ? <div style={{ fontWeight: 600, fontFamily: tokens.headingFontFamily }}>{title}</div> : <span />}
|
|
159
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
|
160
|
+
{recording && <RecordingIndicator tokens={tokens} t={t} />}
|
|
161
|
+
<ConnectionStatus status={status} tokens={tokens} t={t} onRetry={retry} />
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.375rem", fontSize: "0.75rem", color: tokens.muted }}>
|
|
166
|
+
<span>{t("forge.call_stage.head_count", { count: callHeadCount(tiles) })}</span>
|
|
167
|
+
<CallHelpHint label={t("forge.call_stage.help_hear_not_see_label")}>
|
|
168
|
+
{t("forge.call_stage.help_hear_not_see")}
|
|
169
|
+
</CallHelpHint>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div
|
|
173
|
+
style={{
|
|
174
|
+
display: "grid",
|
|
175
|
+
gridTemplateColumns: "repeat(auto-fit, minmax(12rem, 1fr))",
|
|
176
|
+
gap: "0.75rem",
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
<LocalPreview tokens={tokens} aspectRatio={aspectRatio} t={t} />
|
|
180
|
+
{tiles.map((tile) => (
|
|
181
|
+
<ParticipantTile key={tile.key} tile={tile} tokens={tokens} aspectRatio={aspectRatio} t={t} />
|
|
182
|
+
))}
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
{audioProducerIds.map((producerId) => (
|
|
186
|
+
<RemoteAudio key={producerId} producerId={producerId} />
|
|
187
|
+
))}
|
|
188
|
+
|
|
189
|
+
<CallControls tokens={tokens} t={t} connected={connectionState === "connected"} onLeave={onLeave} />
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The connection, said plainly.
|
|
196
|
+
*
|
|
197
|
+
* Four different words for four different situations, because "connecting",
|
|
198
|
+
* "reconnecting" and "we were refused" call for three different responses and a
|
|
199
|
+
* single spinner tells you which of them you are in exactly never.
|
|
200
|
+
*/
|
|
201
|
+
function ConnectionStatus({
|
|
202
|
+
status,
|
|
203
|
+
tokens,
|
|
204
|
+
t,
|
|
205
|
+
onRetry,
|
|
206
|
+
}: {
|
|
207
|
+
status: CallStatus;
|
|
208
|
+
tokens: ResolvedThemeTokens;
|
|
209
|
+
t: ForgeT;
|
|
210
|
+
onRetry: () => void;
|
|
211
|
+
}) {
|
|
212
|
+
const busy = status.tone === "connecting" || status.tone === "reconnecting";
|
|
213
|
+
const alarming = status.tone === "failed";
|
|
214
|
+
// Our own copy is translated; the server's sentence is shown as it came.
|
|
215
|
+
const detail = status.detailKey ? t(status.detailKey) : status.detailText;
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<div
|
|
219
|
+
role="status"
|
|
220
|
+
aria-live="polite"
|
|
221
|
+
style={{
|
|
222
|
+
display: "flex",
|
|
223
|
+
alignItems: "center",
|
|
224
|
+
gap: "0.5rem",
|
|
225
|
+
fontSize: "0.8125rem",
|
|
226
|
+
color: alarming ? tokens.text : tokens.muted,
|
|
227
|
+
}}
|
|
228
|
+
>
|
|
229
|
+
{busy && <Loader2 width={14} height={14} aria-hidden="true" />}
|
|
230
|
+
{alarming && <AlertTriangle width={14} height={14} aria-hidden="true" color={tokens.secondary} />}
|
|
231
|
+
{status.tone === "live" && (
|
|
232
|
+
<span
|
|
233
|
+
aria-hidden="true"
|
|
234
|
+
style={{ width: 8, height: 8, borderRadius: 9999, backgroundColor: tokens.primary, display: "inline-block" }}
|
|
235
|
+
/>
|
|
236
|
+
)}
|
|
237
|
+
<span style={{ fontWeight: alarming ? 600 : 400 }}>{t(status.headlineKey)}</span>
|
|
238
|
+
{detail && <span style={{ color: tokens.muted }}>{detail}</span>}
|
|
239
|
+
{status.canRetry && (
|
|
240
|
+
<button
|
|
241
|
+
type="button"
|
|
242
|
+
onClick={onRetry}
|
|
243
|
+
style={{
|
|
244
|
+
display: "inline-flex",
|
|
245
|
+
alignItems: "center",
|
|
246
|
+
gap: "0.25rem",
|
|
247
|
+
border: `1px solid ${tokens.border}`,
|
|
248
|
+
borderRadius: tokens.cornerRadius,
|
|
249
|
+
backgroundColor: "transparent",
|
|
250
|
+
color: tokens.text,
|
|
251
|
+
padding: "0.125rem 0.5rem",
|
|
252
|
+
cursor: "pointer",
|
|
253
|
+
font: "inherit",
|
|
254
|
+
fontSize: "0.75rem",
|
|
255
|
+
}}
|
|
256
|
+
>
|
|
257
|
+
<RefreshCw width={12} height={12} aria-hidden="true" />
|
|
258
|
+
{t("forge.call_stage.retry")}
|
|
259
|
+
</button>
|
|
260
|
+
)}
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Somebody is recording. A consent signal, so it is content and always shown. */
|
|
266
|
+
function RecordingIndicator({ tokens, t }: { tokens: ResolvedThemeTokens; t: ForgeT }) {
|
|
267
|
+
return (
|
|
268
|
+
<span
|
|
269
|
+
style={{
|
|
270
|
+
display: "inline-flex",
|
|
271
|
+
alignItems: "center",
|
|
272
|
+
gap: "0.375rem",
|
|
273
|
+
fontSize: "0.75rem",
|
|
274
|
+
fontWeight: 600,
|
|
275
|
+
color: tokens.text,
|
|
276
|
+
border: `1px solid ${tokens.border}`,
|
|
277
|
+
borderRadius: tokens.cornerRadius,
|
|
278
|
+
padding: "0.125rem 0.5rem",
|
|
279
|
+
}}
|
|
280
|
+
>
|
|
281
|
+
<span
|
|
282
|
+
aria-hidden="true"
|
|
283
|
+
style={{ width: 8, height: 8, borderRadius: 9999, backgroundColor: tokens.secondary, display: "inline-block" }}
|
|
284
|
+
/>
|
|
285
|
+
{t("forge.call_stage.recording")}
|
|
286
|
+
</span>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const tileFrame = (tokens: ResolvedThemeTokens, aspectRatio: number, speaking: boolean): CSSProperties => ({
|
|
291
|
+
position: "relative",
|
|
292
|
+
overflow: "hidden",
|
|
293
|
+
aspectRatio: String(aspectRatio),
|
|
294
|
+
borderRadius: tokens.cornerRadius,
|
|
295
|
+
backgroundColor: tokens.surface,
|
|
296
|
+
// The active speaker is shown by the FRAME rather than by re-ordering the
|
|
297
|
+
// grid: a grid that reshuffles itself every time somebody says "mm" is
|
|
298
|
+
// unusable, and the person you were looking at moves out from under the eye.
|
|
299
|
+
border: `2px solid ${speaking ? tokens.primary : tokens.border}`,
|
|
300
|
+
display: "flex",
|
|
301
|
+
alignItems: "center",
|
|
302
|
+
justifyContent: "center",
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const tileName = (tokens: ResolvedThemeTokens): CSSProperties => ({
|
|
306
|
+
position: "absolute",
|
|
307
|
+
left: "0.5rem",
|
|
308
|
+
bottom: "0.5rem",
|
|
309
|
+
maxWidth: "calc(100% - 1rem)",
|
|
310
|
+
overflow: "hidden",
|
|
311
|
+
textOverflow: "ellipsis",
|
|
312
|
+
whiteSpace: "nowrap",
|
|
313
|
+
fontSize: "0.75rem",
|
|
314
|
+
padding: "0.125rem 0.375rem",
|
|
315
|
+
borderRadius: tokens.cornerRadius,
|
|
316
|
+
backgroundColor: tokens.background,
|
|
317
|
+
color: tokens.text,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* One participant.
|
|
322
|
+
*
|
|
323
|
+
* A tile with no video renders the NAME, at size, and keeps its place in the
|
|
324
|
+
* grid. That is the whole point: somebody with the camera off has not left, and
|
|
325
|
+
* a grid that drops them re-flows under everyone else's eyes the moment one
|
|
326
|
+
* person turns a camera off.
|
|
327
|
+
*/
|
|
328
|
+
function ParticipantTile({
|
|
329
|
+
tile,
|
|
330
|
+
tokens,
|
|
331
|
+
aspectRatio,
|
|
332
|
+
t,
|
|
333
|
+
}: {
|
|
334
|
+
tile: CallTile;
|
|
335
|
+
tokens: ResolvedThemeTokens;
|
|
336
|
+
aspectRatio: number;
|
|
337
|
+
t: ForgeT;
|
|
338
|
+
}) {
|
|
339
|
+
// `useRemoteTrack` takes a producer id unconditionally so the hook order is
|
|
340
|
+
// the same for a tile with video and a tile without. An id that matches
|
|
341
|
+
// nothing simply returns no track, which is the state we want anyway.
|
|
342
|
+
const { track, attach } = useRemoteTrack(tile.videoProducerId ?? "");
|
|
343
|
+
const showVideo = !!tile.videoProducerId && !!track && !tile.videoPaused;
|
|
344
|
+
|
|
345
|
+
return (
|
|
346
|
+
<div
|
|
347
|
+
style={tileFrame(tokens, aspectRatio, tile.isSpeaking)}
|
|
348
|
+
data-testid={`tile-${tile.identity}`}
|
|
349
|
+
// The active set as an attribute as well as a border: a site restyling
|
|
350
|
+
// the grid needs the fact, and a colour is not something a test can
|
|
351
|
+
// assert without pinning a palette this component deliberately has none of.
|
|
352
|
+
data-speaking={tile.isSpeaking ? "true" : "false"}
|
|
353
|
+
>
|
|
354
|
+
{showVideo ? (
|
|
355
|
+
<video ref={attach} autoPlay playsInline muted style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
|
356
|
+
) : (
|
|
357
|
+
<span
|
|
358
|
+
style={{
|
|
359
|
+
fontSize: "0.9375rem",
|
|
360
|
+
fontWeight: 600,
|
|
361
|
+
color: tokens.text,
|
|
362
|
+
padding: "0 0.75rem",
|
|
363
|
+
textAlign: "center",
|
|
364
|
+
}}
|
|
365
|
+
>
|
|
366
|
+
{tile.name}
|
|
367
|
+
</span>
|
|
368
|
+
)}
|
|
369
|
+
<span style={tileName(tokens)}>
|
|
370
|
+
{tile.name}
|
|
371
|
+
{!tile.hasAudio && <span style={{ color: tokens.muted }}> {t("forge.call_stage.listening")}</span>}
|
|
372
|
+
</span>
|
|
373
|
+
</div>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Remote audio, attached and never drawn.
|
|
379
|
+
*
|
|
380
|
+
* Audio has to live on an element or the call is silent. It is deliberately NOT
|
|
381
|
+
* attached to the participant's `<video>`: a tile whose camera is off has no
|
|
382
|
+
* video element, and hanging the audio off one would mute whoever turned their
|
|
383
|
+
* camera off.
|
|
384
|
+
*/
|
|
385
|
+
function RemoteAudio({ producerId }: { producerId: string }) {
|
|
386
|
+
const { attach } = useRemoteTrack(producerId);
|
|
387
|
+
return <audio ref={attach} autoPlay style={{ display: "none" }} />;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** This viewer, as they are being sent. Muted, or it is a feedback loop. */
|
|
391
|
+
function LocalPreview({ tokens, aspectRatio, t }: { tokens: ResolvedThemeTokens; aspectRatio: number; t: ForgeT }) {
|
|
392
|
+
const state = useRoomState();
|
|
393
|
+
const publications = useLocalPublications();
|
|
394
|
+
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
395
|
+
|
|
396
|
+
const camera = useMemo(() => publications.find((p) => p.source === "camera"), [publications]);
|
|
397
|
+
// A paused camera is still captured and still a publication, but nothing is
|
|
398
|
+
// being sent, so the preview shows the name the others are seeing.
|
|
399
|
+
const track = camera && !camera.paused ? camera.track : undefined;
|
|
400
|
+
|
|
401
|
+
useEffect(() => {
|
|
402
|
+
const element = videoRef.current;
|
|
403
|
+
if (!element) return;
|
|
404
|
+
// Rebuilt only when the TRACK changes. Doing this in a ref callback would
|
|
405
|
+
// build a new `MediaStream` on every render, which is the per-frame churn
|
|
406
|
+
// this component exists to avoid.
|
|
407
|
+
element.srcObject = track ? new MediaStream([track]) : null;
|
|
408
|
+
return () => {
|
|
409
|
+
element.srcObject = null;
|
|
410
|
+
};
|
|
411
|
+
}, [track]);
|
|
412
|
+
|
|
413
|
+
const name = state.identity ? t("forge.call_stage.you") : t("forge.call_stage.you_not_connected");
|
|
414
|
+
|
|
415
|
+
return (
|
|
416
|
+
<div style={tileFrame(tokens, aspectRatio, false)} data-testid="tile-local">
|
|
417
|
+
{track ? (
|
|
418
|
+
<video
|
|
419
|
+
ref={videoRef}
|
|
420
|
+
autoPlay
|
|
421
|
+
playsInline
|
|
422
|
+
muted
|
|
423
|
+
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
424
|
+
/>
|
|
425
|
+
) : (
|
|
426
|
+
<span style={{ fontSize: "0.9375rem", fontWeight: 600, color: tokens.text }}>{name}</span>
|
|
427
|
+
)}
|
|
428
|
+
<span style={tileName(tokens)}>{name}</span>
|
|
429
|
+
</div>
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** One sentence per source a drop took away, keyed by the SDK's word for it. */
|
|
434
|
+
const LOST_SOURCE_KEYS: Record<LocalSource, string> = {
|
|
435
|
+
microphone: "forge.call_stage.lost_microphone",
|
|
436
|
+
camera: "forge.call_stage.lost_camera",
|
|
437
|
+
screen: "forge.call_stage.lost_screen",
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
const controlButton = (tokens: ResolvedThemeTokens, active: boolean, busy = false): CSSProperties => ({
|
|
441
|
+
display: "inline-flex",
|
|
442
|
+
alignItems: "center",
|
|
443
|
+
gap: "0.375rem",
|
|
444
|
+
padding: "0.5rem 0.75rem",
|
|
445
|
+
borderRadius: tokens.cornerRadius,
|
|
446
|
+
border: `1px solid ${active ? tokens.primary : tokens.border}`,
|
|
447
|
+
backgroundColor: active ? tokens.primary : "transparent",
|
|
448
|
+
color: active ? tokens.textPrimary : tokens.text,
|
|
449
|
+
cursor: busy ? "progress" : "pointer",
|
|
450
|
+
opacity: busy ? 0.6 : 1,
|
|
451
|
+
font: "inherit",
|
|
452
|
+
fontSize: "0.8125rem",
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Microphone, camera, screen and leave.
|
|
457
|
+
*
|
|
458
|
+
* Each toggle is drawn only when the token was minted with the grant for it, so
|
|
459
|
+
* a listen-only seat is not offered a microphone it would be refused. That is a
|
|
460
|
+
* rendering decision; the node refuses the publish itself either way.
|
|
461
|
+
*
|
|
462
|
+
* ## Mute is a pause, not a republish
|
|
463
|
+
*
|
|
464
|
+
* The first press on Unmute or Start camera captures and publishes. Every press
|
|
465
|
+
* after that PAUSES or RESUMES the publication the room already holds, through
|
|
466
|
+
* `setPaused`. A toggle built as unpublish-then-publish starts every unmute
|
|
467
|
+
* with `getUserMedia`, and Safari puts a permission prompt in front of every
|
|
468
|
+
* call to it, so a person on an iPad was asked whether the site may use the
|
|
469
|
+
* microphone each time they spoke. Pausing keeps the capture open (the
|
|
470
|
+
* browser's device indicator stays on, which the camera hint says) and the way
|
|
471
|
+
* back asks nobody anything. Screen share is the exception: stopping it ends
|
|
472
|
+
* the capture, because sharing again has to go through the picker anyway.
|
|
473
|
+
*
|
|
474
|
+
* ## Why every toggle is disabled while it is working
|
|
475
|
+
*
|
|
476
|
+
* Each of these is a read-then-write: it reads whether the source is live and
|
|
477
|
+
* publishes, pauses or resumes accordingly. The flag it reads does not move
|
|
478
|
+
* until the publish has resolved, and a publish starts with a permission
|
|
479
|
+
* prompt that can sit on screen for seconds - so a button that looks untouched
|
|
480
|
+
* is a button a person presses again, and two presses used to mean two
|
|
481
|
+
* producers from one identity and everyone else hearing them doubled.
|
|
482
|
+
* `useLocalMedia` holds the real lock (a synchronous ref, since both clicks
|
|
483
|
+
* land before React re-renders anything); this is the part that says so on
|
|
484
|
+
* screen, because a control that silently ignores you is only marginally
|
|
485
|
+
* better than one that misbehaves.
|
|
486
|
+
*/
|
|
487
|
+
function CallControls({
|
|
488
|
+
tokens,
|
|
489
|
+
t,
|
|
490
|
+
connected,
|
|
491
|
+
onLeave,
|
|
492
|
+
}: {
|
|
493
|
+
tokens: ResolvedThemeTokens;
|
|
494
|
+
t: ForgeT;
|
|
495
|
+
connected: boolean;
|
|
496
|
+
onLeave?: () => void;
|
|
497
|
+
}) {
|
|
498
|
+
const grants = useGrants();
|
|
499
|
+
const { room } = useMediaRoom();
|
|
500
|
+
const media = useLocalMedia();
|
|
501
|
+
|
|
502
|
+
// What the person is actually SENDING, which is what the button says.
|
|
503
|
+
const microphoneLive = media.isMicrophoneEnabled && !media.paused.microphone;
|
|
504
|
+
const cameraLive = media.isCameraEnabled && !media.paused.camera;
|
|
505
|
+
|
|
506
|
+
// Plain handlers, deliberately not `useCallback`. `useLocalMedia()` returns a
|
|
507
|
+
// fresh object every render, so memoising on it would rebuild the callback
|
|
508
|
+
// every render anyway while looking as though it did not.
|
|
509
|
+
const toggleMicrophone = () => {
|
|
510
|
+
if (!media.isMicrophoneEnabled) {
|
|
511
|
+
void media.publishMicrophone();
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
void media.setPaused("microphone", microphoneLive);
|
|
515
|
+
};
|
|
516
|
+
const toggleCamera = () => {
|
|
517
|
+
if (!media.isCameraEnabled) {
|
|
518
|
+
void media.publishCamera();
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
void media.setPaused("camera", cameraLive);
|
|
522
|
+
};
|
|
523
|
+
const toggleScreen = () => {
|
|
524
|
+
void (media.screenSharing ? media.unpublish("screen") : media.publishScreen());
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const leave = useCallback(() => {
|
|
528
|
+
// Closed here AND unmounted by the host. Closing alone would leave a
|
|
529
|
+
// provider mounted that reconnects on its next render; unmounting alone
|
|
530
|
+
// relies on a cleanup running before the person walks away from the laptop.
|
|
531
|
+
void room?.close().catch(() => undefined);
|
|
532
|
+
onLeave?.();
|
|
533
|
+
}, [room, onLeave]);
|
|
534
|
+
|
|
535
|
+
return (
|
|
536
|
+
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "0.5rem" }}>
|
|
537
|
+
{canPublishSource(grants, "microphone") && (
|
|
538
|
+
<button
|
|
539
|
+
type="button"
|
|
540
|
+
onClick={toggleMicrophone}
|
|
541
|
+
disabled={media.pending.microphone}
|
|
542
|
+
aria-busy={media.pending.microphone}
|
|
543
|
+
style={controlButton(tokens, microphoneLive, media.pending.microphone)}
|
|
544
|
+
>
|
|
545
|
+
{microphoneLive ? (
|
|
546
|
+
<Mic width={16} height={16} aria-hidden="true" />
|
|
547
|
+
) : (
|
|
548
|
+
<MicOff width={16} height={16} aria-hidden="true" />
|
|
549
|
+
)}
|
|
550
|
+
{microphoneLive ? t("forge.call_stage.mute") : t("forge.call_stage.unmute")}
|
|
551
|
+
</button>
|
|
552
|
+
)}
|
|
553
|
+
|
|
554
|
+
{canPublishSource(grants, "camera") && (
|
|
555
|
+
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.25rem" }}>
|
|
556
|
+
<button
|
|
557
|
+
type="button"
|
|
558
|
+
onClick={toggleCamera}
|
|
559
|
+
disabled={media.pending.camera}
|
|
560
|
+
aria-busy={media.pending.camera}
|
|
561
|
+
style={controlButton(tokens, cameraLive, media.pending.camera)}
|
|
562
|
+
>
|
|
563
|
+
{cameraLive ? (
|
|
564
|
+
<Video width={16} height={16} aria-hidden="true" />
|
|
565
|
+
) : (
|
|
566
|
+
<VideoOff width={16} height={16} aria-hidden="true" />
|
|
567
|
+
)}
|
|
568
|
+
{cameraLive ? t("forge.call_stage.camera_stop") : t("forge.call_stage.camera_start")}
|
|
569
|
+
</button>
|
|
570
|
+
<CallHelpHint label={t("forge.call_stage.help_camera_stop_label")}>
|
|
571
|
+
{t("forge.call_stage.help_camera_stop")}
|
|
572
|
+
</CallHelpHint>
|
|
573
|
+
</span>
|
|
574
|
+
)}
|
|
575
|
+
|
|
576
|
+
{canPublishSource(grants, "screen") && (
|
|
577
|
+
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.25rem" }}>
|
|
578
|
+
<button
|
|
579
|
+
type="button"
|
|
580
|
+
onClick={toggleScreen}
|
|
581
|
+
disabled={media.pending.screen}
|
|
582
|
+
aria-busy={media.pending.screen}
|
|
583
|
+
style={controlButton(tokens, media.screenSharing, media.pending.screen)}
|
|
584
|
+
>
|
|
585
|
+
{media.screenSharing ? (
|
|
586
|
+
<MonitorX width={16} height={16} aria-hidden="true" />
|
|
587
|
+
) : (
|
|
588
|
+
<MonitorUp width={16} height={16} aria-hidden="true" />
|
|
589
|
+
)}
|
|
590
|
+
{media.screenSharing ? t("forge.call_stage.screen_stop") : t("forge.call_stage.screen_start")}
|
|
591
|
+
</button>
|
|
592
|
+
<CallHelpHint label={t("forge.call_stage.help_screen_label")}>
|
|
593
|
+
{t("forge.call_stage.help_screen")}
|
|
594
|
+
</CallHelpHint>
|
|
595
|
+
</span>
|
|
596
|
+
)}
|
|
597
|
+
|
|
598
|
+
<button
|
|
599
|
+
type="button"
|
|
600
|
+
onClick={leave}
|
|
601
|
+
style={{ ...controlButton(tokens, false), marginLeft: "auto", borderColor: tokens.secondary }}
|
|
602
|
+
>
|
|
603
|
+
<PhoneOff width={16} height={16} aria-hidden="true" />
|
|
604
|
+
{t("forge.call_stage.leave")}
|
|
605
|
+
</button>
|
|
606
|
+
|
|
607
|
+
{/* A denied permission prompt is something the person has to be told
|
|
608
|
+
about without asking, so it stays visible rather than going behind
|
|
609
|
+
the `?`. */}
|
|
610
|
+
{media.error && (
|
|
611
|
+
<span role="alert" style={{ width: "100%", fontSize: "0.8125rem", color: tokens.text }}>
|
|
612
|
+
{media.error.message}
|
|
613
|
+
</span>
|
|
614
|
+
)}
|
|
615
|
+
|
|
616
|
+
{/* State the person has to act on, so it is content and stays visible: a
|
|
617
|
+
drop stopped their capture (the light went out, on purpose), the room
|
|
618
|
+
is back, and nothing will turn the microphone on again but them. Shown
|
|
619
|
+
once the room is back rather than during the drop, when the status
|
|
620
|
+
line above is already saying "reconnecting". An alert rather than a
|
|
621
|
+
status: somebody who misses it is talking to nobody. */}
|
|
622
|
+
{connected && media.lostSources.length > 0 && (
|
|
623
|
+
<span role="alert" style={{ width: "100%", fontSize: "0.8125rem", color: tokens.text }}>
|
|
624
|
+
{media.lostSources.map((source) => (
|
|
625
|
+
<span key={source} style={{ display: "block" }}>
|
|
626
|
+
{t(LOST_SOURCE_KEYS[source])}
|
|
627
|
+
</span>
|
|
628
|
+
))}
|
|
629
|
+
</span>
|
|
630
|
+
)}
|
|
631
|
+
</div>
|
|
632
|
+
);
|
|
633
|
+
}
|