@tribe-nest/forge 3.39.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 +1 -1
- package/src/data/queries/useBroadcasts.ts +60 -0
- package/src/i18n/de.json +5 -0
- package/src/i18n/en.json +5 -0
- package/src/ui/headless/broadcast/useBroadcastPoll.ts +225 -0
- package/src/ui/index.ts +4 -0
- package/src/ui/styled/_tests/broadcastPollVote.spec.ts +127 -0
- package/src/ui/styled/_tests/pollResults.spec.ts +66 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +23 -1
- package/src/ui/styled/broadcast/BroadcastPollPanel.tsx +124 -0
- package/src/ui/styled/broadcast/pollResults.ts +66 -0
package/package.json
CHANGED
|
@@ -181,6 +181,66 @@ export type BroadcastViewerCredentials = {
|
|
|
181
181
|
* the SAME call to the same address. A session that is pinged on one stack and
|
|
182
182
|
* not the other reports a different audience count for the same room.
|
|
183
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
|
+
|
|
184
244
|
export function useBroadcastSessionApi() {
|
|
185
245
|
const { client } = useForge();
|
|
186
246
|
|
package/src/i18n/de.json
CHANGED
|
@@ -175,6 +175,11 @@
|
|
|
175
175
|
"forge.broadcast_player.sound_on": "Ton einschalten",
|
|
176
176
|
"forge.broadcast_player.started": "Vor {duration} gestartet",
|
|
177
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)",
|
|
178
183
|
"forge.broadcast_ticket_purchase.buy_tickets": "Tickets kaufen",
|
|
179
184
|
"forge.broadcast_ticket_purchase.from_price": "Ab {price}",
|
|
180
185
|
"forge.broadcast_watch.leave": "Übertragung verlassen",
|
package/src/i18n/en.json
CHANGED
|
@@ -175,6 +175,11 @@
|
|
|
175
175
|
"forge.broadcast_player.sound_on": "Turn sound on",
|
|
176
176
|
"forge.broadcast_player.started": "Started {duration} ago",
|
|
177
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)",
|
|
178
183
|
"forge.broadcast_ticket_purchase.buy_tickets": "Buy tickets",
|
|
179
184
|
"forge.broadcast_ticket_purchase.from_price": "From {price}",
|
|
180
185
|
"forge.broadcast_watch.leave": "Leave broadcast",
|
|
@@ -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
|
+
}
|
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 {
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { pollPercentages, type PollLike } from "../broadcast/pollResults";
|
|
4
|
+
|
|
5
|
+
const poll = (counts: number[]): PollLike => ({
|
|
6
|
+
totalVotes: counts.reduce((a, b) => a + b, 0),
|
|
7
|
+
options: counts.map((voteCount, i) => ({ id: `o${i}`, label: `Option ${i}`, voteCount })),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const percents = (counts: number[]) => pollPercentages(poll(counts)).map((r) => r.percent);
|
|
11
|
+
|
|
12
|
+
describe("pollPercentages", () => {
|
|
13
|
+
it("shows nothing rather than an even split before anybody votes", () => {
|
|
14
|
+
// "50% / 50%" on an untouched poll claims two votes that were never cast.
|
|
15
|
+
expect(percents([0, 0])).toEqual([0, 0]);
|
|
16
|
+
expect(percents([0, 0, 0])).toEqual([0, 0, 0]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("adds up to 100 where naive rounding shows 99", () => {
|
|
20
|
+
// One vote each on three options: 33.33 floored three times is 99.
|
|
21
|
+
expect(percents([1, 1, 1]).reduce((a, b) => a + b, 0)).toBe(100);
|
|
22
|
+
expect(percents([1, 2]).reduce((a, b) => a + b, 0)).toBe(100);
|
|
23
|
+
expect(percents([1, 1, 1, 1, 1, 1]).reduce((a, b) => a + b, 0)).toBe(100);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("gives the spare point to the biggest remainder", () => {
|
|
27
|
+
// 1/3 = 33.33 (remainder .33), 2/3 = 66.67 (remainder .67).
|
|
28
|
+
expect(percents([1, 2])).toEqual([33, 67]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("is exact when it can be", () => {
|
|
32
|
+
expect(percents([1, 1])).toEqual([50, 50]);
|
|
33
|
+
expect(percents([1, 3])).toEqual([25, 75]);
|
|
34
|
+
expect(percents([7, 3])).toEqual([70, 30]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("gives a unanimous poll all of it", () => {
|
|
38
|
+
expect(percents([5, 0, 0])).toEqual([100, 0, 0]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("breaks a remainder tie the same way every time", () => {
|
|
42
|
+
// Two options tied on both share and votes: the earlier one wins, so the
|
|
43
|
+
// overlay does not swap them between renders of an unchanged tally.
|
|
44
|
+
const first = percents([1, 1, 1]);
|
|
45
|
+
const second = percents([1, 1, 1]);
|
|
46
|
+
expect(first).toEqual(second);
|
|
47
|
+
expect(first.reduce((a, b) => a + b, 0)).toBe(100);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("keeps the labels and the raw counts, which are shown beside the bar", () => {
|
|
51
|
+
const results = pollPercentages(poll([3, 1]));
|
|
52
|
+
expect(results.map((r) => r.label)).toEqual(["Option 0", "Option 1"]);
|
|
53
|
+
expect(results.map((r) => r.voteCount)).toEqual([3, 1]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("survives a poll with no options at all", () => {
|
|
57
|
+
expect(pollPercentages({ totalVotes: 0, options: [] })).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("holds up on a large tally", () => {
|
|
61
|
+
const counts = [4321, 987, 65, 4];
|
|
62
|
+
const result = percents(counts);
|
|
63
|
+
expect(result.reduce((a, b) => a + b, 0)).toBe(100);
|
|
64
|
+
expect(result[0]).toBeGreaterThan(result[1]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -29,12 +29,20 @@ import {
|
|
|
29
29
|
// on the server path of every site that loads `@tribe-nest/forge/ui`. It is
|
|
30
30
|
// reached through the `import()` in the effect below, which no server runs.
|
|
31
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";
|
|
32
35
|
|
|
33
36
|
/** The socket messages a watching browser sends and receives. */
|
|
34
37
|
const SocketEvent = {
|
|
35
38
|
BroadcastChatMessage: "broadcastChatMessage",
|
|
36
39
|
BroadcastJoin: "broadcastJoin",
|
|
37
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",
|
|
38
46
|
} as const;
|
|
39
47
|
|
|
40
48
|
/**
|
|
@@ -133,6 +141,7 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
133
141
|
const theme = useForgeTheme();
|
|
134
142
|
const { apiUrl, profileId } = useForge();
|
|
135
143
|
const sessionApi = useBroadcastSessionApi();
|
|
144
|
+
const poll = useBroadcastPoll(broadcast.id);
|
|
136
145
|
|
|
137
146
|
const [comments, setComments] = useState<BroadcastComment[]>([]);
|
|
138
147
|
const [comment, setComment] = useState("");
|
|
@@ -364,6 +373,17 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
364
373
|
setComments((prev) => [...prev, data.message]);
|
|
365
374
|
return;
|
|
366
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
|
+
}
|
|
367
387
|
if (message.event === SocketEvent.BroadcastPinComment) {
|
|
368
388
|
const data = message.data as { broadcastId: string; comment: BroadcastComment | null };
|
|
369
389
|
setPinnedComment(data.comment);
|
|
@@ -371,7 +391,7 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
371
391
|
});
|
|
372
392
|
|
|
373
393
|
socketRef.current = socket;
|
|
374
|
-
}, [apiUrl, profileId, broadcast.id]);
|
|
394
|
+
}, [apiUrl, profileId, broadcast.id, poll.onPollEvent]);
|
|
375
395
|
|
|
376
396
|
const sendComment = () => {
|
|
377
397
|
if (!profileId || !socketRef.current || !comment.trim()) return;
|
|
@@ -654,6 +674,8 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
654
674
|
{t("forge.broadcast_player.chat")}
|
|
655
675
|
</p>
|
|
656
676
|
|
|
677
|
+
<BroadcastPollPanel poll={poll} />
|
|
678
|
+
|
|
657
679
|
{pinnedComment && (
|
|
658
680
|
<div
|
|
659
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;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Poll tallies as the studio, the stage overlay and the player all render them.
|
|
3
|
+
*
|
|
4
|
+
* One function because three surfaces show the same numbers, and three
|
|
5
|
+
* roundings of the same votes would disagree with each other on screen.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type PollLike = {
|
|
9
|
+
totalVotes: number;
|
|
10
|
+
options: Array<{ id: string; label: string; voteCount: number }>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type PollResult = {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
voteCount: number;
|
|
17
|
+
/** 0..100, integer. */
|
|
18
|
+
percent: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Percentages that add up to 100.
|
|
23
|
+
*
|
|
24
|
+
* Rounding each share on its own is what usually goes wrong: three options on
|
|
25
|
+
* one vote each show as 33% three times and a viewer reads 99, and 1/3 + 2/3
|
|
26
|
+
* shows as 33 + 67 only by luck. This uses largest remainder, so the displayed
|
|
27
|
+
* numbers sum to exactly 100 whenever anybody has voted at all.
|
|
28
|
+
*
|
|
29
|
+
* With no votes every option is 0, NOT an even split. A poll that has just
|
|
30
|
+
* opened showing "50% / 50%" claims two votes that were never cast.
|
|
31
|
+
*/
|
|
32
|
+
export function pollPercentages(poll: PollLike): PollResult[] {
|
|
33
|
+
const options = poll.options ?? [];
|
|
34
|
+
const total = options.reduce((sum, option) => sum + option.voteCount, 0);
|
|
35
|
+
if (total <= 0) {
|
|
36
|
+
return options.map((option) => ({ ...option, percent: 0 }));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const exact = options.map((option) => {
|
|
40
|
+
const share = (option.voteCount / total) * 100;
|
|
41
|
+
return { option, floor: Math.floor(share), remainder: share - Math.floor(share) };
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
let left = 100 - exact.reduce((sum, entry) => sum + entry.floor, 0);
|
|
45
|
+
// Biggest remainder first; a tie goes to the option with more votes, then to
|
|
46
|
+
// the one listed first, so the same tally always renders the same way.
|
|
47
|
+
const order = [...exact].sort(
|
|
48
|
+
(a, b) =>
|
|
49
|
+
b.remainder - a.remainder ||
|
|
50
|
+
b.option.voteCount - a.option.voteCount ||
|
|
51
|
+
options.indexOf(a.option) - options.indexOf(b.option),
|
|
52
|
+
);
|
|
53
|
+
const bonus = new Set<string>();
|
|
54
|
+
for (const entry of order) {
|
|
55
|
+
if (left <= 0) break;
|
|
56
|
+
bonus.add(entry.option.id);
|
|
57
|
+
left--;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return exact.map(({ option, floor }) => ({
|
|
61
|
+
id: option.id,
|
|
62
|
+
label: option.label,
|
|
63
|
+
voteCount: option.voteCount,
|
|
64
|
+
percent: floor + (bonus.has(option.id) ? 1 : 0),
|
|
65
|
+
}));
|
|
66
|
+
}
|