Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/snap-networks-utils/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add shared `Asset` and `AssetIcon` Snaps JSX components for displaying an asset inline in transaction confirmations ([#418](https://github.com/MetaMask/internal-snaps/pull/418))
- `Asset` renders an optional display-ready fiat value (or a loading skeleton), the asset icon, then the amount and symbol, optionally as an explorer link
- The icon can be an image URL, a Snaps icon (`iconName`), or hidden (`hideIcon`); `AssetIcon` falls back to a question-mark icon when the URL is missing or blank
- Add a shared `TransactionAlert` Snaps JSX component for transaction confirmations, rendering a single scan banner for the in-progress, scan-failed, scan-error, and malicious/warning security validation states ([#408](https://github.com/MetaMask/internal-snaps/pull/408))
- Takes translated `labels` (`TransactionAlertLabels`), `isFetching` and `isFetchError` flags, a resolved `error` (`TransactionAlertError`), and a `validation` (`TransactionAlertValidation`)
- Populate the optional `transaction_type` property on the `Transaction Added`, `Transaction Approved`, `Transaction Rejected`, and `Transaction Submitted` events tracked by `AnalyticsService`. ([#393](https://github.com/MetaMask/internal-snaps/pull/393))
Expand Down
2 changes: 2 additions & 0 deletions packages/snap-networks-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ export {
SynchronizationError,
} from './utils/errors';
export { InFlightCoalescer } from './utils/dedupe/InFlightCoalescer';
export { Asset, AssetIcon } from './ui/Asset/Asset';
export type { AssetIconProps, AssetProps } from './ui/Asset/Asset';
export { EstimatedChanges } from './ui/EstimatedChanges/EstimatedChanges';
export type {
EstimatedChangesAsset,
Expand Down
86 changes: 86 additions & 0 deletions packages/snap-networks-utils/src/ui/Asset/Asset.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Asset, AssetIcon, QUESTION_MARK_SVG } from './Asset';

const render = (props: Partial<Parameters<typeof Asset>[0]> = {}): string =>
JSON.stringify(Asset({ symbol: 'XLM', amount: '1.5', ...props }));

describe('AssetIcon', () => {
it('renders the icon URL as a round image of the requested size', () => {
const serialized = JSON.stringify(
AssetIcon({ iconUrl: 'https://icon.example/xlm.svg', size: 'xl' }),
);

expect(serialized).toContain('"src":"https://icon.example/xlm.svg"');
expect(serialized).toContain('"borderRadius":"full"');
expect(serialized).toContain('"height":48');
expect(serialized).toContain('"width":48');
});

it.each([undefined, null, '', ' '])(
'falls back to the question-mark icon for %p',
(iconUrl) => {
const serialized = JSON.stringify(AssetIcon({ iconUrl }));

expect(serialized).toContain(JSON.stringify(QUESTION_MARK_SVG));
expect(serialized).toContain('"height":16');
},
);
});

describe('Asset', () => {
it('renders the amount and symbol next to the icon', () => {
const serialized = render({ iconUrl: 'https://icon.example/xlm.svg' });

expect(serialized).toContain('"children":"1.5 XLM"');
expect(serialized).toContain('"src":"https://icon.example/xlm.svg"');
});

it('renders only the symbol when there is no amount', () => {
const serialized = render({ amount: undefined });

expect(serialized).toContain('"children":"XLM"');
});

it('renders the asset text as a link when a link is provided', () => {
const serialized = render({ link: 'https://explorer.example/xlm' });

expect(serialized).toContain('"type":"Link"');
expect(serialized).toContain('"href":"https://explorer.example/xlm"');
});

it('renders a Snaps icon instead of the image when an icon name is provided', () => {
const serialized = render({ iconName: 'flash' });

expect(serialized).toContain('"name":"flash"');
expect(serialized).not.toContain('"type":"Image"');
});

it('renders no icon when the icon is hidden', () => {
const serialized = render({ hideIcon: true });

expect(serialized).not.toContain('"type":"Image"');
expect(serialized).not.toContain('"type":"Icon"');
});

it('renders the fiat value before the amount', () => {
const serialized = render({ fiat: '$0.45' });

expect(serialized).toContain('"children":"$0.45"');
expect(serialized.indexOf('$0.45')).toBeLessThan(
serialized.indexOf('1.5 XLM'),
);
});

it('renders a skeleton instead of the fiat value while it loads', () => {
const serialized = render({ fiat: '$0.45', isFiatLoading: true });

expect(serialized).toContain('"type":"Skeleton"');
expect(serialized).not.toContain('$0.45');
});

it('renders neither fiat nor skeleton when there is no fiat value', () => {
const serialized = render({ fiat: null });

expect(serialized).not.toContain('"type":"Skeleton"');
expect(serialized).not.toContain('"color":"muted"');
});
});
139 changes: 139 additions & 0 deletions packages/snap-networks-utils/src/ui/Asset/Asset.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import type { ComponentOrElement } from '@metamask/snaps-sdk';
import {
Box,
Icon,
Image,
Link,
Skeleton,
Text as SnapText,
} from '@metamask/snaps-sdk/jsx';
import type { IconProps } from '@metamask/snaps-sdk/jsx';

// Raw SVG markup: `Image` accepts it as `src`, and the library build cannot
// bundle `.svg` imports the way `mm-snap` does.
export const QUESTION_MARK_SVG =
Comment thread
Julink-eth marked this conversation as resolved.
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="16" height="16"><path fill="#999999" d="M42.75 24c0 10.59375-8.53125 19.21875-19.125 19.21875-10.6875 0-19.21875-8.625-19.21875-19.21875 0-10.59375 8.53125-19.21875 19.21875-19.21875 10.59375 0 19.125 8.625 19.125 19.21875zM23.625 14.0625c-3.09375 0-5.90625 2.25-5.90625 5.4375 0 0.84375 0.75 1.59375 1.59375 1.59375 0.84375 0 1.59375-0.75 1.59375-1.59375 0-1.125 1.03125-2.25 2.71875-2.25 1.59375 0 2.625 1.125 2.625 2.25 0 0.5625-0.375 1.03125-1.3125 1.59375l0 0c-1.125 0.75-3 1.96875-3 4.5l0 0.46875c0 0.84375 0.75 1.59375 1.6875 1.59375 0.84375 0 1.59375-0.75 1.59375-1.59375l0-0.46875c0-0.65625 0.28125-1.125 1.40625-1.78125 1.03125-0.65625 2.8125-1.875 2.8125-4.3125 0-3.1875-2.8125-5.4375-5.8125-5.4375zM23.53125 31.875c-0.84375 0-1.59375 0.65625-1.59375 1.59375 0 0.84375 0.75 1.59375 1.59375 1.59375l0.09375 0c0.84375 0 1.59375-0.75 1.59375-1.59375 0-0.9375-0.75-1.59375-1.59375-1.59375z"/></svg>';

const IconSize = {
sm: 16,
md: 24,
lg: 32,
xl: 48,
} as const;

export type AssetIconProps = {
/** Icon URL; a question-mark icon is shown when missing or blank. */
iconUrl?: string | null;
size?: keyof typeof IconSize;
};

export type AssetProps = {
symbol: string;
/** Display-ready amount shown before the symbol; omit to show the symbol only. */
amount?: string;
/** Icon URL; a question-mark icon is shown when missing or blank. */
iconUrl?: string | null;
/** Snaps icon shown instead of the image, e.g. for network resources. */
iconName?: IconProps['name'];
/** Whether to render the asset without any icon. */
hideIcon?: boolean;
/** Explorer link; when set, the asset text is rendered as a link. */
link?: string;
/**
* Display-ready fiat value shown before the asset. The caller decides whether
* fiat applies (e.g. `useExternalPricingData`) and formats it with the user's
* currency and locale; pass `null` to show no fiat.
*/
fiat?: string | null;
/** Whether the fiat value is loading; shows a skeleton in its place. */
isFiatLoading?: boolean;
};

/**
* Renders a round asset icon, falling back to a question mark.
*
* @param props - The component props.
* @param props.iconUrl - The icon URL.
* @param props.size - The icon size, `sm` (16px) by default.
* @returns The icon image.
*/
export const AssetIcon = ({
iconUrl,
size = 'sm',
}: AssetIconProps): ComponentOrElement => (
<Image
borderRadius="full"
src={iconUrl?.trim() ? iconUrl : QUESTION_MARK_SVG}
height={IconSize[size]}
width={IconSize[size]}
/>
);

/**
* Renders an inline asset: optional fiat value (or a loading skeleton), the
* asset icon, then the amount and symbol, optionally as an explorer link.
*
* @param props - The component props.
* @param props.symbol - The asset symbol.
* @param props.amount - The display-ready amount.
* @param props.iconUrl - The icon URL.
* @param props.iconName - A Snaps icon shown instead of the image.
* @param props.hideIcon - Whether to render no icon.
* @param props.link - The explorer link.
* @param props.fiat - The display-ready fiat value.
Comment thread
stanleyyconsensys marked this conversation as resolved.
* @param props.isFiatLoading - Whether the fiat value is loading.
* @returns The asset element.
* @example
* ```tsx
* const price = preferences.useExternalPricingData
* ? tokenPrices[assetId]
* : null;
*
* <Asset
* symbol="XLM"
* amount="1.5"
* iconUrl={iconUrl}
* fiat={
* price
* ? formatFiat(
* tokenToFiat('1.5', price),
* preferences.currency,
* preferences.locale,
* )
* : null
* }
* isFiatLoading={tokenPricesFetchStatus === 'fetching'}
* />;
* ```
*/
export const Asset = ({
symbol,
amount,
iconUrl,
iconName,
hideIcon = false,
link,
fiat,
isFiatLoading = false,
}: AssetProps): ComponentOrElement => {
const text = amount === undefined ? symbol : `${amount} ${symbol}`;

return (
<Box direction="horizontal" alignment="end">
{isFiatLoading ? <Skeleton width={80} /> : null}
{!isFiatLoading && fiat ? (
<SnapText color="muted">{fiat}</SnapText>
) : null}
{hideIcon ? null : (
<Box alignment="center" center>
{iconName ? (
<Icon name={iconName} size="md" />
) : (
<AssetIcon iconUrl={iconUrl} />
)}
</Box>
)}
{link ? <Link href={link}>{text}</Link> : <SnapText>{text}</SnapText>}
</Box>
);
};
Loading