feat(polls): "See who voted" — per-answer voter list
The poll card showed vote counts but never who voted, even though computePollState already parses a sender for every response. Surface it: - tallyResponses now also returns voters: Map<answerId, senderId[]>, built in the same latest-response-per-sender loop as the counts, so voters can never disagree with the numbers (voters.get(id).length === counts.get(id)). +5 unit tests. - PollContent adds a "Show who voted" toggle, shown only when results are visible (disclosed live, or undisclosed after end — so a secret ballot stays secret). When on, each answer lists its voters' display names (getMemberName), rendered as a sibling of the answer button so the radiogroup keyboard model is untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -864,10 +864,11 @@ player.kick).
|
||||
|
||||
- **Vote / change / clear** in place — sends stable `m.poll.response` (`m.selections`); latest response per voter wins; clearing removes you from the tally. Multi-choice enforces `max_selections` ("Select up to N").
|
||||
- **Disclosed vs undisclosed** — undisclosed polls hide counts/percentages/bars (and the vote total) until the poll ends; disclosed polls show live results.
|
||||
- **See who voted** — a "Show who voted" toggle (shown only when results are visible, i.e. disclosed live or undisclosed-after-end) reveals the voter names under each answer. The voter list rides the same tally as the counts (`voters: Map<answerId, senderId[]>` populated in `tallyResponses`'s latest-response-per-sender loop), so it can never disagree with the numbers; a re-vote moves the voter, and a cleared vote drops them. Names via `getMemberName`; undisclosed polls stay secret until close.
|
||||
- **End a poll** — the poll's creator or a moderator (redact power) can end it via an inline confirm; sends stable `m.poll.end`. Ended polls lock voting, show "Poll closed · Final results", reveal results, and highlight the winner(s) (ties supported). Only responses cast on/before the end event count.
|
||||
- **Cross-client** — reads **both** the stable (`m.poll`/`m.id`/`m.selections`) and unstable (`org.matrix.msc3381.poll.*`) wire formats by hand (matrix-js-sdk 41.7.0's poll parsers only speak unstable), and uses the SDK `Poll` model for end-event validation (creator / redact-PL) + before-end response filtering. Polls authored in Element render/vote/end correctly and vice-versa.
|
||||
- **Accessibility** — `radiogroup`/`radio` (single, with arrow-key roving) or `group`/`checkbox` (multi) semantics, `aria-checked`/`aria-disabled`, winner announced to AT.
|
||||
- Pure tally/visibility/winner + wire-format parsing live in `utils/poll.ts` (+ `poll.test.ts`, 14 tests incl. the stable/unstable round-trip).
|
||||
- Pure tally/visibility/winner/voters + wire-format parsing live in `utils/poll.ts` (+ `poll.test.ts`, 18 tests incl. the stable/unstable round-trip and voter attribution).
|
||||
|
||||
### Voice Message Playback (waveform + speed)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
|
||||
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
||||
import { MatrixEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { getMemberName } from '../../../utils/room';
|
||||
import {
|
||||
ParsedPoll,
|
||||
PollResponse,
|
||||
@@ -16,7 +17,12 @@ import {
|
||||
winningAnswerIds,
|
||||
} from '../../../utils/poll';
|
||||
|
||||
const EMPTY_TALLY: PollTally = { counts: new Map(), myVotes: new Set(), total: 0 };
|
||||
const EMPTY_TALLY: PollTally = {
|
||||
counts: new Map(),
|
||||
voters: new Map(),
|
||||
myVotes: new Set(),
|
||||
total: 0,
|
||||
};
|
||||
|
||||
type PollState = { tally: PollTally; isEnded: boolean };
|
||||
|
||||
@@ -106,6 +112,7 @@ export function PollContent({
|
||||
const [pending, setPending] = useState<Set<string> | null>(null);
|
||||
const [confirmEnd, setConfirmEnd] = useState(false);
|
||||
const [ending, setEnding] = useState(false);
|
||||
const [showVoters, setShowVoters] = useState(false);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!eventId || !parsed) return;
|
||||
@@ -194,8 +201,11 @@ export function PollContent({
|
||||
const canEnd = !!eventId && !isEnded && (senderId === mx.getUserId() || canRedact);
|
||||
|
||||
const myVotes = pending ?? tally.myVotes;
|
||||
const { counts, total } = tally;
|
||||
const { counts, total, voters } = tally;
|
||||
const winners = isEnded && showResults ? new Set(winningAnswerIds(counts)) : new Set<string>();
|
||||
// Voter identities are shown under the same rule as the counts: disclosed polls
|
||||
// reveal them live, undisclosed polls only once ended.
|
||||
const canShowVoters = showResults && total > 0;
|
||||
|
||||
const handleVote = (answerId: string) => {
|
||||
if (!roomId || !eventId || !canVote) return;
|
||||
@@ -320,8 +330,8 @@ export function PollContent({
|
||||
? 0
|
||||
: -1;
|
||||
return (
|
||||
<React.Fragment key={id}>
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
data-poll-answer
|
||||
data-selected={selected}
|
||||
@@ -416,9 +426,32 @@ export function PollContent({
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0 && (
|
||||
<Text
|
||||
size="T200"
|
||||
priority="300"
|
||||
style={{ padding: `0 ${config.space.S300} ${config.space.S100}` }}
|
||||
>
|
||||
{(voters.get(id) ?? []).map((s) => getMemberName(room, s)).join(', ')}
|
||||
</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{canShowVoters && (
|
||||
<Box>
|
||||
<Chip
|
||||
variant={showVoters ? 'Primary' : 'SurfaceVariant'}
|
||||
radii="Pill"
|
||||
aria-pressed={showVoters}
|
||||
onClick={() => setShowVoters((v) => !v)}
|
||||
before={<Icon size="50" src={Icons.User} />}
|
||||
>
|
||||
<Text size="T200">{showVoters ? 'Hide voters' : 'Show who voted'}</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
)}
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<Text size="T200" priority="300" style={{ minWidth: 0 }}>
|
||||
<i>{footerNote}</i>
|
||||
|
||||
@@ -70,6 +70,51 @@ test('tallyResponses: re-voting after an empty clear counts the newest non-empty
|
||||
assert.equal(total, 1);
|
||||
});
|
||||
|
||||
test('tallyResponses: voters list the senders per answer, consistent with counts', () => {
|
||||
const responses: PollResponse[] = [
|
||||
{ sender: '@a:s', ts: 1, answerIds: ['1'] },
|
||||
{ sender: '@b:s', ts: 1, answerIds: ['1'] },
|
||||
{ sender: '@c:s', ts: 1, answerIds: ['2'] },
|
||||
];
|
||||
const { counts, voters } = tallyResponses(responses, me);
|
||||
assert.deepEqual([...(voters.get('1') ?? [])].sort(), ['@a:s', '@b:s']);
|
||||
assert.deepEqual(voters.get('2'), ['@c:s']);
|
||||
// voters must always agree with counts
|
||||
for (const [id, list] of voters) {
|
||||
assert.equal(list.length, counts.get(id));
|
||||
}
|
||||
});
|
||||
|
||||
test('tallyResponses: a re-vote moves the sender to the new answer in voters', () => {
|
||||
const responses: PollResponse[] = [
|
||||
{ sender: '@a:s', ts: 1, answerIds: ['1'] },
|
||||
{ sender: '@a:s', ts: 5, answerIds: ['2'] }, // changed vote
|
||||
];
|
||||
const { voters } = tallyResponses(responses, me);
|
||||
assert.equal(voters.get('1'), undefined); // no longer under the old answer
|
||||
assert.deepEqual(voters.get('2'), ['@a:s']);
|
||||
});
|
||||
|
||||
test('tallyResponses: a cleared latest response drops the sender from every voter list', () => {
|
||||
const responses: PollResponse[] = [
|
||||
{ sender: '@a:s', ts: 1, answerIds: ['1', '2'] },
|
||||
{ sender: '@a:s', ts: 2, answerIds: [] }, // cleared
|
||||
];
|
||||
const { voters } = tallyResponses(responses, me);
|
||||
assert.equal(voters.get('1'), undefined);
|
||||
assert.equal(voters.get('2'), undefined);
|
||||
});
|
||||
|
||||
test('tallyResponses: multi-select lists a sender under each chosen answer', () => {
|
||||
const responses: PollResponse[] = [
|
||||
{ sender: '@a:s', ts: 1, answerIds: ['1', '3'] },
|
||||
{ sender: '@b:s', ts: 1, answerIds: ['3'] },
|
||||
];
|
||||
const { voters } = tallyResponses(responses, me);
|
||||
assert.deepEqual(voters.get('1'), ['@a:s']);
|
||||
assert.deepEqual([...(voters.get('3') ?? [])].sort(), ['@a:s', '@b:s']);
|
||||
});
|
||||
|
||||
test('resultsVisible: disclosed always, undisclosed only when ended', () => {
|
||||
assert.equal(resultsVisible(false, false), true); // disclosed, open
|
||||
assert.equal(resultsVisible(false, true), true); // disclosed, ended
|
||||
|
||||
@@ -102,6 +102,9 @@ export type PollResponse = {
|
||||
export type PollTally = {
|
||||
/** answerId → number of voters who selected it. */
|
||||
counts: Map<string, number>;
|
||||
/** answerId → the sender ids who selected it (latest response per sender). Always
|
||||
* consistent with `counts` — `voters.get(id).length === counts.get(id)`. */
|
||||
voters: Map<string, string[]>;
|
||||
/** answer ids the current user selected (from their latest response). */
|
||||
myVotes: Set<string>;
|
||||
/** number of distinct voters counted. */
|
||||
@@ -126,6 +129,7 @@ export function tallyResponses(responses: PollResponse[], myUserId: string): Pol
|
||||
}
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
const voters = new Map<string, string[]>();
|
||||
const myVotes = new Set<string>();
|
||||
let total = 0;
|
||||
for (const [sender, r] of latestBySender) {
|
||||
@@ -133,11 +137,14 @@ export function tallyResponses(responses: PollResponse[], myUserId: string): Pol
|
||||
total += 1;
|
||||
for (const id of r.answerIds) {
|
||||
counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||
const list = voters.get(id);
|
||||
if (list) list.push(sender);
|
||||
else voters.set(id, [sender]);
|
||||
if (sender === myUserId) myVotes.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return { counts, myVotes, total };
|
||||
return { counts, voters, myVotes, total };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user