import clsx from 'clsx' import { ComponentType, Fragment, ReactNode, RefCallback, useEffect, useRef, useState, } from 'react' import { CSVLink } from 'react-csv' import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' import TimeAgo from 'react-timeago' import { LoadingDataWithError, StatefulEntityDisplayProps, } from '@dao-dao/types' import { formatDateTimeTz, formatPercentOf100, getScrollableAncestor, processError, } from '@dao-dao/utils' import { useTranslatedTimeDeltaFormatter } from '../../hooks' import { Button } from '../buttons' import { Loader } from '../logo' import { Tooltip } from '../tooltip/Tooltip' export interface ProposalVote { voterAddress: string vote: Vote /** * The voting power of the vote as a percentage of the total voting power. * Includes all voting power (both individual and delegated). */ votingPowerPercent: number /** * The voting power of the vote owned by the voter as a percentage of the * total voting power. Excludes delegated power. * * Since delegations were added in v2.7.0, this equals `votingPowerPercent` * for earlier versions. */ individualPowerPercent: number rationale?: string | null votedAt?: Date } export interface ProposalVotesProps { votes: LoadingDataWithError[]> EntityDisplay: ComponentType VoteDisplay: ComponentType<{ vote: Vote }> // Only allows refreshing when voting is open. votingOpen: boolean footer?: ReactNode hideVotedAt?: boolean /** * Function to fetch all votes (non-paginated) for downloading the CSV. If * undefined, download link is hidden. */ getAllVotes?: () => Promise[]> /** * A function to convert the vote type into a string for the CSV. */ exportVoteTransformer: (vote: Vote) => string /** * Optional class names to apply to the container. */ className?: string /** * Optional ref to apply to the container. */ containerRef?: RefCallback } export const ProposalVotes = ({ votes, EntityDisplay, VoteDisplay, votingOpen, footer, hideVotedAt, getAllVotes, exportVoteTransformer, className, containerRef, }: ProposalVotesProps) => { const { t } = useTranslation() const timeAgoFormatter = useTranslatedTimeDeltaFormatter({ words: true }) const votesLoadingOrUpdating = votes.loading || !!votes.updating const [loadingAllVotes, setLoadingAllVotes] = useState(false) const [allVotes, setAllVotes] = useState[]>() const downloadVotes = async () => { if (!getAllVotes) { return } setLoadingAllVotes(true) try { const allVotes = (await getAllVotes()).sort( (a, b) => // Sort descending by date, and those without a date last. (b.votedAt?.getTime() ?? -Infinity) - (a.votedAt?.getTime() ?? -Infinity) ) setAllVotes(allVotes) } catch (err) { console.error(err) toast.error(processError(err, { forceCapture: false })) } finally { setLoadingAllVotes(false) } } // If a new vote is added to existing votes and the window is scrolled to the // bottom, scroll to the bottom again to show the new vote. const [prevVoteCount, setPrevVoteCount] = useState(0) const ourContainerRef = useRef(null) useEffect(() => { const newVoteCount = votes.loading || votes.errored ? 0 : votes.data.length if ( votes.loading || !ourContainerRef.current || prevVoteCount === newVoteCount ) { return } const parent = getScrollableAncestor(ourContainerRef.current) if (!parent) { return } const wasScrolledNearBottom = parent.scrollHeight - parent.scrollTop - parent.clientHeight < 100 if ( wasScrolledNearBottom && prevVoteCount > 0 && newVoteCount > prevVoteCount ) { parent.scrollTo({ top: parent.scrollHeight, behavior: 'smooth', }) } setPrevVoteCount(newVoteCount) }, [prevVoteCount, votes]) return ( <>
{ ourContainerRef.current = ref containerRef?.(ref) }} >

{t('title.votesCast')}

{votingOpen && (

{t('info.votesRefreshAutomatically')}

)} {!!getAllVotes && ( )}
{/* Titles */} {!hideVotedAt && (

{t('title.when')}

)}

{t('title.voter')}

{t('title.vote')}

{t('title.votingPower')}

{/* Votes */} {(votes.loading || votes.errored ? [] : votes.data).map( ( { votedAt, voterAddress, vote, votingPowerPercent, individualPowerPercent, rationale, }, index ) => ( {!hideVotedAt && ( )}
{t('title.individual') + ': '} {formatPercentOf100(individualPowerPercent)}
{t('title.delegated') + ': '} 0 ? 'text-text-brand-secondary' : 'text-text-tertiary' )} > {formatPercentOf100( votingPowerPercent - individualPowerPercent )} } >

{formatPercentOf100(votingPowerPercent)}

) )}
{votes.loading ? ( ) : votes.updating ? ( ) : null} {footer}
{!!getAllVotes && allVotes && ( [ votedAt?.toISOString() ?? '', voterAddress, votingPowerPercent, exportVoteTransformer(vote), rationale, ] ), ]} filename="votes.csv" ref={(ref: any) => { // Download right away and clear. ;(ref?.link as HTMLAnchorElement | undefined)?.click() setAllVotes(undefined) }} /> )} ) }