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 e30212f409
commit a2331eab1f
@@ -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<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({
content,
roomId,
@@ -32,13 +102,56 @@ export function PollContent({
eventId?: string;
}) {
const mx = useMatrixClient();
const [myVote, setMyVote] = useState<string | null>(null);
const isStable = !!content['m.poll'];
const poll = (
content['m.poll'] ?? content['org.matrix.msc3381.poll.start']
) 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) {
return (
<Text style={{ opacity: 0.6 }}>
@@ -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 (
<Box
direction="Column"
@@ -94,13 +224,15 @@ export function PollContent({
{questionText}
</Text>
<Box direction="Column" gap="100" style={{ marginTop: '2px' }}>
{(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 (
<button
key={id}
@@ -117,28 +249,54 @@ export function PollContent({
cursor: canVote ? 'pointer' : 'default',
color: 'var(--text-primary)',
display: 'flex',
alignItems: 'center',
gap: '8px',
flexDirection: 'column',
gap: '4px',
width: '100%',
position: 'relative',
overflow: 'hidden',
}}
>
<span style={{ flexGrow: 1 }}>{text}</span>
{selected && (
<span style={{ opacity: 0.8, fontSize: '1rem', flexShrink: 0 }}></span>
{/* vote progress bar */}
{total > 0 && (
<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>
);
})}
</Box>
{canVote ? (
<Text size="T200" style={{ opacity: 0.5, marginTop: '2px' }}>
<i>{myVote ? 'Vote cast — click another to change' : 'Click an option to vote'}</i>
</Text>
) : (
<Text size="T200" style={{ opacity: 0.4, marginTop: '2px' }}>
<i>Open in Element to vote</i>
</Text>
)}
<Text size="T200" style={{ opacity: 0.5, marginTop: '2px' }}>
<i>
{total > 0 ? `${total} vote${total === 1 ? '' : 's'}` : ''}
{canVote
? myVote
? 'click another to change'
: 'click an option to vote'
: 'voting not available'}
</i>
</Text>
</Box>
);
}