@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tribe-nest/forge",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.48.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,162 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import { useForge } from "../../provider/ForgeProvider";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The member's own call calendar (`GET /public/membership-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 a second copy is how the two drift, as
|
|
10
|
+
* `useMyTickets` and `useMyBookings` already document.
|
|
11
|
+
*
|
|
12
|
+
* ## What the server owns and this file does not mirror
|
|
13
|
+
*
|
|
14
|
+
* - **Who may see a call.** Resolved from the SESSION against the artist's
|
|
15
|
+
* membership; `profileId` here names the artist, never the viewer. A call the
|
|
16
|
+
* member cannot join does not appear, so there is no client-side filter to get
|
|
17
|
+
* wrong. An empty list is the honest answer for a lapsed member.
|
|
18
|
+
* - **Whether the room is open.** `isOpen`, `opensAt` and `closesAt` come back
|
|
19
|
+
* on every row and are computed from the same window the JOIN enforces.
|
|
20
|
+
* Re-deriving "has it started yet" here would give two answers to one
|
|
21
|
+
* question, and the wrong one is the one the member acts on.
|
|
22
|
+
* - **Where the call happens.** `callPublicId` addresses the generic
|
|
23
|
+
* `/i/video-calls/:id` page. The id addresses a CALL; the server decides what
|
|
24
|
+
* it is attached to and what seat this viewer holds in it.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export type MembershipCallParticipation = "open" | "host_only";
|
|
28
|
+
|
|
29
|
+
export interface MyMembershipCall {
|
|
30
|
+
id: string;
|
|
31
|
+
title: string;
|
|
32
|
+
description: string | null;
|
|
33
|
+
/** ISO. */
|
|
34
|
+
startsAt: string;
|
|
35
|
+
endsAt: string;
|
|
36
|
+
/** The creator's IANA zone, when they set one. Render the time in it, not the viewer's guess. */
|
|
37
|
+
timezone: string | null;
|
|
38
|
+
participation: MembershipCallParticipation;
|
|
39
|
+
/**
|
|
40
|
+
* Which side of the call this viewer is on. Derived by the server, never
|
|
41
|
+
* claimed.
|
|
42
|
+
*
|
|
43
|
+
* `attendee`, not `member`: the seat-layer vocabulary is closed across every
|
|
44
|
+
* call kind, because the participant identity is built from it and the seat
|
|
45
|
+
* cap reads that grammar. The UI is free to say "member".
|
|
46
|
+
*/
|
|
47
|
+
role: "host" | "attendee";
|
|
48
|
+
/**
|
|
49
|
+
* Who the call is for, already labelled by the server.
|
|
50
|
+
*
|
|
51
|
+
* Generic `(audienceType, audienceId, label)` rows rather than tiers, because
|
|
52
|
+
* one call can be addressed to a tier, to every member, or later to a course
|
|
53
|
+
* or a cohort. Render `label`; a client never has to know which table an
|
|
54
|
+
* `audienceId` points at.
|
|
55
|
+
*/
|
|
56
|
+
audiences: { id: string; audienceType: string; audienceId: string | null; label: string }[];
|
|
57
|
+
/** The window a ticket may be minted in. Only NEW joins are refused after `closesAt`. */
|
|
58
|
+
opensAt: string;
|
|
59
|
+
closesAt: string;
|
|
60
|
+
isOpen: boolean;
|
|
61
|
+
/** The `/i/video-calls/:id` address. Null only if the room row is missing, which is a bug. */
|
|
62
|
+
callPublicId: string | null;
|
|
63
|
+
/**
|
|
64
|
+
* What there is to rewatch, if anything. Null for a call with no recording.
|
|
65
|
+
*
|
|
66
|
+
* `processing` means "come back shortly", NOT an error: the video is assembled
|
|
67
|
+
* after the call ends, one call at a time, so a member looking straight
|
|
68
|
+
* afterwards sees it for a few minutes.
|
|
69
|
+
*/
|
|
70
|
+
recording: {
|
|
71
|
+
id: string;
|
|
72
|
+
status: "processing" | "ready";
|
|
73
|
+
durationSeconds: number | null;
|
|
74
|
+
/** Signed per request and short-lived. Null while `processing`. */
|
|
75
|
+
watchUrl: string | null;
|
|
76
|
+
} | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const membershipCallKeys = {
|
|
80
|
+
mine: (profileId: string | undefined) => ["membership-calls", "mine", profileId] as const,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param options.from Inclusive lower bound. Absent means "from now on", which is
|
|
85
|
+
* what a caller wanting only the next few calls sends.
|
|
86
|
+
* @param options.to Inclusive upper bound. Absent means "however far ahead".
|
|
87
|
+
*/
|
|
88
|
+
export function useMyMembershipCalls(options?: {
|
|
89
|
+
from?: Date;
|
|
90
|
+
to?: Date;
|
|
91
|
+
limit?: number;
|
|
92
|
+
enabled?: boolean;
|
|
93
|
+
}) {
|
|
94
|
+
const { client, profileId } = useForge();
|
|
95
|
+
const from = options?.from?.toISOString();
|
|
96
|
+
const to = options?.to?.toISOString();
|
|
97
|
+
return useQuery<MyMembershipCall[]>({
|
|
98
|
+
// The WINDOW is part of the key: paging back a month is a different question
|
|
99
|
+
// with a different answer, and sharing one cache entry would show February's
|
|
100
|
+
// calls in March until a refetch landed.
|
|
101
|
+
queryKey: [...membershipCallKeys.mine(profileId), from ?? "now", to ?? "open"],
|
|
102
|
+
queryFn: async () => {
|
|
103
|
+
const res = await client.get("/public/membership-calls/mine", {
|
|
104
|
+
params: { profileId, from, to, limit: options?.limit },
|
|
105
|
+
});
|
|
106
|
+
return res.data;
|
|
107
|
+
},
|
|
108
|
+
enabled: !!profileId && !!client && options?.enabled !== false,
|
|
109
|
+
/**
|
|
110
|
+
* A call opens fifteen minutes before it starts, and the member watching
|
|
111
|
+
* this page is waiting for exactly that. Without a refetch the Join button
|
|
112
|
+
* appears only when they happen to reload, which on the one page whose whole
|
|
113
|
+
* job is "can I get in yet" is the wrong behaviour.
|
|
114
|
+
*/
|
|
115
|
+
refetchInterval: 60_000,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** One past call the viewer could attend, and what has become of its video. */
|
|
120
|
+
export interface MyMembershipCallRecording {
|
|
121
|
+
callId: string;
|
|
122
|
+
title: string;
|
|
123
|
+
description: string | null;
|
|
124
|
+
/** ISO. */
|
|
125
|
+
startsAt: string;
|
|
126
|
+
endsAt: string;
|
|
127
|
+
timezone: string | null;
|
|
128
|
+
recordingId: string;
|
|
129
|
+
/**
|
|
130
|
+
* `processing` is not an error and must not be rendered as one. The video is
|
|
131
|
+
* assembled from every participant's stream after the call ends, one call at a
|
|
132
|
+
* time, so a member who looks straight afterwards sees this for a few minutes.
|
|
133
|
+
* A `failed` recording never reaches the client at all.
|
|
134
|
+
*/
|
|
135
|
+
status: "processing" | "ready";
|
|
136
|
+
durationSeconds: number | null;
|
|
137
|
+
/**
|
|
138
|
+
* Signed per request and short-lived, so it is not stored and not shareable
|
|
139
|
+
* for long. Null while `status` is `processing`.
|
|
140
|
+
*/
|
|
141
|
+
watchUrl: string | null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function useMyMembershipCallRecordings(options?: { limit?: number; enabled?: boolean }) {
|
|
145
|
+
const { client, profileId } = useForge();
|
|
146
|
+
return useQuery<MyMembershipCallRecording[]>({
|
|
147
|
+
queryKey: ["membership-calls", "recordings", profileId],
|
|
148
|
+
queryFn: async () => {
|
|
149
|
+
const res = await client.get("/public/membership-calls/mine/recordings", {
|
|
150
|
+
params: { profileId, limit: options?.limit },
|
|
151
|
+
});
|
|
152
|
+
return res.data;
|
|
153
|
+
},
|
|
154
|
+
enabled: !!profileId && !!client && options?.enabled !== false,
|
|
155
|
+
/**
|
|
156
|
+
* Slower than the upcoming list: nothing here changes on a fifteen-minute
|
|
157
|
+
* boundary, and the only transition worth catching is `processing` ->
|
|
158
|
+
* `ready`, which takes minutes rather than seconds.
|
|
159
|
+
*/
|
|
160
|
+
refetchInterval: 120_000,
|
|
161
|
+
});
|
|
162
|
+
}
|
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",
|
package/src/i18n/en.json
CHANGED
|
@@ -201,23 +201,83 @@
|
|
|
201
201
|
"forge.bundle_confirmation.view_orders": "View your orders",
|
|
202
202
|
"forge.call_stage.camera_start": "Start camera",
|
|
203
203
|
"forge.call_stage.camera_stop": "Stop camera",
|
|
204
|
+
"forge.call_stage.chat": "Chat",
|
|
205
|
+
"forge.call_stage.chat_empty": "No messages yet. Say hello.",
|
|
206
|
+
"forge.call_stage.chat_loading": "Opening the chat…",
|
|
207
|
+
"forge.call_stage.chat_placeholder": "Message everyone…",
|
|
208
|
+
"forge.call_stage.chat_send": "Send",
|
|
209
|
+
"forge.call_stage.chat_typing": "{name} is typing…",
|
|
210
|
+
"forge.call_stage.close_panel": "Close panel",
|
|
211
|
+
"forge.call_stage.dismiss": "Dismiss",
|
|
212
|
+
"forge.call_stage.everyone_left": "Everyone else has left the call",
|
|
213
|
+
"forge.call_stage.hand_raised_by": "{name} raised their hand",
|
|
214
|
+
"forge.call_stage.hands_raised_named": "{name} +{count}",
|
|
215
|
+
"forge.call_stage.hands_raised_open": "Raised hands: {count}. Open the people panel.",
|
|
204
216
|
"forge.call_stage.head_count": "In this call: {count}",
|
|
217
|
+
"forge.call_stage.health_close": "Close call health",
|
|
218
|
+
"forge.call_stage.health_estimate": "Bandwidth estimate",
|
|
219
|
+
"forge.call_stage.health_measuring": "Measuring...",
|
|
220
|
+
"forge.call_stage.health_no_video": "No video tiles to measure.",
|
|
221
|
+
"forge.call_stage.health_sending": "Sending",
|
|
222
|
+
"forge.call_stage.health_title": "Call health",
|
|
223
|
+
"forge.call_stage.health_toggle": "Call health",
|
|
205
224
|
"forge.call_stage.help_camera_stop": "It stops sending your picture: the others see your name instead. The browser keeps the camera open so that starting it again is instant and does not ask for permission a second time, which means its indicator light can stay on until you leave the call.",
|
|
206
225
|
"forge.call_stage.help_camera_stop_label": "What does Stop camera do?",
|
|
226
|
+
"forge.call_stage.help_health": "Each line shows a tile: the quality asked for and the quality being received (q quarter, h half, f full), then the decoded picture. A RED line is receiving less than it asked for. The bandwidth estimate is what the server believes this connection can carry; when it sits low, every tile is kept small. Under Sending, each of your own outgoing quality layers has a score; 0 means that layer is not reaching the server at all.",
|
|
227
|
+
"forge.call_stage.help_health_label": "How do I read this?",
|
|
207
228
|
"forge.call_stage.help_hear_not_see": "A tile with a name and no picture means that person has their camera off, or the call is busy enough that only the people currently speaking are sent in full. Both are normal, and their audio is unaffected.",
|
|
208
229
|
"forge.call_stage.help_hear_not_see_label": "Why can I hear someone but not see them?",
|
|
230
|
+
"forge.call_stage.help_record": "Recording starts now and captures everyone in the call. It is assembled into one video after the call ends, which takes a few minutes, and then appears for the people who could join this call.",
|
|
231
|
+
"forge.call_stage.help_record_label": "Who can see the recording?",
|
|
209
232
|
"forge.call_stage.help_screen": "Whatever you pick in the browser prompt, live, to everyone in the call. Your camera keeps running alongside it rather than being replaced, so people can still see you while you present.",
|
|
210
233
|
"forge.call_stage.help_screen_label": "What does sharing my screen send?",
|
|
234
|
+
"forge.call_stage.hide_self_view": "Hide self view",
|
|
211
235
|
"forge.call_stage.leave": "Leave",
|
|
236
|
+
"forge.call_stage.leave_recording_body": "This call is being recorded. If you leave without stopping, the recording keeps running until everybody has left.",
|
|
237
|
+
"forge.call_stage.leave_recording_cancel": "Stay on the call",
|
|
238
|
+
"forge.call_stage.leave_recording_keep": "Leave, keep recording",
|
|
239
|
+
"forge.call_stage.leave_recording_stop": "Stop recording and leave",
|
|
240
|
+
"forge.call_stage.leave_recording_title": "You are still recording",
|
|
212
241
|
"forge.call_stage.listening": "(listening)",
|
|
213
242
|
"forge.call_stage.lost_camera": "Your camera was turned off when the connection dropped. Start it again to be seen.",
|
|
214
243
|
"forge.call_stage.lost_microphone": "Your microphone was turned off when the connection dropped. Unmute to be heard again.",
|
|
215
244
|
"forge.call_stage.lost_screen": "Your screen share ended when the connection dropped. Share it again to continue.",
|
|
245
|
+
"forge.call_stage.lower_all_hands": "Lower all hands",
|
|
246
|
+
"forge.call_stage.lower_hand": "Lower hand",
|
|
247
|
+
"forge.call_stage.lower_hand_of": "Lower",
|
|
248
|
+
"forge.call_stage.many_joined": "{count} people joined or left",
|
|
216
249
|
"forge.call_stage.mute": "Mute",
|
|
250
|
+
"forge.call_stage.muted_talking": "You appear to be talking while muted.",
|
|
251
|
+
"forge.call_stage.no_devices": "No devices found",
|
|
252
|
+
"forge.call_stage.page_next": "Next page",
|
|
253
|
+
"forge.call_stage.page_of": "{page} / {pages}",
|
|
254
|
+
"forge.call_stage.page_prev": "Previous page",
|
|
255
|
+
"forge.call_stage.peer_joined": "{name} joined",
|
|
256
|
+
"forge.call_stage.peer_left": "{name} left",
|
|
257
|
+
"forge.call_stage.people": "People",
|
|
258
|
+
"forge.call_stage.pick_camera": "Choose a camera",
|
|
259
|
+
"forge.call_stage.pick_microphone": "Choose a microphone",
|
|
260
|
+
"forge.call_stage.pin": "Pin this tile",
|
|
261
|
+
"forge.call_stage.prejoin_camera_off": "Your camera is off",
|
|
262
|
+
"forge.call_stage.prejoin_camera_off_toggle": "Camera off",
|
|
263
|
+
"forge.call_stage.prejoin_camera_on": "Camera on",
|
|
264
|
+
"forge.call_stage.prejoin_join": "Join the call",
|
|
265
|
+
"forge.call_stage.prejoin_mic_off": "Mic off",
|
|
266
|
+
"forge.call_stage.prejoin_mic_on": "Mic on",
|
|
267
|
+
"forge.call_stage.prejoin_permission": "The browser did not grant access to your devices:",
|
|
268
|
+
"forge.call_stage.presenting_name": "{name} is presenting",
|
|
269
|
+
"forge.call_stage.raise_hand": "Raise hand",
|
|
270
|
+
"forge.call_stage.react_to_message": "React to this message",
|
|
271
|
+
"forge.call_stage.reactions": "Send a reaction",
|
|
272
|
+
"forge.call_stage.record_start": "Record",
|
|
273
|
+
"forge.call_stage.record_stop": "Stop recording",
|
|
217
274
|
"forge.call_stage.recording": "This call is being recorded",
|
|
275
|
+
"forge.call_stage.recording_live": "Recording",
|
|
218
276
|
"forge.call_stage.retry": "Try again",
|
|
277
|
+
"forge.call_stage.screen_of": "{name}'s screen",
|
|
219
278
|
"forge.call_stage.screen_start": "Share screen",
|
|
220
279
|
"forge.call_stage.screen_stop": "Stop sharing",
|
|
280
|
+
"forge.call_stage.speaking_off_page": "Speaking: {names}",
|
|
221
281
|
"forge.call_stage.status_connected": "Connected",
|
|
222
282
|
"forge.call_stage.status_connecting": "Connecting to the call",
|
|
223
283
|
"forge.call_stage.status_ended": "The call has ended",
|
|
@@ -231,11 +291,23 @@
|
|
|
231
291
|
"forge.call_stage.status_reconnecting": "Connection lost, reconnecting",
|
|
232
292
|
"forge.call_stage.status_reconnecting_detail": "Everyone else is still in the call.",
|
|
233
293
|
"forge.call_stage.status_refused": "Could not join this call",
|
|
294
|
+
"forge.call_stage.stop_presenting": "Stop presenting",
|
|
295
|
+
"forge.call_stage.tab_chat": "Chat",
|
|
296
|
+
"forge.call_stage.tab_people": "People",
|
|
234
297
|
"forge.call_stage.unmute": "Unmute",
|
|
298
|
+
"forge.call_stage.unnamed_device": "Device {number}",
|
|
299
|
+
"forge.call_stage.unpin": "Unpin this tile",
|
|
300
|
+
"forge.call_stage.view_gallery": "Gallery view",
|
|
301
|
+
"forge.call_stage.view_speaker": "Speaker view",
|
|
302
|
+
"forge.call_stage.waiting_for_others": "Waiting for others to join",
|
|
303
|
+
"forge.call_stage.waiting_for_others_detail": "You are the first one here. The call starts when somebody else arrives.",
|
|
235
304
|
"forge.call_stage.you": "You",
|
|
305
|
+
"forge.call_stage.you_are_presenting": "You are presenting your screen",
|
|
306
|
+
"forge.call_stage.you_joining": "You (joining…)",
|
|
236
307
|
"forge.call_stage.you_not_connected": "You (not connected yet)",
|
|
308
|
+
"forge.call_stage.you_suffix": "(you)",
|
|
237
309
|
"forge.call_window.ended": "This call has ended",
|
|
238
|
-
"forge.call_window.ended_detail": "The room
|
|
310
|
+
"forge.call_window.ended_detail": "The room is closed, so nobody can join it now. If you think that is wrong, ask the host.",
|
|
239
311
|
"forge.call_window.not_started": "This call has not started yet",
|
|
240
312
|
"forge.call_window.opens_at": "You can join from {datetime}. Come back then and this page will let you in.",
|
|
241
313
|
"forge.cancellation_terms.title": "Cancellation policy",
|