From 016544474f9d989b385e68588d69b85bcb3ee423 Mon Sep 17 00:00:00 2001 From: Lizaveta Miasayedava Date: Tue, 11 Nov 2025 23:38:24 +0000 Subject: [PATCH 1/5] feat: add ability to pin tokens --- .../components/TokenList/TokenListItem.tsx | 54 +++++++- .../TokenList/VirtualizedTokenList.tsx | 118 ++++++++++++------ packages/widget/src/hooks/useTokenBalances.ts | 41 +++++- packages/widget/src/i18n/en.json | 1 + packages/widget/src/stores/StoreProvider.tsx | 17 +-- .../stores/pinnedTokens/PinnedTokensStore.tsx | 38 ++++++ .../pinnedTokens/createPinnedTokensStore.ts | 71 +++++++++++ .../widget/src/stores/pinnedTokens/types.ts | 20 +++ packages/widget/src/types/token.ts | 1 + packages/widget/src/utils/tokenList.ts | 105 ++++++++++++++-- 10 files changed, 415 insertions(+), 51 deletions(-) create mode 100644 packages/widget/src/stores/pinnedTokens/PinnedTokensStore.tsx create mode 100644 packages/widget/src/stores/pinnedTokens/createPinnedTokensStore.ts create mode 100644 packages/widget/src/stores/pinnedTokens/types.ts diff --git a/packages/widget/src/components/TokenList/TokenListItem.tsx b/packages/widget/src/components/TokenList/TokenListItem.tsx index eb9c76b08..dc118b88c 100644 --- a/packages/widget/src/components/TokenList/TokenListItem.tsx +++ b/packages/widget/src/components/TokenList/TokenListItem.tsx @@ -1,6 +1,8 @@ import type { StaticToken } from '@lifi/sdk' import { ChainType } from '@lifi/sdk' import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined' +import PushPinIcon from '@mui/icons-material/PushPin' +import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined' import { Avatar, Box, @@ -15,6 +17,7 @@ import type { MouseEventHandler } from 'react' import { memo, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useLongPress } from '../../hooks/useLongPress.js' +import { usePinnedTokensStore } from '../../stores/pinnedTokens/PinnedTokensStore.js' import { formatTokenAmount, formatTokenPrice } from '../../utils/format.js' import { shortenAddress } from '../../utils/wallet.js' import { TokenAvatar } from '../Avatar/TokenAvatar.js' @@ -104,7 +107,7 @@ const OpenTokenDetailsButton = ({ size="small" onClick={(e) => { e.stopPropagation() - e.currentTarget.blur() // Remove focus to prevent accessibility issues when opening drawer + ;(e.currentTarget as HTMLElement).blur() // Remove focus to prevent accessibility issues when opening drawer onClick(tokenAddress, withoutContractAddress, chainId) }} > @@ -113,6 +116,43 @@ const OpenTokenDetailsButton = ({ ) } +interface PinTokenButtonProps { + chainId: number + tokenAddress: string +} + +const PinTokenButton = ({ chainId, tokenAddress }: PinTokenButtonProps) => { + const pinnedTokens = usePinnedTokensStore((state) => state.pinnedTokens) + const pinToken = usePinnedTokensStore((state) => state.pinToken) + const unpinToken = usePinnedTokensStore((state) => state.unpinToken) + + const isPinned = + pinnedTokens[chainId]?.includes(tokenAddress.toLowerCase()) ?? false + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation() + ;(e.currentTarget as HTMLElement).blur() + if (isPinned) { + unpinToken(chainId, tokenAddress) + } else { + pinToken(chainId, tokenAddress) + } + } + + const PinIcon = isPinned ? PushPinIcon : PushPinOutlinedIcon + + return ( + + + + ) +} + const TokenListItemButton: React.FC = memo( ({ onClick, @@ -229,8 +269,14 @@ const TokenListItemButton: React.FC = memo( + = memo( = memo( > {shortenAddress(token.address)} + = ({ // Base size for TokenListItem let size = tokenItemHeight - // Early return if categories are not shown + const previousToken = tokens[index - 1] + + // Always account for pinned tokens category (even in all networks mode) + if (currentToken.pinned && index === 0) { + size += 24 + } + + // Adjust size when transitioning from pinned tokens (works in all networks mode too) + if (previousToken?.pinned && !currentToken.pinned) { + size += 32 + } + + // Early return if categories are not shown (for other categories) if (!showCategories) { return size } - const previousToken = tokens[index - 1] - - // Adjust size for the first featured token - if (currentToken.featured && index === 0) { + // Adjust size for the first featured token (if not pinned) + if (currentToken.featured && !currentToken.pinned && index === 0) { size += 24 } // Adjust size based on changes between the current and previous tokens const isCategoryChanged = - (previousToken?.amount && !currentToken.amount) || - (previousToken?.featured && !currentToken.featured) || - (previousToken?.popular && !currentToken.popular) + (previousToken?.amount && + !currentToken.amount && + !currentToken.pinned && + !previousToken?.pinned) || + (previousToken?.featured && + !currentToken.featured && + !currentToken.pinned && + !previousToken?.pinned) || + (previousToken?.popular && + !currentToken.popular && + !currentToken.pinned && + !previousToken?.pinned) if (isCategoryChanged) { size += 32 @@ -138,44 +157,73 @@ export const VirtualizedTokenList: FC = ({ const chain = chainsSet?.get(currentToken.chainId) - const isFirstFeaturedToken = currentToken.featured && item.index === 0 + const isFirstPinnedToken = currentToken.pinned && item.index === 0 + + const isTransitionFromPinnedTokens = + previousToken?.pinned && !currentToken.pinned + + const isFirstFeaturedToken = + currentToken.featured && !currentToken.pinned && item.index === 0 const isTransitionFromFeaturedTokens = - previousToken?.featured && !currentToken.featured + previousToken?.featured && + !currentToken.featured && + !currentToken.pinned const isTransitionFromMyTokens = - previousToken?.amount && !currentToken.amount + previousToken?.amount && + !currentToken.amount && + !currentToken.pinned const isTransitionToMyTokens = - isTransitionFromFeaturedTokens && currentToken.amount + (isTransitionFromFeaturedTokens || isTransitionFromPinnedTokens) && + currentToken.amount && + !currentToken.pinned const isTransitionToPopularTokens = - (isTransitionFromFeaturedTokens || isTransitionFromMyTokens) && - currentToken.popular + (isTransitionFromFeaturedTokens || + isTransitionFromMyTokens || + isTransitionFromPinnedTokens) && + currentToken.popular && + !currentToken.pinned const shouldShowAllTokensCategory = isTransitionFromMyTokens || isTransitionFromFeaturedTokens || - (previousToken?.popular && !currentToken.popular) - - const startAdornmentLabel = - !isAllNetworks && showCategories - ? (() => { - if (isFirstFeaturedToken) { - return t('main.featuredTokens') - } - if (isTransitionToMyTokens) { - return t('main.myTokens') - } - if (isTransitionToPopularTokens) { - return t('main.popularTokens') - } - if (shouldShowAllTokensCategory) { - return t('main.allTokens') - } - return null - })() - : null + isTransitionFromPinnedTokens || + (previousToken?.popular && + !currentToken.popular && + !currentToken.pinned) + + const startAdornmentLabel = (() => { + // Always show pinned tokens category if it's the first pinned token + if (isFirstPinnedToken) { + return t('main.pinnedTokens') + } + // Show "All tokens" category when transitioning from pinned in all networks mode + if (isAllNetworks && isTransitionFromPinnedTokens) { + return t('main.allTokens') + } + // Show other categories only when not in all networks mode + if (!isAllNetworks && showCategories) { + if (isTransitionFromPinnedTokens && currentToken.featured) { + return t('main.featuredTokens') + } + if (isFirstFeaturedToken) { + return t('main.featuredTokens') + } + if (isTransitionToMyTokens) { + return t('main.myTokens') + } + if (isTransitionToPopularTokens) { + return t('main.popularTokens') + } + if (shouldShowAllTokensCategory) { + return t('main.allTokens') + } + } + return null + })() const isSelected = selectedTokenAddress === currentToken.address && @@ -200,7 +248,7 @@ export const VirtualizedTokenList: FC = ({ fontWeight: 600, lineHeight: '16px', px: 1.5, - pt: isFirstFeaturedToken ? 0 : 1, + pt: isFirstPinnedToken || isFirstFeaturedToken ? 0 : 1, pb: 1, }} > diff --git a/packages/widget/src/hooks/useTokenBalances.ts b/packages/widget/src/hooks/useTokenBalances.ts index c63a51af6..b4f1b8652 100644 --- a/packages/widget/src/hooks/useTokenBalances.ts +++ b/packages/widget/src/hooks/useTokenBalances.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react' import { useWidgetConfig } from '../providers/WidgetProvider/WidgetProvider.js' import type { FormType } from '../stores/form/types.js' +import { usePinnedTokensStore } from '../stores/pinnedTokens/PinnedTokensStore.js' import { isSearchMatch, processTokenBalances } from '../utils/tokenList.js' import { useAccountsBalancesData } from './useAccountsBalancesData.js' import { useTokenBalancesQueries } from './useTokenBalancesQueries.js' @@ -29,10 +30,46 @@ export const useTokenBalances = ( const { tokens: configTokens } = useWidgetConfig() + const pinnedTokens = usePinnedTokensStore((state) => state.pinnedTokens) + const isBalanceLoading = (isBalanceQueriesLoading || isAccountsLoading) && !allTokensWithBalances?.length + // Create function to check if token is pinned + const isPinnedToken = useMemo(() => { + if (isAllNetworks) { + // For all networks, check all pinned tokens + const allPinned: Array<{ chainId: number; tokenAddress: string }> = [] + Object.entries(pinnedTokens).forEach(([chainIdStr, addresses]) => { + const chainId = Number.parseInt(chainIdStr, 10) + addresses.forEach((address) => { + allPinned.push({ chainId, tokenAddress: address }) + }) + }) + const pinnedSet = new Set( + allPinned.map((p) => `${p.chainId}-${p.tokenAddress.toLowerCase()}`) + ) + return (chainId: number, tokenAddress: string) => { + const key = `${chainId}-${tokenAddress.toLowerCase()}` + return pinnedSet.has(key) + } + } else if (selectedChainId) { + // For single chain, check only selected chain + const chainPinnedTokens = pinnedTokens[selectedChainId] || [] + const pinnedSet = new Set( + chainPinnedTokens.map((addr) => addr.toLowerCase()) + ) + return (chainId: number, tokenAddress: string) => { + return ( + chainId === selectedChainId && + pinnedSet.has(tokenAddress.toLowerCase()) + ) + } + } + return undefined + }, [isAllNetworks, selectedChainId, pinnedTokens]) + const displayedTokensList = useMemo(() => { const tokensByChain = isAllNetworks ? Object.values(allTokens ?? {}).flat() @@ -76,7 +113,8 @@ export const useTokenBalances = ( configTokens, selectedChainId, displayedTokensList, - displayedTokensWithBalances + displayedTokensWithBalances, + isPinnedToken ) }, [ isBalanceLoading, @@ -86,6 +124,7 @@ export const useTokenBalances = ( displayedTokensList, displayedTokensWithBalances, search, + isPinnedToken, ]) return { diff --git a/packages/widget/src/i18n/en.json b/packages/widget/src/i18n/en.json index 42bda9d9f..fe9ae63fb 100644 --- a/packages/widget/src/i18n/en.json +++ b/packages/widget/src/i18n/en.json @@ -237,6 +237,7 @@ "myTokens": "My tokens", "onChain": "on {{chainName}}", "ownedBy": "Owned by", + "pinnedTokens": "Pinned tokens", "popularTokens": "Popular tokens", "priceImpact": "Price impact", "process": { diff --git a/packages/widget/src/stores/StoreProvider.tsx b/packages/widget/src/stores/StoreProvider.tsx index 2cfd3ac2d..bfd60943f 100644 --- a/packages/widget/src/stores/StoreProvider.tsx +++ b/packages/widget/src/stores/StoreProvider.tsx @@ -4,6 +4,7 @@ import { BookmarkStoreProvider } from './bookmarks/BookmarkStore.js' import { ChainOrderStoreProvider } from './chains/ChainOrderStore.js' import { FormStoreProvider } from './form/FormStore.js' import { HeaderStoreProvider } from './header/useHeaderStore.js' +import { PinnedTokensStoreProvider } from './pinnedTokens/PinnedTokensStore.js' import { RouteExecutionStoreProvider } from './routes/RouteExecutionStore.js' import { SplitSubvariantStoreProvider } from './settings/useSplitSubvariantStore.js' @@ -22,13 +23,15 @@ export const StoreProvider: React.FC> = ({ > - - - - {children} - - - + + + + + {children} + + + + diff --git a/packages/widget/src/stores/pinnedTokens/PinnedTokensStore.tsx b/packages/widget/src/stores/pinnedTokens/PinnedTokensStore.tsx new file mode 100644 index 000000000..503137bf3 --- /dev/null +++ b/packages/widget/src/stores/pinnedTokens/PinnedTokensStore.tsx @@ -0,0 +1,38 @@ +import { createContext, useContext, useRef } from 'react' +import { useShallow } from 'zustand/shallow' +import type { PersistStoreProviderProps } from '../types.js' +import { createPinnedTokensStore } from './createPinnedTokensStore.js' +import type { PinnedTokensState, PinnedTokensStore } from './types.js' + +const PinnedTokensStoreContext = createContext(null) + +export const PinnedTokensStoreProvider: React.FC = ({ + children, + ...props +}) => { + const storeRef = useRef(null) + + if (!storeRef.current) { + storeRef.current = createPinnedTokensStore(props) + } + + return ( + + {children} + + ) +} + +export function usePinnedTokensStore( + selector: (store: PinnedTokensState) => T +) { + const useStore = useContext(PinnedTokensStoreContext) + + if (!useStore) { + throw new Error( + `You forgot to wrap your component in <${PinnedTokensStoreProvider.name}>.` + ) + } + + return useStore(useShallow(selector)) +} diff --git a/packages/widget/src/stores/pinnedTokens/createPinnedTokensStore.ts b/packages/widget/src/stores/pinnedTokens/createPinnedTokensStore.ts new file mode 100644 index 000000000..e1db26926 --- /dev/null +++ b/packages/widget/src/stores/pinnedTokens/createPinnedTokensStore.ts @@ -0,0 +1,71 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' +import type { PersistStoreProps } from '../types.js' +import type { PinnedTokensState } from './types.js' + +export const createPinnedTokensStore = ({ namePrefix }: PersistStoreProps) => + create()( + persist( + (set, get) => ({ + pinnedTokens: {}, + pinToken: (chainId: number, tokenAddress: string) => { + set((state) => { + const normalizedAddress = tokenAddress.toLowerCase() + const chainTokens = state.pinnedTokens[chainId] || [] + if (!chainTokens.includes(normalizedAddress)) { + return { + pinnedTokens: { + ...state.pinnedTokens, + [chainId]: [...chainTokens, normalizedAddress], + }, + } + } + return state + }) + }, + unpinToken: (chainId: number, tokenAddress: string) => { + set((state) => { + const normalizedAddress = tokenAddress.toLowerCase() + const chainTokens = state.pinnedTokens[chainId] || [] + if (chainTokens.includes(normalizedAddress)) { + return { + pinnedTokens: { + ...state.pinnedTokens, + [chainId]: chainTokens.filter( + (addr) => addr !== normalizedAddress + ), + }, + } + } + return state + }) + }, + isPinned: (chainId: number, tokenAddress: string) => { + const normalizedAddress = tokenAddress.toLowerCase() + const chainTokens = get().pinnedTokens[chainId] || [] + return chainTokens.includes(normalizedAddress) + }, + getPinnedTokens: (chainId: number) => { + return get().pinnedTokens[chainId] || [] + }, + getAllPinnedTokens: () => { + const allPinned: Array<{ chainId: number; tokenAddress: string }> = [] + const pinnedTokens = get().pinnedTokens + Object.entries(pinnedTokens).forEach(([chainIdStr, addresses]) => { + const chainId = Number.parseInt(chainIdStr, 10) + addresses.forEach((address) => { + allPinned.push({ chainId, tokenAddress: address }) + }) + }) + return allPinned + }, + }), + { + name: `${namePrefix || 'li.fi'}-pinned-tokens`, + version: 0, + partialize: (state) => ({ + pinnedTokens: state.pinnedTokens, + }), + } + ) + ) diff --git a/packages/widget/src/stores/pinnedTokens/types.ts b/packages/widget/src/stores/pinnedTokens/types.ts new file mode 100644 index 000000000..256d78f9f --- /dev/null +++ b/packages/widget/src/stores/pinnedTokens/types.ts @@ -0,0 +1,20 @@ +import type { StoreApi } from 'zustand' +import type { UseBoundStoreWithEqualityFn } from 'zustand/traditional' + +export interface PinnedTokensProps { + pinnedTokens: Record +} + +export interface PinnedTokensActions { + pinToken: (chainId: number, tokenAddress: string) => void + unpinToken: (chainId: number, tokenAddress: string) => void + isPinned: (chainId: number, tokenAddress: string) => boolean + getPinnedTokens: (chainId: number) => string[] + getAllPinnedTokens: () => Array<{ chainId: number; tokenAddress: string }> +} + +export type PinnedTokensState = PinnedTokensProps & PinnedTokensActions + +export type PinnedTokensStore = UseBoundStoreWithEqualityFn< + StoreApi +> diff --git a/packages/widget/src/types/token.ts b/packages/widget/src/types/token.ts index a90d08f2b..a0b15ee19 100644 --- a/packages/widget/src/types/token.ts +++ b/packages/widget/src/types/token.ts @@ -6,6 +6,7 @@ import type { interface TokenFlags { featured?: boolean popular?: boolean + pinned?: boolean } export interface TokenAmount extends SDKTokenAmount, TokenFlags {} diff --git a/packages/widget/src/utils/tokenList.ts b/packages/widget/src/utils/tokenList.ts index 4634e580e..dc0a666ba 100644 --- a/packages/widget/src/utils/tokenList.ts +++ b/packages/widget/src/utils/tokenList.ts @@ -18,11 +18,29 @@ export const processTokenBalances = ( configTokens?: WidgetTokens, selectedChainId?: number, tokens?: TokenExtended[], - tokensWithBalances?: TokenAmount[] + tokensWithBalances?: TokenAmount[], + isPinnedToken?: (chainId: number, tokenAddress: string) => boolean ) => { if (isBalanceLoading) { if (noCategories) { const sortedTokens = [...(tokens ?? [])].sort(sortByVolume) + // Separate pinned tokens if we have the function + if (isPinnedToken) { + const pinned: TokenAmount[] = [] + const notPinned: TokenAmount[] = [] + for (const token of sortedTokens) { + if (isPinnedToken(token.chainId, token.address)) { + const pinnedToken = { ...token, pinned: true } as TokenAmount + pinned.push(pinnedToken) + } else { + notPinned.push(token) + } + } + return { + processedTokens: [...pinned, ...notPinned], + withCategories: pinned.length > 0, + } + } return { processedTokens: sortedTokens, withCategories: false, @@ -32,7 +50,8 @@ export const processTokenBalances = ( tokens ?? [], [], selectedChainId, - configTokens + configTokens, + isPinnedToken ) } } @@ -55,6 +74,42 @@ export const processTokenBalances = ( .sort(sortByVolume) ?? [] if (noCategories) { + // Separate pinned tokens if we have the function + if (isPinnedToken) { + const pinnedWithBalances: TokenAmount[] = [] + const notPinnedWithBalances: TokenAmount[] = [] + const pinnedWithoutBalances: TokenAmount[] = [] + const notPinnedWithoutBalances: TokenAmount[] = [] + + for (const token of sortedTokensWithBalances) { + if (isPinnedToken(token.chainId, token.address)) { + const pinnedToken = { ...token, pinned: true } as TokenAmount + pinnedWithBalances.push(pinnedToken) + } else { + notPinnedWithBalances.push(token) + } + } + + for (const token of tokensWithoutBalances) { + if (isPinnedToken(token.chainId, token.address)) { + const pinnedToken = { ...token, pinned: true } as TokenAmount + pinnedWithoutBalances.push(pinnedToken) + } else { + notPinnedWithoutBalances.push(token) + } + } + + return { + processedTokens: [ + ...pinnedWithBalances, + ...pinnedWithoutBalances, + ...notPinnedWithBalances, + ...notPinnedWithoutBalances, + ], + withCategories: + pinnedWithBalances.length > 0 || pinnedWithoutBalances.length > 0, + } + } return { processedTokens: [...sortedTokensWithBalances, ...tokensWithoutBalances], withCategories: false, @@ -64,7 +119,8 @@ export const processTokenBalances = ( tokensWithoutBalances, sortedTokensWithBalances, selectedChainId, - configTokens + configTokens, + isPinnedToken ) } } @@ -74,12 +130,14 @@ const processedTypedTokens = ( tokens: TokenAmount[], tokensWithBalances: TokenAmount[], selectedChainId?: number, - configTokens?: WidgetTokens + configTokens?: WidgetTokens, + isPinnedToken?: (chainId: number, tokenAddress: string) => boolean ) => { const filteredTokensMap = new Map( tokens.map((token) => [token.address, token]) ) + const pinnedTokens: TokenAmount[] = [] const featuredTokensFromConfig: TokenAmount[] = [] const popularTokensFromConfig: TokenAmount[] = [] @@ -121,8 +179,17 @@ const processedTypedTokens = ( const otherTokens: TokenAmount[] = [] + // Separate pinned tokens and categorize remaining tokens for (const token of remainingTokens) { - if (token.featured) { + const isPinned = + isPinnedToken && selectedChainId + ? isPinnedToken(selectedChainId, token.address) + : false + + if (isPinned) { + const pinnedToken = { ...token, pinned: true } as TokenAmount + pinnedTokens.push(pinnedToken) + } else if (token.featured) { featuredTokensFromConfig.push(token) } else if (token.popular) { popularTokensFromConfig.push(token) @@ -131,19 +198,43 @@ const processedTypedTokens = ( } } + // Also check tokens with balances for pinned status + const pinnedTokensWithBalances: TokenAmount[] = [] + const nonPinnedTokensWithBalances: TokenAmount[] = [] + + if (isPinnedToken && selectedChainId) { + for (const token of tokensWithBalances) { + if (isPinnedToken(selectedChainId, token.address)) { + const pinnedToken = { ...token, pinned: true } as TokenAmount + pinnedTokensWithBalances.push(pinnedToken) + } else { + nonPinnedTokensWithBalances.push(token) + } + } + } else { + nonPinnedTokensWithBalances.push(...tokensWithBalances) + } + + const sortedPinnedTokens = [ + ...pinnedTokens, + ...pinnedTokensWithBalances, + ].sort(sortByVolume) const sortedFeaturedTokens = [...featuredTokensFromConfig].sort(sortByVolume) const sortedPopularTokens = [...popularTokensFromConfig].sort(sortByVolume) const sortedOtherTokens = [...otherTokens].sort(sortByVolume) return { processedTokens: [ + ...sortedPinnedTokens, ...sortedFeaturedTokens, - ...tokensWithBalances, + ...nonPinnedTokensWithBalances, ...sortedPopularTokens, ...sortedOtherTokens, ], withCategories: Boolean( - featuredTokensFromConfig?.length || popularTokensFromConfig?.length + sortedPinnedTokens?.length || + featuredTokensFromConfig?.length || + popularTokensFromConfig?.length ), } } From e79fdf5c7a4cc348dc94463c53f401fd038c7ae1 Mon Sep 17 00:00:00 2001 From: Lizaveta Miasayedava Date: Wed, 12 Nov 2025 13:43:08 +0000 Subject: [PATCH 2/5] refactor: labels in list --- .../components/TokenList/PinTokenButton.tsx | 52 +++++++ .../src/components/TokenList/TokenList.tsx | 2 + .../components/TokenList/TokenListItem.tsx | 59 ++----- .../TokenList/VirtualizedTokenList.tsx | 145 ++++++++---------- .../widget/src/components/TokenList/types.ts | 1 + packages/widget/src/hooks/useTokenBalances.ts | 3 +- packages/widget/src/utils/tokenList.ts | 32 ++-- 7 files changed, 151 insertions(+), 143 deletions(-) create mode 100644 packages/widget/src/components/TokenList/PinTokenButton.tsx diff --git a/packages/widget/src/components/TokenList/PinTokenButton.tsx b/packages/widget/src/components/TokenList/PinTokenButton.tsx new file mode 100644 index 000000000..5945d57c0 --- /dev/null +++ b/packages/widget/src/components/TokenList/PinTokenButton.tsx @@ -0,0 +1,52 @@ +import PushPinIcon from '@mui/icons-material/PushPin' +import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined' +import { IconButton } from '@mui/material' +import { usePinnedTokensStore } from '../../stores/pinnedTokens/PinnedTokensStore.js' + +interface PinTokenButtonProps { + chainId: number + tokenAddress: string +} + +export const PinTokenButton = ({ + chainId, + tokenAddress, +}: PinTokenButtonProps) => { + const { pinnedTokens, pinToken, unpinToken } = usePinnedTokensStore( + (state) => ({ + pinnedTokens: state.pinnedTokens, + pinToken: state.pinToken, + unpinToken: state.unpinToken, + }) + ) + + const isPinned = + pinnedTokens[chainId]?.includes(tokenAddress.toLowerCase()) ?? false + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation() + ;(e.currentTarget as HTMLElement).blur() + if (isPinned) { + unpinToken(chainId, tokenAddress) + } else { + pinToken(chainId, tokenAddress) + } + } + + const PinIcon = isPinned ? PushPinIcon : PushPinOutlinedIcon + + return ( + + + + ) +} diff --git a/packages/widget/src/components/TokenList/TokenList.tsx b/packages/widget/src/components/TokenList/TokenList.tsx index 04da166b6..bd104b9a1 100644 --- a/packages/widget/src/components/TokenList/TokenList.tsx +++ b/packages/widget/src/components/TokenList/TokenList.tsx @@ -33,6 +33,7 @@ export const TokenList: FC = memo( const { tokens, withCategories, + withPinnedTokens, isTokensLoading, isBalanceLoading, isSearchLoading, @@ -70,6 +71,7 @@ export const TokenList: FC = memo( isLoading={isTokensLoading || isSearchLoading} isBalanceLoading={isBalanceLoading} showCategories={showCategories} + showPinnedTokens={withPinnedTokens} onClick={handleTokenClick} selectedTokenAddress={selectedTokenAddress} isAllNetworks={isAllNetworks} diff --git a/packages/widget/src/components/TokenList/TokenListItem.tsx b/packages/widget/src/components/TokenList/TokenListItem.tsx index dc118b88c..c32dbe756 100644 --- a/packages/widget/src/components/TokenList/TokenListItem.tsx +++ b/packages/widget/src/components/TokenList/TokenListItem.tsx @@ -1,8 +1,6 @@ import type { StaticToken } from '@lifi/sdk' import { ChainType } from '@lifi/sdk' import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined' -import PushPinIcon from '@mui/icons-material/PushPin' -import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined' import { Avatar, Box, @@ -17,11 +15,11 @@ import type { MouseEventHandler } from 'react' import { memo, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useLongPress } from '../../hooks/useLongPress.js' -import { usePinnedTokensStore } from '../../stores/pinnedTokens/PinnedTokensStore.js' import { formatTokenAmount, formatTokenPrice } from '../../utils/format.js' import { shortenAddress } from '../../utils/wallet.js' import { TokenAvatar } from '../Avatar/TokenAvatar.js' import { ListItemButton } from '../ListItem/ListItemButton.js' +import { PinTokenButton } from './PinTokenButton.js' import { IconButton, ListItem } from './TokenList.style.js' import type { TokenListItemAvatarProps, @@ -107,7 +105,7 @@ const OpenTokenDetailsButton = ({ size="small" onClick={(e) => { e.stopPropagation() - ;(e.currentTarget as HTMLElement).blur() // Remove focus to prevent accessibility issues when opening drawer + e.currentTarget.blur() // Remove focus to prevent accessibility issues when opening drawer onClick(tokenAddress, withoutContractAddress, chainId) }} > @@ -116,43 +114,6 @@ const OpenTokenDetailsButton = ({ ) } -interface PinTokenButtonProps { - chainId: number - tokenAddress: string -} - -const PinTokenButton = ({ chainId, tokenAddress }: PinTokenButtonProps) => { - const pinnedTokens = usePinnedTokensStore((state) => state.pinnedTokens) - const pinToken = usePinnedTokensStore((state) => state.pinToken) - const unpinToken = usePinnedTokensStore((state) => state.unpinToken) - - const isPinned = - pinnedTokens[chainId]?.includes(tokenAddress.toLowerCase()) ?? false - - const handleClick = (e: React.MouseEvent) => { - e.stopPropagation() - ;(e.currentTarget as HTMLElement).blur() - if (isPinned) { - unpinToken(chainId, tokenAddress) - } else { - pinToken(chainId, tokenAddress) - } - } - - const PinIcon = isPinned ? PushPinIcon : PushPinOutlinedIcon - - return ( - - - - ) -} - const TokenListItemButton: React.FC = memo( ({ onClick, @@ -273,16 +234,16 @@ const TokenListItemButton: React.FC = memo( gap: 0.5, }} > - + @@ -338,16 +299,16 @@ const TokenListItemButton: React.FC = memo( > {shortenAddress(token.address)} - + diff --git a/packages/widget/src/components/TokenList/VirtualizedTokenList.tsx b/packages/widget/src/components/TokenList/VirtualizedTokenList.tsx index f14a03937..44ba2d457 100644 --- a/packages/widget/src/components/TokenList/VirtualizedTokenList.tsx +++ b/packages/widget/src/components/TokenList/VirtualizedTokenList.tsx @@ -23,6 +23,7 @@ export const VirtualizedTokenList: FC = ({ isLoading, isBalanceLoading, showCategories, + showPinnedTokens, onClick, isAllNetworks, }) => { @@ -62,48 +63,33 @@ export const VirtualizedTokenList: FC = ({ const estimateSize = useCallback( (index: number) => { const currentToken = tokens[index] - - // Base size for TokenListItem - let size = tokenItemHeight - const previousToken = tokens[index - 1] + let size = tokenItemHeight - // Always account for pinned tokens category (even in all networks mode) + // Pinned tokens (always shown, even in all networks mode) if (currentToken.pinned && index === 0) { size += 24 } - - // Adjust size when transitioning from pinned tokens (works in all networks mode too) if (previousToken?.pinned && !currentToken.pinned) { size += 32 } - // Early return if categories are not shown (for other categories) if (!showCategories) { return size } - // Adjust size for the first featured token (if not pinned) if (currentToken.featured && !currentToken.pinned && index === 0) { size += 24 } - // Adjust size based on changes between the current and previous tokens - const isCategoryChanged = - (previousToken?.amount && - !currentToken.amount && - !currentToken.pinned && - !previousToken?.pinned) || - (previousToken?.featured && - !currentToken.featured && - !currentToken.pinned && - !previousToken?.pinned) || - (previousToken?.popular && - !currentToken.popular && - !currentToken.pinned && - !previousToken?.pinned) - - if (isCategoryChanged) { + // Category transition (excluding pinned tokens) + const isNotPinned = !currentToken.pinned && !previousToken?.pinned + if ( + isNotPinned && + ((previousToken?.amount && !currentToken.amount) || + (previousToken?.featured && !currentToken.featured) || + (previousToken?.popular && !currentToken.popular)) + ) { size += 32 } @@ -112,7 +98,6 @@ export const VirtualizedTokenList: FC = ({ [tokens, showCategories] ) - // Chunk the tokens for infinite loading simulation const virtualizerConfig = useMemo( () => ({ count: tokens.length, @@ -154,73 +139,61 @@ export const VirtualizedTokenList: FC = ({ {getVirtualItems().map((item) => { const currentToken = tokens[item.index] const previousToken: TokenAmount | undefined = tokens[item.index - 1] - const chain = chainsSet?.get(currentToken.chainId) + const isNotPinned = !currentToken.pinned const isFirstPinnedToken = currentToken.pinned && item.index === 0 + const isTransitionFromPinned = previousToken?.pinned && isNotPinned - const isTransitionFromPinnedTokens = - previousToken?.pinned && !currentToken.pinned - - const isFirstFeaturedToken = - currentToken.featured && !currentToken.pinned && item.index === 0 - - const isTransitionFromFeaturedTokens = - previousToken?.featured && - !currentToken.featured && - !currentToken.pinned - + // Category transitions (excluding pinned) + const isTransitionFromFeatured = + previousToken?.featured && !currentToken.featured && isNotPinned const isTransitionFromMyTokens = - previousToken?.amount && - !currentToken.amount && - !currentToken.pinned - - const isTransitionToMyTokens = - (isTransitionFromFeaturedTokens || isTransitionFromPinnedTokens) && - currentToken.amount && - !currentToken.pinned - - const isTransitionToPopularTokens = - (isTransitionFromFeaturedTokens || - isTransitionFromMyTokens || - isTransitionFromPinnedTokens) && - currentToken.popular && - !currentToken.pinned - - const shouldShowAllTokensCategory = - isTransitionFromMyTokens || - isTransitionFromFeaturedTokens || - isTransitionFromPinnedTokens || - (previousToken?.popular && - !currentToken.popular && - !currentToken.pinned) + previousToken?.amount && !currentToken.amount && isNotPinned + const isTransitionFromPopular = + previousToken?.popular && !currentToken.popular && isNotPinned + // Determine which category label to show const startAdornmentLabel = (() => { - // Always show pinned tokens category if it's the first pinned token - if (isFirstPinnedToken) { + if (showPinnedTokens && isFirstPinnedToken) { return t('main.pinnedTokens') } - // Show "All tokens" category when transitioning from pinned in all networks mode - if (isAllNetworks && isTransitionFromPinnedTokens) { + if (showPinnedTokens && !showCategories && isTransitionFromPinned) { return t('main.allTokens') } - // Show other categories only when not in all networks mode - if (!isAllNetworks && showCategories) { - if (isTransitionFromPinnedTokens && currentToken.featured) { - return t('main.featuredTokens') - } - if (isFirstFeaturedToken) { - return t('main.featuredTokens') - } - if (isTransitionToMyTokens) { - return t('main.myTokens') - } - if (isTransitionToPopularTokens) { - return t('main.popularTokens') - } - if (shouldShowAllTokensCategory) { - return t('main.allTokens') - } + if (!showCategories) { + return null + } + + if ( + (isTransitionFromPinned && currentToken.featured) || + (currentToken.featured && isNotPinned && item.index === 0) + ) { + return t('main.featuredTokens') + } + if ( + (isTransitionFromFeatured || isTransitionFromPinned) && + currentToken.amount && + isNotPinned + ) { + return t('main.myTokens') + } + if ( + (isTransitionFromFeatured || + isTransitionFromMyTokens || + isTransitionFromPinned) && + currentToken.popular && + isNotPinned + ) { + return t('main.popularTokens') + } + if ( + isTransitionFromMyTokens || + isTransitionFromFeatured || + isTransitionFromPinned || + isTransitionFromPopular + ) { + return t('main.allTokens') } return null })() @@ -248,7 +221,13 @@ export const VirtualizedTokenList: FC = ({ fontWeight: 600, lineHeight: '16px', px: 1.5, - pt: isFirstPinnedToken || isFirstFeaturedToken ? 0 : 1, + pt: + isFirstPinnedToken || + (currentToken.featured && + isNotPinned && + item.index === 0) + ? 0 + : 1, pb: 1, }} > diff --git a/packages/widget/src/components/TokenList/types.ts b/packages/widget/src/components/TokenList/types.ts index 573f83039..cc000037a 100644 --- a/packages/widget/src/components/TokenList/types.ts +++ b/packages/widget/src/components/TokenList/types.ts @@ -17,6 +17,7 @@ export interface VirtualizedTokenListProps { isBalanceLoading: boolean chainId?: number showCategories?: boolean + showPinnedTokens?: boolean onClick(tokenAddress: string, chainId?: number): void selectedTokenAddress?: string isAllNetworks: boolean diff --git a/packages/widget/src/hooks/useTokenBalances.ts b/packages/widget/src/hooks/useTokenBalances.ts index b4f1b8652..2cdaa0cd7 100644 --- a/packages/widget/src/hooks/useTokenBalances.ts +++ b/packages/widget/src/hooks/useTokenBalances.ts @@ -106,7 +106,7 @@ export const useTokenBalances = ( isAllNetworks, ]) - const { processedTokens, withCategories } = useMemo(() => { + const { processedTokens, withCategories, withPinnedTokens } = useMemo(() => { return processTokenBalances( isBalanceLoading, isAllNetworks || !!search, @@ -130,6 +130,7 @@ export const useTokenBalances = ( return { tokens: processedTokens ?? [], withCategories, + withPinnedTokens, isTokensLoading, isSearchLoading, isBalanceLoading, diff --git a/packages/widget/src/utils/tokenList.ts b/packages/widget/src/utils/tokenList.ts index dc0a666ba..b5879a369 100644 --- a/packages/widget/src/utils/tokenList.ts +++ b/packages/widget/src/utils/tokenList.ts @@ -24,7 +24,7 @@ export const processTokenBalances = ( if (isBalanceLoading) { if (noCategories) { const sortedTokens = [...(tokens ?? [])].sort(sortByVolume) - // Separate pinned tokens if we have the function + // Separate pinned tokens if (isPinnedToken) { const pinned: TokenAmount[] = [] const notPinned: TokenAmount[] = [] @@ -38,12 +38,14 @@ export const processTokenBalances = ( } return { processedTokens: [...pinned, ...notPinned], - withCategories: pinned.length > 0, + withCategories: false, + withPinnedTokens: !!pinned.length, } } return { processedTokens: sortedTokens, withCategories: false, + withPinnedTokens: false, } } else { return processedTypedTokens( @@ -74,7 +76,7 @@ export const processTokenBalances = ( .sort(sortByVolume) ?? [] if (noCategories) { - // Separate pinned tokens if we have the function + // Separate pinned tokens if (isPinnedToken) { const pinnedWithBalances: TokenAmount[] = [] const notPinnedWithBalances: TokenAmount[] = [] @@ -106,13 +108,15 @@ export const processTokenBalances = ( ...notPinnedWithBalances, ...notPinnedWithoutBalances, ], - withCategories: - pinnedWithBalances.length > 0 || pinnedWithoutBalances.length > 0, + withCategories: false, + withPinnedTokens: + !!pinnedWithBalances.length || !!pinnedWithoutBalances.length, } } return { processedTokens: [...sortedTokensWithBalances, ...tokensWithoutBalances], withCategories: false, + withPinnedTokens: false, } } else { return processedTypedTokens( @@ -163,6 +167,16 @@ const processedTypedTokens = ( } else { featuredTokensFromConfig.push(tokenAmount) } + + // Additionally add to pinned tokens if it is pinned + const isPinned = + isPinnedToken && selectedChainId + ? isPinnedToken(selectedChainId, token.address) + : false + if (isPinned) { + const pinnedToken = { ...tokenAmount, pinned: true } as TokenAmount + pinnedTokens.push(pinnedToken) + } }) }) @@ -231,11 +245,9 @@ const processedTypedTokens = ( ...sortedPopularTokens, ...sortedOtherTokens, ], - withCategories: Boolean( - sortedPinnedTokens?.length || - featuredTokensFromConfig?.length || - popularTokensFromConfig?.length - ), + withCategories: + !!featuredTokensFromConfig.length || !!popularTokensFromConfig.length, + withPinnedTokens: !!sortedPinnedTokens.length, } } From fbf438d7411da61be8f92a3075d4aa6871597f81 Mon Sep 17 00:00:00 2001 From: Lizaveta Miasayedava Date: Fri, 5 Dec 2025 10:33:34 +0000 Subject: [PATCH 3/5] refactor: reduce space between pin and info buttons --- .../components/TokenList/TokenListItem.tsx | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/packages/widget/src/components/TokenList/TokenListItem.tsx b/packages/widget/src/components/TokenList/TokenListItem.tsx index c32dbe756..5663a53a7 100644 --- a/packages/widget/src/components/TokenList/TokenListItem.tsx +++ b/packages/widget/src/components/TokenList/TokenListItem.tsx @@ -227,13 +227,7 @@ const TokenListItemButton: React.FC = memo( appear={false} mountOnEnter > - + = memo( > {shortenAddress(token.address)} - - + + + + From 305332eb4f3331b29589b51ea27cfc354d8699e6 Mon Sep 17 00:00:00 2001 From: Lizaveta Miasayedava Date: Fri, 5 Dec 2025 10:39:48 +0000 Subject: [PATCH 4/5] refactor: revert version --- packages/widget/src/config/version.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/widget/src/config/version.ts b/packages/widget/src/config/version.ts index ad23332d0..179c67273 100644 --- a/packages/widget/src/config/version.ts +++ b/packages/widget/src/config/version.ts @@ -1,2 +1,2 @@ export const name = '@lifi/widget' -export const version = '4.0.0-alpha.0' +export const version = '3.34.2' From 2c270fb2a46b31f48b1ccc2128d3786319ee6c82 Mon Sep 17 00:00:00 2001 From: Lizaveta Miasayedava Date: Fri, 5 Dec 2025 15:45:39 +0000 Subject: [PATCH 5/5] refactor: replace obj with array --- .../src/components/TokenList/PinTokenButton.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/widget/src/components/TokenList/PinTokenButton.tsx b/packages/widget/src/components/TokenList/PinTokenButton.tsx index 5945d57c0..e9dab8681 100644 --- a/packages/widget/src/components/TokenList/PinTokenButton.tsx +++ b/packages/widget/src/components/TokenList/PinTokenButton.tsx @@ -12,13 +12,11 @@ export const PinTokenButton = ({ chainId, tokenAddress, }: PinTokenButtonProps) => { - const { pinnedTokens, pinToken, unpinToken } = usePinnedTokensStore( - (state) => ({ - pinnedTokens: state.pinnedTokens, - pinToken: state.pinToken, - unpinToken: state.unpinToken, - }) - ) + const [pinnedTokens, pinToken, unpinToken] = usePinnedTokensStore((state) => [ + state.pinnedTokens, + state.pinToken, + state.unpinToken, + ]) const isPinned = pinnedTokens[chainId]?.includes(tokenAddress.toLowerCase()) ?? false