From 348dc200b3ae3057ceb678e8b40ddcdcd1773134 Mon Sep 17 00:00:00 2001 From: Jaco Greeff Date: Tue, 28 Jan 2020 13:52:29 +0100 Subject: [PATCH] Staking list rendering optimizations (#2206) * Staking list rendering optimizations * Cleanups --- packages/app-staking/src/Overview/Address.tsx | 281 +++++++----------- .../app-staking/src/Overview/CurrentList.tsx | 127 +++++--- packages/app-staking/src/Overview/types.ts | 10 + 3 files changed, 197 insertions(+), 221 deletions(-) create mode 100644 packages/app-staking/src/Overview/types.ts diff --git a/packages/app-staking/src/Overview/Address.tsx b/packages/app-staking/src/Overview/Address.tsx index 5cc687db23..9362f7210e 100644 --- a/packages/app-staking/src/Overview/Address.tsx +++ b/packages/app-staking/src/Overview/Address.tsx @@ -2,15 +2,15 @@ // This software may be modified and distributed under the terms // of the Apache-2.0 license. See the LICENSE file for details. -import { AccountId, Balance, Points, ValidatorPrefsTo196 } from '@polkadot/types/interfaces'; -import { DeriveAccountInfo, DerivedStakingQuery, DerivedHeartbeats } from '@polkadot/api-derive/types'; +import { AccountId, Balance, Points } from '@polkadot/types/interfaces'; +import { DeriveAccountInfo, DerivedStakingQuery, DerivedHeartbeatAuthor } from '@polkadot/api-derive/types'; import { ValidatorFilter } from '../types'; import BN from 'bn.js'; import React, { useEffect, useState } from 'react'; -import styled from 'styled-components'; +import ApiPromise from '@polkadot/api/promise'; import { AddressMini, AddressSmall, Badge, Icon } from '@polkadot/react-components'; -import { useCall, useApi } from '@polkadot/react-hooks'; +import { useApi, useCall } from '@polkadot/react-hooks'; import { FormatBalance } from '@polkadot/react-query'; import keyring from '@polkadot/ui-keyring'; import { formatNumber } from '@polkadot/util'; @@ -18,19 +18,19 @@ import { formatNumber } from '@polkadot/util'; import { useTranslation } from '../translate'; interface Props { - address: AccountId | string; - authorsMap: Record; + address: string; className?: string; defaultName: string; filter: ValidatorFilter; filterName: string; hasQueries: boolean; + heartbeat?: DerivedHeartbeatAuthor; + isAuthor?: boolean; isElected: boolean; isFavorite: boolean; - lastAuthors?: string[]; + lastBlock?: string; myAccounts: string[]; points?: Points; - recentlyOnline?: DerivedHeartbeats; toggleFavorite: (accountId: string) => void; withNominations?: boolean; } @@ -45,103 +45,100 @@ interface StakingState { stakeTotal?: BN; stakeOther?: BN; stakeOwn?: BN; - stashId: string; - validatorPayment?: BN; } -function Address ({ address, authorsMap, className, filter, filterName, hasQueries, isElected, isFavorite, lastAuthors, myAccounts, points, recentlyOnline, toggleFavorite, withNominations = true }: Props): React.ReactElement | null { +function expandInfo ({ controllerId, nextSessionIds, stakers, validatorPrefs }: DerivedStakingQuery, myAccounts: string[], withNominations = true): StakingState { + const nominators = withNominations && stakers + ? stakers.others.map(({ who, value }): [AccountId, Balance] => [who, value.unwrap()]) + : []; + const stakeTotal = (stakers && !stakers.total.isEmpty && stakers.total.unwrap()) || undefined; + const stakeOwn = (stakers && !stakers.own.isEmpty && stakers.own.unwrap()) || undefined; + const stakeOther = (stakeTotal && stakeOwn) ? stakeTotal.sub(stakeOwn) : undefined; + const commission = validatorPrefs?.commission?.unwrap(); + + return { + commission: commission + ? `${(commission.toNumber() / 10000000).toFixed(2)}%` + : undefined, + controllerId: controllerId?.toString(), + hasNominators: nominators.length !== 0, + isNominatorMe: nominators.some(([who]): boolean => + myAccounts.includes(who.toString()) + ), + nominators, + sessionId: nextSessionIds && nextSessionIds[0]?.toString(), + stakeOther, + stakeOwn, + stakeTotal + }; +} + +function checkFilter (filter: string, hasNominators: boolean, isElected: boolean, isNominatorMe: boolean, heartbeat?: DerivedHeartbeatAuthor): boolean { + return (filter === 'hasNominators' && !hasNominators) || + (filter === 'noNominators' && hasNominators) || + (filter === 'hasWarnings' && heartbeat?.isOnline) || + (filter === 'noWarnings' && !heartbeat?.isOnline) || + (filter === 'iNominated' && !isNominatorMe) || + (filter === 'nextSet' && !isElected); +} + +function checkVisibility (api: ApiPromise, address: string, filterName: string, info: DeriveAccountInfo | undefined): boolean { + let isVisible = false; + const filterLower = filterName.toLowerCase(); + + if (filterLower) { + if (info) { + const { identity, nickname, accountId, accountIndex } = info; + + if (accountId?.toString().includes(filterName) || accountIndex?.toString().includes(filterName)) { + isVisible = true; + } else if (api.query.identity && api.query.identity.identityOf && identity?.display) { + isVisible = identity.display.toLowerCase().includes(filterLower); + } else if (nickname) { + isVisible = nickname.toLowerCase().includes(filterLower); + } + } + + if (!isVisible) { + const account = keyring.getAddress(address); + + isVisible = account?.meta?.name + ? account.meta.name.toLowerCase().includes(filterLower) + : false; + } + } else { + isVisible = true; + } + + return isVisible; +} + +export default function Address ({ address, className, filter, filterName, hasQueries, heartbeat, isAuthor, isElected, isFavorite, lastBlock, myAccounts, points, toggleFavorite, withNominations }: Props): React.ReactElement | null { const { t } = useTranslation(); const { api } = useApi(); // FIXME Any horrors, caused by derive type mismatches const info = useCall(api.derive.accounts.info as any, [address]); const stakingInfo = useCall(api.derive.staking.query as any, [address]); - const [hasActivity, setHasActivity] = useState(true); - const [{ commission, hasNominators, isNominatorMe, nominators, stashId, stakeOwn, stakeOther, validatorPayment }, setStakingState] = useState({ - hasNominators: false, - isNominatorMe: false, - nominators: [], - stashId: address.toString() - }); + const [{ commission, hasNominators, isNominatorMe, nominators, stakeOwn, stakeOther }, setStakingState] = useState({ hasNominators: false, isNominatorMe: false, nominators: [] }); const [isExpanded, setIsExpanded] = useState(false); const [isVisible, setIsVisible] = useState(true); useEffect((): void => { - if (stakingInfo) { - const { controllerId, nextSessionIds, stakers, stashId, validatorPrefs } = stakingInfo; - const nominators = withNominations && stakers - ? stakers.others.map(({ who, value }): [AccountId, Balance] => [who, value.unwrap()]) - : []; - const stakeTotal = (stakers && !stakers.total.isEmpty && stakers.total.unwrap()) || undefined; - const stakeOwn = (stakers && !stakers.own.isEmpty && stakers.own.unwrap()) || undefined; - const stakeOther = (stakeTotal && stakeOwn) ? stakeTotal.sub(stakeOwn) : undefined; - const commission = validatorPrefs?.commission?.unwrap(); - - setStakingState({ - commission: commission - ? `${(commission.toNumber() / 10000000).toFixed(2)}%` - : undefined, - controllerId: controllerId?.toString(), - hasNominators: nominators.length !== 0, - isNominatorMe: nominators.some(([who]): boolean => - myAccounts.includes(who.toString()) - ), - nominators, - sessionId: nextSessionIds && nextSessionIds[0]?.toString(), - stashId: (stashId || address).toString(), - stakeOther, - stakeOwn, - stakeTotal, - validatorPayment: (validatorPrefs as any as ValidatorPrefsTo196)?.validatorPayment?.unwrap() - }); - } + stakingInfo && setStakingState( + expandInfo(stakingInfo, myAccounts, withNominations) + ); }, [stakingInfo]); useEffect((): void => { - if (recentlyOnline && stashId && recentlyOnline[stashId]) { - setHasActivity(recentlyOnline[stashId].isOnline); - } - }, [recentlyOnline, stashId]); + setIsVisible( + checkFilter(filter, hasNominators, isElected, isNominatorMe, heartbeat) || + checkVisibility(api, address, filterName, info) + ); + }, [filter, filterName, heartbeat, info, hasNominators, isNominatorMe]); - useEffect((): void => { - let isVisible = false; - const filterLower = filterName.toLowerCase(); - - if ((filter === 'hasNominators' && !hasNominators) || (filter === 'noNominators' && hasNominators) || (filter === 'hasWarnings' && hasActivity) || (filter === 'noWarnings' && !hasActivity) || (filter === 'iNominated' && !isNominatorMe) || (filter === 'nextSet' && !isElected)) { - isVisible = true; - } else if (filterLower) { - if (info) { - const { identity, nickname, accountId, accountIndex } = info; - - if (accountId?.toString().includes(filterName) || accountIndex?.toString().includes(filterName)) { - isVisible = true; - } else if (api.query.identity && api.query.identity.identityOf) { - if (identity?.display) { - isVisible = identity.display.toLowerCase().includes(filterLower); - } - } else if (nickname) { - isVisible = nickname.toLowerCase().includes(filterLower); - } - } - - if (!isVisible) { - const account = keyring.getAddress(address); - - isVisible = account?.meta?.name - ? account.meta.name.toLowerCase().includes(filterLower) - : false; - } - } else { - isVisible = true; - } - - setIsVisible(isVisible); - }, [address, filter, filterName, info]); - - const lastBlockNumber = authorsMap[stashId]; - const isAuthor = lastAuthors && lastAuthors.includes(stashId); - const _onFavorite = (): void => toggleFavorite(stashId); + const _onFavorite = (): void => toggleFavorite(address); const _onQueryStats = (): void => { - window.location.hash = `/staking/query/${stashId}`; + window.location.hash = `/staking/query/${address}`; }; const _toggleNominators = (event: React.SyntheticEvent): void => { event.preventDefault(); @@ -169,12 +166,12 @@ function Address ({ address, authorsMap, className, filter, filterName, hasQueri type='next' /> )} - {recentlyOnline && hasActivity && recentlyOnline[stashId] && ( + {heartbeat && ( } @@ -185,10 +182,15 @@ function Address ({ address, authorsMap, className, filter, filterName, hasQueri )} - + - {stakeOwn && {t('own stake')}} value={stakeOwn} />} + {stakeOwn && ( + {t('own stake')}} + value={stakeOwn} + /> + )} {stakeOther && ( @@ -205,16 +207,21 @@ function Address ({ address, authorsMap, className, filter, filterName, hasQueri )} ) - : {t('other stake')}} value={stakeOther}> ({formatNumber(nominators.length)})  + : ( + {t('other stake')}} + value={stakeOther} + > +  ({formatNumber(nominators.length)})  + + ) )} {!isExpanded && ( <> - {(commission || validatorPayment) && ( - commission - ? <>{commission} - : {t('commission')}} value={validatorPayment} /> + {commission && ( + <>{commission} )} @@ -223,7 +230,9 @@ function Address ({ address, authorsMap, className, filter, filterName, hasQueri )} - {lastBlockNumber && <>{lastBlockNumber}} + {lastBlock && ( + <>{lastBlock} + )} {hasQueries && api.query.imOnline?.authoredBlocks && ( @@ -238,75 +247,3 @@ function Address ({ address, authorsMap, className, filter, filterName, hasQueri ); } - -export default styled(Address)` - .extras { - display: inline-block; - margin-bottom: 0.75rem; - - .favorite { - cursor: pointer; - display: inline-block; - margin-left: 0.5rem; - margin-right: -0.25rem; - - &.isSelected { - color: darkorange; - } - } - } - - .blockNumberV1, - .blockNumberV2 { - border-radius: 0.25rem; - font-size: 1.5rem; - font-weight: 100; - line-height: 1.5rem; - opacity: 0.5; - vertical-align: middle; - z-index: 1; - - &.isCurrent { - background: #3f3f3f; - box-shadow: 0 3px 3px rgba(0,0,0,.2); - color: #eee; - opacity: 1; - } - } - - .blockNumberV2 { - display: inline-block; - padding: 0.25rem 0; - - &.isCurrent { - margin-right: -0.25rem; - padding: 0.25rem 0.75rem; - } - } - - .blockNumberV1 { - padding: 0.25rem 0.5rem; - position: absolute; - right: 0; - } - - .staking--Address-info { - margin-right: 0.25rem; - text-align: right; - - .staking--label { - margin: 0 2.25rem -0.75rem 0; - } - } - - .staking--label.controllerSpacer { - margin-top: 0.5rem; - } - - .staking--stats { - bottom: 0.75rem; - cursor: pointer; - position: absolute; - right: 0.5rem; - } -`; diff --git a/packages/app-staking/src/Overview/CurrentList.tsx b/packages/app-staking/src/Overview/CurrentList.tsx index a31852d15f..07c8924528 100644 --- a/packages/app-staking/src/Overview/CurrentList.tsx +++ b/packages/app-staking/src/Overview/CurrentList.tsx @@ -3,12 +3,13 @@ // of the Apache-2.0 license. See the LICENSE file for details. import { DerivedHeartbeats, DerivedStakingOverview } from '@polkadot/api-derive/types'; -import { AccountId, EraPoints, Points } from '@polkadot/types/interfaces'; +import { AccountId } from '@polkadot/types/interfaces'; import { ValidatorFilter } from '../types'; +import { AddressDetails } from './types'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useReducer, useState } from 'react'; import { Dropdown, FilterOverlay, Input, Table } from '@polkadot/react-components'; -import { useAccounts, useApi, useFavorites } from '@polkadot/react-hooks'; +import { useAccounts, useFavorites } from '@polkadot/react-hooks'; import { STORE_FAVS_BASE } from '../constants'; import { useTranslation } from '../translate'; @@ -25,79 +26,115 @@ interface Props { stakingOverview?: DerivedStakingOverview; } -type AccountExtend = [string, boolean, boolean, Points?]; +type AccountExtend = [string, boolean, boolean]; -function filterAccounts (accounts: string[] = [], elected: string[], favorites: string[], without: string[], eraPoints?: EraPoints): AccountExtend[] { +function sortByFav ([,, isFavA]: AccountExtend, [,, isFavB]: AccountExtend): number { + return isFavA === isFavB + ? 0 + : (isFavA ? -1 : 1); +} + +function filterAccounts (accounts: string[] = [], elected: string[], favorites: string[], without: string[]): AccountExtend[] { return accounts .filter((accountId): boolean => !without.includes(accountId as any)) - .sort((a, b): number => { - const isFavA = favorites.includes(a); - const isFavB = favorites.includes(b); - - return isFavA === isFavB - ? 0 - : (isFavA ? -1 : 1); - }) - .map((accountId): AccountExtend => { - const electedIdx = elected.indexOf(accountId); - - return [ - accountId, - elected.includes(accountId), - favorites.includes(accountId), - electedIdx !== -1 - ? eraPoints?.individual[electedIdx] - : undefined - ]; - }); + .map((accountId): AccountExtend => [ + accountId, + elected.includes(accountId), + favorites.includes(accountId) + ]) + .sort(sortByFav); } function accountsToString (accounts: AccountId[]): string[] { return accounts.map((accountId): string => accountId.toString()); } +function reduceDetails (state: Record, _details: AddressDetails | AddressDetails[]): Record { + const details = Array.isArray(_details) + ? _details + : [_details]; + + return details.reduce((result, details): Record => { + result[details.address] = { + ...(state[details.address] || {}), + ...details + }; + + return result; + }, { ...state }); +} + export default function CurrentList ({ authorsMap, hasQueries, isIntentions, isVisible, lastAuthors, next, recentlyOnline, stakingOverview }: Props): React.ReactElement | null { const { t } = useTranslation(); - const { isSubstrateV2 } = useApi(); const { allAccounts } = useAccounts(); const [favorites, toggleFavorite] = useFavorites(STORE_FAVS_BASE); const [filter, setFilter] = useState('all'); - const [{ elected, validators, waiting }, setFiltered] = useState<{ elected: AccountExtend[]; validators: AccountExtend[]; waiting: AccountExtend[] }>({ elected: [], validators: [], waiting: [] }); + const [{ allElected, elected, validators, waiting }, setFiltered] = useState<{ allElected: string[]; elected: AccountExtend[]; validators: AccountExtend[]; waiting: AccountExtend[] }>({ allElected: [], elected: [], validators: [], waiting: [] }); const [nameFilter, setNameFilter] = useState(''); + const [addressDetails, dispatchDetails] = useReducer(reduceDetails, {}); + const filterOpts = useMemo(() => [ + { text: t('Show all validators and intentions'), value: 'all' }, + { text: t('Show only my nominations'), value: 'iNominated' }, + { text: t('Show only with nominators'), value: 'hasNominators' }, + { text: t('Show only without nominators'), value: 'noNominators' }, + { text: t('Show only with warnings'), value: 'hasWarnings' }, + { text: t('Show only without warnings'), value: 'noWarnings' }, + { text: t('Show only elected for next session'), value: 'nextSet' } + ], [t]); useEffect((): void => { if (isVisible && stakingOverview) { - const _elected = accountsToString(stakingOverview.currentElected); + const allElected = accountsToString(stakingOverview.currentElected); const _validators = accountsToString(stakingOverview.validators); - const validators = filterAccounts(_validators, _elected, favorites, [], stakingOverview.eraPoints); - const elected = isSubstrateV2 ? filterAccounts(_elected, _elected, favorites, _validators) : []; + const validators = filterAccounts(_validators, allElected, favorites, []); + const elected = filterAccounts(allElected, allElected, favorites, _validators); setFiltered({ + allElected, elected, validators, - waiting: filterAccounts(next, [], favorites, _elected) + waiting: filterAccounts(next, [], favorites, allElected) }); } - }, [favorites, isVisible, next, stakingOverview]); + }, [favorites, isVisible, next, stakingOverview?.currentElected, stakingOverview?.validators]); - const _renderRows = (addresses: AccountExtend[], defaultName: string, withOnline: boolean): React.ReactNode => - addresses.map(([address, isElected, isFavorite, points]): React.ReactNode => ( + useEffect((): void => { + if (stakingOverview) { + dispatchDetails(validators.map(([address]): AddressDetails => { + const electedIdx = allElected.indexOf(address); + + return { + address, + points: electedIdx !== -1 + ? stakingOverview.eraPoints?.individual[electedIdx] + : undefined + }; + })); + } + }, [stakingOverview?.eraPoints, allElected, validators]); + + const _renderRows = (addresses: AccountExtend[], defaultName: string, isMain: boolean): React.ReactNode => + addresses.map(([address, isElected, isFavorite]): React.ReactNode => (
diff --git a/packages/app-staking/src/Overview/types.ts b/packages/app-staking/src/Overview/types.ts new file mode 100644 index 0000000000..5dd15942fc --- /dev/null +++ b/packages/app-staking/src/Overview/types.ts @@ -0,0 +1,10 @@ +// 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 { Points } from '@polkadot/types/interfaces'; + +export interface AddressDetails { + address: string; + points?: Points; +}