diff --git a/.changelog/1398.feature.md b/.changelog/1398.feature.md new file mode 100644 index 000000000..0dff6d6a5 --- /dev/null +++ b/.changelog/1398.feature.md @@ -0,0 +1 @@ +Initial support for named accounts diff --git a/src/app/components/Account/AccountLink.tsx b/src/app/components/Account/AccountLink.tsx index 8924c7000..6d84ad379 100644 --- a/src/app/components/Account/AccountLink.tsx +++ b/src/app/components/Account/AccountLink.tsx @@ -6,8 +6,12 @@ import { RouteUtils } from '../../utils/route-utils' import InfoIcon from '@mui/icons-material/Info' import Typography from '@mui/material/Typography' import { SearchScope } from '../../../types/searchScope' +import { useAccountMetadata } from '../../hooks/useAccountMetadata' import { trimLongString } from '../../utils/trimLongString' import { MaybeWithTooltip } from '../AdaptiveTrimmer/MaybeWithTooltip' +import Box from '@mui/material/Box' +import { HighlightedText } from '../HighlightedText' +import { AdaptiveHighlightedText } from '../HighlightedText/AdaptiveHighlightedText' import { AdaptiveTrimmer } from '../AdaptiveTrimmer/AdaptiveTrimmer' const WithTypographyAndLink: FC<{ @@ -24,7 +28,7 @@ const WithTypographyAndLink: FC<{ ...(mobile ? { maxWidth: '100%', - overflowX: 'hidden', + overflow: 'hidden', } : {}), }} @@ -52,7 +56,12 @@ interface Props { /** * Should we always trim the text to a short line when on mobile or Tablet? */ - alwaysTrimOnTable?: boolean + alwaysTrimOnTablet?: boolean + + /** + * What part of the name should be highlighted (if any) + */ + highlightedPartOfName?: string | undefined /** * Any extra tooltips to display @@ -73,47 +82,87 @@ export const AccountLink: FC = ({ scope, address, alwaysTrim, - alwaysTrimOnTable, + alwaysTrimOnTablet, + highlightedPartOfName, extraTooltip, labelOnly, }) => { const { isTablet } = useScreenSize() + const { + metadata: accountMetadata, + // isError, // Use this to indicate that we have failed to load the name for this account + } = useAccountMetadata(scope, address) + const accountName = accountMetadata?.name // TODO: we should also use the description + const to = RouteUtils.getAccountRoute(scope, address) const extraTooltipWithIcon = extraTooltip ? ( - <> + {extraTooltip} - + ) : undefined // Are we in a situation when we should always trim? - if (alwaysTrim || (alwaysTrimOnTable && isTablet)) { + if (alwaysTrim || (alwaysTrimOnTablet && isTablet)) { // In a table, we only ever want a short line return ( - {trimLongString(address, 6, 6)} + + {accountName && {accountName}} + {address} + {extraTooltipWithIcon} + + } + > + {accountName ? trimLongString(accountName, 12, 0) : trimLongString(address, 6, 6)} + ) } if (!isTablet) { // Details in desktop mode. - // We want one long line + // We want one long line, with name and address. return ( - {address} + + {accountName ? ( + + ({address}) + + ) : ( + address + )} + ) } // We need to show the data in details mode on mobile. - // Line adaptively shortened to fill available space + // We want two lines, one for name (if available), one for address + // Both lines adaptively shortened to fill available space return ( - + <> + + + ) } diff --git a/src/app/components/Account/index.tsx b/src/app/components/Account/index.tsx index c874540eb..a5fd6dddd 100644 --- a/src/app/components/Account/index.tsx +++ b/src/app/components/Account/index.tsx @@ -33,6 +33,7 @@ type RuntimeAccountDataProps = { isLoading: boolean tokenPrices: AllTokenPrices showLayer?: boolean + highlightedPartOfName: string | undefined } export const RuntimeAccountData: FC = ({ @@ -41,6 +42,7 @@ export const RuntimeAccountData: FC = ({ isLoading, tokenPrices, showLayer, + highlightedPartOfName, }) => { const { t } = useTranslation() const { isMobile } = useScreenSize() @@ -76,7 +78,7 @@ export const RuntimeAccountData: FC = ({
- +
@@ -175,6 +177,7 @@ export type ConsensusAccountDataProps = { isLoading?: boolean showLayer?: boolean standalone?: boolean + highlightedPartOfName?: string | undefined } export const ConsensusAccountData: FC = ({ @@ -182,6 +185,7 @@ export const ConsensusAccountData: FC = ({ isLoading, showLayer, standalone, + highlightedPartOfName, }) => { const { t } = useTranslation() const { isMobile } = useScreenSize() @@ -205,7 +209,11 @@ export const ConsensusAccountData: FC = ({
- +
diff --git a/src/app/components/RuntimeEvents/RuntimeEventDetails.tsx b/src/app/components/RuntimeEvents/RuntimeEventDetails.tsx index a1337e0bc..4db343096 100644 --- a/src/app/components/RuntimeEvents/RuntimeEventDetails.tsx +++ b/src/app/components/RuntimeEvents/RuntimeEventDetails.tsx @@ -119,7 +119,7 @@ const EvmEventParamData: FC<{ // TODO: handle more EVM types case 'address': return address ? ( - + ) : null case 'uint256': // TODO: format with BigNumber diff --git a/src/app/components/Search/search-utils.ts b/src/app/components/Search/search-utils.ts index bebe55a4a..6cc4fd520 100644 --- a/src/app/components/Search/search-utils.ts +++ b/src/app/components/Search/search-utils.ts @@ -119,6 +119,11 @@ export const validateAndNormalize = { return searchTerm.toLowerCase() } }, + accountNameFragment: (searchTerm: string) => { + if (searchTerm?.length >= textSearchMininumLength) { + return searchTerm.toLowerCase() + } + }, } satisfies { [name: string]: (searchTerm: string) => string | undefined } export function isSearchValid(searchTerm: string) { diff --git a/src/app/data/named-accounts.ts b/src/app/data/named-accounts.ts new file mode 100644 index 000000000..72b59eaf4 --- /dev/null +++ b/src/app/data/named-accounts.ts @@ -0,0 +1,51 @@ +import { Network } from '../../types/network' +import { Account, Layer, Runtime, RuntimeAccount } from '../../oasis-nexus/api' + +export type AccountMetadata = { + address: string + name?: string + description?: string +} + +export type AccountMetadataInfo = { + metadata?: AccountMetadata + isLoading: boolean + isError: boolean +} + +export type AccountMap = Map + +export type AccountData = { + map: AccountMap + list: AccountMetadata[] +} + +export type AccountNameSearchMatch = { + network: Network + layer: Layer + address: string +} + +export type AccountNameSearchRuntimeMatch = { + network: Network + layer: Runtime + address: string +} + +export type AccountNameSearchConsensusMatch = { + network: Network + layer: typeof Layer.consensus + address: string +} + +export type AccountNameSearchResults = { + results: (Account | RuntimeAccount)[] | undefined + isLoading: boolean + isError: boolean +} + +export type AccountNameSearchRuntimeResults = { + results: RuntimeAccount[] | undefined + isLoading: boolean + isError: boolean +} diff --git a/src/app/data/oasis-account-names.ts b/src/app/data/oasis-account-names.ts new file mode 100644 index 000000000..099d5f3d5 --- /dev/null +++ b/src/app/data/oasis-account-names.ts @@ -0,0 +1,147 @@ +import axios from 'axios' +import { useQuery } from '@tanstack/react-query' +import { + Layer, + useGetConsensusAccountsAddresses, + useGetRuntimeAccountsAddresses, +} from '../../oasis-nexus/api' +import { Network } from '../../types/network' +import { + AccountData, + AccountMap, + AccountMetadata, + AccountMetadataInfo, + AccountNameSearchConsensusMatch, + AccountNameSearchMatch, + AccountNameSearchResults, + AccountNameSearchRuntimeMatch, +} from './named-accounts' +import { hasTextMatch } from '../components/HighlightedText/text-matching' + +const dataSources: Record>> = { + [Network.mainnet]: { + [Layer.consensus]: + 'https://raw.githubusercontent.com/oasisprotocol/nexus/main/account-names/mainnet_consensus.json', + [Layer.emerald]: + 'https://raw.githubusercontent.com/oasisprotocol/nexus/main/account-names/mainnet_paratime.json', + [Layer.sapphire]: + 'https://raw.githubusercontent.com/oasisprotocol/nexus/main/account-names/mainnet_paratime.json', + }, + [Network.testnet]: { + [Layer.consensus]: + 'https://raw.githubusercontent.com/oasisprotocol/nexus/main/account-names/testnet_consensus.json', + [Layer.emerald]: + 'https://raw.githubusercontent.com/oasisprotocol/nexus/main/account-names/testnet_paratime.json', + [Layer.sapphire]: + 'https://raw.githubusercontent.com/oasisprotocol/nexus/main/account-names/testnet_paratime.json', + }, +} + +const getOasisAccountsMetadata = async (network: Network, layer: Layer): Promise => { + const url = dataSources[network][layer] + if (!url) throw new Error('No data available for this layer') + const response = await axios.get(url) + if (response.status !== 200) throw new Error("Couldn't load names") + if (!response.data) throw new Error("Couldn't load names") + // console.log('Response data is', response.data) + const map: AccountMap = new Map() + const list: AccountMetadata[] = [] + Array.from(response.data).forEach((entry: any) => { + const metadata: AccountMetadata = { + address: entry.Address, + name: entry.Name, + description: entry.Description, + } + // Register the metadata in its native form + list.push(metadata) + map.set(metadata.address, metadata) + }) + return { + map, + list, + } +} + +export const useOasisAccountsMetadata = ( + network: Network, + layer: Layer, + queryOptions: { enabled: boolean }, +) => { + return useQuery(['oasisAccounts', network, layer], () => getOasisAccountsMetadata(network, layer), { + enabled: queryOptions.enabled, + staleTime: Infinity, + }) +} + +export const useOasisAccountMetadata = ( + network: Network, + layer: Layer, + address: string, + queryOptions: { enabled: boolean }, +): AccountMetadataInfo => { + const { isLoading, isError, error, data: allData } = useOasisAccountsMetadata(network, layer, queryOptions) + if (isError) { + console.log('Failed to load Oasis account metadata', error) + } + return { + metadata: allData?.map.get(address), + isLoading, + isError, + } +} + +export const useSearchForOasisAccountsByName = ( + network: Network, + layer: Layer, + nameFragment: string, + queryOptions: { enabled: boolean }, +): AccountNameSearchResults => { + const { + isLoading: isMetadataLoading, + isError: isMetadataError, + error: metadataError, + data: namedAccounts, + } = useOasisAccountsMetadata(network, layer, queryOptions) + if (isMetadataError) { + console.log('Failed to load Oasis account metadata', metadataError) + } + + const textMatcher = + nameFragment && queryOptions.enabled + ? (account: AccountMetadata) => hasTextMatch(account.name, [nameFragment]) + : () => false + + const matches = + namedAccounts?.list.filter(textMatcher).map( + (account): AccountNameSearchMatch => ({ + network, + layer, + address: account.address, + }), + ) ?? [] + + const consensusMatches = layer === Layer.consensus ? (matches as AccountNameSearchConsensusMatch[]) : [] + const runtimeMatches = layer === Layer.consensus ? [] : (matches as AccountNameSearchRuntimeMatch[]) + + const { + isLoading: areConsensusAccountsLoading, + isError: areConsensusAccountsError, + data: consensusResults, + } = useGetConsensusAccountsAddresses(consensusMatches, { + enabled: queryOptions.enabled && !isMetadataLoading && !isMetadataError, + }) + + const { + isLoading: areRuntimeAccountsLoading, + isError: areRuntimeAccountsError, + data: runtimeResults, + } = useGetRuntimeAccountsAddresses(runtimeMatches, { + enabled: queryOptions.enabled && !isMetadataLoading && !isMetadataError, + }) + + return { + isLoading: isMetadataLoading || areConsensusAccountsLoading || areRuntimeAccountsLoading, + isError: isMetadataError || areConsensusAccountsError || areRuntimeAccountsError, + results: [...consensusResults, ...runtimeResults], + } +} diff --git a/src/app/data/pontusx-account-names.ts b/src/app/data/pontusx-account-names.ts new file mode 100644 index 000000000..2e5e59a8e --- /dev/null +++ b/src/app/data/pontusx-account-names.ts @@ -0,0 +1,103 @@ +import axios from 'axios' +import { useQuery } from '@tanstack/react-query' +import { + AccountMetadata, + AccountMap, + AccountMetadataInfo, + AccountNameSearchRuntimeMatch, + AccountNameSearchRuntimeResults, +} from './named-accounts' +import { Layer, useGetRuntimeAccountsAddresses } from '../../oasis-nexus/api' +import { Network } from '../../types/network' +import { hasTextMatch } from '../components/HighlightedText/text-matching' +import { getOasisAddress } from '../utils/helpers' + +const DATA_SOURCE_URL = 'https://raw.githubusercontent.com/deltaDAO/mvg-portal/main/pontusxAddresses.json' + +const getPontusXAccountsMetadata = async () => { + const response = await axios.get(DATA_SOURCE_URL) + if (response.status !== 200) throw new Error("Couldn't load names") + if (!response.data) throw new Error("Couldn't load names") + const map: AccountMap = new Map() + const list: AccountMetadata[] = [] + Object.entries(response.data).forEach(([evmAddress, name]) => { + const account: AccountMetadata = { + address: getOasisAddress(evmAddress), + name: name as string, + } + map.set(evmAddress, account) + list.push(account) + }) + return { + map, + list, + } +} + +export const usePontusXAccountsMetadata = (queryOptions: { enabled: boolean }) => { + return useQuery(['pontusXNames'], getPontusXAccountsMetadata, { + enabled: queryOptions.enabled, + staleTime: Infinity, + }) +} + +export const usePontusXAccountMetadata = ( + address: string, + queryOptions: { enabled: boolean }, +): AccountMetadataInfo => { + const { isLoading, isError, error, data: allData } = usePontusXAccountsMetadata(queryOptions) + if (isError) { + console.log('Failed to load Pontus-X account names', error) + } + return { + metadata: allData?.map.get(address), + isLoading, + isError, + } +} + +export const useSearchForPontusXAccountsByName = ( + network: Network, + nameFragment: string, + queryOptions: { enabled: boolean }, +): AccountNameSearchRuntimeResults => { + const { + isLoading: isMetadataLoading, + isError: isMetadataError, + error: metadataError, + data: namedAccounts, + } = usePontusXAccountsMetadata(queryOptions) + if (isMetadataError) { + console.log('Failed to load Pontus-X account names', metadataError) + } + + const textMatcher = + nameFragment && queryOptions.enabled + ? (account: AccountMetadata) => hasTextMatch(account.name, [nameFragment]) + : () => false + + const matches = + isMetadataLoading || isMetadataLoading + ? undefined + : namedAccounts?.list.filter(textMatcher).map( + (account): AccountNameSearchRuntimeMatch => ({ + network, + layer: Layer.pontusx, + address: account.address, + }), + ) + + const { + isLoading: areAccountsLoading, + isError: areAccountsError, + data: results, + } = useGetRuntimeAccountsAddresses(matches, { + enabled: queryOptions.enabled && !isMetadataLoading && !isMetadataError, + }) + + return { + isLoading: isMetadataLoading || areAccountsLoading, + isError: isMetadataError || areAccountsError, + results, + } +} diff --git a/src/app/hooks/__mocks__/useAccountMetadata.ts b/src/app/hooks/__mocks__/useAccountMetadata.ts new file mode 100644 index 000000000..7cacd25a0 --- /dev/null +++ b/src/app/hooks/__mocks__/useAccountMetadata.ts @@ -0,0 +1,2 @@ +export const useAccountMetadata = () => ({ loading: false }) +export const useSearchForAccountsByName = () => ({ isLoading: false, results: [] }) diff --git a/src/app/hooks/useAccountMetadata.ts b/src/app/hooks/useAccountMetadata.ts new file mode 100644 index 000000000..f101b5ec2 --- /dev/null +++ b/src/app/hooks/useAccountMetadata.ts @@ -0,0 +1,41 @@ +import { SearchScope } from '../../types/searchScope' +import { Layer } from '../../oasis-nexus/api' +import { usePontusXAccountMetadata, useSearchForPontusXAccountsByName } from '../data/pontusx-account-names' +import { AccountMetadataInfo, AccountNameSearchResults } from '../data/named-accounts' +import { useOasisAccountMetadata, useSearchForOasisAccountsByName } from '../data/oasis-account-names' +import { getOasisAddress } from '../utils/helpers' + +/** + * Find out the metadata for an account + * + * This is the entry point that should be used by the application, + * since this function also includes caching. + */ +export const useAccountMetadata = (scope: SearchScope, address: string): AccountMetadataInfo => { + const isPontusX = scope.layer === Layer.pontusx + const pontusXData = usePontusXAccountMetadata(address, { enabled: isPontusX }) + const oasisData = useOasisAccountMetadata(scope.network, scope.layer, getOasisAddress(address), { + enabled: !isPontusX, + }) + return isPontusX ? pontusXData : oasisData +} + +export const useSearchForAccountsByName = ( + scope: SearchScope, + nameFragment = '', +): AccountNameSearchResults => { + const isValidPontusXSearch = scope.layer === Layer.pontusx && !!nameFragment + const pontusXResults = useSearchForPontusXAccountsByName(scope.network, nameFragment, { + enabled: isValidPontusXSearch, + }) + const isValidOasisSearch = scope.layer !== Layer.pontusx && !!nameFragment + const oasisResults = useSearchForOasisAccountsByName(scope.network, scope.layer, nameFragment, { + enabled: isValidOasisSearch, + }) + return { + isLoading: + (isValidPontusXSearch && pontusXResults.isLoading) || (isValidOasisSearch && oasisResults.isLoading), + isError: (isValidPontusXSearch && pontusXResults.isError) || (isValidOasisSearch && oasisResults.isError), + results: [...(pontusXResults.results ?? []), ...(oasisResults.results ?? [])], + } +} diff --git a/src/app/pages/ConsensusAccountDetailsPage/ConsensusAccountDetailsCard.tsx b/src/app/pages/ConsensusAccountDetailsPage/ConsensusAccountDetailsCard.tsx index a571c27e0..c6b6daea6 100644 --- a/src/app/pages/ConsensusAccountDetailsPage/ConsensusAccountDetailsCard.tsx +++ b/src/app/pages/ConsensusAccountDetailsPage/ConsensusAccountDetailsCard.tsx @@ -13,18 +13,25 @@ type ConsensusAccountDetailsCardProps = { account: Account | undefined isError: boolean isLoading: boolean + highlightedPartOfName?: string | undefined } export const ConsensusAccountDetailsCard: FC = ({ account, isError, isLoading, + highlightedPartOfName, }) => { const { t } = useTranslation() return ( - + ) } diff --git a/src/app/pages/ConsensusAccountDetailsPage/ConsensusAccountDetailsView.tsx b/src/app/pages/ConsensusAccountDetailsPage/ConsensusAccountDetailsView.tsx index d6767350b..d3b35d115 100644 --- a/src/app/pages/ConsensusAccountDetailsPage/ConsensusAccountDetailsView.tsx +++ b/src/app/pages/ConsensusAccountDetailsPage/ConsensusAccountDetailsView.tsx @@ -13,6 +13,7 @@ export const ConsensusAccountDetailsView: FC = isLoading, showLayer, standalone, + highlightedPartOfName, }) => { const { t } = useTranslation() @@ -24,6 +25,7 @@ export const ConsensusAccountDetailsView: FC = account={account} showLayer={showLayer} standalone={standalone} + highlightedPartOfName={highlightedPartOfName} /> ) } diff --git a/src/app/pages/ConsensusAccountDetailsPage/index.tsx b/src/app/pages/ConsensusAccountDetailsPage/index.tsx index 3bf4ca875..c4fae03d8 100644 --- a/src/app/pages/ConsensusAccountDetailsPage/index.tsx +++ b/src/app/pages/ConsensusAccountDetailsPage/index.tsx @@ -18,7 +18,7 @@ export const ConsensusAccountDetailsPage: FC = () => { const { isMobile } = useScreenSize() const scope = useRequiredScopeParam() const { network } = scope - const { address } = useLoaderData() as AddressLoaderData + const { address, searchTerm } = useLoaderData() as AddressLoaderData const accountQuery = useGetConsensusAccountsAddress(network, address) const { isError, isLoading, data } = accountQuery const account = data?.data @@ -27,7 +27,12 @@ export const ConsensusAccountDetailsPage: FC = () => { return ( - + diff --git a/src/app/pages/RuntimeAccountDetailsPage/RuntimeAccountDetailsCard.tsx b/src/app/pages/RuntimeAccountDetailsPage/RuntimeAccountDetailsCard.tsx index a06321270..6a4ec9674 100644 --- a/src/app/pages/RuntimeAccountDetailsPage/RuntimeAccountDetailsCard.tsx +++ b/src/app/pages/RuntimeAccountDetailsPage/RuntimeAccountDetailsCard.tsx @@ -12,6 +12,7 @@ type RuntimeAccountDetailsProps = { account: RuntimeAccount | undefined token: EvmToken | undefined tokenPrices: AllTokenPrices + highlightedPartOfName?: string | undefined } export const RuntimeAccountDetailsCard: FC = ({ @@ -21,6 +22,7 @@ export const RuntimeAccountDetailsCard: FC = ({ account, token, tokenPrices, + highlightedPartOfName, }) => { const { t } = useTranslation() return ( @@ -36,6 +38,7 @@ export const RuntimeAccountDetailsCard: FC = ({ account={account} token={token} tokenPrices={tokenPrices} + highlightedPartOfName={highlightedPartOfName} /> ) diff --git a/src/app/pages/RuntimeAccountDetailsPage/RuntimeAccountDetailsView.tsx b/src/app/pages/RuntimeAccountDetailsPage/RuntimeAccountDetailsView.tsx index 8fc58c194..579aaa8fc 100644 --- a/src/app/pages/RuntimeAccountDetailsPage/RuntimeAccountDetailsView.tsx +++ b/src/app/pages/RuntimeAccountDetailsPage/RuntimeAccountDetailsView.tsx @@ -12,7 +12,8 @@ export const RuntimeAccountDetailsView: FC<{ token?: EvmToken tokenPrices: AllTokenPrices showLayer?: boolean -}> = ({ isLoading, isError, account, token, tokenPrices, showLayer }) => { + highlightedPartOfName?: string | undefined +}> = ({ isLoading, isError, account, token, tokenPrices, showLayer, highlightedPartOfName }) => { const { t } = useTranslation() return isError ? ( @@ -23,6 +24,7 @@ export const RuntimeAccountDetailsView: FC<{ isLoading={isLoading} tokenPrices={tokenPrices} showLayer={showLayer} + highlightedPartOfName={highlightedPartOfName} /> ) } diff --git a/src/app/pages/RuntimeAccountDetailsPage/index.tsx b/src/app/pages/RuntimeAccountDetailsPage/index.tsx index 33d6fb785..10e3400d3 100644 --- a/src/app/pages/RuntimeAccountDetailsPage/index.tsx +++ b/src/app/pages/RuntimeAccountDetailsPage/index.tsx @@ -32,7 +32,7 @@ export const RuntimeAccountDetailsPage: FC = () => { const { t } = useTranslation() const scope = useRequiredScopeParam() - const { address } = useLoaderData() as AddressLoaderData + const { address, searchTerm } = useLoaderData() as AddressLoaderData const { account, isLoading: isAccountLoading, isError } = useAccount(scope, address) const isContract = !!account?.evm_contract const { token, isLoading: isTokenLoading } = useTokenInfo(scope, address, isContract) @@ -62,6 +62,7 @@ export const RuntimeAccountDetailsPage: FC = () => { account={account} token={token} tokenPrices={tokenPrices} + highlightedPartOfName={searchTerm} /> = ({ searchTerm, searchResults, tokenPrices }) => { +}> = ({ searchParams, searchResults, tokenPrices }) => { const { t } = useTranslation() const [othersOpen, setOthersOpen] = useState(false) - useRedirectIfSingleResult(undefined, searchTerm, searchResults) + useRedirectIfSingleResult(undefined, searchParams, searchResults) const themes = getThemesForNetworks() const networkNames = getNetworkNames(t) + const { searchTerm } = searchParams if (fixedNetwork) { return ( diff --git a/src/app/pages/SearchResultsPage/ScopedSearchResultsView.tsx b/src/app/pages/SearchResultsPage/ScopedSearchResultsView.tsx index 755202e7e..27cb31e5f 100644 --- a/src/app/pages/SearchResultsPage/ScopedSearchResultsView.tsx +++ b/src/app/pages/SearchResultsPage/ScopedSearchResultsView.tsx @@ -15,13 +15,15 @@ import { NoResultsInScope } from './NoResults' import { AllTokenPrices } from '../../../coin-gecko/api' import { HideMoreResults, ShowMoreResults } from './notifications' import { useRedirectIfSingleResult } from './useRedirectIfSingleResult' +import { SearchParams } from '../../components/Search/search-utils' export const ScopedSearchResultsView: FC<{ wantedScope: SearchScope - searchTerm: string + searchParams: SearchParams searchResults: SearchResults + isPotentiallyIncomplete: boolean // Some of the searches failed, so we might not see everything // TODO: indicate this on the UI tokenPrices: AllTokenPrices -}> = ({ wantedScope, searchTerm, searchResults, tokenPrices }) => { +}> = ({ wantedScope, searchParams, searchResults, tokenPrices }) => { const { t } = useTranslation() const [othersOpen, setOthersOpen] = useState(false) const networkNames = getNetworkNames(t) @@ -32,7 +34,9 @@ export const ScopedSearchResultsView: FC<{ const otherResults = searchResults.filter(isNotInWantedScope) const notificationTheme = themes[otherResults.some(isOnMainnet) ? Network.mainnet : Network.testnet] - useRedirectIfSingleResult(wantedScope, searchTerm, searchResults) + useRedirectIfSingleResult(wantedScope, searchParams, searchResults) + + const { searchTerm } = searchParams return ( <> diff --git a/src/app/pages/SearchResultsPage/SearchResultsList.tsx b/src/app/pages/SearchResultsPage/SearchResultsList.tsx index 792c3f02f..e86871604 100644 --- a/src/app/pages/SearchResultsPage/SearchResultsList.tsx +++ b/src/app/pages/SearchResultsPage/SearchResultsList.tsx @@ -90,6 +90,7 @@ export const SearchResultsList: FC<{ isError={false} account={item as Account} showLayer={true} + highlightedPartOfName={searchTerm} /> ) : ( ) } @@ -115,6 +117,7 @@ export const SearchResultsList: FC<{ account={item} tokenPrices={tokenPrices} showLayer={true} + highlightedPartOfName={searchTerm} /> )} link={acc => RouteUtils.getAccountRoute(acc, acc.address_eth ?? acc.address)} diff --git a/src/app/pages/SearchResultsPage/SearchResultsView.tsx b/src/app/pages/SearchResultsPage/SearchResultsView.tsx index e97916a7f..7f15cfd38 100644 --- a/src/app/pages/SearchResultsPage/SearchResultsView.tsx +++ b/src/app/pages/SearchResultsPage/SearchResultsView.tsx @@ -10,14 +10,16 @@ import { ScopedSearchResultsView } from './ScopedSearchResultsView' import { AllTokenPrices } from '../../../coin-gecko/api' import { getFilterForLayer } from '../../../types/layers' import { useScreenSize } from '../../hooks/useScreensize' +import { SearchParams } from '../../components/Search/search-utils' export const SearchResultsView: FC<{ wantedScope?: SearchScope - searchTerm: string + searchParams: SearchParams searchResults: SearchResults isLoading: boolean + isPotentiallyIncomplete: boolean tokenPrices: AllTokenPrices -}> = ({ wantedScope, searchTerm, searchResults, isLoading, tokenPrices }) => { +}> = ({ wantedScope, searchParams, searchResults, isLoading, isPotentiallyIncomplete, tokenPrices }) => { const { isMobile } = useScreenSize() return ( @@ -29,14 +31,16 @@ export const SearchResultsView: FC<{ ) : wantedScope ? ( ) : ( )} diff --git a/src/app/pages/SearchResultsPage/__tests__/SearchResultsList.test.tsx b/src/app/pages/SearchResultsPage/__tests__/SearchResultsList.test.tsx index a4ed7119d..0928617d1 100644 --- a/src/app/pages/SearchResultsPage/__tests__/SearchResultsList.test.tsx +++ b/src/app/pages/SearchResultsPage/__tests__/SearchResultsList.test.tsx @@ -10,6 +10,8 @@ import { Network } from '../../../../types/network' import { SearchResultsList } from '../SearchResultsList' import { Ticker } from '../../../../types/ticker' +jest.mock('../../../hooks/useAccountMetadata') + describe('SearchResultsView', () => { beforeEach(() => { jest.useFakeTimers() diff --git a/src/app/pages/SearchResultsPage/hooks.ts b/src/app/pages/SearchResultsPage/hooks.ts index cd63fe89a..ce79d36a4 100644 --- a/src/app/pages/SearchResultsPage/hooks.ts +++ b/src/app/pages/SearchResultsPage/hooks.ts @@ -21,12 +21,13 @@ import { import { RouteUtils } from '../../utils/route-utils' import { SearchParams } from '../../components/Search/search-utils' import { SearchScope } from '../../../types/searchScope' +import { useSearchForAccountsByName } from '../../hooks/useAccountMetadata' function isDefined(item: T): item is NonNullable { return item != null } -export type ConditionalResults = { isLoading: boolean; results: T[] } +export type ConditionalResults = { isLoading: boolean; isError?: boolean; results: T[] } type SearchResultItemCore = HasScope & { resultType: 'block' | 'transaction' | 'account' | 'contract' | 'token' | 'proposal' @@ -193,6 +194,7 @@ export function useRuntimeTokenConditionally( return { isLoading: queries.some(query => query.isInitialLoading), + isError: queries.some(query => query.isError), results: queries.map(query => query.data?.data).filter(isDefined), } } @@ -206,6 +208,25 @@ export function useNetworkProposalsConditionally( ) return { isLoading: queries.some(query => query.isInitialLoading), + isError: queries.some(query => query.isError), + results: queries + .map(query => query.results) + .filter(isDefined) + .flat(), + } +} + +export function useNamedAccountConditionally( + currentScope: SearchScope | undefined, + nameFragment: string | undefined, +): ConditionalResults { + const queries = RouteUtils.getVisibleScopes(currentScope).map(scope => + // eslint-disable-next-line react-hooks/rules-of-hooks + useSearchForAccountsByName(scope, nameFragment), + ) + return { + isLoading: queries.some(query => query.isLoading), + isError: queries.some(query => query.isError), results: queries .map(query => query.results) .filter(isDefined) @@ -222,16 +243,19 @@ export const useSearch = (currentScope: SearchScope | undefined, q: SearchParams oasisRuntimeAccount: useRuntimeAccountConditionally(currentScope, q.consensusAccount), // TODO: remove evmBech32Account and use evmAccount when API is ready evmBech32Account: useRuntimeAccountConditionally(currentScope, q.evmBech32Account), + accountsByName: useNamedAccountConditionally(currentScope, q.accountNameFragment), tokens: useRuntimeTokenConditionally(currentScope, q.evmTokenNameFragment), proposals: useNetworkProposalsConditionally(q.networkProposalNameFragment), } const isLoading = Object.values(queries).some(query => query.isLoading) + const hasErrors = Object.values(queries).some(query => query.isError) const blocks = [...queries.blockHeight.results, ...queries.blockHash.results] const transactions = queries.txHash.results || [] const accounts = [ ...(queries.oasisConsensusAccount.results || []), ...(queries.oasisRuntimeAccount.results || []), ...(queries.evmBech32Account.results || []), + ...(queries.accountsByName.results || []), ].filter(isAccountNonEmpty) const tokens = queries.tokens.results .map(l => l.evm_tokens) @@ -256,6 +280,7 @@ export const useSearch = (currentScope: SearchScope | undefined, q: SearchParams ] return { isLoading, + hasErrors, results, } } diff --git a/src/app/pages/SearchResultsPage/index.tsx b/src/app/pages/SearchResultsPage/index.tsx index 084ea2d96..d55aead4a 100644 --- a/src/app/pages/SearchResultsPage/index.tsx +++ b/src/app/pages/SearchResultsPage/index.tsx @@ -9,16 +9,17 @@ import { getFiatCurrencyForScope } from '../../../config' export const SearchResultsPage: FC = () => { const searchParams = useParamSearch() const scope = useScopeParam() - const { results, isLoading } = useSearch(scope, searchParams) + const { results, isLoading, hasErrors } = useSearch(scope, searchParams) const tokenPrices = useAllTokenPrices(getFiatCurrencyForScope(scope)) return ( ) diff --git a/src/app/pages/SearchResultsPage/useRedirectIfSingleResult.ts b/src/app/pages/SearchResultsPage/useRedirectIfSingleResult.ts index 3c21caffe..f8f41e43f 100644 --- a/src/app/pages/SearchResultsPage/useRedirectIfSingleResult.ts +++ b/src/app/pages/SearchResultsPage/useRedirectIfSingleResult.ts @@ -5,16 +5,19 @@ import { RouteUtils } from '../../utils/route-utils' import { isItemInScope, SearchScope } from '../../../types/searchScope' import { Network } from '../../../types/network' import { exhaustedTypeWarning } from '../../../types/errors' -import { RuntimeAccount } from '../../../oasis-nexus/api' +import { Account, RuntimeAccount } from '../../../oasis-nexus/api' +import { SearchParams } from '../../components/Search/search-utils' /** If search only finds one result then redirect to it */ export function useRedirectIfSingleResult( scope: SearchScope | undefined, - searchTerm: string, + searchParams: SearchParams, results: SearchResults, ) { const navigate = useNavigate() + const { searchTerm, accountNameFragment, evmAccount, consensusAccount } = searchParams + let shouldRedirect = results.length === 1 if (shouldRedirect) { @@ -35,6 +38,16 @@ export function useRedirectIfSingleResult( break case 'account': redirectTo = RouteUtils.getAccountRoute(item, (item as RuntimeAccount).address_eth ?? item.address) + if ( + accountNameFragment && // Is there anything to highlight? + !( + (!!evmAccount && (item as RuntimeAccount).address_eth === evmAccount) || // Did we find this searching for evm address + // Did we find this searching for oasis address + (!!consensusAccount && (item as Account | RuntimeAccount).address === consensusAccount) + ) // If we found this account based on address, then we don't want to highlight that. + ) { + redirectTo += `?q=${accountNameFragment}` + } break case 'contract': redirectTo = RouteUtils.getAccountRoute(item, item.address_eth ?? item.address) diff --git a/src/oasis-nexus/api.ts b/src/oasis-nexus/api.ts index 406364940..48a26b745 100644 --- a/src/oasis-nexus/api.ts +++ b/src/oasis-nexus/api.ts @@ -12,6 +12,7 @@ import { HumanReadableErrorResponse, Layer, NotFoundErrorResponse, + Runtime, RuntimeAccount, RuntimeEventType, } from './generated/api' @@ -110,17 +111,20 @@ declare module './generated/api' { export const isAccountEmpty = (account: RuntimeAccount | Account) => { if (account.layer === Layer.consensus) { - const { available, size, nonce, debonding_delegations_balance, delegations_balance, escrow, total } = - account as Account - return ( - available === '0' && - debonding_delegations_balance === '0' && - delegations_balance === '0' && - escrow === '0' && - total === '0' && - nonce === 0 && - size === 'XXS' - ) + // TODO: find a sane way to recognize an important consensus account. + // The heuristics below is clearly insufficient, because it would indicate even named accounts like "Governance Escrow" to be empty. + return false + // const { available, size, nonce, debonding_delegations_balance, delegations_balance, escrow, total } = + // account as Account + // return ( + // available === '0' && + // debonding_delegations_balance === '0' && + // delegations_balance === '0' && + // escrow === '0' && + // total === '0' && + // nonce === 0 && + // size === 'XXS' + // ) // TODO: we should also check the number of transactions, where it becomes available } else { const { balances, evm_balances, stats } = account as RuntimeAccount @@ -304,6 +308,10 @@ export const useGetConsensusAccountsAddress: typeof generated.useGetConsensusAcc const ticker = getTokensForScope({ network, layer: Layer.consensus })[0].ticker return generated.useGetConsensusAccountsAddress(network, address, { ...options, + query: { + ...(options?.query ?? {}), + enabled: !!address && (options?.query?.enabled ?? true), + }, request: { ...options?.request, transformResponse: [ @@ -341,6 +349,7 @@ export const useGetRuntimeAccountsAddress: typeof generated.useGetRuntimeAccount address, options, ) => { + // console.log('Should we get', runtime, '/', address, '?', options?.query?.enabled) const oasisAddress = getOasisAddressOrNull(address) const query = generated.useGetRuntimeAccountsAddress(network, runtime, oasisAddress!, { @@ -433,6 +442,62 @@ export const useGetRuntimeAccountsAddress: typeof generated.useGetRuntimeAccount } as any } +const MAX_LOADED_ADDRESSES = 100 +const MASS_LOAD_INDEXES = [...Array(MAX_LOADED_ADDRESSES).keys()] + +type RuntimeTarget = { + network: Network + layer: Runtime + address: string +} + +export const useGetRuntimeAccountsAddresses = ( + targets: RuntimeTarget[] | undefined = [], + queryOptions: { enabled: boolean }, +) => { + const queries = MASS_LOAD_INDEXES.map((i): RuntimeTarget | undefined => targets[i]).map(target => + // The number of iterations is constant here, so we will always call the hook the same number. + // eslint-disable-next-line react-hooks/rules-of-hooks + useGetRuntimeAccountsAddress( + target?.network ?? Network.mainnet, + target?.layer ?? Layer.emerald, + target?.address ?? '', + { + query: { enabled: queryOptions.enabled && !!target?.address }, + }, + ), + ) + return { + isLoading: !!targets?.length && queries.some(query => query.isLoading && query.fetchStatus !== 'idle'), + isError: queries.some(query => query.isError) || targets?.length > MAX_LOADED_ADDRESSES, + data: queries.map(query => query.data?.data).filter(account => !!account) as RuntimeAccount[], + } +} + +type ConsensusTarget = { + network: Network + address: string +} + +export const useGetConsensusAccountsAddresses = ( + targets: ConsensusTarget[] | undefined = [], + queryOptions: { enabled: boolean }, +) => { + const queries = MASS_LOAD_INDEXES.map((i): ConsensusTarget | undefined => targets[i]).map(target => + // The number of iterations is constant here, so we will always call the hook the same number. + // eslint-disable-next-line react-hooks/rules-of-hooks + useGetConsensusAccountsAddress(target?.network ?? Network.mainnet, target?.address ?? '', { + query: { enabled: queryOptions.enabled && !!target?.address }, + }), + ) + + return { + isLoading: !!targets?.length && queries.some(query => query.isLoading && query.fetchStatus !== 'idle'), + isError: queries.some(query => query.isError) || targets?.length > MAX_LOADED_ADDRESSES, + data: queries.map(query => query.data?.data).filter(account => !!account) as generated.Account[], + } +} + export function useGetConsensusBlockByHeight( network: Network, blockHeight: number, @@ -866,7 +931,7 @@ export const useGetConsensusProposalsProposalId: typeof generated.useGetConsensu export const useGetConsensusProposalsByName = (network: Network, nameFragment: string | undefined) => { const query = useGetConsensusProposals(network, {}, { query: { enabled: !!nameFragment } }) - const { isLoading, isInitialLoading, data, status, error } = query + const { isError, isLoading, isInitialLoading, data, status, error } = query const textMatcher = nameFragment ? (proposal: generated.Proposal): boolean => !!proposal.handler && proposal.handler?.includes(nameFragment) @@ -874,6 +939,7 @@ export const useGetConsensusProposalsByName = (network: Network, nameFragment: s const results = data ? query.data.data.proposals.filter(textMatcher) : undefined return { isLoading, + isError, isInitialLoading, status, error,