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
+12 -5
View File
@@ -829,14 +829,21 @@ player.kick).
### Poll Creation
- `PollCreator.tsx` creates stable `m.poll.start` events
- Supports 2 to 10 answer options
- Supports both single-choice and multiple-choice modes
- `PollCreator.tsx` creates stable `m.poll.start` events (with a text fallback body for non-poll clients)
- Supports 2 to 10 answer options; single-choice or multiple-choice
- **Results visibility toggle** — _Show live results_ (disclosed, default) vs _Hidden until ended_ (undisclosed)
- Accessible via the `Icons.OrderList` button in the composer toolbar
### Poll Display
### Poll Display & Voting (MSC3381, full lifecycle)
`PollContent.tsx` renders polls in read-only mode. Handles both the stable `m.poll` format and the legacy MSC3381 unstable `org.matrix.msc3381.poll.start` format. Displays current vote counts and a note directing users to Element to cast votes.
`PollContent.tsx` is a fully interactive, spec-correct poll card:
- **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.
- **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).
### Voice Message Playback Speed
+290 -207
View File
@@ -1,179 +1,183 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useCallback, useEffect, useState } from 'react';
import { Box, color, config, Icon, Icons, Text, toRem } from 'folds';
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
import { RoomEvent } from 'matrix-js-sdk';
import { MatrixEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import {
ParsedPoll,
PollResponse,
PollTally,
parsePollStart,
parseResponseAnswerIds,
resultsVisible,
tallyResponses,
validateSelections,
winningAnswerIds,
} from '../../../utils/poll';
type PollTextValue = Array<{ body: string }> | string;
const EMPTY_TALLY: PollTally = { counts: new Map(), myVotes: new Set(), total: 0 };
function extractText(val: PollTextValue | undefined): string {
if (!val) return '';
if (typeof val === 'string') return val;
return val[0]?.body ?? '';
type PollState = { tally: PollTally; isEnded: boolean };
function setsEqual(a: Set<string>, b: Set<string>): boolean {
if (a.size !== b.size) return false;
for (const x of a) if (!b.has(x)) return false;
return true;
}
type PollAnswer = {
'm.id'?: string;
id?: string;
'm.text'?: PollTextValue;
'org.matrix.msc3381.poll.answer'?: { body: string };
};
type PollData = {
question?: { body?: string; 'm.text'?: PollTextValue };
answers?: PollAnswer[];
max_selections?: number;
};
type VoteState = {
counts: Map<string, number>;
myVotes: Set<string>;
total: number;
};
function computeVotes(
function computePollState(
mx: ReturnType<typeof useMatrixClient>,
roomId: string,
room: Room,
eventId: string,
_isStable: boolean,
): VoteState {
const empty: VoteState = { counts: new Map(), myVotes: new Set(), total: 0 };
const room = mx.getRoom(roomId);
if (!room) return empty;
const timelineSet = room.getUnfilteredTimelineSet();
const stableRels = timelineSet.relations.getChildEventsForEvent(
eventId,
'm.reference',
'm.poll.response',
);
const unstableRels = timelineSet.relations.getChildEventsForEvent(
eventId,
'org.matrix.msc3381.poll.response' as any,
'org.matrix.msc3381.poll.response',
);
// Per-sender keep only the latest response (which may include multiple selections)
const latestBySender = new Map<string, { ts: number; answerIds: string[] }>();
parsed: ParsedPoll
): PollState {
const relations = room.getUnfilteredTimelineSet().relations;
const myUserId = mx.getSafeUserId();
const validIds = new Set(parsed.answers.map((a) => a.id));
const processRelations = (rels: typeof stableRels, stable: boolean) => {
const events = rels?.getRelations() ?? [];
for (const ev of events) {
const getRels = (type: string): MatrixEvent[] =>
relations.getChildEventsForEvent(eventId, 'm.reference', type as any)?.getRelations() ?? [];
const endEvents = [...getRels('m.poll.end'), ...getRels('org.matrix.msc3381.poll.end')];
const respEvents = [
...getRels('m.poll.response'),
...getRels('org.matrix.msc3381.poll.response'),
];
// End state: prefer the SDK Poll model (validates ender = creator or redact PL,
// refilters responses). Fall back to an end from the poll's own creator, which is
// always valid, in case the model hasn't processed it yet.
const poll = room.polls.get(eventId);
let isEnded = poll?.isEnded ?? false;
let endTs = poll?.endEventId ? room.findEventById(poll.endEventId)?.getTs() : undefined;
if (!isEnded) {
const creator = room.findEventById(eventId)?.getSender();
for (const ev of endEvents) {
if (ev.isRedacted()) continue;
const sender = ev.getSender();
if (!sender) continue;
const content = ev.getContent();
let answerIds: string[] = [];
if (stable) {
answerIds = (content['m.selections'] as string[] | undefined) ?? [];
} else {
answerIds =
((content['org.matrix.msc3381.poll.response'] as any)?.answers as string[] | undefined) ??
[];
if (creator && ev.getSender() === creator) {
isEnded = true;
const t = ev.getTs();
if (endTs === undefined || t < endTs) endTs = t;
}
if (answerIds.length === 0) continue;
const ts = ev.getTs();
const existing = latestBySender.get(sender);
if (!existing || ts > existing.ts) {
latestBySender.set(sender, { ts, answerIds });
}
}
};
processRelations(stableRels, true);
processRelations(unstableRels, false);
const counts = new Map<string, number>();
const myVotes = new Set<string>();
for (const [sender, { answerIds }] of latestBySender) {
for (const id of answerIds) {
counts.set(id, (counts.get(id) ?? 0) + 1);
if (sender === myUserId) myVotes.add(id);
}
}
return { counts, myVotes, total: latestBySender.size };
const responses: PollResponse[] = [];
for (const ev of respEvents) {
if (ev.isRedacted()) continue;
const sender = ev.getSender();
if (!sender) continue;
const ts = ev.getTs();
// Only responses cast on or before the end event are valid.
if (isEnded && endTs !== undefined && ts > endTs) continue;
const answerIds = validateSelections(
parseResponseAnswerIds(ev.getContent()),
validIds,
parsed.maxSelections
);
responses.push({ sender, ts, answerIds });
}
return { tally: tallyResponses(responses, myUserId), isEnded };
}
export function PollContent({
content,
roomId,
eventId,
mEvent,
room,
canRedact,
}: {
content: Record<string, unknown>;
roomId?: string;
eventId?: string;
mEvent: MatrixEvent;
room: Room;
/** Whether the current user may redact in this room (gates the End-poll action). */
canRedact: boolean;
}) {
const mx = useMatrixClient();
const _isStable = !!content['m.poll'];
const roomId = room.roomId;
const eventId = mEvent.getId();
const senderId = mEvent.getSender();
const poll = (content['m.poll'] ?? content['org.matrix.msc3381.poll.start']) as
| PollData
| undefined;
const parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]);
const [votes, setVotes] = useState<VoteState>(() => {
if (!roomId || !eventId) return { counts: new Map(), myVotes: new Set(), total: 0 };
return computeVotes(mx, roomId, eventId, _isStable);
const [state, setState] = useState<PollState>(() => {
if (!eventId || !parsed) return { tally: EMPTY_TALLY, isEnded: false };
return computePollState(mx, room, eventId, parsed);
});
const [pending, setPending] = useState<Set<string> | null>(null);
const [confirmEnd, setConfirmEnd] = useState(false);
const [ending, setEnding] = useState(false);
// Refresh votes whenever Relations events fire
const refresh = useCallback(() => {
if (!roomId || !eventId) return;
setVotes(computeVotes(mx, roomId, eventId, _isStable));
}, [mx, roomId, eventId, _isStable]);
if (!eventId || !parsed) return;
const next = computePollState(mx, room, eventId, parsed);
setState(next);
// Drop the optimistic selection only once our own vote is actually reflected,
// so an unrelated refresh (another user voting) can't revert our pending click.
setPending((prev) => (prev !== null && setsEqual(prev, next.tally.myVotes) ? null : prev));
}, [mx, room, eventId, parsed]);
useEffect(() => {
if (!roomId || !eventId) return;
const room = mx.getRoom(roomId);
if (!room) return;
const timelineSet = room.getUnfilteredTimelineSet();
if (!eventId || !parsed) return undefined;
const relations = room.getUnfilteredTimelineSet().relations;
const relObjs = [
relations.getChildEventsForEvent(eventId, 'm.reference', 'm.poll.response' as any),
relations.getChildEventsForEvent(
eventId,
'm.reference',
'org.matrix.msc3381.poll.response' as any
),
relations.getChildEventsForEvent(eventId, 'm.reference', 'm.poll.end' as any),
relations.getChildEventsForEvent(eventId, 'm.reference', 'org.matrix.msc3381.poll.end' as any),
];
relObjs.forEach((r) => {
r?.on(RelationsEvent.Add, refresh);
r?.on(RelationsEvent.Remove, refresh);
r?.on(RelationsEvent.Redaction, refresh);
});
const stableRels = timelineSet.relations.getChildEventsForEvent(
eventId,
'm.reference',
'm.poll.response',
);
const unstableRels = timelineSet.relations.getChildEventsForEvent(
eventId,
'org.matrix.msc3381.poll.response' as any,
'org.matrix.msc3381.poll.response',
);
stableRels?.on(RelationsEvent.Add, refresh);
stableRels?.on(RelationsEvent.Remove, refresh);
stableRels?.on(RelationsEvent.Redaction, refresh);
unstableRels?.on(RelationsEvent.Add, refresh);
unstableRels?.on(RelationsEvent.Remove, refresh);
unstableRels?.on(RelationsEvent.Redaction, refresh);
// Also listen at room level: if no votes exist yet, the Relations object is null
// and the listeners above are no-ops. The room timeline event catches the first vote.
const onTimeline = (ev: any) => {
const type = ev.getType?.();
const relatesTo = ev.getContent?.()?.['m.relates_to'];
// The relations object for the first response/end may not exist yet at mount, and
// redactions (m.room.redaction) don't flow through the relations listeners for a
// post-mount response — the room timeline catches those.
const onTimeline = (ev: MatrixEvent) => {
const type = ev.getType();
if (type === 'm.room.redaction') {
refresh();
return;
}
const relatesTo = ev.getContent()['m.relates_to'] as { event_id?: string } | undefined;
if (
(type === 'm.poll.response' || type === 'org.matrix.msc3381.poll.response') &&
(type === 'm.poll.response' ||
type === 'org.matrix.msc3381.poll.response' ||
type === 'm.poll.end' ||
type === 'org.matrix.msc3381.poll.end') &&
relatesTo?.event_id === eventId
) {
refresh();
}
};
const room2 = mx.getRoom(roomId);
room2?.on(RoomEvent.Timeline, onTimeline);
room.on(RoomEvent.Timeline, onTimeline);
const poll = room.polls.get(eventId);
poll?.on(PollEvent.Update, refresh);
poll?.on(PollEvent.Responses, refresh);
poll?.on(PollEvent.End, refresh);
room.on(PollEvent.New, refresh);
return () => {
stableRels?.off(RelationsEvent.Add, refresh);
stableRels?.off(RelationsEvent.Remove, refresh);
stableRels?.off(RelationsEvent.Redaction, refresh);
unstableRels?.off(RelationsEvent.Add, refresh);
unstableRels?.off(RelationsEvent.Remove, refresh);
unstableRels?.off(RelationsEvent.Redaction, refresh);
room2?.off(RoomEvent.Timeline, onTimeline);
relObjs.forEach((r) => {
r?.off(RelationsEvent.Add, refresh);
r?.off(RelationsEvent.Remove, refresh);
r?.off(RelationsEvent.Redaction, refresh);
});
room.off(RoomEvent.Timeline, onTimeline);
poll?.off(PollEvent.Update, refresh);
poll?.off(PollEvent.Responses, refresh);
poll?.off(PollEvent.End, refresh);
room.off(PollEvent.New, refresh);
};
}, [mx, roomId, eventId, refresh]);
}, [room, eventId, parsed, refresh]);
if (!poll) {
if (!parsed) {
return (
<Text priority="300">
<i>Poll (unreadable format)</i>
@@ -181,61 +185,93 @@ export function PollContent({
);
}
const questionText =
extractText((poll.question as any)?.['m.text']) ||
(poll.question as any)?.body ||
'Untitled poll';
const canVote = !!roomId && !!eventId;
const maxSelections = (poll as any).max_selections ?? 1;
const { tally, isEnded } = state;
const { isUndisclosed, maxSelections, question, answers } = parsed;
const isMultiple = maxSelections > 1;
const { counts, myVotes, total } = votes;
const showResults = resultsVisible(isUndisclosed, isEnded);
const canVote = !!roomId && !!eventId && !isEnded;
const canEnd = !!eventId && !isEnded && (senderId === mx.getUserId() || canRedact);
const myVotes = pending ?? tally.myVotes;
const { counts, total } = tally;
const winners = isEnded && showResults ? new Set(winningAnswerIds(counts)) : new Set<string>();
const handleVote = (answerId: string) => {
if (!roomId || !eventId) return;
if (!roomId || !eventId || !canVote) return;
const newVotes = new Set(myVotes);
if (newVotes.has(answerId)) {
newVotes.delete(answerId);
const next = new Set(myVotes);
if (next.has(answerId)) {
next.delete(answerId);
} else if (isMultiple) {
if (next.size >= maxSelections) return; // enforce max_selections
next.add(answerId);
} else {
if (!isMultiple) newVotes.clear();
newVotes.add(answerId);
next.clear();
next.add(answerId);
}
// Optimistic local update
setVotes((prev) => {
const next = new Map(prev.counts);
// Remove all old vote counts for this user
for (const id of prev.myVotes) {
const c = next.get(id) ?? 1;
if (c <= 1) next.delete(id);
else next.set(id, c - 1);
}
// Add new vote counts
for (const id of newVotes) {
next.set(id, (next.get(id) ?? 0) + 1);
}
const hadVotes = prev.myVotes.size > 0;
const hasVotes = newVotes.size > 0;
const newTotal = prev.total + (hasVotes && !hadVotes ? 1 : !hasVotes && hadVotes ? -1 : 0);
return { counts: next, myVotes: newVotes, total: newTotal };
});
const selectionsArr = Array.from(newVotes);
if (_isStable) {
mx.sendEvent(roomId, 'm.poll.response' as any, {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.selections': selectionsArr,
}).catch(() => undefined);
} else {
mx.sendEvent(roomId, 'org.matrix.msc3381.poll.response' as any, {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'org.matrix.msc3381.poll.response': { answers: selectionsArr },
}).catch(() => undefined);
}
setPending(next);
// Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader
// accepts both namespaces).
mx.sendEvent(roomId, 'm.poll.response' as any, {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.selections': Array.from(next),
}).catch(() => setPending(null));
};
const answers = poll.answers ?? [];
const handleEndPoll = () => {
if (!roomId || !eventId || ending) return;
setEnding(true);
mx.sendEvent(roomId, 'm.poll.end' as any, {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.poll.end': {},
'm.text': 'The poll has ended.',
})
.then(() => {
setConfirmEnd(false);
setEnding(false);
})
.catch(() => setEnding(false));
};
// Radiogroup keyboard model (single-choice only): arrows move focus + selection.
const handleRadioKeyDown = (evt: KeyboardEvent<HTMLDivElement>) => {
if (isMultiple || !canVote) return;
const nav = ['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft', 'Home', 'End'];
if (!nav.includes(evt.key)) return;
evt.preventDefault();
const buttons = Array.from(
evt.currentTarget.querySelectorAll<HTMLButtonElement>('[data-poll-answer]')
);
if (buttons.length === 0) return;
const current = buttons.findIndex((b) => b === document.activeElement);
let idx = current < 0 ? 0 : current;
if (evt.key === 'ArrowDown' || evt.key === 'ArrowRight') idx = (idx + 1) % buttons.length;
else if (evt.key === 'ArrowUp' || evt.key === 'ArrowLeft')
idx = (idx - 1 + buttons.length) % buttons.length;
else if (evt.key === 'Home') idx = 0;
else if (evt.key === 'End') idx = buttons.length - 1;
buttons[idx]?.focus();
handleVote(answers[idx].id);
};
const headerLabel = isEnded
? 'Poll closed · Final results'
: `Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`;
const footerNote = (() => {
if (isUndisclosed && !isEnded) {
return canVote
? 'Voting open · Results hidden until the poll ends'
: 'Results hidden until the poll ends';
}
const votesPart = total > 0 ? `${total} vote${total === 1 ? '' : 's'}` : 'No votes yet';
if (isEnded) return `${votesPart} · Poll closed`;
if (!canVote) return votesPart;
if (isMultiple) return `${votesPart} · Select up to ${maxSelections}`;
return `${votesPart} · ${myVotes.size > 0 ? 'Click to change' : 'Click to vote'}`;
})();
return (
<Box
@@ -256,34 +292,55 @@ export function PollContent({
marginBottom: config.space.S100,
}}
>
{`◉ Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`}
<span aria-hidden> </span>
{headerLabel}
</Text>
<Text size="T400" style={{ fontWeight: 600 }}>
{questionText}
{question}
</Text>
<Box direction="Column" gap="100" style={{ marginTop: '2px' }}>
<Box
direction="Column"
gap="100"
style={{ marginTop: '2px' }}
role={isMultiple ? 'group' : 'radiogroup'}
aria-label={question}
onKeyDown={handleRadioKeyDown}
>
{answers.map((answer, i) => {
const text =
extractText((answer as any)['m.text']) ||
(answer as any)['org.matrix.msc3381.poll.answer']?.body ||
`Option ${i + 1}`;
const id = answer['m.id'] ?? answer.id ?? String(i);
const id = answer.id;
const text = answer.text;
const selected = myVotes.has(id);
const voteCount = counts.get(id) ?? 0;
const pct = total > 0 ? Math.round((voteCount / total) * 100) : 0;
const pct = showResults && total > 0 ? Math.round((voteCount / total) * 100) : 0;
const isWinner = winners.has(id);
// Roving tabindex for the single-choice radiogroup; checkboxes stay tabbable.
const tabIndex = isMultiple
? 0
: selected || (myVotes.size === 0 && i === 0)
? 0
: -1;
return (
<button
key={id}
type="button"
data-poll-answer
data-selected={selected}
role={isMultiple ? 'checkbox' : 'radio'}
aria-checked={selected}
aria-disabled={!canVote}
aria-label={isWinner ? `${text}, winning answer` : undefined}
tabIndex={tabIndex}
onClick={canVote ? () => handleVote(id) : undefined}
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
background: selected ? color.Primary.Container : color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${
selected ? color.Primary.Main : color.SurfaceVariant.ContainerLine
isWinner
? color.Success.Main
: selected
? color.Primary.Main
: color.SurfaceVariant.ContainerLine
}`,
lineHeight: 1.4,
textAlign: 'left',
@@ -298,7 +355,7 @@ export function PollContent({
transition: 'border-color 0.15s, background 0.15s',
}}
>
{total > 0 && (
{showResults && total > 0 && (
<span
aria-hidden
style={{
@@ -323,6 +380,7 @@ export function PollContent({
}}
>
<span
aria-hidden
style={{
flexShrink: 0,
width: toRem(14),
@@ -339,12 +397,19 @@ export function PollContent({
transition: 'all 0.15s',
}}
>
{selected && isMultiple ? <Icon size="50" src={Icons.Check} /> : null}
{selected ? <Icon size="50" src={Icons.Check} /> : null}
</span>
<Text as="span" size="T300" style={{ flexGrow: 1 }}>
{text}
</Text>
{total > 0 && (
{isWinner && (
<Icon
size="50"
src={Icons.Check}
style={{ flexShrink: 0, color: color.Success.Main }}
/>
)}
{showResults && total > 0 && (
<Text as="span" size="T200" priority="300" style={{ flexShrink: 0 }}>
{pct}%
</Text>
@@ -354,18 +419,36 @@ export function PollContent({
);
})}
</Box>
<Text size="T200" priority="300" style={{ marginTop: '2px' }}>
<i>
{total > 0 ? `${total} vote${total === 1 ? '' : 's'} · ` : ''}
{canVote
? isMultiple
? 'Select all that apply'
: myVotes.size > 0
? 'Click to change'
: 'Click to vote'
: 'Voting not available'}
</i>
</Text>
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="T200" priority="300" style={{ minWidth: 0 }}>
<i>{footerNote}</i>
</Text>
{canEnd &&
(confirmEnd ? (
<Box gap="100" shrink="No" alignItems="Center">
<Chip
variant="Critical"
radii="Pill"
aria-disabled={ending}
onClick={ending ? undefined : handleEndPoll}
>
<Text size="T200">{ending ? 'Ending…' : 'End poll'}</Text>
</Chip>
<Chip variant="Secondary" radii="Pill" onClick={() => setConfirmEnd(false)}>
<Text size="T200">Cancel</Text>
</Chip>
</Box>
) : (
<Chip
variant="SurfaceVariant"
radii="Pill"
onClick={() => setConfirmEnd(true)}
before={<Icon size="50" src={Icons.Cross} />}
>
<Text size="T200">End poll</Text>
</Chip>
))}
</Box>
</Box>
);
}
+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>
);
+170
View File
@@ -0,0 +1,170 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
tallyResponses,
resultsVisible,
winningAnswerIds,
parsePollStart,
parseResponseAnswerIds,
validateSelections,
PollResponse,
} from './poll';
const me = '@me:server';
test('tallyResponses counts one vote per selection', () => {
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, total } = tallyResponses(responses, me);
assert.equal(counts.get('1'), 2);
assert.equal(counts.get('2'), 1);
assert.equal(total, 3);
});
test('tallyResponses keeps only the latest response per sender', () => {
const responses: PollResponse[] = [
{ sender: '@a:s', ts: 1, answerIds: ['1'] },
{ sender: '@a:s', ts: 5, answerIds: ['2'] }, // changed vote
{ sender: '@a:s', ts: 3, answerIds: ['1'] }, // older, ignored
];
const { counts, total } = tallyResponses(responses, me);
assert.equal(counts.get('1'), undefined);
assert.equal(counts.get('2'), 1);
assert.equal(total, 1);
});
test('tallyResponses handles multi-select and reports myVotes', () => {
const responses: PollResponse[] = [
{ sender: me, ts: 2, answerIds: ['1', '3'] },
{ sender: '@b:s', ts: 1, answerIds: ['3'] },
];
const { counts, myVotes, total } = tallyResponses(responses, me);
assert.equal(counts.get('1'), 1);
assert.equal(counts.get('3'), 2);
assert.deepEqual([...myVotes].sort(), ['1', '3']);
assert.equal(total, 2);
});
test('tallyResponses: a cleared (empty) latest response removes the voter', () => {
const responses: PollResponse[] = [
{ sender: '@a:s', ts: 1, answerIds: ['1'] },
{ sender: '@a:s', ts: 2, answerIds: [] }, // latest is empty → sender dropped entirely
];
const { counts, total } = tallyResponses(responses, me);
assert.equal(counts.get('1'), undefined);
assert.equal(total, 0);
});
test('tallyResponses: re-voting after an empty clear counts the newest non-empty', () => {
const responses: PollResponse[] = [
{ sender: '@a:s', ts: 1, answerIds: ['1'] },
{ sender: '@a:s', ts: 2, answerIds: [] }, // cleared
{ sender: '@a:s', ts: 3, answerIds: ['2'] }, // re-voted, newest wins
];
const { counts, total } = tallyResponses(responses, me);
assert.equal(counts.get('1'), undefined);
assert.equal(counts.get('2'), 1);
assert.equal(total, 1);
});
test('resultsVisible: disclosed always, undisclosed only when ended', () => {
assert.equal(resultsVisible(false, false), true); // disclosed, open
assert.equal(resultsVisible(false, true), true); // disclosed, ended
assert.equal(resultsVisible(true, false), false); // undisclosed, open → hidden
assert.equal(resultsVisible(true, true), true); // undisclosed, ended → revealed
});
test('winningAnswerIds returns the single top answer', () => {
const counts = new Map([
['1', 3],
['2', 1],
]);
assert.deepEqual(winningAnswerIds(counts), ['1']);
});
test('winningAnswerIds returns all tied answers', () => {
const counts = new Map([
['1', 2],
['2', 2],
['3', 1],
]);
assert.deepEqual(winningAnswerIds(counts).sort(), ['1', '2']);
});
test('winningAnswerIds is empty when there are no votes', () => {
assert.deepEqual(winningAnswerIds(new Map()), []);
});
// --- wire-format round-trip: exactly what PollCreator + handleVote send (STABLE) ---
test('parsePollStart reads the STABLE m.poll format Lotus sends', () => {
const content = {
'm.poll': {
question: { 'm.text': 'Favorite color?' },
answers: [
{ 'm.id': '0', 'm.text': 'Red' },
{ 'm.id': '1', 'm.text': 'Blue' },
],
max_selections: 1,
kind: 'm.poll.disclosed',
},
body: 'Favorite color?\n1. Red\n2. Blue',
msgtype: 'm.text',
};
const parsed = parsePollStart(content);
assert.ok(parsed);
assert.equal(parsed!.question, 'Favorite color?');
assert.deepEqual(parsed!.answers, [
{ id: '0', text: 'Red' },
{ id: '1', text: 'Blue' },
]);
assert.equal(parsed!.maxSelections, 1);
assert.equal(parsed!.isUndisclosed, false);
});
test('parsePollStart reads an UNSTABLE (Element-authored) poll + defaults kind to undisclosed', () => {
const content = {
'org.matrix.msc3381.poll.start': {
question: { 'org.matrix.msc1767.text': 'Lunch?' },
answers: [
{ id: 'a', 'org.matrix.msc1767.text': 'Pizza' },
{ id: 'b', 'org.matrix.msc1767.text': 'Tacos' },
],
max_selections: 2,
// no kind → undisclosed per spec
},
};
const parsed = parsePollStart(content);
assert.ok(parsed);
assert.equal(parsed!.question, 'Lunch?');
assert.deepEqual(parsed!.answers, [
{ id: 'a', text: 'Pizza' },
{ id: 'b', text: 'Tacos' },
]);
assert.equal(parsed!.maxSelections, 2);
assert.equal(parsed!.isUndisclosed, true);
});
test('parsePollStart returns null for non-poll content', () => {
assert.equal(parsePollStart({ body: 'hi', msgtype: 'm.text' }), null);
});
test('parseResponseAnswerIds reads stable m.selections and unstable nested answers', () => {
assert.deepEqual(parseResponseAnswerIds({ 'm.selections': ['0', '1'] }), ['0', '1']);
assert.deepEqual(
parseResponseAnswerIds({ 'org.matrix.msc3381.poll.response': { answers: ['a'] } }),
['a']
);
assert.deepEqual(parseResponseAnswerIds({}), []);
});
test('validateSelections filters unknown ids, de-dupes, and caps to max_selections', () => {
const valid = new Set(['0', '1', '2']);
assert.deepEqual(validateSelections(['0', 'x', '1'], valid, 3), ['0', '1']); // drops unknown 'x'
assert.deepEqual(validateSelections(['1', '1', '2'], valid, 3), ['1', '2']); // de-dupes
assert.deepEqual(validateSelections(['0', '1', '2'], valid, 1), ['0']); // caps to first max_selections
assert.deepEqual(validateSelections(['nope'], valid, 3), []); // all invalid → spoiled/empty
});
+166
View File
@@ -0,0 +1,166 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { M_POLL_KIND_DISCLOSED } from 'matrix-js-sdk';
// Pure helpers for poll display. matrix-js-sdk 41.7.0's PollStartEvent /
// PollResponseEvent parsers only understand the UNSTABLE MSC3381 wire format, but
// this app (and spec-compliant clients) send the STABLE format (`m.poll`, `m.id`,
// `m.text`, `m.selections`, `m.poll.disclosed`). So we parse both namespaces by hand
// here rather than delegating to the SDK. Kept in a plain module so the full
// wire-format round-trip is unit-testable (see poll.test.ts).
export type ParsedPoll = {
question: string;
answers: { id: string; text: string }[];
maxSelections: number;
isUndisclosed: boolean;
};
function extractText(val: unknown): string {
if (typeof val === 'string') return val;
if (Array.isArray(val)) {
const first = val[0] as { body?: string } | undefined;
if (first && typeof first.body === 'string') return first.body;
}
return '';
}
/** Parse an `m.poll.start` event's content (stable `m.poll` or unstable
* `org.matrix.msc3381.poll.start`). Returns null if it can't be read as a poll. */
export function parsePollStart(content: Record<string, any>): ParsedPoll | null {
const poll = content['m.poll'] ?? content['org.matrix.msc3381.poll.start'];
if (!poll || typeof poll !== 'object') return null;
const q = poll.question ?? {};
const question =
extractText(q['m.text']) ||
(typeof q['org.matrix.msc1767.text'] === 'string' ? q['org.matrix.msc1767.text'] : '') ||
(typeof q.body === 'string' ? q.body : '') ||
'Untitled poll';
const rawAnswers = Array.isArray(poll.answers) ? poll.answers : [];
const answers = rawAnswers
.map((a: Record<string, any>, i: number) => ({
id:
(typeof a['m.id'] === 'string' && a['m.id']) ||
(typeof a.id === 'string' && a.id) ||
String(i),
text:
extractText(a['m.text']) ||
a['org.matrix.msc3381.poll.answer']?.body ||
(typeof a['org.matrix.msc1767.text'] === 'string' ? a['org.matrix.msc1767.text'] : '') ||
`Option ${i + 1}`,
}))
.filter((a: { id: string }) => a.id);
if (answers.length === 0) return null;
const maxSelections = Math.max(1, Math.floor(Number(poll.max_selections)) || 1);
const kind = typeof poll.kind === 'string' ? poll.kind : '';
// Per MSC3381, anything not explicitly disclosed is treated as undisclosed.
const isUndisclosed = !M_POLL_KIND_DISCLOSED.matches(kind);
return { question, answers, maxSelections, isUndisclosed };
}
/** Read the selected answer ids out of an `m.poll.response` content (stable
* `m.selections` or unstable nested `…poll.response.answers`). */
export function parseResponseAnswerIds(content: Record<string, any>): string[] {
const stable = content['m.selections'];
if (Array.isArray(stable)) return stable.filter((x) => typeof x === 'string');
const nested = content['org.matrix.msc3381.poll.response'] ?? content['m.poll.response'];
const answers = nested && typeof nested === 'object' ? nested.answers : undefined;
if (Array.isArray(answers)) return answers.filter((x) => typeof x === 'string');
return [];
}
/** Restrict a raw selection list to the poll's valid answer ids, de-dupe, and cap to
* max_selections (keep the first N) MSC3381 spoiled-vote handling. */
export function validateSelections(
rawIds: string[],
validIds: Set<string>,
maxSelections: number
): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const id of rawIds) {
if (validIds.has(id) && !seen.has(id)) {
out.push(id);
seen.add(id);
if (out.length >= maxSelections) break;
}
}
return out;
}
/** A single voter's validated response: non-spoiled, answer ids already restricted
* to the poll's valid answers and capped to max_selections. */
export type PollResponse = {
sender: string;
ts: number;
answerIds: string[];
};
export type PollTally = {
/** answerId → number of voters who selected it. */
counts: Map<string, number>;
/** answer ids the current user selected (from their latest response). */
myVotes: Set<string>;
/** number of distinct voters counted. */
total: number;
};
/**
* Aggregate responses into a tally. The LATEST response per sender wins (a later
* response fully replaces an earlier one). If a sender's latest response is empty
* (they cleared their vote, or it was spoiled and reduced to no valid answers),
* that sender is NOT counted so clearing a vote removes you from the total.
* Ties in timestamp keep the first seen (stable). Callers pass answer ids that are
* already restricted to the poll's valid answers + capped to max_selections.
*/
export function tallyResponses(responses: PollResponse[], myUserId: string): PollTally {
const latestBySender = new Map<string, PollResponse>();
for (const r of responses) {
const existing = latestBySender.get(r.sender);
if (!existing || r.ts > existing.ts) {
latestBySender.set(r.sender, r);
}
}
const counts = new Map<string, number>();
const myVotes = new Set<string>();
let total = 0;
for (const [sender, r] of latestBySender) {
if (r.answerIds.length === 0) continue; // cleared / spoiled latest → not a voter
total += 1;
for (const id of r.answerIds) {
counts.set(id, (counts.get(id) ?? 0) + 1);
if (sender === myUserId) myVotes.add(id);
}
}
return { counts, myVotes, total };
}
/**
* Whether vote counts may be shown. Disclosed polls always show live results;
* undisclosed polls hide them until the poll has ended.
*/
export function resultsVisible(isUndisclosed: boolean, isEnded: boolean): boolean {
return !isUndisclosed || isEnded;
}
/**
* Answer id(s) with the highest vote count. Returns every tied id, or an empty
* array when there are no votes.
*/
export function winningAnswerIds(counts: Map<string, number>): string[] {
let max = 0;
for (const c of counts.values()) {
if (c > max) max = c;
}
if (max === 0) return [];
const winners: string[] = [];
for (const [id, c] of counts) {
if (c === max) winners.push(id);
}
return winners;
}