@tribe-nest/forge 3.42.0 → 3.48.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 +2 -2
- package/src/data/queries/useCallChat.ts +162 -0
- package/src/data/queries/useCallRecording.ts +80 -0
- package/src/data/queries/useMembershipCalls.ts +162 -0
- package/src/i18n/de.json +73 -1
- package/src/i18n/en.json +73 -1
- package/src/index.ts +7 -0
- package/src/ui/headless/memberHome/index.ts +4 -0
- package/src/ui/headless/memberHome/useMemberCalls.ts +137 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +29 -5
- package/src/ui/media/CallStage.tsx +691 -553
- package/src/ui/media/PreJoin.tsx +269 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +338 -5
- package/src/ui/media/_tests/callLayout.spec.ts +182 -0
- package/src/ui/media/_tests/callSignals.spec.ts +87 -0
- package/src/ui/media/_tests/callState.spec.ts +93 -1
- package/src/ui/media/_tests/fakeNode.ts +4 -1
- package/src/ui/media/callChat.ts +33 -0
- package/src/ui/media/callState.ts +125 -9
- package/src/ui/media/hooks/useCallSignals.ts +135 -0
- package/src/ui/media/hooks/useMeasuredWidth.ts +39 -0
- package/src/ui/media/index.ts +41 -1
- package/src/ui/media/panels/CallChatPanel.tsx +200 -0
- package/src/ui/media/panels/CallParticipantsPanel.tsx +125 -0
- package/src/ui/media/panels/CallSidePanel.tsx +152 -0
- package/src/ui/media/stage/CallCenterNotice.tsx +89 -0
- package/src/ui/media/stage/CallControlsBar.tsx +547 -0
- package/src/ui/media/stage/CallHealthPanel.tsx +132 -0
- package/src/ui/media/stage/CallToasts.tsx +49 -0
- package/src/ui/media/stage/CallTopBar.tsx +257 -0
- package/src/ui/media/stage/DeviceMenu.tsx +142 -0
- package/src/ui/media/stage/ReactionOverlay.tsx +38 -0
- package/src/ui/media/stage/SelfView.tsx +146 -0
- package/src/ui/media/stage/VideoTile.tsx +232 -0
- package/src/ui/media/stage/callLayout.ts +253 -0
- package/src/ui/media/stage/callSignals.ts +91 -0
- package/src/ui/media/stage/callSounds.ts +78 -0
- package/src/ui/media/stage/stageTheme.ts +50 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +84 -7
- package/src/ui/styled/broadcast/broadcastStage.spec.ts +32 -0
- package/src/ui/styled/broadcast/relayReachable.spec.ts +42 -0
- package/src/ui/styled/broadcast/relayReachable.ts +47 -0
- package/src/ui/styled/forge-utilities.css +13 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +571 -0
- package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +135 -0
- package/src/ui/styled/members/index.ts +1 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
import { useState, type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
CalendarDays,
|
|
4
|
+
ChevronLeft,
|
|
5
|
+
ChevronRight,
|
|
6
|
+
FileText,
|
|
7
|
+
Loader2,
|
|
8
|
+
MessageSquare,
|
|
9
|
+
Mic,
|
|
10
|
+
MicOff,
|
|
11
|
+
PlayCircle,
|
|
12
|
+
Users,
|
|
13
|
+
X,
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
import { useThemeTokens } from "../../theme/ForgeThemeProvider";
|
|
16
|
+
import { useMemberCalls, type UseMemberCallsOptions } from "../../headless/memberHome/useMemberCalls";
|
|
17
|
+
import type { MyMembershipCall } from "../../../data/queries/useMembershipCalls";
|
|
18
|
+
import { Loading } from "../Loading";
|
|
19
|
+
|
|
20
|
+
export interface MemberCallsCalendarProps extends UseMemberCallsOptions {
|
|
21
|
+
/**
|
|
22
|
+
* Builds the href of the call itself. Receives the call's public id, which
|
|
23
|
+
* addresses the GENERIC call page - the server decides what the call is
|
|
24
|
+
* attached to and what seat the viewer holds in it.
|
|
25
|
+
*/
|
|
26
|
+
getCallHref?: (publicId: string) => string;
|
|
27
|
+
/** SPA navigation adapter. Defaults to a full-page visit. */
|
|
28
|
+
navigate?: (href: string) => void;
|
|
29
|
+
/** Where a viewer with nothing at all is sent to see what a membership includes. */
|
|
30
|
+
membershipHref?: string;
|
|
31
|
+
className?: string;
|
|
32
|
+
style?: CSSProperties;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The creator's zone when they set one, else the viewer's. See `useMemberCalls`. */
|
|
36
|
+
const formatTime = (iso: string, timeZone: string | null): string =>
|
|
37
|
+
new Date(iso).toLocaleTimeString("en-US", {
|
|
38
|
+
hour: "numeric",
|
|
39
|
+
minute: "2-digit",
|
|
40
|
+
hour12: true,
|
|
41
|
+
timeZone: timeZone || undefined,
|
|
42
|
+
timeZoneName: "short",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const formatMinutes = (startsAt: string, endsAt: string): string => {
|
|
46
|
+
const minutes = Math.round((new Date(endsAt).getTime() - new Date(startsAt).getTime()) / 60_000);
|
|
47
|
+
if (minutes < 60) return `${minutes} min`;
|
|
48
|
+
const hours = Math.floor(minutes / 60);
|
|
49
|
+
const rest = minutes % 60;
|
|
50
|
+
return rest === 0 ? `${hours} hr` : `${hours} hr ${rest} min`;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* "Opens in 3 hours" - what a member is actually waiting for.
|
|
55
|
+
*
|
|
56
|
+
* Counts to `opensAt`, NOT `startsAt`: the room opens fifteen minutes early, and
|
|
57
|
+
* counting to the start would tell somebody to come back at a moment when they
|
|
58
|
+
* could already have been inside.
|
|
59
|
+
*/
|
|
60
|
+
const opensIn = (opensAt: string, now: Date): string => {
|
|
61
|
+
const ms = new Date(opensAt).getTime() - now.getTime();
|
|
62
|
+
if (ms <= 0) return "Opening";
|
|
63
|
+
const minutes = Math.round(ms / 60_000);
|
|
64
|
+
if (minutes < 60) return `Opens in ${minutes} min`;
|
|
65
|
+
const hours = Math.round(minutes / 60);
|
|
66
|
+
if (hours < 24) return `Opens in ${hours} ${hours === 1 ? "hour" : "hours"}`;
|
|
67
|
+
const days = Math.round(hours / 24);
|
|
68
|
+
return `Opens in ${days} ${days === 1 ? "day" : "days"}`;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The member's call calendar: a month you can page through, with upcoming calls
|
|
75
|
+
* and past recordings in the same grid.
|
|
76
|
+
*
|
|
77
|
+
* Every call shown is one this viewer may act on - the server filters by the
|
|
78
|
+
* membership it reads for them, so there is no locked state to render and no
|
|
79
|
+
* client-side access check to get wrong. That is a deliberate difference from
|
|
80
|
+
* `PostCollectionsGrid`, which DOES show locked rows: a members-only collection
|
|
81
|
+
* is an offer worth announcing to a non-member, whereas listing a call somebody
|
|
82
|
+
* cannot enter is an invitation to a door that will not open.
|
|
83
|
+
*
|
|
84
|
+
* Nothing here decides whether Join appears: `isOpen` comes from the server,
|
|
85
|
+
* computed from the same window the join enforces. A client re-deriving it from
|
|
86
|
+
* the clock would give two answers to one question, and the wrong one is the one
|
|
87
|
+
* the member acts on.
|
|
88
|
+
*/
|
|
89
|
+
export function MemberCallsCalendar({
|
|
90
|
+
getCallHref = (publicId) => `/i/video-calls/${publicId}`,
|
|
91
|
+
navigate,
|
|
92
|
+
membershipHref,
|
|
93
|
+
className,
|
|
94
|
+
style,
|
|
95
|
+
...options
|
|
96
|
+
}: MemberCallsCalendarProps) {
|
|
97
|
+
const t = useThemeTokens();
|
|
98
|
+
const calendar = useMemberCalls(options);
|
|
99
|
+
const go = navigate ?? ((href: string) => window.location.assign(href));
|
|
100
|
+
const now = options.now ?? new Date();
|
|
101
|
+
// The call whose details modal is open. Clicking an event opens it, the way
|
|
102
|
+
// Google Calendar opens an event rather than jumping straight into it.
|
|
103
|
+
const [selected, setSelected] = useState<MyMembershipCall | null>(null);
|
|
104
|
+
|
|
105
|
+
if (calendar.isLoading) return <Loading />;
|
|
106
|
+
|
|
107
|
+
if (calendar.error) {
|
|
108
|
+
return (
|
|
109
|
+
<p style={{ color: t.muted, fontFamily: t.fontFamily, fontSize: 14 }}>
|
|
110
|
+
We could not load your calls just now. Refresh to try again.
|
|
111
|
+
</p>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const navButton: CSSProperties = {
|
|
116
|
+
display: "inline-flex",
|
|
117
|
+
alignItems: "center",
|
|
118
|
+
gap: 4,
|
|
119
|
+
padding: "6px 10px",
|
|
120
|
+
borderRadius: t.cornerRadius,
|
|
121
|
+
border: `1px solid ${t.border}`,
|
|
122
|
+
background: "transparent",
|
|
123
|
+
color: t.text,
|
|
124
|
+
fontFamily: t.fontFamily,
|
|
125
|
+
fontSize: 13,
|
|
126
|
+
cursor: "pointer",
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div
|
|
131
|
+
className={className}
|
|
132
|
+
style={{ display: "flex", flexDirection: "column", gap: 16, color: t.text, fontFamily: t.fontFamily, ...style }}
|
|
133
|
+
>
|
|
134
|
+
<header style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
|
135
|
+
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, flex: "1 1 auto" }}>{calendar.monthLabel}</h2>
|
|
136
|
+
<button type="button" onClick={calendar.goToPreviousMonth} aria-label="Previous month" style={navButton}>
|
|
137
|
+
<ChevronLeft size={16} />
|
|
138
|
+
</button>
|
|
139
|
+
{/* Only offered when it would do something. A "Today" that is already
|
|
140
|
+
today is a control that does nothing when pressed. */}
|
|
141
|
+
{!calendar.isCurrentMonth && (
|
|
142
|
+
<button type="button" onClick={calendar.goToToday} style={navButton}>
|
|
143
|
+
Today
|
|
144
|
+
</button>
|
|
145
|
+
)}
|
|
146
|
+
<button type="button" onClick={calendar.goToNextMonth} aria-label="Next month" style={navButton}>
|
|
147
|
+
<ChevronRight size={16} />
|
|
148
|
+
</button>
|
|
149
|
+
</header>
|
|
150
|
+
|
|
151
|
+
{/* A seven-column month is unreadable on a phone and overflows it
|
|
152
|
+
sideways, so the grid is the DESKTOP face and a phone gets an agenda
|
|
153
|
+
(below). Both open the same details modal on tap. `minmax(0, 1fr)`
|
|
154
|
+
rather than `1fr` so a long title in a cell cannot force the grid
|
|
155
|
+
wider than its container. */}
|
|
156
|
+
<div className="hidden sm:block">
|
|
157
|
+
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, minmax(0, 1fr))", gap: 4 }}>
|
|
158
|
+
{WEEKDAYS.map((day) => (
|
|
159
|
+
<div key={day} style={{ fontSize: 11, fontWeight: 700, color: t.muted, padding: "4px 6px" }}>
|
|
160
|
+
{day}
|
|
161
|
+
</div>
|
|
162
|
+
))}
|
|
163
|
+
{calendar.days.map((day) => (
|
|
164
|
+
<div
|
|
165
|
+
key={day.key}
|
|
166
|
+
aria-current={day.isToday ? "date" : undefined}
|
|
167
|
+
style={{
|
|
168
|
+
minHeight: 72,
|
|
169
|
+
padding: 6,
|
|
170
|
+
borderRadius: t.cornerRadius,
|
|
171
|
+
border: `1px solid ${day.isToday ? t.primary : t.border}`,
|
|
172
|
+
background: day.inMonth ? t.surface : "transparent",
|
|
173
|
+
// Days outside the month are context, not content.
|
|
174
|
+
opacity: day.inMonth ? 1 : 0.45,
|
|
175
|
+
display: "flex",
|
|
176
|
+
flexDirection: "column",
|
|
177
|
+
gap: 3,
|
|
178
|
+
}}
|
|
179
|
+
>
|
|
180
|
+
<span style={{ fontSize: 11, fontWeight: day.isToday ? 700 : 500, color: t.muted }}>
|
|
181
|
+
{day.date.getDate()}
|
|
182
|
+
</span>
|
|
183
|
+
{day.calls.map((call) => (
|
|
184
|
+
<button
|
|
185
|
+
key={call.id}
|
|
186
|
+
type="button"
|
|
187
|
+
onClick={() => setSelected(call)}
|
|
188
|
+
title={call.title}
|
|
189
|
+
style={{
|
|
190
|
+
textAlign: "left",
|
|
191
|
+
padding: "2px 4px",
|
|
192
|
+
borderRadius: 4,
|
|
193
|
+
border: "none",
|
|
194
|
+
cursor: "pointer",
|
|
195
|
+
fontSize: 11,
|
|
196
|
+
lineHeight: 1.3,
|
|
197
|
+
overflow: "hidden",
|
|
198
|
+
textOverflow: "ellipsis",
|
|
199
|
+
whiteSpace: "nowrap",
|
|
200
|
+
// Open now is the one thing worth shouting about in a cell
|
|
201
|
+
// this small.
|
|
202
|
+
background: call.isOpen ? t.primary : "transparent",
|
|
203
|
+
color: call.isOpen ? t.textPrimary : t.text,
|
|
204
|
+
}}
|
|
205
|
+
>
|
|
206
|
+
{call.title}
|
|
207
|
+
</button>
|
|
208
|
+
))}
|
|
209
|
+
</div>
|
|
210
|
+
))}
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
{/* The phone face: an agenda, not a grid. A vertical list cannot overflow
|
|
215
|
+
a narrow screen sideways, and every row is a comfortable tap target
|
|
216
|
+
that opens the same details modal. Only days WITH a call appear. */}
|
|
217
|
+
<div className="sm:hidden">
|
|
218
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
219
|
+
{calendar.days
|
|
220
|
+
.filter((day) => day.inMonth && day.calls.length > 0)
|
|
221
|
+
.map((day) => (
|
|
222
|
+
<div key={day.key} style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
223
|
+
<div
|
|
224
|
+
style={{
|
|
225
|
+
fontSize: 12,
|
|
226
|
+
fontWeight: 700,
|
|
227
|
+
color: day.isToday ? t.primary : t.muted,
|
|
228
|
+
textTransform: "uppercase",
|
|
229
|
+
letterSpacing: 0.4,
|
|
230
|
+
}}
|
|
231
|
+
>
|
|
232
|
+
{day.date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })}
|
|
233
|
+
</div>
|
|
234
|
+
{day.calls.map((call) => (
|
|
235
|
+
<button
|
|
236
|
+
key={call.id}
|
|
237
|
+
type="button"
|
|
238
|
+
onClick={() => setSelected(call)}
|
|
239
|
+
style={{
|
|
240
|
+
display: "flex",
|
|
241
|
+
alignItems: "center",
|
|
242
|
+
gap: 10,
|
|
243
|
+
width: "100%",
|
|
244
|
+
textAlign: "left",
|
|
245
|
+
padding: "10px 12px",
|
|
246
|
+
borderRadius: t.cornerRadius,
|
|
247
|
+
border: `1px solid ${call.isOpen ? t.primary : t.border}`,
|
|
248
|
+
background: t.surface,
|
|
249
|
+
color: t.text,
|
|
250
|
+
fontFamily: t.fontFamily,
|
|
251
|
+
cursor: "pointer",
|
|
252
|
+
}}
|
|
253
|
+
>
|
|
254
|
+
<span
|
|
255
|
+
aria-hidden="true"
|
|
256
|
+
style={{
|
|
257
|
+
flexShrink: 0,
|
|
258
|
+
width: 8,
|
|
259
|
+
height: 8,
|
|
260
|
+
borderRadius: 999,
|
|
261
|
+
background: call.isOpen ? t.primary : t.muted,
|
|
262
|
+
}}
|
|
263
|
+
/>
|
|
264
|
+
<span style={{ minWidth: 0, display: "flex", flexDirection: "column", gap: 2 }}>
|
|
265
|
+
<span style={{ fontSize: 14, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
266
|
+
{call.title}
|
|
267
|
+
</span>
|
|
268
|
+
<span style={{ fontSize: 12, color: t.muted }}>
|
|
269
|
+
{formatTime(call.startsAt, call.timezone)}
|
|
270
|
+
{call.isOpen ? " · Open now" : ""}
|
|
271
|
+
</span>
|
|
272
|
+
</span>
|
|
273
|
+
</button>
|
|
274
|
+
))}
|
|
275
|
+
</div>
|
|
276
|
+
))}
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
|
|
280
|
+
{/* No list any more: the calendar IS the surface, and an event opens its
|
|
281
|
+
own details. Only a wholly empty month gets a word, so the grid does
|
|
282
|
+
not sit above a silent gap. */}
|
|
283
|
+
{calendar.calls.length === 0 && <EmptyMonth tokens={t} membershipHref={membershipHref} onNavigate={go} />}
|
|
284
|
+
|
|
285
|
+
{selected && (
|
|
286
|
+
<CallDetailsModal
|
|
287
|
+
call={selected}
|
|
288
|
+
now={now}
|
|
289
|
+
tokens={t}
|
|
290
|
+
onClose={() => setSelected(null)}
|
|
291
|
+
onJoin={() => selected.callPublicId && go(getCallHref(selected.callPublicId))}
|
|
292
|
+
/>
|
|
293
|
+
)}
|
|
294
|
+
</div>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function EmptyMonth({
|
|
299
|
+
tokens: t,
|
|
300
|
+
membershipHref,
|
|
301
|
+
onNavigate,
|
|
302
|
+
}: {
|
|
303
|
+
tokens: ReturnType<typeof useThemeTokens>;
|
|
304
|
+
membershipHref?: string;
|
|
305
|
+
onNavigate: (href: string) => void;
|
|
306
|
+
}) {
|
|
307
|
+
return (
|
|
308
|
+
<div
|
|
309
|
+
style={{
|
|
310
|
+
display: "flex",
|
|
311
|
+
flexDirection: "column",
|
|
312
|
+
alignItems: "center",
|
|
313
|
+
gap: 8,
|
|
314
|
+
padding: "32px 24px",
|
|
315
|
+
textAlign: "center",
|
|
316
|
+
border: `1px solid ${t.border}`,
|
|
317
|
+
borderRadius: t.cornerRadius,
|
|
318
|
+
background: t.surface,
|
|
319
|
+
}}
|
|
320
|
+
>
|
|
321
|
+
<CalendarDays size={26} style={{ opacity: 0.5 }} />
|
|
322
|
+
<p style={{ fontSize: 15, fontWeight: 600, margin: 0 }}>Nothing this month</p>
|
|
323
|
+
<p style={{ fontSize: 14, color: t.muted, margin: 0, maxWidth: 380 }}>
|
|
324
|
+
Use the arrows to look at another month. Calls you can join show up here, and so do recordings of
|
|
325
|
+
ones that have already happened.
|
|
326
|
+
</p>
|
|
327
|
+
{membershipHref && (
|
|
328
|
+
<button
|
|
329
|
+
type="button"
|
|
330
|
+
onClick={() => onNavigate(membershipHref)}
|
|
331
|
+
style={{
|
|
332
|
+
marginTop: 4,
|
|
333
|
+
padding: "8px 16px",
|
|
334
|
+
borderRadius: t.cornerRadius,
|
|
335
|
+
border: `1px solid ${t.border}`,
|
|
336
|
+
background: "transparent",
|
|
337
|
+
color: t.text,
|
|
338
|
+
fontFamily: t.fontFamily,
|
|
339
|
+
fontSize: 14,
|
|
340
|
+
cursor: "pointer",
|
|
341
|
+
}}
|
|
342
|
+
>
|
|
343
|
+
My membership
|
|
344
|
+
</button>
|
|
345
|
+
)}
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The event details, the way Google Calendar shows one: tap an event, get its
|
|
352
|
+
* particulars and everything you can DO with it, without leaving the month.
|
|
353
|
+
*
|
|
354
|
+
* This is where the actions moved when the chronological list was removed:
|
|
355
|
+
* Join while the room is open, Watch once the recording is ready. It is also
|
|
356
|
+
* the home for the two things still to come - a transcript and the chat
|
|
357
|
+
* history of the call - which are shown as clearly-labelled placeholders
|
|
358
|
+
* rather than hidden, so the shape a member will grow into is already visible.
|
|
359
|
+
*/
|
|
360
|
+
function CallDetailsModal({
|
|
361
|
+
call,
|
|
362
|
+
now,
|
|
363
|
+
tokens: t,
|
|
364
|
+
onClose,
|
|
365
|
+
onJoin,
|
|
366
|
+
}: {
|
|
367
|
+
call: MyMembershipCall;
|
|
368
|
+
now: Date;
|
|
369
|
+
tokens: ReturnType<typeof useThemeTokens>;
|
|
370
|
+
onClose: () => void;
|
|
371
|
+
onJoin: () => void;
|
|
372
|
+
}) {
|
|
373
|
+
const canJoin = call.isOpen && !!call.callPublicId;
|
|
374
|
+
const hasEnded = new Date(call.closesAt).getTime() < now.getTime();
|
|
375
|
+
const watchable = call.recording?.status === "ready" && !!call.recording.watchUrl;
|
|
376
|
+
const processing = call.recording?.status === "processing";
|
|
377
|
+
|
|
378
|
+
const row: CSSProperties = { display: "flex", alignItems: "center", gap: 8, fontSize: 14, color: t.muted };
|
|
379
|
+
|
|
380
|
+
/** A future capability, present but plainly not ready. Named so a member
|
|
381
|
+
* knows it is coming, greyed so nobody mistakes it for a dead control. */
|
|
382
|
+
const comingSoon = (icon: ReactNode, label: string, detail: string) => (
|
|
383
|
+
<div
|
|
384
|
+
style={{
|
|
385
|
+
display: "flex",
|
|
386
|
+
alignItems: "flex-start",
|
|
387
|
+
gap: 10,
|
|
388
|
+
padding: "10px 12px",
|
|
389
|
+
borderRadius: t.cornerRadius,
|
|
390
|
+
border: `1px dashed ${t.border}`,
|
|
391
|
+
opacity: 0.7,
|
|
392
|
+
}}
|
|
393
|
+
>
|
|
394
|
+
<span style={{ marginTop: 1 }}>{icon}</span>
|
|
395
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
|
|
396
|
+
<span style={{ fontSize: 14, fontWeight: 600, display: "flex", alignItems: "center", gap: 8 }}>
|
|
397
|
+
{label}
|
|
398
|
+
<span
|
|
399
|
+
style={{
|
|
400
|
+
fontSize: 10,
|
|
401
|
+
fontWeight: 700,
|
|
402
|
+
textTransform: "uppercase",
|
|
403
|
+
letterSpacing: 0.5,
|
|
404
|
+
color: t.muted,
|
|
405
|
+
border: `1px solid ${t.border}`,
|
|
406
|
+
borderRadius: 999,
|
|
407
|
+
padding: "1px 6px",
|
|
408
|
+
}}
|
|
409
|
+
>
|
|
410
|
+
Coming soon
|
|
411
|
+
</span>
|
|
412
|
+
</span>
|
|
413
|
+
<span style={{ fontSize: 13, color: t.muted }}>{detail}</span>
|
|
414
|
+
</div>
|
|
415
|
+
</div>
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
return (
|
|
419
|
+
<div
|
|
420
|
+
role="dialog"
|
|
421
|
+
aria-modal="true"
|
|
422
|
+
aria-label={call.title}
|
|
423
|
+
onClick={onClose}
|
|
424
|
+
style={{
|
|
425
|
+
position: "fixed",
|
|
426
|
+
inset: 0,
|
|
427
|
+
zIndex: 1000,
|
|
428
|
+
display: "flex",
|
|
429
|
+
alignItems: "center",
|
|
430
|
+
justifyContent: "center",
|
|
431
|
+
padding: 16,
|
|
432
|
+
background: "rgba(0,0,0,0.5)",
|
|
433
|
+
}}
|
|
434
|
+
>
|
|
435
|
+
<div
|
|
436
|
+
onClick={(e) => e.stopPropagation()}
|
|
437
|
+
style={{
|
|
438
|
+
width: "100%",
|
|
439
|
+
maxWidth: 460,
|
|
440
|
+
maxHeight: "85vh",
|
|
441
|
+
overflowY: "auto",
|
|
442
|
+
display: "flex",
|
|
443
|
+
flexDirection: "column",
|
|
444
|
+
gap: 16,
|
|
445
|
+
padding: 20,
|
|
446
|
+
borderRadius: t.cornerRadius,
|
|
447
|
+
background: t.surface,
|
|
448
|
+
color: t.text,
|
|
449
|
+
fontFamily: t.fontFamily,
|
|
450
|
+
border: `1px solid ${t.border}`,
|
|
451
|
+
}}
|
|
452
|
+
>
|
|
453
|
+
<header style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
|
|
454
|
+
<div style={{ minWidth: 0, flex: 1, display: "flex", flexDirection: "column", gap: 4 }}>
|
|
455
|
+
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700 }}>{call.title}</h2>
|
|
456
|
+
{call.role === "host" && (
|
|
457
|
+
<span style={{ fontSize: 11, fontWeight: 700, color: t.muted, textTransform: "uppercase" }}>
|
|
458
|
+
You are hosting
|
|
459
|
+
</span>
|
|
460
|
+
)}
|
|
461
|
+
</div>
|
|
462
|
+
<button
|
|
463
|
+
type="button"
|
|
464
|
+
onClick={onClose}
|
|
465
|
+
aria-label="Close"
|
|
466
|
+
style={{ background: "transparent", border: "none", cursor: "pointer", color: t.muted, padding: 2 }}
|
|
467
|
+
>
|
|
468
|
+
<X size={18} />
|
|
469
|
+
</button>
|
|
470
|
+
</header>
|
|
471
|
+
|
|
472
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
473
|
+
<div style={row}>
|
|
474
|
+
<CalendarDays size={15} />
|
|
475
|
+
<span style={{ color: t.text }}>
|
|
476
|
+
{new Date(call.startsAt).toLocaleDateString("en-US", {
|
|
477
|
+
weekday: "long",
|
|
478
|
+
month: "long",
|
|
479
|
+
day: "numeric",
|
|
480
|
+
timeZone: call.timezone || undefined,
|
|
481
|
+
})}
|
|
482
|
+
{" · "}
|
|
483
|
+
{formatTime(call.startsAt, call.timezone)} · {formatMinutes(call.startsAt, call.endsAt)}
|
|
484
|
+
</span>
|
|
485
|
+
</div>
|
|
486
|
+
{!hasEnded && (
|
|
487
|
+
<div style={row}>
|
|
488
|
+
{call.participation === "open" ? <Mic size={15} /> : <MicOff size={15} />}
|
|
489
|
+
{call.participation === "open" ? "Everyone can speak" : "Listen and chat"}
|
|
490
|
+
</div>
|
|
491
|
+
)}
|
|
492
|
+
{call.audiences.length > 0 && (
|
|
493
|
+
<div style={row}>
|
|
494
|
+
<Users size={15} />
|
|
495
|
+
{call.audiences.map((audience) => audience.label).join(", ")}
|
|
496
|
+
</div>
|
|
497
|
+
)}
|
|
498
|
+
{call.description && (
|
|
499
|
+
<p style={{ margin: "4px 0 0", fontSize: 14, color: t.text, opacity: 0.85 }}>{call.description}</p>
|
|
500
|
+
)}
|
|
501
|
+
</div>
|
|
502
|
+
|
|
503
|
+
{/* What a member can do RIGHT NOW. */}
|
|
504
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
|
505
|
+
{canJoin && (
|
|
506
|
+
<button
|
|
507
|
+
type="button"
|
|
508
|
+
onClick={onJoin}
|
|
509
|
+
style={{
|
|
510
|
+
padding: "10px 20px",
|
|
511
|
+
borderRadius: t.cornerRadius,
|
|
512
|
+
border: "none",
|
|
513
|
+
background: t.primary,
|
|
514
|
+
color: t.textPrimary,
|
|
515
|
+
fontFamily: t.fontFamily,
|
|
516
|
+
fontSize: 14,
|
|
517
|
+
fontWeight: 700,
|
|
518
|
+
cursor: "pointer",
|
|
519
|
+
}}
|
|
520
|
+
>
|
|
521
|
+
Join call
|
|
522
|
+
</button>
|
|
523
|
+
)}
|
|
524
|
+
{watchable && (
|
|
525
|
+
<a
|
|
526
|
+
href={call.recording!.watchUrl!}
|
|
527
|
+
target="_blank"
|
|
528
|
+
rel="noreferrer"
|
|
529
|
+
style={{
|
|
530
|
+
display: "flex",
|
|
531
|
+
alignItems: "center",
|
|
532
|
+
gap: 6,
|
|
533
|
+
padding: "10px 20px",
|
|
534
|
+
borderRadius: t.cornerRadius,
|
|
535
|
+
background: canJoin ? "transparent" : t.primary,
|
|
536
|
+
border: canJoin ? `1px solid ${t.border}` : "none",
|
|
537
|
+
color: canJoin ? t.text : t.textPrimary,
|
|
538
|
+
fontSize: 14,
|
|
539
|
+
fontWeight: 700,
|
|
540
|
+
textDecoration: "none",
|
|
541
|
+
}}
|
|
542
|
+
>
|
|
543
|
+
<PlayCircle size={16} />
|
|
544
|
+
Watch recording
|
|
545
|
+
{call.recording!.durationSeconds ? ` · ${Math.round(call.recording!.durationSeconds / 60)} min` : ""}
|
|
546
|
+
</a>
|
|
547
|
+
)}
|
|
548
|
+
{!canJoin && !watchable && processing && (
|
|
549
|
+
<span style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 14, color: t.muted, fontWeight: 600 }}>
|
|
550
|
+
<Loader2 size={15} />
|
|
551
|
+
Recording is processing
|
|
552
|
+
</span>
|
|
553
|
+
)}
|
|
554
|
+
{!canJoin && !watchable && !processing && (
|
|
555
|
+
<span style={{ fontSize: 14, color: t.muted, fontWeight: 600 }}>
|
|
556
|
+
{hasEnded ? "This call has ended" : opensIn(call.opensAt, now)}
|
|
557
|
+
</span>
|
|
558
|
+
)}
|
|
559
|
+
</div>
|
|
560
|
+
|
|
561
|
+
{/* What is coming: the transcript and the chat history of the call. Shown
|
|
562
|
+
so a member knows the modal is where they will live, not hidden until
|
|
563
|
+
the day they ship. */}
|
|
564
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
565
|
+
{comingSoon(<FileText size={16} />, "Transcript", "A searchable transcript of what was said.")}
|
|
566
|
+
{comingSoon(<MessageSquare size={16} />, "Chat history", "The messages sent during the call.")}
|
|
567
|
+
</div>
|
|
568
|
+
</div>
|
|
569
|
+
</div>
|
|
570
|
+
);
|
|
571
|
+
}
|