Skip to content

Commit 8134084

Browse files
feat(social-leaderboard): top-rank decorations, skeleton alignment, chain icon overlay
- Remove the dot separator next to the rank in trader rows. - Add gold/silver/bronze podium decorations (gradient ring + floating emoji + tinted rank digit) on ranks 1, 2, 3 across the leaderboard. - Show the same podium decoration on the trader's profile header. - Re-tune the leaderboard skeleton row to perfectly match the loaded row layout (avatar, columns, spacing). - Overlay the chain icon on the token avatar in the position page and the quick buy bottom sheet. Made-with: Cursor
1 parent 60e51d2 commit 8134084

26 files changed

Lines changed: 685 additions & 116 deletions

app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const mockTraders = [
1212
{
1313
id: 'trader-1',
1414
rank: 1,
15+
overallRank: 1,
1516
username: 'alice',
1617
percentageChange: 96.2,
1718
pnlValue: 963000,
@@ -134,7 +135,7 @@ describe('TopTradersSection', () => {
134135

135136
expect(mockNavigate).toHaveBeenCalledWith(
136137
Routes.SOCIAL_LEADERBOARD.PROFILE,
137-
{ traderId: 'trader-1', traderName: 'alice' },
138+
{ traderId: 'trader-1', traderName: 'alice', rank: 1 },
138139
);
139140
});
140141

app/components/Views/Homepage/Sections/TopTraders/TopTradersSection.tsx

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import { Box } from '@metamask/design-system-react-native';
2+
import { useTailwind } from '@metamask/design-system-twrnc-preset';
3+
import { useNavigation } from '@react-navigation/native';
4+
import type { StackNavigationProp } from '@react-navigation/stack';
15
import React, {
26
forwardRef,
37
useCallback,
@@ -6,24 +10,20 @@ import React, {
610
} from 'react';
711
import { View } from 'react-native';
812
import { ScrollView } from 'react-native-gesture-handler';
9-
import { useNavigation } from '@react-navigation/native';
10-
import type { StackNavigationProp } from '@react-navigation/stack';
11-
import type { RootStackParamList } from '../../../../../core/NavigationService/types';
1213
import { useSelector } from 'react-redux';
13-
import { Box } from '@metamask/design-system-react-native';
14-
import { useTailwind } from '@metamask/design-system-twrnc-preset';
15-
import SectionHeader from '../../../../../component-library/components-temp/SectionHeader';
16-
import { SectionRefreshHandle } from '../../types';
17-
import { selectSocialLeaderboardEnabled } from '../../../../../selectors/featureFlagController/socialLeaderboard';
1814
import { strings } from '../../../../../../locales/i18n';
15+
import SectionHeader from '../../../../../component-library/components-temp/SectionHeader';
1916
import Routes from '../../../../../constants/navigation/Routes';
17+
import type { RootStackParamList } from '../../../../../core/NavigationService/types';
18+
import { selectSocialLeaderboardEnabled } from '../../../../../selectors/featureFlagController/socialLeaderboard';
19+
import ViewMoreCard from '../../components/ViewMoreCard';
2020
import useHomeViewedEvent, {
2121
HomeSectionNames,
2222
} from '../../hooks/useHomeViewedEvent';
23+
import { useSectionPerformance } from '../../hooks/useSectionPerformance';
24+
import { SectionRefreshHandle } from '../../types';
2325
import { TopTraderCard, TopTraderCardSkeleton } from './components';
2426
import { useTopTraders } from './hooks';
25-
import { useSectionPerformance } from '../../hooks/useSectionPerformance';
26-
import ViewMoreCard from '../../components/ViewMoreCard';
2727

2828
const HOME_TRADER_LIMIT = 3;
2929
const SKELETON_KEYS = Array.from(
@@ -89,10 +89,11 @@ const TopTradersSection = forwardRef<
8989
}, [navigation]);
9090

9191
const handleTraderPress = useCallback(
92-
(traderId: string, traderName: string) => {
92+
(traderId: string, traderName: string, rank: number) => {
9393
navigation.navigate(Routes.SOCIAL_LEADERBOARD.PROFILE, {
9494
traderId,
9595
traderName,
96+
rank,
9697
});
9798
},
9899
[navigation],

app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.test.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { TopTrader } from '../types';
77
const baseTrader: TopTrader = {
88
id: 'trader-1',
99
rank: 1,
10+
overallRank: 1,
1011
username: 'sniperliquid',
1112
avatarUri: 'https://example.com/avatar.png',
1213
percentageChange: 43,
@@ -96,7 +97,7 @@ describe('TopTraderCard', () => {
9697
expect(mockOnFollowPress).toHaveBeenCalledWith('trader-1');
9798
});
9899

99-
it('calls onTraderPress with trader.id and username when card content is tapped', () => {
100+
it('calls onTraderPress with trader id, username, and rank when card content is tapped', () => {
100101
renderWithProvider(
101102
<TopTraderCard
102103
trader={baseTrader}
@@ -107,7 +108,11 @@ describe('TopTraderCard', () => {
107108

108109
fireEvent.press(screen.getByTestId('top-trader-card-pressable-trader-1'));
109110

110-
expect(mockOnTraderPress).toHaveBeenCalledWith('trader-1', 'sniperliquid');
111+
expect(mockOnTraderPress).toHaveBeenCalledWith(
112+
'trader-1',
113+
'sniperliquid',
114+
1,
115+
);
111116
});
112117

113118
it('does not call onTraderPress when the prop is not provided', () => {

app/components/Views/Homepage/Sections/TopTraders/components/TopTraderCard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { formatPnl } from '../utils/formatPnl';
2222
export interface TopTraderCardProps {
2323
trader: TopTrader;
2424
onFollowPress: (traderId: string) => void;
25-
onTraderPress?: (traderId: string, traderName: string) => void;
25+
onTraderPress?: (traderId: string, traderName: string, rank: number) => void;
2626
testID?: string;
2727
}
2828

@@ -58,7 +58,7 @@ const TopTraderCard: React.FC<TopTraderCardProps> = ({
5858
activeOpacity={onTraderPress ? 0.7 : 1}
5959
onPress={
6060
onTraderPress
61-
? () => onTraderPress(trader.id, trader.username)
61+
? () => onTraderPress(trader.id, trader.username, trader.rank)
6262
: undefined
6363
}
6464
disabled={!onTraderPress}

app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.test.tsx

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1+
import { fireEvent, screen } from '@testing-library/react-native';
12
import React from 'react';
23
import { StyleSheet } from 'react-native';
3-
import { screen, fireEvent } from '@testing-library/react-native';
44
import type { ReactTestInstance } from 'react-test-renderer';
55
import renderWithProvider from '../../../../../../util/test/renderWithProvider';
6-
import TraderRow from './TraderRow';
76
import type { TopTrader } from '../types';
7+
import TraderRow from './TraderRow';
88

99
const baseTrader: TopTrader = {
1010
id: 'trader-1',
1111
rank: 1,
12+
overallRank: 1,
1213
username: 'sniperliquid',
1314
avatarUri: 'https://example.com/avatar.png',
1415
percentageChange: 43,
@@ -29,7 +30,7 @@ describe('TraderRow', () => {
2930
renderWithProvider(
3031
<TraderRow trader={baseTrader} onFollowPress={mockOnFollowPress} />,
3132
);
32-
expect(screen.getByText('1.')).toBeOnTheScreen();
33+
expect(screen.getByText('1')).toBeOnTheScreen();
3334
expect(screen.getByText('sniperliquid')).toBeOnTheScreen();
3435
expect(screen.getByText('+43.0%')).toBeOnTheScreen();
3536
expect(screen.getByText('+$963K')).toBeOnTheScreen();
@@ -82,7 +83,11 @@ describe('TraderRow', () => {
8283
/>,
8384
);
8485
fireEvent.press(screen.getByText('sniperliquid'));
85-
expect(mockOnTraderPress).toHaveBeenCalledWith('trader-1', 'sniperliquid');
86+
expect(mockOnTraderPress).toHaveBeenCalledWith(
87+
'trader-1',
88+
'sniperliquid',
89+
1,
90+
);
8691
});
8792

8893
it('does not fire onTraderPress when the prop is undefined', () => {
@@ -137,6 +142,13 @@ describe('TraderRow', () => {
137142
return flat?.minWidth;
138143
};
139144

145+
const resolveAlignSelf = (node: ReactTestInstance): string | undefined => {
146+
const flat = StyleSheet.flatten(node.props.style) as
147+
| { alignSelf?: string }
148+
| undefined;
149+
return flat?.alignSelf;
150+
};
151+
140152
it('renders the stats line with numberOfLines=1 so it does not wrap when the button grows', () => {
141153
const trader: TopTrader = {
142154
...baseTrader,
@@ -172,19 +184,37 @@ describe('TraderRow', () => {
172184
});
173185

174186
it('renders the rank on a single line so the trailing dot does not wrap for double-digit ranks', () => {
175-
const doubleDigitTrader: TopTrader = { ...baseTrader, rank: 20 };
187+
const doubleDigitTrader: TopTrader = {
188+
...baseTrader,
189+
rank: 20,
190+
overallRank: 20,
191+
};
176192
renderWithProvider(
177193
<TraderRow
178194
trader={doubleDigitTrader}
179195
onFollowPress={mockOnFollowPress}
180196
/>,
181197
);
182198

183-
const rankText = screen.getByText('20.');
199+
const rankText = screen.getByText('20');
184200

185201
expect(rankText.props.numberOfLines).toBe(1);
186202
});
187203

204+
it('vertically centers the Follow button so it sits in the middle of the row (overrides ButtonBase self-start default)', () => {
205+
renderWithProvider(
206+
<TraderRow trader={baseTrader} onFollowPress={mockOnFollowPress} />,
207+
);
208+
209+
const followLabel = screen.getByText('Follow');
210+
const buttonWithAlignSelf = findAncestor(
211+
followLabel,
212+
(node) => resolveAlignSelf(node) === 'center',
213+
);
214+
215+
expect(buttonWithAlignSelf).not.toBeNull();
216+
});
217+
188218
it('keeps the same minimum width when toggling between Follow and Following', () => {
189219
const { rerender } = renderWithProvider(
190220
<TraderRow trader={baseTrader} onFollowPress={mockOnFollowPress} />,

app/components/Views/Homepage/Sections/TopTraders/components/TraderRow.tsx

Lines changed: 29 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,17 @@ import { useTailwind } from '@metamask/design-system-twrnc-preset';
2020
import { strings } from '../../../../../../../locales/i18n';
2121
import type { TopTrader } from '../types';
2222
import { formatPnl } from '../utils/formatPnl';
23+
import { TopRankAvatar, TopRankIndicator } from '../topRank';
2324

2425
const AVATAR_SIZE = 40;
26+
// Fixed row height so the skeleton placeholder can match it exactly without
27+
// drifting due to font-scale or button-size differences.
28+
export const TRADER_ROW_HEIGHT = 64;
2529

2630
export interface TraderRowProps {
2731
trader: TopTrader;
2832
onFollowPress: (traderId: string) => void;
29-
onTraderPress?: (traderId: string, traderName: string) => void;
33+
onTraderPress?: (traderId: string, traderName: string, rank: number) => void;
3034
testID?: string;
3135
}
3236

@@ -55,14 +59,15 @@ const TraderRow: React.FC<TraderRowProps> = ({
5559
flexDirection={BoxFlexDirection.Row}
5660
alignItems={BoxAlignItems.Center}
5761
justifyContent={BoxJustifyContent.Between}
58-
twClassName="px-4 py-3"
62+
twClassName="px-4"
63+
style={{ height: TRADER_ROW_HEIGHT }}
5964
testID={testID ?? `trader-row-${trader.id}`}
6065
>
6166
<TouchableOpacity
6267
activeOpacity={onTraderPress ? 0.7 : 1}
6368
onPress={
6469
onTraderPress
65-
? () => onTraderPress(trader.id, trader.username)
70+
? () => onTraderPress(trader.id, trader.username, trader.rank)
6671
: undefined
6772
}
6873
style={tw.style('flex-1 min-w-0 mr-3')}
@@ -73,30 +78,27 @@ const TraderRow: React.FC<TraderRowProps> = ({
7378
alignItems={BoxAlignItems.Center}
7479
gap={3}
7580
>
76-
<Text
77-
variant={TextVariant.BodyMd}
78-
fontWeight={FontWeight.Medium}
79-
color={TextColor.TextDefault}
80-
numberOfLines={1}
81-
twClassName="w-8 text-right"
82-
>
83-
{`${trader.rank}.`}
84-
</Text>
81+
<TopRankIndicator
82+
rank={trader.rank}
83+
podiumRank={trader.overallRank}
84+
/>
8585

86-
{trader.avatarUri ? (
87-
<Image
88-
source={{ uri: trader.avatarUri }}
89-
style={tw.style(
90-
`w-[${AVATAR_SIZE}px] h-[${AVATAR_SIZE}px] rounded-full bg-muted`,
91-
)}
92-
resizeMode="cover"
93-
/>
94-
) : (
95-
<AvatarBase
96-
size={AvatarBaseSize.Lg}
97-
fallbackText={trader.username.charAt(0).toUpperCase()}
98-
/>
99-
)}
86+
<TopRankAvatar rank={trader.overallRank}>
87+
{trader.avatarUri ? (
88+
<Image
89+
source={{ uri: trader.avatarUri }}
90+
style={tw.style(
91+
`w-[${AVATAR_SIZE}px] h-[${AVATAR_SIZE}px] rounded-full bg-muted`,
92+
)}
93+
resizeMode="cover"
94+
/>
95+
) : (
96+
<AvatarBase
97+
size={AvatarBaseSize.Lg}
98+
fallbackText={trader.username.charAt(0).toUpperCase()}
99+
/>
100+
)}
101+
</TopRankAvatar>
100102

101103
<Box twClassName="flex-1 min-w-0">
102104
<Text
@@ -156,7 +158,7 @@ const TraderRow: React.FC<TraderRowProps> = ({
156158
}
157159
size={ButtonSize.Sm}
158160
onPress={() => onFollowPress(trader.id)}
159-
twClassName="min-w-[96px]"
161+
twClassName="min-w-[96px] self-center"
160162
style={
161163
trader.isFollowing
162164
? undefined

app/components/Views/Homepage/Sections/TopTraders/components/TraderRowSkeleton.tsx

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,38 +3,53 @@ import { View } from 'react-native';
33
import SkeletonPlaceholder from 'react-native-skeleton-placeholder';
44
import { useTailwind } from '@metamask/design-system-twrnc-preset';
55
import { useTheme } from '../../../../../../util/theme';
6+
import { TRADER_ROW_HEIGHT } from './TraderRow';
67

78
/**
89
* TraderRowSkeleton — loading placeholder that mirrors the TraderRow layout.
910
*
1011
* Uses react-native-skeleton-placeholder to animate shimmer effect
1112
* over rank, avatar, username/stats text, and action button shapes.
13+
*
14+
* Outer wrapper height is locked to `TRADER_ROW_HEIGHT` so the skeleton
15+
* occupies the exact same vertical space as a rendered <TraderRow />.
1216
*/
1317
const TraderRowSkeleton: React.FC = () => {
1418
const tw = useTailwind();
1519
const { colors } = useTheme();
1620

1721
return (
18-
<View style={tw.style('px-4 py-3')}>
22+
<View
23+
style={[tw.style('px-4 justify-center'), { height: TRADER_ROW_HEIGHT }]}
24+
>
1925
<SkeletonPlaceholder
2026
backgroundColor={colors.background.section}
2127
highlightColor={colors.background.subsection}
2228
>
2329
<View style={tw.style('flex-row items-center')}>
24-
{/* Rank placeholder */}
25-
<View style={tw.style('w-8 h-4 rounded mr-3')} />
30+
{/* Rank placeholder — outer cell mirrors the rendered rank's
31+
`w-6 text-right` (24px container, content right-aligned) so the
32+
bar's visible right edge lines up with where the loaded digit
33+
sits. The inner bar's width (`w-4`) approximates a 1–2 digit
34+
rank glyph. */}
35+
<View style={tw.style('w-6 mr-3 items-end')}>
36+
<View style={tw.style('w-4 h-5 rounded')} />
37+
</View>
2638

2739
{/* Avatar placeholder */}
2840
<View style={tw.style('w-10 h-10 rounded-full mr-3')} />
2941

30-
{/* Text info placeholder */}
31-
<View style={tw.style('flex-1 gap-1.5')}>
32-
<View style={tw.style('w-24 h-4 rounded')} />
33-
<View style={tw.style('w-40 h-3 rounded')} />
42+
{/* Text info placeholder — total height matches the avatar (40px)
43+
so the two bars line up with the actual BodyMd + BodySm text rows
44+
(~20px + ~16px) and don't appear vertically centered with gaps. */}
45+
<View style={tw.style('flex-1 gap-1')}>
46+
<View style={tw.style('w-24 h-5 rounded')} />
47+
<View style={tw.style('w-40 h-4 rounded')} />
3448
</View>
3549

36-
{/* Button placeholder */}
37-
<View style={tw.style('w-20 h-8 rounded-xl ml-3')} />
50+
{/* Button placeholder — `min-w-[96px]` matches the rendered Follow
51+
button so the right edge of the row aligns between states. */}
52+
<View style={tw.style('w-24 h-8 rounded-xl ml-3')} />
3853
</View>
3954
</SkeletonPlaceholder>
4055
</View>

app/components/Views/Homepage/Sections/TopTraders/hooks/useTopTraders.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ describe('useTopTraders', () => {
111111
expect(result.current.traders[0]).toEqual({
112112
id: first.profileId,
113113
rank: first.rank,
114+
overallRank: first.rank,
114115
username: first.name,
115116
avatarUri: first.imageUrl,
116117
percentageChange: first.roiPercent30d * 100,

app/components/Views/Homepage/Sections/TopTraders/hooks/useTopTraders.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export const useTopTraders = (
4848
return data.traders.map((entry) => ({
4949
id: entry.profileId,
5050
rank: entry.rank,
51+
overallRank: entry.rank,
5152
username: entry.name,
5253
avatarUri: entry.imageUrl ?? undefined,
5354
percentageChange: (entry.roiPercent30d ?? 0) * 100,

0 commit comments

Comments
 (0)