Files
cinny/src/app/components/message/content/PollContent.tsx
T
Lotus CIandClaude Opus 5.5 f9172cc339
CI / Build & Quality Checks (pull_request) Successful in 2m53s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 14m12s
fix(polls): you can vote on a poll you just created (#284)
PollContent read mEvent.getId() once at render. For your own new poll
that is the local echo id (~!room:txn), and nothing re-rendered it when
the SDK swapped in the real $id, so the vote related to the local id
and the SDK threw synchronously (getPendingEvents under chronological
ordering): no vote was ever sent. Track the id via LocalEventIdReplaced
and Status, block voting while it is still local, and catch synchronous
throws. Adds the Playwright regression the issue asks for.

Fixes #284

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 19:54:01 -04:00

565 lines
21 KiB
TypeScript

import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
import { MatrixEvent, MatrixEventEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMemberName, sendRoomEvent } from '../../../utils/room';
import {
ParsedPoll,
PollResponse,
PollTally,
parsePollStart,
parseResponseAnswerIds,
resultsVisible,
tallyResponses,
validateSelections,
winningAnswerIds,
} from '../../../utils/poll';
const EMPTY_TALLY: PollTally = {
counts: new Map(),
voters: new Map(),
myVotes: new Set(),
total: 0,
};
type PollState = { tally: PollTally; isEnded: boolean };
function setsEqual(a: Set<string>, b: Set<string>): boolean {
if (a.size !== b.size) return false;
for (const x of a) if (!b.has(x)) return false;
return true;
}
function computePollState(
mx: ReturnType<typeof useMatrixClient>,
room: Room,
eventId: string,
parsed: ParsedPoll,
): PollState {
const relations = room.getUnfilteredTimelineSet().relations;
const myUserId = mx.getSafeUserId();
const validIds = new Set(parsed.answers.map((a) => a.id));
const getRels = (type: string): MatrixEvent[] =>
relations.getChildEventsForEvent(eventId, 'm.reference', type)?.getRelations() ?? [];
const endEvents = [...getRels('m.poll.end'), ...getRels('org.matrix.msc3381.poll.end')];
const respEvents = [
...getRels('m.poll.response'),
...getRels('org.matrix.msc3381.poll.response'),
];
// End state: prefer the SDK Poll model (validates ender = creator or redact PL,
// refilters responses). Fall back to an end from the poll's own creator, which is
// always valid, in case the model hasn't processed it yet.
const poll = room.polls.get(eventId);
let isEnded = poll?.isEnded ?? false;
let endTs = poll?.endEventId ? room.findEventById(poll.endEventId)?.getTs() : undefined;
if (!isEnded) {
const creator = room.findEventById(eventId)?.getSender();
for (const ev of endEvents) {
if (ev.isRedacted()) continue;
if (creator && ev.getSender() === creator) {
isEnded = true;
const t = ev.getTs();
if (endTs === undefined || t < endTs) endTs = t;
}
}
}
const responses: PollResponse[] = [];
for (const ev of respEvents) {
if (ev.isRedacted()) continue;
const sender = ev.getSender();
if (!sender) continue;
const ts = ev.getTs();
// Only responses cast on or before the end event are valid.
if (isEnded && endTs !== undefined && ts > endTs) continue;
const answerIds = validateSelections(
parseResponseAnswerIds(ev.getContent()),
validIds,
parsed.maxSelections,
);
responses.push({ sender, ts, answerIds });
}
return { tally: tallyResponses(responses, myUserId), isEnded };
}
export function PollContent({
mEvent,
room,
canRedact,
}: {
mEvent: MatrixEvent;
room: Room;
/** Whether the current user may redact in this room (gates the End-poll action). */
canRedact: boolean;
}) {
const mx = useMatrixClient();
const roomId = room.roomId;
// [Gitea #284] A poll you just created starts with its local-echo id
// (`~!room:txn`); the SDK swaps in the real `$event` id once it's sent but
// doesn't re-render us. Track the id so votes and the tally use the real one.
const [eventId, setEventId] = useState(() => mEvent.getId());
useEffect(() => {
const sync = () => setEventId(mEvent.getId());
sync();
mEvent.on(MatrixEventEvent.LocalEventIdReplaced, sync);
mEvent.on(MatrixEventEvent.Status, sync);
return () => {
mEvent.off(MatrixEventEvent.LocalEventIdReplaced, sync);
mEvent.off(MatrixEventEvent.Status, sync);
};
}, [mEvent]);
const isLocalEcho = !!eventId?.startsWith('~');
const senderId = mEvent.getSender();
const parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]);
const [state, setState] = useState<PollState>(() => {
if (!eventId || !parsed) return { tally: EMPTY_TALLY, isEnded: false };
return computePollState(mx, room, eventId, parsed);
});
const [pending, setPending] = useState<Set<string> | null>(null);
const [confirmEnd, setConfirmEnd] = useState(false);
const [ending, setEnding] = useState(false);
const [showVoters, setShowVoters] = useState(false);
const refresh = useCallback(() => {
if (!eventId || !parsed) return;
const next = computePollState(mx, room, eventId, parsed);
setState(next);
// Drop the optimistic selection only once our own vote is actually reflected,
// so an unrelated refresh (another user voting) can't revert our pending click.
setPending((prev) => (prev !== null && setsEqual(prev, next.tally.myVotes) ? null : prev));
}, [mx, room, eventId, parsed]);
useEffect(() => {
if (!eventId || !parsed) return undefined;
const relations = room.getUnfilteredTimelineSet().relations;
const relObjs = [
relations.getChildEventsForEvent(eventId, 'm.reference', 'm.poll.response'),
relations.getChildEventsForEvent(eventId, 'm.reference', 'org.matrix.msc3381.poll.response'),
relations.getChildEventsForEvent(eventId, 'm.reference', 'm.poll.end'),
relations.getChildEventsForEvent(eventId, 'm.reference', 'org.matrix.msc3381.poll.end'),
];
relObjs.forEach((r) => {
r?.on(RelationsEvent.Add, refresh);
r?.on(RelationsEvent.Remove, refresh);
r?.on(RelationsEvent.Redaction, refresh);
});
// The relations object for the first response/end may not exist yet at mount, and
// redactions (m.room.redaction) don't flow through the relations listeners for a
// post-mount response — the room timeline catches those.
const onTimeline = (ev: MatrixEvent) => {
const type = ev.getType();
if (type === 'm.room.redaction') {
refresh();
return;
}
const relatesTo = ev.getContent()['m.relates_to'] as { event_id?: string } | undefined;
if (
(type === 'm.poll.response' ||
type === 'org.matrix.msc3381.poll.response' ||
type === 'm.poll.end' ||
type === 'org.matrix.msc3381.poll.end') &&
relatesTo?.event_id === eventId
) {
refresh();
}
};
room.on(RoomEvent.Timeline, onTimeline);
const poll = room.polls.get(eventId);
poll?.on(PollEvent.Update, refresh);
poll?.on(PollEvent.Responses, refresh);
poll?.on(PollEvent.End, refresh);
room.on(PollEvent.New, refresh);
return () => {
relObjs.forEach((r) => {
r?.off(RelationsEvent.Add, refresh);
r?.off(RelationsEvent.Remove, refresh);
r?.off(RelationsEvent.Redaction, refresh);
});
room.off(RoomEvent.Timeline, onTimeline);
poll?.off(PollEvent.Update, refresh);
poll?.off(PollEvent.Responses, refresh);
poll?.off(PollEvent.End, refresh);
room.off(PollEvent.New, refresh);
};
}, [room, eventId, parsed, refresh]);
if (!parsed) {
return (
<Text priority="300">
<i>Poll (unreadable format)</i>
</Text>
);
}
const { tally, isEnded } = state;
const { isUndisclosed, maxSelections, question, answers } = parsed;
const isMultiple = maxSelections > 1;
const showResults = resultsVisible(isUndisclosed, isEnded);
const canVote = !!roomId && !!eventId && !isLocalEcho && !isEnded;
const canEnd =
!!eventId && !isLocalEcho && !isEnded && (senderId === mx.getUserId() || canRedact);
const myVotes = pending ?? tally.myVotes;
const { counts, total, voters } = tally;
const winners = isEnded && showResults ? new Set(winningAnswerIds(counts)) : new Set<string>();
// Voter identities are shown under the same rule as the counts: disclosed polls
// reveal them live, undisclosed polls only once ended.
const canShowVoters = showResults && total > 0;
const handleVote = (answerId: string) => {
if (!roomId || !eventId || !canVote) return;
const next = new Set(myVotes);
if (next.has(answerId)) {
next.delete(answerId);
} else if (isMultiple) {
if (next.size >= maxSelections) return; // enforce max_selections
next.add(answerId);
} else {
next.clear();
next.add(answerId);
}
setPending(next);
// Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader
// accepts both namespaces).
// The SDK can throw synchronously (e.g. a relation to a pending event).
try {
sendRoomEvent(mx, roomId, null, 'm.poll.response', {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.selections': Array.from(next),
}).catch(() => setPending(null));
} catch {
setPending(null);
}
};
const handleEndPoll = () => {
if (!roomId || !eventId || ending) return;
setEnding(true);
try {
sendRoomEvent(mx, roomId, null, 'm.poll.end', {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.poll.end': {},
'm.text': 'The poll has ended.',
})
.then(() => {
setConfirmEnd(false);
setEnding(false);
})
.catch(() => setEnding(false));
} catch {
setEnding(false);
}
};
// Radiogroup keyboard model (single-choice only): arrows move focus + selection.
const handleRadioKeyDown = (evt: KeyboardEvent<HTMLDivElement>) => {
if (isMultiple || !canVote) return;
const nav = ['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft', 'Home', 'End'];
if (!nav.includes(evt.key)) return;
evt.preventDefault();
const buttons = Array.from(
evt.currentTarget.querySelectorAll<HTMLButtonElement>('[data-poll-answer]'),
);
if (buttons.length === 0) return;
const current = buttons.findIndex((b) => b === document.activeElement);
let idx = current < 0 ? 0 : current;
if (evt.key === 'ArrowDown' || evt.key === 'ArrowRight') idx = (idx + 1) % buttons.length;
else if (evt.key === 'ArrowUp' || evt.key === 'ArrowLeft')
idx = (idx - 1 + buttons.length) % buttons.length;
else if (evt.key === 'Home') idx = 0;
else if (evt.key === 'End') idx = buttons.length - 1;
buttons[idx]?.focus();
handleVote(answers[idx].id);
};
// [Gitea #246] Type line under the "Poll" label.
const typeLabel = (() => {
if (isEnded) return 'Final results';
if (isUndisclosed) return 'Results hidden until the end';
return isMultiple ? `Pick up to ${maxSelections}` : 'Single choice';
})();
// Multiple choice counts people, not answers ("1 voter" who picked two).
const unit = isMultiple ? 'voter' : 'vote';
const footerNote = (() => {
const totalPart = total > 0 ? `${total} ${unit}${total === 1 ? '' : 's'}` : `No ${unit}s yet`;
if (isUndisclosed && !isEnded) {
return canVote
? `${myVotes.size > 0 ? 'You voted' : 'Voting open'} · results shown when the poll ends`
: 'Results shown when the poll ends';
}
if (isEnded) return `${totalPart} · Poll closed`;
if (!canVote) return totalPart;
return `${totalPart} · ${myVotes.size > 0 ? 'Select another answer to change your vote' : 'Select an answer to vote'}`;
})();
// Neutral tones derived from the theme's text colour, so they have contrast
// in every theme (the Container/ContainerLine tokens were near-invisible).
const muted = (pct: number) =>
`color-mix(in srgb, ${color.SurfaceVariant.OnContainer} ${pct}%, transparent)`;
return (
<Box
data-poll-content
direction="Column"
gap="300"
style={{
// Same width for every poll (the message body shrink-wraps its content).
width: toRem(460),
maxWidth: '100%',
margin: `${config.space.S100} 0`,
padding: config.space.S300,
borderRadius: config.radii.R400,
border: `${config.borderWidth.B300} solid ${muted(18)}`,
background: muted(4),
boxSizing: 'border-box',
}}
>
<Box direction="Column" gap="100">
<Box alignItems="Center" gap="200" wrap="Wrap" data-poll-content-label>
<Icon size="100" src={Icons.OrderList} style={{ opacity: 0.8 }} />
<Text as="span" size="L400" style={{ fontWeight: 600 }}>
Poll
</Text>
<Text as="span" size="T200" priority="300">
·
</Text>
<Text as="span" size="T200" priority="300">
{typeLabel}
</Text>
</Box>
<Text size="T400" style={{ fontWeight: 600, overflowWrap: 'anywhere' }}>
{question}
</Text>
</Box>
<Box
direction="Column"
gap="200"
role={isMultiple ? 'group' : 'radiogroup'}
aria-label={question}
onKeyDown={handleRadioKeyDown}
>
{answers.map((answer, i) => {
const id = answer.id;
const text = answer.text;
const selected = myVotes.has(id);
const voteCount = counts.get(id) ?? 0;
const showBar = showResults && total > 0;
const pct = showBar ? Math.round((voteCount / total) * 100) : 0;
const isWinner = winners.has(id);
const resultText = `${voteCount} ${voteCount === 1 ? 'vote' : 'votes'} · ${pct}%`;
// Roving tabindex for the single-choice radiogroup; checkboxes stay tabbable.
const tabIndex = isMultiple ? 0 : selected || (myVotes.size === 0 && i === 0) ? 0 : -1;
let barColor = muted(40);
if (isWinner) barColor = color.Success.Main;
else if (selected) barColor = color.Primary.Main;
let rowBorder = muted(22);
if (isWinner) rowBorder = color.Success.Main;
else if (selected) rowBorder = color.Primary.Main;
const labelParts = [text];
if (showBar) labelParts.push(resultText);
if (isWinner) labelParts.push('winning answer');
return (
<React.Fragment key={id}>
<button
type="button"
data-poll-answer
data-selected={selected}
data-winner={isWinner || undefined}
role={isMultiple ? 'checkbox' : 'radio'}
aria-checked={selected}
aria-disabled={!canVote}
aria-label={labelParts.join(', ')}
aria-describedby={
showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0
? `poll-voters-${eventId}-${id}`
: undefined
}
tabIndex={tabIndex}
onClick={canVote ? () => handleVote(id) : undefined}
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
background: selected ? muted(8) : 'transparent',
border: `${config.borderWidth.B300} solid ${rowBorder}`,
lineHeight: 1.4,
textAlign: 'left',
cursor: canVote ? 'pointer' : 'default',
color: 'inherit',
font: 'inherit',
display: 'flex',
flexDirection: 'column',
gap: config.space.S200,
width: '100%',
transition: 'border-color 0.15s, background 0.15s',
}}
>
<span
style={{
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
width: '100%',
}}
>
<span
aria-hidden
style={{
flexShrink: 0,
boxSizing: 'border-box',
width: toRem(18),
height: toRem(18),
border: `2px solid ${selected ? color.Primary.Main : muted(55)}`,
borderRadius: isMultiple ? config.radii.R300 : config.radii.Pill,
background: selected ? color.Primary.Main : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: color.Primary.OnMain,
transition: 'all 0.15s',
}}
>
{selected ? <Icon size="50" src={Icons.Check} /> : null}
</span>
<Text
as="span"
size="T300"
style={{ flexGrow: 1, minWidth: 0, overflowWrap: 'anywhere' }}
>
{text}
</Text>
{isWinner && (
<Text
as="span"
size="T200"
style={{
flexShrink: 0,
display: 'inline-flex',
alignItems: 'center',
gap: config.space.S100,
color: color.Success.Main,
fontWeight: 600,
}}
>
<Icon size="50" src={Icons.Star} filled />
Winner
</Text>
)}
{showBar && (
<Text
as="span"
size="T200"
priority="300"
style={{ flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}
>
{resultText}
</Text>
)}
</span>
{showBar && (
<span
aria-hidden
style={{
display: 'block',
width: '100%',
height: toRem(6),
borderRadius: config.radii.Pill,
background: muted(12),
overflow: 'hidden',
}}
>
<span
style={{
display: 'block',
height: '100%',
width: `${pct}%`,
borderRadius: config.radii.Pill,
background: barColor,
transition: 'width 0.3s ease',
}}
/>
</span>
)}
</button>
{showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0 && (
<Text
id={`poll-voters-${eventId}-${id}`}
size="T200"
priority="300"
style={{ padding: `0 ${config.space.S300}`, marginTop: `-${config.space.S100}` }}
>
{`Voted by ${(voters.get(id) ?? []).map((s) => getMemberName(room, s)).join(', ')}`}
</Text>
)}
</React.Fragment>
);
})}
</Box>
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200" wrap="Wrap">
<Text size="T200" priority="300" style={{ minWidth: 0, flex: '1 1 12rem' }}>
{footerNote}
</Text>
<Box gap="100" shrink="No" alignItems="Center" wrap="Wrap">
{canShowVoters && (
<Chip
variant={showVoters ? 'Primary' : 'SurfaceVariant'}
radii="Pill"
aria-pressed={showVoters}
onClick={() => setShowVoters((v) => !v)}
before={<Icon size="50" src={Icons.User} />}
>
<Text size="T200" style={{ whiteSpace: 'nowrap' }}>
{showVoters ? 'Hide voters' : 'Who voted'}
</Text>
</Chip>
)}
{canEnd &&
(confirmEnd ? (
<>
<Chip
variant="Critical"
radii="Pill"
aria-disabled={ending}
onClick={ending ? undefined : handleEndPoll}
>
<Text size="T200" style={{ whiteSpace: 'nowrap' }}>
{ending ? 'Ending…' : 'End poll'}
</Text>
</Chip>
<Chip variant="Secondary" radii="Pill" onClick={() => setConfirmEnd(false)}>
<Text size="T200" style={{ whiteSpace: 'nowrap' }}>
Cancel
</Text>
</Chip>
</>
) : (
<Chip
variant="SurfaceVariant"
radii="Pill"
onClick={() => setConfirmEnd(true)}
before={<Icon size="50" src={Icons.Cross} />}
>
<Text size="T200" style={{ whiteSpace: 'nowrap' }}>
End poll
</Text>
</Chip>
))}
</Box>
</Box>
</Box>
);
}