@tribe-nest/forge 3.42.1 → 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.
Files changed (42) hide show
  1. package/package.json +2 -2
  2. package/src/data/queries/useCallChat.ts +162 -0
  3. package/src/data/queries/useCallRecording.ts +80 -0
  4. package/src/data/queries/useMembershipCalls.ts +162 -0
  5. package/src/i18n/de.json +73 -1
  6. package/src/i18n/en.json +73 -1
  7. package/src/index.ts +7 -0
  8. package/src/ui/headless/memberHome/index.ts +4 -0
  9. package/src/ui/headless/memberHome/useMemberCalls.ts +137 -0
  10. package/src/ui/headless/memberHome/useMemberHomeNav.ts +29 -5
  11. package/src/ui/media/CallStage.tsx +691 -553
  12. package/src/ui/media/PreJoin.tsx +269 -0
  13. package/src/ui/media/_tests/CallStage.spec.tsx +338 -5
  14. package/src/ui/media/_tests/callLayout.spec.ts +182 -0
  15. package/src/ui/media/_tests/callSignals.spec.ts +87 -0
  16. package/src/ui/media/_tests/callState.spec.ts +93 -1
  17. package/src/ui/media/_tests/fakeNode.ts +4 -1
  18. package/src/ui/media/callChat.ts +33 -0
  19. package/src/ui/media/callState.ts +125 -9
  20. package/src/ui/media/hooks/useCallSignals.ts +135 -0
  21. package/src/ui/media/hooks/useMeasuredWidth.ts +39 -0
  22. package/src/ui/media/index.ts +41 -1
  23. package/src/ui/media/panels/CallChatPanel.tsx +200 -0
  24. package/src/ui/media/panels/CallParticipantsPanel.tsx +125 -0
  25. package/src/ui/media/panels/CallSidePanel.tsx +152 -0
  26. package/src/ui/media/stage/CallCenterNotice.tsx +89 -0
  27. package/src/ui/media/stage/CallControlsBar.tsx +547 -0
  28. package/src/ui/media/stage/CallHealthPanel.tsx +132 -0
  29. package/src/ui/media/stage/CallToasts.tsx +49 -0
  30. package/src/ui/media/stage/CallTopBar.tsx +257 -0
  31. package/src/ui/media/stage/DeviceMenu.tsx +142 -0
  32. package/src/ui/media/stage/ReactionOverlay.tsx +38 -0
  33. package/src/ui/media/stage/SelfView.tsx +146 -0
  34. package/src/ui/media/stage/VideoTile.tsx +232 -0
  35. package/src/ui/media/stage/callLayout.ts +253 -0
  36. package/src/ui/media/stage/callSignals.ts +91 -0
  37. package/src/ui/media/stage/callSounds.ts +78 -0
  38. package/src/ui/media/stage/stageTheme.ts +50 -0
  39. package/src/ui/styled/forge-utilities.css +13 -0
  40. package/src/ui/styled/members/MemberCallsCalendar.tsx +571 -0
  41. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +135 -0
  42. package/src/ui/styled/members/index.ts +1 -0
@@ -0,0 +1,50 @@
1
+ import type { ResolvedThemeTokens } from "../../theme/ForgeThemeProvider";
2
+
3
+ /**
4
+ * The call stage's palette: a dark stage, with the site's accents.
5
+ *
6
+ * THE one sanctioned exception to "colors only from theme tokens" (a locked
7
+ * product decision): the call screen commits to a near-black chrome on every
8
+ * surface, the way every video product does, because faces read against dark
9
+ * and a light-themed site would otherwise produce a white call screen. The
10
+ * neutral constants live in THIS file and nowhere else; every accent (the
11
+ * speaking ring, active buttons, badges, danger) still flows from the theme,
12
+ * so the stage is recognisably the site's.
13
+ */
14
+ export type StageTokens = {
15
+ /** Near-black stage floor. */
16
+ stageBg: string;
17
+ /** Tiles, panels, dialogs: one step up from the floor. */
18
+ stageSurface: string;
19
+ /** The control bar and top bar: one more step. */
20
+ stageChrome: string;
21
+ stageText: string;
22
+ stageMuted: string;
23
+ stageBorder: string;
24
+ /** The site's primary: speaking ring, active controls, badges. */
25
+ accent: string;
26
+ /** Text on the accent. */
27
+ accentText: string;
28
+ /** The site's alarming color: leave, recording dot, errors. */
29
+ danger: string;
30
+ cornerRadius: number;
31
+ fontFamily?: string | undefined;
32
+ headingFontFamily?: string | undefined;
33
+ };
34
+
35
+ export function stageTokens(tokens: ResolvedThemeTokens): StageTokens {
36
+ return {
37
+ stageBg: "#0c0d10",
38
+ stageSurface: "#17181d",
39
+ stageChrome: "#1e2026",
40
+ stageText: "#f4f5f7",
41
+ stageMuted: "#9aa0ab",
42
+ stageBorder: "#2c2f37",
43
+ accent: tokens.primary,
44
+ accentText: tokens.textPrimary,
45
+ danger: tokens.secondary,
46
+ cornerRadius: tokens.cornerRadius,
47
+ fontFamily: tokens.fontFamily,
48
+ headingFontFamily: tokens.headingFontFamily,
49
+ };
50
+ }
@@ -331,6 +331,9 @@
331
331
  .p-6 {
332
332
  padding: calc(var(--spacing) * 6);
333
333
  }
334
+ .p-10 {
335
+ padding: calc(var(--spacing) * 10);
336
+ }
334
337
  .px-4 {
335
338
  padding-inline: calc(var(--spacing) * 4);
336
339
  }
@@ -465,6 +468,16 @@
465
468
  --tw-ease: var(--ease-out);
466
469
  transition-timing-function: var(--ease-out);
467
470
  }
471
+ .sm\:block {
472
+ @media (width >= 40rem) {
473
+ display: block;
474
+ }
475
+ }
476
+ .sm\:hidden {
477
+ @media (width >= 40rem) {
478
+ display: none;
479
+ }
480
+ }
468
481
  .sm\:grid-cols-2 {
469
482
  @media (width >= 40rem) {
470
483
  grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -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
+ }