diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index 488e67281..187f6a365 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -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` 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) diff --git a/src/app/components/message/content/PollContent.tsx b/src/app/components/message/content/PollContent.tsx index 4a2f90098..84a9eb059 100644 --- a/src/app/components/message/content/PollContent.tsx +++ b/src/app/components/message/content/PollContent.tsx @@ -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 | 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(); + // 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 ( + + {showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0 && ( + + {(voters.get(id) ?? []).map((s) => getMemberName(room, s)).join(', ')} + + )} + ); })} + {canShowVoters && ( + + setShowVoters((v) => !v)} + before={} + > + {showVoters ? 'Hide voters' : 'Show who voted'} + + + )} {footerNote} diff --git a/src/app/utils/poll.test.ts b/src/app/utils/poll.test.ts index 02656a192..e71112ae0 100644 --- a/src/app/utils/poll.test.ts +++ b/src/app/utils/poll.test.ts @@ -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 diff --git a/src/app/utils/poll.ts b/src/app/utils/poll.ts index 9fb2470d8..a190aacf7 100644 --- a/src/app/utils/poll.ts +++ b/src/app/utils/poll.ts @@ -102,6 +102,9 @@ export type PollResponse = { export type PollTally = { /** answerId → number of voters who selected it. */ counts: Map; + /** answerId → the sender ids who selected it (latest response per sender). Always + * consistent with `counts` — `voters.get(id).length === counts.get(id)`. */ + voters: Map; /** answer ids the current user selected (from their latest response). */ myVotes: Set; /** number of distinct voters counted. */ @@ -126,6 +129,7 @@ export function tallyResponses(responses: PollResponse[], myUserId: string): Pol } const counts = new Map(); + const voters = new Map(); const myVotes = new Set(); 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 }; } /**