diff --git a/src/app/components/message/content/PollContent.tsx b/src/app/components/message/content/PollContent.tsx index 16ecd31ed..898380417 100644 --- a/src/app/components/message/content/PollContent.tsx +++ b/src/app/components/message/content/PollContent.tsx @@ -1,5 +1,6 @@ -import React, { useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Box, Text } from 'folds'; +import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; type PollTextValue = Array<{ body: string }> | string; @@ -22,6 +23,75 @@ type PollData = { 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, @@ -32,13 +102,56 @@ export function PollContent({ eventId?: string; }) { const mx = useMatrixClient(); - const [myVote, setMyVote] = useState(null); 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); + 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); + }; + }, [mx, roomId, eventId, refresh]); + if (!poll) { return ( @@ -53,10 +166,21 @@ export function PollContent({ 'Untitled poll'; const canVote = !!roomId && !!eventId; + const { counts, myVote, total } = votes; const handleVote = (answerId: string) => { if (!roomId || !eventId) return; - setMyVote(answerId); + // 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 }, @@ -70,6 +194,12 @@ export function PollContent({ } }; + const answers = poll.answers ?? []; + const maxVotes = answers.reduce((m, a, i) => { + const id = a['m.id'] ?? a.id ?? String(i); + return Math.max(m, counts.get(id) ?? 0); + }, 0); + return ( - {(poll.answers ?? []).map((answer, i) => { + {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 ( ); })} - {canVote ? ( - - {myVote ? 'Vote cast — click another to change' : 'Click an option to vote'} - - ) : ( - - Open in Element to vote - - )} + + + {total > 0 ? `${total} vote${total === 1 ? '' : 's'} — ` : ''} + {canVote + ? myVote + ? 'click another to change' + : 'click an option to vote' + : 'voting not available'} + + ); }