Skip to content

Commit

Permalink
Support named accounts
Browse files Browse the repository at this point in the history
- Load the Pontus-X account names
- Load metadata for Oasis Consensus, Emerald and Sapphire
- Support displaying account names
- Support for searching for accounts by name
- Implement highlighting matches in account names
  • Loading branch information
csillag committed May 7, 2024
1 parent 6cb1ec6 commit 5f7cfee
Show file tree
Hide file tree
Showing 16 changed files with 455 additions and 13 deletions.
1 change: 1 addition & 0 deletions .changelog/1246.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Initial support for displaying account names
64 changes: 56 additions & 8 deletions src/app/components/Account/AccountLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand All @@ -23,7 +27,7 @@ const WithTypographyAndLink: FC<{
...(mobile
? {
maxWidth: '100%',
overflowX: 'hidden',
overflow: 'hidden',
}
: {}),
}}
Expand All @@ -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
*
Expand All @@ -52,8 +61,17 @@ interface Props {
extraTooltip?: ReactNode
}

export const AccountLink: FC<Props> = ({ scope, address, alwaysTrim, extraTooltip }) => {
export const AccountLink: FC<Props> = ({
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 ? (
Expand All @@ -65,31 +83,61 @@ export const AccountLink: FC<Props> = ({ 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 (
<WithTypographyAndLink to={to}>
<MaybeWithTooltip title={address}>{trimLongString(address, 6, 6)}</MaybeWithTooltip>
<MaybeWithTooltip
title={
accountName ? (
<div>
<Box sx={{ fontWeight: 'bold' }}>{accountName}</Box>
<Box sx={{ fontWeight: 'normal' }}>{address}</Box>
{tooltipPostfix}
</div>
) : (
address
)
}
>
{accountName ? trimLongString(accountName, 12, 0) : trimLongString(address, 6, 6)}
</MaybeWithTooltip>
</WithTypographyAndLink>
)
}

if (!isTablet) {
// Details in desktop mode.
// We want one long line
// We want one long line, with name and address.

return (
<WithTypographyAndLink to={to}>
<MaybeWithTooltip title={tooltipPostfix}>{address} </MaybeWithTooltip>
<MaybeWithTooltip title={tooltipPostfix}>
{accountName ? (
<span>
<HighlightedText text={accountName} pattern={highlightedPartOfName} /> ({address})
</span>
) : (
address
)}
</MaybeWithTooltip>
</WithTypographyAndLink>
)
}

// 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 (
<WithTypographyAndLink to={to} mobile>
<AdaptiveTrimmer text={address} strategy="middle" extraTooltip={extraTooltip} />
<>
<AdaptiveHighlightedText
text={accountName}
pattern={highlightedPartOfName}
extraTooltip={extraTooltip}
/>
<AdaptiveTrimmer text={address} strategy="middle" extraTooltip={extraTooltip} />
</>
</WithTypographyAndLink>
)
}
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 account={account} />
</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 @@ -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) {
Expand Down
18 changes: 18 additions & 0 deletions src/app/data/named-accounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export type AccountMetadata = {
name?: string
description?: string
}

export type AccountMetadataInfo = {
metadata?: AccountMetadata
loading: boolean
}

export type AccountMap = Map<string, AccountMetadata>

export type AccountEntry = { address: string } & AccountMetadata

export type AccountData = {
map: AccountMap
list: AccountEntry[]
}
120 changes: 120 additions & 0 deletions src/app/data/oasis-account-names.ts
Original file line number Diff line number Diff line change
@@ -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, Partial<Record<Layer, string>>> = {
[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<AccountData>((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,
}
}
84 changes: 84 additions & 0 deletions src/app/data/pontusx-account-names.ts
Original file line number Diff line number Diff line change
@@ -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<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 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,
}
}
Loading

0 comments on commit 5f7cfee

Please sign in to comment.