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:
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user