From ec63bfadfbcc3fcf22073c042316d5b29f0d3435 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Fri, 14 Nov 2025 09:29:33 +0000 Subject: [PATCH] feat(tokenselector): implement recent tokens feature and enhance token selection context --- apps/cowswap-frontend/src/locales/en-US.po | 15 +- .../containers/SelectTokenWidget/index.tsx | 27 ++- .../containers/TokenSearchResults/index.tsx | 11 +- .../tokensList/hooks/recentTokensStorage.ts | 218 ++++++++++++++++++ .../tokensList/hooks/useRecentTokens.ts | 136 +++++++++++ .../pure/FavoriteTokensList/index.tsx | 106 +++++---- .../pure/FavoriteTokensList/styled.ts | 47 ++-- .../pure/SelectTokenModal/index.cosmos.tsx | 8 + .../pure/SelectTokenModal/index.tsx | 21 +- .../pure/SelectTokenModal/styled.ts | 25 ++ .../tokensList/pure/TokenListItem/index.tsx | 3 +- .../pure/TokenListItemContainer/index.tsx | 12 +- .../tokensList/pure/TokensContent/index.tsx | 165 +++++++++---- .../pure/TokensVirtualList/index.tsx | 128 +++++++++- .../src/modules/tokensList/types.ts | 5 +- .../src/modules/tokensList/utils/tokenKey.ts | 7 + 16 files changed, 803 insertions(+), 131 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po index ea987f8f0b..8916c8fb9a 100644 --- a/apps/cowswap-frontend/src/locales/en-US.po +++ b/apps/cowswap-frontend/src/locales/en-US.po @@ -455,6 +455,7 @@ msgid "View details" msgstr "View details" #: apps/cowswap-frontend/src/modules/application/containers/App/menuConsts.tsx +#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx msgid "More" msgstr "More" @@ -1219,8 +1220,8 @@ msgid "Partner fee can not be more than {PARTNER_FEE_MAX_BPS} BPS!" msgstr "Partner fee can not be more than {PARTNER_FEE_MAX_BPS} BPS!" #: apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx -#~ msgid "Manage Token Lists" -#~ msgstr "Manage Token Lists" +msgid "Manage Token Lists" +msgstr "Manage Token Lists" #: apps/cowswap-frontend/src/legacy/components/Tokens/TokensTable.tsx msgid "No results found" @@ -3155,6 +3156,7 @@ msgstr "CoW Swap's robust solver competition protects your slippage from being e msgid "Aave Debt Swap Flashloan" msgstr "Aave Debt Swap Flashloan" +#: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx #: apps/cowswap-frontend/src/modules/yield/pure/TargetPoolPreviewInfo.tsx msgid "Details" msgstr "Details" @@ -4324,6 +4326,7 @@ msgstr "Decrease Value" #: apps/cowswap-frontend/src/legacy/components/Tokens/TokensTable.tsx #: apps/cowswap-frontend/src/modules/ethFlow/pure/WrappingPreview/WrapCard.tsx #: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx +#: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx msgid "Balance" msgstr "Balance" @@ -4386,8 +4389,8 @@ msgid "funds" msgstr "funds" #: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx -#~ msgid "Pool details" -#~ msgstr "Pool details" +msgid "Pool details" +msgstr "Pool details" #: apps/cowswap-frontend/src/modules/tradeSlippage/containers/HighSuggestedSlippageWarning/index.tsx msgid "Slippage adjusted to {slippageBpsPercentage}% to ensure quick execution" @@ -5895,8 +5898,8 @@ msgid "You sold <0/>" msgstr "You sold <0/>" #: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx -#~ msgid "Less" -#~ msgstr "Less" +msgid "Less" +msgstr "Less" #: libs/hook-dapp-lib/src/hookDappsRegistry.ts msgid "Claim your LlamaPay vesting contract funds" diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx index a58d847acc..fa8966f62f 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx @@ -31,6 +31,7 @@ import { useChainsToSelect } from '../../hooks/useChainsToSelect' import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget' import { useOnSelectChain } from '../../hooks/useOnSelectChain' import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError' +import { useRecentTokens } from '../../hooks/useRecentTokens' import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' import { useTokensToSelect } from '../../hooks/useTokensToSelect' import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' @@ -69,6 +70,7 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok selectedPoolAddress, field, oppositeToken, + selectedTargetChainId, } = useSelectTokenWidgetState() const { count: lpTokensWithBalancesCount } = useLpTokensWithBalances() const chainsToSelect = useChainsToSelect() @@ -82,7 +84,7 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok ) const updateSelectTokenWidget = useUpdateSelectTokenWidgetState() - const { account } = useWalletInfo() + const { account, chainId: walletChainId } = useWalletInfo() const cowAnalytics = useCowAnalytics() const addCustomTokenLists = useAddList((source) => { @@ -101,6 +103,17 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok areTokensFromBridge, isRouteAvailable, } = useTokensToSelect() + const { recentTokens, addRecentToken, clearRecentTokens } = useRecentTokens({ + allTokens, + favoriteTokens, + activeChainId: selectedTargetChainId ?? walletChainId, + }) + const handleTokenListItemClick = useCallback( + (token: TokenWithLogo) => { + addRecentToken(token) + }, + [addRecentToken], + ) const userAddedTokens = useUserAddedTokens() const allTokenLists = useAllListsList() @@ -138,7 +151,13 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok const importTokenAndClose = (tokens: TokenWithLogo[]): void => { importTokenCallback(tokens) - onSelectToken?.(tokens[0]) + const [tokenToSelect] = tokens + + if (tokenToSelect) { + handleTokenListItemClick(tokenToSelect) + onSelectToken?.(tokenToSelect) + } + onDismiss() } @@ -209,9 +228,11 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok selectedToken={selectedToken} allTokens={allTokens} favoriteTokens={standalone ? EMPTY_FAV_TOKENS : favoriteTokens} + recentTokens={standalone ? undefined : recentTokens} balancesState={balancesState} permitCompatibleTokens={permitCompatibleTokens} onSelectToken={onSelectToken} + onTokenListItemClick={handleTokenListItemClick} onInputPressEnter={onInputPressEnter} onDismiss={onDismiss} onOpenManageWidget={() => setIsManageWidgetOpen(true)} @@ -226,6 +247,8 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok tokenListTags={tokenListTags} areTokensFromBridge={areTokensFromBridge} isRouteAvailable={isRouteAvailable} + onClearRecentTokens={clearRecentTokens} + selectedTargetChainId={selectedTargetChainId} /> ) })()} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx index 4bb84a16f8..b43723b9f2 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx @@ -23,7 +23,7 @@ export function TokenSearchResults({ areTokensFromBridge, allTokens, }: TokenSearchResultsProps): ReactNode { - const { onSelectToken } = selectTokenContext + const { onSelectToken, onTokenListItemClick } = selectTokenContext // Do not make search when tokens are from bridge const defaultSearchResults = useSearchToken(areTokensFromBridge ? null : searchInput) @@ -56,9 +56,14 @@ export function TokenSearchResults({ if (!searchInput || !activeListsResult) return if (activeListsResult.length === 1 || matchedTokens.length === 1) { - onSelectToken(matchedTokens[0] || activeListsResult[0]) + const tokenToSelect = matchedTokens[0] || activeListsResult[0] + + if (tokenToSelect) { + onTokenListItemClick?.(tokenToSelect) + onSelectToken(tokenToSelect) + } } - }, [searchInput, activeListsResult, matchedTokens, onSelectToken]) + }, [searchInput, activeListsResult, matchedTokens, onSelectToken, onTokenListItemClick]) useEffect(() => { updateSelectTokenWidget({ diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts new file mode 100644 index 0000000000..990b944bc2 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts @@ -0,0 +1,218 @@ +import { TokenWithLogo } from '@cowprotocol/common-const' + +import { getTokenUniqueKey } from '../utils/tokenKey' + +export const RECENT_TOKENS_LIMIT = 4 +export const RECENT_TOKENS_STORAGE_KEY = 'select-token-widget:recent-tokens:v1' + +export interface StoredRecentToken { + chainId: number + address: string + decimals: number + symbol?: string + name?: string + logoURI?: string + tags?: string[] +} + +export type StoredRecentTokensByChain = Record + +export function buildTokensByKey(tokens: TokenWithLogo[]): Map { + const map = new Map() + + for (const token of tokens) { + map.set(getTokenUniqueKey(token), token) + } + + return map +} + +export function buildFavoriteTokenKeys(tokens: TokenWithLogo[]): Set { + const set = new Set() + + for (const token of tokens) { + set.add(getTokenUniqueKey(token)) + } + + return set +} + +export function hydrateStoredToken(entry: StoredRecentToken, canonical?: TokenWithLogo): TokenWithLogo | null { + if (canonical) { + return canonical + } + + try { + return new TokenWithLogo( + entry.logoURI, + entry.chainId, + entry.address, + entry.decimals, + entry.symbol, + entry.name, + undefined, + entry.tags ?? [], + ) + } catch { + return null + } +} + +export function getStoredTokenKey(token: StoredRecentToken): string { + return getTokenUniqueKey(token) +} + +export function readStoredTokens(limit: number): StoredRecentTokensByChain { + if (!canUseLocalStorage()) { + return {} + } + + try { + const rawValue = window.localStorage.getItem(RECENT_TOKENS_STORAGE_KEY) + + if (!rawValue) { + return {} + } + + const parsed: unknown = JSON.parse(rawValue) + + if (Array.isArray(parsed)) { + return migrateLegacyStoredTokens(parsed, limit) + } + + if (parsed && typeof parsed === 'object') { + return sanitizeStoredTokensMap(parsed as Record, limit) + } + + return {} + } catch { + return {} + } +} + +export function persistStoredTokens(tokens: StoredRecentTokensByChain): void { + if (!canUseLocalStorage()) { + return + } + + try { + window.localStorage.setItem(RECENT_TOKENS_STORAGE_KEY, JSON.stringify(tokens)) + } catch { + // Best effort persistence + } +} + +export function buildNextStoredTokens( + prev: StoredRecentTokensByChain, + token: TokenWithLogo, + maxItems: number, +): StoredRecentTokensByChain { + const chainId = token.chainId + const normalized = toStoredToken(token) + const chainEntries = prev[chainId] ?? [] + const updatedChain = insertToken(chainEntries, normalized, maxItems) + + return { + ...prev, + [chainId]: updatedChain, + } +} + +export function persistRecentTokenSelection( + token: TokenWithLogo, + favoriteTokens: TokenWithLogo[], + maxItems = RECENT_TOKENS_LIMIT, +): void { + const favoriteKeys = buildFavoriteTokenKeys(favoriteTokens) + + if (favoriteKeys.has(getTokenUniqueKey(token))) { + return + } + + const current = readStoredTokens(maxItems) + const next = buildNextStoredTokens(current, token, maxItems) + + persistStoredTokens(next) +} + +function sanitizeStoredTokensMap(record: Record, limit: number): StoredRecentTokensByChain { + const entries: StoredRecentTokensByChain = {} + + for (const [chainKey, tokens] of Object.entries(record)) { + const chainId = Number(chainKey) + + if (Number.isNaN(chainId) || !Array.isArray(tokens)) { + continue + } + + const sanitized = tokens + .map((token) => sanitizeStoredToken(token)) + .filter((token): token is StoredRecentToken => Boolean(token)) + + if (sanitized.length) { + entries[chainId] = sanitized.slice(0, limit) + } + } + + return entries +} + +function migrateLegacyStoredTokens(entries: unknown[], limit: number): StoredRecentTokensByChain { + return entries + .map((entry) => sanitizeStoredToken(entry)) + .filter((entry): entry is StoredRecentToken => Boolean(entry)) + .reverse() + .reduce((acc, sanitized) => { + const chainId = sanitized.chainId + const chain = acc[chainId] ?? [] + + acc[chainId] = insertToken(chain, sanitized, limit) + + return acc + }, {}) +} + +function sanitizeStoredToken(token: unknown): StoredRecentToken | null { + if (!token || typeof token !== 'object') { + return null + } + + const { chainId, address, decimals, symbol, name, logoURI, tags } = token as StoredRecentToken + + if (typeof chainId !== 'number' || typeof address !== 'string' || typeof decimals !== 'number') { + return null + } + + return { + chainId, + address: address.toLowerCase(), + decimals, + symbol: typeof symbol === 'string' ? symbol : undefined, + name: typeof name === 'string' ? name : undefined, + logoURI: typeof logoURI === 'string' ? logoURI : undefined, + tags: Array.isArray(tags) ? tags.filter((tag): tag is string => typeof tag === 'string') : undefined, + } +} + +function insertToken(tokens: StoredRecentToken[], token: StoredRecentToken, limit: number): StoredRecentToken[] { + const key = getTokenUniqueKey(token) + const withoutToken = tokens.filter((entry) => getTokenUniqueKey(entry) !== key) + + return [token, ...withoutToken].slice(0, limit) +} + +function toStoredToken(token: TokenWithLogo): StoredRecentToken { + return { + chainId: token.chainId, + address: token.address.toLowerCase(), + decimals: token.decimals, + symbol: token.symbol, + name: token.name, + logoURI: token.logoURI, + tags: token.tags, + } +} + +function canUseLocalStorage(): boolean { + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined' +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts new file mode 100644 index 0000000000..e1ea8a97cf --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts @@ -0,0 +1,136 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { TokenWithLogo } from '@cowprotocol/common-const' + +import { + RECENT_TOKENS_LIMIT, + buildFavoriteTokenKeys, + buildNextStoredTokens, + buildTokensByKey, + getStoredTokenKey, + hydrateStoredToken, + persistRecentTokenSelection as persistRecentTokenSelectionInternal, + persistStoredTokens, + readStoredTokens, + type StoredRecentTokensByChain, +} from './recentTokensStorage' + +import { getTokenUniqueKey } from '../utils/tokenKey' + +interface UseRecentTokensParams { + allTokens: TokenWithLogo[] + favoriteTokens: TokenWithLogo[] + activeChainId?: number + maxItems?: number +} + +export interface RecentTokensState { + recentTokens: TokenWithLogo[] + addRecentToken(token: TokenWithLogo): void + clearRecentTokens(): void +} + +export function useRecentTokens({ + allTokens, + favoriteTokens, + activeChainId, + maxItems = RECENT_TOKENS_LIMIT, +}: UseRecentTokensParams): RecentTokensState { + const [storedTokensByChain, setStoredTokensByChain] = useState(() => + readStoredTokens(maxItems), + ) + + useEffect(() => { + persistStoredTokens(storedTokensByChain) + }, [storedTokensByChain]) + + const tokensByKey = useMemo(() => buildTokensByKey(allTokens), [allTokens]) + const favoriteKeys = useMemo(() => buildFavoriteTokenKeys(favoriteTokens), [favoriteTokens]) + + useEffect(() => { + setStoredTokensByChain((prev) => { + const nextEntries: StoredRecentTokensByChain = {} + let didChange = false + + for (const [chainKey, tokens] of Object.entries(prev)) { + const chainId = Number(chainKey) + const filtered = tokens.filter((token) => !favoriteKeys.has(getStoredTokenKey(token))) + + if (filtered.length) { + nextEntries[chainId] = filtered + } + + didChange = didChange || filtered.length !== tokens.length + } + + return didChange ? nextEntries : prev + }) + }, [favoriteKeys]) + + const recentTokens = useMemo(() => { + const chainEntries = activeChainId ? storedTokensByChain[activeChainId] ?? [] : [] + const seenKeys = new Set() + const result: TokenWithLogo[] = [] + + for (const entry of chainEntries) { + const key = getStoredTokenKey(entry) + + if (seenKeys.has(key) || favoriteKeys.has(key)) { + continue + } + + const hydrated = hydrateStoredToken(entry, tokensByKey.get(key)) + + if (hydrated) { + result.push(hydrated) + seenKeys.add(key) + } + + if (result.length >= maxItems) { + break + } + } + + return result + }, [activeChainId, favoriteKeys, maxItems, storedTokensByChain, tokensByKey]) + + const addRecentToken = useCallback( + (token: TokenWithLogo) => { + if (favoriteKeys.has(getTokenUniqueKey(token))) { + return + } + + setStoredTokensByChain((prev) => { + const next = buildNextStoredTokens(prev, token, maxItems) + + persistStoredTokens(next) + + return next + }) + }, + [favoriteKeys, maxItems], + ) + + const clearRecentTokens = useCallback(() => { + if (!activeChainId) { + return + } + + setStoredTokensByChain((prev) => { + const chainEntries = prev[activeChainId] + + if (!chainEntries?.length) { + return prev + } + + const next: StoredRecentTokensByChain = { ...prev, [activeChainId]: [] } + persistStoredTokens(next) + + return next + }) + }, [activeChainId]) + + return { recentTokens, addRecentToken, clearRecentTokens } +} + +export { persistRecentTokenSelectionInternal as persistRecentTokenSelection } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx index b7c3623b89..103ceaa01b 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx @@ -1,61 +1,87 @@ import { ReactNode } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' +import { areAddressesEqual, getCurrencyAddress } from '@cowprotocol/common-utils' import { TokenLogo } from '@cowprotocol/tokens' -import { HelpTooltip, TokenSymbol } from '@cowprotocol/ui' +import { TokenSymbol } from '@cowprotocol/ui' import { Trans } from '@lingui/react/macro' import { Link } from 'react-router' import * as styledEl from './styled' +import { SelectTokenContext } from '../../types' + export interface FavoriteTokensListProps { tokens: TokenWithLogo[] + selectTokenContext: SelectTokenContext hideTooltip?: boolean - selectedToken?: string - - onSelectToken(token: TokenWithLogo): void } export function FavoriteTokensList(props: FavoriteTokensListProps): ReactNode { - const { tokens, hideTooltip, selectedToken, onSelectToken } = props + const { tokens, selectTokenContext, hideTooltip } = props + + if (!tokens.length) { + return null + } return ( -
- -

+ + + Favorite tokens -

- {!hideTooltip && ( - - Your favorite saved tokens. Edit this list in the Tokens page. - - } - /> - )} -
- - {tokens.map((token) => { - const isTokenSelected = token.address.toLowerCase() === selectedToken?.toLowerCase() - - return ( - onSelectToken(token)} - > - - - - ) - })} - -
+ + {!hideTooltip && } + + {renderFavoriteTokenItems(tokens, selectTokenContext)} + + ) +} + +function FavoriteTokensTooltip(): ReactNode { + return ( + + Your favorite saved tokens. Edit this list in the Tokens page. + + } + /> ) } + +function renderFavoriteTokenItems(tokens: TokenWithLogo[], context: SelectTokenContext): ReactNode[] { + const { selectedToken } = context + const selectedAddress = selectedToken ? getCurrencyAddress(selectedToken) : undefined + + return tokens.map((token) => { + const isSelected = + !!selectedToken && + token.chainId === selectedToken.chainId && + !!selectedAddress && + areAddressesEqual(token.address, selectedAddress) + + const handleClick = (): void => { + if (isSelected) { + return + } + context.onTokenListItemClick?.(token) + context.onSelectToken(token) + } + + return ( + + + + + ) + }) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts index ee278a509a..ad258cd2cc 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts @@ -1,18 +1,25 @@ -import { Media, UI } from '@cowprotocol/ui' +import { HelpTooltip, Media, UI } from '@cowprotocol/ui' import styled from 'styled-components/macro' -export const Header = styled.div` +export const Section = styled.div` + padding: 0 14px 14px; + + ${Media.upToSmall()} { + padding: 8px 14px 4px; + } +` + +export const TitleRow = styled.div` display: flex; - gap: 5px; - flex-direction: row; align-items: center; +` - > h4 { - font-size: 14px; - font-weight: 500; - margin: 0; - } +export const Title = styled.h4` + font-size: 14px; + font-weight: 500; + margin: 0; + color: var(${UI.COLOR_TEXT_OPACITY_70}); ` export const List = styled.div` @@ -25,9 +32,8 @@ export const List = styled.div` width: 0; min-width: 100%; flex-wrap: nowrap; - overflow-x: scroll; + overflow-x: auto; overflow-y: hidden; - padding: 10px 0; -webkit-overflow-scrolling: touch; @@ -44,9 +50,8 @@ export const List = styled.div` } ` -export const TokensItem = styled.button` +export const TokenButton = styled.button` display: inline-flex; - flex-direction: row; align-items: center; gap: 6px; justify-content: center; @@ -58,9 +63,9 @@ export const TokensItem = styled.button` border: 1px solid var(${UI.COLOR_PAPER_DARKER}); font-weight: 500; font-size: 16px; - cursor: ${({ disabled }) => (disabled ? '' : 'pointer')}; - background: ${({ disabled }) => disabled && `var(${UI.COLOR_PAPER_DARKER})`}; - opacity: ${({ disabled }) => (disabled ? 0.6 : 1)}; + cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')}; + background: ${({ disabled }) => (disabled ? `var(${UI.COLOR_PAPER_DARKER})` : 'transparent')}; + opacity: ${({ disabled }) => (disabled ? 0.65 : 1)}; transition: border var(${UI.ANIMATION_DURATION}) ease-in-out; white-space: nowrap; @@ -72,3 +77,13 @@ export const TokensItem = styled.button` border: 1px solid ${({ disabled }) => (disabled ? `var(${UI.COLOR_PAPER_DARKER})` : `var(${UI.COLOR_PRIMARY})`)}; } ` + +export const FavoriteTooltip = styled(HelpTooltip)` + color: var(${UI.COLOR_TEXT_OPACITY_50}); + transition: color 0.2s ease-in-out; + margin-left: 6px; + + &:hover { + color: var(${UI.COLOR_TEXT}); + } +` diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx index 4a432e5c98..00fef62373 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx @@ -48,9 +48,17 @@ const defaultProps: SelectTokenModalProps = { }, selectedToken, isRouteAvailable: true, + recentTokens: favoriteTokensMock.slice(0, 2), + selectedTargetChainId: SupportedChainId.SEPOLIA, onSelectToken() { console.log('onSelectToken') }, + onTokenListItemClick(token) { + console.log('onTokenListItemClick', token.symbol) + }, + onClearRecentTokens() { + console.log('onClearRecentTokens') + }, onOpenManageWidget() { console.log('onOpenManageWidget') }, diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx index f1092ce7ec..a4e1e5f560 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx @@ -17,7 +17,7 @@ import { SelectTokenModalContent } from './SelectTokenModalContent' import * as styledEl from './styled' import { LpTokenListsWidget } from '../../containers/LpTokenListsWidget' -import { ChainsToSelectState, SelectTokenContext } from '../../types' +import { ChainsToSelectState, SelectTokenContext, TokenSelectionHandler } from '../../types' import { ChainsSelector } from '../ChainsSelector' import { IconButton } from '../commonElements' import { TokensContent } from '../TokensContent' @@ -25,6 +25,7 @@ import { TokensContent } from '../TokensContent' export interface SelectTokenModalProps { allTokens: TokenWithLogo[] favoriteTokens: TokenWithLogo[] + recentTokens?: TokenWithLogo[] balancesState: BalancesState unsupportedTokens: UnsupportedTokensState selectedToken?: Nullish @@ -41,13 +42,16 @@ export interface SelectTokenModalProps { standalone?: boolean areTokensFromBridge: boolean isRouteAvailable: boolean | undefined + selectedTargetChainId?: number - onSelectToken(token: TokenWithLogo): void + onSelectToken: TokenSelectionHandler + onTokenListItemClick?(token: TokenWithLogo): void openPoolPage(poolAddress: string): void onInputPressEnter?(): void onOpenManageWidget(): void onDismiss(): void onSelectChain(chain: ChainInfo): void + onClearRecentTokens?(): void } function useSelectTokenContext(props: SelectTokenModalProps): SelectTokenContext { @@ -57,6 +61,7 @@ function useSelectTokenContext(props: SelectTokenModalProps): SelectTokenContext unsupportedTokens, permitCompatibleTokens, onSelectToken, + onTokenListItemClick, account, tokenListTags, } = props @@ -66,12 +71,22 @@ function useSelectTokenContext(props: SelectTokenModalProps): SelectTokenContext balancesState, selectedToken, onSelectToken, + onTokenListItemClick, unsupportedTokens, permitCompatibleTokens, tokenListTags, isWalletConnected: !!account, }), - [balancesState, selectedToken, onSelectToken, unsupportedTokens, permitCompatibleTokens, tokenListTags, account], + [ + balancesState, + selectedToken, + onSelectToken, + onTokenListItemClick, + unsupportedTokens, + permitCompatibleTokens, + tokenListTags, + account, + ], ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts index 3016d33f0c..9bcc0473df 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts @@ -80,3 +80,28 @@ export const RouteNotAvailable = styled.div` padding: 20px 0; text-align: center; ` + +export const ListTitle = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(${UI.COLOR_TEXT_OPACITY_50}); + padding: 12px 20px 4px; +` + +export const ListTitleActionButton = styled.button` + ${blankButtonMixin}; + font-size: 12px; + font-weight: 600; + color: var(${UI.COLOR_PRIMARY}); + cursor: pointer; + transition: opacity var(${UI.ANIMATION_DURATION}) ease-in-out; + + &:hover { + opacity: 0.75; + } +` diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx index 9e141417f3..d9d4c867a4 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx @@ -13,6 +13,7 @@ import { Nullish } from 'types' import * as styledEl from './styled' import { useDeferredVisibility } from '../../hooks/useDeferredVisibility' +import { TokenSelectionHandler } from '../../types' import { TokenInfo } from '../TokenInfo' import { TokenTags } from '../TokenTags' @@ -28,7 +29,7 @@ export interface TokenListItemProps { balance: BigNumber | undefined usdAmount?: CurrencyAmount | null - onSelectToken?(token: TokenWithLogo): void + onSelectToken?: TokenSelectionHandler isWalletConnected: boolean isUnsupported?: boolean diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx index 4dd4ca71e4..97208d6eb0 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react' +import { ReactNode, useCallback } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' @@ -14,6 +14,7 @@ export function TokenListItemContainer({ token, context }: TokenListItemContaine const { unsupportedTokens, onSelectToken, + onTokenListItemClick, selectedToken, tokenListTags, permitCompatibleTokens, @@ -22,6 +23,13 @@ export function TokenListItemContainer({ token, context }: TokenListItemContaine } = context const addressLowerCase = token.address.toLowerCase() + const handleSelectToken = useCallback( + (tokenToSelect: TokenWithLogo) => { + onTokenListItemClick?.(tokenToSelect) + onSelectToken(tokenToSelect) + }, + [onSelectToken, onTokenListItemClick], + ) return ( diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx index fa6b1ccf04..cd8bb60770 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx @@ -1,17 +1,14 @@ -import React, { ReactNode } from 'react' +import React, { ReactNode, useMemo } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' -import { getCurrencyAddress } from '@cowprotocol/common-utils' -import { Nullish } from '@cowprotocol/types' import { Loader } from '@cowprotocol/ui' -import { Currency } from '@uniswap/sdk-core' import { Trans } from '@lingui/react/macro' import { Edit } from 'react-feather' import { TokenSearchResults } from '../../containers/TokenSearchResults' import { SelectTokenContext } from '../../types' -import { FavoriteTokensList } from '../FavoriteTokensList' +import { getTokenUniqueKey } from '../../utils/tokenKey' import * as styledEl from '../SelectTokenModal/styled' import { TokensVirtualList } from '../TokensVirtualList' @@ -19,24 +16,22 @@ export interface TokensContentProps { displayLpTokenLists?: boolean selectTokenContext: SelectTokenContext favoriteTokens: TokenWithLogo[] - selectedToken?: Nullish + recentTokens?: TokenWithLogo[] hideFavoriteTokensTooltip?: boolean areTokensLoading: boolean allTokens: TokenWithLogo[] searchInput: string standalone?: boolean areTokensFromBridge: boolean - - onSelectToken(token: TokenWithLogo): void + selectedTargetChainId?: number onOpenManageWidget(): void + onClearRecentTokens?: () => void } export function TokensContent({ selectTokenContext, - onSelectToken, - onOpenManageWidget, - selectedToken, favoriteTokens, + recentTokens, hideFavoriteTokensTooltip, areTokensLoading, allTokens, @@ -44,44 +39,60 @@ export function TokensContent({ searchInput, standalone, areTokensFromBridge, + selectedTargetChainId, + onOpenManageWidget, + onClearRecentTokens, }: TokensContentProps): ReactNode { + const shouldShowFavoritesInline = !areTokensLoading && !searchInput && favoriteTokens.length > 0 + const shouldShowRecentsInline = !areTokensLoading && !searchInput && (recentTokens?.length ?? 0) > 0 + + const pinnedTokenKeys = useMemo(() => { + if (!shouldShowFavoritesInline && !shouldShowRecentsInline) { + return undefined + } + + const pinned = new Set() + + if (shouldShowFavoritesInline) { + favoriteTokens.forEach((token) => pinned.add(getTokenUniqueKey(token))) + } + + if (shouldShowRecentsInline && recentTokens) { + recentTokens.forEach((token) => pinned.add(getTokenUniqueKey(token))) + } + + return pinned + }, [favoriteTokens, recentTokens, shouldShowFavoritesInline, shouldShowRecentsInline]) + + const tokensWithoutPinned = useMemo(() => { + if (!pinnedTokenKeys) { + return allTokens + } + + return allTokens.filter((token) => !pinnedTokenKeys.has(getTokenUniqueKey(token))) + }, [allTokens, pinnedTokenKeys]) + + const favoriteTokensInline = shouldShowFavoritesInline ? favoriteTokens : undefined + const recentTokensInline = shouldShowRecentsInline ? recentTokens : undefined + + const tokensView = renderTokensView({ + areTokensLoading, + searchInput, + selectTokenContext, + areTokensFromBridge, + allTokens, + tokensWithoutPinned, + displayLpTokenLists, + favoriteTokens: favoriteTokensInline, + recentTokens: recentTokensInline, + hideFavoriteTokensTooltip, + selectedTargetChainId, + onClearRecentTokens, + }) + return ( <> - {!areTokensLoading && !!favoriteTokens.length && ( - <> - - - - - - )} - {areTokensLoading ? ( - - - - ) : ( - <> - {searchInput ? ( - - ) : ( - - )} - - )} + {tokensView} {!standalone && ( <> @@ -98,3 +109,65 @@ export function TokensContent({ ) } + +interface TokensViewProps { + areTokensLoading: boolean + searchInput: string + selectTokenContext: SelectTokenContext + areTokensFromBridge: boolean + allTokens: TokenWithLogo[] + tokensWithoutPinned: TokenWithLogo[] + displayLpTokenLists?: boolean + favoriteTokens?: TokenWithLogo[] + recentTokens?: TokenWithLogo[] + hideFavoriteTokensTooltip?: boolean + selectedTargetChainId?: number + onClearRecentTokens?: () => void +} + +function renderTokensView({ + areTokensLoading, + searchInput, + selectTokenContext, + areTokensFromBridge, + allTokens, + tokensWithoutPinned, + displayLpTokenLists, + favoriteTokens, + recentTokens, + hideFavoriteTokensTooltip, + selectedTargetChainId, + onClearRecentTokens, +}: TokensViewProps): ReactNode { + if (areTokensLoading) { + return ( + + + + ) + } + + if (searchInput) { + return ( + + ) + } + + return ( + + ) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx index f657eb27c1..a4f65bfbaa 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx @@ -2,6 +2,7 @@ import { ReactNode, useCallback, useMemo } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' import { useFeatureFlags } from '@cowprotocol/common-hooks' +import { getIsNativeToken } from '@cowprotocol/common-utils' import { VirtualItem } from '@tanstack/react-virtual' @@ -10,37 +11,142 @@ import { VirtualList } from 'common/pure/VirtualList' import { SelectTokenContext } from '../../types' import { tokensListSorter } from '../../utils/tokensListSorter' +import { FavoriteTokensList } from '../FavoriteTokensList' +import * as modalStyled from '../SelectTokenModal/styled' import { TokenListItemContainer } from '../TokenListItemContainer' export interface TokensVirtualListProps { allTokens: TokenWithLogo[] displayLpTokenLists?: boolean selectTokenContext: SelectTokenContext + favoriteTokens?: TokenWithLogo[] + recentTokens?: TokenWithLogo[] + hideFavoriteTokensTooltip?: boolean + scrollResetKey?: number + onClearRecentTokens?: () => void } +type TokensVirtualRow = + | { type: 'favorite-section'; tokens: TokenWithLogo[]; hideTooltip?: boolean } + | { type: 'title'; label: string; actionLabel?: string; onAction?: () => void } + | { type: 'token'; token: TokenWithLogo } + export function TokensVirtualList(props: TokensVirtualListProps): ReactNode { - const { allTokens, selectTokenContext, displayLpTokenLists } = props + const { + allTokens, + selectTokenContext, + displayLpTokenLists, + favoriteTokens, + recentTokens, + hideFavoriteTokensTooltip, + scrollResetKey, + onClearRecentTokens, + } = props const { values: balances } = selectTokenContext.balancesState const { isYieldEnabled } = useFeatureFlags() - const sortedTokens = useMemo( - () => (balances ? allTokens.sort(tokensListSorter(balances)) : allTokens), - [allTokens, balances], - ) + const sortedTokens = useMemo(() => { + if (!balances) { + return allTokens + } + + const prioritized: TokenWithLogo[] = [] + const remainder: TokenWithLogo[] = [] + + for (const token of allTokens) { + const hasBalance = Boolean(balances[token.address.toLowerCase()]) + if (hasBalance || getIsNativeToken(token)) { + prioritized.push(token) + } else { + remainder.push(token) + } + } + + const sortedPrioritized = + prioritized.length > 1 ? [...prioritized].sort(tokensListSorter(balances)) : prioritized + + return [...sortedPrioritized, ...remainder] + }, [allTokens, balances]) + + const rows = useMemo(() => { + const tokenRows = sortedTokens.map((token) => ({ type: 'token', token })) + const composedRows: TokensVirtualRow[] = [] + + if (favoriteTokens?.length) { + composedRows.push({ + type: 'favorite-section', + tokens: favoriteTokens, + hideTooltip: hideFavoriteTokensTooltip, + }) + } - const getItemView = useCallback( - (sortedTokens: TokenWithLogo[], virtualRow: VirtualItem) => { - const token = sortedTokens[virtualRow.index] + if (recentTokens?.length) { + composedRows.push({ + type: 'title', + label: 'Recent', + actionLabel: onClearRecentTokens ? 'Clear' : undefined, + onAction: onClearRecentTokens, + }) + recentTokens.forEach((token) => composedRows.push({ type: 'token', token })) + } - return - }, + if (favoriteTokens?.length || recentTokens?.length) { + composedRows.push({ type: 'title', label: 'All tokens' }) + } + + return [...composedRows, ...tokenRows] + }, [favoriteTokens, hideFavoriteTokensTooltip, onClearRecentTokens, recentTokens, sortedTokens]) + + const virtualListKey = scrollResetKey ?? 'tokens-list' + + const renderVirtualRow = useCallback( + (virtualRows: TokensVirtualRow[], virtualItem: VirtualItem) => ( + + ), [selectTokenContext], ) return ( - + {displayLpTokenLists || !isYieldEnabled ? null : } ) } + +interface TokensVirtualRowRendererProps { + row: TokensVirtualRow + selectTokenContext: SelectTokenContext +} + +function TokensVirtualRowRenderer({ row, selectTokenContext }: TokensVirtualRowRendererProps): ReactNode { + switch (row.type) { + case 'favorite-section': + return ( + + ) + case 'title': + return ( + + {row.label} + {row.actionLabel && row.onAction ? ( + + {row.actionLabel} + + ) : null} + + ) + default: + return + } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/types.ts b/apps/cowswap-frontend/src/modules/tokensList/types.ts index 5c775d8e0a..da71e3ab96 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/types.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/types.ts @@ -8,11 +8,14 @@ import { Nullish } from 'types' import { PermitCompatibleTokens } from 'modules/permit' +export type TokenSelectionHandler = (token: TokenWithLogo) => Promise | void + export interface SelectTokenContext { balancesState: BalancesState selectedToken?: Nullish - onSelectToken(token: TokenWithLogo): void + onSelectToken: TokenSelectionHandler + onTokenListItemClick?(token: TokenWithLogo): void unsupportedTokens: { [tokenAddress: string]: { dateAdded: number } } permitCompatibleTokens: PermitCompatibleTokens diff --git a/apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts b/apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts new file mode 100644 index 0000000000..8f827b0288 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts @@ -0,0 +1,7 @@ +import { TokenWithLogo } from '@cowprotocol/common-const' + +type TokenIdentifier = Pick + +export function getTokenUniqueKey(token: TokenIdentifier): string { + return `${token.chainId}:${token.address.toLowerCase()}` +}