From 155b6b2f618977e7e8a21cc141af84e63e82a0b9 Mon Sep 17 00:00:00 2001 From: Depth-Hoar <44712760+Depth-Hoar@users.noreply.github.com> Date: Wed, 4 Jan 2023 16:02:42 -0700 Subject: [PATCH 01/11] ui outline --- .../page-ddelegation/src/Accounts/Account.tsx | 884 ++++++++++++++++++ .../page-ddelegation/src/Accounts/index.tsx | 311 ++++++ packages/page-ddelegation/src/index.tsx | 62 ++ .../page-ddelegation/src/modals/Delegate.tsx | 135 +++ 4 files changed, 1392 insertions(+) create mode 100644 packages/page-ddelegation/src/Accounts/Account.tsx create mode 100644 packages/page-ddelegation/src/Accounts/index.tsx create mode 100644 packages/page-ddelegation/src/index.tsx create mode 100644 packages/page-ddelegation/src/modals/Delegate.tsx diff --git a/packages/page-ddelegation/src/Accounts/Account.tsx b/packages/page-ddelegation/src/Accounts/Account.tsx new file mode 100644 index 000000000000..a22ceeaeba5f --- /dev/null +++ b/packages/page-ddelegation/src/Accounts/Account.tsx @@ -0,0 +1,884 @@ +// Copyright 2017-2022 @polkadot/app-accounts authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { SubmittableExtrinsic } from '@polkadot/api/types'; +import type { DeriveDemocracyLock, DeriveStakingAccount } from '@polkadot/api-derive/types'; +import type { Ledger } from '@polkadot/hw-ledger'; +import type { ActionStatus } from '@polkadot/react-components/Status/types'; +import type { ThemeDef } from '@polkadot/react-components/types'; +import type { Option } from '@polkadot/types'; +import type { ProxyDefinition, RecoveryConfig } from '@polkadot/types/interfaces'; +import type { KeyringAddress, KeyringJson$Meta } from '@polkadot/ui-keyring/types'; +import type { AccountBalance, Delegation } from '../types'; + +import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import styled, { ThemeContext } from 'styled-components'; + +import { ApiPromise } from '@polkadot/api'; +import useAccountLocks from '@polkadot/app-referenda/useAccountLocks'; +import { AddressInfo, AddressSmall, Badge, Button, ChainLock, CryptoType, ExpandButton, Forget, Icon, LinkExternal, Menu, Popup, StatusContext, Tags } from '@polkadot/react-components'; +import { useAccountInfo, useApi, useBalancesAll, useBestNumber, useCall, useLedger, useStakingInfo, useToggle } from '@polkadot/react-hooks'; +import { keyring } from '@polkadot/ui-keyring'; +import { BN, BN_ZERO, formatBalance, formatNumber, isFunction } from '@polkadot/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'; +import MultisigApprove from '../modals/MultisigApprove'; +import ProxyOverview from '../modals/ProxyOverview'; +import RecoverAccount from '../modals/RecoverAccount'; +import RecoverSetup from '../modals/RecoverSetup'; +import Transfer from '../modals/Transfer'; +import UndelegateModal from '../modals/Undelegate'; +import { useTranslation } from '../translate'; +import { createMenuGroup } from '../util'; +import useMultisigApprovals from './useMultisigApprovals'; +import useProxies from './useProxies'; + +interface Props { + account: KeyringAddress; + className?: string; + delegation?: Delegation; + filter: string; + isFavorite: boolean; + proxy?: [ProxyDefinition[], BN]; + setBalance: (address: string, value: AccountBalance) => void; + toggleFavorite: (address: string) => void; +} + +interface DemocracyUnlockable { + democracyUnlockTx: SubmittableExtrinsic<'promise'> | null; + ids: BN[]; +} + +interface ReferendaUnlockable { + referendaUnlockTx: SubmittableExtrinsic<'promise'> | null; + ids: [classId: BN, refId: BN][]; +} + +const BAL_OPTS_DEFAULT = { + available: false, + bonded: false, + locked: false, + redeemable: false, + reserved: false, + total: true, + unlocking: false, + vested: false +}; + +const BAL_OPTS_EXPANDED = { + available: true, + bonded: true, + locked: true, + redeemable: true, + reserved: true, + total: false, + unlocking: true, + vested: true +}; + +function calcVisible (filter: string, name: string, tags: string[]): boolean { + if (filter.length === 0) { + return true; + } + + const _filter = filter.toLowerCase(); + + return tags.reduce((result: boolean, tag: string): boolean => { + return result || tag.toLowerCase().includes(_filter); + }, name.toLowerCase().includes(_filter)); +} + +function calcUnbonding (stakingInfo?: DeriveStakingAccount) { + if (!stakingInfo?.unlocking) { + return BN_ZERO; + } + + const filtered = stakingInfo.unlocking + .filter(({ remainingEras, value }) => value.gt(BN_ZERO) && remainingEras.gt(BN_ZERO)) + .map((unlock) => unlock.value); + const total = filtered.reduce((total, value) => total.iadd(value), new BN(0)); + + return total; +} + +function createClearDemocracyTx (api: ApiPromise, address: string, ids: BN[]): SubmittableExtrinsic<'promise'> | null { + return api.tx.utility && ids.length + ? api.tx.utility.batch( + ids + .map((id) => api.tx.democracy.removeVote(id)) + .concat(api.tx.democracy.unlock(address)) + ) + : null; +} + +function createClearReferendaTx (api: ApiPromise, address: string, ids: [BN, BN][], palletReferenda = 'convictionVoting'): SubmittableExtrinsic<'promise'> | null { + if (!api.tx.utility || !ids.length) { + return null; + } + + const inner = ids.map(([classId, refId]) => api.tx[palletReferenda].removeVote(classId, refId)); + + ids + .reduce((all: BN[], [classId]) => { + if (!all.find((id) => id.eq(classId))) { + all.push(classId); + } + + return all; + }, []) + .forEach((classId): void => { + inner.push(api.tx[palletReferenda].unlock(classId, address)); + }); + + return api.tx.utility.batch(inner); +} + +async function showLedgerAddress (getLedger: () => Ledger, meta: KeyringJson$Meta): Promise { + const ledger = getLedger(); + + await ledger.getAddress(true, meta.accountOffset as number || 0, meta.addressOffset as number || 0); +} + +const transformRecovery = { + transform: (opt: Option) => opt.unwrapOr(null) +}; + +function Account ({ account: { address, meta }, className = '', delegation, filter, isFavorite, proxy, setBalance, toggleFavorite }: Props): React.ReactElement | null { + const { t } = useTranslation(); + const [isExpanded, toggleIsExpanded] = useToggle(false); + const { theme } = useContext(ThemeContext as React.Context); + const { queueExtrinsic } = useContext(StatusContext); + const api = useApi(); + const { getLedger } = useLedger(); + const bestNumber = useBestNumber(); + const balancesAll = useBalancesAll(address); + const stakingInfo = useStakingInfo(address); + const democracyLocks = useCall(api.api.derive.democracy?.locks, [address]); + const recoveryInfo = useCall(api.api.query.recovery?.recoverable, [address], transformRecovery); + const multiInfos = useMultisigApprovals(address); + const proxyInfo = useProxies(address); + const { flags: { isDevelopment, isEditable, isEthereum, isExternal, isHardware, isInjected, isMultisig, isProxied }, genesisHash, identity, name: accName, onSetGenesisHash, tags } = useAccountInfo(address); + const convictionLocks = useAccountLocks('referenda', 'convictionVoting', address); + const [{ democracyUnlockTx }, setDemocracyUnlock] = useState({ democracyUnlockTx: null, ids: [] }); + const [{ referendaUnlockTx }, setReferandaUnlock] = useState({ ids: [], referendaUnlockTx: null }); + const [vestingVestTx, setVestingTx] = useState | null>(null); + const [isBackupOpen, toggleBackup] = useToggle(); + const [isDeriveOpen, toggleDerive] = useToggle(); + const [isForgetOpen, toggleForget] = useToggle(); + const [isIdentityMainOpen, toggleIdentityMain] = useToggle(); + const [isIdentitySubOpen, toggleIdentitySub] = useToggle(); + const [isMultisigOpen, toggleMultisig] = useToggle(); + const [isProxyOverviewOpen, toggleProxyOverview] = useToggle(); + const [isPasswordOpen, togglePassword] = useToggle(); + const [isRecoverAccountOpen, toggleRecoverAccount] = useToggle(); + const [isRecoverSetupOpen, toggleRecoverSetup] = useToggle(); + const [isTransferOpen, toggleTransfer] = useToggle(); + const [isDelegateOpen, toggleDelegate] = useToggle(); + const [isUndelegateOpen, toggleUndelegate] = useToggle(); + + useEffect((): void => { + if (balancesAll) { + setBalance(address, { + // some chains don't have "active" in the Ledger + bonded: stakingInfo?.stakingLedger.active?.unwrap() || BN_ZERO, + locked: balancesAll.lockedBalance, + redeemable: stakingInfo?.redeemable || BN_ZERO, + total: balancesAll.freeBalance.add(balancesAll.reservedBalance), + transferrable: balancesAll.availableBalance, + unbonding: calcUnbonding(stakingInfo) + }); + + api.api.tx.vesting?.vest && setVestingTx(() => + balancesAll.vestingLocked.isZero() + ? null + : api.api.tx.vesting.vest() + ); + } + }, [address, api, balancesAll, setBalance, stakingInfo]); + + useEffect((): void => { + bestNumber && democracyLocks && setDemocracyUnlock( + (prev): DemocracyUnlockable => { + const ids = democracyLocks + .filter(({ isFinished, unlockAt }) => isFinished && bestNumber.gt(unlockAt)) + .map(({ referendumId }) => referendumId); + + if (JSON.stringify(prev.ids) === JSON.stringify(ids)) { + return prev; + } + + return { + democracyUnlockTx: createClearDemocracyTx(api.api, address, ids), + ids + }; + } + ); + }, [address, api, bestNumber, democracyLocks]); + + useEffect((): void => { + bestNumber && convictionLocks && setReferandaUnlock( + (prev): ReferendaUnlockable => { + const ids = convictionLocks + .filter(({ endBlock }) => endBlock.gt(BN_ZERO) && bestNumber.gt(endBlock)) + .map(({ classId, refId }): [classId: BN, refId: BN] => [classId, refId]); + + if (JSON.stringify(prev.ids) === JSON.stringify(ids)) { + return prev; + } + + return { + ids, + referendaUnlockTx: createClearReferendaTx(api.api, address, ids) + }; + } + ); + }, [address, api, bestNumber, convictionLocks]); + + const isVisible = useMemo( + () => calcVisible(filter, accName, tags), + [accName, filter, tags] + ); + + const _onFavorite = useCallback( + () => toggleFavorite(address), + [address, toggleFavorite] + ); + + const _onForget = useCallback( + (): void => { + if (!address) { + return; + } + + const status: Partial = { + account: address, + action: 'forget' + }; + + try { + keyring.forgetAccount(address); + status.status = 'success'; + status.message = t('account forgotten'); + } catch (error) { + status.status = 'error'; + status.message = (error as Error).message; + } + }, + [address, t] + ); + + const _clearDemocracyLocks = useCallback( + () => democracyUnlockTx && queueExtrinsic({ + accountId: address, + extrinsic: democracyUnlockTx + }), + [address, democracyUnlockTx, queueExtrinsic] + ); + + const _clearReferendaLocks = useCallback( + () => referendaUnlockTx && queueExtrinsic({ + accountId: address, + extrinsic: referendaUnlockTx + }), + [address, referendaUnlockTx, queueExtrinsic] + ); + + const _vestingVest = useCallback( + () => vestingVestTx && queueExtrinsic({ + accountId: address, + extrinsic: vestingVestTx + }), + [address, queueExtrinsic, vestingVestTx] + ); + + const _showOnHardware = useCallback( + // TODO: we should check the hardwareType from metadata here as well, + // for now we are always assuming hardwareType === 'ledger' + (): void => { + showLedgerAddress(getLedger, meta).catch((error): void => { + console.error(`ledger: ${(error as Error).message}`); + }); + }, + [getLedger, meta] + ); + + const menuItems = useMemo(() => [ + createMenuGroup('identityGroup', [ + isFunction(api.api.tx.identity?.setIdentity) && !isHardware && ( + + ), + isFunction(api.api.tx.identity?.setSubs) && identity?.display && !isHardware && ( + + ), + isFunction(api.api.tx.democracy?.unlock) && democracyUnlockTx && ( + + ), + isFunction(api.api.tx.convictionVoting?.unlock) && referendaUnlockTx && ( + + ), + isFunction(api.api.tx.vesting?.vest) && vestingVestTx && ( + + ) + ], t('Identity')), + createMenuGroup('deriveGroup', [ + !(isEthereum || isExternal || isHardware || isInjected || isMultisig || api.isEthereum) && ( + + ), + isHardware && ( + + ) + ], t('Derive')), + createMenuGroup('backupGroup', [ + !(isExternal || isHardware || isInjected || isMultisig || isDevelopment) && ( + + ), + !(isExternal || isHardware || isInjected || isMultisig || isDevelopment) && ( + + ), + !(isInjected || isDevelopment) && ( + + ) + ], t('Backup')), + isFunction(api.api.tx.recovery?.createRecovery) && createMenuGroup('reoveryGroup', [ + !recoveryInfo && ( + + ), + + ], t('Recovery')), + isFunction(api.api.tx.multisig?.asMulti) && isMultisig && createMenuGroup('multisigGroup', [ + + ], t('Multisig')), + isFunction(api.api.query.democracy?.votingOf) && delegation?.accountDelegated && createMenuGroup('undelegateGroup', [ + , + + ], t('Undelegate')), + createMenuGroup('delegateGroup', [ + isFunction(api.api.query.democracy?.votingOf) && !delegation?.accountDelegated && ( + + ), + isFunction(api.api.query.proxy?.proxies) && ( + + ) + ], t('Delegate')), + isEditable && !api.isDevelopment && createMenuGroup('genesisGroup', [ + + ]) + ].filter((i) => i), + [_clearDemocracyLocks, _clearReferendaLocks, _showOnHardware, _vestingVest, api, delegation, democracyUnlockTx, genesisHash, identity, isDevelopment, isEditable, isEthereum, isExternal, isHardware, isInjected, isMultisig, multiInfos, onSetGenesisHash, proxy, referendaUnlockTx, recoveryInfo, t, toggleBackup, toggleDelegate, toggleDerive, toggleForget, toggleIdentityMain, toggleIdentitySub, toggleMultisig, togglePassword, toggleProxyOverview, toggleRecoverAccount, toggleRecoverSetup, toggleUndelegate, vestingVestTx]); + + if (!isVisible) { + return null; + } + + return ( + <> + {delegation?.accountDelegated && ( +
+ + + + + +
+
+ {meta.genesisHash + ? + : isDevelopment + ? ( + ('This is a development account derived from the known development seed. Do not use for any funds on a non-development network.')} + icon='wrench' + /> + ) + : ( + +

{t('This account is available on all networks. It is recommended to link to a specific network via the account options ("only this network" option) to limit availability. For accounts from an extension, set the network on the extension.')}

+

{t('This does not send any transaction, rather it only sets the genesis in the account JSON.')}

+
+ } + icon='exclamation-triangle' + /> + ) + } + {recoveryInfo && ( + +

{t('This account is recoverable, with the following friends:')}

+
+ {recoveryInfo.friends.map((friend, index): React.ReactNode => ( + + ))} +
+ + + + + + + + + + + + + + + +
{t('threshold')}{formatNumber(recoveryInfo.threshold)}
{t('delay')}{formatNumber(recoveryInfo.delayPeriod)}
{t('deposit')}{formatBalance(recoveryInfo.deposit)}
+
+ } + icon='redo' + /> + )} + {isProxied && !proxyInfo.hasOwned && ( + ('Proxied account has no owned proxies')} + icon='sitemap' + info='0' + /> + )} +
+
+ {multiInfos && multiInfos.length !== 0 && ( + ('Multisig approvals pending')} + hoverAction={t('View pending approvals')} + icon='file-signature' + info={multiInfos.length} + onClick={toggleMultisig} + /> + )} + {delegation?.accountDelegated && ( + ('This account has a governance delegation')} + hoverAction={t('Manage delegation')} + icon='calendar-check' + onClick={toggleDelegate} + /> + )} + {!!proxy?.[0].length && api.api.tx.utility && ( + ('This account has {{proxyNumber}} proxy set.', { + replace: { + proxyNumber: proxy[0].length + } + })} + hoverAction={t('Proxy overview')} + icon='sitemap' + onClick={toggleProxyOverview} + /> + )} +
+ + + + + {isBackupOpen && ( + + )} + {isDelegateOpen && ( + + )} + {isDeriveOpen && ( + + )} + {isForgetOpen && ( + + )} + {isIdentityMainOpen && ( + + )} + {isIdentitySubOpen && ( + + )} + {isPasswordOpen && ( + + )} + {isTransferOpen && ( + + )} + {isProxyOverviewOpen && ( + + )} + {isMultisigOpen && multiInfos && ( + + )} + {isRecoverAccountOpen && ( + + )} + {isRecoverSetupOpen && ( + + )} + {isUndelegateOpen && ( + + )} + + {/* + + + + {balancesAll?.accountNonce.gt(BN_ZERO) && formatNumber(balancesAll.accountNonce)} + + + + */} + {delegation?.accountDelegated && ( + + {isFunction(api.api.tx.balances?.transfer) && ( +