@tribe-nest/forge 3.29.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/contexts/PublicAuthContext.tsx +34 -5
- package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
- package/src/data/queries/useBroadcasts.ts +151 -0
- package/src/data/queries/useMyBookings.ts +9 -1
- package/src/i18n/de.json +59 -0
- package/src/i18n/en.json +59 -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 +36 -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/styled/AccountDashboard.tsx +92 -3
- 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/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
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import type { ConnectionState, DisconnectCause, ProducerEntry, RoomState } from "@tribe-nest/media-client";
|
|
2
|
+
import {
|
|
3
|
+
mayPublish,
|
|
4
|
+
type MediaGrants,
|
|
5
|
+
type ParticipantKind,
|
|
6
|
+
type Peer,
|
|
7
|
+
type PublishSource,
|
|
8
|
+
} from "@tribe-nest/media-protocol";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The call UI's decisions, as pure functions.
|
|
12
|
+
*
|
|
13
|
+
* Everything a call screen has to work out from room state lives here rather
|
|
14
|
+
* than inside a component, because these are the parts that are worth being
|
|
15
|
+
* sure about and a component is the hardest place to be sure about anything.
|
|
16
|
+
* Two of them have a specific failure they exist to prevent.
|
|
17
|
+
*
|
|
18
|
+
* ## The grid must not collapse
|
|
19
|
+
*
|
|
20
|
+
* `callTiles` derives one tile per PARTICIPANT, never one tile per video
|
|
21
|
+
* stream. A participant with the camera off is an ordinary state, not an
|
|
22
|
+
* absence: they are still in the room, still speaking, still someone the other
|
|
23
|
+
* side has to be able to see listed. The commonest bug in this kind of
|
|
24
|
+
* component is a grid built by mapping over video tracks, which quietly drops
|
|
25
|
+
* everybody who is not publishing video and re-flows the whole layout the
|
|
26
|
+
* moment one person turns a camera off.
|
|
27
|
+
*
|
|
28
|
+
* ## The connection state must be honest
|
|
29
|
+
*
|
|
30
|
+
* `callStatus` maps the room's own five states onto something a person can act
|
|
31
|
+
* on, and it deliberately keeps "reconnecting" and "failed" apart. A spinner
|
|
32
|
+
* that means both is a spinner that means nothing: one is worth waiting
|
|
33
|
+
* through, the other is worth pressing a button about, and only the UI can say
|
|
34
|
+
* which because only the UI is being looked at.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** One rendered tile. A tile with no `videoProducerId` is NORMAL, not broken. */
|
|
38
|
+
export type CallTile = {
|
|
39
|
+
/**
|
|
40
|
+
* React key. Deliberately the participant plus an ORDINAL rather than the
|
|
41
|
+
* producer id: a camera going on and off must change what a tile shows, not
|
|
42
|
+
* unmount and remount the tile.
|
|
43
|
+
*/
|
|
44
|
+
key: string;
|
|
45
|
+
identity: string;
|
|
46
|
+
/** What to print. The peer's name when it has one, else its identity, which
|
|
47
|
+
* at least tells two unnamed participants apart. */
|
|
48
|
+
name: string;
|
|
49
|
+
kind: ParticipantKind;
|
|
50
|
+
/** The node put one of this participant's audio producers in the active set. */
|
|
51
|
+
isSpeaking: boolean;
|
|
52
|
+
/** Attach this with `useRemoteTrack`. Absent means "no camera", not "error". */
|
|
53
|
+
videoProducerId?: string;
|
|
54
|
+
/** The publisher paused it at source. Their choice, and shown as such. */
|
|
55
|
+
videoPaused: boolean;
|
|
56
|
+
/** They are publishing audio at all. A room can be joined listen-only. */
|
|
57
|
+
hasAudio: boolean;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type CallTileInput = {
|
|
61
|
+
/** Our own identity, so we are not drawn twice: self is the local preview. */
|
|
62
|
+
identity: string | null;
|
|
63
|
+
peers: readonly Peer[];
|
|
64
|
+
producers: readonly ProducerEntry[];
|
|
65
|
+
/** Producer ids, as the node sent them. Not identities. */
|
|
66
|
+
activeSpeakers: readonly string[];
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const displayName = (identity: string, name?: string): string => {
|
|
70
|
+
const trimmed = name?.trim();
|
|
71
|
+
return trimmed && trimmed.length > 0 ? trimmed : identity;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Every participant, once, plus an extra tile for each additional video stream.
|
|
76
|
+
*
|
|
77
|
+
* A participant publishing both a camera and a screen has two video producers
|
|
78
|
+
* and the wire does not label which is which (`source` travels in the
|
|
79
|
+
* publisher's `appData`, and the node does not echo it). So the second stream
|
|
80
|
+
* gets its own tile under the same name rather than being guessed at, and
|
|
81
|
+
* neither one replaces the other: a shared screen must never take the place of
|
|
82
|
+
* the face that is talking over it.
|
|
83
|
+
*
|
|
84
|
+
* ## The recorder is not a participant
|
|
85
|
+
*
|
|
86
|
+
* A recording joins the room as a real peer with `kind: "egress"`, so a grid
|
|
87
|
+
* that maps over `peers` draws a third tile called something like `egress-9f2c`
|
|
88
|
+
* in a 1:1 session and the head count says three. That happens at exactly the
|
|
89
|
+
* moment a coach is most likely to be reading the head count, and the thing it
|
|
90
|
+
* would be telling them (somebody is recording) is already said properly by
|
|
91
|
+
* `<RecordingIndicator>`, in words, as content. So `egress` is the one kind
|
|
92
|
+
* that gets no tile. Every other kind is somebody or something a person is
|
|
93
|
+
* actually talking to - a dial-in caller, an agent - and belongs on screen.
|
|
94
|
+
*/
|
|
95
|
+
export function callTiles(input: CallTileInput): readonly CallTile[] {
|
|
96
|
+
const speaking = new Set(input.activeSpeakers);
|
|
97
|
+
const tiles: CallTile[] = [];
|
|
98
|
+
const recorders = new Set(input.peers.filter((p) => p.kind === "egress").map((p) => p.identity));
|
|
99
|
+
|
|
100
|
+
const push = (identity: string, name: string, kind: ParticipantKind) => {
|
|
101
|
+
const mine = input.producers.filter((p) => p.identity === identity);
|
|
102
|
+
const videos = mine.filter((p) => p.kind === "video");
|
|
103
|
+
const hasAudio = mine.some((p) => p.kind === "audio");
|
|
104
|
+
const isSpeaking = mine.some((p) => p.kind === "audio" && speaking.has(p.producerId));
|
|
105
|
+
|
|
106
|
+
if (videos.length === 0) {
|
|
107
|
+
tiles.push({ key: `${identity}#0`, identity, name, kind, isSpeaking, videoPaused: false, hasAudio });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
videos.forEach((video, index) => {
|
|
111
|
+
tiles.push({
|
|
112
|
+
key: `${identity}#${index}`,
|
|
113
|
+
identity,
|
|
114
|
+
name,
|
|
115
|
+
kind,
|
|
116
|
+
isSpeaking,
|
|
117
|
+
videoProducerId: video.producerId,
|
|
118
|
+
videoPaused: video.paused,
|
|
119
|
+
hasAudio,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const seen = new Set<string>();
|
|
125
|
+
for (const peer of input.peers) {
|
|
126
|
+
if (peer.identity === input.identity) continue;
|
|
127
|
+
if (peer.kind === "egress") continue;
|
|
128
|
+
if (seen.has(peer.identity)) continue;
|
|
129
|
+
seen.add(peer.identity);
|
|
130
|
+
push(peer.identity, displayName(peer.identity, peer.name), peer.kind);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Producers whose peer we have not been told about yet.
|
|
135
|
+
*
|
|
136
|
+
* `producerAppeared` and `peerJoined` are separate frames with no ordering
|
|
137
|
+
* guarantee, so for a frame or two a stream can exist with nobody to hang it
|
|
138
|
+
* on. Dropping it would blank a tile that has media in it; drawing it under
|
|
139
|
+
* the identity is honest and self-corrects on the next frame.
|
|
140
|
+
*/
|
|
141
|
+
for (const producer of input.producers) {
|
|
142
|
+
if (producer.identity === input.identity) continue;
|
|
143
|
+
if (recorders.has(producer.identity)) continue;
|
|
144
|
+
if (seen.has(producer.identity)) continue;
|
|
145
|
+
seen.add(producer.identity);
|
|
146
|
+
push(producer.identity, producer.identity, "human");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return tiles;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* How many people are in this call, counting the viewer.
|
|
154
|
+
*
|
|
155
|
+
* Derived from the tiles' IDENTITIES rather than from how many tiles there are,
|
|
156
|
+
* because those are different numbers by design: `callTiles` gives a screen
|
|
157
|
+
* sharer two tiles, one for their face and one for what they are presenting.
|
|
158
|
+
* Counting tiles therefore announced a third person in the room the instant a
|
|
159
|
+
* coach shared their screen, which is the one moment a two-person call is
|
|
160
|
+
* certain to have a second video stream.
|
|
161
|
+
*
|
|
162
|
+
* This number is the one thing on the screen a person uses to answer "has my
|
|
163
|
+
* client actually arrived", so it counts people or it should not be shown.
|
|
164
|
+
*/
|
|
165
|
+
export function callHeadCount(tiles: readonly CallTile[]): number {
|
|
166
|
+
// +1 for the viewer, who is the local preview and never has a tile here.
|
|
167
|
+
return new Set(tiles.map((tile) => tile.identity)).size + 1;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Remote audio has to be attached to an element or the call is silent. */
|
|
171
|
+
export function remoteAudioProducerIds(input: {
|
|
172
|
+
identity: string | null;
|
|
173
|
+
producers: readonly ProducerEntry[];
|
|
174
|
+
}): readonly string[] {
|
|
175
|
+
return input.producers.filter((p) => p.kind === "audio" && p.identity !== input.identity).map((p) => p.producerId);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export type CallStatusTone = "connecting" | "live" | "reconnecting" | "ended" | "failed";
|
|
179
|
+
|
|
180
|
+
export type CallStatus = {
|
|
181
|
+
tone: CallStatusTone;
|
|
182
|
+
/** Always shown. Connection state is CONTENT, never a help hint. */
|
|
183
|
+
headline: string;
|
|
184
|
+
/** The server's own words when it has any. Shown verbatim rather than
|
|
185
|
+
* translated into a friendlier lie. */
|
|
186
|
+
detail?: string;
|
|
187
|
+
/** Is pressing something the right response? Only when waiting will not fix it. */
|
|
188
|
+
canRetry: boolean;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
export type CallStatusInput = {
|
|
192
|
+
connectionState: ConnectionState;
|
|
193
|
+
error: DisconnectCause | undefined;
|
|
194
|
+
/** The ROOM's own phase. `closed` means the node said so, on a live socket. */
|
|
195
|
+
phase?: RoomState["phase"];
|
|
196
|
+
closedReason?: string | null;
|
|
197
|
+
/**
|
|
198
|
+
* Has the SDK actually booked another attempt?
|
|
199
|
+
*
|
|
200
|
+
* `connectionState` is `reconnecting` both while one is in flight and after
|
|
201
|
+
* the policy has given up, so without this the screen promises a recovery
|
|
202
|
+
* that may not be happening. Defaults to false, which is the safe reading: it
|
|
203
|
+
* offers a control rather than a spinner.
|
|
204
|
+
*/
|
|
205
|
+
recovering?: boolean;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The room's states, told honestly.
|
|
210
|
+
*
|
|
211
|
+
* `reconnecting` is the room's state for BOTH "the socket dropped and we are
|
|
212
|
+
* coming back" and "the node refused us", because the signal client reports one
|
|
213
|
+
* close path. They are completely different things to a person sitting in front
|
|
214
|
+
* of it, and the cause is what separates them, so the cause is read here rather
|
|
215
|
+
* than being flattened into a spinner.
|
|
216
|
+
*
|
|
217
|
+
* `phase` is read as well as `connectionState`, and it is not redundant: a
|
|
218
|
+
* `roomClosed` frame arrives on a socket that is still open, so the connection
|
|
219
|
+
* is "connected" for as long as it takes the node to hang up. Reading only the
|
|
220
|
+
* connection would show "Connected" over a call that has ended, which is the
|
|
221
|
+
* one thing on this list a person would act on wrongly.
|
|
222
|
+
*
|
|
223
|
+
* `recovering` is read for the same reason and it is the other half of being
|
|
224
|
+
* honest. "Moving you to another server" and "Connection lost, reconnecting"
|
|
225
|
+
* are both PROMISES, and a promise only one side of the SDK is keeping is a
|
|
226
|
+
* spinner over a call that is finished: the person waits, because the screen
|
|
227
|
+
* told them to, and the way back into a paid hour turns out to be working out
|
|
228
|
+
* for themselves that they should reload the page. When nothing is coming, the
|
|
229
|
+
* words change and a control appears.
|
|
230
|
+
*/
|
|
231
|
+
export function callStatus(input: CallStatusInput): CallStatus {
|
|
232
|
+
const { connectionState, error } = input;
|
|
233
|
+
const recovering = input.recovering ?? false;
|
|
234
|
+
|
|
235
|
+
if (input.phase === "closed") {
|
|
236
|
+
return {
|
|
237
|
+
tone: "ended",
|
|
238
|
+
headline: "The call has ended",
|
|
239
|
+
...(input.closedReason ? { detail: input.closedReason } : {}),
|
|
240
|
+
canRetry: false,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (connectionState === "connected") return { tone: "live", headline: "Connected", canRetry: false };
|
|
245
|
+
|
|
246
|
+
if (connectionState === "idle" || connectionState === "connecting") {
|
|
247
|
+
return { tone: "connecting", headline: "Connecting to the call", canRetry: false };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (connectionState === "closed") {
|
|
251
|
+
if (error?.type === "room_closed") {
|
|
252
|
+
return { tone: "ended", headline: "The call has ended", detail: error.reason, canRetry: false };
|
|
253
|
+
}
|
|
254
|
+
return { tone: "ended", headline: "You have left the call", canRetry: false };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Everything below is `reconnecting`, where the cause is the whole story.
|
|
258
|
+
switch (error?.type) {
|
|
259
|
+
case "refused":
|
|
260
|
+
return {
|
|
261
|
+
tone: "failed",
|
|
262
|
+
headline: "Could not join this call",
|
|
263
|
+
detail: error.message ?? error.code,
|
|
264
|
+
canRetry: true,
|
|
265
|
+
};
|
|
266
|
+
case "room_closed":
|
|
267
|
+
return { tone: "ended", headline: "The call has ended", detail: error.reason, canRetry: false };
|
|
268
|
+
case "draining":
|
|
269
|
+
// A drain is the node asking to be left, so the move is the SDK's job and
|
|
270
|
+
// a button would only get in its way - as long as the move is actually
|
|
271
|
+
// happening. When it is not, this is the worst screen on the list: an
|
|
272
|
+
// ordinary deploy would otherwise end every call on that node under a
|
|
273
|
+
// spinner saying nobody was dropped.
|
|
274
|
+
return recovering
|
|
275
|
+
? {
|
|
276
|
+
tone: "reconnecting",
|
|
277
|
+
headline: "Moving you to another server",
|
|
278
|
+
detail: "This takes a few seconds and nobody else is dropped.",
|
|
279
|
+
canRetry: false,
|
|
280
|
+
}
|
|
281
|
+
: {
|
|
282
|
+
tone: "failed",
|
|
283
|
+
headline: "Could not move you to another server",
|
|
284
|
+
detail: "Nobody else was dropped. Try again to rejoin.",
|
|
285
|
+
canRetry: true,
|
|
286
|
+
};
|
|
287
|
+
case "closed_by_client":
|
|
288
|
+
return { tone: "ended", headline: "You have left the call", canRetry: false };
|
|
289
|
+
default:
|
|
290
|
+
return recovering
|
|
291
|
+
? {
|
|
292
|
+
tone: "reconnecting",
|
|
293
|
+
headline: "Connection lost, reconnecting",
|
|
294
|
+
detail: "Everyone else is still in the call.",
|
|
295
|
+
canRetry: true,
|
|
296
|
+
}
|
|
297
|
+
: {
|
|
298
|
+
tone: "failed",
|
|
299
|
+
headline: "Connection lost",
|
|
300
|
+
detail: "Everyone else is still in the call. Try again to rejoin.",
|
|
301
|
+
canRetry: true,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* What a control publishes, in the vocabulary the SDK sends.
|
|
308
|
+
*
|
|
309
|
+
* The SOURCE, not the grant's kind, and the difference is the whole point:
|
|
310
|
+
* `room.publish(track, source)` puts one of these three words on the wire and
|
|
311
|
+
* the node decides against it. A control asking about "audio" would be asking
|
|
312
|
+
* a question no publish ever asks.
|
|
313
|
+
*
|
|
314
|
+
* Re-exported from the protocol rather than restated, because a second list of
|
|
315
|
+
* the same three words is a second list that can drift from the one the node
|
|
316
|
+
* reads.
|
|
317
|
+
*/
|
|
318
|
+
export type { PublishSource };
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Should this control be drawn at all?
|
|
322
|
+
*
|
|
323
|
+
* A UI hint and nothing more. The node checks the same grant on the publish
|
|
324
|
+
* itself and its answer is the one that counts, so hiding a button here is
|
|
325
|
+
* about not offering something that will be refused, never about enforcing
|
|
326
|
+
* anything. `publishKinds` absent means every kind, which is what the protocol
|
|
327
|
+
* says and what the node does.
|
|
328
|
+
*
|
|
329
|
+
* `mayPublish` is the protocol's own function, the same one the node calls, and
|
|
330
|
+
* that is deliberate rather than tidy. A grant is written in kinds ("audio",
|
|
331
|
+
* "video", "screen") and a publish declares a source ("microphone", "camera",
|
|
332
|
+
* "screen"), so a second copy of the translation living here is a second copy
|
|
333
|
+
* that can disagree with the node - and a button offered for a publish that is
|
|
334
|
+
* about to be refused is worse than no button at all.
|
|
335
|
+
*
|
|
336
|
+
* The track kind is implied by the source, because these three are the only
|
|
337
|
+
* sources this UI has: a microphone is audio and the other two are video.
|
|
338
|
+
*/
|
|
339
|
+
export function canPublishSource(grants: MediaGrants | undefined, source: PublishSource): boolean {
|
|
340
|
+
return mayPublish(grants, { source, trackKind: source === "microphone" ? "audio" : "video" });
|
|
341
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forge UI - live calls.
|
|
3
|
+
*
|
|
4
|
+
* ## Where the ONE call UI lives
|
|
5
|
+
*
|
|
6
|
+
* Here. `CallStage` is built once in this package, over the hooks in
|
|
7
|
+
* `@tribe-nest/media-client/react`, and BOTH rendering surfaces render that one
|
|
8
|
+
* component: code websites import it from `@tribe-nest/forge/media`, and admin
|
|
9
|
+
* imports the same export and feeds it the dashboard's own theme tokens through
|
|
10
|
+
* `<ForgeThemeProvider>`. A second implementation would mean every fix to the
|
|
11
|
+
* ordering rules - device before transport, attach before resume, follow the
|
|
12
|
+
* active set - had to be made twice, and the second copy would be the one that
|
|
13
|
+
* drifted.
|
|
14
|
+
*
|
|
15
|
+
* The HOOKS are still pure re-exports, because the room logic belongs to the
|
|
16
|
+
* SDK and Forge has no business owning a second copy of it.
|
|
17
|
+
*
|
|
18
|
+
* ## Why a barrel at all, rather than telling sites to import the SDK
|
|
19
|
+
*
|
|
20
|
+
* The code agent builds sites from Forge's manifest, which is generated from
|
|
21
|
+
* these barrels. An export that is not in one does not exist as far as the
|
|
22
|
+
* agent is concerned, however well documented it is elsewhere. `./media` is
|
|
23
|
+
* registered in `ENTRY_SUBPATHS` in `apps/backend/src/utils/forgeManifest.ts`
|
|
24
|
+
* for exactly that reason.
|
|
25
|
+
*
|
|
26
|
+
* ## What a site actually needs to do
|
|
27
|
+
*
|
|
28
|
+
* Wrap the call in `<MediaRoomProvider>` and give it a `getCredentials`
|
|
29
|
+
* callback that fetches a fresh join ticket. It is a CALLBACK rather than a
|
|
30
|
+
* token because a ticket expires in minutes and a call lasts an hour: a token
|
|
31
|
+
* passed once means the first reconnect presents an expired one.
|
|
32
|
+
*
|
|
33
|
+
* ```tsx
|
|
34
|
+
* <MediaRoomProvider getCredentials={() => fetchJoinTicket(bookingId)}>
|
|
35
|
+
* <CallStage />
|
|
36
|
+
* </MediaRoomProvider>
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* Inside, `useParticipants()`, `useRemoteTrack(producerId)` and
|
|
40
|
+
* `useLocalMedia()` are the whole surface. Nothing here decides what a
|
|
41
|
+
* participant is allowed to do: `useGrants()` is for RENDERING - so a button
|
|
42
|
+
* that the node would refuse is not offered - and the node enforces
|
|
43
|
+
* independently. A client-side grant check is a UI hint, never the decision.
|
|
44
|
+
*/
|
|
45
|
+
export {
|
|
46
|
+
MediaRoomProvider,
|
|
47
|
+
useActiveSpeakers,
|
|
48
|
+
useGrants,
|
|
49
|
+
useLocalMedia,
|
|
50
|
+
useLocalPublications,
|
|
51
|
+
useMediaRoom,
|
|
52
|
+
useParticipants,
|
|
53
|
+
useRecording,
|
|
54
|
+
useRemoteTrack,
|
|
55
|
+
useRoomState,
|
|
56
|
+
// The producers a UI should RENDER: `useRoomState().producers` is the node's
|
|
57
|
+
// full announcement history and deliberately keeps producers the current
|
|
58
|
+
// subscribe rule bars, so that a barrier which narrows can later widen.
|
|
59
|
+
useVisibleProducers,
|
|
60
|
+
type LocalMediaControls,
|
|
61
|
+
type LocalSource,
|
|
62
|
+
type MediaRoomProviderProps,
|
|
63
|
+
} from "@tribe-nest/media-client/react";
|
|
64
|
+
|
|
65
|
+
export type {
|
|
66
|
+
ConnectionState,
|
|
67
|
+
DisconnectCause,
|
|
68
|
+
LocalPublication,
|
|
69
|
+
MediaRoom,
|
|
70
|
+
MediaTrack,
|
|
71
|
+
// `CallTileInput` is built from these, so a site laying out its own grid over
|
|
72
|
+
// `callTiles` needs them by name.
|
|
73
|
+
ProducerEntry,
|
|
74
|
+
RoomState,
|
|
75
|
+
} from "@tribe-nest/media-client";
|
|
76
|
+
|
|
77
|
+
export type { MediaGrants, Peer, SubscribeRule } from "@tribe-nest/media-protocol";
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The screen itself, and the decisions behind it.
|
|
81
|
+
*
|
|
82
|
+
* `CallStage` is the whole call: participant tiles with names, the local
|
|
83
|
+
* preview, mic / camera / screenshare toggles, an active-speaker indication, a
|
|
84
|
+
* leave control and an honest connection state. Mount it inside a
|
|
85
|
+
* `<MediaRoomProvider>` (or `<BookingCallProvider>`) and behind a Join control,
|
|
86
|
+
* because the provider connects on mount.
|
|
87
|
+
*
|
|
88
|
+
* `callTiles`, `callStatus` and `canPublishSource` are exported alongside it for
|
|
89
|
+
* a surface that wants a different layout over the same decisions. They are
|
|
90
|
+
* pure and they are where the two failures worth being sure about live: a grid
|
|
91
|
+
* that keeps a tile for somebody with the camera off, and a connection state
|
|
92
|
+
* that separates "reconnecting" from "refused" rather than showing one spinner
|
|
93
|
+
* for both.
|
|
94
|
+
*/
|
|
95
|
+
export { CallStage, type CallStageProps } from "./CallStage";
|
|
96
|
+
export { CallHelpHint, type CallHelpHintProps } from "./CallHelpHint";
|
|
97
|
+
export {
|
|
98
|
+
callHeadCount,
|
|
99
|
+
callStatus,
|
|
100
|
+
callTiles,
|
|
101
|
+
canPublishSource,
|
|
102
|
+
remoteAudioProducerIds,
|
|
103
|
+
type CallStatus,
|
|
104
|
+
type CallStatusInput,
|
|
105
|
+
type CallStatusTone,
|
|
106
|
+
type CallTile,
|
|
107
|
+
type CallTileInput,
|
|
108
|
+
type PublishSource,
|
|
109
|
+
} from "./callState";
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Coaching sessions held on the platform's own media network.
|
|
113
|
+
*
|
|
114
|
+
* These are the ONE thing in this barrel that is not a re-export, because they
|
|
115
|
+
* are the only part that knows about a TribeNest endpoint: `BookingCallProvider`
|
|
116
|
+
* is `MediaRoomProvider` already wired to the booking credential endpoint, so a
|
|
117
|
+
* site never has to know what a join ticket is or how often to fetch one.
|
|
118
|
+
*
|
|
119
|
+
* They live in THIS barrel rather than in `./ui` for a mechanical reason: the
|
|
120
|
+
* code agent builds sites from Forge's manifest, and the manifest is generated
|
|
121
|
+
* from the registered entry points. An export outside one of them does not
|
|
122
|
+
* exist as far as the agent is concerned, however well documented it is.
|
|
123
|
+
*/
|
|
124
|
+
export {
|
|
125
|
+
BOOKING_CALL_CLOSES_MINUTES_AFTER,
|
|
126
|
+
BOOKING_CALL_OPENS_MINUTES_BEFORE,
|
|
127
|
+
BookingCallProvider,
|
|
128
|
+
bookingCallWindow,
|
|
129
|
+
useBookingSessionCredentials,
|
|
130
|
+
type BookingCallProviderProps,
|
|
131
|
+
type BookingCallSubject,
|
|
132
|
+
type BookingCallWindow,
|
|
133
|
+
type BookingSessionCredentials,
|
|
134
|
+
type BookingSessionRole,
|
|
135
|
+
} from "./bookingSession";
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
useCancelMembership,
|
|
22
22
|
useMembershipAccess,
|
|
23
23
|
useOpenBillingPortal,
|
|
24
|
+
useCommunitySpaces,
|
|
24
25
|
useUpdateAccount,
|
|
25
26
|
useChangePassword,
|
|
26
27
|
useExportAccountData,
|
|
@@ -85,6 +86,17 @@ export interface AccountDashboardProps {
|
|
|
85
86
|
* this to its router (e.g. `/i/membership`).
|
|
86
87
|
*/
|
|
87
88
|
onNavigateMembership?: () => void;
|
|
89
|
+
/**
|
|
90
|
+
* Navigate to the community spaces, the member home's Spaces tab (host maps
|
|
91
|
+
* this to its router, e.g. `/i/members/spaces`).
|
|
92
|
+
*
|
|
93
|
+
* Optional, and drawn only when the signed-in member has at least one space
|
|
94
|
+
* they can actually open: `/public/community/spaces` returns open spaces plus
|
|
95
|
+
* the spaces gated to a tier they hold, so an empty list is the server saying
|
|
96
|
+
* there is no door here. Without that check the account page would offer a
|
|
97
|
+
* member a room they get bounced out of, which is worse than no link at all.
|
|
98
|
+
*/
|
|
99
|
+
onNavigateCommunity?: () => void;
|
|
88
100
|
/**
|
|
89
101
|
* Optional currency formatter override. Defaults to Forge's `useFormatCurrency`
|
|
90
102
|
* (which honors the visitor's selected currency + tenant exchange rates), so the
|
|
@@ -150,6 +162,7 @@ interface TabContext {
|
|
|
150
162
|
formatDate: (value: string | number | Date) => string;
|
|
151
163
|
currency?: string;
|
|
152
164
|
onNavigateMembership?: () => void;
|
|
165
|
+
onNavigateCommunity?: () => void;
|
|
153
166
|
}
|
|
154
167
|
|
|
155
168
|
// ---- page -------------------------------------------------------------------
|
|
@@ -163,6 +176,7 @@ export function AccountDashboard({
|
|
|
163
176
|
tab,
|
|
164
177
|
onTabChange,
|
|
165
178
|
onNavigateMembership,
|
|
179
|
+
onNavigateCommunity,
|
|
166
180
|
formatAmount,
|
|
167
181
|
formatDate,
|
|
168
182
|
currency,
|
|
@@ -184,6 +198,7 @@ export function AccountDashboard({
|
|
|
184
198
|
// settlement currency (single source), unless the host overrides it.
|
|
185
199
|
currency: currency ?? siteConfig?.currency,
|
|
186
200
|
onNavigateMembership,
|
|
201
|
+
onNavigateCommunity,
|
|
187
202
|
};
|
|
188
203
|
|
|
189
204
|
if (!user) return <Loading fullPage />;
|
|
@@ -244,9 +259,23 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
|
244
259
|
const { user } = usePublicAuth();
|
|
245
260
|
const { t, card, button, ghostButton } = useCardStyles();
|
|
246
261
|
const tr = useForgeT();
|
|
247
|
-
const { formatAmount, formatDate, currency, onNavigateMembership } = ctx;
|
|
262
|
+
const { formatAmount, formatDate, currency, onNavigateMembership, onNavigateCommunity } = ctx;
|
|
248
263
|
const cancelMembership = useCancelMembership();
|
|
264
|
+
// The way into the community, which the account page had no link to at all.
|
|
265
|
+
// The list is already access-filtered by the server (open spaces plus the
|
|
266
|
+
// ones gated to a tier this member holds), so its emptiness is the whole
|
|
267
|
+
// gate: no spaces, no link, and nobody is sent at a locked door.
|
|
268
|
+
const { data: communitySpaces } = useCommunitySpaces();
|
|
269
|
+
const canOpenCommunity = !!onNavigateCommunity && (communitySpaces?.length ?? 0) > 0;
|
|
249
270
|
const [isCancelling, setIsCancelling] = useState(false);
|
|
271
|
+
/**
|
|
272
|
+
* Cancelling is destructive and irreversible from here, so it asks first.
|
|
273
|
+
* CLAUDE.md puts a destructive confirmation in the category that must stay
|
|
274
|
+
* VISIBLE rather than behind a `?`, and until now one click on Cancel simply
|
|
275
|
+
* ended the membership. The client app has always asked (`EditorModal` in
|
|
276
|
+
* `MembershipTab.tsx`); this surface was ported without it.
|
|
277
|
+
*/
|
|
278
|
+
const [confirmingCancel, setConfirmingCancel] = useState(false);
|
|
250
279
|
const [cancelError, setCancelError] = useState<string | null>(null);
|
|
251
280
|
|
|
252
281
|
const membership = user?.membership;
|
|
@@ -348,7 +377,14 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
|
348
377
|
<p style={{ opacity: 0.8 }}>{tr("forge.account_dashboard.membership_empty")}</p>
|
|
349
378
|
)}
|
|
350
379
|
|
|
351
|
-
<div style={{ display: "flex", gap: 12, justifyContent: "flex-end", marginTop: 24 }}>
|
|
380
|
+
<div style={{ display: "flex", gap: 12, justifyContent: "flex-end", marginTop: 24, flexWrap: "wrap" }}>
|
|
381
|
+
{/* Kept hard left, apart from the billing actions: this is the door to
|
|
382
|
+
what the membership is FOR, not another thing to do to the plan. */}
|
|
383
|
+
{canOpenCommunity && (
|
|
384
|
+
<button onClick={() => onNavigateCommunity?.()} style={{ ...ghostButton, marginRight: "auto" }}>
|
|
385
|
+
{tr("forge.account_dashboard.community_spaces")}
|
|
386
|
+
</button>
|
|
387
|
+
)}
|
|
352
388
|
{message.showUpdatePayment && (
|
|
353
389
|
<button onClick={onUpdatePayment} disabled={openBillingPortal.isPending} style={button}>
|
|
354
390
|
{openBillingPortal.isPending
|
|
@@ -361,7 +397,7 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
|
361
397
|
but not leave. Hidden once it is already scheduled ("Ending"), where
|
|
362
398
|
the only honest label would be "Cancel again". */}
|
|
363
399
|
{access.hasAccess && !access.cancelAtPeriodEnd && (
|
|
364
|
-
<button onClick={
|
|
400
|
+
<button onClick={() => setConfirmingCancel(true)} disabled={isCancelling} style={ghostButton}>
|
|
365
401
|
{isCancelling ? tr("forge.account_dashboard.cancelling") : tr("forge.account_dashboard.cancel")}
|
|
366
402
|
</button>
|
|
367
403
|
)}
|
|
@@ -371,6 +407,41 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
|
371
407
|
: tr("forge.account_dashboard.membership_join")}
|
|
372
408
|
</button>
|
|
373
409
|
</div>
|
|
410
|
+
|
|
411
|
+
{/* Says WHICH of the two cancellations this is. A paid membership runs to
|
|
412
|
+
the end of the period already paid for; a free one stops now. A flat
|
|
413
|
+
"are you sure" tells a member who paid this month that they have lost
|
|
414
|
+
the rest of it, which is not true and reads as a threat. */}
|
|
415
|
+
{confirmingCancel && (
|
|
416
|
+
<div role="alertdialog" aria-modal="true" style={{ ...card, marginTop: 16 }}>
|
|
417
|
+
<p style={{ fontWeight: 700 }}>{tr("forge.account_dashboard.cancel_confirm_title")}</p>
|
|
418
|
+
<p style={{ fontSize: 14, opacity: 0.85, marginTop: 8 }}>
|
|
419
|
+
{membership?.subscriptionAmount
|
|
420
|
+
? membership.endDate
|
|
421
|
+
? tr("forge.account_dashboard.cancel_confirm_paid_until", { date: formatDate(membership.endDate) })
|
|
422
|
+
: tr("forge.account_dashboard.cancel_confirm_paid")
|
|
423
|
+
: tr("forge.account_dashboard.cancel_confirm_free")}
|
|
424
|
+
</p>
|
|
425
|
+
<div style={{ display: "flex", gap: 12, justifyContent: "flex-end", marginTop: 16 }}>
|
|
426
|
+
<button onClick={() => setConfirmingCancel(false)} disabled={isCancelling} style={ghostButton}>
|
|
427
|
+
{tr("forge.account_dashboard.cancel_confirm_keep")}
|
|
428
|
+
</button>
|
|
429
|
+
<button
|
|
430
|
+
onClick={() => {
|
|
431
|
+
// Closed FIRST, so the dialog cannot be double-submitted while
|
|
432
|
+
// the request is in flight. The row behind it already renders
|
|
433
|
+
// "Cancelling" from `isCancelling`.
|
|
434
|
+
setConfirmingCancel(false);
|
|
435
|
+
void onCancel();
|
|
436
|
+
}}
|
|
437
|
+
disabled={isCancelling}
|
|
438
|
+
style={button}
|
|
439
|
+
>
|
|
440
|
+
{tr("forge.account_dashboard.cancel_confirm_confirm")}
|
|
441
|
+
</button>
|
|
442
|
+
</div>
|
|
443
|
+
</div>
|
|
444
|
+
)}
|
|
374
445
|
</div>
|
|
375
446
|
);
|
|
376
447
|
}
|
|
@@ -858,6 +929,24 @@ function BookingLocationLine({
|
|
|
858
929
|
);
|
|
859
930
|
}
|
|
860
931
|
|
|
932
|
+
/**
|
|
933
|
+
* A call on the platform's own media network. A LINE, never the call itself.
|
|
934
|
+
*
|
|
935
|
+
* There is no URL to render and there never will be: entry is a ticket minted
|
|
936
|
+
* per attempt and valid for minutes. Mounting the room here is also the wrong
|
|
937
|
+
* place mechanically - the call UI lives behind `@tribe-nest/forge/media`,
|
|
938
|
+
* whose media-client dependency is an OPTIONAL peer, and importing it from
|
|
939
|
+
* this entry point would break every site that has not installed it.
|
|
940
|
+
*/
|
|
941
|
+
if (location.type === "video") {
|
|
942
|
+
return (
|
|
943
|
+
<div style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>
|
|
944
|
+
<span style={{ fontWeight: 600 }}>{tr("forge.account_dashboard.booking_join_label")}{" "}</span>
|
|
945
|
+
{tr("forge.account_dashboard.booking_video_call")}
|
|
946
|
+
</div>
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
|
|
861
950
|
return (
|
|
862
951
|
<div style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>
|
|
863
952
|
<span style={{ fontWeight: 600 }}>{tr("forge.account_dashboard.booking_where_label")}{" "}</span>
|