diff --git a/src/features/accounts/components/account-info.tsx b/src/features/accounts/components/account-info.tsx index 205f3ceed..af5b3211b 100644 --- a/src/features/accounts/components/account-info.tsx +++ b/src/features/accounts/components/account-info.tsx @@ -15,16 +15,20 @@ import { accountBalanceLabel, accountInformationLabel, accountMinBalanceLabel, + accountNfdLabel, accountRekeyedToLabel, } from './labels' import { OpenJsonViewDialogButton } from '@/features/common/components/json-view-dialog-button' import { CopyButton } from '@/features/common/components/copy-button' +import { useLoadableNfd } from '@/features/nfd/data/nfd' +import { RenderLoadable } from '@/features/common/components/render-loadable' type Props = { account: Account } export function AccountInfo({ account }: Props) { + const [loadableNfd] = useLoadableNfd(account.address) const accountInfoItems = useMemo(() => { const items = [ { @@ -36,6 +40,20 @@ export function AccountInfo({ account }: Props) { ), }, + ...(loadableNfd.state === 'hasData' && loadableNfd.data !== null + ? [ + { + dt: accountNfdLabel, + dd: ( +
+ }> + {(nfd) => {nfd?.name}} + +
+ ), + }, + ] + : []), { dt: accountBalanceLabel, dd: , @@ -84,6 +102,7 @@ export function AccountInfo({ account }: Props) { account.totalApplicationsCreated, account.totalApplicationsOptedIn, account.rekeyedTo, + loadableNfd, ]) return ( diff --git a/src/features/accounts/components/account-link.tsx b/src/features/accounts/components/account-link.tsx index 0790519b5..8d1efaf40 100644 --- a/src/features/accounts/components/account-link.tsx +++ b/src/features/accounts/components/account-link.tsx @@ -1,21 +1,43 @@ +import { PropsWithChildren } from 'react' +import { useLoadableNfd } from '@/features/nfd/data/nfd' +import { RenderLoadable } from '@/features/common/components/render-loadable' +import { fixedForwardRef } from '@/utils/fixed-forward-ref' import { CopyButton } from '@/features/common/components/copy-button' import { cn } from '@/features/common/utils' +import { useSelectedNetwork } from '@/features/network/data' import { TemplatedNavLink } from '@/features/routing/components/templated-nav-link/templated-nav-link' import { Urls } from '@/routes/urls' -import { ellipseAddress } from '@/utils/ellipse-address' -import { fixedForwardRef } from '@/utils/fixed-forward-ref' -import { PropsWithChildren } from 'react' -import { useSelectedNetwork } from '@/features/network/data' +import { ellipseAddress, ellipseNfd } from '@/utils/ellipse-address' -type Props = PropsWithChildren<{ +export type AccountLinkProps = PropsWithChildren<{ address: string short?: boolean className?: string showCopyButton?: boolean + truncate?: boolean }> -export const AccountLink = fixedForwardRef( - ({ address, short, className, children, showCopyButton, ...rest }: Props, ref?: React.LegacyRef) => { +export const AccountLink = ({ address, ...rest }: AccountLinkProps) => { + const [loadableNfd] = useLoadableNfd(address) + + return ( + <> + }> + {(nfd) => } + + + ) +} + +type AccountLinkInnerProps = AccountLinkProps & { + nfd?: string +} + +const AccountLinkInner = fixedForwardRef( + ( + { address, nfd, short, className, children, showCopyButton, truncate, ...rest }: AccountLinkInnerProps, + ref?: React.LegacyRef + ) => { const [selectedNetwork] = useSelectedNetwork() const link = ( @@ -28,6 +50,10 @@ export const AccountLink = fixedForwardRef( > {children ? ( children + ) : nfd ? ( + + {truncate ? ellipseNfd(nfd) : nfd} + ) : short ? ( {ellipseAddress(address)} diff --git a/src/features/accounts/components/labels.ts b/src/features/accounts/components/labels.ts index 66b756e41..2f2a91f8f 100644 --- a/src/features/accounts/components/labels.ts +++ b/src/features/accounts/components/labels.ts @@ -20,6 +20,7 @@ export const accountOptedApplicationsTabId = 'opted-applications' export const accountInformationLabel = 'Account Information' export const accountAddressLabel = 'Address' +export const accountNfdLabel = 'NFD' export const accountBalanceLabel = 'Balance' export const accountMinBalanceLabel = 'Min Balance' export const accountAssetsHeldLabel = 'Held assets' diff --git a/src/features/accounts/pages/account-page.test.tsx b/src/features/accounts/pages/account-page.test.tsx index 23f669da7..c5689fc57 100644 --- a/src/features/accounts/pages/account-page.test.tsx +++ b/src/features/accounts/pages/account-page.test.tsx @@ -22,11 +22,16 @@ import { accountRekeyedToLabel, accountAssetLabel, accountApplicationLabel, + accountNfdLabel, } from '../components/labels' import { assetResultsAtom } from '@/features/assets/data' import { assetResultMother } from '@/tests/object-mother/asset-result' import { refreshButtonLabel } from '@/features/common/components/refresh-button' import { algod } from '@/features/common/data/algo-client' +import { reverseNfdsAtom } from '@/features/nfd/data/nfd-result' +import { nfdResultMother } from '@/tests/object-mother/nfd-result' +import { atom } from 'jotai' +import { useLoadableNfd } from '@/features/nfd/data/nfd' vi.mock('@/features/common/data/algo-client', async () => { const original = await vi.importActual('@/features/common/data/algo-client') @@ -84,6 +89,7 @@ describe('account-page', () => { myStore.set(assetResultsAtom, assetResults) vi.mocked(useParams).mockImplementation(() => ({ address: accountResult.address })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) return executeComponentTest( () => render(, undefined, myStore), @@ -136,6 +142,7 @@ describe('account-page', () => { myStore.set(accountResultsAtom, new Map([[accountResult.address, createReadOnlyAtomAndTimestamp(accountResult)]])) myStore.set(assetResultsAtom, assetResults) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) vi.mocked(useParams).mockImplementation(() => ({ address: accountResult.address })) return executeComponentTest( @@ -180,6 +187,7 @@ describe('account-page', () => { const myStore = createStore() myStore.set(accountResultsAtom, new Map([[accountResult.address, createReadOnlyAtomAndTimestamp(accountResult)]])) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) vi.mocked(useParams).mockImplementation(() => ({ address: accountResult.address })) return executeComponentTest( @@ -231,6 +239,7 @@ describe('account-page', () => { myStore.set(accountResultsAtom, new Map([[accountResult.address, createReadOnlyAtomAndTimestamp(accountResult)]])) myStore.set(assetResultsAtom, assetResults) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) vi.mocked(useParams).mockImplementation(() => ({ address: accountResult.address })) return executeComponentTest( @@ -271,6 +280,7 @@ describe('account-page', () => { const myStore = createStore() myStore.set(accountResultsAtom, new Map([[accountResult.address, createReadOnlyAtomAndTimestamp(accountResult)]])) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) vi.mocked(useParams).mockImplementation(() => ({ address: accountResult.address })) return executeComponentTest( @@ -307,4 +317,42 @@ describe('account-page', () => { ) }) }) + + describe('when rendering an account with Nfd', () => { + const accountResult = accountResultMother['mainnet-DHMCHBN4W5MBO72C3L3ZP6GGJHQ4OR6SW2EP3VDEJ5VHT4MERQLCTVW6PU']().build() + const nfdResult = nfdResultMother['mainnet-datamuseum.algo']().build() + + it('should be rendered with the correct data', async () => { + const myStore = createStore() + const mockReverseNfdAtom = atom | null>('datamuseum.algo') + myStore.set(accountResultsAtom, new Map([[accountResult.address, createReadOnlyAtomAndTimestamp(accountResult)]])) + myStore.set(reverseNfdsAtom, new Map([[nfdResult.depositAccount, [mockReverseNfdAtom, Date.now()] as const]])) + + vi.mocked(useParams).mockImplementation(() => ({ address: accountResult.address })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'hasData', data: nfdResult }]) + + return executeComponentTest( + () => render(, undefined, myStore), + async (component) => { + await waitFor(() => { + const informationCard = component.getByLabelText(accountInformationLabel) + descriptionListAssertion({ + container: informationCard, + items: [ + { term: accountAddressLabel, description: 'DHMCHBN4W5MBO72C3L3ZP6GGJHQ4OR6SW2EP3VDEJ5VHT4MERQLCTVW6PU' }, + { term: accountNfdLabel, description: 'datamuseum.algo' }, + { term: accountBalanceLabel, description: '1915.70635' }, + { term: accountMinBalanceLabel, description: '0.1' }, + { term: accountAssetsHeldLabel, description: '0' }, + { term: accountAssetsCreatedLabel, description: '0' }, + { term: accountAssetsOptedInLabel, description: '0' }, + { term: accountApplicationsCreatedLabel, description: '0' }, + { term: accountApplicationsOptedInLabel, description: '0' }, + ], + }) + }) + } + ) + }) + }) }) diff --git a/src/features/app-interfaces/data/read.ts b/src/features/app-interfaces/data/read.ts index ea3cfaf4a..e30ffa417 100644 --- a/src/features/app-interfaces/data/read.ts +++ b/src/features/app-interfaces/data/read.ts @@ -11,6 +11,13 @@ export const getAppInterfaces = async (dbConnection: DbConnection) => { return await dbConnection.getAll('app-interfaces') } +export const createAppInterfaceAtom = (applicationId: ApplicationId) => { + return atom(async (get) => { + const dbConnection = await get(dbConnectionAtom) + return await getAppInterface(dbConnection, applicationId) + }) +} + export const useAppInterfaces = () => { const appInterfacesAtom = useMemo(() => { return atomWithRefresh(async (get) => { @@ -22,10 +29,3 @@ export const useAppInterfaces = () => { }, []) return [useAtomValue(loadable(appInterfacesAtom)), useSetAtom(appInterfacesAtom)] as const } - -export const createAppInterfaceAtom = (applicationId: ApplicationId) => { - return atom(async (get) => { - const dbConnection = await get(dbConnectionAtom) - return await getAppInterface(dbConnection, applicationId) - }) -} diff --git a/src/features/applications/components/application-method-definitions.test.tsx b/src/features/applications/components/application-method-definitions.test.tsx index 0b2356851..f6761f941 100644 --- a/src/features/applications/components/application-method-definitions.test.tsx +++ b/src/features/applications/components/application-method-definitions.test.tsx @@ -18,6 +18,7 @@ import { sendButtonLabel } from '@/features/transaction-wizard/components/transa import { algo } from '@algorandfoundation/algokit-utils' import { transactionActionsLabel, transactionGroupTableLabel } from '@/features/transaction-wizard/components/labels' import { selectOption } from '@/tests/utils/select-option' +import { useLoadableNfd, useLoadableNfdResult } from '@/features/nfd/data/nfd' const myStore = await vi.hoisted(async () => { const { getDefaultStore } = await import('jotai/index') @@ -51,6 +52,7 @@ describe('application-method-definitions', () => { ], lastModified: createTimestamp(), } satisfies AppInterfaceEntity) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) }) describe('when a wallet is connected', () => { @@ -61,6 +63,7 @@ describe('application-method-definitions', () => { describe('when calling calculator add method', () => { it('reports validation errors when required fields have not been supplied', () => { vi.mocked(useParams).mockImplementation(() => ({ applicationId: appId.toString() })) + vi.mocked(useLoadableNfdResult).mockReturnValue([{ state: 'loading' }]) return executeComponentTest( () => { @@ -93,6 +96,7 @@ describe('application-method-definitions', () => { it('succeeds when all fields have been correctly supplied', () => { const { testAccount } = localnet.context vi.mocked(useParams).mockImplementation(() => ({ applicationId: appId.toString() })) + vi.mocked(useLoadableNfdResult).mockReturnValue([{ state: 'loading' }]) return executeComponentTest( () => { @@ -165,6 +169,7 @@ describe('application-method-definitions', () => { it('allows the users to switch to echo_bytes method and send the transaction', async () => { const { testAccount } = localnet.context vi.mocked(useParams).mockImplementation(() => ({ applicationId: appId.toString() })) + vi.mocked(useLoadableNfdResult).mockReturnValue([{ state: 'loading' }]) return executeComponentTest( () => { diff --git a/src/features/applications/pages/application-page.test.tsx b/src/features/applications/pages/application-page.test.tsx index abe3bf7bd..5914db04b 100644 --- a/src/features/applications/pages/application-page.test.tsx +++ b/src/features/applications/pages/application-page.test.tsx @@ -40,6 +40,7 @@ import { writeAppInterface } from '@/features/app-interfaces/data' import SampleSevenAppSpec from '@/tests/test-app-specs/sample-seven.arc32.json' import { AppSpecStandard, Arc32AppSpec } from '@/features/app-interfaces/data/types' import { searchTransactionsMock } from '@/tests/setup/mocks' +import { useLoadableNfd } from '@/features/nfd/data/nfd' vi.mock('@/features/common/data/algo-client', async () => { const original = await vi.importActual('@/features/common/data/algo-client') @@ -115,6 +116,7 @@ describe('application-page', () => { const applicationResult = applicationResultMother['mainnet-80441968']().build() it('should be rendered with the correct data', () => { + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const myStore = createStore() myStore.set(genesisHashAtom, 'some-hash') @@ -234,6 +236,7 @@ describe('application-page', () => { const transactionResult = transactionResultMother['mainnet-XCXQW7J5G5QSPVU5JFYEELVIAAABPLZH2I36BMNVZLVHOA75MPAQ']().build() it('should be rendered with the correct app name', () => { + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const myStore = createStore() myStore.set(genesisHashAtom, 'some-hash') @@ -268,6 +271,7 @@ describe('application-page', () => { const applicationResult = applicationResultMother['mainnet-80441968']().build() it('should be rendered with the refresh button', () => { + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const myStore = createStore() myStore.set(genesisHashAtom, 'some-hash') @@ -357,6 +361,7 @@ describe('application-page', () => { it('should be rendered with the correct data', async () => { vi.mocked(useParams).mockImplementation(() => ({ applicationId: applicationResult.id.toString() })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) vi.mocked(indexer.searchForTransactions().applicationID(applicationResult.id).limit(3).do).mockImplementation(() => Promise.resolve({ currentRound: 123, transactions: [], nextToken: '' }) ) diff --git a/src/features/assets/components/asset-transaction-history.test.tsx b/src/features/assets/components/asset-transaction-history.test.tsx index 3108fe0ef..f3e236190 100644 --- a/src/features/assets/components/asset-transaction-history.test.tsx +++ b/src/features/assets/components/asset-transaction-history.test.tsx @@ -11,6 +11,7 @@ import { transactionResultMother } from '@/tests/object-mother/transaction-resul import { getAllByRole } from '@testing-library/dom' import { ANY_NUMBER, ANY_STRING, searchTransactionsMock } from '@/tests/setup/mocks' import { RenderResult } from '@testing-library/react' +import { useLoadableNfd } from '@/features/nfd/data/nfd' vi.mock('@/features/common/data/algo-client', async () => { const original = await vi.importActual('@/features/common/data/algo-client') @@ -26,6 +27,8 @@ describe('asset-transaction-history', () => { const asset = assetResultMother['testnet-642327435']().build() it('should be able to handle pagination', () => { + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) + const myStore = createStore() myStore.set(assetResultsAtom, new Map([[asset.index, createReadOnlyAtomAndTimestamp(asset)]])) diff --git a/src/features/assets/pages/asset-page.test.tsx b/src/features/assets/pages/asset-page.test.tsx index 6f7ad529a..79a8b6b45 100644 --- a/src/features/assets/pages/asset-page.test.tsx +++ b/src/features/assets/pages/asset-page.test.tsx @@ -35,6 +35,7 @@ import { algod, indexer } from '@/features/common/data/algo-client' import { setupServer } from 'msw/node' import { http, HttpResponse } from 'msw' import { searchTransactionsMock } from '@/tests/setup/mocks' +import { useLoadableNfd } from '@/features/nfd/data/nfd' const server = setupServer() @@ -79,6 +80,7 @@ describe('asset-page', () => { describe('when rendering an asset with asset Id that does not exist', () => { it('should display not found message', () => { vi.mocked(useParams).mockImplementation(() => ({ assetId: '123456' })) + vi.mocked(algod.getAssetByID(0).do).mockImplementation(() => Promise.reject(new HttpError('boom', 404))) vi.mocked(indexer.lookupAssetByID(0).includeAll(true).do).mockImplementation(() => Promise.reject(new HttpError('boom', 404))) @@ -94,6 +96,7 @@ describe('asset-page', () => { describe('when rendering an asset that failed to load', () => { it('should display failed to load message', () => { vi.mocked(useParams).mockImplementation(() => ({ assetId: '123456' })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) vi.mocked(algod.getAssetByID(0).do).mockImplementation(() => Promise.reject({})) return executeComponentTest( @@ -110,6 +113,7 @@ describe('asset-page', () => { const transactionResult = transactionResultMother.assetConfig().build() it('should be rendered with the correct data', () => { + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const myStore = createStore() myStore.set(assetResultsAtom, new Map([[assetResult.index, createReadOnlyAtomAndTimestamp(assetResult)]])) @@ -195,6 +199,7 @@ describe('asset-page', () => { const transactionResult = transactionResultMother.assetConfig().build() it('should be rendered with the correct data', () => { + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const myStore = createStore() myStore.set(assetResultsAtom, new Map([[assetResult.index, createReadOnlyAtomAndTimestamp(assetResult)]])) @@ -296,6 +301,7 @@ describe('asset-page', () => { const transactionResult = transactionResultMother['mainnet-4BFQTYKSJNRF52LXCMBXKDWLODRDVGSUCW36ND3B7C3ZQKPMLUJA']().build() it('should be rendered with the correct data', () => { + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const myStore = createStore() myStore.set(assetResultsAtom, new Map([[assetResult.index, createReadOnlyAtomAndTimestamp(assetResult)]])) @@ -382,6 +388,7 @@ describe('asset-page', () => { const transactionResult = transactionResultMother['mainnet-P4IX7SYWTTFRQGYTCLFOZSTYSJ5FJKNR3MEIVRR4OA2JJXTQZHTQ']().build() it('should be rendered with the correct data', () => { + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const myStore = createStore() myStore.set(assetResultsAtom, new Map([[assetResult.index, createReadOnlyAtomAndTimestamp(assetResult)]])) @@ -467,6 +474,7 @@ describe('asset-page', () => { const transactionResult = transactionResultMother['mainnet-K66JS73E3BDJ4OYHIC4QRRNSGY2PQMKSQMPYFQ6EEYJTOIPDUA3Q']().build() it('should be rendered with the correct data', () => { + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const myStore = createStore() myStore.set(assetResultsAtom, new Map([[assetResult.index, createReadOnlyAtomAndTimestamp(assetResult)]])) @@ -584,6 +592,7 @@ describe('asset-page', () => { const transactionResult = transactionResultMother['mainnet-W7UVVLOW6RWZYEC64WTOVL5RME33UGI6H6AUP7GSEZW4QNDM4GHA']().build() it('should be rendered with the correct data', () => { + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const myStore = createStore() myStore.set(assetResultsAtom, new Map([[assetResult.index, createReadOnlyAtomAndTimestamp(assetResult)]])) @@ -718,6 +727,7 @@ describe('asset-page', () => { const destroyAssetTransactionResult = transactionResultMother['mainnet-U4XH6AS5UUYQI4IZ3E5JSUEIU64Y3FGNYKLH26W4HRY7T6PK745A']().build() it('should be rendered with the correct data', () => { + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const myStore = createStore() myStore.set(assetResultsAtom, new Map([[assetResult.index, createReadOnlyAtomAndTimestamp(assetResult)]])) @@ -803,6 +813,7 @@ describe('asset-page', () => { const transactionResult = transactionResultMother.assetConfig().build() it('should be rendered with the refresh button', () => { + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const myStore = createStore() myStore.set(assetResultsAtom, new Map([[assetResult.index, createReadOnlyAtomAndTimestamp(assetResult)]])) @@ -862,6 +873,7 @@ describe('asset-page', () => { const assetResult = assetResultMother['mainnet-1024439078']().build() const transactionResult = transactionResultMother.assetConfig().build() it('should be rendered with the correct data', () => { + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const myStore = createStore() myStore.set(assetResultsAtom, new Map([[assetResult.index, createReadOnlyAtomAndTimestamp(assetResult)]])) diff --git a/src/features/blocks/pages/block-page.test.tsx b/src/features/blocks/pages/block-page.test.tsx index db4e7ba3b..a81ddf5f3 100644 --- a/src/features/blocks/pages/block-page.test.tsx +++ b/src/features/blocks/pages/block-page.test.tsx @@ -18,6 +18,7 @@ import { tableAssertion } from '@/tests/assertions/table-assertion' import { descriptionListAssertion } from '@/tests/assertions/description-list-assertion' import { assetResultsAtom } from '@/features/assets/data' import { indexer } from '@/features/common/data/algo-client' +import { useLoadableNfd } from '@/features/nfd/data/nfd' vi.mock('@/features/common/data/algo-client', async () => { const original = await vi.importActual('@/features/common/data/algo-client') @@ -114,6 +115,7 @@ describe('block-page', () => { it('should be rendered with the correct data', () => { vi.mocked(useParams).mockImplementation(() => ({ round: block.round.toString() })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const myStore = createStore() myStore.set(blockResultsAtom, new Map([[block.round, createReadOnlyAtomAndTimestamp(block)]])) myStore.set(transactionResultsAtom, new Map(transactionResults.map((x) => [x.id, createReadOnlyAtomAndTimestamp(x)]))) diff --git a/src/features/common/components/platform-provider.tsx b/src/features/common/components/platform-provider.tsx index 8a35ceffe..396531b19 100644 --- a/src/features/common/components/platform-provider.tsx +++ b/src/features/common/components/platform-provider.tsx @@ -7,10 +7,12 @@ import { useTheme } from '../hooks/use-theme' import { useSubscribeToBlocksEffect } from '@/features/blocks/data' import { useDataProviderToken, useStateCleanupEffect } from '../data' import { ToastContainer } from 'react-toastify' +import { useBatchNfdResolutionEffect } from '@/features/nfd/data/nfd-result' function RegisterGlobalEffects() { useSubscribeToBlocksEffect() useStateCleanupEffect() + useBatchNfdResolutionEffect() return <> } diff --git a/src/features/explore/pages/explore-page.test.tsx b/src/features/explore/pages/explore-page.test.tsx index 928f050e1..2c510d8bf 100644 --- a/src/features/explore/pages/explore-page.test.tsx +++ b/src/features/explore/pages/explore-page.test.tsx @@ -1,7 +1,7 @@ import { executeComponentTest } from '@/tests/test-component' import { getAllByRole, getByRole, queryAllByRole, render, waitFor } from '@/tests/testing-library' import { Atom, createStore } from 'jotai' -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { ExplorePage } from './explore-page' import { latestBlocksTitle } from '@/features/blocks/components/latest-blocks' import { latestTransactionsTitle } from '@/features/transactions/components/latest-transactions' @@ -16,6 +16,7 @@ import { randomNumberBetween } from '@makerx/ts-dossier' import { ellipseId } from '@/utils/ellipse-id' import { ellipseAddress } from '@/utils/ellipse-address' import { createReadOnlyAtomAndTimestamp, createTimestamp } from '@/features/common/data' +import { useLoadableNfd } from '@/features/nfd/data/nfd' describe('explore-page', () => { describe('when no blocks are available', () => { @@ -62,6 +63,9 @@ describe('explore-page', () => { transactionResults.map((t) => [t.id, createTimestamp()] as const) ) myStore.set(syncedRoundAtom, block.round) + beforeEach(() => { + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) + }) it('the processed blocks are displayed', () => { return executeComponentTest( @@ -124,6 +128,9 @@ describe('explore-page', () => { transactions: new Map, number]>(), } ) + beforeEach(() => { + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) + }) it('only the latest 10 blocks are displayed', () => { const myStore = createStore() diff --git a/src/features/groups/pages/group-page.test.tsx b/src/features/groups/pages/group-page.test.tsx index df91f0bce..d08472a08 100644 --- a/src/features/groups/pages/group-page.test.tsx +++ b/src/features/groups/pages/group-page.test.tsx @@ -19,6 +19,7 @@ import { tableAssertion } from '@/tests/assertions/table-assertion' import { assetResultsAtom } from '@/features/assets/data' import { indexer } from '@/features/common/data/algo-client' import { genesisHashAtom } from '@/features/blocks/data' +import { useLoadableNfd } from '@/features/nfd/data/nfd' vi.mock('@/features/common/data/algo-client', async () => { const original = await vi.importActual('@/features/common/data/algo-client') @@ -92,6 +93,7 @@ describe('group-page', () => { it('should be rendered with the correct data', () => { vi.mocked(useParams).mockImplementation(() => ({ round: group.round.toString(), groupId: group.id })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const myStore = createStore() myStore.set(groupResultsAtom, new Map([[group.id, createReadOnlyAtomAndTimestamp(group)]])) myStore.set(transactionResultsAtom, new Map(transactionResults.map((x) => [x.id, createReadOnlyAtomAndTimestamp(x)]))) diff --git a/src/features/layout/pages/layout-page.test.tsx b/src/features/layout/pages/layout-page.test.tsx index fd068defa..33a2f7f71 100644 --- a/src/features/layout/pages/layout-page.test.tsx +++ b/src/features/layout/pages/layout-page.test.tsx @@ -8,6 +8,7 @@ import { Event as TauriEvent, listen } from '@tauri-apps/api/event' import { networkConfigAtom } from '@/features/network/data' import { useNavigate } from 'react-router-dom' import { settingsStore } from '@/features/settings/data' +import { useLoadableNfd } from '@/features/nfd/data/nfd' describe('when rendering the layout page', () => { describe('and the wallet is not connected', () => { @@ -42,6 +43,7 @@ describe('when rendering the layout page', () => { describe('and the wallet is connected', () => { it('the wallet address is shown', async () => { + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const original = await vi.importActual<{ useWallet: () => ReturnType }>('@txnlab/use-wallet') vi.mocked(useWallet).mockImplementation(() => { return { @@ -68,6 +70,7 @@ describe('when rendering the layout page', () => { }) describe('and there is more than one account', () => { it('the account switcher should be shown', async () => { + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const original = await vi.importActual<{ useWallet: () => ReturnType }>('@txnlab/use-wallet') vi.mocked(useWallet).mockImplementation(() => { return { @@ -109,6 +112,7 @@ describe('when rendering the layout page', () => { describe('and the user disconnects the wallet', () => { it('the wallet should be disconnected', async () => { + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const original = await vi.importActual<{ useWallet: () => ReturnType }>('@txnlab/use-wallet') const disconnect = vi.fn() vi.mocked(useWallet).mockImplementation(() => { diff --git a/src/features/nfd/data/is-nfd.ts b/src/features/nfd/data/is-nfd.ts index 17c478165..5bcf08f25 100644 --- a/src/features/nfd/data/is-nfd.ts +++ b/src/features/nfd/data/is-nfd.ts @@ -1,3 +1,3 @@ const nfdRegex = /^(.+\.algo)$/ -export const isNFD = (name: string) => nfdRegex.test(name) +export const isNfd = (name: string) => nfdRegex.test(name) diff --git a/src/features/nfd/data/nfd-result.ts b/src/features/nfd/data/nfd-result.ts index 217768f95..244b76049 100644 --- a/src/features/nfd/data/nfd-result.ts +++ b/src/features/nfd/data/nfd-result.ts @@ -1,12 +1,101 @@ -import { createReadOnlyAtomAndTimestamp, readOnlyAtomCache } from '@/features/common/data' -import { Atom, atom, Getter, Setter } from 'jotai' +import { createReadOnlyAtomAndTimestamp, createTimestamp, readOnlyAtomCache, writableAtomCache } from '@/features/common/data' +import { Atom, atom, Getter, Setter, useAtomValue } from 'jotai' import { Nfd, NfdLookup, NfdResult } from './types' import { Address } from '@/features/accounts/data/types' import { networkConfig } from '@/features/common/data/algo-client' +import { atomEffect } from 'jotai-effect' +import { useMemo } from 'react' + +const MAX_BATCH_SIZE = 20 +const addressesToResolveAtom = atom(new Set()) + +export const batchNfdResolutionEffect = atomEffect((get, set) => { + if (!networkConfig.nfdApiUrl) { + return + } + + const addressesToResolve = get.peek(addressesToResolveAtom) + const cleanup = setInterval(() => { + ;(async () => { + if (!networkConfig.nfdApiUrl || addressesToResolve.size === 0) { + return + } + const networkNfdApiUrl = networkConfig.nfdApiUrl + + const addressesArray = Array.from(addressesToResolve) + const batches = [] + for (let i = 0; i < addressesArray.length; i += MAX_BATCH_SIZE) { + batches.push(addressesArray.slice(i, i + MAX_BATCH_SIZE)) + } + const batchPromises = batches.map((batch) => reverseLookup(batch, networkNfdApiUrl)) + const allResults = await Promise.all(batchPromises) + + const nfdResults = allResults.flat() + // TODO: NC - this needs to respect the API limit. We should break the batches smaller and parallelise the requests + // const nfdResults = await reverseLookup(Array.from(addressesToResolve), networkConfig.nfdApiUrl) + + const [forwardNfdResultsToAdd, reverseNfdsToAdd] = nfdResults.reduce( + (acc, nfdResult) => { + if (!acc[0].has(nfdResult.name)) { + acc[0].set(nfdResult.name, nfdResult) + } + const addressesToAdd = new Set([nfdResult.depositAccount, ...nfdResult.caAlgo]) + addressesToAdd.forEach((address) => { + if (!acc[1].has(address)) { + acc[1].set(address, nfdResult.name) + } + }) + return acc + }, + [new Map(), new Map()] as const + ) + addressesToResolve.forEach((address) => { + if (!reverseNfdsToAdd.has(address)) { + reverseNfdsToAdd.set(address, null) + } + }) + + // Cache the NFD result for forward lookups. Reverse lookups will also use this cache + set(forwardNfdResultsAtom, (prev) => { + const next = new Map(prev) + forwardNfdResultsToAdd.forEach((nfdResult) => { + if (!next.has(nfdResult.name)) { + next.set(nfdResult.name, createReadOnlyAtomAndTimestamp(nfdResult)) + } + }) + return next + }) + + // Cache all addresses associated with the resolved NFD for reverse lookups + set(reverseNfdsAtom, (_prev) => { + const next = new Map(_prev) + reverseNfdsToAdd.forEach((nfd, address) => { + // Ensures we replace the pending promise with resolved data + if (addressesToResolve.has(address) && next.get(address)) { + set(next.get(address)![0], nfd) + } else if (!next.has(address)) { + next.set(address, [atom | Nfd | null>(nfd), createTimestamp()]) + } + }) + return next + }) + addressesToResolve.clear() + })() + }, 200) + return () => clearInterval(cleanup) +}) + +const getReverseLookupNfd = (get: Getter, __: Setter, address: Address) => { + const addressesToResolve = get(addressesToResolveAtom) + addressesToResolve.add(address) + return atom | Nfd | null>(new Promise(() => {})) +} + +const reverseLookup = async (addresses: Address[], nfdApiUrl: string): Promise => { + const query = `address=${addresses.join('&address=')}` -const getReverseLookupNfd = async (_: Getter, set: Setter, address: Address, nfdApiUrl: string): Promise => { try { - const response = await fetch(`${nfdApiUrl}/nfd/lookup?address=${address}`, { + const response = await fetch(`${nfdApiUrl}/nfd/lookup?${query}`, { method: 'GET', headers: { accept: 'application/json', @@ -14,42 +103,13 @@ const getReverseLookupNfd = async (_: Getter, set: Setter, address: Address, nfd }) if (!response.ok) { - return null + return [] } - const body = await response.json() - const nfd = body[address] as NfdResult - const nfdResult = { - name: nfd.name, - depositAccount: nfd.depositAccount, - caAlgo: nfd.caAlgo ?? [], - } satisfies NfdResult - - // Cache the NFD result for forward lookups. Reverse lookups will also use this cache - set(forwardNfdResultsAtom, (prev) => { - if (!prev.has(nfdResult.name)) { - const next = new Map(prev) - next.set(nfdResult.name, createReadOnlyAtomAndTimestamp(nfdResult)) - return next - } - return prev - }) - - // Cache all *other* addresses associated with this NFD for reverse lookups - set(reverseNfdsAtom, (prev) => { - const next = new Map(prev) - const addressesToAdd = new Set([nfdResult.depositAccount, ...nfdResult.caAlgo].filter((a) => a !== address)) - addressesToAdd.forEach((address) => { - if (!prev.has(address)) { - next.set(address, createReadOnlyAtomAndTimestamp(nfdResult.name)) - } - }) - return next - }) - - return nfdResult.name + const body = (await response.json()) as Record + return Object.values(body) } catch (e: unknown) { - return null + return [] } } @@ -65,7 +125,6 @@ const getForwardLookupNfdResult = async (_: Getter, set: Setter, nfd: Nfd, nfdAp if (!response.ok) { return null } - const body = (await response.json()) as NfdResult const nfdResult = { name: body.name, @@ -79,7 +138,7 @@ const getForwardLookupNfdResult = async (_: Getter, set: Setter, nfd: Nfd, nfdAp const addressesToAdd = new Set([nfdResult.depositAccount, ...nfdResult.caAlgo]) addressesToAdd.forEach((address) => { if (!prev.has(address)) { - next.set(address, createReadOnlyAtomAndTimestamp(nfdResult.name)) + next.set(address, [atom | Nfd | null>(nfdResult.name), createTimestamp()]) } }) return next @@ -91,6 +150,10 @@ const getForwardLookupNfdResult = async (_: Getter, set: Setter, nfd: Nfd, nfdAp } } +export const useBatchNfdResolutionEffect = () => { + return useAtomValue(batchNfdResolutionEffect) +} + export const getNfdResultAtom = (nfdLookup: NfdLookup): Atom> => { return atom(async (get) => { if (!networkConfig.nfdApiUrl) { @@ -101,7 +164,7 @@ export const getNfdResultAtom = (nfdLookup: NfdLookup): Atom { + return useMemo(() => { + return getNfdResultAtom({ address: address }) + }, [address]) +} + +export const useNfdResultAtom = (nfd: Nfd) => { + return useMemo(() => { + return getNfdResultAtom({ nfd: nfd }) + }, [nfd]) +} + const [forwardNfdResultsAtom, getForwardNfdResultAtom] = readOnlyAtomCache(getForwardLookupNfdResult, (nfd) => nfd) -const [reverseNfdsAtom, getReverseNfdAtom] = readOnlyAtomCache(getReverseLookupNfd, (address) => address) +const [reverseNfdsAtom, getReverseNfdAtom] = writableAtomCache(getReverseLookupNfd, (address) => address) export { forwardNfdResultsAtom, reverseNfdsAtom } diff --git a/src/features/nfd/data/nfd.ts b/src/features/nfd/data/nfd.ts new file mode 100644 index 000000000..c3af10e57 --- /dev/null +++ b/src/features/nfd/data/nfd.ts @@ -0,0 +1,15 @@ +import { Address } from '@/features/accounts/data/types' +import { useAtomValue } from 'jotai' +import { useNfdAtom, useNfdResultAtom } from './nfd-result' +import { loadable } from 'jotai/utils' +import { Nfd } from './types' + +export const useLoadableNfd = (address: Address) => { + const nfdAtom = useNfdAtom(address) + return [useAtomValue(loadable(nfdAtom))] as const +} + +export const useLoadableNfdResult = (nfd: Nfd) => { + const nfdAtom = useNfdResultAtom(nfd) + return [useAtomValue(loadable(nfdAtom))] as const +} diff --git a/src/features/search/components/search.test.tsx b/src/features/search/components/search.test.tsx index c46a2899b..55d1a1bf0 100644 --- a/src/features/search/components/search.test.tsx +++ b/src/features/search/components/search.test.tsx @@ -2,7 +2,7 @@ import { Search, noSearchResultsMessage, searchPlaceholderLabel } from './search import { describe, it, expect, vi } from 'vitest' import { render, waitFor } from '@/tests/testing-library' import { executeComponentTest } from '@/tests/test-component' -import { createStore } from 'jotai' +import { atom, createStore } from 'jotai' import { assetResultMother } from '@/tests/object-mother/asset-result' import { applicationResultsAtom } from '@/features/applications/data' import { applicationResultMother } from '@/tests/object-mother/application-result' @@ -14,6 +14,8 @@ import { assetResultsAtom } from '@/features/assets/data' import { createReadOnlyAtomAndTimestamp } from '@/features/common/data' import { transactionResultsAtom } from '@/features/transactions/data' import { transactionResultMother } from '@/tests/object-mother/transaction-result' +import { forwardNfdResultsAtom, reverseNfdsAtom } from '@/features/nfd/data/nfd-result' +import { nfdResultMother } from '@/tests/object-mother/nfd-result' describe('search', () => { describe('when no search results have been returned', () => { @@ -75,6 +77,10 @@ describe('search', () => { it(`should navigate to the ${type.toLowerCase()} page`, () => { const mockNavigate = vi.fn() vi.mocked(useNavigate).mockReturnValue(mockNavigate) + if (type === SearchResultType.Account) { + const mockReverseNfdAtom = atom | null>(null) + myStore.set(reverseNfdsAtom, new Map([[id, [mockReverseNfdAtom, Date.now()] as const]])) + } return executeComponentTest( () => render(, undefined, myStore), @@ -95,4 +101,44 @@ describe('search', () => { }) }) }) + + describe('when search results for nfd have been returned', () => { + const nfdResult = nfdResultMother['mainnet-datamuseum.algo']().build() + const myStore = createStore() + myStore.set(forwardNfdResultsAtom, new Map([[nfdResult.name, createReadOnlyAtomAndTimestamp(nfdResult)]])) + + describe.each([ + { + type: SearchResultType.Account, + term: 'datamuseum.algo', + id: 'DHMCHBN4W5MBO72C3L3ZP6GGJHQ4OR6SW2EP3VDEJ5VHT4MERQLCTVW6PU', + label: 'DHMC…W6PU (datamuseum.algo)', + }, + ])( + 'and the $type result is selected for nfd', + ({ type, id, term, label }: { type: SearchResultType; id: string; term: string; label: string }) => { + it(`should navigate to the ${type.toLowerCase()} page`, () => { + const mockNavigate = vi.fn() + vi.mocked(useNavigate).mockReturnValue(mockNavigate) + + return executeComponentTest( + () => render(, undefined, myStore), + async (component, user) => { + await waitFor( + async () => { + const input = component.getByPlaceholderText(searchPlaceholderLabel) + await user.type(input, term) + const results = (await component.findAllByText(label, undefined, { timeout: 1000 })).map((result) => result.parentElement) + const result = results.find((result) => result!.textContent!.includes(type))! + await user.click(result) + expect(mockNavigate).toHaveBeenCalledWith(`/localnet/${type.toLowerCase()}/${id}`) + }, + { timeout: 10000 } + ) + } + ) + }) + } + ) + }) }) diff --git a/src/features/search/data/search.ts b/src/features/search/data/search.ts index 2f2d5c5cc..1e50d10a2 100644 --- a/src/features/search/data/search.ts +++ b/src/features/search/data/search.ts @@ -17,7 +17,7 @@ import { syncedRoundAtom } from '@/features/blocks/data' import { createApplicationSummaryAtom } from '@/features/applications/data/application-summary' import { useSelectedNetwork } from '@/features/network/data' import { getTransactionResultAtom } from '@/features/transactions/data' -import { isNFD } from '@/features/nfd/data/is-nfd' +import { isNfd } from '@/features/nfd/data/is-nfd' import { getNfdResultAtom } from '@/features/nfd/data/nfd-result' const handle404 = (e: Error) => { @@ -44,14 +44,14 @@ const createSearchAtoms = (store: JotaiStore, selectedNetwork: string) => { if (isAddress(term)) { const nfdAtom = getNfdResultAtom({ address: term }) - const nfd = await get(nfdAtom) + const nfd = await get(nfdAtom) // TODO: NC - This blocks until the NFD is resolved, maybe we should do this in the UI instead? results.push({ type: SearchResultType.Account, id: term, label: `${ellipseAddress(term)}${nfd ? ` (${nfd.name})` : ''}`, url: Urls.Explore.Account.ByAddress.build({ address: term, networkId: selectedNetwork }), }) - } else if (isNFD(term)) { + } else if (isNfd(term)) { const nfdAtom = getNfdResultAtom({ nfd: term }) const nfd = await get(nfdAtom) if (nfd && isAddress(nfd.depositAccount)) { diff --git a/src/features/transaction-wizard/components/account-close-transaction-builder.tsx b/src/features/transaction-wizard/components/account-close-transaction-builder.tsx index df0371748..9b2bb5999 100644 --- a/src/features/transaction-wizard/components/account-close-transaction-builder.tsx +++ b/src/features/transaction-wizard/components/account-close-transaction-builder.tsx @@ -12,9 +12,9 @@ import { Form } from '@/features/forms/components/form' import { BuildableTransactionType, BuildAccountCloseTransactionResult } from '../models' import { randomGuid } from '@/utils/random-guid' import { TransactionBuilderMode } from '../data' -import { ZERO_ADDRESS } from '@/features/common/constants' import SvgAlgorand from '@/features/common/components/icons/algorand' import { TransactionBuilderNoteField } from './transaction-builder-note-field' +import { TransactionBuilderAddressField } from './transaction-builder-address-field' const senderLabel = 'Sender' const receiverLabel = 'Receiver' @@ -109,24 +109,21 @@ export function AccountCloseTransactionBuilder({ mode, transaction, activeAddres > {(helper) => ( <> - {helper.textField({ - field: 'sender', - label: senderLabel, - helpText: 'Account to be closed. Sends the transaction and pays the fee', - placeholder: ZERO_ADDRESS, - })} - {helper.textField({ - field: 'closeRemainderTo', - label: closeRemainderToLabel, - helpText: `Account to receive the remaining balance when '${senderLabel}' account is closed`, - placeholder: ZERO_ADDRESS, - })} - {helper.textField({ - field: 'receiver', - label: receiverLabel, - helpText: `Account to receive the amount. Leave blank if '${closeRemainderToLabel}' account should receive the full balance`, - placeholder: ZERO_ADDRESS, - })} + + + {helper.numberField({ field: 'amount', label: ( diff --git a/src/features/transaction-wizard/components/app-call-transaction-builder.tsx b/src/features/transaction-wizard/components/app-call-transaction-builder.tsx index 25e096ca1..3beafc1f8 100644 --- a/src/features/transaction-wizard/components/app-call-transaction-builder.tsx +++ b/src/features/transaction-wizard/components/app-call-transaction-builder.tsx @@ -14,6 +14,7 @@ import { BuildAppCallTransactionResult, BuildableTransactionType } from '../mode import { randomGuid } from '@/utils/random-guid' import { TransactionBuilderMode } from '../data' import { TransactionBuilderNoteField } from './transaction-builder-note-field' +import { TransactionBuilderAddressField } from './transaction-builder-address-field' const formData = zfd.formData({ ...commonSchema, @@ -108,11 +109,11 @@ export function AppCallTransactionBuilder({ mode, transaction, activeAddress, de options: onCompleteOptions, helpText: 'Action to perform after executing the program', })} - {helper.textField({ - field: 'sender', - label: 'Sender', - helpText: 'Account to call from. Sends the transaction and pays the fee', - })} + {helper.arrayField({ field: 'args', label: 'Arguments', diff --git a/src/features/transaction-wizard/components/asset-clawback-transaction-builder.tsx b/src/features/transaction-wizard/components/asset-clawback-transaction-builder.tsx index bc63e963a..05d3dbf2a 100644 --- a/src/features/transaction-wizard/components/asset-clawback-transaction-builder.tsx +++ b/src/features/transaction-wizard/components/asset-clawback-transaction-builder.tsx @@ -17,10 +17,10 @@ import { useFormContext, UseFormReturn } from 'react-hook-form' import { useLoadableAssetSummaryAtom } from '@/features/assets/data' import { RenderLoadable } from '@/features/common/components/render-loadable' import { AssetId } from '@/features/assets/data/types' -import { ZERO_ADDRESS } from '@/features/common/constants' import { useDebounce } from 'use-debounce' import { TransactionBuilderMode } from '../data' import { TransactionBuilderNoteField } from './transaction-builder-note-field' +import { TransactionBuilderAddressField } from './transaction-builder-address-field' const clawbackTargetLabel = 'Clawback target' @@ -79,24 +79,17 @@ function FormFields({ helper, asset }: FormFieldsProps) { label: Asset ID {asset && asset.name ? ` (${asset.name})` : ''}, helpText: 'The asset to be clawed back', })} - {helper.textField({ - field: 'sender', - label: 'Sender', - helpText: 'The clawback account of the asset. Sends the transaction and pays the fee', - placeholder: ZERO_ADDRESS, - })} - {helper.textField({ - field: 'receiver', - label: 'Receiver', - helpText: 'Account to receive the asset', - placeholder: ZERO_ADDRESS, - })} - {helper.textField({ - field: 'clawbackTarget', - label: clawbackTargetLabel, - helpText: 'Account the asset will be clawed back from', - placeholder: ZERO_ADDRESS, - })} + + + {helper.numberField({ field: 'amount', label: Amount{asset && asset.unitName ? ` (${asset.unitName})` : ''}, diff --git a/src/features/transaction-wizard/components/asset-create-transaction-builder.tsx b/src/features/transaction-wizard/components/asset-create-transaction-builder.tsx index 90ce73944..0b8390f6a 100644 --- a/src/features/transaction-wizard/components/asset-create-transaction-builder.tsx +++ b/src/features/transaction-wizard/components/asset-create-transaction-builder.tsx @@ -12,9 +12,9 @@ import { Form } from '@/features/forms/components/form' import { BuildableTransactionType, BuildAssetCreateTransactionResult } from '../models' import { randomGuid } from '@/utils/random-guid' import { FormFieldHelper } from '@/features/forms/components/form-field-helper' -import { ZERO_ADDRESS } from '@/features/common/constants' import { TransactionBuilderMode } from '../data' import { TransactionBuilderNoteField } from './transaction-builder-note-field' +import { TransactionBuilderAddressField } from './transaction-builder-address-field' const formSchema = { ...commonSchema, @@ -61,36 +61,31 @@ function FormFields({ helper }: FormFieldsProps) { label: 'Decimals', helpText: "Set to 0 for a non-divisible asset. Can't be changed after creation", })} - {helper.textField({ - field: 'sender', - label: 'Creator', - helpText: 'Account that creates the asset. Sends the transaction and pays the fee', - placeholder: ZERO_ADDRESS, - })} - {helper.textField({ - field: 'manager', - label: 'Manager', - helpText: "Account that can re-configure and destroy the asset. If empty, the asset can't be re-configured", - placeholder: ZERO_ADDRESS, - })} - {helper.textField({ - field: 'reserve', - label: 'Reserve', - helpText: "Account that holds the reserve units of the asset. If empty, this address can't be changed", - placeholder: ZERO_ADDRESS, - })} - {helper.textField({ - field: 'freeze', - label: 'Freeze', - helpText: "Account that can freeze the asset. If empty, assets can't be frozen and this address can't be changed", - placeholder: ZERO_ADDRESS, - })} - {helper.textField({ - field: 'clawback', - label: 'Clawback', - helpText: "Account that can claw back the asset. If empty, assets can't be clawed back and this address can't be changed", - placeholder: ZERO_ADDRESS, - })} + + + + + {helper.checkboxField({ field: 'defaultFrozen', label: 'Freeze holdings of this asset by default', diff --git a/src/features/transaction-wizard/components/asset-destroy-transaction-builder.tsx b/src/features/transaction-wizard/components/asset-destroy-transaction-builder.tsx index 87ced4701..7880146f5 100644 --- a/src/features/transaction-wizard/components/asset-destroy-transaction-builder.tsx +++ b/src/features/transaction-wizard/components/asset-destroy-transaction-builder.tsx @@ -17,13 +17,13 @@ import { useFormContext, UseFormReturn } from 'react-hook-form' import { useLoadableAssetSummaryAtom } from '@/features/assets/data' import { RenderLoadable } from '@/features/common/components/render-loadable' import { AssetId } from '@/features/assets/data/types' -import { ZERO_ADDRESS } from '@/features/common/constants' import { useDebounce } from 'use-debounce' import { AccountLink } from '@/features/accounts/components/account-link' import { ellipseAddress } from '@/utils/ellipse-address' import { cn } from '@/features/common/utils' import { TransactionBuilderMode } from '../data' import { TransactionBuilderNoteField } from './transaction-builder-note-field' +import { TransactionBuilderAddressField } from './transaction-builder-address-field' const formSchema = { ...commonSchema, @@ -77,12 +77,11 @@ function FormFields({ helper, asset }: FormFieldsProps) {  must hold all units )} - {helper.textField({ - field: 'sender', - label: 'Sender', - helpText: 'The current asset manager address. Sends the transaction and pays the fee', - placeholder: ZERO_ADDRESS, - })} + diff --git a/src/features/transaction-wizard/components/asset-opt-in-transaction-builder.tsx b/src/features/transaction-wizard/components/asset-opt-in-transaction-builder.tsx index 46e1ddd82..f1dae7441 100644 --- a/src/features/transaction-wizard/components/asset-opt-in-transaction-builder.tsx +++ b/src/features/transaction-wizard/components/asset-opt-in-transaction-builder.tsx @@ -17,10 +17,10 @@ import { useFormContext, UseFormReturn } from 'react-hook-form' import { useLoadableAssetSummaryAtom } from '@/features/assets/data' import { RenderLoadable } from '@/features/common/components/render-loadable' import { AssetId } from '@/features/assets/data/types' -import { ZERO_ADDRESS } from '@/features/common/constants' import { useDebounce } from 'use-debounce' import { TransactionBuilderMode } from '../data' import { TransactionBuilderNoteField } from './transaction-builder-note-field' +import { TransactionBuilderAddressField } from './transaction-builder-address-field' const formSchema = { ...commonSchema, @@ -58,12 +58,11 @@ function FormFields({ helper, asset }: FormFieldsProps) { label: Asset ID {asset && asset.name ? ` (${asset.name})` : ''}, helpText: 'The asset to be opted in to', })} - {helper.textField({ - field: 'sender', - label: 'Sender', - helpText: 'Account to opt in to the asset. Sends the transaction and pays the fee', - placeholder: ZERO_ADDRESS, - })} + diff --git a/src/features/transaction-wizard/components/asset-opt-out-transaction-builder.tsx b/src/features/transaction-wizard/components/asset-opt-out-transaction-builder.tsx index 5d6e2195e..0a46eb167 100644 --- a/src/features/transaction-wizard/components/asset-opt-out-transaction-builder.tsx +++ b/src/features/transaction-wizard/components/asset-opt-out-transaction-builder.tsx @@ -17,10 +17,10 @@ import { useFormContext, UseFormReturn } from 'react-hook-form' import { useLoadableAssetSummaryAtom } from '@/features/assets/data' import { RenderLoadable } from '@/features/common/components/render-loadable' import { AssetId } from '@/features/assets/data/types' -import { ZERO_ADDRESS } from '@/features/common/constants' import { useDebounce } from 'use-debounce' import { TransactionBuilderMode } from '../data' import { TransactionBuilderNoteField } from './transaction-builder-note-field' +import { TransactionBuilderAddressField } from './transaction-builder-address-field' const formSchema = { ...commonSchema, @@ -59,18 +59,16 @@ function FormFields({ helper, asset }: FormFieldsProps) { label: Asset ID {asset && asset.name ? ` (${asset.name})` : ''}, helpText: 'The asset to be opted out of', })} - {helper.textField({ - field: 'sender', - label: 'Sender', - helpText: 'Account to opt out of the asset. Sends the transaction and pays the fee', - placeholder: ZERO_ADDRESS, - })} - {helper.textField({ - field: 'closeRemainderTo', - label: 'Close remainder to', - helpText: 'Account to receive the remaining balance of the asset', - placeholder: ZERO_ADDRESS, - })} + + diff --git a/src/features/transaction-wizard/components/asset-reconfigure-transaction-builder.tsx b/src/features/transaction-wizard/components/asset-reconfigure-transaction-builder.tsx index e1d439697..6b641cf84 100644 --- a/src/features/transaction-wizard/components/asset-reconfigure-transaction-builder.tsx +++ b/src/features/transaction-wizard/components/asset-reconfigure-transaction-builder.tsx @@ -17,10 +17,10 @@ import { useFormContext, UseFormReturn } from 'react-hook-form' import { useLoadableAssetSummaryAtom } from '@/features/assets/data' import { RenderLoadable } from '@/features/common/components/render-loadable' import { AssetId } from '@/features/assets/data/types' -import { ZERO_ADDRESS } from '@/features/common/constants' import { useDebounce } from 'use-debounce' import { TransactionBuilderMode } from '../data' import { TransactionBuilderNoteField } from './transaction-builder-note-field' +import { TransactionBuilderAddressField } from './transaction-builder-address-field' const formSchema = { ...commonSchema, @@ -68,36 +68,31 @@ function FormFields({ helper, asset }: FormFieldsProps) { label: Asset ID {asset && asset.name ? ` (${asset.name})` : ''}, helpText: 'The asset to be reconfigured', })} - {helper.textField({ - field: 'sender', - label: 'Sender', - helpText: 'The current asset manager address. Sends the transaction and pays the fee', - placeholder: ZERO_ADDRESS, - })} - {helper.textField({ - field: 'manager', - label: 'Manager', - helpText: "Account that can re-configure and destroy the asset. If empty, the asset can't be re-configured", - placeholder: ZERO_ADDRESS, - })} - {helper.textField({ - field: 'reserve', - label: 'Reserve', - helpText: "Account that holds the reserve units of the asset. If empty, this address can't be changed", - placeholder: ZERO_ADDRESS, - })} - {helper.textField({ - field: 'freeze', - label: 'Freeze', - helpText: "Account that can freeze the asset. If empty, assets can't be frozen and this address can't be changed", - placeholder: ZERO_ADDRESS, - })} - {helper.textField({ - field: 'clawback', - label: 'Clawback', - helpText: "Account that can claw back the asset. If empty, assets can't be clawed back and this address can't be changed", - placeholder: ZERO_ADDRESS, - })} + + + + + diff --git a/src/features/transaction-wizard/components/asset-transfer-transaction-builder.tsx b/src/features/transaction-wizard/components/asset-transfer-transaction-builder.tsx index 6ac5e8f40..422221365 100644 --- a/src/features/transaction-wizard/components/asset-transfer-transaction-builder.tsx +++ b/src/features/transaction-wizard/components/asset-transfer-transaction-builder.tsx @@ -17,10 +17,11 @@ import { useFormContext, UseFormReturn } from 'react-hook-form' import { useLoadableAssetSummaryAtom } from '@/features/assets/data' import { RenderLoadable } from '@/features/common/components/render-loadable' import { AssetId } from '@/features/assets/data/types' -import { ZERO_ADDRESS } from '@/features/common/constants' import { useDebounce } from 'use-debounce' import { TransactionBuilderMode } from '../data' import { TransactionBuilderNoteField } from './transaction-builder-note-field' +import { NfdResult } from '@/features/nfd/data/types' +import { TransactionBuilderAddressField } from './transaction-builder-address-field' const receiverLabel = 'Receiver' @@ -47,11 +48,12 @@ const formSchema = { amount: numberSchema(z.number({ required_error: 'Required', invalid_type_error: 'Required' })), } -const formData = zfd.formData(formSchema) +export const formData = zfd.formData(formSchema) type FormFieldsProps = { helper: FormFieldHelper> asset?: AssetSummary + nfd?: NfdResult } function FormFields({ helper, asset }: FormFieldsProps) { @@ -62,18 +64,12 @@ function FormFields({ helper, asset }: FormFieldsProps) { label: Asset ID {asset && asset.name ? ` (${asset.name})` : ''}, helpText: 'The asset to be transfered', })} - {helper.textField({ - field: 'sender', - label: 'Sender', - helpText: 'Account to transfer from. Sends the transaction and pays the fee', - placeholder: ZERO_ADDRESS, - })} - {helper.textField({ - field: 'receiver', - label: receiverLabel, - helpText: 'Account to receive the asset', - placeholder: ZERO_ADDRESS, - })} + + {helper.numberField({ field: 'amount', label: Amount{asset && asset.unitName ? ` (${asset.unitName})` : ''}, diff --git a/src/features/transaction-wizard/components/method-call-transaction-builder.tsx b/src/features/transaction-wizard/components/method-call-transaction-builder.tsx index 169911310..addc0c644 100644 --- a/src/features/transaction-wizard/components/method-call-transaction-builder.tsx +++ b/src/features/transaction-wizard/components/method-call-transaction-builder.tsx @@ -36,6 +36,7 @@ import { TransactionBuilder } from './transaction-builder' import { Arc32AppSpec } from '@/features/app-interfaces/data/types' import { AppSpec } from '@algorandfoundation/algokit-utils/types/app-spec' import { TransactionBuilderNoteField } from './transaction-builder-note-field' +import { TransactionBuilderAddressField } from './transaction-builder-address-field' const appCallFormSchema = { ...commonSchema, @@ -342,11 +343,11 @@ function FormInner({ helper, methodForm, onSetAppSpec, onSetMethodForm, onSetTra options: onCompleteOptions, helpText: 'Action to perform after executing the program', })} - {helper.textField({ - field: 'sender', - label: 'Sender', - helpText: 'Account to call from. Sends the transaction and pays the fee', - })} + {abiMethodArgs.map((arg, index) => (
{`Argument ${index + 1}`}
diff --git a/src/features/transaction-wizard/components/payment-transaction-builder.tsx b/src/features/transaction-wizard/components/payment-transaction-builder.tsx index fa1732dec..b8897a0c2 100644 --- a/src/features/transaction-wizard/components/payment-transaction-builder.tsx +++ b/src/features/transaction-wizard/components/payment-transaction-builder.tsx @@ -12,9 +12,9 @@ import { Form } from '@/features/forms/components/form' import { BuildableTransactionType, BuildPaymentTransactionResult } from '../models' import { randomGuid } from '@/utils/random-guid' import { TransactionBuilderMode } from '../data' -import { ZERO_ADDRESS } from '@/features/common/constants' import SvgAlgorand from '@/features/common/components/icons/algorand' import { TransactionBuilderNoteField } from './transaction-builder-note-field' +import { TransactionBuilderAddressField } from './transaction-builder-address-field' const receiverLabel = 'Receiver' @@ -87,18 +87,12 @@ export function PaymentTransactionBuilder({ mode, transaction, activeAddress, on > {(helper) => ( <> - {helper.textField({ - field: 'sender', - label: 'Sender', - helpText: 'Account to pay from. Sends the transaction and pays the fee', - placeholder: ZERO_ADDRESS, - })} - {helper.textField({ - field: 'receiver', - label: receiverLabel, - helpText: 'Account to receive the amount', - placeholder: ZERO_ADDRESS, - })} + + {helper.numberField({ field: 'amount', label: ( diff --git a/src/features/transaction-wizard/components/transaction-builder-address-field.tsx b/src/features/transaction-wizard/components/transaction-builder-address-field.tsx new file mode 100644 index 000000000..9e4fe748c --- /dev/null +++ b/src/features/transaction-wizard/components/transaction-builder-address-field.tsx @@ -0,0 +1,49 @@ +import { FormFieldHelper } from '@/features/forms/components/form-field-helper' +import { z } from 'zod' +import { useFormContext } from 'react-hook-form' +import { useLoadableNfdResult } from '@/features/nfd/data/nfd' +import { useDebounce } from 'use-debounce' +import { NfdResult } from '@/features/nfd/data/types' +import { ZERO_ADDRESS } from '@/features/common/constants' +import { useEffect, useState } from 'react' +import { isNfd } from '@/features/nfd/data/is-nfd' +import { commonAddressFormData } from '../data/common' +import { ellipseAddress } from '@/utils/ellipse-address' + +type TransactionBuilderAddressFieldProps = { + fieldName: keyof z.infer + helpText: string + label: string +} + +export function TransactionBuilderAddressField({ fieldName, helpText, label }: TransactionBuilderAddressFieldProps) { + const helper = new FormFieldHelper>() + const formCtx = useFormContext>() + const fieldValue = formCtx.watch(fieldName) + const [debouncedValue] = useDebounce(fieldValue && isNfd(String(fieldValue)) ? String(fieldValue) : '', 500) + const [loadableNfd] = useLoadableNfdResult(debouncedValue) + const [currentNfd, setCurrentNfd] = useState(undefined) + + useEffect(() => { + if (loadableNfd.state === 'hasData' && loadableNfd.data !== null) { + setCurrentNfd(loadableNfd.data) + } else { + setCurrentNfd(undefined) + } + }, [loadableNfd]) + + return ( +
+ {helper.textField({ + field: fieldName, + label: ( + + {label} {currentNfd ? ` (${ellipseAddress(currentNfd.depositAccount)})` : ''} + + ), + helpText: helpText, + placeholder: ZERO_ADDRESS, + })} +
+ ) +} diff --git a/src/features/transaction-wizard/data/common.ts b/src/features/transaction-wizard/data/common.ts index bc4364489..839775834 100644 --- a/src/features/transaction-wizard/data/common.ts +++ b/src/features/transaction-wizard/data/common.ts @@ -4,14 +4,17 @@ import { isAddress } from '@/utils/is-address' import { bigIntSchema, numberSchema } from '@/features/forms/data/common' import algosdk from 'algosdk' import { asOnCompleteLabel } from '../mappers/as-description-list-items' +import { isNfd } from '@/features/nfd/data/is-nfd' export const requiredMessage = 'Required' const invalidAddressMessage = 'Invalid address' -export const optionalAddressFieldSchema = zfd.text(z.string().optional()).refine((value) => (value ? isAddress(value) : true), { - message: invalidAddressMessage, -}) -export const addressFieldSchema = zfd.text().refine((value) => (value ? isAddress(value) : true), { +export const optionalAddressFieldSchema = zfd + .text(z.string().optional()) + .refine((value) => (value ? isAddress(value) || isNfd(value) : true), { + message: invalidAddressMessage, + }) +export const addressFieldSchema = zfd.text().refine((value) => (value ? isAddress(value) || isNfd(value) : true), { message: invalidAddressMessage, }) @@ -101,3 +104,16 @@ export const commonSchema = { } export const commonFormData = zfd.formData(commonSchema) + +export const commonAddressSchema = { + ...senderFieldSchema, + ...receiverFieldSchema, + closeRemainderTo: addressFieldSchema, + clawbackTarget: addressFieldSchema, + manager: optionalAddressFieldSchema, + reserve: optionalAddressFieldSchema, + freeze: optionalAddressFieldSchema, + clawback: optionalAddressFieldSchema, +} + +export const commonAddressFormData = zfd.formData(commonAddressSchema) diff --git a/src/features/transaction-wizard/transaction-wizard-page.test.tsx b/src/features/transaction-wizard/transaction-wizard-page.test.tsx index 7c7737e4c..951fd9e98 100644 --- a/src/features/transaction-wizard/transaction-wizard-page.test.tsx +++ b/src/features/transaction-wizard/transaction-wizard-page.test.tsx @@ -8,6 +8,7 @@ import { sendButtonLabel, transactionTypeLabel, TransactionWizardPage } from './ import { selectOption } from '@/tests/utils/select-option' import { setWalletAddressAndSigner } from '@/tests/utils/set-wallet-address-and-signer' import { addTransactionLabel } from './components/transactions-builder' +import { useLoadableNfd, useLoadableNfdResult } from '../nfd/data/nfd' describe('transaction-wizard-page', () => { const localnet = algorandFixture() @@ -47,6 +48,8 @@ describe('transaction-wizard-page', () => { describe('when a wallet is connected', () => { beforeEach(async () => { await setWalletAddressAndSigner(localnet) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) + vi.mocked(useLoadableNfdResult).mockReturnValue([{ state: 'loading' }]) }) describe('and a payment transaction is being sent', () => { diff --git a/src/features/transactions-graph/components/__snapshots__/application-transaction-graph.CF6HEO6Z5UZIPCUXTIAGUHHDV7W4FCZG5WPKUGU3BIJYF6X6SPYA.html b/src/features/transactions-graph/components/__snapshots__/application-transaction-graph.CF6HEO6Z5UZIPCUXTIAGUHHDV7W4FCZG5WPKUGU3BIJYF6X6SPYA.html index 1848a20ba..13b499943 100644 --- a/src/features/transactions-graph/components/__snapshots__/application-transaction-graph.CF6HEO6Z5UZIPCUXTIAGUHHDV7W4FCZG5WPKUGU3BIJYF6X6SPYA.html +++ b/src/features/transactions-graph/components/__snapshots__/application-transaction-graph.CF6HEO6Z5UZIPCUXTIAGUHHDV7W4FCZG5WPKUGU3BIJYF6X6SPYA.html @@ -115,7 +115,7 @@ class="flex items-center" > { transactionResultMother['mainnet-ILDCD5Z64CYSLEZIHBG5DVME2ITJI2DIVZAPDPEWPCYMTRA5SVGA']().build(), ])('when rendering transaction $id', (transactionResult: TransactionResult) => { it('should match snapshot', () => { + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const model = asPaymentTransaction(transactionResult) const graphData = asTransactionsGraphData([model]) return executeComponentTest( @@ -65,6 +67,7 @@ describe('asset-transfer-transaction-graph', () => { 'when rendering transaction $transactionResult.id', ({ transactionResult, assetResult }: { transactionResult: TransactionResult; assetResult: AssetResult }) => { it('should match snapshot', () => { + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const assetResolver = createAssetResolver([assetResult]) const transaction = asAssetTransferTransaction(transactionResult, assetResolver) const graphData = asTransactionsGraphData([transaction]) @@ -119,6 +122,7 @@ describe('application-call-graph', () => { 'when rendering transaction $transactionResult.id', ({ transactionResult, assetResults }: { transactionResult: TransactionResult; assetResults: AssetResult[] }) => { it('should match snapshot', () => { + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) vi.mocked(useParams).mockImplementation(() => ({ transactionId: transactionResult.id })) const model = asAppCallTransaction(transactionResult, createAssetResolver(assetResults), createAbiMethodResolver()) @@ -146,7 +150,7 @@ describe('key-reg-graph', () => { ])('when rendering transaction $transactionResult.id', ({ transactionResult }: { transactionResult: TransactionResult }) => { it('should match snapshot', () => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transactionResult.id })) - + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const model = asKeyRegTransaction(transactionResult) const graphData = asTransactionsGraphData([model]) return executeComponentTest( @@ -220,6 +224,7 @@ describe('group-graph', () => { assetResults: AssetResult[] }) => { it('should match snapshot', () => { + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const assetResolver = createAssetResolver(assetResults) const transactions = transactionResults.map((t) => asTransaction(t, assetResolver, createAbiMethodResolver())) const groupResult = groupResultMother.groupWithTransactions(transactionResults).withId(groupId).build() diff --git a/src/features/transactions-graph/components/vertical-title.tsx b/src/features/transactions-graph/components/vertical-title.tsx index c523e7a44..37a62f96c 100644 --- a/src/features/transactions-graph/components/vertical-title.tsx +++ b/src/features/transactions-graph/components/vertical-title.tsx @@ -38,7 +38,7 @@ export function VerticalTitle({ vertical }: { vertical: Vertical }) { {vertical.type === 'Account' && (
}> - + {vertical.associatedAccounts.map((associatedAccount, i) => ( @@ -54,7 +54,7 @@ export function VerticalTitle({ vertical }: { vertical: Vertical }) { leftComponent={} rightComponent={} > - + {vertical.associatedAccounts.map((associatedAccount, i) => ( diff --git a/src/features/transactions/components/latest-transactions.tsx b/src/features/transactions/components/latest-transactions.tsx index 251608667..b00c5344f 100644 --- a/src/features/transactions/components/latest-transactions.tsx +++ b/src/features/transactions/components/latest-transactions.tsx @@ -7,6 +7,11 @@ import { DescriptionList } from '@/features/common/components/description-list' import { ArrowRightLeft, Info } from 'lucide-react' import { Badge } from '@/features/common/components/badge' import { TransactionSummary } from '@/features/transactions/models' +import { useLoadableNfd } from '@/features/nfd/data/nfd' +import { RenderLoadable } from '@/features/common/components/render-loadable' +import { useMemo } from 'react' +import { Loadable } from 'jotai/vanilla/utils/loadable' +import { NfdResult } from '@/features/nfd/data/types' export const latestTransactionsTitle = 'Latest Transactions' @@ -14,6 +19,58 @@ type Props = { latestTransactions: TransactionSummary[] } +function useConditionalLoadableNfdResult(address: string | number | undefined) { + const [loadableNfd] = useLoadableNfd(typeof address === 'string' ? address : '') + return useMemo(() => ({ loadableNfd, isString: typeof address === 'string' }), [address, loadableNfd]) as { + loadableNfd: Loadable + isString: boolean + } +} + +function Transaction({ transaction }: { transaction: TransactionSummary }) { + const { loadableNfd: loadableNfdFrom } = useConditionalLoadableNfdResult(transaction.from) + const { loadableNfd: loadableNfdTo, isString: isTransactionToString } = useConditionalLoadableNfdResult(transaction.to) + + return ( +
  • + + +
    +

    {ellipseId(transaction.id)}

    + + {(nfd) => <>{nfd?.name ?? ellipseAddress(transaction.from)}} + + ), + }, + { + dt: 'To:', + dd: isTransactionToString ? ( + + {(nfd) => <>{nfd?.name ?? ellipseAddress(transaction.to as string)}} + + ) : ( + <>{transaction.to} + ), + }, + ]} + /> +
    + + {transaction.type} + +
    +
  • + ) +} + export function LatestTransactions({ latestTransactions }: Props) { return ( @@ -22,29 +79,7 @@ export function LatestTransactions({ latestTransactions }: Props) { {latestTransactions.length > 0 && (
      {latestTransactions.map((transaction) => ( -
    • - - -
      -

      {ellipseId(transaction.id)}

      - -
      - - {transaction.type} - -
      -
    • + ))}
    )} diff --git a/src/features/transactions/pages/transaction-page.test.tsx b/src/features/transactions/pages/transaction-page.test.tsx index ad8bd0541..2dd3e19aa 100644 --- a/src/features/transactions/pages/transaction-page.test.tsx +++ b/src/features/transactions/pages/transaction-page.test.tsx @@ -86,6 +86,7 @@ import { AppInterfaceEntity, dbConnectionAtom } from '@/features/common/data/ind import { genesisHashAtom } from '@/features/blocks/data' import { writeAppInterface } from '@/features/app-interfaces/data' import { algod } from '@/features/common/data/algo-client' +import { useLoadableNfd } from '@/features/nfd/data/nfd' vi.mock('@/features/common/data/algo-client', async () => { const original = await vi.importActual('@/features/common/data/algo-client') @@ -159,6 +160,8 @@ describe('transaction-page', () => { it('should be rendered with the correct data', () => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transaction.id })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) + const myStore = createStore() myStore.set(transactionResultsAtom, new Map([[transaction.id, createReadOnlyAtomAndTimestamp(transaction)]])) @@ -218,6 +221,7 @@ describe('transaction-page', () => { beforeEach(() => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transaction.id })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) }) it('should show the multisig information', () => { @@ -259,6 +263,7 @@ describe('transaction-page', () => { beforeEach(() => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transaction.id })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) }) it('should show 2 tabs with the logicsig base64 as default', () => { @@ -320,6 +325,7 @@ describe('transaction-page', () => { beforeEach(() => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transaction.id })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) }) it('should show 2 tabs with the note base64 as default', () => { @@ -370,6 +376,7 @@ describe('transaction-page', () => { beforeEach(() => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transaction.id })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) }) it('should show 3 tabs with the note json as default', () => { @@ -437,6 +444,7 @@ describe('transaction-page', () => { beforeEach(() => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transaction.id })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) }) it('should show 3 tabs with the note arc-2 as default', () => { @@ -504,6 +512,7 @@ describe('transaction-page', () => { it('should be rendered with the correct data', () => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transaction.id })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const myStore = createStore() myStore.set(transactionResultsAtom, new Map([[transaction.id, createReadOnlyAtomAndTimestamp(transaction)]])) myStore.set(assetResultsAtom, new Map([[asset.index, createReadOnlyAtomAndTimestamp(asset)]])) @@ -565,6 +574,7 @@ describe('transaction-page', () => { it('should be rendered with the correct data', () => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transaction.id })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const myStore = createStore() myStore.set(transactionResultsAtom, new Map([[transaction.id, createReadOnlyAtomAndTimestamp(transaction)]])) myStore.set(assetResultsAtom, new Map([[asset.index, createReadOnlyAtomAndTimestamp(asset)]])) @@ -590,6 +600,8 @@ describe('transaction-page', () => { it('should be rendered with the correct data', () => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transaction.id })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) + const myStore = createStore() myStore.set(transactionResultsAtom, new Map([[transaction.id, createReadOnlyAtomAndTimestamp(transaction)]])) myStore.set(assetResultsAtom, new Map([[asset.index, createReadOnlyAtomAndTimestamp(asset)]])) @@ -615,6 +627,8 @@ describe('transaction-page', () => { it('should be rendered with the correct data', () => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transaction.id })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) + const myStore = createStore() myStore.set(transactionResultsAtom, new Map([[transaction.id, createReadOnlyAtomAndTimestamp(transaction)]])) myStore.set(assetResultsAtom, new Map([[asset.index, createReadOnlyAtomAndTimestamp(asset)]])) @@ -648,6 +662,8 @@ describe('transaction-page', () => { it('should be rendered with the correct data', () => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transaction.id })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) + const myStore = createStore() myStore.set(transactionResultsAtom, new Map([[transaction.id, createReadOnlyAtomAndTimestamp(transaction)]])) myStore.set( @@ -747,6 +763,8 @@ describe('transaction-page', () => { it('should be rendered with the correct data', () => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transaction.id, '*': '2' })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) + const myStore = createStore() myStore.set(transactionResultsAtom, new Map([[transaction.id, createReadOnlyAtomAndTimestamp(transaction)]])) myStore.set( @@ -877,6 +895,7 @@ describe('transaction-page', () => { it('should be rendered with the correct data', () => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transaction.id, '*': '2/1' })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const myStore = createStore() myStore.set(transactionResultsAtom, new Map([[transaction.id, createReadOnlyAtomAndTimestamp(transaction)]])) myStore.set( @@ -914,6 +933,7 @@ describe('transaction-page', () => { it('should be rendered without error', () => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transaction.id })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const myStore = createStore() myStore.set(transactionResultsAtom, new Map([[transaction.id, createReadOnlyAtomAndTimestamp(transaction)]])) myStore.set( @@ -945,6 +965,8 @@ describe('transaction-page', () => { it('should be rendered with the correct data', () => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transaction.id })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) + const myStore = createStore() myStore.set(transactionResultsAtom, new Map([[transaction.id, createReadOnlyAtomAndTimestamp(transaction)]])) @@ -978,6 +1000,8 @@ describe('transaction-page', () => { it('should be rendered with the correct data', () => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transaction.id })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) + const myStore = createStore() myStore.set(transactionResultsAtom, new Map([[transaction.id, createReadOnlyAtomAndTimestamp(transaction)]])) @@ -1039,6 +1063,8 @@ describe('transaction-page', () => { it('should be rendered with the correct data', () => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transaction.id })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) + const myStore = createStore() myStore.set(transactionResultsAtom, new Map([[transaction.id, createReadOnlyAtomAndTimestamp(transaction)]])) @@ -1069,6 +1095,8 @@ describe('transaction-page', () => { it('should be rendered with the correct data', () => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transaction.id })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) + const myStore = createStore() myStore.set(transactionResultsAtom, new Map([[transaction.id, createReadOnlyAtomAndTimestamp(transaction)]])) myStore.set(assetResultsAtom, new Map([[asset.index, createReadOnlyAtomAndTimestamp(asset)]])) @@ -1132,6 +1160,8 @@ describe('transaction-page', () => { it('should be rendered correctly', () => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transaction.id })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) + const myStore = createStore() myStore.set(transactionResultsAtom, new Map([[transaction.id, createReadOnlyAtomAndTimestamp(transaction)]])) return executeComponentTest( @@ -1161,6 +1191,8 @@ describe('transaction-page', () => { it('should be rendered with the correct data', () => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transaction.id })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) + const myStore = createStore() myStore.set(transactionResultsAtom, new Map([[transaction.id, createReadOnlyAtomAndTimestamp(transaction)]])) @@ -1197,6 +1229,8 @@ describe('transaction-page', () => { it('should be rendered with the correct data', () => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transaction.id })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) + const myStore = createStore() myStore.set(transactionResultsAtom, new Map([[transaction.id, createReadOnlyAtomAndTimestamp(transaction)]])) @@ -1229,6 +1263,8 @@ describe('when rendering a rekey transaction', () => { it('should be rendered with the correct data', () => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transaction.id })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) + const myStore = createStore() myStore.set(transactionResultsAtom, new Map([[transaction.id, createReadOnlyAtomAndTimestamp(transaction)]])) @@ -1257,6 +1293,7 @@ describe('when rendering an app call transaction with ARC-32 app spec loaded', ( it('should be rendered with the correct data', async () => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transaction.id })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) const myStore = createStore() myStore.set(genesisHashAtom, 'some-hash') myStore.set(transactionResultsAtom, new Map([[transaction.id, createReadOnlyAtomAndTimestamp(transaction)]])) @@ -1301,6 +1338,8 @@ describe('when rendering an app call transaction with ARC-4 app spec loaded', () it('should be rendered with the correct data', async () => { vi.mocked(useParams).mockImplementation(() => ({ transactionId: transaction.id })) + vi.mocked(useLoadableNfd).mockReturnValue([{ state: 'loading' }]) + const myStore = createStore() myStore.set(genesisHashAtom, 'some-hash') myStore.set(transactionResultsAtom, new Map([[transaction.id, createReadOnlyAtomAndTimestamp(transaction)]])) diff --git a/src/features/wallet/components/connect-wallet-button.tsx b/src/features/wallet/components/connect-wallet-button.tsx index f17372ce7..6ff1589ba 100644 --- a/src/features/wallet/components/connect-wallet-button.tsx +++ b/src/features/wallet/components/connect-wallet-button.tsx @@ -2,7 +2,7 @@ import { Button } from '@/features/common/components/button' import { cn } from '@/features/common/utils' import { Account, PROVIDER_ID, Provider, useWallet } from '@txnlab/use-wallet' import { Dialog, DialogContent, DialogHeader, SmallSizeDialogBody } from '@/features/common/components/dialog' -import { ellipseAddress } from '@/utils/ellipse-address' +import { ellipseAddress, ellipseNfd } from '@/utils/ellipse-address' import { AccountLink } from '@/features/accounts/components/account-link' import { Loader2 as Loader, CircleMinus, Wallet } from 'lucide-react' import { useNetworkConfig } from '@/features/network/data' @@ -20,6 +20,8 @@ import { walletDialogOpenAtom } from '../data/wallet-dialog' import { clearAvailableWallets } from '../utils/clear-available-wallets' import { useDisconnectWallet } from '../hooks/use-disconnect-wallet' import { CopyButton } from '@/features/common/components/copy-button' +import { useLoadableNfd } from '@/features/nfd/data/nfd' +import { RenderLoadable } from '@/features/common/components/render-loadable' export const connectWalletLabel = 'Connect Wallet' export const disconnectWalletLabel = 'Disconnect Wallet' @@ -68,7 +70,7 @@ function ConnectedWallet({ activeAddress, connectedActiveAccounts, providers }: }, [activeProvider] ) - + const [loadableNfd] = useLoadableNfd(activeAddress) return ( @@ -84,7 +86,11 @@ function ConnectedWallet({ activeAddress, connectedActiveAccounts, providers }: /> ))} - {ellipseAddress(activeAddress)} + {loadableNfd.state === 'hasData' && loadableNfd.data !== null ? ( + {(nfd) => {ellipseNfd(nfd?.name)}} + ) : ( + <>{ellipseAddress(activeAddress)} + )} diff --git a/src/tests/builders/nfd-result-builder.ts b/src/tests/builders/nfd-result-builder.ts new file mode 100644 index 000000000..0656114e7 --- /dev/null +++ b/src/tests/builders/nfd-result-builder.ts @@ -0,0 +1,18 @@ +import { NfdResult } from '@/features/nfd/data/types' +import { DataBuilder, dossierProxy, randomString } from '@makerx/ts-dossier' + +export class NfdResultBuilder extends DataBuilder { + constructor(initialState?: NfdResult) { + super( + initialState + ? initialState + : { + name: randomString(5, 20), + depositAccount: randomString(52, 52), + caAlgo: [randomString(52, 52), randomString(52, 52)], + } + ) + } +} + +export const nfdResultBuilder = dossierProxy(NfdResultBuilder) diff --git a/src/tests/object-mother/account-result.ts b/src/tests/object-mother/account-result.ts index 6e645038b..7c79dddaa 100644 --- a/src/tests/object-mother/account-result.ts +++ b/src/tests/object-mother/account-result.ts @@ -279,4 +279,29 @@ export const accountResultMother = { 'total-created-assets': 984393, }) }, + ['mainnet-DHMCHBN4W5MBO72C3L3ZP6GGJHQ4OR6SW2EP3VDEJ5VHT4MERQLCTVW6PU']: () => { + return new AccountResultBuilder({ + address: 'DHMCHBN4W5MBO72C3L3ZP6GGJHQ4OR6SW2EP3VDEJ5VHT4MERQLCTVW6PU', + amount: 1915706350, + 'amount-without-pending-rewards': 1915706350, + 'apps-local-state': [], + 'apps-total-schema': { + 'num-byte-slice': 0, + 'num-uint': 0, + }, + assets: [], + 'created-apps': [], + 'created-assets': [], + 'min-balance': 100000, + 'pending-rewards': 0, + 'reward-base': 218288, + rewards: 19424, + round: 43483662, + status: AccountStatus.Offline, + 'total-apps-opted-in': 0, + 'total-assets-opted-in': 0, + 'total-created-apps': 0, + 'total-created-assets': 0, + } satisfies AccountResult) + }, } diff --git a/src/tests/object-mother/nfd-result.ts b/src/tests/object-mother/nfd-result.ts new file mode 100644 index 000000000..17133e13c --- /dev/null +++ b/src/tests/object-mother/nfd-result.ts @@ -0,0 +1,15 @@ +import { NfdResult } from '@/features/nfd/data/types' +import { NfdResultBuilder, nfdResultBuilder } from '../builders/nfd-result-builder' + +export const nfdResultMother = { + basic: () => { + return nfdResultBuilder() + }, + ['mainnet-datamuseum.algo']: () => { + return new NfdResultBuilder({ + name: 'datamuseum.algo', + depositAccount: 'DHMCHBN4W5MBO72C3L3ZP6GGJHQ4OR6SW2EP3VDEJ5VHT4MERQLCTVW6PU', + caAlgo: ['DHMCHBN4W5MBO72C3L3ZP6GGJHQ4OR6SW2EP3VDEJ5VHT4MERQLCTVW6PU'], + } satisfies NfdResult) + }, +} diff --git a/src/tests/setup/mocks/index.ts b/src/tests/setup/mocks/index.ts index 7f8370993..d87cd9287 100644 --- a/src/tests/setup/mocks/index.ts +++ b/src/tests/setup/mocks/index.ts @@ -83,3 +83,12 @@ vi.mock('@auth0/auth0-react', async () => { }) window.HTMLElement.prototype.hasPointerCapture = vi.fn() + +vi.mock('@/features/nfd/data/nfd', async () => { + const original = await vi.importActual('@/features/nfd/data/nfd') + return { + ...original, + useLoadableNfd: vi.fn(), + useLoadableNfdResult: vi.fn(), + } +}) diff --git a/src/tests/test-platform-provider.tsx b/src/tests/test-platform-provider.tsx index f127a2875..c6ba5dd48 100644 --- a/src/tests/test-platform-provider.tsx +++ b/src/tests/test-platform-provider.tsx @@ -12,6 +12,7 @@ type Props = PropsWithChildren<{ export function TestPlatformProvider({ children, store }: Props) { const networkConfig = { id: localnetId, + nfdApiUrl: 'http://not-used', ...defaultNetworkConfigs.localnet, } useTheme() diff --git a/src/utils/ellipse-address.ts b/src/utils/ellipse-address.ts index b17fffafe..f38d4851d 100644 --- a/src/utils/ellipse-address.ts +++ b/src/utils/ellipse-address.ts @@ -1,3 +1,10 @@ export function ellipseAddress(address = '', width = 4): string { return address ? `${address.slice(0, width)}…${address.slice(-width)}` : address } + +export function ellipseNfd(address = '', width = 5): string { + if (address.length <= width * 2) { + return address + } + return address ? `${address.slice(0, width)}…${address.slice(-width)}` : address +}