@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
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useBroadcast, useMediaRoom, useRaisedHands as useRaisedHandsState, useRoomState } from "@tribe-nest/media-client/react";
|
|
3
|
+
import type { RaisedHand } from "@tribe-nest/media-client";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
cullReactions,
|
|
7
|
+
isKnownReaction,
|
|
8
|
+
laneFor,
|
|
9
|
+
orderHands,
|
|
10
|
+
reactionGate,
|
|
11
|
+
type FlyingReaction,
|
|
12
|
+
type ReactionEmoji,
|
|
13
|
+
} from "../stage/callSignals";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Hands and flying reactions, as the stage consumes them.
|
|
17
|
+
*
|
|
18
|
+
* The SEMANTICS live here in Forge; the transport lives in media-client.
|
|
19
|
+
* Hands are server-materialized room state (`setHand` on the wire), so this
|
|
20
|
+
* hook renders `state.raisedHands` and never keeps its own ledger: a
|
|
21
|
+
* reconnect's snapshot is the truth and there is nothing local to drift.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type RaisedHandsControls = {
|
|
25
|
+
/** Oldest first: the queue a host calls on. */
|
|
26
|
+
hands: readonly RaisedHand[];
|
|
27
|
+
myHandRaised: boolean;
|
|
28
|
+
raiseHand: () => void;
|
|
29
|
+
lowerHand: () => void;
|
|
30
|
+
/**
|
|
31
|
+
* Host courtesy controls. HONORIFIC by design: they broadcast a request
|
|
32
|
+
* and the TARGET's own client lowers its hand, because the server-held
|
|
33
|
+
* state only ever moves on its owner's verb. Any participant could send
|
|
34
|
+
* the same broadcast; the harm is a lowered hand, which is why this is
|
|
35
|
+
* acceptable, and why nothing stronger may ever be built on it.
|
|
36
|
+
*/
|
|
37
|
+
lowerHandOf: (identity: string) => void;
|
|
38
|
+
lowerAll: () => void;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function useRaisedHands(): RaisedHandsControls {
|
|
42
|
+
const { room } = useMediaRoom();
|
|
43
|
+
const state = useRoomState();
|
|
44
|
+
const raw = useRaisedHandsState();
|
|
45
|
+
const hands = useMemo(() => orderHands(raw), [raw]);
|
|
46
|
+
const myHandRaised = useMemo(
|
|
47
|
+
() => !!state.identity && raw.some((h) => h.identity === state.identity),
|
|
48
|
+
[raw, state.identity],
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// Obey a courtesy lower-hand addressed to US (or to everyone).
|
|
52
|
+
const identityRef = useRef(state.identity);
|
|
53
|
+
identityRef.current = state.identity;
|
|
54
|
+
useBroadcast(
|
|
55
|
+
useCallback(
|
|
56
|
+
(event) => {
|
|
57
|
+
if (event.type !== "lowerHand") return;
|
|
58
|
+
const target = (event.data as { target?: unknown }).target;
|
|
59
|
+
if (target === identityRef.current || target === "all") {
|
|
60
|
+
void room?.setHandRaised(false);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
[room],
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
hands,
|
|
69
|
+
myHandRaised,
|
|
70
|
+
raiseHand: useCallback(() => void room?.setHandRaised(true), [room]),
|
|
71
|
+
lowerHand: useCallback(() => void room?.setHandRaised(false), [room]),
|
|
72
|
+
lowerHandOf: useCallback((identity: string) => void room?.sendLowerHand(identity), [room]),
|
|
73
|
+
lowerAll: useCallback(() => void room?.sendLowerHand("all"), [room]),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type FlyingReactionsControls = {
|
|
78
|
+
reactions: readonly FlyingReaction[];
|
|
79
|
+
/** Gated to one per half second; the node's rate limit is never met. */
|
|
80
|
+
sendReaction: (emoji: ReactionEmoji) => void;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export function useFlyingReactions(): FlyingReactionsControls {
|
|
84
|
+
const { room } = useMediaRoom();
|
|
85
|
+
const state = useRoomState();
|
|
86
|
+
const [reactions, setReactions] = useState<readonly FlyingReaction[]>([]);
|
|
87
|
+
const lastSentAt = useRef<number | null>(null);
|
|
88
|
+
const counter = useRef(0);
|
|
89
|
+
const identityRef = useRef(state.identity);
|
|
90
|
+
identityRef.current = state.identity;
|
|
91
|
+
|
|
92
|
+
const add = useCallback((emoji: ReactionEmoji, identity: string) => {
|
|
93
|
+
const id = `${identity}-${counter.current++}-${Date.now()}`;
|
|
94
|
+
setReactions((current) =>
|
|
95
|
+
cullReactions([...current, { id, emoji, identity, lane: laneFor(id), at: Date.now() }], Date.now()),
|
|
96
|
+
);
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
useBroadcast(
|
|
100
|
+
useCallback(
|
|
101
|
+
(event) => {
|
|
102
|
+
if (event.type !== "reaction") return;
|
|
103
|
+
// Our own send already flew locally; the node echoes it back to us
|
|
104
|
+
// as the delivery mechanism, and rendering the echo doubles it.
|
|
105
|
+
if (event.identity === identityRef.current) return;
|
|
106
|
+
const emoji = (event.data as { emoji?: unknown }).emoji;
|
|
107
|
+
// The whitelist applies on RECEIPT too: a hostile peer must not fly
|
|
108
|
+
// arbitrary strings across everyone's screen.
|
|
109
|
+
if (isKnownReaction(emoji)) add(emoji, event.identity);
|
|
110
|
+
},
|
|
111
|
+
[add],
|
|
112
|
+
),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// Cull on a slow tick only while anything is flying.
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (reactions.length === 0) return undefined;
|
|
118
|
+
const timer = setInterval(() => setReactions((current) => cullReactions(current, Date.now())), 1000);
|
|
119
|
+
return () => clearInterval(timer);
|
|
120
|
+
}, [reactions.length > 0]);
|
|
121
|
+
|
|
122
|
+
const sendReaction = useCallback(
|
|
123
|
+
(emoji: ReactionEmoji) => {
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
if (!reactionGate(lastSentAt.current, now)) return;
|
|
126
|
+
lastSentAt.current = now;
|
|
127
|
+
void room?.sendReaction({ emoji });
|
|
128
|
+
// Our own copy flies immediately; the node's echo is not waited for.
|
|
129
|
+
add(emoji, "me");
|
|
130
|
+
},
|
|
131
|
+
[room, add],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
return { reactions, sendReaction };
|
|
135
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The rendered width of an element, tracked live.
|
|
5
|
+
*
|
|
6
|
+
* The call stage is a portable SDK component styled with INLINE styles, so it
|
|
7
|
+
* cannot lean on CSS media queries or a Tailwind breakpoint to go responsive.
|
|
8
|
+
* It also may not be the full window: it embeds inside a booking page, a modal,
|
|
9
|
+
* a split view. So the breakpoint that matters is the element's OWN width, read
|
|
10
|
+
* with a ResizeObserver, not `window.innerWidth`.
|
|
11
|
+
*
|
|
12
|
+
* Returns 0 until the first measurement (and where ResizeObserver does not
|
|
13
|
+
* exist, jsdom included), which every caller reads as "not narrow yet" so the
|
|
14
|
+
* default is the roomy desktop layout rather than a flash of the mobile one.
|
|
15
|
+
*/
|
|
16
|
+
export function useMeasuredWidth<T extends HTMLElement>(): [React.RefObject<T | null>, number] {
|
|
17
|
+
const ref = useRef<T | null>(null);
|
|
18
|
+
const [width, setWidth] = useState(0);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const element = ref.current;
|
|
22
|
+
if (!element || typeof ResizeObserver === "undefined") return undefined;
|
|
23
|
+
const observer = new ResizeObserver((entries) => {
|
|
24
|
+
const entry = entries[0];
|
|
25
|
+
if (entry) setWidth(Math.round(entry.contentRect.width));
|
|
26
|
+
});
|
|
27
|
+
observer.observe(element);
|
|
28
|
+
// Seed synchronously so the first paint is not a frame of width 0.
|
|
29
|
+
setWidth(Math.round(element.getBoundingClientRect().width));
|
|
30
|
+
return () => observer.disconnect();
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
return [ref, width];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Below this the stage folds the side panel into a full-screen overlay and
|
|
37
|
+
* tightens the chrome. A phone in portrait is ~360-430 CSS px; a tablet and up
|
|
38
|
+
* keeps the panel docked beside the stage. */
|
|
39
|
+
export const STAGE_NARROW_MAX_PX = 600;
|
package/src/ui/media/index.ts
CHANGED
|
@@ -57,11 +57,29 @@ export {
|
|
|
57
57
|
// full announcement history and deliberately keeps producers the current
|
|
58
58
|
// subscribe rule bars, so that a barrier which narrows can later widen.
|
|
59
59
|
useVisibleProducers,
|
|
60
|
+
// Layer-2 capacity: a custom layout that renders its own tiles must report
|
|
61
|
+
// them, or the room subscribes to the whole active set on its behalf.
|
|
62
|
+
useTileViewport,
|
|
63
|
+
useSpeakers,
|
|
60
64
|
type LocalMediaControls,
|
|
61
65
|
type LocalSource,
|
|
62
66
|
type MediaRoomProviderProps,
|
|
67
|
+
type PublishDeviceOptions,
|
|
68
|
+
type TileViewportControls,
|
|
63
69
|
} from "@tribe-nest/media-client/react";
|
|
64
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Hands and flying reactions, with the stage's semantics (queue order, the
|
|
73
|
+
* courtesy lower-hand, the whitelist and the send gate). The raw wire hook
|
|
74
|
+
* is `useBroadcast` in the SDK; a site should not need it.
|
|
75
|
+
*/
|
|
76
|
+
export {
|
|
77
|
+
useFlyingReactions,
|
|
78
|
+
useRaisedHands,
|
|
79
|
+
type FlyingReactionsControls,
|
|
80
|
+
type RaisedHandsControls,
|
|
81
|
+
} from "./hooks/useCallSignals";
|
|
82
|
+
|
|
65
83
|
export type {
|
|
66
84
|
ConnectionState,
|
|
67
85
|
DisconnectCause,
|
|
@@ -92,7 +110,29 @@ export type { MediaGrants, Peer, SubscribeRule } from "@tribe-nest/media-protoco
|
|
|
92
110
|
* that separates "reconnecting" from "refused" rather than showing one spinner
|
|
93
111
|
* for both.
|
|
94
112
|
*/
|
|
95
|
-
export {
|
|
113
|
+
export {
|
|
114
|
+
CallStage,
|
|
115
|
+
type CallStageMediaDefaults,
|
|
116
|
+
type CallStageProps,
|
|
117
|
+
type CallStageRecording,
|
|
118
|
+
} from "./CallStage";
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The green room: device check and join-muted toggles BEFORE anything
|
|
122
|
+
* connects. Pages mount this in place of `MediaRoomProvider` and mount the
|
|
123
|
+
* provider (with `mediaDefaults` on the stage) only on its `onJoin`.
|
|
124
|
+
*/
|
|
125
|
+
export { PreJoin, type PreJoinProps, type PreJoinSettings } from "./PreJoin";
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The chat adapter's shape, injected per surface. The stage never touches a
|
|
129
|
+
* socket or an HTTP client; each surface composes its own lane's hooks into
|
|
130
|
+
* this and hands it over as the `chat` prop.
|
|
131
|
+
*/
|
|
132
|
+
export type { CallChatMessage, CallStageChat } from "./callChat";
|
|
133
|
+
|
|
134
|
+
/** The reaction vocabulary, shared by the picker and the receive whitelist. */
|
|
135
|
+
export { REACTION_EMOJI, type ReactionEmoji } from "./stage/callSignals";
|
|
96
136
|
|
|
97
137
|
/**
|
|
98
138
|
* What a call page shows OUTSIDE the window, when there is nothing to connect
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { SendHorizontal, SmilePlus } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import type { ForgeT } from "../../../i18n";
|
|
5
|
+
import type { CallStageChat } from "../callChat";
|
|
6
|
+
import { REACTION_EMOJI } from "../stage/callSignals";
|
|
7
|
+
import type { StageTokens } from "../stage/stageTheme";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The in-call chat rail.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately its OWN bubbles and composer rather than the shared
|
|
13
|
+
* `MessageBubble`/`ChatComposer`: those read Forge's theme and data contexts
|
|
14
|
+
* (attachment uploads run through `useForge()`), and the admin dashboard
|
|
15
|
+
* mounts neither. Everything this panel touches arrives through the
|
|
16
|
+
* `CallStageChat` adapter, which is what lets one stage serve three
|
|
17
|
+
* differently-authenticated surfaces. Text only, on purpose: an attachment
|
|
18
|
+
* picker is a lane-specific feature the adapter can grow later.
|
|
19
|
+
*/
|
|
20
|
+
export function CallChatPanel({ chat, tokens, t }: { chat: CallStageChat; tokens: StageTokens; t: ForgeT }) {
|
|
21
|
+
const [draft, setDraft] = useState("");
|
|
22
|
+
const [pickerFor, setPickerFor] = useState<string | null>(null);
|
|
23
|
+
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
24
|
+
const stickToBottom = useRef(true);
|
|
25
|
+
|
|
26
|
+
// Follow new messages only while the person is AT the bottom; scrolling up
|
|
27
|
+
// to read history must not be yanked back down by every arrival.
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const element = scrollRef.current;
|
|
30
|
+
if (element && stickToBottom.current) element.scrollTop = element.scrollHeight;
|
|
31
|
+
}, [chat.messages.length]);
|
|
32
|
+
|
|
33
|
+
const send = () => {
|
|
34
|
+
const text = draft.trim();
|
|
35
|
+
if (!text) return;
|
|
36
|
+
chat.sendMessage(text);
|
|
37
|
+
setDraft("");
|
|
38
|
+
stickToBottom.current = true;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}>
|
|
43
|
+
<div
|
|
44
|
+
ref={scrollRef}
|
|
45
|
+
data-testid="call-chat-messages"
|
|
46
|
+
onScroll={(event) => {
|
|
47
|
+
const element = event.currentTarget;
|
|
48
|
+
stickToBottom.current = element.scrollHeight - element.scrollTop - element.clientHeight < 40;
|
|
49
|
+
}}
|
|
50
|
+
style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: "0.75rem", display: "flex", flexDirection: "column", gap: "0.625rem" }}
|
|
51
|
+
>
|
|
52
|
+
{!chat.ready && <span style={{ fontSize: "0.8125rem", color: tokens.stageMuted }}>{t("forge.call_stage.chat_loading")}</span>}
|
|
53
|
+
{chat.ready && chat.messages.length === 0 && (
|
|
54
|
+
<span style={{ fontSize: "0.8125rem", color: tokens.stageMuted }}>{t("forge.call_stage.chat_empty")}</span>
|
|
55
|
+
)}
|
|
56
|
+
{chat.messages.map((message) => {
|
|
57
|
+
const mine = !!chat.currentSenderKey && message.senderKey === chat.currentSenderKey;
|
|
58
|
+
return (
|
|
59
|
+
<div key={message.id} style={{ alignSelf: mine ? "flex-end" : "flex-start", maxWidth: "85%" }}>
|
|
60
|
+
{!mine && <div style={{ fontSize: "0.6875rem", color: tokens.stageMuted, marginBottom: 2 }}>{message.senderName}</div>}
|
|
61
|
+
<div
|
|
62
|
+
style={{
|
|
63
|
+
position: "relative",
|
|
64
|
+
padding: "0.4rem 0.65rem",
|
|
65
|
+
borderRadius: tokens.cornerRadius,
|
|
66
|
+
backgroundColor: mine ? tokens.accent : tokens.stageChrome,
|
|
67
|
+
color: mine ? tokens.accentText : tokens.stageText,
|
|
68
|
+
fontSize: "0.8125rem",
|
|
69
|
+
whiteSpace: "pre-wrap",
|
|
70
|
+
wordBreak: "break-word",
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
{message.content}
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
onClick={() => setPickerFor((current) => (current === message.id ? null : message.id))}
|
|
77
|
+
aria-label={t("forge.call_stage.react_to_message")}
|
|
78
|
+
style={{
|
|
79
|
+
position: "absolute",
|
|
80
|
+
top: -8,
|
|
81
|
+
...(mine ? { left: -8 } : { right: -8 }),
|
|
82
|
+
width: 20,
|
|
83
|
+
height: 20,
|
|
84
|
+
display: "inline-flex",
|
|
85
|
+
alignItems: "center",
|
|
86
|
+
justifyContent: "center",
|
|
87
|
+
borderRadius: 9999,
|
|
88
|
+
border: `1px solid ${tokens.stageBorder}`,
|
|
89
|
+
backgroundColor: tokens.stageSurface,
|
|
90
|
+
color: tokens.stageMuted,
|
|
91
|
+
cursor: "pointer",
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
<SmilePlus width={11} height={11} aria-hidden="true" />
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
{pickerFor === message.id && (
|
|
98
|
+
<div style={{ display: "flex", gap: "0.25rem", marginTop: 3 }}>
|
|
99
|
+
{REACTION_EMOJI.map((emoji) => (
|
|
100
|
+
<button
|
|
101
|
+
key={emoji}
|
|
102
|
+
type="button"
|
|
103
|
+
onClick={() => {
|
|
104
|
+
chat.reactToMessage(message.id, emoji);
|
|
105
|
+
setPickerFor(null);
|
|
106
|
+
}}
|
|
107
|
+
style={{ border: "none", background: "transparent", cursor: "pointer", fontSize: "1rem", padding: 0 }}
|
|
108
|
+
>
|
|
109
|
+
{emoji}
|
|
110
|
+
</button>
|
|
111
|
+
))}
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
{message.reactions.length > 0 && (
|
|
115
|
+
<div style={{ display: "flex", gap: "0.25rem", marginTop: 3, flexWrap: "wrap" }}>
|
|
116
|
+
{message.reactions.map((reaction) => (
|
|
117
|
+
<button
|
|
118
|
+
key={reaction.emoji}
|
|
119
|
+
type="button"
|
|
120
|
+
onClick={() => chat.reactToMessage(message.id, reaction.emoji)}
|
|
121
|
+
aria-pressed={reaction.reactedByMe}
|
|
122
|
+
style={{
|
|
123
|
+
display: "inline-flex",
|
|
124
|
+
alignItems: "center",
|
|
125
|
+
gap: "0.2rem",
|
|
126
|
+
fontSize: "0.6875rem",
|
|
127
|
+
padding: "0.05rem 0.375rem",
|
|
128
|
+
borderRadius: 9999,
|
|
129
|
+
border: `1px solid ${reaction.reactedByMe ? tokens.accent : tokens.stageBorder}`,
|
|
130
|
+
backgroundColor: reaction.reactedByMe ? tokens.accent : "transparent",
|
|
131
|
+
color: reaction.reactedByMe ? tokens.accentText : tokens.stageText,
|
|
132
|
+
cursor: "pointer",
|
|
133
|
+
}}
|
|
134
|
+
>
|
|
135
|
+
{reaction.emoji} {reaction.count}
|
|
136
|
+
</button>
|
|
137
|
+
))}
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
})}
|
|
143
|
+
{chat.typingNames.length > 0 && (
|
|
144
|
+
<span style={{ fontSize: "0.6875rem", color: tokens.stageMuted }}>
|
|
145
|
+
{t("forge.call_stage.chat_typing", { name: chat.typingNames[0] ?? "" })}
|
|
146
|
+
</span>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<div style={{ display: "flex", gap: "0.375rem", padding: "0.5rem", borderTop: `1px solid ${tokens.stageBorder}` }}>
|
|
151
|
+
<input
|
|
152
|
+
value={draft}
|
|
153
|
+
onChange={(event) => {
|
|
154
|
+
setDraft(event.target.value);
|
|
155
|
+
chat.onTyping?.();
|
|
156
|
+
}}
|
|
157
|
+
onKeyDown={(event) => {
|
|
158
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
send();
|
|
161
|
+
}
|
|
162
|
+
}}
|
|
163
|
+
placeholder={t("forge.call_stage.chat_placeholder")}
|
|
164
|
+
disabled={!chat.ready}
|
|
165
|
+
style={{
|
|
166
|
+
flex: 1,
|
|
167
|
+
minWidth: 0,
|
|
168
|
+
padding: "0.45rem 0.6rem",
|
|
169
|
+
borderRadius: tokens.cornerRadius,
|
|
170
|
+
border: `1px solid ${tokens.stageBorder}`,
|
|
171
|
+
backgroundColor: tokens.stageBg,
|
|
172
|
+
color: tokens.stageText,
|
|
173
|
+
font: "inherit",
|
|
174
|
+
fontSize: "0.8125rem",
|
|
175
|
+
outline: "none",
|
|
176
|
+
}}
|
|
177
|
+
/>
|
|
178
|
+
<button
|
|
179
|
+
type="button"
|
|
180
|
+
onClick={send}
|
|
181
|
+
disabled={!chat.ready || !draft.trim()}
|
|
182
|
+
aria-label={t("forge.call_stage.chat_send")}
|
|
183
|
+
style={{
|
|
184
|
+
display: "inline-flex",
|
|
185
|
+
alignItems: "center",
|
|
186
|
+
justifyContent: "center",
|
|
187
|
+
width: 34,
|
|
188
|
+
borderRadius: tokens.cornerRadius,
|
|
189
|
+
border: "none",
|
|
190
|
+
backgroundColor: draft.trim() ? tokens.accent : tokens.stageChrome,
|
|
191
|
+
color: draft.trim() ? tokens.accentText : tokens.stageMuted,
|
|
192
|
+
cursor: draft.trim() ? "pointer" : "default",
|
|
193
|
+
}}
|
|
194
|
+
>
|
|
195
|
+
<SendHorizontal width={15} height={15} aria-hidden="true" />
|
|
196
|
+
</button>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Hand, MicOff, Video, VideoOff } from "lucide-react";
|
|
2
|
+
import type { RaisedHand } from "@tribe-nest/media-client";
|
|
3
|
+
|
|
4
|
+
import type { ForgeT } from "../../../i18n";
|
|
5
|
+
import type { CallTile } from "../callState";
|
|
6
|
+
import type { StageTokens } from "../stage/stageTheme";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The roster: everyone in the call with their mic and camera state, hands
|
|
10
|
+
* FIRST and in queue order, because a host reads this list to call on the
|
|
11
|
+
* next question. The lower-hand controls are host courtesies (the target's
|
|
12
|
+
* client obeys); they render only for the host so nobody else is offered a
|
|
13
|
+
* button whose whole meaning is "I am running this call".
|
|
14
|
+
*/
|
|
15
|
+
export function CallParticipantsPanel({
|
|
16
|
+
tiles,
|
|
17
|
+
hands,
|
|
18
|
+
isHost,
|
|
19
|
+
myIdentity,
|
|
20
|
+
onLowerHand,
|
|
21
|
+
onLowerAll,
|
|
22
|
+
tokens,
|
|
23
|
+
t,
|
|
24
|
+
}: {
|
|
25
|
+
tiles: readonly CallTile[];
|
|
26
|
+
hands: readonly RaisedHand[];
|
|
27
|
+
isHost: boolean;
|
|
28
|
+
myIdentity: string | null;
|
|
29
|
+
onLowerHand: (identity: string) => void;
|
|
30
|
+
onLowerAll: () => void;
|
|
31
|
+
tokens: StageTokens;
|
|
32
|
+
t: ForgeT;
|
|
33
|
+
}) {
|
|
34
|
+
const byIdentity = new Map<string, CallTile>();
|
|
35
|
+
for (const tile of tiles) {
|
|
36
|
+
if (tile.source === "camera" && !byIdentity.has(tile.identity)) byIdentity.set(tile.identity, tile);
|
|
37
|
+
}
|
|
38
|
+
const handOrder = new Map(hands.map((hand, index) => [hand.identity, index]));
|
|
39
|
+
const people = [...byIdentity.values()].sort((a, b) => {
|
|
40
|
+
const aHand = handOrder.get(a.identity) ?? Number.MAX_SAFE_INTEGER;
|
|
41
|
+
const bHand = handOrder.get(b.identity) ?? Number.MAX_SAFE_INTEGER;
|
|
42
|
+
return aHand - bHand || a.name.localeCompare(b.name);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
data-testid="call-participants-panel"
|
|
48
|
+
style={{ flex: 1, minHeight: 0, overflowY: "auto", display: "flex", flexDirection: "column", padding: "0.5rem" }}
|
|
49
|
+
>
|
|
50
|
+
{isHost && hands.length > 0 && (
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
onClick={onLowerAll}
|
|
54
|
+
style={{
|
|
55
|
+
alignSelf: "flex-end",
|
|
56
|
+
margin: "0.25rem",
|
|
57
|
+
padding: "0.25rem 0.6rem",
|
|
58
|
+
fontSize: "0.75rem",
|
|
59
|
+
borderRadius: tokens.cornerRadius,
|
|
60
|
+
border: `1px solid ${tokens.stageBorder}`,
|
|
61
|
+
backgroundColor: "transparent",
|
|
62
|
+
color: tokens.stageText,
|
|
63
|
+
cursor: "pointer",
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
{t("forge.call_stage.lower_all_hands")}
|
|
67
|
+
</button>
|
|
68
|
+
)}
|
|
69
|
+
{people.map((person) => {
|
|
70
|
+
const raisedAt = hands.find((h) => h.identity === person.identity)?.raisedAt;
|
|
71
|
+
const queuePosition = handOrder.get(person.identity);
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
key={person.identity}
|
|
75
|
+
style={{
|
|
76
|
+
display: "flex",
|
|
77
|
+
alignItems: "center",
|
|
78
|
+
gap: "0.5rem",
|
|
79
|
+
padding: "0.45rem 0.5rem",
|
|
80
|
+
borderRadius: tokens.cornerRadius,
|
|
81
|
+
fontSize: "0.8125rem",
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
{raisedAt !== undefined && (
|
|
85
|
+
<span
|
|
86
|
+
aria-label={t("forge.call_stage.hand_raised_by", { name: person.name })}
|
|
87
|
+
style={{ display: "inline-flex", alignItems: "center", gap: "0.2rem", color: tokens.accent, fontWeight: 600 }}
|
|
88
|
+
>
|
|
89
|
+
<Hand width={14} height={14} aria-hidden="true" />
|
|
90
|
+
{queuePosition !== undefined ? queuePosition + 1 : ""}
|
|
91
|
+
</span>
|
|
92
|
+
)}
|
|
93
|
+
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
94
|
+
{person.name}
|
|
95
|
+
{person.identity === myIdentity && <span style={{ color: tokens.stageMuted }}> {t("forge.call_stage.you_suffix")}</span>}
|
|
96
|
+
</span>
|
|
97
|
+
{(person.audioMuted || !person.hasAudio) && <MicOff width={14} height={14} aria-hidden="true" color={tokens.stageMuted} />}
|
|
98
|
+
{person.videoProducerId && !person.videoPaused ? (
|
|
99
|
+
<Video width={14} height={14} aria-hidden="true" color={tokens.stageMuted} />
|
|
100
|
+
) : (
|
|
101
|
+
<VideoOff width={14} height={14} aria-hidden="true" color={tokens.stageMuted} />
|
|
102
|
+
)}
|
|
103
|
+
{isHost && raisedAt !== undefined && person.identity !== myIdentity && (
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
onClick={() => onLowerHand(person.identity)}
|
|
107
|
+
style={{
|
|
108
|
+
padding: "0.15rem 0.45rem",
|
|
109
|
+
fontSize: "0.6875rem",
|
|
110
|
+
borderRadius: tokens.cornerRadius,
|
|
111
|
+
border: `1px solid ${tokens.stageBorder}`,
|
|
112
|
+
backgroundColor: "transparent",
|
|
113
|
+
color: tokens.stageText,
|
|
114
|
+
cursor: "pointer",
|
|
115
|
+
}}
|
|
116
|
+
>
|
|
117
|
+
{t("forge.call_stage.lower_hand_of")}
|
|
118
|
+
</button>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
})}
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|