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:
2026-07-10 21:56:50 -04:00
co-authored by Claude Opus 4.8
parent 5cce94edba
commit 1aaea09fc9
4 changed files with 91 additions and 5 deletions
@@ -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>