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:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user