Skip to content

Commit

Permalink
Implement searching for account names
Browse files Browse the repository at this point in the history
  • Loading branch information
csillag committed Feb 21, 2024
1 parent 6ca82f4 commit 4b72f48
Show file tree
Hide file tree
Showing 12 changed files with 159 additions and 14 deletions.
9 changes: 6 additions & 3 deletions src/app/components/Account/AccountLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ import { trimLongString } from '../../utils/trimLongString'
import Tooltip from '@mui/material/Tooltip'
import { tooltipDelay } from '../../../styles/theme'
import Box from '@mui/material/Box'
import { HighlightedText } from '../HighlightedText'

export const AccountLink: FC<{
scope: SearchScope
address: string
alwaysTrim?: boolean
plain?: boolean
}> = ({ scope, address, alwaysTrim, plain }) => {
highlightedPartOfName?: string | undefined
}> = ({ scope, address, alwaysTrim, plain, highlightedPartOfName }) => {
const { isTablet } = useScreenSize()
const { name: accountName } = useAccountName(scope, address)
const to = RouteUtils.getAccountRoute(scope, address)
Expand All @@ -34,10 +36,11 @@ export const AccountLink: FC<{
enterDelay={tooltipDelay}
enterNextDelay={tooltipDelay}
>
{/* TODO: we should focus on the part which has the match */}
<span>{trimLongString(accountName, 12, 0)}</span>
</Tooltip>
) : (
accountName
<HighlightedText text={accountName} pattern={highlightedPartOfName} />
)}
</Box>
) : undefined
Expand All @@ -61,7 +64,7 @@ export const AccountLink: FC<{
)
) : // We can have a long line
accountName ? (
`${accountName} (${address})`
<HighlightedText text={`${accountName} (${address})`} pattern={highlightedPartOfName} />
) : (
address
)
Expand Down
12 changes: 10 additions & 2 deletions src/app/components/Account/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,17 @@ type AccountProps = {
isLoading: boolean
tokenPrices: AllTokenPrices
showLayer?: boolean
highlightedPartOfName: string | undefined
}

export const Account: FC<AccountProps> = ({ account, token, isLoading, tokenPrices, showLayer }) => {
export const Account: FC<AccountProps> = ({
account,
token,
isLoading,
tokenPrices,
showLayer,
highlightedPartOfName,
}) => {
const { t } = useTranslation()
const { isMobile } = useScreenSize()
const address = account ? account.address_eth ?? account.address : undefined
Expand Down Expand Up @@ -67,7 +75,7 @@ export const Account: FC<AccountProps> = ({ account, token, isLoading, tokenPric
<AccountAvatar address={account.address} />
</StyledListTitleWithAvatar>
<dd>
<AccountLink scope={account} address={address!} />
<AccountLink scope={account} address={address!} highlightedPartOfName={highlightedPartOfName} />
<CopyToClipboard value={address!} />
</dd>

Expand Down
5 changes: 5 additions & 0 deletions src/app/components/Search/search-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,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) {
Expand Down
56 changes: 51 additions & 5 deletions src/app/data/pontusx-account-names.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,41 @@
import axios from 'axios'
import { useQuery } from '@tanstack/react-query'
import type { AccountNameInfo } from '../hooks/useAccountName'
import { Layer } from '../../oasis-nexus/api'
import { Network } from '../../types/network'
import { findTextMatch } from '../components/HighlightedText/text-matching'

const DATA_SOURCE_URL = 'https://raw.githubusercontent.com/deltaDAO/mvg-portal/main/pontusxAddresses.json'

type AccountMap = Map<string, string>
type AccountEntry = {
name: string
address: string
}
type AccountData = {
map: AccountMap
list: AccountEntry[]
}

const getPontusXAccountNames = () =>
new Promise<AccountMap>((resolve, reject) => {
new Promise<AccountData>((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 result = new Map()
const map = new Map()
const list: AccountEntry[] = []
Object.entries(response.data).forEach(([address, name]) => {
result.set(address, name)
map.set(address, name)
const normalizedEntry: AccountEntry = {
name: name as string,
address,
}
list.push(normalizedEntry)
})
resolve({
map,
list,
})
resolve(result)
}, reject)
})

Expand All @@ -32,7 +52,33 @@ export const usePontusXAccountName = (address: string, enabled: boolean): Accoun
console.log('Failed to load Pontus-X account names', error)
}
return {
name: allNames?.get(address),
name: allNames?.map.get(address),
loading: isLoading,
}
}

export const useSearchForPontusXAccountsByName = (
network: Network,
nameFragment: string,
enabled: boolean,
) => {
const { isLoading, error, data: allNames } = usePontusXAccountNames(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,
}
}
13 changes: 12 additions & 1 deletion src/app/hooks/useAccountName.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { SearchScope } from '../../types/searchScope'
import Chance from 'chance'
import { Layer } from '../../oasis-nexus/api'
import { usePontusXAccountName } from '../data/pontusx-account-names'
import { usePontusXAccountName, useSearchForPontusXAccountsByName } from '../data/pontusx-account-names'

const NO_MATCH = '__no_match__'

Expand Down Expand Up @@ -59,3 +59,14 @@ export const useAccountName = (scope: SearchScope, address: string, dropCache =
loading: false,
}
}

export const useSearchForAccountsByName = (scope: SearchScope, nameFragment = '') => {
const isValidPontusXSearch = scope.layer === Layer.pontusx && !!nameFragment
const pontusXResults = useSearchForPontusXAccountsByName(scope.network, nameFragment, isValidPontusXSearch)
return isValidPontusXSearch
? pontusXResults
: {
isLoading: false,
results: [],
}
}
3 changes: 3 additions & 0 deletions src/app/pages/AccountDetailsPage/AccountDetailsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type AccountDetailsProps = {
account: RuntimeAccount | undefined
token: EvmToken | undefined
tokenPrices: AllTokenPrices
highlightedPartOfName?: string | undefined
}

export const AccountDetailsCard: FC<AccountDetailsProps> = ({
Expand All @@ -21,6 +22,7 @@ export const AccountDetailsCard: FC<AccountDetailsProps> = ({
account,
token,
tokenPrices,
highlightedPartOfName,
}) => {
const { t } = useTranslation()
return (
Expand All @@ -35,6 +37,7 @@ export const AccountDetailsCard: FC<AccountDetailsProps> = ({
account={account}
token={token}
tokenPrices={tokenPrices}
highlightedPartOfName={highlightedPartOfName}
/>
</SubPageCard>
)
Expand Down
4 changes: 3 additions & 1 deletion src/app/pages/AccountDetailsPage/AccountDetailsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? (
<CardEmptyState label={t('account.cantLoadDetails')} />
Expand All @@ -23,6 +24,7 @@ export const AccountDetailsView: FC<{
isLoading={isLoading}
tokenPrices={tokenPrices}
showLayer={showLayer}
highlightedPartOfName={highlightedPartOfName}
/>
)
}
24 changes: 24 additions & 0 deletions src/app/pages/AccountDetailsPage/AccountSeedDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { FC } from 'react'
import { AccountSeedResult } from '../SearchResultsPage/hooks'
import { Runtime, useGetRuntimeAccountsAddress } from '../../../oasis-nexus/api'
import { AllTokenPrices } from '../../../coin-gecko/api'
import { AccountDetailsView } from './AccountDetailsView'

export const AccountSeedDetails: FC<{
result: AccountSeedResult
tokenPrices: AllTokenPrices
highlightedPartOfName: string | undefined
}> = ({ result, tokenPrices, highlightedPartOfName }) => {
const { network, layer, address } = result
const { data, isLoading, isError } = useGetRuntimeAccountsAddress(network, layer as Runtime, address)
return (
<AccountDetailsView
isLoading={isLoading}
isError={isError}
account={data?.data}
tokenPrices={tokenPrices}
showLayer={true}
highlightedPartOfName={highlightedPartOfName}
/>
)
}
3 changes: 2 additions & 1 deletion src/app/pages/AccountDetailsPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const AccountDetailsPage: 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)
Expand Down Expand Up @@ -62,6 +62,7 @@ export const AccountDetailsPage: FC = () => {
account={account}
token={token}
tokenPrices={tokenPrices}
highlightedPartOfName={searchTerm}
/>
<DappBanner scope={scope} ethAddress={account?.address_eth} />
<RouterTabs
Expand Down
14 changes: 14 additions & 0 deletions src/app/pages/SearchResultsPage/SearchResultsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { RuntimeTransactionDetailView } from '../RuntimeTransactionDetailPage'
import { AccountDetailsView } from '../AccountDetailsPage/AccountDetailsView'
import {
AccountResult,
AccountSeedResult,
BlockResult,
ContractResult,
ProposalResult,
Expand All @@ -21,6 +22,7 @@ import { AllTokenPrices } from '../../../coin-gecko/api'
import { ResultListFrame } from './ResultListFrame'
import { TokenDetails } from '../../components/Tokens/TokenDetails'
import { ProposalDetailView } from '../ProposalDetailsPage'
import { AccountSeedDetails } from '../AccountDetailsPage/AccountSeedDetails'

/**
* Component for displaying a list of search results
Expand Down Expand Up @@ -94,6 +96,18 @@ export const SearchResultsList: FC<{
linkLabel={t('search.results.accounts.viewLink')}
/>

<ResultsGroupByType
title={t('search.results.accounts.title')}
results={searchResults.filter(
(item): item is AccountSeedResult => item.resultType === 'accountSeed',
)}
resultComponent={item => (
<AccountSeedDetails result={item} tokenPrices={tokenPrices} highlightedPartOfName={searchTerm} />
)}
link={acc => RouteUtils.getAccountRoute(acc, acc.address)}
linkLabel={t('search.results.accounts.viewLink')}
/>

<ResultsGroupByType
title={t('search.results.contracts.title')}
results={searchResults.filter((item): item is ContractResult => item.resultType === 'contract')}
Expand Down
27 changes: 26 additions & 1 deletion src/app/pages/SearchResultsPage/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from '../../../oasis-nexus/api'
import { RouteUtils } from '../../utils/route-utils'
import { SearchParams } from '../../components/Search/search-utils'
import { useSearchForAccountsByName } from '../../hooks/useAccountName'

function isDefined<T>(item: T): item is NonNullable<T> {
return item != null
Expand All @@ -26,7 +27,7 @@ function isDefined<T>(item: T): item is NonNullable<T> {
export type ConditionalResults<T> = { isLoading: boolean; results: T[] }

type SearchResultItemCore = HasScope & {
resultType: 'block' | 'transaction' | 'account' | 'contract' | 'token' | 'proposal'
resultType: 'block' | 'transaction' | 'account' | 'accountSeed' | 'contract' | 'token' | 'proposal'
}

export type BlockResult = SearchResultItemCore & RuntimeBlock & { resultType: 'block' }
Expand All @@ -35,6 +36,8 @@ export type TransactionResult = SearchResultItemCore & RuntimeTransaction & { re

export type AccountResult = SearchResultItemCore & RuntimeAccount & { resultType: 'account' }

export type AccountSeedResult = SearchResultItemCore & { address: string } & { resultType: 'accountSeed' }

export type ContractResult = SearchResultItemCore & RuntimeAccount & { resultType: 'contract' }

export type TokenResult = SearchResultItemCore & EvmToken & { resultType: 'token' }
Expand All @@ -45,6 +48,7 @@ export type SearchResultItem =
| BlockResult
| TransactionResult
| AccountResult
| AccountSeedResult
| ContractResult
| TokenResult
| ProposalResult
Expand Down Expand Up @@ -187,6 +191,24 @@ export function useNetworkProposalsConditionally(
}
}

type AccountNameSearchMatch = Pick<RuntimeAccount, 'network' | 'layer' | 'address'>

export function useNamedAccountConditionally(
nameFragment: string | undefined,
): ConditionalResults<AccountNameSearchMatch> {
const queries = RouteUtils.getEnabledScopes().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 = (q: SearchParams) => {
const queries = {
blockHeight: useBlocksByHeightConditionally(q.blockHeight),
Expand All @@ -197,6 +219,7 @@ export const useSearch = (q: SearchParams) => {
evmBech32Account: useRuntimeAccountConditionally(q.evmBech32Account),
tokens: useRuntimeTokenConditionally(q.evmTokenNameFragment),
proposals: useNetworkProposalsConditionally(q.networkProposalNameFragment),
accountsByName: useNamedAccountConditionally(q.accountNameFragment),
}
const isLoading = Object.values(queries).some(query => query.isLoading)
const blocks = [...queries.blockHeight.results, ...queries.blockHash.results]
Expand All @@ -205,6 +228,7 @@ export const useSearch = (q: SearchParams) => {
...(queries.oasisAccount.results || []),
...(queries.evmBech32Account.results || []),
].filter(isAccountNonEmpty)
const accountSeeds = queries.accountsByName.results || []
const tokens = queries.tokens.results
.map(l => l.evm_tokens)
.flat()
Expand All @@ -222,6 +246,7 @@ export const useSearch = (q: SearchParams) => {
...accounts
.filter(account => account.evm_contract)
.map((account): ContractResult => ({ ...account, resultType: 'contract' })),
...accountSeeds.map((seed): AccountSeedResult => ({ ...seed, resultType: 'accountSeed' })),
...tokens.map((token): TokenResult => ({ ...token, resultType: 'token' })),
...proposals.map((proposal): ProposalResult => ({ ...proposal, resultType: 'proposal' })),
]
Expand Down
3 changes: 3 additions & 0 deletions src/app/pages/SearchResultsPage/useRedirectIfSingleResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export function useRedirectIfSingleResult(
case 'account':
redirectTo = RouteUtils.getAccountRoute(item, item.address_eth ?? item.address)
break
case 'accountSeed':
redirectTo = `${RouteUtils.getAccountRoute(item, item.address)}?q=${searchTerm}`
break
case 'contract':
redirectTo = RouteUtils.getAccountRoute(item, item.address_eth ?? item.address)
break
Expand Down

0 comments on commit 4b72f48

Please sign in to comment.