@tribe-nest/forge 3.26.0 → 3.31.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 +6 -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/client/_tests/localeHeader.spec.tsx +98 -0
- package/src/client/activeLocale.ts +34 -0
- package/src/client/createForgeClient.ts +13 -0
- package/src/contexts/CartContext.tsx +76 -0
- package/src/contexts/PublicAuthContext.tsx +34 -5
- package/src/contexts/_tests/CartContext.spec.tsx +210 -0
- package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
- package/src/data/queries/useBroadcasts.ts +151 -0
- package/src/data/queries/useCheckouts.ts +83 -0
- package/src/data/queries/useMyBookings.ts +9 -1
- package/src/data/queries/useWebsite.ts +7 -0
- package/src/i18n/CONVENTION.md +184 -0
- package/src/i18n/_tests/translationKeys.spec.ts +153 -0
- package/src/i18n/de.json +1019 -0
- package/src/i18n/en.json +1019 -0
- package/src/i18n/index.ts +125 -0
- package/src/index.ts +21 -0
- package/src/provider/ForgeAppProvider.tsx +2 -1
- package/src/provider/SiteConfigProvider.tsx +11 -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/checkout/_tests/cartResume.spec.ts +291 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
- package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
- package/src/ui/headless/checkout/useCartResume.ts +340 -0
- package/src/ui/headless/checkout/useCheckout.ts +9 -2
- package/src/ui/headless/event/useEventCheckout.ts +8 -13
- package/src/ui/headless/index.ts +32 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
- package/src/ui/index.ts +38 -0
- package/src/ui/media/CallHelpHint.tsx +87 -0
- package/src/ui/media/CallStage.tsx +542 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
- package/src/ui/media/_tests/callState.spec.ts +452 -0
- package/src/ui/media/_tests/fakeNode.ts +178 -0
- package/src/ui/media/bookingSession.tsx +194 -0
- package/src/ui/media/callState.ts +341 -0
- package/src/ui/media/index.ts +135 -0
- package/src/ui/shell/TribeNestApp.tsx +36 -27
- package/src/ui/styled/AccountDashboard.tsx +289 -96
- package/src/ui/styled/AiAgentWidget.tsx +10 -6
- package/src/ui/styled/AudioPlayer.tsx +14 -6
- package/src/ui/styled/BlogCategory.tsx +4 -2
- package/src/ui/styled/BlogComments.tsx +27 -20
- package/src/ui/styled/BlogList.tsx +5 -3
- package/src/ui/styled/BlogPost.tsx +14 -11
- package/src/ui/styled/BroadcastWatch.tsx +107 -0
- package/src/ui/styled/BundleConfirmation.tsx +32 -23
- package/src/ui/styled/CancellationTerms.tsx +3 -1
- package/src/ui/styled/Cart.tsx +32 -14
- package/src/ui/styled/ChatRoom.tsx +65 -20
- package/src/ui/styled/Checkout.tsx +151 -89
- package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
- package/src/ui/styled/CoachingBooking.tsx +71 -44
- package/src/ui/styled/CoachingConfirmation.tsx +56 -19
- package/src/ui/styled/CoachingDetail.tsx +15 -11
- package/src/ui/styled/CohortPage.tsx +10 -8
- package/src/ui/styled/ConfirmSubscription.tsx +14 -8
- package/src/ui/styled/ContactForm.tsx +31 -12
- package/src/ui/styled/CookieConsent.tsx +19 -15
- package/src/ui/styled/CourseAccess.tsx +54 -47
- package/src/ui/styled/CourseCheckout.tsx +56 -38
- package/src/ui/styled/CourseConfirmation.tsx +39 -17
- package/src/ui/styled/CourseDetail.tsx +21 -15
- package/src/ui/styled/CoursesGrid.tsx +4 -2
- package/src/ui/styled/CurrencySwitcher.tsx +4 -1
- package/src/ui/styled/DiscountCode.tsx +22 -18
- package/src/ui/styled/DonationButton.tsx +52 -17
- package/src/ui/styled/DonationPage.tsx +4 -4
- package/src/ui/styled/EmailListForm.tsx +22 -9
- package/src/ui/styled/EventConfirmation.tsx +37 -14
- package/src/ui/styled/EventCountdown.tsx +10 -6
- package/src/ui/styled/EventDetail.tsx +8 -6
- package/src/ui/styled/EventSeriesDetail.tsx +18 -13
- package/src/ui/styled/EventTickets.tsx +232 -108
- package/src/ui/styled/EventWaitlist.tsx +42 -33
- package/src/ui/styled/EventsList.tsx +15 -13
- package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
- package/src/ui/styled/FormRenderer.tsx +12 -7
- package/src/ui/styled/InstallBanner.tsx +21 -18
- package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
- package/src/ui/styled/InvoicePayment.tsx +37 -16
- package/src/ui/styled/LeadMagnet.tsx +9 -7
- package/src/ui/styled/LiveBroadcastList.tsx +171 -0
- package/src/ui/styled/Loading.tsx +7 -1
- package/src/ui/styled/LoginForm.tsx +32 -14
- package/src/ui/styled/MembershipCheckout.tsx +341 -49
- package/src/ui/styled/MembershipTiers.tsx +34 -18
- package/src/ui/styled/OfferButton.tsx +33 -13
- package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
- package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
- package/src/ui/styled/PodcastEpisode.tsx +20 -9
- package/src/ui/styled/PodcastList.tsx +9 -3
- package/src/ui/styled/PodcastShow.tsx +29 -7
- package/src/ui/styled/PostsFeed.tsx +10 -8
- package/src/ui/styled/PresaleCode.tsx +8 -11
- package/src/ui/styled/ProductBrowseNav.tsx +21 -11
- package/src/ui/styled/ProductDetail.tsx +58 -28
- package/src/ui/styled/ReactionBar.tsx +4 -2
- package/src/ui/styled/ReplayList.tsx +24 -13
- package/src/ui/styled/ResetPasswordForm.tsx +32 -15
- package/src/ui/styled/ResumeCart.tsx +289 -0
- package/src/ui/styled/ReviewForm.tsx +52 -58
- package/src/ui/styled/SignupForm.tsx +34 -16
- package/src/ui/styled/TicketTransfer.tsx +55 -54
- package/src/ui/styled/UserMenu.tsx +16 -14
- 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/_tests/ResumeCart.spec.tsx +183 -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
|
@@ -0,0 +1,542 @@
|
|
|
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
|
+
} from "@tribe-nest/media-client/react";
|
|
27
|
+
|
|
28
|
+
import { useThemeTokens, type ResolvedThemeTokens } from "../theme/ForgeThemeProvider";
|
|
29
|
+
import { CallHelpHint } from "./CallHelpHint";
|
|
30
|
+
import {
|
|
31
|
+
callHeadCount,
|
|
32
|
+
callStatus,
|
|
33
|
+
callTiles,
|
|
34
|
+
canPublishSource,
|
|
35
|
+
remoteAudioProducerIds,
|
|
36
|
+
type CallStatus,
|
|
37
|
+
type CallTile,
|
|
38
|
+
} from "./callState";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The call, as a screen. One implementation, both surfaces.
|
|
42
|
+
*
|
|
43
|
+
* Everything above this file was reachable only from a test: the room, the
|
|
44
|
+
* hooks and `BookingCallProvider` all existed and nothing rendered a call, so
|
|
45
|
+
* no human could join one. This is the part that makes it reachable, and it is
|
|
46
|
+
* built ONCE here because there are two rendering surfaces (admin, and code
|
|
47
|
+
* websites through Forge) and a second implementation would mean every fix to
|
|
48
|
+
* the ordering rules had to be made twice. The second copy is always the one
|
|
49
|
+
* that drifts.
|
|
50
|
+
*
|
|
51
|
+
* ## Where the colours come from
|
|
52
|
+
*
|
|
53
|
+
* `useThemeTokens()`, which on a code website is the creator's own
|
|
54
|
+
* `themeSettings` fed through `<ForgeThemeProvider>`, and in admin is the
|
|
55
|
+
* dashboard's own tokens fed through the same provider. Not one colour is
|
|
56
|
+
* written down in this file. A call screen with a hard-coded palette is a call
|
|
57
|
+
* screen that looks like somebody else's product on every site that installs
|
|
58
|
+
* it.
|
|
59
|
+
*
|
|
60
|
+
* ## What it does NOT decide
|
|
61
|
+
*
|
|
62
|
+
* Whether a control may be used. `useGrants()` says what the node minted this
|
|
63
|
+
* token for, and it is read here so a button that would be refused is not
|
|
64
|
+
* offered; the node checks the same thing on the publish itself and its answer
|
|
65
|
+
* is the only one that counts. Hiding a control is courtesy, never enforcement.
|
|
66
|
+
*
|
|
67
|
+
* ## Mount it behind a Join control
|
|
68
|
+
*
|
|
69
|
+
* The provider connects on mount and closes on unmount. Rendering the whole
|
|
70
|
+
* thing inside a list of sessions would open a media connection for every row.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
export type CallStageProps = {
|
|
74
|
+
/**
|
|
75
|
+
* Called once the local participant has left. The host is expected to unmount
|
|
76
|
+
* the provider: this component closes the room, but a provider left mounted
|
|
77
|
+
* would reconnect on the next render.
|
|
78
|
+
*/
|
|
79
|
+
onLeave?: () => void;
|
|
80
|
+
/** Whose call this is. Rendered above the grid. */
|
|
81
|
+
title?: ReactNode;
|
|
82
|
+
/** Ratio for each tile. Defaults to 16 / 9. */
|
|
83
|
+
aspectRatio?: number;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStageProps) {
|
|
87
|
+
const tokens = useThemeTokens();
|
|
88
|
+
const { connectionState, error, recovering, retry } = useMediaRoom();
|
|
89
|
+
const state = useRoomState();
|
|
90
|
+
const peers = useParticipants();
|
|
91
|
+
const activeSpeakers = useActiveSpeakers();
|
|
92
|
+
const recording = useRecording();
|
|
93
|
+
/**
|
|
94
|
+
* The barrier, applied. NOT `state.producers`.
|
|
95
|
+
*
|
|
96
|
+
* The reducer keeps every producer the node has ever announced so a subscribe
|
|
97
|
+
* rule that narrows and later widens is reversible, which means the raw list
|
|
98
|
+
* can hold people this seat has been barred from seeing. On a sealed room
|
|
99
|
+
* their presence is most of what the barrier was hiding, and a tile carries
|
|
100
|
+
* their identity.
|
|
101
|
+
*/
|
|
102
|
+
const producers = useVisibleProducers();
|
|
103
|
+
|
|
104
|
+
const status = useMemo(
|
|
105
|
+
() => callStatus({ connectionState, error, recovering, phase: state.phase, closedReason: state.closedReason }),
|
|
106
|
+
[connectionState, error, recovering, state.phase, state.closedReason],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Memoised on the room's own snapshots, every one of which is a stable
|
|
110
|
+
// reference while nothing changed. Rebuilding these per render is what turns
|
|
111
|
+
// a call screen into a component that re-renders per FRAME.
|
|
112
|
+
const tiles = useMemo(
|
|
113
|
+
() => callTiles({ identity: state.identity, peers, producers, activeSpeakers }),
|
|
114
|
+
[state.identity, peers, producers, activeSpeakers],
|
|
115
|
+
);
|
|
116
|
+
const audioProducerIds = useMemo(
|
|
117
|
+
() => remoteAudioProducerIds({ identity: state.identity, producers }),
|
|
118
|
+
[state.identity, producers],
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div
|
|
123
|
+
style={{
|
|
124
|
+
display: "flex",
|
|
125
|
+
flexDirection: "column",
|
|
126
|
+
gap: "0.75rem",
|
|
127
|
+
padding: "1rem",
|
|
128
|
+
borderRadius: tokens.cornerRadius,
|
|
129
|
+
border: `1px solid ${tokens.border}`,
|
|
130
|
+
backgroundColor: tokens.background,
|
|
131
|
+
color: tokens.text,
|
|
132
|
+
fontFamily: tokens.fontFamily,
|
|
133
|
+
}}
|
|
134
|
+
>
|
|
135
|
+
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "0.75rem", flexWrap: "wrap" }}>
|
|
136
|
+
{title ? <div style={{ fontWeight: 600, fontFamily: tokens.headingFontFamily }}>{title}</div> : <span />}
|
|
137
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
|
138
|
+
{recording && <RecordingIndicator tokens={tokens} />}
|
|
139
|
+
<ConnectionStatus status={status} tokens={tokens} onRetry={retry} />
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.375rem", fontSize: "0.75rem", color: tokens.muted }}>
|
|
144
|
+
<span>
|
|
145
|
+
In this call: {callHeadCount(tiles)}
|
|
146
|
+
</span>
|
|
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.
|
|
150
|
+
</CallHelpHint>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div
|
|
154
|
+
style={{
|
|
155
|
+
display: "grid",
|
|
156
|
+
gridTemplateColumns: "repeat(auto-fit, minmax(12rem, 1fr))",
|
|
157
|
+
gap: "0.75rem",
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
<LocalPreview tokens={tokens} aspectRatio={aspectRatio} />
|
|
161
|
+
{tiles.map((tile) => (
|
|
162
|
+
<ParticipantTile key={tile.key} tile={tile} tokens={tokens} aspectRatio={aspectRatio} />
|
|
163
|
+
))}
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
{audioProducerIds.map((producerId) => (
|
|
167
|
+
<RemoteAudio key={producerId} producerId={producerId} />
|
|
168
|
+
))}
|
|
169
|
+
|
|
170
|
+
<CallControls tokens={tokens} onLeave={onLeave} />
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The connection, said plainly.
|
|
177
|
+
*
|
|
178
|
+
* Four different words for four different situations, because "connecting",
|
|
179
|
+
* "reconnecting" and "we were refused" call for three different responses and a
|
|
180
|
+
* single spinner tells you which of them you are in exactly never.
|
|
181
|
+
*/
|
|
182
|
+
function ConnectionStatus({
|
|
183
|
+
status,
|
|
184
|
+
tokens,
|
|
185
|
+
onRetry,
|
|
186
|
+
}: {
|
|
187
|
+
status: CallStatus;
|
|
188
|
+
tokens: ResolvedThemeTokens;
|
|
189
|
+
onRetry: () => void;
|
|
190
|
+
}) {
|
|
191
|
+
const busy = status.tone === "connecting" || status.tone === "reconnecting";
|
|
192
|
+
const alarming = status.tone === "failed";
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<div
|
|
196
|
+
role="status"
|
|
197
|
+
aria-live="polite"
|
|
198
|
+
style={{
|
|
199
|
+
display: "flex",
|
|
200
|
+
alignItems: "center",
|
|
201
|
+
gap: "0.5rem",
|
|
202
|
+
fontSize: "0.8125rem",
|
|
203
|
+
color: alarming ? tokens.text : tokens.muted,
|
|
204
|
+
}}
|
|
205
|
+
>
|
|
206
|
+
{busy && <Loader2 width={14} height={14} aria-hidden="true" />}
|
|
207
|
+
{alarming && <AlertTriangle width={14} height={14} aria-hidden="true" color={tokens.secondary} />}
|
|
208
|
+
{status.tone === "live" && (
|
|
209
|
+
<span
|
|
210
|
+
aria-hidden="true"
|
|
211
|
+
style={{ width: 8, height: 8, borderRadius: 9999, backgroundColor: tokens.primary, display: "inline-block" }}
|
|
212
|
+
/>
|
|
213
|
+
)}
|
|
214
|
+
<span style={{ fontWeight: alarming ? 600 : 400 }}>{status.headline}</span>
|
|
215
|
+
{status.detail && <span style={{ color: tokens.muted }}>{status.detail}</span>}
|
|
216
|
+
{status.canRetry && (
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
onClick={onRetry}
|
|
220
|
+
style={{
|
|
221
|
+
display: "inline-flex",
|
|
222
|
+
alignItems: "center",
|
|
223
|
+
gap: "0.25rem",
|
|
224
|
+
border: `1px solid ${tokens.border}`,
|
|
225
|
+
borderRadius: tokens.cornerRadius,
|
|
226
|
+
backgroundColor: "transparent",
|
|
227
|
+
color: tokens.text,
|
|
228
|
+
padding: "0.125rem 0.5rem",
|
|
229
|
+
cursor: "pointer",
|
|
230
|
+
font: "inherit",
|
|
231
|
+
fontSize: "0.75rem",
|
|
232
|
+
}}
|
|
233
|
+
>
|
|
234
|
+
<RefreshCw width={12} height={12} aria-hidden="true" />
|
|
235
|
+
Try again
|
|
236
|
+
</button>
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Somebody is recording. A consent signal, so it is content and always shown. */
|
|
243
|
+
function RecordingIndicator({ tokens }: { tokens: ResolvedThemeTokens }) {
|
|
244
|
+
return (
|
|
245
|
+
<span
|
|
246
|
+
style={{
|
|
247
|
+
display: "inline-flex",
|
|
248
|
+
alignItems: "center",
|
|
249
|
+
gap: "0.375rem",
|
|
250
|
+
fontSize: "0.75rem",
|
|
251
|
+
fontWeight: 600,
|
|
252
|
+
color: tokens.text,
|
|
253
|
+
border: `1px solid ${tokens.border}`,
|
|
254
|
+
borderRadius: tokens.cornerRadius,
|
|
255
|
+
padding: "0.125rem 0.5rem",
|
|
256
|
+
}}
|
|
257
|
+
>
|
|
258
|
+
<span
|
|
259
|
+
aria-hidden="true"
|
|
260
|
+
style={{ width: 8, height: 8, borderRadius: 9999, backgroundColor: tokens.secondary, display: "inline-block" }}
|
|
261
|
+
/>
|
|
262
|
+
This call is being recorded
|
|
263
|
+
</span>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const tileFrame = (tokens: ResolvedThemeTokens, aspectRatio: number, speaking: boolean): CSSProperties => ({
|
|
268
|
+
position: "relative",
|
|
269
|
+
overflow: "hidden",
|
|
270
|
+
aspectRatio: String(aspectRatio),
|
|
271
|
+
borderRadius: tokens.cornerRadius,
|
|
272
|
+
backgroundColor: tokens.surface,
|
|
273
|
+
// The active speaker is shown by the FRAME rather than by re-ordering the
|
|
274
|
+
// grid: a grid that reshuffles itself every time somebody says "mm" is
|
|
275
|
+
// unusable, and the person you were looking at moves out from under the eye.
|
|
276
|
+
border: `2px solid ${speaking ? tokens.primary : tokens.border}`,
|
|
277
|
+
display: "flex",
|
|
278
|
+
alignItems: "center",
|
|
279
|
+
justifyContent: "center",
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const tileName = (tokens: ResolvedThemeTokens): CSSProperties => ({
|
|
283
|
+
position: "absolute",
|
|
284
|
+
left: "0.5rem",
|
|
285
|
+
bottom: "0.5rem",
|
|
286
|
+
maxWidth: "calc(100% - 1rem)",
|
|
287
|
+
overflow: "hidden",
|
|
288
|
+
textOverflow: "ellipsis",
|
|
289
|
+
whiteSpace: "nowrap",
|
|
290
|
+
fontSize: "0.75rem",
|
|
291
|
+
padding: "0.125rem 0.375rem",
|
|
292
|
+
borderRadius: tokens.cornerRadius,
|
|
293
|
+
backgroundColor: tokens.background,
|
|
294
|
+
color: tokens.text,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* One participant.
|
|
299
|
+
*
|
|
300
|
+
* A tile with no video renders the NAME, at size, and keeps its place in the
|
|
301
|
+
* grid. That is the whole point: somebody with the camera off has not left, and
|
|
302
|
+
* a grid that drops them re-flows under everyone else's eyes the moment one
|
|
303
|
+
* person turns a camera off.
|
|
304
|
+
*/
|
|
305
|
+
function ParticipantTile({
|
|
306
|
+
tile,
|
|
307
|
+
tokens,
|
|
308
|
+
aspectRatio,
|
|
309
|
+
}: {
|
|
310
|
+
tile: CallTile;
|
|
311
|
+
tokens: ResolvedThemeTokens;
|
|
312
|
+
aspectRatio: number;
|
|
313
|
+
}) {
|
|
314
|
+
// `useRemoteTrack` takes a producer id unconditionally so the hook order is
|
|
315
|
+
// the same for a tile with video and a tile without. An id that matches
|
|
316
|
+
// nothing simply returns no track, which is the state we want anyway.
|
|
317
|
+
const { track, attach } = useRemoteTrack(tile.videoProducerId ?? "");
|
|
318
|
+
const showVideo = !!tile.videoProducerId && !!track && !tile.videoPaused;
|
|
319
|
+
|
|
320
|
+
return (
|
|
321
|
+
<div
|
|
322
|
+
style={tileFrame(tokens, aspectRatio, tile.isSpeaking)}
|
|
323
|
+
data-testid={`tile-${tile.identity}`}
|
|
324
|
+
// The active set as an attribute as well as a border: a site restyling
|
|
325
|
+
// the grid needs the fact, and a colour is not something a test can
|
|
326
|
+
// assert without pinning a palette this component deliberately has none of.
|
|
327
|
+
data-speaking={tile.isSpeaking ? "true" : "false"}
|
|
328
|
+
>
|
|
329
|
+
{showVideo ? (
|
|
330
|
+
<video
|
|
331
|
+
ref={attach}
|
|
332
|
+
autoPlay
|
|
333
|
+
playsInline
|
|
334
|
+
muted
|
|
335
|
+
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
336
|
+
/>
|
|
337
|
+
) : (
|
|
338
|
+
<span style={{ fontSize: "0.9375rem", fontWeight: 600, color: tokens.text, padding: "0 0.75rem", textAlign: "center" }}>
|
|
339
|
+
{tile.name}
|
|
340
|
+
</span>
|
|
341
|
+
)}
|
|
342
|
+
<span style={tileName(tokens)}>
|
|
343
|
+
{tile.name}
|
|
344
|
+
{!tile.hasAudio && <span style={{ color: tokens.muted }}> (listening)</span>}
|
|
345
|
+
</span>
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Remote audio, attached and never drawn.
|
|
352
|
+
*
|
|
353
|
+
* Audio has to live on an element or the call is silent. It is deliberately NOT
|
|
354
|
+
* attached to the participant's `<video>`: a tile whose camera is off has no
|
|
355
|
+
* video element, and hanging the audio off one would mute whoever turned their
|
|
356
|
+
* camera off.
|
|
357
|
+
*/
|
|
358
|
+
function RemoteAudio({ producerId }: { producerId: string }) {
|
|
359
|
+
const { attach } = useRemoteTrack(producerId);
|
|
360
|
+
return <audio ref={attach} autoPlay style={{ display: "none" }} />;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** This viewer, as they are being sent. Muted, or it is a feedback loop. */
|
|
364
|
+
function LocalPreview({ tokens, aspectRatio }: { tokens: ResolvedThemeTokens; aspectRatio: number }) {
|
|
365
|
+
const state = useRoomState();
|
|
366
|
+
const publications = useLocalPublications();
|
|
367
|
+
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
368
|
+
|
|
369
|
+
const camera = useMemo(() => publications.find((p) => p.source === "camera"), [publications]);
|
|
370
|
+
const track = camera?.track;
|
|
371
|
+
|
|
372
|
+
useEffect(() => {
|
|
373
|
+
const element = videoRef.current;
|
|
374
|
+
if (!element) return;
|
|
375
|
+
// Rebuilt only when the TRACK changes. Doing this in a ref callback would
|
|
376
|
+
// build a new `MediaStream` on every render, which is the per-frame churn
|
|
377
|
+
// this component exists to avoid.
|
|
378
|
+
element.srcObject = track ? new MediaStream([track]) : null;
|
|
379
|
+
return () => {
|
|
380
|
+
element.srcObject = null;
|
|
381
|
+
};
|
|
382
|
+
}, [track]);
|
|
383
|
+
|
|
384
|
+
const name = state.identity ? "You" : "You (not connected yet)";
|
|
385
|
+
|
|
386
|
+
return (
|
|
387
|
+
<div style={tileFrame(tokens, aspectRatio, false)} data-testid="tile-local">
|
|
388
|
+
{track ? (
|
|
389
|
+
<video
|
|
390
|
+
ref={videoRef}
|
|
391
|
+
autoPlay
|
|
392
|
+
playsInline
|
|
393
|
+
muted
|
|
394
|
+
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
395
|
+
/>
|
|
396
|
+
) : (
|
|
397
|
+
<span style={{ fontSize: "0.9375rem", fontWeight: 600, color: tokens.text }}>{name}</span>
|
|
398
|
+
)}
|
|
399
|
+
<span style={tileName(tokens)}>{name}</span>
|
|
400
|
+
</div>
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const controlButton = (tokens: ResolvedThemeTokens, active: boolean, busy = false): CSSProperties => ({
|
|
405
|
+
display: "inline-flex",
|
|
406
|
+
alignItems: "center",
|
|
407
|
+
gap: "0.375rem",
|
|
408
|
+
padding: "0.5rem 0.75rem",
|
|
409
|
+
borderRadius: tokens.cornerRadius,
|
|
410
|
+
border: `1px solid ${active ? tokens.primary : tokens.border}`,
|
|
411
|
+
backgroundColor: active ? tokens.primary : "transparent",
|
|
412
|
+
color: active ? tokens.textPrimary : tokens.text,
|
|
413
|
+
cursor: busy ? "progress" : "pointer",
|
|
414
|
+
opacity: busy ? 0.6 : 1,
|
|
415
|
+
font: "inherit",
|
|
416
|
+
fontSize: "0.8125rem",
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Microphone, camera, screen and leave.
|
|
421
|
+
*
|
|
422
|
+
* Each toggle is drawn only when the token was minted with the grant for it, so
|
|
423
|
+
* a listen-only seat is not offered a microphone it would be refused. That is a
|
|
424
|
+
* rendering decision; the node refuses the publish itself either way.
|
|
425
|
+
*
|
|
426
|
+
* ## Why every toggle is disabled while it is working
|
|
427
|
+
*
|
|
428
|
+
* Each of these is a read-then-write: it reads whether the source is live and
|
|
429
|
+
* publishes or unpublishes accordingly. The flag it reads does not move until
|
|
430
|
+
* the publish has resolved, and a publish starts with a permission prompt that
|
|
431
|
+
* can sit on screen for seconds - so a button that looks untouched is a button
|
|
432
|
+
* a person presses again, and two presses used to mean two producers from one
|
|
433
|
+
* identity and everyone else hearing them doubled. `useLocalMedia` holds the
|
|
434
|
+
* real lock (a synchronous ref, since both clicks land before React re-renders
|
|
435
|
+
* anything); this is the part that says so on screen, because a control that
|
|
436
|
+
* silently ignores you is only marginally better than one that misbehaves.
|
|
437
|
+
*/
|
|
438
|
+
function CallControls({ tokens, onLeave }: { tokens: ResolvedThemeTokens; onLeave?: () => void }) {
|
|
439
|
+
const grants = useGrants();
|
|
440
|
+
const { room } = useMediaRoom();
|
|
441
|
+
const media = useLocalMedia();
|
|
442
|
+
|
|
443
|
+
// Plain handlers, deliberately not `useCallback`. `useLocalMedia()` returns a
|
|
444
|
+
// fresh object every render, so memoising on it would rebuild the callback
|
|
445
|
+
// every render anyway while looking as though it did not.
|
|
446
|
+
const toggleMicrophone = () => {
|
|
447
|
+
void (media.isMicrophoneEnabled ? media.unpublish("microphone") : media.publishMicrophone());
|
|
448
|
+
};
|
|
449
|
+
const toggleCamera = () => {
|
|
450
|
+
void (media.isCameraEnabled ? media.unpublish("camera") : media.publishCamera());
|
|
451
|
+
};
|
|
452
|
+
const toggleScreen = () => {
|
|
453
|
+
void (media.screenSharing ? media.unpublish("screen") : media.publishScreen());
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const leave = useCallback(() => {
|
|
457
|
+
// Closed here AND unmounted by the host. Closing alone would leave a
|
|
458
|
+
// provider mounted that reconnects on its next render; unmounting alone
|
|
459
|
+
// relies on a cleanup running before the person walks away from the laptop.
|
|
460
|
+
void room?.close().catch(() => undefined);
|
|
461
|
+
onLeave?.();
|
|
462
|
+
}, [room, onLeave]);
|
|
463
|
+
|
|
464
|
+
return (
|
|
465
|
+
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "0.5rem" }}>
|
|
466
|
+
{canPublishSource(grants, "microphone") && (
|
|
467
|
+
<button
|
|
468
|
+
type="button"
|
|
469
|
+
onClick={toggleMicrophone}
|
|
470
|
+
disabled={media.pending.microphone}
|
|
471
|
+
aria-busy={media.pending.microphone}
|
|
472
|
+
style={controlButton(tokens, media.isMicrophoneEnabled, media.pending.microphone)}
|
|
473
|
+
>
|
|
474
|
+
{media.isMicrophoneEnabled ? (
|
|
475
|
+
<Mic width={16} height={16} aria-hidden="true" />
|
|
476
|
+
) : (
|
|
477
|
+
<MicOff width={16} height={16} aria-hidden="true" />
|
|
478
|
+
)}
|
|
479
|
+
{media.isMicrophoneEnabled ? "Mute" : "Unmute"}
|
|
480
|
+
</button>
|
|
481
|
+
)}
|
|
482
|
+
|
|
483
|
+
{canPublishSource(grants, "camera") && (
|
|
484
|
+
<button
|
|
485
|
+
type="button"
|
|
486
|
+
onClick={toggleCamera}
|
|
487
|
+
disabled={media.pending.camera}
|
|
488
|
+
aria-busy={media.pending.camera}
|
|
489
|
+
style={controlButton(tokens, media.isCameraEnabled, media.pending.camera)}
|
|
490
|
+
>
|
|
491
|
+
{media.isCameraEnabled ? (
|
|
492
|
+
<Video width={16} height={16} aria-hidden="true" />
|
|
493
|
+
) : (
|
|
494
|
+
<VideoOff width={16} height={16} aria-hidden="true" />
|
|
495
|
+
)}
|
|
496
|
+
{media.isCameraEnabled ? "Stop camera" : "Start camera"}
|
|
497
|
+
</button>
|
|
498
|
+
)}
|
|
499
|
+
|
|
500
|
+
{canPublishSource(grants, "screen") && (
|
|
501
|
+
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.25rem" }}>
|
|
502
|
+
<button
|
|
503
|
+
type="button"
|
|
504
|
+
onClick={toggleScreen}
|
|
505
|
+
disabled={media.pending.screen}
|
|
506
|
+
aria-busy={media.pending.screen}
|
|
507
|
+
style={controlButton(tokens, media.screenSharing, media.pending.screen)}
|
|
508
|
+
>
|
|
509
|
+
{media.screenSharing ? (
|
|
510
|
+
<MonitorX width={16} height={16} aria-hidden="true" />
|
|
511
|
+
) : (
|
|
512
|
+
<MonitorUp width={16} height={16} aria-hidden="true" />
|
|
513
|
+
)}
|
|
514
|
+
{media.screenSharing ? "Stop sharing" : "Share screen"}
|
|
515
|
+
</button>
|
|
516
|
+
<CallHelpHint label="What does sharing my screen send?">
|
|
517
|
+
Whatever you pick in the browser prompt, live, to everyone in the call. Your camera keeps running alongside
|
|
518
|
+
it rather than being replaced, so people can still see you while you present.
|
|
519
|
+
</CallHelpHint>
|
|
520
|
+
</span>
|
|
521
|
+
)}
|
|
522
|
+
|
|
523
|
+
<button
|
|
524
|
+
type="button"
|
|
525
|
+
onClick={leave}
|
|
526
|
+
style={{ ...controlButton(tokens, false), marginLeft: "auto", borderColor: tokens.secondary }}
|
|
527
|
+
>
|
|
528
|
+
<PhoneOff width={16} height={16} aria-hidden="true" />
|
|
529
|
+
Leave
|
|
530
|
+
</button>
|
|
531
|
+
|
|
532
|
+
{/* A denied permission prompt is something the person has to be told
|
|
533
|
+
about without asking, so it stays visible rather than going behind
|
|
534
|
+
the `?`. */}
|
|
535
|
+
{media.error && (
|
|
536
|
+
<span role="alert" style={{ width: "100%", fontSize: "0.8125rem", color: tokens.text }}>
|
|
537
|
+
{media.error.message}
|
|
538
|
+
</span>
|
|
539
|
+
)}
|
|
540
|
+
</div>
|
|
541
|
+
);
|
|
542
|
+
}
|