Veto UI (#1295)
* useVetoingPop * show and dis/enable veto button * refactor to hook * tc * refactor action to accept all vote kinds * refactor: let components handle undefined data * add vetos to useProposalVotes * . * version proposalVotes results * refactor ApprovalQuorum * generalize quorum component * rename file * exh-deps * [unrelated] always show multi vote ui * show veto progress * kill unused UI * rename file to match component * add assertUnreachable for exhaustive switches * vetoed proposal badge * show veto progress on card iff there are any vetos * ProposalSelectCard always shows veto * always show multi vote * consolidate some duplicate code * search for exhaustive logic involving proposal state and add Vetoed * proposal result shows veto * [wip] refactor vote panel components, break them up * . * break up files * add vetoed filter * fix imports * rename files * refine logic * more refactor * refactor * correct some max vote weight logic * lint * comments * exhaustive YouVoted display * use react query to efficiently grab vote records * . * refactor our old ownVoteRecord hook * show both veto and electoral vote status * incorporate new voterecord hooks * handle not-found accounts * add react query devtools * fix * fix typescript * murder husky * delete accidental paren * clearer language * invalidate voteRecord upon voting * Fix oversight for checking if a token is the council mint while doing some maths * remove debug console logs * exclude vetos from vote explorer
This commit is contained in:
parent
ded1393950
commit
a1a33b92a6
|
@ -1,4 +1,2 @@
|
|||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
yarn lint-staged
|
||||
|
|
|
@ -6,8 +6,9 @@ import {
|
|||
Proposal,
|
||||
Realm,
|
||||
TokenOwnerRecord,
|
||||
VoteChoice,
|
||||
VoteKind,
|
||||
withPostChatMessage,
|
||||
YesNoVote,
|
||||
} from '@solana/spl-governance'
|
||||
import { ProgramAccount } from '@solana/spl-governance'
|
||||
import { RpcContext } from '@solana/spl-governance'
|
||||
|
@ -27,12 +28,27 @@ import { NftVoterClient } from '@solana/governance-program-library'
|
|||
import { calcCostOfNftVote, checkHasEnoughSolToVote } from '@tools/nftVoteCalc'
|
||||
import useNftProposalStore from 'NftVotePlugin/NftProposalStore'
|
||||
|
||||
const getVetoTokenMint = (
|
||||
proposal: ProgramAccount<Proposal>,
|
||||
realm: ProgramAccount<Realm>
|
||||
) => {
|
||||
const communityMint = realm.account.communityMint
|
||||
const councilMint = realm.account.config.councilMint
|
||||
const governingMint = proposal.account.governingTokenMint
|
||||
const vetoTokenMint = governingMint.equals(communityMint)
|
||||
? councilMint
|
||||
: communityMint
|
||||
if (vetoTokenMint === undefined)
|
||||
throw new Error('There is no token that can veto this proposal')
|
||||
return vetoTokenMint
|
||||
}
|
||||
|
||||
export async function castVote(
|
||||
{ connection, wallet, programId, walletPubkey }: RpcContext,
|
||||
realm: ProgramAccount<Realm>,
|
||||
proposal: ProgramAccount<Proposal>,
|
||||
tokeOwnerRecord: ProgramAccount<TokenOwnerRecord>,
|
||||
yesNoVote: YesNoVote,
|
||||
voteKind: VoteKind,
|
||||
message?: ChatMessageBody | undefined,
|
||||
votingPlugin?: VotingClient,
|
||||
runAfterConfirmation?: (() => void) | null
|
||||
|
@ -57,6 +73,42 @@ export async function castVote(
|
|||
tokeOwnerRecord
|
||||
)
|
||||
|
||||
// It is not clear that defining these extraneous fields, `deny` and `veto`, is actually necessary.
|
||||
// See: https://discord.com/channels/910194960941338677/910630743510777926/1044741454175674378
|
||||
const vote =
|
||||
voteKind === VoteKind.Approve
|
||||
? new Vote({
|
||||
voteType: VoteKind.Approve,
|
||||
approveChoices: [new VoteChoice({ rank: 0, weightPercentage: 100 })],
|
||||
deny: undefined,
|
||||
veto: undefined,
|
||||
})
|
||||
: voteKind === VoteKind.Deny
|
||||
? new Vote({
|
||||
voteType: VoteKind.Deny,
|
||||
approveChoices: undefined,
|
||||
deny: true,
|
||||
veto: undefined,
|
||||
})
|
||||
: voteKind == VoteKind.Veto
|
||||
? new Vote({
|
||||
voteType: VoteKind.Veto,
|
||||
veto: true,
|
||||
deny: undefined,
|
||||
approveChoices: undefined,
|
||||
})
|
||||
: new Vote({
|
||||
voteType: VoteKind.Abstain,
|
||||
veto: undefined,
|
||||
deny: undefined,
|
||||
approveChoices: undefined,
|
||||
})
|
||||
|
||||
const tokenMint =
|
||||
voteKind === VoteKind.Veto
|
||||
? getVetoTokenMint(proposal, realm)
|
||||
: proposal.account.governingTokenMint
|
||||
|
||||
await withCastVote(
|
||||
instructions,
|
||||
programId,
|
||||
|
@ -67,8 +119,8 @@ export async function castVote(
|
|||
proposal.account.tokenOwnerRecord,
|
||||
tokeOwnerRecord.pubkey,
|
||||
governanceAuthority,
|
||||
proposal.account.governingTokenMint,
|
||||
Vote.fromYesNoVote(yesNoVote),
|
||||
tokenMint,
|
||||
vote,
|
||||
payer,
|
||||
plugin?.voterWeightPk,
|
||||
plugin?.maxVoterWeightRecord
|
||||
|
|
|
@ -1,64 +0,0 @@
|
|||
import Tooltip from './Tooltip'
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
InformationCircleIcon,
|
||||
} from '@heroicons/react/outline'
|
||||
|
||||
type ApprovalProgressProps = {
|
||||
progress: number
|
||||
showBg?: boolean
|
||||
yesVotesRequired: number
|
||||
}
|
||||
|
||||
const ApprovalProgress = ({
|
||||
progress,
|
||||
showBg,
|
||||
yesVotesRequired,
|
||||
}: ApprovalProgressProps) => {
|
||||
return (
|
||||
<div className={`${showBg ? 'bg-bkg-1 p-3' : ''} rounded-md`}>
|
||||
<div className="flex items-center">
|
||||
<div className="w-full">
|
||||
<div className="flex items-center">
|
||||
<p className="text-fgd-2 mb-0 mr-1.5">Approval Quorum</p>
|
||||
<Tooltip content="Proposals must reach a minimum number of 'Yes' votes before they are eligible to pass. If the minimum is reached but there are more 'No' votes when voting ends the proposal will fail.">
|
||||
<InformationCircleIcon className="cursor-help h-5 text-fgd-2 w-5" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{progress < 100 ? (
|
||||
<p className="font-bold mb-0 text-fgd-1">{`${yesVotesRequired?.toLocaleString(
|
||||
undefined,
|
||||
{
|
||||
maximumFractionDigits: 0,
|
||||
}
|
||||
)} ${progress > 0 ? 'more' : ''} Yes vote${
|
||||
yesVotesRequired > 1 ? 's' : ''
|
||||
} required`}</p>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<CheckCircleIcon className="flex-shrink-0 h-5 mr-1.5 text-green w-5" />
|
||||
<p className="font-bold mb-0 text-fgd-1">
|
||||
Required approval achieved
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* {progress < 100 ? ( */}
|
||||
<div className="bg-bkg-4 h-2 flex flex-grow mt-2.5 rounded w-full">
|
||||
<div
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
}}
|
||||
className={`${
|
||||
progress >= 100 ? 'bg-green' : 'bg-fgd-3'
|
||||
} flex rounded`}
|
||||
></div>
|
||||
</div>
|
||||
{/* ) : null} */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ApprovalProgress
|
|
@ -32,7 +32,7 @@ export const BaseGovernanceFormV3 = ({
|
|||
// @asktree: unclear that this should not just be an effect in the parent, I am just replicating the behavior of previous components
|
||||
useEffect(() => {
|
||||
setFormErrors({})
|
||||
}, [form])
|
||||
}, [form, setFormErrors])
|
||||
|
||||
const [communityTokenHelpers, councilTokenHelpers] = useMemo(() => {
|
||||
return [realmMint, councilMint].map((mint) => {
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import styled from '@emotion/styled'
|
||||
import { ChevronRightIcon } from '@heroicons/react/solid'
|
||||
import ProposalStateBadge from './ProposalStatusBadge'
|
||||
import ProposalStateBadge from './ProposalStateBadge'
|
||||
import Link from 'next/link'
|
||||
import { Proposal, ProposalState } from '@solana/spl-governance'
|
||||
import ApprovalQuorum from './ApprovalQuorum'
|
||||
import { ApprovalProgress, VetoProgress } from './QuorumProgress'
|
||||
import useRealm from '../hooks/useRealm'
|
||||
import useProposalVotes from '../hooks/useProposalVotes'
|
||||
import ProposalTimeStatus from './ProposalTimeStatus'
|
||||
|
@ -31,7 +31,7 @@ const StyledCardWrapper = styled.div`
|
|||
const ProposalCard = ({ proposalPk, proposal }: ProposalCardProps) => {
|
||||
const { symbol } = useRealm()
|
||||
const { fmtUrlWithCluster } = useQueryContext()
|
||||
const { yesVoteProgress, yesVotesRequired } = useProposalVotes(proposal)
|
||||
const votesData = useProposalVotes(proposal)
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
@ -61,16 +61,34 @@ const ProposalCard = ({ proposalPk, proposal }: ProposalCardProps) => {
|
|||
<ProposalTimeStatus proposal={proposal} />
|
||||
</div>
|
||||
{proposal.state === ProposalState.Voting && (
|
||||
<div className="border-t border-fgd-4 flex flex-col lg:flex-row mt-2 p-4">
|
||||
<div className="pb-3 lg:pb-0 lg:border-r lg:border-fgd-4 lg:pr-4 w-full lg:w-1/2">
|
||||
<div className="border-t border-fgd-4 flex flex-col lg:flex-row mt-2 p-4 gap-x-4 gap-y-3">
|
||||
<div className="w-full lg:w-auto flex-1">
|
||||
<VoteResults isListView proposal={proposal} />
|
||||
</div>
|
||||
<div className="lg:pl-4 w-full lg:w-1/2">
|
||||
<ApprovalQuorum
|
||||
progress={yesVoteProgress}
|
||||
yesVotesRequired={yesVotesRequired}
|
||||
<div className="border-r border-fgd-4 hidden lg:block" />
|
||||
<div className="w-full lg:w-auto flex-1">
|
||||
<ApprovalProgress
|
||||
progress={votesData.yesVoteProgress}
|
||||
votesRequired={votesData.yesVotesRequired}
|
||||
/>
|
||||
</div>
|
||||
{votesData._programVersion !== undefined &&
|
||||
// @asktree: here is some typescript gore because typescript doesn't know that a number being > 3 means it isn't 1 or 2
|
||||
votesData._programVersion !== 1 &&
|
||||
votesData._programVersion !== 2 &&
|
||||
votesData.veto !== undefined &&
|
||||
votesData.veto.voteProgress > 0 ? (
|
||||
<>
|
||||
<div className="border-r border-fgd-4 hidden lg:block" />
|
||||
|
||||
<div className="w-full lg:w-auto flex-1">
|
||||
<VetoProgress
|
||||
progress={votesData.veto.voteProgress}
|
||||
votesRequired={votesData.veto.votesRequired}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : undefined}
|
||||
</div>
|
||||
)}
|
||||
</StyledCardWrapper>
|
||||
|
|
|
@ -13,6 +13,7 @@ export const InitialFilters = {
|
|||
ExecutingWithErrors: true,
|
||||
SigningOff: true,
|
||||
Voting: true,
|
||||
Vetoed: true,
|
||||
}
|
||||
|
||||
export type Filters = typeof InitialFilters
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { CheckIcon } from '@heroicons/react/solid'
|
||||
import ProposalStateBadge from './ProposalStatusBadge'
|
||||
import ProposalStateBadge from './ProposalStateBadge'
|
||||
import { Proposal, ProposalState } from '@solana/spl-governance'
|
||||
import ApprovalQuorum from './ApprovalQuorum'
|
||||
import { ApprovalProgress, VetoProgress } from './QuorumProgress'
|
||||
import useProposalVotes from '../hooks/useProposalVotes'
|
||||
import ProposalTimeStatus from './ProposalTimeStatus'
|
||||
import { PublicKey } from '@solana/web3.js'
|
||||
|
@ -21,7 +21,7 @@ const ProposalSelectCard = ({
|
|||
setSelectedProposals,
|
||||
selectedProposals,
|
||||
}: ProposalCardProps) => {
|
||||
const { yesVoteProgress, yesVotesRequired } = useProposalVotes(proposal)
|
||||
const votesData = useProposalVotes(proposal)
|
||||
|
||||
const checked = !!selectedProposals.find(
|
||||
// @ts-ignore
|
||||
|
@ -66,16 +66,32 @@ const ProposalSelectCard = ({
|
|||
<ProposalTimeStatus proposal={proposal} />
|
||||
</div>
|
||||
{proposal.state === ProposalState.Voting && (
|
||||
<div className="border-t border-fgd-4 flex flex-col lg:flex-row mt-2 p-4">
|
||||
<div className="pb-3 lg:pb-0 lg:border-r lg:border-fgd-4 lg:pr-4 w-full lg:w-1/2">
|
||||
<div className="border-t border-fgd-4 flex flex-col lg:flex-row mt-2 p-4 gap-x-4 gap-y-3">
|
||||
<div className="w-full lg:w-auto flex-1">
|
||||
<VoteResults isListView proposal={proposal} />
|
||||
</div>
|
||||
<div className="lg:pl-4 w-full lg:w-1/2">
|
||||
<ApprovalQuorum
|
||||
progress={yesVoteProgress}
|
||||
yesVotesRequired={yesVotesRequired}
|
||||
<div className="border-r border-fgd-4 hidden lg:block" />
|
||||
<div className="w-full lg:w-auto flex-1">
|
||||
<ApprovalProgress
|
||||
progress={votesData.yesVoteProgress}
|
||||
votesRequired={votesData.yesVotesRequired}
|
||||
/>
|
||||
</div>
|
||||
{votesData._programVersion !== undefined &&
|
||||
// @asktree: here is some typescript gore because typescript doesn't know that a number being > 3 means it isn't 1 or 2
|
||||
votesData._programVersion !== 1 &&
|
||||
votesData._programVersion !== 2 &&
|
||||
votesData.veto !== undefined ? (
|
||||
<>
|
||||
<div className="border-r border-fgd-4 hidden lg:block" />
|
||||
<div className="w-full lg:w-auto flex-1">
|
||||
<VetoProgress
|
||||
progress={votesData.veto.voteProgress}
|
||||
votesRequired={votesData.veto.votesRequired}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : undefined}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
|
|
@ -3,6 +3,7 @@ import classNames from 'classnames'
|
|||
|
||||
import useRealm from '@hooks/useRealm'
|
||||
import useRealmGovernance from '../hooks/useRealmGovernance'
|
||||
import assertUnreachable from '@utils/typescript/assertUnreachable'
|
||||
|
||||
export const hasInstructions = (proposal: Proposal) => {
|
||||
if (proposal.instructionsCount) {
|
||||
|
@ -33,6 +34,7 @@ function getBorderColor(proposalState: ProposalState, otherState: OtherState) {
|
|||
case ProposalState.Completed:
|
||||
case ProposalState.Defeated:
|
||||
case ProposalState.ExecutingWithErrors:
|
||||
case ProposalState.Vetoed:
|
||||
return 'border-transparent'
|
||||
case ProposalState.Executing:
|
||||
return 'border-[#5DC9EB]'
|
||||
|
@ -46,6 +48,8 @@ function getBorderColor(proposalState: ProposalState, otherState: OtherState) {
|
|||
: 'border-[#5DC9EB]'
|
||||
case ProposalState.Voting:
|
||||
return otherState.votingEnded ? 'border-[#5DC9EB]' : 'border-[#8EFFDD]'
|
||||
default:
|
||||
assertUnreachable(proposalState)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -72,6 +76,10 @@ function getLabel(
|
|||
return !hasInstructions(otherState.proposal) ? 'Completed' : 'Executable'
|
||||
case ProposalState.Voting:
|
||||
return otherState.votingEnded ? 'Finalizing' : 'Voting'
|
||||
case ProposalState.Vetoed:
|
||||
return 'Vetoed'
|
||||
default:
|
||||
assertUnreachable(proposalState)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -84,6 +92,7 @@ function getOpacity(
|
|||
case ProposalState.Completed:
|
||||
case ProposalState.Defeated:
|
||||
case ProposalState.ExecutingWithErrors:
|
||||
case ProposalState.Vetoed:
|
||||
return 'opacity-70'
|
||||
case ProposalState.Draft:
|
||||
return otherState.isCreator ? '' : 'opacity-70'
|
||||
|
@ -91,8 +100,11 @@ function getOpacity(
|
|||
return otherState.isSignatory ? '' : 'opacity-70'
|
||||
case ProposalState.Succeeded:
|
||||
return !hasInstructions(otherState.proposal) ? 'opacity-70' : ''
|
||||
default:
|
||||
case ProposalState.Voting:
|
||||
case ProposalState.Executing:
|
||||
return ''
|
||||
default:
|
||||
assertUnreachable(proposalState)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -107,6 +119,7 @@ function getTextColor(
|
|||
case ProposalState.Completed:
|
||||
return 'text-[#8EFFDD]'
|
||||
case ProposalState.Defeated:
|
||||
case ProposalState.Vetoed:
|
||||
case ProposalState.ExecutingWithErrors:
|
||||
return 'text-[#FF7C7C]'
|
||||
case ProposalState.Executing:
|
||||
|
@ -121,6 +134,8 @@ function getTextColor(
|
|||
return otherState.votingEnded
|
||||
? 'bg-gradient-to-r from-[#00C2FF] via-[#00E4FF] to-[#87F2FF] bg-clip-text text-transparent'
|
||||
: 'text-[#8EFFDD]'
|
||||
default:
|
||||
assertUnreachable(proposalState)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import Tooltip from './Tooltip'
|
||||
import { InformationCircleIcon } from '@heroicons/react/outline'
|
||||
|
||||
type Props = {
|
||||
progress?: number
|
||||
votesRequired?: number
|
||||
showBg?: boolean
|
||||
}
|
||||
type GenericProps = {
|
||||
voteKindTitle: string
|
||||
quorumTitle: string
|
||||
tooltip: string
|
||||
}
|
||||
|
||||
const QuorumProgress = ({
|
||||
progress,
|
||||
showBg,
|
||||
votesRequired,
|
||||
quorumTitle,
|
||||
tooltip,
|
||||
voteKindTitle,
|
||||
}: Props & GenericProps) => {
|
||||
return (
|
||||
<div className={`${showBg ? 'bg-bkg-1 p-3' : ''} rounded-md`}>
|
||||
<div className="flex items-center">
|
||||
<div className="w-full">
|
||||
<div className="flex items-center">
|
||||
<p className="text-fgd-2 mb-0 mr-1.5">{quorumTitle} Quorum</p>
|
||||
<Tooltip content={tooltip}>
|
||||
<InformationCircleIcon className="cursor-help h-5 text-fgd-2 w-5" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="font-bold mb-0 text-fgd-1">{`${(
|
||||
votesRequired ?? 0
|
||||
).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
})} ${(progress ?? 0) > 0 ? 'more' : ''} ${voteKindTitle} vote${
|
||||
(votesRequired ?? 0) > 1 ? 's' : ''
|
||||
} required`}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* {progress < 100 ? ( */}
|
||||
<div className="bg-bkg-4 h-2 flex flex-grow mt-2.5 rounded w-full">
|
||||
<div
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
}}
|
||||
className={`${
|
||||
(progress ?? 0) >= 100 ? 'bg-green' : 'bg-fgd-3'
|
||||
} flex rounded`}
|
||||
></div>
|
||||
</div>
|
||||
{/* ) : null} */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ApprovalProgress = (props: Props) => (
|
||||
<QuorumProgress
|
||||
tooltip={`Proposals must reach a minimum number of 'Yes' votes before they are eligible to pass. If the minimum is reached but there are more 'No' votes when voting ends the proposal will fail.`}
|
||||
quorumTitle="Approval"
|
||||
voteKindTitle="Yes"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export const VetoProgress = (props: Props) => (
|
||||
<QuorumProgress
|
||||
tooltip={`This proposal can be vetoed. If the veto quorum is reached the proposal will fail regardless of the approval quorum.`}
|
||||
quorumTitle="Veto"
|
||||
voteKindTitle="Veto"
|
||||
{...props}
|
||||
/>
|
||||
)
|
|
@ -1,9 +1,9 @@
|
|||
import React, { FunctionComponent, useState } from 'react'
|
||||
import { ThumbDownIcon, ThumbUpIcon } from '@heroicons/react/solid'
|
||||
import { BanIcon, ThumbDownIcon, ThumbUpIcon } from '@heroicons/react/solid'
|
||||
import {
|
||||
ChatMessageBody,
|
||||
ChatMessageBodyType,
|
||||
YesNoVote,
|
||||
VoteKind,
|
||||
} from '@solana/spl-governance'
|
||||
import { RpcContext } from '@solana/spl-governance'
|
||||
import useWalletStore from '../stores/useWalletStore'
|
||||
|
@ -23,25 +23,29 @@ import useVotePluginsClientStore from 'stores/useVotePluginsClientStore'
|
|||
import { nftPluginsPks } from '@hooks/useVotingPlugins'
|
||||
import useNftProposalStore from 'NftVotePlugin/NftProposalStore'
|
||||
import { NftVoterClient } from '@solana/governance-program-library'
|
||||
import queryClient from '@hooks/queries/queryClient'
|
||||
import { voteRecordQueryKeys } from '@hooks/queries/voteRecord'
|
||||
|
||||
interface VoteCommentModalProps {
|
||||
onClose: () => void
|
||||
isOpen: boolean
|
||||
vote: YesNoVote
|
||||
vote: VoteKind
|
||||
voterTokenRecord: ProgramAccount<TokenOwnerRecord>
|
||||
}
|
||||
|
||||
const VoteCommentModal: FunctionComponent<VoteCommentModalProps> = ({
|
||||
const useSubmitVote = ({
|
||||
comment,
|
||||
onClose,
|
||||
isOpen,
|
||||
vote,
|
||||
voterTokenRecord,
|
||||
}: {
|
||||
comment: string
|
||||
onClose: () => void
|
||||
voterTokenRecord: ProgramAccount<TokenOwnerRecord>
|
||||
}) => {
|
||||
const client = useVotePluginsClientStore(
|
||||
(s) => s.state.currentRealmVotingClient
|
||||
)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [comment, setComment] = useState('')
|
||||
const wallet = useWalletStore((s) => s.current)
|
||||
const connection = useWalletStore((s) => s.connection)
|
||||
const { proposal } = useWalletStore((s) => s.selectedProposal)
|
||||
|
@ -54,7 +58,7 @@ const VoteCommentModal: FunctionComponent<VoteCommentModalProps> = ({
|
|||
config?.account.communityTokenConfig.voterWeightAddin?.toBase58()
|
||||
)
|
||||
const { closeNftVotingCountingModal } = useNftProposalStore.getState()
|
||||
const submitVote = async (vote: YesNoVote) => {
|
||||
const submitVote = async (vote: VoteKind) => {
|
||||
setSubmitting(true)
|
||||
const rpcContext = new RpcContext(
|
||||
proposal!.owner,
|
||||
|
@ -71,6 +75,12 @@ const VoteCommentModal: FunctionComponent<VoteCommentModalProps> = ({
|
|||
})
|
||||
: undefined
|
||||
|
||||
const confirmationCallback = async () => {
|
||||
await refetchProposals()
|
||||
// TODO refine this to only invalidate the one query
|
||||
await queryClient.invalidateQueries(voteRecordQueryKeys.all)
|
||||
}
|
||||
|
||||
try {
|
||||
await castVote(
|
||||
rpcContext,
|
||||
|
@ -80,7 +90,7 @@ const VoteCommentModal: FunctionComponent<VoteCommentModalProps> = ({
|
|||
vote,
|
||||
msg,
|
||||
client,
|
||||
refetchProposals
|
||||
confirmationCallback
|
||||
)
|
||||
if (!isNftPlugin) {
|
||||
await refetchProposals()
|
||||
|
@ -104,7 +114,30 @@ const VoteCommentModal: FunctionComponent<VoteCommentModalProps> = ({
|
|||
fetchChatMessages(proposal!.pubkey)
|
||||
}
|
||||
|
||||
const voteString = vote === YesNoVote.Yes ? 'Yes' : 'No'
|
||||
return { submitting, submitVote }
|
||||
}
|
||||
|
||||
const VOTE_STRINGS = {
|
||||
[VoteKind.Approve]: 'Yes',
|
||||
[VoteKind.Deny]: 'No',
|
||||
[VoteKind.Veto]: 'Veto',
|
||||
[VoteKind.Abstain]: 'Abstain',
|
||||
}
|
||||
|
||||
const VoteCommentModal: FunctionComponent<VoteCommentModalProps> = ({
|
||||
onClose,
|
||||
isOpen,
|
||||
vote,
|
||||
voterTokenRecord,
|
||||
}) => {
|
||||
const [comment, setComment] = useState('')
|
||||
const { submitting, submitVote } = useSubmitVote({
|
||||
comment,
|
||||
onClose,
|
||||
voterTokenRecord,
|
||||
})
|
||||
|
||||
const voteString = VOTE_STRINGS[vote]
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} isOpen={isOpen}>
|
||||
|
@ -136,10 +169,12 @@ const VoteCommentModal: FunctionComponent<VoteCommentModalProps> = ({
|
|||
>
|
||||
<div className="flex items-center">
|
||||
{!submitting &&
|
||||
(vote === YesNoVote.Yes ? (
|
||||
(vote === VoteKind.Approve ? (
|
||||
<ThumbUpIcon className="h-4 w-4 fill-black mr-2" />
|
||||
) : (
|
||||
) : vote === VoteKind.Deny ? (
|
||||
<ThumbDownIcon className="h-4 w-4 fill-black mr-2" />
|
||||
) : (
|
||||
<BanIcon className="h-4 w-4 fill-black mr-2" />
|
||||
))}
|
||||
{submitting ? <Loading /> : <span>Vote {voteString}</span>}
|
||||
</div>
|
||||
|
|
|
@ -1,311 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { withFinalizeVote, YesNoVote } from '@solana/spl-governance'
|
||||
import { TransactionInstruction } from '@solana/web3.js'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { relinquishVote } from '../actions/relinquishVote'
|
||||
import { useHasVoteTimeExpired } from '../hooks/useHasVoteTimeExpired'
|
||||
import useRealm from '../hooks/useRealm'
|
||||
import { ProposalState } from '@solana/spl-governance'
|
||||
import { RpcContext } from '@solana/spl-governance'
|
||||
import { GoverningTokenRole } from '@solana/spl-governance'
|
||||
import { BanIcon, ThumbUpIcon, ThumbDownIcon } from '@heroicons/react/solid'
|
||||
|
||||
import useWalletStore from '../stores/useWalletStore'
|
||||
import Button, { SecondaryButton } from './Button'
|
||||
import VoteCommentModal from './VoteCommentModal'
|
||||
import { getProgramVersionForRealm } from '@models/registry/api'
|
||||
import useVotePluginsClientStore from 'stores/useVotePluginsClientStore'
|
||||
import { useRouter } from 'next/router'
|
||||
import useNftPluginStore from 'NftVotePlugin/store/nftPluginStore'
|
||||
import { REALM_ID as PYTH_REALM_ID } from 'pyth-staking-api'
|
||||
import { isYesVote } from '@models/voteRecords'
|
||||
import Tooltip from '@components/Tooltip'
|
||||
import { VotingClientType } from '@utils/uiTypes/VotePlugin'
|
||||
|
||||
const VotePanel = () => {
|
||||
const [showVoteModal, setShowVoteModal] = useState(false)
|
||||
const [vote, setVote] = useState<YesNoVote | null>(null)
|
||||
const client = useVotePluginsClientStore(
|
||||
(s) => s.state.currentRealmVotingClient
|
||||
)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
const { pk } = router.query
|
||||
const {
|
||||
governance,
|
||||
proposal,
|
||||
voteRecordsByVoter,
|
||||
tokenRole,
|
||||
} = useWalletStore((s) => s.selectedProposal)
|
||||
const {
|
||||
ownTokenRecord,
|
||||
ownCouncilTokenRecord,
|
||||
realm,
|
||||
realmInfo,
|
||||
ownVoterWeight,
|
||||
} = useRealm()
|
||||
const wallet = useWalletStore((s) => s.current)
|
||||
const connection = useWalletStore((s) => s.connection)
|
||||
const connected = useWalletStore((s) => s.connected)
|
||||
const refetchProposals = useWalletStore((s) => s.actions.refetchProposals)
|
||||
const fetchProposal = useWalletStore((s) => s.actions.fetchProposal)
|
||||
const hasVoteTimeExpired = useHasVoteTimeExpired(governance, proposal!)
|
||||
const maxVoterWeight =
|
||||
useNftPluginStore((s) => s.state.maxVoteRecord)?.pubkey || undefined
|
||||
|
||||
// Handle state based on if a delegated wallet has already voted or not
|
||||
const ownVoteRecord =
|
||||
tokenRole === GoverningTokenRole.Community && ownTokenRecord
|
||||
? voteRecordsByVoter[
|
||||
ownTokenRecord.account.governingTokenOwner.toBase58()
|
||||
]
|
||||
: ownCouncilTokenRecord
|
||||
? voteRecordsByVoter[
|
||||
ownCouncilTokenRecord.account.governingTokenOwner.toBase58()
|
||||
]
|
||||
: wallet?.publicKey && voteRecordsByVoter[wallet.publicKey.toBase58()]
|
||||
|
||||
const voterTokenRecord =
|
||||
tokenRole === GoverningTokenRole.Community
|
||||
? ownTokenRecord
|
||||
: ownCouncilTokenRecord
|
||||
|
||||
const isVoteCast = ownVoteRecord !== undefined
|
||||
const isVoting =
|
||||
proposal?.account.state === ProposalState.Voting && !hasVoteTimeExpired
|
||||
|
||||
const hasMinAmountToVote =
|
||||
voterTokenRecord &&
|
||||
ownVoterWeight.hasMinAmountToVote(
|
||||
voterTokenRecord.account.governingTokenMint
|
||||
)
|
||||
|
||||
const isVoteEnabled =
|
||||
connected && isVoting && !isVoteCast && hasMinAmountToVote
|
||||
|
||||
const isWithdrawEnabled =
|
||||
connected &&
|
||||
ownVoteRecord &&
|
||||
!ownVoteRecord?.account.isRelinquished &&
|
||||
proposal &&
|
||||
(proposal!.account.state === ProposalState.Voting ||
|
||||
proposal!.account.state === ProposalState.Completed ||
|
||||
proposal!.account.state === ProposalState.Cancelled ||
|
||||
proposal!.account.state === ProposalState.Succeeded ||
|
||||
proposal!.account.state === ProposalState.Executing ||
|
||||
proposal!.account.state === ProposalState.Defeated)
|
||||
|
||||
const submitRelinquishVote = async () => {
|
||||
const rpcContext = new RpcContext(
|
||||
proposal!.owner,
|
||||
getProgramVersionForRealm(realmInfo!),
|
||||
wallet!,
|
||||
connection.current,
|
||||
connection.endpoint
|
||||
)
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const instructions: TransactionInstruction[] = []
|
||||
|
||||
if (
|
||||
proposal?.account.state === ProposalState.Voting &&
|
||||
hasVoteTimeExpired
|
||||
) {
|
||||
await withFinalizeVote(
|
||||
instructions,
|
||||
realmInfo!.programId,
|
||||
getProgramVersionForRealm(realmInfo!),
|
||||
realm!.pubkey,
|
||||
proposal.account.governance,
|
||||
proposal.pubkey,
|
||||
proposal.account.tokenOwnerRecord,
|
||||
proposal.account.governingTokenMint,
|
||||
maxVoterWeight
|
||||
)
|
||||
}
|
||||
|
||||
await relinquishVote(
|
||||
rpcContext,
|
||||
realm!.pubkey,
|
||||
proposal!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
voterTokenRecord!.pubkey,
|
||||
ownVoteRecord!.pubkey,
|
||||
instructions,
|
||||
client
|
||||
)
|
||||
await refetchProposals()
|
||||
if (pk) {
|
||||
fetchProposal(pk)
|
||||
}
|
||||
} catch (ex) {
|
||||
console.error("Can't relinquish vote", ex)
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const handleShowVoteModal = (vote: YesNoVote) => {
|
||||
setVote(vote)
|
||||
setShowVoteModal(true)
|
||||
}
|
||||
|
||||
const handleCloseShowVoteModal = useCallback(() => {
|
||||
setShowVoteModal(false)
|
||||
}, [])
|
||||
|
||||
const actionLabel =
|
||||
!isVoteCast || !connected
|
||||
? `Cast your ${
|
||||
tokenRole === GoverningTokenRole.Community ? 'community' : 'council'
|
||||
} vote`
|
||||
: 'Your vote'
|
||||
|
||||
const withdrawTooltipContent = !connected
|
||||
? 'You need to connect your wallet'
|
||||
: !isWithdrawEnabled
|
||||
? !ownVoteRecord?.account.isRelinquished
|
||||
? 'Owner vote record is not relinquished'
|
||||
: 'The proposal is not in a valid state to execute this action.'
|
||||
: ''
|
||||
|
||||
const voteTooltipContent = !connected
|
||||
? 'You need to connect your wallet to be able to vote'
|
||||
: !isVoting && isVoteCast
|
||||
? 'Proposal is not in a voting state anymore.'
|
||||
: client.clientType === VotingClientType.NftVoterClient && !voterTokenRecord
|
||||
? 'You must join the Realm to be able to vote'
|
||||
: !voterTokenRecord ||
|
||||
!ownVoterWeight.hasMinAmountToVote(
|
||||
voterTokenRecord.account.governingTokenMint
|
||||
)
|
||||
? 'You don’t have governance power to vote in this dao'
|
||||
: ''
|
||||
|
||||
const notVisibleStatesForNotConnectedWallet = [
|
||||
ProposalState.Cancelled,
|
||||
ProposalState.Succeeded,
|
||||
ProposalState.Draft,
|
||||
ProposalState.Completed,
|
||||
]
|
||||
|
||||
const isVisibleToWallet = !connected
|
||||
? !hasVoteTimeExpired &&
|
||||
typeof notVisibleStatesForNotConnectedWallet.find(
|
||||
(x) => x === proposal?.account.state
|
||||
) === 'undefined'
|
||||
: !ownVoteRecord?.account.isRelinquished
|
||||
|
||||
const isPanelVisible = (isVoting || isVoteCast) && isVisibleToWallet
|
||||
const didNotVote =
|
||||
!!proposal &&
|
||||
!isVoting &&
|
||||
proposal.account.state !== ProposalState.Cancelled &&
|
||||
proposal.account.state !== ProposalState.Draft &&
|
||||
!isVoteCast &&
|
||||
isVisibleToWallet
|
||||
|
||||
//Todo: move to own components with refactor to dao folder structure
|
||||
const isPyth = realmInfo?.realmId.toBase58() === PYTH_REALM_ID.toBase58()
|
||||
|
||||
const isRelinquishVotePanelVisible = !(
|
||||
isPyth &&
|
||||
isVoteCast &&
|
||||
connected &&
|
||||
!isVoting
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{isPanelVisible && isRelinquishVotePanelVisible && (
|
||||
<div className="bg-bkg-2 p-4 md:p-6 rounded-lg space-y-4">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<h3 className="text-center">{actionLabel}</h3>
|
||||
{isVoteCast &&
|
||||
connected &&
|
||||
ownVoteRecord &&
|
||||
(isYesVote(ownVoteRecord.account) ? (
|
||||
<Tooltip content={`You voted "Yes"`}>
|
||||
<div className="flex flex-row items-center justify-center rounded-full border border-[#8EFFDD] p-2 mt-2">
|
||||
<ThumbUpIcon className="h-4 w-4 fill-[#8EFFDD]" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip content={`You voted "No"`}>
|
||||
<div className="flex flex-row items-center justify-center rounded-full border border-[#FF7C7C] p-2 mt-2">
|
||||
<ThumbDownIcon className="h-4 w-4 fill-[#FF7C7C]" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="items-center justify-center flex w-full gap-5">
|
||||
{isVoteCast && connected ? (
|
||||
<div className="flex flex-col gap-6 items-center">
|
||||
{isVoting && (
|
||||
<SecondaryButton
|
||||
className="min-w-[200px]"
|
||||
isLoading={isLoading}
|
||||
tooltipMessage={withdrawTooltipContent}
|
||||
onClick={() => submitRelinquishVote()}
|
||||
disabled={!isWithdrawEnabled || isLoading}
|
||||
>
|
||||
Withdraw
|
||||
</SecondaryButton>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isVoting && (
|
||||
<div className="w-full flex justify-between items-center gap-5">
|
||||
<Button
|
||||
tooltipMessage={voteTooltipContent}
|
||||
className="w-1/2"
|
||||
onClick={() => handleShowVoteModal(YesNoVote.Yes)}
|
||||
disabled={!isVoteEnabled}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<ThumbUpIcon className="h-4 w-4 mr-2" />
|
||||
Vote Yes
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
tooltipMessage={voteTooltipContent}
|
||||
className="w-1/2"
|
||||
onClick={() => handleShowVoteModal(YesNoVote.No)}
|
||||
disabled={!isVoteEnabled}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<ThumbDownIcon className="h-4 w-4 mr-2" />
|
||||
Vote No
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showVoteModal ? (
|
||||
<VoteCommentModal
|
||||
isOpen={showVoteModal}
|
||||
onClose={handleCloseShowVoteModal}
|
||||
vote={vote!}
|
||||
voterTokenRecord={voterTokenRecord!}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{didNotVote && (
|
||||
<div className="bg-bkg-2 p-4 md:p-6 rounded-lg flex flex-col items-center justify-center">
|
||||
<h3 className="text-center mb-0">You did not vote</h3>
|
||||
<Tooltip content="You did not vote on this proposal">
|
||||
<BanIcon className="h-[34px] w-[34px] fill-white/50 mt-2" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default VotePanel
|
|
@ -0,0 +1,115 @@
|
|||
import { VoteKind } from '@solana/spl-governance'
|
||||
import { useState } from 'react'
|
||||
import { ThumbUpIcon, ThumbDownIcon } from '@heroicons/react/solid'
|
||||
import Button from '../Button'
|
||||
import VoteCommentModal from '../VoteCommentModal'
|
||||
import {
|
||||
useIsVoting,
|
||||
useProposalVoteRecordQuery,
|
||||
useVoterTokenRecord,
|
||||
useVotingPop,
|
||||
} from './hooks'
|
||||
import useRealm from '@hooks/useRealm'
|
||||
import { VotingClientType } from '@utils/uiTypes/VotePlugin'
|
||||
import useVotePluginsClientStore from 'stores/useVotePluginsClientStore'
|
||||
import useWalletStore from 'stores/useWalletStore'
|
||||
|
||||
const useCanVote = () => {
|
||||
const client = useVotePluginsClientStore(
|
||||
(s) => s.state.currentRealmVotingClient
|
||||
)
|
||||
const { ownVoterWeight } = useRealm()
|
||||
const connected = useWalletStore((s) => s.connected)
|
||||
|
||||
const { data: ownVoteRecord } = useProposalVoteRecordQuery('electoral')
|
||||
const voterTokenRecord = useVoterTokenRecord()
|
||||
|
||||
const isVoteCast = !!ownVoteRecord?.found
|
||||
|
||||
const hasMinAmountToVote =
|
||||
voterTokenRecord &&
|
||||
ownVoterWeight.hasMinAmountToVote(
|
||||
voterTokenRecord.account.governingTokenMint
|
||||
)
|
||||
|
||||
const canVote =
|
||||
connected &&
|
||||
!(
|
||||
client.clientType === VotingClientType.NftVoterClient && !voterTokenRecord
|
||||
) &&
|
||||
!isVoteCast &&
|
||||
hasMinAmountToVote
|
||||
|
||||
const voteTooltipContent = !connected
|
||||
? 'You need to connect your wallet to be able to vote'
|
||||
: client.clientType === VotingClientType.NftVoterClient && !voterTokenRecord
|
||||
? 'You must join the Realm to be able to vote'
|
||||
: !hasMinAmountToVote
|
||||
? 'You don’t have governance power to vote in this dao'
|
||||
: ''
|
||||
return [canVote, voteTooltipContent] as const
|
||||
}
|
||||
|
||||
export const CastVoteButtons = () => {
|
||||
const [showVoteModal, setShowVoteModal] = useState(false)
|
||||
const [vote, setVote] = useState<'yes' | 'no' | null>(null)
|
||||
const votingPop = useVotingPop()
|
||||
const voterTokenRecord = useVoterTokenRecord()
|
||||
|
||||
const [canVote, tooltipContent] = useCanVote()
|
||||
const { data: ownVoteRecord } = useProposalVoteRecordQuery('electoral')
|
||||
|
||||
const isVoteCast = !!ownVoteRecord?.found
|
||||
const isVoting = useIsVoting()
|
||||
|
||||
return isVoting && !isVoteCast ? (
|
||||
<div className="bg-bkg-2 p-4 md:p-6 rounded-lg space-y-4">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<h3 className="text-center">Cast your {votingPop} vote</h3>
|
||||
</div>
|
||||
|
||||
<div className="items-center justify-center flex w-full gap-5">
|
||||
<div className="w-full flex justify-between items-center gap-5">
|
||||
<Button
|
||||
tooltipMessage={tooltipContent}
|
||||
className="w-1/2"
|
||||
onClick={() => {
|
||||
setVote('yes')
|
||||
setShowVoteModal(true)
|
||||
}}
|
||||
disabled={!canVote}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<ThumbUpIcon className="h-4 w-4 mr-2" />
|
||||
Vote Yes
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
tooltipMessage={tooltipContent}
|
||||
className="w-1/2"
|
||||
onClick={() => {
|
||||
setVote('no')
|
||||
setShowVoteModal(true)
|
||||
}}
|
||||
disabled={!canVote}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<ThumbDownIcon className="h-4 w-4 mr-2" />
|
||||
Vote No
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showVoteModal && vote ? (
|
||||
<VoteCommentModal
|
||||
isOpen={showVoteModal}
|
||||
onClose={() => setShowVoteModal(false)}
|
||||
vote={vote === 'yes' ? VoteKind.Approve : VoteKind.Deny}
|
||||
voterTokenRecord={voterTokenRecord!}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null
|
||||
}
|
|
@ -0,0 +1,137 @@
|
|||
import Button from '@components/Button'
|
||||
import VoteCommentModal from '@components/VoteCommentModal'
|
||||
import { BanIcon } from '@heroicons/react/solid'
|
||||
import useRealm from '@hooks/useRealm'
|
||||
import {
|
||||
GoverningTokenRole,
|
||||
VoteThresholdType,
|
||||
VoteKind,
|
||||
} from '@solana/spl-governance'
|
||||
import { useMemo, useState } from 'react'
|
||||
import useWalletStore from 'stores/useWalletStore'
|
||||
import { useIsVoting, useProposalVoteRecordQuery } from './hooks'
|
||||
|
||||
/*
|
||||
returns: undefined if loading, false if nobody can veto, 'council' if council can veto, 'community' if community can veto
|
||||
*/
|
||||
export const useVetoingPop = () => {
|
||||
const { tokenRole, governance } = useWalletStore((s) => s.selectedProposal)
|
||||
|
||||
const vetoingPop = useMemo(() => {
|
||||
if (governance === undefined) return undefined
|
||||
|
||||
return tokenRole === GoverningTokenRole.Community
|
||||
? governance?.account.config.councilVetoVoteThreshold.type !==
|
||||
VoteThresholdType.Disabled && 'council'
|
||||
: governance?.account.config.communityVetoVoteThreshold.type !==
|
||||
VoteThresholdType.Disabled && 'community'
|
||||
}, [governance, tokenRole])
|
||||
|
||||
return vetoingPop
|
||||
}
|
||||
|
||||
const useIsVetoable = (): undefined | boolean => {
|
||||
const vetoingPop = useVetoingPop()
|
||||
const isVoting = useIsVoting()
|
||||
|
||||
// TODO is this accurate?
|
||||
if (isVoting === false) return false
|
||||
if (vetoingPop === undefined) return undefined
|
||||
return !!vetoingPop
|
||||
}
|
||||
|
||||
const useUserVetoTokenRecord = () => {
|
||||
const { ownTokenRecord, ownCouncilTokenRecord } = useRealm()
|
||||
const vetoingPop = useVetoingPop()
|
||||
const voterTokenRecord =
|
||||
vetoingPop === 'community' ? ownTokenRecord : ownCouncilTokenRecord
|
||||
return voterTokenRecord
|
||||
}
|
||||
|
||||
const useCanVeto = ():
|
||||
| undefined
|
||||
| { canVeto: true }
|
||||
| { canVeto: false; message: string } => {
|
||||
const { ownVoterWeight } = useRealm()
|
||||
const connected = useWalletStore((s) => s.connected)
|
||||
const isVetoable = useIsVetoable()
|
||||
const { data: userVetoRecord } = useProposalVoteRecordQuery('veto')
|
||||
const voterTokenRecord = useUserVetoTokenRecord()
|
||||
|
||||
if (isVetoable === false)
|
||||
return {
|
||||
canVeto: false,
|
||||
// (Note that users should never actually see this)
|
||||
message: 'This proposal is not vetoable',
|
||||
}
|
||||
|
||||
// Are you connected?
|
||||
if (connected === false)
|
||||
return { canVeto: false, message: 'You must connect your wallet' }
|
||||
|
||||
// Did you already veto?
|
||||
if (userVetoRecord?.found)
|
||||
return { canVeto: false, message: 'You already voted' }
|
||||
|
||||
// Do you have any voting power?
|
||||
const hasMinAmountToVote =
|
||||
voterTokenRecord &&
|
||||
ownVoterWeight.hasMinAmountToVote(
|
||||
voterTokenRecord.account.governingTokenMint
|
||||
)
|
||||
if (hasMinAmountToVote === undefined) return undefined
|
||||
if (hasMinAmountToVote === false)
|
||||
return {
|
||||
canVeto: false,
|
||||
message: 'You don’t have governance power to vote in this dao',
|
||||
}
|
||||
|
||||
return { canVeto: true }
|
||||
}
|
||||
|
||||
const VetoButtons = () => {
|
||||
const vetoable = useIsVetoable()
|
||||
const vetoingPop = useVetoingPop()
|
||||
const canVeto = useCanVeto()
|
||||
const [openModal, setOpenModal] = useState(false)
|
||||
const voterTokenRecord = useUserVetoTokenRecord()
|
||||
const { data: userVetoRecord } = useProposalVoteRecordQuery('veto')
|
||||
|
||||
return vetoable &&
|
||||
vetoingPop &&
|
||||
voterTokenRecord &&
|
||||
!userVetoRecord?.found ? (
|
||||
<>
|
||||
<div className="bg-bkg-2 p-4 md:p-6 rounded-lg space-y-4">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<h3 className="text-center">Cast your {vetoingPop} veto vote</h3>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<Button
|
||||
tooltipMessage={
|
||||
canVeto?.canVeto === false ? canVeto.message : undefined
|
||||
}
|
||||
className="w-full"
|
||||
onClick={() => setOpenModal(true)}
|
||||
disabled={!canVeto?.canVeto}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<BanIcon className="h-4 w-4 mr-2" />
|
||||
Veto
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{openModal ? (
|
||||
<VoteCommentModal
|
||||
onClose={() => setOpenModal(false)}
|
||||
isOpen={openModal}
|
||||
voterTokenRecord={voterTokenRecord}
|
||||
vote={VoteKind.Veto}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
|
||||
export default VetoButtons
|
|
@ -0,0 +1,48 @@
|
|||
import { ProposalState } from '@solana/spl-governance'
|
||||
import { BanIcon } from '@heroicons/react/solid'
|
||||
|
||||
import useWalletStore from '../../stores/useWalletStore'
|
||||
import Tooltip from '@components/Tooltip'
|
||||
import VetoButtons from './VetoButtons'
|
||||
import { CastVoteButtons } from './CastVoteButtons'
|
||||
import { YouVoted } from './YouVoted'
|
||||
import { useIsVoting, useProposalVoteRecordQuery } from './hooks'
|
||||
|
||||
const VotePanel = () => {
|
||||
const { proposal } = useWalletStore((s) => s.selectedProposal)
|
||||
const connected = useWalletStore((s) => s.connected)
|
||||
|
||||
const { data: ownVoteRecord } = useProposalVoteRecordQuery('electoral')
|
||||
|
||||
const isVoteCast = ownVoteRecord?.result !== undefined
|
||||
const isVoting = useIsVoting()
|
||||
|
||||
const didNotVote =
|
||||
connected &&
|
||||
!!proposal &&
|
||||
!isVoting &&
|
||||
proposal.account.state !== ProposalState.Cancelled &&
|
||||
proposal.account.state !== ProposalState.Draft &&
|
||||
!isVoteCast
|
||||
|
||||
return (
|
||||
<>
|
||||
{didNotVote && (
|
||||
<div className="bg-bkg-2 p-4 md:p-6 rounded-lg flex flex-col items-center justify-center">
|
||||
<h3 className="text-center mb-0">You did not vote electorally</h3>
|
||||
<Tooltip content="You did not vote on this proposal">
|
||||
<BanIcon className="h-[34px] w-[34px] fill-white/50 mt-2" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{/* START: Note that these components control for themselves whether they are displayed and may not be visible */}
|
||||
<YouVoted quorum="electoral" />
|
||||
<CastVoteButtons />
|
||||
<YouVoted quorum="veto" />
|
||||
<VetoButtons />
|
||||
{/* END */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default VotePanel
|
|
@ -0,0 +1,179 @@
|
|||
import { VoteKind, withFinalizeVote } from '@solana/spl-governance'
|
||||
import { TransactionInstruction } from '@solana/web3.js'
|
||||
import { useState } from 'react'
|
||||
import { relinquishVote } from '../../actions/relinquishVote'
|
||||
import useRealm from '../../hooks/useRealm'
|
||||
import { ProposalState } from '@solana/spl-governance'
|
||||
import { RpcContext } from '@solana/spl-governance'
|
||||
import {
|
||||
ThumbUpIcon,
|
||||
ThumbDownIcon,
|
||||
BanIcon,
|
||||
MinusCircleIcon,
|
||||
} from '@heroicons/react/solid'
|
||||
import useWalletStore from '../../stores/useWalletStore'
|
||||
import { SecondaryButton } from '../Button'
|
||||
import { getProgramVersionForRealm } from '@models/registry/api'
|
||||
import useVotePluginsClientStore from 'stores/useVotePluginsClientStore'
|
||||
import { useRouter } from 'next/router'
|
||||
import useNftPluginStore from 'NftVotePlugin/store/nftPluginStore'
|
||||
import Tooltip from '@components/Tooltip'
|
||||
import {
|
||||
useVoterTokenRecord,
|
||||
useIsVoting,
|
||||
useProposalVoteRecordQuery,
|
||||
} from './hooks'
|
||||
import assertUnreachable from '@utils/typescript/assertUnreachable'
|
||||
|
||||
export const YouVoted = ({ quorum }: { quorum: 'electoral' | 'veto' }) => {
|
||||
const client = useVotePluginsClientStore(
|
||||
(s) => s.state.currentRealmVotingClient
|
||||
)
|
||||
const router = useRouter()
|
||||
const { pk } = router.query
|
||||
const { proposal } = useWalletStore((s) => s.selectedProposal)
|
||||
const { realm, realmInfo } = useRealm()
|
||||
const wallet = useWalletStore((s) => s.current)
|
||||
const connection = useWalletStore((s) => s.connection)
|
||||
const connected = useWalletStore((s) => s.connected)
|
||||
const refetchProposals = useWalletStore((s) => s.actions.refetchProposals)
|
||||
const fetchProposal = useWalletStore((s) => s.actions.fetchProposal)
|
||||
const maxVoterWeight =
|
||||
useNftPluginStore((s) => s.state.maxVoteRecord)?.pubkey || undefined
|
||||
|
||||
const isVoting = useIsVoting()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const { data } = useProposalVoteRecordQuery(quorum)
|
||||
const ownVoteRecord = data?.result
|
||||
const voterTokenRecord = useVoterTokenRecord()
|
||||
|
||||
const isWithdrawEnabled =
|
||||
connected &&
|
||||
ownVoteRecord &&
|
||||
!ownVoteRecord?.account.isRelinquished &&
|
||||
proposal &&
|
||||
(proposal!.account.state === ProposalState.Voting ||
|
||||
proposal!.account.state === ProposalState.Completed ||
|
||||
proposal!.account.state === ProposalState.Cancelled ||
|
||||
proposal!.account.state === ProposalState.Succeeded ||
|
||||
proposal!.account.state === ProposalState.Executing ||
|
||||
proposal!.account.state === ProposalState.Defeated)
|
||||
|
||||
const withdrawTooltipContent = !connected
|
||||
? 'You need to connect your wallet'
|
||||
: !isWithdrawEnabled
|
||||
? !ownVoteRecord?.account.isRelinquished
|
||||
? 'Owner vote record is not relinquished'
|
||||
: 'The proposal is not in a valid state to execute this action.'
|
||||
: ''
|
||||
|
||||
const submitRelinquishVote = async () => {
|
||||
if (
|
||||
realm === undefined ||
|
||||
proposal === undefined ||
|
||||
voterTokenRecord === undefined ||
|
||||
ownVoteRecord === undefined ||
|
||||
ownVoteRecord === null
|
||||
)
|
||||
return
|
||||
|
||||
const rpcContext = new RpcContext(
|
||||
proposal!.owner,
|
||||
getProgramVersionForRealm(realmInfo!),
|
||||
wallet!,
|
||||
connection.current,
|
||||
connection.endpoint
|
||||
)
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const instructions: TransactionInstruction[] = []
|
||||
|
||||
if (proposal !== undefined && isVoting) {
|
||||
await withFinalizeVote(
|
||||
instructions,
|
||||
realmInfo!.programId,
|
||||
getProgramVersionForRealm(realmInfo!),
|
||||
realm!.pubkey,
|
||||
proposal.account.governance,
|
||||
proposal.pubkey,
|
||||
proposal.account.tokenOwnerRecord,
|
||||
proposal.account.governingTokenMint,
|
||||
maxVoterWeight
|
||||
)
|
||||
}
|
||||
|
||||
await relinquishVote(
|
||||
rpcContext,
|
||||
realm.pubkey,
|
||||
proposal,
|
||||
voterTokenRecord.pubkey,
|
||||
ownVoteRecord.pubkey,
|
||||
instructions,
|
||||
client
|
||||
)
|
||||
await refetchProposals()
|
||||
if (pk) {
|
||||
fetchProposal(pk)
|
||||
}
|
||||
} catch (ex) {
|
||||
console.error("Can't relinquish vote", ex)
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const vote = ownVoteRecord?.account.vote
|
||||
|
||||
return vote !== undefined ? (
|
||||
<div className="bg-bkg-2 p-4 md:p-6 rounded-lg space-y-4">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<h3 className="text-center">
|
||||
{quorum === 'electoral' ? 'Your vote' : 'You voted to veto'}
|
||||
</h3>
|
||||
{vote.voteType === VoteKind.Approve ? (
|
||||
<Tooltip content={`You voted "Yes"`}>
|
||||
<div className="flex flex-row items-center justify-center rounded-full border border-[#8EFFDD] p-2 mt-2">
|
||||
<ThumbUpIcon className="h-4 w-4 fill-[#8EFFDD]" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : vote.voteType === VoteKind.Deny ? (
|
||||
<Tooltip content={`You voted "No"`}>
|
||||
<div className="flex flex-row items-center justify-center rounded-full border border-[#FF7C7C] p-2 mt-2">
|
||||
<ThumbDownIcon className="h-4 w-4 fill-[#FF7C7C]" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : vote.voteType === VoteKind.Veto ? (
|
||||
<Tooltip content={`You voted "Veto"`}>
|
||||
<div className="flex flex-row items-center justify-center rounded-full border border-[#FF7C7C] p-2 mt-2">
|
||||
<BanIcon className="h-4 w-4 fill-[#FF7C7C]" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : vote.voteType === VoteKind.Abstain ? (
|
||||
<Tooltip content={`You voted "Abstain"`}>
|
||||
<div className="flex flex-row items-center justify-center rounded-full border border-gray-400 p-2 mt-2">
|
||||
<MinusCircleIcon className="h-4 w-4 fill-gray-400" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
assertUnreachable(vote.voteType)
|
||||
)}
|
||||
</div>
|
||||
{isVoting && (
|
||||
<div className="items-center justify-center flex w-full gap-5">
|
||||
<div className="flex flex-col gap-6 items-center">
|
||||
<SecondaryButton
|
||||
className="min-w-[200px]"
|
||||
isLoading={isLoading}
|
||||
tooltipMessage={withdrawTooltipContent}
|
||||
onClick={() => submitRelinquishVote()}
|
||||
disabled={!isWithdrawEnabled || isLoading}
|
||||
>
|
||||
Withdraw
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
import {
|
||||
useAddressQuery_CommunityTokenOwner,
|
||||
useAddressQuery_CouncilTokenOwner,
|
||||
} from '@hooks/queries/addresses/tokenOwner'
|
||||
import { useAddressQuery_SelectedProposalVoteRecord } from '@hooks/queries/addresses/voteRecord'
|
||||
import { useVoteRecordByPubkeyQuery } from '@hooks/queries/voteRecord'
|
||||
import { useHasVoteTimeExpired } from '@hooks/useHasVoteTimeExpired'
|
||||
import useRealm from '@hooks/useRealm'
|
||||
import { ProposalState, GoverningTokenRole } from '@solana/spl-governance'
|
||||
import useWalletStore from 'stores/useWalletStore'
|
||||
|
||||
export const useIsVoting = () => {
|
||||
const { governance, proposal } = useWalletStore((s) => s.selectedProposal)
|
||||
const hasVoteTimeExpired = useHasVoteTimeExpired(governance, proposal!)
|
||||
|
||||
const isVoting =
|
||||
proposal?.account.state === ProposalState.Voting && !hasVoteTimeExpired
|
||||
return isVoting
|
||||
}
|
||||
|
||||
export const useVotingPop = () => {
|
||||
const { tokenRole } = useWalletStore((s) => s.selectedProposal)
|
||||
|
||||
const votingPop =
|
||||
tokenRole === GoverningTokenRole.Community ? 'community' : 'council'
|
||||
|
||||
return votingPop
|
||||
}
|
||||
|
||||
export const useVoterTokenRecord = () => {
|
||||
const { tokenRole } = useWalletStore((s) => s.selectedProposal)
|
||||
const { ownTokenRecord, ownCouncilTokenRecord } = useRealm()
|
||||
|
||||
const voterTokenRecord =
|
||||
tokenRole === GoverningTokenRole.Community
|
||||
? ownTokenRecord
|
||||
: ownCouncilTokenRecord
|
||||
return voterTokenRecord
|
||||
}
|
||||
|
||||
export const useProposalVoteRecordQuery = (quorum: 'electoral' | 'veto') => {
|
||||
const tokenRole = useWalletStore((s) => s.selectedProposal.tokenRole)
|
||||
const community = useAddressQuery_CommunityTokenOwner()
|
||||
const council = useAddressQuery_CouncilTokenOwner()
|
||||
|
||||
const electoral =
|
||||
tokenRole === undefined
|
||||
? undefined
|
||||
: tokenRole === GoverningTokenRole.Community
|
||||
? community
|
||||
: council
|
||||
const veto =
|
||||
tokenRole === undefined
|
||||
? undefined
|
||||
: tokenRole === GoverningTokenRole.Community
|
||||
? council
|
||||
: community
|
||||
|
||||
const selectedTokenRecord = quorum === 'electoral' ? electoral : veto
|
||||
|
||||
const pda = useAddressQuery_SelectedProposalVoteRecord(
|
||||
selectedTokenRecord?.data
|
||||
)
|
||||
|
||||
return useVoteRecordByPubkeyQuery(pda.data)
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { default } from './VotePanel'
|
|
@ -1,47 +1,81 @@
|
|||
import { XCircleIcon, CheckCircleIcon } from '@heroicons/react/outline'
|
||||
import { BanIcon } from '@heroicons/react/solid'
|
||||
import useProposal from '@hooks/useProposal'
|
||||
import useProposalVotes from '@hooks/useProposalVotes'
|
||||
import { ProposalState } from '@solana/spl-governance'
|
||||
import { useVetoingPop } from './VotePanel/VetoButtons'
|
||||
|
||||
type VoteResultStatusProps = {
|
||||
progress: number
|
||||
votePassed: boolean | undefined
|
||||
yesVotesRequired: number
|
||||
const VetoResult = () => {
|
||||
const vetoingPop = useVetoingPop()
|
||||
return (
|
||||
<div className="bg-bkg-1 flex items-center p-3 rounded-md">
|
||||
<BanIcon className="h-5 mr-1.5 text-red w-5" />
|
||||
<h4 className="mb-0">Vetoed by the {vetoingPop}</h4>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const VoteResultStatus = ({
|
||||
progress,
|
||||
votePassed,
|
||||
yesVotesRequired,
|
||||
}: VoteResultStatusProps) => {
|
||||
return votePassed ? (
|
||||
<div className="bg-bkg-1 flex items-center p-3 rounded-md">
|
||||
<CheckCircleIcon className="h-5 mr-1.5 text-green w-5" />
|
||||
<h4 className="mb-0">The proposal has passed</h4>
|
||||
</div>
|
||||
) : (
|
||||
const ApprovalResult = () => (
|
||||
<div className="bg-bkg-1 flex items-center p-3 rounded-md">
|
||||
<CheckCircleIcon className="h-5 mr-1.5 text-green w-5" />
|
||||
<h4 className="mb-0">The proposal has passed</h4>
|
||||
</div>
|
||||
)
|
||||
|
||||
const FailResult = () => {
|
||||
const { proposal } = useProposal()
|
||||
const voteData = useProposalVotes(proposal?.account)
|
||||
|
||||
return voteData.yesVotesRequired === undefined ? null : (
|
||||
<div className="bg-bkg-1 p-3 rounded-md">
|
||||
<div className="flex items-center">
|
||||
<XCircleIcon className="h-5 mr-1.5 text-red w-5" />
|
||||
<div>
|
||||
<h4 className="mb-0">The proposal has failed</h4>
|
||||
<p className="mb-0 text-fgd-2">{`${yesVotesRequired?.toLocaleString(
|
||||
<p className="mb-0 text-fgd-2">{`${voteData.yesVotesRequired.toLocaleString(
|
||||
undefined,
|
||||
{
|
||||
maximumFractionDigits: 0,
|
||||
}
|
||||
)} more Yes vote${yesVotesRequired > 1 ? 's' : ''} were needed`}</p>
|
||||
)} more Yes vote${
|
||||
voteData.yesVotesRequired > 1 ? 's' : ''
|
||||
} were needed`}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-bkg-4 h-2 flex flex-grow mt-2.5 rounded w-full">
|
||||
<div
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
width: `${voteData.yesVoteProgress}%`,
|
||||
}}
|
||||
className={`${
|
||||
progress >= 100 ? 'bg-green' : 'bg-fgd-3'
|
||||
} flex rounded`}
|
||||
className={`bg-fgd-3 flex rounded`}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const VoteResultStatus = () => {
|
||||
const { proposal } = useProposal()
|
||||
|
||||
const status =
|
||||
proposal &&
|
||||
(proposal.account.state === ProposalState.Completed ||
|
||||
proposal.account.state === ProposalState.Executing ||
|
||||
proposal.account.state === ProposalState.SigningOff ||
|
||||
proposal.account.state === ProposalState.Succeeded ||
|
||||
proposal.account.state === ProposalState.ExecutingWithErrors
|
||||
? 'approved'
|
||||
: proposal.account.state === ProposalState.Vetoed
|
||||
? 'vetoed'
|
||||
: 'denied')
|
||||
|
||||
return status === undefined ? null : status === 'approved' ? (
|
||||
<ApprovalResult />
|
||||
) : status === 'vetoed' ? (
|
||||
<VetoResult />
|
||||
) : (
|
||||
<FailResult />
|
||||
)
|
||||
}
|
||||
|
||||
export default VoteResultStatus
|
||||
|
|
|
@ -7,6 +7,7 @@ type VoteResultsProps = {
|
|||
proposal: Proposal
|
||||
}
|
||||
|
||||
// TODO make component display well when data is loading
|
||||
const VoteResults = ({ isListView, proposal }: VoteResultsProps) => {
|
||||
const {
|
||||
yesVoteCount,
|
||||
|
@ -29,7 +30,7 @@ const VoteResults = ({ isListView, proposal }: VoteResultsProps) => {
|
|||
!isListView ? 'hero-text' : ''
|
||||
}`}
|
||||
>
|
||||
{yesVoteCount.toLocaleString()}
|
||||
{(yesVoteCount ?? 0).toLocaleString()}
|
||||
{isListView ? (
|
||||
<span className="ml-1 text-xs font-normal text-fgd-3">
|
||||
{relativeYesVotes?.toFixed(1)}%
|
||||
|
@ -49,7 +50,7 @@ const VoteResults = ({ isListView, proposal }: VoteResultsProps) => {
|
|||
!isListView ? 'hero-text' : ''
|
||||
}`}
|
||||
>
|
||||
{noVoteCount.toLocaleString()}
|
||||
{(noVoteCount ?? 0).toLocaleString()}
|
||||
{isListView ? (
|
||||
<span className="ml-1 text-xs font-normal text-fgd-3">
|
||||
{relativeNoVotes?.toFixed(1)}%
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import ApprovalQuorum from '@components/ApprovalQuorum'
|
||||
import { ApprovalProgress } from '@components/QuorumProgress'
|
||||
import Button from '@components/Button'
|
||||
import VoteResultsForRealmProposal from '@components/VoteResultsForRealmProposal'
|
||||
import { ExternalLinkIcon } from '@heroicons/react/outline'
|
||||
|
@ -25,104 +25,12 @@ import {
|
|||
import { PublicKey, Transaction, TransactionInstruction } from '@solana/web3.js'
|
||||
import { notify } from '@utils/notifications'
|
||||
import { InstructionDataWithHoldUpTime } from 'actions/createProposal'
|
||||
import classNames from 'classnames'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useCallback, useState } from 'react'
|
||||
import useWalletStore from 'stores/useWalletStore'
|
||||
import VoteProposalModal from './VoteProposalModal'
|
||||
|
||||
function getLabel(proposalState: ProposalState, hasVotingExpired?: boolean) {
|
||||
switch (proposalState) {
|
||||
case ProposalState.Cancelled:
|
||||
return 'Cancelled'
|
||||
case ProposalState.Completed:
|
||||
return 'Completed'
|
||||
case ProposalState.Defeated:
|
||||
return 'Defeated'
|
||||
case ProposalState.Draft:
|
||||
return 'Draft'
|
||||
case ProposalState.Executing:
|
||||
return 'Executable'
|
||||
case ProposalState.ExecutingWithErrors:
|
||||
return 'Executing w/ errors'
|
||||
case ProposalState.SigningOff:
|
||||
return 'Signing off'
|
||||
case ProposalState.Succeeded:
|
||||
return 'Completed'
|
||||
case ProposalState.Voting:
|
||||
return hasVotingExpired ? 'Finalizing' : 'Voting'
|
||||
}
|
||||
}
|
||||
|
||||
function getTextColor(
|
||||
proposalState: ProposalState,
|
||||
hasVotingExpired?: boolean
|
||||
) {
|
||||
switch (proposalState) {
|
||||
case ProposalState.Cancelled:
|
||||
case ProposalState.Draft:
|
||||
return 'text-white'
|
||||
case ProposalState.Completed:
|
||||
return 'text-[#8EFFDD]'
|
||||
case ProposalState.Defeated:
|
||||
case ProposalState.ExecutingWithErrors:
|
||||
return 'text-[#FF7C7C]'
|
||||
case ProposalState.Executing:
|
||||
return 'text-[#5DC9EB]'
|
||||
case ProposalState.SigningOff:
|
||||
return 'text-[#F5A458]'
|
||||
case ProposalState.Succeeded:
|
||||
return 'text-[#8EFFDD]'
|
||||
|
||||
case ProposalState.Voting:
|
||||
return hasVotingExpired
|
||||
? 'bg-gradient-to-r from-[#00C2FF] via-[#00E4FF] to-[#87F2FF] bg-clip-text text-transparent'
|
||||
: 'text-[#8EFFDD]'
|
||||
}
|
||||
}
|
||||
|
||||
function getBorderColor(
|
||||
proposalState: ProposalState,
|
||||
hasVotingExpired?: boolean
|
||||
) {
|
||||
switch (proposalState) {
|
||||
case ProposalState.Cancelled:
|
||||
case ProposalState.Completed:
|
||||
case ProposalState.Defeated:
|
||||
case ProposalState.ExecutingWithErrors:
|
||||
return 'border-transparent'
|
||||
case ProposalState.Executing:
|
||||
return 'border-[#5DC9EB]'
|
||||
case ProposalState.Draft:
|
||||
return 'border-white'
|
||||
case ProposalState.SigningOff:
|
||||
return 'border-[#F5A458]'
|
||||
case ProposalState.Succeeded:
|
||||
return 'border-transparent'
|
||||
|
||||
case ProposalState.Voting:
|
||||
return hasVotingExpired ? 'border-[#5DC9EB]' : 'border-[#8EFFDD]'
|
||||
}
|
||||
}
|
||||
|
||||
function getOpacity(proposalState: ProposalState) {
|
||||
switch (proposalState) {
|
||||
case ProposalState.Cancelled:
|
||||
case ProposalState.Completed:
|
||||
case ProposalState.Defeated:
|
||||
case ProposalState.ExecutingWithErrors:
|
||||
return 'opacity-70'
|
||||
case ProposalState.Draft:
|
||||
return ''
|
||||
case ProposalState.SigningOff:
|
||||
return ''
|
||||
case ProposalState.Succeeded:
|
||||
return 'opacity-70'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
import ProposalStateBadge from '@components/ProposalStateBadge'
|
||||
|
||||
interface Props {
|
||||
voteRecord?: ProgramAccount<VoteRecord>
|
||||
|
@ -286,23 +194,7 @@ export default function ProposalDetails({
|
|||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'border',
|
||||
'inline-flex',
|
||||
'min-w-max',
|
||||
'items-center',
|
||||
'px-2',
|
||||
'py-1',
|
||||
'rounded-full',
|
||||
'text-xs',
|
||||
getBorderColor(proposal.account.state, hasVoteTimeExpired),
|
||||
getOpacity(proposal.account.state),
|
||||
getTextColor(proposal.account.state, hasVoteTimeExpired)
|
||||
)}
|
||||
>
|
||||
{getLabel(proposal.account.state, hasVoteTimeExpired)}
|
||||
</div>
|
||||
<ProposalStateBadge proposal={proposal.account} />
|
||||
</div>
|
||||
<div className="flex flex-col lg:flex-row space-y-1 lg:space-y-0 lg:space-x-3">
|
||||
<div className="flex-1">
|
||||
|
@ -315,9 +207,9 @@ export default function ProposalDetails({
|
|||
</div>
|
||||
<div className="hidden lg:block self-stretch w-0.5 bg-fgd-4" />
|
||||
<div className="flex-1">
|
||||
<ApprovalQuorum
|
||||
<ApprovalProgress
|
||||
progress={voteData.yesVoteProgress}
|
||||
yesVotesRequired={voteData.yesVotesRequired}
|
||||
votesRequired={voteData.yesVotesRequired}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,80 @@
|
|||
import useRealm from '@hooks/useRealm'
|
||||
import { getTokenOwnerRecordAddress } from '@solana/spl-governance'
|
||||
import { PublicKey } from '@solana/web3.js'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import useWalletStore from 'stores/useWalletStore'
|
||||
|
||||
export const useAddressQuery_CouncilTokenOwner = () => {
|
||||
const { realm } = useRealm()
|
||||
const wallet = useWalletStore((x) => x.current)
|
||||
const selectedCouncilDelegator = useWalletStore(
|
||||
(s) => s.selectedCouncilDelegate
|
||||
)
|
||||
|
||||
// if we have a council token delegator selected (this is rare), use that. otherwise use user wallet.
|
||||
const owner =
|
||||
selectedCouncilDelegator !== undefined
|
||||
? new PublicKey(selectedCouncilDelegator)
|
||||
: wallet?.publicKey ?? undefined
|
||||
|
||||
return useAdressQuery_TokenOwnerRecord(
|
||||
realm?.owner,
|
||||
realm?.pubkey,
|
||||
realm?.account.config.councilMint,
|
||||
owner
|
||||
)
|
||||
}
|
||||
|
||||
export const useAddressQuery_CommunityTokenOwner = () => {
|
||||
const { realm } = useRealm()
|
||||
const wallet = useWalletStore((x) => x.current)
|
||||
const selectedCommunityDelegator = useWalletStore(
|
||||
(s) => s.selectedCommunityDelegate
|
||||
)
|
||||
|
||||
// if we have a community token delegator selected (this is rare), use that. otherwise use user wallet.
|
||||
const owner =
|
||||
selectedCommunityDelegator !== undefined
|
||||
? new PublicKey(selectedCommunityDelegator)
|
||||
: // I wanted to eliminate `null` as a possible type
|
||||
wallet?.publicKey ?? undefined
|
||||
|
||||
return useAdressQuery_TokenOwnerRecord(
|
||||
realm?.owner,
|
||||
realm?.pubkey,
|
||||
realm?.account.communityMint,
|
||||
owner
|
||||
)
|
||||
}
|
||||
|
||||
export const useAdressQuery_TokenOwnerRecord = (
|
||||
programId?: PublicKey,
|
||||
realmPk?: PublicKey,
|
||||
governingTokenMint?: PublicKey,
|
||||
owner?: PublicKey
|
||||
) => {
|
||||
const enabled =
|
||||
owner !== undefined &&
|
||||
governingTokenMint !== undefined &&
|
||||
realmPk !== undefined &&
|
||||
programId !== undefined
|
||||
|
||||
return useQuery({
|
||||
queryKey: enabled
|
||||
? ['TokenOwnerAddress', [programId, realmPk, governingTokenMint, owner]]
|
||||
: undefined,
|
||||
queryFn: async () => {
|
||||
if (!enabled) throw new Error()
|
||||
|
||||
return getTokenOwnerRecordAddress(
|
||||
programId,
|
||||
realmPk,
|
||||
governingTokenMint,
|
||||
owner
|
||||
)
|
||||
},
|
||||
enabled,
|
||||
// Staletime is zero by default, so queries get refetched often. PDAs will never go stale.
|
||||
staleTime: Number.MAX_SAFE_INTEGER,
|
||||
})
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
import useRealm from '@hooks/useRealm'
|
||||
import { getVoteRecordAddress } from '@solana/spl-governance'
|
||||
import { PublicKey } from '@solana/web3.js'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
export const useAddressQuery_SelectedProposalVoteRecord = (
|
||||
tokenOwnerRecordAddress?: PublicKey
|
||||
) => {
|
||||
const router = useRouter()
|
||||
const { pk } = router.query
|
||||
const { realm } = useRealm()
|
||||
|
||||
const programId = realm?.owner // TODO make me cached plz
|
||||
const proposalAddress = typeof pk === 'string' ? new PublicKey(pk) : undefined
|
||||
|
||||
return useAddressQuery_VoteRecord(
|
||||
programId,
|
||||
proposalAddress,
|
||||
tokenOwnerRecordAddress
|
||||
)
|
||||
}
|
||||
|
||||
export const useAddressQuery_VoteRecord = (
|
||||
programId?: PublicKey,
|
||||
proposalAddress?: PublicKey,
|
||||
tokenOwnerRecordAddress?: PublicKey
|
||||
) => {
|
||||
const enabled =
|
||||
programId !== undefined &&
|
||||
proposalAddress !== undefined &&
|
||||
tokenOwnerRecordAddress !== undefined
|
||||
|
||||
return useQuery({
|
||||
queryKey: enabled
|
||||
? [
|
||||
'VoteRecordAddress',
|
||||
[programId, proposalAddress, tokenOwnerRecordAddress],
|
||||
]
|
||||
: undefined,
|
||||
queryFn: () => {
|
||||
if (!enabled) throw new Error()
|
||||
|
||||
return getVoteRecordAddress(
|
||||
programId,
|
||||
proposalAddress,
|
||||
tokenOwnerRecordAddress
|
||||
)
|
||||
},
|
||||
enabled,
|
||||
// Staletime is zero by default, so queries get refetched often. PDAs will never go stale.
|
||||
staleTime: Number.MAX_SAFE_INTEGER,
|
||||
})
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import { getVoteRecord } from '@solana/spl-governance'
|
||||
import { Connection, PublicKey } from '@solana/web3.js'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import asFindable from '@utils/queries/asFindable'
|
||||
import useWalletStore from 'stores/useWalletStore'
|
||||
import { useAddressQuery_SelectedProposalVoteRecord } from './addresses/voteRecord'
|
||||
import queryClient from './queryClient'
|
||||
|
||||
export const voteRecordQueryKeys = {
|
||||
all: ['VoteRecord'],
|
||||
byPubkey: (k: PublicKey) => [...voteRecordQueryKeys.all, k.toString()],
|
||||
}
|
||||
|
||||
// currently unused
|
||||
export const useVoteRecordByTokenOwnerRecordQuery = (
|
||||
tokenOwnerRecordAddress?: PublicKey
|
||||
) => {
|
||||
const pda = useAddressQuery_SelectedProposalVoteRecord(
|
||||
tokenOwnerRecordAddress
|
||||
)
|
||||
const query = useVoteRecordByPubkeyQuery(pda.data)
|
||||
return query
|
||||
}
|
||||
|
||||
export const useVoteRecordByPubkeyQuery = (pubkey?: PublicKey) => {
|
||||
const connection = useWalletStore((s) => s.connection)
|
||||
|
||||
const enabled = pubkey !== undefined
|
||||
const query = useQuery({
|
||||
queryKey: enabled ? voteRecordQueryKeys.byPubkey(pubkey) : undefined,
|
||||
queryFn: async () => {
|
||||
if (!enabled) throw new Error()
|
||||
return asFindable(getVoteRecord)(connection.current, pubkey)
|
||||
},
|
||||
enabled,
|
||||
})
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
export const fetchVoteRecordByPubkey = (
|
||||
connection: Connection,
|
||||
pubkey: PublicKey
|
||||
) =>
|
||||
queryClient.fetchQuery({
|
||||
queryKey: voteRecordQueryKeys.byPubkey(pubkey),
|
||||
queryFn: () => asFindable(getVoteRecord)(connection, pubkey),
|
||||
})
|
|
@ -2,13 +2,16 @@ import { Proposal } from '@solana/spl-governance'
|
|||
import useNftPluginStore from 'NftVotePlugin/store/nftPluginStore'
|
||||
import { getProposalMaxVoteWeight } from '../models/voteWeights'
|
||||
import { calculatePct, fmtTokenAmount } from '../utils/formatting'
|
||||
import useProgramVersion from './useProgramVersion'
|
||||
import useRealm from './useRealm'
|
||||
|
||||
// TODO support council plugins
|
||||
export default function useProposalVotes(proposal?: Proposal) {
|
||||
const { realm, mint, councilMint, governances } = useRealm()
|
||||
const maxVoteRecord = useNftPluginStore((s) => s.state.maxVoteRecord)
|
||||
const governance =
|
||||
proposal && governances[proposal.governance?.toBase58()]?.account
|
||||
const programVersion = useProgramVersion()
|
||||
|
||||
const proposalMint =
|
||||
proposal?.governingTokenMint.toBase58() ===
|
||||
|
@ -18,23 +21,31 @@ export default function useProposalVotes(proposal?: Proposal) {
|
|||
// TODO: optimize using memo
|
||||
if (!realm || !proposal || !governance || !proposalMint)
|
||||
return {
|
||||
voteThresholdPct: 100,
|
||||
yesVotePct: 0,
|
||||
yesVoteProgress: 0,
|
||||
yesVoteCount: 0,
|
||||
noVoteCount: 0,
|
||||
minimumYesVotes: 0,
|
||||
yesVotesRequired: 0,
|
||||
_programVersion: undefined,
|
||||
voteThresholdPct: undefined,
|
||||
yesVotePct: undefined,
|
||||
yesVoteProgress: undefined,
|
||||
yesVoteCount: undefined,
|
||||
noVoteCount: undefined,
|
||||
minimumYesVotes: undefined,
|
||||
yesVotesRequired: undefined,
|
||||
relativeNoVotes: undefined,
|
||||
relativeYesVotes: undefined,
|
||||
}
|
||||
|
||||
const isCommunityVote =
|
||||
proposal?.governingTokenMint.toBase58() ===
|
||||
realm?.account.communityMint.toBase58()
|
||||
const isPluginCommunityVoting = maxVoteRecord && isCommunityVote
|
||||
const voteThresholdPct =
|
||||
(proposal.isVoteFinalized() && proposal.voteThreshold?.value) ||
|
||||
governance.config.communityVoteThreshold.value!
|
||||
const voteThresholdPct = isCommunityVote
|
||||
? governance.config.communityVoteThreshold.value
|
||||
: governance.config.councilVoteThreshold.value
|
||||
if (voteThresholdPct === undefined)
|
||||
throw new Error(
|
||||
'Proposal has no vote threshold (this shouldnt be possible)'
|
||||
)
|
||||
|
||||
// note this can be WRONG if the proposal status is vetoed
|
||||
const maxVoteWeight = isPluginCommunityVoting
|
||||
? maxVoteRecord.account.maxVoterWeight
|
||||
: getProposalMaxVoteWeight(realm.account, proposal, proposalMint)
|
||||
|
@ -67,7 +78,7 @@ export default function useProposalVotes(proposal?: Proposal) {
|
|||
? Math.ceil(rawYesVotesRequired)
|
||||
: rawYesVotesRequired
|
||||
|
||||
return {
|
||||
const results = {
|
||||
voteThresholdPct,
|
||||
yesVotePct,
|
||||
yesVoteProgress,
|
||||
|
@ -78,4 +89,79 @@ export default function useProposalVotes(proposal?: Proposal) {
|
|||
minimumYesVotes,
|
||||
yesVotesRequired,
|
||||
}
|
||||
|
||||
// @asktree: you may be asking yourself, "is this different from the more succinct way to write this?"
|
||||
// the answer is yes, in typescript it is different and this lets us use discriminated unions properly.
|
||||
if (programVersion === 1)
|
||||
return {
|
||||
_programVersion: programVersion,
|
||||
...results,
|
||||
}
|
||||
if (programVersion === 2)
|
||||
return {
|
||||
_programVersion: programVersion,
|
||||
...results,
|
||||
}
|
||||
|
||||
// VETOS
|
||||
const vetoThreshold = isCommunityVote
|
||||
? governance.config.councilVetoVoteThreshold
|
||||
: governance.config.communityVetoVoteThreshold
|
||||
|
||||
if (vetoThreshold.value === undefined)
|
||||
return {
|
||||
_programVersion: programVersion,
|
||||
...results,
|
||||
veto: undefined,
|
||||
}
|
||||
|
||||
const vetoMintInfo = isCommunityVote ? councilMint : mint
|
||||
const vetoMintPk = isCommunityVote
|
||||
? realm.account.config.councilMint
|
||||
: realm.account.communityMint
|
||||
|
||||
// This represents an edge case where councilVetoVoteThreshold is defined but there is no councilMint
|
||||
if (vetoMintInfo === undefined || vetoMintPk === undefined)
|
||||
return {
|
||||
_programVersion: programVersion,
|
||||
...results,
|
||||
veto: undefined,
|
||||
}
|
||||
|
||||
const isPluginCommunityVeto = maxVoteRecord && !isCommunityVote
|
||||
|
||||
const vetoMaxVoteWeight = isPluginCommunityVeto
|
||||
? maxVoteRecord.account.maxVoterWeight
|
||||
: getProposalMaxVoteWeight(
|
||||
realm.account,
|
||||
proposal,
|
||||
vetoMintInfo,
|
||||
vetoMintPk
|
||||
)
|
||||
|
||||
const vetoVoteCount = fmtTokenAmount(
|
||||
proposal.vetoVoteWeight,
|
||||
vetoMintInfo.decimals
|
||||
)
|
||||
|
||||
const vetoVoteProgress = calculatePct(
|
||||
proposal.vetoVoteWeight,
|
||||
vetoMaxVoteWeight
|
||||
)
|
||||
|
||||
const minimumVetoVotes =
|
||||
fmtTokenAmount(vetoMaxVoteWeight, vetoMintInfo.decimals) *
|
||||
(vetoThreshold.value / 100)
|
||||
|
||||
const vetoVotesRequired = minimumVetoVotes - vetoVoteCount
|
||||
|
||||
return {
|
||||
_programVersion: programVersion,
|
||||
...results,
|
||||
veto: {
|
||||
votesRequired: vetoVotesRequired,
|
||||
voteCount: vetoVoteCount,
|
||||
voteProgress: vetoVoteProgress,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -132,8 +132,7 @@ export default function useRealm() {
|
|||
return tokenRecords[wallet.publicKey.toBase58()]
|
||||
}
|
||||
return undefined
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- TODO please fix, it can cause difficult bugs. You might wanna check out https://bobbyhadz.com/blog/react-hooks-exhaustive-deps for info. -@asktree
|
||||
}, [tokenRecords, wallet, connected, selectedCommunityDelegate])
|
||||
}, [tokenRecords, wallet, selectedCommunityDelegate])
|
||||
|
||||
// returns array of community tokenOwnerRecords that connected wallet has been delegated
|
||||
const ownDelegateTokenRecords = useMemo(() => {
|
||||
|
|
|
@ -28,6 +28,7 @@ const VotingFilter: Filters = {
|
|||
ExecutingWithErrors: false,
|
||||
SigningOff: false,
|
||||
Voting: true,
|
||||
Vetoed: false,
|
||||
}
|
||||
|
||||
export default function useRealmProposals(
|
||||
|
|
|
@ -4,6 +4,7 @@ import {
|
|||
TokenOwnerRecord,
|
||||
Realm,
|
||||
Proposal,
|
||||
VoteKind,
|
||||
} from '@solana/spl-governance'
|
||||
import { MintInfo } from '@solana/spl-token'
|
||||
import BN from 'bn.js'
|
||||
|
@ -56,11 +57,15 @@ export function buildTopVoters(
|
|||
): VoterDisplayData[] {
|
||||
const maxVote = calculateMaxVoteScore(realm, proposal, governingTokenMint)
|
||||
|
||||
const electoralVotes = voteRecords.filter(
|
||||
(x) => x.account.vote?.voteType !== VoteKind.Veto
|
||||
)
|
||||
|
||||
const undecidedData = tokenOwnerRecords
|
||||
.filter(
|
||||
(tokenOwnerRecord) =>
|
||||
!tokenOwnerRecord.account.governingTokenDepositAmount.isZero() &&
|
||||
!voteRecords.some(
|
||||
!electoralVotes.some(
|
||||
(voteRecord) =>
|
||||
voteRecord.account.governingTokenOwner.toBase58() ===
|
||||
tokenOwnerRecord.account.governingTokenOwner.toBase58()
|
||||
|
@ -76,7 +81,7 @@ export function buildTopVoters(
|
|||
)
|
||||
)
|
||||
|
||||
const noVoteData = voteRecords
|
||||
const noVoteData = electoralVotes
|
||||
.filter((record) => record.account.getNoVoteWeight()?.gt(ZERO))
|
||||
.map((record) =>
|
||||
buildResults(
|
||||
|
@ -88,7 +93,7 @@ export function buildTopVoters(
|
|||
)
|
||||
)
|
||||
|
||||
const yesVoteData = voteRecords
|
||||
const yesVoteData = electoralVotes
|
||||
.filter((record) => record.account.getYesVoteWeight()?.gt(ZERO))
|
||||
.map((record) =>
|
||||
buildResults(
|
||||
|
|
|
@ -10,16 +10,7 @@ export function isYesVote(voteRecord: VoteRecord) {
|
|||
return voteRecord.voteWeight?.yes && !voteRecord.voteWeight.yes.isZero()
|
||||
}
|
||||
case GovernanceAccountType.VoteRecordV2: {
|
||||
switch (voteRecord.vote?.voteType) {
|
||||
case VoteKind.Approve: {
|
||||
return true
|
||||
}
|
||||
case VoteKind.Deny: {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
throw new Error('Invalid voteKind')
|
||||
}
|
||||
return voteRecord.vote?.voteType === VoteKind.Approve
|
||||
}
|
||||
default:
|
||||
throw new Error(`Invalid account type ${voteRecord.accountType} `)
|
||||
|
|
|
@ -551,7 +551,7 @@ export class SimpleGatedVoterWeight implements VoterWeightInterface {
|
|||
}
|
||||
}
|
||||
|
||||
/// Returns max VoteWeight for given mint and max source
|
||||
/** Returns max VoteWeight for given mint and max source */
|
||||
export function getMintMaxVoteWeight(
|
||||
mint: MintInfo,
|
||||
maxVoteWeightSource: MintMaxVoteWeightSource
|
||||
|
@ -574,11 +574,13 @@ export function getMintMaxVoteWeight(
|
|||
}
|
||||
}
|
||||
|
||||
/// Returns max vote weight for a proposal
|
||||
/** Returns max vote weight for a proposal */
|
||||
export function getProposalMaxVoteWeight(
|
||||
realm: Realm,
|
||||
proposal: Proposal,
|
||||
governingTokenMint: MintInfo
|
||||
governingTokenMint: MintInfo,
|
||||
// For vetos we want to override the proposal.governingTokenMint
|
||||
governingTokenMintPk?: PublicKey
|
||||
) {
|
||||
// For finalized proposals the max is stored on the proposal in case it can change in the future
|
||||
if (proposal.isVoteFinalized() && proposal.maxVoteWeight) {
|
||||
|
@ -587,7 +589,7 @@ export function getProposalMaxVoteWeight(
|
|||
|
||||
// Council votes are currently not affected by MaxVoteWeightSource
|
||||
if (
|
||||
proposal.governingTokenMint.toBase58() ===
|
||||
(governingTokenMintPk ?? proposal.governingTokenMint).toBase58() ===
|
||||
realm.config.councilMint?.toBase58()
|
||||
) {
|
||||
return governingTokenMint.supply
|
||||
|
|
|
@ -101,6 +101,7 @@
|
|||
"@switchboard-xyz/switchboard-v2": "0.0.110",
|
||||
"@tailwindcss/line-clamp": "0.4.2",
|
||||
"@tanstack/react-query": "4.14.3",
|
||||
"@tanstack/react-query-devtools": "4.19.1",
|
||||
"@tippyjs/react": "4.2.6",
|
||||
"@types/ramda": "0.28.15",
|
||||
"@urql/exchange-auth": "1.0.0",
|
||||
|
|
|
@ -60,7 +60,6 @@ const REALM = () => {
|
|||
ownTokenRecord,
|
||||
councilTokenOwnerRecords,
|
||||
ownCouncilTokenRecord,
|
||||
isNftMode,
|
||||
} = useRealm()
|
||||
const proposalsPerPage = 20
|
||||
const [filters, setFilters] = useState<Filters>(InitialFilters)
|
||||
|
@ -86,7 +85,6 @@ const REALM = () => {
|
|||
(s) => s.state.currentRealmVotingClient
|
||||
)
|
||||
const wallet = useWalletStore((s) => s.current)
|
||||
const connected = useWalletStore((s) => s.connected)
|
||||
const connection = useWalletStore((s) => s.connection.current)
|
||||
|
||||
const allProposals = Object.entries(proposals).sort((a, b) =>
|
||||
|
@ -266,14 +264,6 @@ const REALM = () => {
|
|||
setIsMultiVoting(false)
|
||||
}
|
||||
|
||||
const showMultiVote = useMemo(
|
||||
() =>
|
||||
realm
|
||||
? realm.account.votingProposalCount > 1 && connected && !isNftMode
|
||||
: false,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- TODO please fix, it can cause difficult bugs. You might wanna check out https://bobbyhadz.com/blog/react-hooks-exhaustive-deps for info. -@asktree
|
||||
[realm, connected]
|
||||
)
|
||||
//Todo: move to own components with refactor to dao folder structure
|
||||
const isPyth = realmInfo?.realmId.toBase58() === PYTH_REALM_ID.toBase58()
|
||||
|
||||
|
@ -382,9 +372,7 @@ const REALM = () => {
|
|||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`flex lg:flex-row items-center justify-between lg:space-x-3 w-full ${
|
||||
showMultiVote ? 'flex-col-reverse' : 'flex-row'
|
||||
}`}
|
||||
className={`flex lg:flex-row items-center justify-between lg:space-x-3 w-full flex-col-reverse`}
|
||||
>
|
||||
<h4 className="font-normal mb-0 text-fgd-2 whitespace-nowrap">{`${
|
||||
filteredProposals.length
|
||||
|
@ -392,25 +380,17 @@ const REALM = () => {
|
|||
filteredProposals.length === 1 ? '' : 's'
|
||||
}`}</h4>
|
||||
<div
|
||||
className={`flex items-center lg:justify-end lg:pb-0 lg:space-x-3 w-full ${
|
||||
showMultiVote
|
||||
? 'justify-between pb-3'
|
||||
: 'justify-end'
|
||||
}`}
|
||||
className={`flex items-center lg:justify-end lg:pb-0 lg:space-x-3 w-full justify-between pb-3`}
|
||||
>
|
||||
{showMultiVote ? (
|
||||
<div className="flex items-center">
|
||||
<p className="mb-0 mr-1 text-fgd-3">
|
||||
Multi-vote Mode
|
||||
</p>
|
||||
<Switch
|
||||
checked={multiVoteMode}
|
||||
onChange={() => {
|
||||
toggleMultiVoteMode()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center">
|
||||
<p className="mb-0 mr-1 text-fgd-3">Batch voting</p>
|
||||
<Switch
|
||||
checked={multiVoteMode}
|
||||
onChange={() => {
|
||||
toggleMultiVoteMode()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<NewProposalBtn />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -5,7 +5,7 @@ import { ChevronLeftIcon } from '@heroicons/react/solid'
|
|||
|
||||
import useProposal from '@hooks/useProposal'
|
||||
import useVoteRecords from '@hooks/useVoteRecords'
|
||||
import ProposalStateBadge from '@components/ProposalStatusBadge'
|
||||
import ProposalStateBadge from '@components/ProposalStateBadge'
|
||||
import ProposalTopVotersList from '@components/ProposalTopVotersList'
|
||||
import ProposalTopVotersBubbleChart from '@components/ProposalTopVotersBubbleChart'
|
||||
import useWalletStore from 'stores/useWalletStore'
|
||||
|
|
|
@ -2,11 +2,11 @@ import ReactMarkdown from 'react-markdown/react-markdown.min'
|
|||
import remarkGfm from 'remark-gfm'
|
||||
import { ExternalLinkIcon } from '@heroicons/react/outline'
|
||||
import useProposal from 'hooks/useProposal'
|
||||
import ProposalStateBadge from 'components/ProposalStatusBadge'
|
||||
import ProposalStateBadge from '@components/ProposalStateBadge'
|
||||
import { InstructionPanel } from 'components/instructions/instructionPanel'
|
||||
import DiscussionPanel from 'components/chat/DiscussionPanel'
|
||||
import VotePanel from 'components/VotePanel'
|
||||
import ApprovalQuorum from 'components/ApprovalQuorum'
|
||||
import VotePanel from '@components/VotePanel/VotePanel'
|
||||
import { ApprovalProgress, VetoProgress } from '@components/QuorumProgress'
|
||||
import useRealm from 'hooks/useRealm'
|
||||
import useProposalVotes from 'hooks/useProposalVotes'
|
||||
import ProposalTimeStatus from 'components/ProposalTimeStatus'
|
||||
|
@ -32,23 +32,13 @@ const Proposal = () => {
|
|||
const { realmInfo, symbol } = useRealm()
|
||||
const { proposal, descriptionLink, governance } = useProposal()
|
||||
const [description, setDescription] = useState('')
|
||||
const { yesVoteProgress, yesVotesRequired } = useProposalVotes(
|
||||
proposal?.account
|
||||
)
|
||||
const voteData = useProposalVotes(proposal?.account)
|
||||
const currentWallet = useWalletStore((s) => s.current)
|
||||
const showResults =
|
||||
proposal &&
|
||||
proposal.account.state !== ProposalState.Cancelled &&
|
||||
proposal.account.state !== ProposalState.Draft
|
||||
|
||||
const votePassed =
|
||||
proposal &&
|
||||
(proposal.account.state === ProposalState.Completed ||
|
||||
proposal.account.state === ProposalState.Executing ||
|
||||
proposal.account.state === ProposalState.SigningOff ||
|
||||
proposal.account.state === ProposalState.Succeeded ||
|
||||
proposal.account.state === ProposalState.ExecutingWithErrors)
|
||||
|
||||
const votingEnded =
|
||||
!!governance &&
|
||||
!!proposal &&
|
||||
|
@ -148,20 +138,28 @@ const Proposal = () => {
|
|||
<h3 className="mb-4">Results</h3>
|
||||
)}
|
||||
{proposal?.account.state === ProposalState.Voting ? (
|
||||
<div className="pb-3">
|
||||
<ApprovalQuorum
|
||||
yesVotesRequired={yesVotesRequired}
|
||||
progress={yesVoteProgress}
|
||||
showBg
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div className="pb-3">
|
||||
<ApprovalProgress
|
||||
votesRequired={voteData.yesVotesRequired}
|
||||
progress={voteData.yesVoteProgress}
|
||||
showBg
|
||||
/>
|
||||
</div>
|
||||
{voteData._programVersion === 3 &&
|
||||
voteData.veto !== undefined ? (
|
||||
<div className="pb-3">
|
||||
<VetoProgress
|
||||
votesRequired={voteData.veto.votesRequired}
|
||||
progress={voteData.veto.voteProgress}
|
||||
showBg
|
||||
/>
|
||||
</div>
|
||||
) : undefined}
|
||||
</>
|
||||
) : (
|
||||
<div className="pb-3">
|
||||
<VoteResultStatus
|
||||
progress={yesVoteProgress}
|
||||
votePassed={votePassed}
|
||||
yesVotesRequired={yesVotesRequired}
|
||||
/>
|
||||
<VoteResultStatus />
|
||||
</div>
|
||||
)}
|
||||
<VoteResults proposal={proposal.account} />
|
||||
|
|
|
@ -100,6 +100,7 @@ const MyProposalsBn = () => {
|
|||
x.account.state === ProposalState.Succeeded ||
|
||||
x.account.state === ProposalState.ExecutingWithErrors ||
|
||||
x.account.state === ProposalState.Defeated ||
|
||||
x.account.state === ProposalState.Vetoed ||
|
||||
x.account.state === ProposalState.Cancelled) &&
|
||||
ownVoteRecordsByProposal[x.pubkey.toBase58()] &&
|
||||
!ownVoteRecordsByProposal[x.pubkey.toBase58()]?.account.isRelinquished
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Filters } from '@components/ProposalFilter'
|
||||
import { hasInstructions } from '@components/ProposalStatusBadge'
|
||||
import { hasInstructions } from '@components/ProposalStateBadge'
|
||||
import {
|
||||
Governance,
|
||||
ProgramAccount,
|
||||
|
@ -76,6 +76,10 @@ export const filterProposals = (
|
|||
}
|
||||
}
|
||||
|
||||
if (!filters.Vetoed && proposal.account.state === ProposalState.Vetoed) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
!filters.Defeated &&
|
||||
proposal.account.state === ProposalState.Defeated
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
const asFindable = <P extends any[], R>(f: (...p: P) => Promise<R>) => async (
|
||||
...p: P
|
||||
) => {
|
||||
try {
|
||||
return {
|
||||
found: true,
|
||||
result: await f(...p),
|
||||
} as const
|
||||
} catch (e) {
|
||||
if ((e.message as string).includes('not found')) {
|
||||
return { found: false, result: undefined } as const
|
||||
}
|
||||
|
||||
return Promise.reject(e)
|
||||
}
|
||||
}
|
||||
|
||||
export default asFindable
|
|
@ -0,0 +1,3 @@
|
|||
export default function assertUnreachable(_: never): never {
|
||||
throw new Error('An unreachability assertion was reached')
|
||||
}
|
28
yarn.lock
28
yarn.lock
|
@ -5163,11 +5163,27 @@
|
|||
resolved "https://registry.yarnpkg.com/@tailwindcss/line-clamp/-/line-clamp-0.4.2.tgz#f353c5a8ab2c939c6267ac5b907f012e5ee130f9"
|
||||
integrity sha512-HFzAQuqYCjyy/SX9sLGB1lroPzmcnWv1FHkIpmypte10hptf4oPUfucryMKovZh2u0uiS9U5Ty3GghWfEJGwVw==
|
||||
|
||||
"@tanstack/match-sorter-utils@^8.7.0":
|
||||
version "8.7.0"
|
||||
resolved "https://registry.yarnpkg.com/@tanstack/match-sorter-utils/-/match-sorter-utils-8.7.0.tgz#60b09a6d3d7974d5f86f1318053c1bd5a85fb0be"
|
||||
integrity sha512-OgfIPMHTfuw9JGcXCCoEHWFP/eSP2eyhCYwkrFnWBM3NbUPAgOlFP11DbM7cozDRVB0XbPr1tD4pLAtWKlVUVg==
|
||||
dependencies:
|
||||
remove-accents "0.4.2"
|
||||
|
||||
"@tanstack/query-core@4.14.3":
|
||||
version "4.14.3"
|
||||
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-4.14.3.tgz#9ab6763b61d5e491816483f3aeed771c1fbd657b"
|
||||
integrity sha512-1OrxZk5jSKAjTIDgH/OHa6GfVpgGDVbCVf3KHQjXLFdutK4PSSXQIyX7I1HvBR3mYKyvFPo6yXKNG7QrkwUj9g==
|
||||
|
||||
"@tanstack/react-query-devtools@4.19.1":
|
||||
version "4.19.1"
|
||||
resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-4.19.1.tgz#850058df8dba932362838c17f566bd717044449b"
|
||||
integrity sha512-U63A+ly9JLPJj7ryR9omdXT3n+gS7jlExrHty4klsd/6xdUhC38CKZyZ0Gi3vctaVYRGTU8/vI+uKzBYdFqLaA==
|
||||
dependencies:
|
||||
"@tanstack/match-sorter-utils" "^8.7.0"
|
||||
superjson "^1.10.0"
|
||||
use-sync-external-store "^1.2.0"
|
||||
|
||||
"@tanstack/react-query@4.14.3":
|
||||
version "4.14.3"
|
||||
resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-4.14.3.tgz#3bafea000480463de35721c742265730e2973550"
|
||||
|
@ -8030,6 +8046,18 @@ cookiejar@^2.1.3:
|
|||
resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.3.tgz#fc7a6216e408e74414b90230050842dacda75acc"
|
||||
integrity sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==
|
||||
|
||||
copy-anything@^3.0.2:
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/copy-anything/-/copy-anything-3.0.3.tgz#206767156f08da0e02efd392f71abcdf79643559"
|
||||
integrity sha512-fpW2W/BqEzqPp29QS+MwwfisHCQZtiduTe/m8idFo0xbti9fIZ2WVhAsCv4ggFVH3AgCkVdpoOCtQC6gBrdhjw==
|
||||
dependencies:
|
||||
is-what "^4.1.8"
|
||||
|
||||
copy-descriptor@^0.1.0:
|
||||
version "0.1.1"
|
||||
resolved "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz"
|
||||
integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=
|
||||
|
||||
copy-to-clipboard@^3.3.1:
|
||||
version "3.3.3"
|
||||
resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz#55ac43a1db8ae639a4bd99511c148cdd1b83a1b0"
|
||||
|
|
Loading…
Reference in New Issue