@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
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import type { RaisedHand } from "@tribe-nest/media-client";
|
|
3
|
+
|
|
4
|
+
import type { ForgeT } from "../../../i18n";
|
|
5
|
+
import type { CallStageChat } from "../callChat";
|
|
6
|
+
import type { CallTile } from "../callState";
|
|
7
|
+
import type { StageTokens } from "../stage/stageTheme";
|
|
8
|
+
import { CallChatPanel } from "./CallChatPanel";
|
|
9
|
+
import { CallParticipantsPanel } from "./CallParticipantsPanel";
|
|
10
|
+
|
|
11
|
+
export type SidePanelTab = "chat" | "people";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The right rail: one width, two tabs, the Zoom-familiar shape. Tabs rather
|
|
15
|
+
* than stacked panels because the rail's width is the stage's loss, and two
|
|
16
|
+
* rails would take a third of a laptop screen.
|
|
17
|
+
*/
|
|
18
|
+
export function CallSidePanel({
|
|
19
|
+
tab,
|
|
20
|
+
onTab,
|
|
21
|
+
onClose,
|
|
22
|
+
tokens,
|
|
23
|
+
t,
|
|
24
|
+
chat,
|
|
25
|
+
tiles,
|
|
26
|
+
hands,
|
|
27
|
+
isHost,
|
|
28
|
+
myIdentity,
|
|
29
|
+
onLowerHand,
|
|
30
|
+
onLowerAll,
|
|
31
|
+
unread,
|
|
32
|
+
fill = false,
|
|
33
|
+
}: {
|
|
34
|
+
tab: SidePanelTab;
|
|
35
|
+
onTab: (tab: SidePanelTab) => void;
|
|
36
|
+
onClose: () => void;
|
|
37
|
+
tokens: StageTokens;
|
|
38
|
+
t: ForgeT;
|
|
39
|
+
chat?: CallStageChat;
|
|
40
|
+
tiles: readonly CallTile[];
|
|
41
|
+
hands: readonly RaisedHand[];
|
|
42
|
+
isHost: boolean;
|
|
43
|
+
myIdentity: string | null;
|
|
44
|
+
onLowerHand: (identity: string) => void;
|
|
45
|
+
onLowerAll: () => void;
|
|
46
|
+
unread: number;
|
|
47
|
+
/** Fill the parent (a full-screen overlay on a phone) instead of the fixed
|
|
48
|
+
* docked width. */
|
|
49
|
+
fill?: boolean;
|
|
50
|
+
}) {
|
|
51
|
+
const tabButton = (which: SidePanelTab, label: string, badge?: number) => (
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
onClick={() => onTab(which)}
|
|
55
|
+
aria-pressed={tab === which}
|
|
56
|
+
style={{
|
|
57
|
+
flex: 1,
|
|
58
|
+
display: "inline-flex",
|
|
59
|
+
alignItems: "center",
|
|
60
|
+
justifyContent: "center",
|
|
61
|
+
gap: "0.375rem",
|
|
62
|
+
padding: "0.5rem",
|
|
63
|
+
border: "none",
|
|
64
|
+
borderBottom: `2px solid ${tab === which ? tokens.accent : "transparent"}`,
|
|
65
|
+
backgroundColor: "transparent",
|
|
66
|
+
color: tab === which ? tokens.stageText : tokens.stageMuted,
|
|
67
|
+
cursor: "pointer",
|
|
68
|
+
font: "inherit",
|
|
69
|
+
fontSize: "0.8125rem",
|
|
70
|
+
fontWeight: 600,
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
{label}
|
|
74
|
+
{!!badge && (
|
|
75
|
+
<span
|
|
76
|
+
style={{
|
|
77
|
+
minWidth: 18,
|
|
78
|
+
height: 18,
|
|
79
|
+
borderRadius: 9999,
|
|
80
|
+
backgroundColor: tokens.accent,
|
|
81
|
+
color: tokens.accentText,
|
|
82
|
+
fontSize: "0.6875rem",
|
|
83
|
+
display: "inline-flex",
|
|
84
|
+
alignItems: "center",
|
|
85
|
+
justifyContent: "center",
|
|
86
|
+
padding: "0 0.25rem",
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
{badge > 99 ? "99+" : badge}
|
|
90
|
+
</span>
|
|
91
|
+
)}
|
|
92
|
+
</button>
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<aside
|
|
97
|
+
data-testid="call-side-panel"
|
|
98
|
+
style={{
|
|
99
|
+
width: fill ? "100%" : "min(20rem, 85vw)",
|
|
100
|
+
...(fill ? { height: "100%" } : {}),
|
|
101
|
+
display: "flex",
|
|
102
|
+
flexDirection: "column",
|
|
103
|
+
minHeight: 0,
|
|
104
|
+
borderRadius: tokens.cornerRadius,
|
|
105
|
+
backgroundColor: tokens.stageSurface,
|
|
106
|
+
border: `1px solid ${tokens.stageBorder}`,
|
|
107
|
+
color: tokens.stageText,
|
|
108
|
+
overflow: "hidden",
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
<div style={{ display: "flex", alignItems: "center", borderBottom: `1px solid ${tokens.stageBorder}` }}>
|
|
112
|
+
{chat && tabButton("chat", t("forge.call_stage.tab_chat"), tab === "chat" ? 0 : unread)}
|
|
113
|
+
{tabButton("people", t("forge.call_stage.tab_people"), hands.length)}
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
onClick={onClose}
|
|
117
|
+
aria-label={t("forge.call_stage.close_panel")}
|
|
118
|
+
style={{
|
|
119
|
+
display: "inline-flex",
|
|
120
|
+
alignItems: "center",
|
|
121
|
+
justifyContent: "center",
|
|
122
|
+
width: 32,
|
|
123
|
+
height: 32,
|
|
124
|
+
margin: "0 0.25rem",
|
|
125
|
+
border: "none",
|
|
126
|
+
borderRadius: tokens.cornerRadius,
|
|
127
|
+
backgroundColor: "transparent",
|
|
128
|
+
color: tokens.stageMuted,
|
|
129
|
+
cursor: "pointer",
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
<X width={16} height={16} aria-hidden="true" />
|
|
133
|
+
</button>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
{tab === "chat" && chat ? (
|
|
137
|
+
<CallChatPanel chat={chat} tokens={tokens} t={t} />
|
|
138
|
+
) : (
|
|
139
|
+
<CallParticipantsPanel
|
|
140
|
+
tiles={tiles}
|
|
141
|
+
hands={hands}
|
|
142
|
+
isHost={isHost}
|
|
143
|
+
myIdentity={myIdentity}
|
|
144
|
+
onLowerHand={onLowerHand}
|
|
145
|
+
onLowerAll={onLowerAll}
|
|
146
|
+
tokens={tokens}
|
|
147
|
+
t={t}
|
|
148
|
+
/>
|
|
149
|
+
)}
|
|
150
|
+
</aside>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Loader2, PhoneOff, RefreshCw, Users } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
import type { ForgeT } from "../../../i18n";
|
|
4
|
+
import type { StageNotice } from "../callState";
|
|
5
|
+
import type { StageTokens } from "./stageTheme";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The centre of the stage, when the stage itself is the message.
|
|
9
|
+
*
|
|
10
|
+
* Terminal states (ended, failed) and empty ones (joining, waiting,
|
|
11
|
+
* everyone-left) render HERE, where a person staring at a dead stage actually
|
|
12
|
+
* looks; the top pill keeps the transient states so live tiles stay visible
|
|
13
|
+
* behind them. What renders is `stageNotice`'s one decision, so this file
|
|
14
|
+
* holds no logic worth testing.
|
|
15
|
+
*/
|
|
16
|
+
export function CallCenterNotice({
|
|
17
|
+
notice,
|
|
18
|
+
tokens,
|
|
19
|
+
t,
|
|
20
|
+
onRetry,
|
|
21
|
+
}: {
|
|
22
|
+
notice: StageNotice;
|
|
23
|
+
tokens: StageTokens;
|
|
24
|
+
t: ForgeT;
|
|
25
|
+
onRetry: () => void;
|
|
26
|
+
}) {
|
|
27
|
+
const detail = notice.detailKey ? t(notice.detailKey) : notice.detailText;
|
|
28
|
+
const busy = notice.kind === "joining";
|
|
29
|
+
const Icon = notice.kind === "joining" ? Loader2 : notice.kind === "waiting" || notice.kind === "everyone_left" ? Users : PhoneOff;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
// `alert` only for a failure somebody must not miss. The other kinds
|
|
34
|
+
// carry NO live region: the top pill already announces the connection
|
|
35
|
+
// state, and two live regions saying the same thing read twice.
|
|
36
|
+
{...(notice.kind === "failed" ? { role: "alert" } : {})}
|
|
37
|
+
data-testid={`stage-notice-${notice.kind}`}
|
|
38
|
+
style={{
|
|
39
|
+
position: "absolute",
|
|
40
|
+
inset: 0,
|
|
41
|
+
display: "flex",
|
|
42
|
+
flexDirection: "column",
|
|
43
|
+
alignItems: "center",
|
|
44
|
+
justifyContent: "center",
|
|
45
|
+
gap: "0.75rem",
|
|
46
|
+
textAlign: "center",
|
|
47
|
+
padding: "1.5rem",
|
|
48
|
+
color: tokens.stageText,
|
|
49
|
+
// Terminal states cover what is behind them; the empty states have
|
|
50
|
+
// nothing behind them to cover.
|
|
51
|
+
backgroundColor: notice.kind === "ended" || notice.kind === "failed" ? tokens.stageBg : "transparent",
|
|
52
|
+
borderRadius: tokens.cornerRadius,
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
<Icon
|
|
56
|
+
width={28}
|
|
57
|
+
height={28}
|
|
58
|
+
aria-hidden="true"
|
|
59
|
+
color={notice.kind === "failed" ? tokens.danger : tokens.stageMuted}
|
|
60
|
+
style={busy ? { animation: "forge-call-spin 1.2s linear infinite" } : undefined}
|
|
61
|
+
/>
|
|
62
|
+
<strong style={{ fontSize: "1.05rem" }}>{t(notice.headlineKey)}</strong>
|
|
63
|
+
{detail && <span style={{ fontSize: "0.875rem", color: tokens.stageMuted, maxWidth: "28rem" }}>{detail}</span>}
|
|
64
|
+
{notice.canRetry && (
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
onClick={onRetry}
|
|
68
|
+
style={{
|
|
69
|
+
display: "inline-flex",
|
|
70
|
+
alignItems: "center",
|
|
71
|
+
gap: "0.375rem",
|
|
72
|
+
padding: "0.5rem 1rem",
|
|
73
|
+
borderRadius: tokens.cornerRadius,
|
|
74
|
+
border: "none",
|
|
75
|
+
cursor: "pointer",
|
|
76
|
+
backgroundColor: tokens.accent,
|
|
77
|
+
color: tokens.accentText,
|
|
78
|
+
font: "inherit",
|
|
79
|
+
fontSize: "0.875rem",
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
<RefreshCw width={14} height={14} aria-hidden="true" />
|
|
83
|
+
{t("forge.call_stage.retry")}
|
|
84
|
+
</button>
|
|
85
|
+
)}
|
|
86
|
+
<style>{`@keyframes forge-call-spin { to { transform: rotate(360deg); } }`}</style>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|