check:prettier was not part of my gate routine, so formatting drift accumulated across the session's touched files (and a few older ones). Run prettier --write to bring the repo back to 'All matched files use Prettier code style!'. Formatting only — no logic changes. tsc/tests/build all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
174 lines
6.4 KiB
TypeScript
174 lines
6.4 KiB
TypeScript
/* 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>;
|
|
/** answerId → the sender ids who selected it (latest response per sender). Always
|
|
* consistent with `counts` — `voters.get(id).length === counts.get(id)`. */
|
|
voters: Map<string, string[]>;
|
|
/** 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 voters = new Map<string, string[]>();
|
|
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);
|
|
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, 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;
|
|
}
|