@tribe-nest/forge 3.38.0 → 3.41.1

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.
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode } from "react";
2
2
  import { Maximize, Pause, Pin, Play, Send } from "lucide-react";
3
3
  import { io, type Socket } from "socket.io-client";
4
4
  import type { IBroadcastPass, ILiveBroadcast } from "../../../types/models";
@@ -10,18 +10,39 @@ import {
10
10
  useBroadcastComments,
11
11
  useBroadcastSessionApi,
12
12
  type BroadcastComment,
13
+ type BroadcastViewerCredentials,
13
14
  } from "../../../data/queries/useBroadcasts";
14
15
  import { useForgeT } from "../../../i18n";
15
16
  import { useForgeTheme } from "../../theme/ForgeThemeProvider";
17
+ import { CallHelpHint } from "../../media/CallHelpHint";
16
18
  import { timeAgo } from "../community/util";
17
19
  import { buttonStyle } from "../Button";
18
20
  import { Loading } from "../Loading";
21
+ import {
22
+ broadcastFellBack,
23
+ chooseBroadcastStage,
24
+ type BroadcastCredentialsError,
25
+ type BroadcastRoomState,
26
+ } from "./broadcastStage";
27
+ // Type-only, and it has to stay that way: the module it names imports
28
+ // `mediasoup-client`, and a value import here would put a browser-only package
29
+ // on the server path of every site that loads `@tribe-nest/forge/ui`. It is
30
+ // reached through the `import()` in the effect below, which no server runs.
31
+ import type { BroadcastRealtimeStageProps } from "./BroadcastRealtimeStage";
32
+ import { BroadcastPollPanel } from "./BroadcastPollPanel";
33
+ import { useBroadcastPoll } from "../../headless/broadcast/useBroadcastPoll";
34
+ import type { BroadcastPoll } from "../../../data/queries/useBroadcasts";
19
35
 
20
36
  /** The socket messages a watching browser sends and receives. */
21
37
  const SocketEvent = {
22
38
  BroadcastChatMessage: "broadcastChatMessage",
23
39
  BroadcastJoin: "broadcastJoin",
24
40
  BroadcastPinComment: "broadcastPinComment",
41
+ // Server to viewer only. A vote goes over HTTP, because it needs the unique
42
+ // index behind it; the result comes back here to everyone watching.
43
+ BroadcastPollStarted: "broadcastPollStarted",
44
+ BroadcastPollResults: "broadcastPollResults",
45
+ BroadcastPollEnded: "broadcastPollEnded",
25
46
  } as const;
26
47
 
27
48
  /**
@@ -35,6 +56,26 @@ const SIMULCAST_QUALITIES = [
35
56
  { label: "forge.broadcast_player.quality_low", value: "q" },
36
57
  ] as const;
37
58
 
59
+ /**
60
+ * The token endpoint's refusal, reduced to what the decision needs.
61
+ *
62
+ * Nothing here distinguishes a 409 from a 500 from a dead network, and that is
63
+ * the point: every one of them means this viewer is not getting into the live
64
+ * room, and every one of them ends with them watching the stream instead. The
65
+ * status and the message are carried so a site can log which it was.
66
+ */
67
+ function credentialsRefusal(error: unknown): BroadcastCredentialsError {
68
+ const response = (error as { response?: { status?: number; data?: { reason?: unknown; message?: unknown } } } | null)
69
+ ?.response;
70
+ const status = response?.status;
71
+ // The 409 answers with `{ reason }` (`full` or `unavailable`); everything
72
+ // else on this API answers with `{ message }`. Either is worth keeping for a
73
+ // log, and neither changes what happens next.
74
+ const detail = response?.data?.reason ?? response?.data?.message;
75
+ const message = typeof detail === "string" ? detail : undefined;
76
+ return { ...(typeof status === "number" ? { status } : {}), ...(message ? { message } : {}) };
77
+ }
78
+
38
79
  const newId = (): string => {
39
80
  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
40
81
  return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
@@ -65,15 +106,42 @@ export interface BroadcastPlayerProps {
65
106
  /**
66
107
  * The watch surface: the stream, who else is here, and the chat.
67
108
  *
68
- * Mounted ONLY behind a validated pass. Two playback paths, chosen by the
69
- * broadcast itself: a WebRTC subscription when the broadcast carries a realtime
70
- * config (sub-second, with a quality picker), and HLS otherwise.
109
+ * Mounted ONLY behind a validated pass. Three playback paths, chosen by the
110
+ * broadcast itself and never by the viewer:
111
+ *
112
+ * 1. **The media plane** (`realtime.available`). A v2 broadcast whose live room
113
+ * is open, watched over WebRTC in well under a second. This is the path that
114
+ * can be REFUSED: the room has a hard cap, so being turned away is ordinary.
115
+ * 2. **Cloudflare Calls** (`realtimeConfig`). The v1 realtime path, unchanged
116
+ * and still carrying its quality picker, until v1 itself is retired.
117
+ * 3. **HLS.** Six to ten seconds behind and always available.
118
+ *
119
+ * ## Falling back is the feature, not the error path
120
+ *
121
+ * A viewer who cannot get onto the plane must end up watching the broadcast,
122
+ * without doing anything and without being asked anything. So every uncertain
123
+ * state resolves to HLS, and the four ways the plane can fail (the token
124
+ * endpoint answering 409, any other failure fetching one, the room closing, and
125
+ * the SDK giving up) all land in the same place. `chooseBroadcastStage` holds
126
+ * the rule and `broadcastStage.spec.ts` walks every combination of it.
127
+ *
128
+ * The one thing the viewer is told is a single line saying which stream they
129
+ * are on, because a stream that is suddenly eight seconds behind a chat they
130
+ * are reading is something a person needs an explanation for. Why it happened
131
+ * is behind the `?`, per the house design law.
132
+ *
133
+ * ## Why the quality picker is not on the new path
134
+ *
135
+ * The plane has no simulcast. Every viewer receives the program feed's single
136
+ * encoding, so High, Medium and Low would be three controls that change
137
+ * nothing. A control that lies about what it does is worse than no control.
71
138
  */
72
139
  export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: BroadcastPlayerProps) {
73
140
  const t = useForgeT();
74
141
  const theme = useForgeTheme();
75
142
  const { apiUrl, profileId } = useForge();
76
143
  const sessionApi = useBroadcastSessionApi();
144
+ const poll = useBroadcastPoll(broadcast.id);
77
145
 
78
146
  const [comments, setComments] = useState<BroadcastComment[]>([]);
79
147
  const [comment, setComment] = useState("");
@@ -92,11 +160,125 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
92
160
  const [realtimeSessionId, setRealtimeSessionId] = useState<string | null>(null);
93
161
  const isSubscribingRef = useRef(false);
94
162
 
95
- const isRealtime = useMemo(
96
- () => !!broadcast.realtimeConfig && Object.keys(broadcast.realtimeConfig).length > 0,
97
- [broadcast.realtimeConfig],
163
+ const realtimeAvailable = broadcast.realtime?.available === true;
164
+
165
+ /**
166
+ * The v1 Cloudflare path, and it yields to the plane rather than competing
167
+ * with it. A broadcast carries one or the other in practice; if one ever
168
+ * carried both, the newer plane is the one to attempt, and a plane that
169
+ * refuses falls back to HLS as the contract says rather than sideways into a
170
+ * v1 session that will not exist.
171
+ */
172
+ const isLegacyRealtime = useMemo(
173
+ () => !realtimeAvailable && !!broadcast.realtimeConfig && Object.keys(broadcast.realtimeConfig).length > 0,
174
+ [realtimeAvailable, broadcast.realtimeConfig],
98
175
  );
99
176
 
177
+ // ── The media plane ────────────────────────────────────────────────────────
178
+ const [PlaneStage, setPlaneStage] = useState<ComponentType<BroadcastRealtimeStageProps> | null>(null);
179
+ const [credentialsReady, setCredentialsReady] = useState(false);
180
+ const [credentialsError, setCredentialsError] = useState<BroadcastCredentialsError | null>(null);
181
+ const [roomState, setRoomState] = useState<BroadcastRoomState | undefined>(undefined);
182
+ /** The ticket the probe already minted, spent on the SDK's FIRST attempt. */
183
+ const primedCredentials = useRef<BroadcastViewerCredentials | null>(null);
184
+
185
+ // `sessionApi` is rebuilt every render, so it is read through a ref rather
186
+ // than depended on: as a dependency it would re-mint a ticket on every render.
187
+ const sessionApiRef = useRef(sessionApi);
188
+ sessionApiRef.current = sessionApi;
189
+
190
+ /**
191
+ * The pass is the credential this endpoint takes, and `sessionId` is the id
192
+ * the pass is known by everywhere else on this surface (it is what
193
+ * `validate-session` is given, and `viewer-token` validates the pass the same
194
+ * way before minting).
195
+ */
196
+ const passId = broadcastPass.sessionId;
197
+
198
+ const mintCredentials = useCallback(
199
+ () => sessionApiRef.current.viewerToken({ broadcastId: broadcast.id, ...(passId ? { passId } : {}) }),
200
+ [broadcast.id, passId],
201
+ );
202
+
203
+ /**
204
+ * Load the SDK and mint a ticket, together, in an effect.
205
+ *
206
+ * The effect is what makes this safe on a server: Forge renders inside
207
+ * TanStack Start on Cloudflare Workers, `BroadcastRealtimeStage` imports
208
+ * `mediasoup-client`, and an effect is code no server ever runs. Nothing on
209
+ * the module path of this file touches the media SDK.
210
+ *
211
+ * They are fetched together because neither is any use without the other, and
212
+ * because a viewer must not be moved onto the plane until BOTH are in hand:
213
+ * mounting the provider first would show a black rectangle for as long as the
214
+ * chunk took to arrive. A chunk that never arrives lands in the same `catch`
215
+ * as a refused ticket and falls back the same way.
216
+ */
217
+ useEffect(() => {
218
+ if (!realtimeAvailable) return;
219
+ let cancelled = false;
220
+
221
+ Promise.all([import("./BroadcastRealtimeStage"), mintCredentials()])
222
+ .then(([module, credentials]) => {
223
+ if (cancelled) return;
224
+ primedCredentials.current = credentials;
225
+ setPlaneStage(() => module.BroadcastRealtimeStage);
226
+ setCredentialsReady(true);
227
+ })
228
+ .catch((error: unknown) => {
229
+ if (cancelled) return;
230
+ setCredentialsError(credentialsRefusal(error));
231
+ });
232
+
233
+ return () => {
234
+ cancelled = true;
235
+ };
236
+ }, [realtimeAvailable, mintCredentials]);
237
+
238
+ /**
239
+ * Handed to the provider, which calls it before EVERY attempt rather than
240
+ * once. A viewer ticket expires in ten minutes and a broadcast runs longer,
241
+ * so a token fetched once means the first reconnect presents a dead one.
242
+ *
243
+ * The probe's ticket is spent here on the first call, so one viewer does not
244
+ * mint two identities to watch one broadcast.
245
+ */
246
+ const getCredentials = useCallback(async () => {
247
+ const primed = primedCredentials.current;
248
+ primedCredentials.current = null;
249
+ if (primed) return primed;
250
+ try {
251
+ return await mintCredentials();
252
+ } catch (error) {
253
+ // A refusal on a reconnect says the same thing as a refusal on the first
254
+ // attempt: this viewer is not getting back into the room. Recording it is
255
+ // what moves them onto HLS instead of leaving the SDK knocking.
256
+ setCredentialsError(credentialsRefusal(error));
257
+ throw error;
258
+ }
259
+ }, [mintCredentials]);
260
+
261
+ /**
262
+ * Never cleared back to `undefined` when the stage unmounts, and that is
263
+ * load-bearing. A room that has closed is the reason we left it, so forgetting
264
+ * it would choose the plane again, mount the provider again, and watch it
265
+ * close again, for ever.
266
+ */
267
+ const handleRoomState = useCallback((next: BroadcastRoomState) => setRoomState(next), []);
268
+
269
+ const stageInput = useMemo(
270
+ () => ({ realtimeAvailable, credentialsReady, credentialsError, roomState }),
271
+ [realtimeAvailable, credentialsReady, credentialsError, roomState],
272
+ );
273
+ const stage = chooseBroadcastStage(stageInput);
274
+ const showPlane = stage === "realtime" && !!PlaneStage;
275
+ // Nothing is mounted while the ticket is in flight. `react-player` builds an
276
+ // hls.js instance on mount and starts fetching immediately, so rendering the
277
+ // HLS stage "meanwhile" made every realtime viewer download the broadcast a
278
+ // second time, over the CDN, for a stage that was about to be replaced.
279
+ const showHls = stage === "hls" && !isLegacyRealtime;
280
+ const fellBack = broadcastFellBack(stageInput);
281
+
100
282
  const { data: audienceData } = useBroadcastAudience(broadcast.id, !broadcast.endedAt);
101
283
  const { data: initialComments } = useBroadcastComments(broadcast.id);
102
284
 
@@ -113,10 +295,10 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
113
295
  });
114
296
  }, [comments]);
115
297
 
116
- // ── WebRTC subscription ────────────────────────────────────────────────────
298
+ // ── WebRTC subscription, v1 (Cloudflare Calls) ─────────────────────────────
117
299
  useEffect(() => {
118
300
  const config = broadcast.realtimeConfig;
119
- if (!isRealtime || !config) return;
301
+ if (!isLegacyRealtime || !config) return;
120
302
  if (isSubscribingRef.current) return;
121
303
  isSubscribingRef.current = true;
122
304
 
@@ -150,6 +332,10 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
150
332
  type: local?.type,
151
333
  trackIds: config.tracks.map((track) => track.trackName),
152
334
  sessionId: config.sessionId,
335
+ // The pass, because subscribing IS playback: this endpoint used to
336
+ // take a session id and a track name from anybody and hand back the
337
+ // stream, which made the ticket box in front of it decoration.
338
+ passId,
153
339
  });
154
340
 
155
341
  setRealtimeSessionId(answer.sessionId);
@@ -166,7 +352,7 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
166
352
  // `sessionApi` is rebuilt each render and is deliberately not a dependency:
167
353
  // including it would re-negotiate the peer connection on every render.
168
354
  // eslint-disable-next-line react-hooks/exhaustive-deps
169
- }, [isRealtime, broadcast.realtimeConfig]);
355
+ }, [isLegacyRealtime, broadcast.realtimeConfig]);
170
356
 
171
357
  // ── Chat socket ────────────────────────────────────────────────────────────
172
358
  useEffect(() => {
@@ -187,6 +373,17 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
187
373
  setComments((prev) => [...prev, data.message]);
188
374
  return;
189
375
  }
376
+ if (
377
+ message.event === SocketEvent.BroadcastPollStarted ||
378
+ message.event === SocketEvent.BroadcastPollResults
379
+ ) {
380
+ poll.onPollEvent((message.data as { poll: BroadcastPoll }).poll);
381
+ return;
382
+ }
383
+ if (message.event === SocketEvent.BroadcastPollEnded) {
384
+ poll.onPollEvent(null);
385
+ return;
386
+ }
190
387
  if (message.event === SocketEvent.BroadcastPinComment) {
191
388
  const data = message.data as { broadcastId: string; comment: BroadcastComment | null };
192
389
  setPinnedComment(data.comment);
@@ -194,7 +391,7 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
194
391
  });
195
392
 
196
393
  socketRef.current = socket;
197
- }, [apiUrl, profileId, broadcast.id]);
394
+ }, [apiUrl, profileId, broadcast.id, poll.onPollEvent]);
198
395
 
199
396
  const sendComment = () => {
200
397
  if (!profileId || !socketRef.current || !comment.trim()) return;
@@ -213,7 +410,7 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
213
410
  };
214
411
 
215
412
  const togglePlay = () => {
216
- if (!isRealtime || !isAudioLoaded || !isVideoLoaded) return;
413
+ if (!isLegacyRealtime || !isAudioLoaded || !isVideoLoaded) return;
217
414
  if (isPlaying) {
218
415
  audioRef.current?.pause();
219
416
  videoRef.current?.pause();
@@ -239,6 +436,7 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
239
436
  const videoTrack = config.tracks.find((track) => track.mid === "0");
240
437
  try {
241
438
  await sessionApi.switchQuality({
439
+ passId,
242
440
  track: {
243
441
  ...videoTrack,
244
442
  sessionId: realtimeSessionId,
@@ -283,7 +481,15 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
283
481
  }}
284
482
  >
285
483
  <div style={{ flex: "1 1 520px", minWidth: 0, ...panel }}>
286
- {!isRealtime && (
484
+ {showPlane && PlaneStage && <PlaneStage getCredentials={getCredentials} onRoomState={handleRoomState} />}
485
+
486
+ {stage === "pending" && (
487
+ <div style={{ aspectRatio: "16/9", display: "grid", placeItems: "center" }}>
488
+ <Loading />
489
+ </div>
490
+ )}
491
+
492
+ {showHls && (
287
493
  <div data-testid="broadcast-hls-stage" style={{ aspectRatio: "16/9" }}>
288
494
  {renderPlayer ? (
289
495
  renderPlayer({ src: broadcast.liveUrl })
@@ -299,7 +505,34 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
299
505
  </div>
300
506
  )}
301
507
 
302
- {isRealtime && (
508
+ {/*
509
+ Which stream they ended up on. This is STATE, not an explanation, so
510
+ it stays visible: a viewer whose picture is suddenly eight seconds
511
+ behind the chat beside it needs to know that before they go looking
512
+ for a fault. The reason it happened is help, so it is behind the `?`.
513
+ */}
514
+ {fellBack && (
515
+ <div
516
+ role="status"
517
+ data-testid="broadcast-fallback-notice"
518
+ style={{
519
+ display: "flex",
520
+ alignItems: "center",
521
+ gap: 6,
522
+ padding: "8px 16px 0",
523
+ fontSize: 13,
524
+ color: theme.colors.text,
525
+ opacity: 0.75,
526
+ }}
527
+ >
528
+ <span>{t("forge.broadcast_player.fallback_notice")}</span>
529
+ <CallHelpHint label={t("forge.broadcast_player.fallback_help_label")}>
530
+ {t("forge.broadcast_player.fallback_help")}
531
+ </CallHelpHint>
532
+ </div>
533
+ )}
534
+
535
+ {isLegacyRealtime && (
303
536
  <div
304
537
  ref={videoContainerRef}
305
538
  data-testid="broadcast-realtime-stage"
@@ -403,7 +636,13 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
403
636
  </div>
404
637
  </div>
405
638
 
406
- {isRealtime && (
639
+ {/*
640
+ v1 only. The media plane has no simulcast, so on that path these
641
+ four options would all resolve to the one encoding the program feed
642
+ publishes, and a picker that cannot change anything is a lie about
643
+ what the viewer is receiving.
644
+ */}
645
+ {isLegacyRealtime && (
407
646
  <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
408
647
  <label htmlFor="broadcast-quality">{t("forge.broadcast_player.quality")}</label>
409
648
  <select
@@ -435,6 +674,8 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
435
674
  {t("forge.broadcast_player.chat")}
436
675
  </p>
437
676
 
677
+ <BroadcastPollPanel poll={poll} />
678
+
438
679
  {pinnedComment && (
439
680
  <div
440
681
  data-testid="broadcast-pinned-comment"
@@ -0,0 +1,124 @@
1
+ import { BarChart3 } from "lucide-react";
2
+
3
+ import { useForgeT } from "../../../i18n";
4
+ import { useForgeTheme } from "../../theme/ForgeThemeProvider";
5
+ import { pollPercentages } from "./pollResults";
6
+ import type { BroadcastPollApi } from "../../headless/broadcast/useBroadcastPoll";
7
+
8
+ /**
9
+ * The poll, beside the stream.
10
+ *
11
+ * This is the interactive half of the feature. The other half is a results
12
+ * block composited into the video itself by the studio, which is what an
13
+ * audience on YouTube or Twitch sees: they get the outcome, and only viewers on
14
+ * the creator's own site can actually answer.
15
+ *
16
+ * ## Results are shown after voting, not before
17
+ *
18
+ * A poll that shows the tallies while you are still choosing is not measuring
19
+ * what the room thinks, it is measuring what the room saw the room think. So
20
+ * the options are buttons until this browser has answered, and bars afterwards.
21
+ * A closed poll goes straight to bars for everybody.
22
+ */
23
+ export function BroadcastPollPanel({ poll: api }: { poll: BroadcastPollApi }) {
24
+ const t = useForgeT();
25
+ const theme = useForgeTheme();
26
+ const { poll, votedOptionId, voting, error } = api;
27
+
28
+ if (!poll || poll.status === "draft") return null;
29
+
30
+ const showResults = !!votedOptionId || poll.status !== "live";
31
+ const results = pollPercentages(poll);
32
+
33
+ return (
34
+ <div
35
+ data-testid="broadcast-poll"
36
+ style={{
37
+ margin: "8px 16px 0",
38
+ padding: 12,
39
+ backgroundColor: `${theme.colors.primary}15`,
40
+ border: `1px solid ${theme.colors.primary}50`,
41
+ borderRadius: theme.cornerRadius,
42
+ }}
43
+ >
44
+ <div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 8 }}>
45
+ <BarChart3 size={14} style={{ color: theme.colors.primary, flexShrink: 0 }} />
46
+ <span style={{ fontSize: 12, fontWeight: 500, color: theme.colors.primary }}>
47
+ {poll.status === "live" ? t("forge.broadcast_poll.live") : t("forge.broadcast_poll.closed")}
48
+ </span>
49
+ </div>
50
+
51
+ <p style={{ fontSize: 14, fontWeight: 500, marginBottom: 10 }}>{poll.question}</p>
52
+
53
+ <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
54
+ {results.map((option) => {
55
+ const chosen = option.id === votedOptionId;
56
+
57
+ if (!showResults) {
58
+ return (
59
+ <button
60
+ key={option.id}
61
+ type="button"
62
+ disabled={voting}
63
+ onClick={() => api.vote(option.id)}
64
+ style={{
65
+ textAlign: "left",
66
+ padding: "8px 12px",
67
+ fontSize: 14,
68
+ cursor: voting ? "default" : "pointer",
69
+ borderRadius: theme.cornerRadius,
70
+ border: `1px solid ${theme.colors.primary}50`,
71
+ background: "transparent",
72
+ color: "inherit",
73
+ opacity: voting ? 0.6 : 1,
74
+ }}
75
+ >
76
+ {option.label}
77
+ </button>
78
+ );
79
+ }
80
+
81
+ return (
82
+ <div key={option.id}>
83
+ <div style={{ display: "flex", justifyContent: "space-between", fontSize: 13, marginBottom: 2 }}>
84
+ <span style={{ fontWeight: chosen ? 600 : 400 }}>
85
+ {option.label}
86
+ {chosen ? ` ${t("forge.broadcast_poll.your_vote")}` : ""}
87
+ </span>
88
+ <span style={{ opacity: 0.7 }}>{option.percent}%</span>
89
+ </div>
90
+ <div
91
+ style={{
92
+ height: 6,
93
+ borderRadius: 3,
94
+ overflow: "hidden",
95
+ backgroundColor: `${theme.colors.primary}25`,
96
+ }}
97
+ >
98
+ <div
99
+ style={{
100
+ height: "100%",
101
+ width: `${option.percent}%`,
102
+ backgroundColor: theme.colors.primary,
103
+ transition: "width 200ms",
104
+ }}
105
+ />
106
+ </div>
107
+ </div>
108
+ );
109
+ })}
110
+ </div>
111
+
112
+ <p style={{ fontSize: 12, opacity: 0.7, marginTop: 8 }}>
113
+ {poll.totalVotes === 1
114
+ ? t("forge.broadcast_poll.votes_one", { count: poll.totalVotes })
115
+ : t("forge.broadcast_poll.votes_other", { count: poll.totalVotes })}
116
+ </p>
117
+
118
+ {/* An error the viewer has to act on (press it again), so it is visible. */}
119
+ {error && <p style={{ fontSize: 12, marginTop: 4, color: theme.colors.primary }}>{error}</p>}
120
+ </div>
121
+ );
122
+ }
123
+
124
+ export default BroadcastPollPanel;