@tribe-nest/forge 3.58.0 → 3.60.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 +1 -1
- package/src/_tests/testCommunity.tsx +28 -0
- package/src/contexts/CommunityContext.tsx +192 -0
- package/src/data/queries/useChat.ts +42 -39
- package/src/data/queries/useCommunities.ts +343 -0
- package/src/data/queries/useCommunity.ts +93 -89
- package/src/data/queries/useMembership.ts +32 -7
- package/src/data/queries/useMembershipCalls.ts +116 -7
- package/src/data/queries/usePostCollections.ts +28 -16
- package/src/data/queries/usePosts.ts +16 -14
- package/src/data/queries/useSubscriptions.ts +22 -8
- package/src/i18n/de.json +145 -1
- package/src/i18n/en.json +145 -1
- package/src/index.ts +19 -7
- package/src/runtime/pages.spec.ts +31 -0
- package/src/runtime/pages.ts +21 -22
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
- package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
- package/src/ui/format/membershipGate.ts +19 -15
- package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
- package/src/ui/headless/_tests/communityHarness.tsx +205 -0
- package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
- package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
- package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +382 -0
- package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +151 -0
- package/src/ui/headless/_tests/useCommunityReads.spec.tsx +286 -0
- package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
- package/src/ui/headless/auth/useSignupForm.ts +10 -2
- package/src/ui/headless/communities/CommunityGate.tsx +67 -0
- package/src/ui/headless/communities/index.ts +55 -0
- package/src/ui/headless/communities/paths.ts +36 -0
- package/src/ui/headless/communities/useCommunity.ts +140 -0
- package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
- package/src/ui/headless/communities/useCommunityHome.ts +88 -0
- package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
- package/src/ui/headless/communities/useCommunityJoin.ts +444 -0
- package/src/ui/headless/communities/useCommunityMembership.ts +136 -0
- package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
- package/src/ui/headless/communities/useMyMemberships.ts +59 -0
- package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
- package/src/ui/headless/index.ts +8 -4
- package/src/ui/headless/memberHome/index.ts +1 -1
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +44 -42
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
- package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
- package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
- package/src/ui/headless/posts/upsell.ts +54 -8
- package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
- package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
- package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
- package/src/ui/headless/posts/usePostItem.ts +6 -5
- package/src/ui/index.ts +17 -12
- package/src/ui/styled/AccountDashboard.tsx +48 -256
- package/src/ui/styled/MembershipCheckout.tsx +37 -17
- package/src/ui/styled/MembershipTierCallout.tsx +20 -1
- package/src/ui/styled/MembershipTiers.tsx +49 -11
- package/src/ui/styled/Paywall.tsx +1 -1
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +1 -1
- package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
- package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
- package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
- package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
- package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
- package/src/ui/styled/communities/CommunityAbout.tsx +140 -0
- package/src/ui/styled/communities/CommunityHome.tsx +175 -0
- package/src/ui/styled/communities/CommunityJoinSheet.tsx +444 -0
- package/src/ui/styled/communities/CommunityMembershipMenu.tsx +133 -0
- package/src/ui/styled/communities/CommunityPending.tsx +49 -0
- package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
- package/src/ui/styled/communities/MyMembershipsList.tsx +89 -0
- package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
- package/src/ui/styled/communities/index.ts +10 -0
- package/src/ui/styled/communities/shared.tsx +149 -0
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
- package/src/ui/styled/forge-utilities.css +19 -0
- package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +142 -10
- package/src/ui/styled/members/MemberHomeLayout.tsx +35 -17
- package/src/ui/styled/members/MemberPostCard.tsx +9 -5
- package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
- package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
- package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
- package/src/ui/styled/members/callTranscriptText.ts +67 -0
- package/src/utils/_tests/safeRedirect.spec.ts +2 -2
- package/src/ui/headless/membership/MembershipGate.tsx +0 -44
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { Download, Search, X } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import { useThemeTokens } from "../../theme/ForgeThemeProvider";
|
|
5
|
+
import { useMeasuredWidth } from "../../media/hooks/useMeasuredWidth";
|
|
6
|
+
import { useMemberCallTranscript, type MemberCallTranscriptSegment } from "../../../data/queries/useMembershipCalls";
|
|
7
|
+
import { Loading } from "../Loading";
|
|
8
|
+
import { activeSegmentIndex, downloadTranscript, formatOffset } from "./callTranscriptText";
|
|
9
|
+
|
|
10
|
+
/** How long after the member scrolls the transcript themselves it stops following playback. */
|
|
11
|
+
const FOLLOW_PAUSE_MS = 4_000;
|
|
12
|
+
/** Below this width the video sits above the transcript instead of beside it. */
|
|
13
|
+
const STACK_BELOW_PX = 860;
|
|
14
|
+
|
|
15
|
+
/** `text` with every case-insensitive match of `term` highlighted. */
|
|
16
|
+
function highlight(text: string, term: string, color: string): ReactNode {
|
|
17
|
+
if (!term) return text;
|
|
18
|
+
const lower = text.toLowerCase();
|
|
19
|
+
const needle = term.toLowerCase();
|
|
20
|
+
const out: ReactNode[] = [];
|
|
21
|
+
let from = 0;
|
|
22
|
+
for (let at = lower.indexOf(needle); at !== -1; at = lower.indexOf(needle, from)) {
|
|
23
|
+
out.push(
|
|
24
|
+
text.slice(from, at),
|
|
25
|
+
<mark key={at} style={{ background: color, color: "inherit", borderRadius: 2 }}>
|
|
26
|
+
{text.slice(at, at + needle.length)}
|
|
27
|
+
</mark>,
|
|
28
|
+
);
|
|
29
|
+
from = at + needle.length;
|
|
30
|
+
}
|
|
31
|
+
out.push(text.slice(from));
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A recorded call's transcript beside its video, full-screen.
|
|
37
|
+
*
|
|
38
|
+
* A viewer rather than a route: members' calls render on hosted `/i/` pages,
|
|
39
|
+
* on code-built sites and in the client app, and an overlay works on all of
|
|
40
|
+
* them without a new page on each. The same layout as the creator's transcript
|
|
41
|
+
* page: the video plays on one side, the line being spoken highlights and
|
|
42
|
+
* follows along, clicking a line seeks, and there is a search and a Summary
|
|
43
|
+
* tab.
|
|
44
|
+
*
|
|
45
|
+
* The video address is signed for sixty seconds on purpose. When playback
|
|
46
|
+
* fails after that, the viewer fetches a fresh one and resumes where the member
|
|
47
|
+
* was.
|
|
48
|
+
*/
|
|
49
|
+
export function CallTranscriptViewer({ recordingId, onClose }: { recordingId: string; onClose: () => void }) {
|
|
50
|
+
const t = useThemeTokens();
|
|
51
|
+
const query = useMemberCallTranscript(recordingId);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const onKey = (event: KeyboardEvent) => event.key === "Escape" && onClose();
|
|
55
|
+
window.addEventListener("keydown", onKey);
|
|
56
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
57
|
+
}, [onClose]);
|
|
58
|
+
|
|
59
|
+
const shell: CSSProperties = {
|
|
60
|
+
position: "fixed",
|
|
61
|
+
inset: 0,
|
|
62
|
+
zIndex: 1001,
|
|
63
|
+
display: "flex",
|
|
64
|
+
flexDirection: "column",
|
|
65
|
+
background: t.background,
|
|
66
|
+
color: t.text,
|
|
67
|
+
fontFamily: t.fontFamily,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (query.isLoading) {
|
|
71
|
+
return (
|
|
72
|
+
<div role="dialog" aria-modal="true" aria-label="Transcript" style={shell}>
|
|
73
|
+
<Loading />
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
if (query.error || !query.data) {
|
|
78
|
+
return (
|
|
79
|
+
<div role="dialog" aria-modal="true" aria-label="Transcript" style={{ ...shell, padding: 24, gap: 12 }}>
|
|
80
|
+
<p style={{ margin: 0, color: t.muted }}>We could not open this transcript just now.</p>
|
|
81
|
+
<button type="button" onClick={onClose} style={buttonStyle(t)}>
|
|
82
|
+
Close
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return <ViewerBody detail={query.data} refetch={query.refetch} onClose={onClose} shell={shell} />;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type Tokens = ReturnType<typeof useThemeTokens>;
|
|
92
|
+
|
|
93
|
+
function buttonStyle(t: Tokens): CSSProperties {
|
|
94
|
+
return {
|
|
95
|
+
display: "inline-flex",
|
|
96
|
+
alignItems: "center",
|
|
97
|
+
gap: 6,
|
|
98
|
+
padding: "8px 14px",
|
|
99
|
+
borderRadius: t.cornerRadius,
|
|
100
|
+
border: `1px solid ${t.border}`,
|
|
101
|
+
background: "transparent",
|
|
102
|
+
color: t.text,
|
|
103
|
+
fontFamily: t.fontFamily,
|
|
104
|
+
fontSize: 14,
|
|
105
|
+
fontWeight: 600,
|
|
106
|
+
cursor: "pointer",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function ViewerBody({
|
|
111
|
+
detail,
|
|
112
|
+
refetch,
|
|
113
|
+
onClose,
|
|
114
|
+
shell,
|
|
115
|
+
}: {
|
|
116
|
+
detail: NonNullable<ReturnType<typeof useMemberCallTranscript>["data"]>;
|
|
117
|
+
refetch: ReturnType<typeof useMemberCallTranscript>["refetch"];
|
|
118
|
+
onClose: () => void;
|
|
119
|
+
shell: CSSProperties;
|
|
120
|
+
}) {
|
|
121
|
+
const t = useThemeTokens();
|
|
122
|
+
const { segments, summary } = detail.transcript;
|
|
123
|
+
const [bodyRef, width] = useMeasuredWidth<HTMLDivElement>();
|
|
124
|
+
const stacked = width > 0 && width < STACK_BELOW_PX;
|
|
125
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
126
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
127
|
+
const [watchUrl, setWatchUrl] = useState(detail.watchUrl);
|
|
128
|
+
const [currentMs, setCurrentMs] = useState(0);
|
|
129
|
+
const [term, setTerm] = useState("");
|
|
130
|
+
const [tab, setTab] = useState<"summary" | "transcript">(summary ? "summary" : "transcript");
|
|
131
|
+
const lastManualScroll = useRef(0);
|
|
132
|
+
const resume = useRef<{ atSec: number; play: boolean } | null>(null);
|
|
133
|
+
const lastRefresh = useRef(0);
|
|
134
|
+
|
|
135
|
+
const active = activeSegmentIndex(segments, currentMs);
|
|
136
|
+
const needle = term.trim();
|
|
137
|
+
const matches = useMemo(() => {
|
|
138
|
+
const lower = needle.toLowerCase();
|
|
139
|
+
if (!lower) return null;
|
|
140
|
+
return segments
|
|
141
|
+
.map((segment, index) => ({ segment, index }))
|
|
142
|
+
.filter(({ segment }) => `${segment.name} ${segment.text}`.toLowerCase().includes(lower));
|
|
143
|
+
}, [segments, needle]);
|
|
144
|
+
const shown = matches ?? segments.map((segment, index) => ({ segment, index }));
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (tab !== "transcript" || matches || active < 0) return;
|
|
148
|
+
if (Date.now() - lastManualScroll.current < FOLLOW_PAUSE_MS) return;
|
|
149
|
+
listRef.current
|
|
150
|
+
?.querySelector<HTMLElement>(`[data-index="${active}"]`)
|
|
151
|
+
?.scrollIntoView?.({ block: "nearest", behavior: "smooth" });
|
|
152
|
+
}, [active, matches, tab]);
|
|
153
|
+
|
|
154
|
+
const recover = async () => {
|
|
155
|
+
const video = videoRef.current;
|
|
156
|
+
if (!video || Date.now() - lastRefresh.current < 5_000) return;
|
|
157
|
+
lastRefresh.current = Date.now();
|
|
158
|
+
resume.current = { atSec: video.currentTime, play: !video.paused || video.seeking };
|
|
159
|
+
const fresh = await refetch();
|
|
160
|
+
if (fresh.data?.watchUrl) setWatchUrl(fresh.data.watchUrl);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const seek = (segment: MemberCallTranscriptSegment) => {
|
|
164
|
+
const video = videoRef.current;
|
|
165
|
+
if (!video) return;
|
|
166
|
+
video.currentTime = segment.startMs / 1000;
|
|
167
|
+
setCurrentMs(segment.startMs);
|
|
168
|
+
void video.play()?.catch(() => undefined);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const tabStyle = (selected: boolean): CSSProperties => ({
|
|
172
|
+
padding: "6px 12px",
|
|
173
|
+
border: "none",
|
|
174
|
+
borderBottom: `2px solid ${selected ? t.primary : "transparent"}`,
|
|
175
|
+
background: "transparent",
|
|
176
|
+
color: selected ? t.text : t.muted,
|
|
177
|
+
fontFamily: t.fontFamily,
|
|
178
|
+
fontSize: 14,
|
|
179
|
+
fontWeight: 600,
|
|
180
|
+
cursor: "pointer",
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<div role="dialog" aria-modal="true" aria-label={`Transcript: ${detail.title}`} style={shell}>
|
|
185
|
+
<header
|
|
186
|
+
style={{
|
|
187
|
+
display: "flex",
|
|
188
|
+
alignItems: "center",
|
|
189
|
+
gap: 12,
|
|
190
|
+
padding: "12px 16px",
|
|
191
|
+
borderBottom: `1px solid ${t.border}`,
|
|
192
|
+
}}
|
|
193
|
+
>
|
|
194
|
+
<div style={{ minWidth: 0, flex: 1 }}>
|
|
195
|
+
<h2
|
|
196
|
+
style={{
|
|
197
|
+
margin: 0,
|
|
198
|
+
fontSize: 17,
|
|
199
|
+
fontWeight: 700,
|
|
200
|
+
whiteSpace: "nowrap",
|
|
201
|
+
overflow: "hidden",
|
|
202
|
+
textOverflow: "ellipsis",
|
|
203
|
+
}}
|
|
204
|
+
>
|
|
205
|
+
{detail.title}
|
|
206
|
+
</h2>
|
|
207
|
+
<span style={{ fontSize: 13, color: t.muted }}>
|
|
208
|
+
{new Date(detail.startsAt).toLocaleString("en-US", {
|
|
209
|
+
dateStyle: "long",
|
|
210
|
+
timeStyle: "short",
|
|
211
|
+
timeZone: detail.timezone || undefined,
|
|
212
|
+
})}
|
|
213
|
+
</span>
|
|
214
|
+
</div>
|
|
215
|
+
<button type="button" onClick={() => downloadTranscript(detail)} style={buttonStyle(t)}>
|
|
216
|
+
<Download size={15} />
|
|
217
|
+
Download
|
|
218
|
+
</button>
|
|
219
|
+
<button
|
|
220
|
+
type="button"
|
|
221
|
+
onClick={onClose}
|
|
222
|
+
aria-label="Close"
|
|
223
|
+
style={{ background: "transparent", border: "none", cursor: "pointer", color: t.muted, padding: 4 }}
|
|
224
|
+
>
|
|
225
|
+
<X size={20} />
|
|
226
|
+
</button>
|
|
227
|
+
</header>
|
|
228
|
+
|
|
229
|
+
<div
|
|
230
|
+
ref={bodyRef}
|
|
231
|
+
style={{
|
|
232
|
+
flex: 1,
|
|
233
|
+
minHeight: 0,
|
|
234
|
+
display: "flex",
|
|
235
|
+
flexDirection: stacked ? "column" : "row",
|
|
236
|
+
gap: 16,
|
|
237
|
+
padding: 16,
|
|
238
|
+
overflow: stacked ? "auto" : "hidden",
|
|
239
|
+
}}
|
|
240
|
+
>
|
|
241
|
+
<div style={{ flex: stacked ? "none" : 3, minWidth: 0 }}>
|
|
242
|
+
{watchUrl ? (
|
|
243
|
+
<video
|
|
244
|
+
ref={videoRef}
|
|
245
|
+
src={watchUrl}
|
|
246
|
+
controls
|
|
247
|
+
preload="metadata"
|
|
248
|
+
data-testid="member-transcript-video"
|
|
249
|
+
style={{ width: "100%", aspectRatio: "16 / 9", background: "#000", borderRadius: t.cornerRadius }}
|
|
250
|
+
onTimeUpdate={(event) => setCurrentMs(event.currentTarget.currentTime * 1000)}
|
|
251
|
+
onError={() => void recover()}
|
|
252
|
+
onLoadedMetadata={(event) => {
|
|
253
|
+
const pending = resume.current;
|
|
254
|
+
if (!pending) return;
|
|
255
|
+
resume.current = null;
|
|
256
|
+
event.currentTarget.currentTime = pending.atSec;
|
|
257
|
+
if (pending.play) void event.currentTarget.play()?.catch(() => undefined);
|
|
258
|
+
}}
|
|
259
|
+
/>
|
|
260
|
+
) : (
|
|
261
|
+
<div
|
|
262
|
+
style={{
|
|
263
|
+
width: "100%",
|
|
264
|
+
aspectRatio: "16 / 9",
|
|
265
|
+
display: "flex",
|
|
266
|
+
alignItems: "center",
|
|
267
|
+
justifyContent: "center",
|
|
268
|
+
border: `1px solid ${t.border}`,
|
|
269
|
+
borderRadius: t.cornerRadius,
|
|
270
|
+
color: t.muted,
|
|
271
|
+
fontSize: 14,
|
|
272
|
+
}}
|
|
273
|
+
>
|
|
274
|
+
The video is not available.
|
|
275
|
+
</div>
|
|
276
|
+
)}
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<div
|
|
280
|
+
style={{
|
|
281
|
+
flex: stacked ? "none" : 2,
|
|
282
|
+
minWidth: 0,
|
|
283
|
+
minHeight: 0,
|
|
284
|
+
display: "flex",
|
|
285
|
+
flexDirection: "column",
|
|
286
|
+
gap: 10,
|
|
287
|
+
}}
|
|
288
|
+
>
|
|
289
|
+
<div role="tablist" style={{ display: "flex", gap: 4, borderBottom: `1px solid ${t.border}` }}>
|
|
290
|
+
<button
|
|
291
|
+
type="button"
|
|
292
|
+
role="tab"
|
|
293
|
+
aria-selected={tab === "summary"}
|
|
294
|
+
onClick={() => setTab("summary")}
|
|
295
|
+
style={tabStyle(tab === "summary")}
|
|
296
|
+
>
|
|
297
|
+
Summary
|
|
298
|
+
</button>
|
|
299
|
+
<button
|
|
300
|
+
type="button"
|
|
301
|
+
role="tab"
|
|
302
|
+
aria-selected={tab === "transcript"}
|
|
303
|
+
onClick={() => setTab("transcript")}
|
|
304
|
+
style={tabStyle(tab === "transcript")}
|
|
305
|
+
>
|
|
306
|
+
Transcript
|
|
307
|
+
</button>
|
|
308
|
+
</div>
|
|
309
|
+
|
|
310
|
+
{tab === "summary" ? (
|
|
311
|
+
<div
|
|
312
|
+
style={{
|
|
313
|
+
display: "flex",
|
|
314
|
+
flexDirection: "column",
|
|
315
|
+
gap: 12,
|
|
316
|
+
fontSize: 14,
|
|
317
|
+
lineHeight: 1.5,
|
|
318
|
+
overflowY: "auto",
|
|
319
|
+
}}
|
|
320
|
+
>
|
|
321
|
+
{summary ? (
|
|
322
|
+
<>
|
|
323
|
+
<p style={{ margin: 0 }}>{summary.summary}</p>
|
|
324
|
+
{[
|
|
325
|
+
["Decisions", summary.decisions],
|
|
326
|
+
["Action items", summary.actionItems],
|
|
327
|
+
].map(
|
|
328
|
+
([label, items]) =>
|
|
329
|
+
(items as string[]).length > 0 && (
|
|
330
|
+
<div key={label as string}>
|
|
331
|
+
<span style={{ fontWeight: 600 }}>{label as string}</span>
|
|
332
|
+
<ul style={{ margin: "4px 0 0", paddingLeft: 20 }}>
|
|
333
|
+
{(items as string[]).map((item, i) => (
|
|
334
|
+
<li key={i}>{item}</li>
|
|
335
|
+
))}
|
|
336
|
+
</ul>
|
|
337
|
+
</div>
|
|
338
|
+
),
|
|
339
|
+
)}
|
|
340
|
+
</>
|
|
341
|
+
) : (
|
|
342
|
+
<p style={{ margin: 0, color: t.muted }}>There is no summary for this call.</p>
|
|
343
|
+
)}
|
|
344
|
+
</div>
|
|
345
|
+
) : (
|
|
346
|
+
<>
|
|
347
|
+
<label style={{ position: "relative", display: "block" }}>
|
|
348
|
+
<Search size={15} style={{ position: "absolute", left: 10, top: 10, color: t.muted }} />
|
|
349
|
+
<input
|
|
350
|
+
value={term}
|
|
351
|
+
onChange={(event) => setTerm(event.target.value)}
|
|
352
|
+
placeholder="Search the transcript"
|
|
353
|
+
aria-label="Search the transcript"
|
|
354
|
+
style={{
|
|
355
|
+
width: "100%",
|
|
356
|
+
boxSizing: "border-box",
|
|
357
|
+
padding: "8px 10px 8px 32px",
|
|
358
|
+
borderRadius: t.cornerRadius,
|
|
359
|
+
border: `1px solid ${t.border}`,
|
|
360
|
+
background: t.surface,
|
|
361
|
+
color: t.text,
|
|
362
|
+
fontFamily: t.fontFamily,
|
|
363
|
+
fontSize: 14,
|
|
364
|
+
}}
|
|
365
|
+
/>
|
|
366
|
+
</label>
|
|
367
|
+
{matches && (
|
|
368
|
+
<span role="status" style={{ fontSize: 12, color: t.muted }}>
|
|
369
|
+
{matches.length === 1 ? "1 match" : `${matches.length} matches`}
|
|
370
|
+
</span>
|
|
371
|
+
)}
|
|
372
|
+
<div
|
|
373
|
+
ref={listRef}
|
|
374
|
+
data-testid="member-transcript-lines"
|
|
375
|
+
onWheel={() => (lastManualScroll.current = Date.now())}
|
|
376
|
+
onTouchMove={() => (lastManualScroll.current = Date.now())}
|
|
377
|
+
style={{
|
|
378
|
+
flex: 1,
|
|
379
|
+
minHeight: stacked ? 320 : 0,
|
|
380
|
+
maxHeight: stacked ? 480 : undefined,
|
|
381
|
+
overflowY: "auto",
|
|
382
|
+
border: `1px solid ${t.border}`,
|
|
383
|
+
borderRadius: t.cornerRadius,
|
|
384
|
+
}}
|
|
385
|
+
>
|
|
386
|
+
{shown.map(({ segment, index }) => (
|
|
387
|
+
<button
|
|
388
|
+
type="button"
|
|
389
|
+
key={index}
|
|
390
|
+
data-index={index}
|
|
391
|
+
aria-current={index === active ? "true" : undefined}
|
|
392
|
+
onClick={() => seek(segment)}
|
|
393
|
+
style={{
|
|
394
|
+
display: "flex",
|
|
395
|
+
gap: 10,
|
|
396
|
+
width: "100%",
|
|
397
|
+
padding: "8px 10px",
|
|
398
|
+
border: "none",
|
|
399
|
+
textAlign: "left",
|
|
400
|
+
background: index === active ? t.surface : "transparent",
|
|
401
|
+
color: t.text,
|
|
402
|
+
fontFamily: t.fontFamily,
|
|
403
|
+
fontSize: 14,
|
|
404
|
+
lineHeight: 1.5,
|
|
405
|
+
cursor: "pointer",
|
|
406
|
+
}}
|
|
407
|
+
>
|
|
408
|
+
<span style={{ width: 56, flexShrink: 0, fontFamily: "monospace", fontSize: 12, color: t.primary }}>
|
|
409
|
+
{formatOffset(segment.startMs)}
|
|
410
|
+
</span>
|
|
411
|
+
<span style={{ minWidth: 0 }}>
|
|
412
|
+
<span style={{ fontWeight: 600 }}>{highlight(segment.name, needle, t.border)}</span>{" "}
|
|
413
|
+
{highlight(segment.text, needle, t.border)}
|
|
414
|
+
</span>
|
|
415
|
+
</button>
|
|
416
|
+
))}
|
|
417
|
+
</div>
|
|
418
|
+
</>
|
|
419
|
+
)}
|
|
420
|
+
</div>
|
|
421
|
+
</div>
|
|
422
|
+
</div>
|
|
423
|
+
);
|
|
424
|
+
}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
CalendarDays,
|
|
4
4
|
ChevronLeft,
|
|
5
5
|
ChevronRight,
|
|
6
|
+
Download,
|
|
6
7
|
FileText,
|
|
7
8
|
Loader2,
|
|
8
9
|
MessageSquare,
|
|
@@ -14,7 +15,16 @@ import {
|
|
|
14
15
|
} from "lucide-react";
|
|
15
16
|
import { useThemeTokens } from "../../theme/ForgeThemeProvider";
|
|
16
17
|
import { useMemberCalls, type UseMemberCallsOptions } from "../../headless/memberHome/useMemberCalls";
|
|
17
|
-
import
|
|
18
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
19
|
+
import { useForge } from "../../../provider/ForgeProvider";
|
|
20
|
+
import {
|
|
21
|
+
fetchMemberCallTranscript,
|
|
22
|
+
memberCallTranscriptKey,
|
|
23
|
+
type MemberCallTranscript,
|
|
24
|
+
type MyMembershipCall,
|
|
25
|
+
} from "../../../data/queries/useMembershipCalls";
|
|
26
|
+
import { CallTranscriptViewer } from "./CallTranscriptViewer";
|
|
27
|
+
import { downloadTranscript } from "./callTranscriptText";
|
|
18
28
|
import { Loading } from "../Loading";
|
|
19
29
|
|
|
20
30
|
export interface MemberCallsCalendarProps extends UseMemberCallsOptions {
|
|
@@ -119,6 +129,8 @@ export function MemberCallsCalendar({
|
|
|
119
129
|
// The call whose details modal is open. Clicking an event opens it, the way
|
|
120
130
|
// Google Calendar opens an event rather than jumping straight into it.
|
|
121
131
|
const [selected, setSelected] = useState<MyMembershipCall | null>(null);
|
|
132
|
+
// The recording whose transcript is open full-screen, beside its video.
|
|
133
|
+
const [readingRecordingId, setReadingRecordingId] = useState<string | null>(null);
|
|
122
134
|
|
|
123
135
|
if (calendar.isLoading) return <Loading />;
|
|
124
136
|
|
|
@@ -302,13 +314,23 @@ export function MemberCallsCalendar({
|
|
|
302
314
|
|
|
303
315
|
{selected && (
|
|
304
316
|
<CallDetailsModal
|
|
305
|
-
|
|
317
|
+
// Re-read from the latest fetch, not the snapshot taken on click, so
|
|
318
|
+
// a transcript that finishes while the modal is open appears in it.
|
|
319
|
+
call={calendar.calls.find((call) => call.id === selected.id) ?? selected}
|
|
306
320
|
now={now}
|
|
307
321
|
tokens={t}
|
|
308
322
|
onClose={() => setSelected(null)}
|
|
309
323
|
onJoin={() => selected.callPublicId && go(getCallHref(selected.callPublicId))}
|
|
324
|
+
onReadTranscript={(recordingId) => {
|
|
325
|
+
setSelected(null);
|
|
326
|
+
setReadingRecordingId(recordingId);
|
|
327
|
+
}}
|
|
310
328
|
/>
|
|
311
329
|
)}
|
|
330
|
+
|
|
331
|
+
{readingRecordingId && (
|
|
332
|
+
<CallTranscriptViewer recordingId={readingRecordingId} onClose={() => setReadingRecordingId(null)} />
|
|
333
|
+
)}
|
|
312
334
|
</div>
|
|
313
335
|
);
|
|
314
336
|
}
|
|
@@ -370,10 +392,10 @@ function EmptyMonth({
|
|
|
370
392
|
* particulars and everything you can DO with it, without leaving the month.
|
|
371
393
|
*
|
|
372
394
|
* This is where the actions moved when the chronological list was removed:
|
|
373
|
-
* Join while the room is open, Watch once the recording is ready
|
|
374
|
-
* the
|
|
375
|
-
*
|
|
376
|
-
*
|
|
395
|
+
* Join while the room is open, Watch once the recording is ready, and read
|
|
396
|
+
* the transcript and summary once they are written. The chat history of the
|
|
397
|
+
* call is still to come, and is shown as a clearly-labelled placeholder rather
|
|
398
|
+
* than hidden, so the shape a member will grow into is already visible.
|
|
377
399
|
*/
|
|
378
400
|
function CallDetailsModal({
|
|
379
401
|
call,
|
|
@@ -381,12 +403,14 @@ function CallDetailsModal({
|
|
|
381
403
|
tokens: t,
|
|
382
404
|
onClose,
|
|
383
405
|
onJoin,
|
|
406
|
+
onReadTranscript,
|
|
384
407
|
}: {
|
|
385
408
|
call: MyMembershipCall;
|
|
386
409
|
now: Date;
|
|
387
410
|
tokens: ReturnType<typeof useThemeTokens>;
|
|
388
411
|
onClose: () => void;
|
|
389
412
|
onJoin: () => void;
|
|
413
|
+
onReadTranscript: (recordingId: string) => void;
|
|
390
414
|
}) {
|
|
391
415
|
const canJoin = call.isOpen && !!call.callPublicId;
|
|
392
416
|
const hasEnded = new Date(call.closesAt).getTime() < now.getTime();
|
|
@@ -576,14 +600,122 @@ function CallDetailsModal({
|
|
|
576
600
|
)}
|
|
577
601
|
</div>
|
|
578
602
|
|
|
579
|
-
{
|
|
580
|
-
|
|
581
|
-
|
|
603
|
+
{call.recording?.transcript && (
|
|
604
|
+
<TranscriptPanel
|
|
605
|
+
recordingId={call.recording.id}
|
|
606
|
+
transcript={call.recording.transcript}
|
|
607
|
+
tokens={t}
|
|
608
|
+
onRead={() => onReadTranscript(call.recording!.id)}
|
|
609
|
+
/>
|
|
610
|
+
)}
|
|
611
|
+
|
|
612
|
+
{/* What is coming: the chat history of the call. Shown so a member knows
|
|
613
|
+
the modal is where it will live, not hidden until the day it ships. */}
|
|
582
614
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
583
|
-
{comingSoon(<FileText size={16} />, "Transcript", "A searchable transcript of what was said.")}
|
|
584
615
|
{comingSoon(<MessageSquare size={16} />, "Chat history", "The messages sent during the call.")}
|
|
585
616
|
</div>
|
|
586
617
|
</div>
|
|
587
618
|
</div>
|
|
588
619
|
);
|
|
589
620
|
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* The call's summary, and the way into its transcript.
|
|
624
|
+
*
|
|
625
|
+
* The lines are NOT here: a two-hour meeting is thousands of them, which is
|
|
626
|
+
* unusable in a modal. "Read transcript" opens them beside the video, and
|
|
627
|
+
* Download reads them once without opening anything. A member sees the
|
|
628
|
+
* finished thing or that it is being prepared, never a failure: why one could
|
|
629
|
+
* not be made is the business's problem, not its audience's.
|
|
630
|
+
*/
|
|
631
|
+
function TranscriptPanel({
|
|
632
|
+
recordingId,
|
|
633
|
+
transcript,
|
|
634
|
+
tokens: t,
|
|
635
|
+
onRead,
|
|
636
|
+
}: {
|
|
637
|
+
recordingId: string;
|
|
638
|
+
transcript: MemberCallTranscript;
|
|
639
|
+
tokens: ReturnType<typeof useThemeTokens>;
|
|
640
|
+
onRead: () => void;
|
|
641
|
+
}) {
|
|
642
|
+
const { client, profileId } = useForge();
|
|
643
|
+
const queryClient = useQueryClient();
|
|
644
|
+
const [downloading, setDownloading] = useState(false);
|
|
645
|
+
const heading: CSSProperties = { display: "flex", alignItems: "center", gap: 8, fontSize: 14, fontWeight: 700 };
|
|
646
|
+
const button: CSSProperties = {
|
|
647
|
+
display: "inline-flex",
|
|
648
|
+
alignItems: "center",
|
|
649
|
+
gap: 6,
|
|
650
|
+
padding: "8px 14px",
|
|
651
|
+
borderRadius: t.cornerRadius,
|
|
652
|
+
border: `1px solid ${t.border}`,
|
|
653
|
+
background: "transparent",
|
|
654
|
+
color: t.text,
|
|
655
|
+
fontFamily: t.fontFamily,
|
|
656
|
+
fontSize: 14,
|
|
657
|
+
fontWeight: 600,
|
|
658
|
+
cursor: "pointer",
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
if (transcript.status === "processing") {
|
|
662
|
+
return (
|
|
663
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 6 }} data-testid="member-call-transcript">
|
|
664
|
+
<span style={heading}>
|
|
665
|
+
<FileText size={16} />
|
|
666
|
+
Transcript
|
|
667
|
+
</span>
|
|
668
|
+
<span style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 14, color: t.muted }} role="status">
|
|
669
|
+
<Loader2 size={15} />
|
|
670
|
+
The transcript is being prepared
|
|
671
|
+
</span>
|
|
672
|
+
</div>
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const download = async () => {
|
|
677
|
+
setDownloading(true);
|
|
678
|
+
try {
|
|
679
|
+
const detail = await queryClient.fetchQuery({
|
|
680
|
+
queryKey: memberCallTranscriptKey(profileId, recordingId),
|
|
681
|
+
queryFn: () => fetchMemberCallTranscript(client, profileId, recordingId),
|
|
682
|
+
});
|
|
683
|
+
downloadTranscript(detail);
|
|
684
|
+
} finally {
|
|
685
|
+
setDownloading(false);
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
return (
|
|
690
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 10 }} data-testid="member-call-transcript">
|
|
691
|
+
<span style={heading}>
|
|
692
|
+
<FileText size={16} />
|
|
693
|
+
Summary
|
|
694
|
+
</span>
|
|
695
|
+
<p
|
|
696
|
+
style={{
|
|
697
|
+
margin: 0,
|
|
698
|
+
fontSize: 14,
|
|
699
|
+
lineHeight: 1.5,
|
|
700
|
+
display: "-webkit-box",
|
|
701
|
+
WebkitLineClamp: 4,
|
|
702
|
+
WebkitBoxOrient: "vertical",
|
|
703
|
+
overflow: "hidden",
|
|
704
|
+
color: transcript.summary ? t.text : t.muted,
|
|
705
|
+
}}
|
|
706
|
+
>
|
|
707
|
+
{transcript.summary ? transcript.summary.summary : "The transcript is ready."}
|
|
708
|
+
</p>
|
|
709
|
+
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
|
710
|
+
<button type="button" onClick={onRead} style={button}>
|
|
711
|
+
<FileText size={15} />
|
|
712
|
+
Read transcript
|
|
713
|
+
</button>
|
|
714
|
+
<button type="button" onClick={() => void download()} disabled={downloading} style={button}>
|
|
715
|
+
{downloading ? <Loader2 size={15} /> : <Download size={15} />}
|
|
716
|
+
Download
|
|
717
|
+
</button>
|
|
718
|
+
</div>
|
|
719
|
+
</div>
|
|
720
|
+
);
|
|
721
|
+
}
|