Skip to content
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

Markets - Buy/Sell/Earn/Trade Buttons #5435

Merged
merged 1 commit into from
Jan 30, 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- added: `NotificationCenterScene`
- added: Search bar to `EarnScene`
- added: Buy, Sell, Earn, and Trade buttons to `CoinRankingDetailsScene`
- added: Price chart to `TransactionListScene`
- added: Add Unizen DEX
- changed: `TransactionListScene` split into two scenes: `TransactionListScene` and `TransactionListScene2`
Expand Down
300 changes: 290 additions & 10 deletions src/components/scenes/CoinRankingDetailsScene.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,38 @@
import { useIsFocused } from '@react-navigation/native'
import * as React from 'react'
import { View } from 'react-native'
import { ActivityIndicator, View } from 'react-native'
import FastImage from 'react-native-fast-image'
import Feather from 'react-native-vector-icons/Feather'
import Ionicons from 'react-native-vector-icons/Ionicons'

import { createWallet, getUniqueWalletName } from '../../actions/CreateWalletActions'
import { getFirstOpenInfo } from '../../actions/FirstOpenActions'
import { updateStakingState } from '../../actions/scene/StakingActions'
import { Fontello } from '../../assets/vector/index'
import { WalletListModal, WalletListResult } from '../../components/modals/WalletListModal'
import { SPECIAL_CURRENCY_INFO } from '../../constants/WalletAndCurrencyConstants'
import { useAsyncValue } from '../../hooks/useAsyncValue'
import { formatFiatString } from '../../hooks/useFiatText'
import { useHandler } from '../../hooks/useHandler'
import { useWatch } from '../../hooks/useWatch'
import { toLocaleDate, toPercentString } from '../../locales/intl'
import { lstrings } from '../../locales/strings'
import { defaultWalletStakingState } from '../../reducers/StakingReducer'
import { getDefaultFiat } from '../../selectors/SettingsSelectors'
import { asCoinRankingData, CoinRankingData, CoinRankingDataPercentChange } from '../../types/coinrankTypes'
import { useSelector } from '../../types/reactRedux'
import { EdgeAppSceneProps } from '../../types/routerTypes'
import { useDispatch, useSelector } from '../../types/reactRedux'
import { EdgeAppSceneProps, NavigationBase } from '../../types/routerTypes'
import { EdgeAsset } from '../../types/types'
import { CryptoAmount } from '../../util/CryptoAmount'
import { fetchRates } from '../../util/network'
import { formatLargeNumberString as formatLargeNumber } from '../../util/utils'
import { getPluginFromPolicy } from '../../util/stakeUtils'
import { getUkCompliantString } from '../../util/ukComplianceUtils'
import { DECIMAL_PRECISION, formatLargeNumberString as formatLargeNumber } from '../../util/utils'
import { IconButton } from '../buttons/IconButton'
import { SwipeChart } from '../charts/SwipeChart'
import { EdgeAnim, fadeInLeft } from '../common/EdgeAnim'
import { SceneWrapper } from '../common/SceneWrapper'
import { Airship, showError } from '../services/AirshipInstance'
import { cacheStyles, Theme, useTheme } from '../services/ThemeContext'
import { EdgeText } from '../themed/EdgeText'
import { COINGECKO_SUPPORTED_FIATS } from './CoinRankingScene'
Expand Down Expand Up @@ -81,9 +98,19 @@ const COLUMN_RIGHT_DATA_KEYS: Array<keyof CoinRankingData> = [
const CoinRankingDetailsSceneComponent = (props: Props) => {
const theme = useTheme()
const styles = getStyles(theme)
const dispatch = useDispatch()
const { route, navigation } = props
const { assetId, fiatCurrencyCode, coinRankingData: initCoinRankingData } = route.params

const account = useSelector(state => state.core.account)
const exchangeRates = useSelector(state => state.exchangeRates)
const walletStakingStateMap = useSelector(state => state.staking.walletStakingMap ?? defaultWalletStakingState)

const currencyWallets = useWatch(account, 'currencyWallets')
const isFocused = useIsFocused()

const [countryCode] = useAsyncValue(async () => (await getFirstOpenInfo()).countryCode)

// In case the user changes their default fiat while viewing this scene, we
// want to go back since the parent scene handles fetching data.
const defaultFiat = useSelector(state => getDefaultFiat(state))
Expand All @@ -106,19 +133,58 @@ const CoinRankingDetailsSceneComponent = (props: Props) => {

const coinRankingData = fetchedCoinRankingData ?? initCoinRankingData

const { currencyCode, currencyName } = coinRankingData ?? {}
const currencyCodeUppercase = currencyCode?.toUpperCase() ?? ''
const { currencyCode: coinRankingCurrencyCode, currencyName } = coinRankingData ?? {}
// `coinRankingCurrencyCode` is lowercase and that breaks a lot of our utility
// calls
const currencyCode = coinRankingCurrencyCode?.toUpperCase() ?? ''

/** Loosely Equivalent EdgeAssets for the CoinGecko coin on this scene */
const edgeAssets = React.useMemo<EdgeAsset[]>(() => {
if (coinRankingData == null) return []

const out = []
// Search for mainnet coins:
for (const pluginId of Object.keys(account.currencyConfig)) {
const config = account.currencyConfig[pluginId]
if (config.currencyInfo.currencyCode.toLowerCase() === currencyCode.toLowerCase()) out.push({ tokenId: null, pluginId })
}
// Search for tokens:
for (const pluginId of Object.keys(account.currencyConfig)) {
const config = account.currencyConfig[pluginId]
for (const tokenId of Object.keys(config.allTokens)) {
const token = config.allTokens[tokenId]
if (token.currencyCode.toLowerCase() === currencyCode.toLowerCase()) out.push({ tokenId, pluginId })
}
}
return out
}, [account.currencyConfig, coinRankingData, currencyCode])

const isFocused = useIsFocused()
const initFiat = React.useState<string>(fiatCurrencyCode)[0]

/** Find all wallets that can hold this asset */
const matchingWallets = React.useMemo(
() => Object.values(currencyWallets).filter(wallet => edgeAssets.some(asset => asset.pluginId === wallet.currencyInfo.pluginId)),
[edgeAssets, currencyWallets]
)

/** Check if all the stake plugins are loaded for this asset type */
const isStakingLoading = matchingWallets.some(wallet => walletStakingStateMap[wallet.id] == null || walletStakingStateMap[wallet.id].isLoading)

React.useEffect(() => {
if (isFocused && initFiat !== supportedFiat) {
// Take this stale scene off the stack
navigation.pop()
// Force a refresh & refetch
navigation.navigate('coinRanking')

// Update staking state:
if (coinRankingData != null && matchingWallets.length > 0) {
matchingWallets.forEach(wallet => {
dispatch(updateStakingState(currencyCode, wallet)).catch(err => showError(err))
})
}
}
}, [supportedFiat, initFiat, isFocused, navigation])
}, [coinRankingData, currencyCode, dispatch, edgeAssets, initFiat, isFocused, matchingWallets, navigation, supportedFiat])

const imageUrlObject = React.useMemo(
() => ({
Expand Down Expand Up @@ -214,15 +280,217 @@ const CoinRankingDetailsSceneComponent = (props: Props) => {
return rows
}

/**
* Returns a WalletListResult to use for the button navigation. Returns the
* wallet the user chose from the wallet picker, automatically selects a
* single wallet, or undefined if the user was presented with the wallet
* picker but dismissed it.
*/
const chooseWalletListResult = async (): Promise<Extract<WalletListResult, { type: 'wallet' }> | undefined> => {
// No compatible assets. Shouldn't happen since buttons are blocked from
// handlers anyway, if there's no edgeAssets
if (edgeAssets.length === 0) return

// If no wallet exists, auto create one.
// Only do this if there is only one possible match that we know of
if (matchingWallets.length === 0 && edgeAssets.length === 1) {
const walletName = getUniqueWalletName(account, edgeAssets[0].pluginId)
const targetWallet = await createWallet(account, {
name: walletName,
walletType: `wallet:${edgeAssets[0].pluginId}`,
fiatCurrencyCode: fiatCurrencyCode
})
if (edgeAssets[0].tokenId != null) {
await targetWallet.changeEnabledTokenIds([...targetWallet.enabledTokenIds, edgeAssets[0].tokenId])
}
return {
type: 'wallet',
tokenId: edgeAssets[0].tokenId,
walletId: targetWallet.id
}
}

// If only one wallet, auto-select it
if (matchingWallets.length === 1) {
return {
type: 'wallet',
tokenId: edgeAssets[0].tokenId,
walletId: matchingWallets[0].id
}
}

// Else, If multiple wallets, show picker. Tokens also can be added here.
const result = await Airship.show<WalletListResult>(bridge => (
<WalletListModal
bridge={bridge}
navigation={navigation as NavigationBase}
headerTitle={lstrings.select_wallet_to_send_from}
allowedAssets={edgeAssets}
showCreateWallet
/>
))
// User aborted the flow. Callers will also noop.
if (result?.type !== 'wallet') return
return result
}

const handleBuyPress = useHandler(async () => {
if (edgeAssets.length === 0) return
const forcedWalletResult = await chooseWalletListResult()
if (forcedWalletResult == null) return

navigation.navigate('edgeTabs', {
screen: 'buyTab',
params: {
screen: 'pluginListBuy',
params: {
forcedWalletResult
}
}
})
})

const handleSellPress = useHandler(async () => {
if (edgeAssets.length === 0) return
const forcedWalletResult = await chooseWalletListResult()
if (forcedWalletResult == null) return

navigation.navigate('edgeTabs', {
screen: 'sellTab',
params: {
screen: 'pluginListSell',
params: {
forcedWalletResult
}
}
})
})

const handleTradePress = useHandler(async () => {
if (edgeAssets.length === 0) return

const walletListResult = await chooseWalletListResult()
if (walletListResult == null) return

const { walletId, tokenId } = walletListResult

// Find the wallet with highest USD value to use as source (swap from)
// TODO: Include token balances in this sort
const sourceWallet = Object.values(currencyWallets)
.filter(wallet => wallet.id !== walletId)
.sort((a, b) => {
const aCryptoAmount = new CryptoAmount({
currencyConfig: a.currencyConfig,
tokenId: null,
nativeAmount: a.balanceMap.get(null) ?? '0'
})
const aDollarValue = parseFloat(aCryptoAmount.displayDollarValue(exchangeRates, DECIMAL_PRECISION))

const bCryptoAmount = new CryptoAmount({
currencyConfig: b.currencyConfig,
tokenId: null,
nativeAmount: b.balanceMap.get(null) ?? '0'
})
const bDollarValue = parseFloat(bCryptoAmount.displayDollarValue(exchangeRates, DECIMAL_PRECISION))

return bDollarValue - aDollarValue
})[0]

// Navigate to the swap scene
navigation.navigate('edgeTabs', {
screen: 'swapTab',
params: {
screen: 'swapCreate',
params: {
fromWalletId: sourceWallet.id,
toWalletId: walletId,
toTokenId: tokenId
}
}
})
})

const isStakingAvailable = (): boolean => {
if (countryCode == null || edgeAssets.length === 0) return false

// Special case for FIO because it uses it's own staking plugin
const isStakingSupported = currencyCode === 'FIO' || edgeAssets.some(asset => SPECIAL_CURRENCY_INFO[asset.pluginId]?.isStakingSupported === true)
return isStakingSupported
}

const handleStakePress = useHandler(async () => {
const walletListResult = await chooseWalletListResult()
if (walletListResult == null) return
const { walletId } = walletListResult

const walletStakingState = walletStakingStateMap[walletId] ?? defaultWalletStakingState
const { stakePlugins } = walletStakingState
const stakePolicies = Object.values(walletStakingState.stakePolicies)

// Handle FIO staking
if (currencyCode === 'FIO') {
navigation.push('fioStakingOverview', {
tokenId: null,
walletId
})
return
}

// Handle StakePlugin staking
if (stakePlugins != null && stakePolicies != null) {
if (stakePolicies.length > 1) {
navigation.push('stakeOptions', {
walletId,
currencyCode
})
} else if (stakePolicies.length === 1) {
const [stakePolicy] = stakePolicies
const { stakePolicyId } = stakePolicy
const stakePlugin = getPluginFromPolicy(stakePlugins, stakePolicy, {
pluginId: currencyWallets[walletId].currencyInfo.pluginId,
currencyCode
})
if (stakePlugin != null)
navigation.push('stakeOverview', {
stakePlugin,
walletId,
stakePolicyId
})
}
}
})

return (
<SceneWrapper hasTabs hasNotifications scroll>
{coinRankingData != null ? (
<View style={styles.container}>
<EdgeAnim style={styles.titleContainer} enter={fadeInLeft}>
<FastImage style={styles.icon} source={imageUrlObject} />
<EdgeText style={styles.title}>{`${currencyName} (${currencyCodeUppercase})`}</EdgeText>
<EdgeText style={styles.title}>{`${currencyName} (${currencyCode})`}</EdgeText>
</EdgeAnim>
<SwipeChart assetId={coinRankingData.assetId} currencyCode={currencyCodeUppercase} fiatCurrencyCode={initFiat} />
<SwipeChart assetId={coinRankingData.assetId} currencyCode={currencyCode} fiatCurrencyCode={initFiat} />
{edgeAssets.length <= 0 ? null : (
<View style={styles.buttonsContainer}>
<IconButton label={lstrings.title_buy} onPress={handleBuyPress}>
<Fontello name="buy" size={theme.rem(2)} color={theme.primaryText} />
</IconButton>
<IconButton label={lstrings.title_sell} onPress={handleSellPress}>
<Fontello name="sell" size={theme.rem(2)} color={theme.primaryText} />
</IconButton>
{!isStakingAvailable() ? null : (
<IconButton label={getUkCompliantString(countryCode, 'stake_earn_button_label')} onPress={handleStakePress}>
{isStakingLoading ? (
<ActivityIndicator color={theme.primaryText} style={styles.buttonLoader} />
) : (
<Feather name="percent" size={theme.rem(2)} color={theme.primaryText} />
)}
</IconButton>
)}
<IconButton label={lstrings.trade_currency} onPress={handleTradePress}>
<Ionicons name="swap-horizontal" size={theme.rem(2)} color={theme.primaryText} />
</IconButton>
</View>
)}
<View style={styles.columns}>
<View style={styles.column}>{renderRows(coinRankingData, COLUMN_LEFT_DATA_KEYS)}</View>
<View style={styles.column}>{renderRows(coinRankingData, COLUMN_RIGHT_DATA_KEYS)}</View>
Expand Down Expand Up @@ -273,6 +541,18 @@ const getStyles = cacheStyles((theme: Theme) => {
margin: theme.rem(0.5),
flexDirection: 'row',
alignItems: 'center'
},
buttonsContainer: {
flexDirection: 'row',
justifyContent: 'space-around',
alignItems: 'center',
marginVertical: theme.rem(1),
paddingHorizontal: theme.rem(1)
},
buttonLoader: {
justifyContent: 'center',
alignItems: 'center',
minHeight: theme.rem(2)
}
}
})
Loading