@tribe-nest/forge 3.31.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 +7 -2
- package/src/contexts/CartContext.tsx +17 -1
- package/src/contexts/_tests/CartContext.spec.tsx +36 -0
- package/src/data/queries/useMyBookings.ts +9 -0
- package/src/i18n/_tests/translationKeys.spec.ts +15 -0
- package/src/i18n/de.json +40 -2
- package/src/i18n/en.json +40 -2
- package/src/ui/index.ts +25 -0
- package/src/ui/media/BookingCallScreen.tsx +33 -0
- package/src/ui/media/CallStage.tsx +153 -62
- package/src/ui/media/_tests/CallStage.spec.tsx +265 -19
- package/src/ui/media/_tests/bookingSession.spec.tsx +48 -0
- package/src/ui/media/_tests/callState.spec.ts +63 -16
- package/src/ui/media/bookingSession.tsx +45 -57
- package/src/ui/media/bookingWindow.ts +81 -0
- package/src/ui/media/callState.ts +42 -23
- package/src/ui/styled/AccountDashboard.tsx +106 -6
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +166 -0
- package/src/ui/styled/forge-utilities.css +832 -0
|
@@ -23,8 +23,10 @@ import {
|
|
|
23
23
|
useRemoteTrack,
|
|
24
24
|
useRoomState,
|
|
25
25
|
useVisibleProducers,
|
|
26
|
+
type LocalSource,
|
|
26
27
|
} from "@tribe-nest/media-client/react";
|
|
27
28
|
|
|
29
|
+
import { useForgeT, type ForgeT } from "../../i18n";
|
|
28
30
|
import { useThemeTokens, type ResolvedThemeTokens } from "../theme/ForgeThemeProvider";
|
|
29
31
|
import { CallHelpHint } from "./CallHelpHint";
|
|
30
32
|
import {
|
|
@@ -57,6 +59,17 @@ import {
|
|
|
57
59
|
* screen that looks like somebody else's product on every site that installs
|
|
58
60
|
* it.
|
|
59
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
|
+
*
|
|
60
73
|
* ## What it does NOT decide
|
|
61
74
|
*
|
|
62
75
|
* Whether a control may be used. `useGrants()` says what the node minted this
|
|
@@ -85,6 +98,7 @@ export type CallStageProps = {
|
|
|
85
98
|
|
|
86
99
|
export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStageProps) {
|
|
87
100
|
const tokens = useThemeTokens();
|
|
101
|
+
const t = useForgeT();
|
|
88
102
|
const { connectionState, error, recovering, retry } = useMediaRoom();
|
|
89
103
|
const state = useRoomState();
|
|
90
104
|
const peers = useParticipants();
|
|
@@ -132,21 +146,26 @@ export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStagePro
|
|
|
132
146
|
fontFamily: tokens.fontFamily,
|
|
133
147
|
}}
|
|
134
148
|
>
|
|
135
|
-
<div
|
|
149
|
+
<div
|
|
150
|
+
style={{
|
|
151
|
+
display: "flex",
|
|
152
|
+
alignItems: "center",
|
|
153
|
+
justifyContent: "space-between",
|
|
154
|
+
gap: "0.75rem",
|
|
155
|
+
flexWrap: "wrap",
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
136
158
|
{title ? <div style={{ fontWeight: 600, fontFamily: tokens.headingFontFamily }}>{title}</div> : <span />}
|
|
137
159
|
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
|
138
|
-
{recording && <RecordingIndicator tokens={tokens} />}
|
|
139
|
-
<ConnectionStatus status={status} tokens={tokens} onRetry={retry} />
|
|
160
|
+
{recording && <RecordingIndicator tokens={tokens} t={t} />}
|
|
161
|
+
<ConnectionStatus status={status} tokens={tokens} t={t} onRetry={retry} />
|
|
140
162
|
</div>
|
|
141
163
|
</div>
|
|
142
164
|
|
|
143
165
|
<div style={{ display: "flex", alignItems: "center", gap: "0.375rem", fontSize: "0.75rem", color: tokens.muted }}>
|
|
144
|
-
<span>
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
<CallHelpHint label="Why can I hear someone but not see them?">
|
|
148
|
-
A tile with a name and no picture means that person has their camera off, or the call is busy enough that only
|
|
149
|
-
the people currently speaking are sent in full. Both are normal, and their audio is unaffected.
|
|
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")}
|
|
150
169
|
</CallHelpHint>
|
|
151
170
|
</div>
|
|
152
171
|
|
|
@@ -157,9 +176,9 @@ export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStagePro
|
|
|
157
176
|
gap: "0.75rem",
|
|
158
177
|
}}
|
|
159
178
|
>
|
|
160
|
-
<LocalPreview tokens={tokens} aspectRatio={aspectRatio} />
|
|
179
|
+
<LocalPreview tokens={tokens} aspectRatio={aspectRatio} t={t} />
|
|
161
180
|
{tiles.map((tile) => (
|
|
162
|
-
<ParticipantTile key={tile.key} tile={tile} tokens={tokens} aspectRatio={aspectRatio} />
|
|
181
|
+
<ParticipantTile key={tile.key} tile={tile} tokens={tokens} aspectRatio={aspectRatio} t={t} />
|
|
163
182
|
))}
|
|
164
183
|
</div>
|
|
165
184
|
|
|
@@ -167,7 +186,7 @@ export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStagePro
|
|
|
167
186
|
<RemoteAudio key={producerId} producerId={producerId} />
|
|
168
187
|
))}
|
|
169
188
|
|
|
170
|
-
<CallControls tokens={tokens} onLeave={onLeave} />
|
|
189
|
+
<CallControls tokens={tokens} t={t} connected={connectionState === "connected"} onLeave={onLeave} />
|
|
171
190
|
</div>
|
|
172
191
|
);
|
|
173
192
|
}
|
|
@@ -182,14 +201,18 @@ export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStagePro
|
|
|
182
201
|
function ConnectionStatus({
|
|
183
202
|
status,
|
|
184
203
|
tokens,
|
|
204
|
+
t,
|
|
185
205
|
onRetry,
|
|
186
206
|
}: {
|
|
187
207
|
status: CallStatus;
|
|
188
208
|
tokens: ResolvedThemeTokens;
|
|
209
|
+
t: ForgeT;
|
|
189
210
|
onRetry: () => void;
|
|
190
211
|
}) {
|
|
191
212
|
const busy = status.tone === "connecting" || status.tone === "reconnecting";
|
|
192
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;
|
|
193
216
|
|
|
194
217
|
return (
|
|
195
218
|
<div
|
|
@@ -211,8 +234,8 @@ function ConnectionStatus({
|
|
|
211
234
|
style={{ width: 8, height: 8, borderRadius: 9999, backgroundColor: tokens.primary, display: "inline-block" }}
|
|
212
235
|
/>
|
|
213
236
|
)}
|
|
214
|
-
<span style={{ fontWeight: alarming ? 600 : 400 }}>{status.
|
|
215
|
-
{
|
|
237
|
+
<span style={{ fontWeight: alarming ? 600 : 400 }}>{t(status.headlineKey)}</span>
|
|
238
|
+
{detail && <span style={{ color: tokens.muted }}>{detail}</span>}
|
|
216
239
|
{status.canRetry && (
|
|
217
240
|
<button
|
|
218
241
|
type="button"
|
|
@@ -232,7 +255,7 @@ function ConnectionStatus({
|
|
|
232
255
|
}}
|
|
233
256
|
>
|
|
234
257
|
<RefreshCw width={12} height={12} aria-hidden="true" />
|
|
235
|
-
|
|
258
|
+
{t("forge.call_stage.retry")}
|
|
236
259
|
</button>
|
|
237
260
|
)}
|
|
238
261
|
</div>
|
|
@@ -240,7 +263,7 @@ function ConnectionStatus({
|
|
|
240
263
|
}
|
|
241
264
|
|
|
242
265
|
/** Somebody is recording. A consent signal, so it is content and always shown. */
|
|
243
|
-
function RecordingIndicator({ tokens }: { tokens: ResolvedThemeTokens }) {
|
|
266
|
+
function RecordingIndicator({ tokens, t }: { tokens: ResolvedThemeTokens; t: ForgeT }) {
|
|
244
267
|
return (
|
|
245
268
|
<span
|
|
246
269
|
style={{
|
|
@@ -259,7 +282,7 @@ function RecordingIndicator({ tokens }: { tokens: ResolvedThemeTokens }) {
|
|
|
259
282
|
aria-hidden="true"
|
|
260
283
|
style={{ width: 8, height: 8, borderRadius: 9999, backgroundColor: tokens.secondary, display: "inline-block" }}
|
|
261
284
|
/>
|
|
262
|
-
|
|
285
|
+
{t("forge.call_stage.recording")}
|
|
263
286
|
</span>
|
|
264
287
|
);
|
|
265
288
|
}
|
|
@@ -306,10 +329,12 @@ function ParticipantTile({
|
|
|
306
329
|
tile,
|
|
307
330
|
tokens,
|
|
308
331
|
aspectRatio,
|
|
332
|
+
t,
|
|
309
333
|
}: {
|
|
310
334
|
tile: CallTile;
|
|
311
335
|
tokens: ResolvedThemeTokens;
|
|
312
336
|
aspectRatio: number;
|
|
337
|
+
t: ForgeT;
|
|
313
338
|
}) {
|
|
314
339
|
// `useRemoteTrack` takes a producer id unconditionally so the hook order is
|
|
315
340
|
// the same for a tile with video and a tile without. An id that matches
|
|
@@ -327,21 +352,23 @@ function ParticipantTile({
|
|
|
327
352
|
data-speaking={tile.isSpeaking ? "true" : "false"}
|
|
328
353
|
>
|
|
329
354
|
{showVideo ? (
|
|
330
|
-
<video
|
|
331
|
-
ref={attach}
|
|
332
|
-
autoPlay
|
|
333
|
-
playsInline
|
|
334
|
-
muted
|
|
335
|
-
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
336
|
-
/>
|
|
355
|
+
<video ref={attach} autoPlay playsInline muted style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
|
337
356
|
) : (
|
|
338
|
-
<span
|
|
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
|
+
>
|
|
339
366
|
{tile.name}
|
|
340
367
|
</span>
|
|
341
368
|
)}
|
|
342
369
|
<span style={tileName(tokens)}>
|
|
343
370
|
{tile.name}
|
|
344
|
-
{!tile.hasAudio && <span style={{ color: tokens.muted }}> (listening)</span>}
|
|
371
|
+
{!tile.hasAudio && <span style={{ color: tokens.muted }}> {t("forge.call_stage.listening")}</span>}
|
|
345
372
|
</span>
|
|
346
373
|
</div>
|
|
347
374
|
);
|
|
@@ -361,13 +388,15 @@ function RemoteAudio({ producerId }: { producerId: string }) {
|
|
|
361
388
|
}
|
|
362
389
|
|
|
363
390
|
/** This viewer, as they are being sent. Muted, or it is a feedback loop. */
|
|
364
|
-
function LocalPreview({ tokens, aspectRatio }: { tokens: ResolvedThemeTokens; aspectRatio: number }) {
|
|
391
|
+
function LocalPreview({ tokens, aspectRatio, t }: { tokens: ResolvedThemeTokens; aspectRatio: number; t: ForgeT }) {
|
|
365
392
|
const state = useRoomState();
|
|
366
393
|
const publications = useLocalPublications();
|
|
367
394
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
368
395
|
|
|
369
396
|
const camera = useMemo(() => publications.find((p) => p.source === "camera"), [publications]);
|
|
370
|
-
|
|
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;
|
|
371
400
|
|
|
372
401
|
useEffect(() => {
|
|
373
402
|
const element = videoRef.current;
|
|
@@ -381,7 +410,7 @@ function LocalPreview({ tokens, aspectRatio }: { tokens: ResolvedThemeTokens; as
|
|
|
381
410
|
};
|
|
382
411
|
}, [track]);
|
|
383
412
|
|
|
384
|
-
const name = state.identity ? "
|
|
413
|
+
const name = state.identity ? t("forge.call_stage.you") : t("forge.call_stage.you_not_connected");
|
|
385
414
|
|
|
386
415
|
return (
|
|
387
416
|
<div style={tileFrame(tokens, aspectRatio, false)} data-testid="tile-local">
|
|
@@ -401,6 +430,13 @@ function LocalPreview({ tokens, aspectRatio }: { tokens: ResolvedThemeTokens; as
|
|
|
401
430
|
);
|
|
402
431
|
}
|
|
403
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
|
+
|
|
404
440
|
const controlButton = (tokens: ResolvedThemeTokens, active: boolean, busy = false): CSSProperties => ({
|
|
405
441
|
display: "inline-flex",
|
|
406
442
|
alignItems: "center",
|
|
@@ -423,31 +459,66 @@ const controlButton = (tokens: ResolvedThemeTokens, active: boolean, busy = fals
|
|
|
423
459
|
* a listen-only seat is not offered a microphone it would be refused. That is a
|
|
424
460
|
* rendering decision; the node refuses the publish itself either way.
|
|
425
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
|
+
*
|
|
426
474
|
* ## Why every toggle is disabled while it is working
|
|
427
475
|
*
|
|
428
476
|
* Each of these is a read-then-write: it reads whether the source is live and
|
|
429
|
-
* publishes or
|
|
430
|
-
* the publish has resolved, and a publish starts with a permission
|
|
431
|
-
* can sit on screen for seconds - so a button that looks untouched
|
|
432
|
-
* a person presses again, and two presses used to mean two
|
|
433
|
-
* identity and everyone else hearing them doubled.
|
|
434
|
-
* real lock (a synchronous ref, since both clicks
|
|
435
|
-
* anything); this is the part that says so on
|
|
436
|
-
* silently ignores you is only marginally
|
|
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.
|
|
437
486
|
*/
|
|
438
|
-
function CallControls({
|
|
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
|
+
}) {
|
|
439
498
|
const grants = useGrants();
|
|
440
499
|
const { room } = useMediaRoom();
|
|
441
500
|
const media = useLocalMedia();
|
|
442
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
|
+
|
|
443
506
|
// Plain handlers, deliberately not `useCallback`. `useLocalMedia()` returns a
|
|
444
507
|
// fresh object every render, so memoising on it would rebuild the callback
|
|
445
508
|
// every render anyway while looking as though it did not.
|
|
446
509
|
const toggleMicrophone = () => {
|
|
447
|
-
|
|
510
|
+
if (!media.isMicrophoneEnabled) {
|
|
511
|
+
void media.publishMicrophone();
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
void media.setPaused("microphone", microphoneLive);
|
|
448
515
|
};
|
|
449
516
|
const toggleCamera = () => {
|
|
450
|
-
|
|
517
|
+
if (!media.isCameraEnabled) {
|
|
518
|
+
void media.publishCamera();
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
void media.setPaused("camera", cameraLive);
|
|
451
522
|
};
|
|
452
523
|
const toggleScreen = () => {
|
|
453
524
|
void (media.screenSharing ? media.unpublish("screen") : media.publishScreen());
|
|
@@ -469,32 +540,37 @@ function CallControls({ tokens, onLeave }: { tokens: ResolvedThemeTokens; onLeav
|
|
|
469
540
|
onClick={toggleMicrophone}
|
|
470
541
|
disabled={media.pending.microphone}
|
|
471
542
|
aria-busy={media.pending.microphone}
|
|
472
|
-
style={controlButton(tokens,
|
|
543
|
+
style={controlButton(tokens, microphoneLive, media.pending.microphone)}
|
|
473
544
|
>
|
|
474
|
-
{
|
|
545
|
+
{microphoneLive ? (
|
|
475
546
|
<Mic width={16} height={16} aria-hidden="true" />
|
|
476
547
|
) : (
|
|
477
548
|
<MicOff width={16} height={16} aria-hidden="true" />
|
|
478
549
|
)}
|
|
479
|
-
{
|
|
550
|
+
{microphoneLive ? t("forge.call_stage.mute") : t("forge.call_stage.unmute")}
|
|
480
551
|
</button>
|
|
481
552
|
)}
|
|
482
553
|
|
|
483
554
|
{canPublishSource(grants, "camera") && (
|
|
484
|
-
<
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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>
|
|
498
574
|
)}
|
|
499
575
|
|
|
500
576
|
{canPublishSource(grants, "screen") && (
|
|
@@ -511,11 +587,10 @@ function CallControls({ tokens, onLeave }: { tokens: ResolvedThemeTokens; onLeav
|
|
|
511
587
|
) : (
|
|
512
588
|
<MonitorUp width={16} height={16} aria-hidden="true" />
|
|
513
589
|
)}
|
|
514
|
-
{media.screenSharing ? "
|
|
590
|
+
{media.screenSharing ? t("forge.call_stage.screen_stop") : t("forge.call_stage.screen_start")}
|
|
515
591
|
</button>
|
|
516
|
-
<CallHelpHint label="
|
|
517
|
-
|
|
518
|
-
it rather than being replaced, so people can still see you while you present.
|
|
592
|
+
<CallHelpHint label={t("forge.call_stage.help_screen_label")}>
|
|
593
|
+
{t("forge.call_stage.help_screen")}
|
|
519
594
|
</CallHelpHint>
|
|
520
595
|
</span>
|
|
521
596
|
)}
|
|
@@ -526,7 +601,7 @@ function CallControls({ tokens, onLeave }: { tokens: ResolvedThemeTokens; onLeav
|
|
|
526
601
|
style={{ ...controlButton(tokens, false), marginLeft: "auto", borderColor: tokens.secondary }}
|
|
527
602
|
>
|
|
528
603
|
<PhoneOff width={16} height={16} aria-hidden="true" />
|
|
529
|
-
|
|
604
|
+
{t("forge.call_stage.leave")}
|
|
530
605
|
</button>
|
|
531
606
|
|
|
532
607
|
{/* A denied permission prompt is something the person has to be told
|
|
@@ -537,6 +612,22 @@ function CallControls({ tokens, onLeave }: { tokens: ResolvedThemeTokens; onLeav
|
|
|
537
612
|
{media.error.message}
|
|
538
613
|
</span>
|
|
539
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
|
+
)}
|
|
540
631
|
</div>
|
|
541
632
|
);
|
|
542
633
|
}
|