diff --git a/public/images/shutter-icon.svg b/public/images/shutter-icon.svg
new file mode 100644
index 0000000000..26acc2846f
--- /dev/null
+++ b/public/images/shutter-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/components/Proposals/ProposalActions/CastVote.tsx b/src/components/Proposals/ProposalActions/CastVote.tsx
index 59934232f1..e454872c5b 100644
--- a/src/components/Proposals/ProposalActions/CastVote.tsx
+++ b/src/components/Proposals/ProposalActions/CastVote.tsx
@@ -1,5 +1,5 @@
-import { Button, Tooltip } from '@chakra-ui/react';
-import { CloseX, Check } from '@decent-org/fractal-ui';
+import { Button, Tooltip, Box, Text, Image, Flex } from '@chakra-ui/react';
+import { CloseX, Check, SingleCheckOutline } from '@decent-org/fractal-ui';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import useSnapshotProposal from '../../../hooks/DAO/loaders/snapshot/useSnapshotProposal';
@@ -12,22 +12,32 @@ import {
AzoriusVoteChoice,
ExtendedSnapshotProposal,
} from '../../../types';
+import WeightedInput from '../../ui/forms/WeightedInput';
import { useVoteContext } from '../ProposalVotes/context/VoteContext';
function Vote({
proposal,
extendedSnapshotProposal,
+ onCastSnapshotVote,
}: {
proposal: FractalProposal;
extendedSnapshotProposal?: ExtendedSnapshotProposal;
+ onCastSnapshotVote?: () => Promise;
}) {
const [pending, setPending] = useState(false);
- const { t } = useTranslation(['common', 'proposal']);
+ const { t } = useTranslation(['common', 'proposal', 'transaction']);
const { isLoaded: isCurrentBlockLoaded, currentBlockNumber } = useCurrentBlockNumber();
const azoriusProposal = proposal as AzoriusProposal;
- const { castVote, castSnapshotVote } = useCastVote({
+ const {
+ castVote,
+ castSnapshotVote,
+ handleChangeSnapshotWeightedChoice,
+ handleSelectSnapshotChoice,
+ selectedChoice,
+ snapshotWeightedChoice,
+ } = useCastVote({
proposal,
setPending,
extendedSnapshotProposal,
@@ -44,7 +54,7 @@ function Vote({
// If user is lucky enough - he could create a proposal and proceed to vote on it
// even before the block, in which proposal was created, was mined.
// This gives a weird behavior when casting vote fails due to requirement under LinearERC20Voting contract that current block number
- // Shouldn't be equal to proposal's start block number. Which is dictated by the need to have voting tokens delegation being "finalized" to prevent proposal hijacking.
+ // shouldn't be equal to proposal's start block number. Which is dictated by the need to have voting tokens delegation being "finalized" to prevent proposal hijacking.
const proposalStartBlockNotFinalized = Boolean(
!isSnapshotProposal &&
isCurrentBlockLoaded &&
@@ -60,19 +70,85 @@ function Vote({
hasVotedLoading;
if (isSnapshotProposal && extendedSnapshotProposal) {
+ const isWeighted = extendedSnapshotProposal.type === 'weighted';
+ const weightedTotalValue = snapshotWeightedChoice.reduce((prev, curr) => prev + curr, 0);
+ const voteDisabled =
+ (!isWeighted && typeof selectedChoice === 'undefined') ||
+ (isWeighted && weightedTotalValue === 0);
return (
<>
- {extendedSnapshotProposal.choices.map((choice, i) => (
-
- ))}
+
+
+ {t('successCastVote', { ns: 'transaction' })}
+
+ {t('snapshotRecastVoteHelper', { ns: 'transaction' })}
+
+ )}
+
+ {t('poweredBy')}
+
+
+
+ {t('snapshot')}
+
+ {extendedSnapshotProposal.privacy === 'shutter' && (
+
+
+ {t('shutter')}
+
+ )}
+
+
>
);
}
diff --git a/src/components/Proposals/ProposalActions/ProposalAction.tsx b/src/components/Proposals/ProposalActions/ProposalAction.tsx
index 0de83e1468..bad0ed455a 100644
--- a/src/components/Proposals/ProposalActions/ProposalAction.tsx
+++ b/src/components/Proposals/ProposalActions/ProposalAction.tsx
@@ -18,12 +18,15 @@ import { useVoteContext } from '../ProposalVotes/context/VoteContext';
import CastVote from './CastVote';
import { Execute } from './Execute';
+// TODO: Refactor extendedSnapshotProposal and onCastSnapshotVote to the context
export function ProposalActions({
proposal,
extendedSnapshotProposal,
+ onCastSnapshotVote,
}: {
proposal: FractalProposal;
extendedSnapshotProposal?: ExtendedSnapshotProposal;
+ onCastSnapshotVote?: () => Promise;
}) {
switch (proposal.state) {
case FractalProposalState.ACTIVE:
@@ -31,6 +34,7 @@ export function ProposalActions({
);
case FractalProposalState.EXECUTABLE:
@@ -45,10 +49,12 @@ export function ProposalAction({
proposal,
expandedView,
extendedSnapshotProposal,
+ onCastSnapshotVote,
}: {
proposal: FractalProposal;
expandedView?: boolean;
extendedSnapshotProposal?: ExtendedSnapshotProposal;
+ onCastSnapshotVote?: () => Promise;
}) {
const {
node: { daoAddress },
@@ -65,11 +71,12 @@ export function ProposalAction({
);
const showActionButton =
- user.votingWeight.gt(0) &&
- (isActiveProposal ||
- proposal.state === FractalProposalState.EXECUTABLE ||
- proposal.state === FractalProposalState.TIMELOCKABLE ||
- proposal.state === FractalProposalState.TIMELOCKED);
+ (isSnapshotProposal && canVote) ||
+ (user.votingWeight.gt(0) &&
+ (isActiveProposal ||
+ proposal.state === FractalProposalState.EXECUTABLE ||
+ proposal.state === FractalProposalState.TIMELOCKABLE ||
+ proposal.state === FractalProposalState.TIMELOCKED));
const handleClick = () => {
if (isSnapshotProposal) {
@@ -125,7 +132,8 @@ export function ProposalAction({
}
if (expandedView) {
- if (user.votingWeight.eq(0) || (isActiveProposal && !canVote)) return null;
+ if (!isSnapshotProposal && (user.votingWeight.eq(0) || (isActiveProposal && !canVote)))
+ return null;
return (
@@ -140,6 +148,7 @@ export function ProposalAction({
);
diff --git a/src/components/Proposals/ProposalVotes/context/VoteContext.tsx b/src/components/Proposals/ProposalVotes/context/VoteContext.tsx
index 1a9bcada66..81766b1f3d 100644
--- a/src/components/Proposals/ProposalVotes/context/VoteContext.tsx
+++ b/src/components/Proposals/ProposalVotes/context/VoteContext.tsx
@@ -8,6 +8,7 @@ import {
AzoriusProposal,
MultisigProposal,
GovernanceType,
+ ExtendedSnapshotProposal,
} from '../../../../types';
interface IVoteContext {
@@ -36,8 +37,10 @@ export const useVoteContext = () => {
export function VoteContextProvider({
proposal,
children,
+ extendedSnapshotProposal,
}: {
proposal: FractalProposal;
+ extendedSnapshotProposal?: ExtendedSnapshotProposal;
children: ReactNode;
}) {
const [canVote, setCanVote] = useState(false);
@@ -61,8 +64,10 @@ export function VoteContextProvider({
const getHasVoted = useCallback(() => {
setHasVotedLoading(true);
if (isSnapshotProposal) {
- // Snapshot proposals not tracking votes
- setHasVoted(false);
+ setHasVoted(
+ !!extendedSnapshotProposal &&
+ !!extendedSnapshotProposal.votes.find(vote => vote.voter === user.address)
+ );
} else if (dao?.isAzorius) {
const azoriusProposal = proposal as AzoriusProposal;
if (azoriusProposal?.votes) {
@@ -75,18 +80,17 @@ export function VoteContextProvider({
);
}
setHasVotedLoading(false);
- }, [dao, isSnapshotProposal, proposal, user.address]);
+ }, [dao, isSnapshotProposal, proposal, user.address, extendedSnapshotProposal]);
const getCanVote = useCallback(
async (refetchUserTokens?: boolean) => {
setCanVoteLoading(true);
let newCanVote = false;
- if (isSnapshotProposal) {
- const votingWeightData = await loadVotingWeight();
- newCanVote = votingWeightData.votingWeight > 1;
- }
if (user.address) {
- if (type === GovernanceType.AZORIUS_ERC20) {
+ if (isSnapshotProposal) {
+ const votingWeightData = await loadVotingWeight();
+ newCanVote = votingWeightData.votingWeight >= 1;
+ } else if (type === GovernanceType.AZORIUS_ERC20) {
newCanVote = user.votingWeight.gt(0) && !hasVoted;
} else if (type === GovernanceType.AZORIUS_ERC721) {
if (refetchUserTokens) {
@@ -131,7 +135,14 @@ export function VoteContextProvider({
return (
{children}
diff --git a/src/components/Proposals/SnapshotProposalDetails/SnapshotProposalVotes.tsx b/src/components/Proposals/SnapshotProposalDetails/SnapshotProposalVotes.tsx
index f19708fddd..76e81239b8 100644
--- a/src/components/Proposals/SnapshotProposalDetails/SnapshotProposalVotes.tsx
+++ b/src/components/Proposals/SnapshotProposalDetails/SnapshotProposalVotes.tsx
@@ -15,7 +15,7 @@ interface ISnapshotProposalVotes {
export default function SnapshotProposalVotes({ proposal }: ISnapshotProposalVotes) {
const { t } = useTranslation('proposal');
const { totalVotesCasted } = useTotalVotes({ proposal });
- const { votes, votesBreakdown, choices, strategies, privacy, state } = proposal;
+ const { votes, votesBreakdown, choices, strategies, privacy, state, type } = proposal;
const strategySymbol = strategies[0].params.symbol;
return (
@@ -43,9 +43,15 @@ export default function SnapshotProposalVotes({ proposal }: ISnapshotProposalVot
colSpan={4}
rowGap={4}
>
- {choices.map(choice => {
+ {choices.map((choice, i) => {
+ const votesBreakdownChoice =
+ type === 'weighted' ? votesBreakdown[i] : votesBreakdown[choice];
+ const votesBreakdownChoiceTotal =
+ votesBreakdownChoice && votesBreakdownChoice?.total
+ ? votesBreakdownChoice?.total
+ : 0;
const choicePercentageFromTotal =
- ((votesBreakdown[choice]?.total || 0) * 100) / totalVotesCasted;
+ (votesBreakdownChoiceTotal * 100) / totalVotesCasted;
return (
);
diff --git a/src/components/Proposals/SnapshotProposalDetails/index.tsx b/src/components/Proposals/SnapshotProposalDetails/index.tsx
index aca9a85e33..8b22d21c7c 100644
--- a/src/components/Proposals/SnapshotProposalDetails/index.tsx
+++ b/src/components/Proposals/SnapshotProposalDetails/index.tsx
@@ -42,10 +42,14 @@ export default function SnapshotProposalDetails({ proposal }: ISnapshotProposalD
{user.address && (
-
+
diff --git a/src/components/ui/forms/WeightedInput.tsx b/src/components/ui/forms/WeightedInput.tsx
new file mode 100644
index 0000000000..030ccdda82
--- /dev/null
+++ b/src/components/ui/forms/WeightedInput.tsx
@@ -0,0 +1,74 @@
+import { IconButton, Flex, Input, Text } from '@chakra-ui/react';
+import { AddPlus, Minus } from '@decent-org/fractal-ui';
+
+interface IWeightedInput {
+ onChange: (value: number) => void;
+ label: string;
+ value: number;
+ totalValue: number;
+}
+
+export default function WeightedInput({ label, value, totalValue, onChange }: IWeightedInput) {
+ return (
+
+
+ {label}
+
+
+ onChange(Math.max(0, value - 1))}
+ disabled={!value}
+ >
+
+
+ onChange(Math.max(parseInt(e.target.value), 0))}
+ value={value.toString()}
+ type="number"
+ border="none"
+ bg="transparent"
+ padding={0}
+ textAlign="center"
+ color="gold.500"
+ width="48px"
+ />
+ onChange(value + 1)}
+ mr={3}
+ >
+
+
+ {totalValue ? ((value * 100) / totalValue).toFixed(2) : 0}%
+
+
+ );
+}
diff --git a/src/hooks/DAO/loaders/snapshot/index.ts b/src/hooks/DAO/loaders/snapshot/index.ts
index df748eae9d..855da5e2ec 100644
--- a/src/hooks/DAO/loaders/snapshot/index.ts
+++ b/src/hooks/DAO/loaders/snapshot/index.ts
@@ -1,8 +1,20 @@
-import { ApolloClient, InMemoryCache } from '@apollo/client';
+import { ApolloClient, InMemoryCache, DefaultOptions } from '@apollo/client';
+
+const defaultOptions: DefaultOptions = {
+ watchQuery: {
+ fetchPolicy: 'no-cache',
+ errorPolicy: 'ignore',
+ },
+ query: {
+ fetchPolicy: 'no-cache',
+ errorPolicy: 'all',
+ },
+};
const client = new ApolloClient({
uri: 'https://hub.snapshot.org/graphql',
cache: new InMemoryCache(),
+ defaultOptions,
});
export default client;
diff --git a/src/hooks/DAO/loaders/snapshot/useSnapshotProposal.ts b/src/hooks/DAO/loaders/snapshot/useSnapshotProposal.ts
index f509d26324..e162641749 100644
--- a/src/hooks/DAO/loaders/snapshot/useSnapshotProposal.ts
+++ b/src/hooks/DAO/loaders/snapshot/useSnapshotProposal.ts
@@ -128,14 +128,14 @@ export default function useSnapshotProposal(proposal: FractalProposal | null | u
const isShielded = privacy === 'shutter';
const isClosed = snapshotProposal.state === FractalProposalState.CLOSED;
+
if (!(isShielded && !isClosed)) {
votesQueryResult.forEach((vote: SnapshotVote) => {
if (type === 'weighted') {
const voteChoices = vote.choice as SnapshotWeightedVotingChoice;
- Object.keys(voteChoices).forEach((choiceIndex: any) => {
- // In Snapshot API choices are indexed 1-based. The first choice has index 1.
- // https://docs.snapshot.org/tools/api#vote
- const voteChoice = choices[choiceIndex - 1];
+ if (typeof voteChoices === 'number') {
+ // Means vote casted for single option, and Snapshot API returns just just number then =/
+ const voteChoice = voteChoices - 1;
const existingChoiceType = votesBreakdown[voteChoice];
if (existingChoiceType) {
votesBreakdown[voteChoice] = {
@@ -148,7 +148,25 @@ export default function useSnapshotProposal(proposal: FractalProposal | null | u
votes: [vote],
};
}
- });
+ } else {
+ Object.keys(voteChoices).forEach((choiceIndex: any) => {
+ // In Snapshot API choices are indexed 1-based. The first choice has index 1.
+ // https://docs.snapshot.org/tools/api#vote
+ const voteChoice = choices[choiceIndex - 1];
+ const existingChoiceType = votesBreakdown[voteChoice];
+ if (existingChoiceType) {
+ votesBreakdown[voteChoice] = {
+ total: existingChoiceType.total + getVoteWeight(vote),
+ votes: [...existingChoiceType.votes, vote],
+ };
+ } else {
+ votesBreakdown[voteChoice] = {
+ total: getVoteWeight(vote),
+ votes: [vote],
+ };
+ }
+ });
+ }
} else {
const voteChoice = vote.choice as number;
const choiceKey = choices[voteChoice - 1];
diff --git a/src/hooks/DAO/proposal/useCastVote.ts b/src/hooks/DAO/proposal/useCastVote.ts
index fcd2bb8aab..ff5498b382 100644
--- a/src/hooks/DAO/proposal/useCastVote.ts
+++ b/src/hooks/DAO/proposal/useCastVote.ts
@@ -1,6 +1,6 @@
import snapshot from '@snapshot-labs/snapshot.js';
import { ethers } from 'ethers';
-import { useCallback, useEffect, useMemo } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { useSigner } from 'wagmi';
@@ -28,6 +28,9 @@ const useCastVote = ({
setPending?: React.Dispatch>;
extendedSnapshotProposal?: ExtendedSnapshotProposal;
}) => {
+ const [selectedChoice, setSelectedChoice] = useState();
+ const [snapshotWeightedChoice, setSnapshotWeightedChoice] = useState([]);
+
const {
governanceContracts: { ozLinearVotingContract, erc721LinearVotingContract },
governance,
@@ -54,8 +57,24 @@ const useCastVote = ({
}
}, [setPending, contractCallPending]);
+ useEffect(() => {
+ if (extendedSnapshotProposal) {
+ setSnapshotWeightedChoice(extendedSnapshotProposal.choices.map(() => 0));
+ }
+ }, [extendedSnapshotProposal]);
+
const { t } = useTranslation('transaction');
+ const handleSelectSnapshotChoice = useCallback((choiceIndex: number) => {
+ setSelectedChoice(choiceIndex);
+ }, []);
+
+ const handleChangeSnapshotWeightedChoice = useCallback((choiceIndex: number, value: number) => {
+ setSnapshotWeightedChoice(prevState =>
+ prevState.map((choiceValue, index) => (index === choiceIndex ? value : choiceValue))
+ );
+ }, []);
+
const castVote = useCallback(
async (vote: number) => {
let contractFn;
@@ -101,9 +120,13 @@ const useCastVote = ({
);
const castSnapshotVote = useCallback(
- async (choice: number) => {
+ async (onSuccess?: () => Promise) => {
if (signer && signer?.provider && address && daoSnapshotURL && extendedSnapshotProposal) {
let toastId;
+ const choice =
+ extendedSnapshotProposal.type === 'weighted'
+ ? snapshotWeightedChoice
+ : (selectedChoice as number) + 1;
try {
toastId = toast(t('pendingCastVote'), {
autoClose: false,
@@ -135,7 +158,12 @@ const useCastVote = ({
});
}
toast.dismiss(toastId);
- toast.success(t('successCastVote'));
+ toast.success(`${t('successCastVote')}. ${t('snapshotRecastVoteHelper')}`);
+ setSelectedChoice(undefined);
+ if (onSuccess) {
+ // Need to refetch votes after timeout so that Snapshot API has enough time to record the vote
+ setTimeout(() => onSuccess(), 3000);
+ }
} catch (e) {
toast.dismiss(toastId);
toast.error('failedCastVote');
@@ -143,10 +171,25 @@ const useCastVote = ({
}
}
},
- [signer, address, daoSnapshotURL, extendedSnapshotProposal, t]
+ [
+ signer,
+ address,
+ daoSnapshotURL,
+ extendedSnapshotProposal,
+ t,
+ selectedChoice,
+ snapshotWeightedChoice,
+ ]
);
- return { castVote, castSnapshotVote };
+ return {
+ castVote,
+ castSnapshotVote,
+ selectedChoice,
+ snapshotWeightedChoice,
+ handleSelectSnapshotChoice,
+ handleChangeSnapshotWeightedChoice,
+ };
};
export default useCastVote;
diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json
index a112258f65..a4bd181c69 100644
--- a/src/i18n/locales/en/common.json
+++ b/src/i18n/locales/en/common.json
@@ -17,6 +17,7 @@
"vote": "Vote",
"sign": "Sign",
"snapshot": "Snapshot",
+ "shutter": "shutter",
"send": "Send",
"transfer": "Transfer",
"received": "Received",
@@ -99,5 +100,6 @@
"invalidSafe2": "Try refreshing the page or changing networks in your wallet.",
"invalidChain": "Your currently connected chain is not supported.",
"showMore": "Show More",
- "showLess": "Show Less"
+ "showLess": "Show Less",
+ "poweredBy": "Powered by"
}
\ No newline at end of file
diff --git a/src/i18n/locales/en/transaction.json b/src/i18n/locales/en/transaction.json
index 1a09dae458..c8a590d0bf 100644
--- a/src/i18n/locales/en/transaction.json
+++ b/src/i18n/locales/en/transaction.json
@@ -20,5 +20,6 @@
"pendingTokenClaim": "Claiming {{ symbol }}",
"failedTokenClaim": "Failed to claim {{ symbol }}",
"successTokenClaim": "You have successfully claimed {{ amount }} {{ symbol }}",
+ "snapshotRecastVoteHelper": "You can still change your vote until the vote end date",
"modifyGovernanceSetAzoriusProposalPendingMessage": "Submitting proposal to modify governance..."
}
\ No newline at end of file