diff --git a/packages/common/src/services/audius-backend/AudiusBackend.ts b/packages/common/src/services/audius-backend/AudiusBackend.ts index 8a1b620b7d8..037a1cd2cd8 100644 --- a/packages/common/src/services/audius-backend/AudiusBackend.ts +++ b/packages/common/src/services/audius-backend/AudiusBackend.ts @@ -36,7 +36,7 @@ import { } from '../../store' import { getErrorMessage, uuid, Maybe, Nullable } from '../../utils' -import { MintName } from './solana' +import { MintName, createUserBankIfNeeded } from './solana' type DisplayEncoding = 'utf8' | 'hex' type PhantomEvent = 'disconnect' | 'connect' | 'accountChanged' @@ -914,13 +914,13 @@ export const audiusBackend = ({ let tokenAccountAddress: PublicKey if (recipientEthAddress) { - // When sending to a user, derive their user-bank ATA for this Solana mint - // The user-bank is a PDA derived from their Ethereum address and the mint - tokenAccountAddress = - await sdk.services.claimableTokensClient.deriveUserBank({ - ethWallet: recipientEthAddress, - mint - }) + // When sending to a user, ensure their user-bank account exists + // This will create it if needed (in a separate transaction) + tokenAccountAddress = await createUserBankIfNeeded(sdk, { + ethAddress: recipientEthAddress, + mint: mint as any, + recordAnalytics: () => {} // Analytics handled elsewhere + }) } else { // When sending to a Solana wallet address directly, use regular ATA logic tokenAccountAddress = await getOrCreateAssociatedTokenAccount({ diff --git a/packages/mobile/src/components/core/Screen/Screen.tsx b/packages/mobile/src/components/core/Screen/Screen.tsx index 03283cbabe0..bffff46d5ad 100644 --- a/packages/mobile/src/components/core/Screen/Screen.tsx +++ b/packages/mobile/src/components/core/Screen/Screen.tsx @@ -56,6 +56,8 @@ export type ScreenProps = { variant?: ScreenVariant as?: ComponentType header?: () => ReactElement + // Callback called when user presses back button + onBack?: () => void } export const Screen = (props: ScreenProps) => { @@ -73,7 +75,8 @@ export const Screen = (props: ScreenProps) => { variant = 'primary', style, as: RootComponent = View, - header + header, + onBack } = props const palette = useThemePalette() const styles = useStyles() @@ -81,6 +84,17 @@ export const Screen = (props: ScreenProps) => { const navigation = useNavigation() const isSecondary = variant === 'secondary' || variant === 'white' + // Handle back button press + useEffect(() => { + if (!onBack) return + + const unsubscribe = navigation.addListener('beforeRemove', () => { + onBack() + }) + + return unsubscribe + }, [navigation, onBack]) + // Record screen view useEffect(() => { if (url) { diff --git a/packages/mobile/src/components/send-tokens-drawer/SendTokensDrawer.tsx b/packages/mobile/src/components/send-tokens-drawer/SendTokensDrawer.tsx index e57ea8e60d8..00a30e158ed 100644 --- a/packages/mobile/src/components/send-tokens-drawer/SendTokensDrawer.tsx +++ b/packages/mobile/src/components/send-tokens-drawer/SendTokensDrawer.tsx @@ -71,6 +71,15 @@ export const SendTokensDrawer = () => { }) } + const handleUserChange = (user: User | null) => { + // Update state when user is selected/reselected + setState((prev) => ({ + ...prev, + selectedUser: user, + destinationAddress: user?.spl_wallet ?? prev.destinationAddress + })) + } + const handleConfirm = async () => { setState((prev) => ({ ...prev, step: 'progress' })) setError('') @@ -97,8 +106,11 @@ export const SendTokensDrawer = () => { errorString.includes('0x3') || errorString.includes('custom program error: 0x3') ) { + // This error should no longer occur as we now automatically create + // user-bank accounts in the transaction. If it still occurs, it's likely + // a different issue or the account creation failed. errorMessage = - 'The recipient wallet does not have a token account for this coin. They may need to receive tokens of this type first, or the transaction needs to create the account automatically.' + 'Failed to create recipient token account. Please try again.' } setError(errorMessage) @@ -141,6 +153,11 @@ export const SendTokensDrawer = () => { setError('') } + const handleBeforeUserSelectionNavigate = () => { + // Close the drawer but preserve state so it can be restored when user selects + onClose() + } + const renderHeader = () => { return ( @@ -161,7 +178,8 @@ export const SendTokensDrawer = () => { initialDestinationAddress={state.destinationAddress} initialSelectedUser={state.selectedUser} initialRecipientType={state.recipientType} - onBeforeUserSelectionNavigate={handleClose} + onBeforeUserSelectionNavigate={handleBeforeUserSelectionNavigate} + onUserChange={handleUserChange} /> ) : null} diff --git a/packages/mobile/src/components/send-tokens-drawer/components/SendTokensInput.tsx b/packages/mobile/src/components/send-tokens-drawer/components/SendTokensInput.tsx index 741e23caa4d..d4e6e1c403e 100644 --- a/packages/mobile/src/components/send-tokens-drawer/components/SendTokensInput.tsx +++ b/packages/mobile/src/components/send-tokens-drawer/components/SendTokensInput.tsx @@ -36,6 +36,7 @@ type SendTokensInputProps = { initialSelectedUser?: User | null initialRecipientType?: RecipientType onBeforeUserSelectionNavigate?: () => void + onUserChange?: (user: User | null) => void } const messages = { @@ -48,7 +49,7 @@ const messages = { insufficientBalance: 'Insufficient balance', validWalletAddressRequired: 'A valid wallet address is required.', amountRequired: 'Amount is required', - amountTooLow: 'Amount is too low to send', + amountTooLow: 'Amount must be at least $0.50', walletAddress: 'Wallet Address', userRequired: 'Please select a user', userNoWallet: @@ -70,7 +71,8 @@ export const SendTokensInput = ({ initialDestinationAddress = '', initialSelectedUser = null, initialRecipientType = 'user', - onBeforeUserSelectionNavigate + onBeforeUserSelectionNavigate, + onUserChange }: SendTokensInputProps) => { const [recipientType, setRecipientType] = useState(initialRecipientType) @@ -129,6 +131,20 @@ export const SendTokensInput = ({ }) const tokenInfo = coin ? transformArtistCoinToTokenInfo(coin) : undefined + // Calculate USD value for display + const usdValueInfo = useMemo(() => { + if (!amount || parseFloat(amount) <= 0 || !coin) return null + const price = + coin.price === 0 ? coin.dynamicBondingCurve?.priceUSD : coin.price + if (!price || price <= 0) return null + const amountNum = parseFloat(amount) + const usdValue = amountNum * price + return { + usdValue, + isBelowMinimum: usdValue < 0.5 + } + }, [amount, coin]) + // Find the selected token in owned coins for the dropdown const selectedToken = useMemo(() => { const ownedToken = ownedCoins.find( @@ -161,7 +177,13 @@ export const SendTokensInput = ({ } }, []) const handleAmountChange = useCallback((value: string) => { - setAmount(value) + // Only allow numbers and a single decimal point + const numericValue = value.replace(/[^0-9.]/g, '') + // Ensure only one decimal point + const parts = numericValue.split('.') + const filteredValue = + parts.length > 2 ? parts[0] + '.' + parts.slice(1).join('') : numericValue + setAmount(filteredValue) setAmountError(null) }, []) @@ -170,17 +192,22 @@ export const SendTokensInput = ({ setAddressError(null) }, []) - const handleUserChange = useCallback((user: User | null) => { - setSelectedUser(user) - setAddressError(null) - // When sending to a user, we derive their user-bank ATA from their ETH address on the backend - // But we still set spl_wallet for display purposes in the UI - if (user?.spl_wallet) { - setDestinationAddress(user.spl_wallet) - } else { - setDestinationAddress('') - } - }, []) + const handleUserChange = useCallback( + (user: User | null) => { + setSelectedUser(user) + setAddressError(null) + // When sending to a user, we derive their user-bank ATA from their ETH address on the backend + // But we still set spl_wallet for display purposes in the UI + if (user?.spl_wallet) { + setDestinationAddress(user.spl_wallet) + } else { + setDestinationAddress('') + } + // Notify parent component of user change + onUserChange?.(user) + }, + [onUserChange] + ) const handleRecipientTypeChange = useCallback((type: RecipientType) => { setRecipientType(type) @@ -204,10 +231,22 @@ export const SendTokensInput = ({ if (amountWei > currentBalance) { setAmountError('INSUFFICIENT_BALANCE') isValid = false - } else if (amountWei < BigInt(1000)) { - // Minimum amount - setAmountError('AMOUNT_TOO_LOW') - isValid = false + } else { + // Check minimum USD value ($0.50) + const price = + coin?.price === 0 ? coin?.dynamicBondingCurve?.priceUSD : coin?.price + if (price && price > 0) { + const amountNum = parseFloat(amount) + const usdValue = amountNum * price + if (usdValue < 0.5) { + setAmountError('AMOUNT_TOO_LOW') + isValid = false + } + } else if (amountWei < BigInt(1000)) { + // Fallback to minimum token amount if price is not available + setAmountError('AMOUNT_TOO_LOW') + isValid = false + } } } @@ -360,6 +399,16 @@ export const SendTokensInput = ({ tokenInfo?.symbol ? `$${tokenInfo.symbol}` : undefined } /> + {usdValueInfo && ( + + ≈ ${usdValueInfo.usdValue.toFixed(2)} USD + {usdValueInfo.isBelowMinimum && ' (minimum $0.50)'} + + )} diff --git a/packages/mobile/src/components/send-tokens-drawer/components/SendTokensSuccess.tsx b/packages/mobile/src/components/send-tokens-drawer/components/SendTokensSuccess.tsx index 55891175d5d..423306c25f8 100644 --- a/packages/mobile/src/components/send-tokens-drawer/components/SendTokensSuccess.tsx +++ b/packages/mobile/src/components/send-tokens-drawer/components/SendTokensSuccess.tsx @@ -12,13 +12,12 @@ import { Flex, Text, Divider, - CompletionCheck, IconExternalLink, Avatar } from '@audius/harmony-native' -import { BalanceSection } from 'app/components/core' +import { TokenIcon } from 'app/components/core' import { useProfilePicture } from 'app/components/image/UserImage' -import { UserBadges } from 'app/components/user-badges' +import { UserLink } from 'app/components/user-link' import { ExternalLink } from 'app/harmony-native/components/TextLink/ExternalLink' type SendTokensSuccessProps = { @@ -31,11 +30,10 @@ type SendTokensSuccessProps = { } const messages = { - sent: 'Sent', + sentSuccessfully: 'Sent successfully', recipient: 'Recipient', destinationAddress: 'Destination Address', viewOnSolana: 'View On Solana Block Explorer', - transactionComplete: 'Your transaction is complete!', done: 'Done' } @@ -69,8 +67,6 @@ export const SendTokensSuccess = ({ if (!tokenInfo) { return ( - - Loading... @@ -82,33 +78,37 @@ export const SendTokensSuccess = ({ return ( - {/* Token Balance Section */} - + {/* Sent Successfully Message */} + + {messages.sentSuccessfully} + - - - {/* Sent Section */} - - - {messages.sent} - + {/* Sent Amount Section */} + + - + {tokenInfo.name} - - {formatAmount(amount)} ${tokenInfo.symbol} - + + + {formatAmount(amount)} + + + ${tokenInfo.symbol} + + - - - {/* To Recipient Section */} - - - {messages.recipient} - + {/* Recipient Section */} + + + + {messages.recipient} + + + {selectedUser ? ( - - - - {selectedUser.name} - - - - + + + @{selectedUser.handle} @@ -141,6 +130,7 @@ export const SendTokensSuccess = ({ )} + {/* View on Block Explorer */} @@ -150,13 +140,7 @@ export const SendTokensSuccess = ({ - - - - {messages.transactionComplete} - - - + {/* Done Button */} diff --git a/packages/mobile/src/screens/send-tokens-user-selection-screen/SendTokensUserSelectionScreen.tsx b/packages/mobile/src/screens/send-tokens-user-selection-screen/SendTokensUserSelectionScreen.tsx index dc871e5c666..0d0b5a9522d 100644 --- a/packages/mobile/src/screens/send-tokens-user-selection-screen/SendTokensUserSelectionScreen.tsx +++ b/packages/mobile/src/screens/send-tokens-user-selection-screen/SendTokensUserSelectionScreen.tsx @@ -1,21 +1,15 @@ import { useCallback, useEffect, useState } from 'react' -import { useCurrentUserId, useUsers, useFollowers } from '@audius/common/api' -import type { User } from '@audius/common/models' -import { - Status, - statusIsNotFinalized, - SquareSizes -} from '@audius/common/models' import { - searchUsersModalActions, - searchUsersModalSelectors, - useSendTokensModal -} from '@audius/common/store' -import { useFocusEffect } from '@react-navigation/native' + useCurrentUserId, + useFollowers, + useSearchUserResults +} from '@audius/common/api' +import type { User } from '@audius/common/models' +import { SquareSizes } from '@audius/common/models' +import { useSendTokensModal } from '@audius/common/store' import { Pressable, View } from 'react-native' import { KeyboardAwareFlatList } from 'react-native-keyboard-aware-scroll-view' -import { useDispatch, useSelector } from 'react-redux' import { useDebounce } from 'react-use' import { IconSearch, Avatar, Flex, Text, Divider } from '@audius/harmony-native' @@ -32,37 +26,13 @@ import { UserLink } from 'app/components/user-link/UserLink' import { useNavigation } from 'app/hooks/useNavigation' import { useRoute } from 'app/hooks/useRoute' -const { searchUsers } = searchUsersModalActions -const { getUserList } = searchUsersModalSelectors - -const DEBOUNCE_MS = 150 +const DEBOUNCE_MS = 300 const messages = { title: 'Select Recipient', search: ' Search Users' } -const useQueryUserList = (query: string, excludedUserIds?: number[]) => { - const dispatch = useDispatch() - const { userIds, status, hasMore } = useSelector(getUserList) - - const loadMore = useCallback(() => { - dispatch(searchUsers({ query })) - }, [query, dispatch]) - - useEffect(() => { - if (query.trim()) { - loadMore() - } - }, [loadMore, query]) - - const filteredUserIds = excludedUserIds - ? userIds.filter((id) => !excludedUserIds.includes(id)) - : userIds - - return { hasMore, loadMore, status, userIds: filteredUserIds } -} - type UserItemProps = { user: User onSelect: (user: User) => void @@ -102,34 +72,45 @@ const UserItem = ({ user, onSelect }: UserItemProps) => { } export const SendTokensUserSelectionScreen = () => { - const [query, setQuery] = useState('') const [inputValue, setInputValue] = useState('') - const dispatch = useDispatch() + const [debouncedQuery, setDebouncedQuery] = useState('') const navigation = useNavigation() const { params } = useRoute<'SendTokensUserSelection'>() const excludedUserIds = params?.excludedUserIds const callbackId = params?.callbackId const { onOpen: openSendTokensDrawer } = useSendTokensModal() - // Reopen the send drawer when navigating back from this screen - useFocusEffect( - useCallback(() => { - return () => { - // This runs when the screen loses focus (user navigates back) - // If callback still exists, user navigated back without selecting - // If callback doesn't exist, user selected someone (callback was deleted in handleSelectUser) - if (callbackId && userSelectionCallbacks.has(callbackId)) { - // Clean up the callback - userSelectionCallbacks.delete(callbackId) - // Reopen the send drawer since user navigated back without selecting - setTimeout(() => { - openSendTokensDrawer() - }, 100) - } - } - }, [callbackId, openSendTokensDrawer]) + // Debounce the search query + useDebounce( + () => { + setDebouncedQuery(inputValue) + }, + DEBOUNCE_MS, + [inputValue] ) + // Handle back button press to reopen drawer + const handleBack = useCallback(() => { + // Only reopen drawer if callback still exists (user didn't select someone) + if (callbackId && userSelectionCallbacks.has(callbackId)) { + // Clean up the callback + userSelectionCallbacks.delete(callbackId) + // Reopen the send drawer after navigation completes + setTimeout(() => { + openSendTokensDrawer() + }, 150) + } + }, [callbackId, openSendTokensDrawer]) + + // Clean up callback if component unmounts without user selection + useEffect(() => { + return () => { + if (callbackId && userSelectionCallbacks.has(callbackId)) { + userSelectionCallbacks.delete(callbackId) + } + } + }, [callbackId]) + const { data: currentUserId } = useCurrentUserId() // Fetch current user's followers for zero state @@ -142,51 +123,58 @@ export const SendTokensUserSelectionScreen = () => { (user) => !excludedUserIds?.includes(user.user_id) ) - const queryUserList = useQueryUserList(query, excludedUserIds) - - const { hasMore, loadMore, status, userIds } = queryUserList - - const { data: users } = useUsers(userIds.length > 0 ? userIds : null) - - useDebounce( - () => { - setQuery(inputValue) + // Use tan-query for user search with debounced query + const { + data: searchUsers, + isPending: isSearchPending, + loadNextPage, + hasNextPage, + isFetchingNextPage + } = useSearchUserResults( + { + query: debouncedQuery.trim(), + pageSize: 20 }, - DEBOUNCE_MS, - [inputValue, setQuery, dispatch] + { + enabled: debouncedQuery.trim().length > 0 + } + ) + + // Filter out excluded users (data is already flattened by FlatUseInfiniteQueryResult) + const filteredUsers = (searchUsers ?? []).filter( + (user) => !excludedUserIds?.includes(user.user_id) ) const handleClear = useCallback(() => { setInputValue('') - setQuery('') - }, [setQuery]) + setDebouncedQuery('') + }, []) const handleLoadMore = useCallback(() => { - if (status !== Status.LOADING && hasMore) { - loadMore?.() + if (hasNextPage && !isFetchingNextPage) { + loadNextPage() } - }, [status, loadMore, hasMore]) + }, [hasNextPage, isFetchingNextPage, loadNextPage]) const handleSelectUser = useCallback( (user: User) => { if (callbackId) { const callback = userSelectionCallbacks.get(callbackId) if (callback) { + // Callback will handle reopening the drawer and updating state callback(user) } // Clean up the callback userSelectionCallbacks.delete(callbackId) } + // Close the user selection screen navigation.goBack() }, [callbackId, navigation] ) - const isLoading = - statusIsNotFinalized(status) && - userIds.length === 0 && - query.trim().length > 0 - const hasNoQuery = !query.trim() + const hasNoQuery = !debouncedQuery.trim() + const isLoading = isSearchPending && debouncedQuery.trim().length > 0 return ( { icon={IconSearch} variant='secondary' topbarRight={null} + onBack={handleBack} > - {users && users.length > 0 && } + {filteredUsers && filteredUsers.length > 0 && } { ) : ( ( )} @@ -269,7 +258,9 @@ export const SendTokensUserSelectionScreen = () => { contentContainerStyle={{ minHeight: '100%', flexGrow: 1 }} ListEmptyComponent={null} keyboardShouldPersistTaps='always' - ListFooterComponent={} + ListFooterComponent={ + + } /> )} diff --git a/packages/web/src/components/send-tokens-modal/SendTokensInput.tsx b/packages/web/src/components/send-tokens-modal/SendTokensInput.tsx index 246916fbf35..8ab7a95e083 100644 --- a/packages/web/src/components/send-tokens-modal/SendTokensInput.tsx +++ b/packages/web/src/components/send-tokens-modal/SendTokensInput.tsx @@ -63,7 +63,7 @@ const messages = { insufficientBalance: 'Insufficient balance', validWalletAddressRequired: 'A valid wallet address is required.', amountRequired: 'Amount is required', - amountTooLow: 'Amount is too low to send', + amountTooLow: 'Amount must be at least $0.50', walletAddress: 'Wallet Address', userRequired: 'Please select a user', userNoWallet: @@ -125,6 +125,20 @@ const SendTokensInput = ({ const { data: currentUserId } = useCurrentUserId() const tokenInfo = coin ? transformArtistCoinToTokenInfo(coin) : undefined + // Calculate USD value for display + const usdValueInfo = useMemo(() => { + if (!amount || parseFloat(amount) <= 0 || !coin) return null + const price = + coin.price === 0 ? coin.dynamicBondingCurve?.priceUSD : coin.price + if (!price || price <= 0) return null + const amountNum = parseFloat(amount) + const usdValue = amountNum * price + return { + usdValue, + isBelowMinimum: usdValue < 0.5 + } + }, [amount, coin]) + // Find the selected token in owned coins for the dropdown // If not found in owned coins, try to find it in available coins (for initial load) const selectedToken = useMemo(() => { @@ -145,7 +159,13 @@ const SendTokensInput = ({ }, []) const handleAmountChange = useCallback((value: string, weiAmount: bigint) => { - setAmount(value) + // Only allow numbers and a single decimal point + const numericValue = value.replace(/[^0-9.]/g, '') + // Ensure only one decimal point + const parts = numericValue.split('.') + const filteredValue = + parts.length > 2 ? parts[0] + '.' + parts.slice(1).join('') : numericValue + setAmount(filteredValue) setAmountError(null) }, []) @@ -191,10 +211,22 @@ const SendTokensInput = ({ if (amountWei > currentBalance) { setAmountError('INSUFFICIENT_BALANCE') isValid = false - } else if (amountWei < BigInt(1000)) { - // Minimum amount - setAmountError('AMOUNT_TOO_LOW') - isValid = false + } else { + // Check minimum USD value ($0.50) + const price = + coin?.price === 0 ? coin?.dynamicBondingCurve?.priceUSD : coin?.price + if (price && price > 0) { + const amountNum = parseFloat(amount) + const usdValue = amountNum * price + if (usdValue < 0.5) { + setAmountError('AMOUNT_TOO_LOW') + isValid = false + } + } else if (amountWei < BigInt(1000)) { + // Fallback to minimum token amount if price is not available + setAmountError('AMOUNT_TOO_LOW') + isValid = false + } } } @@ -337,6 +369,17 @@ const SendTokensInput = ({ )} + {usdValueInfo && ( + + ≈ ${usdValueInfo.usdValue.toFixed(2)} USD + {usdValueInfo.isBelowMinimum && ' (minimum $0.50)'} + + )} + {amountError && ( {getErrorText(amountError)} diff --git a/packages/web/src/components/send-tokens-modal/SendTokensModal.tsx b/packages/web/src/components/send-tokens-modal/SendTokensModal.tsx index 3ae6af0e289..cc47f87bc89 100644 --- a/packages/web/src/components/send-tokens-modal/SendTokensModal.tsx +++ b/packages/web/src/components/send-tokens-modal/SendTokensModal.tsx @@ -102,8 +102,11 @@ const SendTokensModal = () => { errorString.includes('0x3') || errorString.includes('custom program error: 0x3') ) { + // This error should no longer occur as we now automatically create + // user-bank accounts in the transaction. If it still occurs, it's likely + // a different issue or the account creation failed. errorMessage = - 'The recipient wallet does not have a token account for this coin. They may need to receive tokens of this type first, or the transaction needs to create the account automatically.' + 'Failed to create recipient token account. Please try again.' } setError(errorMessage)