/* 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; /** answerId → the sender ids who selected it (latest response per sender). Always * consistent with `counts` — `voters.get(id).length === counts.get(id)`. */ voters: 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 voters = 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); const list = voters.get(id); if (list) list.push(sender); else voters.set(id, [sender]); if (sender === myUserId) myVotes.add(id); } } return { counts, voters, 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; }