diff --git a/src/components/selectors/AssetSelector.test.tsx b/src/components/selectors/AssetSelector.test.tsx
new file mode 100644
index 000000000..adfad9aa6
--- /dev/null
+++ b/src/components/selectors/AssetSelector.test.tsx
@@ -0,0 +1,206 @@
+// @vitest-environment jsdom
+
+import { i18n } from '@lingui/core';
+import { I18nProvider } from '@lingui/react';
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+} from '@testing-library/react';
+import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
+
+vi.mock('@/bindings', () => ({
+ commands: {
+ getCats: vi.fn(() =>
+ Promise.resolve({
+ cats: [
+ {
+ asset_id: 'cat1',
+ name: 'Cat',
+ ticker: 'CAT',
+ precision: 3,
+ balance: '500000',
+ selectable_balance: '500000',
+ visible: true,
+ },
+ ],
+ }),
+ ),
+ getToken: vi.fn(() =>
+ Promise.resolve({
+ token: {
+ asset_id: null,
+ name: 'Chia',
+ ticker: 'XCH',
+ precision: 12,
+ balance: '10000000000000',
+ selectable_balance: '10000000000000',
+ visible: true,
+ },
+ }),
+ ),
+ getOffersForAsset: vi.fn(() => Promise.resolve({ offers: [] })),
+ },
+ events: { syncEvent: { listen: vi.fn(() => Promise.resolve(vi.fn())) } },
+}));
+vi.mock('./TokenSelector', () => ({ TokenSelector: () => null }));
+vi.mock('./NftSelector', () => ({ NftSelector: () => null }));
+vi.mock('./OptionSelector', () => ({ OptionSelector: () => null }));
+vi.mock('@/components/ui/masked-input', () => ({
+ TokenAmountInput: (props: {
+ value: string;
+ onValueChange: (values: { value: string }) => void;
+ }) => (
+ props.onValueChange({ value: e.target.value })}
+ />
+ ),
+}));
+
+import { Assets } from '@/state';
+import { AssetSelector } from './AssetSelector';
+
+beforeAll(() => {
+ i18n.loadAndActivate({ locale: 'en', messages: {} });
+});
+
+afterEach(cleanup);
+
+function renderOffering(assets: Assets, fee = '0') {
+ const setAssets = vi.fn();
+ render(
+
+
+ ,
+ );
+ return setAssets;
+}
+
+const tokens = (assetId: string | null, amount: string): Assets => ({
+ tokens: [{ asset_id: assetId, amount }],
+ nfts: [],
+ options: [],
+});
+
+describe('AssetSelector offered tokens', () => {
+ it('shows the spendable balance under the row', async () => {
+ renderOffering(tokens('cat1', ''));
+ expect(await screen.findByText(/500 CAT spendable/)).toBeTruthy();
+ });
+
+ it('flags an amount above the spendable balance', async () => {
+ renderOffering(tokens('cat1', '501'));
+ expect(
+ await screen.findByText('More than your spendable balance'),
+ ).toBeTruthy();
+ });
+
+ it('counts the network fee against an XCH amount', async () => {
+ renderOffering(tokens(null, '10'), '0.5');
+ expect(
+ await screen.findByText(
+ 'More than your spendable balance after the network fee',
+ ),
+ ).toBeTruthy();
+ });
+
+ it('fills in the spendable balance less the fee with Max', async () => {
+ const setAssets = renderOffering(tokens(null, ''), '0.5');
+ await screen.findByText(/XCH spendable/);
+
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Use maximum spendable balance' }),
+ );
+
+ expect(setAssets).toHaveBeenCalledWith(tokens(null, '9.5'));
+ });
+});
+
+function renderSelector(props: {
+ assets: Assets;
+ offering?: boolean;
+ emptyMessage?: string;
+}) {
+ const setAssets = vi.fn();
+ render(
+
+
+ ,
+ );
+ return setAssets;
+}
+
+const noAssets: Assets = { tokens: [], nfts: [], options: [] };
+
+describe('AssetSelector balance edges', () => {
+ it('accepts an amount equal to the spendable balance', async () => {
+ renderOffering(tokens('cat1', '500'));
+ await screen.findByText(/500 CAT spendable/);
+ expect(screen.queryByText(/More than your spendable balance/)).toBeNull();
+ });
+
+ it('only takes the network fee off XCH', async () => {
+ renderOffering(tokens('cat1', '500'), '0.5');
+ await screen.findByText(/500 CAT spendable/);
+ expect(screen.queryByText(/More than your spendable balance/)).toBeNull();
+ });
+});
+
+describe('AssetSelector requested tokens', () => {
+ it("doesn't show a balance, Max or a balance error", async () => {
+ renderSelector({ assets: tokens('cat1', '501') });
+ // Give any balance lookup the chance to finish.
+ await act(async () => undefined);
+
+ expect(screen.queryByText(/spendable/)).toBeNull();
+ expect(
+ screen.queryByRole('button', { name: 'Use maximum spendable balance' }),
+ ).toBeNull();
+ expect(screen.queryByText(/More than your spendable balance/)).toBeNull();
+ });
+});
+
+describe('AssetSelector layout', () => {
+ it('shows the empty message only while nothing has been added', () => {
+ renderSelector({ assets: noAssets, emptyMessage: 'Nothing yet' });
+ expect(screen.getByText('Nothing yet')).toBeTruthy();
+
+ cleanup();
+ renderSelector({
+ assets: { ...noAssets, nfts: [''] },
+ emptyMessage: 'Nothing yet',
+ });
+ expect(screen.queryByText('Nothing yet')).toBeNull();
+ });
+
+ it('stores a pasted NFT ID without surrounding whitespace', () => {
+ const setAssets = renderSelector({
+ assets: { ...noAssets, nfts: [''] },
+ });
+
+ fireEvent.change(screen.getByLabelText('NFT ID'), {
+ target: { value: ' nft1example\n' },
+ });
+ expect(setAssets).toHaveBeenCalledWith({
+ ...noAssets,
+ nfts: ['nft1example'],
+ });
+ });
+});
diff --git a/src/components/selectors/AssetSelector.tsx b/src/components/selectors/AssetSelector.tsx
index af7de112f..1c9bd4e0a 100644
--- a/src/components/selectors/AssetSelector.tsx
+++ b/src/components/selectors/AssetSelector.tsx
@@ -3,29 +3,22 @@ import { NftSelector } from '@/components/selectors/NftSelector';
import { TokenSelector } from '@/components/selectors/TokenSelector';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
import { TokenAmountInput } from '@/components/ui/masked-input';
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from '@/components/ui/tooltip';
+import { useRequestedNft } from '@/hooks/useRequestedNft';
import { optionsEnabled } from '@/lib/features';
-import { toDecimal } from '@/lib/utils';
+import { fromMojos, toDecimal } from '@/lib/utils';
import { Assets } from '@/state';
import BigNumber from 'bignumber.js';
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
-import {
- AlertTriangle,
- FilePenLine,
- HandCoins,
- ImageIcon,
- PlusIcon,
- TrashIcon,
-} from 'lucide-react';
+import { AlertTriangle, PlusIcon } from 'lucide-react';
import { ReactNode, useCallback, useEffect, useState } from 'react';
+import { NumberFormat } from '../NumberFormat';
+import {
+ OfferAssetRow,
+ OfferAssetRowFooter,
+ RequestedNftPreview,
+} from './OfferAssetRow';
import { OptionSelector } from './OptionSelector';
interface AssetSelectorProps {
@@ -208,206 +201,185 @@ export function AssetSelector({
)}
- {assets.tokens.length > 0 && (
-
-
-
- Tokens
-
- {assets.tokens.map(({ asset_id: assetId, amount }, i) => {
- const ownedToken = ownedTokens.find((t) => t.asset_id === assetId);
- return (
-
-
{
- const newTokens = [...assets.tokens];
- newTokens[i] = {
- ...newTokens[i],
- asset_id: newAssetId,
- amount: '',
- };
- setAssets({ ...assets, tokens: newTokens });
- }}
- disabled={assets.tokens
- .filter((t, idx) => t.asset_id !== '' && idx !== i)
- .map((t) => t.asset_id)}
- className='!rounded-r-none'
- hideZeroBalance={offering === true}
- showAllCats={offering !== true}
- includeXch={true}
- />
-
-
- updateToken(i, 'amount', values.value)
- }
- precision={assetId === null ? 12 : 3}
- maxValue={
- ownedToken
- ? BigNumber.max(
- 0,
- BigNumber(
- toDecimal(
- ownedToken.selectable_balance,
- ownedToken.precision,
- ),
- ).minus(assetId === null ? fee || 0 : 0),
- ).toString()
- : undefined
- }
- maxButtonClassName='!rounded-r-none'
- hideMaxButton={!offering}
- />
- removeToken(i)}
- >
-
-
-
-
- );
- })}
-
- )}
-
- {assets.nfts.length > 0 && (
-
-
-
- NFTs
-
- {assets.nfts.map((nft, i) => (
-
- {offering === true ? (
-
updateNft(i, nftId || '')}
- disabled={assets.nfts.filter(
- (id, idx) => id !== '' && idx !== i,
+ {assets.tokens.map(({ asset_id: assetId, amount }, i) => {
+ const ownedToken = offering
+ ? ownedTokens.find((t) => t.asset_id === assetId)
+ : undefined;
+ const maxValue = ownedToken
+ ? BigNumber.max(
+ 0,
+ BigNumber(
+ toDecimal(ownedToken.selectable_balance, ownedToken.precision),
+ ).minus(assetId === null ? fee || 0 : 0),
+ )
+ : undefined;
+ const overBalance =
+ maxValue !== undefined &&
+ amount !== '' &&
+ BigNumber(amount.toString()).gt(maxValue);
+
+ return (
+ removeToken(i)}
+ removeLabel={t`Remove token`}
+ tone={overBalance ? 'error' : undefined}
+ footer={
+ ownedToken && (
+
+ {overBalance ? (
+ assetId === null ? (
+
+ More than your spendable balance after the network fee
+
+ ) : (
+ More than your spendable balance
+ )
+ ) : (
+
+
+
)}
- className='!rounded-r-none'
- />
- ) : (
- updateNft(i, e.target.value)}
- />
- )}
-
- {/* Warning icon for assets in offers */}
- {offering && nft && assetsInOffers.has(nft) && (
-
-
-
-
-
-
- This NFT is part of an open offer
-
-
-
- )}
-
+
+ )
+ }
+ >
+
+
+ updateToken(i, 'amount', values.value)
+ }
+ precision={assetId === null ? 12 : 3}
+ hideMaxButton
+ />
+
+ {offering && (
removeNft(i)}
+ type='button'
+ variant='ghost'
+ className='h-12 shrink-0 rounded-none px-3 text-xs'
+ disabled={!maxValue || maxValue.isZero()}
+ onClick={() =>
+ maxValue && updateToken(i, 'amount', maxValue.toFixed())
+ }
+ aria-label={t`Use maximum spendable balance`}
+ title={t`Use maximum spendable balance`}
>
-
+ Max
+ )}
+
+ {
+ const newTokens = [...assets.tokens];
+ newTokens[i] = {
+ ...newTokens[i],
+ asset_id: newAssetId,
+ amount: '',
+ };
+ setAssets({ ...assets, tokens: newTokens });
+ }}
+ disabled={assets.tokens
+ .filter((t, idx) => t.asset_id !== '' && idx !== i)
+ .map((t) => t.asset_id)}
+ className={rowSelectClassName}
+ hideZeroBalance={offering === true}
+ showAllCats={offering !== true}
+ includeXch={true}
+ />
- ))}
-
+
+ );
+ })}
+
+ {assets.nfts.map((nft, i) =>
+ offering ? (
+
removeNft(i)}
+ removeLabel={t`Remove NFT`}
+ tone={nft && assetsInOffers.has(nft) ? 'warning' : undefined}
+ footer={
+ nft &&
+ assetsInOffers.has(nft) && (
+
+
+ This NFT is part of an open offer
+
+ )
+ }
+ >
+
+ updateNft(i, nftId || '')}
+ disabled={assets.nfts.filter(
+ (id, idx) => id !== '' && idx !== i,
+ )}
+ className={rowSelectClassName}
+ />
+
+
+ ) : (
+
updateNft(i, value)}
+ onRemove={() => removeNft(i)}
+ />
+ ),
)}
- {assets.options.length > 0 && (
-
-
-
- Options
-
- {assets.options.map((option, i) => (
-
- ))}
-
- )}
@@ -429,3 +401,63 @@ export function AssetSelector({
);
}
+
+// The selectors and inputs sit inside an OfferAssetRow, which draws the border.
+const rowSelectClassName =
+ 'h-12 rounded-none border-0 bg-transparent shadow-none';
+const rowInputClassName =
+ 'h-12 rounded-none border-0 bg-transparent font-mono text-sm shadow-none focus-visible:ring-0';
+
+function SpendableBalance({ token }: { token: TokenRecord }) {
+ const ticker = token.ticker ?? '';
+
+ return (
+
+ {' '}
+ {ticker} spendable
+
+ );
+}
+
+interface RequestedNftRowProps {
+ value: string;
+ onChange: (value: string) => void;
+ onRemove: () => void;
+}
+
+function RequestedNftRow({ value, onChange, onRemove }: RequestedNftRowProps) {
+ const lookup = useRequestedNft(value);
+
+ return (
+ }
+ >
+
+ onChange(e.target.value.trim())}
+ />
+
+
+ );
+}
diff --git a/src/components/selectors/OfferAssetRow.test.tsx b/src/components/selectors/OfferAssetRow.test.tsx
new file mode 100644
index 000000000..6fff3441b
--- /dev/null
+++ b/src/components/selectors/OfferAssetRow.test.tsx
@@ -0,0 +1,51 @@
+// @vitest-environment jsdom
+
+import { i18n } from '@lingui/core';
+import { I18nProvider } from '@lingui/react';
+import { cleanup, render, screen } from '@testing-library/react';
+import { afterEach, beforeAll, describe, expect, it } from 'vitest';
+import { NftLookup } from '@/hooks/useRequestedNft';
+import { RequestedNftPreview } from './OfferAssetRow';
+
+beforeAll(() => {
+ i18n.loadAndActivate({ locale: 'en', messages: {} });
+});
+
+afterEach(cleanup);
+
+function renderPreview(lookup: NftLookup) {
+ return render(
+
+
+ ,
+ );
+}
+
+describe('RequestedNftPreview', () => {
+ it('shows nothing before an ID is typed', () => {
+ const { container } = renderPreview({ status: 'idle' });
+ expect(container.textContent).toBe('');
+ });
+
+ it('says when the ID is not valid', () => {
+ renderPreview({ status: 'invalid' });
+ expect(screen.getByText("This isn't a valid NFT ID.")).toBeTruthy();
+ });
+
+ it('says when the NFT could not be found', () => {
+ renderPreview({ status: 'missing' });
+ expect(screen.getByText(/Couldn't find this NFT/)).toBeTruthy();
+ });
+
+ it('shows the name and marks a mutable NFT', () => {
+ renderPreview({ status: 'found', name: 'Cool NFT', kind: 'any' });
+ expect(screen.getByText('Cool NFT')).toBeTruthy();
+ expect(screen.getByLabelText('Mutable NFT')).toBeTruthy();
+ });
+
+ it("doesn't call a found NFT unnamed before its name is known", () => {
+ renderPreview({ status: 'found', name: null, kind: 'default' });
+ expect(screen.getByText('NFT found')).toBeTruthy();
+ expect(screen.queryByLabelText('Mutable NFT')).toBeNull();
+ });
+});
diff --git a/src/components/selectors/OfferAssetRow.tsx b/src/components/selectors/OfferAssetRow.tsx
new file mode 100644
index 000000000..5e034d3e1
--- /dev/null
+++ b/src/components/selectors/OfferAssetRow.tsx
@@ -0,0 +1,130 @@
+import { MutableNftBadge } from '@/components/MutableNftBadge';
+import { Button } from '@/components/ui/button';
+import { NftLookup } from '@/hooks/useRequestedNft';
+import { cn } from '@/lib/utils';
+import { t } from '@lingui/core/macro';
+import { Trans } from '@lingui/react/macro';
+import { ImageIcon, Loader2, TrashIcon } from 'lucide-react';
+import { ReactNode } from 'react';
+
+export interface OfferAssetRowProps {
+ /** The inputs, laid out left to right and separated by dividers. */
+ children: ReactNode;
+ onRemove: () => void;
+ removeLabel: string;
+ /** Shown inside the frame, below the inputs. */
+ footer?: ReactNode;
+ tone?: 'error' | 'warning';
+}
+
+/** One asset in an offer: its inputs in a single bordered frame. */
+export function OfferAssetRow({
+ children,
+ onRemove,
+ removeLabel,
+ footer,
+ tone,
+}: OfferAssetRowProps) {
+ return (
+
+
+ {children}
+
+
+
+
+ {footer}
+
+ );
+}
+
+export interface OfferAssetRowFooterProps {
+ children: ReactNode;
+ tone?: 'error' | 'warning';
+ className?: string;
+}
+
+/** A thin strip along the bottom of an `OfferAssetRow`. */
+export function OfferAssetRowFooter({
+ children,
+ tone,
+ className,
+}: OfferAssetRowFooterProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export interface RequestedNftPreviewProps {
+ lookup: NftLookup;
+}
+
+/** What the wallet found for a requested NFT ID, shown under its row. */
+export function RequestedNftPreview({ lookup }: RequestedNftPreviewProps) {
+ switch (lookup.status) {
+ case 'idle':
+ return null;
+ case 'invalid':
+ return (
+
+ This isn't a valid NFT ID.
+
+ );
+ case 'loading':
+ return (
+
+
+ Looking up NFT…
+
+ );
+ case 'missing':
+ return (
+
+
+ Couldn't find this NFT. Check the ID, or try again once
+ you're connected to a peer.
+
+
+ );
+ case 'found':
+ return (
+ // There's no thumbnail: the wallet only keeps images for NFTs it
+ // holds, and a requested NFT usually belongs to someone else.
+
+
+
+
+
+
+ {lookup.name ?? t`NFT found`}
+
+
+
+
+ );
+ }
+}
diff --git a/src/hooks/useRequestedNft.test.tsx b/src/hooks/useRequestedNft.test.tsx
new file mode 100644
index 000000000..a797b9beb
--- /dev/null
+++ b/src/hooks/useRequestedNft.test.tsx
@@ -0,0 +1,166 @@
+// @vitest-environment jsdom
+
+import { act, cleanup, render, screen } from '@testing-library/react';
+import { bech32m } from 'bech32';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const getNftMetadataUpdater = vi.fn();
+
+vi.mock('@/bindings', () => ({
+ commands: {
+ getNftMetadataUpdater: (...args: unknown[]) =>
+ getNftMetadataUpdater(...args),
+ },
+}));
+
+import { useRequestedNft } from './useRequestedNft';
+
+const encodeNftId = (byte: number) =>
+ bech32m.encode('nft', bech32m.toWords(new Uint8Array(32).fill(byte)));
+const nftId = encodeNftId(7);
+const otherNftId = encodeNftId(8);
+
+function Probe({ id }: { id: string }) {
+ const lookup = useRequestedNft(id);
+ return (
+
+ {lookup.status}
+
+ {lookup.status === 'found' ? (lookup.name ?? '') : ''}
+
+
+ );
+}
+
+async function settle() {
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(500);
+ });
+}
+
+beforeEach(() => {
+ vi.useFakeTimers();
+});
+
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ vi.clearAllMocks();
+});
+
+describe('useRequestedNft', () => {
+ it('stays idle while the field is empty', async () => {
+ render( );
+ await settle();
+ expect(screen.getByTestId('status').textContent).toBe('idle');
+ expect(getNftMetadataUpdater).not.toHaveBeenCalled();
+ });
+
+ it('rejects text that is not an NFT ID without a lookup', async () => {
+ render( );
+ await settle();
+ expect(screen.getByTestId('status').textContent).toBe('invalid');
+ expect(getNftMetadataUpdater).not.toHaveBeenCalled();
+ });
+
+ it('shows the name of an NFT found on the network', async () => {
+ getNftMetadataUpdater.mockResolvedValue({
+ metadata_updater_kind: 'default',
+ name: 'Cool NFT',
+ });
+
+ render( );
+ expect(screen.getByTestId('status').textContent).toBe('loading');
+ await settle();
+
+ expect(getNftMetadataUpdater).toHaveBeenCalledWith({ nft_id: nftId });
+ expect(screen.getByTestId('status').textContent).toBe('found');
+ expect(screen.getByTestId('name').textContent).toBe('Cool NFT');
+ });
+
+ it('reports an NFT the network does not know', async () => {
+ getNftMetadataUpdater.mockResolvedValue({
+ metadata_updater_kind: null,
+ name: null,
+ });
+
+ render( );
+ await settle();
+
+ expect(screen.getByTestId('status').textContent).toBe('missing');
+ });
+
+ it('treats a failed lookup as missing', async () => {
+ getNftMetadataUpdater.mockRejectedValue(new Error('no peer'));
+
+ render( );
+ await settle();
+
+ expect(screen.getByTestId('status').textContent).toBe('missing');
+ });
+
+ it('waits for typing to stop before looking up', async () => {
+ getNftMetadataUpdater.mockResolvedValue({
+ metadata_updater_kind: 'default',
+ name: 'Cool NFT',
+ });
+
+ // An ID already there when the row appears is looked up straight away,
+ // so start from an empty field and type.
+ const { rerender } = render( );
+ rerender( );
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(200);
+ });
+ rerender( );
+ await settle();
+
+ expect(getNftMetadataUpdater).toHaveBeenCalledTimes(1);
+ expect(getNftMetadataUpdater).toHaveBeenCalledWith({ nft_id: nftId });
+ });
+
+ it('ignores a lookup that finishes after the ID changed', async () => {
+ let finishFirst: (value: unknown) => void = () => undefined;
+ getNftMetadataUpdater
+ .mockReturnValueOnce(
+ new Promise((resolve) => {
+ finishFirst = resolve;
+ }),
+ )
+ .mockResolvedValueOnce({
+ metadata_updater_kind: 'default',
+ name: 'Second NFT',
+ });
+
+ const { rerender } = render( );
+ await settle();
+ rerender( );
+ // The new ID hasn't been checked yet, so nothing from the old one shows.
+ expect(screen.getByTestId('status').textContent).toBe('loading');
+ expect(screen.getByTestId('name').textContent).toBe('');
+ await settle();
+ expect(screen.getByTestId('name').textContent).toBe('Second NFT');
+
+ await act(async () => {
+ finishFirst({ metadata_updater_kind: 'default', name: 'First NFT' });
+ });
+ expect(screen.getByTestId('name').textContent).toBe('Second NFT');
+ });
+
+ it('clears a found NFT as soon as the field is emptied or broken', async () => {
+ getNftMetadataUpdater.mockResolvedValue({
+ metadata_updater_kind: 'default',
+ name: 'Cool NFT',
+ });
+
+ const { rerender } = render( );
+ await settle();
+ expect(screen.getByTestId('status').textContent).toBe('found');
+
+ rerender( );
+ expect(screen.getByTestId('status').textContent).toBe('idle');
+
+ rerender( );
+ expect(screen.getByTestId('status').textContent).toBe('invalid');
+ });
+});
diff --git a/src/hooks/useRequestedNft.ts b/src/hooks/useRequestedNft.ts
new file mode 100644
index 000000000..c2fc2e58b
--- /dev/null
+++ b/src/hooks/useRequestedNft.ts
@@ -0,0 +1,71 @@
+import { commands, NftUpdaterKind } from '@/bindings';
+import { isValidAddress } from '@/lib/utils';
+import { useEffect, useState } from 'react';
+import { useDebounce } from './useDebounce';
+
+export type NftLookup =
+ | { status: 'idle' }
+ | { status: 'invalid' }
+ | { status: 'loading' }
+ | { status: 'missing' }
+ | {
+ status: 'found';
+ /** Null until the NFT's metadata has been downloaded. */
+ name: string | null;
+ kind: NftUpdaterKind;
+ };
+
+/**
+ * Looks up an NFT the user is requesting, so they can see they typed the
+ * right ID before making the offer. The lookup goes to the network, the same
+ * way the offer itself fetches a requested NFT.
+ */
+export function useRequestedNft(nftId: string): NftLookup {
+ const trimmed = nftId.trim();
+ const debounced = useDebounce(trimmed, 400);
+ const [lookup, setLookup] = useState({ status: 'idle' });
+
+ useEffect(() => {
+ if (!debounced) {
+ setLookup({ status: 'idle' });
+ return;
+ }
+
+ if (!isValidAddress(debounced, 'nft')) {
+ setLookup({ status: 'invalid' });
+ return;
+ }
+
+ let cancelled = false;
+ setLookup({ status: 'loading' });
+
+ commands
+ .getNftMetadataUpdater({ nft_id: debounced })
+ .then(({ metadata_updater_kind: kind, name }) => {
+ if (cancelled) return;
+ setLookup(
+ kind === null
+ ? { status: 'missing' }
+ : { status: 'found', name, kind },
+ );
+ })
+ .catch(() => {
+ if (!cancelled) setLookup({ status: 'missing' });
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [debounced]);
+
+ // An empty or malformed ID needs no lookup, so it's shown straight away
+ // rather than after the debounce. A valid ID that's still being typed
+ // hasn't been checked yet.
+ if (trimmed !== debounced) {
+ if (!trimmed) return { status: 'idle' };
+ if (!isValidAddress(trimmed, 'nft')) return { status: 'invalid' };
+ return { status: 'loading' };
+ }
+
+ return lookup;
+}