import PropTypes from 'prop-types'; import { createContext, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; import useContextHook from '../../contexts/useContextHook'; import useLocalStorage from '../../hooks/useLocalStorage'; import { extractChannelIdfromChannelArn } from '../../utils'; import { pack, unpack } from '../../helpers/streamActionHelpers'; import { useChannel } from '../Channel'; import { useUser } from '../User'; import { useLocation } from 'react-router-dom'; import { NUM_MILLISECONDS_TO_SHOW_POLL_RESULTS, EXTRA_TIME_TO_WAIT_FOR_END_POLL_EVENT } from '../../constants'; const COMPOSER_HEIGHT = 92; const SPACE_BETWEEN_COMPOSER_AND_POLL = 100; const Context = createContext(null); Context.displayName = 'Poll'; const POLL_TAB_LABEL = 'Live poll'; const initialPollProps = { votes: [], question: null, isActive: false, duration: 0, expiry: null, startTime: null, delay: 0 }; const initialPollState = { isSubmitting: false, isVoting: true, isExpanded: true, pollHeight: 0, pollRef: undefined, hasListReordered: false, showFinalResults: false, hasPollEnded: false, noVotesCaptured: false, tieFound: false, hasScrollbar: false }; const localStorageInitialState = { ...initialPollProps, voters: {} }; export const Provider = ({ children }) => { const forceResetPollPropsTimerRef = useRef(); const stopPollTimerRef = useRef(); const [composerRefState, setComposerRefState] = useState(); const shouldAnimateListRef = useRef(false); const [selectedOption, setSelectedOption] = useState(); const { channelData } = useChannel(); const { username, channelArn = '', isViewerBanned } = channelData || {}; const { userData, isSessionValid } = useUser(); const channelId = extractChannelIdfromChannelArn(channelArn); const isModerator = channelId === userData?.trackingId; const { pathname } = useLocation(); const isStreamManagerPage = pathname === '/manager'; // Poll UI states const [pollState, dispatchPollState] = useReducer( (prevState, nextState) => ({ ...prevState, ...nextState }), initialPollState ); // Active poll props const [pollProps, dispatchPollProps] = useReducer( (prevState, nextState) => ({ ...prevState, ...nextState }), initialPollProps ); const pollHasEnded = useCallback(() => { dispatchPollState({ hasPollEnded: true }); }, []); const { votes, question, isActive, duration, expiry, startTime, delay } = pollProps; const { isSubmitting, isVoting, isExpanded, pollHeight, pollRef, showFinalResults, hasListReordered, hasPollEnded, noVotesCaptured, tieFound, hasScrollbar } = pollState; const { value: savedPollData, set: savePollDataToLocalStorage } = useLocalStorage({ key: username, initialValue: localStorageInitialState, options: { keyPrefix: 'poll', serialize: pack, deserialize: unpack } }); const showFinalResultActionButton = () => ({ duration: 10, expiry: new Date(Date.now() + 10 * 1000).toISOString() }); const updatePollData = ({ votes, duration, question, expiry, startTime, isActive, delay = 0 }) => { const props = { ...(duration && { duration }), ...(question && { question }), ...(votes && { votes }), ...(expiry && { expiry }), ...(isActive && { isActive }), ...(startTime && { startTime }), ...(delay && { delay }) }; dispatchPollProps(props); }; const clearPollLocalStorage = useCallback(() => { savePollDataToLocalStorage(localStorageInitialState); }, [savePollDataToLocalStorage]); const resetPollProps = useCallback(() => { if (stopPollTimerRef.current) clearTimeout(stopPollTimerRef.current); clearPollLocalStorage(); dispatchPollProps(initialPollProps); dispatchPollState(initialPollState); setSelectedOption(); shouldAnimateListRef.current = false; stopPollTimerRef.current = undefined; forceResetPollPropsTimerRef.current = undefined; }, [clearPollLocalStorage]); const hasMounted = useRef(false); useEffect(() => { if (!channelData || hasMounted.current) return; if (isModerator && isStreamManagerPage && savedPollData?.isActive) { hasMounted.current = true; const { question, duration, startTime, votes: options, expiry } = savedPollData; updatePollData({ expiry, startTime, question, duration, isActive: true, votes: options, delay }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [channelData, savedPollData]); useEffect(() => { let timeout; if (savedPollData?.hasPollEnded && !hasPollEnded) { pollHasEnded(); return; } if (duration && !hasPollEnded && !savedPollData?.hasPollEnded) { const pollDuration = duration * 1000 - delay * 1000; timeout = setTimeout(() => { dispatchPollState({ hasPollEnded: true }); }, pollDuration); } return () => { clearTimeout(timeout); }; }, [ delay, duration, hasPollEnded, pollHasEnded, savedPollData?.hasPollEnded ]); useEffect(() => { if (showFinalResults) { dispatchPollState({ hasListReordered: true }); } }, [showFinalResults]); const checkForTie = (votes) => { const maxVote = Math.max(...votes.map((vote) => vote.count)); const count = votes.filter((vote) => vote.count === maxVote).length; return count > 1; }; useEffect(() => { if (hasPollEnded && !noVotesCaptured && !showFinalResults && !tieFound) { const noVotesCaptured = votes.every((vote) => vote.count === 0); const hasTie = checkForTie(votes); if (noVotesCaptured) { dispatchPollState({ noVotesCaptured: true }); } else { if (hasTie) { dispatchPollState({ tieFound: true }); } else { dispatchPollState({ showFinalResults: true }); } const sortedVotes = votes.sort((a, b) => a.count < b.count ? 1 : a.count > b.count ? -1 : 0 ); dispatchPollProps({ votes: sortedVotes }); } } }, [hasPollEnded, noVotesCaptured, showFinalResults, tieFound, votes]); // The value set here will determine the min height of the chat + poll container. // The reason its calculated this way is because the poll has a position: absolute const containerMinHeight = `${ pollHeight + SPACE_BETWEEN_COMPOSER_AND_POLL + COMPOSER_HEIGHT }px`; useEffect(() => { if (pollRef) { dispatchPollState({ pollHeight: pollRef.offsetHeight }); } }, [pollRef, isExpanded]); const getPollDetails = (votes) => { return votes.reduce( (acc, vote) => { if (!acc.highestCountOption) { acc.highestCountOption = vote; } else { if (vote.count > acc.highestCountOption.count) { acc.highestCountOption = vote; } } acc.totalVotes += vote.count; return acc; }, { totalVotes: 0, highestCountOption: null } ); }; const { highestCountOption, totalVotes } = getPollDetails(votes); const saveVotesToLocalStorage = useCallback( (currentVotes, voter) => { savePollDataToLocalStorage({ ...savedPollData, votes: currentVotes, voters: { ...savedPollData.voters, ...voter } }); }, [savePollDataToLocalStorage, savedPollData] ); const updateSavedPollPropsOnTimerExpiry = useCallback(() => { const { duration, expiry } = showFinalResultActionButton(); savePollDataToLocalStorage({ ...savedPollData, duration, expiry, hasPollEnded: true }); }, [savePollDataToLocalStorage, savedPollData]); const isAbleToVote = isVoting && !showFinalResults && !noVotesCaptured && !isViewerBanned; const shouldRenderRadioInput = isAbleToVote && isSessionValid && !isStreamManagerPage; const shouldRenderVoteButton = isAbleToVote && !!userData; const shouldRenderProgressbar = !showFinalResults && !noVotesCaptured && !tieFound && startTime; const endPollAndResetPollProps = useCallback(() => { clearTimeout(forceResetPollPropsTimerRef.current); dispatchPollProps({ isActive: false }); setTimeout(resetPollProps, 100); hasMounted.current = false; }, [resetPollProps]); useEffect(() => { if (hasPollEnded) { if (forceResetPollPropsTimerRef.current) { clearTimeout(forceResetPollPropsTimerRef.current); } else { forceResetPollPropsTimerRef.current = setTimeout(() => { if (isActive) endPollAndResetPollProps(); }, NUM_MILLISECONDS_TO_SHOW_POLL_RESULTS + EXTRA_TIME_TO_WAIT_FOR_END_POLL_EVENT); } } }, [endPollAndResetPollProps, isActive, hasPollEnded]); useEffect(() => { /* This code ensures that any expired poll is removed from the user interface (UI) when the user closes all instances of the UGC app and revist the app again. */ const isPollExpired = startTime + duration * 1000 + NUM_MILLISECONDS_TO_SHOW_POLL_RESULTS + EXTRA_TIME_TO_WAIT_FOR_END_POLL_EVENT < Date.now(); if (isPollExpired && isActive) { endPollAndResetPollProps(); } }, [duration, startTime, isActive, endPollAndResetPollProps]); const value = useMemo( () => ({ isExpanded, pollHeight, containerMinHeight, showFinalResults, votes, highestCountOption, totalVotes, hasListReordered, shouldAnimateListRef, question, isActive, startTime, duration, selectedOption, setSelectedOption, isSubmitting, isVoting, updatePollData, expiry, resetPollProps, noVotesCaptured, tieFound, delay, clearPollLocalStorage, pollTabLabel: POLL_TAB_LABEL, showFinalResultActionButton, hasPollEnded, stopPollTimerRef, pollHasEnded, saveVotesToLocalStorage, savedPollData, savePollDataToLocalStorage, updateSavedPollPropsOnTimerExpiry, hasScrollbar, composerRefState, setComposerRefState, dispatchPollState, shouldRenderRadioInput, shouldRenderVoteButton, endPollAndResetPollProps, hasVotes: votes.length > 0, shouldRenderProgressbar }), [ isExpanded, pollHeight, containerMinHeight, showFinalResults, votes, highestCountOption, totalVotes, hasListReordered, question, isActive, startTime, duration, selectedOption, isSubmitting, isVoting, expiry, resetPollProps, noVotesCaptured, tieFound, delay, clearPollLocalStorage, hasPollEnded, pollHasEnded, saveVotesToLocalStorage, savedPollData, savePollDataToLocalStorage, updateSavedPollPropsOnTimerExpiry, hasScrollbar, composerRefState, shouldRenderRadioInput, shouldRenderVoteButton, endPollAndResetPollProps, shouldRenderProgressbar ] ); return {children}; }; Provider.propTypes = { children: PropTypes.node.isRequired }; export const usePoll = () => useContextHook(Context);