feat: poll vote counting — show tallies, persist vote across refreshes

PollContent now:
- Reads existing m.poll.response / org.matrix.msc3381.poll.response events
  from the room timeline on mount to restore vote state across refreshes
- Counts votes per answer (per-sender latest-wins deduplication)
- Shows percentage bars and vote totals in real time
- Subscribes to RelationsEvent.Add/Remove/Redaction so counts update live
  when other users vote without requiring a page reload
- Optimistic local update keeps the UI snappy while the send request flies
This commit is contained in:
root
2026-05-15 15:21:20 -04:00
parent d81c3c8721
commit c0c0b54280
@@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text } from 'folds'; import { Box, Text } from 'folds';
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
type PollTextValue = Array<{ body: string }> | string; type PollTextValue = Array<{ body: string }> | string;
@@ -22,6 +23,75 @@ type PollData = {
answers?: PollAnswer[]; answers?: PollAnswer[];
}; };
type VoteState = {
counts: Map<string, number>;
myVote: string | null;
total: number;
};
function computeVotes(
mx: ReturnType<typeof useMatrixClient>,
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<string, { ts: number; answerId: string }>();
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<string, number>();
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({ export function PollContent({
content, content,
roomId, roomId,
@@ -32,13 +102,56 @@ export function PollContent({
eventId?: string; eventId?: string;
}) { }) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [myVote, setMyVote] = useState<string | null>(null);
const isStable = !!content['m.poll']; const isStable = !!content['m.poll'];
const poll = ( const poll = (
content['m.poll'] ?? content['org.matrix.msc3381.poll.start'] content['m.poll'] ?? content['org.matrix.msc3381.poll.start']
) as PollData | undefined; ) as PollData | undefined;
const [votes, setVotes] = useState<VoteState>(() => {
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) { if (!poll) {
return ( return (
<Text style={{ opacity: 0.6 }}> <Text style={{ opacity: 0.6 }}>
@@ -53,10 +166,21 @@ export function PollContent({
'Untitled poll'; 'Untitled poll';
const canVote = !!roomId && !!eventId; const canVote = !!roomId && !!eventId;
const { counts, myVote, total } = votes;
const handleVote = (answerId: string) => { const handleVote = (answerId: string) => {
if (!roomId || !eventId) return; 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) { if (isStable) {
mx.sendEvent(roomId, 'm.poll.response' as any, { mx.sendEvent(roomId, 'm.poll.response' as any, {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId }, '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 ( return (
<Box <Box
direction="Column" direction="Column"
@@ -94,13 +224,15 @@ export function PollContent({
{questionText} {questionText}
</Text> </Text>
<Box direction="Column" gap="100" style={{ marginTop: '2px' }}> <Box direction="Column" gap="100" style={{ marginTop: '2px' }}>
{(poll.answers ?? []).map((answer, i) => { {answers.map((answer, i) => {
const text = const text =
extractText((answer as any)['m.text']) || extractText((answer as any)['m.text']) ||
(answer as any)['org.matrix.msc3381.poll.answer']?.body || (answer as any)['org.matrix.msc3381.poll.answer']?.body ||
`Option ${i + 1}`; `Option ${i + 1}`;
const id = answer['m.id'] ?? answer.id ?? String(i); const id = answer['m.id'] ?? answer.id ?? String(i);
const selected = myVote === id; const selected = myVote === id;
const voteCount = counts.get(id) ?? 0;
const pct = total > 0 ? Math.round((voteCount / total) * 100) : 0;
return ( return (
<button <button
key={id} key={id}
@@ -117,28 +249,54 @@ export function PollContent({
cursor: canVote ? 'pointer' : 'default', cursor: canVote ? 'pointer' : 'default',
color: 'var(--text-primary)', color: 'var(--text-primary)',
display: 'flex', display: 'flex',
alignItems: 'center', flexDirection: 'column',
gap: '8px', gap: '4px',
width: '100%', width: '100%',
position: 'relative',
overflow: 'hidden',
}} }}
> >
<span style={{ flexGrow: 1 }}>{text}</span> {/* vote progress bar */}
{selected && ( {total > 0 && (
<span style={{ opacity: 0.8, fontSize: '1rem', flexShrink: 0 }}></span> <span
aria-hidden
style={{
position: 'absolute',
inset: 0,
width: `${pct}%`,
background: selected
? 'rgba(255,255,255,0.10)'
: 'rgba(255,255,255,0.05)',
borderRadius: '8px',
pointerEvents: 'none',
}}
/>
)} )}
<span style={{ display: 'flex', alignItems: 'center', gap: '8px', position: 'relative' }}>
<span style={{ flexGrow: 1 }}>{text}</span>
{selected && (
<span style={{ opacity: 0.8, fontSize: '1rem', flexShrink: 0 }}></span>
)}
{total > 0 && (
<span style={{ opacity: 0.6, fontSize: '0.78rem', flexShrink: 0 }}>
{pct}%
</span>
)}
</span>
</button> </button>
); );
})} })}
</Box> </Box>
{canVote ? ( <Text size="T200" style={{ opacity: 0.5, marginTop: '2px' }}>
<Text size="T200" style={{ opacity: 0.5, marginTop: '2px' }}> <i>
<i>{myVote ? 'Vote cast — click another to change' : 'Click an option to vote'}</i> {total > 0 ? `${total} vote${total === 1 ? '' : 's'}` : ''}
</Text> {canVote
) : ( ? myVote
<Text size="T200" style={{ opacity: 0.4, marginTop: '2px' }}> ? 'click another to change'
<i>Open in Element to vote</i> : 'click an option to vote'
</Text> : 'voting not available'}
)} </i>
</Text>
</Box> </Box>
); );
} }