/* eslint-disable @typescript-eslint/no-explicit-any */ import React, { useCallback, useEffect, useState } from 'react'; import { Box, Text } from 'folds'; import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations'; import { RoomEvent } from 'matrix-js-sdk'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; type PollTextValue = Array<{ body: string }> | string; function extractText(val: PollTextValue | undefined): string { if (!val) return ''; if (typeof val === 'string') return val; return val[0]?.body ?? ''; } 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[]; }; type VoteState = { counts: Map; myVote: string | null; total: number; }; function computeVotes( mx: ReturnType, roomId: string, eventId: string, _isStable: boolean, ): VoteState { const empty: VoteState = { counts: new Map(), myVote: null, 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', ); // Collect all response events; per-sender keep only latest const latestBySender = new Map(); const myUserId = mx.getSafeUserId(); const processRelations = (rels: typeof stableRels, stable: boolean) => { const events = rels?.getRelations() ?? []; for (const ev of events) { if (ev.isRedacted()) continue; const sender = ev.getSender(); if (!sender) continue; const content = ev.getContent(); let answerId: string | undefined; if (stable) { answerId = (content['m.selections'] as string[] | undefined)?.[0]; } else { answerId = ( (content['org.matrix.msc3381.poll.response'] as any)?.answers as string[] | undefined )?.[0]; } if (!answerId) continue; const ts = ev.getTs(); const existing = latestBySender.get(sender); if (!existing || ts > existing.ts) { latestBySender.set(sender, { ts, answerId }); } } }; processRelations(stableRels, true); processRelations(unstableRels, false); const counts = new Map(); let myVote: string | null = null; for (const [sender, { answerId }] of latestBySender) { counts.set(answerId, (counts.get(answerId) ?? 0) + 1); if (sender === myUserId) myVote = answerId; } return { counts, myVote, total: latestBySender.size }; } export function PollContent({ content, roomId, eventId, }: { content: Record; roomId?: string; eventId?: string; }) { const mx = useMatrixClient(); const _isStable = !!content['m.poll']; const poll = (content['m.poll'] ?? content['org.matrix.msc3381.poll.start']) as | PollData | undefined; const [votes, setVotes] = useState(() => { if (!roomId || !eventId) return { counts: new Map(), myVote: null, total: 0 }; return computeVotes(mx, roomId, eventId, _isStable); }); // Refresh votes whenever Relations events fire const refresh = useCallback(() => { if (!roomId || !eventId) return; setVotes(computeVotes(mx, roomId, eventId, _isStable)); }, [mx, roomId, eventId, _isStable]); useEffect(() => { if (!roomId || !eventId) return; const room = mx.getRoom(roomId); if (!room) return; 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', ); 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 ( (type === 'm.poll.response' || type === 'org.matrix.msc3381.poll.response') && relatesTo?.event_id === eventId ) { refresh(); } }; const room2 = mx.getRoom(roomId); room2?.on(RoomEvent.Timeline, onTimeline); 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); }; }, [mx, roomId, eventId, refresh]); if (!poll) { return ( Poll (unreadable format) ); } const questionText = extractText((poll.question as any)?.['m.text']) || (poll.question as any)?.body || 'Untitled poll'; const canVote = !!roomId && !!eventId; const { counts, myVote, total } = votes; const handleVote = (answerId: string) => { if (!roomId || !eventId) return; // Optimistic local update setVotes((prev) => { const next = new Map(prev.counts); if (prev.myVote) { const prevCount = next.get(prev.myVote) ?? 1; if (prevCount <= 1) next.delete(prev.myVote); else next.set(prev.myVote, prevCount - 1); } next.set(answerId, (next.get(answerId) ?? 0) + 1); return { counts: next, myVote: answerId, total: prev.myVote ? prev.total : prev.total + 1 }; }); if (_isStable) { mx.sendEvent(roomId, 'm.poll.response' as any, { 'm.relates_to': { rel_type: 'm.reference', event_id: eventId }, 'm.selections': [answerId], }); } 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: [answerId] }, }); } }; const answers = poll.answers ?? []; return ( ◉ Poll {questionText} {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 selected = myVote === id; const voteCount = counts.get(id) ?? 0; const pct = total > 0 ? Math.round((voteCount / total) * 100) : 0; return ( ); })} {total > 0 ? `${total} vote${total === 1 ? '' : 's'} — ` : ''} {canVote ? myVote ? 'click another to change' : 'click an option to vote' : 'voting not available'} ); }