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
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
565 lines
21 KiB
TypeScript
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>
|
|
);
|
|
}
|