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
|
### Poll Creation
|
||||||
|
|
||||||
- `PollCreator.tsx` creates stable `m.poll.start` events
|
- `PollCreator.tsx` creates stable `m.poll.start` events (with a text fallback body for non-poll clients)
|
||||||
- Supports 2 to 10 answer options
|
- Supports 2 to 10 answer options; single-choice or multiple-choice
|
||||||
- Supports both single-choice and multiple-choice modes
|
- **Results visibility toggle** — _Show live results_ (disclosed, default) vs _Hidden until ended_ (undisclosed)
|
||||||
- Accessible via the `Icons.OrderList` button in the composer toolbar
|
- 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
|
### Voice Message Playback Speed
|
||||||
|
|
||||||
|
|||||||
@@ -1,179 +1,183 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Box, color, config, Icon, Icons, Text, toRem } from 'folds';
|
import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
|
||||||
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
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 { 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 {
|
type PollState = { tally: PollTally; isEnded: boolean };
|
||||||
if (!val) return '';
|
|
||||||
if (typeof val === 'string') return val;
|
function setsEqual(a: Set<string>, b: Set<string>): boolean {
|
||||||
return val[0]?.body ?? '';
|
if (a.size !== b.size) return false;
|
||||||
|
for (const x of a) if (!b.has(x)) return false;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PollAnswer = {
|
function computePollState(
|
||||||
'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(
|
|
||||||
mx: ReturnType<typeof useMatrixClient>,
|
mx: ReturnType<typeof useMatrixClient>,
|
||||||
roomId: string,
|
room: Room,
|
||||||
eventId: string,
|
eventId: string,
|
||||||
_isStable: boolean,
|
parsed: ParsedPoll
|
||||||
): VoteState {
|
): PollState {
|
||||||
const empty: VoteState = { counts: new Map(), myVotes: new Set(), total: 0 };
|
const relations = room.getUnfilteredTimelineSet().relations;
|
||||||
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[] }>();
|
|
||||||
const myUserId = mx.getSafeUserId();
|
const myUserId = mx.getSafeUserId();
|
||||||
|
const validIds = new Set(parsed.answers.map((a) => a.id));
|
||||||
|
|
||||||
const processRelations = (rels: typeof stableRels, stable: boolean) => {
|
const getRels = (type: string): MatrixEvent[] =>
|
||||||
const events = rels?.getRelations() ?? [];
|
relations.getChildEventsForEvent(eventId, 'm.reference', type as any)?.getRelations() ?? [];
|
||||||
for (const ev of events) {
|
|
||||||
|
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;
|
if (ev.isRedacted()) continue;
|
||||||
const sender = ev.getSender();
|
if (creator && ev.getSender() === creator) {
|
||||||
if (!sender) continue;
|
isEnded = true;
|
||||||
const content = ev.getContent();
|
const t = ev.getTs();
|
||||||
let answerIds: string[] = [];
|
if (endTs === undefined || t < endTs) endTs = t;
|
||||||
if (stable) {
|
|
||||||
answerIds = (content['m.selections'] as string[] | undefined) ?? [];
|
|
||||||
} else {
|
|
||||||
answerIds =
|
|
||||||
((content['org.matrix.msc3381.poll.response'] as any)?.answers as string[] | undefined) ??
|
|
||||||
[];
|
|
||||||
}
|
}
|
||||||
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({
|
export function PollContent({
|
||||||
content,
|
mEvent,
|
||||||
roomId,
|
room,
|
||||||
eventId,
|
canRedact,
|
||||||
}: {
|
}: {
|
||||||
content: Record<string, unknown>;
|
mEvent: MatrixEvent;
|
||||||
roomId?: string;
|
room: Room;
|
||||||
eventId?: string;
|
/** Whether the current user may redact in this room (gates the End-poll action). */
|
||||||
|
canRedact: boolean;
|
||||||
}) {
|
}) {
|
||||||
const mx = useMatrixClient();
|
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
|
const parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]);
|
||||||
| PollData
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
const [votes, setVotes] = useState<VoteState>(() => {
|
const [state, setState] = useState<PollState>(() => {
|
||||||
if (!roomId || !eventId) return { counts: new Map(), myVotes: new Set(), total: 0 };
|
if (!eventId || !parsed) return { tally: EMPTY_TALLY, isEnded: false };
|
||||||
return computeVotes(mx, roomId, eventId, _isStable);
|
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(() => {
|
const refresh = useCallback(() => {
|
||||||
if (!roomId || !eventId) return;
|
if (!eventId || !parsed) return;
|
||||||
setVotes(computeVotes(mx, roomId, eventId, _isStable));
|
const next = computePollState(mx, room, eventId, parsed);
|
||||||
}, [mx, roomId, eventId, _isStable]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!roomId || !eventId) return;
|
if (!eventId || !parsed) return undefined;
|
||||||
const room = mx.getRoom(roomId);
|
const relations = room.getUnfilteredTimelineSet().relations;
|
||||||
if (!room) return;
|
const relObjs = [
|
||||||
const timelineSet = room.getUnfilteredTimelineSet();
|
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(
|
// The relations object for the first response/end may not exist yet at mount, and
|
||||||
eventId,
|
// redactions (m.room.redaction) don't flow through the relations listeners for a
|
||||||
'm.reference',
|
// post-mount response — the room timeline catches those.
|
||||||
'm.poll.response',
|
const onTimeline = (ev: MatrixEvent) => {
|
||||||
);
|
const type = ev.getType();
|
||||||
const unstableRels = timelineSet.relations.getChildEventsForEvent(
|
if (type === 'm.room.redaction') {
|
||||||
eventId,
|
refresh();
|
||||||
'org.matrix.msc3381.poll.response' as any,
|
return;
|
||||||
'org.matrix.msc3381.poll.response',
|
}
|
||||||
);
|
const relatesTo = ev.getContent()['m.relates_to'] as { event_id?: string } | undefined;
|
||||||
|
|
||||||
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'];
|
|
||||||
if (
|
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
|
relatesTo?.event_id === eventId
|
||||||
) {
|
) {
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const room2 = mx.getRoom(roomId);
|
room.on(RoomEvent.Timeline, onTimeline);
|
||||||
room2?.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 () => {
|
return () => {
|
||||||
stableRels?.off(RelationsEvent.Add, refresh);
|
relObjs.forEach((r) => {
|
||||||
stableRels?.off(RelationsEvent.Remove, refresh);
|
r?.off(RelationsEvent.Add, refresh);
|
||||||
stableRels?.off(RelationsEvent.Redaction, refresh);
|
r?.off(RelationsEvent.Remove, refresh);
|
||||||
unstableRels?.off(RelationsEvent.Add, refresh);
|
r?.off(RelationsEvent.Redaction, refresh);
|
||||||
unstableRels?.off(RelationsEvent.Remove, refresh);
|
});
|
||||||
unstableRels?.off(RelationsEvent.Redaction, refresh);
|
room.off(RoomEvent.Timeline, onTimeline);
|
||||||
room2?.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 (
|
return (
|
||||||
<Text priority="300">
|
<Text priority="300">
|
||||||
<i>Poll (unreadable format)</i>
|
<i>Poll (unreadable format)</i>
|
||||||
@@ -181,61 +185,93 @@ export function PollContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const questionText =
|
const { tally, isEnded } = state;
|
||||||
extractText((poll.question as any)?.['m.text']) ||
|
const { isUndisclosed, maxSelections, question, answers } = parsed;
|
||||||
(poll.question as any)?.body ||
|
|
||||||
'Untitled poll';
|
|
||||||
|
|
||||||
const canVote = !!roomId && !!eventId;
|
|
||||||
const maxSelections = (poll as any).max_selections ?? 1;
|
|
||||||
const isMultiple = maxSelections > 1;
|
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) => {
|
const handleVote = (answerId: string) => {
|
||||||
if (!roomId || !eventId) return;
|
if (!roomId || !eventId || !canVote) return;
|
||||||
|
|
||||||
const newVotes = new Set(myVotes);
|
const next = new Set(myVotes);
|
||||||
if (newVotes.has(answerId)) {
|
if (next.has(answerId)) {
|
||||||
newVotes.delete(answerId);
|
next.delete(answerId);
|
||||||
|
} else if (isMultiple) {
|
||||||
|
if (next.size >= maxSelections) return; // enforce max_selections
|
||||||
|
next.add(answerId);
|
||||||
} else {
|
} else {
|
||||||
if (!isMultiple) newVotes.clear();
|
next.clear();
|
||||||
newVotes.add(answerId);
|
next.add(answerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimistic local update
|
setPending(next);
|
||||||
setVotes((prev) => {
|
// Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader
|
||||||
const next = new Map(prev.counts);
|
// accepts both namespaces).
|
||||||
// Remove all old vote counts for this user
|
mx.sendEvent(roomId, 'm.poll.response' as any, {
|
||||||
for (const id of prev.myVotes) {
|
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||||
const c = next.get(id) ?? 1;
|
'm.selections': Array.from(next),
|
||||||
if (c <= 1) next.delete(id);
|
}).catch(() => setPending(null));
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -256,34 +292,55 @@ export function PollContent({
|
|||||||
marginBottom: config.space.S100,
|
marginBottom: config.space.S100,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{`◉ Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`}
|
<span aria-hidden>◉ </span>
|
||||||
|
{headerLabel}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T400" style={{ fontWeight: 600 }}>
|
<Text size="T400" style={{ fontWeight: 600 }}>
|
||||||
{questionText}
|
{question}
|
||||||
</Text>
|
</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) => {
|
{answers.map((answer, i) => {
|
||||||
const text =
|
const id = answer.id;
|
||||||
extractText((answer as any)['m.text']) ||
|
const text = answer.text;
|
||||||
(answer as any)['org.matrix.msc3381.poll.answer']?.body ||
|
|
||||||
`Option ${i + 1}`;
|
|
||||||
const id = answer['m.id'] ?? answer.id ?? String(i);
|
|
||||||
const selected = myVotes.has(id);
|
const selected = myVotes.has(id);
|
||||||
const voteCount = counts.get(id) ?? 0;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
type="button"
|
type="button"
|
||||||
data-poll-answer
|
data-poll-answer
|
||||||
data-selected={selected}
|
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}
|
onClick={canVote ? () => handleVote(id) : undefined}
|
||||||
style={{
|
style={{
|
||||||
padding: `${config.space.S200} ${config.space.S300}`,
|
padding: `${config.space.S200} ${config.space.S300}`,
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
background: selected ? color.Primary.Container : color.SurfaceVariant.Container,
|
background: selected ? color.Primary.Container : color.SurfaceVariant.Container,
|
||||||
border: `${config.borderWidth.B300} solid ${
|
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,
|
lineHeight: 1.4,
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
@@ -298,7 +355,7 @@ export function PollContent({
|
|||||||
transition: 'border-color 0.15s, background 0.15s',
|
transition: 'border-color 0.15s, background 0.15s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{total > 0 && (
|
{showResults && total > 0 && (
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
style={{
|
style={{
|
||||||
@@ -323,6 +380,7 @@ export function PollContent({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
aria-hidden
|
||||||
style={{
|
style={{
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
width: toRem(14),
|
width: toRem(14),
|
||||||
@@ -339,12 +397,19 @@ export function PollContent({
|
|||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selected && isMultiple ? <Icon size="50" src={Icons.Check} /> : null}
|
{selected ? <Icon size="50" src={Icons.Check} /> : null}
|
||||||
</span>
|
</span>
|
||||||
<Text as="span" size="T300" style={{ flexGrow: 1 }}>
|
<Text as="span" size="T300" style={{ flexGrow: 1 }}>
|
||||||
{text}
|
{text}
|
||||||
</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 }}>
|
<Text as="span" size="T200" priority="300" style={{ flexShrink: 0 }}>
|
||||||
{pct}%
|
{pct}%
|
||||||
</Text>
|
</Text>
|
||||||
@@ -354,18 +419,36 @@ export function PollContent({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
<Text size="T200" priority="300" style={{ marginTop: '2px' }}>
|
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||||
<i>
|
<Text size="T200" priority="300" style={{ minWidth: 0 }}>
|
||||||
{total > 0 ? `${total} vote${total === 1 ? '' : 's'} · ` : ''}
|
<i>{footerNote}</i>
|
||||||
{canVote
|
</Text>
|
||||||
? isMultiple
|
{canEnd &&
|
||||||
? 'Select all that apply'
|
(confirmEnd ? (
|
||||||
: myVotes.size > 0
|
<Box gap="100" shrink="No" alignItems="Center">
|
||||||
? 'Click to change'
|
<Chip
|
||||||
: 'Click to vote'
|
variant="Critical"
|
||||||
: 'Voting not available'}
|
radii="Pill"
|
||||||
</i>
|
aria-disabled={ending}
|
||||||
</Text>
|
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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
|||||||
const [question, setQuestion] = useState('');
|
const [question, setQuestion] = useState('');
|
||||||
const [options, setOptions] = useState<string[]>(['', '']);
|
const [options, setOptions] = useState<string[]>(['', '']);
|
||||||
const [isMultiple, setIsMultiple] = useState(false);
|
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 [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -73,14 +76,20 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
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, {
|
await mx.sendEvent(roomId, 'm.poll.start' as any, {
|
||||||
'm.poll': {
|
'm.poll': {
|
||||||
question: { 'm.text': trimmedQuestion },
|
question: { 'm.text': trimmedQuestion },
|
||||||
answers: filledOptions.map((o, i) => ({ 'm.id': `${i}`, 'm.text': o })),
|
answers: filledOptions.map((o, i) => ({ 'm.id': `${i}`, 'm.text': o })),
|
||||||
max_selections: isMultiple ? filledOptions.length : 1,
|
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',
|
msgtype: 'm.text',
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
@@ -216,6 +225,31 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
</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 */}
|
||||||
{error && (
|
{error && (
|
||||||
<Text size="T300" style={{ color: color.Critical.Main }}>
|
<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() === 'm.poll.start' ||
|
||||||
mEvent.getType() === 'org.matrix.msc3381.poll.start'
|
mEvent.getType() === 'org.matrix.msc3381.poll.start'
|
||||||
)
|
)
|
||||||
return (
|
return <PollContent mEvent={mEvent} room={room} canRedact={canRedact} />;
|
||||||
<PollContent
|
|
||||||
content={mEvent.getContent()}
|
|
||||||
roomId={room.roomId}
|
|
||||||
eventId={mEvent.getId() ?? undefined}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
|
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
|
||||||
return (
|
return (
|
||||||
<Text>
|
<Text>
|
||||||
@@ -1449,11 +1443,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
{mEvent.isRedacted() ? (
|
{mEvent.isRedacted() ? (
|
||||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||||
) : (
|
) : (
|
||||||
<PollContent
|
<PollContent mEvent={mEvent} room={room} canRedact={canRedact} />
|
||||||
content={mEvent.getContent()}
|
|
||||||
roomId={room.roomId}
|
|
||||||
eventId={mEvent.getId() ?? undefined}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Message>
|
</Message>
|
||||||
);
|
);
|
||||||
@@ -1506,11 +1496,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
{mEvent.isRedacted() ? (
|
{mEvent.isRedacted() ? (
|
||||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||||
) : (
|
) : (
|
||||||
<PollContent
|
<PollContent mEvent={mEvent} room={room} canRedact={canRedact} />
|
||||||
content={mEvent.getContent()}
|
|
||||||
roomId={room.roomId}
|
|
||||||
eventId={mEvent.getId() ?? undefined}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Message>
|
</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