From 0b06158477751e76714e44d347c928a63fd72e09 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 9 Jul 2026 19:48:58 -0400 Subject: [PATCH] =?UTF-8?q?feat(polls):=20complete=20the=20poll=20lifecycl?= =?UTF-8?q?e=20=E2=80=94=20voting,=20undisclosed,=20end,=20spec-correct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- LOTUS_FEATURES.md | 17 +- .../message/content/PollContent.tsx | 497 ++++++++++-------- src/app/features/room/PollCreator.tsx | 38 +- src/app/features/room/RoomTimeline.tsx | 20 +- src/app/utils/poll.test.ts | 170 ++++++ src/app/utils/poll.ts | 166 ++++++ 6 files changed, 677 insertions(+), 231 deletions(-) create mode 100644 src/app/utils/poll.test.ts create mode 100644 src/app/utils/poll.ts diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index 095c32b60..11f4e8992 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -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 diff --git a/src/app/components/message/content/PollContent.tsx b/src/app/components/message/content/PollContent.tsx index 6f811d22c..4a2f90098 100644 --- a/src/app/components/message/content/PollContent.tsx +++ b/src/app/components/message/content/PollContent.tsx @@ -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, b: Set): 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; - myVotes: Set; - total: number; -}; - -function computeVotes( +function computePollState( mx: ReturnType, - 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(); + 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(); - const myVotes = new Set(); - 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; - 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(() => { - if (!roomId || !eventId) return { counts: new Map(), myVotes: new Set(), total: 0 }; - return computeVotes(mx, roomId, eventId, _isStable); + const [state, setState] = useState(() => { + if (!eventId || !parsed) return { tally: EMPTY_TALLY, isEnded: false }; + return computePollState(mx, room, eventId, parsed); }); + const [pending, setPending] = useState | 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 ( Poll (unreadable format) @@ -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(); 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) => { + 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('[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 ( - {`◉ Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`} + + {headerLabel} - {questionText} + {question} - + {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 ( + ); + })} + + + {/* Error */} {error && ( diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index fa78f9382..462fef090 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -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 ( - - ); + return ; if (mEvent.getType() === MessageEvent.RoomMessageEncrypted) return ( @@ -1449,11 +1443,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli {mEvent.isRedacted() ? ( ) : ( - + )} ); @@ -1506,11 +1496,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli {mEvent.isRedacted() ? ( ) : ( - + )} ); diff --git a/src/app/utils/poll.test.ts b/src/app/utils/poll.test.ts new file mode 100644 index 000000000..02656a192 --- /dev/null +++ b/src/app/utils/poll.test.ts @@ -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 +}); diff --git a/src/app/utils/poll.ts b/src/app/utils/poll.ts new file mode 100644 index 000000000..9fb2470d8 --- /dev/null +++ b/src/app/utils/poll.ts @@ -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): 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, 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[] { + 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, + maxSelections: number +): string[] { + const out: string[] = []; + const seen = new Set(); + 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; + /** answer ids the current user selected (from their latest response). */ + myVotes: Set; + /** 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(); + 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(); + const myVotes = new Set(); + 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[] { + 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; +}