@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.
Files changed (42) hide show
  1. package/package.json +2 -2
  2. package/src/data/queries/useCallChat.ts +162 -0
  3. package/src/data/queries/useCallRecording.ts +80 -0
  4. package/src/data/queries/useMembershipCalls.ts +162 -0
  5. package/src/i18n/de.json +73 -1
  6. package/src/i18n/en.json +73 -1
  7. package/src/index.ts +7 -0
  8. package/src/ui/headless/memberHome/index.ts +4 -0
  9. package/src/ui/headless/memberHome/useMemberCalls.ts +137 -0
  10. package/src/ui/headless/memberHome/useMemberHomeNav.ts +29 -5
  11. package/src/ui/media/CallStage.tsx +691 -553
  12. package/src/ui/media/PreJoin.tsx +269 -0
  13. package/src/ui/media/_tests/CallStage.spec.tsx +338 -5
  14. package/src/ui/media/_tests/callLayout.spec.ts +182 -0
  15. package/src/ui/media/_tests/callSignals.spec.ts +87 -0
  16. package/src/ui/media/_tests/callState.spec.ts +93 -1
  17. package/src/ui/media/_tests/fakeNode.ts +4 -1
  18. package/src/ui/media/callChat.ts +33 -0
  19. package/src/ui/media/callState.ts +125 -9
  20. package/src/ui/media/hooks/useCallSignals.ts +135 -0
  21. package/src/ui/media/hooks/useMeasuredWidth.ts +39 -0
  22. package/src/ui/media/index.ts +41 -1
  23. package/src/ui/media/panels/CallChatPanel.tsx +200 -0
  24. package/src/ui/media/panels/CallParticipantsPanel.tsx +125 -0
  25. package/src/ui/media/panels/CallSidePanel.tsx +152 -0
  26. package/src/ui/media/stage/CallCenterNotice.tsx +89 -0
  27. package/src/ui/media/stage/CallControlsBar.tsx +547 -0
  28. package/src/ui/media/stage/CallHealthPanel.tsx +132 -0
  29. package/src/ui/media/stage/CallToasts.tsx +49 -0
  30. package/src/ui/media/stage/CallTopBar.tsx +257 -0
  31. package/src/ui/media/stage/DeviceMenu.tsx +142 -0
  32. package/src/ui/media/stage/ReactionOverlay.tsx +38 -0
  33. package/src/ui/media/stage/SelfView.tsx +146 -0
  34. package/src/ui/media/stage/VideoTile.tsx +232 -0
  35. package/src/ui/media/stage/callLayout.ts +253 -0
  36. package/src/ui/media/stage/callSignals.ts +91 -0
  37. package/src/ui/media/stage/callSounds.ts +78 -0
  38. package/src/ui/media/stage/stageTheme.ts +50 -0
  39. package/src/ui/styled/forge-utilities.css +13 -0
  40. package/src/ui/styled/members/MemberCallsCalendar.tsx +571 -0
  41. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +135 -0
  42. 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;
@@ -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 { CallStage, type CallStageProps } from "./CallStage";
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
+ }