diff --git a/apollo/subgraph.ts b/apollo/subgraph.ts index 89083f72..e948cd27 100644 --- a/apollo/subgraph.ts +++ b/apollo/subgraph.ts @@ -26,14 +26,6 @@ export type Scalars = { Timestamp: any; }; -/** Indicates whether the current, partially filled bucket should be included in the response. Defaults to `exclude` */ -export enum Aggregation_Current { - /** Exclude the current, partially filled bucket from the response */ - Exclude = 'exclude', - /** Include the current, partially filled bucket in the response */ - Include = 'include' -} - export enum Aggregation_Interval { Day = 'day', Hour = 'hour' @@ -7578,8 +7570,10 @@ export type TreasuryProposal_Filter = { and?: InputMaybe>>; calldatas?: InputMaybe>; calldatas_contains?: InputMaybe>; + calldatas_contains_nocase?: InputMaybe>; calldatas_not?: InputMaybe>; calldatas_not_contains?: InputMaybe>; + calldatas_not_contains_nocase?: InputMaybe>; description?: InputMaybe; description_contains?: InputMaybe; description_contains_nocase?: InputMaybe; @@ -7654,8 +7648,10 @@ export type TreasuryProposal_Filter = { totalVotes_not_in?: InputMaybe>; values?: InputMaybe>; values_contains?: InputMaybe>; + values_contains_nocase?: InputMaybe>; values_not?: InputMaybe>; values_not_contains?: InputMaybe>; + values_not_contains_nocase?: InputMaybe>; voteEnd?: InputMaybe; voteEnd_gt?: InputMaybe; voteEnd_gte?: InputMaybe; @@ -9681,7 +9677,7 @@ export type PollQueryVariables = Exact<{ }>; -export type PollQuery = { __typename: 'Query', poll?: { __typename: 'Poll', id: string, proposal: string, endBlock: string, quorum: string, quota: string, tally?: { __typename: 'PollTally', yes: string, no: string } | null, votes: Array<{ __typename: 'Vote', id: string }> } | null }; +export type PollQuery = { __typename: 'Query', poll?: { __typename: 'Poll', id: string, proposal: string, endBlock: string, quorum: string, quota: string, tally?: { __typename: 'PollTally', yes: string, no: string } | null, votes: Array<{ __typename: 'Vote', id: string, choiceID?: PollChoice | null, voter: string, voteStake: string, nonVoteStake: string }> } | null }; export type PollsQueryVariables = Exact<{ [key: string]: never; }>; @@ -9719,6 +9715,16 @@ export type TranscoderActivatedEventsQueryVariables = Exact<{ export type TranscoderActivatedEventsQuery = { __typename: 'Query', transcoderActivatedEvents: Array<{ __typename: 'TranscoderActivatedEvent', activationRound: string, id: string }> }; +export type TranscoderUpdateEventsQueryVariables = Exact<{ + where?: InputMaybe; + first?: InputMaybe; + orderBy?: InputMaybe; + orderDirection?: InputMaybe; +}>; + + +export type TranscoderUpdateEventsQuery = { __typename: 'Query', transcoderUpdateEvents: Array<{ __typename: 'TranscoderUpdateEvent', id: string, rewardCut: string, feeShare: string, timestamp: number, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string } }> }; + export type TreasuryProposalQueryVariables = Exact<{ id: Scalars['ID']; }>; @@ -9755,7 +9761,15 @@ export type VoteQueryVariables = Exact<{ }>; -export type VoteQuery = { __typename: 'Query', vote?: { __typename: 'Vote', choiceID?: PollChoice | null, voteStake: string, nonVoteStake: string } | null }; +export type VoteQuery = { __typename: 'Query', vote?: { __typename: 'Vote', choiceID?: PollChoice | null, voteStake: string, nonVoteStake: string, poll?: { __typename: 'Poll', id: string, votes: Array<{ __typename: 'Vote', voteStake: string, id: string }> } | null } | null }; + +export type VoteEventsQueryVariables = Exact<{ + first?: InputMaybe; + where?: InputMaybe; +}>; + + +export type VoteEventsQuery = { __typename: 'Query', voteEvents: Array<{ __typename: 'VoteEvent', id: string, choiceID: string, voter: string, timestamp: number, poll: { __typename: 'Poll', id: string, proposal: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } }> }; export const AccountDocument = gql` @@ -10451,6 +10465,10 @@ export const PollDocument = gql` } votes { id + choiceID + voter + voteStake + nonVoteStake } } } @@ -10838,16 +10856,6 @@ export function useTranscoderActivatedEventsLazyQuery(baseOptions?: Apollo.LazyQ export type TranscoderActivatedEventsQueryHookResult = ReturnType; export type TranscoderActivatedEventsLazyQueryHookResult = ReturnType; export type TranscoderActivatedEventsQueryResult = Apollo.QueryResult; - -export type TranscoderUpdateEventsQueryVariables = Exact<{ - where?: InputMaybe; - first?: InputMaybe; - orderBy?: InputMaybe; - orderDirection?: InputMaybe; -}>; - -export type TranscoderUpdateEventsQuery = { __typename: 'Query', transcoderUpdateEvents: Array<{ __typename: 'TranscoderUpdateEvent', id: string, rewardCut: string, feeShare: string, timestamp: number, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string } }> }; - export const TranscoderUpdateEventsDocument = gql` query transcoderUpdateEvents($where: TranscoderUpdateEvent_filter, $first: Int, $orderBy: TranscoderUpdateEvent_orderBy, $orderDirection: OrderDirection) { transcoderUpdateEvents( @@ -10870,6 +10878,25 @@ export const TranscoderUpdateEventsDocument = gql` } `; +/** + * __useTranscoderUpdateEventsQuery__ + * + * To run a query within a React component, call `useTranscoderUpdateEventsQuery` and pass it any options that fit your needs. + * When your component renders, `useTranscoderUpdateEventsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useTranscoderUpdateEventsQuery({ + * variables: { + * where: // value for 'where' + * first: // value for 'first' + * orderBy: // value for 'orderBy' + * orderDirection: // value for 'orderDirection' + * }, + * }); + */ export function useTranscoderUpdateEventsQuery(baseOptions?: Apollo.QueryHookOptions) { const options = {...defaultOptions, ...baseOptions} return Apollo.useQuery(TranscoderUpdateEventsDocument, options); @@ -10881,7 +10908,6 @@ export function useTranscoderUpdateEventsLazyQuery(baseOptions?: Apollo.LazyQuer export type TranscoderUpdateEventsQueryHookResult = ReturnType; export type TranscoderUpdateEventsLazyQueryHookResult = ReturnType; export type TranscoderUpdateEventsQueryResult = Apollo.QueryResult; - export const TreasuryProposalDocument = gql` query treasuryProposal($id: ID!) { treasuryProposal(id: $id) { @@ -11093,6 +11119,13 @@ export const VoteDocument = gql` choiceID voteStake nonVoteStake + poll { + id + votes { + voteStake + id + } + } } } `; @@ -11123,4 +11156,56 @@ export function useVoteLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions; export type VoteLazyQueryHookResult = ReturnType; -export type VoteQueryResult = Apollo.QueryResult; \ No newline at end of file +export type VoteQueryResult = Apollo.QueryResult; +export const VoteEventsDocument = gql` + query voteEvents($first: Int, $where: VoteEvent_filter) { + voteEvents( + orderBy: timestamp + orderDirection: desc + first: $first + where: $where + ) { + id + choiceID + voter + timestamp + poll { + id + proposal + } + transaction { + id + timestamp + } + } +} + `; + +/** + * __useVoteEventsQuery__ + * + * To run a query within a React component, call `useVoteEventsQuery` and pass it any options that fit your needs. + * When your component renders, `useVoteEventsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useVoteEventsQuery({ + * variables: { + * first: // value for 'first' + * where: // value for 'where' + * }, + * }); + */ +export function useVoteEventsQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(VoteEventsDocument, options); + } +export function useVoteEventsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(VoteEventsDocument, options); + } +export type VoteEventsQueryHookResult = ReturnType; +export type VoteEventsLazyQueryHookResult = ReturnType; +export type VoteEventsQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/components/PollVote/DesktopVoteTable.tsx b/components/PollVote/DesktopVoteTable.tsx new file mode 100644 index 00000000..cca78e94 --- /dev/null +++ b/components/PollVote/DesktopVoteTable.tsx @@ -0,0 +1,213 @@ +import EthAddressBadge from "@components/EthAddressBadge"; +import { ExplorerTooltip } from "@components/ExplorerTooltip"; +import DataTable from "@components/Table"; +import TransactionBadge from "@components/TransactionBadge"; +import { VOTING_SUPPORT_MAP } from "@lib/api/types/votes"; +import { Badge, Box, Text } from "@livepeer/design-system"; +import { CounterClockwiseClockIcon } from "@radix-ui/react-icons"; +import dayjs from "dayjs"; +import React, { useMemo } from "react"; +import { Column } from "react-table"; + +import { PollVoteType } from "."; + +export interface PollVoteTableProps { + votes: PollVoteType[]; + onSelect: (voter: { + address: string; + voteStake: string; + ensName?: string; + }) => void; + pageSize?: number; + totalPages?: number; + currentPage?: number; + onPageChange?: (page: number) => void; + formatWeight: (stake: string) => string; +} + +export const DesktopVoteTable: React.FC = ({ + votes, + onSelect, + pageSize = 10, + formatWeight, +}) => { + const columns = useMemo[]>( + () => [ + { + Header: "Voter", + accessor: "ensName", + id: "voter", + Cell: ({ row }) => ( + + + + ), + }, + { + Header: "Support", + accessor: "choiceID", + id: "support", + Cell: ({ row }) => { + const support = VOTING_SUPPORT_MAP[row.original.choiceID]; + + return ( + + + + {support.text} + + + ); + }, + }, + { + Header: "Weight", + accessor: "voteStake", + id: "weight", + Cell: ({ row }) => ( + + + {formatWeight(row.original.voteStake)} + + + ), + }, + { + Header: "Timestamp", + accessor: "timestamp", + id: "timestamp", + Cell: ({ row }) => { + if (row.original.timestamp) { + return ( + + {dayjs + .unix(row.original.timestamp) + .format("MMM D YYYY, h:mm a")} + + ); + } else { + return ( + + N/A + + ); + } + }, + }, + { + Header: "Transaction", + accessor: "transactionHash", + id: "transaction", + Cell: ({ row }) => ( + + {row.original.transactionHash ? ( + + ) : ( + + N/A + + )} + + ), + }, + { + Header: "", + id: "history", + Cell: ({ row }) => ( + + + { + e.stopPropagation(); + onSelect({ + address: row.original.voter, + ensName: row.original.ensName, + voteStake: row.original.voteStake, + }); + }} + css={{ + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: 32, + height: 32, + borderRadius: "50%", + cursor: "pointer", + border: "none", + backgroundColor: "transparent", + color: "$neutral10", + "&:hover": { + color: "$primary11", + backgroundColor: "$primary3", + transform: "rotate(-15deg)", + }, + transition: "color .2s, background-color .2s, transform .2s", + }} + > + + + + + ), + disableSortBy: true, + }, + ], + [formatWeight, onSelect] + ); + + return ( + + + + ); +}; diff --git a/components/PollVote/MobileVoteCards.tsx b/components/PollVote/MobileVoteCards.tsx new file mode 100644 index 00000000..d81ebb19 --- /dev/null +++ b/components/PollVote/MobileVoteCards.tsx @@ -0,0 +1,57 @@ +import Pagination from "@components/Table/Pagination"; +import { Box, Text } from "@livepeer/design-system"; +import React from "react"; + +import { PollVoteTableProps } from "./DesktopVoteTable"; +import { MobileVoteView } from "./MobileVoteView"; + +export const MobileVoteCards: React.FC = (props) => { + const { + votes, + onSelect, + totalPages = 0, + currentPage = 1, + formatWeight, + onPageChange, + } = props; + + return ( + + + View a voter's proposal voting history by clicking the history + icon. + + + {votes.map((vote) => { + return ( + + ); + })} + + {/* Pagination */} + {totalPages > 1 && ( + 1} + canNext={currentPage < totalPages} + onPrevious={() => onPageChange?.(currentPage - 1)} + onNext={() => onPageChange?.(currentPage + 1)} + /> + )} + + ); +}; diff --git a/components/PollVote/MobileVoteView.tsx b/components/PollVote/MobileVoteView.tsx new file mode 100644 index 00000000..e556ec87 --- /dev/null +++ b/components/PollVote/MobileVoteView.tsx @@ -0,0 +1,202 @@ +import { VOTING_SUPPORT_MAP } from "@lib/api/types/votes"; +import dayjs from "@lib/dayjs"; +import { + Badge, + Box, + Card, + Flex, + Heading, + Link, + Text, +} from "@livepeer/design-system"; +import { + ArrowTopRightIcon, + CounterClockwiseClockIcon, +} from "@radix-ui/react-icons"; +import { formatAddress, formatTransactionHash } from "@utils/web3"; + +import { PollVoteType } from "."; + +interface MobileVoteViewProps { + vote: PollVoteType; + formatWeight: (stake: string) => string; + onSelect: (voter: { + address: string; + voteStake: string; + ensName?: string; + }) => void; +} + +export function MobileVoteView({ + vote, + formatWeight, + onSelect, +}: MobileVoteViewProps) { + const support = VOTING_SUPPORT_MAP[vote.choiceID]; + const voterId = vote.ensName ? vote.ensName : formatAddress(vote.voter); + + return ( + + + {/* Hero: Vote badge */} + + + {support.text} + + + {/* Voter name + History */} + + + + {voterId} + + + + onSelect({ + address: vote.voter, + voteStake: vote.voteStake, + ensName: vote.ensName, + }) + } + > + + History + + + + + + + {formatWeight(vote.voteStake)} + + + {/* Footer: Transaction + Timestamp */} + + {vote.transactionHash ? ( + *": { + border: "1.5px solid $grass7 !important", + backgroundColor: "$grass3 !important", + color: "$grass11 !important", + }, + "&:focus-visible > *": { + outline: "2px solid $primary11", + outlineOffset: "2px", + }, + }} + > + + {formatTransactionHash(vote.transactionHash)} + + + + ) : ( + + N/A + + )} + {vote.timestamp && ( + <> + + · + + + {dayjs.unix(vote.timestamp).format("MMM D, h:mm a")} + + + )} + + + + ); +} diff --git a/components/PollVote/PollVoteDetail.tsx b/components/PollVote/PollVoteDetail.tsx new file mode 100644 index 00000000..ad3b58af --- /dev/null +++ b/components/PollVote/PollVoteDetail.tsx @@ -0,0 +1,256 @@ +import TransactionBadge from "@components/TransactionBadge"; +import { parsePollText } from "@lib/api/polls"; +import { POLL_VOTES } from "@lib/api/types/votes"; +import dayjs from "@lib/dayjs"; +import { + Badge, + Box, + Card, + Flex, + Heading, + Link, + Text, +} from "@livepeer/design-system"; +import { formatLPT, formatPercent } from "@utils/numberFormatters"; +import { useVoteQuery, VoteEvent } from "apollo"; +import React, { useEffect, useMemo, useState } from "react"; + +interface PollVoteDetailProps { + vote: VoteEvent; +} + +const Index: React.FC = ({ vote }) => { + const [title, setTitle] = useState(""); + const support = POLL_VOTES[vote.choiceID]; + const voteId = `${vote.voter}-${vote.poll.id}`; + + const { data: voteData } = useVoteQuery({ variables: { id: voteId } }); + + const getStake = useMemo( + () => (stake: string) => { + const totalVoteStake = + voteData?.vote?.poll?.votes.reduce( + (sum, v) => sum + parseFloat(v.voteStake), + 0 + ) ?? 0; + + return `${formatLPT(parseFloat(stake), { abbreviate: false })} (${ + totalVoteStake > 0 + ? formatPercent(parseFloat(stake) / totalVoteStake) + : formatPercent(0) + })`; + }, + [voteData?.vote?.poll?.votes] + ); + + useEffect(() => { + async function getTitle() { + const pollText = await parsePollText(vote.poll.proposal); + + if (pollText?.title) { + setTitle(pollText.title); + } + } + + getTitle(); + }, [vote.poll.proposal]); + + return ( + + {/* Mobile Card Layout */} + + + {/* Hero: Vote badge */} + + + {support.text} + + + {/* Title link */} + + + {title} + + + + {/* Weight */} + + {getStake(voteData?.vote?.voteStake ?? "0")} + + + {/* Footer: Transaction + Timestamp */} + + {vote.transaction.id ? ( + + ) : ( + + N/A + + )} + + · + + + {dayjs.unix(vote.transaction.timestamp).format("MMM D, h:mm a")} + + + + + + {/* Desktop Timeline Layout */} + + {/* Timeline Dot */} + + + + + {/* Hero: Vote badge */} + + + {support.text} + + + {/* Title link */} + + + {title} + + + + {/* Weight */} + + {getStake(voteData?.vote?.voteStake ?? "0")} + + + {/* Footer: Transaction + Timestamp */} + + {vote.transaction.id ? ( + + ) : ( + + N/A + + )} + + · + + + {dayjs.unix(vote.transaction.timestamp).format("MMM D, h:mm a")} + + + + + + + ); +}; + +export default Index; diff --git a/components/PollVote/PollVotePopover.tsx b/components/PollVote/PollVotePopover.tsx new file mode 100644 index 00000000..a78d81c2 --- /dev/null +++ b/components/PollVote/PollVotePopover.tsx @@ -0,0 +1,219 @@ +import Spinner from "@components/Spinner"; +import { POLL_VOTES } from "@lib/api/types/votes"; +import { Badge, Box, Flex, Link, Text } from "@livepeer/design-system"; +import { ArrowTopRightIcon } from "@radix-ui/react-icons"; +import { formatNumber } from "@utils/numberFormatters"; +import { useVoteEventsQuery, VoteEvent } from "apollo"; +import React from "react"; + +import TreasuryVoteHistoryModal from "../Treasury/TreasuryVoteTable/TreasuryVoteHistoryModal"; +import PollVoteDetail from "./PollVoteDetail"; + +interface PollVotePopoverProps { + voter: string; + ensName?: string; + onClose: () => void; +} + +const Index: React.FC = ({ voter, ensName, onClose }) => { + const { data: votesEventsData, loading: isLoading } = useVoteEventsQuery({ + variables: { + where: { + voter: voter, + }, + }, + }); + + const voteEvents = React.useMemo(() => { + return votesEventsData?.voteEvents + ? [...votesEventsData.voteEvents].sort( + (a, b) => b.transaction.timestamp - a.transaction.timestamp + ) + : []; + }, [votesEventsData]); + + const stats = React.useMemo(() => { + if (!voteEvents.length) return null; + + return { + total: voteEvents.length, + for: voteEvents.filter((v) => v.choiceID === "0").length, + against: voteEvents.filter((v) => v.choiceID === "1").length, + }; + }, [voteEvents]); + + const summaryHeader = React.useMemo(() => { + return ( + + + + {ensName || voter} + + + {stats && ( + + + Total: + + + {formatNumber(stats.total, { precision: 0 })} + + + )} + + {stats && ( + + + + Total: + + + {formatNumber(stats.total, { precision: 0 })} + + + + + For: {formatNumber(stats.for, { precision: 0 })} + + + + Against: {formatNumber(stats.against, { precision: 0 })} + + + )} + + ); + }, [stats, voter, ensName]); + + return ( + + {isLoading ? ( + + + + ) : voteEvents.length > 0 ? ( + + {voteEvents.map((vote, idx) => ( + + + + ))} + + ) : ( + + No votes found for this voter. + + )} + + ); +}; + +export default Index; diff --git a/components/PollVote/index.tsx b/components/PollVote/index.tsx new file mode 100644 index 00000000..a2820d31 --- /dev/null +++ b/components/PollVote/index.tsx @@ -0,0 +1,209 @@ +import Spinner from "@components/Spinner"; +import { getEnsForVotes } from "@lib/api/ens"; +import { Flex, Text } from "@livepeer/design-system"; +import { formatLPT, formatPercent } from "@utils/numberFormatters"; +import { formatAddress } from "@utils/web3"; +import { PollChoice, usePollQuery, useVoteEventsQuery } from "apollo"; +import React, { useEffect, useMemo, useState } from "react"; +import { useWindowSize } from "react-use"; + +import { DesktopVoteTable } from "./DesktopVoteTable"; +import { MobileVoteCards } from "./MobileVoteCards"; +import PollVotePopover from "./PollVotePopover"; + +interface PollVotingTableProps { + pollId: string; +} + +export type PollVoteType = { + __typename: "Vote"; + id: string; + choiceID: PollChoice; + voter: string; + voteStake: string; + nonVoteStake: string; + ensName?: string; + transactionHash: string; + timestamp: number; +}; + +const useVotes = (pollId: string) => { + const pollInterval = 10000; + + const { + data: pollVotesData, + loading, + error: pollError, + } = usePollQuery({ + variables: { + id: pollId ?? "", + }, + pollInterval, + }); + + const { data: pollVoteEventsData, error: pollVoteEventsError } = + useVoteEventsQuery({ + variables: { + first: 200, + where: { + poll: pollId, + }, + }, + }); + + const [votes, setVotes] = useState([]); + const [votesLoading, setVotesLoading] = useState(false); + + useEffect(() => { + if (!pollVotesData?.poll?.votes || !pollVoteEventsData?.voteEvents) { + setVotes([]); + } + + const decorateVotes = async () => { + setVotesLoading(true); + + const uniqueVoters = Array.from( + new Set(pollVotesData?.poll?.votes?.map((v) => v.voter) ?? []) + ); + + const localEnsCache: { [address: string]: string } = {}; + + await Promise.all( + uniqueVoters.map(async (address) => { + try { + if (localEnsCache[address]) { + return; + } + const ensAddress = await getEnsForVotes(address); + + if (ensAddress && ensAddress.name) { + localEnsCache[address] = ensAddress.name; + } else { + localEnsCache[address] = formatAddress(address); + } + } catch (e) { + console.warn(`Failed to fetch ENS for ${address}`, e); + } + }) + ); + + const votes = + pollVotesData?.poll?.votes?.map((vote) => { + const events = (pollVoteEventsData?.voteEvents ?? []) + .filter((event) => event.voter === vote.voter) + .sort((a, b) => b.timestamp - a.timestamp); + + const latestEvent = events[0]; + const ensName = localEnsCache[vote.voter] ?? ""; + + return { + ...vote, + ensName, + transactionHash: latestEvent?.transaction.id ?? "", + timestamp: latestEvent?.timestamp, + }; + }) ?? []; + + setVotes(votes as PollVoteType[]); + setVotesLoading(false); + }; + + decorateVotes(); + }, [pollVotesData?.poll?.votes, pollVoteEventsData?.voteEvents]); + + return { + votes, + loading: loading || votesLoading, + error: pollError || pollVoteEventsError, + }; +}; + +const Index: React.FC = ({ pollId }) => { + const { width } = useWindowSize(); + const isDesktop = width >= 900; + + const [selectedVoter, setSelectedVoter] = useState<{ + address: string; + voteStake: string; + ensName?: string; + } | null>(null); + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 10; + + const { votes, loading } = useVotes(pollId); + + const totalVoteStake = useMemo( + () => votes.reduce((sum, v) => sum + parseFloat(v.voteStake), 0), + [votes] + ); + + const formatWeight = useMemo( + () => (stake: string) => + `${formatLPT(parseFloat(stake), { abbreviate: false })} (${ + totalVoteStake > 0 + ? formatPercent(parseFloat(stake) / totalVoteStake) + : formatPercent(0) + })`, + [totalVoteStake] + ); + + const paginatedVotesForMobile = useMemo(() => { + const sorted = [...votes].sort((a, b) => b.timestamp - a.timestamp); + const startIndex = (currentPage - 1) * pageSize; + return sorted.slice(startIndex, startIndex + pageSize); + }, [votes, currentPage, pageSize]); + + const totalPages = Math.ceil(votes.length / pageSize); + + if (loading) { + return ( + + + + ); + } + + if (!votes.length) + return ( + + No votes found for this poll. + + ); + + return ( + <> + {isDesktop ? ( + + ) : ( + + )} + {selectedVoter && ( + setSelectedVoter(null)} + /> + )} + + ); +}; + +export default Index; diff --git a/components/PollVotingWidget/index.tsx b/components/PollVotingWidget/index.tsx index 8b90568a..0f9b1592 100644 --- a/components/PollVotingWidget/index.tsx +++ b/components/PollVotingWidget/index.tsx @@ -1,13 +1,22 @@ import CliVotingInstructionsDialog from "@components/CliVotingInstructionsDialog"; +import {} from "@components/PollVote"; import VoteButton from "@components/VoteButton"; import { PollExtended } from "@lib/api/polls"; import dayjs from "@lib/dayjs"; -import { Box, Button, Flex, Heading, Text } from "@livepeer/design-system"; +import { + Box, + Button, + Flex, + Heading, + Link as A, + Text, +} from "@livepeer/design-system"; import { CheckCircledIcon, CrossCircledIcon } from "@radix-ui/react-icons"; import { formatPercent, formatVotingPower } from "@utils/numberFormatters"; import { formatAddress } from "@utils/web3"; import { AccountQuery, PollChoice, TranscoderStatus } from "apollo"; import { useAccountAddress, usePendingFeesAndStakeData } from "hooks"; +import Link, { LinkProps } from "next/link"; import { useMemo } from "react"; import { getVotingPower } from "utils/voting"; @@ -32,6 +41,7 @@ type Props = { | undefined | null; myAccount: AccountQuery; + votesTabHref?: LinkProps["href"] | string; }; const SectionLabel = ({ children }: { children: React.ReactNode }) => ( @@ -216,23 +226,53 @@ const Index = ({ data }: { data: Props }) => { - - {data.poll.votes.length}{" "} - {`${ - data.poll.votes.length > 1 || data.poll.votes.length === 0 - ? "votes" - : "vote" - }`}{" "} - · {formatVotingPower(data.poll.stake.voters)} ·{" "} - {data.poll.status !== "active" - ? "Final Results" - : dayjs - .duration( - dayjs().unix() - data.poll.estimatedEndTime, - "seconds" - ) - .humanize() + " left"} - + {/* Summary line */} + + + {data.poll.votes.length}{" "} + {`${ + data.poll.votes.length > 1 || data.poll.votes.length === 0 + ? "votes" + : "vote" + }`}{" "} + · {formatVotingPower(data.poll.stake.voters)} ·{" "} + {data.poll.status !== "active" + ? "Final Results" + : dayjs + .duration( + dayjs().unix() - data.poll.estimatedEndTime, + "seconds" + ) + .humanize() + " left"} + + {data.votesTabHref ? ( + + + View votes + + + ) : ( + + View votes + + )} + {/* ========== YOUR VOTE SECTION ========== */} diff --git a/lib/api/polls.ts b/lib/api/polls.ts index 87192da4..888cf19f 100644 --- a/lib/api/polls.ts +++ b/lib/api/polls.ts @@ -61,6 +61,13 @@ export const parsePollIpfs = (ipfsObject?: IpfsPoll | null): Fm | null => { }; }; +export const parsePollText = async (proposal: string): Promise => { + const ipfsObject = await catIpfsJson(proposal); + const attributes = parsePollIpfs(ipfsObject); + + return attributes; +}; + export const getPollExtended = async ( poll: | NonNullable["polls"][number] diff --git a/lib/api/types/votes.ts b/lib/api/types/votes.ts index 87bee124..1d3b5688 100644 --- a/lib/api/types/votes.ts +++ b/lib/api/types/votes.ts @@ -3,7 +3,7 @@ import { CrossCircledIcon, MinusCircledIcon, } from "@radix-ui/react-icons"; -import { TreasuryVoteSupport } from "apollo"; +import { PollChoice, TreasuryVoteSupport } from "apollo"; // Standardized Poll Votes ("0" is yes/for) export const POLL_VOTES = { @@ -56,6 +56,8 @@ export const VOTING_SUPPORT_MAP = { [TreasuryVoteSupport.Against]: TREASURY_VOTES.against, [TreasuryVoteSupport.For]: TREASURY_VOTES.for, [TreasuryVoteSupport.Abstain]: TREASURY_VOTES.abstain, + [PollChoice.Yes]: POLL_VOTES.Yes, + [PollChoice.No]: POLL_VOTES.No, } as const; // Legacy support (to be replaced by POLL_VOTES or TREASURY_VOTES) diff --git a/pages/voting/[poll].tsx b/pages/voting/[poll].tsx index 5cc80eed..85c69858 100644 --- a/pages/voting/[poll].tsx +++ b/pages/voting/[poll].tsx @@ -1,5 +1,7 @@ import BottomDrawer from "@components/BottomDrawer"; +import HorizontalScrollContainer from "@components/HorizontalScrollContainer"; import MarkdownRenderer from "@components/MarkdownRenderer"; +import PollVotingTable from "@components/PollVote"; import PollVotingWidget from "@components/PollVotingWidget"; import Spinner from "@components/Spinner"; import Stat from "@components/Stat"; @@ -15,6 +17,7 @@ import { Container, Flex, Heading, + Link as A, Text, } from "@livepeer/design-system"; import { CheckCircledIcon, CrossCircledIcon } from "@radix-ui/react-icons"; @@ -29,8 +32,9 @@ import { } from "apollo"; import { sentenceCase } from "change-case"; import Head from "next/head"; +import NextLink from "next/link"; import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useWindowSize } from "react-use"; import { @@ -48,12 +52,21 @@ const Poll = () => { const [pollData, setPollData] = useState(null); const { query } = router; + const view = query?.view?.toString().toLowerCase() || "overview"; const pollId = query?.poll?.toString().toLowerCase(); const pollInterval = 10000; const { setBottomDrawerOpen } = useExplorerStore(); + const votesTabHref = useMemo( + () => ({ + pathname: router.pathname, + query: { ...query, view: "votes" }, + }), + [router.pathname, query] + ); + const { data, error: pollError } = usePollQuery({ variables: { id: pollId ?? "", @@ -101,6 +114,31 @@ const Poll = () => { init(); }, [data, currentRound?.currentL1Block]); + const tabs = useMemo( + () => [ + { + name: "Overview", + href: { + pathname: router.pathname, + query: { ...query, view: "overview" }, + }, + isActive: view === "overview", + }, + { + name: "Votes", + href: votesTabHref, + isActive: view === "votes", + count: data?.poll?.votes?.length, + }, + ], + [router.pathname, query, view, data?.poll?.votes?.length, votesTabHref] + ); + + const voteContent = useCallback(() => { + if (!pollData?.votes?.length) return No votes yet.; + return ; + }, [pollData]); + if (pollError) { return ; } @@ -212,143 +250,225 @@ const Poll = () => { - - - Total Support ( - {formatPercent( - +pollData.quota / PERCENTAGE_PRECISION_MILLION - )}{" "} - needed) - - } - value={{formatPercent(pollData.percent.yes)}} - meta={ - - ( + + - - - - For ({formatPercent(pollData.percent.yes)}) - - - - {formatLPT(pollData.stake.yes, { precision: 4 })} - - - + - - - - Against ({formatPercent(pollData.percent.no)}) - - - - {formatLPT(pollData.stake.no, { precision: 4 })} - - - - } - /> + {tab.name} + {tab.count !== undefined && ( + + {tab.count} + + )} + + + + ))} + - - Total Participation ( - {formatPercent( - +pollData.quorum / PERCENTAGE_PRECISION_MILLION - )}{" "} - needed) - - } - value={{formatPercent(pollData.percent.voters)}} - meta={ - - - - Voters ({formatPercent(pollData.percent.voters)}) + + + + + Total Support ( + {formatPercent( + +pollData.quota / PERCENTAGE_PRECISION_MILLION + )}{" "} + needed) - - - {formatLPT(pollData.stake.voters, { precision: 4 })} - + } + value={{formatPercent(pollData.percent.yes)}} + meta={ + + + + + + For ({formatPercent(pollData.percent.yes)}) + + + + {formatLPT(pollData.stake.yes, { precision: 4 })} + + + + + + + Against ({formatPercent(pollData.percent.no)}) + + + + {formatLPT(pollData.stake.no, { precision: 4 })} + + - - - - Nonvoters ({formatPercent(pollData.percent.nonVoters)} - ) + } + /> + + + Total Participation ( + {formatPercent( + +pollData.quorum / PERCENTAGE_PRECISION_MILLION + )}{" "} + needed) - - - {formatLPT(pollData.stake.nonVoters, { - precision: 4, - })} - + } + value={ + {formatPercent(pollData.percent.voters)} + } + meta={ + + + + Voters ({formatPercent(pollData.percent.voters)}) + + + + {formatLPT(pollData.stake.voters, { + precision: 4, + })} + + + + + + Nonvoters ( + {formatPercent(pollData.percent.nonVoters)}) + + + + {formatLPT(pollData.stake.nonVoters, { + precision: 4, + })} + + + - - - } - /> + } + /> + + + + {pollData.attributes?.text ?? ""} + + + + + + + {voteContent()} - - - {pollData.attributes?.text ?? ""} - - @@ -388,6 +508,7 @@ const Poll = () => { | undefined | null, myAccount: myAccountData as AccountQuery, + votesTabHref, }} /> @@ -415,6 +536,7 @@ const Poll = () => { | undefined | null, myAccount: myAccountData as AccountQuery, + votesTabHref, }} /> diff --git a/queries/poll.graphql b/queries/poll.graphql index fc928830..ff688e84 100644 --- a/queries/poll.graphql +++ b/queries/poll.graphql @@ -11,6 +11,10 @@ query poll($id: ID!) { } votes { id + choiceID + voter + voteStake + nonVoteStake } } } diff --git a/queries/vote.graphql b/queries/vote.graphql index 75eb8fa1..e0a448c0 100644 --- a/queries/vote.graphql +++ b/queries/vote.graphql @@ -3,5 +3,12 @@ query vote($id: ID!) { choiceID voteStake nonVoteStake + poll { + id + votes { + voteStake + id + } + } } } diff --git a/queries/voteEvents.graphql b/queries/voteEvents.graphql new file mode 100644 index 00000000..a0f4a124 --- /dev/null +++ b/queries/voteEvents.graphql @@ -0,0 +1,21 @@ +query voteEvents($first: Int, $where: VoteEvent_filter) { + voteEvents( + orderBy: timestamp + orderDirection: desc + first: $first + where: $where + ) { + id + choiceID + voter + timestamp + poll { + id + proposal + } + transaction { + id + timestamp + } + } +}