Skip to content

/send page - token select updates needed #425

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 13, 2025
Merged
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
152 changes: 124 additions & 28 deletions components/UI/TokenSelector.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,61 @@ import { IoMdClose } from 'react-icons/io'
import { IoChevronDown } from 'react-icons/io5'
import axios from 'axios'
import { avatarServer, nativeCurrency, nativeCurrenciesImages, useWidth } from '../../utils'
import { niceCurrency, shortAddress } from '../../utils/format'
import { niceCurrency, shortAddress, amountFormat } from '../../utils/format'

const limit = 20

export default function TokenSelector({ value, onChange, excludeNative = false }) {
// Helper function to fetch and process trustlines for a destination address
const fetchTrustlinesForDestination = async (destinationAddress, searchQuery = '') => {
const response = await axios(`v2/objects/${destinationAddress}?limit=1000`)
const objects = response.data?.objects || []

// Filter RippleState objects to get trustlines where destination can hold tokens
const trustlines = objects.filter(obj => {
if (obj.LedgerEntryType !== 'RippleState') return false
if (parseFloat(obj.LowLimit.value) <= 0 && parseFloat(obj.HighLimit.value) <= 0) return false

// If search query is provided, filter by it
if (searchQuery) {
const currency = obj.Balance.currency
const issuerDetails = obj.HighLimit.issuer === destinationAddress ? obj.LowLimit.issuerDetails : obj.HighLimit.issuerDetails || {}
const serviceOrUsername = issuerDetails.service || issuerDetails.username || ''
const issuer = obj.HighLimit.issuer === destinationAddress ? obj.LowLimit.issuer : obj.HighLimit.issuer || ''

const searchLower = searchQuery.toLowerCase()
return (
currency.toLowerCase().includes(searchLower) ||
serviceOrUsername.toLowerCase().includes(searchLower) ||
issuer.toLowerCase().includes(searchLower)
)
}

return true
})

// Convert trustlines to token format
return trustlines.map(tl => ({
currency: tl.Balance.currency,
issuer: tl.HighLimit.issuer === destinationAddress ? tl.LowLimit.issuer : tl.HighLimit.issuer,
issuerDetails: tl.HighLimit.issuer === destinationAddress ? tl.LowLimit.issuerDetails : tl.HighLimit.issuerDetails,
limit: Math.max(parseFloat(tl.LowLimit.value), parseFloat(tl.HighLimit.value)),
balance: tl.Balance.value
}))
}

// Helper function to add native currency to tokens array if needed
const addNativeCurrencyIfNeeded = (tokens, excludeNative, searchQuery = '') => {
if (excludeNative) return tokens

const shouldAddNative = !searchQuery || searchQuery.toUpperCase() === nativeCurrency.toUpperCase()
if (shouldAddNative) {
tokens.unshift({ currency: nativeCurrency, limit: null })
}

return tokens
}

export default function TokenSelector({ value, onChange, excludeNative = false, destinationAddress = null }) {
const { t } = useTranslation()
const width = useWidth()
const [isOpen, setIsOpen] = useState(false)
Expand All @@ -18,6 +68,12 @@ export default function TokenSelector({ value, onChange, excludeNative = false }
const [isLoading, setIsLoading] = useState(false)
const [searchTimeout, setSearchTimeout] = useState(null)

// Clear search results when destination address changes
useEffect(() => {
setSearchResults([])
setSearchQuery('')
}, [destinationAddress])

// Handle search with debounce
useEffect(() => {
if (!isOpen) {
Expand All @@ -30,24 +86,41 @@ export default function TokenSelector({ value, onChange, excludeNative = false }

const timeout = setTimeout(async () => {
if (!searchQuery) {
// do not reload default token list if it's already loaded
// when searched for native currency, we also add the native currency on top,
// so check that it's not that case before canceling the search
if (
searchResults[0]?.currency === nativeCurrency &&
!niceCurrency(searchResults[1]?.currency)?.toLowerCase().startsWith(nativeCurrency.toLowerCase())
)
return
// Only apply the early return logic when there's no destination address
// When destination address is provided, we always want to fetch fresh data
if (!destinationAddress) {
// do not reload default token list if it's already loaded
// when searched for native currency, we also add the native currency on top,
// so check that it's not that case before canceling the search
if (
searchResults[0]?.currency === nativeCurrency &&
!niceCurrency(searchResults[1]?.currency)?.toLowerCase().startsWith(nativeCurrency.toLowerCase())
)
return
}

setIsLoading(true)
try {
const response = await axios('v2/trustlines/tokens?limit=' + limit)
const tokens = response.data?.tokens || []
if (excludeNative) {
setSearchResults(tokens)
let tokens = []

if (destinationAddress) {
// Fetch tokens that destination can hold based on trustlines
tokens = await fetchTrustlinesForDestination(destinationAddress)
tokens = addNativeCurrencyIfNeeded(tokens, excludeNative)
} else {
setSearchResults([{ currency: nativeCurrency }, ...tokens])
// Fallback to original behavior if no destination address
const response = await axios('v2/trustlines/tokens?limit=' + limit)
tokens = response.data?.tokens || []
if (!excludeNative) {
setSearchResults([{ currency: nativeCurrency }, ...tokens])
} else {
setSearchResults(tokens)
}
setIsLoading(false)
return
}

setSearchResults(tokens)
} catch (error) {
console.error('Error loading tokens:', error)
if (excludeNative) {
Expand All @@ -63,16 +136,18 @@ export default function TokenSelector({ value, onChange, excludeNative = false }

setIsLoading(true)
try {
//limit doesn't work with search..
const response = await axios(`v2/trustlines/tokens/search/${searchQuery}?limit=${limit}`)
const tokens = response.data?.tokens || []

if (!excludeNative && searchQuery.toUpperCase() === nativeCurrency.toUpperCase()) {
// If search for native currency, add it first
tokens.unshift({ currency: nativeCurrency })
if (destinationAddress) {
// For destination-specific search, filter the existing trustlines
const tokens = await fetchTrustlinesForDestination(destinationAddress, searchQuery)
const tokensWithNative = addNativeCurrencyIfNeeded(tokens, excludeNative, searchQuery)
setSearchResults(tokensWithNative)
} else {
// Fallback to original search behavior
const response = await axios(`v2/trustlines/tokens/search/${searchQuery}?limit=${limit}`)
const tokens = response.data?.tokens || []
const tokensWithNative = addNativeCurrencyIfNeeded(tokens, excludeNative, searchQuery)
setSearchResults(tokensWithNative)
}

setSearchResults(tokens)
} catch (error) {
console.error('Error searching tokens:', error)
setSearchResults([])
Expand All @@ -89,7 +164,7 @@ export default function TokenSelector({ value, onChange, excludeNative = false }
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery, isOpen])
}, [searchQuery, isOpen, destinationAddress])

const handleSelect = (token) => {
onChange(token)
Expand Down Expand Up @@ -119,6 +194,20 @@ export default function TokenSelector({ value, onChange, excludeNative = false }
return niceCurrency(token.currency)
}

// Helper to get token limit display
const getTokenLimitDisplay = (token) => {
if (!token.limit || token.currency === nativeCurrency) return null

return (
<div className="token-selector-modal-item-limit">
<span className="token-selector-modal-item-limit-label">Max:</span>
<span className="token-selector-modal-item-limit-value">
{amountFormat({ value: token.limit, currency: token.currency, issuer: token.issuer }, { short: true })}
</span>
</div>
)
}

return (
<div className="token-selector">
<div
Expand Down Expand Up @@ -154,7 +243,9 @@ export default function TokenSelector({ value, onChange, excludeNative = false }
{/* Modal */}
<div className="token-selector-modal-container">
<div className="token-selector-modal-header">
<h3 className="token-selector-modal-title">Select Token</h3>
<h3 className="token-selector-modal-title">
{destinationAddress ? 'Select Token (Destination can hold)' : 'Select Token'}
</h3>
<IoMdClose className="token-selector-modal-close" onClick={() => setIsOpen(false)} />
</div>

Expand Down Expand Up @@ -183,7 +274,7 @@ export default function TokenSelector({ value, onChange, excludeNative = false }
<div className="token-selector-modal-items">
{searchResults.map((token, index) => (
<div
key={`${token.token}-${index}`}
key={`${token.currency}-${token.issuer}-${index}`}
className="token-selector-modal-item"
onClick={() => handleSelect(token)}
>
Expand All @@ -198,6 +289,7 @@ export default function TokenSelector({ value, onChange, excludeNative = false }
<div className="token-selector-modal-item-name">
<span>{getTokenDisplayName(token)}</span>
{width > 1100 ? <span>{token.issuer}</span> : <span>{shortAddress(token.issuer)}</span>}
{getTokenLimitDisplay(token)}
</div>
</div>
</div>
Expand All @@ -210,6 +302,10 @@ export default function TokenSelector({ value, onChange, excludeNative = false }
</div>
) : searchQuery ? (
<div className="token-selector-modal-empty">{t('general.no-data')}</div>
) : destinationAddress ? (
<div className="token-selector-modal-empty">
No trustlines found for this destination address.
</div>
) : null}
</div>
</div>
Expand All @@ -218,4 +314,4 @@ export default function TokenSelector({ value, onChange, excludeNative = false }
)}
</div>
)
}
}
58 changes: 55 additions & 3 deletions pages/services/send.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,53 @@ export default function Send({
setSelectedToken(token)
}

// Helper to get maximum amount that can be sent for the selected token
const getMaxAmount = () => {
if (!selectedToken || selectedToken.currency === nativeCurrency) return null
return selectedToken.limit
}

// Helper to format max amount display
const getMaxAmountDisplay = () => {
const maxAmount = getMaxAmount()
if (!maxAmount) return null

return (
<>
<span className="max-amount-display">
(<span className="max-amount-label">Dest. can accept max</span>{' '}
<span className="max-amount-value">
{amountFormat(
{ value: maxAmount, currency: selectedToken.currency, issuer: selectedToken.issuer },
{ short: true }
)}
</span>
)
</span>
<style jsx>{`
.max-amount-display {
align-items: center;
margin-top: 4px;
font-size: 12px;

.max-amount-label {
color: #6b7280;
font-weight: 500;
.dark & {
color: #9ca3af;
}
}

.max-amount-value {
color: var(--accent-link);
font-weight: 500;
}
}
`}</style>
</>
)
}

// Fetch network info for reserve amounts only when account is not activated
useEffect(() => {
const fetchNetworkInfo = async () => {
Expand Down Expand Up @@ -338,7 +385,10 @@ export default function Send({
placeholder="Destination address"
name="destination"
hideButton={true}
setValue={setAddress}
setValue={(value) => {
setAddress(value)
setSelectedToken({ currency: nativeCurrency })
}}
setInnerValue={setAddress}
rawData={isAddressValid(address) ? { address } : {}}
type="address"
Expand Down Expand Up @@ -398,7 +448,9 @@ export default function Send({
<div className="form-spacing" />
<div className="flex flex-col gap-4 sm:flex-row">
<div className="flex-1">
<span className="input-title">{t('table.amount')}</span>
<span className="input-title">
{t('table.amount')} {getMaxAmountDisplay()}
</span>
<input
placeholder="Enter amount"
onChange={(e) => setAmount(e.target.value)}
Expand All @@ -414,7 +466,7 @@ export default function Send({
</div>
<div className="w-full sm:w-1/2">
<span className="input-title">Currency</span>
<TokenSelector value={selectedToken} onChange={onTokenChange} />
<TokenSelector value={selectedToken} onChange={onTokenChange} destinationAddress={address} />
</div>
</div>
</div>
Expand Down
21 changes: 21 additions & 0 deletions styles/components/tokenSelector.scss
Original file line number Diff line number Diff line change
Expand Up @@ -218,4 +218,25 @@
justify-content: center;
overflow: hidden;
}

&-modal-item-limit {
display: flex;
align-items: center;
gap: 4px;
margin-top: 2px;
font-size: 12px;

&-label {
color: #6b7280;
font-weight: 500;
.dark & {
color: #9ca3af;
}
}

&-value {
color: var(--accent-link);
font-weight: 500;
}
}
}