diff --git a/.github/scripts/checkPodfileAndUpdateRenovatePr.ts b/.github/scripts/checkPodfileAndUpdateRenovatePr.ts new file mode 100644 index 00000000000..975fcea8696 --- /dev/null +++ b/.github/scripts/checkPodfileAndUpdateRenovatePr.ts @@ -0,0 +1,50 @@ +import * as $ from 'shelljs' + +const RENOVATE_USER = 'renovate[bot]' + +const exitCode = $.exec('git diff --exit-code').code +if (exitCode === 0) { + console.log('No diff found') + process.exit(0) +} + +console.log('Diff found') + +$.config.fatal = true + +const lastCommitAuthor = $.exec('git log -1 --pretty=format:%an', { silent: true }).stdout.trim() +const branchName = process.env.GITHUB_HEAD_REF + +// Check if this is invoked from a PR, branch name starts with renovate and the +// last commit was from renovate (to avoid infinite loop, in case a diff is +// generated every time). +if ( + process.env.GITHUB_EVENT_NAME === 'pull_request' && + branchName?.startsWith('renovate/') && + lastCommitAuthor === RENOVATE_USER +) { + console.log('Renovate PR, pushing Podfile changes') + // Since github checkouts the PR as a single "merge commit", this doesn't have + // the complete PR branch. Stash the changes and reset the branch to the HEAD + // of the PR and apply changes on top. + $.exec('git stash') + $.exec('git remote set-url origin git@github.com:valora-inc/wallet.git') + $.exec(`git checkout -b ${branchName}`) + $.exec('git fetch') + $.exec(`git reset --hard origin/${branchName}`) + $.exec('git stash pop') + // this assumes the diff is from Podfile.lock only + $.exec('git add ios/Podfile.lock') + $.exec('git config user.email "valorabot@valoraapp.com"') + $.exec('git config user.name "valora-bot"') + $.exec('git commit -m "update podfile.lock"') + + // ensure that we are using ssh + $.exec(`git push --set-upstream origin ${branchName}`) +} else { + console.log('Not a renovate PR') +} + +// Exit with non-zero exit code regardless, since a new commit on the renovate +// PR will trigger a new job anyway. +process.exit(exitCode) diff --git a/.github/workflows/e2e-ios.yml b/.github/workflows/e2e-ios.yml index d74e93a7182..4d3f336895d 100644 --- a/.github/workflows/e2e-ios.yml +++ b/.github/workflows/e2e-ios.yml @@ -49,8 +49,8 @@ jobs: - name: Install CocoaPods dependencies working-directory: ios run: bundle exec pod install || bundle exec pod install --repo-update - - name: Fail if someone forgot to commit "Podfile.lock" - run: git diff --exit-code + - name: Fail if someone forgot to commit "Podfile.lock" and push changes if PR is from renovate + run: yarn ts-node ./.github/scripts/checkPodfileAndUpdateRenovatePr.ts - name: Check E2E wallet balance run: NODE_OPTIONS='--unhandled-rejections=strict' yarn ts-node ./e2e/scripts/check-e2e-wallet-balance.ts - name: Create iOS E2E .env File diff --git a/ios/Podfile.lock b/ios/Podfile.lock index a1d9e1e5e3e..7ddda8b135c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -530,7 +530,7 @@ PODS: - React-Core - react-native-in-app-review (4.3.3): - React-Core - - react-native-launch-arguments (4.0.1): + - react-native-launch-arguments (4.0.2): - React - react-native-mail (6.1.1): - React-Core @@ -1202,7 +1202,7 @@ SPEC CHECKSUMS: react-native-flipper: b9e2e817604af8da0d5a9ba20a8516e780e30f3c react-native-get-random-values: dee677497c6a740b71e5612e8dbd83e7539ed5bb react-native-in-app-review: db8bb167a5f238e7ceca5c242d6b36ce8c4404a4 - react-native-launch-arguments: 4e0fd58e56dcc7f52eedef9dc8eff81eb73ced7a + react-native-launch-arguments: 5f41e0abf88a15e3c5309b8875d6fd5ac43df49d react-native-mail: 8fdcd3aef007c33a6877a18eb4cf7447a1d4ce4a react-native-netinfo: fefd4e98d75cbdd6e85fc530f7111a8afdf2b0c5 react-native-randombytes: 421f1c7d48c0af8dbcd471b0324393ebf8fe7846 diff --git a/locales/base/translation.json b/locales/base/translation.json index 83e6d5a30ea..d4a252eca64 100644 --- a/locales/base/translation.json +++ b/locales/base/translation.json @@ -1837,5 +1837,18 @@ "notificationCenterSpotlight": { "message": "Introducing a new way to claim rewards, view alerts, and see updates in one place", "cta": "Got it" + }, + "tokenDetails": { + "yourBalance": "Your balance", + "learnMore": "Learn more about {{tokenName}}", + "priceUnavailable": "Price Unavailable", + "priceDeltaSuffix": "Today", + "actions": { + "send": "Send", + "swap": "Swap", + "add": "Add", + "withdraw": "Withdraw", + "more": "More" + } } } diff --git a/package.json b/package.json index 31cdefe00a0..ae056d5ec02 100644 --- a/package.json +++ b/package.json @@ -160,7 +160,7 @@ "react-native-image-crop-picker": "^0.35.1", "react-native-in-app-review": "^4.3.3", "react-native-keychain": "8.0.0", - "react-native-launch-arguments": "^4.0.1", + "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.3", "react-native-localize": "^2.2.6", "react-native-mail": "^6.1.1", diff --git a/src/analytics/Events.tsx b/src/analytics/Events.tsx index c392e2c8acf..4a7b9e0627c 100644 --- a/src/analytics/Events.tsx +++ b/src/analytics/Events.tsx @@ -615,6 +615,8 @@ export enum AssetsEvents { view_dapp_positions = 'view_dapp_positions', tap_asset = 'tap_asset', tap_claim_rewards = 'tap_claim_rewards', + tap_token_details_action = 'tap_token_details_action', + tap_token_details_learn_more = 'tap_token_details_learn_more', } export enum NftEvents { diff --git a/src/analytics/Properties.tsx b/src/analytics/Properties.tsx index dad53c7c46b..40764a13aa8 100644 --- a/src/analytics/Properties.tsx +++ b/src/analytics/Properties.tsx @@ -68,6 +68,7 @@ import { NotificationReceiveState } from 'src/notifications/types' import { AdventureCardName } from 'src/onboarding/types' import { RecipientType } from 'src/recipients/recipient' import { Field } from 'src/swap/types' +import { TokenDetailsActionName } from 'src/tokens/types' import { NetworkId } from 'src/transactions/types' import { AnalyticsCurrency, CiCoCurrency, Currency } from 'src/utils/currencies' import { Awaited } from 'src/utils/typescript' @@ -1320,6 +1321,14 @@ interface TokenBottomSheetEventsProperties { } } +export interface TokenProperties { + symbol: string + address: string | null + balanceUsd: number + networkId: NetworkId + tokenId: string +} + interface AssetsEventsProperties { [AssetsEvents.show_asset_balance_info]: undefined [AssetsEvents.view_wallet_assets]: undefined @@ -1342,16 +1351,16 @@ interface AssetsEventsProperties { description: string balanceUsd: number } - | { + | ({ assetType: 'token' - address?: string - networkId: NetworkId - tokenId: string title: string // Example: 'cUSD' description: string - balanceUsd: number - } + } & TokenProperties) [AssetsEvents.tap_claim_rewards]: undefined + [AssetsEvents.tap_token_details_action]: { + action: TokenDetailsActionName + } & TokenProperties + [AssetsEvents.tap_token_details_learn_more]: TokenProperties } interface NftsEventsProperties { diff --git a/src/analytics/docs.ts b/src/analytics/docs.ts index 7b52444af68..7ba46fac85b 100644 --- a/src/analytics/docs.ts +++ b/src/analytics/docs.ts @@ -528,6 +528,8 @@ export const eventDocs: Record = { [AssetsEvents.view_dapp_positions]: `When a user taps on the "Dapp Positions" segmented control or tab`, [AssetsEvents.tap_asset]: `When a user taps on an asset`, [AssetsEvents.tap_claim_rewards]: `When a user taps on the "Claim Rewards" button`, + [AssetsEvents.tap_token_details_action]: `When a user taps one of the actions on the token details screen`, + [AssetsEvents.tap_token_details_learn_more]: `When a user taps the learn more link on the token details screen`, [NftEvents.nft_error_screen_open]: `When the high level error screen is mounted`, [NftEvents.nft_media_load]: `When attempting to load NFT media`, [NftEvents.nft_gallery_screen_open]: `When the gallery screen is mounted`, diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 62b109a1502..d0b3d89293d 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -3,7 +3,7 @@ import React, { ReactNode, useCallback } from 'react' import { ActivityIndicator, StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native' import Touchable from 'src/components/Touchable' import colors, { Colors } from 'src/styles/colors' -import fontStyles from 'src/styles/fonts' +import fontStyles, { typeScale } from 'src/styles/fonts' import { vibrateInformative } from 'src/styles/hapticFeedback' const BUTTON_TAP_DEBOUNCE_TIME = 300 // milliseconds @@ -42,6 +42,7 @@ export interface ButtonProps { testID?: string touchableStyle?: StyleProp iconMargin?: number + fontStyle?: typeof typeScale } export default React.memo(function Button(props: ButtonProps) { @@ -60,6 +61,7 @@ export default React.memo(function Button(props: ButtonProps) { loadingColor, touchableStyle, iconMargin = 4, + fontStyle = fontStyles.regular600, } = props // Debounce onPress event so that it is called once on trigger and @@ -103,7 +105,7 @@ export default React.memo(function Button(props: ButtonProps) { maxFontSizeMultiplier={1} accessibilityLabel={accessibilityLabel} style={{ - ...fontStyles.regular600, + ...fontStyle, color: textColor, marginLeft: icon && iconPositionLeft ? iconMargin : 0, marginRight: icon && !iconPositionLeft ? iconMargin : 0, diff --git a/src/components/LegacyTokenDisplay.test.tsx b/src/components/LegacyTokenDisplay.test.tsx index aa738b61ecb..cfcdd18a3a0 100644 --- a/src/components/LegacyTokenDisplay.test.tsx +++ b/src/components/LegacyTokenDisplay.test.tsx @@ -3,7 +3,7 @@ import BigNumber from 'bignumber.js' import * as React from 'react' import 'react-native' import { Provider } from 'react-redux' -import LegacyTokenDisplay, { formatValueToDisplay } from 'src/components/LegacyTokenDisplay' +import LegacyTokenDisplay from 'src/components/LegacyTokenDisplay' import { LocalCurrencyCode } from 'src/localCurrency/consts' import { RootState } from 'src/redux/reducers' import { Currency } from 'src/utils/currencies' @@ -17,6 +17,7 @@ import { mockCeloTokenId, mockCeloAddress, } from 'test/values' +import { formatValueToDisplay } from 'src/components/TokenDisplay' jest.mock('src/statsig', () => ({ getFeatureGate: jest.fn(() => false), })) diff --git a/src/components/LegacyTokenDisplay.tsx b/src/components/LegacyTokenDisplay.tsx index 8b841c19bb4..af254d6dea0 100644 --- a/src/components/LegacyTokenDisplay.tsx +++ b/src/components/LegacyTokenDisplay.tsx @@ -1,14 +1,10 @@ import BigNumber from 'bignumber.js' import * as React from 'react' -import { StyleProp, Text, TextStyle } from 'react-native' -import { LocalCurrencyCode, LocalCurrencySymbol } from 'src/localCurrency/consts' -import { getLocalCurrencySymbol, usdToLocalCurrencyRateSelector } from 'src/localCurrency/selectors' -import useSelector from 'src/redux/useSelector' +import { StyleProp, TextStyle } from 'react-native' import { useTokenInfoByAddress, useTokenInfoWithAddressBySymbol } from 'src/tokens/hooks' import { LocalAmount } from 'src/transactions/types' import { Currency } from 'src/utils/currencies' - -const DEFAULT_DISPLAY_DECIMALS = 2 +import TokenDisplay from 'src/components/TokenDisplay' interface Props { amount: BigNumber.Value @@ -23,26 +19,6 @@ interface Props { testID?: string } -function calculateDecimalsToShow(value: BigNumber) { - const exponent = value?.e ?? 0 - if (exponent >= 0) { - return DEFAULT_DISPLAY_DECIMALS - } - - return Math.abs(exponent) + 1 -} - -// Formats |value| so that it shows at least 2 significant figures and at least 2 decimal places without trailing zeros. -// TODO: Move this into TokenDisplay.tsx once LegacyTokenDisplay is removed -export function formatValueToDisplay(value: BigNumber) { - let decimals = calculateDecimalsToShow(value) - let text = value.toFormat(decimals) - while (text[text.length - 1] === '0' && decimals-- > 2) { - text = text.substring(0, text.length - 1) - } - return text -} - /** * @deprecated use TokenDisplay instead */ @@ -69,38 +45,18 @@ function LegacyTokenDisplay({ currency! === Currency.Celo ? 'CELO' : currency! ) const tokenInfo = tokenInfoFromAddress || tokenInfoFromCurrency - const localCurrencyExchangeRate = useSelector(usdToLocalCurrencyRateSelector) - const localCurrencySymbol = useSelector(getLocalCurrencySymbol) - - const showError = showLocalAmount - ? !localAmount && (!tokenInfo?.priceUsd || !localCurrencyExchangeRate) - : !tokenInfo?.symbol - - const amountInUsd = tokenInfo?.priceUsd?.multipliedBy(amount) - const amountInLocalCurrency = localAmount - ? new BigNumber(localAmount.value) - : new BigNumber(localCurrencyExchangeRate ?? 0).multipliedBy(amountInUsd ?? 0) - const fiatSymbol = localAmount - ? LocalCurrencySymbol[localAmount.currencyCode as LocalCurrencyCode] - : localCurrencySymbol - - const amountToShow = showLocalAmount ? amountInLocalCurrency : new BigNumber(amount) - - const sign = hideSign ? '' : amountToShow.isNegative() ? '-' : showExplicitPositiveSign ? '+' : '' - return ( - - {showError ? ( - '-' - ) : ( - <> - {sign} - {showLocalAmount && fiatSymbol} - {formatValueToDisplay(amountToShow.absoluteValue())} - {!showLocalAmount && showSymbol && ` ${tokenInfo?.symbol ?? ''}`} - - )} - + ) } diff --git a/src/components/TokenBalance.tsx b/src/components/TokenBalance.tsx index 2ddc87917f4..68b93ca3be4 100644 --- a/src/components/TokenBalance.tsx +++ b/src/components/TokenBalance.tsx @@ -17,7 +17,6 @@ import { hideAlert, showToast } from 'src/alert/actions' import { AssetsEvents, FiatExchangeEvents, HomeEvents } from 'src/analytics/Events' import ValoraAnalytics from 'src/analytics/ValoraAnalytics' import Dialog from 'src/components/Dialog' -import { formatValueToDisplay } from 'src/components/LegacyTokenDisplay' import { useShowOrHideAnimation } from 'src/components/useShowOrHideAnimation' import { refreshAllBalances } from 'src/home/actions' import InfoIcon from 'src/icons/InfoIcon' @@ -42,6 +41,7 @@ import { useTotalTokenBalance, } from 'src/tokens/hooks' import { tokenFetchErrorSelector, tokenFetchLoadingSelector } from 'src/tokens/selectors' +import { formatValueToDisplay } from 'src/components/TokenDisplay' import { getSupportedNetworkIdsForTokenBalances, showAssetDetailsScreen } from 'src/tokens/utils' function TokenBalance({ diff --git a/src/components/TokenDisplay.test.tsx b/src/components/TokenDisplay.test.tsx index c7e18b812da..d1b2daee559 100644 --- a/src/components/TokenDisplay.test.tsx +++ b/src/components/TokenDisplay.test.tsx @@ -3,8 +3,7 @@ import BigNumber from 'bignumber.js' import * as React from 'react' import 'react-native' import { Provider } from 'react-redux' -import TokenDisplay from 'src/components/TokenDisplay' -import { formatValueToDisplay } from 'src/components/LegacyTokenDisplay' +import TokenDisplay, { formatValueToDisplay } from 'src/components/TokenDisplay' import { LocalCurrencyCode } from 'src/localCurrency/consts' import { RootState } from 'src/redux/reducers' import { createMockStore, getElementText, RecursivePartial } from 'test/utils' diff --git a/src/components/TokenDisplay.tsx b/src/components/TokenDisplay.tsx index 44ad4625639..537a9fddf4b 100644 --- a/src/components/TokenDisplay.tsx +++ b/src/components/TokenDisplay.tsx @@ -6,7 +6,27 @@ import { getLocalCurrencySymbol, usdToLocalCurrencyRateSelector } from 'src/loca import useSelector from 'src/redux/useSelector' import { useTokenInfo } from 'src/tokens/hooks' import { LocalAmount } from 'src/transactions/types' -import { formatValueToDisplay } from 'src/components/LegacyTokenDisplay' + +const DEFAULT_DISPLAY_DECIMALS = 2 + +function calculateDecimalsToShow(value: BigNumber) { + const exponent = value?.e ?? 0 + if (exponent >= 0) { + return DEFAULT_DISPLAY_DECIMALS + } + + return Math.abs(exponent) + 1 +} + +// Formats |value| so that it shows at least 2 significant figures and at least 2 decimal places without trailing zeros. +export function formatValueToDisplay(value: BigNumber) { + let decimals = calculateDecimalsToShow(value) + let text = value.toFormat(decimals) + while (text[text.length - 1] === '0' && decimals-- > 2) { + text = text.substring(0, text.length - 1) + } + return text +} interface Props { amount: BigNumber.Value diff --git a/src/components/TokenTotalLineItem.tsx b/src/components/TokenTotalLineItem.tsx index 83ab503ec2d..e1fbcce92ce 100644 --- a/src/components/TokenTotalLineItem.tsx +++ b/src/components/TokenTotalLineItem.tsx @@ -3,13 +3,13 @@ import * as React from 'react' import { Trans, useTranslation } from 'react-i18next' import { StyleSheet, Text } from 'react-native' import LineItemRow from 'src/components/LineItemRow' -import { formatValueToDisplay } from 'src/components/LegacyTokenDisplay' import TokenDisplay from 'src/components/TokenDisplay' import { LocalCurrencyCode, LocalCurrencySymbol } from 'src/localCurrency/consts' import colors from 'src/styles/colors' import fontStyles from 'src/styles/fonts' import { useTokenInfo } from 'src/tokens/hooks' import { LocalAmount } from 'src/transactions/types' +import { formatValueToDisplay } from 'src/components/TokenDisplay' interface Props { tokenAmount: BigNumber diff --git a/src/config.ts b/src/config.ts index 92346afa730..a6547877ece 100644 --- a/src/config.ts +++ b/src/config.ts @@ -74,9 +74,6 @@ export const WALLET_BALANCE_UPPER_BOUND = new BigNumber('1e10') export const TIME_UNTIL_TOKEN_INFO_BECOMES_STALE = 12 * ONE_HOUR_IN_MILLIS -// The amount of time -export const TIME_OF_SUPPORTED_UNSYNC_HISTORICAL_PRICES = ONE_HOUR_IN_MILLIS - export const DEFAULT_FORNO_URL = DEFAULT_TESTNET === 'mainnet' ? 'https://forno.celo.org/' diff --git a/src/icons/ArrowRightThick.tsx b/src/icons/ArrowRightThick.tsx new file mode 100644 index 00000000000..f92b31a6553 --- /dev/null +++ b/src/icons/ArrowRightThick.tsx @@ -0,0 +1,15 @@ +import * as React from 'react' +import Svg, { Path } from 'react-native-svg' +import colors from 'src/styles/colors' + +interface Props { + color?: colors +} + +const ArrowRightThick = ({ color = colors.gray3 }: Props) => ( + + + +) + +export default React.memo(ArrowRightThick) diff --git a/src/icons/DataDown.tsx b/src/icons/DataDown.tsx new file mode 100644 index 00000000000..a3573c00896 --- /dev/null +++ b/src/icons/DataDown.tsx @@ -0,0 +1,15 @@ +import * as React from 'react' +import Svg, { Path } from 'react-native-svg' +import colors from 'src/styles/colors' + +interface Props { + color?: colors +} + +const DataDown = ({ color = colors.warning }: Props) => ( + + + +) + +export default React.memo(DataDown) diff --git a/src/icons/DataUp.tsx b/src/icons/DataUp.tsx new file mode 100644 index 00000000000..6b23d35b466 --- /dev/null +++ b/src/icons/DataUp.tsx @@ -0,0 +1,15 @@ +import * as React from 'react' +import Svg, { Path } from 'react-native-svg' +import colors from 'src/styles/colors' + +interface Props { + color?: colors +} + +const DataUp = ({ color = colors.greenUI }: Props) => ( + + + +) + +export default React.memo(DataUp) diff --git a/src/icons/quick-actions/More.tsx b/src/icons/quick-actions/More.tsx new file mode 100644 index 00000000000..7d38fcf43e9 --- /dev/null +++ b/src/icons/quick-actions/More.tsx @@ -0,0 +1,19 @@ +import * as React from 'react' +import Svg, { Path } from 'react-native-svg' +import Colors from 'src/styles/colors' + +interface Props { + color: Colors +} + +const QuickActionsMore = ({ color }: Props) => ( + + + +) + +export default React.memo(QuickActionsMore) diff --git a/src/navigator/Navigator.tsx b/src/navigator/Navigator.tsx index 2caf0be1ee1..41974e5f6e9 100644 --- a/src/navigator/Navigator.tsx +++ b/src/navigator/Navigator.tsx @@ -112,6 +112,7 @@ import SwapReviewScreen from 'src/swap/SwapReviewScreen' import SwapScreen from 'src/swap/SwapScreen' import AssetsScreen from 'src/tokens/Assets' import TokenBalancesScreen from 'src/tokens/TokenBalances' +import TokenDetailsScreen from 'src/tokens/TokenDetails' import TransactionDetailsScreen from 'src/transactions/feed/TransactionDetailsScreen' import Logger from 'src/utils/Logger' import { ExtractProps } from 'src/utils/typescript' @@ -554,6 +555,11 @@ const assetScreens = (Navigator: typeof Stack) => ( component={AssetsScreen} options={AssetsScreen.navigationOptions} /> + ) diff --git a/src/navigator/Screens.tsx b/src/navigator/Screens.tsx index c96a016533f..79aff12d19e 100644 --- a/src/navigator/Screens.tsx +++ b/src/navigator/Screens.tsx @@ -85,6 +85,7 @@ export enum Screens { SwapExecuteScreen = 'SwapExecuteScreen', SwapReviewScreen = 'SwapReviewScreen', TokenBalances = 'TokenBalances', + TokenDetails = 'TokenDetails', TransactionDetailsScreen = 'TransactionDetailsScreen', UpgradeScreen = 'UpgradeScreen', ValidateRecipientAccount = 'ValidateRecipientAccount', diff --git a/src/navigator/types.tsx b/src/navigator/types.tsx index e8e88072963..5065ea4d6db 100644 --- a/src/navigator/types.tsx +++ b/src/navigator/types.tsx @@ -266,6 +266,7 @@ export type StackParamList = { [Screens.SwapExecuteScreen]: undefined [Screens.SwapReviewScreen]: undefined [Screens.SwapScreenWithBack]: { fromTokenId: string } | undefined + [Screens.TokenDetails]: { tokenId: string } [Screens.TransactionDetailsScreen]: { transaction: TokenTransaction } diff --git a/src/send/SendAmount/SendAmountValue.tsx b/src/send/SendAmount/SendAmountValue.tsx index 8cd61b8a3fc..afc31b41552 100644 --- a/src/send/SendAmount/SendAmountValue.tsx +++ b/src/send/SendAmount/SendAmountValue.tsx @@ -2,7 +2,6 @@ import BigNumber from 'bignumber.js' import React from 'react' import { useTranslation } from 'react-i18next' import { StyleSheet, Text, View } from 'react-native' -import { formatValueToDisplay } from 'src/components/LegacyTokenDisplay' import Touchable from 'src/components/Touchable' import SwapInput from 'src/icons/SwapInput' import { getLocalCurrencyCode, getLocalCurrencySymbol } from 'src/localCurrency/selectors' @@ -10,6 +9,7 @@ import useSelector from 'src/redux/useSelector' import colors from 'src/styles/colors' import fontStyles from 'src/styles/fonts' import { useTokenInfoByAddress, useTokenToLocalAmount } from 'src/tokens/hooks' +import { formatValueToDisplay } from 'src/components/TokenDisplay' interface Props { inputAmount: string diff --git a/src/statsig/constants.ts b/src/statsig/constants.ts index 71937a3439d..466ac41befb 100644 --- a/src/statsig/constants.ts +++ b/src/statsig/constants.ts @@ -93,6 +93,8 @@ export const DynamicConfigs = { defaultValues: { showCico: [networkConfig.defaultNetworkId], showBalances: [networkConfig.defaultNetworkId], + showSend: [networkConfig.defaultNetworkId], + showSwap: [networkConfig.defaultNetworkId], showTransfers: [networkConfig.defaultNetworkId], }, }, diff --git a/src/styles/fonts.tsx b/src/styles/fonts.tsx index d143e90c9bb..0c4e7b20cf2 100644 --- a/src/styles/fonts.tsx +++ b/src/styles/fonts.tsx @@ -81,7 +81,7 @@ export const typeScale = StyleSheet.create({ lineHeight: 28, }, labelLarge: { - fontFamily: Inter.Bold, + fontFamily: Inter.Medium, fontSize: 18, lineHeight: 28, }, diff --git a/src/swap/SwapReviewScreen.tsx b/src/swap/SwapReviewScreen.tsx index 229c09b0a57..5dc6a64f51d 100644 --- a/src/swap/SwapReviewScreen.tsx +++ b/src/swap/SwapReviewScreen.tsx @@ -14,7 +14,7 @@ import BackButton from 'src/components/BackButton' import Button, { BtnSizes } from 'src/components/Button' import Dialog from 'src/components/Dialog' import CustomHeader from 'src/components/header/CustomHeader' -import LegacyTokenDisplay, { formatValueToDisplay } from 'src/components/LegacyTokenDisplay' +import LegacyTokenDisplay from 'src/components/LegacyTokenDisplay' import Touchable from 'src/components/Touchable' import { useFeeCurrency } from 'src/fees/hooks' import InfoIcon from 'src/icons/InfoIcon' @@ -32,6 +32,7 @@ import { divideByWei } from 'src/utils/formatting' import Logger from 'src/utils/Logger' import networkConfig from 'src/web3/networkConfig' import { walletAddressSelector } from 'src/web3/selectors' +import { formatValueToDisplay } from 'src/components/TokenDisplay' const TAG = 'SWAP_REVIEW_SCREEN' const initialUserInput = { diff --git a/src/tokens/AssetItem.tsx b/src/tokens/AssetItem.tsx index c1a4c20dd5e..b473bd5c303 100644 --- a/src/tokens/AssetItem.tsx +++ b/src/tokens/AssetItem.tsx @@ -4,17 +4,16 @@ import { Image, StyleSheet, Text, View } from 'react-native' import { TouchableWithoutFeedback } from 'react-native-gesture-handler' import { AssetsEvents } from 'src/analytics/Events' import ValoraAnalytics from 'src/analytics/ValoraAnalytics' +import LegacyTokenDisplay from 'src/components/LegacyTokenDisplay' import PercentageIndicator from 'src/components/PercentageIndicator' import TokenDisplay from 'src/components/TokenDisplay' -import LegacyTokenDisplay from 'src/components/LegacyTokenDisplay' -import { TIME_OF_SUPPORTED_UNSYNC_HISTORICAL_PRICES } from 'src/config' import { Position } from 'src/positions/types' import Colors from 'src/styles/colors' import fontStyles from 'src/styles/fonts' import { Spacing } from 'src/styles/styles' import { TokenBalance } from 'src/tokens/slice' +import { isHistoricalPriceUpdated } from 'src/tokens/utils' import { Currency } from 'src/utils/currencies' -import { ONE_DAY_IN_MILLIS } from 'src/utils/time' export const PositionItem = ({ position }: { position: Position }) => { const balanceInDecimal = @@ -85,14 +84,6 @@ export const TokenBalanceItem = ({ token: TokenBalance showPriceChangeIndicatorInBalances: boolean }) => { - const isHistoricalPriceUpdated = () => { - return ( - token.historicalPricesUsd?.lastDay && - TIME_OF_SUPPORTED_UNSYNC_HISTORICAL_PRICES > - Math.abs(token.historicalPricesUsd.lastDay.at - (Date.now() - ONE_DAY_IN_MILLIS)) - ) - } - const onPress = () => { ValoraAnalytics.track(AssetsEvents.tap_asset, { assetType: 'token', @@ -130,7 +121,7 @@ export const TokenBalanceItem = ({ {showPriceChangeIndicatorInBalances && token.historicalPricesUsd && - isHistoricalPriceUpdated() && ( + isHistoricalPriceUpdated(token) && ( { + beforeEach(() => { + jest.clearAllMocks() + }) + it('renders tokens and collectibles tabs when positions is disabled', () => { jest.mocked(getFeatureGate).mockReturnValue(false) const store = createMockStore(storeWithPositions) @@ -198,6 +203,24 @@ describe('AssetsScreen', () => { expect(queryAllByTestId('PositionItem')).toHaveLength(0) }) + it('clicking a token navigates to the token details screen and fires analytics event', () => { + jest.mocked(getFeatureGate).mockReturnValue(false) + const store = createMockStore(storeWithPositions) + + const { getAllByTestId } = render( + + + + ) + + expect(getAllByTestId('TokenBalanceItem')).toHaveLength(2) + + fireEvent.press(getAllByTestId('TokenBalanceItem')[0]) + expect(navigate).toHaveBeenCalledTimes(1) + expect(navigate).toHaveBeenCalledWith(Screens.TokenDetails, { tokenId: mockCusdTokenId }) + expect(ValoraAnalytics.track).toHaveBeenCalledTimes(1) + }) + it('hides claim rewards if feature gate is false', () => { jest .mocked(getFeatureGate) diff --git a/src/tokens/Assets.tsx b/src/tokens/Assets.tsx index 9ca2032956a..4fad8a2271b 100644 --- a/src/tokens/Assets.tsx +++ b/src/tokens/Assets.tsx @@ -52,7 +52,11 @@ import { useTotalTokenBalance, } from 'src/tokens/hooks' import { TokenBalance } from 'src/tokens/slice' -import { getSupportedNetworkIdsForTokenBalances, sortByUsdBalance } from 'src/tokens/utils' +import { + getSupportedNetworkIdsForTokenBalances, + getTokenAnalyticsProps, + sortByUsdBalance, +} from 'src/tokens/utils' const DEVICE_WIDTH_BREAKPOINT = 340 @@ -279,7 +283,20 @@ function AssetsScreen({ navigation, route }: Props) { if (assetIsPosition(item)) { return } - return + return ( + { + navigate(Screens.TokenDetails, { tokenId: item.tokenId }) + ValoraAnalytics.track(AssetsEvents.tap_asset, { + ...getTokenAnalyticsProps(item), + title: item.symbol, + description: item.name, + assetType: 'token', + }) + }} + /> + ) } const tabBarItems = useMemo(() => { diff --git a/src/tokens/TokenDetails.test.tsx b/src/tokens/TokenDetails.test.tsx new file mode 100644 index 00000000000..8ad8c322aba --- /dev/null +++ b/src/tokens/TokenDetails.test.tsx @@ -0,0 +1,299 @@ +import { fireEvent, render } from '@testing-library/react-native' +import React from 'react' +import { Provider } from 'react-redux' +import { navigate } from 'src/navigator/NavigationService' +import { Screens } from 'src/navigator/Screens' +import TokenDetailsScreen from 'src/tokens/TokenDetails' +import { ONE_DAY_IN_MILLIS } from 'src/utils/time' +import MockedNavigator from 'test/MockedNavigator' +import { createMockStore } from 'test/utils' +import { mockCeloTokenId, mockPoofTokenId, mockTokenBalances } from 'test/values' + +jest.mock('src/statsig', () => ({ + getDynamicConfigParams: jest.fn(() => { + return { + showCico: ['celo-alfajores'], + showSend: ['celo-alfajores'], + showSwap: ['celo-alfajores'], + } + }), +})) + +describe('TokenDetails', () => { + it('renders title, balance and token balance item', () => { + const store = createMockStore({ + tokens: { + tokenBalances: { + [mockPoofTokenId]: mockTokenBalances[mockPoofTokenId], + }, + }, + }) + + const { getByTestId, getByText, queryByTestId } = render( + + + + ) + + expect(getByTestId('TokenDetails/TitleImage')).toBeTruthy() + expect(getByTestId('TokenDetails/Title')).toHaveTextContent('Poof Governance Token') + expect(getByTestId('TokenDetails/Balance')).toHaveTextContent('₱0.67') + expect(getByText('tokenDetails.yourBalance')).toBeTruthy() + expect(getByTestId('TokenBalanceItem')).toBeTruthy() + expect(queryByTestId('TokenDetails/LearnMore')).toBeFalsy() + }) + + it('renders learn more if token has infoUrl', () => { + const store = createMockStore({ + tokens: { + tokenBalances: { + [mockPoofTokenId]: { + ...mockTokenBalances[mockPoofTokenId], + infoUrl: 'https://poofToken', + }, + }, + }, + }) + + const { getByTestId } = render( + + + + ) + + expect(getByTestId('TokenDetails/LearnMore')).toBeTruthy() + fireEvent.press(getByTestId('TokenDetails/LearnMore')) + expect(navigate).toHaveBeenCalledWith(Screens.WebViewScreen, { uri: 'https://poofToken' }) + }) + + it('renders price unavailable if token price is not present', () => { + const store = createMockStore({ + tokens: { + tokenBalances: { + [mockPoofTokenId]: { + ...mockTokenBalances[mockPoofTokenId], + priceUsd: undefined, + }, + }, + }, + }) + + const { queryByTestId, getByText } = render( + + + + ) + + expect(queryByTestId('TokenDetails/PriceDelta')).toBeFalsy() + expect(getByText('tokenDetails.priceUnavailable')).toBeTruthy() + }) + + it('renders no price info if historical price info is not available', () => { + const store = createMockStore({ + tokens: { + tokenBalances: { + [mockPoofTokenId]: mockTokenBalances[mockPoofTokenId], + }, + }, + }) + + const { queryByTestId, queryByText } = render( + + + + ) + + expect(queryByTestId('TokenDetails/PriceDelta')).toBeFalsy() + expect(queryByText('tokenDetails.priceUnavailable')).toBeFalsy() + }) + + it('renders no price info if historical price info is out of date', () => { + const store = createMockStore({ + tokens: { + tokenBalances: { + [mockPoofTokenId]: { + ...mockTokenBalances[mockPoofTokenId], + historicalPricesUsd: { + lastDay: { + at: Date.now() - 2 * ONE_DAY_IN_MILLIS, + price: 1, + }, + }, + }, + }, + }, + }) + + const { queryByTestId, queryByText } = render( + + + + ) + + expect(queryByTestId('TokenDetails/PriceDelta')).toBeFalsy() + expect(queryByText('tokenDetails.priceUnavailable')).toBeFalsy() + }) + + it('renders price delta if historical price is available and one day old', () => { + const store = createMockStore({ + tokens: { + tokenBalances: { + [mockPoofTokenId]: { + ...mockTokenBalances[mockPoofTokenId], + historicalPricesUsd: { + lastDay: { + at: Date.now() - ONE_DAY_IN_MILLIS, + price: 1, + }, + }, + }, + }, + }, + }) + + const { getByTestId, queryByText } = render( + + + + ) + + expect(getByTestId('TokenDetails/PriceDelta')).toBeTruthy() + expect(queryByText('tokenDetails.priceUnavailable')).toBeFalsy() + }) + + it('renders send action only if token has balance, is not swappable and not a CICO token', () => { + const store = createMockStore({ + tokens: { + tokenBalances: { + [mockPoofTokenId]: mockTokenBalances[mockPoofTokenId], + }, + }, + app: { + showSwapMenuInDrawerMenu: true, + }, + }) + + const { getByTestId, queryByTestId } = render( + + + + ) + + expect(getByTestId('TokenDetails/Action/Send')).toBeTruthy() + expect(queryByTestId('TokenDetails/Action/Swap')).toBeFalsy() + expect(queryByTestId('TokenDetails/Action/Add')).toBeFalsy() + expect(queryByTestId('TokenDetails/Action/Withdraw')).toBeFalsy() + expect(queryByTestId('TokenDetails/Action/More')).toBeFalsy() + }) + + it('renders send and swap action only if token has balance, is swappable and not a CICO token', () => { + const store = createMockStore({ + tokens: { + tokenBalances: { + [mockPoofTokenId]: { ...mockTokenBalances[mockPoofTokenId], isSwappable: true }, + }, + }, + app: { + showSwapMenuInDrawerMenu: true, + }, + }) + + const { getByTestId, queryByTestId } = render( + + + + ) + + expect(getByTestId('TokenDetails/Action/Send')).toBeTruthy() + expect(getByTestId('TokenDetails/Action/Swap')).toBeTruthy() + expect(queryByTestId('TokenDetails/Action/Add')).toBeFalsy() + expect(queryByTestId('TokenDetails/Action/Withdraw')).toBeFalsy() + expect(queryByTestId('TokenDetails/Action/More')).toBeFalsy() + }) + + it('renders send, swap and more if token has balance, is swappable and a CICO token', () => { + const store = createMockStore({ + tokens: { + tokenBalances: { + [mockCeloTokenId]: { + ...mockTokenBalances[mockCeloTokenId], + balance: '10', + isSwappable: true, + }, + }, + }, + app: { + showSwapMenuInDrawerMenu: true, + }, + }) + + const { getByTestId, queryByTestId } = render( + + + + ) + + expect(getByTestId('TokenDetails/Action/Send')).toBeTruthy() + expect(getByTestId('TokenDetails/Action/Swap')).toBeTruthy() + expect(queryByTestId('TokenDetails/Action/Add')).toBeFalsy() + expect(queryByTestId('TokenDetails/Action/Withdraw')).toBeFalsy() + expect(getByTestId('TokenDetails/Action/More')).toBeTruthy() + }) + + it('renders add only for CICO token with 0 balance', () => { + const store = createMockStore({ + tokens: { + tokenBalances: { + [mockCeloTokenId]: { + ...mockTokenBalances[mockCeloTokenId], + isSwappable: true, + }, + }, + }, + app: { + showSwapMenuInDrawerMenu: true, + }, + }) + + const { getByTestId, queryByTestId } = render( + + + + ) + + expect(queryByTestId('TokenDetails/Action/Send')).toBeFalsy() + expect(queryByTestId('TokenDetails/Action/Swap')).toBeFalsy() + expect(getByTestId('TokenDetails/Action/Add')).toBeTruthy() + expect(queryByTestId('TokenDetails/Action/Withdraw')).toBeFalsy() + expect(queryByTestId('TokenDetails/Action/More')).toBeFalsy() + }) + + it('hides swap action and shows more action if token is swappable, has balance and CICO token but swapfeature gate is false', () => { + const store = createMockStore({ + tokens: { + tokenBalances: { + [mockCeloTokenId]: { + ...mockTokenBalances[mockCeloTokenId], + balance: '10', + isSwappable: true, + }, + }, + }, + app: { + showSwapMenuInDrawerMenu: false, + }, + }) + + const { getByTestId, queryByTestId } = render( + + + + ) + + expect(getByTestId('TokenDetails/Action/Send')).toBeTruthy() + expect(queryByTestId('TokenDetails/Action/Swap')).toBeFalsy() + expect(getByTestId('TokenDetails/Action/Add')).toBeTruthy() + expect(getByTestId('TokenDetails/Action/More')).toBeTruthy() + expect(queryByTestId('TokenDetails/Action/Withdraw')).toBeFalsy() + }) +}) diff --git a/src/tokens/TokenDetails.tsx b/src/tokens/TokenDetails.tsx new file mode 100644 index 00000000000..75489817afa --- /dev/null +++ b/src/tokens/TokenDetails.tsx @@ -0,0 +1,342 @@ +import { NativeStackScreenProps } from '@react-navigation/native-stack' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { ScrollView, StyleSheet, Text, View } from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import { useSelector } from 'react-redux' +import { AssetsEvents } from 'src/analytics/Events' +import { TokenProperties } from 'src/analytics/Properties' +import ValoraAnalytics from 'src/analytics/ValoraAnalytics' +import Button, { BtnSizes } from 'src/components/Button' +import PercentageIndicator from 'src/components/PercentageIndicator' +import TokenDisplay from 'src/components/TokenDisplay' +import TokenIcon, { IconSize } from 'src/components/TokenIcon' +import Touchable from 'src/components/Touchable' +import { TOKEN_MIN_AMOUNT } from 'src/config' +import { CICOFlow } from 'src/fiatExchanges/utils' +import ArrowRightThick from 'src/icons/ArrowRightThick' +import DataDown from 'src/icons/DataDown' +import DataUp from 'src/icons/DataUp' +import QuickActionsAdd from 'src/icons/quick-actions/Add' +import QuickActionsMore from 'src/icons/quick-actions/More' +import QuickActionsSend from 'src/icons/quick-actions/Send' +import QuickActionsSwap from 'src/icons/quick-actions/Swap' +import QuickActionsWithdraw from 'src/icons/quick-actions/Withdraw' +import { headerWithBackButton } from 'src/navigator/Headers' +import { navigate } from 'src/navigator/NavigationService' +import { Screens } from 'src/navigator/Screens' +import { isAppSwapsEnabledSelector } from 'src/navigator/selectors' +import { StackParamList } from 'src/navigator/types' +import Colors from 'src/styles/colors' +import { typeScale } from 'src/styles/fonts' +import { Spacing } from 'src/styles/styles' +import { TokenBalanceItem } from 'src/tokens/TokenBalanceItem' +import { + useCashInTokens, + useCashOutTokens, + useSendableTokens, + useSwappableTokens, + useTokenInfo, +} from 'src/tokens/hooks' +import { TokenBalance } from 'src/tokens/slice' +import { TokenDetailsActionName } from 'src/tokens/types' +import { getTokenAnalyticsProps, isCicoToken, isHistoricalPriceUpdated } from 'src/tokens/utils' +import { Network } from 'src/transactions/types' + +type Props = NativeStackScreenProps + +const MAX_ACTION_BUTTONS = 3 + +export default function TokenDetailsScreen({ route }: Props) { + const { tokenId } = route.params + const { t } = useTranslation() + const token = useTokenInfo(tokenId) + + if (!token) { + throw new Error(`token with id ${tokenId} not found`) + } + + return ( + + + + + + {token.name} + + + + {!token.isStableCoin && } + + {t('tokenDetails.yourBalance')} + + {token.infoUrl && ( + + )} + + + ) +} + +TokenDetailsScreen.navigationOptions = { + ...headerWithBackButton, +} + +function PriceInfo({ token }: { token: TokenBalance }) { + const { t } = useTranslation() + if (!token.priceUsd) { + return ( + + {t('tokenDetails.priceUnavailable')} + + ) + } + + if (!token.historicalPricesUsd || !isHistoricalPriceUpdated(token)) { + return null + } + + return ( + + + + ) +} + +function Actions({ token }: { token: TokenBalance }) { + const { t } = useTranslation() + const sendableTokens = useSendableTokens() + const swappableTokens = useSwappableTokens() + const cashInTokens = useCashInTokens() + const cashOutTokens = useCashOutTokens() + const isSwapEnabled = useSelector(isAppSwapsEnabledSelector) + const showWithdraw = !!cashOutTokens.find((tokenInfo) => tokenInfo.tokenId === token.tokenId) + + const onPressCicoAction = (flow: CICOFlow) => { + const tokenSymbol = token.symbol + // this should always be true given that we only show Add / Withdraw if a + // token is CiCoCurrency, but adding it here to ensure type safety + if (isCicoToken(tokenSymbol)) { + navigate(Screens.FiatExchangeAmount, { + currency: tokenSymbol, + tokenId: token.tokenId, + flow, + network: Network.Celo, + }) + } + } + + const actions = [ + { + name: TokenDetailsActionName.Send, + text: t('tokenDetails.actions.send'), + iconComponent: QuickActionsSend, + onPress: () => { + // TODO: this should change to passing tokenId when #4242 is merged + navigate(Screens.Send, { defaultTokenOverride: token.address! }) + }, + visible: !!sendableTokens.find((tokenInfo) => tokenInfo.tokenId === token.tokenId), + }, + { + name: TokenDetailsActionName.Swap, + text: t('tokenDetails.actions.swap'), + iconComponent: QuickActionsSwap, + onPress: () => { + navigate(Screens.SwapScreenWithBack, { fromTokenId: token.tokenId }) + }, + visible: + isSwapEnabled && + !!swappableTokens.find((tokenInfo) => tokenInfo.tokenId === token.tokenId) && + token.balance.gt(TOKEN_MIN_AMOUNT), + }, + { + name: TokenDetailsActionName.Add, + text: t('tokenDetails.actions.add'), + iconComponent: QuickActionsAdd, + onPress: () => { + onPressCicoAction(CICOFlow.CashIn) + }, + visible: !!cashInTokens.find((tokenInfo) => tokenInfo.tokenId === token.tokenId), + }, + { + name: TokenDetailsActionName.Withdraw, + text: t('tokenDetails.actions.withdraw'), + iconComponent: QuickActionsWithdraw, + onPress: () => { + onPressCicoAction(CICOFlow.CashOut) + }, + visible: showWithdraw, + }, + ].filter((action) => action.visible) + + const moreAction = { + name: TokenDetailsActionName.More, + text: t('tokenDetails.actions.more'), + iconComponent: QuickActionsMore, + onPress: () => { + // TODO(ACT-917): open bottom sheet + }, + } + + // if there are 4 actions or 3 actions and one of them is withdraw, show the + // More button. The withdraw condition exists to avoid the visual overflow, + // since the icon + withdraw text is bigger + const actionButtons = + actions.length > MAX_ACTION_BUTTONS || (actions.length === MAX_ACTION_BUTTONS && showWithdraw) + ? [...actions.slice(0, MAX_ACTION_BUTTONS - 1), moreAction] + : actions + + return ( + + {actionButtons.map((action) => ( +