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
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import ErrorState from '../Homepage/components/ErrorState/ErrorState';
import WhatsHappeningExpandedCard from './components/WhatsHappeningExpandedCard';
import WhatsHappeningSourcesBottomSheet from './components/WhatsHappeningSourcesBottomSheet';
import PageIndicator from './components/PageIndicator';
import { PerpsStreamProvider } from '../../UI/Perps/providers/PerpsStreamManager';
import { MetaMetricsEvents } from '../../../core/Analytics';
import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics';

Expand Down Expand Up @@ -210,64 +211,66 @@ const WhatsHappeningDetailView = () => {
<Box twClassName="w-10" />
</Box>

<Box twClassName="flex-1">
{isLoading ? (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={tw.style('px-4 gap-3 items-stretch')}
testID="whats-happening-detail-skeleton"
>
{SKELETON_KEYS.map((key) => (
<WhatsHappeningCardSkeleton key={key} />
))}
</ScrollView>
) : hasError ? (
<ErrorState
title={strings('homepage.error.unable_to_load', {
section: strings(
'homepage.sections.whats_happening',
).toLowerCase(),
})}
onRetry={refresh}
/>
) : (
<>
<PerpsStreamProvider>
<Box twClassName="flex-1">
{isLoading ? (
<ScrollView
ref={scrollViewRef}
horizontal
showsHorizontalScrollIndicator={false}
decelerationRate="fast"
snapToInterval={SNAP_INTERVAL}
snapToAlignment="start"
style={tw`flex-1`}
contentContainerStyle={tw.style('px-4 gap-3')}
onLayout={handleCarouselLayout}
onContentSizeChange={handleContentSizeChange}
onScroll={handleScroll}
scrollEventThrottle={16}
onMomentumScrollEnd={handleScrollEnd}
testID="whats-happening-detail-carousel"
contentContainerStyle={tw.style('px-4 gap-3 items-stretch')}
testID="whats-happening-detail-skeleton"
>
{cardHeight > 0 &&
items.map((item, index) => (
<WhatsHappeningExpandedCard
key={item.id}
item={item}
cardIndex={index}
cardWidth={CARD_WIDTH}
cardHeight={cardHeight}
onSourcesPress={(articles) =>
handleSourcesPress(articles, item, index)
}
/>
))}
{SKELETON_KEYS.map((key) => (
<WhatsHappeningCardSkeleton key={key} />
))}
</ScrollView>
) : hasError ? (
<ErrorState
title={strings('homepage.error.unable_to_load', {
section: strings(
'homepage.sections.whats_happening',
).toLowerCase(),
})}
onRetry={refresh}
/>
) : (
<>
<ScrollView
ref={scrollViewRef}
horizontal
showsHorizontalScrollIndicator={false}
decelerationRate="fast"
snapToInterval={SNAP_INTERVAL}
snapToAlignment="start"
style={tw`flex-1`}
contentContainerStyle={tw.style('px-4 gap-3')}
onLayout={handleCarouselLayout}
onContentSizeChange={handleContentSizeChange}
onScroll={handleScroll}
scrollEventThrottle={16}
onMomentumScrollEnd={handleScrollEnd}
testID="whats-happening-detail-carousel"
>
{cardHeight > 0 &&
items.map((item, index) => (
<WhatsHappeningExpandedCard
key={item.id}
item={item}
cardIndex={index}
cardWidth={CARD_WIDTH}
cardHeight={cardHeight}
onSourcesPress={(articles) =>
handleSourcesPress(articles, item, index)
}
/>
))}
</ScrollView>

<PageIndicator count={items.length} activeIndex={currentIndex} />
</>
)}
</Box>
<PageIndicator count={items.length} activeIndex={currentIndex} />
</>
)}
</Box>
</PerpsStreamProvider>
{sourcesContext && (
<WhatsHappeningSourcesBottomSheet
onClose={handleSourcesClose}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import React from 'react';
import { screen, fireEvent } from '@testing-library/react-native';
import { TextColor } from '@metamask/design-system-react-native';
import type { RelatedAsset } from '@metamask/ai-controllers';
import renderWithProvider from '../../../../util/test/renderWithProvider';
import AssetRow from './AssetRow';

jest.mock('../utils/getRelatedAssetImageSource', () => ({
getRelatedAssetImageSource: jest.fn(() => undefined),
}));

const btcAsset: RelatedAsset = {
sourceAssetId: 'bitcoin',
symbol: 'BTC',
name: 'Bitcoin',
caip19: ['eip155:1/slip44:0'],
};

const symbolOnlyAsset: RelatedAsset = {
sourceAssetId: 'unknown',
symbol: 'UNK',
name: '',
caip19: [],
};

describe('AssetRow', () => {
const onAction = jest.fn();

beforeEach(() => {
jest.clearAllMocks();
});

it('displays asset.name when name is present', () => {
renderWithProvider(
<AssetRow
asset={btcAsset}
actionLabel="Buy"
accessibilityLabel="Buy BTC"
onAction={onAction}
/>,
);
expect(screen.getByText('Bitcoin')).toBeOnTheScreen();
});

it('falls back to asset.symbol when name is empty', () => {
renderWithProvider(
<AssetRow
asset={symbolOnlyAsset}
actionLabel="Buy"
accessibilityLabel="Buy UNK"
onAction={onAction}
/>,
);
expect(screen.getByText('UNK')).toBeOnTheScreen();
});

it('does not render an action button when onAction is omitted', () => {
renderWithProvider(<AssetRow asset={btcAsset} />);
expect(screen.queryByText('Buy')).toBeNull();
expect(screen.queryByText('Trade')).toBeNull();
});

it('renders the action button with the provided label', () => {
renderWithProvider(
<AssetRow
asset={btcAsset}
actionLabel="Trade"
accessibilityLabel="Trade BTC"
onAction={onAction}
/>,
);
expect(screen.getByText('Trade')).toBeOnTheScreen();
});

it('calls onAction when the button is pressed', () => {
renderWithProvider(
<AssetRow
asset={btcAsset}
actionLabel="Buy"
accessibilityLabel="Buy BTC"
onAction={onAction}
/>,
);
fireEvent.press(screen.getByText('Buy'));
expect(onAction).toHaveBeenCalledTimes(1);
});

it('renders the price secondary line when secondaryLine is provided', () => {
renderWithProvider(
<AssetRow
asset={btcAsset}
actionLabel="Buy"
accessibilityLabel="Buy BTC"
onAction={onAction}
secondaryLine={{
priceText: '$95,000.00',
changeText: '+2.50%',
changeColor: TextColor.SuccessDefault,
}}
/>,
);
expect(screen.getByText('$95,000.00')).toBeOnTheScreen();
expect(screen.getByText('+2.50%')).toBeOnTheScreen();
});

it('renders just the price without change text when changeText is undefined', () => {
renderWithProvider(
<AssetRow
asset={btcAsset}
actionLabel="Buy"
accessibilityLabel="Buy BTC"
onAction={onAction}
secondaryLine={{
priceText: '$95,000.00',
changeText: undefined,
changeColor: TextColor.TextAlternative,
}}
/>,
);
expect(screen.getByText('$95,000.00')).toBeOnTheScreen();
expect(screen.queryByText('%')).toBeNull();
});

it('does not render secondary line when secondaryLine is not provided', () => {
renderWithProvider(
<AssetRow
asset={btcAsset}
actionLabel="Buy"
accessibilityLabel="Buy BTC"
onAction={onAction}
/>,
);
expect(screen.queryByText('$')).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,31 @@ import {
import type { RelatedAsset } from '@metamask/ai-controllers';
import { getRelatedAssetImageSource } from '../utils/getRelatedAssetImageSource';

export interface AssetRowSecondaryLine {
priceText: string;
changeText: string | undefined;
changeColor: TextColor;
}

interface AssetRowProps {
asset: RelatedAsset;
actionLabel: string;
accessibilityLabel: string;
onAction: () => void;
actionLabel?: string;
accessibilityLabel?: string;
onAction?: () => void;
/** When provided, renders price + 24h change below the asset name. */
secondaryLine?: AssetRowSecondaryLine;
}

/**
* Shared layout for a single asset row (logo + symbol + action button).
* Used by TokenRow (Buy/Trade) and PerpsRow (Trade); each wrapper supplies its
* own hook logic and passes the resolved label and handler here.
* Shared layout for a single asset row (logo + name + optional badge + optional
* price/change + optional action button). Used by PerpsRow (Trade when tradable).
*/
const AssetRow: React.FC<AssetRowProps> = ({
asset,
actionLabel,
accessibilityLabel,
onAction,
secondaryLine,
}) => {
const rawImageSource = getRelatedAssetImageSource(asset);
const imageSource = Array.isArray(rawImageSource)
Expand All @@ -59,22 +67,58 @@ const AssetRow: React.FC<AssetRowProps> = ({
alignItems={BoxAlignItems.Center}
justifyContent={BoxJustifyContent.Between}
>
<Text
variant={TextVariant.BodyMd}
fontWeight={FontWeight.Medium}
color={TextColor.TextDefault}
>
{asset.symbol}
</Text>
{/* Left: name + optional badge + optional price/change */}
<Box twClassName="flex-1 mr-2">
<Text
variant={TextVariant.BodyMd}
fontWeight={FontWeight.Medium}
color={TextColor.TextDefault}
numberOfLines={1}
>
{asset.name || asset.symbol}
</Text>

{secondaryLine && (
<Box
flexDirection={BoxFlexDirection.Row}
alignItems={BoxAlignItems.Center}
>
<Text
variant={TextVariant.BodySm}
color={TextColor.TextAlternative}
>
{secondaryLine.priceText}
</Text>
{secondaryLine.changeText ? (
<>
<Text
variant={TextVariant.BodySm}
color={TextColor.TextAlternative}
>
{' \u2022 '}
</Text>
<Text
variant={TextVariant.BodySm}
color={secondaryLine.changeColor}
>
{secondaryLine.changeText}
</Text>
</>
) : null}
</Box>
)}
</Box>

<Button
variant={ButtonVariant.Primary}
size={ButtonSize.Md}
onPress={onAction}
accessibilityLabel={accessibilityLabel}
>
{actionLabel}
</Button>
{onAction && actionLabel && accessibilityLabel ? (
<Button
variant={ButtonVariant.Secondary}
size={ButtonSize.Md}
onPress={onAction}
accessibilityLabel={accessibilityLabel}
>
{actionLabel}
</Button>
) : null}
</Box>
</Box>
);
Expand Down
Loading
Loading