diff --git a/src/components/AddressInputPanel/index.tsx b/src/components/AddressInputPanel/index.tsx index f723170532..9fbe8f249e 100644 --- a/src/components/AddressInputPanel/index.tsx +++ b/src/components/AddressInputPanel/index.tsx @@ -40,7 +40,7 @@ const Input = styled.input<{ error?: boolean }>` width: 0; background-color: ${({ theme }) => theme.bg1}; transition: color 300ms ${({ error }) => (error ? 'step-end' : 'step-start')}; - color: ${({ error, theme }) => (error ? theme.red1 : theme.primary1)}; + color: ${({ error, theme }) => (error ? theme.red1 : theme.text1)}; overflow: hidden; text-overflow: ellipsis; font-weight: 500; @@ -67,10 +67,16 @@ const Input = styled.input<{ error?: boolean }>` export default function AddressInputPanel({ id, + className = 'recipient-address-input', + label = 'Recipient', + placeholder, value, onChange, }: { id?: string + className?: string + label?: string + placeholder?: string // the typed string value value: string // triggers whenever the typed value changes @@ -99,7 +105,7 @@ export default function AddressInputPanel({ - Recipient + {label} {address && chainId && ( ) => ReactNode locked?: boolean } @@ -174,6 +176,8 @@ export default function CurrencyInputPanel({ otherCurrency, id, showCommonBases, + showCurrencyAmount, + disableNonToken, renderBalance, fiatValue, priceImpact, @@ -298,6 +302,8 @@ export default function CurrencyInputPanel({ selectedCurrency={currency} otherSelectedCurrency={otherCurrency} showCommonBases={showCommonBases} + showCurrencyAmount={showCurrencyAmount} + disableNonToken={disableNonToken} /> )} diff --git a/src/components/NavigationTabs/index.tsx b/src/components/NavigationTabs/index.tsx index 6d8e2a435e..0f5cdfc930 100644 --- a/src/components/NavigationTabs/index.tsx +++ b/src/components/NavigationTabs/index.tsx @@ -6,7 +6,7 @@ import { NavLink, Link as HistoryLink, useLocation } from 'react-router-dom' import { Percent } from '@uniswap/sdk-core' import { ArrowLeft } from 'react-feather' -import { RowBetween } from '../Row' +import Row, { RowBetween } from '../Row' import SettingsTab from '../Settings' import { useAppDispatch } from 'state/hooks' @@ -137,3 +137,16 @@ export function AddRemoveTabs({ ) } + +export function CreateProposalTabs() { + return ( + + + + + + Create Proposal + + + ) +} diff --git a/src/components/SearchModal/CurrencyList.tsx b/src/components/SearchModal/CurrencyList.tsx index 397ed5bf3b..5045e0a7e7 100644 --- a/src/components/SearchModal/CurrencyList.tsx +++ b/src/components/SearchModal/CurrencyList.tsx @@ -104,12 +104,14 @@ function CurrencyRow({ isSelected, otherSelected, style, + showCurrencyAmount, }: { currency: Currency onSelect: () => void isSelected: boolean otherSelected: boolean style: CSSProperties + showCurrencyAmount?: boolean }) { const { account } = useActiveWeb3React() const key = currencyKey(currency) @@ -141,9 +143,11 @@ function CurrencyRow({ - - {balance ? : account ? : null} - + {showCurrencyAmount && ( + + {balance ? : account ? : null} + + )} ) } @@ -189,6 +193,7 @@ export default function CurrencyList({ fixedListRef, showImportView, setImportToken, + showCurrencyAmount, }: { height: number currencies: Currency[] @@ -199,6 +204,7 @@ export default function CurrencyList({ fixedListRef?: MutableRefObject showImportView: () => void setImportToken: (token: Token) => void + showCurrencyAmount?: boolean }) { const itemData: (Currency | BreakLine)[] = useMemo(() => { if (otherListTokens && otherListTokens?.length > 0) { @@ -237,13 +243,22 @@ export default function CurrencyList({ isSelected={isSelected} onSelect={handleSelect} otherSelected={otherSelected} + showCurrencyAmount={showCurrencyAmount} /> ) } else { return null } }, - [currencies.length, onCurrencySelect, otherCurrency, selectedCurrency, setImportToken, showImportView] + [ + currencies.length, + onCurrencySelect, + otherCurrency, + selectedCurrency, + setImportToken, + showImportView, + showCurrencyAmount, + ] ) const itemKey = useCallback((index: number, data: typeof itemData) => { diff --git a/src/components/SearchModal/CurrencySearch.tsx b/src/components/SearchModal/CurrencySearch.tsx index bdd4e78782..004f744391 100644 --- a/src/components/SearchModal/CurrencySearch.tsx +++ b/src/components/SearchModal/CurrencySearch.tsx @@ -48,6 +48,8 @@ interface CurrencySearchProps { onCurrencySelect: (currency: Currency) => void otherSelectedCurrency?: Currency | null showCommonBases?: boolean + showCurrencyAmount?: boolean + disableNonToken?: boolean showManageView: () => void showImportView: () => void setImportToken: (token: Token) => void @@ -58,6 +60,8 @@ export function CurrencySearch({ onCurrencySelect, otherSelectedCurrency, showCommonBases, + showCurrencyAmount, + disableNonToken, onDismiss, isOpen, showManageView, @@ -203,7 +207,7 @@ export function CurrencySearch({ {({ height }) => ( )} diff --git a/src/components/SearchModal/CurrencySearchModal.tsx b/src/components/SearchModal/CurrencySearchModal.tsx index b2ca00b713..73518e141f 100644 --- a/src/components/SearchModal/CurrencySearchModal.tsx +++ b/src/components/SearchModal/CurrencySearchModal.tsx @@ -17,6 +17,8 @@ interface CurrencySearchModalProps { onCurrencySelect: (currency: Currency) => void otherSelectedCurrency?: Currency | null showCommonBases?: boolean + showCurrencyAmount?: boolean + disableNonToken?: boolean } export enum CurrencyModalView { @@ -33,6 +35,8 @@ export default function CurrencySearchModal({ selectedCurrency, otherSelectedCurrency, showCommonBases = false, + showCurrencyAmount = true, + disableNonToken = false, }: CurrencySearchModalProps) { const [modalView, setModalView] = useState(CurrencyModalView.manage) const lastOpen = useLast(isOpen) @@ -74,6 +78,8 @@ export default function CurrencySearchModal({ selectedCurrency={selectedCurrency} otherSelectedCurrency={otherSelectedCurrency} showCommonBases={showCommonBases} + showCurrencyAmount={showCurrencyAmount} + disableNonToken={disableNonToken} showImportView={() => setModalView(CurrencyModalView.importToken)} setImportToken={setImportToken} showManageView={() => setModalView(CurrencyModalView.manage)} diff --git a/src/components/TextInput/index.tsx b/src/components/TextInput/index.tsx new file mode 100644 index 0000000000..3886cb36f1 --- /dev/null +++ b/src/components/TextInput/index.tsx @@ -0,0 +1,146 @@ +import React, { memo, useCallback, useRef } from 'react' +import styled from 'styled-components' + +const Input = styled.input<{ error?: boolean; fontSize?: string }>` + font-size: ${({ fontSize }) => fontSize || '1.25rem'}; + outline: none; + border: none; + flex: 1 1 auto; + width: 0; + background-color: ${({ theme }) => theme.bg1}; + transition: color 300ms ${({ error }) => (error ? 'step-end' : 'step-start')}; + color: ${({ error, theme }) => (error ? theme.red1 : theme.text1)}; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; + width: 100%; + padding: 0px; + -webkit-appearance: textfield; + + ::-webkit-search-decoration { + -webkit-appearance: none; + } + + ::-webkit-outer-spin-button, + ::-webkit-inner-spin-button { + -webkit-appearance: none; + } + + ::placeholder { + color: ${({ theme }) => theme.text4}; + } +` + +const TextAreaInput = styled.textarea<{ error?: boolean; fontSize?: string }>` + font-size: ${({ fontSize }) => fontSize || '1.25rem'}; + outline: none; + border: none; + flex: 1 1 auto; + width: 0; + resize: none; + background-color: ${({ theme }) => theme.bg1}; + transition: color 300ms ${({ error }) => (error ? 'step-end' : 'step-start')}; + color: ${({ error, theme }) => (error ? theme.red1 : theme.text1)}; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; + width: 100%; + line-height: 1.2; + padding: 0px; + -webkit-appearance: textfield; + + ::-webkit-search-decoration { + -webkit-appearance: none; + } + + ::-webkit-outer-spin-button, + ::-webkit-inner-spin-button { + -webkit-appearance: none; + } + + ::placeholder { + color: ${({ theme }) => theme.text4}; + } +` + +export const TextInput = ({ + className, + value, + onUserInput, + placeholder, + fontSize, +}: { + className?: string + value: string + onUserInput: (value: string) => void + placeholder: string + fontSize: string +}) => { + const handleInput = useCallback( + (event) => { + onUserInput(event.target.value) + }, + [onUserInput] + ) + + return ( +
+ +
+ ) +} + +export const ResizingTextArea = memo( + ({ + className, + value, + onUserInput, + placeholder, + fontSize, + }: { + className?: string + value: string + onUserInput: (value: string) => void + placeholder: string + fontSize: string + }) => { + const inputRef = useRef(document.createElement('textarea')) + + const handleInput = useCallback( + (event) => { + inputRef.current.style.height = 'auto' + inputRef.current.style.height = inputRef.current.scrollHeight + 'px' + onUserInput(event.target.value) + }, + [onUserInput] + ) + + return ( + + ) + } +) + +ResizingTextArea.displayName = 'ResizingTextArea' diff --git a/src/pages/App.tsx b/src/pages/App.tsx index dfc84a0502..526cb4b841 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -29,6 +29,7 @@ import { RedirectDuplicateTokenIdsV2 } from './AddLiquidityV2/redirects' import { PositionPage } from './Pool/PositionPage' import AddLiquidity from './AddLiquidity' import ApeModeQueryParamReader from 'hooks/useApeModeQueryParamReader' +import CreateProposal from './CreateProposal' const AppWrapper = styled.div` display: flex; @@ -40,14 +41,13 @@ const BodyWrapper = styled.div` display: flex; flex-direction: column; width: 100%; - padding-top: 120px; + padding: 120px 16px 0px 16px; align-items: center; flex: 1; z-index: 1; ${({ theme }) => theme.mediaWidth.upToSmall` - padding: 16px; - padding-top: 6rem; + padding: 6rem 16px 16px 16px; `}; ` @@ -122,6 +122,7 @@ export default function App() { + diff --git a/src/pages/AppBody.tsx b/src/pages/AppBody.tsx index d1f18f77f4..3ce133e62b 100644 --- a/src/pages/AppBody.tsx +++ b/src/pages/AppBody.tsx @@ -1,10 +1,10 @@ import React from 'react' import styled from 'styled-components/macro' -export const BodyWrapper = styled.div<{ margin?: string }>` +export const BodyWrapper = styled.div<{ margin?: string; maxWidth?: string }>` position: relative; margin-top: ${({ margin }) => margin ?? '0px'}; - max-width: 480px; + max-width: ${({ maxWidth }) => maxWidth ?? '480px'}; width: 100%; background: ${({ theme }) => theme.bg0}; box-shadow: 0px 0px 1px rgba(0, 0, 0, 0.01), 0px 4px 8px rgba(0, 0, 0, 0.04), 0px 16px 24px rgba(0, 0, 0, 0.04), diff --git a/src/pages/CreateProposal/ProposalActionDetail.tsx b/src/pages/CreateProposal/ProposalActionDetail.tsx new file mode 100644 index 0000000000..7262899afd --- /dev/null +++ b/src/pages/CreateProposal/ProposalActionDetail.tsx @@ -0,0 +1,83 @@ +import React from 'react' +import AddressInputPanel from 'components/AddressInputPanel' +import CurrencyInputPanel from 'components/CurrencyInputPanel' +import styled from 'styled-components' +import { ProposalAction } from './ProposalActionSelector' +import { Currency } from '@uniswap/sdk-core' + +enum ProposalActionDetailField { + ADDRESS, + CURRENCY, +} + +const ProposalActionDetailContainer = styled.div` + margin-top: 10px; + display: grid; + grid-template-columns: repeat(1, 1fr); + grid-gap: 10px; +` + +export const ProposalActionDetail = ({ + className, + proposalAction, + currency, + amount, + toAddress, + onCurrencySelect, + onAmountInput, + onToAddressInput, +}: { + className?: string + proposalAction: ProposalAction + currency: Currency | undefined + amount: string + toAddress: string + onCurrencySelect: (currency: Currency) => void + onAmountInput: (amount: string) => void + onToAddressInput: (address: string) => void +}) => { + const proposalActionsData = { + [ProposalAction.TRANSFER_TOKEN]: [ + { + type: ProposalActionDetailField.ADDRESS, + label: 'To', + }, + { + type: ProposalActionDetailField.CURRENCY, + }, + ], + [ProposalAction.APPROVE_TOKEN]: [ + { + type: ProposalActionDetailField.ADDRESS, + label: 'To', + }, + { + type: ProposalActionDetailField.CURRENCY, + }, + ], + } + + return ( + + {proposalActionsData[proposalAction].map((field, i) => + field.type === ProposalActionDetailField.ADDRESS ? ( + + ) : field.type === ProposalActionDetailField.CURRENCY ? ( + onAmountInput(amount)} + onCurrencySelect={(currency: Currency) => onCurrencySelect(currency)} + showMaxButton={false} + showCommonBases={false} + showCurrencyAmount={false} + disableNonToken={true} + hideBalance={true} + id="currency-input" + /> + ) : null + )} + + ) +} diff --git a/src/pages/CreateProposal/ProposalActionSelector.tsx b/src/pages/CreateProposal/ProposalActionSelector.tsx new file mode 100644 index 0000000000..2dba80e6ab --- /dev/null +++ b/src/pages/CreateProposal/ProposalActionSelector.tsx @@ -0,0 +1,129 @@ +import React, { useCallback } from 'react' +import styled from 'styled-components' +import { Text } from 'rebass' +import { CloseIcon } from 'theme' +import { Trans } from '@lingui/macro' +import Column from 'components/Column' +import Modal from 'components/Modal' +import { RowBetween } from 'components/Row' +import { MenuItem, PaddedColumn, Separator } from 'components/SearchModal/styleds' +import { ButtonDropdown } from 'components/Button' + +export enum ProposalAction { + TRANSFER_TOKEN = 'Transfer Token', + APPROVE_TOKEN = 'Approve Token', +} + +interface ProposalActionSelectorModalProps { + isOpen: boolean + onDismiss: () => void + onProposalActionSelect: (proposalAction: ProposalAction) => void +} + +const ContentWrapper = styled(Column)` + width: 100%; + flex: 1 1; + position: relative; +` +const ActionSelectorHeader = styled.div` + font-size: 14px; + font-weight: 500; + color: ${({ theme }) => theme.text2}; +` + +const ActionDropdown = styled(ButtonDropdown)` + padding: 0px; + background-color: transparent; + color: ${({ theme }) => theme.text1} + font-size: 1.25rem; + + :hover, + :active, + :focus { + outline: 0px; + box-shadow: none; + background-color: transparent; + } +` + +const ProposalActionSelectorFlex = styled.div` + margin-top: 10px; + display: flex; + flex-flow: column nowrap; + border-radius: 20px; + border: 1px solid ${({ theme }) => theme.bg2}; + background-color: ${({ theme }) => theme.bg1}; +` + +const ProposalActionSelectorContainer = styled.div` + flex: 1; + padding: 1rem; + display: grid; + grid-auto-rows: auto; + grid-row-gap: 10px; +` + +export const ProposalActionSelector = ({ + className, + onClick, + proposalAction, +}: { + className?: string + onClick: () => void + proposalAction: ProposalAction +}) => { + return ( + + + + Proposed Action + + {proposalAction} + + + ) +} + +export function ProposalActionSelectorModal({ + isOpen, + onDismiss, + onProposalActionSelect, +}: ProposalActionSelectorModalProps) { + const handleProposalActionSelect = useCallback( + (proposalAction: ProposalAction) => { + onProposalActionSelect(proposalAction) + onDismiss() + }, + [onDismiss, onProposalActionSelect] + ) + + return ( + + + + + + Select an action + + + + + + handleProposalActionSelect(ProposalAction.TRANSFER_TOKEN)}> + + + Transfer Token + + + + handleProposalActionSelect(ProposalAction.APPROVE_TOKEN)}> + + + Approve Token + + + + + + ) +} diff --git a/src/pages/CreateProposal/ProposalEditor.tsx b/src/pages/CreateProposal/ProposalEditor.tsx new file mode 100644 index 0000000000..71744dfff1 --- /dev/null +++ b/src/pages/CreateProposal/ProposalEditor.tsx @@ -0,0 +1,63 @@ +import React, { memo } from 'react' +import styled from 'styled-components' +import { Text } from 'rebass' +import { ResizingTextArea, TextInput } from 'components/TextInput' +import { t, Trans } from '@lingui/macro' + +const ProposalEditorHeader = styled(Text)` + font-size: 14px; + font-weight: 500; + color: ${({ theme }) => theme.text2}; +` + +const ProposalTitle = memo(styled(TextInput)` + margin-top: 10.5px; + margin-bottom: 7.5px; +`) + +const ProposalEditorContainer = styled.div` + margin-top: 10px; + padding: 0.75rem 1rem 0.75rem 1rem; + border-radius: 20px; + border: 1px solid ${({ theme }) => theme.bg2}; + background-color: ${({ theme }) => theme.bg1}; +` + +export const ProposalEditor = ({ + className, + title, + body, + onTitleInput, + onBodyInput, +}: { + className?: string + title: string + body: string + onTitleInput: (title: string) => void + onBodyInput: (body: string) => void +}) => { + const bodyPlaceholder = `## Summary + +Insert your summary here + +## Methodology + +Insert your methodology here + +## Conclusion + +Insert your conclusion here + + ` + + return ( + + + Proposal + + +
+ +
+ ) +} diff --git a/src/pages/CreateProposal/ProposalSubmissionModal.tsx b/src/pages/CreateProposal/ProposalSubmissionModal.tsx new file mode 100644 index 0000000000..b727234ced --- /dev/null +++ b/src/pages/CreateProposal/ProposalSubmissionModal.tsx @@ -0,0 +1,57 @@ +import React, { useContext } from 'react' +import { ThemeContext } from 'styled-components' +import { Text } from 'rebass' +import { ExternalLink, TYPE } from 'theme' +import { ButtonPrimary } from 'components/Button' +import { AutoColumn } from 'components/Column' +import Modal from 'components/Modal' +import { LoadingView, SubmittedView } from 'components/ModalViews' +import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' +import { Link } from 'react-router-dom' +import { Trans } from '@lingui/macro' + +export const ProposalSubmissionModal = ({ + isOpen, + hash, + onDismiss, +}: { + isOpen: boolean + hash: string | undefined + onDismiss: () => void +}) => { + const theme = useContext(ThemeContext) + + return ( + + {!hash ? ( + + + + Submitting Proposal + + + + ) : ( + + + + Proposal Submitted + + {hash && ( + + + View on Etherscan + + + )} + + + Return + + + + + )} + + ) +} diff --git a/src/pages/CreateProposal/index.tsx b/src/pages/CreateProposal/index.tsx new file mode 100644 index 0000000000..4591ab4e2e --- /dev/null +++ b/src/pages/CreateProposal/index.tsx @@ -0,0 +1,285 @@ +import React, { useCallback, useMemo, useState } from 'react' +import JSBI from 'jsbi' +import styled from 'styled-components' +import { utils } from 'ethers' +import { ExternalLink, TYPE } from 'theme' +import { Currency, CurrencyAmount, Token } from '@uniswap/sdk-core' +import { UNI } from '../../constants/tokens' +import AppBody from '../AppBody' +import { CreateProposalTabs } from '../../components/NavigationTabs' +import { ButtonError } from 'components/Button' +import { AutoColumn } from 'components/Column' +import { BlueCard } from 'components/Card' +import { Wrapper } from 'pages/Pool/styleds' +import { ProposalAction, ProposalActionSelector, ProposalActionSelectorModal } from './ProposalActionSelector' +import { ProposalEditor } from './ProposalEditor' +import { ProposalActionDetail } from './ProposalActionDetail' +import { ProposalSubmissionModal } from './ProposalSubmissionModal' +import { useActiveWeb3React } from 'hooks/web3' +import { + CreateProposalData, + ProposalState, + useCreateProposalCallback, + useLatestProposalId, + useProposalData, + useProposalThreshold, + useUserVotes, +} from 'state/governance/hooks' +import { Trans } from '@lingui/macro' +import { tryParseAmount } from 'state/swap/hooks' +import { getAddress } from '@ethersproject/address' + +const CreateProposalButton = ({ + proposalThreshold, + hasActiveOrPendingProposal, + hasEnoughVote, + isFormInvalid, + handleCreateProposal, +}: { + proposalThreshold?: CurrencyAmount + hasActiveOrPendingProposal: boolean + hasEnoughVote: boolean + isFormInvalid: boolean + handleCreateProposal: () => void +}) => { + const formattedProposalThreshold = proposalThreshold + ? JSBI.divide( + proposalThreshold.quotient, + JSBI.exponentiate(JSBI.BigInt(10), JSBI.BigInt(proposalThreshold.currency.decimals)) + ).toLocaleString() + : undefined + + return ( + + {hasActiveOrPendingProposal ? ( + You already have an active or pending proposal + ) : !hasEnoughVote ? ( + <> + {formattedProposalThreshold ? ( + You must have {formattedProposalThreshold} votes to submit a proposal + ) : ( + You don't have enough votes to submit a proposal + )} + + ) : ( + Create Proposal + )} + + ) +} + +const CreateProposalWrapper = styled(Wrapper)` + display: flex; + flex-flow: column wrap; +` + +const AutonomousProposalCTA = styled.div` + text-align: center; + margin-top: 10px; +` + +export default function CreateProposal() { + const { account, chainId } = useActiveWeb3React() + + const latestProposalId = useLatestProposalId(account ?? '0x0000000000000000000000000000000000000000') ?? '0' + const latestProposalData = useProposalData(0, latestProposalId) + const availableVotes: CurrencyAmount | undefined = useUserVotes() + const proposalThreshold: CurrencyAmount | undefined = useProposalThreshold() + + const [modalOpen, setModalOpen] = useState(false) + const [hash, setHash] = useState() + const [attempting, setAttempting] = useState(false) + const [proposalAction, setProposalAction] = useState(ProposalAction.TRANSFER_TOKEN) + const [toAddressValue, setToAddressValue] = useState('') + const [currencyValue, setCurrencyValue] = useState(UNI[chainId ?? 1]) + const [amountValue, setAmountValue] = useState('') + const [titleValue, setTitleValue] = useState('') + const [bodyValue, setBodyValue] = useState('') + + const handleActionSelectorClick = useCallback(() => { + setModalOpen(true) + }, [setModalOpen]) + + const handleActionChange = useCallback( + (proposalAction: ProposalAction) => { + setProposalAction(proposalAction) + }, + [setProposalAction] + ) + + const handleDismissActionSelector = useCallback(() => { + setModalOpen(false) + }, [setModalOpen]) + + const handleDismissSubmissionModal = useCallback(() => { + setHash(undefined) + setAttempting(false) + }, [setHash, setAttempting]) + + const handleToAddressInput = useCallback( + (toAddress: string) => { + setToAddressValue(toAddress) + }, + [setToAddressValue] + ) + + const handleCurrencySelect = useCallback( + (currency: Currency) => { + setCurrencyValue(currency) + }, + [setCurrencyValue] + ) + + const handleAmountInput = useCallback( + (amount: string) => { + setAmountValue(amount) + }, + [setAmountValue] + ) + + const handleTitleInput = useCallback( + (title: string) => { + setTitleValue(title) + }, + [setTitleValue] + ) + + const handleBodyInput = useCallback( + (body: string) => { + setBodyValue(body) + }, + [setBodyValue] + ) + + const isFormInvalid = useMemo( + () => + Boolean( + !proposalAction || + !utils.isAddress(toAddressValue) || + !currencyValue?.isToken || + amountValue === '' || + titleValue === '' || + bodyValue === '' + ), + [proposalAction, toAddressValue, currencyValue, amountValue, titleValue, bodyValue] + ) + + const hasEnoughVote = Boolean( + availableVotes && proposalThreshold && JSBI.greaterThanOrEqual(availableVotes.quotient, proposalThreshold.quotient) + ) + + const createProposalCallback = useCreateProposalCallback() + + const handleCreateProposal = async () => { + setAttempting(true) + + const createProposalData: CreateProposalData = {} as CreateProposalData + + if (!createProposalCallback || !proposalAction || !currencyValue.isToken) return + + const tokenAmount = tryParseAmount(amountValue, currencyValue) + if (!tokenAmount) return + + createProposalData.targets = [currencyValue.address] + createProposalData.values = ['0'] + createProposalData.description = `# ${titleValue} + +${bodyValue} +` + + let types: string[][] + let values: string[][] + switch (proposalAction) { + case ProposalAction.TRANSFER_TOKEN: { + types = [['address', 'uint256']] + values = [[getAddress(toAddressValue), tokenAmount.quotient.toString()]] + createProposalData.signatures = [`transfer(${types[0].join(',')})`] + break + } + + case ProposalAction.APPROVE_TOKEN: { + types = [['address', 'uint256']] + values = [[getAddress(toAddressValue), tokenAmount.quotient.toString()]] + createProposalData.signatures = [`approve(${types[0].join(',')})`] + break + } + } + + createProposalData.calldatas = [] + for (let i = 0; i < createProposalData.signatures.length; i++) { + createProposalData.calldatas[i] = utils.defaultAbiCoder.encode(types[i], values[i]) + } + + const hash = await createProposalCallback(createProposalData ?? undefined)?.catch(() => { + setAttempting(false) + }) + + if (hash) setHash(hash) + } + + return ( + + + + + + + + Tip: Select an action and describe your proposal for the community. The proposal cannot + be modified after submission, so please verify all information before submitting. The voting period will + begin immediately and last for 7 days. To propose a custom action,{' '} + + read the docs + + . + + + + + + + + + + {!hasEnoughVote ? ( + + Don’t have 2.5M votes? Anyone can create an autonomous proposal using{' '} + fish.vote + + ) : null} + + handleActionChange(proposalAction)} + /> + + + ) +} diff --git a/src/pages/Vote/index.tsx b/src/pages/Vote/index.tsx index e537f2b171..31dfc6273e 100644 --- a/src/pages/Vote/index.tsx +++ b/src/pages/Vote/index.tsx @@ -4,7 +4,7 @@ import styled from 'styled-components/macro' import { SwitchLocaleLink } from '../../components/SwitchLocaleLink' import { UNI } from '../../constants/tokens' import { ExternalLink, TYPE } from '../../theme' -import { RowBetween, RowFixed } from '../../components/Row' +import { AutoRow, RowBetween, RowFixed } from '../../components/Row' import { Link } from 'react-router-dom' import { getExplorerLink, ExplorerDataType } from '../../utils/getExplorerLink' import { ProposalStatus } from './styled' @@ -182,34 +182,44 @@ export default function Vote() { Proposals - {(!allProposals || allProposals.length === 0) && !availableVotes && } - {showUnlockVoting ? ( + + {(!allProposals || allProposals.length === 0) && !availableVotes && } + {showUnlockVoting ? ( + + Unlock Voting + + ) : availableVotes && JSBI.notEqual(JSBI.BigInt(0), availableVotes?.quotient) ? ( + + + Votes + + + ) : uniBalance && + userDelegatee && + userDelegatee !== ZERO_ADDRESS && + JSBI.notEqual(JSBI.BigInt(0), uniBalance?.quotient) ? ( + + + Votes + + + ) : ( + '' + )} - Unlock Voting + Create Proposal - ) : availableVotes && JSBI.notEqual(JSBI.BigInt(0), availableVotes?.quotient) ? ( - - - Votes - - - ) : uniBalance && - userDelegatee && - userDelegatee !== ZERO_ADDRESS && - JSBI.notEqual(JSBI.BigInt(0), uniBalance?.quotient) ? ( - - - Votes - - - ) : ( - '' - )} + {!showUnlockVoting && ( diff --git a/src/state/governance/hooks.ts b/src/state/governance/hooks.ts index 80ca5abb1d..a13e2ba120 100644 --- a/src/state/governance/hooks.ts +++ b/src/state/governance/hooks.ts @@ -14,6 +14,7 @@ import { UNISWAP_GRANTS_START_BLOCK } from '../../constants/proposals' import { UNI } from '../../constants/tokens' import { useMultipleContractMultipleData, useSingleCallResult } from '../multicall/hooks' import { useTransactionAdder } from '../transactions/hooks' +import { t } from '@lingui/macro' interface ProposalDetail { target: string @@ -35,6 +36,14 @@ export interface ProposalData { governorIndex: number // index in the governance address array for which this proposal pertains } +export interface CreateProposalData { + targets: string[] + values: string[] + signatures: string[] + calldatas: string[] + description: string +} + export enum ProposalState { Undetermined = -1, Pending, @@ -325,3 +334,68 @@ export function useVoteCallback(): { ) return { voteCallback } } + +export function useCreateProposalCallback(): ( + createProposalData: CreateProposalData | undefined +) => undefined | Promise { + const { account } = useActiveWeb3React() + + const govContracts = useGovernanceContracts() + const latestGovernanceContract = govContracts ? govContracts[0] : null + const addTransaction = useTransactionAdder() + + const createProposalCallback = useCallback( + (createProposalData: CreateProposalData | undefined) => { + if (!account || !latestGovernanceContract || !createProposalData) return undefined + + const args = [ + createProposalData.targets, + createProposalData.values, + createProposalData.signatures, + createProposalData.calldatas, + createProposalData.description, + ] + + return latestGovernanceContract.estimateGas.propose(...args).then((estimatedGasLimit) => { + return latestGovernanceContract + .propose(...args, { gasLimit: calculateGasMargin(estimatedGasLimit) }) + .then((response: TransactionResponse) => { + addTransaction(response, { + summary: t`Submitted new proposal`, + }) + return response.hash + }) + }) + }, + [account, addTransaction, latestGovernanceContract] + ) + + return createProposalCallback +} + +export function useLatestProposalId(address: string): string | undefined { + const govContracts = useGovernanceContracts() + const latestGovernanceContract = govContracts ? govContracts[0] : null + const res = useSingleCallResult(latestGovernanceContract, 'latestProposalIds', [address]) + + if (res?.result?.[0]) { + return (res.result[0] as BigNumber).toString() + } + + return undefined +} + +export function useProposalThreshold(): CurrencyAmount | undefined { + const { chainId } = useActiveWeb3React() + + const govContracts = useGovernanceContracts() + const latestGovernanceContract = govContracts ? govContracts[0] : null + const res = useSingleCallResult(latestGovernanceContract, 'proposalThreshold') + const uni = chainId ? UNI[chainId] : undefined + + if (res?.result?.[0] && uni) { + return CurrencyAmount.fromRawAmount(uni, res.result[0]) + } + + return undefined +}