/* eslint-disable @typescript-eslint/no-explicit-any */ 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 { MatrixEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { getMemberName } from '../../../utils/room'; import { ParsedPoll, PollResponse, PollTally, parsePollStart, parseResponseAnswerIds, resultsVisible, tallyResponses, validateSelections, winningAnswerIds, } from '../../../utils/poll'; const EMPTY_TALLY: PollTally = { counts: new Map(), voters: new Map(), myVotes: new Set(), total: 0, }; 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; } function computePollState( mx: ReturnType, room: Room, eventId: string, parsed: ParsedPoll, ): PollState { const relations = room.getUnfilteredTimelineSet().relations; const myUserId = mx.getSafeUserId(); const validIds = new Set(parsed.answers.map((a) => a.id)); 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; if (creator && ev.getSender() === creator) { isEnded = true; const t = ev.getTs(); if (endTs === undefined || t < endTs) endTs = t; } } } 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({ mEvent, room, canRedact, }: { mEvent: MatrixEvent; room: Room; /** Whether the current user may redact in this room (gates the End-poll action). */ canRedact: boolean; }) { const mx = useMatrixClient(); const roomId = room.roomId; const eventId = mEvent.getId(); const senderId = mEvent.getSender(); const parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]); 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); const [showVoters, setShowVoters] = useState(false); const refresh = useCallback(() => { 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 (!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); }); // 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.end' || type === 'org.matrix.msc3381.poll.end') && relatesTo?.event_id === eventId ) { refresh(); } }; 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 () => { 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); }; }, [room, eventId, parsed, refresh]); if (!parsed) { return ( Poll (unreadable format) ); } const { tally, isEnded } = state; const { isUndisclosed, maxSelections, question, answers } = parsed; const isMultiple = maxSelections > 1; 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, voters } = tally; const winners = isEnded && showResults ? new Set(winningAnswerIds(counts)) : new Set(); // Voter identities are shown under the same rule as the counts: disclosed polls // reveal them live, undisclosed polls only once ended. const canShowVoters = showResults && total > 0; const handleVote = (answerId: string) => { if (!roomId || !eventId || !canVote) return; 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 { next.clear(); next.add(answerId); } 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 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 ( ◉ {headerLabel} {question} {answers.map((answer, i) => { const id = answer.id; const text = answer.text; const selected = myVotes.has(id); const voteCount = counts.get(id) ?? 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 ( {showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0 && ( {`Voted by ${(voters.get(id) ?? []).map((s) => getMemberName(room, s)).join(', ')}`} )} ); })} {canShowVoters && ( setShowVoters((v) => !v)} before={} > {showVoters ? 'Hide voters' : 'Show who voted'} )} {footerNote} {canEnd && (confirmEnd ? ( {ending ? 'Ending…' : 'End poll'} setConfirmEnd(false)}> Cancel ) : ( setConfirmEnd(true)} before={} > End poll ))} ); }