Files
cinny/src/app/components/message/content/PollContent.tsx
T
Lotus CIandClaude Opus 5.5 1e217592fc
CI / Build & Quality Checks (pull_request) Successful in 1m35s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
CI / Playwright smoke (e2e) (pull_request) Successful in 7m55s
fix(polls): readable poll card: counts, visible bars/indicators, no chip overflow (#246)
Rendering only; the vote logic (tally, optimistic votes, end poll, keyboard
radiogroup, voters) is unchanged.

- Every row shows "N votes · P%". Multiple choice looked broken as
  "100% / 100%" with no counts; the footer now says "voters" there.
- Results are a thin progress bar under each answer: accent for your pick,
  success for the winner, neutral otherwise. The old full-row fill was the
  same grey as the row, so a 100% answer just looked disabled.
- Radio and checkbox indicators are 18px with a 2px border in a colour mixed
  from the theme's text colour. Primary.ContainerLine was nearly invisible,
  especially in dark themes.
- The winner shows a star and "Winner" instead of a second check mark.
- Bordered card, 460px wide (max 100%, same width for every poll). The header
  is now "Poll · Single choice | Pick up to N | Results hidden until the end |
  Final results", replacing the letter-spaced "◉ POLL · …" line.
- Footer: plain muted text that wraps, and chips ("Who voted", "End poll")
  that never wrap. Previously the chip label broke onto two lines inside a
  one-line chip and spilled out of it. Reproduced before/after in Lotus
  Terminal at 150% zoom, a 360px phone, and dark at 125%.
- Lotus Terminal theme: data-winner gets its own green border rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 23:18:25 -04:00

541 lines
20 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, 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;
const eventId = mEvent.getId();
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 && !isEnded;
const canEnd = !!eventId && !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).
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));
};
const handleEndPoll = () => {
if (!roomId || !eventId || ending) return;
setEnding(true);
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));
};
// 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>
);
}