From 42e537f74b39acfbc1e84809d84309e92329ca3c Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Wed, 24 Sep 2025 14:41:22 -0500 Subject: [PATCH 1/8] Update buy/sell flow on mobile --- packages/mobile/fixes.md | 53 +++ packages/mobile/src/app/App.tsx | 1 + .../components/buy-sell/InputTokenSection.tsx | 35 +- .../buy-sell/OutputTokenSection.tsx | 30 +- .../buy-sell/TokenDropdownSelect.tsx | 54 --- .../components/buy-sell/TokenSelectButton.tsx | 124 ++++++ .../components/buy-sell/TokenSelectItem.tsx | 43 +++ .../src/screens/app-screen/AppScreen.tsx | 7 - .../screens/buy-sell-screen/BuySellFlow.tsx | 353 ++++++++++-------- .../buy-sell-screen/ConfirmSwapScreen.tsx | 31 +- .../buy-sell-screen/components/BuyScreen.tsx | 13 +- .../components/ConvertScreen.tsx | 247 ++++++++++++ .../buy-sell-screen/components/SellScreen.tsx | 13 +- .../buy-sell-screen/components/index.ts | 1 + packages/mobile/src/types/navigation.ts | 9 +- 15 files changed, 756 insertions(+), 258 deletions(-) create mode 100644 packages/mobile/fixes.md delete mode 100644 packages/mobile/src/components/buy-sell/TokenDropdownSelect.tsx create mode 100644 packages/mobile/src/components/buy-sell/TokenSelectButton.tsx create mode 100644 packages/mobile/src/components/buy-sell/TokenSelectItem.tsx create mode 100644 packages/mobile/src/screens/buy-sell-screen/components/ConvertScreen.tsx diff --git a/packages/mobile/fixes.md b/packages/mobile/fixes.md new file mode 100644 index 00000000000..cef8ea48c41 --- /dev/null +++ b/packages/mobile/fixes.md @@ -0,0 +1,53 @@ +# Fix Formik and Navigation Warnings in BuySellFlow - COMPLETE ✅ + +## Issues Fixed + +1. **Formik context warning**: `ListSelectionScreen` → `FormScreen` → `useRevertOnCancel` → `useFormikContext()` expects Formik context, but none is provided when navigating to `CoinSelectScreen` + +2. **Navigation serialization warning**: Passing functions (`onTokenChange`) through React Navigation params isn't supported and causes non-serializable values warning + +## Solution: Modal-based Token Selection + +Switched from navigation-based to modal-based token selection for better control and to avoid React Navigation serialization issues. + +## Changes Made + +### 1. Updated TokenSelectButton to use Modal ✅ WORKING + +- Replaced `navigation.navigate()` with local state management (`useState` for `isVisible`) +- Replaced Portal with React Native `Modal` component with `presentationStyle='pageSheet'` +- Wrapped `ListSelectionScreen` in the Modal for full-screen token selection +- Added proper event handlers (`handleTokenSelect`, `handleClose`) + +### 2. Removed CoinSelectScreen from Navigation ✅ + +- Removed `CoinSelectScreen` route from `AppScreen.tsx` +- Deleted `/screens/coin-select-screen/` directory + +### 3. Added TokenSelectItem Component ✅ + +- Created reusable `TokenSelectItem` component for consistent token display in lists +- Shows token icon, name, and symbol + +## Why Modal Over Portal? + +**Portal approach was initially attempted but abandoned because:** + +- Required complex PortalHost setup at app level +- Conditional portal rendering was unreliable +- Added unnecessary complexity for simple modal behavior + +**Modal approach is better because:** + +- Simple and reliable React Native Modal API +- No additional setup required +- Proper modal presentation with `pageSheet` style +- Avoids navigation serialization warnings +- Avoids Formik context issues by not navigating to Formik-dependent screens + +## Verification + +✅ **Formik context warning**: Resolved - no navigation to Formik-dependent screens +✅ **Navigation serialization warning**: Resolved - no functions passed through navigation params +✅ **Token selection works**: Modal renders and allows token selection +✅ **Clean implementation**: Removed unused navigation routes and files diff --git a/packages/mobile/src/app/App.tsx b/packages/mobile/src/app/App.tsx index 1b1cd8862e6..603e878d21e 100644 --- a/packages/mobile/src/app/App.tsx +++ b/packages/mobile/src/app/App.tsx @@ -87,6 +87,7 @@ const App = () => { + diff --git a/packages/mobile/src/components/buy-sell/InputTokenSection.tsx b/packages/mobile/src/components/buy-sell/InputTokenSection.tsx index 7389b6c0110..a7b87dabbf1 100644 --- a/packages/mobile/src/components/buy-sell/InputTokenSection.tsx +++ b/packages/mobile/src/components/buy-sell/InputTokenSection.tsx @@ -4,13 +4,16 @@ import { useDebouncedCallback } from '@audius/common/hooks' import { buySellMessages as messages } from '@audius/common/messages' import type { TokenInfo } from '@audius/common/store' import { useTokenAmountFormatting } from '@audius/common/store' -import { sanitizeNumericInput } from '@audius/common/utils' +import { + sanitizeNumericInput, + formatTokenInputWithSmartDecimals +} from '@audius/common/utils' import { Button, Flex, Text, TextInput, useTheme } from '@audius/harmony-native' import { TokenIcon } from '../core' -import { TokenDropdownSelect } from './TokenDropdownSelect' +import { TokenSelectButton } from './TokenSelectButton' import { TooltipInfoIcon } from './TooltipInfoIcon' type InputTokenSectionProps = { @@ -28,6 +31,7 @@ type InputTokenSectionProps = { isTokenPriceLoading?: boolean tokenPriceDecimalPlaces?: number availableTokens?: TokenInfo[] + onTokenChange?: (token: TokenInfo) => void } export const InputTokenSection = ({ @@ -41,7 +45,8 @@ export const InputTokenSection = ({ placeholder = '0.00', error, errorMessage, - availableTokens + availableTokens, + onTokenChange }: InputTokenSectionProps) => { const { logoURI } = tokenInfo const { iconSizes } = useTheme() @@ -61,9 +66,12 @@ export const InputTokenSection = ({ const displayTokenDropdown = availableTokens && availableTokens.length > 0 - // Sync local state with prop changes + // Sync local state with prop changes and apply smart decimal formatting useEffect(() => { - setLocalAmount(amount || '') + const formattedAmount = amount + ? formatTokenInputWithSmartDecimals(amount) + : '' + setLocalAmount(formattedAmount) }, [amount]) const debouncedOnAmountChange = useDebouncedCallback( @@ -75,8 +83,9 @@ export const InputTokenSection = ({ const handleTextChange = useCallback( (text: string) => { const sanitizedText = sanitizeNumericInput(text) - setLocalAmount(sanitizedText) - debouncedOnAmountChange(sanitizedText) + const formattedText = formatTokenInputWithSmartDecimals(sanitizedText) + setLocalAmount(formattedText) + debouncedOnAmountChange(formattedText) }, [debouncedOnAmountChange] ) @@ -106,16 +115,18 @@ export const InputTokenSection = ({ - {displayTokenDropdown ? ( + {displayTokenDropdown && onTokenChange ? ( - ) : null} - {!displayTokenDropdown ? ( + {!displayTokenDropdown || !onTokenChange ? ( - {displayTokenDropdown ? ( + {displayTokenDropdown && onTokenChange ? ( { const { symbol, isStablecoin } = tokenInfo const [localAmount, setLocalAmount] = useState(amount || '') - // Sync local state with prop changes + // Sync local state with prop changes and apply smart decimal formatting useEffect(() => { - setLocalAmount(amount || '') + const formattedAmount = amount + ? formatTokenInputWithSmartDecimals(amount) + : '' + setLocalAmount(formattedAmount) }, [amount]) const debouncedOnAmountChange = useDebouncedCallback( @@ -49,8 +56,9 @@ export const OutputTokenSection = ({ const handleTextChange = useCallback( (text: string) => { const sanitizedText = sanitizeNumericInput(text) - setLocalAmount(sanitizedText) - debouncedOnAmountChange(sanitizedText) + const formattedText = formatTokenInputWithSmartDecimals(sanitizedText) + setLocalAmount(formattedText) + debouncedOnAmountChange(formattedText) }, [debouncedOnAmountChange] ) @@ -76,11 +84,13 @@ export const OutputTokenSection = ({ error={error} /> - {availableTokens && availableTokens.length > 0 && ( + {availableTokens && availableTokens.length > 0 && onTokenChange && ( - )} diff --git a/packages/mobile/src/components/buy-sell/TokenDropdownSelect.tsx b/packages/mobile/src/components/buy-sell/TokenDropdownSelect.tsx deleted file mode 100644 index f1a2bec27e4..00000000000 --- a/packages/mobile/src/components/buy-sell/TokenDropdownSelect.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useCallback } from 'react' - -import type { TokenInfo } from '@audius/common/store' -import { TouchableOpacity } from 'react-native' - -import { Text, Flex, IconCaretDown } from '@audius/harmony-native' -import { useNavigation } from 'app/hooks/useNavigation' - -import { TokenIcon } from '../core' - -type TokenDropdownSelectProps = { - selectedToken: TokenInfo - navigationRoute: string -} - -export const TokenDropdownSelect = ({ - selectedToken, - navigationRoute -}: TokenDropdownSelectProps) => { - const navigation = useNavigation() - - const handlePressButton = useCallback(() => { - navigation.navigate(navigationRoute) - }, [navigation, navigationRoute]) - - if (!selectedToken) { - return null - } - - return ( - - - - {selectedToken ? ( - - ) : null} - - {selectedToken.symbol} - - - - - - ) -} diff --git a/packages/mobile/src/components/buy-sell/TokenSelectButton.tsx b/packages/mobile/src/components/buy-sell/TokenSelectButton.tsx new file mode 100644 index 00000000000..e1170bced70 --- /dev/null +++ b/packages/mobile/src/components/buy-sell/TokenSelectButton.tsx @@ -0,0 +1,124 @@ +import React, { useCallback, useMemo, useState } from 'react' + +import type { TokenInfo } from '@audius/common/store' +import { Modal, TouchableOpacity } from 'react-native' + +import { Text, Flex, IconCaretDown } from '@audius/harmony-native' +import type { ListSelectionData } from 'app/screens/list-selection-screen' +import { ListSelectionScreen } from 'app/screens/list-selection-screen' + +import { TokenIcon } from '../core' + +import { TokenSelectItem } from './TokenSelectItem' + +type TokenSelectButtonProps = { + selectedToken: TokenInfo + availableTokens: TokenInfo[] + onTokenChange: (token: TokenInfo) => void + title: string +} + +export const TokenSelectButton = ({ + selectedToken, + availableTokens, + onTokenChange, + title +}: TokenSelectButtonProps) => { + const [isVisible, setIsVisible] = useState(false) + const [tempSelectedToken, setTempSelectedToken] = useState('') + + // Create token options map for quick lookup + const tokensMap: { [key: string]: TokenInfo } = useMemo( + () => + availableTokens.reduce( + (acc, token) => { + acc[token.symbol] = token + return acc + }, + {} as { [key: string]: TokenInfo } + ), + [availableTokens] + ) + + const handlePress = useCallback(() => { + setTempSelectedToken(selectedToken.symbol) + setIsVisible(true) + }, [selectedToken.symbol]) + + const handleSubmit = useCallback(() => { + const token = tokensMap[tempSelectedToken] + if (token) { + onTokenChange(token) + } + setIsVisible(false) + }, [tempSelectedToken, tokensMap, onTokenChange]) + + // Convert tokens to selection data format + const tokenOptions: ListSelectionData[] = useMemo( + () => + availableTokens.map((token) => ({ + label: token.name, + value: token.symbol + })), + [availableTokens] + ) + + const handleTokenSelect = useCallback((value: string) => { + setTempSelectedToken(value) + }, []) + + const renderItem = useCallback( + ({ item }: { item: ListSelectionData }) => { + const token = tokensMap[item.value] + return + }, + [tokensMap] + ) + + if (!selectedToken) { + return null + } + + return ( + <> + + + + + + {selectedToken.symbol} + + + + + + + + + + ) +} diff --git a/packages/mobile/src/components/buy-sell/TokenSelectItem.tsx b/packages/mobile/src/components/buy-sell/TokenSelectItem.tsx new file mode 100644 index 00000000000..fe4a957697f --- /dev/null +++ b/packages/mobile/src/components/buy-sell/TokenSelectItem.tsx @@ -0,0 +1,43 @@ +import React from 'react' + +import type { TokenInfo } from '@audius/common/store' + +import { Flex, Text } from '@audius/harmony-native' +import type { ListSelectionData } from 'app/screens/list-selection-screen' + +import { TokenIcon } from '../core' + +type TokenSelectItemProps = { + token: TokenInfo + item: ListSelectionData +} + +export const TokenSelectItem = ({ token, item }: TokenSelectItemProps) => { + return ( + + + + + + + {token.name} + + + {token.symbol} + + + + ) +} diff --git a/packages/mobile/src/screens/app-screen/AppScreen.tsx b/packages/mobile/src/screens/app-screen/AppScreen.tsx index f01dcdba811..8b861015220 100644 --- a/packages/mobile/src/screens/app-screen/AppScreen.tsx +++ b/packages/mobile/src/screens/app-screen/AppScreen.tsx @@ -1,7 +1,6 @@ import { useCallback } from 'react' import { MobileOS } from '@audius/common/models' -import { PortalHost } from '@gorhom/portal' import { createNativeStackNavigator } from '@react-navigation/native-stack' import { Platform } from 'react-native' @@ -73,12 +72,6 @@ export const AppScreen = () => { name='ChangePassword' component={ChangePasswordModalScreen} /> - - {() => } - ) diff --git a/packages/mobile/src/screens/buy-sell-screen/BuySellFlow.tsx b/packages/mobile/src/screens/buy-sell-screen/BuySellFlow.tsx index 23610eadc89..a96a38d71c7 100644 --- a/packages/mobile/src/screens/buy-sell-screen/BuySellFlow.tsx +++ b/packages/mobile/src/screens/buy-sell-screen/BuySellFlow.tsx @@ -1,30 +1,33 @@ -import React, { useState, useEffect, useMemo, useCallback } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useTokenPair, useTokens } from '@audius/common/api' -import { useBuySellAnalytics, useOwnedTokens } from '@audius/common/hooks' +import { + useBuySellAnalytics, + useFeatureFlag, + useOwnedTokens +} from '@audius/common/hooks' import { buySellMessages as messages } from '@audius/common/messages' +import { FeatureFlags } from '@audius/common/services' import type { BuySellTab, TokenInfo } from '@audius/common/store' import { + AUDIO_TICKER, + getSwapTokens, + useAddCashModal, useBuySellScreen, useBuySellSwap, useBuySellTabs, useBuySellTransactionData, + useCurrentTokenPair, useSwapDisplayData, - useAddCashModal, - getSwapTokens, - AUDIO_TICKER, - USDC_SYMBOL + useTokenStates } from '@audius/common/store' -import { Portal } from '@gorhom/portal' import { useFocusEffect } from '@react-navigation/native' -import { Button, Flex, Hint, Text, TextLink } from '@audius/harmony-native' -import { SegmentedControl, TokenIcon } from 'app/components/core' +import { Button, Flex, Hint, TextLink } from '@audius/harmony-native' +import { SegmentedControl } from 'app/components/core' import { useNavigation } from 'app/hooks/useNavigation' -import type { ListSelectionData } from 'app/screens/list-selection-screen' -import { ListSelectionScreen } from 'app/screens/list-selection-screen' -import { BuyScreen, SellScreen } from './components' +import { BuyScreen, ConvertScreen, SellScreen } from './components' type BuySellFlowProps = { onClose: () => void @@ -32,34 +35,6 @@ type BuySellFlowProps = { coinTicker?: string } -const WALLET_GUIDE_URL = 'https://help.audius.co/product/wallet-guide' - -const TokenSelectItem = ({ - token, - item -}: { - token: TokenInfo - item: ListSelectionData -}) => { - return ( - - - - - - {item.label} - - - ) -} - export const BuySellFlow = ({ onClose, initialTab = 'buy', @@ -68,24 +43,19 @@ export const BuySellFlow = ({ const navigation = useNavigation() const { onOpen: openAddCashModal } = useAddCashModal() const { trackSwapRequested, trackAddFundsClicked } = useBuySellAnalytics() - const [selectedBaseToken, setSelectedBaseToken] = useState(coinTicker) - const [selectedQuoteToken] = useState(USDC_SYMBOL) + const { isEnabled: isArtistCoinsEnabled } = useFeatureFlag( + FeatureFlags.ARTIST_COINS + ) + // Get token pair for the initial coin, fallback to AUDIO/USDC + const { data: selectedPair } = useTokenPair({ + baseSymbol: coinTicker, + quoteSymbol: 'USDC' + }) const { currentScreen, setCurrentScreen } = useBuySellScreen({ initialScreen: 'input' }) - const { tokens } = useTokens() - - // Get all available tokens and owned tokens - const allAvailableTokens: TokenInfo[] = useMemo(() => { - return Object.values(tokens).filter( - (token) => token.symbol !== selectedQuoteToken - ) - }, [tokens, selectedQuoteToken]) - - const { ownedTokens } = useOwnedTokens(allAvailableTokens) - const { transactionData, hasSufficientBalance, @@ -99,39 +69,58 @@ export const BuySellFlow = ({ initialTab }) - // Determine which tokens to show based on current tab - // For buy tab: "You Receive" section should show all tokens - // For sell tab: "You Sell" section should show only owned tokens - const availableBaseTokens: TokenInfo[] = useMemo(() => { - if (activeTab === 'sell') { - // In sell tab, the input section should only show owned tokens - return ownedTokens - } else { - // In buy tab, the output section should show all tokens - return allAvailableTokens - } - }, [activeTab, ownedTokens, allAvailableTokens]) - - const baseTokenOptions = useMemo( - () => - availableBaseTokens.map((token) => ({ - label: token.symbol, - value: token.symbol - })), - [availableBaseTokens] + // Use custom hooks for token state management + const { + getCurrentTabTokens, + handleInputTokenChange: handleInputTokenChangeInternal, + handleOutputTokenChange: handleOutputTokenChangeInternal, + handleSwapDirection + } = useTokenStates(selectedPair) + + // Get current tab's token symbols + const currentTabTokens = getCurrentTabTokens(activeTab) + const baseTokenSymbol = currentTabTokens.baseToken + const quoteTokenSymbol = currentTabTokens.quoteToken + + const { tokens, isLoading: tokensLoading } = useTokens() + + // Get all available tokens + const availableTokens: TokenInfo[] = useMemo(() => { + return tokensLoading ? [] : Object.values(tokens) + }, [tokens, tokensLoading]) + + const { ownedTokens } = useOwnedTokens(availableTokens) + + // Create a helper to check if user has positive balance for a token + const hasPositiveBalance = useCallback( + (tokenAddress: string): boolean => { + return ownedTokens.some((token) => token.address === tokenAddress) + }, + [ownedTokens] ) - const baseTokensMap: { [key: string]: TokenInfo } = useMemo( - () => - availableBaseTokens.reduce( - (acc, token) => { - acc[token.symbol] = token - return acc - }, - {} as { [key: string]: TokenInfo } - ), - [availableBaseTokens] - ) + // Create filtered token lists for each tab (unified filtering approach) + const availableInputTokensForSell = useMemo(() => { + return availableTokens.filter( + (t) => + t.symbol !== baseTokenSymbol && + t.symbol !== 'USDC' && + hasPositiveBalance(t.address) + ) + }, [availableTokens, baseTokenSymbol, hasPositiveBalance]) + + const availableInputTokensForConvert = useMemo(() => { + return availableTokens.filter( + (t) => + t.symbol !== baseTokenSymbol && + t.symbol !== quoteTokenSymbol && + hasPositiveBalance(t.address) + ) + }, [availableTokens, baseTokenSymbol, quoteTokenSymbol, hasPositiveBalance]) + + const availableOutputTokensForConvert = useMemo(() => { + return availableTokens.filter((t) => t.symbol !== baseTokenSymbol) + }, [availableTokens, baseTokenSymbol]) // Reset screen state to 'input' when this screen comes into focus // This handles the case where we navigate back from ConfirmSwapScreen @@ -166,18 +155,79 @@ export const BuySellFlow = ({ })) } - // Get token pair for the specific coin with USDC, fallback to AUDIO/USDC - const { data: selectedPair } = useTokenPair({ - baseSymbol: coinTicker, - quoteSymbol: 'USDC' + // Update input value for convert tab + const handleConvertInputValueChange = (value: string) => { + setTabInputValues((prev) => ({ + ...prev, + convert: value + })) + } + + // Handle token changes with transaction reset + const handleInputTokenChange = useCallback( + (symbol: string) => { + handleInputTokenChangeInternal(symbol, activeTab) + resetTransactionData() + }, + [handleInputTokenChangeInternal, activeTab, resetTransactionData] + ) + + const handleOutputTokenChange = useCallback( + (symbol: string) => { + handleOutputTokenChangeInternal(symbol, activeTab) + resetTransactionData() + }, + [handleOutputTokenChangeInternal, activeTab, resetTransactionData] + ) + + // Handle swap direction change for convert tab + const handleChangeSwapDirection = useCallback(() => { + handleSwapDirection(activeTab) + resetTransactionData() + }, [handleSwapDirection, activeTab, resetTransactionData]) + + // Get token pair for the current tab's tokens using the same approach as web + const currentTabTokenPair = useCurrentTokenPair({ + baseTokenSymbol, + quoteTokenSymbol, + availableTokens, + selectedPair }) + // Create a safe selectedPair for hooks that can't handle null values (same as web) + const safeSelectedPair = useMemo(() => { + if (currentTabTokenPair?.baseToken && currentTabTokenPair?.quoteToken) { + return currentTabTokenPair + } + + // Return minimal safe token pair to prevent hook crashes + return { + baseToken: { + symbol: 'AUDIO', + name: 'Audius', + decimals: 8, + balance: null, + address: '', + isStablecoin: false + }, + quoteToken: { + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + balance: null, + address: '', + isStablecoin: true + }, + exchangeRate: null + } + }, [currentTabTokenPair]) + const { handleShowConfirmation, isContinueButtonLoading } = useBuySellSwap({ transactionData, currentScreen, setCurrentScreen, activeTab, - selectedPair, + selectedPair: safeSelectedPair, onClose }) @@ -185,8 +235,8 @@ export const BuySellFlow = ({ const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false) const swapTokens = useMemo( - () => getSwapTokens(activeTab, selectedPair), - [activeTab, selectedPair] + () => getSwapTokens(activeTab, safeSelectedPair), + [activeTab, safeSelectedPair] ) const currentExchangeRate = useMemo( @@ -200,13 +250,21 @@ export const BuySellFlow = ({ transactionData, swapResult: null, // Not needed in this component activeTab, - selectedPair + selectedPair: safeSelectedPair }) - const tabs = [ - { key: 'buy' as BuySellTab, text: messages.buy }, - { key: 'sell' as BuySellTab, text: messages.sell } - ] + const tabs = useMemo(() => { + const baseTabs = [ + { key: 'buy' as BuySellTab, text: messages.buy }, + { key: 'sell' as BuySellTab, text: messages.sell } + ] + + if (isArtistCoinsEnabled) { + baseTabs.push({ key: 'convert' as BuySellTab, text: messages.convert }) + } + + return baseTabs + }, [isArtistCoinsEnabled]) const handleContinueClick = useCallback(() => { setHasAttemptedSubmit(true) @@ -225,21 +283,26 @@ export const BuySellFlow = ({ // Navigate to confirmation screen with the data if (confirmationScreenData) { navigation.navigate('ConfirmSwapScreen', { - confirmationData: confirmationScreenData + confirmationData: confirmationScreenData, + activeTab, + selectedPair: safeSelectedPair }) } } }, [ - setHasAttemptedSubmit, + transactionData?.isValid, + transactionData?.inputAmount, + transactionData?.outputAmount, isContinueButtonLoading, + trackSwapRequested, + activeTab, + swapTokens.inputToken, + swapTokens.outputToken, + currentExchangeRate, handleShowConfirmation, confirmationScreenData, navigation, - activeTab, - transactionData, - swapTokens, - currentExchangeRate, - trackSwapRequested + safeSelectedPair ]) useEffect(() => { @@ -278,22 +341,6 @@ export const BuySellFlow = ({ transactionData ]) - const [selectedBaseTokenValue, setSelectedBaseTokenValue] = useState( - selectedBaseToken ?? '' - ) - const handleBaseTokenValChange = useCallback( - (value: string) => { - setSelectedBaseTokenValue(value ?? '') - }, - [setSelectedBaseTokenValue] - ) - - const handleBaseTokenChange = useCallback(() => { - if (selectedBaseTokenValue !== selectedBaseToken) { - setSelectedBaseToken(selectedBaseTokenValue) - } - }, [selectedBaseToken, selectedBaseTokenValue]) - const handleAddCash = useCallback(() => { openAddCashModal() trackAddFundsClicked('insufficient_balance_hint') @@ -303,7 +350,6 @@ export const BuySellFlow = ({ !!displayErrorMessage || (activeTab === 'buy' && !hasSufficientBalance && !!tabInputValues.buy) - // Always show the input screen in mobile - other screens are separate return { content: ( @@ -319,9 +365,9 @@ export const BuySellFlow = ({ {/* Tab Content */} - {selectedPair ? ( + {safeSelectedPair ? ( + handleOutputTokenChange(token.symbol) + } + availableOutputTokens={availableTokens.filter( + (t) => t.symbol !== quoteTokenSymbol && t.symbol !== 'USDC' + )} /> ) : null} - {selectedPair ? ( + {safeSelectedPair ? ( + handleInputTokenChange(token.symbol) + } + availableInputTokens={availableInputTokensForSell} + /> + ) : null} + + + {safeSelectedPair ? ( + ) : null} - - {/* Portal for base token select */} - - { - const token = baseTokensMap[item.value] - return - }} - onChange={handleBaseTokenValChange} - onSubmit={handleBaseTokenChange} - clearable={false} - /> - {/* Insufficient Balance Message for Buy */} {activeTab === 'buy' && @@ -379,16 +438,6 @@ export const BuySellFlow = ({ {messages.insufficientUSDC} )} - - {/* Help Center Hint */} - {hasSufficientBalance && ( - - {messages.helpCenter}{' '} - - {messages.walletGuide} - - - )} ), footer: ( diff --git a/packages/mobile/src/screens/buy-sell-screen/ConfirmSwapScreen.tsx b/packages/mobile/src/screens/buy-sell-screen/ConfirmSwapScreen.tsx index ae301a75170..41791745a10 100644 --- a/packages/mobile/src/screens/buy-sell-screen/ConfirmSwapScreen.tsx +++ b/packages/mobile/src/screens/buy-sell-screen/ConfirmSwapScreen.tsx @@ -1,19 +1,15 @@ -import React, { useCallback, useEffect, useMemo } from 'react' +import { useCallback, useEffect, useMemo } from 'react' -import { - formatUSDCValue, - SLIPPAGE_BPS, - useDefaultTokenPair -} from '@audius/common/api' +import { formatUSDCValue, SLIPPAGE_BPS } from '@audius/common/api' import { useBuySellAnalytics } from '@audius/common/hooks' import { buySellMessages as baseMessages } from '@audius/common/messages' -import type { TokenInfo } from '@audius/common/store' +import type { TokenInfo, TokenPair } from '@audius/common/store' import { + getSwapTokens, useBuySellScreen, useBuySellSwap, useSwapDisplayData, - useTokenAmountFormatting, - getSwapTokens + useTokenAmountFormatting } from '@audius/common/store' import { @@ -25,10 +21,10 @@ import { Text } from '@audius/harmony-native' import { - Screen, - ScreenContent, FixedFooter, - FixedFooterContent + FixedFooterContent, + Screen, + ScreenContent } from 'app/components/core' import { useNavigation } from 'app/hooks/useNavigation' @@ -58,6 +54,8 @@ type ConfirmSwapScreenProps = { baseTokenSymbol: string exchangeRate?: number | null } + activeTab: 'buy' | 'sell' | 'convert' + selectedPair: TokenPair } } } @@ -103,7 +101,9 @@ export const ConfirmSwapScreen = ({ route }: ConfirmSwapScreenProps) => { pricePerBaseToken, baseTokenSymbol, exchangeRate = null - } + }, + activeTab, + selectedPair } = route.params const stableOnScreenChange = useCallback(() => { @@ -129,11 +129,6 @@ export const ConfirmSwapScreen = ({ route }: ConfirmSwapScreenProps) => { [payAmount, receiveAmount] ) - // Determine if this is a buy or sell based on token types - const activeTab = payTokenInfo.symbol === 'USDC' ? 'buy' : 'sell' - - const { data: selectedPair } = useDefaultTokenPair() - const { handleConfirmSwap, isConfirmButtonLoading, diff --git a/packages/mobile/src/screens/buy-sell-screen/components/BuyScreen.tsx b/packages/mobile/src/screens/buy-sell-screen/components/BuyScreen.tsx index fc132d5b4f3..66c03c1b961 100644 --- a/packages/mobile/src/screens/buy-sell-screen/components/BuyScreen.tsx +++ b/packages/mobile/src/screens/buy-sell-screen/components/BuyScreen.tsx @@ -64,6 +64,8 @@ type BuyScreenProps = { errorMessage?: string initialInputValue?: string onInputValueChange?: (value: string) => void + onOutputTokenChange?: (token: TokenInfo) => void + availableOutputTokens?: TokenInfo[] } export const BuyScreen = ({ @@ -72,7 +74,9 @@ export const BuyScreen = ({ error, errorMessage, initialInputValue, - onInputValueChange + onInputValueChange, + onOutputTokenChange, + availableOutputTokens: propAvailableOutputTokens }: BuyScreenProps) => { const { data: tokenPriceData, isPending: isTokenPriceLoading } = useArtistCoin(tokenPair?.baseToken?.address) @@ -103,10 +107,14 @@ export const BuyScreen = ({ }) const { data: coins } = useArtistCoins() - const availableOutputTokens: TokenInfo[] = useMemo(() => { + const localAvailableOutputTokens: TokenInfo[] = useMemo(() => { return Object.values(transformArtistCoinsToTokenInfoMap(coins ?? [])) }, [coins]) + // Use prop if provided, otherwise use local data + const availableOutputTokens = + propAvailableOutputTokens || localAvailableOutputTokens + // Track if an exchange rate has ever been successfully fetched const hasRateEverBeenFetched = useRef(false) if (currentExchangeRate !== null) { @@ -149,6 +157,7 @@ export const BuyScreen = ({ isTokenPriceLoading={isTokenPriceLoading} tokenPriceDecimalPlaces={decimalPlaces} availableTokens={availableOutputTokens} + onTokenChange={onOutputTokenChange} /> )} diff --git a/packages/mobile/src/screens/buy-sell-screen/components/ConvertScreen.tsx b/packages/mobile/src/screens/buy-sell-screen/components/ConvertScreen.tsx new file mode 100644 index 00000000000..e88a103adf1 --- /dev/null +++ b/packages/mobile/src/screens/buy-sell-screen/components/ConvertScreen.tsx @@ -0,0 +1,247 @@ +import React, { useCallback, useMemo, useRef } from 'react' + +import { + transformArtistCoinsToTokenInfoMap, + useArtistCoin, + useArtistCoins +} from '@audius/common/api' +import { buySellMessages } from '@audius/common/messages' +import type { TokenInfo, TokenPair } from '@audius/common/store' +import { useTokenSwapForm } from '@audius/common/store' +import { getCurrencyDecimalPlaces } from '@audius/common/utils' + +import { + Box, + Flex, + IconButton, + IconTransaction, + Skeleton, + Divider +} from '@audius/harmony-native' +import { InputTokenSection } from 'app/components/buy-sell/InputTokenSection' +import { OutputTokenSection } from 'app/components/buy-sell/OutputTokenSection' + +const YouPaySkeleton = () => ( + + + + + + + + + + + + + +) + +const YouReceiveSkeleton = () => ( + + + + + + + + + + + +) + +const SwapFormSkeleton = () => ( + + + + +) + +type ConvertScreenProps = { + tokenPair: TokenPair + onTransactionDataChange?: (data: { + inputAmount: number + outputAmount: number + isValid: boolean + error: string | null + isInsufficientBalance: boolean + }) => void + error?: boolean + errorMessage?: string + initialInputValue?: string + onInputValueChange?: (value: string) => void + availableInputTokens?: TokenInfo[] + availableOutputTokens?: TokenInfo[] + onInputTokenChange?: (symbol: string) => void + onOutputTokenChange?: (symbol: string) => void + onChangeSwapDirection?: () => void +} + +export const ConvertScreen = ({ + tokenPair, + onTransactionDataChange, + error, + errorMessage, + initialInputValue, + onInputValueChange, + availableInputTokens, + availableOutputTokens, + onInputTokenChange, + onOutputTokenChange, + onChangeSwapDirection +}: ConvertScreenProps) => { + const { baseToken, quoteToken } = tokenPair + + // Use tokens from the tokenPair prop instead of local state + const selectedInputToken = baseToken + const selectedOutputToken = quoteToken + + const { data: tokenPriceData, isPending: isTokenPriceLoading } = + useArtistCoin(selectedOutputToken?.address ?? '') + + const tokenPrice = tokenPriceData?.price?.toString() ?? null + + const decimalPlaces = useMemo(() => { + if (!tokenPrice) return 2 + return getCurrencyDecimalPlaces(parseFloat(tokenPrice)) + }, [tokenPrice]) + + const { + inputAmount, + outputAmount, + isExchangeRateLoading, + isBalanceLoading, + availableBalance, + currentExchangeRate, + handleInputAmountChange, + handleOutputAmountChange, + handleMaxClick + } = useTokenSwapForm({ + inputToken: selectedInputToken, + outputToken: selectedOutputToken, + onTransactionDataChange, + initialInputValue, + onInputValueChange + }) + + const { data: coins } = useArtistCoins() + const artistCoins: TokenInfo[] = useMemo(() => { + return Object.values(transformArtistCoinsToTokenInfoMap(coins ?? [])) + }, [coins]) + + const totalAvailableTokens = useMemo(() => { + return [...(availableOutputTokens ?? []), ...artistCoins].filter( + (token, index, arr) => + arr.findIndex((t) => t.symbol === token.symbol) === index + ) // Remove duplicates + }, [availableOutputTokens, artistCoins]) + + // Filter out the currently selected input token from available output tokens + const filteredAvailableOutputTokens = useMemo(() => { + return totalAvailableTokens.filter( + (token) => token.symbol !== selectedInputToken?.symbol + ) + }, [totalAvailableTokens, selectedInputToken?.symbol]) + + const handleInputTokenChange = useCallback( + (token: TokenInfo) => { + onInputTokenChange?.(token.symbol) + + // If there are only 2 total available tokens, automatically set the other token + if (totalAvailableTokens.length === 2) { + const otherToken = totalAvailableTokens.find( + (t) => t.symbol !== token.symbol + ) + if (otherToken) { + onOutputTokenChange?.(otherToken.symbol) + } + } + }, + [onInputTokenChange, onOutputTokenChange, totalAvailableTokens] + ) + + const handleOutputTokenChange = useCallback( + (token: TokenInfo) => { + onOutputTokenChange?.(token.symbol) + + // If there are only 2 total available tokens, automatically set the other token + if (totalAvailableTokens.length === 2) { + const otherToken = totalAvailableTokens.find( + (t) => t.symbol !== token.symbol + ) + if (otherToken) { + onInputTokenChange?.(otherToken.symbol) + } + } + }, + [onInputTokenChange, onOutputTokenChange, totalAvailableTokens] + ) + + // Track if an exchange rate has ever been successfully fetched + const hasRateEverBeenFetched = useRef(false) + if (currentExchangeRate !== null) { + hasRateEverBeenFetched.current = true + } + + if (!tokenPair) return null + + // Show initial loading state if balance is loading, + // OR if exchange rate is loading AND we've never fetched a rate before. + const isInitialLoading = + isBalanceLoading || + (isExchangeRateLoading && !hasRateEverBeenFetched.current) + + return ( + + {isInitialLoading ? ( + + ) : ( + <> + + + {/* Swap Direction Divider */} + + + + + + + + + + + + + )} + + ) +} diff --git a/packages/mobile/src/screens/buy-sell-screen/components/SellScreen.tsx b/packages/mobile/src/screens/buy-sell-screen/components/SellScreen.tsx index 0cb78edd96a..643ea2b6330 100644 --- a/packages/mobile/src/screens/buy-sell-screen/components/SellScreen.tsx +++ b/packages/mobile/src/screens/buy-sell-screen/components/SellScreen.tsx @@ -67,6 +67,8 @@ type SellScreenProps = { errorMessage?: string initialInputValue?: string onInputValueChange?: (value: string) => void + onInputTokenChange?: (token: TokenInfo) => void + availableInputTokens?: TokenInfo[] } export const SellScreen = ({ @@ -75,7 +77,9 @@ export const SellScreen = ({ error, errorMessage, initialInputValue, - onInputValueChange + onInputValueChange, + onInputTokenChange, + availableInputTokens: propAvailableInputTokens }: SellScreenProps) => { const { data: tokenPriceData } = useArtistCoin(tokenPair?.baseToken?.address) @@ -116,7 +120,11 @@ export const SellScreen = ({ // For sell screen: // - "You Sell" section (input): Should show only tokens the user owns // - "You Receive" section (output): Should show all available tokens (but only USDC in practice) - const availableInputTokens = ownedTokens + const localAvailableInputTokens = ownedTokens + + // Use prop if provided, otherwise use local data + const availableInputTokens = + propAvailableInputTokens || localAvailableInputTokens if (!tokenPair) return null @@ -145,6 +153,7 @@ export const SellScreen = ({ error={error} errorMessage={errorMessage} availableTokens={availableInputTokens} + onTokenChange={onInputTokenChange} /> Date: Wed, 24 Sep 2025 14:46:30 -0500 Subject: [PATCH 2/8] Remove md --- packages/mobile/fixes.md | 53 ---------------------------------------- 1 file changed, 53 deletions(-) delete mode 100644 packages/mobile/fixes.md diff --git a/packages/mobile/fixes.md b/packages/mobile/fixes.md deleted file mode 100644 index cef8ea48c41..00000000000 --- a/packages/mobile/fixes.md +++ /dev/null @@ -1,53 +0,0 @@ -# Fix Formik and Navigation Warnings in BuySellFlow - COMPLETE ✅ - -## Issues Fixed - -1. **Formik context warning**: `ListSelectionScreen` → `FormScreen` → `useRevertOnCancel` → `useFormikContext()` expects Formik context, but none is provided when navigating to `CoinSelectScreen` - -2. **Navigation serialization warning**: Passing functions (`onTokenChange`) through React Navigation params isn't supported and causes non-serializable values warning - -## Solution: Modal-based Token Selection - -Switched from navigation-based to modal-based token selection for better control and to avoid React Navigation serialization issues. - -## Changes Made - -### 1. Updated TokenSelectButton to use Modal ✅ WORKING - -- Replaced `navigation.navigate()` with local state management (`useState` for `isVisible`) -- Replaced Portal with React Native `Modal` component with `presentationStyle='pageSheet'` -- Wrapped `ListSelectionScreen` in the Modal for full-screen token selection -- Added proper event handlers (`handleTokenSelect`, `handleClose`) - -### 2. Removed CoinSelectScreen from Navigation ✅ - -- Removed `CoinSelectScreen` route from `AppScreen.tsx` -- Deleted `/screens/coin-select-screen/` directory - -### 3. Added TokenSelectItem Component ✅ - -- Created reusable `TokenSelectItem` component for consistent token display in lists -- Shows token icon, name, and symbol - -## Why Modal Over Portal? - -**Portal approach was initially attempted but abandoned because:** - -- Required complex PortalHost setup at app level -- Conditional portal rendering was unreliable -- Added unnecessary complexity for simple modal behavior - -**Modal approach is better because:** - -- Simple and reliable React Native Modal API -- No additional setup required -- Proper modal presentation with `pageSheet` style -- Avoids navigation serialization warnings -- Avoids Formik context issues by not navigating to Formik-dependent screens - -## Verification - -✅ **Formik context warning**: Resolved - no navigation to Formik-dependent screens -✅ **Navigation serialization warning**: Resolved - no functions passed through navigation params -✅ **Token selection works**: Modal renders and allows token selection -✅ **Clean implementation**: Removed unused navigation routes and files From 43d7f83e013328792a560309b9b396fad421a8bc Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Wed, 24 Sep 2025 14:51:44 -0500 Subject: [PATCH 3/8] Comment --- packages/mobile/src/types/navigation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mobile/src/types/navigation.ts b/packages/mobile/src/types/navigation.ts index 4abb235a923..525e7e85f84 100644 --- a/packages/mobile/src/types/navigation.ts +++ b/packages/mobile/src/types/navigation.ts @@ -5,7 +5,7 @@ import type { } from '@audius/common/store' export type BuySellScreenParams = { - initialTab?: 'buy' | 'sell' + initialTab?: 'buy' | 'sell' | 'convert' coinTicker?: string } From 88e8a260438cee53698281ed1fc8c5da725c9b0d Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Wed, 24 Sep 2025 14:56:06 -0500 Subject: [PATCH 4/8] Update skeleton --- .../wallet-screen/components/CoinCard.tsx | 4 +-- .../wallet-screen/components/YourCoins.tsx | 29 ++++--------------- 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/packages/mobile/src/screens/wallet-screen/components/CoinCard.tsx b/packages/mobile/src/screens/wallet-screen/components/CoinCard.tsx index 013145eee91..c98476df545 100644 --- a/packages/mobile/src/screens/wallet-screen/components/CoinCard.tsx +++ b/packages/mobile/src/screens/wallet-screen/components/CoinCard.tsx @@ -16,7 +16,7 @@ import { useNavigation } from 'app/hooks/useNavigation' const ICON_SIZE = 64 -const CoinCardSkeleton = () => { +export const CoinCardSkeleton = () => { return ( @@ -29,7 +29,7 @@ const CoinCardSkeleton = () => { ) } -const HexagonalSkeleton = () => { +export const HexagonalSkeleton = () => { return ( diff --git a/packages/mobile/src/screens/wallet-screen/components/YourCoins.tsx b/packages/mobile/src/screens/wallet-screen/components/YourCoins.tsx index 614f1fbb4c7..7fc421c60f3 100644 --- a/packages/mobile/src/screens/wallet-screen/components/YourCoins.tsx +++ b/packages/mobile/src/screens/wallet-screen/components/YourCoins.tsx @@ -11,18 +11,10 @@ import { FeatureFlags } from '@audius/common/services' import { AUDIO_TICKER } from '@audius/common/store' import { ownedCoinsFilter } from '@audius/common/utils' -import { - Box, - Button, - Divider, - Flex, - Paper, - Skeleton, - Text -} from '@audius/harmony-native' +import { Box, Button, Divider, Flex, Paper, Text } from '@audius/harmony-native' import { useNavigation } from 'app/hooks/useNavigation' -import { CoinCard } from './CoinCard' +import { CoinCard, CoinCardSkeleton, HexagonalSkeleton } from './CoinCard' const messages = { ...buySellMessages @@ -30,19 +22,10 @@ const messages = { const YourCoinsSkeleton = () => { return ( - - - - - - - - - - - - - + + + + ) From 7c2cff0df2f757a3f1397cc0563364d0c92bda3f Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Wed, 24 Sep 2025 15:06:19 -0500 Subject: [PATCH 5/8] Make more like web --- .../screens/buy-sell-screen/BuySellFlow.tsx | 3 -- .../buy-sell-screen/components/BuyScreen.tsx | 12 ++------ .../buy-sell-screen/components/SellScreen.tsx | 28 ++----------------- 3 files changed, 6 insertions(+), 37 deletions(-) diff --git a/packages/mobile/src/screens/buy-sell-screen/BuySellFlow.tsx b/packages/mobile/src/screens/buy-sell-screen/BuySellFlow.tsx index a96a38d71c7..fd8c60e83bd 100644 --- a/packages/mobile/src/screens/buy-sell-screen/BuySellFlow.tsx +++ b/packages/mobile/src/screens/buy-sell-screen/BuySellFlow.tsx @@ -378,9 +378,6 @@ export const BuySellFlow = ({ onOutputTokenChange={(token) => handleOutputTokenChange(token.symbol) } - availableOutputTokens={availableTokens.filter( - (t) => t.symbol !== quoteTokenSymbol && t.symbol !== 'USDC' - )} /> ) : null} diff --git a/packages/mobile/src/screens/buy-sell-screen/components/BuyScreen.tsx b/packages/mobile/src/screens/buy-sell-screen/components/BuyScreen.tsx index 66c03c1b961..7a166da5bde 100644 --- a/packages/mobile/src/screens/buy-sell-screen/components/BuyScreen.tsx +++ b/packages/mobile/src/screens/buy-sell-screen/components/BuyScreen.tsx @@ -65,7 +65,6 @@ type BuyScreenProps = { initialInputValue?: string onInputValueChange?: (value: string) => void onOutputTokenChange?: (token: TokenInfo) => void - availableOutputTokens?: TokenInfo[] } export const BuyScreen = ({ @@ -75,8 +74,7 @@ export const BuyScreen = ({ errorMessage, initialInputValue, onInputValueChange, - onOutputTokenChange, - availableOutputTokens: propAvailableOutputTokens + onOutputTokenChange }: BuyScreenProps) => { const { data: tokenPriceData, isPending: isTokenPriceLoading } = useArtistCoin(tokenPair?.baseToken?.address) @@ -107,14 +105,10 @@ export const BuyScreen = ({ }) const { data: coins } = useArtistCoins() - const localAvailableOutputTokens: TokenInfo[] = useMemo(() => { + const artistCoins: TokenInfo[] = useMemo(() => { return Object.values(transformArtistCoinsToTokenInfoMap(coins ?? [])) }, [coins]) - // Use prop if provided, otherwise use local data - const availableOutputTokens = - propAvailableOutputTokens || localAvailableOutputTokens - // Track if an exchange rate has ever been successfully fetched const hasRateEverBeenFetched = useRef(false) if (currentExchangeRate !== null) { @@ -156,7 +150,7 @@ export const BuyScreen = ({ tokenPrice={tokenPrice} isTokenPriceLoading={isTokenPriceLoading} tokenPriceDecimalPlaces={decimalPlaces} - availableTokens={availableOutputTokens} + availableTokens={artistCoins} onTokenChange={onOutputTokenChange} /> diff --git a/packages/mobile/src/screens/buy-sell-screen/components/SellScreen.tsx b/packages/mobile/src/screens/buy-sell-screen/components/SellScreen.tsx index 643ea2b6330..d887cb7a4bf 100644 --- a/packages/mobile/src/screens/buy-sell-screen/components/SellScreen.tsx +++ b/packages/mobile/src/screens/buy-sell-screen/components/SellScreen.tsx @@ -1,11 +1,6 @@ -import React, { useMemo, useRef } from 'react' +import React, { useRef } from 'react' -import { - transformArtistCoinsToTokenInfoMap, - useArtistCoin, - useArtistCoins -} from '@audius/common/api' -import { useOwnedTokens } from '@audius/common/hooks' +import { useArtistCoin } from '@audius/common/api' import type { TokenInfo, TokenPair } from '@audius/common/store' import { useTokenSwapForm } from '@audius/common/store' @@ -79,7 +74,7 @@ export const SellScreen = ({ initialInputValue, onInputValueChange, onInputTokenChange, - availableInputTokens: propAvailableInputTokens + availableInputTokens }: SellScreenProps) => { const { data: tokenPriceData } = useArtistCoin(tokenPair?.baseToken?.address) @@ -109,23 +104,6 @@ export const SellScreen = ({ hasRateEverBeenFetched.current = true } - const { data: coins } = useArtistCoins() - const allAvailableTokens: TokenInfo[] = useMemo(() => { - return Object.values(transformArtistCoinsToTokenInfoMap(coins ?? [])) - }, [coins]) - - // Use the owned tokens hook to get filtered tokens - const { ownedTokens } = useOwnedTokens(allAvailableTokens) - - // For sell screen: - // - "You Sell" section (input): Should show only tokens the user owns - // - "You Receive" section (output): Should show all available tokens (but only USDC in practice) - const localAvailableInputTokens = ownedTokens - - // Use prop if provided, otherwise use local data - const availableInputTokens = - propAvailableInputTokens || localAvailableInputTokens - if (!tokenPair) return null // Extract the tokens from the pair From ae56ea16c193fb5ce0f02923dba5492c9bc0c8ea Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Wed, 24 Sep 2025 15:11:24 -0500 Subject: [PATCH 6/8] Use harmony value --- packages/mobile/src/components/buy-sell/TokenSelectItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mobile/src/components/buy-sell/TokenSelectItem.tsx b/packages/mobile/src/components/buy-sell/TokenSelectItem.tsx index fe4a957697f..0c2cef8ce87 100644 --- a/packages/mobile/src/components/buy-sell/TokenSelectItem.tsx +++ b/packages/mobile/src/components/buy-sell/TokenSelectItem.tsx @@ -16,7 +16,7 @@ export const TokenSelectItem = ({ token, item }: TokenSelectItemProps) => { return ( - + Date: Wed, 24 Sep 2025 15:18:31 -0500 Subject: [PATCH 7/8] Refactor shared logic --- .../common/src/store/ui/buy-sell/index.ts | 1 + .../store/ui/buy-sell/useBuySellFlowLogic.ts | 85 ++++++++++++++++++ .../screens/buy-sell-screen/BuySellFlow.tsx | 90 +++++-------------- .../components/buy-sell-modal/BuySellFlow.tsx | 87 +++++------------- 4 files changed, 128 insertions(+), 135 deletions(-) create mode 100644 packages/common/src/store/ui/buy-sell/useBuySellFlowLogic.ts diff --git a/packages/common/src/store/ui/buy-sell/index.ts b/packages/common/src/store/ui/buy-sell/index.ts index fcd5a243559..d6a8142e9af 100644 --- a/packages/common/src/store/ui/buy-sell/index.ts +++ b/packages/common/src/store/ui/buy-sell/index.ts @@ -11,3 +11,4 @@ export * from './useTokenSwapForm' export * from './useAvailableTokens' export * from './useCurrentTokenPair' export * from './useTokenStates' +export * from './useBuySellFlowLogic' diff --git a/packages/common/src/store/ui/buy-sell/useBuySellFlowLogic.ts b/packages/common/src/store/ui/buy-sell/useBuySellFlowLogic.ts new file mode 100644 index 00000000000..c3ffecd7b3b --- /dev/null +++ b/packages/common/src/store/ui/buy-sell/useBuySellFlowLogic.ts @@ -0,0 +1,85 @@ +import { useMemo } from 'react' + +import { useFeatureFlag } from '~/hooks' +import { buySellMessages as messages } from '~/messages' +import { FeatureFlags } from '~/services' + +import type { BuySellTab, TokenInfo, TokenPair } from './types' +import { createFallbackPair } from './utils' + +/** + * Creates filtered token lists for buy/sell/convert tabs + */ +export const useBuySellTokenFilters = ({ + availableTokens, + baseTokenSymbol, + quoteTokenSymbol, + hasPositiveBalance +}: { + availableTokens: TokenInfo[] + baseTokenSymbol: string + quoteTokenSymbol: string + hasPositiveBalance: (tokenAddress: string) => boolean +}) => { + const availableInputTokensForSell = useMemo(() => { + return availableTokens.filter( + (t) => + t.symbol !== baseTokenSymbol && + t.symbol !== 'USDC' && + hasPositiveBalance(t.address) + ) + }, [availableTokens, baseTokenSymbol, hasPositiveBalance]) + + const availableInputTokensForConvert = useMemo(() => { + return availableTokens.filter( + (t) => + t.symbol !== baseTokenSymbol && + t.symbol !== quoteTokenSymbol && + hasPositiveBalance(t.address) + ) + }, [availableTokens, baseTokenSymbol, quoteTokenSymbol, hasPositiveBalance]) + + const availableOutputTokensForConvert = useMemo(() => { + return availableTokens.filter((t) => t.symbol !== baseTokenSymbol) + }, [availableTokens, baseTokenSymbol]) + + return { + availableInputTokensForSell, + availableInputTokensForConvert, + availableOutputTokensForConvert + } +} + +/** + * Creates a safe token pair, falling back to AUDIO/USDC if needed + */ +export const useSafeTokenPair = (currentTokenPair: TokenPair | null) => { + return useMemo(() => { + if (currentTokenPair?.baseToken && currentTokenPair?.quoteToken) { + return currentTokenPair + } + return createFallbackPair() + }, [currentTokenPair]) +} + +/** + * Creates the tabs array based on feature flags + */ +export const useBuySellTabsArray = () => { + const { isEnabled: isArtistCoinsEnabled } = useFeatureFlag( + FeatureFlags.ARTIST_COINS + ) + + return useMemo(() => { + const baseTabs = [ + { key: 'buy' as BuySellTab, text: messages.buy }, + { key: 'sell' as BuySellTab, text: messages.sell } + ] + + if (isArtistCoinsEnabled) { + baseTabs.push({ key: 'convert' as BuySellTab, text: messages.convert }) + } + + return baseTabs + }, [isArtistCoinsEnabled]) +} diff --git a/packages/mobile/src/screens/buy-sell-screen/BuySellFlow.tsx b/packages/mobile/src/screens/buy-sell-screen/BuySellFlow.tsx index fd8c60e83bd..c10ab217a38 100644 --- a/packages/mobile/src/screens/buy-sell-screen/BuySellFlow.tsx +++ b/packages/mobile/src/screens/buy-sell-screen/BuySellFlow.tsx @@ -1,13 +1,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useTokenPair, useTokens } from '@audius/common/api' -import { - useBuySellAnalytics, - useFeatureFlag, - useOwnedTokens -} from '@audius/common/hooks' +import { useBuySellAnalytics, useOwnedTokens } from '@audius/common/hooks' import { buySellMessages as messages } from '@audius/common/messages' -import { FeatureFlags } from '@audius/common/services' import type { BuySellTab, TokenInfo } from '@audius/common/store' import { AUDIO_TICKER, @@ -16,8 +11,11 @@ import { useBuySellScreen, useBuySellSwap, useBuySellTabs, + useBuySellTabsArray, + useBuySellTokenFilters, useBuySellTransactionData, useCurrentTokenPair, + useSafeTokenPair, useSwapDisplayData, useTokenStates } from '@audius/common/store' @@ -43,9 +41,7 @@ export const BuySellFlow = ({ const navigation = useNavigation() const { onOpen: openAddCashModal } = useAddCashModal() const { trackSwapRequested, trackAddFundsClicked } = useBuySellAnalytics() - const { isEnabled: isArtistCoinsEnabled } = useFeatureFlag( - FeatureFlags.ARTIST_COINS - ) + // Get token pair for the initial coin, fallback to AUDIO/USDC const { data: selectedPair } = useTokenPair({ baseSymbol: coinTicker, @@ -99,28 +95,17 @@ export const BuySellFlow = ({ [ownedTokens] ) - // Create filtered token lists for each tab (unified filtering approach) - const availableInputTokensForSell = useMemo(() => { - return availableTokens.filter( - (t) => - t.symbol !== baseTokenSymbol && - t.symbol !== 'USDC' && - hasPositiveBalance(t.address) - ) - }, [availableTokens, baseTokenSymbol, hasPositiveBalance]) - - const availableInputTokensForConvert = useMemo(() => { - return availableTokens.filter( - (t) => - t.symbol !== baseTokenSymbol && - t.symbol !== quoteTokenSymbol && - hasPositiveBalance(t.address) - ) - }, [availableTokens, baseTokenSymbol, quoteTokenSymbol, hasPositiveBalance]) - - const availableOutputTokensForConvert = useMemo(() => { - return availableTokens.filter((t) => t.symbol !== baseTokenSymbol) - }, [availableTokens, baseTokenSymbol]) + // Use shared token filtering logic + const { + availableInputTokensForSell, + availableInputTokensForConvert, + availableOutputTokensForConvert + } = useBuySellTokenFilters({ + availableTokens, + baseTokenSymbol, + quoteTokenSymbol, + hasPositiveBalance + }) // Reset screen state to 'input' when this screen comes into focus // This handles the case where we navigate back from ConfirmSwapScreen @@ -194,33 +179,8 @@ export const BuySellFlow = ({ selectedPair }) - // Create a safe selectedPair for hooks that can't handle null values (same as web) - const safeSelectedPair = useMemo(() => { - if (currentTabTokenPair?.baseToken && currentTabTokenPair?.quoteToken) { - return currentTabTokenPair - } - - // Return minimal safe token pair to prevent hook crashes - return { - baseToken: { - symbol: 'AUDIO', - name: 'Audius', - decimals: 8, - balance: null, - address: '', - isStablecoin: false - }, - quoteToken: { - symbol: 'USDC', - name: 'USD Coin', - decimals: 6, - balance: null, - address: '', - isStablecoin: true - }, - exchangeRate: null - } - }, [currentTabTokenPair]) + // Use shared safe token pair logic + const safeSelectedPair = useSafeTokenPair(currentTabTokenPair) const { handleShowConfirmation, isContinueButtonLoading } = useBuySellSwap({ transactionData, @@ -253,18 +213,8 @@ export const BuySellFlow = ({ selectedPair: safeSelectedPair }) - const tabs = useMemo(() => { - const baseTabs = [ - { key: 'buy' as BuySellTab, text: messages.buy }, - { key: 'sell' as BuySellTab, text: messages.sell } - ] - - if (isArtistCoinsEnabled) { - baseTabs.push({ key: 'convert' as BuySellTab, text: messages.convert }) - } - - return baseTabs - }, [isArtistCoinsEnabled]) + // Use shared tabs array logic + const tabs = useBuySellTabsArray() const handleContinueClick = useCallback(() => { setHasAttemptedSubmit(true) diff --git a/packages/web/src/components/buy-sell-modal/BuySellFlow.tsx b/packages/web/src/components/buy-sell-modal/BuySellFlow.tsx index 06b60a16e43..0db26ef077d 100644 --- a/packages/web/src/components/buy-sell-modal/BuySellFlow.tsx +++ b/packages/web/src/components/buy-sell-modal/BuySellFlow.tsx @@ -20,7 +20,10 @@ import { BuySellTab, Screen, useTokenStates, - useCurrentTokenPair + useCurrentTokenPair, + useBuySellTokenFilters, + useSafeTokenPair, + useBuySellTabsArray } from '@audius/common/store' import { Button, Flex, Hint, SegmentedControl, TextLink } from '@audius/harmony' import { matchPath, useLocation } from 'react-router-dom' @@ -164,29 +167,6 @@ export const BuySellFlow = (props: BuySellFlowProps) => { [userCoins] ) - // Create filtered token lists for each tab (unified filtering approach) - const availableInputTokensForSell = useMemo(() => { - return availableTokens.filter( - (t) => - t.symbol !== baseTokenSymbol && - t.symbol !== 'USDC' && - hasPositiveBalance(t.address) - ) - }, [availableTokens, baseTokenSymbol, hasPositiveBalance]) - - const availableInputTokensForConvert = useMemo(() => { - return availableTokens.filter( - (t) => - t.symbol !== baseTokenSymbol && - t.symbol !== quoteTokenSymbol && - hasPositiveBalance(t.address) - ) - }, [availableTokens, baseTokenSymbol, quoteTokenSymbol, hasPositiveBalance]) - - const availableOutputTokensForConvert = useMemo(() => { - return availableTokens.filter((t) => t.symbol !== baseTokenSymbol) - }, [availableTokens, baseTokenSymbol]) - // Create current token pair based on selected base and quote tokens const currentTokenPair = useCurrentTokenPair({ baseTokenSymbol, @@ -195,6 +175,24 @@ export const BuySellFlow = (props: BuySellFlowProps) => { selectedPair }) + // Use shared token filtering logic + const { + availableInputTokensForSell, + availableInputTokensForConvert, + availableOutputTokensForConvert + } = useBuySellTokenFilters({ + availableTokens, + baseTokenSymbol, + quoteTokenSymbol, + hasPositiveBalance + }) + + // Use shared safe token pair logic + const safeSelectedPair = useSafeTokenPair(currentTokenPair) + + // Use shared tabs array logic + const tabs = useBuySellTabsArray() + const swapTokens = useMemo(() => { // Return safe defaults if currentTokenPair is not available if (!currentTokenPair?.baseToken || !currentTokenPair?.quoteToken) { @@ -220,34 +218,6 @@ export const BuySellFlow = (props: BuySellFlowProps) => { } }, [activeTab, baseTokenSymbol, quoteTokenSymbol, currentTokenPair]) - // Create a safe selectedPair for hooks that can't handle null values - const safeSelectedPair = useMemo(() => { - if (currentTokenPair?.baseToken && currentTokenPair?.quoteToken) { - return currentTokenPair - } - - // Return minimal safe token pair to prevent hook crashes - return { - baseToken: { - symbol: 'AUDIO', - name: 'Audius', - decimals: 8, - balance: null, - address: '', - isStablecoin: false - }, - quoteToken: { - symbol: 'USDC', - name: 'USD Coin', - decimals: 6, - balance: null, - address: '', - isStablecoin: true - }, - exchangeRate: null - } - }, [currentTokenPair]) - const { handleShowConfirmation, handleConfirmSwap, @@ -316,19 +286,6 @@ export const BuySellFlow = (props: BuySellFlowProps) => { trackSwapFailure ]) - const tabs = useMemo(() => { - const baseTabs = [ - { key: 'buy' as BuySellTab, text: messages.buy }, - { key: 'sell' as BuySellTab, text: messages.sell } - ] - - if (isArtistCoinsEnabled) { - baseTabs.push({ key: 'convert' as BuySellTab, text: messages.convert }) - } - - return baseTabs - }, [isArtistCoinsEnabled]) - const { successDisplayData, resetSuccessDisplayData, From 0fbdafedb56e29eb2af26f66ca6c782dcad55368 Mon Sep 17 00:00:00 2001 From: Farid Salau Date: Wed, 24 Sep 2025 15:31:20 -0500 Subject: [PATCH 8/8] Comments --- packages/mobile/src/app/App.tsx | 1 - packages/mobile/src/components/buy-sell/InputTokenSection.tsx | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/mobile/src/app/App.tsx b/packages/mobile/src/app/App.tsx index 603e878d21e..1b1cd8862e6 100644 --- a/packages/mobile/src/app/App.tsx +++ b/packages/mobile/src/app/App.tsx @@ -87,7 +87,6 @@ const App = () => { - diff --git a/packages/mobile/src/components/buy-sell/InputTokenSection.tsx b/packages/mobile/src/components/buy-sell/InputTokenSection.tsx index a7b87dabbf1..aca822e240f 100644 --- a/packages/mobile/src/components/buy-sell/InputTokenSection.tsx +++ b/packages/mobile/src/components/buy-sell/InputTokenSection.tsx @@ -53,7 +53,7 @@ export const InputTokenSection = ({ const iconSize = iconSizes.s const { spacing } = useTheme() const { symbol, isStablecoin } = tokenInfo - const [localAmount, setLocalAmount] = useState(amount || '') + const [localAmount, setLocalAmount] = useState(amount ?? '') const { formattedAvailableBalance } = useTokenAmountFormatting({ amount, @@ -119,7 +119,7 @@ export const InputTokenSection = ({