@tribe-nest/forge 3.37.0 → 3.39.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.
@@ -0,0 +1,189 @@
1
+ import type { ConnectionState, DisconnectCause, ProducerEntry, RoomState } from "@tribe-nest/media-client";
2
+
3
+ /**
4
+ * Which stage a live broadcast plays on, as pure functions.
5
+ *
6
+ * A v2 broadcast can be watched two ways. The platform's own media plane
7
+ * carries it over WebRTC in well under a second, and HLS carries it to
8
+ * everybody else six to ten seconds behind. The plane is the better picture and
9
+ * it is also the one with a hard cap on it (`STREAM_V2_REALTIME_VIEWER_CAP`,
10
+ * 100 by default), so being turned away from it is an ORDINARY outcome rather
11
+ * than an error, and the viewer who is turned away must simply end up watching
12
+ * the stream.
13
+ *
14
+ * That is the whole reason this is a pure function in its own file. The choice
15
+ * is made from four independent signals arriving at four different times (what
16
+ * the broadcast advertises, what the token endpoint answered, what the SDK is
17
+ * doing, and whether the room is still open), and a rule assembled inline out
18
+ * of four `if`s inside a component is a rule nobody can test and everybody
19
+ * edits. `callState.ts` splits the call screen's decisions out for the same
20
+ * reason.
21
+ *
22
+ * ## The bias, stated once
23
+ *
24
+ * Every uncertain state resolves to `hls`. A viewer on HLS is watching the
25
+ * broadcast a few seconds late; a viewer left on a realtime stage that is not
26
+ * going to connect is watching a black rectangle. Those are not comparable
27
+ * failures, so the ambiguity is spent in the direction that always shows a
28
+ * picture.
29
+ */
30
+
31
+ /** `realtime` is the media plane. `hls` is the stream everybody can always watch. */
32
+ /**
33
+ * Which stage the player shows.
34
+ *
35
+ * `pending` is the state between "this broadcast offers realtime" and knowing
36
+ * whether this viewer gets it. It draws nothing on purpose: the alternative
37
+ * was showing HLS meanwhile, and an HLS engine starts fetching the moment it
38
+ * mounts, so a realtime viewer pulled segments they would never watch.
39
+ */
40
+ export type BroadcastStage = "realtime" | "hls" | "pending";
41
+
42
+ /**
43
+ * The viewer-token endpoint said no.
44
+ *
45
+ * `409` is the designed refusal (the room is full, realtime is off, or nothing
46
+ * is live) and it is not special-cased below: a refusal is a refusal, and a
47
+ * network failure with no status at all falls back exactly the same way. The
48
+ * status is carried so a caller can log which one happened, never so this
49
+ * function can treat one as recoverable.
50
+ */
51
+ export type BroadcastCredentialsError = {
52
+ /** The HTTP status when the server answered at all. Absent on a network failure. */
53
+ status?: number;
54
+ message?: string;
55
+ };
56
+
57
+ /** What the SDK says about the live room. Absent until the provider is mounted. */
58
+ export type BroadcastRoomState = {
59
+ connectionState: ConnectionState;
60
+ /** The ROOM's phase. `closed` means the node said so, on a socket still open. */
61
+ phase: RoomState["phase"];
62
+ /**
63
+ * Is another attempt actually booked?
64
+ *
65
+ * `connectionState` is `reconnecting` both while the SDK is coming back and
66
+ * after its policy has given up, so without this a broadcast whose room died
67
+ * would sit on a frozen frame for ever rather than falling back.
68
+ */
69
+ recovering: boolean;
70
+ error?: DisconnectCause;
71
+ };
72
+
73
+ export type BroadcastStageInput = {
74
+ /** `broadcast.realtime.available`, as the public read reported it. */
75
+ realtimeAvailable: boolean;
76
+ /** A viewer credential has been minted. Nothing is mounted before it has. */
77
+ credentialsReady: boolean;
78
+ /** The refusal, when the fetch failed. `null` while it has not. */
79
+ credentialsError: BroadcastCredentialsError | null;
80
+ roomState?: BroadcastRoomState;
81
+ };
82
+
83
+ /**
84
+ * Is the realtime attempt over, as opposed to merely in progress?
85
+ *
86
+ * A drop that the SDK is recovering from is NOT over: it holds the last frame
87
+ * for a second or two and comes back, and tearing the room down to swap in an
88
+ * HLS player would turn a blink into a restart. A drain is the same, as long as
89
+ * the move is really happening. Everything else on this list is finished:
90
+ * refused, the room closed, the socket closed for good, or the reconnect policy
91
+ * exhausted with nothing booked.
92
+ */
93
+ function realtimeIsOver(roomState: BroadcastRoomState | undefined): boolean {
94
+ if (!roomState) return false;
95
+ if (roomState.phase === "closed") return true;
96
+ if (roomState.connectionState === "closed") return true;
97
+ if (roomState.error?.type === "refused") return true;
98
+ if (roomState.error?.type === "room_closed") return true;
99
+ return roomState.connectionState === "reconnecting" && !roomState.recovering;
100
+ }
101
+
102
+ /**
103
+ * The stage to render right now.
104
+ *
105
+ * Read in order, and the order is the safety property: nothing reaches the
106
+ * realtime branch until the broadcast has offered it, a credential has actually
107
+ * been minted, and the room is still alive.
108
+ */
109
+ export function chooseBroadcastStage(input: BroadcastStageInput): BroadcastStage {
110
+ if (!input.realtimeAvailable) return "hls";
111
+ if (input.credentialsError) return "hls";
112
+ // PENDING, not "hls". Answering "hls" here mounted the HLS engine for the
113
+ // moment the ticket was in flight, and an HLS engine does not idle: it
114
+ // fetched the playlist and started pulling segments for a stage that was
115
+ // about to be replaced, so a realtime viewer downloaded the broadcast twice.
116
+ // Nothing is drawn until the answer is known.
117
+ if (!input.credentialsReady) return "pending";
118
+ if (realtimeIsOver(input.roomState)) return "hls";
119
+ return "realtime";
120
+ }
121
+
122
+ /**
123
+ * Did this viewer come DOWN to HLS, or were they always going to be here?
124
+ *
125
+ * The distinction is the whole content of the line the player shows. A
126
+ * broadcast that never offered realtime has nothing to explain, and a page that
127
+ * says "watching the standard stream" on every ordinary stream is a page that
128
+ * has taught its viewers to ignore the one time it matters.
129
+ *
130
+ * A token fetch still in flight is deliberately NOT a fallback. It resolves in
131
+ * a moment, and announcing a fallback that is about to be withdrawn is a line
132
+ * that flickers on every single load.
133
+ */
134
+ export function broadcastFellBack(input: BroadcastStageInput): boolean {
135
+ if (!input.realtimeAvailable) return false;
136
+ if (input.credentialsError) return true;
137
+ if (!input.credentialsReady) return false;
138
+ return realtimeIsOver(input.roomState);
139
+ }
140
+
141
+ /** The one program feed, split into the elements that carry it. */
142
+ export type BroadcastProgramTracks = {
143
+ /** Attach with `useRemoteTrack`. Absent means no video has arrived yet. */
144
+ videoProducerId?: string;
145
+ /** The publisher paused it at source. Their choice, not a failure. */
146
+ videoPaused: boolean;
147
+ /** The one program soundtrack. An array because it may legitimately be empty. */
148
+ audioProducerIds: readonly string[];
149
+ };
150
+
151
+ /**
152
+ * What to render out of the producers this viewer may actually receive.
153
+ *
154
+ * The live room holds exactly one publisher, `program:<broadcastId>`, with one
155
+ * video producer and one audio producer, and a viewer token's subscribe rule
156
+ * names that identity and nothing else. So there is no filtering to do here and
157
+ * deliberately none attempted: re-deriving the identity in the browser would
158
+ * give a second answer to a question the node has already answered, and the
159
+ * wrong answer is a black stage on a broadcast that is playing perfectly.
160
+ *
161
+ * The FIRST video is taken rather than the last. A studio that briefly
162
+ * republishes leaves two announced for a frame or two, and picking the newer
163
+ * one would swap the element's stream under a viewer mid-sentence.
164
+ *
165
+ * Audio is returned as a list rather than as one id because the cost of being
166
+ * wrong is asymmetric: a spare `<audio>` with no track attached does nothing at
167
+ * all, and a missed one is a broadcast with no sound.
168
+ */
169
+ export function programTracks(producers: readonly ProducerEntry[]): BroadcastProgramTracks {
170
+ const video = producers.find((producer) => producer.kind === "video");
171
+ const audio = producers.filter((producer) => producer.kind === "audio");
172
+ return {
173
+ ...(video ? { videoProducerId: video.producerId } : {}),
174
+ videoPaused: video?.paused ?? false,
175
+ // ONE, deliberately, not every audio producer in the room.
176
+ //
177
+ // A broadcast has a single soundtrack: the studio's mix, published once by
178
+ // the program feed. If the room ever holds two (a republish after a
179
+ // reconnect racing the old producer's teardown), playing both is never the
180
+ // right answer, so the newer publication wins and the stale one is dropped
181
+ // rather than mixed in.
182
+ //
183
+ // This is a guard, not the fix for the doubled audio that was reported
184
+ // during the build: THAT was the player mounting the HLS stage while the
185
+ // viewer ticket was still in flight, so the same broadcast arrived twice,
186
+ // seconds apart, over two transports. See `chooseBroadcastStage`.
187
+ audioProducerIds: audio.length > 0 ? [audio[audio.length - 1]!.producerId] : [],
188
+ };
189
+ }
@@ -0,0 +1,224 @@
1
+ import { useRef, useState } from "react";
2
+ import { FileText, Paperclip, Upload } from "lucide-react";
3
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
4
+ import {
5
+ formatWorkDate,
6
+ useUploadWorkDocument,
7
+ useWorkOpenSharedDocument,
8
+ useWorkSharedDocuments,
9
+ type WorkSharedDocument,
10
+ } from "../../headless/work/useWorkPortal";
11
+
12
+ export interface WorkProjectDocumentsProps {
13
+ /** The client project id. */
14
+ projectId: string;
15
+ /** Hide the upload control. The list still renders. Defaults to `true`. */
16
+ allowUpload?: boolean;
17
+ }
18
+
19
+ const humanSize = (size: number | null | undefined): string => {
20
+ if (!size || size <= 0) return "";
21
+ const units = ["B", "KB", "MB", "GB"];
22
+ let n = size;
23
+ let i = 0;
24
+ while (n >= 1024 && i < units.length - 1) {
25
+ n /= 1024;
26
+ i += 1;
27
+ }
28
+ return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
29
+ };
30
+
31
+ const errorMessage = (error: unknown): string =>
32
+ (error as { response?: { data?: { message?: string } } })?.response?.data?.message ||
33
+ "The upload didn't go through. Please try again.";
34
+
35
+ /**
36
+ * The documents on a client's project: what the firm shared with them, and
37
+ * what they sent in themselves, with an upload control for the latter.
38
+ *
39
+ * A file opens through a short-lived signed read fetched on the click, never
40
+ * from an address held in the page. Built on `useWorkSharedDocuments`,
41
+ * `useWorkOpenSharedDocument` and `useUploadWorkDocument`; theme tokens only.
42
+ *
43
+ * Unlike the invoices block this renders even when the list is empty, because
44
+ * the empty state is where the upload control lives.
45
+ */
46
+ export function WorkProjectDocuments({ projectId, allowUpload = true }: WorkProjectDocumentsProps) {
47
+ const t = useThemeTokens();
48
+ const { data: documents, isLoading } = useWorkSharedDocuments(projectId);
49
+ const open = useWorkOpenSharedDocument(projectId);
50
+ const upload = useUploadWorkDocument(projectId);
51
+ const fileInputRef = useRef<HTMLInputElement>(null);
52
+ const [error, setError] = useState<string | null>(null);
53
+ const [opening, setOpening] = useState<string | null>(null);
54
+
55
+ if (isLoading) return null;
56
+
57
+ const onFiles = async (files: FileList | null) => {
58
+ if (!files || files.length === 0) return;
59
+ setError(null);
60
+ // One at a time, in order: each is its own signed PUT and its own row, and
61
+ // a failure names the file that failed rather than the whole batch.
62
+ for (const file of Array.from(files)) {
63
+ try {
64
+ await upload.mutateAsync({ file });
65
+ } catch (err) {
66
+ setError(`${file.name}: ${errorMessage(err)}`);
67
+ return;
68
+ }
69
+ }
70
+ };
71
+
72
+ const openDocument = async (doc: WorkSharedDocument) => {
73
+ if (!doc.file) return;
74
+ setOpening(doc.id);
75
+ try {
76
+ const { url } = await open.mutateAsync(doc.id);
77
+ window.open(url, "_blank", "noopener");
78
+ } catch (err) {
79
+ setError(errorMessage(err));
80
+ } finally {
81
+ setOpening(null);
82
+ }
83
+ };
84
+
85
+ const list = documents ?? [];
86
+
87
+ return (
88
+ <div
89
+ data-testid="work-project-documents"
90
+ style={{
91
+ border: `1px solid ${t.border}`,
92
+ borderRadius: t.cornerRadius,
93
+ background: t.surface,
94
+ padding: 20,
95
+ color: t.text,
96
+ fontFamily: t.fontFamily,
97
+ }}
98
+ >
99
+ <div
100
+ style={{
101
+ display: "flex",
102
+ alignItems: "center",
103
+ justifyContent: "space-between",
104
+ gap: 12,
105
+ flexWrap: "wrap",
106
+ marginBottom: 14,
107
+ }}
108
+ >
109
+ <h2 style={{ fontSize: 16, fontWeight: 800, margin: 0 }}>Documents</h2>
110
+ {allowUpload && (
111
+ <>
112
+ <input
113
+ ref={fileInputRef}
114
+ type="file"
115
+ multiple
116
+ hidden
117
+ data-testid="work-document-file-input"
118
+ onChange={(e) => {
119
+ void onFiles(e.target.files);
120
+ e.target.value = "";
121
+ }}
122
+ />
123
+ <button
124
+ type="button"
125
+ data-testid="work-document-upload"
126
+ onClick={() => fileInputRef.current?.click()}
127
+ disabled={upload.isPending}
128
+ style={{
129
+ display: "inline-flex",
130
+ alignItems: "center",
131
+ gap: 8,
132
+ padding: "8px 16px",
133
+ borderRadius: t.cornerRadius,
134
+ border: "none",
135
+ background: t.primary,
136
+ color: t.textPrimary,
137
+ fontWeight: 700,
138
+ fontSize: 13,
139
+ cursor: upload.isPending ? "wait" : "pointer",
140
+ opacity: upload.isPending ? 0.7 : 1,
141
+ }}
142
+ >
143
+ <Upload size={14} />
144
+ {upload.isPending ? "Uploading…" : "Upload a document"}
145
+ </button>
146
+ </>
147
+ )}
148
+ </div>
149
+
150
+ {error && (
151
+ <p
152
+ data-testid="work-document-error"
153
+ role="alert"
154
+ style={{ margin: "0 0 12px", fontSize: 13, color: "#ef4444" }}
155
+ >
156
+ {error}
157
+ </p>
158
+ )}
159
+
160
+ {list.length === 0 ? (
161
+ <p data-testid="work-project-documents-empty" style={{ margin: 0, fontSize: 13, color: t.muted }}>
162
+ {allowUpload
163
+ ? "Nothing here yet. Upload a document to add it to this project."
164
+ : "Nothing has been shared with you yet."}
165
+ </p>
166
+ ) : (
167
+ <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 }}>
168
+ {list.map((doc) => (
169
+ <li
170
+ key={doc.id}
171
+ data-testid={`work-document-${doc.id}`}
172
+ style={{
173
+ display: "flex",
174
+ alignItems: "center",
175
+ justifyContent: "space-between",
176
+ gap: 12,
177
+ flexWrap: "wrap",
178
+ padding: "10px 14px",
179
+ border: `1px solid ${t.border}`,
180
+ borderRadius: t.cornerRadius,
181
+ }}
182
+ >
183
+ <div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
184
+ {doc.file ? (
185
+ <Paperclip size={14} style={{ color: t.muted, flexShrink: 0 }} />
186
+ ) : (
187
+ <FileText size={14} style={{ color: t.muted, flexShrink: 0 }} />
188
+ )}
189
+ <div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
190
+ <span style={{ fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
191
+ {doc.title}
192
+ </span>
193
+ <span style={{ fontSize: 12, color: t.muted }}>
194
+ {formatWorkDate(doc.sharedAt)}
195
+ {doc.file?.size ? ` · ${humanSize(doc.file.size)}` : ""}
196
+ </span>
197
+ </div>
198
+ </div>
199
+ {doc.file && (
200
+ <button
201
+ type="button"
202
+ data-testid={`work-document-open-${doc.id}`}
203
+ onClick={() => void openDocument(doc)}
204
+ disabled={opening === doc.id}
205
+ style={{
206
+ background: "transparent",
207
+ border: "none",
208
+ padding: 0,
209
+ color: t.primary,
210
+ fontSize: 13,
211
+ textDecoration: "underline",
212
+ cursor: opening === doc.id ? "wait" : "pointer",
213
+ }}
214
+ >
215
+ {opening === doc.id ? "Opening…" : "Open"}
216
+ </button>
217
+ )}
218
+ </li>
219
+ ))}
220
+ </ul>
221
+ )}
222
+ </div>
223
+ );
224
+ }
@@ -3,6 +3,7 @@ import { Loading } from "../Loading";
3
3
  import { useWorkProjectReport } from "../../headless/work/useWorkPortal";
4
4
  import { WorkReportBody } from "./WorkReportBody";
5
5
  import { WorkProjectInvoices } from "./WorkProjectInvoices";
6
+ import { WorkProjectDocuments } from "./WorkProjectDocuments";
6
7
 
7
8
  export interface WorkProjectReportProps {
8
9
  /** The client project id (from the route). */
@@ -17,7 +18,7 @@ export interface WorkProjectReportProps {
17
18
  }
18
19
 
19
20
  /**
20
- * The authenticated client's read-only project report — milestones, task list
21
+ * The authenticated client's read-only project report: milestones, task list
21
22
  * and the billable-only time report. Each task links to its detail/comments
22
23
  * page. Built on `useWorkProjectReport`; theme tokens only.
23
24
  */
@@ -55,11 +56,12 @@ export function WorkProjectReport({ projectId, taskHref, onOpenTask }: WorkProje
55
56
  ? undefined
56
57
  : (taskHref ?? ((taskId: string) => `/i/work/projects/${projectId}/tasks/${taskId}`));
57
58
 
58
- // The invoices section is authed-only (not part of the login-free token
59
- // share, which renders WorkReportBody directly).
59
+ // The documents and invoices sections are authed-only (not part of the
60
+ // login-free token share, which renders WorkReportBody directly).
60
61
  return (
61
62
  <div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
62
63
  <WorkReportBody report={report} taskHref={resolvedTaskHref} onOpenTask={onOpenTask} />
64
+ <WorkProjectDocuments projectId={projectId} />
63
65
  <WorkProjectInvoices projectId={projectId} />
64
66
  </div>
65
67
  );
@@ -8,3 +8,4 @@ export { WorkTaskAttachments, type WorkTaskAttachmentsProps } from "./WorkTaskAt
8
8
  export { WorkTaskDetail, type WorkTaskDetailProps } from "./WorkTaskDetail";
9
9
  export { WorkInviteAccept, type WorkInviteAcceptProps } from "./WorkInviteAccept";
10
10
  export { WorkProjectInvoices, type WorkProjectInvoicesProps } from "./WorkProjectInvoices";
11
+ export { WorkProjectDocuments, type WorkProjectDocumentsProps } from "./WorkProjectDocuments";