@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.
- 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/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
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
|
|
80
|
-
isReady:
|
|
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
|
}
|