diff --git a/README.md b/README.md index a1d21eff..beb53f3f 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,8 @@ To run the application in production mode, follow these steps: | `PINATA_JWT` | JWT token used to create Polls data in IPFS for LIPs. | | `NEXT_PUBLIC_NETWORK` | The network/chain the Explorer will interact with. The default is `ARBITRUM_ONE`. | | `NEXT_PUBLIC_INFURA_KEY` | The private API key used to interact with the Infura RPC endpoints. If you prefer to use your own RPC, you can ignore this and instead set the RPC URLs in `NEXT_PUBLIC_L1_RPC_URL` and `NEXT_PUBLIC_L2_RPC_URL`. | -| `NEXT_PUBLIC_L1_RPC_URL` (Optional) | The L1 RPC URL endpoint to use if not using Infura, or as a supplementary fallback. | -| `NEXT_PUBLIC_L2_RPC_URL` (Optional) | The L2 RPC URL endpoint to use if not using Infura, or as a supplementary fallback. | +| `NEXT_PUBLIC_L1_RPC_URL` (Optional) | The L1 RPC URL endpoint to use if not using Infura, or as a supplementary fallback. | +| `NEXT_PUBLIC_L2_RPC_URL` (Optional) | The L2 RPC URL endpoint to use if not using Infura, or as a supplementary fallback. | | `NEXT_PUBLIC_SUBGRAPH_API_KEY` | The API key to interact with the Livepeer published subgraph. This is used for various functions such as displaying current round data. | | `NEXT_PUBLIC_SUBGRAPH_ID` | The ID of the Livepeer published subgraph. This is used for various functions such as displaying current round data. | | `NEXT_PUBLIC_SUBGRAPH_ENDPOINT` | Optional override for the subgraph URL. Must be a full URL (for example, a gateway deployments URL or a Studio URL). | diff --git a/apollo/subgraph.ts b/apollo/subgraph.ts index 388763d3..c91df564 100644 --- a/apollo/subgraph.ts +++ b/apollo/subgraph.ts @@ -9597,7 +9597,7 @@ export type EventsQueryVariables = Exact<{ }>; -export type EventsQuery = { __typename: 'Query', transactions: Array<{ __typename: 'Transaction', events?: Array<{ __typename: 'BondEvent', additionalAmount: string, delegator: { __typename: 'Delegator', id: string }, newDelegate: { __typename: 'Transcoder', id: string }, oldDelegate?: { __typename: 'Transcoder', id: string } | null, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'BurnEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'DepositFundedEvent', amount: string, sender: { __typename: 'Broadcaster', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'EarningsClaimedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'MigrateDelegatorFinalizedEvent', l1Addr: string, l2Addr: string, stake: string, delegatedStake: string, fees: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'MintEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'NewRoundEvent', transaction: { __typename: 'Transaction', from: string, id: string, timestamp: number }, round: { __typename: 'Round', id: string } } | { __typename: 'ParameterUpdateEvent', param: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'PauseEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'PollCreatedEvent', endBlock: string, poll: { __typename: 'Poll', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'RebondEvent', amount: string, delegate: { __typename: 'Transcoder', id: string }, delegator: { __typename: 'Delegator', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'ReserveClaimedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'ReserveFundedEvent', amount: string, reserveHolder: { __typename: 'Broadcaster', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'RewardEvent', rewardTokens: string, delegate: { __typename: 'Transcoder', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'ServiceURIUpdateEvent', addr: string, serviceURI: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'SetCurrentRewardTokensEvent', currentInflation: string, currentMintableTokens: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'StakeClaimedEvent', stake: string, fees: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'TranscoderActivatedEvent', activationRound: string, delegate: { __typename: 'Transcoder', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'TranscoderDeactivatedEvent', deactivationRound: string, delegate: { __typename: 'Transcoder', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'TranscoderEvictedEvent', delegate: { __typename: 'Transcoder', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'TranscoderResignedEvent', delegate: { __typename: 'Transcoder', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'TranscoderSlashedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'TranscoderUpdateEvent', rewardCut: string, feeShare: string, delegate: { __typename: 'Transcoder', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'TransferBondEvent', amount: string, newDelegator: { __typename: 'Delegator', id: string }, oldDelegator: { __typename: 'Delegator', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'TreasuryVoteEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'UnbondEvent', amount: string, delegate: { __typename: 'Transcoder', id: string }, delegator: { __typename: 'Delegator', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'UnpauseEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'VoteEvent', voter: string, choiceID: string, poll: { __typename: 'Poll', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'WinningTicketRedeemedEvent', faceValue: string, recipient: { __typename: 'Transcoder', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'WithdrawFeesEvent', amount: string, delegator: { __typename: 'Delegator', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'WithdrawStakeEvent', amount: string, delegator: { __typename: 'Delegator', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'WithdrawalEvent', deposit: string, reserve: string, sender: { __typename: 'Broadcaster', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } }> | null }>, transcoders: Array<{ __typename: 'Transcoder', id: string }> }; +export type EventsQuery = { __typename: 'Query', transactions: Array<{ __typename: 'Transaction', events?: Array<{ __typename: 'BondEvent', additionalAmount: string, delegator: { __typename: 'Delegator', id: string }, newDelegate: { __typename: 'Transcoder', id: string }, oldDelegate?: { __typename: 'Transcoder', id: string } | null, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'BurnEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'DepositFundedEvent', amount: string, sender: { __typename: 'Broadcaster', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'EarningsClaimedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'MigrateDelegatorFinalizedEvent', l1Addr: string, l2Addr: string, stake: string, delegatedStake: string, fees: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'MintEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'NewRoundEvent', transaction: { __typename: 'Transaction', from: string, id: string, timestamp: number }, round: { __typename: 'Round', id: string } } | { __typename: 'ParameterUpdateEvent', param: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'PauseEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'PollCreatedEvent', endBlock: string, poll: { __typename: 'Poll', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'RebondEvent', amount: string, delegate: { __typename: 'Transcoder', id: string }, delegator: { __typename: 'Delegator', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'ReserveClaimedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'ReserveFundedEvent', amount: string, reserveHolder: { __typename: 'Broadcaster', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'RewardEvent', rewardTokens: string, delegate: { __typename: 'Transcoder', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'ServiceURIUpdateEvent', addr: string, serviceURI: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'SetCurrentRewardTokensEvent', currentInflation: string, currentMintableTokens: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'StakeClaimedEvent', stake: string, fees: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'TranscoderActivatedEvent', activationRound: string, delegate: { __typename: 'Transcoder', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'TranscoderDeactivatedEvent', deactivationRound: string, delegate: { __typename: 'Transcoder', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'TranscoderEvictedEvent', delegate: { __typename: 'Transcoder', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'TranscoderResignedEvent', delegate: { __typename: 'Transcoder', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'TranscoderSlashedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'TranscoderUpdateEvent', rewardCut: string, feeShare: string, delegate: { __typename: 'Transcoder', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'TransferBondEvent', amount: string, newDelegator: { __typename: 'Delegator', id: string }, oldDelegator: { __typename: 'Delegator', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'TreasuryVoteEvent', support: TreasuryVoteSupport, proposal: { __typename: 'TreasuryProposal', id: string, description: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'UnbondEvent', amount: string, delegate: { __typename: 'Transcoder', id: string }, delegator: { __typename: 'Delegator', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'UnpauseEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'VoteEvent', voter: string, choiceID: string, poll: { __typename: 'Poll', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'WinningTicketRedeemedEvent', faceValue: string, recipient: { __typename: 'Transcoder', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'WithdrawFeesEvent', amount: string, delegator: { __typename: 'Delegator', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'WithdrawStakeEvent', amount: string, delegator: { __typename: 'Delegator', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } } | { __typename: 'WithdrawalEvent', deposit: string, reserve: string, sender: { __typename: 'Broadcaster', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number, from: string } }> | null }>, transcoders: Array<{ __typename: 'Transcoder', id: string }> }; export type MetaQueryVariables = Exact<{ [key: string]: never; }>; @@ -9653,7 +9653,17 @@ export type TransactionsQueryVariables = Exact<{ }>; -export type TransactionsQuery = { __typename: 'Query', transactions: Array<{ __typename: 'Transaction', events?: Array<{ __typename: 'BondEvent', additionalAmount: string, delegator: { __typename: 'Delegator', id: string }, newDelegate: { __typename: 'Transcoder', id: string }, oldDelegate?: { __typename: 'Transcoder', id: string } | null, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'BurnEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'DepositFundedEvent', amount: string, sender: { __typename: 'Broadcaster', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'EarningsClaimedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'MigrateDelegatorFinalizedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'MintEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'NewRoundEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'ParameterUpdateEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'PauseEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'PollCreatedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'RebondEvent', amount: string, delegate: { __typename: 'Transcoder', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'ReserveClaimedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'ReserveFundedEvent', amount: string, reserveHolder: { __typename: 'Broadcaster', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'RewardEvent', rewardTokens: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'ServiceURIUpdateEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'SetCurrentRewardTokensEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'StakeClaimedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TranscoderActivatedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TranscoderDeactivatedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TranscoderEvictedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TranscoderResignedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TranscoderSlashedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TranscoderUpdateEvent', rewardCut: string, feeShare: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TransferBondEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TreasuryVoteEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'UnbondEvent', amount: string, delegate: { __typename: 'Transcoder', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'UnpauseEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'VoteEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'WinningTicketRedeemedEvent', faceValue: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'WithdrawFeesEvent', amount: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'WithdrawStakeEvent', amount: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'WithdrawalEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } }> | null }>, winningTicketRedeemedEvents: Array<{ __typename: 'WinningTicketRedeemedEvent', id: string, faceValue: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } }> }; +export type TransactionsQuery = { __typename: 'Query', transactions: Array<{ __typename: 'Transaction', events?: Array<{ __typename: 'BondEvent', additionalAmount: string, delegator: { __typename: 'Delegator', id: string }, newDelegate: { __typename: 'Transcoder', id: string }, oldDelegate?: { __typename: 'Transcoder', id: string } | null, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'BurnEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'DepositFundedEvent', amount: string, sender: { __typename: 'Broadcaster', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'EarningsClaimedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'MigrateDelegatorFinalizedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'MintEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'NewRoundEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'ParameterUpdateEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'PauseEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'PollCreatedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'RebondEvent', amount: string, delegate: { __typename: 'Transcoder', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'ReserveClaimedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'ReserveFundedEvent', amount: string, reserveHolder: { __typename: 'Broadcaster', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'RewardEvent', rewardTokens: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'ServiceURIUpdateEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'SetCurrentRewardTokensEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'StakeClaimedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TranscoderActivatedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TranscoderDeactivatedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TranscoderEvictedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TranscoderResignedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TranscoderSlashedEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TranscoderUpdateEvent', rewardCut: string, feeShare: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TransferBondEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'TreasuryVoteEvent', id: string, reason?: string | null, support: TreasuryVoteSupport, timestamp: number, weight: string, proposal: { __typename: 'TreasuryProposal', id: string, targets: Array, description: string }, treasuryVoter: { __typename: 'LivepeerAccount', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'UnbondEvent', amount: string, delegate: { __typename: 'Transcoder', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'UnpauseEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'VoteEvent', voter: string, choiceID: string, id: string, timestamp: number, poll: { __typename: 'Poll', id: string, proposal: string, endBlock: string, quorum: string, quota: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number }, round: { __typename: 'Round', id: string } } | { __typename: 'WinningTicketRedeemedEvent', faceValue: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'WithdrawFeesEvent', amount: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'WithdrawStakeEvent', amount: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } } | { __typename: 'WithdrawalEvent', round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } }> | null }>, winningTicketRedeemedEvents: Array<{ __typename: 'WinningTicketRedeemedEvent', id: string, faceValue: string, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } }> }; + +export type TranscoderActivatedEventsQueryVariables = Exact<{ + where?: InputMaybe; + first?: InputMaybe; + orderBy?: InputMaybe; + orderDirection?: InputMaybe; +}>; + + +export type TranscoderActivatedEventsQuery = { __typename: 'Query', transcoderActivatedEvents: Array<{ __typename: 'TranscoderActivatedEvent', activationRound: string, id: string }> }; export type TreasuryProposalQueryVariables = Exact<{ id: Scalars['ID']; @@ -9662,11 +9672,30 @@ export type TreasuryProposalQueryVariables = Exact<{ export type TreasuryProposalQuery = { __typename: 'Query', treasuryProposal?: { __typename: 'TreasuryProposal', id: string, description: string, calldatas: Array, targets: Array, values: Array, voteEnd: string, voteStart: string, proposer: { __typename: 'LivepeerAccount', id: string } } | null }; -export type TreasuryProposalsQueryVariables = Exact<{ [key: string]: never; }>; +export type TreasuryProposalsQueryVariables = Exact<{ + where?: InputMaybe; + orderBy?: InputMaybe; + orderDirection?: InputMaybe; +}>; export type TreasuryProposalsQuery = { __typename: 'Query', treasuryProposals: Array<{ __typename: 'TreasuryProposal', id: string, description: string, calldatas: Array, targets: Array, values: Array, voteEnd: string, voteStart: string, proposer: { __typename: 'LivepeerAccount', id: string } }> }; +export type TreasuryVoteEventsQueryVariables = Exact<{ + first?: InputMaybe; + where?: InputMaybe; +}>; + + +export type TreasuryVoteEventsQuery = { __typename: 'Query', treasuryVoteEvents: Array<{ __typename: 'TreasuryVoteEvent', id: string, reason?: string | null, support: TreasuryVoteSupport, timestamp: number, weight: string, proposal: { __typename: 'TreasuryProposal', id: string, targets: Array, description: string }, voter: { __typename: 'LivepeerAccount', id: string }, round: { __typename: 'Round', id: string }, transaction: { __typename: 'Transaction', id: string, timestamp: number } }> }; + +export type TreasuryVotesQueryVariables = Exact<{ + where?: InputMaybe; +}>; + + +export type TreasuryVotesQuery = { __typename: 'Query', treasuryVotes: Array<{ __typename: 'TreasuryVote', id: string, reason?: string | null, support: TreasuryVoteSupport, weight: string, proposal: { __typename: 'TreasuryProposal', id: string, voteStart: string, voteEnd: string }, voter: { __typename: 'LivepeerAccount', id: string, delegate?: { __typename: 'Transcoder', id: string, activationRound: string, deactivationRound: string } | null } }> }; + export type VoteQueryVariables = Exact<{ id: Scalars['ID']; }>; @@ -9904,7 +9933,12 @@ export type DaysLazyQueryHookResult = ReturnType; export type DaysQueryResult = Apollo.QueryResult; export const EventsDocument = gql` query events($first: Int) { - transactions(first: $first, orderBy: timestamp, orderDirection: desc) { + transactions( + first: $first + orderBy: timestamp + orderDirection: desc + where: {timestamp_lt: 1768380104} + ) { events { __typename round { @@ -10066,6 +10100,13 @@ export const EventsDocument = gql` stake fees } + ... on TreasuryVoteEvent { + support + proposal { + id + description + } + } } } transcoders(where: {active: true}) { @@ -10507,6 +10548,48 @@ export const TransactionsDocument = gql` } amount } + ... on TreasuryVoteEvent { + id + reason + support + timestamp + proposal { + id + targets + description + } + treasuryVoter: voter { + id + } + weight + round { + id + } + transaction { + id + timestamp + } + } + ... on VoteEvent { + voter + poll { + id + proposal + endBlock + quorum + quota + } + transaction { + id + timestamp + } + choiceID + id + round { + id + } + timestamp + } } } winningTicketRedeemedEvents( @@ -10559,6 +10642,50 @@ export function useTransactionsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptio export type TransactionsQueryHookResult = ReturnType; export type TransactionsLazyQueryHookResult = ReturnType; export type TransactionsQueryResult = Apollo.QueryResult; +export const TranscoderActivatedEventsDocument = gql` + query transcoderActivatedEvents($where: TranscoderActivatedEvent_filter, $first: Int, $orderBy: TranscoderActivatedEvent_orderBy, $orderDirection: OrderDirection) { + transcoderActivatedEvents( + where: $where + first: $first + orderBy: $orderBy + orderDirection: $orderDirection + ) { + activationRound + id + } +} + `; + +/** + * __useTranscoderActivatedEventsQuery__ + * + * To run a query within a React component, call `useTranscoderActivatedEventsQuery` and pass it any options that fit your needs. + * When your component renders, `useTranscoderActivatedEventsQuery` 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 } = useTranscoderActivatedEventsQuery({ + * variables: { + * where: // value for 'where' + * first: // value for 'first' + * orderBy: // value for 'orderBy' + * orderDirection: // value for 'orderDirection' + * }, + * }); + */ +export function useTranscoderActivatedEventsQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(TranscoderActivatedEventsDocument, options); + } +export function useTranscoderActivatedEventsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(TranscoderActivatedEventsDocument, options); + } +export type TranscoderActivatedEventsQueryHookResult = ReturnType; +export type TranscoderActivatedEventsLazyQueryHookResult = ReturnType; +export type TranscoderActivatedEventsQueryResult = Apollo.QueryResult; export const TreasuryProposalDocument = gql` query treasuryProposal($id: ID!) { treasuryProposal(id: $id) { @@ -10604,8 +10731,12 @@ export type TreasuryProposalQueryHookResult = ReturnType; export type TreasuryProposalQueryResult = Apollo.QueryResult; export const TreasuryProposalsDocument = gql` - query treasuryProposals { - treasuryProposals(orderBy: voteStart, orderDirection: desc) { + query treasuryProposals($where: TreasuryProposal_filter, $orderBy: TreasuryProposal_orderBy = voteStart, $orderDirection: OrderDirection = desc) { + treasuryProposals( + where: $where + orderBy: $orderBy + orderDirection: $orderDirection + ) { id description calldatas @@ -10632,6 +10763,9 @@ export const TreasuryProposalsDocument = gql` * @example * const { data, loading, error } = useTreasuryProposalsQuery({ * variables: { + * where: // value for 'where' + * orderBy: // value for 'orderBy' + * orderDirection: // value for 'orderDirection' * }, * }); */ @@ -10646,6 +10780,117 @@ export function useTreasuryProposalsLazyQuery(baseOptions?: Apollo.LazyQueryHook export type TreasuryProposalsQueryHookResult = ReturnType; export type TreasuryProposalsLazyQueryHookResult = ReturnType; export type TreasuryProposalsQueryResult = Apollo.QueryResult; +export const TreasuryVoteEventsDocument = gql` + query treasuryVoteEvents($first: Int, $where: TreasuryVoteEvent_filter) { + treasuryVoteEvents( + orderBy: timestamp + orderDirection: desc + first: $first + where: $where + ) { + id + reason + support + timestamp + proposal { + id + targets + description + } + voter { + id + } + weight + round { + id + } + transaction { + id + timestamp + } + } +} + `; + +/** + * __useTreasuryVoteEventsQuery__ + * + * To run a query within a React component, call `useTreasuryVoteEventsQuery` and pass it any options that fit your needs. + * When your component renders, `useTreasuryVoteEventsQuery` 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 } = useTreasuryVoteEventsQuery({ + * variables: { + * first: // value for 'first' + * where: // value for 'where' + * }, + * }); + */ +export function useTreasuryVoteEventsQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(TreasuryVoteEventsDocument, options); + } +export function useTreasuryVoteEventsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(TreasuryVoteEventsDocument, options); + } +export type TreasuryVoteEventsQueryHookResult = ReturnType; +export type TreasuryVoteEventsLazyQueryHookResult = ReturnType; +export type TreasuryVoteEventsQueryResult = Apollo.QueryResult; +export const TreasuryVotesDocument = gql` + query treasuryVotes($where: TreasuryVote_filter) { + treasuryVotes(where: $where) { + id + reason + support + weight + proposal { + id + voteStart + voteEnd + } + voter { + id + delegate { + id + activationRound + deactivationRound + } + } + } +} + `; + +/** + * __useTreasuryVotesQuery__ + * + * To run a query within a React component, call `useTreasuryVotesQuery` and pass it any options that fit your needs. + * When your component renders, `useTreasuryVotesQuery` 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 } = useTreasuryVotesQuery({ + * variables: { + * where: // value for 'where' + * }, + * }); + */ +export function useTreasuryVotesQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(TreasuryVotesDocument, options); + } +export function useTreasuryVotesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(TreasuryVotesDocument, options); + } +export type TreasuryVotesQueryHookResult = ReturnType; +export type TreasuryVotesLazyQueryHookResult = ReturnType; +export type TreasuryVotesQueryResult = Apollo.QueryResult; export const VoteDocument = gql` query vote($id: ID!) { vote(id: $id) { diff --git a/components/EthAddressBadge/index.tsx b/components/EthAddressBadge/index.tsx new file mode 100644 index 00000000..5faf81f2 --- /dev/null +++ b/components/EthAddressBadge/index.tsx @@ -0,0 +1,21 @@ +import { Badge } from "@livepeer/design-system"; +import { useEnsData } from "hooks"; +import Link from "next/link"; + +interface EthAddressBadgeProps { + value: string | undefined; +} + +const EthAddressBadge = ({ value }: EthAddressBadgeProps) => { + const ensName = useEnsData(value); + + return ( + + + {ensName?.name ? ensName?.name : ensName?.idShort ?? ""} + + + ); +}; + +export default EthAddressBadge; diff --git a/components/HistoryView/index.tsx b/components/HistoryView/index.tsx index 1bfd9c97..cbc0ce70 100644 --- a/components/HistoryView/index.tsx +++ b/components/HistoryView/index.tsx @@ -1,20 +1,31 @@ import Spinner from "@components/Spinner"; +import TransactionBadge from "@components/TransactionBadge"; +import { Fm, parsePollIpfs } from "@lib/api/polls"; +import { parseProposalText, Proposal } from "@lib/api/treasury"; +import { POLL_VOTES, VOTING_SUPPORT_MAP } from "@lib/api/types/votes"; import dayjs from "@lib/dayjs"; -import { formatAddress, formatTransactionHash } from "@lib/utils"; +import { formatAddress } from "@lib/utils"; import { + Badge, Box, Card as CardBase, Flex, Link as A, styled, } from "@livepeer/design-system"; -import { ExternalLinkIcon } from "@modulz/radix-icons"; -import { useTransactionsQuery } from "apollo"; +import { + TransactionsQuery, + TreasuryVoteEvent, + TreasuryVoteSupport, + useTransactionsQuery, + VoteEvent, +} from "apollo"; import { CHAIN_INFO, DEFAULT_CHAIN_ID } from "lib/chains"; import { useRouter } from "next/router"; import numbro from "numbro"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import InfiniteScroll from "react-infinite-scroll-component"; +import { catIpfsJson, IpfsPoll } from "utils/ipfs"; const Card = styled(CardBase, { length: {}, @@ -28,16 +39,20 @@ const Index = () => { const query = router.query; const account = query.account as string; - const { data, loading, error, fetchMore, stopPolling } = useTransactionsQuery( - { - variables: { - account: account.toLowerCase(), - first: 10, - skip: 0, - }, - notifyOnNetworkStatusChange: true, - } - ); + const { + data, + loading, + error, + fetchMore: fetchMoreTransactions, + stopPolling, + } = useTransactionsQuery({ + variables: { + account: account.toLowerCase(), + first: 10, + skip: 0, + }, + notifyOnNetworkStatusChange: true, + }); const events = useMemo(() => { // First reverse the order of the array of events per transaction to have events in descending order @@ -50,26 +65,115 @@ const Index = () => { return reversedEvents?.flatMap(({ events: e }) => e ?? []) ?? []; }, [data]); + type TransactionEvent = NonNullable< + TransactionsQuery["transactions"][number]["events"] + >[number]; + const isType = + (t: T) => + (e: TransactionEvent): e is Extract => + e.__typename === t; + const isVoteEvent = isType("VoteEvent"); + const isTreasuryVoteEvent = isType("TreasuryVoteEvent"); + const lastEventTimestamp = useMemo( () => Number(events?.[(events?.length || 0) - 1]?.transaction?.timestamp ?? 0), [events] ); + const [extendedVoteEventsData, setExtendedVoteEventsData] = useState< + (VoteEvent & { attributes: Fm | null })[] + >([]); + useEffect(() => { + // Enrich poll vote events with parsed IPFS proposal metadata. + const getExtendedVoteEventsData = async () => { + const newVoteEvents = events + .filter(isVoteEvent) + .filter((e) => !extendedVoteEventsData.find((ve) => ve.id === e.id)); + const newExtendedVoteEventsData = await Promise.all( + newVoteEvents.map(async (voteEvent) => { + const ipfsObject = await catIpfsJson( + voteEvent.poll?.proposal + ); + const attributes = parsePollIpfs(ipfsObject); + return { + ...voteEvent, + attributes, + }; + }) || [] + ); + setExtendedVoteEventsData( + (current) => + [...current, ...newExtendedVoteEventsData] as (VoteEvent & { + attributes: Fm | null; + })[] + ); + }; + getExtendedVoteEventsData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [events]); + + const [extendedTreasuryVoteEventsData, setExtendedTreasuryVoteEventsData] = + useState<(TreasuryVoteEvent & { attributes: Fm | null })[]>([]); + useEffect(() => { + // Attach parsed treasury proposal attributes to treasury vote events. + const newTreasuryVoteEvents = events + .filter(isTreasuryVoteEvent) + .filter( + (e) => !extendedTreasuryVoteEventsData.find((te) => te.id === e.id) + ); + const newExtendedTreasureVoteEventsData = newTreasuryVoteEvents.map( + (treasuryVoteEvent) => { + const parsed = parseProposalText( + treasuryVoteEvent.proposal as Proposal + ); + return { + ...treasuryVoteEvent, + attributes: parsed.attributes, + }; + } + ); + setExtendedTreasuryVoteEventsData( + (current) => + [ + ...current, + ...newExtendedTreasureVoteEventsData, + ] as (TreasuryVoteEvent & { attributes: Fm | null })[] + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [events]); + // performs filtering of winning ticket redeemed events and merges with separate "winning tickets" // this is so Os winning tickets show properly: https://github.com/livepeer/explorer/issues/108 const mergedEvents = useMemo( () => [ - ...events.filter((e) => e?.__typename !== "WinningTicketRedeemedEvent"), + ...events.filter( + (e) => + e?.__typename !== "WinningTicketRedeemedEvent" && + e?.__typename !== "TreasuryVoteEvent" && + e?.__typename !== "VoteEvent" + ), ...(data?.winningTicketRedeemedEvents?.filter( (e) => (e?.transaction?.timestamp ?? 0) > lastEventTimestamp ) ?? []), + ...extendedTreasuryVoteEventsData.filter( + (e) => (e?.transaction?.timestamp ?? 0) > lastEventTimestamp + ), + ...extendedVoteEventsData.filter( + (e) => (e?.transaction?.timestamp ?? 0) > lastEventTimestamp + ), ].sort( (a, b) => (b?.transaction?.timestamp ?? 0) - (a?.transaction?.timestamp ?? 0) ), - [events, data, lastEventTimestamp] + [ + events, + data, + lastEventTimestamp, + extendedTreasuryVoteEventsData, + extendedVoteEventsData, + ] ); if (error) { @@ -104,7 +208,7 @@ const Index = () => { stopPolling(); if (!loading && data.transactions.length >= 10) { try { - await fetchMore({ + await fetchMoreTransactions({ variables: { skip: data.transactions.length, }, @@ -112,6 +216,7 @@ const Index = () => { if (!fetchMoreResult) { return previousResult; } + return { ...previousResult, transactions: [ @@ -203,19 +308,9 @@ function renderSwitch(event, i: number) { .format("MM/DD/YYYY h:mm:ss a")}{" "} - Round #{event.round.id} - - - {formatTransactionHash(event.transaction.id)} - - - + + + {" "} @@ -263,19 +358,9 @@ function renderSwitch(event, i: number) { .format("MM/DD/YYYY h:mm:ss a")}{" "} - Round #{event.round.id} - - - {formatTransactionHash(event.transaction.id)} - - - + + + Round # @@ -320,19 +405,9 @@ function renderSwitch(event, i: number) { .format("MM/DD/YYYY h:mm:ss a")}{" "} - Round #{event.round.id} - - - {formatTransactionHash(event.transaction.id)} - - - + + + {" "} @@ -382,19 +457,9 @@ function renderSwitch(event, i: number) { .format("MM/DD/YYYY h:mm:ss a")}{" "} - Round #{event.round.id} - - - {formatTransactionHash(event.transaction.id)} - - - + + + {" "} @@ -444,19 +509,9 @@ function renderSwitch(event, i: number) { .format("MM/DD/YYYY h:mm:ss a")}{" "} - Round #{event.round.id} - - - {formatTransactionHash(event.transaction.id)} - - - + + + {" "} @@ -504,19 +559,9 @@ function renderSwitch(event, i: number) { .format("MM/DD/YYYY h:mm:ss a")}{" "} - Round #{event.round.id} - - - {formatTransactionHash(event.transaction.id)} - - - + + + @@ -568,19 +613,9 @@ function renderSwitch(event, i: number) { .format("MM/DD/YYYY h:mm:ss a")}{" "} - Round #{event.round.id} - - - {formatTransactionHash(event.transaction.id)} - - - + + + {" "} @@ -627,19 +662,9 @@ function renderSwitch(event, i: number) { .format("MM/DD/YYYY h:mm:ss a")}{" "} - Round #{event.round.id} - - - {formatTransactionHash(event.transaction.id)} - - - + + + {" "} @@ -686,19 +711,9 @@ function renderSwitch(event, i: number) { .format("MM/DD/YYYY h:mm:ss a")}{" "} - Round #{event.round.id} - - - {formatTransactionHash(event.transaction.id)} - - - + + + {" "} @@ -746,19 +761,9 @@ function renderSwitch(event, i: number) { .format("MM/DD/YYYY h:mm:ss a")}{" "} - Round #{event.round.id} - - - {formatTransactionHash(event.transaction.id)} - - - + + + {" "} @@ -811,19 +816,9 @@ function renderSwitch(event, i: number) { .format("MM/DD/YYYY h:mm:ss a")}{" "} - Round #{event.round.id} - - - {formatTransactionHash(event.transaction.id)} - - - + + + {" "} @@ -839,6 +834,143 @@ function renderSwitch(event, i: number) { ); + case "TreasuryVoteEvent": + const supportTreasuryVoteEvent = + VOTING_SUPPORT_MAP[event.support] || + VOTING_SUPPORT_MAP[TreasuryVoteSupport.Abstain]; + return ( + + + + + Voted on treasury proposal " + {event.attributes?.title?.trim()}" + + + {dayjs + .unix(event.transaction.timestamp) + .format("MM/DD/YYYY h:mm:ss a")}{" "} + - Round #{event.round.id} + + + + + + + + + {supportTreasuryVoteEvent.text} + + + + + ); + case "VoteEvent": + const supportVoteEvent = POLL_VOTES[event.choiceID]; + if (!supportVoteEvent) { + return null; + } + return ( + + + + + Voted on poll "{event.attributes?.title?.trim()}" + + + {dayjs + .unix(event.transaction.timestamp) + .format("MM/DD/YYYY h:mm:ss a")}{" "} + - Round #{event.round.id} + + + + + + + + + {supportVoteEvent.text} + + + + + ); default: return null; } diff --git a/components/HorizontalScrollContainer/index.tsx b/components/HorizontalScrollContainer/index.tsx index 44ea0d80..5a0d95b4 100644 --- a/components/HorizontalScrollContainer/index.tsx +++ b/components/HorizontalScrollContainer/index.tsx @@ -97,8 +97,14 @@ const HorizontalScrollContainer = forwardRef< { const scores = useScoreData(transcoder?.id); const knownRegions = useRegionsData(); + const { data: firstTranscoderActivatedEventsData } = + useTranscoderActivatedEventsQuery({ + variables: { + where: { + delegate: transcoder?.id, + }, + first: 1, + orderBy: TranscoderActivatedEvent_OrderBy.ActivationRound, + orderDirection: OrderDirection.Asc, + }, + }); + + const firstActivationRound = useMemo(() => { + return firstTranscoderActivatedEventsData?.transcoderActivatedEvents[0] + ?.activationRound; + }, [firstTranscoderActivatedEventsData]); + + const { data: treasuryVotesData } = useTreasuryVotesQuery({ + variables: { + where: { + voter: transcoder?.id, + }, + }, + }); + + const { data: eligebleProposalsData } = useTreasuryProposalsQuery({ + variables: { + where: { + voteStart_gt: firstActivationRound, + }, + }, + skip: !firstActivationRound, + }); + + const govStats = useMemo(() => { + if (!treasuryVotesData || !eligebleProposalsData) return null; + return { + voted: treasuryVotesData?.treasuryVotes.length ?? 0, + eligible: eligebleProposalsData?.treasuryProposals.length ?? 0, + }; + }, [treasuryVotesData, eligebleProposalsData]); + const maxScore = useMemo(() => { const topTransData = Object.keys(scores?.scores ?? {}).reduce( (prev, curr) => { @@ -279,6 +329,111 @@ const Index = ({ currentRound, transcoder, isActive }: Props) => { } /> )} + + + Number of proposals voted on relative to the number of proposals + the orchestrator was eligible for while active. + + } + value={ + govStats ? ( + + {govStats.voted} + + / {govStats.eligible} Proposals + + + ) : ( + "N/A" + ) + } + meta={ + + {govStats && ( + + + + )} + + {govStats && ( + + {numbro(govStats.voted / govStats.eligible).format({ + output: "percent", + mantissa: 0, + })}{" "} + Participation + + )} + + See history + + + + + } + /> + ); diff --git a/components/PollVotingWidget/index.tsx b/components/PollVotingWidget/index.tsx new file mode 100644 index 00000000..1bfeb429 --- /dev/null +++ b/components/PollVotingWidget/index.tsx @@ -0,0 +1,641 @@ +import VoteButton from "@components/VoteButton"; +import { PollExtended } from "@lib/api/polls"; +import dayjs from "@lib/dayjs"; +import { abbreviateNumber, formatAddress } from "@lib/utils"; +import { + Box, + Button, + Dialog, + DialogClose, + DialogContent, + DialogTitle, + Flex, + Heading, + Text, + useSnackbar, +} from "@livepeer/design-system"; +import { + CheckCircledIcon, + Cross1Icon, + CrossCircledIcon, +} from "@radix-ui/react-icons"; +import { AccountQuery, PollChoice, TranscoderStatus } from "apollo"; +import { useAccountAddress, usePendingFeesAndStakeData } from "hooks"; +import { useEffect, useMemo, useState } from "react"; +import { CopyToClipboard } from "react-copy-to-clipboard"; +import { formatPercent, getVotingPower } from "utils/voting"; + +import Check from "../../public/img/check.svg"; +import Copy from "../../public/img/copy.svg"; + +type Props = { + poll: PollExtended; + delegateVote: + | { + __typename: "Vote"; + choiceID?: PollChoice; + voteStake: string; + nonVoteStake: string; + } + | undefined + | null; + vote: + | { + __typename: "Vote"; + choiceID?: PollChoice; + voteStake: string; + nonVoteStake: string; + } + | undefined + | null; + myAccount: AccountQuery; +}; + +const SectionLabel = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +const Index = ({ data }: { data: Props }) => { + const accountAddress = useAccountAddress(); + const [copied, setCopied] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [openSnackbar] = useSnackbar(); + + useEffect(() => { + if (copied) { + setTimeout(() => { + setCopied(false); + }, 5000); + } + }, [copied]); + + const pendingFeesAndStake = usePendingFeesAndStakeData( + data?.myAccount?.delegator?.id + ); + + const votingPower = useMemo( + () => + getVotingPower( + accountAddress ?? "", + data?.myAccount, + data?.vote, + pendingFeesAndStake?.pendingStake + ? pendingFeesAndStake?.pendingStake + : "0" + ), + [accountAddress, data, pendingFeesAndStake] + ); + + let delegate: { + __typename: "Transcoder"; + id: string; + active: boolean; + status: TranscoderStatus; + totalStake: string; + } | null = null; + + if (data?.myAccount?.delegator?.delegate) { + delegate = data?.myAccount?.delegator?.delegate; + } + + return ( + + + + Do you support LIP-{data?.poll?.attributes?.lip ?? "ERR"}? + + + {/* ========== RESULTS SECTION ========== */} + + Results + + + {/* For bar */} + + + + For + + + + + + + + {formatPercent(data.poll.percent.yes, 2)} + + + + {/* Against bar */} + + + + + Against + + + + + + + + + {formatPercent(data.poll.percent.no, 2)} + + + + + + {data.poll.votes.length}{" "} + {`${ + data.poll.votes.length > 1 || data.poll.votes.length === 0 + ? "votes" + : "vote" + }`}{" "} + · {abbreviateNumber(data.poll.stake.voters, 4)} LPT ·{" "} + {data.poll.status !== "active" + ? "Final Results" + : dayjs + .duration( + dayjs().unix() - data.poll.estimatedEndTime, + "seconds" + ) + .humanize() + " left"} + + + + {/* ========== YOUR VOTE SECTION ========== */} + {accountAddress ? ( + + Your vote + + + + My Delegate Vote{" "} + {delegate && `(${formatAddress(delegate?.id)})`} + + + {data?.delegateVote?.choiceID + ? data?.delegateVote?.choiceID === "Yes" + ? "For" + : "Against" + : "N/A"} + + + + + My Vote ({formatAddress(accountAddress)}) + + + {data?.vote?.choiceID + ? data?.vote?.choiceID === "Yes" + ? "For" + : "Against" + : "N/A"} + + + {((!data?.vote?.choiceID && data.poll.status === "active") || + data?.vote?.choiceID) && ( + + + My Voting Power + + + + {abbreviateNumber(votingPower, 4)} LPT ( + {( + (+votingPower / + (data.poll.stake.nonVoters + + data.poll.stake.voters)) * + 100 + ).toPrecision(2)} + %) + + + + )} + + {data.poll.status === "active" && ( + + )} + + ) : ( + + Your vote + + + + Connect your wallet to vote. + + + + )} + + + {data.poll.status === "active" && ( + + + Are you an orchestrator?{" "} + setModalOpen(true)} + css={{ color: "$primary11", cursor: "pointer" }} + > + Follow these instructions + {" "} + if you prefer to vote with the Livepeer CLI. + + + )} + + + + + + Livepeer CLI Voting Instructions + + + + + + + + + + + Run the Livepeer CLI and select the option to "Vote on a + poll". When prompted for a contract address, copy and paste + this poll's contract address: + + + {data.poll.id} + { + setCopied(true); + openSnackbar("Copied to clipboard"); + }} + > + + {copied ? ( + + ) : ( + + )} + + + + + + + The Livepeer CLI will prompt you for your vote. Enter 0 to vote + "For" or 1 to vote "Against". + + + + + Once your vote is confirmed, check back here to see it reflected + in the UI. + + + + + + + ); +}; + +export default Index; + +function PollVoteButton({ + vote, + poll, + pendingStake, +}: { + vote: Props["vote"]; + poll: Props["poll"]; + pendingStake: string; +}) { + switch (vote?.choiceID) { + case "Yes": + return ( + 0)} + css={{ + marginTop: "$4", + width: "100%", + backgroundColor: "$tomato3", + color: "$tomato11", + fontWeight: 600, + border: "1px solid $tomato4", + "&:hover": { + backgroundColor: "$tomato4", + borderColor: "$tomato5", + }, + }} + size="4" + choiceId={1} + pollAddress={poll?.id} + > + + + Change Vote To Against + + + ); + case "No": + return ( + 0)} + css={{ + marginTop: "$4", + width: "100%", + backgroundColor: "$grass3", + color: "$grass11", + fontWeight: 600, + border: "1px solid $grass4", + "&:hover": { + backgroundColor: "$grass4", + borderColor: "$grass5", + }, + }} + size="4" + choiceId={0} + pollAddress={poll?.id} + > + + + Change Vote To For + + + ); + default: + return ( + + 0)} + css={{ + backgroundColor: "$grass3", + color: "$grass11", + fontWeight: 600, + border: "1px solid $grass4", + "&:hover": { + backgroundColor: "$grass4", + borderColor: "$grass5", + }, + }} + choiceId={0} + size="4" + pollAddress={poll?.id} + > + + + For + + + 0)} + css={{ + backgroundColor: "$tomato3", + color: "$tomato11", + fontWeight: 600, + border: "1px solid $tomato4", + "&:hover": { + backgroundColor: "$tomato4", + borderColor: "$tomato5", + }, + }} + size="4" + choiceId={1} + pollAddress={poll?.id} + > + + + Against + + + + ); + } +} diff --git a/components/Table/index.tsx b/components/Table/index.tsx index c0d2df11..a58a2d7b 100644 --- a/components/Table/index.tsx +++ b/components/Table/index.tsx @@ -77,7 +77,7 @@ function DataTable({ <> {input && ( @@ -95,7 +95,7 @@ function DataTable({ css={{ borderCollapse: "collapse", tableLayout: "auto", - minWidth: 980, + minWidth: 960, width: "100%", "@bp4": { width: "100%", diff --git a/components/TransactionBadge/index.tsx b/components/TransactionBadge/index.tsx new file mode 100644 index 00000000..cac3d7d0 --- /dev/null +++ b/components/TransactionBadge/index.tsx @@ -0,0 +1,52 @@ +import { formatTransactionHash } from "@lib/utils"; +import { Badge, Box, Link as A } from "@livepeer/design-system"; +import { ArrowTopRightIcon } from "@modulz/radix-icons"; + +interface TransactionBadgeProps { + id: string | undefined; +} + +const TransactionBadge = ({ id }: TransactionBadgeProps) => { + return ( + *": { + border: "1.5px solid $grass7 !important", + backgroundColor: "$grass3 !important", + color: "$grass11 !important", + }, + }} + > + + {formatTransactionHash(id)} + + + + ); +}; + +export default TransactionBadge; diff --git a/components/TransactionsList/index.tsx b/components/TransactionsList/index.tsx index f887da8a..b1b10d78 100644 --- a/components/TransactionsList/index.tsx +++ b/components/TransactionsList/index.tsx @@ -1,12 +1,12 @@ +import EthAddressBadge from "@components/EthAddressBadge"; import Table from "@components/Table"; +import TransactionBadge from "@components/TransactionBadge"; +import { parseProposalText } from "@lib/api/treasury"; +import { VOTING_SUPPORT_MAP } from "@lib/api/types/votes"; import dayjs from "@lib/dayjs"; -import { formatTransactionHash } from "@lib/utils"; import { Badge, Box, Flex, Link as A, Text } from "@livepeer/design-system"; -import { ArrowTopRightIcon } from "@modulz/radix-icons"; -import { EventsQueryResult } from "apollo"; +import { EventsQueryResult, TreasuryProposal } from "apollo"; import { sentenceCase } from "change-case"; -import { useEnsData } from "hooks"; -import Link from "next/link"; import numbro from "numbro"; import { useCallback, useMemo } from "react"; @@ -57,39 +57,6 @@ const getPercentAmount = (number: number | string | undefined) => { ); }; -const EthAddress = (props: { value: string | undefined }) => { - const ensName = useEnsData(props.value); - - return ( - - - {ensName?.name ? ensName?.name : ensName?.idShort ?? ""} - - - ); -}; - -const Transaction = (props: { id: string | undefined }) => { - return ( - - - {props.id ? formatTransactionHash(props.id) : "N/A"} - - - - ); -}; - const renderEmoji = (emoji: string) => ( {emoji} @@ -113,86 +80,89 @@ const TransactionsList = ({ ) => { switch (event.__typename) { case "BondEvent": - return ; + return ; case "UnbondEvent": - return ; + return ; case "RebondEvent": - return ; + return ; case "TranscoderUpdateEvent": - return ; + return ; case "RewardEvent": - return ; + return ; case "WithdrawStakeEvent": - return ; + return ; case "WithdrawFeesEvent": - return ; + return ; case "WinningTicketRedeemedEvent": - return ; + return ; case "DepositFundedEvent": - return ; + return ; case "ReserveFundedEvent": - return ; + return ; case "TransferBondEvent": - return ; + return ; case "TranscoderActivatedEvent": - return ; + return ; case "TranscoderDeactivatedEvent": - return ; + return ; // case "EarningsClaimedEvent": - // return ; + // return ; case "TranscoderResignedEvent": - return ; + return ; case "TranscoderEvictedEvent": - return ; + return ; case "NewRoundEvent": - return ; + return ; case "WithdrawalEvent": - return ; + return ; case "SetCurrentRewardTokensEvent": - return ; + return ; case "PauseEvent": - return ; + return ; case "UnpauseEvent": - return ; + return ; case "ParameterUpdateEvent": - return ; + return ; case "VoteEvent": - return ; + return ; case "PollCreatedEvent": - return ; + return ; case "ServiceURIUpdateEvent": - return ; + return ; // case "MintEvent": - // return ; + // return ; case "BurnEvent": - return ; + return ; case "MigrateDelegatorFinalizedEvent": - return ; + return ; case "StakeClaimedEvent": - return ; + return ; + + case "TreasuryVoteEvent": + return ; default: return {`Error fetching event information.`}; @@ -212,16 +182,16 @@ const TransactionsList = ({ return event?.additionalAmount === "0" && event?.oldDelegate?.id ? ( {`Migrated from `} - + {` to `} - + ) : ( {`Delegated `} {getLptAmount(event?.additionalAmount)} {` to `} - + ); case "UnbondEvent": @@ -230,7 +200,7 @@ const TransactionsList = ({ {`Undelegated `} {getLptAmount(event.amount)} {` from `} - + ); case "RebondEvent": @@ -239,7 +209,7 @@ const TransactionsList = ({ {`Rebonded `} {getLptAmount(event.amount)} {` to `} - + ); case "TranscoderUpdateEvent": @@ -303,9 +273,9 @@ const TransactionsList = ({ {getLptAmount(Number(event?.amount))} {` was transferred between `} - + {` and `} - + ); case "TranscoderActivatedEvent": @@ -379,10 +349,14 @@ const TransactionsList = ({ {`Voted `} - {+event?.choiceID === 0 ? '"Yes"' : '"No"'} + {+event?.choiceID === 0 ? '"For"' : '"Against"'} {` on a proposal`} {renderEmoji("👩‍⚖️")} @@ -392,7 +366,7 @@ const TransactionsList = ({ return ( {`Poll `} - + {` has been created and will end on block ${getRound( event?.endBlock )}`} @@ -425,7 +399,24 @@ const TransactionsList = ({ {` from L1 Ethereum`} ); + case "TreasuryVoteEvent": { + const support = VOTING_SUPPORT_MAP[event.support]; + const title = parseProposalText(event.proposal as TreasuryProposal) + .attributes.title; + return ( + + Voted{" "} + + "{support.text}" + {" "} + on{" "} + + {title} + + + ); + } default: return {`Error fetching event information.`}; } @@ -544,7 +535,7 @@ const TransactionsList = ({ }} size="2" > - + ), diff --git a/components/TreasuryProposalRow/index.tsx b/components/Treasury/TreasuryProposalRow/index.tsx similarity index 96% rename from components/TreasuryProposalRow/index.tsx rename to components/Treasury/TreasuryProposalRow/index.tsx index f6ec6611..e42afe45 100644 --- a/components/TreasuryProposalRow/index.tsx +++ b/components/Treasury/TreasuryProposalRow/index.tsx @@ -81,9 +81,11 @@ const TreasuryProposalRow = ({ flexDirection: "column-reverse", justifyContent: "space-between", alignItems: "flex-start", + gap: "$2", "@bp2": { flexDirection: "row", alignItems: "center", + gap: 0, }, }} > @@ -116,6 +118,8 @@ const TreasuryProposalRow = ({ css={{ textTransform: "capitalize", fontWeight: 700, + marginLeft: "-$1", + "@bp2": { marginLeft: 0 }, }} > {sentenceCase( diff --git a/components/Treasury/TreasuryVoteTable/TreasuryVoteDetail.tsx b/components/Treasury/TreasuryVoteTable/TreasuryVoteDetail.tsx new file mode 100644 index 00000000..80797173 --- /dev/null +++ b/components/Treasury/TreasuryVoteTable/TreasuryVoteDetail.tsx @@ -0,0 +1,356 @@ +import TransactionBadge from "@components/TransactionBadge"; +import { parseProposalText } from "@lib/api/treasury"; +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 { ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons"; +import { TreasuryVoteEvent, TreasuryVoteSupport } from "apollo"; +import React, { useState } from "react"; + +interface TreasuryVoteDetailProps { + vote: TreasuryVoteEvent; + formatWeight: (weight: string) => string; +} + +const Index: React.FC = ({ vote, formatWeight }) => { + const [isExpanded, setIsExpanded] = useState(false); + const support = + VOTING_SUPPORT_MAP[vote.support] || + VOTING_SUPPORT_MAP[TreasuryVoteSupport.Abstain]; + const hasReason = + vote.reason && vote.reason.toLowerCase() !== "no reason provided"; + + const title = parseProposalText(vote.proposal).attributes.title; + const reasonId = `reason-${vote.transaction.id}`; + + return ( + + {/* Mobile Card Layout */} + + + {/* Hero: Vote badge */} + + + {support.text} + + + {/* Title link */} + + + {title} + + + + {/* Weight */} + + {formatWeight(vote.weight)} + + + {/* Collapsible Reason */} + {hasReason && ( + + setIsExpanded(!isExpanded)} + aria-expanded={isExpanded} + aria-controls={reasonId} + css={{ + display: "flex", + alignItems: "center", + gap: "$1", + color: "$primary11", + cursor: "pointer", + border: "none", + backgroundColor: "transparent", + padding: "$2", + margin: "-$2", + borderRadius: "$1", + minHeight: "44px", + fontSize: "$1", + fontWeight: 600, + transition: "background-color 0.2s ease", + "&:hover": { + backgroundColor: "$neutral3", + }, + "&:focus-visible": { + outline: "2px solid $primary11", + outlineOffset: "2px", + }, + }} + > + + {isExpanded ? "Hide reason" : "Show reason"} + + {isExpanded && ( + + + “{vote.reason}” + + + )} + + )} + + {/* 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 */} + + {formatWeight(vote.weight)} + + + {/* Collapsible Reason */} + {hasReason && ( + + setIsExpanded(!isExpanded)} + aria-expanded={isExpanded} + aria-controls={reasonId} + css={{ + display: "flex", + alignItems: "center", + gap: "$1", + color: "$primary11", + cursor: "pointer", + border: "none", + backgroundColor: "transparent", + padding: "$2", + margin: "-$2", + borderRadius: "$1", + minHeight: "44px", + fontSize: "$1", + fontWeight: 600, + transition: "background-color 0.2s ease", + "&:hover": { + backgroundColor: "$neutral3", + }, + "&:focus-visible": { + outline: "2px solid $primary11", + outlineOffset: "2px", + }, + }} + > + + {isExpanded ? "Hide reason" : "Show reason"} + + {isExpanded && ( + + + “{vote.reason}” + + + )} + + )} + + {/* 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/Treasury/TreasuryVoteTable/TreasuryVoteHistoryModal.tsx b/components/Treasury/TreasuryVoteTable/TreasuryVoteHistoryModal.tsx new file mode 100644 index 00000000..68dea1b2 --- /dev/null +++ b/components/Treasury/TreasuryVoteTable/TreasuryVoteHistoryModal.tsx @@ -0,0 +1,222 @@ +import { Box, Text } from "@livepeer/design-system"; +import { Cross1Icon } from "@radix-ui/react-icons"; +import React, { useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; + +interface TreasuryVoteHistoryModalProps { + onClose: () => void; + children: React.ReactNode; + title?: string; + header?: React.ReactNode; +} + +const Index: React.FC = ({ + onClose, + children, + title, + header, +}) => { + const modalRef = useRef(null); + + useEffect(() => { + // Disable scroll on mount + const originalStyle = window.getComputedStyle(document.body).overflow; + document.body.style.overflow = "hidden"; + + // Focus management + const focusableElementsSelector = + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + const firstFocusableElement = modalRef.current?.querySelectorAll( + focusableElementsSelector + )[0] as HTMLElement; + + if (firstFocusableElement) { + firstFocusableElement.focus(); + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + + if (e.key === "Tab") { + const focusableContent = modalRef.current?.querySelectorAll( + focusableElementsSelector + ); + if (!focusableContent) return; + + const focusableArray = Array.from(focusableContent) as HTMLElement[]; + const firstElement = focusableArray[0]; + const lastElement = focusableArray[focusableArray.length - 1]; + + if (e.shiftKey) { + // if shift key pressed for shift + tab combination + if (document.activeElement === firstElement) { + lastElement.focus(); // add focus for the last focusable element + e.preventDefault(); + } + } else { + // if tab key is pressed + if (document.activeElement === lastElement) { + // if focused has reached to last element then focus again first element + firstElement.focus(); // add focus for the first focusable element + e.preventDefault(); + } + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + + return () => { + document.body.style.overflow = originalStyle; + window.removeEventListener("keydown", handleKeyDown); + }; + }, [onClose]); + + return createPortal( + + e.stopPropagation()} + > + + + {title && ( + + {title} + + )} + + + ESC TO CLOSE + + + + + + + {header && {header}} + + + + {children} + + + , + document.body + ); +}; + +export default Index; diff --git a/components/Treasury/TreasuryVoteTable/TreasuryVotePopover.tsx b/components/Treasury/TreasuryVoteTable/TreasuryVotePopover.tsx new file mode 100644 index 00000000..b150e5c5 --- /dev/null +++ b/components/Treasury/TreasuryVoteTable/TreasuryVotePopover.tsx @@ -0,0 +1,252 @@ +import Spinner from "@components/Spinner"; +import { TREASURY_VOTES } from "@lib/api/types/votes"; +import { Badge, Box, Flex, Link, Text } from "@livepeer/design-system"; +import { ArrowTopRightIcon } from "@radix-ui/react-icons"; +import { + TreasuryVoteEvent, + TreasuryVoteSupport, + useTreasuryVoteEventsQuery, +} from "apollo"; +import React from "react"; + +import TreasuryVoteDetail from "./TreasuryVoteDetail"; +import TreasuryVoteHistoryModal from "./TreasuryVoteHistoryModal"; + +interface TreasuryVotePopoverProps { + voter: string; + ensName?: string; + onClose: () => void; + formatWeight: (weight: string) => string; +} + +const Index: React.FC = ({ + voter, + ensName, + onClose, + formatWeight, +}) => { + const { data: votesData, loading: isLoading } = useTreasuryVoteEventsQuery({ + variables: { + where: { + voter: voter, + }, + }, + }); + + const votes = React.useMemo(() => { + return votesData?.treasuryVoteEvents + ? [...votesData.treasuryVoteEvents].sort( + (a, b) => b.transaction.timestamp - a.transaction.timestamp + ) + : []; + }, [votesData]); + + const stats = React.useMemo(() => { + if (!votes.length) return null; + return { + total: votes.length, + for: votes.filter((v) => v.support === TreasuryVoteSupport.For).length, + against: votes.filter((v) => v.support === TreasuryVoteSupport.Against) + .length, + abstain: votes.filter((v) => v.support === TreasuryVoteSupport.Abstain) + .length, + }; + }, [votes]); + + const summaryHeader = React.useMemo(() => { + return ( + + + + {ensName || voter} + + + {stats && ( + + + Total: + + + {stats.total} + + + )} + + {stats && ( + + + + Total: + + + {stats.total} + + + + + For: {stats.for} + + + + Against: {stats.against} + + + + Abstain: {stats.abstain} + + + )} + + ); + }, [stats, voter, ensName]); + + return ( + + {isLoading ? ( + + + + ) : votes.length > 0 ? ( + + {votes.map((vote, idx) => ( + + + + ))} + + ) : ( + + No votes found for this voter. + + )} + + ); +}; + +export default Index; diff --git a/components/Treasury/TreasuryVoteTable/Views/DesktopVoteTable.tsx b/components/Treasury/TreasuryVoteTable/Views/DesktopVoteTable.tsx new file mode 100644 index 00000000..6d512f79 --- /dev/null +++ b/components/Treasury/TreasuryVoteTable/Views/DesktopVoteTable.tsx @@ -0,0 +1,262 @@ +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 { TreasuryVote, TreasuryVoteSupport } from "apollo"; +import React, { useMemo } from "react"; +import { Column } from "react-table"; + +import { VoteReasonPopover } from "./VoteReasonPopover"; + +export type Vote = TreasuryVote & { + ensName?: string; + transactionHash?: string; + timestamp?: number; +}; + +export interface TreasuryVoteTableProps { + votes: Vote[]; + formatWeight: (weight: string) => string; + onSelect: (voter: { address: string; ensName?: string }) => void; + pageSize?: number; + totalPages?: number; + currentPage?: number; + onPageChange?: (page: number) => void; +} + +export const DesktopVoteTable: React.FC = ({ + votes, + formatWeight, + onSelect, + pageSize = 10, +}) => { + const columns = useMemo[]>( + () => [ + { + Header: "Voter", + accessor: "ensName", + id: "voter", + Cell: ({ row }) => ( + + + + ), + }, + { + Header: "Support", + accessor: "support", + id: "support", + Cell: ({ row }) => { + const support = + VOTING_SUPPORT_MAP[row.original.support] || + VOTING_SUPPORT_MAP[TreasuryVoteSupport.Abstain]; + + return ( + + + + {support.text} + + + ); + }, + }, + { + Header: "Weight", + accessor: "weight", + id: "weight", + Cell: ({ row }) => ( + + + {formatWeight(row.original.weight)} + + + ), + sortType: (rowA, rowB) => { + return ( + parseFloat(rowA.original.weight) - parseFloat(rowB.original.weight) + ); + }, + }, + { + Header: "Reason", + accessor: "reason", + id: "reason", + Cell: ({ row }) => { + const reason = row.original.reason?.trim(); + const isEmpty = + !reason || reason.toLowerCase() === "no reason provided"; + + const isLongReason = reason && reason.length > 50; + + return ( + + {!isEmpty ? ( + isLongReason ? ( + + + {reason} + + + ) : ( + + {reason} + + ) + ) : ( + + — + + )} + + ); + }, + }, + { + 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.id, + ensName: row.original.ensName, + }); + }} + 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/Treasury/TreasuryVoteTable/Views/MobileVoteTable.tsx b/components/Treasury/TreasuryVoteTable/Views/MobileVoteTable.tsx new file mode 100644 index 00000000..4572fd40 --- /dev/null +++ b/components/Treasury/TreasuryVoteTable/Views/MobileVoteTable.tsx @@ -0,0 +1,58 @@ +import Pagination from "@components/Table/Pagination"; +import { Box, Text } from "@livepeer/design-system"; +import React from "react"; + +import { TreasuryVoteTableProps } from "./DesktopVoteTable"; +import { VoteView } from "./VoteItem"; + +export const MobileVoteCards: React.FC = (props) => { + const { + votes, + formatWeight, + onSelect, + totalPages = 0, + currentPage = 1, + 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/Treasury/TreasuryVoteTable/Views/VoteItem.tsx b/components/Treasury/TreasuryVoteTable/Views/VoteItem.tsx new file mode 100644 index 00000000..f94c508e --- /dev/null +++ b/components/Treasury/TreasuryVoteTable/Views/VoteItem.tsx @@ -0,0 +1,531 @@ +import { ExplorerTooltip } from "@components/ExplorerTooltip"; +import { VOTING_SUPPORT_MAP } from "@lib/api/types/votes"; +import dayjs from "@lib/dayjs"; +import { formatTransactionHash } from "@lib/utils"; +import { + Badge, + Box, + Card, + Flex, + Heading, + Link, + Text, +} from "@livepeer/design-system"; +import { + ArrowTopRightIcon, + ChevronDownIcon, + ChevronUpIcon, + CounterClockwiseClockIcon, +} from "@radix-ui/react-icons"; +import { TreasuryVoteSupport } from "apollo/subgraph"; +import { useState } from "react"; + +import { Vote } from "./DesktopVoteTable"; +import { VoteReasonPopover } from "./VoteReasonPopover"; + +interface VoteViewProps { + vote: Vote; + onSelect: (voter: { address: string; ensName?: string }) => void; + formatWeight: (weight: string) => string; + isMobile?: boolean; +} + +export function VoteView({ + vote, + onSelect, + formatWeight, + isMobile, +}: VoteViewProps) { + return isMobile ? ( + + ) : ( + + ); +} + +function MobileVoteView({ vote, onSelect, formatWeight }: VoteViewProps) { + const [isExpanded, setIsExpanded] = useState(false); + const support = + VOTING_SUPPORT_MAP[vote.support] || + VOTING_SUPPORT_MAP[TreasuryVoteSupport.Abstain]; + const hasReason = + vote.reason && vote.reason.toLowerCase() !== "no reason provided"; + const reasonId = `reason-${vote.transactionHash || vote.voter.id}`; + + return ( + + + {/* Hero: Vote badge */} + + + {support.text} + + + {/* Voter name + History */} + + + + {vote.ensName} + + + + onSelect({ address: vote.voter.id, ensName: vote.ensName }) + } + > + + History + + + + + + {/* Weight */} + + {formatWeight(vote.weight)} + + + {/* Collapsible Reason */} + {hasReason && ( + + setIsExpanded(!isExpanded)} + aria-expanded={isExpanded} + aria-controls={reasonId} + css={{ + display: "flex", + alignItems: "center", + gap: "$1", + color: "$primary11", + cursor: "pointer", + border: "none", + backgroundColor: "transparent", + padding: "$2", + margin: "-$2", + minHeight: "44px", + fontSize: "$1", + fontWeight: 600, + }} + > + + {isExpanded ? "Hide reason" : "Show reason"} + + {isExpanded && ( + + + “{vote.reason}” + + + )} + + )} + + {/* 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")} + + + )} + + + + ); +} + +function DesktopVoteView({ vote, onSelect, formatWeight }: VoteViewProps) { + const support = + VOTING_SUPPORT_MAP[vote.support] || + VOTING_SUPPORT_MAP[TreasuryVoteSupport.Abstain]; + + return ( + td": { padding: "$2 $3" }, + }} + > + + e.stopPropagation()} + > + + {vote.ensName} + + + + + + + {support.text} + + + + + {formatWeight(vote.weight)} + + + + + {!vote.reason || + vote.reason.toLowerCase() === "no reason provided" ? ( + + — + + ) : (vote.reason?.length ?? 0) > 50 ? ( + + + {vote.reason} + + + ) : ( + + {vote.reason} + + )} + + + + {vote.transactionHash ? ( + e.stopPropagation()} + css={{ + display: "inline-flex", + textDecoration: "none", + transition: "transform 0.2s ease", + "&:hover": { + transform: "scale(1.02)", + textDecoration: "none", + }, + "&:hover > *": { + borderColor: "$grass4 !important", + backgroundColor: "$grass3 !important", + color: "$grass11 !important", + }, + }} + > + + {formatTransactionHash(vote.transactionHash)} + + + + ) : ( + N/A + )} + + + + { + e.stopPropagation(); + onSelect({ address: vote.voter.id, ensName: vote.ensName }); + }} + 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)", + }, + "&:focus-visible": { + outline: "2px solid $primary11", + outlineOffset: "2px", + color: "$primary11", + backgroundColor: "$primary3", + }, + transition: "all 0.2s", + }} + > + + + + + + ); +} diff --git a/components/Treasury/TreasuryVoteTable/Views/VoteReasonPopover.tsx b/components/Treasury/TreasuryVoteTable/Views/VoteReasonPopover.tsx new file mode 100644 index 00000000..18e787aa --- /dev/null +++ b/components/Treasury/TreasuryVoteTable/Views/VoteReasonPopover.tsx @@ -0,0 +1,117 @@ +import { + Box, + HoverCardArrow, + HoverCardContent, + HoverCardRoot, + HoverCardTrigger, + styled, + Text, +} from "@livepeer/design-system"; +import { ChatBubbleIcon } from "@radix-ui/react-icons"; +import React from "react"; + +const Content = styled(HoverCardContent, { + width: 320, + padding: "$3", + backgroundColor: "$neutral3", + border: "1px solid $neutral5", + borderRadius: "$3", + boxShadow: + "0px 10px 38px -10px rgba(22, 23, 24, 0.35), 0px 10px 20px -15px rgba(22, 23, 24, 0.2)", + zIndex: 100, + outline: "none", + animationDuration: "400ms", + animationTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)", + willChange: "transform, opacity", +}); + +interface VoteReasonPopoverProps { + reason: string; + children?: React.ReactNode; +} + +export function VoteReasonPopover({ + reason, + children, +}: VoteReasonPopoverProps) { + if (!reason || reason.toLowerCase() === "no reason provided") { + return null; + } + + return ( + + + {children || ( + + + + Reason + + + )} + + + + + Vote Reason + + + + {reason} + + + + + + + ); +} diff --git a/components/Treasury/TreasuryVoteTable/index.tsx b/components/Treasury/TreasuryVoteTable/index.tsx new file mode 100644 index 00000000..583ab35c --- /dev/null +++ b/components/Treasury/TreasuryVoteTable/index.tsx @@ -0,0 +1,203 @@ +import Spinner from "@components/Spinner"; +import { getEnsForVotes } from "@lib/api/ens"; +import { formatAddress, lptFormatter } from "@lib/utils"; +import { Flex, Text } from "@livepeer/design-system"; +import { useTreasuryVoteEventsQuery, useTreasuryVotesQuery } from "apollo"; +import React, { useEffect, useMemo, useState } from "react"; +import { useWindowSize } from "react-use"; + +import TreasuryVotePopover from "./TreasuryVotePopover"; +import { DesktopVoteTable, Vote } from "./Views/DesktopVoteTable"; +import { MobileVoteCards } from "./Views/MobileVoteTable"; + +interface TreasuryVoteTableProps { + proposalId: string; +} + +const useVotes = (proposalId: string) => { + const { + data: treasuryVotesData, + loading, + error, + } = useTreasuryVotesQuery({ + variables: { + where: { + proposal: proposalId, + }, + }, + }); + + const { + data: treasuryVoteEventsData, + loading: treasuryVoteEventsLoading, + error: treasuryVoteEventsError, + } = useTreasuryVoteEventsQuery({ + variables: { + first: 200, + where: { + proposal: proposalId, + }, + }, + }); + + const [votes, setVotes] = useState([]); + const [votesLoading, setVotesLoading] = useState(false); + useEffect(() => { + if ( + !treasuryVotesData?.treasuryVotes || + !treasuryVoteEventsData?.treasuryVoteEvents + ) { + setVotes([]); + } + const decorateVotes = async () => { + setVotesLoading(true); + const uniqueVoters = Array.from( + new Set(treasuryVotesData?.treasuryVotes?.map((v) => v.voter.id) ?? []) + ); + 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 = + treasuryVotesData?.treasuryVotes?.map((vote) => { + const events = (treasuryVoteEventsData?.treasuryVoteEvents ?? []) + .filter((event) => event.voter.id === vote.voter.id) + .sort((a, b) => b.timestamp - a.timestamp); + + const latestEvent = events[0]; + const ensName = localEnsCache[vote.voter.id] ?? ""; + + return { + ...vote, + reason: latestEvent?.reason || vote.reason || "", + ensName, + transactionHash: latestEvent?.transaction.id ?? "", + timestamp: latestEvent?.timestamp, + }; + }) ?? []; + setVotes(votes as Vote[]); + setVotesLoading(false); + }; + decorateVotes(); + }, [ + treasuryVotesData?.treasuryVotes, + treasuryVoteEventsData?.treasuryVoteEvents, + ]); + + return { + votes, + loading: loading || votesLoading || treasuryVoteEventsLoading, + error: error || treasuryVoteEventsError, + }; +}; + +const Index: React.FC = ({ proposalId }) => { + const { width } = useWindowSize(); + const isDesktop = width >= 900; + + const [selectedVoter, setSelectedVoter] = useState<{ + address: string; + ensName?: string; + } | null>(null); + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 10; + + const { votes, loading, error } = useVotes(proposalId); + const totalWeight = useMemo( + () => votes.reduce((sum, v) => sum + parseFloat(v.weight), 0), + [votes] + ); + + const formatWeight = useMemo( + () => (w: string) => + `${lptFormatter.format(parseFloat(w))} LPT (${ + totalWeight > 0 ? ((parseFloat(w) / totalWeight) * 100).toFixed(2) : "0" + }%)`, + [totalWeight] + ); + + const paginatedVotesForMobile = useMemo(() => { + const sorted = [...votes].sort( + (a, b) => parseFloat(b.weight) - parseFloat(a.weight) + ); + 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 (error) + return ( + + Error loading votes: {error.message} + + ); + + if (!votes.length) + return ( + + No votes found for this proposal. + + ); + + return ( + <> + {isDesktop ? ( + + ) : ( + + )} + {selectedVoter && ( + setSelectedVoter(null)} + formatWeight={formatWeight} + /> + )} + + ); +}; + +export default Index; diff --git a/components/TreasuryVotingReason/index.tsx b/components/Treasury/TreasuryVotingWidget/TreasuryVotingReason.tsx similarity index 67% rename from components/TreasuryVotingReason/index.tsx rename to components/Treasury/TreasuryVotingWidget/TreasuryVotingReason.tsx index 52724984..67b77998 100644 --- a/components/TreasuryVotingReason/index.tsx +++ b/components/Treasury/TreasuryVotingWidget/TreasuryVotingReason.tsx @@ -1,4 +1,4 @@ -import { Text, TextArea } from "@livepeer/design-system"; +import { Box, Text, TextArea } from "@livepeer/design-system"; const MAX_INPUT_LENGTH = 256; const MIN_INPUT_LENGTH = 3; @@ -22,17 +22,30 @@ const Index = ({ const charsLeft = MAX_INPUT_LENGTH - reason.length; return ( - <> - + + Reason (optional)