diff --git a/.changelog/1398.feature.md b/.changelog/1398.feature.md new file mode 100644 index 0000000000..0dff6d6a54 --- /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 665545c49f..8bac0a4062 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<{ @@ -23,7 +27,7 @@ const WithTypographyAndLink: FC<{ ...(mobile ? { maxWidth: '100%', - overflowX: 'hidden', + overflow: 'hidden', } : {}), }} @@ -44,6 +48,11 @@ interface Props { */ alwaysTrim?: boolean + /** + * What part of the name should be highlighted (if any) + */ + highlightedPartOfName?: string | undefined + /** * Any extra tooltips to display * @@ -52,8 +61,17 @@ interface Props { extraTooltip?: ReactNode } -export const AccountLink: FC = ({ scope, address, alwaysTrim, extraTooltip }) => { +export const AccountLink: FC = ({ + scope, + address, + alwaysTrim, + highlightedPartOfName, + extraTooltip, +}) => { const { isTablet } = useScreenSize() + const { metadata: accountMetadata } = useAccountMetadata(scope, address) + const accountName = accountMetadata?.name // TODO: we should also use the description + const to = RouteUtils.getAccountRoute(scope, address) const tooltipPostfix = extraTooltip ? ( @@ -65,31 +83,61 @@ export const AccountLink: FC = ({ scope, address, alwaysTrim, extraToolti // Are we in a table? if (alwaysTrim) { - // In a table, we only ever want a short line + // In a table, we only ever want one short line return ( - {trimLongString(address, 6, 6)} + + {accountName} + {address} + {tooltipPostfix} + + ) : ( + address + ) + } + > + {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 line adaptively shortened to fill available space return ( - + <> + + + ) } diff --git a/src/app/components/Account/index.tsx b/src/app/components/Account/index.tsx index 3ab89c2ba6..bc275873ea 100644 --- a/src/app/components/Account/index.tsx +++ b/src/app/components/Account/index.tsx @@ -30,9 +30,17 @@ type AccountProps = { isLoading: boolean tokenPrices: AllTokenPrices showLayer?: boolean + highlightedPartOfName: string | undefined } -export const Account: FC = ({ account, token, isLoading, tokenPrices, showLayer }) => { +export const Account: FC = ({ + account, + token, + isLoading, + tokenPrices, + showLayer, + highlightedPartOfName, +}) => { const { t } = useTranslation() const { isMobile } = useScreenSize() const address = account ? account.address_eth ?? account.address : undefined @@ -67,7 +75,7 @@ export const Account: FC = ({ account, token, isLoading, tokenPric
- +
diff --git a/src/app/components/Search/search-utils.ts b/src/app/components/Search/search-utils.ts index bebe55a4ac..6cc4fd520a 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 0000000000..da5f12417e --- /dev/null +++ b/src/app/data/named-accounts.ts @@ -0,0 +1,18 @@ +export type AccountMetadata = { + name?: string + description?: string +} + +export type AccountMetadataInfo = { + metadata?: AccountMetadata + loading: boolean +} + +export type AccountMap = Map + +export type AccountEntry = { address: string } & AccountMetadata + +export type AccountData = { + map: AccountMap + list: AccountEntry[] +} diff --git a/src/app/data/oasis-account-names.ts b/src/app/data/oasis-account-names.ts new file mode 100644 index 0000000000..77c5feaaf6 --- /dev/null +++ b/src/app/data/oasis-account-names.ts @@ -0,0 +1,120 @@ +import axios from 'axios' +import { useQuery } from '@tanstack/react-query' +import { Layer } from '../../oasis-nexus/api' +import { Network } from '../../types/network' +import { findTextMatch } from '../components/HighlightedText/text-matching' +import * as process from 'process' +import { AccountData, AccountEntry, AccountMap, AccountMetadata, AccountMetadataInfo } from './named-accounts' + +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 = (network: Network, layer: Layer) => + new Promise((resolve, reject) => { + const url = dataSources[network][layer] + if (!url) { + reject('No data available for this layer') + } else { + axios.get(url).then(response => { + if (response.status !== 200) reject("Couldn't load names") + if (!response.data) reject("Couldn't load names") + // console.log('Response data is', response.data) + const map: AccountMap = new Map() + const list: AccountEntry[] = [] + Array.from(response.data).forEach((entry: any) => { + const address = entry.Address + const metadata: AccountMetadata = { + name: entry.Name, + description: entry.Description, + } + // Register the metadata in its native form + list.push({ + address, + ...metadata, + }) + map.set(address, metadata) + }) + resolve({ + map, + list, + }) + }, reject) + } + }) + +export const useOasisAccountsMetadata = (network: Network, layer: Layer, enabled: boolean) => { + return useQuery(['oasisAccounts', network, layer], () => getOasisAccountsMetadata(network, layer), { + enabled, + staleTime: Infinity, + }) +} + +export const useOasisAccountMetadata = ( + network: Network, + layer: Layer, + address: string, + enabled: boolean, +): AccountMetadataInfo => { + // When running jest tests, we don't want to load from Pontus-X. + if (process.env.NODE_ENV === 'test') { + return { + metadata: { + name: undefined, + }, + loading: false, + } + } + // This is not a condition that can change while the app is running, so it's OK. + // eslint-disable-next-line react-hooks/rules-of-hooks + const { isLoading, error, data: allData } = useOasisAccountsMetadata(network, layer, enabled) + if (error) { + console.log('Failed to load Oasis account metadata', error) + } + return { + metadata: allData?.map.get(address), + loading: isLoading, + } +} + +export const useSearchForOasisAccountsByName = ( + network: Network, + layer: Layer, + nameFragment: string, + enabled: boolean, +) => { + const { isLoading, error, data: allData } = useOasisAccountsMetadata(network, layer, enabled) + if (error) { + console.log('Failed to load Oasis account metadata', error) + } + + const textMatcher = + nameFragment && enabled + ? (entry: AccountEntry): boolean => { + return !!findTextMatch(entry.name, [nameFragment]) + } + : () => false + return { + results: (allData?.list || []).filter(textMatcher).map(entry => ({ + network, + layer, + address: entry.address, + })), + isLoading, + } +} diff --git a/src/app/data/pontusx-account-names.ts b/src/app/data/pontusx-account-names.ts new file mode 100644 index 0000000000..b12d00dc85 --- /dev/null +++ b/src/app/data/pontusx-account-names.ts @@ -0,0 +1,84 @@ +import axios from 'axios' +import { useQuery } from '@tanstack/react-query' +import { Layer } from '../../oasis-nexus/api' +import { Network } from '../../types/network' +import { findTextMatch } from '../components/HighlightedText/text-matching' +import * as process from 'process' +import { AccountData, AccountEntry, AccountMap, AccountMetadataInfo } from './named-accounts' + +const DATA_SOURCE_URL = 'https://raw.githubusercontent.com/deltaDAO/mvg-portal/main/pontusxAddresses.json' + +const getPontusXAccountsMetadata = () => + new Promise((resolve, reject) => { + axios.get(DATA_SOURCE_URL).then(response => { + if (response.status !== 200) reject("Couldn't load names") + if (!response.data) reject("Couldn't load names") + const map: AccountMap = new Map() + const list: AccountEntry[] = [] + Object.entries(response.data).forEach(([address, name]) => { + map.set(address, { name: name as string }) + const normalizedEntry: AccountEntry = { + name: name as string, + address, + } + list.push(normalizedEntry) + }) + resolve({ + map, + list, + }) + }, reject) + }) + +export const usePontusXAccountsMetadata = (enabled: boolean) => { + return useQuery(['pontusXNames'], getPontusXAccountsMetadata, { + enabled, + staleTime: Infinity, + }) +} + +export const usePontusXAccountMetadata = (address: string, enabled: boolean): AccountMetadataInfo => { + // When running jest tests, we don't want to load from Pontus-X. + if (process.env.NODE_ENV === 'test') { + return { + metadata: undefined, + loading: false, + } + } + // This is not a condition that can change while the app is running, so it's OK. + // eslint-disable-next-line react-hooks/rules-of-hooks + const { isLoading, error, data: allData } = usePontusXAccountsMetadata(enabled) + if (error) { + console.log('Failed to load Pontus-X account names', error) + } + return { + metadata: allData?.map.get(address), + loading: isLoading, + } +} + +export const useSearchForPontusXAccountsByName = ( + network: Network, + nameFragment: string, + enabled: boolean, +) => { + const { isLoading, error, data: allNames } = usePontusXAccountsMetadata(enabled) + if (error) { + console.log('Failed to load Pontus-X account names', error) + } + + const textMatcher = + nameFragment && enabled + ? (entry: AccountEntry): boolean => { + return !!findTextMatch(entry.name, [nameFragment]) + } + : () => false + return { + results: (allNames?.list || []).filter(textMatcher).map(entry => ({ + network, + layer: Layer.pontusx, + address: entry.address, + })), + isLoading, + } +} diff --git a/src/app/hooks/useAccountMetadata.ts b/src/app/hooks/useAccountMetadata.ts new file mode 100644 index 0000000000..bffc8713ae --- /dev/null +++ b/src/app/hooks/useAccountMetadata.ts @@ -0,0 +1,36 @@ +import { SearchScope } from '../../types/searchScope' +import { Layer } from '../../oasis-nexus/api' +import { usePontusXAccountMetadata, useSearchForPontusXAccountsByName } from '../data/pontusx-account-names' +import { AccountMetadataInfo } 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, isPontusX) + const oasisData = useOasisAccountMetadata(scope.network, scope.layer, getOasisAddress(address), !isPontusX) + return isPontusX ? pontusXData : oasisData +} + +export const useSearchForAccountsByName = (scope: SearchScope, nameFragment = '') => { + const isValidPontusXSearch = scope.layer === Layer.pontusx && !!nameFragment + const pontusXResults = useSearchForPontusXAccountsByName(scope.network, nameFragment, isValidPontusXSearch) + const isValidOasisSearch = scope.layer !== Layer.pontusx && !!nameFragment + const oasisResults = useSearchForOasisAccountsByName( + scope.network, + scope.layer, + nameFragment, + isValidOasisSearch, + ) + return { + isLoading: + (isValidPontusXSearch && pontusXResults.isLoading) || (isValidOasisSearch && oasisResults.isLoading), + results: [...pontusXResults.results, ...oasisResults.results], + } +} diff --git a/src/app/pages/ConsensusAccountDetailsPage/DeferredConsensusAccountDetails.tsx b/src/app/pages/ConsensusAccountDetailsPage/DeferredConsensusAccountDetails.tsx new file mode 100644 index 0000000000..5720b4ecd7 --- /dev/null +++ b/src/app/pages/ConsensusAccountDetailsPage/DeferredConsensusAccountDetails.tsx @@ -0,0 +1,21 @@ +import { FC } from 'react' +import { AllTokenPrices } from '../../../coin-gecko/api' +import { Network } from '../../../types/network' + +/** + * Load and display details of a RuntimeAccount + */ +export const DeferredConsensusAccountDetails: FC<{ + network: Network + address: string + tokenPrices: AllTokenPrices + highlightedPartOfName: string | undefined + showLayer?: boolean +}> = () => + // { + // network, address, tokenPrices, highlightedPartOfName, showLayer + // }, + { + // TODO: load and display consensus account details when API and component becomes available + return null + } diff --git a/src/app/pages/RuntimeAccountDetailsPage/AccountDetailsCard.tsx b/src/app/pages/RuntimeAccountDetailsPage/AccountDetailsCard.tsx index 18c57866db..ca4f98625e 100644 --- a/src/app/pages/RuntimeAccountDetailsPage/AccountDetailsCard.tsx +++ b/src/app/pages/RuntimeAccountDetailsPage/AccountDetailsCard.tsx @@ -12,6 +12,7 @@ type AccountDetailsProps = { account: RuntimeAccount | undefined token: EvmToken | undefined tokenPrices: AllTokenPrices + highlightedPartOfName?: string | undefined } export const AccountDetailsCard: FC = ({ @@ -21,6 +22,7 @@ export const AccountDetailsCard: FC = ({ account, token, tokenPrices, + highlightedPartOfName, }) => { const { t } = useTranslation() return ( @@ -36,6 +38,7 @@ export const AccountDetailsCard: FC = ({ account={account} token={token} tokenPrices={tokenPrices} + highlightedPartOfName={highlightedPartOfName} /> ) diff --git a/src/app/pages/RuntimeAccountDetailsPage/AccountDetailsView.tsx b/src/app/pages/RuntimeAccountDetailsPage/AccountDetailsView.tsx index 0c9f702259..4e94e2a4d0 100644 --- a/src/app/pages/RuntimeAccountDetailsPage/AccountDetailsView.tsx +++ b/src/app/pages/RuntimeAccountDetailsPage/AccountDetailsView.tsx @@ -12,7 +12,8 @@ export const AccountDetailsView: 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 AccountDetailsView: FC<{ isLoading={isLoading} tokenPrices={tokenPrices} showLayer={showLayer} + highlightedPartOfName={highlightedPartOfName} /> ) } diff --git a/src/app/pages/RuntimeAccountDetailsPage/DeferredRuntimeAccountDetails.tsx b/src/app/pages/RuntimeAccountDetailsPage/DeferredRuntimeAccountDetails.tsx new file mode 100644 index 0000000000..882f742cc0 --- /dev/null +++ b/src/app/pages/RuntimeAccountDetailsPage/DeferredRuntimeAccountDetails.tsx @@ -0,0 +1,29 @@ +import { FC } from 'react' +import { Runtime, useGetRuntimeAccountsAddress } from '../../../oasis-nexus/api' +import { AllTokenPrices } from '../../../coin-gecko/api' +import { AccountDetailsView } from './AccountDetailsView' +import { Network } from '../../../types/network' + +/** + * Load and display details of a RuntimeAccount + */ +export const DeferredRuntimeAccountDetails: FC<{ + network: Network + layer: Runtime + address: string + tokenPrices: AllTokenPrices + highlightedPartOfName: string | undefined + showLayer?: boolean +}> = ({ network, layer, address, tokenPrices, highlightedPartOfName, showLayer }) => { + const { data, isLoading, isError } = useGetRuntimeAccountsAddress(network, layer, address) + return ( + + ) +} diff --git a/src/app/pages/RuntimeAccountDetailsPage/index.tsx b/src/app/pages/RuntimeAccountDetailsPage/index.tsx index b6c50e28d7..8d57ecd013 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} /> + item.resultType === 'accountAddress', + )} + resultComponent={item => + item.layer === Layer.consensus ? ( + + ) : ( + + ) + } + link={acc => RouteUtils.getAccountRoute(acc, acc.address)} + linkLabel={t('search.results.accounts.viewLink')} + /> + item.resultType === 'contract')} diff --git a/src/app/pages/SearchResultsPage/hooks.ts b/src/app/pages/SearchResultsPage/hooks.ts index 395a96cbfb..5099614e24 100644 --- a/src/app/pages/SearchResultsPage/hooks.ts +++ b/src/app/pages/SearchResultsPage/hooks.ts @@ -19,6 +19,7 @@ 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 @@ -27,7 +28,7 @@ function isDefined(item: T): item is NonNullable { export type ConditionalResults = { isLoading: boolean; results: T[] } type SearchResultItemCore = HasScope & { - resultType: 'block' | 'transaction' | 'account' | 'contract' | 'token' | 'proposal' + resultType: 'block' | 'transaction' | 'account' | 'accountAddress' | 'contract' | 'token' | 'proposal' } export type BlockResult = SearchResultItemCore & RuntimeBlock & { resultType: 'block' } @@ -36,6 +37,10 @@ export type TransactionResult = SearchResultItemCore & RuntimeTransaction & { re export type AccountResult = SearchResultItemCore & RuntimeAccount & { resultType: 'account' } +export type AccountAddressResult = SearchResultItemCore & { address: string } & { + resultType: 'accountAddress' +} + export type ContractResult = SearchResultItemCore & RuntimeAccount & { resultType: 'contract' } export type TokenResult = SearchResultItemCore & EvmToken & { resultType: 'token' } @@ -46,6 +51,7 @@ export type SearchResultItem = | BlockResult | TransactionResult | AccountResult + | AccountAddressResult | ContractResult | TokenResult | ProposalResult @@ -193,6 +199,25 @@ export function useNetworkProposalsConditionally( } } +type AccountAddressInfo = Pick + +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), + results: queries + .map(query => query.results) + .filter(isDefined) + .flat(), + } +} + export const useSearch = (currentScope: SearchScope | undefined, q: SearchParams) => { const queries = { blockHeight: useBlocksByHeightConditionally(currentScope, q.blockHeight), @@ -201,6 +226,7 @@ export const useSearch = (currentScope: SearchScope | undefined, q: SearchParams oasisAccount: 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), } @@ -211,6 +237,7 @@ export const useSearch = (currentScope: SearchScope | undefined, q: SearchParams ...(queries.oasisAccount.results || []), ...(queries.evmBech32Account.results || []), ].filter(isAccountNonEmpty) + const accountAddresses = queries.accountsByName.results || [] const tokens = queries.tokens.results .map(l => l.evm_tokens) .flat() @@ -228,6 +255,9 @@ export const useSearch = (currentScope: SearchScope | undefined, q: SearchParams ...accounts .filter(account => account.evm_contract) .map((account): ContractResult => ({ ...account, resultType: 'contract' })), + ...accountAddresses.map( + (account): AccountAddressResult => ({ ...account, resultType: 'accountAddress' }), + ), ...tokens.map((token): TokenResult => ({ ...token, resultType: 'token' })), ...proposals.map((proposal): ProposalResult => ({ ...proposal, resultType: 'proposal' })), ] diff --git a/src/app/pages/SearchResultsPage/useRedirectIfSingleResult.ts b/src/app/pages/SearchResultsPage/useRedirectIfSingleResult.ts index ebcb816b8c..e3e704e029 100644 --- a/src/app/pages/SearchResultsPage/useRedirectIfSingleResult.ts +++ b/src/app/pages/SearchResultsPage/useRedirectIfSingleResult.ts @@ -35,6 +35,9 @@ export function useRedirectIfSingleResult( case 'account': redirectTo = RouteUtils.getAccountRoute(item, item.address_eth ?? item.address) break + case 'accountAddress': + redirectTo = `${RouteUtils.getAccountRoute(item, item.address)}?q=${searchTerm}` + break case 'contract': redirectTo = RouteUtils.getAccountRoute(item, item.address_eth ?? item.address) break