@tribe-nest/forge 3.42.1 → 3.50.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 +212 -0
- package/src/i18n/de.json +74 -1
- package/src/i18n/en.json +74 -1
- package/src/index.ts +7 -0
- package/src/types/models.ts +58 -5
- package/src/ui/headless/memberHome/index.ts +4 -0
- package/src/ui/headless/memberHome/useMemberCalls.ts +150 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +29 -5
- package/src/ui/index.ts +3 -0
- 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/CohortPage.tsx +15 -1
- package/src/ui/styled/CourseAccess.tsx +19 -6
- package/src/ui/styled/CourseCallsSection.tsx +99 -0
- package/src/ui/styled/FormRenderer.tsx +14 -4
- package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +163 -0
- package/src/ui/styled/forge-utilities.css +13 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +589 -0
- package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +135 -0
- package/src/ui/styled/members/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tribe-nest/forge",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.50.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"@stripe/react-stripe-js": "^3.8.0",
|
|
32
32
|
"@stripe/stripe-js": "^7.6.1",
|
|
33
33
|
"@tanstack/react-query": "^5.62.7",
|
|
34
|
-
"@tribe-nest/media-client": "^0.
|
|
34
|
+
"@tribe-nest/media-client": "^0.4.0",
|
|
35
35
|
"@tribe-nest/media-protocol": "^1.0.0",
|
|
36
36
|
"axios": "^1.7.9",
|
|
37
37
|
"lucide-react": "^0.510.0",
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
import type { CallChatMessage, CallStageChat } from "../../ui/media/callChat";
|
|
5
|
+
import { usePublicAuth } from "../../contexts/PublicAuthContext";
|
|
6
|
+
import { useForge } from "../../provider/ForgeProvider";
|
|
7
|
+
import { useChatSocket, type ChatMessage } from "./useChat";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The PUBLIC lane's `CallStageChat` adapter: the reference implementation of
|
|
11
|
+
* the shape the stage renders.
|
|
12
|
+
*
|
|
13
|
+
* One GET to the CALL's address (`/video-calls/:id/chat`) resolves the seat
|
|
14
|
+
* and the channel in one move; everything after that is the ordinary chat
|
|
15
|
+
* socket. Admin composes its own adapter against the dashboard lane; the
|
|
16
|
+
* SHAPE is the seam, so neither surface knows how the other authenticates.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
type CallChatChannel = { channelId: string; role: string; title: string; unreadCount: number };
|
|
20
|
+
|
|
21
|
+
type ReactionRow = { emoji: string; count: number; reactedByMe: boolean };
|
|
22
|
+
type MessageWithReactions = ChatMessage & { reactions?: ReactionRow[] };
|
|
23
|
+
|
|
24
|
+
const senderName = (message: MessageWithReactions): string =>
|
|
25
|
+
[message.senderFirstName, message.senderLastName].filter(Boolean).join(" ").trim() || message.senderAccountId;
|
|
26
|
+
|
|
27
|
+
export function useCallChat(callId: string | undefined): CallStageChat | undefined {
|
|
28
|
+
const { client, profileId } = useForge();
|
|
29
|
+
const { user } = usePublicAuth();
|
|
30
|
+
const queryClient = useQueryClient();
|
|
31
|
+
const socket = useChatSocket(!!callId);
|
|
32
|
+
const [typingNames, setTypingNames] = useState<readonly string[]>([]);
|
|
33
|
+
const typingTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
34
|
+
|
|
35
|
+
const channelQuery = useQuery<CallChatChannel>({
|
|
36
|
+
queryKey: ["call-chat-channel", callId],
|
|
37
|
+
queryFn: async () => {
|
|
38
|
+
const res = await client.get(`/video-calls/${callId}/chat`);
|
|
39
|
+
return res.data;
|
|
40
|
+
},
|
|
41
|
+
enabled: !!callId && !!client,
|
|
42
|
+
});
|
|
43
|
+
const channelId = channelQuery.data?.channelId;
|
|
44
|
+
|
|
45
|
+
const messagesKey = useMemo(() => ["call-chat-messages", channelId] as const, [channelId]);
|
|
46
|
+
const messagesQuery = useQuery<MessageWithReactions[]>({
|
|
47
|
+
queryKey: messagesKey,
|
|
48
|
+
queryFn: async () => {
|
|
49
|
+
// The schema wants a profileId like every member-lane read; ACCESS is
|
|
50
|
+
// the seat's (`canAccessChannel`), so the value only satisfies zod.
|
|
51
|
+
const res = await client.get(`/public/chat/channels/${channelId}/messages`, {
|
|
52
|
+
params: { profileId, page: 1, limit: 100 },
|
|
53
|
+
});
|
|
54
|
+
// The endpoint answers newest first; the panel renders oldest first.
|
|
55
|
+
return [...(res.data.data as MessageWithReactions[])].reverse();
|
|
56
|
+
},
|
|
57
|
+
enabled: !!channelId && !!client,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Join the socket room and follow it: new messages append, reactions
|
|
61
|
+
// replace one message's aggregate, typing decays after two seconds.
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!socket || !channelId) return undefined;
|
|
64
|
+
socket.emit("chatJoinChannel", { channelId });
|
|
65
|
+
|
|
66
|
+
const onMessage = (envelope: { event: string; data: Record<string, unknown> }) => {
|
|
67
|
+
if ((envelope.data as { channelId?: string }).channelId !== channelId) return;
|
|
68
|
+
if (envelope.event === "chatNewMessage") {
|
|
69
|
+
const message = envelope.data.message as MessageWithReactions | undefined;
|
|
70
|
+
if (!message || message.parentMessageId) return;
|
|
71
|
+
queryClient.setQueryData<MessageWithReactions[]>(messagesKey, (current = []) =>
|
|
72
|
+
current.some((m) => m.id === message.id) ? current : [...current, message],
|
|
73
|
+
);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (envelope.event === "chatMessageReaction") {
|
|
77
|
+
const { messageId, reactions, accountId, emoji, action } = envelope.data as {
|
|
78
|
+
messageId: string;
|
|
79
|
+
reactions: ReactionRow[];
|
|
80
|
+
accountId: string;
|
|
81
|
+
emoji: string;
|
|
82
|
+
action: "added" | "removed";
|
|
83
|
+
};
|
|
84
|
+
queryClient.setQueryData<MessageWithReactions[]>(messagesKey, (current = []) =>
|
|
85
|
+
current.map((message) => {
|
|
86
|
+
if (message.id !== messageId) return message;
|
|
87
|
+
// The broadcast aggregate is neutral; our OWN toggle is folded
|
|
88
|
+
// back in so reactedByMe stays true across everyone's updates.
|
|
89
|
+
const mine = accountId === user?.id;
|
|
90
|
+
return {
|
|
91
|
+
...message,
|
|
92
|
+
reactions: reactions.map((row) => ({
|
|
93
|
+
...row,
|
|
94
|
+
reactedByMe:
|
|
95
|
+
(mine && row.emoji === emoji && action === "added") ||
|
|
96
|
+
(!(mine && row.emoji === emoji && action === "removed") &&
|
|
97
|
+
(message.reactions?.find((r) => r.emoji === row.emoji)?.reactedByMe ?? false)),
|
|
98
|
+
})),
|
|
99
|
+
};
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (envelope.event === "chatUserTyping") {
|
|
105
|
+
const { accountId, name } = envelope.data as { accountId: string; name: string };
|
|
106
|
+
if (accountId === user?.id) return;
|
|
107
|
+
setTypingNames([name]);
|
|
108
|
+
if (typingTimer.current) clearTimeout(typingTimer.current);
|
|
109
|
+
typingTimer.current = setTimeout(() => setTypingNames([]), 2000);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
socket.on("message", onMessage);
|
|
114
|
+
return () => {
|
|
115
|
+
socket.off("message", onMessage);
|
|
116
|
+
socket.emit("chatLeaveChannel", { channelId });
|
|
117
|
+
if (typingTimer.current) clearTimeout(typingTimer.current);
|
|
118
|
+
};
|
|
119
|
+
}, [socket, channelId, messagesKey, queryClient, user?.id]);
|
|
120
|
+
|
|
121
|
+
const sendMessage = useCallback(
|
|
122
|
+
(text: string) => {
|
|
123
|
+
if (!socket || !channelId) return;
|
|
124
|
+
socket.emit("chatSendMessage", { channelId, content: text });
|
|
125
|
+
},
|
|
126
|
+
[socket, channelId],
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const reactToMessage = useCallback(
|
|
130
|
+
(messageId: string, emoji: string) => {
|
|
131
|
+
if (!socket || !channelId) return;
|
|
132
|
+
socket.emit("chatReactMessage", { channelId, messageId, emoji });
|
|
133
|
+
},
|
|
134
|
+
[socket, channelId],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const onTyping = useCallback(() => {
|
|
138
|
+
if (!socket || !channelId) return;
|
|
139
|
+
socket.emit("chatTyping", { channelId });
|
|
140
|
+
}, [socket, channelId]);
|
|
141
|
+
|
|
142
|
+
return useMemo<CallStageChat | undefined>(() => {
|
|
143
|
+
if (!callId) return undefined;
|
|
144
|
+
const messages: CallChatMessage[] = (messagesQuery.data ?? []).map((message) => ({
|
|
145
|
+
id: message.id,
|
|
146
|
+
content: message.content,
|
|
147
|
+
senderName: senderName(message),
|
|
148
|
+
senderKey: message.senderAccountId,
|
|
149
|
+
at: message.createdAt,
|
|
150
|
+
reactions: message.reactions ?? [],
|
|
151
|
+
}));
|
|
152
|
+
return {
|
|
153
|
+
ready: !!channelId && !messagesQuery.isLoading,
|
|
154
|
+
messages,
|
|
155
|
+
sendMessage,
|
|
156
|
+
reactToMessage,
|
|
157
|
+
typingNames,
|
|
158
|
+
onTyping,
|
|
159
|
+
currentSenderKey: user?.id ?? null,
|
|
160
|
+
};
|
|
161
|
+
}, [callId, channelId, messagesQuery.data, messagesQuery.isLoading, sendMessage, reactToMessage, typingNames, onTyping, user?.id]);
|
|
162
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { useForge } from "../../provider/ForgeProvider";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The host's recording control for ONE call, whatever that call is attached to.
|
|
7
|
+
*
|
|
8
|
+
* Recording lives on the call address (`/video-calls/:publicId`), not on any
|
|
9
|
+
* domain, because a coaching booking, a consultation and a scheduled call all
|
|
10
|
+
* reach the same screen and `call_recordings` is keyed on the media room. So
|
|
11
|
+
* this hook works for every kind of call there will ever be, and nothing here
|
|
12
|
+
* knows what a membership or a cohort is.
|
|
13
|
+
*
|
|
14
|
+
* ## What the server owns
|
|
15
|
+
*
|
|
16
|
+
* - **Whether this viewer may control it.** `canControl` comes back from the
|
|
17
|
+
* call read and is a RENDERING hint; the server resolves the seat again on
|
|
18
|
+
* every start and stop and is what actually refuses. A non-host asking gets
|
|
19
|
+
* the same 404 every other refusal on this address gives.
|
|
20
|
+
* - **Deduplication.** A second start returns `already_recording` rather than an
|
|
21
|
+
* error, and two hosts pressing Record in the same instant are settled by a
|
|
22
|
+
* unique index. So this does not need to guard against double-clicks for
|
|
23
|
+
* correctness - `pending` is only so the button does not look dead.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export type CallRecordingState = {
|
|
27
|
+
/** `recording` while the egress runs, `processing` while it is composed, else null. */
|
|
28
|
+
status: "recording" | "processing" | null;
|
|
29
|
+
isRecording: boolean;
|
|
30
|
+
startedAt: string | null;
|
|
31
|
+
canControl: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function useCallRecording(callPublicId: string | undefined) {
|
|
35
|
+
const { client } = useForge();
|
|
36
|
+
const queryClient = useQueryClient();
|
|
37
|
+
const [pending, setPending] = useState(false);
|
|
38
|
+
|
|
39
|
+
const queryKey = ["video-call", callPublicId, "recording"] as const;
|
|
40
|
+
|
|
41
|
+
const query = useQuery<CallRecordingState>({
|
|
42
|
+
queryKey,
|
|
43
|
+
queryFn: async () => (await client.get(`/video-calls/${callPublicId}`)).data.recording,
|
|
44
|
+
enabled: !!callPublicId && !!client,
|
|
45
|
+
/**
|
|
46
|
+
* A host who started a recording wants to see it start; everybody else wants
|
|
47
|
+
* to know one is running. The room's own signal drives the participant-facing
|
|
48
|
+
* indicator, so this is only keeping the BUTTON honest - a slow poll is
|
|
49
|
+
* enough, and a fast one on every call page would be a request per viewer
|
|
50
|
+
* per few seconds for the whole call.
|
|
51
|
+
*/
|
|
52
|
+
refetchInterval: 30_000,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const act = useCallback(
|
|
56
|
+
async (path: string) => {
|
|
57
|
+
if (!callPublicId) return;
|
|
58
|
+
setPending(true);
|
|
59
|
+
try {
|
|
60
|
+
const res = await client.post(`/video-calls/${callPublicId}${path}`, {});
|
|
61
|
+
// The response IS the new state, so the button flips without waiting for
|
|
62
|
+
// the poll. A refetch as well would race it.
|
|
63
|
+
queryClient.setQueryData(queryKey, res.data);
|
|
64
|
+
} finally {
|
|
65
|
+
setPending(false);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
69
|
+
[callPublicId, client, queryClient],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
status: query.data?.status ?? null,
|
|
74
|
+
isRecording: query.data?.isRecording ?? false,
|
|
75
|
+
canControl: query.data?.canControl ?? false,
|
|
76
|
+
pending,
|
|
77
|
+
start: useCallback(() => void act("/recording"), [act]),
|
|
78
|
+
stop: useCallback(() => void act("/recording/stop"), [act]),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import { useForge } from "../../provider/ForgeProvider";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The viewer's own call calendar (`GET /public/scheduled-calls/mine`).
|
|
6
|
+
*
|
|
7
|
+
* Lives in Forge rather than in either app because BOTH rendering surfaces need
|
|
8
|
+
* exactly this - the site-starter's `/i/members/calendar` and the client app's
|
|
9
|
+
* `/i/members/calendar`, and now the classroom and cohort pages on both - and a
|
|
10
|
+
* second copy is how the two drift, as `useMyTickets` and `useMyBookings`
|
|
11
|
+
* already document.
|
|
12
|
+
*
|
|
13
|
+
* ## It was never only memberships
|
|
14
|
+
*
|
|
15
|
+
* The server resolves the caller through every registered audience kind, so a
|
|
16
|
+
* course enrolment and a cohort seat come back alongside membership tiers. The
|
|
17
|
+
* endpoint answered on `/public/membership-calls/mine` for a year while doing
|
|
18
|
+
* exactly that; that path is kept as an alias for sites published against older
|
|
19
|
+
* Forge builds, and this file now calls the honest one. The names here keep the
|
|
20
|
+
* `MembershipCall` spelling because they are PUBLISHED exports that a code site
|
|
21
|
+
* may import; `useMyScheduledCalls` is the alias to write new code against.
|
|
22
|
+
*
|
|
23
|
+
* ## What the server owns and this file does not mirror
|
|
24
|
+
*
|
|
25
|
+
* - **Who may see a call.** Resolved from the SESSION against the artist's
|
|
26
|
+
* membership; `profileId` here names the artist, never the viewer. A call the
|
|
27
|
+
* member cannot join does not appear, so there is no client-side filter to get
|
|
28
|
+
* wrong. An empty list is the honest answer for a lapsed member.
|
|
29
|
+
* - **Whether the room is open.** `isOpen`, `opensAt` and `closesAt` come back
|
|
30
|
+
* on every row and are computed from the same window the JOIN enforces.
|
|
31
|
+
* Re-deriving "has it started yet" here would give two answers to one
|
|
32
|
+
* question, and the wrong one is the one the member acts on.
|
|
33
|
+
* - **Where the call happens.** `callPublicId` addresses the generic
|
|
34
|
+
* `/i/video-calls/:id` page. The id addresses a CALL; the server decides what
|
|
35
|
+
* it is attached to and what seat this viewer holds in it.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export type MembershipCallParticipation = "open" | "host_only";
|
|
39
|
+
|
|
40
|
+
export interface MyMembershipCall {
|
|
41
|
+
id: string;
|
|
42
|
+
title: string;
|
|
43
|
+
description: string | null;
|
|
44
|
+
/** ISO. */
|
|
45
|
+
startsAt: string;
|
|
46
|
+
endsAt: string;
|
|
47
|
+
/** The creator's IANA zone, when they set one. Render the time in it, not the viewer's guess. */
|
|
48
|
+
timezone: string | null;
|
|
49
|
+
participation: MembershipCallParticipation;
|
|
50
|
+
/**
|
|
51
|
+
* Which side of the call this viewer is on. Derived by the server, never
|
|
52
|
+
* claimed.
|
|
53
|
+
*
|
|
54
|
+
* `attendee`, not `member`: the seat-layer vocabulary is closed across every
|
|
55
|
+
* call kind, because the participant identity is built from it and the seat
|
|
56
|
+
* cap reads that grammar. The UI is free to say "member".
|
|
57
|
+
*/
|
|
58
|
+
role: "host" | "attendee";
|
|
59
|
+
/**
|
|
60
|
+
* Who the call is for, already labelled by the server.
|
|
61
|
+
*
|
|
62
|
+
* Generic `(audienceType, audienceId, label)` rows rather than tiers, because
|
|
63
|
+
* one call can be addressed to a tier, to every member, or later to a course
|
|
64
|
+
* or a cohort. Render `label`; a client never has to know which table an
|
|
65
|
+
* `audienceId` points at.
|
|
66
|
+
*/
|
|
67
|
+
audiences: { id: string; audienceType: string; audienceId: string | null; label: string }[];
|
|
68
|
+
/** The window a ticket may be minted in. Only NEW joins are refused after `closesAt`. */
|
|
69
|
+
opensAt: string;
|
|
70
|
+
closesAt: string;
|
|
71
|
+
isOpen: boolean;
|
|
72
|
+
/** The `/i/video-calls/:id` address. Null only if the room row is missing, which is a bug. */
|
|
73
|
+
callPublicId: string | null;
|
|
74
|
+
/**
|
|
75
|
+
* What there is to rewatch, if anything. Null for a call with no recording.
|
|
76
|
+
*
|
|
77
|
+
* `processing` means "come back shortly", NOT an error: the video is assembled
|
|
78
|
+
* after the call ends, one call at a time, so a member looking straight
|
|
79
|
+
* afterwards sees it for a few minutes.
|
|
80
|
+
*/
|
|
81
|
+
recording: {
|
|
82
|
+
id: string;
|
|
83
|
+
status: "processing" | "ready";
|
|
84
|
+
durationSeconds: number | null;
|
|
85
|
+
/** Signed per request and short-lived. Null while `processing`. */
|
|
86
|
+
watchUrl: string | null;
|
|
87
|
+
} | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const membershipCallKeys = {
|
|
91
|
+
mine: (profileId: string | undefined) => ["membership-calls", "mine", profileId] as const,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param options.from Inclusive lower bound. Absent means "from now on", which is
|
|
96
|
+
* what a caller wanting only the next few calls sends.
|
|
97
|
+
* @param options.to Inclusive upper bound. Absent means "however far ahead".
|
|
98
|
+
* @param options.courseId Show only calls addressed to this course. See below.
|
|
99
|
+
* @param options.cohortId Show only calls addressed to this cohort. See below.
|
|
100
|
+
*
|
|
101
|
+
* ## `courseId` / `cohortId` narrow, they do not unlock
|
|
102
|
+
*
|
|
103
|
+
* Absent, this is the viewer's whole calendar and behaves as it always has.
|
|
104
|
+
* Present, the server INTERSECTS them with the audience keys it resolved for
|
|
105
|
+
* this caller, so they can only remove rows. Passing a course the viewer has no
|
|
106
|
+
* access to returns nothing, which is the same answer a stranger gets.
|
|
107
|
+
*
|
|
108
|
+
* The classroom and the cohort page pass them because a student enrolled in two
|
|
109
|
+
* cohorts opening one of them must not be shown the other's sessions.
|
|
110
|
+
*/
|
|
111
|
+
export function useMyMembershipCalls(options?: {
|
|
112
|
+
from?: Date;
|
|
113
|
+
to?: Date;
|
|
114
|
+
limit?: number;
|
|
115
|
+
enabled?: boolean;
|
|
116
|
+
courseId?: string | null;
|
|
117
|
+
cohortId?: string | null;
|
|
118
|
+
}) {
|
|
119
|
+
const { client, profileId } = useForge();
|
|
120
|
+
const from = options?.from?.toISOString();
|
|
121
|
+
const to = options?.to?.toISOString();
|
|
122
|
+
const courseId = options?.courseId || undefined;
|
|
123
|
+
const cohortId = options?.cohortId || undefined;
|
|
124
|
+
return useQuery<MyMembershipCall[]>({
|
|
125
|
+
// The WINDOW is part of the key: paging back a month is a different question
|
|
126
|
+
// with a different answer, and sharing one cache entry would show February's
|
|
127
|
+
// calls in March until a refetch landed. The SCOPE is in it for the same
|
|
128
|
+
// reason - the classroom's answer and the members calendar's answer are two
|
|
129
|
+
// different lists, and sharing one entry would put one course's sessions on
|
|
130
|
+
// another course's page for as long as the cache held.
|
|
131
|
+
queryKey: [
|
|
132
|
+
...membershipCallKeys.mine(profileId),
|
|
133
|
+
from ?? "now",
|
|
134
|
+
to ?? "open",
|
|
135
|
+
courseId ?? "any-course",
|
|
136
|
+
cohortId ?? "any-cohort",
|
|
137
|
+
],
|
|
138
|
+
queryFn: async () => {
|
|
139
|
+
const res = await client.get("/public/scheduled-calls/mine", {
|
|
140
|
+
params: { profileId, from, to, limit: options?.limit, courseId, cohortId },
|
|
141
|
+
});
|
|
142
|
+
return res.data;
|
|
143
|
+
},
|
|
144
|
+
enabled: !!profileId && !!client && options?.enabled !== false,
|
|
145
|
+
/**
|
|
146
|
+
* A call opens fifteen minutes before it starts, and the member watching
|
|
147
|
+
* this page is waiting for exactly that. Without a refetch the Join button
|
|
148
|
+
* appears only when they happen to reload, which on the one page whose whole
|
|
149
|
+
* job is "can I get in yet" is the wrong behaviour.
|
|
150
|
+
*/
|
|
151
|
+
refetchInterval: 60_000,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** One past call the viewer could attend, and what has become of its video. */
|
|
156
|
+
export interface MyMembershipCallRecording {
|
|
157
|
+
callId: string;
|
|
158
|
+
title: string;
|
|
159
|
+
description: string | null;
|
|
160
|
+
/** ISO. */
|
|
161
|
+
startsAt: string;
|
|
162
|
+
endsAt: string;
|
|
163
|
+
timezone: string | null;
|
|
164
|
+
recordingId: string;
|
|
165
|
+
/**
|
|
166
|
+
* `processing` is not an error and must not be rendered as one. The video is
|
|
167
|
+
* assembled from every participant's stream after the call ends, one call at a
|
|
168
|
+
* time, so a member who looks straight afterwards sees this for a few minutes.
|
|
169
|
+
* A `failed` recording never reaches the client at all.
|
|
170
|
+
*/
|
|
171
|
+
status: "processing" | "ready";
|
|
172
|
+
durationSeconds: number | null;
|
|
173
|
+
/**
|
|
174
|
+
* Signed per request and short-lived, so it is not stored and not shareable
|
|
175
|
+
* for long. Null while `status` is `processing`.
|
|
176
|
+
*/
|
|
177
|
+
watchUrl: string | null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function useMyMembershipCallRecordings(options?: { limit?: number; enabled?: boolean }) {
|
|
181
|
+
const { client, profileId } = useForge();
|
|
182
|
+
return useQuery<MyMembershipCallRecording[]>({
|
|
183
|
+
queryKey: ["membership-calls", "recordings", profileId],
|
|
184
|
+
queryFn: async () => {
|
|
185
|
+
const res = await client.get("/public/scheduled-calls/mine/recordings", {
|
|
186
|
+
params: { profileId, limit: options?.limit },
|
|
187
|
+
});
|
|
188
|
+
return res.data;
|
|
189
|
+
},
|
|
190
|
+
enabled: !!profileId && !!client && options?.enabled !== false,
|
|
191
|
+
/**
|
|
192
|
+
* Slower than the upcoming list: nothing here changes on a fifteen-minute
|
|
193
|
+
* boundary, and the only transition worth catching is `processing` ->
|
|
194
|
+
* `ready`, which takes minutes rather than seconds.
|
|
195
|
+
*/
|
|
196
|
+
refetchInterval: 120_000,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The honest names for the two hooks above.
|
|
202
|
+
*
|
|
203
|
+
* Aliases rather than renames: `useMyMembershipCalls` and its types are
|
|
204
|
+
* PUBLISHED exports that a code-built site may already import, and breaking
|
|
205
|
+
* those to fix a word would cost a site owner a build for nothing. New code
|
|
206
|
+
* should reach for these; the old names stay for as long as anything imports
|
|
207
|
+
* them.
|
|
208
|
+
*/
|
|
209
|
+
export const useMyScheduledCalls = useMyMembershipCalls;
|
|
210
|
+
export const useMyScheduledCallRecordings = useMyMembershipCallRecordings;
|
|
211
|
+
export type MyScheduledCall = MyMembershipCall;
|
|
212
|
+
export type MyScheduledCallRecording = MyMembershipCallRecording;
|
package/src/i18n/de.json
CHANGED
|
@@ -201,23 +201,83 @@
|
|
|
201
201
|
"forge.bundle_confirmation.view_orders": "Bestellungen ansehen",
|
|
202
202
|
"forge.call_stage.camera_start": "Kamera starten",
|
|
203
203
|
"forge.call_stage.camera_stop": "Kamera stoppen",
|
|
204
|
+
"forge.call_stage.chat": "Chat",
|
|
205
|
+
"forge.call_stage.chat_empty": "Noch keine Nachrichten. Sag hallo.",
|
|
206
|
+
"forge.call_stage.chat_loading": "Chat wird geöffnet…",
|
|
207
|
+
"forge.call_stage.chat_placeholder": "Nachricht an alle…",
|
|
208
|
+
"forge.call_stage.chat_send": "Senden",
|
|
209
|
+
"forge.call_stage.chat_typing": "{name} schreibt…",
|
|
210
|
+
"forge.call_stage.close_panel": "Leiste schließen",
|
|
211
|
+
"forge.call_stage.dismiss": "Ausblenden",
|
|
212
|
+
"forge.call_stage.everyone_left": "Alle anderen haben den Anruf verlassen",
|
|
213
|
+
"forge.call_stage.hand_raised_by": "{name} hat die Hand gehoben",
|
|
214
|
+
"forge.call_stage.hands_raised_named": "{name} +{count}",
|
|
215
|
+
"forge.call_stage.hands_raised_open": "Erhobene Hände: {count}. Personenpanel öffnen.",
|
|
204
216
|
"forge.call_stage.head_count": "In diesem Anruf: {count}",
|
|
217
|
+
"forge.call_stage.health_close": "Anrufqualität schließen",
|
|
218
|
+
"forge.call_stage.health_estimate": "Bandbreitenschätzung",
|
|
219
|
+
"forge.call_stage.health_measuring": "Wird gemessen...",
|
|
220
|
+
"forge.call_stage.health_no_video": "Keine Videokacheln zum Messen.",
|
|
221
|
+
"forge.call_stage.health_sending": "Senden",
|
|
222
|
+
"forge.call_stage.health_title": "Anrufqualität",
|
|
223
|
+
"forge.call_stage.health_toggle": "Anrufqualität",
|
|
205
224
|
"forge.call_stage.help_camera_stop": "Dein Bild wird nicht mehr gesendet: Die anderen sehen stattdessen deinen Namen. Der Browser hält die Kamera geöffnet, damit das erneute Starten sofort geht und nicht ein zweites Mal um Erlaubnis fragt. Deshalb kann die Kameraleuchte anbleiben, bis du den Anruf verlässt.",
|
|
206
225
|
"forge.call_stage.help_camera_stop_label": "Was macht „Kamera stoppen“?",
|
|
226
|
+
"forge.call_stage.help_health": "Jede Zeile zeigt eine Kachel: die angefragte und die empfangene Qualität (q Viertel, h Hälfte, f voll), danach das dekodierte Bild. Eine ROTE Zeile empfängt weniger als angefragt. Die Bandbreitenschätzung ist, was der Server dieser Verbindung zutraut; bleibt sie niedrig, bleiben alle Kacheln klein. Unter Senden hat jede eigene ausgehende Qualitätsstufe einen Wert; 0 bedeutet, dass diese Stufe den Server gar nicht erreicht.",
|
|
227
|
+
"forge.call_stage.help_health_label": "Wie lese ich das?",
|
|
207
228
|
"forge.call_stage.help_hear_not_see": "Eine Kachel mit Namen, aber ohne Bild bedeutet, dass diese Person ihre Kamera ausgeschaltet hat oder dass der Anruf so voll ist, dass nur die gerade sprechenden Personen in voller Größe übertragen werden. Beides ist normal, und der Ton ist davon nicht betroffen.",
|
|
208
229
|
"forge.call_stage.help_hear_not_see_label": "Warum höre ich jemanden, sehe ihn aber nicht?",
|
|
230
|
+
"forge.call_stage.help_record": "Die Aufzeichnung startet jetzt und erfasst alle im Gespräch. Nach dem Ende wird daraus ein Video erstellt, das einige Minuten dauert, und erscheint dann für alle, die an diesem Gespräch teilnehmen konnten.",
|
|
231
|
+
"forge.call_stage.help_record_label": "Wer kann die Aufzeichnung sehen?",
|
|
209
232
|
"forge.call_stage.help_screen": "Was auch immer du im Browser-Dialog auswählst, live an alle im Anruf. Deine Kamera läuft dabei weiter, statt ersetzt zu werden, sodass dich die anderen weiterhin sehen können, während du präsentierst.",
|
|
210
233
|
"forge.call_stage.help_screen_label": "Was wird beim Teilen meines Bildschirms gesendet?",
|
|
234
|
+
"forge.call_stage.hide_self_view": "Eigenes Bild ausblenden",
|
|
211
235
|
"forge.call_stage.leave": "Verlassen",
|
|
236
|
+
"forge.call_stage.leave_recording_body": "Dieses Gespräch wird aufgezeichnet. Wenn Sie gehen, ohne zu stoppen, läuft die Aufzeichnung weiter, bis alle gegangen sind.",
|
|
237
|
+
"forge.call_stage.leave_recording_cancel": "Im Gespräch bleiben",
|
|
238
|
+
"forge.call_stage.leave_recording_keep": "Gehen, Aufzeichnung weiterlaufen lassen",
|
|
239
|
+
"forge.call_stage.leave_recording_stop": "Aufzeichnung beenden und gehen",
|
|
240
|
+
"forge.call_stage.leave_recording_title": "Sie zeichnen noch auf",
|
|
212
241
|
"forge.call_stage.listening": "(hört zu)",
|
|
213
242
|
"forge.call_stage.lost_camera": "Deine Kamera wurde ausgeschaltet, als die Verbindung abbrach. Starte sie erneut, um wieder gesehen zu werden.",
|
|
214
243
|
"forge.call_stage.lost_microphone": "Dein Mikrofon wurde ausgeschaltet, als die Verbindung abbrach. Hebe die Stummschaltung auf, um wieder gehört zu werden.",
|
|
215
244
|
"forge.call_stage.lost_screen": "Deine Bildschirmfreigabe wurde beendet, als die Verbindung abbrach. Teile ihn erneut, um fortzufahren.",
|
|
245
|
+
"forge.call_stage.lower_all_hands": "Alle Hände senken",
|
|
246
|
+
"forge.call_stage.lower_hand": "Hand senken",
|
|
247
|
+
"forge.call_stage.lower_hand_of": "Senken",
|
|
248
|
+
"forge.call_stage.many_joined": "{count} Personen kamen oder gingen",
|
|
216
249
|
"forge.call_stage.mute": "Stummschalten",
|
|
250
|
+
"forge.call_stage.muted_talking": "Du scheinst zu sprechen, bist aber stummgeschaltet.",
|
|
251
|
+
"forge.call_stage.no_devices": "Keine Geräte gefunden",
|
|
252
|
+
"forge.call_stage.page_next": "Nächste Seite",
|
|
253
|
+
"forge.call_stage.page_of": "{page} / {pages}",
|
|
254
|
+
"forge.call_stage.page_prev": "Vorherige Seite",
|
|
255
|
+
"forge.call_stage.peer_joined": "{name} ist beigetreten",
|
|
256
|
+
"forge.call_stage.peer_left": "{name} hat verlassen",
|
|
257
|
+
"forge.call_stage.people": "Personen",
|
|
258
|
+
"forge.call_stage.pick_camera": "Kamera wählen",
|
|
259
|
+
"forge.call_stage.pick_microphone": "Mikrofon wählen",
|
|
260
|
+
"forge.call_stage.pin": "Kachel anheften",
|
|
261
|
+
"forge.call_stage.prejoin_camera_off": "Deine Kamera ist aus",
|
|
262
|
+
"forge.call_stage.prejoin_camera_off_toggle": "Kamera aus",
|
|
263
|
+
"forge.call_stage.prejoin_camera_on": "Kamera an",
|
|
264
|
+
"forge.call_stage.prejoin_join": "Anruf beitreten",
|
|
265
|
+
"forge.call_stage.prejoin_mic_off": "Mikro aus",
|
|
266
|
+
"forge.call_stage.prejoin_mic_on": "Mikro an",
|
|
267
|
+
"forge.call_stage.prejoin_permission": "Der Browser hat den Zugriff auf deine Geräte nicht erlaubt:",
|
|
268
|
+
"forge.call_stage.presenting_name": "{name} präsentiert",
|
|
269
|
+
"forge.call_stage.raise_hand": "Hand heben",
|
|
270
|
+
"forge.call_stage.react_to_message": "Auf diese Nachricht reagieren",
|
|
271
|
+
"forge.call_stage.reactions": "Reaktion senden",
|
|
272
|
+
"forge.call_stage.record_start": "Aufzeichnen",
|
|
273
|
+
"forge.call_stage.record_stop": "Aufzeichnung beenden",
|
|
217
274
|
"forge.call_stage.recording": "Dieser Anruf wird aufgezeichnet",
|
|
275
|
+
"forge.call_stage.recording_live": "Aufzeichnung läuft",
|
|
218
276
|
"forge.call_stage.retry": "Erneut versuchen",
|
|
277
|
+
"forge.call_stage.screen_of": "Bildschirm von {name}",
|
|
219
278
|
"forge.call_stage.screen_start": "Bildschirm teilen",
|
|
220
279
|
"forge.call_stage.screen_stop": "Teilen beenden",
|
|
280
|
+
"forge.call_stage.speaking_off_page": "Es spricht: {names}",
|
|
221
281
|
"forge.call_stage.status_connected": "Verbunden",
|
|
222
282
|
"forge.call_stage.status_connecting": "Verbindung zum Anruf wird hergestellt",
|
|
223
283
|
"forge.call_stage.status_ended": "Der Anruf ist beendet",
|
|
@@ -231,11 +291,23 @@
|
|
|
231
291
|
"forge.call_stage.status_reconnecting": "Verbindung unterbrochen, wird wiederhergestellt",
|
|
232
292
|
"forge.call_stage.status_reconnecting_detail": "Alle anderen sind weiterhin im Anruf.",
|
|
233
293
|
"forge.call_stage.status_refused": "Beitritt zu diesem Anruf nicht möglich",
|
|
294
|
+
"forge.call_stage.stop_presenting": "Präsentation beenden",
|
|
295
|
+
"forge.call_stage.tab_chat": "Chat",
|
|
296
|
+
"forge.call_stage.tab_people": "Personen",
|
|
234
297
|
"forge.call_stage.unmute": "Stummschaltung aufheben",
|
|
298
|
+
"forge.call_stage.unnamed_device": "Gerät {number}",
|
|
299
|
+
"forge.call_stage.unpin": "Kachel lösen",
|
|
300
|
+
"forge.call_stage.view_gallery": "Galerieansicht",
|
|
301
|
+
"forge.call_stage.view_speaker": "Sprecheransicht",
|
|
302
|
+
"forge.call_stage.waiting_for_others": "Warten auf weitere Teilnehmer",
|
|
303
|
+
"forge.call_stage.waiting_for_others_detail": "Du bist als Erste(r) hier. Der Anruf beginnt, sobald jemand dazukommt.",
|
|
235
304
|
"forge.call_stage.you": "Du",
|
|
305
|
+
"forge.call_stage.you_are_presenting": "Du präsentierst deinen Bildschirm",
|
|
306
|
+
"forge.call_stage.you_joining": "Du (trittst bei…)",
|
|
236
307
|
"forge.call_stage.you_not_connected": "Du (noch nicht verbunden)",
|
|
308
|
+
"forge.call_stage.you_suffix": "(du)",
|
|
237
309
|
"forge.call_window.ended": "Dieser Anruf ist beendet",
|
|
238
|
-
"forge.call_window.ended_detail": "Der Raum
|
|
310
|
+
"forge.call_window.ended_detail": "Der Raum ist geschlossen, niemand kann jetzt beitreten. Wenn das falsch erscheint, wenden Sie sich an die gastgebende Person.",
|
|
239
311
|
"forge.call_window.not_started": "Dieser Anruf hat noch nicht begonnen",
|
|
240
312
|
"forge.call_window.opens_at": "Sie können ab {datetime} beitreten. Kommen Sie dann zurück, und diese Seite lässt Sie hinein.",
|
|
241
313
|
"forge.cancellation_terms.title": "Stornobedingungen",
|
|
@@ -500,6 +572,7 @@
|
|
|
500
572
|
"forge.course_access.secure_prompt_lead": "Jeder mit diesem Link kann deinen Kurs öffnen. Erstelle ein Konto mit",
|
|
501
573
|
"forge.course_access.secure_prompt_tail": "damit er nur dir gehört.",
|
|
502
574
|
"forge.course_access.signed_in_as": "Angemeldet als",
|
|
575
|
+
"forge.course_calls.title": "Live-Calls",
|
|
503
576
|
"forge.course_checkout.aria_close": "Schließen",
|
|
504
577
|
"forge.course_checkout.back": "Zurück",
|
|
505
578
|
"forge.course_checkout.confirm_email_placeholder": "E-Mail bestätigen",
|