Skip to content

Commit ead50f5

Browse files
committed
feat: enhance token search results with bridge support
1 parent 9688722 commit ead50f5

File tree

18 files changed

+585
-60
lines changed

18 files changed

+585
-60
lines changed

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

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ReactNode, useCallback, useEffect, useMemo } from 'react'
22

3-
import { doesTokenMatchSymbolOrAddress } from '@cowprotocol/common-utils'
3+
import { doesTokenMatchSymbolOrAddress, getTokenAddressKey } from '@cowprotocol/common-utils'
44
import { getTokenSearchFilter, TokenSearchResponse, useSearchToken } from '@cowprotocol/tokens'
55

66
import { useAddTokenImportCallback } from '../../hooks/useAddTokenImportCallback'
@@ -14,25 +14,32 @@ import { TokenSearchContent } from '../../pure/TokenSearchContent'
1414
export function TokenSearchResults(): ReactNode {
1515
const { searchInput } = useTokenListViewState()
1616

17-
const { selectTokenContext, areTokensFromBridge, allTokens } = useTokenListContext()
17+
const { selectTokenContext, areTokensFromBridge, allTokens, bridgeSupportedTokensMap } = useTokenListContext()
1818

1919
const { onTokenListItemClick } = selectTokenContext
2020

2121
const { onSelectToken } = useSelectTokenWidgetState()
2222

23-
// Do not make search when tokens are from bridge
24-
const defaultSearchResults = useSearchToken(areTokensFromBridge ? null : searchInput)
23+
// Search all tokens (used in both modes)
24+
const defaultSearchResults = useSearchToken(searchInput)
2525

26-
// For bridge output tokens just filter them instead of making search
26+
// For bridge mode, merge bridge-supported tokens with search results (non-bridgeable shown as disabled)
2727
const searchResults: TokenSearchResponse = useMemo(() => {
2828
if (!areTokensFromBridge) return defaultSearchResults
2929

30+
// Filter bridge-supported tokens (target chain) by search input
3031
const filter = getTokenSearchFilter(searchInput)
31-
const filteredTokens = allTokens.filter(filter)
32+
const filteredBridgeTokens = allTokens.filter(filter)
33+
34+
// Merge: bridge tokens first, then additional search results (will be marked disabled)
35+
const bridgeAddresses = new Set(filteredBridgeTokens.map((t) => getTokenAddressKey(t.address)))
36+
const additionalTokens = defaultSearchResults.activeListsResult.filter(
37+
(t) => !bridgeAddresses.has(getTokenAddressKey(t.address)),
38+
)
3239

3340
return {
3441
...defaultSearchResults,
35-
activeListsResult: filteredTokens,
42+
activeListsResult: [...filteredBridgeTokens, ...additionalTokens],
3643
}
3744
}, [searchInput, areTokensFromBridge, allTokens, defaultSearchResults])
3845

@@ -53,12 +60,30 @@ export function TokenSearchResults(): ReactNode {
5360
if (activeListsResult.length === 1 || matchedTokens.length === 1) {
5461
const tokenToSelect = matchedTokens[0] || activeListsResult[0]
5562

63+
// In bridge mode, don't select non-bridgeable tokens (also block while map is loading)
5664
if (tokenToSelect) {
57-
onTokenListItemClick?.(tokenToSelect)
58-
onSelectToken?.(tokenToSelect)
65+
const hasAddress = !!tokenToSelect.address
66+
const isInBridgeMap =
67+
hasAddress &&
68+
bridgeSupportedTokensMap !== null &&
69+
!!bridgeSupportedTokensMap[getTokenAddressKey(tokenToSelect.address)]
70+
const isBridgeable = !areTokensFromBridge || isInBridgeMap
71+
72+
if (isBridgeable) {
73+
onTokenListItemClick?.(tokenToSelect)
74+
onSelectToken?.(tokenToSelect)
75+
}
5976
}
6077
}
61-
}, [searchInput, activeListsResult, matchedTokens, onSelectToken, onTokenListItemClick])
78+
}, [
79+
searchInput,
80+
activeListsResult,
81+
matchedTokens,
82+
onSelectToken,
83+
onTokenListItemClick,
84+
areTokensFromBridge,
85+
bridgeSupportedTokensMap,
86+
])
6287

6388
useEffect(() => {
6489
updateSelectTokenWidget({
@@ -73,6 +98,8 @@ export function TokenSearchResults(): ReactNode {
7398
searchInput={searchInput}
7499
selectTokenContext={selectTokenContext}
75100
searchResults={searchResults}
101+
areTokensFromBridge={areTokensFromBridge}
102+
bridgeSupportedTokensMap={bridgeSupportedTokensMap}
76103
/>
77104
</CommonListContainer>
78105
)

apps/cowswap-frontend/src/modules/tokensList/hooks/useTokenListContext.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ export interface TokenListContext {
2727
areTokensLoading: boolean
2828
areTokensFromBridge: boolean
2929

30+
// Bridge support map (null when loading, populated when bridge tokens are fetched)
31+
bridgeSupportedTokensMap: Record<string, boolean> | null
32+
3033
// UI config
3134
hideFavoriteTokensTooltip: boolean
3235
selectedTargetChainId: number | undefined
@@ -71,6 +74,7 @@ export function useTokenListContext(): TokenListContext {
7174
recentTokens,
7275
areTokensLoading: tokensState.isLoading,
7376
areTokensFromBridge: tokensState.areTokensFromBridge,
77+
bridgeSupportedTokensMap: tokensState.bridgeSupportedTokensMap,
7478
hideFavoriteTokensTooltip: isInjectedWidget(),
7579
selectedTargetChainId: widgetState.selectedTargetChainId,
7680
onClearRecentTokens: clearRecentTokens,
@@ -81,6 +85,7 @@ export function useTokenListContext(): TokenListContext {
8185
tokensState.tokens,
8286
tokensState.isLoading,
8387
tokensState.areTokensFromBridge,
88+
tokensState.bridgeSupportedTokensMap,
8489
favoriteTokens,
8590
recentTokens,
8691
widgetState.selectedTargetChainId,

apps/cowswap-frontend/src/modules/tokensList/hooks/useTokensToSelect.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface TokensToSelectContext {
1919
favoriteTokens: TokenWithLogo[]
2020
areTokensFromBridge: boolean
2121
isRouteAvailable: boolean | undefined
22+
bridgeSupportedTokensMap: Record<string, boolean> | null
2223
}
2324

2425
export function useTokensToSelect(): TokensToSelectContext {
@@ -59,6 +60,7 @@ export function useTokensToSelect(): TokensToSelectContext {
5960
favoriteTokens: favoriteTokensToSelect,
6061
areTokensFromBridge,
6162
isRouteAvailable: result?.isRouteAvailable,
63+
bridgeSupportedTokensMap,
6264
}
6365
}, [allTokens, bridgeSupportedTokensMap, isLoading, areTokensFromBridge, favoriteTokens, result])
6466
}

apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenItem/index.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ReactNode } from 'react'
22

33
import { TokenWithLogo } from '@cowprotocol/common-const'
4+
import { HoverTooltip } from '@cowprotocol/ui'
45

56
import { Trans } from '@lingui/react/macro'
67
import { CheckCircle } from 'react-feather'
@@ -18,15 +19,27 @@ export interface ImportTokenItemProps {
1819
wrapperId?: string
1920
isFirstInSection?: boolean
2021
isLastInSection?: boolean
22+
disabledReason?: string
2123
}
2224

2325
export function ImportTokenItem(props: ImportTokenItemProps): ReactNode {
24-
const { token, importToken, shadowed, existing, wrapperId, isFirstInSection, isLastInSection } = props
26+
const { token, importToken, shadowed, existing, wrapperId, isFirstInSection, isLastInSection, disabledReason } = props
27+
28+
const tokenInfo = (
29+
<div style={{ opacity: shadowed ? 0.6 : 1 }}>
30+
<TokenInfo token={token} />
31+
</div>
32+
)
33+
2534
return (
2635
<styledEl.Wrapper id={wrapperId} $isFirst={isFirstInSection} $isLast={isLastInSection}>
27-
<div style={{ opacity: shadowed ? 0.6 : 1 }}>
28-
<TokenInfo token={token} />
29-
</div>
36+
{disabledReason ? (
37+
<HoverTooltip wrapInContainer placement="top" content={disabledReason}>
38+
{tokenInfo}
39+
</HoverTooltip>
40+
) : (
41+
tokenInfo
42+
)}
3043
<div>
3144
{existing && (
3245
<styledEl.ActiveToken>

apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx

Lines changed: 50 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { TokenWithLogo } from '@cowprotocol/common-const'
44
import { areAddressesEqual, getCurrencyAddress, getTokenId } from '@cowprotocol/common-utils'
55
import { SupportedChainId } from '@cowprotocol/cow-sdk'
66
import { TokenListTags } from '@cowprotocol/tokens'
7-
import { FiatAmount, LoadingRows, LoadingRowSmall, TokenAmount } from '@cowprotocol/ui'
7+
import { FiatAmount, HoverTooltip, LoadingRows, LoadingRowSmall, TokenAmount } from '@cowprotocol/ui'
88
import { BigNumber } from '@ethersproject/bignumber'
99
import { Currency, CurrencyAmount } from '@uniswap/sdk-core'
1010

@@ -37,10 +37,38 @@ export interface TokenListItemProps {
3737
tokenListTags?: TokenListTags
3838
children?: ReactNode
3939
className?: string
40+
disabled?: boolean
41+
disabledReason?: string
4042
}
4143

42-
function getClassName(isTokenSelected: boolean, className = ''): string {
43-
return `${className} ${isTokenSelected ? 'token-item-selected' : ''}`
44+
function getClassName(isTokenSelected: boolean, disabled: boolean, className = ''): string {
45+
const selectedClass = isTokenSelected ? 'token-item-selected' : ''
46+
const disabledClass = disabled ? 'token-item-disabled' : ''
47+
return `${className} ${selectedClass} ${disabledClass}`.trim()
48+
}
49+
50+
interface DisabledProps {
51+
'aria-disabled'?: true
52+
tabIndex?: -1
53+
}
54+
55+
function getDisabledProps(disabled: boolean): DisabledProps {
56+
if (!disabled) return {}
57+
return { 'aria-disabled': true, tabIndex: -1 }
58+
}
59+
60+
function checkIsTokenSelected(token: TokenWithLogo, selectedToken: Nullish<Currency>): boolean {
61+
if (!selectedToken) return false
62+
return areAddressesEqual(token.address, getCurrencyAddress(selectedToken)) && token.chainId === selectedToken.chainId
63+
}
64+
65+
function wrapWithTooltip(content: ReactNode, disabled: boolean, disabledReason?: string): ReactNode {
66+
if (!disabled || !disabledReason) return content
67+
return (
68+
<HoverTooltip wrapInContainer placement="top" content={disabledReason}>
69+
{content}
70+
</HoverTooltip>
71+
)
4472
}
4573

4674
const EMPTY_TAGS = {}
@@ -58,46 +86,41 @@ export function TokenListItem(props: TokenListItemProps): ReactNode {
5886
tokenListTags = EMPTY_TAGS,
5987
children,
6088
className,
89+
disabled = false,
90+
disabledReason,
6191
} = props
6292

63-
const tokenKey = getTokenId(token)
64-
// Defer heavyweight UI (tooltips, formatted numbers) until the row is about to enter the viewport.
6593
const { ref: visibilityRef, isVisible: hasIntersected } = useDeferredVisibility<HTMLDivElement>({
66-
resetKey: tokenKey,
94+
resetKey: getTokenId(token),
6795
rootMargin: '200px',
6896
})
6997

70-
const handleClick: MouseEventHandler<HTMLDivElement> = (e) => {
71-
if (isTokenSelected) {
72-
e.preventDefault()
73-
e.stopPropagation()
74-
} else {
75-
onSelectToken?.(token)
76-
}
77-
}
78-
79-
const isTokenSelected = Boolean(
80-
selectedToken &&
81-
areAddressesEqual(token.address, getCurrencyAddress(selectedToken)) &&
82-
token.chainId === selectedToken.chainId,
83-
)
84-
98+
const isTokenSelected = checkIsTokenSelected(token, selectedToken)
8599
const isSupportedChain = token.chainId in SupportedChainId
86100
const shouldShowBalances = isWalletConnected && isSupportedChain
87-
// Formatting balances (BigNumber -> CurrencyAmount -> Fiat) is expensive; delay until the row is visible.
88101
const shouldFormatBalances = shouldShowBalances && hasIntersected
89102
const balanceAmount =
90103
shouldFormatBalances && balance ? CurrencyAmount.fromRawAmount(token, balance.toHexString()) : undefined
91104

92-
return (
105+
const handleClick: MouseEventHandler<HTMLDivElement> = (e) => {
106+
if (isTokenSelected || disabled) {
107+
e.preventDefault()
108+
e.stopPropagation()
109+
return
110+
}
111+
onSelectToken?.(token)
112+
}
113+
114+
return wrapWithTooltip(
93115
<styledEl.TokenItem
94116
ref={visibilityRef}
95117
data-address={token.address.toLowerCase()}
96118
data-token-symbol={token.symbol || ''}
97119
data-token-name={token.name || ''}
98120
data-element-type="token-selection"
99121
onClick={handleClick}
100-
className={getClassName(isTokenSelected, className)}
122+
className={getClassName(isTokenSelected, disabled, className)}
123+
{...getDisabledProps(disabled)}
101124
>
102125
<TokenInfo
103126
token={token}
@@ -120,7 +143,9 @@ export function TokenListItem(props: TokenListItemProps): ReactNode {
120143
usdAmount={usdAmount}
121144
/>
122145
{children}
123-
</styledEl.TokenItem>
146+
</styledEl.TokenItem>,
147+
disabled,
148+
disabledReason,
124149
)
125150
}
126151

apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/styled.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,15 @@ export const TokenItem = styled.div`
4747
&.token-item-selected:hover {
4848
background: none;
4949
}
50+
51+
&.token-item-disabled {
52+
opacity: 0.5;
53+
cursor: not-allowed;
54+
}
55+
56+
&.token-item-disabled:hover {
57+
background: none;
58+
}
5059
`
5160

5261
export const TokenBalance = styled.span`

apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,16 @@ import { TokenListItem } from '../TokenListItem'
1010
interface TokenListItemContainerProps {
1111
token: TokenWithLogo
1212
context: SelectTokenContext
13+
disabled?: boolean
14+
disabledReason?: string
1315
}
1416

15-
export function TokenListItemContainer({ token, context }: TokenListItemContainerProps): ReactNode {
17+
export function TokenListItemContainer({
18+
token,
19+
context,
20+
disabled,
21+
disabledReason,
22+
}: TokenListItemContainerProps): ReactNode {
1623
const {
1724
unsupportedTokens,
1825
onTokenListItemClick,
@@ -43,6 +50,8 @@ export function TokenListItemContainer({ token, context }: TokenListItemContaine
4350
onSelectToken={handleSelectToken}
4451
isWalletConnected={isWalletConnected}
4552
tokenListTags={tokenListTags}
53+
disabled={disabled}
54+
disabledReason={disabledReason}
4655
/>
4756
)
4857
}

apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/TokenSearchRowRenderer.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,14 @@ export function TokenSearchRowRenderer({
1717
case 'banner':
1818
return <GuideBanner />
1919
case 'token':
20-
return <TokenListItemContainer token={row.token} context={selectTokenContext} />
20+
return (
21+
<TokenListItemContainer
22+
token={row.token}
23+
context={selectTokenContext}
24+
disabled={row.disabled}
25+
disabledReason={row.disabledReason}
26+
/>
27+
)
2128
case 'section-title': {
2229
const tooltip = row.tooltip?.trim() || undefined
2330
return (
@@ -30,11 +37,12 @@ export function TokenSearchRowRenderer({
3037
return (
3138
<ImportTokenItem
3239
token={row.token}
33-
importToken={importToken}
40+
importToken={row.hideImport ? undefined : importToken}
3441
shadowed={row.shadowed}
3542
wrapperId={row.wrapperId}
3643
isFirstInSection={row.isFirstInSection}
3744
isLastInSection={row.isLastInSection}
45+
disabledReason={row.disabledReason}
3846
/>
3947
)
4048
default:

0 commit comments

Comments
 (0)