Skip to content

Commit ec63bfa

Browse files
committed
feat(tokenselector): implement recent tokens feature and enhance token selection context
1 parent 272d2c9 commit ec63bfa

File tree

16 files changed

+803
-131
lines changed

16 files changed

+803
-131
lines changed

apps/cowswap-frontend/src/locales/en-US.po

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,7 @@ msgid "View details"
455455
msgstr "View details"
456456

457457
#: apps/cowswap-frontend/src/modules/application/containers/App/menuConsts.tsx
458+
#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
458459
msgid "More"
459460
msgstr "More"
460461

@@ -1219,8 +1220,8 @@ msgid "Partner fee can not be more than {PARTNER_FEE_MAX_BPS} BPS!"
12191220
msgstr "Partner fee can not be more than {PARTNER_FEE_MAX_BPS} BPS!"
12201221

12211222
#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
1222-
#~ msgid "Manage Token Lists"
1223-
#~ msgstr "Manage Token Lists"
1223+
msgid "Manage Token Lists"
1224+
msgstr "Manage Token Lists"
12241225

12251226
#: apps/cowswap-frontend/src/legacy/components/Tokens/TokensTable.tsx
12261227
msgid "No results found"
@@ -3155,6 +3156,7 @@ msgstr "CoW Swap's robust solver competition protects your slippage from being e
31553156
msgid "Aave Debt Swap Flashloan"
31563157
msgstr "Aave Debt Swap Flashloan"
31573158

3159+
#: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx
31583160
#: apps/cowswap-frontend/src/modules/yield/pure/TargetPoolPreviewInfo.tsx
31593161
msgid "Details"
31603162
msgstr "Details"
@@ -4324,6 +4326,7 @@ msgstr "Decrease Value"
43244326
#: apps/cowswap-frontend/src/legacy/components/Tokens/TokensTable.tsx
43254327
#: apps/cowswap-frontend/src/modules/ethFlow/pure/WrappingPreview/WrapCard.tsx
43264328
#: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx
4329+
#: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx
43274330
msgid "Balance"
43284331
msgstr "Balance"
43294332

@@ -4386,8 +4389,8 @@ msgid "funds"
43864389
msgstr "funds"
43874390

43884391
#: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx
4389-
#~ msgid "Pool details"
4390-
#~ msgstr "Pool details"
4392+
msgid "Pool details"
4393+
msgstr "Pool details"
43914394

43924395
#: apps/cowswap-frontend/src/modules/tradeSlippage/containers/HighSuggestedSlippageWarning/index.tsx
43934396
msgid "Slippage adjusted to {slippageBpsPercentage}% to ensure quick execution"
@@ -5895,8 +5898,8 @@ msgid "You sold <0/>"
58955898
msgstr "You sold <0/>"
58965899

58975900
#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
5898-
#~ msgid "Less"
5899-
#~ msgstr "Less"
5901+
msgid "Less"
5902+
msgstr "Less"
59005903

59015904
#: libs/hook-dapp-lib/src/hookDappsRegistry.ts
59025905
msgid "Claim your LlamaPay vesting contract funds"

apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { useChainsToSelect } from '../../hooks/useChainsToSelect'
3131
import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget'
3232
import { useOnSelectChain } from '../../hooks/useOnSelectChain'
3333
import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError'
34+
import { useRecentTokens } from '../../hooks/useRecentTokens'
3435
import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState'
3536
import { useTokensToSelect } from '../../hooks/useTokensToSelect'
3637
import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState'
@@ -69,6 +70,7 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
6970
selectedPoolAddress,
7071
field,
7172
oppositeToken,
73+
selectedTargetChainId,
7274
} = useSelectTokenWidgetState()
7375
const { count: lpTokensWithBalancesCount } = useLpTokensWithBalances()
7476
const chainsToSelect = useChainsToSelect()
@@ -82,7 +84,7 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
8284
)
8385

8486
const updateSelectTokenWidget = useUpdateSelectTokenWidgetState()
85-
const { account } = useWalletInfo()
87+
const { account, chainId: walletChainId } = useWalletInfo()
8688

8789
const cowAnalytics = useCowAnalytics()
8890
const addCustomTokenLists = useAddList((source) => {
@@ -101,6 +103,17 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
101103
areTokensFromBridge,
102104
isRouteAvailable,
103105
} = useTokensToSelect()
106+
const { recentTokens, addRecentToken, clearRecentTokens } = useRecentTokens({
107+
allTokens,
108+
favoriteTokens,
109+
activeChainId: selectedTargetChainId ?? walletChainId,
110+
})
111+
const handleTokenListItemClick = useCallback(
112+
(token: TokenWithLogo) => {
113+
addRecentToken(token)
114+
},
115+
[addRecentToken],
116+
)
104117

105118
const userAddedTokens = useUserAddedTokens()
106119
const allTokenLists = useAllListsList()
@@ -138,7 +151,13 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
138151

139152
const importTokenAndClose = (tokens: TokenWithLogo[]): void => {
140153
importTokenCallback(tokens)
141-
onSelectToken?.(tokens[0])
154+
const [tokenToSelect] = tokens
155+
156+
if (tokenToSelect) {
157+
handleTokenListItemClick(tokenToSelect)
158+
onSelectToken?.(tokenToSelect)
159+
}
160+
142161
onDismiss()
143162
}
144163

@@ -209,9 +228,11 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
209228
selectedToken={selectedToken}
210229
allTokens={allTokens}
211230
favoriteTokens={standalone ? EMPTY_FAV_TOKENS : favoriteTokens}
231+
recentTokens={standalone ? undefined : recentTokens}
212232
balancesState={balancesState}
213233
permitCompatibleTokens={permitCompatibleTokens}
214234
onSelectToken={onSelectToken}
235+
onTokenListItemClick={handleTokenListItemClick}
215236
onInputPressEnter={onInputPressEnter}
216237
onDismiss={onDismiss}
217238
onOpenManageWidget={() => setIsManageWidgetOpen(true)}
@@ -226,6 +247,8 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
226247
tokenListTags={tokenListTags}
227248
areTokensFromBridge={areTokensFromBridge}
228249
isRouteAvailable={isRouteAvailable}
250+
onClearRecentTokens={clearRecentTokens}
251+
selectedTargetChainId={selectedTargetChainId}
229252
/>
230253
)
231254
})()}

apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export function TokenSearchResults({
2323
areTokensFromBridge,
2424
allTokens,
2525
}: TokenSearchResultsProps): ReactNode {
26-
const { onSelectToken } = selectTokenContext
26+
const { onSelectToken, onTokenListItemClick } = selectTokenContext
2727

2828
// Do not make search when tokens are from bridge
2929
const defaultSearchResults = useSearchToken(areTokensFromBridge ? null : searchInput)
@@ -56,9 +56,14 @@ export function TokenSearchResults({
5656
if (!searchInput || !activeListsResult) return
5757

5858
if (activeListsResult.length === 1 || matchedTokens.length === 1) {
59-
onSelectToken(matchedTokens[0] || activeListsResult[0])
59+
const tokenToSelect = matchedTokens[0] || activeListsResult[0]
60+
61+
if (tokenToSelect) {
62+
onTokenListItemClick?.(tokenToSelect)
63+
onSelectToken(tokenToSelect)
64+
}
6065
}
61-
}, [searchInput, activeListsResult, matchedTokens, onSelectToken])
66+
}, [searchInput, activeListsResult, matchedTokens, onSelectToken, onTokenListItemClick])
6267

6368
useEffect(() => {
6469
updateSelectTokenWidget({
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { TokenWithLogo } from '@cowprotocol/common-const'
2+
3+
import { getTokenUniqueKey } from '../utils/tokenKey'
4+
5+
export const RECENT_TOKENS_LIMIT = 4
6+
export const RECENT_TOKENS_STORAGE_KEY = 'select-token-widget:recent-tokens:v1'
7+
8+
export interface StoredRecentToken {
9+
chainId: number
10+
address: string
11+
decimals: number
12+
symbol?: string
13+
name?: string
14+
logoURI?: string
15+
tags?: string[]
16+
}
17+
18+
export type StoredRecentTokensByChain = Record<number, StoredRecentToken[]>
19+
20+
export function buildTokensByKey(tokens: TokenWithLogo[]): Map<string, TokenWithLogo> {
21+
const map = new Map<string, TokenWithLogo>()
22+
23+
for (const token of tokens) {
24+
map.set(getTokenUniqueKey(token), token)
25+
}
26+
27+
return map
28+
}
29+
30+
export function buildFavoriteTokenKeys(tokens: TokenWithLogo[]): Set<string> {
31+
const set = new Set<string>()
32+
33+
for (const token of tokens) {
34+
set.add(getTokenUniqueKey(token))
35+
}
36+
37+
return set
38+
}
39+
40+
export function hydrateStoredToken(entry: StoredRecentToken, canonical?: TokenWithLogo): TokenWithLogo | null {
41+
if (canonical) {
42+
return canonical
43+
}
44+
45+
try {
46+
return new TokenWithLogo(
47+
entry.logoURI,
48+
entry.chainId,
49+
entry.address,
50+
entry.decimals,
51+
entry.symbol,
52+
entry.name,
53+
undefined,
54+
entry.tags ?? [],
55+
)
56+
} catch {
57+
return null
58+
}
59+
}
60+
61+
export function getStoredTokenKey(token: StoredRecentToken): string {
62+
return getTokenUniqueKey(token)
63+
}
64+
65+
export function readStoredTokens(limit: number): StoredRecentTokensByChain {
66+
if (!canUseLocalStorage()) {
67+
return {}
68+
}
69+
70+
try {
71+
const rawValue = window.localStorage.getItem(RECENT_TOKENS_STORAGE_KEY)
72+
73+
if (!rawValue) {
74+
return {}
75+
}
76+
77+
const parsed: unknown = JSON.parse(rawValue)
78+
79+
if (Array.isArray(parsed)) {
80+
return migrateLegacyStoredTokens(parsed, limit)
81+
}
82+
83+
if (parsed && typeof parsed === 'object') {
84+
return sanitizeStoredTokensMap(parsed as Record<string, unknown>, limit)
85+
}
86+
87+
return {}
88+
} catch {
89+
return {}
90+
}
91+
}
92+
93+
export function persistStoredTokens(tokens: StoredRecentTokensByChain): void {
94+
if (!canUseLocalStorage()) {
95+
return
96+
}
97+
98+
try {
99+
window.localStorage.setItem(RECENT_TOKENS_STORAGE_KEY, JSON.stringify(tokens))
100+
} catch {
101+
// Best effort persistence
102+
}
103+
}
104+
105+
export function buildNextStoredTokens(
106+
prev: StoredRecentTokensByChain,
107+
token: TokenWithLogo,
108+
maxItems: number,
109+
): StoredRecentTokensByChain {
110+
const chainId = token.chainId
111+
const normalized = toStoredToken(token)
112+
const chainEntries = prev[chainId] ?? []
113+
const updatedChain = insertToken(chainEntries, normalized, maxItems)
114+
115+
return {
116+
...prev,
117+
[chainId]: updatedChain,
118+
}
119+
}
120+
121+
export function persistRecentTokenSelection(
122+
token: TokenWithLogo,
123+
favoriteTokens: TokenWithLogo[],
124+
maxItems = RECENT_TOKENS_LIMIT,
125+
): void {
126+
const favoriteKeys = buildFavoriteTokenKeys(favoriteTokens)
127+
128+
if (favoriteKeys.has(getTokenUniqueKey(token))) {
129+
return
130+
}
131+
132+
const current = readStoredTokens(maxItems)
133+
const next = buildNextStoredTokens(current, token, maxItems)
134+
135+
persistStoredTokens(next)
136+
}
137+
138+
function sanitizeStoredTokensMap(record: Record<string, unknown>, limit: number): StoredRecentTokensByChain {
139+
const entries: StoredRecentTokensByChain = {}
140+
141+
for (const [chainKey, tokens] of Object.entries(record)) {
142+
const chainId = Number(chainKey)
143+
144+
if (Number.isNaN(chainId) || !Array.isArray(tokens)) {
145+
continue
146+
}
147+
148+
const sanitized = tokens
149+
.map<StoredRecentToken | null>((token) => sanitizeStoredToken(token))
150+
.filter((token): token is StoredRecentToken => Boolean(token))
151+
152+
if (sanitized.length) {
153+
entries[chainId] = sanitized.slice(0, limit)
154+
}
155+
}
156+
157+
return entries
158+
}
159+
160+
function migrateLegacyStoredTokens(entries: unknown[], limit: number): StoredRecentTokensByChain {
161+
return entries
162+
.map((entry) => sanitizeStoredToken(entry))
163+
.filter((entry): entry is StoredRecentToken => Boolean(entry))
164+
.reverse()
165+
.reduce<StoredRecentTokensByChain>((acc, sanitized) => {
166+
const chainId = sanitized.chainId
167+
const chain = acc[chainId] ?? []
168+
169+
acc[chainId] = insertToken(chain, sanitized, limit)
170+
171+
return acc
172+
}, {})
173+
}
174+
175+
function sanitizeStoredToken(token: unknown): StoredRecentToken | null {
176+
if (!token || typeof token !== 'object') {
177+
return null
178+
}
179+
180+
const { chainId, address, decimals, symbol, name, logoURI, tags } = token as StoredRecentToken
181+
182+
if (typeof chainId !== 'number' || typeof address !== 'string' || typeof decimals !== 'number') {
183+
return null
184+
}
185+
186+
return {
187+
chainId,
188+
address: address.toLowerCase(),
189+
decimals,
190+
symbol: typeof symbol === 'string' ? symbol : undefined,
191+
name: typeof name === 'string' ? name : undefined,
192+
logoURI: typeof logoURI === 'string' ? logoURI : undefined,
193+
tags: Array.isArray(tags) ? tags.filter((tag): tag is string => typeof tag === 'string') : undefined,
194+
}
195+
}
196+
197+
function insertToken(tokens: StoredRecentToken[], token: StoredRecentToken, limit: number): StoredRecentToken[] {
198+
const key = getTokenUniqueKey(token)
199+
const withoutToken = tokens.filter((entry) => getTokenUniqueKey(entry) !== key)
200+
201+
return [token, ...withoutToken].slice(0, limit)
202+
}
203+
204+
function toStoredToken(token: TokenWithLogo): StoredRecentToken {
205+
return {
206+
chainId: token.chainId,
207+
address: token.address.toLowerCase(),
208+
decimals: token.decimals,
209+
symbol: token.symbol,
210+
name: token.name,
211+
logoURI: token.logoURI,
212+
tags: token.tags,
213+
}
214+
}
215+
216+
function canUseLocalStorage(): boolean {
217+
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
218+
}

0 commit comments

Comments
 (0)