import { i18n } from '@lingui/core'; import { plural } from '@lingui/core/macro'; import { Plural, Trans, useLingui } from '@lingui/react/macro'; import type { mastodon } from 'masto'; import type { Ref } from 'preact'; import { useEffect, useRef, useState } from 'preact/hooks'; import haptics from '../utils/haptics'; import shortenNumber from '../utils/shorten-number'; import showToast from '../utils/show-toast'; import useTruncated from '../utils/useTruncated'; import EmojiText from './emoji-text'; import Icon from './icon'; import RelativeTime from './relative-time'; const POLL_OPTIONS_BATCH_SIZE = 40; interface PollProps { // The `mastodon.v1.Poll` type asserts `votesCount` is a `number`, but at // runtime older servers (and some federated payloads) can omit it. Override // to keep the defensive `votesCount = 0` default below valid. poll: Omit & { emojis?: mastodon.v1.CustomEmoji[]; votesCount?: number; }; lang?: string; readOnly?: boolean; refresh?: () => void | Promise; votePoll?: (choices: number[]) => void | Promise; } export default function Poll({ poll, lang, readOnly, refresh = () => {}, votePoll = () => {}, }: PollProps) { const { t } = useLingui(); const [uiState, setUIState] = useState<'default' | 'loading'>('default'); const [visibleOptionsCount, setVisibleOptionsCount] = useState( POLL_OPTIONS_BATCH_SIZE, ); const loadMoreRef = useRef(null); const { expired, expiresAt, multiple, options, ownVotes, voted, votersCount, votesCount = 0, emojis, } = poll; const expiresAtDate = !!expiresAt && new Date(expiresAt); // Update poll at point of expiry // NOTE: Disable this because setTimeout runs immediately if delay is too large // https://stackoverflow.com/a/56718027/20838 // useEffect(() => { // let timeout; // if (!expired && expiresAtDate) { // const ms = expiresAtDate.getTime() - Date.now() + 1; // +1 to give it a little buffer // if (ms > 0) { // timeout = setTimeout(() => { // setUIState('loading'); // (async () => { // // await refresh(); // setUIState('default'); // })(); // }, ms); // } // } // return () => { // clearTimeout(timeout); // }; // }, [expired, expiresAtDate]); const pollVotesCount = multiple ? votersCount || votesCount : votesCount; let roundPrecision = 0; if (pollVotesCount <= 1000) { roundPrecision = 0; } else if (pollVotesCount <= 10000) { roundPrecision = 1; } else if (pollVotesCount <= 100000) { roundPrecision = 2; } const [showResults, setShowResults] = useState(false); const optionsHaveVoteCounts = options.every((o) => o.votesCount !== null); const resultsView = (showResults && optionsHaveVoteCounts) || voted || expired; const [selectedOptions, setSelectedOptions] = useState< number[] | number | null >(multiple ? [] : null); useEffect(() => { if (!loadMoreRef.current) return undefined; if (visibleOptionsCount >= options.length) return undefined; const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) { setVisibleOptionsCount((prev) => Math.min(prev + POLL_OPTIONS_BATCH_SIZE, options.length), ); } }, { threshold: 0.1 }, ); observer.observe(loadMoreRef.current); return () => observer.disconnect(); }, [visibleOptionsCount, options.length]); useEffect(() => { setVisibleOptionsCount(POLL_OPTIONS_BATCH_SIZE); }, [resultsView, options.length]); const voteOptionsSelectionCount = Array.isArray(selectedOptions) ? selectedOptions.length : selectedOptions !== null ? 1 : 0; const [showPollInfo, setShowPollInfo] = useState(false); const ref = useTruncated({ onTruncated: setShowPollInfo, }); return (
{resultsView ? ( <>
}> {options.slice(0, visibleOptionsCount).map((option, i) => { const { title, votesCount: optionVotesCountRaw } = option; const optionVotesCount = optionVotesCountRaw ?? 0; const ratio = pollVotesCount ? optionVotesCount / pollVotesCount : 0; const percentage = ratio ? ratio.toLocaleString(i18n.locale || undefined, { style: 'percent', maximumFractionDigits: roundPrecision, }) : '0%'; const isLeading = optionVotesCount > 0 && optionVotesCount === Math.max(...options.map((o) => o.votesCount ?? 0)); return (
{voted && ownVotes?.includes(i) && ( <> {' '} )} {percentage}
); })} {visibleOptionsCount < options.length && (
)}
{!expired && !voted && (
{' '}
{showPollInfo && ( )}
)} ) : (
{ e.preventDefault(); const choices: number[] = Array.isArray(selectedOptions) ? selectedOptions : selectedOptions !== null ? [selectedOptions] : []; if (!choices.length) return; setUIState('loading'); void (async () => { try { await votePoll(choices); } catch (err) { console.error(err); showToast(t`Unable to vote in poll`); } finally { setUIState('default'); } })(); }} >
}> {options.slice(0, visibleOptionsCount).map((option, i) => { const { title } = option; const isSelected = Array.isArray(selectedOptions) ? selectedOptions.includes(i) : selectedOptions === i; return (
); })} {visibleOptionsCount < options.length && (
)}
{' '}
{showPollInfo && (multiple && voteOptionsSelectionCount > 0 ? ( {voteOptionsSelectionCount}{' '} / {options.length} ) : ( ))}
)}

{(expired || voted) && showPollInfo && ( <> {' '} •{' '} )} {shortenNumber(votesCount)} {' '} vote } other={ {shortenNumber(votesCount)} {' '} votes } /> {!!votersCount && votersCount !== votesCount && ( <> {' '} •{' '} {shortenNumber(votersCount)} {' '} voter } other={ {shortenNumber(votersCount)} {' '} voters } /> )}{' '} •{' '} {expired ? ( expiresAtDate ? ( Ended ) : ( t`Ended` ) ) : expiresAtDate ? ( Ending ) : ( t`Ending` )} {!voted && !expired && !readOnly && optionsHaveVoteCounts && ( )} {!expired && !readOnly && ( )}

); }