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:
@@ -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