@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,182 @@
|
|
|
1
|
+
import { useCallback, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { MediaRoomProvider } from "@tribe-nest/media-client/react";
|
|
4
|
+
import { MediaError, type MediaErrorCode, type MediaGrants } from "@tribe-nest/media-protocol";
|
|
5
|
+
|
|
6
|
+
import { useForge } from "../../provider/ForgeProvider";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Live video for a coaching session, on the platform's own media network.
|
|
10
|
+
*
|
|
11
|
+
* A coaching product whose location is `video` holds its sessions in a room
|
|
12
|
+
* this platform provisions, rather than on a link the coach pasted in. There is
|
|
13
|
+
* no URL to render and there deliberately never will be: entry is a signed
|
|
14
|
+
* ticket minted per attempt and valid for minutes, so anything durable enough
|
|
15
|
+
* to put in a page or an email would be a forwardable key to somebody else's
|
|
16
|
+
* session.
|
|
17
|
+
*
|
|
18
|
+
* ## What a site does
|
|
19
|
+
*
|
|
20
|
+
* ```tsx
|
|
21
|
+
* const call = bookingCallWindow(booking);
|
|
22
|
+
*
|
|
23
|
+
* return call.isOpen ? (
|
|
24
|
+
* <BookingCallProvider bookingId={booking.id} fallback={<p>Connecting…</p>}>
|
|
25
|
+
* <CallStage />
|
|
26
|
+
* </BookingCallProvider>
|
|
27
|
+
* ) : null;
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* Inside the provider the whole surface is `useParticipants()`,
|
|
31
|
+
* `useRemoteTrack(producerId)`, `useLocalMedia()` and `useMediaRoom()` - the
|
|
32
|
+
* same hooks every other call surface uses, because there is one call UI.
|
|
33
|
+
*
|
|
34
|
+
* ## Why the credential is a CALLBACK
|
|
35
|
+
*
|
|
36
|
+
* `getCredentials` is called before EVERY connection attempt, not once. A join
|
|
37
|
+
* ticket expires in minutes and a session lasts an hour, so a token fetched
|
|
38
|
+
* once means the first reconnect after a lift or a wifi handover presents an
|
|
39
|
+
* expired one and the call does not come back. Passing the callback (rather
|
|
40
|
+
* than a token) is what makes reconnection work at all.
|
|
41
|
+
*
|
|
42
|
+
* ## What the server owns and this file does not mirror
|
|
43
|
+
*
|
|
44
|
+
* - **Who you are in the room.** Identity, name and grants are all derived
|
|
45
|
+
* server-side from the booking: the client who bought it, or the coach who
|
|
46
|
+
* holds `coaching.write` on the profile selling it. Nothing here can ask to
|
|
47
|
+
* be somebody else, and there is no field through which it could.
|
|
48
|
+
* - **Whether the room is open.** `bookingCallWindow` below is for DRAWING
|
|
49
|
+
* the button; the server refuses a ticket outside the window regardless, and
|
|
50
|
+
* its answer is the one that counts.
|
|
51
|
+
* - **Whether the session is a video session.** Read `booking.location.type`,
|
|
52
|
+
* already resolved server-side (this booking's override, else the product's
|
|
53
|
+
* default). Re-deriving it in the browser would give two answers to one
|
|
54
|
+
* question, and the wrong one sends a client to an empty room.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/** Which side of the call this viewer is on. Derived by the server, never asked for. */
|
|
58
|
+
export type BookingSessionRole = "host" | "guest";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One join ticket, plus the window it belongs to.
|
|
62
|
+
*
|
|
63
|
+
* The first six fields are the flat shape every credential endpoint on this
|
|
64
|
+
* platform returns and the media SDK consumes; `role` and the two window
|
|
65
|
+
* timestamps are extras the SDK ignores and a call UI usually wants.
|
|
66
|
+
*/
|
|
67
|
+
export type BookingSessionCredentials = {
|
|
68
|
+
/** The signalling entrypoint. A load balancer, never a node this client picked. */
|
|
69
|
+
mediaUrl: string;
|
|
70
|
+
token: string;
|
|
71
|
+
/** ISO. When THIS ticket dies, not when the call does. */
|
|
72
|
+
expiresAt: string;
|
|
73
|
+
/** This viewer's identity in the room, as the other participants see it. */
|
|
74
|
+
identity: string;
|
|
75
|
+
roomId: string;
|
|
76
|
+
grants: MediaGrants;
|
|
77
|
+
role: BookingSessionRole;
|
|
78
|
+
/** ISO. The earliest moment the server will hand out a ticket for this session. */
|
|
79
|
+
roomOpensAt: string;
|
|
80
|
+
/** ISO. After this it refuses new tickets; anyone already connected stays. */
|
|
81
|
+
roomClosesAt: string;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Fetch a fresh join ticket for one booking. Hand this to
|
|
86
|
+
* `<MediaRoomProvider>`, or use `<BookingCallProvider>` which does it for you.
|
|
87
|
+
*
|
|
88
|
+
* The identity of the caller is the SESSION's - the endpoint takes the booking
|
|
89
|
+
* id and nothing else, and answers 404 for a booking that is not yours rather
|
|
90
|
+
* than 403, which would confirm the id exists.
|
|
91
|
+
*/
|
|
92
|
+
export function useBookingSessionCredentials(bookingId: string): () => Promise<BookingSessionCredentials> {
|
|
93
|
+
const { client } = useForge();
|
|
94
|
+
|
|
95
|
+
// Stable across renders for a given booking: an inline arrow would be a new
|
|
96
|
+
// function on every parent render, and `<MediaRoomProvider>` holds its
|
|
97
|
+
// options in a ref precisely because that is the default mistake.
|
|
98
|
+
return useCallback(async () => {
|
|
99
|
+
try {
|
|
100
|
+
const res = await client.post(`/public/coaching/bookings/${bookingId}/session/join`, {});
|
|
101
|
+
return res.data as BookingSessionCredentials;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
throw credentialRefusal(error);
|
|
104
|
+
}
|
|
105
|
+
}, [client, bookingId]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The server's refusal, in the SDK's vocabulary, so the screen shows it.
|
|
110
|
+
*
|
|
111
|
+
* The join endpoint answers a person who belongs in the call with the REAL
|
|
112
|
+
* reason when it says no: the room opens in ten minutes, the session is not
|
|
113
|
+
* confirmed, the window has closed. Left as an axios error, that sentence never
|
|
114
|
+
* reached the screen: the SDK's `causeFromError` reads a non-`MediaError` as a
|
|
115
|
+
* dropped socket ("Request failed with status code 409"), and `callStatus`
|
|
116
|
+
* then says "Connection lost" about a call the person was never in. As a
|
|
117
|
+
* `MediaError` the cause is `refused`, which the screen renders as "Could not
|
|
118
|
+
* join this call" with the server's own sentence and a Try again. That is what
|
|
119
|
+
* every docblock on this surface promised and what the wire could not deliver.
|
|
120
|
+
*
|
|
121
|
+
* Only a response with a status is translated. A network failure (no
|
|
122
|
+
* response) really is a connection problem and keeps its own reading.
|
|
123
|
+
*/
|
|
124
|
+
function credentialRefusal(error: unknown): unknown {
|
|
125
|
+
const response = (error as { response?: { status?: number; data?: { message?: unknown } } } | null)?.response;
|
|
126
|
+
const status = response?.status;
|
|
127
|
+
if (typeof status !== "number") return error;
|
|
128
|
+
|
|
129
|
+
const message = typeof response?.data?.message === "string" ? response.data.message : undefined;
|
|
130
|
+
const code: MediaErrorCode =
|
|
131
|
+
status === 401 ? "unauthorized" : status === 404 ? "no_such_room" : status >= 500 ? "internal" : "bad_request";
|
|
132
|
+
return new MediaError(code, message ?? `${status}`, status >= 500);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export type BookingCallProviderProps = {
|
|
136
|
+
bookingId: string;
|
|
137
|
+
/** Rendered while the first connection is in flight. */
|
|
138
|
+
fallback?: ReactNode;
|
|
139
|
+
/**
|
|
140
|
+
* Follow the node's active speaker set automatically. On by default, and
|
|
141
|
+
* leaving it on is almost always right: a client that does not follow it
|
|
142
|
+
* shows black tiles in any room over the threshold.
|
|
143
|
+
*/
|
|
144
|
+
autoSubscribe?: boolean;
|
|
145
|
+
children: ReactNode;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The room for one booking, wired to the credential endpoint.
|
|
150
|
+
*
|
|
151
|
+
* Mount it only when the call should be live. The provider connects on mount
|
|
152
|
+
* and closes on unmount, so rendering it behind a "Join" control is what stops
|
|
153
|
+
* a portal page from opening a media connection for every session in the list.
|
|
154
|
+
*/
|
|
155
|
+
export function BookingCallProvider({
|
|
156
|
+
bookingId,
|
|
157
|
+
fallback,
|
|
158
|
+
autoSubscribe = true,
|
|
159
|
+
children,
|
|
160
|
+
}: BookingCallProviderProps): ReactNode {
|
|
161
|
+
const getCredentials = useBookingSessionCredentials(bookingId);
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<MediaRoomProvider getCredentials={getCredentials} autoSubscribe={autoSubscribe} fallback={fallback}>
|
|
165
|
+
{children}
|
|
166
|
+
</MediaRoomProvider>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The window logic lives in `bookingWindow.ts`, a module with NO media
|
|
172
|
+
* dependency, so `AccountDashboard` (in the `./ui` entry every site loads) can
|
|
173
|
+
* decide whether to DRAW a Join control without pulling the SDK into that
|
|
174
|
+
* bundle. Re-exported here so `@tribe-nest/forge/media` keeps its surface.
|
|
175
|
+
*/
|
|
176
|
+
export {
|
|
177
|
+
BOOKING_CALL_CLOSES_MINUTES_AFTER,
|
|
178
|
+
BOOKING_CALL_OPENS_MINUTES_BEFORE,
|
|
179
|
+
bookingCallWindow,
|
|
180
|
+
type BookingCallSubject,
|
|
181
|
+
type BookingCallWindow,
|
|
182
|
+
} from "./bookingWindow";
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a coaching session's video room is open, computed WITHOUT the media SDK.
|
|
3
|
+
*
|
|
4
|
+
* This module deliberately imports nothing from `@tribe-nest/media-client`.
|
|
5
|
+
* `AccountDashboard` lives in the `./ui` entry that every code website loads,
|
|
6
|
+
* and it needs to know whether to draw a Join control long before anybody
|
|
7
|
+
* presses it. Keeping the window pure means that decision costs the site
|
|
8
|
+
* nothing; the call UI itself is loaded on the click (see `BookingCallScreen`).
|
|
9
|
+
* `@tribe-nest/forge/media` re-exports everything here, so a site that already
|
|
10
|
+
* imports from that barrel sees no difference.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How early the room opens, and how long it stays open.
|
|
15
|
+
*
|
|
16
|
+
* A MIRROR of the server's `SESSION_JOIN_OPENS_MINUTES_BEFORE` and
|
|
17
|
+
* `SESSION_JOIN_CLOSES_MINUTES_AFTER` (`apps/backend/src/services/public/
|
|
18
|
+
* coaching/commands/bookingSession.ts`), and a mirror is the most this side
|
|
19
|
+
* can hold. The server's own `roomOpensAt` / `roomClosesAt` arrive only with a
|
|
20
|
+
* join ticket, and a ticket is only minted INSIDE the window, so the one moment
|
|
21
|
+
* a site needs "opens at" copy (before the window) is the one moment the
|
|
22
|
+
* server's timestamps are unreachable. These constants exist for that pre-join
|
|
23
|
+
* draw and nothing else: the decision is the server's, its refusal names the
|
|
24
|
+
* real window, and `<CallStage>` shows that refusal verbatim. Change the server
|
|
25
|
+
* and this pair together.
|
|
26
|
+
*/
|
|
27
|
+
export const BOOKING_CALL_OPENS_MINUTES_BEFORE = 15;
|
|
28
|
+
export const BOOKING_CALL_CLOSES_MINUTES_AFTER = 120;
|
|
29
|
+
|
|
30
|
+
const MINUTE = 60_000;
|
|
31
|
+
|
|
32
|
+
/** What a booking has to carry for the window to be computable. */
|
|
33
|
+
export type BookingCallSubject = {
|
|
34
|
+
status: string;
|
|
35
|
+
sessionStartTime: string;
|
|
36
|
+
sessionEndTime: string;
|
|
37
|
+
location?: { type: string } | null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type BookingCallWindow = {
|
|
41
|
+
/** Does this session happen on a platform video call at all? */
|
|
42
|
+
isVideo: boolean;
|
|
43
|
+
/** May a ticket be minted right now? Draw the Join control from this. */
|
|
44
|
+
isOpen: boolean;
|
|
45
|
+
/** Before the window, so the answer is "come back at". */
|
|
46
|
+
opensAt: Date;
|
|
47
|
+
/** After it the server refuses new tickets. */
|
|
48
|
+
closesAt: Date;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Is this session's room open, and when does it open?
|
|
53
|
+
*
|
|
54
|
+
* For DRAWING the control and nothing else. The server applies the same window
|
|
55
|
+
* and its refusal is the decision: a clock that is ten minutes fast must not be
|
|
56
|
+
* able to talk itself into a room, and a clock that is ten minutes slow must
|
|
57
|
+
* not hide a session that has already started. Show the server's refusal
|
|
58
|
+
* verbatim when a join fails rather than pre-judging it here.
|
|
59
|
+
*
|
|
60
|
+
* `now` is a parameter so a caller can drive it from a ticking value and get a
|
|
61
|
+
* re-render at the moment the window opens; a hook reading the clock itself
|
|
62
|
+
* would be stale until something else happened to re-render.
|
|
63
|
+
*/
|
|
64
|
+
export function bookingCallWindow(booking: BookingCallSubject, now: Date = new Date()): BookingCallWindow {
|
|
65
|
+
const startsAt = new Date(booking.sessionStartTime);
|
|
66
|
+
const endsAt = new Date(booking.sessionEndTime);
|
|
67
|
+
const opensAt = new Date(startsAt.getTime() - BOOKING_CALL_OPENS_MINUTES_BEFORE * MINUTE);
|
|
68
|
+
const closesAt = new Date(endsAt.getTime() + BOOKING_CALL_CLOSES_MINUTES_AFTER * MINUTE);
|
|
69
|
+
|
|
70
|
+
// A cancelled session has no room to enter, which is also why the server
|
|
71
|
+
// withholds `location` for anything that is not confirmed - so this reads
|
|
72
|
+
// `isVideo: false` for one, rather than offering a button that 409s.
|
|
73
|
+
const isVideo = booking.status === "confirmed" && booking.location?.type === "video";
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
isVideo,
|
|
77
|
+
isOpen: isVideo && now.getTime() >= opensAt.getTime() && now.getTime() <= closesAt.getTime(),
|
|
78
|
+
opensAt,
|
|
79
|
+
closesAt,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
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
|
+
/**
|
|
181
|
+
* The connection, as KEYS into Forge's bundles.
|
|
182
|
+
*
|
|
183
|
+
* `callStatus` is pure and has no locale, so it names copy rather than
|
|
184
|
+
* carrying it: `CallStage` puts the keys through `useForgeT()`, and a site
|
|
185
|
+
* laying out its own status line does the same. Returning English sentences
|
|
186
|
+
* from here was how the whole call screen shipped in one language on sites
|
|
187
|
+
* whose every other word was German.
|
|
188
|
+
*
|
|
189
|
+
* `detailText` is the exception, on purpose: it is the SERVER'S own sentence
|
|
190
|
+
* (a refusal message, a close reason) and is shown verbatim, never translated
|
|
191
|
+
* into a friendlier lie.
|
|
192
|
+
*/
|
|
193
|
+
export type CallStatus = {
|
|
194
|
+
tone: CallStatusTone;
|
|
195
|
+
/** Always shown. Connection state is CONTENT, never a help hint. `forge.call_stage.*`. */
|
|
196
|
+
headlineKey: string;
|
|
197
|
+
/** Our own second line, when there is one. `forge.call_stage.*`. */
|
|
198
|
+
detailKey?: string;
|
|
199
|
+
/** The server's own words when it has any. Shown verbatim. */
|
|
200
|
+
detailText?: string;
|
|
201
|
+
/** Is pressing something the right response? Only when waiting will not fix it. */
|
|
202
|
+
canRetry: boolean;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
export type CallStatusInput = {
|
|
206
|
+
connectionState: ConnectionState;
|
|
207
|
+
error: DisconnectCause | undefined;
|
|
208
|
+
/** The ROOM's own phase. `closed` means the node said so, on a live socket. */
|
|
209
|
+
phase?: RoomState["phase"];
|
|
210
|
+
closedReason?: string | null;
|
|
211
|
+
/**
|
|
212
|
+
* Has the SDK actually booked another attempt?
|
|
213
|
+
*
|
|
214
|
+
* `connectionState` is `reconnecting` both while one is in flight and after
|
|
215
|
+
* the policy has given up, so without this the screen promises a recovery
|
|
216
|
+
* that may not be happening. Defaults to false, which is the safe reading: it
|
|
217
|
+
* offers a control rather than a spinner.
|
|
218
|
+
*/
|
|
219
|
+
recovering?: boolean;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const ENDED = "forge.call_stage.status_ended";
|
|
223
|
+
const LEFT = "forge.call_stage.status_left";
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The room's states, told honestly.
|
|
227
|
+
*
|
|
228
|
+
* `reconnecting` is the room's state for BOTH "the socket dropped and we are
|
|
229
|
+
* coming back" and "the node refused us", because the signal client reports one
|
|
230
|
+
* close path. They are completely different things to a person sitting in front
|
|
231
|
+
* of it, and the cause is what separates them, so the cause is read here rather
|
|
232
|
+
* than being flattened into a spinner.
|
|
233
|
+
*
|
|
234
|
+
* `phase` is read as well as `connectionState`, and it is not redundant: a
|
|
235
|
+
* `roomClosed` frame arrives on a socket that is still open, so the connection
|
|
236
|
+
* is "connected" for as long as it takes the node to hang up. Reading only the
|
|
237
|
+
* connection would show "Connected" over a call that has ended, which is the
|
|
238
|
+
* one thing on this list a person would act on wrongly.
|
|
239
|
+
*
|
|
240
|
+
* `recovering` is read for the same reason and it is the other half of being
|
|
241
|
+
* honest. "Moving you to another server" and "Connection lost, reconnecting"
|
|
242
|
+
* are both PROMISES, and a promise only one side of the SDK is keeping is a
|
|
243
|
+
* spinner over a call that is finished: the person waits, because the screen
|
|
244
|
+
* told them to, and the way back into a paid hour turns out to be working out
|
|
245
|
+
* for themselves that they should reload the page. When nothing is coming, the
|
|
246
|
+
* words change and a control appears.
|
|
247
|
+
*/
|
|
248
|
+
export function callStatus(input: CallStatusInput): CallStatus {
|
|
249
|
+
const { connectionState, error } = input;
|
|
250
|
+
const recovering = input.recovering ?? false;
|
|
251
|
+
|
|
252
|
+
if (input.phase === "closed") {
|
|
253
|
+
return {
|
|
254
|
+
tone: "ended",
|
|
255
|
+
headlineKey: ENDED,
|
|
256
|
+
...(input.closedReason ? { detailText: input.closedReason } : {}),
|
|
257
|
+
canRetry: false,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (connectionState === "connected") {
|
|
262
|
+
return { tone: "live", headlineKey: "forge.call_stage.status_connected", canRetry: false };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (connectionState === "idle" || connectionState === "connecting") {
|
|
266
|
+
return { tone: "connecting", headlineKey: "forge.call_stage.status_connecting", canRetry: false };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (connectionState === "closed") {
|
|
270
|
+
if (error?.type === "room_closed") {
|
|
271
|
+
return { tone: "ended", headlineKey: ENDED, detailText: error.reason, canRetry: false };
|
|
272
|
+
}
|
|
273
|
+
return { tone: "ended", headlineKey: LEFT, canRetry: false };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Everything below is `reconnecting`, where the cause is the whole story.
|
|
277
|
+
switch (error?.type) {
|
|
278
|
+
case "refused":
|
|
279
|
+
return {
|
|
280
|
+
tone: "failed",
|
|
281
|
+
headlineKey: "forge.call_stage.status_refused",
|
|
282
|
+
detailText: error.message ?? error.code,
|
|
283
|
+
canRetry: true,
|
|
284
|
+
};
|
|
285
|
+
case "room_closed":
|
|
286
|
+
return { tone: "ended", headlineKey: ENDED, detailText: error.reason, canRetry: false };
|
|
287
|
+
case "draining":
|
|
288
|
+
// A drain is the node asking to be left, so the move is the SDK's job and
|
|
289
|
+
// a button would only get in its way - as long as the move is actually
|
|
290
|
+
// happening. When it is not, this is the worst screen on the list: an
|
|
291
|
+
// ordinary deploy would otherwise end every call on that node under a
|
|
292
|
+
// spinner saying nobody was dropped.
|
|
293
|
+
return recovering
|
|
294
|
+
? {
|
|
295
|
+
tone: "reconnecting",
|
|
296
|
+
headlineKey: "forge.call_stage.status_moving",
|
|
297
|
+
detailKey: "forge.call_stage.status_moving_detail",
|
|
298
|
+
canRetry: false,
|
|
299
|
+
}
|
|
300
|
+
: {
|
|
301
|
+
tone: "failed",
|
|
302
|
+
headlineKey: "forge.call_stage.status_move_failed",
|
|
303
|
+
detailKey: "forge.call_stage.status_move_failed_detail",
|
|
304
|
+
canRetry: true,
|
|
305
|
+
};
|
|
306
|
+
case "closed_by_client":
|
|
307
|
+
return { tone: "ended", headlineKey: LEFT, canRetry: false };
|
|
308
|
+
default:
|
|
309
|
+
return recovering
|
|
310
|
+
? {
|
|
311
|
+
tone: "reconnecting",
|
|
312
|
+
headlineKey: "forge.call_stage.status_reconnecting",
|
|
313
|
+
detailKey: "forge.call_stage.status_reconnecting_detail",
|
|
314
|
+
canRetry: true,
|
|
315
|
+
}
|
|
316
|
+
: {
|
|
317
|
+
tone: "failed",
|
|
318
|
+
headlineKey: "forge.call_stage.status_lost",
|
|
319
|
+
detailKey: "forge.call_stage.status_lost_detail",
|
|
320
|
+
canRetry: true,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* What a control publishes, in the vocabulary the SDK sends.
|
|
327
|
+
*
|
|
328
|
+
* The SOURCE, not the grant's kind, and the difference is the whole point:
|
|
329
|
+
* `room.publish(track, source)` puts one of these three words on the wire and
|
|
330
|
+
* the node decides against it. A control asking about "audio" would be asking
|
|
331
|
+
* a question no publish ever asks.
|
|
332
|
+
*
|
|
333
|
+
* Re-exported from the protocol rather than restated, because a second list of
|
|
334
|
+
* the same three words is a second list that can drift from the one the node
|
|
335
|
+
* reads.
|
|
336
|
+
*/
|
|
337
|
+
export type { PublishSource };
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Should this control be drawn at all?
|
|
341
|
+
*
|
|
342
|
+
* A UI hint and nothing more. The node checks the same grant on the publish
|
|
343
|
+
* itself and its answer is the one that counts, so hiding a button here is
|
|
344
|
+
* about not offering something that will be refused, never about enforcing
|
|
345
|
+
* anything. `publishKinds` absent means every kind, which is what the protocol
|
|
346
|
+
* says and what the node does.
|
|
347
|
+
*
|
|
348
|
+
* `mayPublish` is the protocol's own function, the same one the node calls, and
|
|
349
|
+
* that is deliberate rather than tidy. A grant is written in kinds ("audio",
|
|
350
|
+
* "video", "screen") and a publish declares a source ("microphone", "camera",
|
|
351
|
+
* "screen"), so a second copy of the translation living here is a second copy
|
|
352
|
+
* that can disagree with the node - and a button offered for a publish that is
|
|
353
|
+
* about to be refused is worse than no button at all.
|
|
354
|
+
*
|
|
355
|
+
* The track kind is implied by the source, because these three are the only
|
|
356
|
+
* sources this UI has: a microphone is audio and the other two are video.
|
|
357
|
+
*/
|
|
358
|
+
export function canPublishSource(grants: MediaGrants | undefined, source: PublishSource): boolean {
|
|
359
|
+
return mayPublish(grants, { source, trackKind: source === "microphone" ? "audio" : "video" });
|
|
360
|
+
}
|