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