Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions apps/cowswap-frontend/src/locales/en-US.po
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -69,6 +70,7 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
selectedPoolAddress,
field,
oppositeToken,
selectedTargetChainId,
} = useSelectTokenWidgetState()
const { count: lpTokensWithBalancesCount } = useLpTokensWithBalances()
const chainsToSelect = useChainsToSelect()
Expand All @@ -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) => {
Expand All @@ -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()
Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -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)}
Expand All @@ -226,6 +247,8 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
tokenListTags={tokenListTags}
areTokensFromBridge={areTokensFromBridge}
isRouteAvailable={isRouteAvailable}
onClearRecentTokens={clearRecentTokens}
selectedTargetChainId={selectedTargetChainId}
/>
)
})()}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
@@ -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<number, StoredRecentToken[]>

export function buildTokensByKey(tokens: TokenWithLogo[]): Map<string, TokenWithLogo> {
const map = new Map<string, TokenWithLogo>()

for (const token of tokens) {
map.set(getTokenUniqueKey(token), token)
}

return map
}

export function buildFavoriteTokenKeys(tokens: TokenWithLogo[]): Set<string> {
const set = new Set<string>()

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<string, unknown>, 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<string, unknown>, 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<StoredRecentToken | null>((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<StoredRecentTokensByChain>((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'
}
Loading