import type { FC } from '../../../lib/teact/teact'; import type React from '../../../lib/teact/teact'; import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, } from '../../../lib/teact/teact'; import { getActions, getGlobal } from '../../../global'; import type { ApiMessage, ApiPeer, ApiPoll, ApiPollAnswer, } from '../../../api/types'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { OldLangFn } from '../../../hooks/useOldLang'; import { selectPeer } from '../../../global/selectors'; import { formatMediaDuration } from '../../../util/dates/dateFormat'; import { getMessageKey } from '../../../util/keys/messageKey'; import { getServerTime } from '../../../util/serverTime'; import { renderTextWithEntities } from '../../common/helpers/renderTextWithEntities'; import useLastCallback from '../../../hooks/useLastCallback'; import useOldLang from '../../../hooks/useOldLang'; import AvatarList from '../../common/AvatarList'; import Button from '../../ui/Button'; import CheckboxGroup from '../../ui/CheckboxGroup'; import RadioGroup from '../../ui/RadioGroup'; import PollOption from './PollOption'; import './Poll.scss'; type OwnProps = { message: ApiMessage; poll: ApiPoll; observeIntersectionForLoading?: ObserveFn; observeIntersectionForPlaying?: ObserveFn; onSendVote: (options: string[]) => void; }; const SOLUTION_CONTAINER_ID = '#middle-column-portals'; const SOLUTION_DURATION = 5000; const TIMER_RADIUS = 6; const TIMER_CIRCUMFERENCE = TIMER_RADIUS * 2 * Math.PI; const TIMER_UPDATE_INTERVAL = 1000; const NBSP = '\u00A0'; const Poll: FC = ({ message, poll, observeIntersectionForLoading, observeIntersectionForPlaying, onSendVote, }) => { const { loadMessage, openPollResults, requestConfetti, showNotification, } = getActions(); const { id: messageId, chatId } = message; const { summary, results } = poll; const [isSubmitting, setIsSubmitting] = useState(false); const [chosenOptions, setChosenOptions] = useState([]); const [wasSubmitted, setWasSubmitted] = useState(false); const [closePeriod, setClosePeriod] = useState(() => ( !summary.closed && summary.closeDate && summary.closeDate > 0 ? Math.min(summary.closeDate - getServerTime(), summary.closePeriod!) : 0 )); const countdownRef = useRef(); const timerCircleRef = useRef(); const { results: voteResults, totalVoters } = results; const hasVoted = voteResults && voteResults.some((r) => r.isChosen); const canVote = !summary.closed && !hasVoted; const canViewResult = !canVote && summary.isPublic && Number(results.totalVoters) > 0; const isMultiple = canVote && summary.multipleChoice; const recentVoterIds = results.recentVoterIds; const maxVotersCount = voteResults ? Math.max(...voteResults.map((r) => r.votersCount)) : totalVoters; const correctResults = useMemo(() => { return voteResults?.filter((r) => r.isCorrect).map((r) => r.option) || []; }, [voteResults]); const answers = useMemo(() => summary.answers.map((a) => ({ label: renderTextWithEntities({ text: a.text.text, entities: a.text.entities, observeIntersectionForLoading, observeIntersectionForPlaying, }), value: a.option, hidden: Boolean(summary.quiz && summary.closePeriod && closePeriod <= 0), })), [ closePeriod, observeIntersectionForLoading, observeIntersectionForPlaying, summary.answers, summary.closePeriod, summary.quiz, ]); useEffect(() => { const chosen = poll.results.results?.find((result) => result.isChosen); if (isSubmitting && chosen) { if (chosen.isCorrect) { requestConfetti({}); } setIsSubmitting(false); } }, [isSubmitting, poll.results.results, requestConfetti]); useLayoutEffect(() => { if (closePeriod > 0) { setTimeout(() => setClosePeriod(closePeriod - 1), TIMER_UPDATE_INTERVAL); } if (!timerCircleRef.current) return; if (closePeriod <= 5) { countdownRef.current!.classList.add('hurry-up'); } const strokeDashOffset = ((summary.closePeriod! - closePeriod) / summary.closePeriod!) * TIMER_CIRCUMFERENCE; timerCircleRef.current.setAttribute('stroke-dashoffset', `-${strokeDashOffset}`); }, [closePeriod, summary.closePeriod]); useEffect(() => { if (summary.quiz && (closePeriod <= 0 || (hasVoted && !summary.closed))) { loadMessage({ chatId, messageId }); } }, [chatId, closePeriod, hasVoted, loadMessage, messageId, summary.closed, summary.quiz]); // If the client time is not synchronized, the poll must be updated after the closePeriod time has expired. useEffect(() => { let timer: number | undefined; if (summary.quiz && !summary.closed && summary.closePeriod && summary.closePeriod > 0) { timer = window.setTimeout(() => { loadMessage({ chatId, messageId }); }, summary.closePeriod * 1000); } return () => { if (timer) { window.clearTimeout(timer); } }; }, [canVote, chatId, loadMessage, messageId, summary.closePeriod, summary.closed, summary.quiz]); const recentVoters = useMemo(() => { // No need for expensive global updates on chats or users, so we avoid them const global = getGlobal(); return recentVoterIds ? recentVoterIds.reduce((result: ApiPeer[], id) => { const peer = selectPeer(global, id); if (peer) { result.push(peer); } return result; }, []) : []; }, [recentVoterIds]); const handleRadioChange = useLastCallback((option: string) => { setChosenOptions([option]); setIsSubmitting(true); setWasSubmitted(true); onSendVote([option]); }); const handleCheckboxChange = useLastCallback((options: string[]) => { setChosenOptions(options); }); const handleVoteClick = useLastCallback(() => { setIsSubmitting(true); setWasSubmitted(true); onSendVote(chosenOptions); }); const handleViewResultsClick = useLastCallback(() => { openPollResults({ chatId, messageId }); }); const showSolution = useLastCallback(() => { showNotification({ localId: getMessageKey(message), message: poll.results.solution!, messageEntities: poll.results.solutionEntities, duration: SOLUTION_DURATION, containerSelector: SOLUTION_CONTAINER_ID, }); }); // Show the solution to quiz if the answer was incorrect useEffect(() => { if (wasSubmitted && hasVoted && summary.quiz && results.results && poll.results.solution) { const correctResult = results.results.find((r) => r.isChosen && r.isCorrect); if (!correctResult) { showSolution(); } } }, [hasVoted, wasSubmitted, results.results, summary.quiz, poll.results.solution]); const lang = useOldLang(); function renderResultOption(answer: ApiPollAnswer) { return ( ); } function renderRecentVoters() { return ( recentVoters.length > 0 && (
) ); } return (
{renderTextWithEntities({ text: summary.question.text, entities: summary.question.entities, observeIntersectionForLoading, observeIntersectionForPlaying, })}
{lang(getPollTypeString(summary))} {renderRecentVoters()} {closePeriod > 0 && canVote && (
{formatMediaDuration(closePeriod)}
)} {summary.quiz && poll.results.solution && !canVote && (
{canVote && (
{isMultiple ? ( ) : ( )}
)} {!canVote && (
{summary.answers.map(renderResultOption)}
)} {!canViewResult && !isMultiple && (
{getReadableVotersCount(lang, summary.quiz, results.totalVoters)}
)} {isMultiple && ( )} {canViewResult && ( )}
); }; function getPollTypeString(summary: ApiPoll['summary']) { // When we just created the poll, some properties don't exist. if (typeof summary.isPublic === 'undefined') { return NBSP; } if (summary.closed) { return 'FinalResults'; } if (summary.quiz) { return summary.isPublic ? 'QuizPoll' : 'AnonymousQuizPoll'; } return summary.isPublic ? 'PublicPoll' : 'AnonymousPoll'; } function getReadableVotersCount(lang: OldLangFn, isQuiz: true | undefined, count?: number) { if (!count) { return lang(isQuiz ? 'Chat.Quiz.TotalVotesEmpty' : 'Chat.Poll.TotalVotesResultEmpty'); } return lang(isQuiz ? 'Answer' : 'Vote', count, 'i'); } function stopPropagation(e: React.MouseEvent) { e.stopPropagation(); } export default memo(Poll);