diff --git a/packages/page-accounts/src/Accounts/Account.tsx b/packages/page-accounts/src/Accounts/Account.tsx index a0520fe9af..8e658aa6cc 100644 --- a/packages/page-accounts/src/Accounts/Account.tsx +++ b/packages/page-accounts/src/Accounts/Account.tsx @@ -7,6 +7,7 @@ import { DeriveBalancesAll, DeriveDemocracyLock } from '@polkadot/api-derive/typ import { ActionStatus } from '@polkadot/react-components/Status/types'; import { RecoveryConfig } from '@polkadot/types/interfaces'; import { KeyringAddress } from '@polkadot/ui-keyring/types'; +import { Delegation } from '../types'; import BN from 'bn.js'; import React, { useCallback, useContext, useState, useEffect } from 'react'; @@ -15,6 +16,7 @@ import { ApiPromise } from '@polkadot/api'; import { getLedger } from '@polkadot/react-api'; import { AddressInfo, AddressMini, AddressSmall, Badge, Button, ChainLock, CryptoType, Forget, Icon, IdentityIcon, LinkExternal, Menu, Popup, StatusContext, Tags } from '@polkadot/react-components'; import { useAccountInfo, useApi, useCall, useToggle } from '@polkadot/react-hooks'; +import { FormatBalance } from '@polkadot/react-query'; import { Option } from '@polkadot/types'; import keyring from '@polkadot/ui-keyring'; import { BN_ZERO, formatBalance, formatNumber } from '@polkadot/util'; @@ -23,6 +25,7 @@ import { useTranslation } from '../translate'; import { createMenuGroup } from '../util'; import Backup from './modals/Backup'; import ChangePass from './modals/ChangePass'; +import DelegateModal from './modals/Delegate'; import Derive from './modals/Derive'; import IdentityMain from './modals/IdentityMain'; import IdentitySub from './modals/IdentitySub'; @@ -30,12 +33,14 @@ import MultisigApprove from './modals/MultisigApprove'; import RecoverAccount from './modals/RecoverAccount'; import RecoverSetup from './modals/RecoverSetup'; import Transfer from './modals/Transfer'; +import UndelegateModal from './modals/Undelegate'; import useMultisigApprovals from './useMultisigApprovals'; import useProxies from './useProxies'; interface Props { account: KeyringAddress; className?: string; + delegation?: Delegation; filter: string; isFavorite: boolean; setBalance: (address: string, value: BN) => void; @@ -67,7 +72,7 @@ function createClearDemocracyTx (api: ApiPromise, address: string, unlockableIds ); } -function Account ({ account: { address, meta }, className = '', filter, isFavorite, setBalance, toggleFavorite }: Props): React.ReactElement | null { +function Account ({ account: { address, meta }, className = '', delegation, filter, isFavorite, setBalance, toggleFavorite }: Props): React.ReactElement | null { const { t } = useTranslation(); const { queueExtrinsic } = useContext(StatusContext); const api = useApi(); @@ -94,6 +99,8 @@ function Account ({ account: { address, meta }, className = '', filter, isFavori const [isRecoverSetupOpen, toggleRecoverSetup] = useToggle(); const [isSettingsOpen, toggleSettings] = useToggle(); const [isTransferOpen, toggleTransfer] = useToggle(); + const [isDelegateOpen, toggleDelegate] = useToggle(); + const [isUndelegateOpen, toggleUndelegate] = useToggle(); useEffect((): void => { if (balancesAll) { @@ -340,6 +347,36 @@ function Account ({ account: { address, meta }, className = '', filter, isFavori )} + + {isDelegateOpen && ( + + )} + {isUndelegateOpen && ( + + )} + {delegation && ( + + +
{delegation.conviction.toString()}
+ + } + value={delegation.accountDelegated} + /> + )} + @@ -484,6 +521,28 @@ function Account ({ account: { address, meta }, className = '', filter, isFavori {t('Multisig approvals')} ])} + {delegation?.accountDelegated && createMenuGroup([ + ( + {t('Change democracy delegation')} + ), + ( + {t('Undelegate')} + ) + ])} + {!delegation?.accountDelegated && createMenuGroup([ + ( + {t('Delegate democracy votes')} + ) + ])} ; balanceTotal?: BN; } +interface Sorted { + sortedAccounts: SortedAccount[]; + sortedAddresses: string[]; +} + interface Props { className?: string; onStatusChange: (status: ActionStatus) => void; @@ -51,53 +57,6 @@ async function queryLedger (): Promise { } } -function expandList (mapped: SortedAccount[], entry: SortedAccount): SortedAccount[] { - mapped.push(entry); - - entry.children.forEach((entry): void => { - expandList(mapped, entry); - }); - - return mapped; -} - -function sortAccounts (addresses: string[], favorites: string[]): SortedAccount[] { - const mapped = addresses - .map((address) => keyring.getAccount(address)) - .filter((account): account is KeyringAddress => !!account) - .map((account): SortedAccount => ({ - account, - children: [], - isFavorite: favorites.includes(account.address) - })) - .sort((a, b) => (a.account.meta.whenCreated || 0) - (b.account.meta.whenCreated || 0)); - - return mapped - .filter((entry): boolean => { - const parentAddress = entry.account.meta.parentAddress; - - if (parentAddress) { - const parent = mapped.find(({ account: { address } }) => address === parentAddress); - - if (parent) { - parent.children.push(entry); - - return false; - } - } - - return true; - }) - .reduce(expandList, []) - .sort((a, b): number => - a.isFavorite === b.isFavorite - ? 0 - : b.isFavorite - ? 1 - : -1 - ); -} - function Overview ({ className = '', onStatusChange }: Props): React.ReactElement { const { t } = useTranslation(); const { api } = useApi(); @@ -110,15 +69,45 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen const [isQrOpen, toggleQr] = useToggle(); const [favorites, toggleFavorite] = useFavorites(STORE_FAVS); const [{ balanceTotal }, setBalances] = useState({ accounts: {} }); - const [sortedAccounts, setSortedAccounts] = useState([]); const [filterOn, setFilter] = useState(''); + const [sortedAccountsWithDelegation, setSortedAccountsWithDelegation] = useState([]); + const [{ sortedAccounts, sortedAddresses }, setSorted] = useState({ sortedAccounts: [], sortedAddresses: [] }); + const delegations = useCall(api.query.democracy.votingOf.multi, [sortedAddresses]); useEffect((): void => { - setSortedAccounts( - sortAccounts(allAccounts, favorites) - ); + const sortedAccounts = sortAccounts(allAccounts, favorites); + const sortedAddresses = sortedAccounts.map((a) => a.account.address); + + setSorted({ sortedAccounts, sortedAddresses }); }, [allAccounts, favorites]); + useEffect(() => { + if (!delegations?.length) { + return; + } + + const sortedAccountsWithDelegation = sortedAccounts?.map((account, index) => { + let delegation: Delegation | undefined; + + if (delegations[index].isDelegating) { + const _delegation = delegations[index].asDelegating; + + delegation = { + accountDelegated: _delegation.target.toString(), + amount: _delegation.balance, + conviction: _delegation.conviction + }; + } + + return ({ + ...account, + delegation + }); + }); + + setSortedAccountsWithDelegation(sortedAccountsWithDelegation); + }, [delegations, sortedAccounts]); + const _setBalance = useCallback( (account: string, balance: BN) => setBalances(({ accounts }: Balances): Balances => { @@ -135,6 +124,7 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen const header = useMemo(() => [ [t('accounts'), 'start', 3], [t('parent'), 'address'], + [t('delegation'), 'address ui--media-1500'], [t('type')], [t('tags'), 'start'], [t('transactions'), 'ui--media-1500'], @@ -245,9 +235,10 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen footer={footer} header={header} > - {sortedAccounts.map(({ account, isFavorite }): React.ReactNode => ( + {sortedAccountsWithDelegation.map(({ account, delegation, isFavorite }): React.ReactNode => ( void; +} + +function Delegate ({ amount: _amount, conviction: _conviction, delegatedAccount: _delegatedAccount, delegatingAccount: _delegatingAccount, onClose }: Props): React.ReactElement { + const { t } = useTranslation(); + const [amountError, setAmountError] = useState(null); + const [maxBalance] = useState(); + const [amount, setAmount] = useState(_amount); + const [delegatingAccount, setDelegatingAccount] = useState(_delegatingAccount || null); + const [delegatedAccount, setDelegatedAccount] = useState(_delegatedAccount || null); + const [conviction, setConviction] = useState(_conviction?.toNumber() || 1); + + return ( + ('Delegate democracy vote')} + size='large' + > + + + + ('delegating account')} + onChange={setDelegatingAccount} + type='account' + value={delegatingAccount} + /> + ('delegated account')} + onChange={setDelegatedAccount} + type='account' + value={delegatedAccount} + /> + + +

{t('Any democracy vote performed by the delegated account will result in an additional vote from the delegating account')}

+

{t('If the delegated account is currently voting in a referendum, the delegating vote and conviction will be added.')}

+
+
+ + + ('Amount to delegate for any democracy vote. This is adjusted using the available funds on the account.')} + isError={!!amountError?.error} + label={t('delegating amount')} + labelExtra={ + {t('balance')}} + params={delegatingAccount} + /> + } + maxValue={maxBalance} + onChange={setAmount} + value={amount} + /> + + ('The conviction that will be used for each delegated vote.')} + label={t('conviction')} + onChange={setConviction} + value={conviction} + /> + + +

{t('The amount to allocate and the conviction that will be applied to all votes made on a referendum.')}

+
+
+
+ + ('Delegate')} + onStart={onClose} + params={[delegatedAccount, conviction, amount]} + tx='democracy.delegate' + /> + +
+ ); +} + +export default React.memo(Delegate); diff --git a/packages/page-accounts/src/Accounts/modals/InputValidateAmount.tsx b/packages/page-accounts/src/Accounts/modals/InputValidateAmount.tsx new file mode 100644 index 0000000000..9097fc068f --- /dev/null +++ b/packages/page-accounts/src/Accounts/modals/InputValidateAmount.tsx @@ -0,0 +1,58 @@ +// Copyright 2017-2020 @polkadot/app-staking authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { DeriveBalancesAll } from '@polkadot/api-derive/types'; +import { AmountValidateState } from '../types'; + +import BN from 'bn.js'; +import React, { useEffect, useState } from 'react'; +import { Icon } from '@polkadot/react-components'; +import { useApi, useCall } from '@polkadot/react-hooks'; +import { BN_ZERO } from '@polkadot/util'; + +import { useTranslation } from '../../translate'; + +interface Props { + amount?: BN | null; + delegatingAccount: string | null; + onError: (state: AmountValidateState | null) => void; +} + +function ValidateAmount ({ amount, delegatingAccount, onError }: Props): React.ReactElement | null { + const { t } = useTranslation(); + const { api } = useApi(); + const delegatingAccountBalance = useCall(api.derive.balances.all, [delegatingAccount]); + const [{ error, warning }, setResult] = useState({ error: null, warning: null }); + + useEffect((): void => { + if (delegatingAccountBalance?.freeBalance && amount?.gt(BN_ZERO)) { + let newError: string | null = null; + + if (amount.gte(delegatingAccountBalance.freeBalance)) { + newError = t('The maximum amount you can delegate is the amount of funds available on the delegating account.'); + } + + setResult((state): AmountValidateState => { + const error = state.error !== newError ? newError : state.error; + const warning = state.warning; + + onError((error || warning) ? { error, warning } : null); + + return { error, warning }; + }); + } + }, [api, onError, amount, t, delegatingAccountBalance]); + + if (error || warning) { + return ( +
+
{error || warning}
+
+ ); + } + + return null; +} + +export default React.memo(ValidateAmount); diff --git a/packages/page-accounts/src/Accounts/modals/Undelegate.tsx b/packages/page-accounts/src/Accounts/modals/Undelegate.tsx new file mode 100644 index 0000000000..b237b99f9e --- /dev/null +++ b/packages/page-accounts/src/Accounts/modals/Undelegate.tsx @@ -0,0 +1,53 @@ +// Copyright 2017-2020 @polkadot/app-staking authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import React from 'react'; +import { InputAddress, Modal, TxButton } from '@polkadot/react-components'; + +import { useTranslation } from '../../translate'; + +interface Props { + accountDelegating: string | null; + onClose: () => void; +} + +function Undelegate ({ accountDelegating, onClose }: Props): React.ReactElement { + const { t } = useTranslation(); + + return ( + ('Undelegate')} + size='large' + > + + + + ('delegating account')} + /> + + +

{t('You will remove any delegation made by this acccount')}

+
+
+
+ + ('Undelegate')} + onStart={onClose} + params={[]} + tx='democracy.undelegate' + /> + +
+ ); +} + +export default React.memo(Undelegate); diff --git a/packages/page-accounts/src/Accounts/types.ts b/packages/page-accounts/src/Accounts/types.ts index 54260b4a12..23e21d9f41 100644 --- a/packages/page-accounts/src/Accounts/types.ts +++ b/packages/page-accounts/src/Accounts/types.ts @@ -2,10 +2,31 @@ // This software may be modified and distributed under the terms // of the Apache-2.0 license. See the LICENSE file for details. +import { ActionStatus } from '@polkadot/react-components/Status/types'; import { KeyringAddress } from '@polkadot/ui-keyring/types'; +import { WithTranslation } from 'react-i18next'; + +export { AppProps as ComponentProps } from '@polkadot/react-components/types'; + +export interface BareProps { + className?: string; +} + +export interface I18nProps extends BareProps, WithTranslation {} + +export interface ModalProps { + onClose: () => void; + onStatusChange: (status: ActionStatus) => void; +} + export interface SortedAccount { account: KeyringAddress; children: SortedAccount[]; isFavorite: boolean; } + +export interface AmountValidateState { + error: string | null; + warning: string | null; +} diff --git a/packages/page-accounts/src/types.ts b/packages/page-accounts/src/types.ts index e82cc7cdaf..bfab1a9140 100644 --- a/packages/page-accounts/src/types.ts +++ b/packages/page-accounts/src/types.ts @@ -3,6 +3,8 @@ // of the Apache-2.0 license. See the LICENSE file for details. import { ActionStatus } from '@polkadot/react-components/Status/types'; +import { Balance, Conviction } from '@polkadot/types/interfaces'; +import { KeyringAddress } from '@polkadot/ui-keyring/types'; import { WithTranslation } from 'react-i18next'; @@ -18,3 +20,16 @@ export interface ModalProps { onClose: () => void; onStatusChange: (status: ActionStatus) => void; } + +export interface Delegation { + accountDelegated: string + amount: Balance + conviction: Conviction +} + +export interface SortedAccount { + account: KeyringAddress; + children: SortedAccount[]; + delegation?: Delegation; + isFavorite: boolean; +} diff --git a/packages/page-accounts/src/util.tsx b/packages/page-accounts/src/util.tsx index 6777accbe1..8dc9b93aa5 100644 --- a/packages/page-accounts/src/util.tsx +++ b/packages/page-accounts/src/util.tsx @@ -2,8 +2,12 @@ // This software may be modified and distributed under the terms // of the Apache-2.0 license. See the LICENSE file for details. +import { SortedAccount } from './types'; + import React from 'react'; import { Menu } from '@polkadot/react-components'; +import keyring from '@polkadot/ui-keyring'; +import { KeyringAddress } from '@polkadot/ui-keyring/types'; export function createMenuGroup (items: (React.ReactNode | false | undefined | null)[]): React.ReactNode | null { const filtered = items.filter((item): item is React.ReactNode => !!item); @@ -12,3 +16,50 @@ export function createMenuGroup (items: (React.ReactNode | false | undefined | n ? <>{filtered} : null; } + +function expandList (mapped: SortedAccount[], entry: SortedAccount): SortedAccount[] { + mapped.push(entry); + + entry.children.forEach((entry): void => { + expandList(mapped, entry); + }); + + return mapped; +} + +export function sortAccounts (addresses: string[], favorites: string[]): SortedAccount[] { + const mapped = addresses + .map((address) => keyring.getAccount(address)) + .filter((account): account is KeyringAddress => !!account) + .map((account): SortedAccount => ({ + account, + children: [], + isFavorite: favorites.includes(account.address) + })) + .sort((a, b) => (a.account.meta.whenCreated || 0) - (b.account.meta.whenCreated || 0)); + + return mapped + .filter((entry): boolean => { + const parentAddress = entry.account.meta.parentAddress; + + if (parentAddress) { + const parent = mapped.find(({ account: { address } }) => address === parentAddress); + + if (parent) { + parent.children.push(entry); + + return false; + } + } + + return true; + }) + .reduce(expandList, []) + .sort((a, b): number => + a.isFavorite === b.isFavorite + ? 0 + : b.isFavorite + ? 1 + : -1 + ); +} diff --git a/packages/page-democracy/src/Overview/Voting.tsx b/packages/page-democracy/src/Overview/Voting.tsx index 4f4bec024b..a60cd71174 100644 --- a/packages/page-democracy/src/Overview/Voting.tsx +++ b/packages/page-democracy/src/Overview/Voting.tsx @@ -6,7 +6,7 @@ import { PropIndex, Proposal } from '@polkadot/types/interfaces'; import BN from 'bn.js'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Button, Dropdown, Modal, ProposedAction, VoteAccount, VoteActions, VoteToggle, VoteValue } from '@polkadot/react-components'; +import { Button, ConvictionDropdown, Modal, ProposedAction, VoteAccount, VoteActions, VoteToggle, VoteValue } from '@polkadot/react-components'; import { useAccounts, useApi, useToggle } from '@polkadot/react-hooks'; import { isBoolean } from '@polkadot/util'; @@ -17,8 +17,6 @@ interface Props { referendumId: PropIndex; } -const CONVICTIONS: [number, number][] = [1, 2, 4, 8, 16, 32].map((lock, index) => [index + 1, lock]); - function Voting ({ proposal, referendumId }: Props): React.ReactElement | null { const { t } = useTranslation(); const { api } = useApi(); @@ -38,24 +36,6 @@ function Voting ({ proposal, referendumId }: Props): React.ReactElement | [api] ); - const [enact] = useState( - (api.consts.democracy.enactmentPeriod.toNumber() * api.consts.timestamp.minimumPeriod.toNumber() / 1000 * 2) / 60 / 60 / 24 - ); - - const convictionOpts = useMemo(() => [ - { text: t('0.1x voting balance, no lockup period'), value: 0 }, - ...CONVICTIONS.map(([value, lock]): { text: string; value: number } => ({ - text: t('{{value}}x voting balance, locked for {{lock}}x enactment ({{period}} days)', { - replace: { - lock, - period: (enact * lock).toFixed(2), - value - } - }), - value - })) - ], [t, enact]); - const _onChangeVote = useCallback( (vote?: boolean): void => setVoteValue(isBoolean(vote) ? vote : true), [] @@ -101,11 +81,10 @@ function Voting ({ proposal, referendumId }: Props): React.ReactElement | onChange={setBalance} /> )} - ('The conviction to use for this vote, with an appropriate lock period.')} label={t('conviction')} onChange={setConviction} - options={convictionOpts} value={conviction} /> diff --git a/packages/react-components/src/AddressMini.tsx b/packages/react-components/src/AddressMini.tsx index 73b6e41fcf..cac20f8a1b 100644 --- a/packages/react-components/src/AddressMini.tsx +++ b/packages/react-components/src/AddressMini.tsx @@ -213,8 +213,9 @@ export default React.memo(styled(AddressMini)` .ui--AddressMini-summary { font-size: 0.75rem; + line-height: 1.2; margin-left: 2.25rem; - margin-top: -0.5rem; + margin-top: -0.2rem; text-align: left; } `);