@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.38.0",
3
+ "version": "3.41.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -38,14 +38,24 @@ export function useValidateBroadcastPass() {
38
38
  * page (the "broadcast ended, keep this tab open" screen). Reading the detail
39
39
  * endpoint is what makes that page survive the end of the stream.
40
40
  */
41
- export function useLiveBroadcast(broadcastId?: string) {
41
+ /**
42
+ * The viewer's validated pass, sent on every read that can return playback
43
+ * credentials.
44
+ *
45
+ * A gated broadcast withholds `liveUrl` and `realtimeConfig` from a caller who
46
+ * cannot show one, because the paywall used to be drawn in the browser only:
47
+ * the public read handed the manifest and the realtime session to anybody who
48
+ * asked, and the ticket box in front of it was decoration. Passing it here is
49
+ * what keeps a paying viewer playing.
50
+ */
51
+ export function useLiveBroadcast(broadcastId?: string, passId?: string) {
42
52
  const { client, profileId } = useForge();
43
53
 
44
54
  return useQuery<ILiveBroadcast>({
45
- queryKey: ["live-broadcast", profileId, broadcastId],
55
+ queryKey: ["live-broadcast", profileId, broadcastId, passId ?? null],
46
56
  queryFn: async () => {
47
57
  const res = await client.get(`/public/broadcasts/${broadcastId}`, {
48
- params: { profileId },
58
+ params: { profileId, ...(passId ? { passId } : {}) },
49
59
  });
50
60
  return res.data;
51
61
  },
@@ -61,14 +71,14 @@ export function useLiveBroadcast(broadcastId?: string) {
61
71
  * poll, so a mid-stream edit to the title or the thumbnail cannot swap the
62
72
  * player out from under someone who is watching.
63
73
  */
64
- export function useLiveBroadcastPoll(broadcastId?: string, intervalMs = 5000) {
74
+ export function useLiveBroadcastPoll(broadcastId?: string, intervalMs = 5000, passId?: string) {
65
75
  const { client, profileId } = useForge();
66
76
 
67
77
  return useQuery<ILiveBroadcast>({
68
- queryKey: ["live-broadcast-poll", profileId, broadcastId],
78
+ queryKey: ["live-broadcast-poll", profileId, broadcastId, passId ?? null],
69
79
  queryFn: async () => {
70
80
  const res = await client.get(`/public/broadcasts/${broadcastId}`, {
71
- params: { profileId },
81
+ params: { profileId, ...(passId ? { passId } : {}) },
72
82
  });
73
83
  return res.data;
74
84
  },
@@ -138,6 +148,30 @@ export type BroadcastSubscribeAnswer = {
138
148
  sessionDescription: { sdp: string; type: string };
139
149
  };
140
150
 
151
+ /**
152
+ * A join ticket for a broadcast's live room on the platform's media plane.
153
+ *
154
+ * A superset of what the media SDK reads (`mediaUrl` and `token` are the only
155
+ * two it touches). The rest is what the server says about the ticket it just
156
+ * minted, useful to a site that wants to show it and ignored by the room.
157
+ *
158
+ * Written out here rather than imported from `@tribe-nest/media-client` on
159
+ * purpose: this module is loaded by every page of every site, and the media
160
+ * SDK is browser-only. A type import would be erased, but the next person to
161
+ * reach for a value from that package would not notice they had crossed the
162
+ * line.
163
+ */
164
+ export type BroadcastViewerCredentials = {
165
+ /** The signalling entrypoint. A load balancer, never a node the client picked. */
166
+ mediaUrl: string;
167
+ token: string;
168
+ /** ISO. When THIS ticket dies, not when the broadcast does. */
169
+ expiresAt?: string;
170
+ /** This viewer's identity in the live room. Minted per viewer, never reused. */
171
+ identity?: string;
172
+ roomId?: string;
173
+ };
174
+
141
175
  /**
142
176
  * The imperative half of watching a broadcast: the calls that are made in
143
177
  * response to something happening (a pass validated, a heartbeat, a viewer
@@ -147,6 +181,66 @@ export type BroadcastSubscribeAnswer = {
147
181
  * the SAME call to the same address. A session that is pinged on one stack and
148
182
  * not the other reports a different audience count for the same room.
149
183
  */
184
+ export type BroadcastPollOption = { id: string; label: string; position: number; voteCount: number };
185
+
186
+ export type BroadcastPoll = {
187
+ id: string;
188
+ question: string;
189
+ status: "draft" | "live" | "closed";
190
+ /**
191
+ * The ASKING of this question in this broadcast, which is what a vote names.
192
+ *
193
+ * A poll belongs to a stream template and can be asked again in a later
194
+ * broadcast; each asking has its own votes and its own one-vote-per-viewer
195
+ * rule. So the same question next month is a different run, and somebody who
196
+ * answered last month may answer again.
197
+ */
198
+ runId: string;
199
+ options: BroadcastPollOption[];
200
+ totalVotes: number;
201
+ };
202
+
203
+ /**
204
+ * Reading and answering the poll a broadcast is asking.
205
+ *
206
+ * Both endpoints are public and take no bearer token, like the broadcast page
207
+ * they belong to. The read returns only what is already on everyone's screen,
208
+ * and the write is identified by a key the viewer's own browser made up.
209
+ */
210
+ export function useBroadcastPollApi() {
211
+ const { client } = useForge();
212
+
213
+ return {
214
+ /**
215
+ * The question being asked right now, or null.
216
+ *
217
+ * Fetched once when the player mounts, for the viewer who arrived in the
218
+ * middle of a poll. Everything after that arrives on the socket, so this is
219
+ * not polled: a timer would be a second, later-arriving source of truth for
220
+ * numbers that are already being pushed.
221
+ */
222
+ livePoll: async (broadcastId: string): Promise<BroadcastPoll | null> => {
223
+ const res = await client.get(`/streams/v2/public/polls/live`, { params: { broadcastId } });
224
+ return res.data?.poll ?? null;
225
+ },
226
+ /**
227
+ * One vote.
228
+ *
229
+ * `alreadyVoted` comes back true rather than an error when this viewer has
230
+ * already answered, which is the ordinary case for somebody with the stream
231
+ * open in two tabs.
232
+ */
233
+ vote: async (payload: {
234
+ runId: string;
235
+ optionId: string;
236
+ voterKey: string;
237
+ }): Promise<{ poll: BroadcastPoll; alreadyVoted: boolean }> => {
238
+ const res = await client.post(`/streams/v2/public/polls/vote`, payload);
239
+ return res.data;
240
+ },
241
+ };
242
+ }
243
+
150
244
  export function useBroadcastSessionApi() {
151
245
  const { client } = useForge();
152
246
 
@@ -164,18 +258,39 @@ export function useBroadcastSessionApi() {
164
258
  leave: async (broadcastId: string, sessionId?: string): Promise<void> => {
165
259
  await client.post(`/public/broadcasts/${broadcastId}/leave`, { sessionId });
166
260
  },
261
+ /**
262
+ * Mint a viewer credential for the broadcast's live room on the media plane.
263
+ *
264
+ * The PASS is the credential here: the endpoint is public and takes no
265
+ * bearer token, and it validates the pass exactly as `validate-session`
266
+ * does before minting anything for a gated broadcast.
267
+ *
268
+ * It answers 409 when the room is full, when realtime is switched off, or
269
+ * when nothing is live. That is a designed answer rather than a fault, and
270
+ * the caller's whole response to it is to watch the HLS stream instead.
271
+ */
272
+ viewerToken: async (payload: { broadcastId: string; passId?: string }): Promise<BroadcastViewerCredentials> => {
273
+ const res = await client.post(`/streams/v2/public/viewer-token`, payload);
274
+ return res.data;
275
+ },
167
276
  /** Subscribe to the WebRTC tracks of a realtime broadcast. */
168
277
  subscribe: async (payload: {
169
278
  sdp?: string;
170
279
  type?: string;
171
280
  trackIds?: string[];
172
281
  sessionId?: string;
282
+ /** The validated pass. A gated broadcast refuses a subscribe without it. */
283
+ passId?: string;
173
284
  }): Promise<BroadcastSubscribeAnswer> => {
174
285
  const res = await client.post(`/public/broadcasts/subscribe`, payload);
175
286
  return res.data;
176
287
  },
177
288
  /** Pin the viewer to one simulcast layer, or back to auto. */
178
- switchQuality: async (payload: { track: Record<string, unknown>; sessionId?: string }): Promise<void> => {
289
+ switchQuality: async (payload: {
290
+ track: Record<string, unknown>;
291
+ sessionId?: string;
292
+ passId?: string;
293
+ }): Promise<void> => {
179
294
  await client.post(`/public/broadcasts/switch-quality`, payload);
180
295
  },
181
296
  };
package/src/i18n/de.json CHANGED
@@ -159,6 +159,9 @@
159
159
  "forge.broadcast_pass_validation.validating": "Wird geprüft…",
160
160
  "forge.broadcast_player.anonymous": "Anonym",
161
161
  "forge.broadcast_player.chat": "Chat",
162
+ "forge.broadcast_player.fallback_help": "Der Stream mit geringer Verzögerung hat nur begrenzt Plätze und ist gerade entweder voll oder nicht aktiv. Der Standard-Stream zeigt dieselbe Übertragung einige Sekunden später. Sonst ändert sich nichts an der Seite.",
163
+ "forge.broadcast_player.fallback_help_label": "Warum ist das nicht der Stream mit geringer Verzögerung?",
164
+ "forge.broadcast_player.fallback_notice": "Du siehst den Standard-Stream",
162
165
  "forge.broadcast_player.fullscreen": "Vollbild",
163
166
  "forge.broadcast_player.message_label": "Nachricht",
164
167
  "forge.broadcast_player.pinned": "Angepinnt",
@@ -168,8 +171,15 @@
168
171
  "forge.broadcast_player.quality_low": "Niedrig",
169
172
  "forge.broadcast_player.quality_medium": "Mittel",
170
173
  "forge.broadcast_player.send": "Senden",
174
+ "forge.broadcast_player.sound_off": "Ton ausschalten",
175
+ "forge.broadcast_player.sound_on": "Ton einschalten",
171
176
  "forge.broadcast_player.started": "Vor {duration} gestartet",
172
177
  "forge.broadcast_player.watching_now": "{count} schauen gerade zu",
178
+ "forge.broadcast_poll.closed": "Umfrage beendet",
179
+ "forge.broadcast_poll.live": "Umfrage",
180
+ "forge.broadcast_poll.votes_one": "{count} Stimme",
181
+ "forge.broadcast_poll.votes_other": "{count} Stimmen",
182
+ "forge.broadcast_poll.your_vote": "(deine Stimme)",
173
183
  "forge.broadcast_ticket_purchase.buy_tickets": "Tickets kaufen",
174
184
  "forge.broadcast_ticket_purchase.from_price": "Ab {price}",
175
185
  "forge.broadcast_watch.leave": "Übertragung verlassen",
package/src/i18n/en.json CHANGED
@@ -159,6 +159,9 @@
159
159
  "forge.broadcast_pass_validation.validating": "Validating…",
160
160
  "forge.broadcast_player.anonymous": "Anonymous",
161
161
  "forge.broadcast_player.chat": "Chat",
162
+ "forge.broadcast_player.fallback_help": "The low-latency stream holds a limited number of viewers, and right now it is either full or not running. The standard stream carries the same broadcast a few seconds behind. Nothing else about the page changes.",
163
+ "forge.broadcast_player.fallback_help_label": "Why is this not the low-latency stream?",
164
+ "forge.broadcast_player.fallback_notice": "Watching the standard stream",
162
165
  "forge.broadcast_player.fullscreen": "Fullscreen",
163
166
  "forge.broadcast_player.message_label": "Message",
164
167
  "forge.broadcast_player.pinned": "Pinned",
@@ -168,8 +171,15 @@
168
171
  "forge.broadcast_player.quality_low": "Low",
169
172
  "forge.broadcast_player.quality_medium": "Medium",
170
173
  "forge.broadcast_player.send": "Send",
174
+ "forge.broadcast_player.sound_off": "Turn sound off",
175
+ "forge.broadcast_player.sound_on": "Turn sound on",
171
176
  "forge.broadcast_player.started": "Started {duration} ago",
172
177
  "forge.broadcast_player.watching_now": "{count} watching now",
178
+ "forge.broadcast_poll.closed": "Poll closed",
179
+ "forge.broadcast_poll.live": "Poll",
180
+ "forge.broadcast_poll.votes_one": "{count} vote",
181
+ "forge.broadcast_poll.votes_other": "{count} votes",
182
+ "forge.broadcast_poll.your_vote": "(your vote)",
173
183
  "forge.broadcast_ticket_purchase.buy_tickets": "Buy tickets",
174
184
  "forge.broadcast_ticket_purchase.from_price": "From {price}",
175
185
  "forge.broadcast_watch.leave": "Leave broadcast",
@@ -1709,6 +1709,11 @@ export type ILiveEvent = {
1709
1709
 
1710
1710
  export type ILiveBroadcast = {
1711
1711
  id: string;
1712
+ /**
1713
+ * The v1 realtime path, over Cloudflare Calls. Kept exactly as it is until v1
1714
+ * itself is retired. Not the same thing as `realtime` below, and a broadcast
1715
+ * carries one or the other, never both.
1716
+ */
1712
1717
  realtimeConfig?: {
1713
1718
  sessionId: string;
1714
1719
  tracks: {
@@ -1716,6 +1721,19 @@ export type ILiveBroadcast = {
1716
1721
  trackName: string;
1717
1722
  }[];
1718
1723
  };
1724
+ /**
1725
+ * Can this broadcast be watched over the platform's own media plane?
1726
+ *
1727
+ * `available` says the live room exists and the feature is on, and it says
1728
+ * nothing about whether THIS viewer will get in: the room has a hard cap and
1729
+ * the token endpoint is the only thing that knows how full it is. So it is a
1730
+ * flag to ATTEMPT the plane on, never a promise, and a player that treats it
1731
+ * as one leaves the hundred-and-first viewer on a black rectangle.
1732
+ *
1733
+ * It deliberately carries no room id and no token. The read that returns it
1734
+ * is unauthenticated, so everything in it is public.
1735
+ */
1736
+ realtime?: { available: boolean };
1719
1737
  events: {
1720
1738
  eventId: string;
1721
1739
  eventTitle: string;
@@ -0,0 +1,225 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+
3
+ import { useBroadcastPollApi, type BroadcastPoll } from "../../../data/queries/useBroadcasts";
4
+
5
+ /**
6
+ * The poll a viewer is being asked, and their answer to it.
7
+ *
8
+ * ## Where the numbers come from
9
+ *
10
+ * One fetch when the player mounts, for somebody who arrived mid-question, and
11
+ * the socket for everything after. The player owns the socket (it is the same
12
+ * connection chat rides on), so it hands the events down here rather than this
13
+ * opening a second one.
14
+ *
15
+ * ## The voter key
16
+ *
17
+ * A random id kept in this browser, per site, not per broadcast: the same
18
+ * person watching two streams is one voter with one key, and the server's
19
+ * uniqueness is per POLL anyway. It identifies nobody. It exists so that
20
+ * pressing the button twice counts once, and it is honestly defeatable by
21
+ * clearing storage, which is the trade the feature was designed around.
22
+ *
23
+ * Storage can throw (a private window, a browser set to block site data), and a
24
+ * viewer who cannot be remembered must still be able to vote. So a failure
25
+ * falls back to a key held in memory for this page view.
26
+ */
27
+
28
+ const VOTER_KEY_STORAGE = "tribenest.broadcast.voter";
29
+
30
+ function randomKey(): string {
31
+ const bytes = new Uint8Array(16);
32
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) {
33
+ crypto.getRandomValues(bytes);
34
+ } else {
35
+ for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
36
+ }
37
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
38
+ }
39
+
40
+ function readVoterKey(): string {
41
+ if (typeof localStorage === "undefined") return randomKey();
42
+ try {
43
+ const held = localStorage.getItem(VOTER_KEY_STORAGE);
44
+ if (held && held.length >= 8) return held;
45
+ const minted = randomKey();
46
+ localStorage.setItem(VOTER_KEY_STORAGE, minted);
47
+ return minted;
48
+ } catch {
49
+ // Blocked or full. One vote per page view is worse than one per browser and
50
+ // much better than no vote at all.
51
+ return randomKey();
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Which ASKINGS this browser has answered, so the UI opens on the results.
57
+ *
58
+ * Keyed by run and not by poll, which is the whole difference. A question
59
+ * belongs to a stream template and gets asked again - later in the same
60
+ * broadcast, or in next week's - and each asking has its own votes and its own
61
+ * one-vote rule. Keyed by poll, this remembered "you answered this question"
62
+ * for ever: a returning viewer met a fresh poll already showing bars and
63
+ * "(your vote)", with no way to take part. The server would have accepted their
64
+ * vote; the panel never offered it.
65
+ */
66
+ const votedKey = (runId: string) => `tribenest.broadcast.voted.run.${runId}`;
67
+
68
+ function readVoted(runId: string): string | null {
69
+ if (typeof localStorage === "undefined") return null;
70
+ try {
71
+ return localStorage.getItem(votedKey(runId));
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function forgetVoted(runId: string): void {
78
+ if (typeof localStorage === "undefined") return;
79
+ try {
80
+ localStorage.removeItem(votedKey(runId));
81
+ } catch {
82
+ // Nothing to undo if it could not be written in the first place.
83
+ }
84
+ }
85
+
86
+ function rememberVoted(runId: string, optionId: string): void {
87
+ if (typeof localStorage === "undefined") return;
88
+ try {
89
+ localStorage.setItem(votedKey(runId), optionId);
90
+ } catch {
91
+ // Nothing to do. The server still refuses the second vote; the viewer just
92
+ // sees the form again in a new tab.
93
+ }
94
+ }
95
+
96
+ /**
97
+ * What a viewer's own vote should be after a poll update arrives.
98
+ *
99
+ * This exists because of a bug that made the first click look ignored. Voting
100
+ * publishes results to EVERY viewer, including the one who just voted, and
101
+ * that push used to re-read the vote from storage. The push routinely beat the
102
+ * HTTP response it was caused by, storage had not been written yet, and the
103
+ * panel flipped back to buttons - so the viewer clicked again, and only the
104
+ * second click appeared to work. Their first vote had counted all along.
105
+ *
106
+ * The rule that fixes it: an update to the same ASKING never changes who this
107
+ * viewer voted for. Only a different asking re-reads storage.
108
+ *
109
+ * The identity is the RUN, not the poll. The same question asked again is a new
110
+ * asking with an empty tally that this viewer has not answered, so comparing
111
+ * polls would carry their old answer into it and lock them out of voting.
112
+ */
113
+ export function votedOptionForIncoming(args: {
114
+ currentRunId: string | null;
115
+ incomingRunId: string | null;
116
+ votedOptionId: string | null;
117
+ /** What storage holds for the incoming asking, if anything. */
118
+ storedForIncoming: string | null;
119
+ }): string | null {
120
+ if (!args.incomingRunId) return null;
121
+ if (args.incomingRunId === args.currentRunId) {
122
+ // Held in memory beats storage: a vote in flight is not in storage yet, and
123
+ // a viewer whose browser refuses storage still gets to keep their answer
124
+ // for as long as the page is open.
125
+ return args.votedOptionId ?? args.storedForIncoming;
126
+ }
127
+ return args.storedForIncoming;
128
+ }
129
+
130
+ export type BroadcastPollApi = {
131
+ poll: BroadcastPoll | null;
132
+ /** The option this browser chose, if it has voted on the current poll. */
133
+ votedOptionId: string | null;
134
+ voting: boolean;
135
+ error: string | null;
136
+ vote: (optionId: string) => void;
137
+ /** Feed socket payloads in. Null ends the poll. */
138
+ onPollEvent: (poll: BroadcastPoll | null) => void;
139
+ };
140
+
141
+ export function useBroadcastPoll(broadcastId?: string): BroadcastPollApi {
142
+ const api = useBroadcastPollApi();
143
+ const [poll, setPoll] = useState<BroadcastPoll | null>(null);
144
+ const [votedOptionId, setVotedOptionId] = useState<string | null>(null);
145
+ const [voting, setVoting] = useState(false);
146
+ const [error, setError] = useState<string | null>(null);
147
+ const voterKeyRef = useRef<string | null>(null);
148
+
149
+ const voterKey = () => {
150
+ if (!voterKeyRef.current) voterKeyRef.current = readVoterKey();
151
+ return voterKeyRef.current;
152
+ };
153
+
154
+ useEffect(() => {
155
+ if (!broadcastId) return;
156
+ let cancelled = false;
157
+ api
158
+ .livePoll(broadcastId)
159
+ .then((live) => {
160
+ if (cancelled) return;
161
+ setPoll(live);
162
+ setVotedOptionId(live?.runId ? readVoted(live.runId) : null);
163
+ })
164
+ .catch(() => {
165
+ // A poll that cannot be fetched is a poll the viewer does not see. It
166
+ // is not worth an error on a page whose job is to play a stream.
167
+ if (!cancelled) setPoll(null);
168
+ });
169
+ return () => {
170
+ cancelled = true;
171
+ };
172
+ // The api object is rebuilt per render; the broadcast is what identifies
173
+ // the fetch.
174
+ // eslint-disable-next-line react-hooks/exhaustive-deps
175
+ }, [broadcastId]);
176
+
177
+ const onPollEvent = useCallback((next: BroadcastPoll | null) => {
178
+ setPoll((current) => {
179
+ setVotedOptionId((held) =>
180
+ votedOptionForIncoming({
181
+ currentRunId: current?.runId ?? null,
182
+ incomingRunId: next?.runId ?? null,
183
+ votedOptionId: held,
184
+ storedForIncoming: next?.runId ? readVoted(next.runId) : null,
185
+ }),
186
+ );
187
+ return next;
188
+ });
189
+ setError(null);
190
+ }, []);
191
+
192
+ const vote = useCallback(
193
+ (optionId: string) => {
194
+ const current = poll;
195
+ if (!current || current.status !== "live" || !current.runId || votedOptionId || voting) return;
196
+
197
+ setVoting(true);
198
+ setError(null);
199
+ // Both recorded before the request goes out, not in its `then`. The
200
+ // results push this vote causes routinely arrives BEFORE the response to
201
+ // it, and anything reading storage at that moment has to already find the
202
+ // vote there.
203
+ setVotedOptionId(optionId);
204
+ rememberVoted(current.runId, optionId);
205
+ api
206
+ .vote({ runId: current.runId, optionId, voterKey: voterKey() })
207
+ .then((result) => setPoll(result.poll))
208
+ .catch((err: unknown) => {
209
+ setVotedOptionId(null);
210
+ forgetVoted(current.runId);
211
+ const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
212
+ setError(message ?? "That vote did not go through.");
213
+ })
214
+ .finally(() => setVoting(false));
215
+ },
216
+ // `api` is rebuilt per render and holds no state of its own.
217
+ // eslint-disable-next-line react-hooks/exhaustive-deps
218
+ [poll, votedOptionId, voting],
219
+ );
220
+
221
+ return useMemo(
222
+ () => ({ poll, votedOptionId, voting, error, vote, onPollEvent }),
223
+ [poll, votedOptionId, voting, error, vote, onPollEvent],
224
+ );
225
+ }
@@ -38,8 +38,12 @@ const readSessionId = (broadcastId?: string): string | null => {
38
38
  * be checked without a browser.
39
39
  */
40
40
  export function useBroadcastWatch(broadcastId?: string) {
41
- const broadcastQuery = useLiveBroadcast(broadcastId);
42
- const pollQuery = useLiveBroadcastPoll(broadcastId);
41
+ // The stored pass rides along on both reads: a gated broadcast withholds its
42
+ // playback credentials from a caller who cannot show one, so a read without
43
+ // it comes back playable-looking and empty.
44
+ const storedSessionId = readSessionId(broadcastId) ?? undefined;
45
+ const broadcastQuery = useLiveBroadcast(broadcastId, storedSessionId);
46
+ const pollQuery = useLiveBroadcastPoll(broadcastId, 5000, storedSessionId);
43
47
  const validatePass = useValidateBroadcastPass();
44
48
  const sessionApi = useBroadcastSessionApi();
45
49
 
package/src/ui/index.ts CHANGED
@@ -183,6 +183,10 @@ export { ReplayList, type ReplayListProps } from "./styled/ReplayList";
183
183
  export { LiveBroadcastList, type LiveBroadcastListProps } from "./styled/LiveBroadcastList";
184
184
  export { BroadcastWatch, type BroadcastWatchProps } from "./styled/BroadcastWatch";
185
185
  export { EndedBroadcast, type EndedBroadcastProps } from "./styled/broadcast/EndedBroadcast";
186
+ // Shared with the admin studio, which renders the same tallies in its Polls
187
+ // tab and burns them into the stage. Three surfaces, one rounding.
188
+ export { pollPercentages, type PollLike, type PollResult } from "./styled/broadcast/pollResults";
189
+ export { BroadcastPollPanel } from "./styled/broadcast/BroadcastPollPanel";
186
190
  export { BroadcastPassValidation, type BroadcastPassValidationProps } from "./styled/broadcast/BroadcastPassValidation";
187
191
  export { BroadcastTicketPurchase, type BroadcastTicketPurchaseProps } from "./styled/broadcast/BroadcastTicketPurchase";
188
192
  export {
@@ -73,6 +73,7 @@ const fakeTransport = (): MediaTransport => ({
73
73
  id: `p-local-${produced.length}`,
74
74
  kind: "audio",
75
75
  closed: false,
76
+ replaceTrack: vi.fn(async () => undefined),
76
77
  pause: vi.fn(),
77
78
  resume: vi.fn(),
78
79
  close: vi.fn(),
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { votedOptionForIncoming } from "../../headless/broadcast/useBroadcastPoll";
4
+
5
+ /**
6
+ * The rule that decides whether a viewer keeps their own vote when a poll
7
+ * update arrives.
8
+ *
9
+ * Reported as "I have to click twice before it records my answer". Voting
10
+ * pushes results to every viewer INCLUDING the one who just voted, that push
11
+ * routinely beat the HTTP response it was caused by, and the handler re-read
12
+ * the vote from storage that the response had not written yet. The panel
13
+ * flipped back to buttons, the viewer clicked again, and their first vote had
14
+ * been counted the whole time.
15
+ *
16
+ * The identity being compared is the ASKING. It was the poll once, and the
17
+ * second report followed: a question asked again showed a returning viewer
18
+ * "(your vote)" over last time's answer and refused to let them take part.
19
+ */
20
+ describe("votedOptionForIncoming", () => {
21
+ it("keeps a vote that is still in flight when the results it caused arrive first", () => {
22
+ // The exact double-click bug: same poll, vote held in memory, storage not
23
+ // written yet. Answering null here is what sent the viewer back to the
24
+ // buttons.
25
+ expect(
26
+ votedOptionForIncoming({
27
+ currentRunId: "run-1",
28
+ incomingRunId: "run-1",
29
+ votedOptionId: "option-a",
30
+ storedForIncoming: null,
31
+ }),
32
+ ).toBe("option-a");
33
+ });
34
+
35
+ it("keeps the vote through every later result push on the same poll", () => {
36
+ expect(
37
+ votedOptionForIncoming({
38
+ currentRunId: "run-1",
39
+ incomingRunId: "run-1",
40
+ votedOptionId: "option-a",
41
+ storedForIncoming: "option-a",
42
+ }),
43
+ ).toBe("option-a");
44
+ });
45
+
46
+ it("never lets another viewer's vote overwrite this one", () => {
47
+ // Storage would only disagree after a stale write, but the rule has to be
48
+ // stated: what is held in memory for the current poll wins.
49
+ expect(
50
+ votedOptionForIncoming({
51
+ currentRunId: "run-1",
52
+ incomingRunId: "run-1",
53
+ votedOptionId: "option-a",
54
+ storedForIncoming: "option-b",
55
+ }),
56
+ ).toBe("option-a");
57
+ });
58
+
59
+ it("recovers a vote from storage on a reload mid-poll", () => {
60
+ expect(
61
+ votedOptionForIncoming({
62
+ currentRunId: "run-1",
63
+ incomingRunId: "run-1",
64
+ votedOptionId: null,
65
+ storedForIncoming: "option-a",
66
+ }),
67
+ ).toBe("option-a");
68
+ });
69
+
70
+ it("starts a NEW asking fresh, so the last answer does not carry over", () => {
71
+ expect(
72
+ votedOptionForIncoming({
73
+ currentRunId: "run-1",
74
+ incomingRunId: "run-2",
75
+ votedOptionId: "option-a",
76
+ storedForIncoming: null,
77
+ }),
78
+ ).toBeNull();
79
+ });
80
+
81
+ it("opens a new asking on its results when this browser already answered THAT asking", () => {
82
+ expect(
83
+ votedOptionForIncoming({
84
+ currentRunId: "run-1",
85
+ incomingRunId: "run-2",
86
+ votedOptionId: null,
87
+ storedForIncoming: "option-x",
88
+ }),
89
+ ).toBe("option-x");
90
+ });
91
+
92
+ it("lets a viewer answer the same question asked again", () => {
93
+ // The second bug, in one assertion. The question is identical and this
94
+ // browser answered it last time; the ASKING is new, so the panel must open
95
+ // on buttons rather than on "(your vote)".
96
+ expect(
97
+ votedOptionForIncoming({
98
+ currentRunId: "run-1",
99
+ incomingRunId: "run-2",
100
+ votedOptionId: "option-a",
101
+ storedForIncoming: null,
102
+ }),
103
+ ).toBeNull();
104
+ });
105
+
106
+ it("clears the vote when the poll ends", () => {
107
+ expect(
108
+ votedOptionForIncoming({
109
+ currentRunId: "run-1",
110
+ incomingRunId: null,
111
+ votedOptionId: "option-a",
112
+ storedForIncoming: null,
113
+ }),
114
+ ).toBeNull();
115
+ });
116
+
117
+ it("handles the first poll of the broadcast, with nothing current", () => {
118
+ expect(
119
+ votedOptionForIncoming({
120
+ currentRunId: null,
121
+ incomingRunId: "run-1",
122
+ votedOptionId: null,
123
+ storedForIncoming: null,
124
+ }),
125
+ ).toBeNull();
126
+ });
127
+ });