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
@@ -1,4 +1,45 @@
import { getFaviconUrl, isXSourceUrl } from './marketInsightsFormatting';
import {
formatRelativeTime,
getFaviconUrl,
isXSourceUrl,
} from './marketInsightsFormatting';

describe('formatRelativeTime', () => {
const ANCHOR = new Date('2026-05-07T12:00:00.000Z');

const minutesBeforeAnchor = (n: number) =>
new Date(ANCHOR.getTime() - n * 60 * 1000).toISOString();

beforeEach(() => {
jest.useFakeTimers({ now: ANCHOR });
});

afterEach(() => {
jest.useRealTimers();
});

it('returns empty string for an invalid date string', () => {
expect(formatRelativeTime('not-a-date')).toBe('');
});

it('returns nowLabel when diff is under 1 minute', () => {
expect(
formatRelativeTime(minutesBeforeAnchor(0), { nowLabel: 'now' }),
).toBe('now');
});

it('returns Xm ago for diffs under 1 hour', () => {
expect(formatRelativeTime(minutesBeforeAnchor(5))).toBe('5m ago');
});

it('returns Xh ago for diffs under 1 day', () => {
expect(formatRelativeTime(minutesBeforeAnchor(3 * 60))).toBe('3h ago');
});

it('returns Xd ago for diffs of 1 day or more', () => {
expect(formatRelativeTime(minutesBeforeAnchor(4 * 24 * 60))).toBe('4d ago');
});
});

describe('getFaviconUrl', () => {
it('uses hostname when source is a full URL', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const formatRelativeTime = (
const { nowLabel = 'just now' } = options;
const now = new Date();
const date = new Date(dateString);
if (isNaN(date.getTime())) return '';
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,24 @@ jest.mock('../../../../../hooks/useAnalytics/useAnalytics', () => ({
}),
}));

jest.mock(
'../../../../../UI/Perps/components/PerpsTokenLogo',
() => 'PerpsTokenLogo',
);

const mockRelatedAsset = {
sourceAssetId: 'btc-mainnet',
symbol: 'BTC',
name: 'Bitcoin',
caip19: ['eip155:1/slip44:0'],
hlPerpsMarket: ['BTC'],
};

const baseItem: WhatsHappeningItem = {
id: 'trend-0',
title: 'Bitcoin ETF inflows hit record high',
description: 'Spot Bitcoin ETFs recorded over $1.2B in net inflows.',
date: '2026-03-15T10:00:00.000Z',
date: new Date(new Date().getTime() - 4 * 24 * 60 * 60 * 1000).toISOString(),
category: 'macro',
impact: 'positive',
relatedAssets: [mockRelatedAsset],
Expand All @@ -58,14 +64,8 @@ describe('WhatsHappeningCard', () => {
expect(screen.getByText(baseItem.description)).toBeOnTheScreen();
});

it('renders category badge when category is provided', () => {
it('does not render category badge', () => {
renderWithProvider(<WhatsHappeningCard item={baseItem} cardIndex={0} />);
expect(screen.getByText('Macro')).toBeOnTheScreen();
});

it('does not render category badge when category is absent', () => {
const item = { ...baseItem, category: undefined };
renderWithProvider(<WhatsHappeningCard item={item} cardIndex={0} />);
expect(screen.queryByText('Macro')).toBeNull();
});

Expand Down Expand Up @@ -95,39 +95,43 @@ describe('WhatsHappeningCard', () => {
expect(screen.queryByText('Neutral')).toBeNull();
});

it('renders impact badge alongside category badge', () => {
renderWithProvider(<WhatsHappeningCard item={baseItem} cardIndex={0} />);
expect(screen.getByText('Bullish')).toBeOnTheScreen();
expect(screen.getByText('Macro')).toBeOnTheScreen();
});

it('renders related asset symbol pills', () => {
it('renders the asset symbol label when there is a single related asset', () => {
renderWithProvider(<WhatsHappeningCard item={baseItem} cardIndex={0} />);
expect(screen.getByText('BTC')).toBeOnTheScreen();
});

it('does not render asset pills when relatedAssets is empty', () => {
const item = { ...baseItem, relatedAssets: [] };
renderWithProvider(<WhatsHappeningCard item={item} cardIndex={0} />);
expect(screen.queryByText('BTC')).toBeNull();
});

it('renders multiple related asset symbols', () => {
it('renders "<symbol> +N" label when there are multiple related assets', () => {
const ethAsset = {
sourceAssetId: 'eth-mainnet',
symbol: 'ETH',
name: 'Ethereum',
caip19: ['eip155:1/slip44:60'],
hlPerpsMarket: ['ETH'],
};
const solAsset = {
sourceAssetId: 'sol-mainnet',
symbol: 'SOL',
name: 'Solana',
caip19: ['solana:mainnet/slip44:501'],
hlPerpsMarket: ['SOL'],
};
const item = {
...baseItem,
relatedAssets: [mockRelatedAsset, ethAsset, solAsset],
};
const item = { ...baseItem, relatedAssets: [mockRelatedAsset, ethAsset] };
renderWithProvider(<WhatsHappeningCard item={item} cardIndex={0} />);
expect(screen.getByText('BTC')).toBeOnTheScreen();
expect(screen.getByText('ETH')).toBeOnTheScreen();
expect(screen.getByText('BTC +2')).toBeOnTheScreen();
});

it('does not render asset label when relatedAssets is empty', () => {
const item = { ...baseItem, relatedAssets: [] };
renderWithProvider(<WhatsHappeningCard item={item} cardIndex={0} />);
expect(screen.queryByText('BTC')).toBeNull();
});

it('renders formatted date when date is valid', () => {
it('renders relative time when date is valid', () => {
renderWithProvider(<WhatsHappeningCard item={baseItem} cardIndex={0} />);
expect(screen.getByText('Mar 15, 2026')).toBeOnTheScreen();
expect(screen.getByText('4d ago')).toBeOnTheScreen();
});

it('does not render date when date string is invalid', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,19 @@
FontWeight,
BoxFlexDirection,
BoxAlignItems,
BoxJustifyContent,
} from '@metamask/design-system-react-native';
import { strings } from '../../../../../../../locales/i18n';
import type { WhatsHappeningItem } from '../types';
import { formatShortDate } from '../util/formatDate';
import {
getImpactLabel,
getImpactBackgroundClass,
getImpactTextColor,
} from '../util/impact';
import PerpsTokenLogo from '../../../../../UI/Perps/components/PerpsTokenLogo';
import { MetaMetricsEvents } from '../../../../../../core/Analytics';
import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics';
import { useViewportTracking } from '../../../../../UI/MarketInsights/hooks/useViewportTracking';
import { formatRelativeTime } from '../../../../../UI/MarketInsights/utils/marketInsightsFormatting';
import { getWhatsHappeningEventProps } from '../eventProperties';

interface WhatsHappeningCardProps {
Expand All @@ -29,13 +30,19 @@
onPress?: (item: WhatsHappeningItem) => void;
}

const MAX_VISIBLE_ASSET_ICONS = 3;

const WhatsHappeningCard: React.FC<WhatsHappeningCardProps> = ({
item,
cardIndex,
onPress,
}) => {
const tw = useTailwind();
const formattedDate = useMemo(() => formatShortDate(item.date), [item.date]);
const formattedDate = useMemo(
() =>
item.date ? formatRelativeTime(item.date, { nowLabel: 'now' }) : null,
[item.date],
);
Comment thread
joaosantos15 marked this conversation as resolved.
const { trackEvent, createEventBuilder } = useAnalytics();

const handlePress = () => onPress?.(item);
Expand All @@ -53,6 +60,15 @@
const { ref: cardRef, onLayout: onVisibilityLayout } =
useViewportTracking(handleVisible);

const visibleAssets = item.relatedAssets.slice(0, MAX_VISIBLE_ASSET_ICONS);
const firstAsset = item.relatedAssets[0];
const remainingAssetCount = Math.max(0, item.relatedAssets.length - 1);
const assetLabel = firstAsset
? remainingAssetCount > 0
? `${firstAsset.symbol} +${remainingAssetCount}`
: firstAsset.symbol

Check warning on line 69 in app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=metamask-mobile&issues=AZ4IDP2N0C254hnsNN4d&open=AZ4IDP2N0C254hnsNN4d&pullRequest=29920
: null;

return (
<View ref={cardRef} collapsable={false} onLayout={onVisibilityLayout}>
<TouchableOpacity
Expand All @@ -63,43 +79,20 @@
)}
>
<Box gap={3}>
{/* Impact + Category badges */}
{(item.impact || item.category) && (
{item.impact && (
<Box
flexDirection={BoxFlexDirection.Row}
alignItems={BoxAlignItems.Center}
twClassName="flex-wrap gap-2"
twClassName={`self-start rounded ${getImpactBackgroundClass(item.impact)} px-2 py-0.5`}
>
{item.impact && (
<Box
twClassName={`self-start rounded-full ${getImpactBackgroundClass(item.impact)} px-2 py-0.5`}
>
<Text
variant={TextVariant.BodyXs}
color={getImpactTextColor(item.impact)}
fontWeight={FontWeight.Medium}
>
{getImpactLabel(item.impact)}
</Text>
</Box>
)}
{item.category && (
<Box twClassName="self-start rounded-full bg-background-default px-2 py-0.5">
<Text
variant={TextVariant.BodyXs}
color={TextColor.TextAlternative}
fontWeight={FontWeight.Medium}
>
{strings(
`homepage.sections.whats_happening_categories.${item.category}`,
)}
</Text>
</Box>
)}
<Text
variant={TextVariant.BodyXs}
color={getImpactTextColor(item.impact)}
fontWeight={FontWeight.Medium}
>
{getImpactLabel(item.impact)}
</Text>
</Box>
)}

{/* Title */}
<Text
variant={TextVariant.BodyMd}
fontWeight={FontWeight.Medium}
Expand All @@ -109,7 +102,6 @@
{item.title}
</Text>

{/* Description */}
<Text
variant={TextVariant.BodySm}
color={TextColor.TextAlternative}
Expand All @@ -119,28 +111,42 @@
</Text>
</Box>

{/* Footer: asset pills + date */}
<Box gap={2}>
{item.relatedAssets.length > 0 && (
<Box
flexDirection={BoxFlexDirection.Row}
alignItems={BoxAlignItems.Center}
justifyContent={BoxJustifyContent.Between}
gap={2}
>
{assetLabel && (
<Box
flexDirection={BoxFlexDirection.Row}
alignItems={BoxAlignItems.Center}
twClassName="flex-wrap gap-1"
gap={1}
twClassName="flex-shrink"
>
{item.relatedAssets.map((asset) => (
<Box
key={asset.sourceAssetId}
twClassName="rounded-full bg-background-default px-2 py-0.5"
>
<Text
variant={TextVariant.BodyXs}
color={TextColor.TextDefault}
fontWeight={FontWeight.Medium}
>
{asset.symbol}
</Text>
{visibleAssets.length > 0 && (
<Box flexDirection={BoxFlexDirection.Row}>
{visibleAssets.map((asset, index) => (
<Box
key={asset.sourceAssetId}
twClassName={index > 0 ? '-ml-1' : ''}
>
<PerpsTokenLogo
symbol={asset.hlPerpsMarket?.[0] ?? asset.symbol}
size={16}
/>
</Box>
))}
</Box>
))}
)}
<Text
variant={TextVariant.BodyXs}
color={TextColor.TextAlternative}
fontWeight={FontWeight.Medium}
numberOfLines={1}
>
{assetLabel}
</Text>
</Box>
)}

Expand Down

This file was deleted.

Loading