feat(polls): complete the poll lifecycle — voting, undisclosed, end, spec-correct

Upgrade the MSC3381 poll feature from a leaky half-implementation to a complete,
cross-client-correct one:

- End/close a poll (creator or redact-PL mod) via inline confirm → m.poll.end;
  locks voting, reveals results, marks winner(s); only pre-end responses count.
- Honor poll kind: undisclosed polls hide counts/percent/bars/total until ended
  (creator gets a Show-live-results vs Hidden-until-ended toggle; default live).
  Previously every poll was created undisclosed yet the UI leaked live results.
- Enforce max_selections for multi-choice; radiogroup/checkbox a11y with arrow-key
  roving and an AT-announced winner.
- Robust, dual-namespace wire handling: parse BOTH stable (m.poll/m.id/m.selections)
  and unstable (org.matrix.msc3381.poll.*) by hand — matrix-js-sdk 41.7.0's
  PollStart/Response parsers only understand the unstable bodies, so delegating to
  them broke every stable poll (caught in agent review). Use the SDK Poll model only
  for end validation + before-end filtering.
- Pure tally/visibility/winner/parse logic extracted to utils/poll.ts with 14 tests
  incl. a stable/unstable wire-format round-trip.

Reviewed by 3 agents (spec/cross-client, logic, a11y/UI); findings applied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 19:48:58 -04:00
co-authored by Claude Opus 4.8
parent 384a1dd262
commit 0b06158477
6 changed files with 677 additions and 231 deletions
+36 -2
View File
@@ -34,6 +34,9 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
const [question, setQuestion] = useState('');
const [options, setOptions] = useState<string[]>(['', '']);
const [isMultiple, setIsMultiple] = useState(false);
// Results visibility: disclosed (live results, default) vs undisclosed (hidden
// until the poll is ended).
const [disclosed, setDisclosed] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -73,14 +76,20 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
setError(null);
setSubmitting(true);
try {
// Text fallback for clients that don't understand polls: the question + a
// numbered list of the options.
const fallbackBody = [
trimmedQuestion,
...filledOptions.map((o, i) => `${i + 1}. ${o}`),
].join('\n');
await mx.sendEvent(roomId, 'm.poll.start' as any, {
'm.poll': {
question: { 'm.text': trimmedQuestion },
answers: filledOptions.map((o, i) => ({ 'm.id': `${i}`, 'm.text': o })),
max_selections: isMultiple ? filledOptions.length : 1,
kind: 'm.poll.undisclosed',
kind: disclosed ? 'm.poll.disclosed' : 'm.poll.undisclosed',
},
body: trimmedQuestion,
body: fallbackBody,
msgtype: 'm.text',
});
onClose();
@@ -216,6 +225,31 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
</Box>
</Box>
{/* Results visibility */}
<Box direction="Column" gap="100">
<Text size="L400">Results</Text>
<Box gap="200">
{(['live', 'hidden'] as const).map((mode) => {
const active = mode === 'live' ? disclosed : !disclosed;
return (
<Button
key={mode}
type="button"
size="300"
variant="Primary"
fill={active ? 'Solid' : 'None'}
radii="300"
onClick={() => setDisclosed(mode === 'live')}
>
<Text size="B300">
{mode === 'live' ? 'Show live results' : 'Hidden until ended'}
</Text>
</Button>
);
})}
</Box>
</Box>
{/* Error */}
{error && (
<Text size="T300" style={{ color: color.Critical.Main }}>
+3 -17
View File
@@ -1314,13 +1314,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
mEvent.getType() === 'm.poll.start' ||
mEvent.getType() === 'org.matrix.msc3381.poll.start'
)
return (
<PollContent
content={mEvent.getContent()}
roomId={room.roomId}
eventId={mEvent.getId() ?? undefined}
/>
);
return <PollContent mEvent={mEvent} room={room} canRedact={canRedact} />;
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
return (
<Text>
@@ -1449,11 +1443,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
) : (
<PollContent
content={mEvent.getContent()}
roomId={room.roomId}
eventId={mEvent.getId() ?? undefined}
/>
<PollContent mEvent={mEvent} room={room} canRedact={canRedact} />
)}
</Message>
);
@@ -1506,11 +1496,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
) : (
<PollContent
content={mEvent.getContent()}
roomId={room.roomId}
eventId={mEvent.getId() ?? undefined}
/>
<PollContent mEvent={mEvent} room={room} canRedact={canRedact} />
)}
</Message>
);