check:prettier was not part of my gate routine, so formatting drift accumulated across the session's touched files (and a few older ones). Run prettier --write to bring the repo back to 'All matched files use Prettier code style!'. Formatting only — no logic changes. tsc/tests/build all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
494 lines
18 KiB
TypeScript
494 lines
18 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
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 } 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 as any)?.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' as any),
|
|
relations.getChildEventsForEvent(
|
|
eventId,
|
|
'm.reference',
|
|
'org.matrix.msc3381.poll.response' as any,
|
|
),
|
|
relations.getChildEventsForEvent(eventId, 'm.reference', 'm.poll.end' as any),
|
|
relations.getChildEventsForEvent(
|
|
eventId,
|
|
'm.reference',
|
|
'org.matrix.msc3381.poll.end' as any,
|
|
),
|
|
];
|
|
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).
|
|
mx.sendEvent(roomId, 'm.poll.response' as any, {
|
|
'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);
|
|
mx.sendEvent(roomId, 'm.poll.end' as any, {
|
|
'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);
|
|
};
|
|
|
|
const headerLabel = isEnded
|
|
? 'Poll closed · Final results'
|
|
: `Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`;
|
|
|
|
const footerNote = (() => {
|
|
if (isUndisclosed && !isEnded) {
|
|
return canVote
|
|
? 'Voting open · Results hidden until the poll ends'
|
|
: 'Results hidden until the poll ends';
|
|
}
|
|
const votesPart = total > 0 ? `${total} vote${total === 1 ? '' : 's'}` : 'No votes yet';
|
|
if (isEnded) return `${votesPart} · Poll closed`;
|
|
if (!canVote) return votesPart;
|
|
if (isMultiple) return `${votesPart} · Select up to ${maxSelections}`;
|
|
return `${votesPart} · ${myVotes.size > 0 ? 'Click to change' : 'Click to vote'}`;
|
|
})();
|
|
|
|
return (
|
|
<Box
|
|
data-poll-content
|
|
direction="Column"
|
|
gap="200"
|
|
style={{ maxWidth: '340px', paddingTop: '2px', paddingBottom: '4px' }}
|
|
>
|
|
<Text
|
|
as="div"
|
|
size="T200"
|
|
priority="300"
|
|
data-poll-content-label
|
|
style={{
|
|
fontWeight: 700,
|
|
letterSpacing: '0.12em',
|
|
textTransform: 'uppercase',
|
|
marginBottom: config.space.S100,
|
|
}}
|
|
>
|
|
<span aria-hidden>◉ </span>
|
|
{headerLabel}
|
|
</Text>
|
|
<Text size="T400" style={{ fontWeight: 600 }}>
|
|
{question}
|
|
</Text>
|
|
<Box
|
|
direction="Column"
|
|
gap="100"
|
|
style={{ marginTop: '2px' }}
|
|
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 pct = showResults && total > 0 ? Math.round((voteCount / total) * 100) : 0;
|
|
const isWinner = winners.has(id);
|
|
// Roving tabindex for the single-choice radiogroup; checkboxes stay tabbable.
|
|
const tabIndex = isMultiple ? 0 : selected || (myVotes.size === 0 && i === 0) ? 0 : -1;
|
|
return (
|
|
<React.Fragment key={id}>
|
|
<button
|
|
type="button"
|
|
data-poll-answer
|
|
data-selected={selected}
|
|
role={isMultiple ? 'checkbox' : 'radio'}
|
|
aria-checked={selected}
|
|
aria-disabled={!canVote}
|
|
aria-label={isWinner ? `${text}, winning answer` : undefined}
|
|
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 ? color.Primary.Container : color.SurfaceVariant.Container,
|
|
border: `${config.borderWidth.B300} solid ${
|
|
isWinner
|
|
? color.Success.Main
|
|
: selected
|
|
? color.Primary.Main
|
|
: color.SurfaceVariant.ContainerLine
|
|
}`,
|
|
lineHeight: 1.4,
|
|
textAlign: 'left',
|
|
cursor: canVote ? 'pointer' : 'default',
|
|
color: 'inherit',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: config.space.S100,
|
|
width: '100%',
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
transition: 'border-color 0.15s, background 0.15s',
|
|
}}
|
|
>
|
|
{showResults && total > 0 && (
|
|
<span
|
|
aria-hidden
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
right: 'auto',
|
|
width: `${pct}%`,
|
|
background: selected
|
|
? color.Primary.ContainerActive
|
|
: color.SurfaceVariant.ContainerActive,
|
|
pointerEvents: 'none',
|
|
transition: 'width 0.3s ease',
|
|
}}
|
|
/>
|
|
)}
|
|
<span
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: config.space.S200,
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
<span
|
|
aria-hidden
|
|
style={{
|
|
flexShrink: 0,
|
|
width: toRem(14),
|
|
height: toRem(14),
|
|
border: `${config.borderWidth.B300} solid ${
|
|
selected ? color.Primary.Main : color.Primary.ContainerLine
|
|
}`,
|
|
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 }}>
|
|
{text}
|
|
</Text>
|
|
{isWinner && (
|
|
<Icon
|
|
size="50"
|
|
src={Icons.Check}
|
|
style={{ flexShrink: 0, color: color.Success.Main }}
|
|
/>
|
|
)}
|
|
{showResults && total > 0 && (
|
|
<Text as="span" size="T200" priority="300" style={{ flexShrink: 0 }}>
|
|
{pct}%
|
|
</Text>
|
|
)}
|
|
</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} ${config.space.S100}` }}
|
|
>
|
|
{`Voted by ${(voters.get(id) ?? []).map((s) => getMemberName(room, s)).join(', ')}`}
|
|
</Text>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</Box>
|
|
{canShowVoters && (
|
|
<Box>
|
|
<Chip
|
|
variant={showVoters ? 'Primary' : 'SurfaceVariant'}
|
|
radii="Pill"
|
|
aria-pressed={showVoters}
|
|
onClick={() => setShowVoters((v) => !v)}
|
|
before={<Icon size="50" src={Icons.User} />}
|
|
>
|
|
<Text size="T200">{showVoters ? 'Hide voters' : 'Show who voted'}</Text>
|
|
</Chip>
|
|
</Box>
|
|
)}
|
|
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
|
<Text size="T200" priority="300" style={{ minWidth: 0 }}>
|
|
<i>{footerNote}</i>
|
|
</Text>
|
|
{canEnd &&
|
|
(confirmEnd ? (
|
|
<Box gap="100" shrink="No" alignItems="Center">
|
|
<Chip
|
|
variant="Critical"
|
|
radii="Pill"
|
|
aria-disabled={ending}
|
|
onClick={ending ? undefined : handleEndPoll}
|
|
>
|
|
<Text size="T200">{ending ? 'Ending…' : 'End poll'}</Text>
|
|
</Chip>
|
|
<Chip variant="Secondary" radii="Pill" onClick={() => setConfirmEnd(false)}>
|
|
<Text size="T200">Cancel</Text>
|
|
</Chip>
|
|
</Box>
|
|
) : (
|
|
<Chip
|
|
variant="SurfaceVariant"
|
|
radii="Pill"
|
|
onClick={() => setConfirmEnd(true)}
|
|
before={<Icon size="50" src={Icons.Cross} />}
|
|
>
|
|
<Text size="T200">End poll</Text>
|
|
</Chip>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|