@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.
Files changed (46) 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/broadcast/BroadcastPlayer.tsx +84 -7
  40. package/src/ui/styled/broadcast/broadcastStage.spec.ts +32 -0
  41. package/src/ui/styled/broadcast/relayReachable.spec.ts +42 -0
  42. package/src/ui/styled/broadcast/relayReachable.ts +47 -0
  43. package/src/ui/styled/forge-utilities.css +13 -0
  44. package/src/ui/styled/members/MemberCallsCalendar.tsx +571 -0
  45. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +135 -0
  46. package/src/ui/styled/members/index.ts +1 -0
package/src/index.ts CHANGED
@@ -120,6 +120,12 @@ export * from "./data/queries/useAccount";
120
120
  export * from "./data/queries/useMyTickets";
121
121
  export * from "./data/queries/usePassTransfers";
122
122
  export * from "./data/queries/useMyBookings";
123
+ // The member's own call calendar. Beside `useMyBookings` and `useMyTickets`
124
+ // because it answers the same shape of question: what have I got coming up
125
+ // with this artist, and where do I go for it.
126
+ export * from "./data/queries/useMembershipCalls";
127
+ // The host's recording control, for ANY call: it addresses the call, not a domain.
128
+ export * from "./data/queries/useCallRecording";
123
129
  export * from "./data/queries/useEventWaitlist";
124
130
  export * from "./data/queries/useWalletPass";
125
131
  export * from "./data/queries/useFinalize";
@@ -137,6 +143,7 @@ export * from "./data/queries/useWebsite";
137
143
  export * from "./data/queries/useWebsiteAgent";
138
144
  export * from "./data/queries/useCurrencies";
139
145
  export * from "./data/queries/useChat";
146
+ export * from "./data/queries/useCallChat";
140
147
  export * from "./data/queries/useAnalytics";
141
148
  export * from "./data/queries/useCommunity";
142
149
  export * from "./data/queries/useReviews";
@@ -5,3 +5,7 @@ export {
5
5
  type MemberHomeNavKey,
6
6
  type UseMemberHomeNavOptions,
7
7
  } from "./useMemberHomeNav";
8
+
9
+ // The member call calendar's model. Grouping lives here so the two rendering
10
+ // surfaces cannot disagree about which calls are "today".
11
+ export { useMemberCalls, type MemberCalendarDay, type UseMemberCallsOptions } from "./useMemberCalls";
@@ -0,0 +1,137 @@
1
+ import { useMemo, useState } from "react";
2
+ import { useMyMembershipCalls, type MyMembershipCall } from "../../../data/queries/useMembershipCalls";
3
+
4
+ /**
5
+ * Headless model for the member CALENDAR: a month you can page through, with
6
+ * upcoming calls and past ones side by side.
7
+ *
8
+ * ## Why a month and not a list
9
+ *
10
+ * A list can only ever run forwards. A member who missed Tuesday has nowhere to
11
+ * go looking for it, and a member wondering what is on next month cannot ask.
12
+ * The month is the unit people already think in for "when is that call", and
13
+ * paging it is what makes both directions reachable.
14
+ *
15
+ * ## The zone
16
+ *
17
+ * A call carries the creator's `timezone` when they set one. When they did not,
18
+ * the fallback is the VIEWER's zone rather than UTC - the instant is absolute
19
+ * either way, and UTC is a time nobody is in. This deliberately differs from
20
+ * `EventDetail`, whose UTC fallback is about a published page that must read the
21
+ * same to everybody who links to it; a personal calendar has every reason to be
22
+ * local.
23
+ *
24
+ * The GRID, though, is laid out in one zone - the viewer's - because a month has
25
+ * to have one shape. A call at 1am Tokyo on the 1st is on the 1st in the grid if
26
+ * that is where the viewer's clock puts it, and its own row still prints the
27
+ * creator's time.
28
+ */
29
+
30
+ export interface MemberCalendarDay {
31
+ /** `YYYY-MM-DD` in the viewer's zone. Stable key for a cell. */
32
+ key: string;
33
+ date: Date;
34
+ /** False for the leading and trailing days that pad the grid to whole weeks. */
35
+ inMonth: boolean;
36
+ isToday: boolean;
37
+ isPast: boolean;
38
+ calls: MyMembershipCall[];
39
+ }
40
+
41
+ export interface UseMemberCallsOptions {
42
+ limit?: number;
43
+ enabled?: boolean;
44
+ /**
45
+ * Injected so a test can pin it. Not a prop anybody passes in production -
46
+ * "today" is genuinely now, and a stale value would mark the wrong cell.
47
+ */
48
+ now?: Date;
49
+ /** Monday-first. The repo's `utils/recurrence` fixes the same convention. */
50
+ weekStartsOn?: 0 | 1;
51
+ }
52
+
53
+ const dayKey = (date: Date): string => date.toLocaleDateString("en-CA");
54
+
55
+ /** Midnight local on the first of the month `offset` months from `from`. */
56
+ const monthStart = (from: Date, offset = 0): Date =>
57
+ new Date(from.getFullYear(), from.getMonth() + offset, 1);
58
+
59
+ export function useMemberCalls(options: UseMemberCallsOptions = {}) {
60
+ const now = options.now ?? new Date();
61
+ const weekStartsOn = options.weekStartsOn ?? 1;
62
+
63
+ /** Which month is on screen, as an offset from the one containing `now`. */
64
+ const [monthOffset, setMonthOffset] = useState(0);
65
+ const visibleMonth = monthStart(now, monthOffset);
66
+
67
+ /**
68
+ * The window fetched, which is the whole GRID rather than the month.
69
+ *
70
+ * A month view shows the tail of the previous month and the head of the next,
71
+ * and a call in one of those cells is a real call the member can act on.
72
+ * Fetching only the month would render those days permanently empty, which
73
+ * reads as "nothing on" rather than "not asked about".
74
+ */
75
+ const gridStart = useMemo(() => {
76
+ const first = new Date(visibleMonth);
77
+ const shift = (first.getDay() - weekStartsOn + 7) % 7;
78
+ return new Date(first.getFullYear(), first.getMonth(), first.getDate() - shift);
79
+ }, [visibleMonth.getTime(), weekStartsOn]);
80
+
81
+ // Six weeks always, so the grid does not change height as you page - a layout
82
+ // that jumps between months is one the eye has to re-find every click.
83
+ const gridEnd = useMemo(
84
+ () => new Date(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate() + 42),
85
+ [gridStart.getTime()],
86
+ );
87
+
88
+ const query = useMyMembershipCalls({
89
+ from: gridStart,
90
+ to: gridEnd,
91
+ limit: options.limit,
92
+ enabled: options.enabled,
93
+ });
94
+
95
+ const days: MemberCalendarDay[] = useMemo(() => {
96
+ const byDay = new Map<string, MyMembershipCall[]>();
97
+ for (const call of query.data ?? []) {
98
+ // Bucketed by the VIEWER's day, which is the grid's zone. See the header.
99
+ const key = dayKey(new Date(call.startsAt));
100
+ const existing = byDay.get(key);
101
+ if (existing) existing.push(call);
102
+ else byDay.set(key, [call]);
103
+ }
104
+
105
+ const todayKey = dayKey(now);
106
+ return Array.from({ length: 42 }, (_, index) => {
107
+ const date = new Date(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate() + index);
108
+ const key = dayKey(date);
109
+ return {
110
+ key,
111
+ date,
112
+ inMonth: date.getMonth() === visibleMonth.getMonth() && date.getFullYear() === visibleMonth.getFullYear(),
113
+ isToday: key === todayKey,
114
+ isPast: key < todayKey,
115
+ calls: byDay.get(key) ?? [],
116
+ };
117
+ });
118
+ // eslint-disable-next-line react-hooks/exhaustive-deps
119
+ }, [query.data, gridStart.getTime(), visibleMonth.getTime(), now.getTime()]);
120
+
121
+ return {
122
+ /** 42 cells, six whole weeks. */
123
+ days,
124
+ visibleMonth,
125
+ monthLabel: visibleMonth.toLocaleDateString("en-US", { month: "long", year: "numeric" }),
126
+ /** True when the grid is showing the month `now` falls in. */
127
+ isCurrentMonth: monthOffset === 0,
128
+ goToPreviousMonth: () => setMonthOffset((offset) => offset - 1),
129
+ goToNextMonth: () => setMonthOffset((offset) => offset + 1),
130
+ goToToday: () => setMonthOffset(0),
131
+ /** Every call in the fetched window, chronological. Drives the list beneath the grid. */
132
+ calls: query.data ?? [],
133
+ isLoading: query.isLoading,
134
+ error: query.error as Error | null,
135
+ refetch: query.refetch,
136
+ };
137
+ }
@@ -2,15 +2,16 @@ import { useMemo } from "react";
2
2
  import { useGetPostCollections } from "../../../data/queries/usePostCollections";
3
3
  import { useCommunitySpaces } from "../../../data/queries/useCommunity";
4
4
  import { useChatChannels } from "../../../data/queries/useChat";
5
+ import { useMyMembershipCalls } from "../../../data/queries/useMembershipCalls";
5
6
 
6
- export type MemberHomeNavKey = "posts" | "collections" | "spaces" | "chat" | "membership";
7
+ export type MemberHomeNavKey = "posts" | "collections" | "spaces" | "calendar" | "chat" | "membership";
7
8
 
8
9
  export interface MemberHomeNavItem {
9
10
  key: MemberHomeNavKey;
10
11
  label: string;
11
12
  /** Absolute in-site href (already run through `buildHref` when supplied). */
12
13
  href: string;
13
- /** Conditional tabs (Collections/Spaces/Chat) are hidden until the profile has content for them. */
14
+ /** Conditional tabs (Collections/Spaces/Calendar/Chat) are hidden until the profile has content for them. */
14
15
  visible: boolean;
15
16
  /** Whether `currentPath` falls inside this tab. */
16
17
  active: boolean;
@@ -52,10 +53,29 @@ export function useMemberHomeNav(opts: UseMemberHomeNavOptions = {}) {
52
53
  const collectionsQ = useGetPostCollections({ page: 1 });
53
54
  const spacesQ = useCommunitySpaces();
54
55
  const channelsQ = useChatChannels();
56
+ /**
57
+ * Upcoming calls this member may join. Already filtered by the server against
58
+ * their membership, so a non-empty list IS the visibility rule - there is no
59
+ * second "can they get in" check to make here, and no way for the tab to
60
+ * appear over an empty page.
61
+ */
62
+ /**
63
+ * ANY call this viewer may see, ever - not just an upcoming one.
64
+ *
65
+ * The Calendar is a month you can page backwards through, so it has something
66
+ * to show from the moment the creator has ever run a call. Keying the tab on
67
+ * upcoming calls alone hid the whole history the hour the last one ended,
68
+ * which is precisely when somebody goes looking for the recording.
69
+ *
70
+ * `limit: 1` because this is an existence check: the calendar fetches its own
71
+ * window, and the answer here is only "draw the tab or not".
72
+ */
73
+ const callsQ = useMyMembershipCalls({ from: new Date(0), limit: 1 });
55
74
 
56
75
  const hasCollections = (collectionsQ.data?.data?.length ?? 0) > 0;
57
76
  const hasSpaces = (spacesQ.data?.length ?? 0) > 0;
58
77
  const hasChannels = (channelsQ.data?.length ?? 0) > 0;
78
+ const hasCalls = (callsQ.data?.length ?? 0) > 0;
59
79
 
60
80
  const items: MemberHomeNavItem[] = useMemo(() => {
61
81
  const isUnder = (segment: string) =>
@@ -64,19 +84,23 @@ export function useMemberHomeNav(opts: UseMemberHomeNavOptions = {}) {
64
84
  { key: "posts", label: "Posts", path: basePath, visible: true, external: false, active: currentPath === normalize(basePath) },
65
85
  { key: "collections", label: "Collections", path: `${basePath}/collections`, visible: hasCollections, external: false, active: isUnder("collections") },
66
86
  { key: "spaces", label: "Spaces", path: `${basePath}/spaces`, visible: hasSpaces, external: false, active: isUnder("spaces") },
87
+ // Scheduled calls. Sits above Chat because a call has a TIME on it: it is
88
+ // the one tab whose content stops being useful if it is found late.
89
+ { key: "calendar", label: "Calendar", path: `${basePath}/calendar`, visible: hasCalls, external: false, active: isUnder("calendar") },
67
90
  { key: "chat", label: "Chat", path: `${basePath}/chat`, visible: hasChannels, external: false, active: isUnder("chat") },
68
91
  { key: "membership", label: "My membership", path: membershipHref, visible: true, external: true, active: false },
69
92
  ];
70
93
  return defs.map(({ path, ...d }) => ({ ...d, href: buildHref(path) }));
71
94
  // eslint-disable-next-line react-hooks/exhaustive-deps
72
- }, [basePath, membershipHref, currentPath, hasCollections, hasSpaces, hasChannels]);
95
+ }, [basePath, membershipHref, currentPath, hasCollections, hasSpaces, hasCalls, hasChannels]);
73
96
 
74
97
  return {
75
98
  items,
76
99
  /** Visible items only — what a shell actually renders. */
77
100
  visibleItems: items.filter((i) => i.visible),
78
101
  activeKey: items.find((i) => i.active)?.key ?? null,
79
- /** True once the three visibility queries have settled (avoids tab flash). */
80
- isReady: !collectionsQ.isLoading && !spacesQ.isLoading && !channelsQ.isLoading,
102
+ /** True once every visibility query has settled (avoids tab flash). */
103
+ isReady:
104
+ !collectionsQ.isLoading && !spacesQ.isLoading && !callsQ.isLoading && !channelsQ.isLoading,
81
105
  };
82
106
  }