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
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
541 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|