Skip to content

Commit fc67edc

Browse files
authored
feat: implement favorites functionality in token selector and enhance UI components (#9550)
- Added favorites feature for tokens, allowing users to mark and filter favorite items. - Updated token selector components to include a favorites tab and corresponding logic. - Introduced a FavoriteButton component for toggling favorites in the token list. - Enhanced the layout of the PerpDesktopLayout to include a FavoritesBar. - Refactored related hooks and atoms to support persistent storage of favorite tokens.
1 parent d66d72e commit fc67edc

File tree

13 files changed

+516
-37
lines changed

13 files changed

+516
-37
lines changed

packages/kit-bg/src/states/jotai/atomNames.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export enum EAtomNames {
8585
perpsCandlesWebviewMountedAtom = 'perpsCandlesWebviewMountedAtom',
8686
perpsWebSocketDataUpdateTimesAtom = 'perpsWebSocketDataUpdateTimesAtom',
8787
perpTokenSelectorConfigPersistAtom = 'perpTokenSelectorConfigPersistAtom',
88+
perpTokenFavoritesPersistAtom = 'perpTokenFavoritesPersistAtom',
8889
perpsDepositOrderAtom = 'perpsDepositOrderAtom',
8990
perpsLastUsedLeverageAtom = 'perpsLastUsedLeverageAtom',
9091
perpsLayoutStateAtom = 'perpsLayoutStateAtom',

packages/kit-bg/src/states/jotai/atoms/perps.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,21 @@ export const {
259259
},
260260
});
261261

262+
export interface IPerpTokenFavorites {
263+
favorites: string[];
264+
}
265+
266+
export const {
267+
target: perpTokenFavoritesPersistAtom,
268+
use: usePerpTokenFavoritesPersistAtom,
269+
} = globalAtom<IPerpTokenFavorites>({
270+
name: EAtomNames.perpTokenFavoritesPersistAtom,
271+
persist: true,
272+
initialValue: {
273+
favorites: [],
274+
},
275+
});
276+
262277
export type IPerpsActiveOrderBookOptionsAtom =
263278
| (IL2BookOptions & {
264279
coin: string;

packages/kit/src/states/jotai/contexts/hyperliquid/atoms.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
resolveTradingSizeBN,
55
sanitizeManualSize,
66
} from '@onekeyhq/shared/src/utils/perpsUtils';
7+
import { XYZ_ASSET_ID_OFFSET } from '@onekeyhq/shared/types/hyperliquid/perp.constants';
78
import type * as HL from '@onekeyhq/shared/types/hyperliquid/sdk';
89
import type {
910
IConnectionState,
@@ -289,3 +290,31 @@ export const {
289290
sliderEnabled: maxSizeBN.isFinite() && maxSizeBN.gte(0),
290291
};
291292
});
293+
294+
export const perpsCtxByCoinAtomCache = new Map<
295+
string,
296+
ReturnType<typeof contextAtomComputed<HL.IPerpsAssetCtx | null>>
297+
>();
298+
299+
function getOrCreateCtxByCoinAtom(dexIndex: number, assetId: number) {
300+
const key = `${dexIndex}-${assetId}`;
301+
let entry = perpsCtxByCoinAtomCache.get(key);
302+
if (!entry) {
303+
const ctxIndex = dexIndex === 1 ? assetId - XYZ_ASSET_ID_OFFSET : assetId;
304+
entry = contextAtomComputed((get) => {
305+
const { assetCtxsByDex } = get(perpsAllAssetCtxsAtom());
306+
return assetCtxsByDex?.[dexIndex]?.[ctxIndex] ?? null;
307+
});
308+
perpsCtxByCoinAtomCache.set(key, entry);
309+
}
310+
return entry;
311+
}
312+
313+
export function usePerpsCtxByCoin(
314+
dexIndex: number,
315+
assetId: number,
316+
): HL.IPerpsAssetCtx | null {
317+
const { use } = getOrCreateCtxByCoinAtom(dexIndex, assetId);
318+
const [ctx] = use();
319+
return ctx;
320+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { memo, useMemo } from 'react';
2+
3+
import { SizableText, XStack } from '@onekeyhq/components';
4+
import { usePerpsCtxByCoin } from '@onekeyhq/kit/src/states/jotai/contexts/hyperliquid/atoms';
5+
import {
6+
usePerpsActiveAssetAtom,
7+
usePerpsActiveAssetCtxAtom,
8+
} from '@onekeyhq/kit-bg/src/states/jotai/atoms';
9+
import perpsUtils, {
10+
formatPriceToSignificantDigits,
11+
} from '@onekeyhq/shared/src/utils/perpsUtils';
12+
13+
interface IFavoriteTokenItemProps {
14+
displayName: string;
15+
coinName: string;
16+
dexIndex: number;
17+
assetId: number;
18+
onPress: () => void;
19+
}
20+
21+
const CtxPriceDisplay = memo(
22+
({ dexIndex, assetId }: { dexIndex: number; assetId: number }) => {
23+
const ctx = usePerpsCtxByCoin(dexIndex, assetId);
24+
const formattedCtx = useMemo(() => perpsUtils.formatAssetCtx(ctx), [ctx]);
25+
26+
const priceDisplay = formattedCtx?.markPrice
27+
? formatPriceToSignificantDigits(formattedCtx.markPrice)
28+
: '-';
29+
30+
const color =
31+
(formattedCtx?.change24hPercent ?? 0) >= 0
32+
? '$textSuccess'
33+
: '$textCritical';
34+
35+
return (
36+
<SizableText
37+
size="$bodySmMedium"
38+
color={color}
39+
style={{ fontVariantNumeric: 'tabular-nums' }}
40+
>
41+
{priceDisplay}
42+
</SizableText>
43+
);
44+
},
45+
);
46+
CtxPriceDisplay.displayName = 'CtxPriceDisplay';
47+
48+
const ActiveAssetPriceDisplay = memo(
49+
({ dexIndex, assetId }: { dexIndex: number; assetId: number }) => {
50+
const [assetCtx] = usePerpsActiveAssetCtxAtom();
51+
const fallbackCtx = usePerpsCtxByCoin(dexIndex, assetId);
52+
const formattedFallback = useMemo(
53+
() => perpsUtils.formatAssetCtx(fallbackCtx),
54+
[fallbackCtx],
55+
);
56+
57+
const activeCtx = assetCtx?.ctx;
58+
const ctx = activeCtx?.markPrice ? activeCtx : formattedFallback;
59+
60+
const priceDisplay = ctx?.markPrice
61+
? formatPriceToSignificantDigits(ctx.markPrice)
62+
: '-';
63+
const color =
64+
(ctx?.change24hPercent ?? 0) >= 0 ? '$textSuccess' : '$textCritical';
65+
66+
return (
67+
<SizableText
68+
size="$bodySmMedium"
69+
color={color}
70+
style={{ fontVariantNumeric: 'tabular-nums' }}
71+
>
72+
{priceDisplay}
73+
</SizableText>
74+
);
75+
},
76+
);
77+
ActiveAssetPriceDisplay.displayName = 'ActiveAssetPriceDisplay';
78+
79+
function FavoriteTokenItem({
80+
displayName,
81+
coinName,
82+
dexIndex,
83+
assetId,
84+
onPress,
85+
}: IFavoriteTokenItemProps) {
86+
const [activeAsset] = usePerpsActiveAssetAtom();
87+
const isActiveToken = activeAsset?.coin === coinName;
88+
89+
return (
90+
<XStack
91+
onPress={onPress}
92+
cursor="pointer"
93+
px="$1"
94+
py="$1"
95+
borderRadius="$2"
96+
hoverStyle={{
97+
bg: '$bgHover',
98+
}}
99+
userSelect="none"
100+
alignItems="center"
101+
gap="$2"
102+
>
103+
<SizableText size="$bodySmMedium" color="$text">
104+
{displayName}-USDC
105+
</SizableText>
106+
{isActiveToken ? (
107+
<ActiveAssetPriceDisplay dexIndex={dexIndex} assetId={assetId} />
108+
) : (
109+
<CtxPriceDisplay dexIndex={dexIndex} assetId={assetId} />
110+
)}
111+
</XStack>
112+
);
113+
}
114+
115+
const FavoriteTokenItemMemo = memo(FavoriteTokenItem);
116+
export { FavoriteTokenItemMemo as FavoriteTokenItem };
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import {
2+
memo,
3+
useCallback,
4+
useEffect,
5+
useLayoutEffect,
6+
useRef,
7+
useState,
8+
} from 'react';
9+
10+
import { Icon, ScrollView, Stack, XStack } from '@onekeyhq/components';
11+
import { useHyperliquidActions } from '@onekeyhq/kit/src/states/jotai/contexts/hyperliquid';
12+
13+
import { usePerpsFavorites } from '../../hooks/usePerpsFavorites';
14+
15+
import { FavoriteTokenItem } from './FavoriteTokenItem';
16+
17+
const SCROLL_DISTANCE = 250;
18+
19+
const ScrollButton = memo(
20+
({
21+
direction,
22+
onPress,
23+
}: {
24+
direction: 'left' | 'right';
25+
onPress: () => void;
26+
}) => {
27+
const isLeft = direction === 'left';
28+
return (
29+
<XStack
30+
position="absolute"
31+
top={0}
32+
bottom={0}
33+
my="auto"
34+
{...(isLeft ? { left: 0 } : { right: 0 })}
35+
width={40}
36+
height={24}
37+
alignItems="center"
38+
justifyContent={isLeft ? 'flex-start' : 'flex-end'}
39+
onPress={onPress}
40+
cursor="pointer"
41+
style={{
42+
background: isLeft
43+
? 'linear-gradient(90deg, var(--bgApp) 40%, transparent 100%)'
44+
: 'linear-gradient(270deg, var(--bgApp) 40%, transparent 100%)',
45+
}}
46+
>
47+
<Stack
48+
width={24}
49+
height={24}
50+
justifyContent="center"
51+
alignItems="center"
52+
ml={isLeft ? '$1' : 0}
53+
mr={isLeft ? 0 : '$1'}
54+
>
55+
<Icon
56+
name={
57+
isLeft ? 'ChevronLeftSmallOutline' : 'ChevronRightSmallOutline'
58+
}
59+
size="$5"
60+
color="$iconSubdued"
61+
/>
62+
</Stack>
63+
</XStack>
64+
);
65+
},
66+
);
67+
ScrollButton.displayName = 'ScrollButton';
68+
69+
function FavoritesBar() {
70+
const { favoriteItems } = usePerpsFavorites();
71+
const actions = useHyperliquidActions();
72+
const hasFavorites = favoriteItems.length > 0;
73+
74+
const scrollRef = useRef<HTMLDivElement>(null);
75+
const [canScrollLeft, setCanScrollLeft] = useState(false);
76+
const [canScrollRight, setCanScrollRight] = useState(false);
77+
78+
const updateScrollState = useCallback(() => {
79+
const el = scrollRef.current;
80+
if (!el) return;
81+
const hasOverflow = el.scrollWidth > el.clientWidth;
82+
setCanScrollLeft(el.scrollLeft > 1);
83+
setCanScrollRight(
84+
hasOverflow && el.scrollLeft + el.clientWidth < el.scrollWidth - 1,
85+
);
86+
}, []);
87+
88+
useLayoutEffect(() => {
89+
requestAnimationFrame(updateScrollState);
90+
}, [favoriteItems, updateScrollState]);
91+
92+
const scrollLeft = useCallback(() => {
93+
scrollRef.current?.scrollBy({ left: -SCROLL_DISTANCE, behavior: 'smooth' });
94+
}, []);
95+
96+
const scrollRight = useCallback(() => {
97+
scrollRef.current?.scrollBy({ left: SCROLL_DISTANCE, behavior: 'smooth' });
98+
}, []);
99+
100+
useEffect(() => {
101+
if (hasFavorites) {
102+
const currentActions = actions.current;
103+
currentActions.markAllAssetCtxsRequired();
104+
return () => {
105+
currentActions.markAllAssetCtxsNotRequired();
106+
};
107+
}
108+
}, [actions, hasFavorites]);
109+
110+
if (!hasFavorites) {
111+
return null;
112+
}
113+
114+
return (
115+
<Stack position="relative" h={40}>
116+
<ScrollView
117+
ref={scrollRef as any}
118+
horizontal
119+
showsHorizontalScrollIndicator={false}
120+
bg="$bgApp"
121+
borderBottomWidth="$px"
122+
borderBottomColor="$borderSubdued"
123+
h={24}
124+
contentContainerStyle={{
125+
alignItems: 'center',
126+
px: '$3',
127+
gap: '$1',
128+
}}
129+
onScroll={updateScrollState}
130+
scrollEventThrottle={16}
131+
>
132+
{favoriteItems.map((item) => (
133+
<FavoriteTokenItem
134+
key={`${item.assetId}`}
135+
displayName={item.displayName}
136+
coinName={item.coinName}
137+
dexIndex={item.dexIndex}
138+
assetId={item.assetId}
139+
onPress={() =>
140+
void actions.current.changeActiveAsset({ coin: item.coinName })
141+
}
142+
/>
143+
))}
144+
</ScrollView>
145+
{canScrollLeft ? (
146+
<ScrollButton direction="left" onPress={scrollLeft} />
147+
) : null}
148+
{canScrollRight ? (
149+
<ScrollButton direction="right" onPress={scrollRight} />
150+
) : null}
151+
</Stack>
152+
);
153+
}
154+
155+
const FavoritesBarMemo = memo(FavoritesBar);
156+
export { FavoritesBarMemo as FavoritesBar };

0 commit comments

Comments
 (0)