diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 884886d8b..ba383cfd1 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1253,11 +1253,6 @@ "count": 1 } }, - "packages/stellar-wallet-snap/src/ui/confirmation/components/EstimatedChanges/EstimatedChanges.tsx": { - "id-length": { - "count": 2 - } - }, "packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx": { "id-length": { "count": 1 diff --git a/packages/snap-networks-utils/CHANGELOG.md b/packages/snap-networks-utils/CHANGELOG.md index 675cf7234..bb5a12114 100644 --- a/packages/snap-networks-utils/CHANGELOG.md +++ b/packages/snap-networks-utils/CHANGELOG.md @@ -10,7 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add a shared `EstimatedChanges` Snaps JSX component for transaction confirmations, rendering send/receive asset rows with loading, not-available, and no-changes states ([#369](https://github.com/MetaMask/internal-snaps/pull/369)) - - Takes translated `labels`, display-ready `assets` (`EstimatedChangesAsset`), a `scanFetchStatus` (`EstimatedChangesFetchStatus`), and a `scanError` - Add `SynchronizationError`, `formatAccountSyncFailures`, and the `AccountSyncFailure` type, for reporting account synchronization failures with per-account failure details embedded in the error message (details must live in the message because `snap_trackError` only serializes `name`, `message`, `stack`, and `cause`). ([#374](https://github.com/MetaMask/internal-snaps/pull/374)) - Add `wrapSnapHandlers` to wrap any Snap entrypoint handlers with `withCatchAndThrowSnapError`, with optional per-handler `logError` overrides ([#341](https://github.com/MetaMask/internal-snaps/pull/341)) - Add `noopAssetHandlers`, the no-op asset entrypoints network Snaps must export to keep the `endowment:assets` permission ([#341](https://github.com/MetaMask/internal-snaps/pull/341)) diff --git a/packages/snap-networks-utils/src/index.ts b/packages/snap-networks-utils/src/index.ts index 0da7c2ff1..a61ee9998 100644 --- a/packages/snap-networks-utils/src/index.ts +++ b/packages/snap-networks-utils/src/index.ts @@ -115,7 +115,6 @@ export { InFlightCoalescer } from './utils/dedupe/InFlightCoalescer'; export { EstimatedChanges } from './ui/EstimatedChanges/EstimatedChanges'; export type { EstimatedChangesAsset, - EstimatedChangesFetchStatus, EstimatedChangesLabels, EstimatedChangesProps, } from './ui/EstimatedChanges/EstimatedChanges'; diff --git a/packages/snap-networks-utils/src/ui/EstimatedChanges/EstimatedChanges.test.tsx b/packages/snap-networks-utils/src/ui/EstimatedChanges/EstimatedChanges.test.tsx index bc1f7c8c9..c4650406c 100644 --- a/packages/snap-networks-utils/src/ui/EstimatedChanges/EstimatedChanges.test.tsx +++ b/packages/snap-networks-utils/src/ui/EstimatedChanges/EstimatedChanges.test.tsx @@ -32,55 +32,39 @@ const render = ( EstimatedChanges({ assets: [], labels, - scanFetchStatus: 'fetched', - scanError: false, + isFetching: false, + isUnavailable: false, ...props, }), ); describe('EstimatedChanges', () => { - it.each(['loading', 'fetching'] as const)( - 'renders a skeleton while %s and nothing is seeded', - (scanFetchStatus) => { - expect(render({ scanFetchStatus })).toContain('"type":"Skeleton"'); - }, - ); - - it('renders not available on error when nothing is seeded', () => { - expect(render({ scanFetchStatus: 'error' })).toContain('Not available'); + it('renders a skeleton while fetching and nothing is seeded', () => { + expect(render({ isFetching: true })).toContain('"type":"Skeleton"'); }); - it('renders not available when a fetched scan reports an error', () => { - const serialized = render({ scanError: true }); + it('renders not available when unavailable and nothing is seeded', () => { + const serialized = render({ isUnavailable: true }); expect(serialized).toContain('Not available'); expect(serialized).not.toContain('No changes'); }); - it.each([ - { scanFetchStatus: 'loading' }, - { scanFetchStatus: 'fetching' }, - { scanFetchStatus: 'error' }, - { scanFetchStatus: 'fetched', scanError: true }, - ] as const)('keeps seeded rows visible for %o', (props) => { - const serialized = render({ assets: [out], ...props }); + it.each([{ isFetching: true }, { isUnavailable: true }])( + 'keeps seeded rows visible for %o', + (props) => { + const serialized = render({ assets: [out], ...props }); - expect(serialized).toContain('-10 XLM'); - expect(serialized).not.toContain('"type":"Skeleton"'); - expect(serialized).not.toContain('Not available'); - }); + expect(serialized).toContain('-10 XLM'); + expect(serialized).not.toContain('"type":"Skeleton"'); + expect(serialized).not.toContain('Not available'); + }, + ); - it('renders no changes when fetched without assets', () => { + it('renders no changes when there are no assets', () => { expect(render({})).toContain('No changes'); }); - it('renders only the header before the first fetch', () => { - const serialized = render({ scanFetchStatus: 'initial' }); - - expect(serialized).toContain('Estimated changes'); - expect(serialized).not.toContain('No changes'); - }); - it('renders send and receive rows', () => { const serialized = render({ assets: [out, inflow] }); diff --git a/packages/snap-networks-utils/src/ui/EstimatedChanges/EstimatedChanges.tsx b/packages/snap-networks-utils/src/ui/EstimatedChanges/EstimatedChanges.tsx index 673f3aa63..2e1a3f859 100644 --- a/packages/snap-networks-utils/src/ui/EstimatedChanges/EstimatedChanges.tsx +++ b/packages/snap-networks-utils/src/ui/EstimatedChanges/EstimatedChanges.tsx @@ -28,19 +28,13 @@ export type EstimatedChangesLabels = { noChanges: string; }; -export type EstimatedChangesFetchStatus = - | 'initial' - | 'loading' - | 'fetching' - | 'fetched' - | 'error'; - export type EstimatedChangesProps = { assets: EstimatedChangesAsset[]; labels: EstimatedChangesLabels; - scanFetchStatus: EstimatedChangesFetchStatus; - /** Whether the completed scan itself reported an error (e.g. simulation failure). */ - scanError: boolean; + /** Whether the estimate is still being fetched. */ + isFetching: boolean; + /** Whether the estimate could not be produced (e.g. fetch or simulation failure). */ + isUnavailable: boolean; }; const Header = ({ @@ -126,50 +120,53 @@ const AssetGroup = ({ * Renders the estimated balance changes of a transaction, grouped into * "send" and "receive" rows, with loading, error and empty states. * + * The header is always rendered. The body depends on the props: + * + * 1. No assets and `isFetching` → loading skeleton (takes precedence over + * `isUnavailable`). + * 2. No assets and `isUnavailable` → "not available" message. + * 3. No assets otherwise → "no changes" message (an empty result means the + * transaction does not change any balance). + * 4. Assets present → "send" and/or "receive" groups; a group with no assets + * is omitted. `isFetching` and `isUnavailable` are ignored in this case, as + * rows seeded locally by the caller are final. + * * @param props - The component props. * @param props.assets - The display-ready asset changes. * @param props.labels - The translated labels. - * @param props.scanFetchStatus - The fetch status of the scan producing the changes. - * @param props.scanError - Whether the completed scan itself reported an error. + * @param props.isFetching - Whether the estimate is still being fetched. + * @param props.isUnavailable - Whether the estimate could not be produced. * @returns The estimated changes section. */ export const EstimatedChanges = ({ assets, labels, - scanFetchStatus, - scanError, + isFetching, + isUnavailable, }: EstimatedChangesProps): ComponentOrElement => { // Rows seeded locally by the caller are final, so they are never replaced by - // the loading/error states driven by the remote scan. - const hasAssets = assets.length > 0; + // the loading/unavailable states. + if (assets.length === 0) { + if (isFetching) { + return ( +
+
+ + + + +
+ ); + } - if ( - !hasAssets && - (scanFetchStatus === 'loading' || scanFetchStatus === 'fetching') - ) { return ( -
-
- - - - -
+ ); } - if ( - !hasAssets && - (scanFetchStatus === 'error' || - (scanFetchStatus === 'fetched' && scanError)) - ) { - return ; - } - - if (scanFetchStatus === 'fetched' && !hasAssets) { - return ; - } - return (
diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index 30dca6484..27c799ca7 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Render the confirmation "Estimated changes" section with the shared `EstimatedChanges` component from `@metamask/snap-networks-utils` ([#386](https://github.com/MetaMask/internal-snaps/pull/386)) + ## [1.1.0] ### Added diff --git a/packages/stellar-wallet-snap/snap.manifest.json b/packages/stellar-wallet-snap/snap.manifest.json index e3820433d..c563d0371 100644 --- a/packages/stellar-wallet-snap/snap.manifest.json +++ b/packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "NPPMsrpTF/jZw4h6yS0Sy+wxr2icoIogu82ckFLEjcc=", + "shasum": "GhrGMVfrDCWuieBCmKWXY1g31zoiCMFhIyVt6RYzJ5k=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/components/EstimatedChanges/EstimatedChanges.test.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/components/EstimatedChanges/EstimatedChanges.test.tsx index 41ae63c6a..6ddb03abe 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/components/EstimatedChanges/EstimatedChanges.test.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/components/EstimatedChanges/EstimatedChanges.test.tsx @@ -47,16 +47,19 @@ describe('EstimatedChanges', () => { logo: 'https://example.com/usdc.png', }; - it('renders a skeleton while the remote scan is fetching and nothing is seeded', () => { - const component = EstimatedChanges({ - changes: { assets: [] }, - preferences, - scanFetchStatus: FetchStatus.Fetching, - }); - - expect(JSON.stringify(component)).toContain('"type":"Skeleton"'); - expect(JSON.stringify(component)).not.toContain('-10 XLM'); - }); + it.each([FetchStatus.Initial, FetchStatus.Fetching])( + 'renders a skeleton while the remote scan is %s and nothing is seeded', + (scanFetchStatus) => { + const component = EstimatedChanges({ + changes: { assets: [] }, + preferences, + scanFetchStatus, + }); + + expect(JSON.stringify(component)).toContain('"type":"Skeleton"'); + expect(JSON.stringify(component)).not.toContain('-10 XLM'); + }, + ); it('keeps locally-seeded rows visible while the remote scan is fetching', () => { const component = EstimatedChanges({ diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/components/EstimatedChanges/EstimatedChanges.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/components/EstimatedChanges/EstimatedChanges.tsx index 3acbf97c0..6550a6f1b 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/components/EstimatedChanges/EstimatedChanges.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/components/EstimatedChanges/EstimatedChanges.tsx @@ -1,23 +1,11 @@ +import { EstimatedChanges as SharedEstimatedChanges } from '@metamask/snap-networks-utils'; import type { ComponentOrElement, GetPreferencesResult, } from '@metamask/snaps-sdk'; -import { - Box, - Icon, - Image, - Section, - Skeleton, - Text as SnapText, - Tooltip, -} from '@metamask/snaps-sdk/jsx'; import { NATIVE_ASSET_SYMBOL } from '../../../../constants'; -import { AssetChangeDirection } from '../../../../services/transaction-scan'; -import type { - TransactionScanAssetChange, - TransactionScanEstimatedChanges, -} from '../../../../services/transaction-scan'; +import type { TransactionScanEstimatedChanges } from '../../../../services/transaction-scan'; import { i18n } from '../../../../utils'; import { xlmIcon } from '../../../images'; import { FetchStatus } from '../../api'; @@ -30,109 +18,9 @@ type EstimatedChangesProps = { }; /** - * Resolves the text color for an estimated-change row. - * - * @param isUnknownValue - True when the amount could not be determined. - * @param isOut - True for an outflow row. - * @returns The SnapText color: neutral for unknown, red for out, green for in. - */ -function resolveRowColor( - isUnknownValue: boolean, - isOut: boolean, -): 'alternative' | 'error' | 'success' { - if (isUnknownValue) { - return 'alternative'; - } - return isOut ? 'error' : 'success'; -} - -/** - * Builds a stable list key for an estimated-change row. - * - * @param asset - The asset change row. - * @param index - The row index within its send/receive group. - * @returns A stable key for JSX list rendering. - */ -function getAssetChangeKey( - asset: TransactionScanAssetChange, - index: number, -): string { - return `${asset.type}-${asset.symbol}-${asset.value ?? 'unknown'}-${index}`; -} - -const EstimatedChangesHeader = ({ - preferences, -}: { - preferences: GetPreferencesResult; -}): ComponentOrElement => { - const t = i18n(preferences.locale); - - return ( - - - {t('confirmation.estimatedChanges.title')} - - - - - - ); -}; - -const EstimatedChangesSkeleton = ({ - preferences, -}: { - preferences: GetPreferencesResult; -}): ComponentOrElement => { - return ( -
- - - - - -
- ); -}; - -/** - * Renders one asset-change row. - * - * @param props - The component props. - * @param props.asset - The asset change to render. - * @returns The row element. - */ -const AssetChangeRow = ({ - asset, -}: { - asset: TransactionScanAssetChange; -}): ComponentOrElement => { - const isOut = asset.type === AssetChangeDirection.Out; - const iconSrc = - asset.logo ?? (asset.symbol === NATIVE_ASSET_SYMBOL ? xlmIcon : null); - - // A null value means the amount is unknown (e.g. a contract token Blockaid - // cannot quantify); render a neutral placeholder rather than a misleading 0. - const isUnknownValue = asset.value === null; - const label = isUnknownValue - ? `– ${asset.symbol}` - : `${isOut ? '-' : '+'}${asset.value} ${asset.symbol}`; - const color = resolveRowColor(isUnknownValue, isOut); - - return ( - - {iconSrc ? ( - - ) : null} - {label} - - ); -}; - -/** - * Renders the signer's estimated balance changes (send / receive breakdown). - * Shows a loading skeleton while the remote scan is in flight, then the best - * available estimate (Blockaid when displayable, otherwise the local fallback). + * Renders the signer's estimated balance changes (send / receive breakdown) + * with the shared component. Locally-seeded rows (send flow) stay visible + * regardless of the remote scan status. * * @param props - The component props. * @param props.changes - The estimated changes, or null when unavailable. @@ -145,81 +33,27 @@ export const EstimatedChanges = ({ preferences, scanFetchStatus, }: EstimatedChangesProps): ComponentOrElement => { - const t = i18n(preferences.locale); - const isFetching = isFetchInProgress(scanFetchStatus); - const isFetched = scanFetchStatus === FetchStatus.Fetched; - const isFetchError = scanFetchStatus === FetchStatus.Error; - // Locally-seeded rows (send flow) are final regardless of the remote scan, so - // keep them visible instead of replacing them with the loading/error chrome - // that the remote scan status would otherwise drive. - const hasSeededRows = (changes?.assets.length ?? 0) > 0; - - if (isFetching && !hasSeededRows) { - return ; - } - - if (isFetchError && !hasSeededRows) { - return ( -
- - - {t('confirmation.estimatedChanges.notAvailable')} - -
- ); - } - - const send = - changes?.assets.filter( - (asset) => asset.type === AssetChangeDirection.Out, - ) ?? []; - const receive = - changes?.assets.filter((asset) => asset.type === AssetChangeDirection.In) ?? - []; - const hasChanges = send.length > 0 || receive.length > 0; - - if (isFetched && !hasChanges) { - return ( -
- - - {t('confirmation.estimatedChanges.noChanges')} - -
- ); - } + const translate = i18n(preferences.locale); return ( -
- - {send.length > 0 ? ( - - - {t('confirmation.estimatedChanges.send')} - - - {send.map((asset, index) => ( - - - - ))} - - - ) : null} - {receive.length > 0 ? ( - - - {t('confirmation.estimatedChanges.receive')} - - - {receive.map((asset, index) => ( - - - - ))} - - - ) : null} -
+ ({ + type: asset.type, + value: asset.value, + symbol: asset.symbol, + logo: + asset.logo ?? (asset.symbol === NATIVE_ASSET_SYMBOL ? xlmIcon : null), + }))} + labels={{ + title: translate('confirmation.estimatedChanges.title'), + tooltip: translate('confirmation.estimatedChanges.tooltip'), + send: translate('confirmation.estimatedChanges.send'), + receive: translate('confirmation.estimatedChanges.receive'), + notAvailable: translate('confirmation.estimatedChanges.notAvailable'), + noChanges: translate('confirmation.estimatedChanges.noChanges'), + }} + isFetching={isFetchInProgress(scanFetchStatus)} + isUnavailable={scanFetchStatus === FetchStatus.Error} + /> ); };