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
+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>
);