diff --git a/.yarn/patches/@shopify-flash-list-npm-1.4.3-dc03c28fdd.patch b/.yarn/patches/@shopify-flash-list-npm-1.4.3-dc03c28fdd.patch deleted file mode 100644 index 7156cebbded..00000000000 --- a/.yarn/patches/@shopify-flash-list-npm-1.4.3-dc03c28fdd.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/android/src/main/kotlin/com/shopify/reactnative/flash_list/AutoLayoutView.kt b/android/src/main/kotlin/com/shopify/reactnative/flash_list/AutoLayoutView.kt -index 6b78bd93e244649ee5d80bf4436bd742994d91fb..457179850746db36f568737e36d31f18968a1e80 100644 ---- a/android/src/main/kotlin/com/shopify/reactnative/flash_list/AutoLayoutView.kt -+++ b/android/src/main/kotlin/com/shopify/reactnative/flash_list/AutoLayoutView.kt -@@ -26,7 +26,7 @@ class AutoLayoutView(context: Context) : ReactViewGroup(context) { - - /** Overriding draw instead of onLayout. RecyclerListView uses absolute positions for each and every item which means that changes in child layouts may not trigger onLayout on this container. The same layout - * can still cause views to overlap. Therefore, it makes sense to override draw to do correction. */ -- override fun dispatchDraw(canvas: Canvas?) { -+ override fun dispatchDraw(canvas: Canvas) { - fixLayout() - fixFooter() - super.dispatchDraw(canvas) diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000000..f70773659eb --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @uniswap/web-admins diff --git a/RELEASE b/RELEASE index ffe6ba6210c..a6fbc253080 100644 --- a/RELEASE +++ b/RELEASE @@ -1,11 +1,63 @@ -Back again with more updates to our Wallet! Here’s what is new: +IPFS hash of the deployment: +- CIDv0: `Qmf6XKUvbFPHvKbi1atVQVyMwr4iVxCSpLHffiTio8Hw2h` +- CIDv1: `bafybeihy7ayma2n3ltw6zn2aewegv4eyvxo4f52n2ixpnxgyx47xidce5i` -Onboarding Polish — We updated many steps in our onboarding flow to be clearer and more intuitive. It is easier than ever to set up a new wallet (or get your friends and family to do the same…) +The latest release is always mirrored at [app.uniswap.org](https://app.uniswap.org). -Increased Gas Buffer — We updated our wallet logic to default to leaving a larger quantity of native tokens in your wallet after a swap. With gas prices rising, we want to ensure that users never get stuck without the ability to make a swap! +You can also access the Uniswap Interface from an IPFS gateway. +**BEWARE**: The Uniswap interface uses [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) to remember your settings, such as which tokens you have imported. +**You should always use an IPFS gateway that enforces origin separation**, or our hosted deployment of the latest release at [app.uniswap.org](https://app.uniswap.org). +Your Uniswap settings are never remembered across different URLs. + +IPFS gateways: +- https://bafybeihy7ayma2n3ltw6zn2aewegv4eyvxo4f52n2ixpnxgyx47xidce5i.ipfs.dweb.link/ +- https://bafybeihy7ayma2n3ltw6zn2aewegv4eyvxo4f52n2ixpnxgyx47xidce5i.ipfs.cf-ipfs.com/ +- [ipfs://Qmf6XKUvbFPHvKbi1atVQVyMwr4iVxCSpLHffiTio8Hw2h/](ipfs://Qmf6XKUvbFPHvKbi1atVQVyMwr4iVxCSpLHffiTio8Hw2h/) + +## 5.9.0 (2024-02-12) + + +### Features + +* **web:** [info] fix TDP/PDP header mobile responsiveness (#5636) 64edb1a +* **web:** [info] move chart type selector and time selector to below chart (#5880) 524734a +* **web:** add copy tooltip behavior (#5919) aed3633 +* **web:** add limit price inversion (#6198) de2d048 +* **web:** Allow feature flag overriding through URL parameters (#6182) 01181cf +* **web:** change output currency from limit price panel (#6192) 8651e59 +* **web:** deploy v2 everywhere feature flag (#6161) dfa90e7 +* **web:** do not reset scroll between tabs (#6090) 4d0d682 +* **web:** explore/tokens and /explore should not be interchangeable (#6088) f4ca806 +* **web:** Make limit price section first and auto-fill with USDC if no output currency selected (#6013) b8ecb58 +* **web:** more dns gateway updates (#5964) 5051029 +* **web:** outage banner for arbitrum, optimism, polygon (#6218) 30af199 +* **web:** remove increment buttons from Limit Price Input (#6189) b27d874 +* **web:** Update submission endpoint for limits (#6236) 35c1e29 +* **web:** use pill toggle for TDP/PDP chart times (#6002) f3631b4 +* **web:** use protocol stats for explore charts (#6030) d42aa99 + + +### Bug Fixes + +* **web:** [landing-page] add missing translations, improve layout responsiveness, update brand assets (#6166) 38e9e47 +* **web:** de-flake swap flow logging test (#6150) 0f7214e +* **web:** de-flake TDP cypress test (#6147) c999070 +* **web:** fix and re-enable swap e2e tests (#6143) 6922d5b +* **web:** followup fixes for outage banner (#6229) a6db458 +* **web:** left aligned input send (#6144) cd926ae +* **web:** make new landing page enabled by default (#6287) ce952ad +* **web:** re-enable some cypress tests (#6122) 0aaecb1 +* **web:** send currency logos in send review are extending too far on safari (#6137) b6eecf2 +* **web:** send numbers cutoff on safari (#6136) eed1540 +* **web:** swap out OP for LDO on homepage (#6206) ea0ea23 +* **web:** update gql schema (#6246) 07432fe +* **web:** use sentence casing (#6046) d2958ee + + +### Tests + +* **web:** add e2e test for cancelling X order (#6146) 131aedc +* **web:** update permit2 tests to use new ConfirmSwapModalV2 (#6165) 02c6883 +* **web:** use ConfirmSwapModalV2 in swap errors e2e tests (#6167) 25e4bad -Other notable changes: -- External profile UI polish -- Token details page polish -- Bug fixes diff --git a/VERSION b/VERSION index 291f35bd6bd..ec9f5b62bea 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -mobile/1.20.1 \ No newline at end of file +web/5.9.0 \ No newline at end of file diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle index 56df9ef428e..83278c886b4 100644 --- a/apps/mobile/android/app/build.gradle +++ b/apps/mobile/android/app/build.gradle @@ -125,17 +125,17 @@ android { dev { isDefault(true) applicationIdSuffix ".dev" - versionName "1.20.1" + versionName "1.21" dimension "variant" } beta { applicationIdSuffix ".beta" - versionName "1.20.1" + versionName "1.21" dimension "variant" } prod { dimension "variant" - versionName "1.20.1" + versionName "1.21" } } diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/MainActivity.kt b/apps/mobile/android/app/src/main/java/com/uniswap/MainActivity.kt index d5b35c60b4b..013d8f2d5e3 100644 --- a/apps/mobile/android/app/src/main/java/com/uniswap/MainActivity.kt +++ b/apps/mobile/android/app/src/main/java/com/uniswap/MainActivity.kt @@ -32,7 +32,7 @@ class MainActivity : ReactActivity() { window.isNavigationBarContrastEnforced = false } val sharedI18nUtilInstance = I18nUtil.getInstance() - sharedI18nUtilInstance.allowRTL(applicationContext, true) + sharedI18nUtilInstance.allowRTL(applicationContext, false) } /** diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/MainApplication.kt b/apps/mobile/android/app/src/main/java/com/uniswap/MainApplication.kt index 09861e8b986..7ef4498dcac 100644 --- a/apps/mobile/android/app/src/main/java/com/uniswap/MainApplication.kt +++ b/apps/mobile/android/app/src/main/java/com/uniswap/MainApplication.kt @@ -9,6 +9,7 @@ import com.facebook.react.defaults.DefaultReactNativeHost; import com.facebook.soloader.SoLoader import androidx.multidex.MultiDexApplication; import com.shopify.reactnativeperformance.ReactNativePerformance +import com.uniswap.onboarding.scantastic.ScantasticEncryptionModule class MainApplication : MultiDexApplication(), ReactApplication { private val mReactNativeHost: ReactNativeHost = @@ -23,6 +24,7 @@ class MainApplication : MultiDexApplication(), ReactApplication { // packages.add(new MyReactNativePackage()); add(UniswapPackage()) add(RNCloudStorageBackupsManagerModule()) + add(ScantasticEncryptionModule()) } override fun getJSMainModuleName(): String { diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/scantastic/ScantasticEncryption.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/scantastic/ScantasticEncryption.kt new file mode 100644 index 00000000000..94eea5dc0a8 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/scantastic/ScantasticEncryption.kt @@ -0,0 +1,79 @@ +package com.uniswap.onboarding.scantastic + +import com.uniswap.RnEthersRs +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import javax.crypto.spec.OAEPParameterSpec +import javax.crypto.spec.PSource +import java.security.spec.MGF1ParameterSpec +import java.math.BigInteger +import java.util.Base64 +import java.security.KeyFactory +import java.security.spec.RSAPublicKeySpec +import javax.crypto.Cipher + +class ScantasticError(override val message: String) : Exception(message) + +class ScantasticEncryption(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + override fun getName() = "ScantasticEncryption" + + private val rnEthersRS = RnEthersRs(reactContext) + + @ReactMethod + fun getEncryptedMnemonic(mnemonicId: String, n: String, e: String, promise: Promise) { + val mnemonic = rnEthersRS.retrieveMnemonic(mnemonicId) ?: run { + promise.reject(ScantasticError("Failed to retrieve mnemonic")) + return + } + + val publicKey = try { + generatePublicRSAKey(n, e) + } catch (ex: Exception) { + promise.reject(ScantasticError("Failed to generate public Key: ${ex.message}")) + return + } + + val encodedCiphertext = try { + encryptForStorage(mnemonic, publicKey) + } catch (ex: Exception) { + promise.reject(ScantasticError("Failed to encrypt the mnemonic: ${ex.message}")) + return + } + // Normal B64 not URL encoded, use getUrlDecoder() if you need URL encoded format + val b64encodedCiphertext = Base64.getEncoder().encodeToString(encodedCiphertext) + promise.resolve(b64encodedCiphertext) + } + + @Throws(Exception::class) + private fun generatePublicRSAKey(modulusStr: String, exponentStr: String): java.security.PublicKey { + val modulus = BigInteger(1, base64UrlToStandardBase64(modulusStr).let { Base64.getDecoder().decode(it) }) + val exponent = BigInteger(1, base64UrlToStandardBase64(exponentStr).let { Base64.getDecoder().decode(it) }) + val keySpec = RSAPublicKeySpec(modulus, exponent) + return KeyFactory.getInstance("RSA").generatePublic(keySpec) + } + + // It is unclear why URLDecoder doesn't do this by default and we have to do it here instead. + private fun base64UrlToStandardBase64(input: String): String { + var base64 = input.replace("-", "+").replace("_", "/") + while (base64.length % 4 != 0) { + base64 += "=" + } + return base64 + } + + @Throws(Exception::class) + private fun encryptForStorage(plaintext: String, publicKey: java.security.PublicKey): ByteArray { + val oaepParams = OAEPParameterSpec( + "SHA-256", + "MGF1", + MGF1ParameterSpec.SHA256, + PSource.PSpecified.DEFAULT + ) + + val cipher = Cipher.getInstance("RSA/ECB/OAEPPadding") + cipher.init(Cipher.ENCRYPT_MODE, publicKey, oaepParams) + return cipher.doFinal(plaintext.toByteArray()) + } +} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/scantastic/ScantasticEncryptionModule.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/scantastic/ScantasticEncryptionModule.kt new file mode 100644 index 00000000000..aec8726e58b --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/scantastic/ScantasticEncryptionModule.kt @@ -0,0 +1,20 @@ +package com.uniswap.onboarding.scantastic + +import android.view.View +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ReactShadowNode +import com.facebook.react.uimanager.ViewManager + +class ScantasticEncryptionModule : ReactPackage { + + override fun createViewManagers( + reactContext: ReactApplicationContext + ): MutableList>> = mutableListOf() + + override fun createNativeModules( + reactContext: ReactApplicationContext + ): MutableList = listOf(ScantasticEncryption(reactContext)).toMutableList() +} + diff --git a/apps/mobile/babel.config.js b/apps/mobile/babel.config.js index e040be1a964..05513dc9aee 100644 --- a/apps/mobile/babel.config.js +++ b/apps/mobile/babel.config.js @@ -4,7 +4,6 @@ const inProduction = NODE_ENV === 'production' module.exports = function (api) { api.cache.using(() => process.env.NODE_ENV) - var plugins = [ [ 'module:react-native-dotenv', diff --git a/apps/mobile/ios/Podfile b/apps/mobile/ios/Podfile index 12023c4fe3c..5a14f7ce24a 100644 --- a/apps/mobile/ios/Podfile +++ b/apps/mobile/ios/Podfile @@ -50,10 +50,6 @@ target 'Uniswap' do installer.pods_project.targets.each do |target| target.build_configurations.each do |config| - if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < '9.0'.to_f - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '9.0' - end - config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO' config.build_settings['APPLICATION_EXTENSION_API_ONLY'] = 'No' end diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock index 03c7bdfa574..27b64e29bc9 100644 --- a/apps/mobile/ios/Podfile.lock +++ b/apps/mobile/ios/Podfile.lock @@ -1272,7 +1272,7 @@ PODS: - nanopb (< 2.30910.0, >= 2.30908.0) - React-Core - RNFBApp - - RNFlashList (1.4.3): + - RNFlashList (1.6.3): - React-Core - RNGestureHandler (2.9.0): - React-Core @@ -1660,7 +1660,7 @@ SPEC CHECKSUMS: Apollo: fe380f40e55e501a2499dd5885fab0cdf082b2bb AppsFlyerFramework: 88a6eed37ad52bcee4ad74232efa8e22809d06c9 Argon2Swift: 99482c1b8122a03524b61e41c4903a9548e7c33b - boost: 57d2868c099736d80fcd648bf211b4431e51a558 + boost: 0a937fbcfdd646fca221c4f1d9750d7ccfdfc2dc BoringSSL-GRPC: 3175b25143e648463a56daeaaa499c6cb86dad33 Burnt: 708556f6283e1b81767e6642e088819d85d1ea08 DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54 @@ -1760,7 +1760,7 @@ SPEC CHECKSUMS: RNFBApp: a3026bdd951dd7a3a88e8e6518b53ddd2f8b3809 RNFBAuth: 553c6e66d3c70e086799104dad4a554c2663c337 RNFBFirestore: a60e6005e071b31360a5bf651eb403b36c7db7de - RNFlashList: ade81b4e928ebd585dd492014d40fb8d0e848aab + RNFlashList: 4b4b6b093afc0df60ae08f9cbf6ccd4c836c667a RNGestureHandler: 071d7a9ad81e8b83fe7663b303d132406a7d8f39 RNImageColors: 9ac05083b52d5c350e6972650ae3ba0e556466c1 RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 @@ -1779,6 +1779,6 @@ SPEC CHECKSUMS: Yoga: 135109c9b8c5d1a8af3a58d21cd4c7aa7f3bf555 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 632909767e5e0022317f148f858c5b6f587e5900 +PODFILE CHECKSUM: 22ab4b87e0bceeaa9e245c485c36fca595139568 COCOAPODS: 1.14.3 diff --git a/apps/mobile/ios/Uniswap.xcodeproj/project.pbxproj b/apps/mobile/ios/Uniswap.xcodeproj/project.pbxproj index 25274efba11..8e7a7f16f4e 100644 --- a/apps/mobile/ios/Uniswap.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/Uniswap.xcodeproj/project.pbxproj @@ -2450,7 +2450,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.20.1; + MARKETING_VERSION = 1.21; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; @@ -2496,7 +2496,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.20.1; + MARKETING_VERSION = 1.21; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.widgets; @@ -2542,7 +2542,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.20.1; + MARKETING_VERSION = 1.21; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.dev.widgets; @@ -2588,7 +2588,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.20.1; + MARKETING_VERSION = 1.21; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.beta.widgets; @@ -2630,7 +2630,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.20.1; + MARKETING_VERSION = 1.21; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; @@ -2673,7 +2673,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.20.1; + MARKETING_VERSION = 1.21; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.WidgetIntentExtension; @@ -2716,7 +2716,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.20.1; + MARKETING_VERSION = 1.21; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.dev.WidgetIntentExtension; @@ -2759,7 +2759,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.20.1; + MARKETING_VERSION = 1.21; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.beta.WidgetIntentExtension; @@ -2795,7 +2795,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.20.1; + MARKETING_VERSION = 1.21; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -2833,7 +2833,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.20.1; + MARKETING_VERSION = 1.21; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -3003,7 +3003,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.20.1; + MARKETING_VERSION = 1.21; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; @@ -3047,7 +3047,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.20.1; + MARKETING_VERSION = 1.21; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.OneSignalNotificationServiceExtension; @@ -3143,7 +3143,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.20.1; + MARKETING_VERSION = 1.21; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -3214,7 +3214,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.20.1; + MARKETING_VERSION = 1.21; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.beta.OneSignalNotificationServiceExtension; @@ -3310,7 +3310,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.20.1; + MARKETING_VERSION = 1.21; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -3381,7 +3381,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.20.1; + MARKETING_VERSION = 1.21; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.dev.OneSignalNotificationServiceExtension; diff --git a/apps/mobile/ios/Uniswap.xcodeproj/xcshareddata/xcschemes/Uniswap.xcscheme b/apps/mobile/ios/Uniswap.xcodeproj/xcshareddata/xcschemes/Uniswap.xcscheme index 0d6918c5b84..c5e27e3a5eb 100644 --- a/apps/mobile/ios/Uniswap.xcodeproj/xcshareddata/xcschemes/Uniswap.xcscheme +++ b/apps/mobile/ios/Uniswap.xcodeproj/xcshareddata/xcschemes/Uniswap.xcscheme @@ -1,6 +1,6 @@ - - - - - { - routingInstrumentation.registerNavigationContainer(navigationRef) - }}> - - - - - - - - - - - - - + + + + + + { + routingInstrumentation.registerNavigationContainer(navigationRef) + }}> + + + + + + + + + + + + + + diff --git a/apps/mobile/src/app/MobileWalletNavigationProvider.tsx b/apps/mobile/src/app/MobileWalletNavigationProvider.tsx index de3985be859..5fa97542348 100644 --- a/apps/mobile/src/app/MobileWalletNavigationProvider.tsx +++ b/apps/mobile/src/app/MobileWalletNavigationProvider.tsx @@ -14,12 +14,14 @@ export function MobileWalletNavigationProvider({ children }: PropsWithChildren): const navigateToAccountTokenList = useNavigateToHomepageTab(HomeScreenTabIndex.Tokens) const navigateToAccountActivityList = useNavigateToHomepageTab(HomeScreenTabIndex.Activity) const navigateToSwapFlow = useNavigateToSwapFlow() + const navigateToTokenDetails = useNavigateToTokenDetails() return ( + navigateToSwapFlow={navigateToSwapFlow} + navigateToTokenDetails={navigateToTokenDetails}> {children} ) @@ -46,3 +48,14 @@ function useNavigateToSwapFlow(): (args: NavigateToSwapFlowArgs) => void { [dispatch] ) } + +function useNavigateToTokenDetails(): (currencyId: string) => void { + const navigation = useAppStackNavigation() + + return useCallback( + (currencyId: string): void => { + navigation.navigate(Screens.TokenDetails, { currencyId }) + }, + [navigation] + ) +} diff --git a/apps/mobile/src/app/migrations.test.ts b/apps/mobile/src/app/migrations.test.ts index 4e5500d58cb..4a6d263322e 100644 --- a/apps/mobile/src/app/migrations.test.ts +++ b/apps/mobile/src/app/migrations.test.ts @@ -56,6 +56,7 @@ import { v54Schema, v55Schema, v56Schema, + v57Schema, v5Schema, v6Schema, v7Schema, @@ -1291,4 +1292,11 @@ describe('Redux state migrations', () => { expect(v57.wallet.accounts[0].showSpamTokens).toBeUndefined() expect(v57.wallet.accounts[0].showSmallBalances).toBeUndefined() }) + + it('migrates from v57 to 58', () => { + const v57Stub = { ...v57Schema } + const v58 = migrations[58](v57Stub) + + expect(v58.behaviorHistory.hasSkippedUnitagPrompt).toBe(false) + }) }) diff --git a/apps/mobile/src/app/migrations.ts b/apps/mobile/src/app/migrations.ts index b6daca9dbf8..2c0cffc52ec 100644 --- a/apps/mobile/src/app/migrations.ts +++ b/apps/mobile/src/app/migrations.ts @@ -782,4 +782,15 @@ export const migrations = { return newState }, + + 58: function addSkippedUnitagBoolean(state: any) { + const newState = { ...state } + + newState.behaviorHistory = { + ...state.behaviorHistory, + hasSkippedUnitagPrompt: false, + } + + return newState + }, } diff --git a/apps/mobile/src/app/modals/AccountSwitcherModal.tsx b/apps/mobile/src/app/modals/AccountSwitcherModal.tsx index 7548ac4a1ea..a238c5ef725 100644 --- a/apps/mobile/src/app/modals/AccountSwitcherModal.tsx +++ b/apps/mobile/src/app/modals/AccountSwitcherModal.tsx @@ -8,7 +8,9 @@ import { AccountList } from 'src/components/accounts/AccountList' import { isCloudStorageAvailable } from 'src/features/CloudBackup/RNCloudStorageBackupsManager' import { closeModal, openModal } from 'src/features/modals/modalSlice' import { selectModalState } from 'src/features/modals/selectModalState' +import { useCompleteOnboardingCallback } from 'src/features/onboarding/hooks' import { OnboardingScreens, Screens } from 'src/screens/Screens' +import { useSagaStatus } from 'src/utils/useSagaStatus' import { Button, Flex, @@ -23,9 +25,14 @@ import { spacing } from 'ui/src/theme' import { AddressDisplay } from 'wallet/src/components/accounts/AddressDisplay' import { ActionSheetModal, MenuItemProp } from 'wallet/src/components/modals/ActionSheetModal' import { BottomSheetModal } from 'wallet/src/components/modals/BottomSheetModal' +import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants' +import { useFeatureFlag } from 'wallet/src/features/experiments/hooks' import { ImportType, OnboardingEntryPoint } from 'wallet/src/features/onboarding/types' import { AccountType } from 'wallet/src/features/wallet/accounts/types' -import { createAccountActions } from 'wallet/src/features/wallet/create/createAccountSaga' +import { + createAccountActions, + createAccountSagaName, +} from 'wallet/src/features/wallet/create/createAccountSaga' import { PendingAccountActions, pendingAccountActions, @@ -46,7 +53,7 @@ export function AccountSwitcherModal(): JSX.Element { backgroundColor={colors.surface1.get()} name={ModalName.AccountSwitcher} onClose={(): Action => dispatch(closeModal({ name: ModalName.AccountSwitcher }))}> - + { dispatch(closeModal({ name: ModalName.AccountSwitcher })) @@ -69,8 +76,14 @@ export function AccountSwitcher({ onClose }: { onClose: () => void }): JSX.Eleme const dispatch = useAppDispatch() const hasImportedSeedPhrase = useNativeAccountExists() const modalState = useAppSelector(selectModalState(ModalName.AccountSwitcher)) + const unitagsFeatureFlagEnabled = useFeatureFlag(FEATURE_FLAGS.Unitags) + const onCompleteOnboarding = useCompleteOnboardingCallback({ + entryPoint: OnboardingEntryPoint.Sidebar, + importType: hasImportedSeedPhrase ? ImportType.CreateAdditional : ImportType.CreateNew, + }) const [showAddWalletModal, setShowAddWalletModal] = useState(false) + const [createdAdditionalAccount, setCreatedAdditionalAccount] = useState(false) const accounts = useAppSelector(selectAllAccountsSorted) @@ -105,19 +118,43 @@ export function AccountSwitcher({ onClose }: { onClose: () => void }): JSX.Eleme }) } + // Pick up account creation and activate + useSagaStatus(createAccountSagaName, async () => { + if (createdAdditionalAccount) { + setCreatedAdditionalAccount(false) + await onCompleteOnboarding() + } + }) + const addWalletOptions = useMemo(() => { - const onPressCreateNewWallet = (): void => { - // Clear any existing pending accounts first. - dispatch(pendingAccountActions.trigger(PendingAccountActions.Delete)) - dispatch(createAccountActions.trigger()) + const onPressCreateNewWallet = async (): Promise => { + // Ensure no pending accounts + await dispatch(pendingAccountActions.trigger(PendingAccountActions.Delete)) + await dispatch(createAccountActions.trigger()) + + if (unitagsFeatureFlagEnabled) { + if (hasImportedSeedPhrase) { + setCreatedAdditionalAccount(true) + } else { + // create pending account and place into welcome flow + navigate(Screens.OnboardingStack, { + screen: OnboardingScreens.WelcomeWallet, + params: { + importType: ImportType.CreateNew, + entryPoint: OnboardingEntryPoint.Sidebar, + }, + }) + } + } else { + navigate(Screens.OnboardingStack, { + screen: OnboardingScreens.EditName, + params: { + entryPoint: OnboardingEntryPoint.Sidebar, + importType: hasImportedSeedPhrase ? ImportType.CreateAdditional : ImportType.CreateNew, + }, + }) + } - navigate(Screens.OnboardingStack, { - screen: OnboardingScreens.EditName, - params: { - importType: hasImportedSeedPhrase ? ImportType.CreateAdditional : ImportType.CreateNew, - entryPoint: OnboardingEntryPoint.Sidebar, - }, - }) setShowAddWalletModal(false) onClose() } @@ -226,7 +263,7 @@ export function AccountSwitcher({ onClose }: { onClose: () => void }): JSX.Eleme } return options - }, [activeAccountAddress, dispatch, hasImportedSeedPhrase, onClose, t]) + }, [activeAccountAddress, dispatch, hasImportedSeedPhrase, onClose, t, unitagsFeatureFlagEnabled]) const accountsWithoutActive = accounts.filter((a) => a.address !== activeAccountAddress) diff --git a/apps/mobile/src/app/modals/AppModals.tsx b/apps/mobile/src/app/modals/AppModals.tsx index 59a0888f4a5..fcf8d2b801b 100644 --- a/apps/mobile/src/app/modals/AppModals.tsx +++ b/apps/mobile/src/app/modals/AppModals.tsx @@ -15,6 +15,7 @@ import { WalletConnectModals } from 'src/components/WalletConnect/WalletConnectM import { LockScreenModal } from 'src/features/authentication/LockScreenModal' import { FiatOnRampModal } from 'src/features/fiatOnRamp/FiatOnRampModal' import { ScantasticModal } from 'src/features/scantastic/ScantasticModal' +import { ReceiveCryptoModal } from 'src/screens/ReceiveCryptoModal' import { SettingsFiatCurrencyModal } from 'src/screens/SettingsFiatCurrencyModal' import { SettingsLanguageModal } from 'src/screens/SettingsLanguageModal' import { ModalName } from 'wallet/src/telemetry/constants' @@ -34,6 +35,10 @@ export function AppModals(): JSX.Element { + + + + diff --git a/apps/mobile/src/app/modals/SwapModal.tsx b/apps/mobile/src/app/modals/SwapModal.tsx index 54391180964..64f8c063631 100644 --- a/apps/mobile/src/app/modals/SwapModal.tsx +++ b/apps/mobile/src/app/modals/SwapModal.tsx @@ -8,24 +8,17 @@ import { } from 'src/features/biometrics/hooks' import { closeModal } from 'src/features/modals/modalSlice' import { selectModalState } from 'src/features/modals/selectModalState' -import { SwapFlow } from 'src/features/transactions/swap/SwapFlow' -import { getFocusOnCurrencyFieldFromInitialState } from 'src/features/transactions/swapRewrite/utils' +import { getFocusOnCurrencyFieldFromInitialState } from 'src/features/transactions/swap/utils' import { useWalletRestore } from 'src/features/wallet/hooks' -import { useSporeColors } from 'ui/src' -import { BottomSheetModal } from 'wallet/src/components/modals/BottomSheetModal' -import { useSwapRewriteEnabled } from 'wallet/src/features/experiments/hooks' import { SwapFormState } from 'wallet/src/features/transactions/contexts/SwapFormContext' -import { SwapFlow as SwapFlowRewrite } from 'wallet/src/features/transactions/swap/SwapFlow' +import { SwapFlow } from 'wallet/src/features/transactions/swap/SwapFlow' import { ModalName } from 'wallet/src/telemetry/constants' import { updateSwapStartTimestamp } from 'wallet/src/telemetry/timing/slice' export function SwapModal(): JSX.Element { - const colors = useSporeColors() const appDispatch = useAppDispatch() const { initialState } = useAppSelector(selectModalState(ModalName.Swap)) - const shouldShowSwapRewrite = useSwapRewriteEnabled() - const onClose = useCallback((): void => { appDispatch(closeModal({ name: ModalName.Swap })) }, [appDispatch]) @@ -37,7 +30,7 @@ export function SwapModal(): JSX.Element { const { openWalletRestoreModal, walletNeedsRestore } = useWalletRestore() - const swapRewritePrefilledState = useMemo( + const swapPrefilledState = useMemo( (): SwapFormState | undefined => initialState ? { @@ -60,26 +53,15 @@ export function SwapModal(): JSX.Element { const { requiredForTransactions: requiresBiometrics } = useBiometricAppSettings() const { trigger: biometricsTrigger } = useBiometricPrompt() - return shouldShowSwapRewrite ? ( - } authTrigger={requiresBiometrics ? biometricsTrigger : undefined} openWalletRestoreModal={openWalletRestoreModal} - prefilledState={swapRewritePrefilledState} + prefilledState={swapPrefilledState} walletNeedsRestore={Boolean(walletNeedsRestore)} onClose={onClose} /> - ) : ( - - - ) } diff --git a/apps/mobile/src/app/modals/TransferTokenModal.tsx b/apps/mobile/src/app/modals/TransferTokenModal.tsx index c6b07f9a73e..940d9e4480a 100644 --- a/apps/mobile/src/app/modals/TransferTokenModal.tsx +++ b/apps/mobile/src/app/modals/TransferTokenModal.tsx @@ -2,8 +2,8 @@ import React, { useCallback } from 'react' import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { closeModal } from 'src/features/modals/modalSlice' import { selectModalState } from 'src/features/modals/selectModalState' -import { TransferFlow as TransferFlowRewrite } from 'src/features/transactions/swapRewrite/transfer/TransferFlow' import { TransferFlow } from 'src/features/transactions/transfer/TransferFlow' +import { TransferFlow as TransferFlowRewrite } from 'src/features/transactions/transfer/transferRewrite/TransferFlow' import { useSporeColors } from 'ui/src' import { BottomSheetModal } from 'wallet/src/components/modals/BottomSheetModal' import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants' @@ -28,6 +28,7 @@ export function TransferTokenModal(): JSX.Element { fullScreen hideHandlebar hideKeyboardOnDismiss + overrideInnerContainer renderBehindTopInset backgroundColor={colors.surface1.get()} name={ModalName.Send} diff --git a/apps/mobile/src/app/modals/ViewOnlyExplainerModal.tsx b/apps/mobile/src/app/modals/ViewOnlyExplainerModal.tsx index 5ca15a28a93..a5644c679bc 100644 --- a/apps/mobile/src/app/modals/ViewOnlyExplainerModal.tsx +++ b/apps/mobile/src/app/modals/ViewOnlyExplainerModal.tsx @@ -58,7 +58,7 @@ export function ViewOnlyExplainerModal(): JSX.Element { @@ -50,7 +49,7 @@ export function TokenDetailsActionButtons({ return ( @@ -39,7 +43,7 @@ export function TokenDetailsLinks({ Icon={getBlockExplorerIcon(chainId)} buttonType={LinkButtonType.Link} element={ElementName.TokenLinkEtherscan} - label={t('Contract')} + label={explorerName} value={explorerLink} /> {homepageUrl && ( @@ -56,10 +60,18 @@ export function TokenDetailsLinks({ Icon={TwitterIcon} buttonType={LinkButtonType.Link} element={ElementName.TokenLinkTwitter} - label={t('X')} + label={t('Twitter')} value={getTwitterLink(twitterName)} /> )} + {!isDefaultNativeAddress(address) && ( + + )} diff --git a/apps/mobile/src/components/TokenDetails/TokenDetailsStats.tsx b/apps/mobile/src/components/TokenDetails/TokenDetailsStats.tsx index 4fee5003053..22847a2d4df 100644 --- a/apps/mobile/src/components/TokenDetails/TokenDetailsStats.tsx +++ b/apps/mobile/src/components/TokenDetails/TokenDetailsStats.tsx @@ -163,7 +163,11 @@ export function TokenDetailsStats({ setShowTranslation(!showTranslation)}> - + {showTranslation ? ( diff --git a/apps/mobile/src/components/TokenDetails/hooks.test.ts b/apps/mobile/src/components/TokenDetails/hooks.test.ts index 9b5e448129b..fe994bb32d6 100644 --- a/apps/mobile/src/components/TokenDetails/hooks.test.ts +++ b/apps/mobile/src/components/TokenDetails/hooks.test.ts @@ -5,7 +5,7 @@ import { USDBC_BASE, USDC_ARBITRUM } from 'wallet/src/constants/tokens' import { Chain } from 'wallet/src/data/__generated__/types-and-hooks' import { fromGraphQLChain } from 'wallet/src/features/chains/utils' import { currencyIdToContractInput } from 'wallet/src/features/dataApi/utils' -import { mockWalletPreloadedState, SAMPLE_CURRENCY_ID_1 } from 'wallet/src/test/fixtures' +import { SAMPLE_CURRENCY_ID_1, mockWalletPreloadedState } from 'wallet/src/test/fixtures' import { Portfolio, Portfolio2, PortfolioBalancesById } from 'wallet/src/test/gqlFixtures' const mockedNavigation = { diff --git a/apps/mobile/src/components/TokenDetails/hooks.ts b/apps/mobile/src/components/TokenDetails/hooks.ts index 3cebca145ee..fe6b4da12f7 100644 --- a/apps/mobile/src/components/TokenDetails/hooks.ts +++ b/apps/mobile/src/components/TokenDetails/hooks.ts @@ -10,9 +10,9 @@ import { fromGraphQLChain } from 'wallet/src/features/chains/utils' import { PortfolioBalance } from 'wallet/src/features/dataApi/types' import { currencyIdToContractInput } from 'wallet/src/features/dataApi/utils' import { + CurrencyId, buildCurrencyId, buildNativeCurrencyId, - CurrencyId, currencyIdToChain, } from 'wallet/src/utils/currencyId' diff --git a/apps/mobile/src/components/TokenSelector/TokenFiatOnRampList.tsx b/apps/mobile/src/components/TokenSelector/TokenFiatOnRampList.tsx index 3f3c2412c35..235867b4258 100644 --- a/apps/mobile/src/components/TokenSelector/TokenFiatOnRampList.tsx +++ b/apps/mobile/src/components/TokenSelector/TokenFiatOnRampList.tsx @@ -3,9 +3,8 @@ import React, { memo, useCallback, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { ListRenderItemInfo } from 'react-native' import { FiatOnRampCurrency } from 'src/features/fiatOnRamp/types' -import { Flex, Icons, Inset, Text, TouchableArea } from 'ui/src' +import { Flex, Icons, Inset, Loader, Text, TouchableArea } from 'ui/src' import { BaseCard } from 'wallet/src/components/BaseCard/BaseCard' -import { TokenLoader } from 'wallet/src/components/loading/TokenLoader' import { TokenOptionItem } from 'wallet/src/components/TokenSelector/TokenOptionItem' import { ChainId } from 'wallet/src/constants/chains' import { ElementName } from 'wallet/src/telemetry/constants' @@ -89,7 +88,7 @@ function _TokenFiatOnRampList({ return (
- + ) } diff --git a/apps/mobile/src/components/Trace/Trace.tsx b/apps/mobile/src/components/Trace/Trace.tsx index 8900a7f1f7b..7d218a99631 100644 --- a/apps/mobile/src/components/Trace/Trace.tsx +++ b/apps/mobile/src/components/Trace/Trace.tsx @@ -1,7 +1,7 @@ import { memo, PropsWithChildren } from 'react' import { ManualPageViewScreen, MobileEventName } from 'src/features/telemetry/constants' import { AppScreen } from 'src/screens/Screens' -import { Trace as UntypedTrace, TraceProps } from 'utilities/src/telemetry/trace/Trace' +import { TraceProps, Trace as UntypedTrace } from 'utilities/src/telemetry/trace/Trace' import { ElementNameType, ModalNameType, SectionNameType } from 'wallet/src/telemetry/constants' // Mobile specific version of ITraceContext diff --git a/apps/mobile/src/components/Trace/TraceUserProperties.tsx b/apps/mobile/src/components/Trace/TraceUserProperties.tsx index 4abf98fe4bb..9e1ab45ea94 100644 --- a/apps/mobile/src/components/Trace/TraceUserProperties.tsx +++ b/apps/mobile/src/components/Trace/TraceUserProperties.tsx @@ -6,7 +6,7 @@ import { useDeviceSupportsBiometricAuth, } from 'src/features/biometrics/hooks' import { setUserProperty } from 'src/features/telemetry' -import { getAuthMethod, UserPropertyName } from 'src/features/telemetry/constants' +import { UserPropertyName, getAuthMethod } from 'src/features/telemetry/constants' import { selectAllowAnalytics } from 'src/features/telemetry/selectors' import { getFullAppVersion } from 'src/utils/version' import { useIsDarkMode } from 'ui/src' diff --git a/apps/mobile/src/components/WalletConnect/ConnectedDapps/DappConnectedNetworksModal.tsx b/apps/mobile/src/components/WalletConnect/ConnectedDapps/DappConnectedNetworksModal.tsx index a00d3c63dc7..2dc6c1864ce 100644 --- a/apps/mobile/src/components/WalletConnect/ConnectedDapps/DappConnectedNetworksModal.tsx +++ b/apps/mobile/src/components/WalletConnect/ConnectedDapps/DappConnectedNetworksModal.tsx @@ -84,7 +84,7 @@ export function DappConnectedNetworkModal({ {chains.map((chainId) => ( - + ))} diff --git a/apps/mobile/src/components/WalletConnect/RequestModal/HeaderText.tsx b/apps/mobile/src/components/WalletConnect/RequestModal/HeaderText.tsx index 25e38f3c385..056464b6cd3 100644 --- a/apps/mobile/src/components/WalletConnect/RequestModal/HeaderText.tsx +++ b/apps/mobile/src/components/WalletConnect/RequestModal/HeaderText.tsx @@ -5,7 +5,7 @@ import { truncateDappName } from 'src/components/WalletConnect/ScanSheet/util' import { WalletConnectRequest } from 'src/features/walletConnect/walletConnectSlice' import { Text } from 'ui/src' import { EthMethod } from 'wallet/src/features/walletConnect/types' -import { getCurrencyAmount, ValueType } from 'wallet/src/utils/getCurrencyAmount' +import { ValueType, getCurrencyAmount } from 'wallet/src/utils/getCurrencyAmount' export function HeaderText({ request, diff --git a/apps/mobile/src/components/WalletConnect/RequestModal/SpendingDetails.tsx b/apps/mobile/src/components/WalletConnect/RequestModal/SpendingDetails.tsx index b8b1a56eaab..6e23096c33a 100644 --- a/apps/mobile/src/components/WalletConnect/RequestModal/SpendingDetails.tsx +++ b/apps/mobile/src/components/WalletConnect/RequestModal/SpendingDetails.tsx @@ -9,7 +9,7 @@ import { useUSDValue } from 'wallet/src/features/gas/hooks' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { useNativeCurrencyInfo } from 'wallet/src/features/tokens/useCurrencyInfo' import { getSymbolDisplayText } from 'wallet/src/utils/currency' -import { getCurrencyAmount, ValueType } from 'wallet/src/utils/getCurrencyAmount' +import { ValueType, getCurrencyAmount } from 'wallet/src/utils/getCurrencyAmount' export function SpendingDetails({ value, diff --git a/apps/mobile/src/components/WalletConnect/ScanSheet/PendingConnectionModal.tsx b/apps/mobile/src/components/WalletConnect/ScanSheet/PendingConnectionModal.tsx index 80d46597a32..666e018dd37 100644 --- a/apps/mobile/src/components/WalletConnect/ScanSheet/PendingConnectionModal.tsx +++ b/apps/mobile/src/components/WalletConnect/ScanSheet/PendingConnectionModal.tsx @@ -58,7 +58,7 @@ const SitePermissions = (): JSX.Element => { return ( { row shrink alignItems="center" - bg="$surface2" + backgroundColor="$surface2" justifyContent="space-between" px="$spacing16" py="$spacing12"> @@ -146,7 +146,7 @@ const SwitchAccountRow = ({ activeAddress, setModalState }: SwitchAccountProps): return ( - + {activeAddress && ( - - + + {walletHasName ? ( - + - .uni.eth + .cantswim.eth { diff --git a/apps/mobile/src/components/carousel/Indicator.tsx b/apps/mobile/src/components/carousel/Indicator.tsx index 72caa3ee08c..c0a00c1a88d 100644 --- a/apps/mobile/src/components/carousel/Indicator.tsx +++ b/apps/mobile/src/components/carousel/Indicator.tsx @@ -23,7 +23,7 @@ export function Indicator({ - + } ListHeaderComponent={ @@ -258,7 +257,7 @@ function FavoritesSection(props: FavoritesSectionProps): JSX.Element | null { return ( { onPress: expect.any(Function), }), expect.objectContaining({ - title: 'Copy contract address', + title: 'Receive', onPress: expect.any(Function), }), expect.objectContaining({ @@ -84,7 +84,7 @@ describe(useExploreTokenContextMenu, () => { onPress: expect.any(Function), }), expect.objectContaining({ - title: 'Copy contract address', + title: 'Receive', onPress: expect.any(Function), }), ]) @@ -138,7 +138,7 @@ describe(useExploreTokenContextMenu, () => { onPress: expect.any(Function), }), expect.objectContaining({ - title: 'Copy contract address', + title: 'Receive', onPress: expect.any(Function), }), expect.objectContaining({ diff --git a/apps/mobile/src/components/explore/hooks.ts b/apps/mobile/src/components/explore/hooks.ts index 6400ed5942a..e3beb0276c7 100644 --- a/apps/mobile/src/components/explore/hooks.ts +++ b/apps/mobile/src/components/explore/hooks.ts @@ -3,6 +3,7 @@ import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { NativeSyntheticEvent, Share } from 'react-native' import { ContextMenuAction, ContextMenuOnPressNativeEvent } from 'react-native-context-menu-view' +import { ScannerModalState } from 'src/components/QRCodeScanner/constants' import { useSelectHasTokenFavorited, useToggleFavoriteCallback } from 'src/features/favorites/hooks' import { openModal } from 'src/features/modals/modalSlice' import { sendMobileAnalyticsEvent } from 'src/features/telemetry' @@ -10,16 +11,14 @@ import { MobileEventName, ShareableEntity } from 'src/features/telemetry/constan import { logger } from 'utilities/src/logger/logger' import { ChainId } from 'wallet/src/constants/chains' import { AssetType } from 'wallet/src/entities/assets' -import { useCopyTokenAddressCallback } from 'wallet/src/features/tokens/hooks' import { CurrencyField, TransactionState, } from 'wallet/src/features/transactions/transactionState/types' import { useAppDispatch } from 'wallet/src/state' import { ElementName, ModalName, SectionNameType } from 'wallet/src/telemetry/constants' -import { getTokenUrl } from 'wallet/src/utils/linking' - import { CurrencyId, currencyIdToAddress } from 'wallet/src/utils/currencyId' +import { getTokenUrl } from 'wallet/src/utils/linking' interface TokenMenuParams { currencyId: CurrencyId @@ -47,7 +46,13 @@ export function useExploreTokenContextMenu({ // currencyId, where we have hardcoded addresses for native currencies const currencyAddress = currencyIdToAddress(currencyId) - const onPressCopyContractAddress = useCopyTokenAddressCallback(currencyAddress) + const onPressReceive = useCallback( + () => + dispatch( + openModal({ name: ModalName.WalletConnectScan, initialState: ScannerModalState.WalletQr }) + ), + [dispatch] + ) const onPressShare = useCallback(async () => { const tokenUrl = getTokenUrl(currencyId) @@ -109,9 +114,9 @@ export function useExploreTokenContextMenu({ : []), { title: t('Swap'), systemIcon: 'arrow.2.squarepath', onPress: onPressSwap }, { - title: t('Copy contract address'), - systemIcon: 'doc.on.doc', - onPress: onPressCopyContractAddress, + title: t('Receive'), + systemIcon: 'qrcode', + onPress: onPressReceive, }, ...(!onEditFavorites ? [ @@ -129,7 +134,7 @@ export function useExploreTokenContextMenu({ onPressToggleFavorite, onEditFavorites, onPressSwap, - onPressCopyContractAddress, + onPressReceive, onPressShare, ] ) diff --git a/apps/mobile/src/components/explore/search/SearchEmptySection.tsx b/apps/mobile/src/components/explore/search/SearchEmptySection.tsx index 7b16b54b95a..2c6ad9a3a5a 100644 --- a/apps/mobile/src/components/explore/search/SearchEmptySection.tsx +++ b/apps/mobile/src/components/explore/search/SearchEmptySection.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { FlatList } from 'react-native' import { FadeIn, FadeOut } from 'react-native-reanimated' @@ -11,18 +11,24 @@ import { AnimatedFlex, Flex, Text, TouchableArea, useSporeColors } from 'ui/src' import ClockIcon from 'ui/src/assets/icons/clock.svg' import TrendArrowIcon from 'ui/src/assets/icons/trend-up.svg' import { iconSizes } from 'ui/src/theme' +import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants' +import { useFeatureFlag } from 'wallet/src/features/experiments/hooks' import { clearSearchHistory } from 'wallet/src/features/search/searchHistorySlice' -import { SearchResultType, WalletSearchResult } from 'wallet/src/features/search/SearchResult' +import { + SearchResult, + SearchResultType, + WalletSearchResult, +} from 'wallet/src/features/search/SearchResult' import { selectSearchHistory } from 'wallet/src/features/search/selectSearchHistory' export const SUGGESTED_WALLETS: WalletSearchResult[] = [ { - type: SearchResultType.Wallet, + type: SearchResultType.ENSAddress, address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', ensName: 'vitalik.eth', }, { - type: SearchResultType.Wallet, + type: SearchResultType.ENSAddress, address: '0x50EC05ADe8280758E2077fcBC08D878D4aef79C3', ensName: 'hayden.eth', }, @@ -32,11 +38,28 @@ export function SearchEmptySection(): JSX.Element { const { t } = useTranslation() const dispatch = useAppDispatch() const searchHistory = useAppSelector(selectSearchHistory) + const unitagFeatureFlagEnabled = useFeatureFlag(FEATURE_FLAGS.Unitags) const onPressClearSearchHistory = (): void => { dispatch(clearSearchHistory()) } + const modifiedHistory: SearchResult[] = useMemo( + () => + searchHistory.map((historyItem: SearchResult) => { + if (!unitagFeatureFlagEnabled && historyItem.type === SearchResultType.Unitag) { + return { + type: SearchResultType.ENSAddress, + address: historyItem.address, + searchId: historyItem.searchId, + } + } else { + return historyItem + } + }), + [searchHistory, unitagFeatureFlagEnabled] + ) + // Show search history (if applicable), trending tokens, and wallets return ( @@ -58,7 +81,7 @@ export function SearchEmptySection(): JSX.Element { } - data={searchHistory} + data={modifiedHistory} renderItem={(props): JSX.Element | null => renderSearchItem({ ...props, searchContext: { isHistory: true } }) } diff --git a/apps/mobile/src/components/explore/search/SearchPopularNFTCollections.tsx b/apps/mobile/src/components/explore/search/SearchPopularNFTCollections.tsx index f3a34991321..7a310c03f7b 100644 --- a/apps/mobile/src/components/explore/search/SearchPopularNFTCollections.tsx +++ b/apps/mobile/src/components/explore/search/SearchPopularNFTCollections.tsx @@ -5,8 +5,7 @@ import { getSearchResultId, gqlNFTToNFTCollectionSearchResult, } from 'src/components/explore/search/utils' -import { Inset } from 'ui/src' -import { TokenLoader } from 'wallet/src/components/loading/TokenLoader' +import { Inset, Loader } from 'ui/src' import { useSearchPopularNftCollectionsQuery } from 'wallet/src/data/__generated__/types-and-hooks' import { NFTCollectionSearchResult, @@ -37,7 +36,7 @@ export function SearchPopularNFTCollections(): JSX.Element { if (loading) { return ( - + ) } diff --git a/apps/mobile/src/components/explore/search/SearchPopularTokens.tsx b/apps/mobile/src/components/explore/search/SearchPopularTokens.tsx index 53b1afca6f0..9701e286518 100644 --- a/apps/mobile/src/components/explore/search/SearchPopularTokens.tsx +++ b/apps/mobile/src/components/explore/search/SearchPopularTokens.tsx @@ -2,8 +2,7 @@ import React, { useMemo } from 'react' import { FlatList, ListRenderItemInfo } from 'react-native' import { SearchTokenItem } from 'src/components/explore/search/items/SearchTokenItem' import { getSearchResultId } from 'src/components/explore/search/utils' -import { Inset } from 'ui/src' -import { TokenLoader } from 'wallet/src/components/loading/TokenLoader' +import { Inset, Loader } from 'ui/src' import { fromGraphQLChain } from 'wallet/src/features/chains/utils' import { SearchResultType, TokenSearchResult } from 'wallet/src/features/search/SearchResult' import { TopToken, usePopularTokens } from 'wallet/src/features/tokens/hooks' @@ -23,11 +22,12 @@ function gqlTokenToTokenSearchResult(token: Maybe): TokenSearchResult return { type: SearchResultType.Token, chainId, - address, + address: address ?? null, name, symbol, - logoUrl: project?.logoUrl, - } as TokenSearchResult + logoUrl: project?.logoUrl ?? null, + safetyLevel: project?.safetyLevel ?? null, + } } export function SearchPopularTokens(): JSX.Element { @@ -43,7 +43,7 @@ export function SearchPopularTokens(): JSX.Element { if (loading) { return ( - + ) } diff --git a/apps/mobile/src/components/explore/search/SearchResultsLoader.tsx b/apps/mobile/src/components/explore/search/SearchResultsLoader.tsx index 5ae906a77f8..4164b79d84e 100644 --- a/apps/mobile/src/components/explore/search/SearchResultsLoader.tsx +++ b/apps/mobile/src/components/explore/search/SearchResultsLoader.tsx @@ -2,8 +2,7 @@ import React from 'react' import { useTranslation } from 'react-i18next' import { FadeIn, FadeOut } from 'react-native-reanimated' import { SectionHeaderText } from 'src/components/explore/search/SearchSectionHeader' -import { AnimatedFlex, Flex } from 'ui/src' -import { TokenLoader } from 'wallet/src/components/loading/TokenLoader' +import { AnimatedFlex, Flex, Loader } from 'ui/src' export const SearchResultsLoader = (): JSX.Element => { const { t } = useTranslation() @@ -12,19 +11,19 @@ export const SearchResultsLoader = (): JSX.Element => { - + - + - + diff --git a/apps/mobile/src/components/explore/search/SearchResultsSection.tsx b/apps/mobile/src/components/explore/search/SearchResultsSection.tsx index e784a3146f4..997ceeb5b60 100644 --- a/apps/mobile/src/components/explore/search/SearchResultsSection.tsx +++ b/apps/mobile/src/components/explore/search/SearchResultsSection.tsx @@ -2,12 +2,13 @@ import React, { useCallback, useMemo } from 'react' import { Trans, useTranslation } from 'react-i18next' import { FlatList, ListRenderItemInfo } from 'react-native' import { FadeIn, FadeOut } from 'react-native-reanimated' +import { SearchResultsLoader } from 'src/components/explore/search/SearchResultsLoader' +import { SectionHeaderText } from 'src/components/explore/search/SearchSectionHeader' +import { SearchENSAddressItem } from 'src/components/explore/search/items/SearchENSAddressItem' import { SearchEtherscanItem } from 'src/components/explore/search/items/SearchEtherscanItem' import { SearchNFTCollectionItem } from 'src/components/explore/search/items/SearchNFTCollectionItem' import { SearchTokenItem } from 'src/components/explore/search/items/SearchTokenItem' -import { SearchWalletItem } from 'src/components/explore/search/items/SearchWalletItem' -import { SearchResultsLoader } from 'src/components/explore/search/SearchResultsLoader' -import { SectionHeaderText } from 'src/components/explore/search/SearchSectionHeader' +import { SearchUnitagItem } from 'src/components/explore/search/items/SearchUnitagItem' import { formatNFTCollectionSearchResults, formatTokenSearchResults, @@ -16,7 +17,7 @@ import { import { AnimatedFlex, Flex, Text } from 'ui/src' import { logger } from 'utilities/src/logger/logger' import { BaseCard } from 'wallet/src/components/BaseCard/BaseCard' -import { ChainId, CHAIN_INFO } from 'wallet/src/constants/chains' +import { CHAIN_INFO, ChainId } from 'wallet/src/constants/chains' import { SafetyLevel, useExploreSearchQuery } from 'wallet/src/data/__generated__/types-and-hooks' import { useENS } from 'wallet/src/features/ens/useENS' import { SearchContext } from 'wallet/src/features/search/SearchContext' @@ -27,6 +28,7 @@ import { WalletSearchResult, } from 'wallet/src/features/search/SearchResult' import { useIsSmartContractAddress } from 'wallet/src/features/transactions/transfer/hooks/useIsSmartContractAddress' +import { useUnitagByAddress, useUnitagByName } from 'wallet/src/features/unitags/hooks' import i18n from 'wallet/src/i18n/i18n' import { getValidAddress } from 'wallet/src/utils/addresses' import { SEARCH_RESULT_HEADER_KEY } from './constants' @@ -87,15 +89,24 @@ export function SearchResultsSection({ searchQuery }: { searchQuery: string }): loading: ensLoading, } = useENS(ChainId.Mainnet, searchQuery, true) - const validAddress: Address | null = getValidAddress(searchQuery, true, false) - ? searchQuery - : null + // Search for matching Unitag by name + const { unitag: unitagByName, loading: unitagLoading } = useUnitagByName(searchQuery) + + const validAddress: Address | undefined = useMemo( + () => getValidAddress(searchQuery, true, false) ?? undefined, + [searchQuery] + ) + + // Search for matching Unitag by address + const { unitag: unitagByAddress, loading: unitagByAddressLoading } = + useUnitagByAddress(validAddress) // Search for matching EOA wallet address const { isSmartContractAddress, loading: loadingIsSmartContractAddress } = - useIsSmartContractAddress(validAddress ?? undefined, ChainId.Mainnet) + useIsSmartContractAddress(validAddress, ChainId.Mainnet) - const walletsLoading = ensLoading || loadingIsSmartContractAddress + const walletsLoading = + ensLoading || loadingIsSmartContractAddress || unitagLoading || unitagByAddressLoading const onRetry = useCallback(async () => { await refetch() @@ -104,30 +115,48 @@ export function SearchResultsSection({ searchQuery }: { searchQuery: string }): const hasENSResult = ensName && ensAddress const hasEOAResult = validAddress && !isSmartContractAddress const walletSearchResults: WalletSearchResult[] = useMemo(() => { - if (hasENSResult) { - return [ - { - type: SearchResultType.Wallet, - address: ensAddress, - ensName, - }, - ] + const results: WalletSearchResult[] = [] + + if (unitagByName?.address?.address && unitagByName?.username) { + results.push({ + type: SearchResultType.Unitag, + address: unitagByName.address.address, + unitag: unitagByName.username, + }) } - if (hasEOAResult) { - return [ - { - type: SearchResultType.Wallet, - address: validAddress, - }, - ] + + // Do not show ENS result if it is the same as the Unitag result + if (hasENSResult && ensAddress !== unitagByName?.address?.address) { + results.push({ + type: SearchResultType.ENSAddress, + address: ensAddress, + ensName, + }) } - return [] - }, [ensAddress, ensName, hasENSResult, hasEOAResult, validAddress]) + + if (unitagByAddress?.username && validAddress) { + results.push({ + type: SearchResultType.Unitag, + address: validAddress, + unitag: unitagByAddress.username, + }) + } + + // Do not show EOA address result if there is a Unitag result by address + if (hasEOAResult && !unitagByAddress) { + results.push({ + type: SearchResultType.ENSAddress, + address: validAddress, + }) + } + + return results as WalletSearchResult[] + }, [ensAddress, ensName, unitagByName, unitagByAddress, hasENSResult, hasEOAResult, validAddress]) const countTokenResults = tokenResults?.length ?? 0 const countNftCollectionResults = nftCollectionResults?.length ?? 0 - const countENSResults = hasENSResult || hasEOAResult ? 1 : 0 - const countTotalResults = countTokenResults + countNftCollectionResults + countENSResults + const countWalletResults = walletSearchResults.length + const countTotalResults = countTokenResults + countNftCollectionResults + countWalletResults // Only consider queries with the .eth suffix as an exact ENS match const exactENSMatch = @@ -146,7 +175,8 @@ export function SearchResultsSection({ searchQuery }: { searchQuery: string }): const hasVerifiedNFTResults = Boolean(nftCollectionResults?.some((res) => res.isVerified)) - const showWalletSectionFirst = exactENSMatch && !prefixTokenMatch + const showWalletSectionFirst = + unitagByName || unitagByAddress || (exactENSMatch && !prefixTokenMatch) const showNftCollectionsBeforeTokens = hasVerifiedNFTResults && !hasVerifiedTokenResults const sortedSearchResults: SearchResultOrHeader[] = useMemo(() => { @@ -245,6 +275,7 @@ export function SearchResultsSection({ searchQuery }: { searchQuery: string }): } // Render function for FlatList of SearchResult items + export const renderSearchItem = ({ item: searchResult, searchContext, @@ -259,8 +290,10 @@ export const renderSearchItem = ({ ) case SearchResultType.Token: return - case SearchResultType.Wallet: - return + case SearchResultType.ENSAddress: + return + case SearchResultType.Unitag: + return case SearchResultType.NFTCollection: return case SearchResultType.Etherscan: diff --git a/apps/mobile/src/components/explore/search/__snapshots__/SearchPopularTokens.test.tsx.snap b/apps/mobile/src/components/explore/search/__snapshots__/SearchPopularTokens.test.tsx.snap index 55dd8b3cc1c..90ab3929ecd 100644 --- a/apps/mobile/src/components/explore/search/__snapshots__/SearchPopularTokens.test.tsx.snap +++ b/apps/mobile/src/components/explore/search/__snapshots__/SearchPopularTokens.test.tsx.snap @@ -4,7 +4,6 @@ exports[`SearchPopularTokens renders without error 1`] = ` + + + + + {completedENSName || formattedAddress} + + {showOwnedBy ? ( + + {t('Owned by {{owner}}', { + owner: primaryENSName || formattedAddress, + })} + + ) : null} + + + + ) +} diff --git a/apps/mobile/src/components/explore/search/items/SearchUnitagItem.tsx b/apps/mobile/src/components/explore/search/items/SearchUnitagItem.tsx new file mode 100644 index 00000000000..bcfc2baab2e --- /dev/null +++ b/apps/mobile/src/components/explore/search/items/SearchUnitagItem.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import { SearchWalletItemBase } from 'src/components/explore/search/items/SearchWalletItemBase' +import { Flex } from 'ui/src' +import { imageSizes } from 'ui/src/theme' +import { AccountIcon } from 'wallet/src/components/accounts/AccountIcon' +import { DisplayNameText } from 'wallet/src/components/accounts/DisplayNameText' +import { SearchContext } from 'wallet/src/features/search/SearchContext' +import { UnitagSearchResult } from 'wallet/src/features/search/SearchResult' +import { useAvatar } from 'wallet/src/features/wallet/hooks' +import { DisplayNameType } from 'wallet/src/features/wallet/types' + +type SearchUnitagItemProps = { + searchResult: UnitagSearchResult + searchContext?: SearchContext +} + +export function SearchUnitagItem({ + searchResult, + searchContext, +}: SearchUnitagItemProps): JSX.Element { + const { address, unitag } = searchResult + const { avatar } = useAvatar(address) + + const displayName = { name: unitag, type: DisplayNameType.Unitag } + + return ( + + + + + + + ) +} diff --git a/apps/mobile/src/components/explore/search/items/SearchWalletItem.tsx b/apps/mobile/src/components/explore/search/items/SearchWalletItem.tsx deleted file mode 100644 index 804a8b87e7d..00000000000 --- a/apps/mobile/src/components/explore/search/items/SearchWalletItem.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { ImpactFeedbackStyle } from 'expo-haptics' -import React, { useMemo } from 'react' -import { useTranslation } from 'react-i18next' -import ContextMenu from 'react-native-context-menu-view' -import { useAppDispatch, useAppSelector } from 'src/app/hooks' -import { useEagerExternalProfileNavigation } from 'src/app/navigation/hooks' -import { useToggleWatchedWalletCallback } from 'src/features/favorites/hooks' -import { sendMobileAnalyticsEvent } from 'src/features/telemetry' -import { MobileEventName } from 'src/features/telemetry/constants' -import { disableOnPress } from 'src/utils/disableOnPress' -import { Flex, Text, TouchableArea } from 'ui/src' -import { imageSizes } from 'ui/src/theme' -import { AccountIcon } from 'wallet/src/components/accounts/AccountIcon' -import { useENSAvatar, useENSName } from 'wallet/src/features/ens/api' -import { getCompletedENSName } from 'wallet/src/features/ens/useENS' -import { selectWatchedAddressSet } from 'wallet/src/features/favorites/selectors' -import { SearchContext } from 'wallet/src/features/search/SearchContext' -import { addToSearchHistory } from 'wallet/src/features/search/searchHistorySlice' -import { WalletSearchResult } from 'wallet/src/features/search/SearchResult' -import { sanitizeAddressText, shortenAddress } from 'wallet/src/utils/addresses' - -type SearchWalletItemProps = { - wallet: WalletSearchResult - searchContext?: SearchContext -} - -export function SearchWalletItem({ wallet, searchContext }: SearchWalletItemProps): JSX.Element { - const { t } = useTranslation() - const dispatch = useAppDispatch() - const { preload, navigate } = useEagerExternalProfileNavigation() - - // Use `savedPrimaryEnsName` for WalletSearchResults that are stored in the search history - // so that we don't have to do an additional ENS fetch when loading search history - const { address, ensName, primaryENSName: savedPrimaryENSName } = wallet - const formattedAddress = sanitizeAddressText(shortenAddress(address)) - - /* - * Fetch primary ENS associated with `address` since it may resolve to an - * ENS different than the `ensName` searched - * ex. if searching `uni.eth` resolves to 0x123, and the primary ENS for 0x123 - * is `uniswap.eth`, then we should show "uni.eth | owned by uniswap.eth" - */ - const completedENSName = getCompletedENSName(ensName ?? null) - const { data: fetchedPrimaryENSName, loading: isFetchingPrimaryENSName } = useENSName( - savedPrimaryENSName ? undefined : address - ) - - const primaryENSName = savedPrimaryENSName ?? fetchedPrimaryENSName - const isPrimaryENSName = completedENSName === primaryENSName - const showOwnedBy = !isFetchingPrimaryENSName && !isPrimaryENSName - - const { data: avatar } = useENSAvatar(address) - - const isFavorited = useAppSelector(selectWatchedAddressSet).has(address) - - const onPress = (): void => { - navigate(address) - if (searchContext) { - sendMobileAnalyticsEvent(MobileEventName.ExploreSearchResultClicked, { - query: searchContext.query, - name: ensName ?? address, - address, - type: 'address', - suggestion_count: searchContext.suggestionCount, - position: searchContext.position, - isHistory: searchContext.isHistory, - }) - } - dispatch( - addToSearchHistory({ - searchResult: { ...wallet, primaryENSName: primaryENSName ?? undefined }, - }) - ) - } - - const toggleFavoriteWallet = useToggleWatchedWalletCallback(address) - - const menuActions = useMemo(() => { - return isFavorited - ? [{ title: t('Remove favorite'), systemIcon: 'heart.fill' }] - : [{ title: t('Favorite wallet'), systemIcon: 'heart' }] - }, [isFavorited, t]) - - return ( - - => { - await preload(address) - }}> - - - - - {completedENSName || formattedAddress} - - {showOwnedBy ? ( - - {t('Owned by {{owner}}', { - owner: primaryENSName || formattedAddress, - })} - - ) : null} - - - - - ) -} diff --git a/apps/mobile/src/components/explore/search/items/SearchWalletItemBase.tsx b/apps/mobile/src/components/explore/search/items/SearchWalletItemBase.tsx new file mode 100644 index 00000000000..07f7ecf76fe --- /dev/null +++ b/apps/mobile/src/components/explore/search/items/SearchWalletItemBase.tsx @@ -0,0 +1,89 @@ +import { ImpactFeedbackStyle } from 'expo-haptics' +import React, { PropsWithChildren, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import ContextMenu from 'react-native-context-menu-view' +import { useAppDispatch, useAppSelector } from 'src/app/hooks' +import { useEagerExternalProfileNavigation } from 'src/app/navigation/hooks' +import { useToggleWatchedWalletCallback } from 'src/features/favorites/hooks' +import { sendMobileAnalyticsEvent } from 'src/features/telemetry' +import { MobileEventName } from 'src/features/telemetry/constants' +import { disableOnPress } from 'src/utils/disableOnPress' +import { TouchableArea } from 'ui/src' +import { selectWatchedAddressSet } from 'wallet/src/features/favorites/selectors' +import { SearchContext } from 'wallet/src/features/search/SearchContext' +import { addToSearchHistory } from 'wallet/src/features/search/searchHistorySlice' +import { SearchResultType, WalletSearchResult } from 'wallet/src/features/search/SearchResult' + +type SearchWalletItemBaseProps = { + searchResult: WalletSearchResult + searchContext?: SearchContext +} + +export function SearchWalletItemBase({ + children, + searchResult, + searchContext, +}: PropsWithChildren): JSX.Element { + const { t } = useTranslation() + const dispatch = useAppDispatch() + const { preload, navigate } = useEagerExternalProfileNavigation() + const { address, type } = searchResult + const isFavorited = useAppSelector(selectWatchedAddressSet).has(address) + + const onPress = (): void => { + navigate(address) + if (searchContext) { + sendMobileAnalyticsEvent(MobileEventName.ExploreSearchResultClicked, { + query: searchContext.query, + name: + type === SearchResultType.Unitag ? searchResult.unitag : searchResult.ensName ?? address, + address, + type: 'address', + suggestion_count: searchContext.suggestionCount, + position: searchContext.position, + isHistory: searchContext.isHistory, + }) + } + + if (type === SearchResultType.Unitag) { + dispatch( + addToSearchHistory({ + searchResult, + }) + ) + } else { + dispatch( + addToSearchHistory({ + searchResult: { + ...searchResult, + primaryENSName: searchResult.primaryENSName ?? undefined, + }, + }) + ) + } + } + + const toggleFavoriteWallet = useToggleWatchedWalletCallback(address) + + const menuActions = useMemo(() => { + return isFavorited + ? [{ title: t('Remove favorite'), systemIcon: 'heart.fill' }] + : [{ title: t('Favorite wallet'), systemIcon: 'heart' }] + }, [isFavorited, t]) + + return ( + + => { + await preload(address) + }}> + {children} + + + ) +} diff --git a/apps/mobile/src/components/explore/search/utils.ts b/apps/mobile/src/components/explore/search/utils.ts index 9a61c0dec90..0b4a806231c 100644 --- a/apps/mobile/src/components/explore/search/utils.ts +++ b/apps/mobile/src/components/explore/search/utils.ts @@ -1,11 +1,11 @@ import { Chain, ExploreSearchQuery } from 'wallet/src/data/__generated__/types-and-hooks' import { fromGraphQLChain } from 'wallet/src/features/chains/utils' -import { searchResultId } from 'wallet/src/features/search/searchHistorySlice' import { NFTCollectionSearchResult, SearchResultType, TokenSearchResult, } from 'wallet/src/features/search/SearchResult' +import { searchResultId } from 'wallet/src/features/search/searchHistorySlice' import { SEARCH_RESULT_HEADER_KEY } from './constants' import { SearchResultOrHeader } from './types' diff --git a/apps/mobile/src/components/fiatOnRamp/QuoteItem.tsx b/apps/mobile/src/components/fiatOnRamp/QuoteItem.tsx index a737a438b9a..3fed1bfa243 100644 --- a/apps/mobile/src/components/fiatOnRamp/QuoteItem.tsx +++ b/apps/mobile/src/components/fiatOnRamp/QuoteItem.tsx @@ -2,13 +2,13 @@ import { Currency } from '@uniswap/sdk-core' import React from 'react' import { useTranslation } from 'react-i18next' import { StyleSheet } from 'react-native' -import { useMeldLogoUrl } from 'src/components/fiatOnRamp/hooks' +import { useFiatOnRampLogoUrl } from 'src/components/fiatOnRamp/hooks' import { Loader } from 'src/components/loading' import { useFormatExactCurrencyAmount } from 'src/features/fiatOnRamp/hooks' import { Flex, Icons, Text, TouchableArea } from 'ui/src' import { fonts, iconSizes } from 'ui/src/theme' import { FiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks' -import { MeldQuote, MeldServiceProvider } from 'wallet/src/features/fiatOnRamp/meld' +import { FORQuote, FORServiceProvider } from 'wallet/src/features/fiatOnRamp/types' import { ImageUri } from 'wallet/src/features/images/ImageUri' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { getSymbolDisplayText } from 'wallet/src/utils/currency' @@ -23,8 +23,8 @@ export function FORQuoteItem({ showCarret, active, }: { - quote: MeldQuote | undefined - serviceProvider: MeldServiceProvider | undefined + quote: FORQuote | undefined + serviceProvider: FORServiceProvider | undefined currency: Maybe loading: boolean baseCurrency: FiatCurrencyInfo @@ -41,12 +41,12 @@ export function FORQuoteItem({ ) const quoteEquivalentInSourceCurrencyAmount = addFiatSymbolToNumber({ - value: quote?.sourceAmountWithoutFees || 0, + value: quote?.sourceAmount || 0, currencyCode: baseCurrency.code, currencySymbol: baseCurrency.symbol, }) - const logoUrl = useMeldLogoUrl(serviceProvider?.logos) + const logoUrl = useFiatOnRampLogoUrl(serviceProvider?.logos) return ( diff --git a/apps/mobile/src/components/fiatOnRamp/hooks.ts b/apps/mobile/src/components/fiatOnRamp/hooks.ts index 2d237df7674..2683ac73140 100644 --- a/apps/mobile/src/components/fiatOnRamp/hooks.ts +++ b/apps/mobile/src/components/fiatOnRamp/hooks.ts @@ -1,7 +1,7 @@ import { useIsDarkMode } from 'ui/src' -import { MeldLogos } from 'wallet/src/features/fiatOnRamp/meld' +import { FORLogo } from 'wallet/src/features/fiatOnRamp/types' -export function useMeldLogoUrl(logos: MeldLogos | undefined): string | undefined { +export function useFiatOnRampLogoUrl(logos: FORLogo | undefined): string | undefined { const isDarkMode = useIsDarkMode() if (!logos) { diff --git a/apps/mobile/src/components/gradients/LandingBackground.tsx b/apps/mobile/src/components/gradients/LandingBackground.tsx index 4962c868d51..72005d16607 100644 --- a/apps/mobile/src/components/gradients/LandingBackground.tsx +++ b/apps/mobile/src/components/gradients/LandingBackground.tsx @@ -82,7 +82,11 @@ export const LandingBackground = (): JSX.Element | null => { } // Android 9 and 10 have issues with Rive, so we fallback on image - if ((isAndroid && Platform.Version < 30) || language !== Language.English) { + if ( + // Android Platform.Version is always a number + (isAndroid && typeof Platform.Version === 'number' && Platform.Version < 30) || + language !== Language.English + ) { return } diff --git a/apps/mobile/src/components/gradients/UniconThemedGradient.tsx b/apps/mobile/src/components/gradients/UniconThemedGradient.tsx index ba791cd46ee..e5c1c0ee3fd 100644 --- a/apps/mobile/src/components/gradients/UniconThemedGradient.tsx +++ b/apps/mobile/src/components/gradients/UniconThemedGradient.tsx @@ -1,6 +1,6 @@ import React, { memo } from 'react' import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg' -import { getTokenValue, Tokens } from 'ui/src' +import { Tokens, getTokenValue } from 'ui/src' function _UniconThemedGradient({ gradientStartColor, diff --git a/apps/mobile/src/components/home/ActivityTab.tsx b/apps/mobile/src/components/home/ActivityTab.tsx index 18bdeda161f..4f0e1c841db 100644 --- a/apps/mobile/src/components/home/ActivityTab.tsx +++ b/apps/mobile/src/components/home/ActivityTab.tsx @@ -1,33 +1,20 @@ import { ForwardedRef, forwardRef, memo, useMemo } from 'react' -import { useTranslation } from 'react-i18next' import { FlatList, RefreshControl } from 'react-native' import Animated from 'react-native-reanimated' import { useAppDispatch } from 'src/app/hooks' +import { ScannerModalState } from 'src/components/QRCodeScanner/constants' import { useAdaptiveFooter } from 'src/components/home/hooks' -import { NoTransactions } from 'src/components/icons/NoTransactions' import { AnimatedBottomSheetFlatList, AnimatedFlatList, } from 'src/components/layout/AnimatedFlatList' -import { TabProps, TAB_BAR_HEIGHT } from 'src/components/layout/TabHelpers' -import { Loader } from 'src/components/loading' -import { ScannerModalState } from 'src/components/QRCodeScanner/constants' +import { TAB_BAR_HEIGHT, TabProps } from 'src/components/layout/TabHelpers' +import { useBiometricAppSettings, useBiometricPrompt } from 'src/features/biometrics/hooks' import { openModal } from 'src/features/modals/modalSlice' -import TransactionSummaryLayout from 'src/features/transactions/SummaryCards/TransactionSummaryLayout' import { removePendingSession } from 'src/features/walletConnect/walletConnectSlice' -import { Flex, Text, useDeviceInsets, useSporeColors } from 'ui/src' -import { BaseCard } from 'wallet/src/components/BaseCard/BaseCard' +import { Flex, useDeviceInsets, useSporeColors } from 'ui/src' import { GQLQueries } from 'wallet/src/data/queries' -import { useFormattedTransactionDataForActivity } from 'wallet/src/features/activity/hooks' -import { - useCreateSwapFormState, - useMergeLocalAndRemoteTransactions, -} from 'wallet/src/features/transactions/hooks' -import { SwapSummaryCallbacks } from 'wallet/src/features/transactions/SummaryCards/types' -import { generateActivityItemRenderer } from 'wallet/src/features/transactions/SummaryCards/utils' -import { useMostRecentSwapTx } from 'wallet/src/features/transactions/swap/hooks' -import { TransactionState } from 'wallet/src/features/transactions/transactionState/types' -import { useHideSpamTokensSetting } from 'wallet/src/features/wallet/hooks' +import { useActivityData } from 'wallet/src/features/activity/useActivityData' import { ModalName } from 'wallet/src/telemetry/constants' import { isAndroid } from 'wallet/src/utils/platform' @@ -35,14 +22,6 @@ export const ACTIVITY_TAB_DATA_DEPENDENCIES = [GQLQueries.TransactionList] const ESTIMATED_ITEM_SIZE = 92 -const SectionTitle = ({ title }: { title: string }): JSX.Element => ( - - - {title} - - -) - export const ActivityTab = memo( forwardRef, TabProps>(function _ActivityTab( { @@ -57,46 +36,17 @@ export const ActivityTab = memo( }, ref ) { - const { t } = useTranslation() const dispatch = useAppDispatch() const colors = useSporeColors() const insets = useDeviceInsets() + const { trigger: biometricsTrigger } = useBiometricPrompt() + const { requiredForTransactions: requiresBiometrics } = useBiometricAppSettings() + const { onContentSizeChange, adaptiveFooter } = useAdaptiveFooter( containerProps?.contentContainerStyle ) - // Hide all spam transactions if active wallet has enabled setting. - const hideSpamTokens = useHideSpamTokensSetting() - - const swapCallbacks: SwapSummaryCallbacks = useMemo(() => { - return { - getLatestSwapTransaction: useMostRecentSwapTx, - getSwapFormTransactionState: useCreateSwapFormState, - onRetryGenerator: (swapFormState: TransactionState | undefined) => { - return () => { - dispatch(openModal({ name: ModalName.Swap, initialState: swapFormState })) - } - }, - } - }, [dispatch]) - - const renderActivityItem = useMemo(() => { - return generateActivityItemRenderer( - TransactionSummaryLayout, - , - SectionTitle, - swapCallbacks - ) - }, [swapCallbacks]) - - const { onRetry, hasData, isLoading, isError, sectionData, keyExtractor } = - useFormattedTransactionDataForActivity( - owner, - hideSpamTokens, - useMergeLocalAndRemoteTransactions - ) - const onPressReceive = (): void => { // in case we received a pending session from a previous scan after closing modal dispatch(removePendingSession()) @@ -105,40 +55,19 @@ export const ActivityTab = memo( ) } - const errorCard = ( - - - - ) - - const emptyListView = ( - - } - title={t('No activity yet')} - onPress={onPressReceive} - /> - - ) - - let emptyComponent = null - if (!hasData && isError) { - emptyComponent = errorCard - } else if (!isLoading && emptyListView) { - emptyComponent = emptyListView - } + const { + maybeLoaderComponent, + maybeEmptyComponent, + renderActivityItem, + sectionData, + keyExtractor, + } = useActivityData({ + owner, + authTrigger: requiresBiometrics ? biometricsTrigger : undefined, + isExternalProfile, + emptyContainerStyle: containerProps?.emptyContainerStyle, + onPressEmptyState: onPressReceive, + }) const refreshControl = useMemo(() => { return ( @@ -153,14 +82,6 @@ export const ActivityTab = memo( ) }, [refreshing, headerHeight, onRefresh, colors.neutral3, insets.top]) - if (!hasData && isError) { - return errorCard - } - - // We want to display the loading shimmer in the footer only when the data haven't been fetched yet - // (list items use their own loading shimmer so there is no need to display it in the footer) - const isLoadingInitially = isLoading && !sectionData - const List = renderedInModal ? AnimatedBottomSheetFlatList : AnimatedFlatList return ( @@ -168,11 +89,11 @@ export const ActivityTab = memo( >} - ListEmptyComponent={emptyComponent} + ListEmptyComponent={maybeEmptyComponent} // we add a footer to cover any possible space, so user can scroll the top menu all the way to the top ListFooterComponent={ <> - {isLoadingInitially && } + {maybeLoaderComponent} {isExternalProfile ? null : adaptiveFooter} } diff --git a/apps/mobile/src/components/home/FeedTab.tsx b/apps/mobile/src/components/home/FeedTab.tsx index 4fa57ade02d..930a7ceaf8d 100644 --- a/apps/mobile/src/components/home/FeedTab.tsx +++ b/apps/mobile/src/components/home/FeedTab.tsx @@ -3,20 +3,20 @@ import { useTranslation } from 'react-i18next' import { FlatList, RefreshControl } from 'react-native' import Animated from 'react-native-reanimated' import { useAppDispatch, useAppSelector } from 'src/app/hooks' +import { ScannerModalState } from 'src/components/QRCodeScanner/constants' import { useAdaptiveFooter } from 'src/components/home/hooks' -import { NoTransactions } from 'src/components/icons/NoTransactions' import { AnimatedFlatList } from 'src/components/layout/AnimatedFlatList' -import { TabProps, TAB_BAR_HEIGHT } from 'src/components/layout/TabHelpers' +import { TAB_BAR_HEIGHT, TabProps } from 'src/components/layout/TabHelpers' import { Loader } from 'src/components/loading' -import { ScannerModalState } from 'src/components/QRCodeScanner/constants' import { openModal } from 'src/features/modals/modalSlice' -import TransactionSummaryLayout from 'src/features/transactions/SummaryCards/TransactionSummaryLayout' import { removePendingSession } from 'src/features/walletConnect/walletConnectSlice' import { Flex, Text, useDeviceInsets, useSporeColors } from 'ui/src' +import { NoTransactions } from 'ui/src/components/icons/NoTransactions' import { BaseCard } from 'wallet/src/components/BaseCard/BaseCard' import { GQLQueries } from 'wallet/src/data/queries' import { useFormattedTransactionDataForFeed } from 'wallet/src/features/activity/hooks' import { selectWatchedAddressSet } from 'wallet/src/features/favorites/selectors' +import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' import { generateActivityItemRenderer } from 'wallet/src/features/transactions/SummaryCards/utils' import { useHideSpamTokensSetting } from 'wallet/src/features/wallet/hooks' import { ModalName } from 'wallet/src/telemetry/constants' @@ -56,7 +56,9 @@ export const FeedTab = memo( return generateActivityItemRenderer( TransactionSummaryLayout, , - SectionTitle + SectionTitle, + undefined, + undefined ) }, []) diff --git a/apps/mobile/src/components/home/NftsTab.tsx b/apps/mobile/src/components/home/NftsTab.tsx index ab772ae997f..74cdf95a1a3 100644 --- a/apps/mobile/src/components/home/NftsTab.tsx +++ b/apps/mobile/src/components/home/NftsTab.tsx @@ -4,7 +4,7 @@ import { RefreshControl } from 'react-native' import { useAppDispatch } from 'src/app/hooks' import { useAppStackNavigation } from 'src/app/navigation/types' import { useAdaptiveFooter } from 'src/components/home/hooks' -import { TabProps, TAB_BAR_HEIGHT } from 'src/components/layout/TabHelpers' +import { TAB_BAR_HEIGHT, TabProps } from 'src/components/layout/TabHelpers' import { NftView } from 'src/components/NFT/NftView' import { ScannerModalState } from 'src/components/QRCodeScanner/constants' import { openModal } from 'src/features/modals/modalSlice' diff --git a/apps/mobile/src/components/home/TokensTab.tsx b/apps/mobile/src/components/home/TokensTab.tsx index 61c7ef6ae6c..9bc5c16ef78 100644 --- a/apps/mobile/src/components/home/TokensTab.tsx +++ b/apps/mobile/src/components/home/TokensTab.tsx @@ -8,13 +8,13 @@ import { NoTokens } from 'src/components/icons/NoTokens' import { TabContentProps, TabProps } from 'src/components/layout/TabHelpers' import { ScannerModalState } from 'src/components/QRCodeScanner/constants' import { TokenBalanceList } from 'src/components/TokenBalanceList/TokenBalanceList' -import { TokenBalanceListRow } from 'src/components/TokenBalanceList/TokenBalanceListContext' import { useTokenDetailsNavigation } from 'src/components/TokenDetails/hooks' import { openModal } from 'src/features/modals/modalSlice' import { Screens } from 'src/screens/Screens' import { Flex } from 'ui/src' import { BaseCard } from 'wallet/src/components/BaseCard/BaseCard' import { GQLQueries } from 'wallet/src/data/queries' +import { TokenBalanceListRow } from 'wallet/src/features/portfolio/TokenBalanceListContext' import { ModalName } from 'wallet/src/telemetry/constants' import { CurrencyId } from 'wallet/src/utils/currencyId' @@ -82,7 +82,7 @@ export const TokensTab = memo( }, [isExternalProfile, onPressAction, t]) return ( - + - } - left={5} - overlay={} - /> - - ) -}) diff --git a/apps/mobile/src/components/icons/TripleDot.tsx b/apps/mobile/src/components/icons/TripleDot.tsx index 43bc7a9941c..32488c129fe 100644 --- a/apps/mobile/src/components/icons/TripleDot.tsx +++ b/apps/mobile/src/components/icons/TripleDot.tsx @@ -9,9 +9,9 @@ type Props = { export const TripleDot = memo(function _TripleDot({ size = 5, color = '$neutral2' }: Props) { return ( - - - + + + ) }) diff --git a/apps/mobile/src/components/input/__snapshots__/SelectionCircle.test.tsx.snap b/apps/mobile/src/components/input/__snapshots__/SelectionCircle.test.tsx.snap index f8f9feaa7b6..3bc395c4674 100644 --- a/apps/mobile/src/components/input/__snapshots__/SelectionCircle.test.tsx.snap +++ b/apps/mobile/src/components/input/__snapshots__/SelectionCircle.test.tsx.snap @@ -28,7 +28,6 @@ exports[`renders selection circle 1`] = ` + {children} ) diff --git a/apps/mobile/src/components/layout/screens/EdgeGestureTarget.tsx b/apps/mobile/src/components/layout/screens/EdgeGestureTarget.tsx index e13059a5d01..d7b147ee92a 100644 --- a/apps/mobile/src/components/layout/screens/EdgeGestureTarget.tsx +++ b/apps/mobile/src/components/layout/screens/EdgeGestureTarget.tsx @@ -20,7 +20,7 @@ export function HorizontalEdgeGestureTarget({ return ( + {showHandleBar ? : null} {children} + return {children} } return ( - + diff --git a/apps/mobile/src/components/loading/__snapshots__/WalletLoader.test.tsx.snap b/apps/mobile/src/components/loading/__snapshots__/WalletLoader.test.tsx.snap index 394dfbb2950..d39184aca0a 100644 --- a/apps/mobile/src/components/loading/__snapshots__/WalletLoader.test.tsx.snap +++ b/apps/mobile/src/components/loading/__snapshots__/WalletLoader.test.tsx.snap @@ -43,7 +43,6 @@ exports[`renders wallet loader 1`] = ` {value} - + ))} diff --git a/apps/mobile/src/components/sortableGrid/SortableGrid.tsx b/apps/mobile/src/components/sortableGrid/SortableGrid.tsx index c2441572e5f..5224ada1f47 100644 --- a/apps/mobile/src/components/sortableGrid/SortableGrid.tsx +++ b/apps/mobile/src/components/sortableGrid/SortableGrid.tsx @@ -1,9 +1,9 @@ import { memo, useRef } from 'react' import { LayoutChangeEvent, MeasureLayoutOnSuccessCallback, View } from 'react-native' import { Flex, FlexProps } from 'ui/src' -import { useStableCallback } from './hooks' import SortableGridItem from './SortableGridItem' import SortableGridProvider, { useSortableGridContext } from './SortableGridProvider' +import { useStableCallback } from './hooks' import { AutoScrollProps, SortableGridChangeEvent, SortableGridRenderItem } from './types' import { defaultKeyExtractor } from './utils' diff --git a/apps/mobile/src/components/sortableGrid/SortableGridItem.tsx b/apps/mobile/src/components/sortableGrid/SortableGridItem.tsx index 40db9a18768..e631e9f2967 100644 --- a/apps/mobile/src/components/sortableGrid/SortableGridItem.tsx +++ b/apps/mobile/src/components/sortableGrid/SortableGridItem.tsx @@ -12,9 +12,9 @@ import Animated, { withTiming, } from 'react-native-reanimated' import ActiveItemDecoration from './ActiveItemDecoration' +import { useSortableGridContext } from './SortableGridProvider' import { TIME_TO_ACTIVATE_PAN } from './constants' import { useAnimatedZIndex, useItemOrderUpdater } from './hooks' -import { useSortableGridContext } from './SortableGridProvider' import { SortableGridRenderItem } from './types' type SortableGridItemProps = { diff --git a/apps/mobile/src/components/sortableGrid/hooks.ts b/apps/mobile/src/components/sortableGrid/hooks.ts index 473bf9f89fb..0536737186a 100644 --- a/apps/mobile/src/components/sortableGrid/hooks.ts +++ b/apps/mobile/src/components/sortableGrid/hooks.ts @@ -1,8 +1,8 @@ import { useCallback, useRef } from 'react' import { FlatList, ScrollView } from 'react-native' -import { runOnJS, SharedValue, useAnimatedReaction, useSharedValue } from 'react-native-reanimated' -import { AUTO_SCROLL_THRESHOLD } from './constants' +import { SharedValue, runOnJS, useAnimatedReaction, useSharedValue } from 'react-native-reanimated' import { useSortableGridContext } from './SortableGridProvider' +import { AUTO_SCROLL_THRESHOLD } from './constants' import { ItemMeasurements } from './types' export function useStableCallback< diff --git a/apps/mobile/src/components/text/AnimatedText.test.tsx b/apps/mobile/src/components/text/AnimatedText.test.tsx index 5136e2db5ef..3a51ebc360a 100644 --- a/apps/mobile/src/components/text/AnimatedText.test.tsx +++ b/apps/mobile/src/components/text/AnimatedText.test.tsx @@ -3,6 +3,7 @@ import React from 'react' import { makeMutable } from 'react-native-reanimated' import { act } from 'react-test-renderer' import { AnimatedText } from 'src/components/text/AnimatedText' +import { renderWithProviders } from 'src/test/render' describe(AnimatedText, () => { it('renders without error', () => { @@ -36,7 +37,7 @@ describe(AnimatedText, () => { describe('when text is in the loading state', () => { it('displays text placeholder with loading shimmer when the loading property is true', async () => { - const tree = render() + const tree = renderWithProviders() const shimmerPlaceholder = tree.getByTestId('shimmer-placeholder') @@ -57,7 +58,7 @@ describe(AnimatedText, () => { }) it('displays the loading placeholder without shimmer when the loading property has "no-shimmer" value', () => { - const tree = render() + const tree = renderWithProviders() const shimmerPlaceholder = tree.queryByTestId('shimmer-placeholder') expect(shimmerPlaceholder).toBeFalsy() @@ -73,7 +74,7 @@ describe(AnimatedText, () => { describe('when text is not in the loading state', () => { it('updates text when text value is modified', async () => { const textValue = makeMutable('Initial') - const tree = render() + const tree = renderWithProviders() expect(tree.queryByDisplayValue('Initial')).toBeTruthy() diff --git a/apps/mobile/src/components/text/AnimatedText.tsx b/apps/mobile/src/components/text/AnimatedText.tsx index e7dba9a511e..4cb62cad7dd 100644 --- a/apps/mobile/src/components/text/AnimatedText.tsx +++ b/apps/mobile/src/components/text/AnimatedText.tsx @@ -1,13 +1,13 @@ import React from 'react' import { + TextProps as RNTextProps, StyleSheet, TextInput, TextInputProps, - TextProps as RNTextProps, useWindowDimensions, } from 'react-native' import Animated, { useAnimatedProps } from 'react-native-reanimated' -import { Flex, TextFrame, TextProps as TamaTextProps, usePropsAndStyle } from 'ui/src' +import { Flex, TextProps as TamaTextProps, TextFrame, usePropsAndStyle } from 'ui/src' import { TextLoaderWrapper } from 'ui/src/components/text/Text' import { fonts } from 'ui/src/theme' @@ -80,6 +80,13 @@ export const BaseAnimatedText = ({ // end of forked from https://github.com/wcandillon/react-native-redash/blob/master/src/ReText.tsx // gives you tamagui props with reanimated support +/** + * @deprecated Prefer + * + * See: https://tamagui.dev/docs/core/animations + * + * TODO(MOB-1948): Remove this + * */ export const AnimatedText = ({ style, ...propsIn }: TextProps): JSX.Element => { const variant = propsIn.variant ?? 'body2' const [props, textStyles] = usePropsAndStyle( diff --git a/apps/mobile/src/components/text/LongMarkdownText.test.tsx b/apps/mobile/src/components/text/LongMarkdownText.test.tsx index f7dd5401f79..c6adee187cd 100644 --- a/apps/mobile/src/components/text/LongMarkdownText.test.tsx +++ b/apps/mobile/src/components/text/LongMarkdownText.test.tsx @@ -101,7 +101,6 @@ describe(LongMarkdownText, () => { const readMoreButton = tree.queryByTestId('read-more-button') expect(readMoreButton).toBeTruthy() - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion expect(within(readMoreButton!).getByText('Read more')).toBeTruthy() }) }) @@ -129,7 +128,7 @@ describe(LongMarkdownText, () => { fireEvent.press(readMoreButton) expect(readMoreButton).toBeTruthy() - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(within(readMoreButton!).getByText('Read less')).toBeTruthy() }) }) diff --git a/apps/mobile/src/components/text/LongText.test.tsx b/apps/mobile/src/components/text/LongText.test.tsx index 821b2a41b20..4ace94ece88 100644 --- a/apps/mobile/src/components/text/LongText.test.tsx +++ b/apps/mobile/src/components/text/LongText.test.tsx @@ -66,7 +66,6 @@ describe(LongText, () => { const readMoreButton = tree.queryByTestId('read-more-button') expect(readMoreButton).toBeTruthy() - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion expect(within(readMoreButton!).getByText('Read more')).toBeTruthy() }) }) diff --git a/apps/mobile/src/components/text/__snapshots__/LongMarkdownText.test.tsx.snap b/apps/mobile/src/components/text/__snapshots__/LongMarkdownText.test.tsx.snap index 3727e86495e..8536d0bc954 100644 --- a/apps/mobile/src/components/text/__snapshots__/LongMarkdownText.test.tsx.snap +++ b/apps/mobile/src/components/text/__snapshots__/LongMarkdownText.test.tsx.snap @@ -4,7 +4,6 @@ exports[`LongMarkdownText renders without error 1`] = ` { + const response = await launchImageLibrary(IMAGE_OPTIONS) + if (!response.didCancel && !response.errorCode && response.assets) { + return response.assets[0]?.uri + } +} + +export function useAvatarSelectionHandler({ + address, + avatarImageUri, + setAvatarImageUri, + showModal, +}: { + address: string + avatarImageUri: string | undefined + setAvatarImageUri: (uri: string) => void + showModal: () => void +}): { avatarSelectionHandler: () => Promise; hasNFTs: boolean } { + const { data: nftsData } = useNftsTabQuery({ + variables: { ownerAddress: address, first: NUM_FIRST_NFTS, filter: { filterSpam: false } }, + }) + const nftItems = formatNftItems(nftsData) + + const hasNFTs = nftItems !== undefined && nftItems?.length > 0 + const hasAvatarImage = avatarImageUri && avatarImageUri !== '' + + if (hasNFTs || hasAvatarImage) { + return { avatarSelectionHandler: async () => showModal(), hasNFTs } + } else { + return { + avatarSelectionHandler: async (): Promise => { + const selectedPhoto = await selectPhotoFromLibrary() + if (selectedPhoto) { + setAvatarImageUri(selectedPhoto) + } + }, + hasNFTs, + } + } +} diff --git a/apps/mobile/src/components/unitags/ChangeUnitagModal.tsx b/apps/mobile/src/components/unitags/ChangeUnitagModal.tsx new file mode 100644 index 00000000000..83bcdddeacd --- /dev/null +++ b/apps/mobile/src/components/unitags/ChangeUnitagModal.tsx @@ -0,0 +1,335 @@ +import { useNavigation } from '@react-navigation/native' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ActivityIndicator, EmitterSubscription, Keyboard } from 'react-native' +import { getUniqueId } from 'react-native-device-info' +import { Button, Flex, Icons, Text, useSporeColors } from 'ui/src' +import { fonts, spacing } from 'ui/src/theme' +import { logger } from 'utilities/src/logger/logger' +import { useAsyncData } from 'utilities/src/react/hooks' +import { TextInput } from 'wallet/src/components/input/TextInput' +import { BottomSheetModal } from 'wallet/src/components/modals/BottomSheetModal' +import { pushNotification } from 'wallet/src/features/notifications/slice' +import { AppNotificationType } from 'wallet/src/features/notifications/types' +import { changeUnitag } from 'wallet/src/features/unitags/api' +import { UNITAG_SUFFIX } from 'wallet/src/features/unitags/constants' +import { useUnitagUpdater } from 'wallet/src/features/unitags/context' +import { useCanAddressClaimUnitag, useCanClaimUnitagName } from 'wallet/src/features/unitags/hooks' +import { UnitagErrorCodes } from 'wallet/src/features/unitags/types' +import { parseUnitagErrorCode } from 'wallet/src/features/unitags/utils' +import { useWalletSigners } from 'wallet/src/features/wallet/context' +import { useAccount } from 'wallet/src/features/wallet/hooks' +import { useAppDispatch } from 'wallet/src/state' +import { ElementName, ModalName } from 'wallet/src/telemetry/constants' +import { isIOS } from 'wallet/src/utils/platform' + +export function ChangeUnitagModal({ + unitag, + address, + onClose, +}: { + unitag: string + address: Address + onClose: () => void +}): JSX.Element { + const { t } = useTranslation() + const colors = useSporeColors() + const navigation = useNavigation() + const dispatch = useAppDispatch() + const { data: deviceId } = useAsyncData(getUniqueId) + const account = useAccount(address) + const signerManager = useWalletSigners() + + const [newUnitag, setNewUnitag] = useState(unitag) + const [keyboardHeight, setKeyboardHeight] = useState(0) + const [showConfirmModal, setShowConfirmModal] = useState(false) + const [isCheckingUnitag, setIsCheckingUnitag] = useState(false) + const [isChangeResponseLoading, setIsChangeResponseLoading] = useState(false) + const [unitagToCheck, setUnitagToCheck] = useState(unitag) + + const { error: canClaimUnitagNameError, loading: loadingUnitagErrorCheck } = + useCanClaimUnitagName(address, unitagToCheck) + const { errorCode } = useCanAddressClaimUnitag(address, true) + const { triggerRefetchUnitags } = useUnitagUpdater() + + const isUnitagEdited = unitag !== newUnitag + const isUnitagInvalid = + newUnitag === unitagToCheck && !!canClaimUnitagNameError && !loadingUnitagErrorCheck + const isUnitagValid = + isUnitagEdited && !canClaimUnitagNameError && !loadingUnitagErrorCheck && !!newUnitag + const hasReachedAddressLimit = errorCode === UnitagErrorCodes.AddressLimitReached + const isSubmitButtonDisabled = + isCheckingUnitag || + isChangeResponseLoading || + !deviceId || + hasReachedAddressLimit || + !isUnitagEdited || + !newUnitag || + isUnitagInvalid + + const onFinishEditing = (): void => { + Keyboard.dismiss() + } + + const onCloseConfirmModal = (): void => { + setShowConfirmModal(false) + } + + const onPressSaveChanges = (): void => { + if (newUnitag !== unitagToCheck) { + // Unitag needs to be checked for errors and availability + setIsCheckingUnitag(true) + setUnitagToCheck(newUnitag) + } else if (isUnitagValid) { + // If unitag is unchanged and is available, continue to speedbump + onFinishEditing() + setShowConfirmModal(true) + } + } + + const onChangeSubmit = async (): Promise => { + if (!deviceId) { + logger.error(new Error('DeviceId is undefined'), { + tags: { file: 'ChangeUnitagModal', function: 'onChangeSubmit' }, + }) + return // Should never hit this condition. Button is disabled if deviceId is undefined + } + + onFinishEditing() + setShowConfirmModal(false) + setIsChangeResponseLoading(true) + try { + // Change unitag backend call + const { data: changeResponse } = await changeUnitag({ + username: unitagToCheck, + deviceId, + account, + signerManager, + }) + setIsChangeResponseLoading(false) + + // If change failed and returns an error code, display the error message + if (!changeResponse.success && !!changeResponse.errorCode) { + dispatch( + pushNotification({ + type: AppNotificationType.Error, + errorMessage: parseUnitagErrorCode(t, unitagToCheck, changeResponse.errorCode), + }) + ) + return + } + + // If change succeeded, exit the modal and display a success message + if (changeResponse.success) { + triggerRefetchUnitags() + dispatch( + pushNotification({ + type: AppNotificationType.Success, + title: t('Username changed'), + }) + ) + navigation.goBack() + onClose() + } + } catch (e) { + // If some other error occurs, log it and display a generic error message + logger.error(e, { + tags: { file: 'ChangeUnitagModal', function: 'onChangeSubmit' }, + }) + dispatch( + pushNotification({ + type: AppNotificationType.Error, + errorMessage: t('Could not change username. Try again later.'), + }) + ) + onClose() + setIsChangeResponseLoading(false) + } + } + + // This useEffect makes KeyboardAvoidingView work when inside a BottomSheetModal + // Dynamically add bottom padding equal to keyboard height so that elements have room to shift up + useEffect(() => { + let showSubscription: EmitterSubscription + let hideSubscription: EmitterSubscription + + if (isIOS) { + // Using keyboardWillShow makes it feel more responsive, but only available on iOS + showSubscription = Keyboard.addListener('keyboardWillShow', (e) => { + setKeyboardHeight(e.endCoordinates.height) + }) + hideSubscription = Keyboard.addListener('keyboardWillHide', () => { + setKeyboardHeight(0) + }) + } else { + // keyboardDidShow only emits after the keyboard has fully appeared + showSubscription = Keyboard.addListener('keyboardDidShow', (e) => { + setKeyboardHeight(e.endCoordinates.height) + }) + hideSubscription = Keyboard.addListener('keyboardDidHide', () => { + setKeyboardHeight(0) + }) + } + + return () => { + showSubscription.remove() + hideSubscription.remove() + } + }, []) + + // When useUnitagError completes loading, if unitag is valid then continue to speedbump + useEffect(() => { + if (isCheckingUnitag && !!unitagToCheck && !loadingUnitagErrorCheck) { + setIsCheckingUnitag(false) + // If unitagError is defined, it's rendered in UI. If no error, continue to speedbump + if (unitagToCheck === newUnitag && isUnitagValid) { + onFinishEditing() + setShowConfirmModal(true) + } + } + }, [isCheckingUnitag, isUnitagValid, loadingUnitagErrorCheck, newUnitag, unitagToCheck]) + + return ( + <> + {showConfirmModal && ( + + )} + + 0 ? keyboardHeight - spacing.spacing20 : '$spacing12'} + pt="$spacing12" + px="$spacing24"> + + {t('Edit username')} + + + + + + {UNITAG_SUFFIX} + + + + {hasReachedAddressLimit ? ( + + + {t('You’ve reached the maximum number of 2 usernames changes.')} + + + ) : ( + + + {t( + 'Once you change your username, you never claim it again. You can only change it 2 times.' + )} + + + )} + {isUnitagEdited && unitagToCheck === newUnitag && canClaimUnitagNameError && ( + + + {canClaimUnitagNameError} + + + )} + + + + + + + ) +} + +function ChangeUnitagConfirmModal({ + onClose, + onChangeSubmit, +}: { + onClose: () => void + onChangeSubmit: () => Promise +}): JSX.Element { + const { t } = useTranslation() + return ( + + + + + + + {t('Are you sure?')} + + + {t( + 'You’re about to change your username. Once you change it, you can never claim it again.' + )} + + + + + + + + ) +} diff --git a/apps/mobile/src/components/unitags/ChooseNftModal.tsx b/apps/mobile/src/components/unitags/ChooseNftModal.tsx index 2949a660781..5fc616a3d27 100644 --- a/apps/mobile/src/components/unitags/ChooseNftModal.tsx +++ b/apps/mobile/src/components/unitags/ChooseNftModal.tsx @@ -1,5 +1,6 @@ import { NftView } from 'src/components/NFT/NftView' -import { Flex, useSporeColors } from 'ui/src' +import { useDeviceInsets, useSporeColors } from 'ui/src' +import { spacing } from 'ui/src/theme' import { BottomSheetModal } from 'wallet/src/components/modals/BottomSheetModal' import { NftsList } from 'wallet/src/components/nfts/NftsList' import { NFTItem } from 'wallet/src/features/nfts/types' @@ -13,6 +14,7 @@ type ChooseNftProps = { export const ChooseNftModal = ({ address, setPhotoUri, onClose }: ChooseNftProps): JSX.Element => { const colors = useSporeColors() + const insets = useDeviceInsets() const renderNFT = (item: NFTItem): JSX.Element => { const onPressNft = (): void => { @@ -24,14 +26,22 @@ export const ChooseNftModal = ({ address, setPhotoUri, onClose }: ChooseNftProps return ( - - - + ) } diff --git a/apps/mobile/src/components/unitags/ChoosePhotoOptionsModal.tsx b/apps/mobile/src/components/unitags/ChoosePhotoOptionsModal.tsx index fd6bb7152cc..bb5b5490531 100644 --- a/apps/mobile/src/components/unitags/ChoosePhotoOptionsModal.tsx +++ b/apps/mobile/src/components/unitags/ChoosePhotoOptionsModal.tsx @@ -1,25 +1,15 @@ import React, { useState } from 'react' import { useTranslation } from 'react-i18next' -import { ImageLibraryOptions, launchImageLibrary } from 'react-native-image-picker' +import { selectPhotoFromLibrary } from 'src/components/unitags/AvatarSelection' import { ChooseNftModal } from 'src/components/unitags/ChooseNftModal' import { Button, Flex, Icons, Text, useSporeColors } from 'ui/src' import { iconSizes } from 'ui/src/theme' import { BottomSheetModal } from 'wallet/src/components/modals/BottomSheetModal' import { ElementName, ModalName } from 'wallet/src/telemetry/constants' -// Selected image will be shrunk to max width/height -// URI will then be for an image of those dimensions -const IMAGE_OPTIONS: ImageLibraryOptions = { - mediaType: 'photo', - maxWidth: 500, - maxHeight: 500, - quality: 1, // best quality - includeBase64: false, - selectionLimit: 1, -} - type ChoosePhotoOptionsProps = { address: Maybe
+ hasNFTs: boolean setPhotoUri: (uri?: string) => void onClose: () => void showRemoveOption: boolean @@ -27,6 +17,7 @@ type ChoosePhotoOptionsProps = { export const ChoosePhotoOptionsModal = ({ address, + hasNFTs, setPhotoUri, onClose, showRemoveOption, @@ -35,7 +26,7 @@ export const ChoosePhotoOptionsModal = ({ const { t } = useTranslation() const [showNftsList, setShowNftsList] = useState(false) - const onPressNftsList = (): void => { + const onPressNftsList = async (): Promise => { setShowNftsList(true) } @@ -44,30 +35,42 @@ export const ChoosePhotoOptionsModal = ({ onClose() } - const onRemovePhoto = (): void => { + const onRemovePhoto = async (): Promise => { setPhotoUri(undefined) + onClose() } const onPressCameraRoll = async (): Promise => { - const response = await launchImageLibrary(IMAGE_OPTIONS) - if (!response.didCancel && !response.errorCode && response.assets) { - setPhotoUri(response.assets[0]?.uri) + const selectedPhoto = await selectPhotoFromLibrary() + if (selectedPhoto) { + setPhotoUri(selectedPhoto) } - onClose() } - const cameraRollOption = { - key: `${ElementName.OpenCameraRoll}`, - onPress: onPressCameraRoll, - render: () => , + const options = [ + { + key: `${ElementName.OpenCameraRoll}`, + onPress: onPressCameraRoll, + item: , + }, + ] + + if (hasNFTs) { + options.push({ + key: `${ElementName.OpenNftsList}`, + onPress: onPressNftsList, + item: , + }) } - const nftsOption = { - key: `${ElementName.OpenNftsList}`, - onPress: onPressNftsList, - render: () => , + + if (showRemoveOption) { + options.push({ + key: `${ElementName.Remove}`, + onPress: onRemovePhoto, + item: , + }) } - const options = address ? [cameraRollOption, nftsOption] : [cameraRollOption] return ( <> @@ -81,14 +84,9 @@ export const ChoosePhotoOptionsModal = ({ {options.map((option) => ( - {option.render()} + {option.item} ))} - {showRemoveOption && ( - - - - )} - + + + ) } -function BodyItem({ - Icon, - title, - subtitle, -}: { - Icon: GeneratedIcon - title: string - subtitle: string -}): JSX.Element { +function BodyItem({ Icon, title }: { Icon: GeneratedIcon; title: string }): JSX.Element { return ( - - - - - {title} - - - {subtitle} - - + + + + {title} + ) } diff --git a/apps/mobile/src/components/unitags/WalletSelectorModal.tsx b/apps/mobile/src/components/unitags/WalletSelectorModal.tsx deleted file mode 100644 index c7e884b9276..00000000000 --- a/apps/mobile/src/components/unitags/WalletSelectorModal.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import React from 'react' -import { useTranslation } from 'react-i18next' -import { useAppDispatch } from 'src/app/hooks' -import { navigate } from 'src/app/navigation/rootNavigation' -import { OnboardingScreens, Screens } from 'src/screens/Screens' -import { Button, Flex, Icons, Separator, Text, Unicon, useSporeColors } from 'ui/src' -import { iconSizes } from 'ui/src/theme' -import { DisplayNameText } from 'wallet/src/components/accounts/DisplayNameText' -import { BottomSheetModal } from 'wallet/src/components/modals/BottomSheetModal' -import { ImportType, OnboardingEntryPoint } from 'wallet/src/features/onboarding/types' -import { Account } from 'wallet/src/features/wallet/accounts/types' -import { createAccountActions } from 'wallet/src/features/wallet/create/createAccountSaga' -import { - PendingAccountActions, - pendingAccountActions, -} from 'wallet/src/features/wallet/create/pendingAccountsSaga' -import { - useDisplayName, - useNativeAccountExists, - useSignerAccounts, -} from 'wallet/src/features/wallet/hooks' -import { ElementName, ModalName } from 'wallet/src/telemetry/constants' -import { shortenAddress } from 'wallet/src/utils/addresses' -type WalletSelectorModalProps = { - activeAccount: Account | null - onPressAccount: (account: Account) => void - onClose: () => void -} - -export const WalletSelectorModal = ({ - activeAccount, - onPressAccount, - onClose, -}: WalletSelectorModalProps): JSX.Element => { - const colors = useSporeColors() - const { t } = useTranslation() - const signerAccounts = useSignerAccounts() - const dispatch = useAppDispatch() - const hasImportedSeedPhrase = useNativeAccountExists() - - const options = signerAccounts.map((account) => { - return { - key: `${ElementName.AccountCard}-${account.address}`, - onPress: () => onPressAccount(account), - render: () => , - } - }) - - const onPressNewWallet = (): void => { - // Clear any existing pending accounts first. - dispatch(pendingAccountActions.trigger(PendingAccountActions.Delete)) - dispatch(createAccountActions.trigger()) - - navigate(Screens.OnboardingStack, { - screen: OnboardingScreens.EditName, - params: { - importType: hasImportedSeedPhrase ? ImportType.CreateAdditional : ImportType.CreateNew, - entryPoint: OnboardingEntryPoint.Sidebar, - }, - }) - onClose() - } - - return ( - - - - {t('Choose a wallet to map to')} - - {t( - 'Choose which wallet you want to assign your username to. You can only claim on 1 wallet, so choose wisely.' - )} - - - - {options.map((option) => ( - - {option.render()} - - ))} - - - - {t('or')} - - - - - - - - - - - - - ) -} - -export const SwitchAccountOption = ({ - account, - activeAccount, -}: { - account: Account - activeAccount: Account | null -}): JSX.Element => { - const displayName = useDisplayName(account.address) - return ( - - - - - - {shortenAddress(account.address)} - - - - ) -} diff --git a/apps/mobile/src/features/CloudBackup/CloudBackupProcessingAnimation.tsx b/apps/mobile/src/features/CloudBackup/CloudBackupProcessingAnimation.tsx index 121ad709776..ad9290a2d9b 100644 --- a/apps/mobile/src/features/CloudBackup/CloudBackupProcessingAnimation.tsx +++ b/apps/mobile/src/features/CloudBackup/CloudBackupProcessingAnimation.tsx @@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next' import { ActivityIndicator, Alert } from 'react-native' import { useAppDispatch } from 'src/app/hooks' import { OnboardingStackParamList, SettingsStackParamList } from 'src/app/navigation/types' -import { CheckmarkCircle } from 'src/components/icons/CheckmarkCircle' import { backupMnemonicToCloudStorage } from 'src/features/CloudBackup/RNCloudStorageBackupsManager' import { OnboardingScreens, Screens } from 'src/screens/Screens' import { Flex, Text, useSporeColors } from 'ui/src' @@ -13,6 +12,7 @@ import { iconSizes } from 'ui/src/theme' import { logger } from 'utilities/src/logger/logger' import { ONE_SECOND_MS } from 'utilities/src/time/time' import { promiseMinDelay } from 'utilities/src/time/timing' +import { CheckmarkCircle } from 'wallet/src/components/icons/CheckmarkCircle' import { EditAccountAction, editAccountActions, diff --git a/apps/mobile/src/features/balances/hooks.ts b/apps/mobile/src/features/balances/hooks.ts index 37654a68a9f..3c2f4e084fd 100644 --- a/apps/mobile/src/features/balances/hooks.ts +++ b/apps/mobile/src/features/balances/hooks.ts @@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next' import { NativeSyntheticEvent, Share } from 'react-native' import { ContextMenuAction, ContextMenuOnPressNativeEvent } from 'react-native-context-menu-view' import { useAppDispatch } from 'src/app/hooks' +import { ScannerModalState } from 'src/components/QRCodeScanner/constants' +import { openModal } from 'src/features/modals/modalSlice' import { useNavigateToSend } from 'src/features/send/hooks' import { useNavigateToSwap } from 'src/features/swap/hooks' import { sendMobileAnalyticsEvent } from 'src/features/telemetry' @@ -15,12 +17,12 @@ import { PortfolioBalance } from 'wallet/src/features/dataApi/types' import { toggleTokenVisibility } from 'wallet/src/features/favorites/slice' import { pushNotification } from 'wallet/src/features/notifications/slice' import { AppNotificationType } from 'wallet/src/features/notifications/types' -import { useCopyTokenAddressCallback } from 'wallet/src/features/tokens/hooks' import { CurrencyField } from 'wallet/src/features/transactions/transactionState/types' import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' +import { ModalName } from 'wallet/src/telemetry/constants' import { - areCurrencyIdsEqual, CurrencyId, + areCurrencyIdsEqual, currencyIdToAddress, currencyIdToChain, } from 'wallet/src/utils/currencyId' @@ -54,21 +56,27 @@ export function useTokenContextMenu({ const currencyAddress = currencyIdToAddress(currencyId) const currencyChainId = currencyIdToChain(currencyId) ?? ChainId.Mainnet - const onPressCopyContractAddress = useCopyTokenAddressCallback(currencyAddress) + const onPressSend = useCallback(() => { + // Do not show warning modal speed-bump if user is trying to send tokens they own + navigateToSend(currencyAddress, currencyChainId) + }, [currencyAddress, currencyChainId, navigateToSend]) + + const onPressReceive = useCallback( + () => + dispatch( + openModal({ name: ModalName.WalletConnectScan, initialState: ScannerModalState.WalletQr }) + ), + [dispatch] + ) const onPressSwap = useCallback( (currencyField: CurrencyField) => { - // Do not show warning modal speedbump if user is trying to swap tokens they own + // Do not show warning modal speed-bump if user is trying to swap tokens they own navigateToSwap(currencyField, currencyAddress, currencyChainId) }, [currencyAddress, currencyChainId, navigateToSwap] ) - const onPressSend = useCallback(() => { - // Do not show warning modal speedbump if user is trying to send tokens they own - navigateToSend(currencyAddress, currencyChainId) - }, [currencyAddress, currencyChainId, navigateToSend]) - const onPressShare = useCallback(async () => { const tokenUrl = getTokenUrl(currencyId) if (!tokenUrl) { @@ -142,16 +150,16 @@ export function useTokenContextMenu({ systemIcon: 'paperplane', onPress: onPressSend, }, + { + title: t('Receive'), + systemIcon: 'qrcode', + onPress: onPressReceive, + }, { title: t('Share'), systemIcon: 'square.and.arrow.up', onPress: onPressShare, }, - { - title: t('Copy contract address'), - systemIcon: 'doc.on.doc', - onPress: onPressCopyContractAddress, - }, ...(activeAccountHoldsToken ? [ { @@ -166,7 +174,7 @@ export function useTokenContextMenu({ [ t, onPressSend, - onPressCopyContractAddress, + onPressReceive, onPressShare, activeAccountHoldsToken, isHidden, diff --git a/apps/mobile/src/features/dataApi/balances.test.ts b/apps/mobile/src/features/dataApi/balances.test.ts index 12e90cd79ed..94b48b3eaf0 100644 --- a/apps/mobile/src/features/dataApi/balances.test.ts +++ b/apps/mobile/src/features/dataApi/balances.test.ts @@ -1,8 +1,8 @@ import { act, renderHook, waitFor } from 'src/test/test-utils' import { - mockWalletPreloadedState, SAMPLE_CURRENCY_ID_1, SAMPLE_CURRENCY_ID_2, + mockWalletPreloadedState, } from 'wallet/src/test/fixtures' import { Portfolio, PortfolioBalancesById } from 'wallet/src/test/gqlFixtures' import { useBalances } from './balances' diff --git a/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.ts b/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.ts index 5d724a7e7e3..bb372a3a114 100644 --- a/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.ts +++ b/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.ts @@ -17,7 +17,7 @@ import { WidgetType } from 'src/features/widgets/widgets' import { Screens } from 'src/screens/Screens' import { call, put, takeLatest } from 'typed-redux-saga' import { logger } from 'utilities/src/logger/logger' -import { uniswapUrls, UNISWAP_APP_HOSTNAME } from 'wallet/src/constants/urls' +import { UNISWAP_APP_HOSTNAME, uniswapUrls } from 'wallet/src/constants/urls' import { fromUniswapWebAppLink } from 'wallet/src/features/chains/utils' import { selectAccounts, @@ -29,7 +29,7 @@ import { setAccountAsActive } from 'wallet/src/features/wallet/slice' import i18n from 'wallet/src/i18n/i18n' import { ModalName } from 'wallet/src/telemetry/constants' import { buildCurrencyId, buildNativeCurrencyId } from 'wallet/src/utils/currencyId' -import { openUri, UNISWAP_APP_NATIVE_TOKEN } from 'wallet/src/utils/linking' +import { UNISWAP_APP_NATIVE_TOKEN, openUri } from 'wallet/src/utils/linking' export interface DeepLink { url: string diff --git a/apps/mobile/src/features/externalProfile/ProfileContextMenu.tsx b/apps/mobile/src/features/externalProfile/ProfileContextMenu.tsx index e29625a39d4..9e05349fd9e 100644 --- a/apps/mobile/src/features/externalProfile/ProfileContextMenu.tsx +++ b/apps/mobile/src/features/externalProfile/ProfileContextMenu.tsx @@ -11,15 +11,18 @@ import { disableOnPress } from 'src/utils/disableOnPress' import { Flex, TouchableArea } from 'ui/src' import { iconSizes } from 'ui/src/theme' import { logger } from 'utilities/src/logger/logger' -import { ChainId, CHAIN_INFO } from 'wallet/src/constants/chains' +import { CHAIN_INFO, ChainId } from 'wallet/src/constants/chains' +import { uniswapUrls } from 'wallet/src/constants/urls' import { pushNotification } from 'wallet/src/features/notifications/slice' import { AppNotificationType, CopyNotificationType } from 'wallet/src/features/notifications/types' +import { useUnitagByAddress } from 'wallet/src/features/unitags/hooks' import { setClipboard } from 'wallet/src/utils/clipboard' import { ExplorerDataType, getExplorerLink, getProfileUrl, openUri } from 'wallet/src/utils/linking' export function ProfileContextMenu({ address }: { address: Address }): JSX.Element { const { t } = useTranslation() const dispatch = useAppDispatch() + const { unitag } = useUnitagByAddress(address) const onPressCopyAddress = useCallback(async () => { if (!address) { @@ -36,6 +39,12 @@ export function ProfileContextMenu({ address }: { address: Address }): JSX.Eleme await openUri(getExplorerLink(ChainId.Mainnet, address, ExplorerDataType.ADDRESS)) }, [address]) + const onReportProfile = useCallback(async () => { + openUri(uniswapUrls.reportUnitagUrl).catch((e) => + logger.error(e, { tags: { file: 'ProfileContextMenu', function: 'reportProfileLink' } }) + ) + }, []) + const onPressShare = useCallback(async () => { if (!address) { return @@ -54,8 +63,8 @@ export function ProfileContextMenu({ address }: { address: Address }): JSX.Eleme } }, [address]) - const menuActions = useMemo( - () => [ + const menuActions = useMemo(() => { + const options = [ { title: t('View on {{ blockExplorerName }}', { blockExplorerName: CHAIN_INFO[ChainId.Mainnet].explorer.name, @@ -73,9 +82,16 @@ export function ProfileContextMenu({ address }: { address: Address }): JSX.Eleme action: onPressShare, systemIcon: 'square.and.arrow.up', }, - ], - [onPressCopyAddress, onPressShare, openExplorerLink, t] - ) + ] + if (unitag) { + options.push({ + title: t('Report profile'), + action: onReportProfile, + systemIcon: 'flag', + }) + } + return options + }, [onPressCopyAddress, onPressShare, onReportProfile, openExplorerLink, t, unitag]) return ( { - if (loading || (hasAvatar && !avatarColors)) { + if (avatarLoading || (hasAvatar && !avatarColors)) { return [colors.surface1.val, colors.surface1.val] } if (hasAvatar && avatarColors && avatarColors.base) { return [avatarColors.base, avatarColors.base] } return [uniconGradientStart, uniconGradientEnd] - }, [avatarColors, hasAvatar, loading, colors.surface1, uniconGradientEnd, uniconGradientStart]) + }, [ + avatarColors, + hasAvatar, + avatarLoading, + colors.surface1, + uniconGradientEnd, + uniconGradientStart, + ]) const onPressFavorite = useToggleWatchedWalletCallback(address) @@ -85,10 +113,16 @@ export const ProfileHeader = memo(function ProfileHeader({ ) }, [dispatch, initialSendState]) + const onPressTwitter = useCallback(async () => { + if (twitter) { + await openUri(`https://twitter.com/${twitter}`) + } + }, [twitter]) + const { t } = useTranslation() return ( - + - + {/* header row */} - - + + @@ -125,19 +166,59 @@ export const ProfileHeader = memo(function ProfileHeader({ {/* button content */} - - + + + + {bio ? ( + + ) : null} + + {(twitter || showENSName) && ( + + + {twitter ? ( + + + + + {twitter} + + + + ) : null} + {showENSName ? ( + + + + {primaryENSName} + + + ) : null} + + + )} + + + + + - + ) }) diff --git a/apps/mobile/src/features/fiatOnRamp/FiatOnRampAggregatorTokenSelector.tsx b/apps/mobile/src/features/fiatOnRamp/FiatOnRampAggregatorTokenSelector.tsx index 9c246d1c0b2..a4af7c76d8d 100644 --- a/apps/mobile/src/features/fiatOnRamp/FiatOnRampAggregatorTokenSelector.tsx +++ b/apps/mobile/src/features/fiatOnRamp/FiatOnRampAggregatorTokenSelector.tsx @@ -9,7 +9,7 @@ import { SectionName } from 'wallet/src/telemetry/constants' import { useAllCommonBaseCurrencies } from 'wallet/src/components/TokenSelector/hooks' import { CurrencyInfo, GqlResult } from 'wallet/src/features/dataApi/types' import { useFiatOnRampAggregatorSupportedTokensQuery } from 'wallet/src/features/fiatOnRamp/api' -import { MeldCryptoCurrency } from 'wallet/src/features/fiatOnRamp/meld' +import { FORSupportedToken } from 'wallet/src/features/fiatOnRamp/types' import { ElementName } from 'wallet/src/telemetry/constants' interface Props { @@ -19,20 +19,20 @@ interface Props { countryCode: string } -const findTokenOptionForMeldCurrency = ( +const findTokenOptionForFiatOnRampToken = ( commonBaseCurrencies: CurrencyInfo[] | undefined, - meldCurrency: MeldCryptoCurrency + fiatOnRampToken: FORSupportedToken ): Maybe => { return (commonBaseCurrencies || []).find( (item) => item && - meldCurrency.cryptoCurrencyCode.toLowerCase() === item.currency.symbol?.toLowerCase() && - meldCurrency.chainId === item.currency.chainId.toString() + fiatOnRampToken.cryptoCurrencyCode.toLowerCase() === item.currency.symbol?.toLowerCase() && + fiatOnRampToken.chainId === item.currency.chainId.toString() ) } function useFiatOnRampTokenList( - supportedTokens: MeldCryptoCurrency[] | undefined + supportedTokens: FORSupportedToken[] | undefined ): GqlResult { const { data: commonBaseCurrencies, @@ -44,8 +44,8 @@ function useFiatOnRampTokenList( const data = useMemo( () => (supportedTokens || []) - .map((meldCurrency) => ({ - currencyInfo: findTokenOptionForMeldCurrency(commonBaseCurrencies, meldCurrency), + .map((fiatOnRampToken) => ({ + currencyInfo: findTokenOptionForFiatOnRampToken(commonBaseCurrencies, fiatOnRampToken), })) .filter((item) => !!item.currencyInfo), [commonBaseCurrencies, supportedTokens] @@ -66,7 +66,7 @@ function _FiatOnRampAggregatorTokenSelector({ countryCode, }: Props): JSX.Element { const { - data: supportedTokens, + data: supportedTokensResponse, isLoading: supportedTokensLoading, error: supportedTokensQueryError, refetch: supportedTokensQueryRefetch, @@ -77,7 +77,7 @@ function _FiatOnRampAggregatorTokenSelector({ loading: tokenListLoading, error: tokenListError, refetch: tokenListRefetch, - } = useFiatOnRampTokenList(supportedTokens) + } = useFiatOnRampTokenList(supportedTokensResponse?.supportedTokens) const loading = supportedTokensLoading || tokenListLoading const error = Boolean(supportedTokensQueryError || tokenListError) diff --git a/apps/mobile/src/features/fiatOnRamp/FiatOnRampContext.tsx b/apps/mobile/src/features/fiatOnRamp/FiatOnRampContext.tsx index 9c4140af2ed..b728ee1ca44 100644 --- a/apps/mobile/src/features/fiatOnRamp/FiatOnRampContext.tsx +++ b/apps/mobile/src/features/fiatOnRamp/FiatOnRampContext.tsx @@ -8,15 +8,15 @@ import { FiatOnRampCurrency } from 'src/features/fiatOnRamp/types' import { getNativeAddress } from 'wallet/src/constants/addresses' import { ChainId } from 'wallet/src/constants/chains' import { FiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks' -import { MeldQuote, MeldServiceProvider } from 'wallet/src/features/fiatOnRamp/meld' +import { FORQuote, FORServiceProvider } from 'wallet/src/features/fiatOnRamp/types' import { useCurrencyInfo } from 'wallet/src/features/tokens/useCurrencyInfo' import { buildCurrencyId } from 'wallet/src/utils/currencyId' interface FiatOnRampContextType { - quotesSections?: SectionListData[] | undefined - setQuotesSections: (quotesSections: SectionListData[] | undefined) => void - selectedQuote?: MeldQuote - setSelectedQuote: (quote: MeldQuote | undefined) => void + quotesSections?: SectionListData[] | undefined + setQuotesSections: (quotesSections: SectionListData[] | undefined) => void + selectedQuote?: FORQuote + setSelectedQuote: (quote: FORQuote | undefined) => void countryCode: string setCountryCode: (countryCode: string) => void baseCurrencyInfo?: FiatCurrencyInfo @@ -25,8 +25,8 @@ interface FiatOnRampContextType { setQuoteCurrency: (quoteCurrency: FiatOnRampCurrency) => void amount?: number setAmount: (amount: number | undefined) => void - serviceProviders?: MeldServiceProvider[] - setServiceProviders: (serviceProviders: MeldServiceProvider[] | undefined) => void + serviceProviders?: FORServiceProvider[] + setServiceProviders: (serviceProviders: FORServiceProvider[] | undefined) => void } const initialState: FiatOnRampContextType = { @@ -49,11 +49,11 @@ export function useFiatOnRampContext(): FiatOnRampContextType { export function FiatOnRampProvider({ children }: { children: React.ReactNode }): JSX.Element { const [quotesSections, setQuotesSections] = useState() - const [selectedQuote, setSelectedQuote] = useState() + const [selectedQuote, setSelectedQuote] = useState() const [countryCode, setCountryCode] = useState(getCountry()) const [baseCurrencyInfo, setBaseCurrencyInfo] = useState() const [amount, setAmount] = useState() - const [serviceProviders, setServiceProviders] = useState() + const [serviceProviders, setServiceProviders] = useState() // We hardcode ETH as the starting currency const ethCurrencyInfo = useCurrencyInfo( diff --git a/apps/mobile/src/features/fiatOnRamp/FiatOnRampCountryListModal.tsx b/apps/mobile/src/features/fiatOnRamp/FiatOnRampCountryListModal.tsx index ddaaacde67a..acc0e0718b4 100644 --- a/apps/mobile/src/features/fiatOnRamp/FiatOnRampCountryListModal.tsx +++ b/apps/mobile/src/features/fiatOnRamp/FiatOnRampCountryListModal.tsx @@ -5,25 +5,24 @@ import { ListRenderItemInfo } from 'react-native' import { FadeIn, FadeOut } from 'react-native-reanimated' import { SvgUri } from 'react-native-svg' import { Loader } from 'src/components/loading' +import { FOR_MODAL_SNAP_POINTS } from 'src/features/fiatOnRamp/constants' import { AnimatedFlex, Flex, - Inset, Text, TouchableArea, useDeviceDimensions, + useDeviceInsets, useSporeColors, } from 'ui/src' import Check from 'ui/src/assets/icons/check.svg' -import { fonts, iconSizes } from 'ui/src/theme' +import { fonts, iconSizes, spacing } from 'ui/src/theme' import { bubbleToTop } from 'utilities/src/primitives/array' import { useDebounce } from 'utilities/src/time/timing' import { BottomSheetModal } from 'wallet/src/components/modals/BottomSheetModal' import { useFiatOnRampAggregatorCountryListQuery } from 'wallet/src/features/fiatOnRamp/api' -import { - getCountryFlagSvgUrl, - MeldCountryPaymentMethodsResponse, -} from 'wallet/src/features/fiatOnRamp/meld' +import { FORSupportedCountry } from 'wallet/src/features/fiatOnRamp/types' +import { getCountryFlagSvgUrl } from 'wallet/src/features/fiatOnRamp/utils' import { SearchTextInput } from 'wallet/src/features/search/SearchTextInput' import { ModalName } from 'wallet/src/telemetry/constants' import { isIOS } from 'wallet/src/utils/platform' @@ -31,13 +30,12 @@ import { isIOS } from 'wallet/src/utils/platform' const ICON_SIZE = 32 // design prefers a custom value here interface CountrySelectorProps { - onSelectCountry: (country: NonNullable['country']) => void + onSelectCountry: (country: FORSupportedCountry) => void countryCode: string } -function key(item: NonNullable): string { - // item.country.countryCode is already a string, but for some reason eslint thinks it's any, so we cast it - return item.country.countryCode as string +function key(item: FORSupportedCountry): string { + return item.countryCode } function CountrySelectorContent({ @@ -45,7 +43,7 @@ function CountrySelectorContent({ countryCode, }: CountrySelectorProps): JSX.Element { const { t } = useTranslation() - + const insets = useDeviceInsets() const colors = useSporeColors() const { data, isLoading } = useFiatOnRampAggregatorCountryListQuery() @@ -54,25 +52,23 @@ function CountrySelectorContent({ const debouncedSearchText = useDebounce(searchText) - const filtredeData = useMemo(() => { + const filteredData: FORSupportedCountry[] = useMemo(() => { if (!data) { return [] } - return bubbleToTop(data, (c) => c.country.countryCode === countryCode).filter( + return bubbleToTop(data.supportedCountries, (c) => c.countryCode === countryCode).filter( (item) => !debouncedSearchText || - item.country.displayName.toLowerCase().startsWith(debouncedSearchText.toLowerCase()) + item.displayName.toLowerCase().startsWith(debouncedSearchText.toLowerCase()) ) }, [countryCode, data, debouncedSearchText]) const renderItem = useCallback( - ({ - item, - }: ListRenderItemInfo>): JSX.Element => { - const countryFlagUrl = getCountryFlagSvgUrl(item.country.countryCode) + ({ item }: ListRenderItemInfo): JSX.Element => { + const countryFlagUrl = getCountryFlagSvgUrl(item.countryCode) return ( - onSelectCountry(item.country)}> + onSelectCountry(item)}> - {item.country.displayName} - {item.country.countryCode === countryCode && ( + {item.displayName} + {item.countryCode === countryCode && ( } - ListFooterComponent={} bounces={true} - data={filtredeData} + contentContainerStyle={{ paddingBottom: insets.bottom + spacing.spacing12 }} + data={filteredData} keyExtractor={key} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="always" @@ -171,7 +167,7 @@ export function FiatOnRampCountryListModal({ hideKeyboardOnSwipeDown backgroundColor={colors.surface1.get()} name={ModalName.FiatOnRampCountryList} - snapPoints={['70%', '100%']} + snapPoints={FOR_MODAL_SNAP_POINTS} onClose={onClose}> diff --git a/apps/mobile/src/features/fiatOnRamp/FiatOnRampCountryPicker.tsx b/apps/mobile/src/features/fiatOnRamp/FiatOnRampCountryPicker.tsx index 60ab8743e5e..44ee5695a9f 100644 --- a/apps/mobile/src/features/fiatOnRamp/FiatOnRampCountryPicker.tsx +++ b/apps/mobile/src/features/fiatOnRamp/FiatOnRampCountryPicker.tsx @@ -3,7 +3,7 @@ import { SvgUri } from 'react-native-svg' import Trace from 'src/components/Trace/Trace' import { Flex, Icons, TouchableArea } from 'ui/src' import { iconSizes } from 'ui/src/theme' -import { getCountryFlagSvgUrl } from 'wallet/src/features/fiatOnRamp/meld' +import { getCountryFlagSvgUrl } from 'wallet/src/features/fiatOnRamp/utils' import { ElementName } from 'wallet/src/telemetry/constants' const ICON_SIZE = iconSizes.icon16 diff --git a/apps/mobile/src/features/fiatOnRamp/FiatOnRampTransferInstitutionSelector.tsx b/apps/mobile/src/features/fiatOnRamp/FiatOnRampTransferInstitutionSelector.tsx new file mode 100644 index 00000000000..fd85c71ec96 --- /dev/null +++ b/apps/mobile/src/features/fiatOnRamp/FiatOnRampTransferInstitutionSelector.tsx @@ -0,0 +1,104 @@ +import { BottomSheetFlatList } from '@gorhom/bottom-sheet' +import { ImpactFeedbackStyle } from 'expo-haptics' +import React, { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { ListRenderItemInfo } from 'react-native' +import { getCountry } from 'react-native-localize' +import { FadeIn, FadeOut } from 'react-native-reanimated' +import { AnimatedFlex, Flex, Loader, Text, TouchableArea } from 'ui/src' +import { iconSizes } from 'ui/src/theme' +import { useFiatOnRampAggregatorTransferInstitutionsQuery } from 'wallet/src/features/fiatOnRamp/api' +import { FORTransferInstitution } from 'wallet/src/features/fiatOnRamp/types' +import { RemoteImage } from 'wallet/src/features/images/RemoteImage' + +function key(item: FORTransferInstitution): string { + return item.id as string +} + +const CEX_ICON_SIZE = iconSizes.icon36 +const CEX_ICON_BORDER_RADIUS = 12 + +function CEXItemWrapper({ + institution, + onSelectTransferInstitution, +}: { + institution: FORTransferInstitution + onSelectTransferInstitution: (transferInstitution: FORTransferInstitution) => void +}): JSX.Element | null { + const { t } = useTranslation() + const onPress = (): void => onSelectTransferInstitution(institution) + + return ( + + + + + + {institution.name} + + + + {t('Not linked')} + + + + ) +} + +export function TransferInstitutionSelector(): JSX.Element { + const { data, isLoading } = useFiatOnRampAggregatorTransferInstitutionsQuery({ + countryCode: getCountry(), + }) + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const onSelectTransferInstitution = useCallback((transferInstitution: FORTransferInstitution) => { + //TODO(MOB-2603): fetch widget and launch transfer flow + }, []) + + const renderItem = useCallback( + ({ item: institution }: ListRenderItemInfo) => ( + + ), + [onSelectTransferInstitution] + ) + + return ( + + + {isLoading ? ( + + ) : ( + + )} + + + ) +} + +const renderItemSeparator = (): JSX.Element => diff --git a/apps/mobile/src/features/fiatOnRamp/meldHooks.ts b/apps/mobile/src/features/fiatOnRamp/aggregatorHooks.ts similarity index 77% rename from apps/mobile/src/features/fiatOnRamp/meldHooks.ts rename to apps/mobile/src/features/fiatOnRamp/aggregatorHooks.ts index 073226ddd18..ff250166d16 100644 --- a/apps/mobile/src/features/fiatOnRamp/meldHooks.ts +++ b/apps/mobile/src/features/fiatOnRamp/aggregatorHooks.ts @@ -3,6 +3,7 @@ import { FetchBaseQueryError, skipToken } from '@reduxjs/toolkit/query/react' import { useTranslation } from 'react-i18next' import { Delay } from 'src/components/layout/Delayed' import { ColorTokens } from 'ui/src' +import { NumberType } from 'utilities/src/format/types' import { useDebounce } from 'utilities/src/time/timing' import { FiatCurrency } from 'wallet/src/features/fiatCurrency/constants' import { @@ -11,11 +12,12 @@ import { useFiatCurrencyInfo, } from 'wallet/src/features/fiatCurrency/hooks' import { useFiatOnRampAggregatorCryptoQuoteQuery } from 'wallet/src/features/fiatOnRamp/api' +import { FORQuote } from 'wallet/src/features/fiatOnRamp/types' import { - extractCurrencyAmountFromError, - isMeldApiError, - MeldQuote, -} from 'wallet/src/features/fiatOnRamp/meld' + isFiatOnRampApiError, + isInvalidRequestAmountTooHigh, + isInvalidRequestAmountTooLow, +} from 'wallet/src/features/fiatOnRamp/utils' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' // TODO: https://linear.app/uniswap/issue/MOB-2532/implement-fetching-of-available-fiat-currencies-from-meld @@ -42,7 +44,7 @@ export function useMeldFiatCurrencySupportInfo(): { /** * Hook to load quotes */ -export function useMeldQuotes({ +export function useFiatOnRampQuotes({ baseCurrencyAmount, baseCurrencyCode, quoteCurrencyCode, @@ -55,18 +57,18 @@ export function useMeldQuotes({ }): { loading: boolean error?: FetchBaseQueryError | SerializedError - quotes: MeldQuote[] | undefined + quotes: FORQuote[] | undefined } { const debouncedBaseCurrencyAmount = useDebounce(baseCurrencyAmount, Delay.Short) const { - currentData: quotes, + currentData: quotesResponse, isFetching: quotesFetching, error: quotesError, } = useFiatOnRampAggregatorCryptoQuoteQuery( baseCurrencyAmount && countryCode && quoteCurrencyCode && baseCurrencyCode ? { - amount: baseCurrencyAmount, + sourceAmount: baseCurrencyAmount, sourceCurrencyCode: baseCurrencyCode, destinationCurrencyCode: quoteCurrencyCode, countryCode, @@ -85,11 +87,14 @@ export function useMeldQuotes({ return { loading, error, - quotes: quotes ?? undefined, + quotes: quotesResponse?.quotes ?? undefined, } } -export function useParseMeldError(error: unknown): { +export function useParseFiatOnRampError( + error: unknown, + currencyCode: string +): { errorText: string | undefined errorColor: ColorTokens | undefined } { @@ -98,16 +103,24 @@ export function useParseMeldError(error: unknown): { let errorText, errorColor: ColorTokens | undefined - if (!isMeldApiError(error)) { + if (!isFiatOnRampApiError(error)) { return { errorText, errorColor } } - if (error.data.code === 'INVALID_AMOUNT_TOO_LOW') { - const formattedAmount = extractCurrencyAmountFromError(error.data.message, formatNumberOrString) + if (isInvalidRequestAmountTooLow(error)) { + const formattedAmount = formatNumberOrString({ + value: error.data.context.minimumAllowed, + type: NumberType.FiatStandard, + currencyCode, + }) errorText = t('Minimum {{amount}}', { amount: formattedAmount }) errorColor = '$statusCritical' - } else if (error.data.code === 'INVALID_AMOUNT_TOO_HIGH') { - const formattedAmount = extractCurrencyAmountFromError(error.data.message, formatNumberOrString) + } else if (isInvalidRequestAmountTooHigh(error)) { + const formattedAmount = formatNumberOrString({ + value: error.data.context.maximumAllowed, + type: NumberType.FiatStandard, + currencyCode, + }) errorText = t('Maximum {{amount}}', { amount: formattedAmount }) errorColor = '$statusCritical' } else { diff --git a/apps/mobile/src/features/fiatOnRamp/constants.ts b/apps/mobile/src/features/fiatOnRamp/constants.ts new file mode 100644 index 00000000000..5345144bf82 --- /dev/null +++ b/apps/mobile/src/features/fiatOnRamp/constants.ts @@ -0,0 +1 @@ +export const FOR_MODAL_SNAP_POINTS = ['70%', '100%'] diff --git a/apps/mobile/src/features/fiatOnRamp/meldUtils.ts b/apps/mobile/src/features/fiatOnRamp/meldUtils.ts deleted file mode 100644 index 44413ec46bd..00000000000 --- a/apps/mobile/src/features/fiatOnRamp/meldUtils.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { MeldQuote, MeldServiceProvider } from 'wallet/src/features/fiatOnRamp/meld' - -export function getServiceProviderForQuote( - quote: MeldQuote | undefined, - serviceProviders: MeldServiceProvider[] | undefined -): MeldServiceProvider | undefined { - return serviceProviders?.find((sp) => sp.serviceProvider === quote?.serviceProvider) -} diff --git a/apps/mobile/src/features/fiatOnRamp/utils.ts b/apps/mobile/src/features/fiatOnRamp/utils.ts new file mode 100644 index 00000000000..8b7d39c314b --- /dev/null +++ b/apps/mobile/src/features/fiatOnRamp/utils.ts @@ -0,0 +1,8 @@ +import { FORQuote, FORServiceProvider } from 'wallet/src/features/fiatOnRamp/types' + +export function getServiceProviderForQuote( + quote: FORQuote | undefined, + serviceProviders: FORServiceProvider[] | undefined +): FORServiceProvider | undefined { + return serviceProviders?.find((sp) => sp.serviceProvider === quote?.serviceProvider) +} diff --git a/apps/mobile/src/features/firebase/firebaseDataSaga.ts b/apps/mobile/src/features/firebase/firebaseDataSaga.ts index 0039e9198b9..d8a748c9e02 100644 --- a/apps/mobile/src/features/firebase/firebaseDataSaga.ts +++ b/apps/mobile/src/features/firebase/firebaseDataSaga.ts @@ -16,8 +16,8 @@ import { getLocale } from 'wallet/src/features/language/hooks' import { selectCurrentLanguage, setCurrentLanguage } from 'wallet/src/features/language/slice' import { EditAccountAction, - editAccountActions, TogglePushNotificationParams, + editAccountActions, } from 'wallet/src/features/wallet/accounts/editAccountSaga' import { Account, AccountType } from 'wallet/src/features/wallet/accounts/types' import { diff --git a/apps/mobile/src/features/gas/hooks.ts b/apps/mobile/src/features/gas/hooks.ts deleted file mode 100644 index 0a066c93a26..00000000000 --- a/apps/mobile/src/features/gas/hooks.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { BigNumber, providers } from 'ethers' -import { useMemo } from 'react' -import { FeeDetails, getAdjustedGasFeeDetails } from 'src/features/gas/adjustGasFee' -import { TRANSACTION_CANCELLATION_GAS_FACTOR } from 'wallet/src/constants/transactions' -import { useTransactionGasFee } from 'wallet/src/features/gas/hooks' -import { FeeType, GasSpeed } from 'wallet/src/features/gas/types' -import { TransactionDetails } from 'wallet/src/features/transactions/types' - -type CancelationGasFeeDetails = { - cancelRequest: providers.TransactionRequest - cancelationGasFee: string -} - -/** - * Construct cancelation transaction with increased gas (based on current network conditions), - * then use it to compute new gas info. - */ -export function useCancelationGasFeeInfo( - transaction: TransactionDetails -): CancelationGasFeeDetails | undefined { - const cancelationRequest = useMemo(() => { - return { - chainId: transaction.chainId, - from: transaction.from, - to: transaction.from, - value: '0x0', - } - }, [transaction]) - - const baseTxGasFee = useTransactionGasFee(cancelationRequest, GasSpeed.Urgent) - return useMemo(() => { - if (!baseTxGasFee.params) { - return - } - - const adjustedFeeDetails = getAdjustedGasFeeDetails( - transaction.options.request, - baseTxGasFee.params, - TRANSACTION_CANCELLATION_GAS_FACTOR - ) - - const cancelRequest = { - ...cancelationRequest, - ...adjustedFeeDetails.params, - gasLimit: baseTxGasFee.params.gasLimit, - } - - return { - cancelRequest, - cancelationGasFee: getCancelationGasFee(adjustedFeeDetails, baseTxGasFee.params.gasLimit), - } - }, [baseTxGasFee, cancelationRequest, transaction.options.request]) -} - -function getCancelationGasFee(adjustedFeeDetails: FeeDetails, gasLimit: string): string { - // doing object destructuring here loses ts checks based on FeeDetails.type >:( - if (adjustedFeeDetails.type === FeeType.Legacy) { - return BigNumber.from(gasLimit).mul(adjustedFeeDetails.params.gasPrice).toString() - } - - return BigNumber.from(adjustedFeeDetails.params.maxFeePerGas).mul(gasLimit).toString() -} diff --git a/apps/mobile/src/features/import/GenericImportForm.tsx b/apps/mobile/src/features/import/GenericImportForm.tsx index b025146bd28..fe88f694d8b 100644 --- a/apps/mobile/src/features/import/GenericImportForm.tsx +++ b/apps/mobile/src/features/import/GenericImportForm.tsx @@ -3,8 +3,8 @@ import { Keyboard, LayoutChangeEvent, LayoutRectangle, - StyleSheet, TextInput as NativeTextInput, + StyleSheet, } from 'react-native' import Trace from 'src/components/Trace/Trace' import InputWithSuffix from 'src/features/import/InputWithSuffix' diff --git a/apps/mobile/src/features/import/WalletPreviewCard.tsx b/apps/mobile/src/features/import/WalletPreviewCard.tsx index ad7e24473db..00807165294 100644 --- a/apps/mobile/src/features/import/WalletPreviewCard.tsx +++ b/apps/mobile/src/features/import/WalletPreviewCard.tsx @@ -43,10 +43,15 @@ export default function WalletPreviewCard({ py="$spacing16" onPress={(): void => onSelect(address)} {...rest}> - - + + - + {balance ? ( diff --git a/apps/mobile/src/features/import/__snapshots__/GenericImportForm.test.tsx.snap b/apps/mobile/src/features/import/__snapshots__/GenericImportForm.test.tsx.snap index 7e1241576a8..b140683507f 100644 --- a/apps/mobile/src/features/import/__snapshots__/GenericImportForm.test.tsx.snap +++ b/apps/mobile/src/features/import/__snapshots__/GenericImportForm.test.tsx.snap @@ -6,7 +6,6 @@ exports[`GenericImportForm renders a placeholder when there is no value 1`] = ` onTouchEnd={[Function]} style={ { - "alignItems": "stretch", "flexDirection": "column", "gap": 12, } @@ -15,7 +14,6 @@ exports[`GenericImportForm renders a placeholder when there is no value 1`] = ` [ModalName.FiatOnRamp]: AppModalState [ModalName.FiatOnRampAggregator]: AppModalState + [ModalName.ReceiveCryptoModal]: AppModalState [ModalName.LanguageSelector]: AppModalState [ModalName.RemoveWallet]: AppModalState [ModalName.RestoreWallet]: AppModalState [ModalName.Scantastic]: AppModalState [ModalName.Send]: AppModalState [ModalName.Swap]: AppModalState - [ModalName.UnitagsIntro]: AppModalState + [ModalName.UnitagsIntro]: AppModalState<{ address: Address }> [ModalName.ViewOnlyExplainer]: AppModalState [ModalName.WalletConnectScan]: AppModalState } diff --git a/apps/mobile/src/features/modals/modalSlice.ts b/apps/mobile/src/features/modals/modalSlice.ts index 112de725753..f2b6b944138 100644 --- a/apps/mobile/src/features/modals/modalSlice.ts +++ b/apps/mobile/src/features/modals/modalSlice.ts @@ -32,6 +32,11 @@ type FiatOnRampAggregatorModalParams = { initialState?: undefined } +type ReceiveCryptoModalParams = { + name: typeof ModalName.ReceiveCryptoModal + initialState?: undefined +} + type LanguageSelectorModalParams = { name: typeof ModalName.LanguageSelector initialState?: undefined @@ -58,7 +63,10 @@ type SwapModalParams = { name: typeof ModalName.Swap; initialState?: Transaction type SendModalParams = { name: typeof ModalName.Send; initialState?: TransactionState } -type UnitagsIntroParams = { name: typeof ModalName.UnitagsIntro; initialState?: undefined } +type UnitagsIntroParams = { + name: typeof ModalName.UnitagsIntro + initialState?: { address: Address } +} type ViewOnlyExplainerParams = { name: typeof ModalName.ViewOnlyExplainer @@ -72,6 +80,7 @@ export type OpenModalParams = | FiatCurrencySelectorParams | FiatOnRampModalParams | FiatOnRampAggregatorModalParams + | ReceiveCryptoModalParams | LanguageSelectorModalParams | ScantasticModalParams | RemoveWalletModalParams @@ -93,6 +102,10 @@ export const initialModalState: ModalsState = { isOpen: false, initialState: undefined, }, + [ModalName.ReceiveCryptoModal]: { + isOpen: false, + initialState: undefined, + }, [ModalName.WalletConnectScan]: { isOpen: false, initialState: ScannerModalState.ScanQr, diff --git a/apps/mobile/src/features/nfts/collection/ListPriceCard.tsx b/apps/mobile/src/features/nfts/collection/ListPriceCard.tsx index 90c68e722bc..6445a24f8e3 100644 --- a/apps/mobile/src/features/nfts/collection/ListPriceCard.tsx +++ b/apps/mobile/src/features/nfts/collection/ListPriceCard.tsx @@ -2,7 +2,7 @@ import { BlurView } from 'expo-blur' import React from 'react' import { StyleSheet } from 'react-native' import { ColorTokens, Flex, FlexProps, Logos, SpaceTokens, Text, useSporeColors } from 'ui/src' -import { borderRadii, iconSizes, spacing, TextVariantTokens } from 'ui/src/theme' +import { TextVariantTokens, borderRadii, iconSizes, spacing } from 'ui/src/theme' import { NumberType } from 'utilities/src/format/types' import { IAmount } from 'wallet/src/data/__generated__/types-and-hooks' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' diff --git a/apps/mobile/src/features/nfts/collection/NFTCollectionHeader.tsx b/apps/mobile/src/features/nfts/collection/NFTCollectionHeader.tsx index 8a9ad4a6284..1ebe33b8829 100644 --- a/apps/mobile/src/features/nfts/collection/NFTCollectionHeader.tsx +++ b/apps/mobile/src/features/nfts/collection/NFTCollectionHeader.tsx @@ -107,7 +107,7 @@ export function NFTCollectionHeader({ - + {data?.name ?? '-'} diff --git a/apps/mobile/src/features/nfts/item/__snapshots__/CollectionPreviewCard.test.tsx.snap b/apps/mobile/src/features/nfts/item/__snapshots__/CollectionPreviewCard.test.tsx.snap index ddecfa94036..83f5f7ca641 100644 --- a/apps/mobile/src/features/nfts/item/__snapshots__/CollectionPreviewCard.test.tsx.snap +++ b/apps/mobile/src/features/nfts/item/__snapshots__/CollectionPreviewCard.test.tsx.snap @@ -76,7 +76,6 @@ exports[`renders collection preview card 1`] = ` + - case AppNotificationType.Error: - return - case AppNotificationType.Default: - return - case AppNotificationType.Success: - return - case AppNotificationType.Copied: - return - case AppNotificationType.ChooseCountry: - return - case AppNotificationType.AssetVisibility: - return case AppNotificationType.ScantasticComplete: return - case AppNotificationType.Transaction: - switch (notification.txType) { - case TransactionType.Approve: - return - case TransactionType.Wrap: - return - case TransactionType.Send: - case TransactionType.Receive: - if (notification.assetType === AssetType.Currency) { - return - } else { - return - } - case TransactionType.Unknown: - return - } } return diff --git a/apps/mobile/src/features/notifications/Notifications.tsx b/apps/mobile/src/features/notifications/Notifications.tsx deleted file mode 100644 index 47d42830a5e..00000000000 --- a/apps/mobile/src/features/notifications/Notifications.tsx +++ /dev/null @@ -1,504 +0,0 @@ -// TODO(MOB-204): reduce file length -// consider splitting into multiple files -/* eslint-disable max-lines */ -import React from 'react' -import { useTranslation } from 'react-i18next' -import { SvgUri } from 'react-native-svg' -import { useAppDispatch, useAppSelector } from 'src/app/hooks' -import { CheckmarkCircle } from 'src/components/icons/CheckmarkCircle' -import { ScannerModalState } from 'src/components/QRCodeScanner/constants' -import { closeModal, openModal } from 'src/features/modals/modalSlice' -import { useNavigateToProfileTab } from 'src/features/notifications/hooks/useNavigateToProfileTab' -import { Flex, Icons, useSporeColors } from 'ui/src' -import EyeOffIcon from 'ui/src/assets/icons/eye-off.svg' -import EyeIcon from 'ui/src/assets/icons/eye.svg' -import { iconSizes } from 'ui/src/theme' -import { - DappLogoWithTxStatus, - LogoWithTxStatus, -} from 'wallet/src/components/CurrencyLogo/LogoWithTxStatus' -import { SplitLogo } from 'wallet/src/components/CurrencyLogo/SplitLogo' -import { AssetType } from 'wallet/src/entities/assets' -import { toSupportedChainId } from 'wallet/src/features/chains/utils' -import { useENS } from 'wallet/src/features/ens/useENS' -import { getCountryFlagSvgUrl } from 'wallet/src/features/fiatOnRamp/meld' -import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' -import { useNFT } from 'wallet/src/features/nfts/hooks' -import { NotificationToast } from 'wallet/src/features/notifications/components/NotificationToast' -import { NOTIFICATION_ICON_SIZE } from 'wallet/src/features/notifications/constants' -import { - AppErrorNotification, - AppNotificationDefault, - ApproveTxNotification, - ChangeAssetVisibilityNotification as ChangeAssetVisibilityNotificationType, - ChooseCountryNotification as ChooseCountryNotificationType, - CopyNotification, - CopyNotificationType, - ScantasticCompleteNotification as ScantasticCompleteNotificationType, - TransactionNotificationBase, - TransferCurrencyTxNotification, - TransferNFTTxNotification, - WalletConnectNotification, - WrapTxNotification, -} from 'wallet/src/features/notifications/types' -import { - formApproveNotificationTitle, - formTransferCurrencyNotificationTitle, - formTransferNFTNotificationTitle, - formUnknownTxTitle, - formWCNotificationTitle, - formWrapNotificationTitle, -} from 'wallet/src/features/notifications/utils' -import { - useCurrencyInfo, - useNativeCurrencyInfo, - useWrappedNativeCurrencyInfo, -} from 'wallet/src/features/tokens/useCurrencyInfo' -import { useCreateWrapFormState } from 'wallet/src/features/transactions/hooks' -import { TransactionStatus, TransactionType } from 'wallet/src/features/transactions/types' -import { selectActiveAccountAddress } from 'wallet/src/features/wallet/selectors' -import { WalletConnectEvent } from 'wallet/src/features/walletConnect/types' -import { ModalName } from 'wallet/src/telemetry/constants' -import { buildCurrencyId } from 'wallet/src/utils/currencyId' - -export function WCNotification({ - notification, -}: { - notification: WalletConnectNotification -}): JSX.Element { - const { imageUrl, chainId, address, event, hideDelay, dappName } = notification - const dispatch = useAppDispatch() - const validChainId = toSupportedChainId(chainId) - const title = formWCNotificationTitle(notification) - - const smallToastEvents = [ - WalletConnectEvent.Connected, - WalletConnectEvent.Disconnected, - WalletConnectEvent.NetworkChanged, - ] - const smallToast = smallToastEvents.includes(event) - - const icon = ( - - ) - - const onPressNotification = (): void => { - dispatch( - openModal({ - name: ModalName.WalletConnectScan, - initialState: ScannerModalState.ConnectedDapps, - }) - ) - } - - return ( - - ) -} - -export function ApproveNotification({ - notification: { address, chainId, tokenAddress, spender, txStatus, txType, hideDelay }, -}: { - notification: ApproveTxNotification -}): JSX.Element { - const { onPress, onPressIn } = useNavigateToProfileTab(address) - - const currencyInfo = useCurrencyInfo(buildCurrencyId(chainId, tokenAddress)) - const title = formApproveNotificationTitle( - txStatus, - currencyInfo?.currency, - tokenAddress, - spender - ) - const icon = ( - - ) - - return ( - - ) -} - -export function WrapNotification({ - notification: { txId, txStatus, currencyAmountRaw, address, hideDelay, unwrapped, chainId }, -}: { - notification: WrapTxNotification -}): JSX.Element { - const formatter = useLocalizationContext() - const nativeCurrencyInfo = useNativeCurrencyInfo(chainId) - const wrappedCurrencyInfo = useWrappedNativeCurrencyInfo(chainId) - const inputCurrencyInfo = unwrapped ? wrappedCurrencyInfo : nativeCurrencyInfo - const outputCurrencyInfo = unwrapped ? nativeCurrencyInfo : wrappedCurrencyInfo - - const title = formWrapNotificationTitle( - formatter, - txStatus, - inputCurrencyInfo?.currency, - outputCurrencyInfo?.currency, - currencyAmountRaw, - unwrapped - ) - - const wrapFormState = useCreateWrapFormState( - address, - chainId, - txId, - inputCurrencyInfo?.currency, - outputCurrencyInfo?.currency - ) - - const dispatch = useAppDispatch() - const { t } = useTranslation() - const retryButton = - txStatus === TransactionStatus.Failed - ? { - title: t('Retry'), - onPress: (): void => { - dispatch(closeModal({ name: ModalName.Swap })) - dispatch(openModal({ name: ModalName.Swap, initialState: wrapFormState ?? undefined })) - }, - } - : undefined - - const { onPress, onPressIn } = useNavigateToProfileTab(address) - - const icon = ( - - ) - - return ( - - ) -} - -export function TransferCurrencyNotification({ - notification, -}: { - notification: TransferCurrencyTxNotification -}): JSX.Element { - const formatter = useLocalizationContext() - const { - address, - assetType, - chainId, - tokenAddress, - currencyAmountRaw, - txType, - txStatus, - hideDelay, - } = notification - const senderOrRecipient = - txType === TransactionType.Send ? notification.recipient : notification.sender - const { name: ensName } = useENS(chainId, senderOrRecipient) - const currencyInfo = useCurrencyInfo(buildCurrencyId(chainId, tokenAddress)) - - const title = formTransferCurrencyNotificationTitle( - formatter, - txType, - txStatus, - currencyInfo?.currency, - tokenAddress, - currencyAmountRaw, - ensName ?? senderOrRecipient - ) - - const { onPress, onPressIn } = useNavigateToProfileTab(address) - - const icon = ( - - ) - - return ( - - ) -} - -export function TransferNFTNotification({ - notification, -}: { - notification: TransferNFTTxNotification -}): JSX.Element { - const { address, assetType, chainId, tokenAddress, tokenId, txType, txStatus, hideDelay } = - notification - const userAddress = useAppSelector(selectActiveAccountAddress) || '' - const senderOrRecipient = - txType === TransactionType.Send ? notification.recipient : notification.sender - const nftOwner = txType === TransactionType.Send ? notification.recipient : userAddress - const { data: nft } = useNFT(nftOwner, tokenAddress, tokenId) - const { name: ensName } = useENS(chainId, senderOrRecipient) - const title = formTransferNFTNotificationTitle( - txType, - txStatus, - nft, - tokenAddress, - tokenId, - ensName ?? senderOrRecipient - ) - - const { onPress, onPressIn } = useNavigateToProfileTab(address) - - const icon = ( - - ) - - return ( - - ) -} - -export function UnknownTxNotification({ - notification: { address, chainId, tokenAddress, txStatus, txType, hideDelay }, -}: { - notification: TransactionNotificationBase -}): JSX.Element { - const { name: ensName } = useENS(chainId, tokenAddress) - const currencyInfo = useCurrencyInfo( - tokenAddress ? buildCurrencyId(chainId, tokenAddress) : undefined - ) - const title = formUnknownTxTitle(txStatus, tokenAddress, ensName) - const icon = ( - - ) - - const { onPress, onPressIn } = useNavigateToProfileTab(address) - - return ( - - ) -} - -export function ErrorNotification({ - notification: { address, errorMessage, hideDelay }, -}: { - notification: AppErrorNotification -}): JSX.Element { - return -} - -export function DefaultNotification({ - notification: { address, title, hideDelay }, -}: { - notification: AppNotificationDefault -}): JSX.Element { - return -} - -export function SuccessNotification({ - notification: { hideDelay = 2000, title }, -}: { - notification: Pick -}): JSX.Element | null { - const colors = useSporeColors() - - return ( - - } - title={title} - /> - ) -} - -export function CopiedNotification({ - notification: { hideDelay = 2000, copyType }, -}: { - notification: CopyNotification -}): JSX.Element | null { - const { t } = useTranslation() - - let title - switch (copyType) { - case CopyNotificationType.Address: - title = t('Address copied') - break - case CopyNotificationType.ContractAddress: - title = t('Contract address copied') - break - case CopyNotificationType.TransactionId: - title = t('Transaction ID copied') - break - case CopyNotificationType.Image: - title = t('Image copied') - break - } - - return -} - -export function ChooseCountryNotification({ - notification: { countryName, countryCode, hideDelay }, -}: { - notification: ChooseCountryNotificationType -}): JSX.Element { - const { t } = useTranslation() - const countryFlagUrl = getCountryFlagSvgUrl(countryCode) - return ( - - - - } - title={t('Switched to {{name}}', { name: countryName })} - /> - ) -} - -export function ScantasticCompleteNotification({ - notification: { hideDelay }, -}: { - notification: ScantasticCompleteNotificationType -}): JSX.Element { - const { t } = useTranslation() - return ( - - - - - - - - - } - subtitle={t('Continue on Uniswap Extension')} - title={t('Success')} - /> - ) -} - -export function ChangeAssetVisibilityNotification({ - notification: { visible, hideDelay, assetName }, -}: { - notification: ChangeAssetVisibilityNotificationType -}): JSX.Element { - const { t } = useTranslation() - const colors = useSporeColors() - - return ( - - ) : ( - - ) - } - title={ - visible - ? t('{{assetName}} hidden', { assetName }) - : t('{{assetName}} unhidden', { assetName }) - } - /> - ) -} diff --git a/apps/mobile/src/features/notifications/PendingNotificationBadge.tsx b/apps/mobile/src/features/notifications/PendingNotificationBadge.tsx index 68a4fdec1ea..37919e9e496 100644 --- a/apps/mobile/src/features/notifications/PendingNotificationBadge.tsx +++ b/apps/mobile/src/features/notifications/PendingNotificationBadge.tsx @@ -1,10 +1,10 @@ import React from 'react' import { useAppSelector } from 'src/app/hooks' import { useEagerActivityNavigation } from 'src/app/navigation/hooks' -import { CheckmarkCircle } from 'src/components/icons/CheckmarkCircle' import { Flex, TouchableArea, useSporeColors } from 'ui/src' import AlertCircle from 'ui/src/assets/icons/alert-circle.svg' import { iconSizes } from 'ui/src/theme' +import { CheckmarkCircle } from 'wallet/src/components/icons/CheckmarkCircle' import { SpinningLoader } from 'wallet/src/components/loading/SpinningLoader' import { useSelectAddressHasNotifications } from 'wallet/src/features/notifications/hooks' import { selectActiveAccountNotifications } from 'wallet/src/features/notifications/selectors' diff --git a/apps/mobile/src/features/notifications/ScantasticCompleteNotification.tsx b/apps/mobile/src/features/notifications/ScantasticCompleteNotification.tsx new file mode 100644 index 00000000000..83c695bf2ba --- /dev/null +++ b/apps/mobile/src/features/notifications/ScantasticCompleteNotification.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { Flex, Icons } from 'ui/src' +import { NotificationToast } from 'wallet/src/features/notifications/components/NotificationToast' +import { ScantasticCompleteNotification as ScantasticCompleteNotificationType } from 'wallet/src/features/notifications/types' + +export function ScantasticCompleteNotification({ + notification: { hideDelay }, +}: { + notification: ScantasticCompleteNotificationType +}): JSX.Element { + const { t } = useTranslation() + return ( + + + + + + + + + } + subtitle={t('Continue on Uniswap Extension')} + title={t('Success')} + /> + ) +} diff --git a/apps/mobile/src/features/notifications/WCNotification.tsx b/apps/mobile/src/features/notifications/WCNotification.tsx new file mode 100644 index 00000000000..b7e89ca69ae --- /dev/null +++ b/apps/mobile/src/features/notifications/WCNotification.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { useAppDispatch } from 'src/app/hooks' +import { ScannerModalState } from 'src/components/QRCodeScanner/constants' +import { openModal } from 'src/features/modals/modalSlice' +import { iconSizes } from 'ui/src/theme' +import { DappLogoWithTxStatus } from 'wallet/src/components/CurrencyLogo/LogoWithTxStatus' +import { toSupportedChainId } from 'wallet/src/features/chains/utils' +import { NotificationToast } from 'wallet/src/features/notifications/components/NotificationToast' +import { NOTIFICATION_ICON_SIZE } from 'wallet/src/features/notifications/constants' +import { WalletConnectNotification } from 'wallet/src/features/notifications/types' +import { formWCNotificationTitle } from 'wallet/src/features/notifications/utils' +import { WalletConnectEvent } from 'wallet/src/features/walletConnect/types' +import { ModalName } from 'wallet/src/telemetry/constants' + +export function WCNotification({ + notification, +}: { + notification: WalletConnectNotification +}): JSX.Element { + const { imageUrl, chainId, address, event, hideDelay, dappName } = notification + const dispatch = useAppDispatch() + const validChainId = toSupportedChainId(chainId) + const title = formWCNotificationTitle(notification) + + const smallToastEvents = [ + WalletConnectEvent.Connected, + WalletConnectEvent.Disconnected, + WalletConnectEvent.NetworkChanged, + ] + const smallToast = smallToastEvents.includes(event) + + const icon = ( + + ) + + const onPressNotification = (): void => { + dispatch( + openModal({ + name: ModalName.WalletConnectScan, + initialState: ScannerModalState.ConnectedDapps, + }) + ) + } + + return ( + + ) +} diff --git a/apps/mobile/src/features/onboarding/OnboardingScreen.tsx b/apps/mobile/src/features/onboarding/OnboardingScreen.tsx index 477ce4e78d1..78d1e8ea43b 100644 --- a/apps/mobile/src/features/onboarding/OnboardingScreen.tsx +++ b/apps/mobile/src/features/onboarding/OnboardingScreen.tsx @@ -2,7 +2,7 @@ import { useHeaderHeight } from '@react-navigation/elements' import React, { PropsWithChildren } from 'react' import { KeyboardAvoidingView, StyleSheet } from 'react-native' import { FadeIn, FadeOut } from 'react-native-reanimated' -import { Screen, SHORT_SCREEN_HEADER_HEIGHT_RATIO } from 'src/components/layout/Screen' +import { SHORT_SCREEN_HEADER_HEIGHT_RATIO, Screen } from 'src/components/layout/Screen' import { AnimatedFlex, Flex, SpaceTokens, Text, useDeviceInsets, useMedia } from 'ui/src' import { fonts } from 'ui/src/theme' import { isIOS } from 'wallet/src/utils/platform' @@ -58,9 +58,9 @@ export function OnboardingScreen({ )} {subtitle ? ( {subtitle} diff --git a/apps/mobile/src/features/onboarding/SafeKeyboardOnboardingScreen.tsx b/apps/mobile/src/features/onboarding/SafeKeyboardOnboardingScreen.tsx index f0802c1bd80..5b6cb5cae5f 100644 --- a/apps/mobile/src/features/onboarding/SafeKeyboardOnboardingScreen.tsx +++ b/apps/mobile/src/features/onboarding/SafeKeyboardOnboardingScreen.tsx @@ -4,7 +4,7 @@ import React, { PropsWithChildren } from 'react' import { KeyboardAvoidingView, ScrollView, StyleSheet } from 'react-native' import { FadeIn, FadeOut } from 'react-native-reanimated' import { Screen } from 'src/components/layout/Screen' -import { AnimatedFlex, Flex, flexStyles, SpaceTokens, Text, useMedia, useSporeColors } from 'ui/src' +import { AnimatedFlex, Flex, SpaceTokens, Text, flexStyles, useMedia, useSporeColors } from 'ui/src' import { opacify, spacing } from 'ui/src/theme' import { isIOS } from 'wallet/src/utils/platform' import { useKeyboardLayout } from 'wallet/src/utils/useKeyboardLayout' diff --git a/apps/mobile/src/features/onboarding/hooks.ts b/apps/mobile/src/features/onboarding/hooks.ts index 8f5fd70fef7..bc72a5ec3e8 100644 --- a/apps/mobile/src/features/onboarding/hooks.ts +++ b/apps/mobile/src/features/onboarding/hooks.ts @@ -1,35 +1,47 @@ import { useAppDispatch } from 'src/app/hooks' -import { useOnboardingStackNavigation } from 'src/app/navigation/types' +import { OnboardingStackBaseParams, useOnboardingStackNavigation } from 'src/app/navigation/types' import { sendMobileAnalyticsEvent } from 'src/features/telemetry' import { MobileEventName } from 'src/features/telemetry/constants' import { Screens } from 'src/screens/Screens' import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' +import { setHasSkippedUnitagPrompt } from 'wallet/src/features/behaviorHistory/slice' +import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants' +import { useFeatureFlag } from 'wallet/src/features/experiments/hooks' +import { pushNotification } from 'wallet/src/features/notifications/slice' +import { AppNotificationType } from 'wallet/src/features/notifications/types' import { ImportType, OnboardingEntryPoint } from 'wallet/src/features/onboarding/types' +import { useClaimUnitag } from 'wallet/src/features/unitags/hooks' import { Account, BackupType } from 'wallet/src/features/wallet/accounts/types' import { - pendingAccountActions, PendingAccountActions, + pendingAccountActions, } from 'wallet/src/features/wallet/create/pendingAccountsSaga' import { usePendingAccounts } from 'wallet/src/features/wallet/hooks' import { setFinishedOnboarding } from 'wallet/src/features/wallet/slice' import { sendWalletAppsFlyerEvent } from 'wallet/src/telemetry' import { WalletAppsFlyerEvents } from 'wallet/src/telemetry/constants' +export type OnboardingCompleteProps = OnboardingStackBaseParams + /** * Bundles various actions that should be performed to complete onboarding. * * Used within the final screen of various onboarding flows. */ -export function useCompleteOnboardingCallback( - entryPoint: OnboardingEntryPoint, - importType: ImportType -): () => Promise { +export function useCompleteOnboardingCallback({ + entryPoint, + importType, + unitagClaim, +}: OnboardingStackBaseParams): () => Promise { const dispatch = useAppDispatch() const pendingAccounts = usePendingAccounts() const pendingWalletAddresses = Object.keys(pendingAccounts) const parentTrace = useTrace() const navigation = useOnboardingStackNavigation() + const unitagsFeatureFlagEnabled = useFeatureFlag(FEATURE_FLAGS.Unitags) + const claimUnitag = useClaimUnitag() + return async () => { sendMobileAnalyticsEvent( entryPoint === OnboardingEntryPoint.Sidebar @@ -45,9 +57,30 @@ export function useCompleteOnboardingCallback( ...parentTrace, } ) + + // Claim unitag if there's a claim to process + if (unitagClaim) { + const { claimError } = await claimUnitag(unitagClaim) + if (claimError) { + dispatch( + pushNotification({ + type: AppNotificationType.Error, + errorMessage: claimError, + }) + ) + } + } + // Remove pending flag from all new accounts. dispatch(pendingAccountActions.trigger(PendingAccountActions.Activate)) + // Dismiss unitags prompt if: + // - the feature was enabled + // - the onboarding method prompts for unitags (create new) + if (unitagsFeatureFlagEnabled && importType === ImportType.CreateNew) { + dispatch(setHasSkippedUnitagPrompt(true)) + } + // Exit flow dispatch(setFinishedOnboarding({ finishedOnboarding: true })) if (entryPoint === OnboardingEntryPoint.Sidebar) { diff --git a/apps/mobile/src/features/scantastic/ScantasticModal.tsx b/apps/mobile/src/features/scantastic/ScantasticModal.tsx index b9713fe32ae..69227e3f6cd 100644 --- a/apps/mobile/src/features/scantastic/ScantasticModal.tsx +++ b/apps/mobile/src/features/scantastic/ScantasticModal.tsx @@ -4,13 +4,14 @@ import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { useBiometricAppSettings, useBiometricPrompt } from 'src/features/biometrics/hooks' import { closeAllModals } from 'src/features/modals/modalSlice' import { selectModalState } from 'src/features/modals/selectModalState' -import { Button, Flex, Icons, Text, useSporeColors } from 'ui/src' +import { Button, Flex, Icons, Text, TouchableArea, useSporeColors } from 'ui/src' import { iconSizes } from 'ui/src/theme' import { logger } from 'utilities/src/logger/logger' -import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { getDurationRemainingString } from 'utilities/src/time/duration' +import { ONE_MINUTE_MS, ONE_SECOND_MS } from 'utilities/src/time/time' import { useInterval } from 'utilities/src/time/timing' import { BottomSheetModal } from 'wallet/src/components/modals/BottomSheetModal' -import { config } from 'wallet/src/config' +import { uniswapUrls } from 'wallet/src/constants/urls' import { pushNotification } from 'wallet/src/features/notifications/slice' import { AppNotificationType } from 'wallet/src/features/notifications/types' import { useActiveAccount } from 'wallet/src/features/wallet/hooks' @@ -24,7 +25,7 @@ enum OtpState { } interface OtpStateApiResponse { otp?: OtpState - expiresAt?: number + expiresAtInSeconds?: number } export function ScantasticModal(): JSX.Element | null { @@ -36,8 +37,9 @@ export function ScantasticModal(): JSX.Element | null { const { initialState } = useAppSelector(selectModalState(ModalName.Scantastic)) const [OTP, setOTP] = useState('') - const [expirationTimestamp, setExpirationTimestamp] = useState( - Number(initialState?.expiry) * ONE_SECOND_MS + // Once a user has scanned a QR they have 6 minutes to correctly input the OTP + const [expirationTimestamp, setExpirationTimestamp] = useState( + Date.now() + 6 * ONE_MINUTE_MS ) const pubKey: JsonWebKey = initialState?.pubKey ? JSON.parse(initialState?.pubKey) : undefined const uuid = initialState?.uuid @@ -60,20 +62,15 @@ export function ScantasticModal(): JSX.Element | null { useEffect(() => { const interval = setInterval(() => { - if (Number.isNaN(expirationTimestamp)) { - return - } const timeLeft = expirationTimestamp - Date.now() - if (timeLeft <= 0) { - setExpiryText(t('Expired')) - clearInterval(interval) - setExpired(true) - } else { - const minutes = Math.floor(timeLeft / 60000) - const seconds = ((timeLeft % 60000) / 1000).toFixed(0) - setExpiryText(t(`Session expires in ${minutes}m${seconds}s`)) + return setExpiryText(t('Expired')) } + return setExpiryText( + t('New code in {{duration}}', { + duration: getDurationRemainingString(expirationTimestamp), + }) + ) }, ONE_SECOND_MS) return () => clearInterval(interval) @@ -98,11 +95,12 @@ export function ScantasticModal(): JSX.Element | null { try { // submit encrypted blob - const response = await fetch(`${config.tempScantasticUrl}/blob`, { + const response = await fetch(`${uniswapUrls.apiBaseExtensionUrl}/scantastic/blob`, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', + Origin: 'https://uniswap.org', }, body: JSON.stringify({ uuid, @@ -116,7 +114,7 @@ export function ScantasticModal(): JSX.Element | null { if (!data?.otp) { throw new Error(t('OTP unavailable')) } else { - setExpirationTimestamp(Date.now() + 1000 * 60 * 2) + setExpirationTimestamp(Date.now() + ONE_MINUTE_MS * 2) setOTP(data.otp) } } catch (e) { @@ -144,7 +142,16 @@ export function ScantasticModal(): JSX.Element | null { return } try { - const response = await fetch(`${config.tempScantasticUrl}/otp-state/${uuid}`) + const response = await fetch( + `${uniswapUrls.apiBaseExtensionUrl}/scantastic/otp-state/${uuid}`, + { + method: 'POST', + headers: { + Accept: 'application/json', + Origin: 'https://uniswap.org', + }, + } + ) if (!response.ok) { return } @@ -153,10 +160,9 @@ export function ScantasticModal(): JSX.Element | null { if (!otpState) { return } - - const expiresAtMs = data.expiresAt && data.expiresAt * ONE_SECOND_MS - setExpirationTimestamp((current) => expiresAtMs ?? current) - + if (data.expiresAtInSeconds) { + setExpirationTimestamp(data.expiresAtInSeconds * ONE_SECOND_MS) + } if (otpState === OtpState.Redeemed) { setRedeemed(true) } @@ -176,20 +182,16 @@ export function ScantasticModal(): JSX.Element | null { backgroundColor={colors.surface1.get()} name={ModalName.OtpInputExpired} onClose={onClose}> - - - + + + - - Your session timed out - - - - Scan the QR code on the Uniswap Extension again to continue syncing your wallet. - + {t('Your connection timed out')} + + {t('Scan the QR code on the Uniswap Extension again to continue syncing your wallet.')} @@ -202,22 +204,21 @@ export function ScantasticModal(): JSX.Element | null { backgroundColor={colors.surface1.get()} name={ModalName.OtpScanInput} onClose={onClose}> - + - + - - Uniswap one-time code - + {t('Uniswap one-time code')} Enter this code in the Uniswap Extension. Your recovery phrase will be safely encrypted and transferred. - - {OTP.substring(0, 3)} {OTP.substring(3)} - + + {OTP.substring(0, 3).split('').join(' ')} + {OTP.substring(3).split('').join(' ')} + {expiryText} @@ -225,54 +226,58 @@ export function ScantasticModal(): JSX.Element | null { ) } + return ( - + - + - - Is this your device? - + {t('Is this your device?')} - - Only continue if you are syncing with the Uniswap Extension on a trusted device. - + {t('Only continue if you are syncing with the Uniswap Extension on a trusted device.')} - {device && ( - - - Device - - {device} - - )} - {browser && ( - - - Browser - - {browser} - - )} + + {device && ( + + + {t('Device')} + + {device} + + )} + {browser && ( + + + {t('Browser')} + + {browser} + + )} + - + diff --git a/apps/mobile/src/features/scantastic/ScantasticModalState.ts b/apps/mobile/src/features/scantastic/ScantasticModalState.ts index 180db1708ce..59bdd6d364f 100644 --- a/apps/mobile/src/features/scantastic/ScantasticModalState.ts +++ b/apps/mobile/src/features/scantastic/ScantasticModalState.ts @@ -4,5 +4,4 @@ export interface ScantasticModalState { vendor: string model: string browser: string - expiry: string // unix timestamp when the uuid should expire } diff --git a/apps/mobile/src/features/telemetry/slice.ts b/apps/mobile/src/features/telemetry/slice.ts index 5ee812ca93a..fd9a6f8abe6 100644 --- a/apps/mobile/src/features/telemetry/slice.ts +++ b/apps/mobile/src/features/telemetry/slice.ts @@ -47,7 +47,8 @@ export const slice = createSlice({ setAllowAnalytics: (state, { payload: { enabled } }: PayloadAction<{ enabled: boolean }>) => { sendWalletAnalyticsEvent(SharedEventName.ANALYTICS_SWITCH_TOGGLED, { enabled }) analytics.flushEvents() - analytics.setAllowAnalytics(enabled).finally(() => undefined) + // eslint-disable-next-line no-void + void analytics.setAllowAnalytics(enabled).finally(() => undefined) state.allowAnalytics = enabled }, }, diff --git a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/ApproveSummaryItem.stories.tsx b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/ApproveSummaryItem.stories.tsx index 6ef7871935e..f310480f714 100644 --- a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/ApproveSummaryItem.stories.tsx +++ b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/ApproveSummaryItem.stories.tsx @@ -1,8 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react' -import TransactionSummaryLayout from 'src/features/transactions/SummaryCards/TransactionSummaryLayout' import { ChainId } from 'wallet/src/constants/chains' import { TokenDocument } from 'wallet/src/data/__generated__/types-and-hooks' import { ApproveSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/ApproveSummaryItem' +import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' import { ApproveTransactionInfo, TransactionDetails, diff --git a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/FiatPurchaseSummaryItem.stories.tsx b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/FiatPurchaseSummaryItem.stories.tsx index 00494bbbd47..96276715e12 100644 --- a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/FiatPurchaseSummaryItem.stories.tsx +++ b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/FiatPurchaseSummaryItem.stories.tsx @@ -1,10 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react' import React from 'react' -import TransactionSummaryLayout from 'src/features/transactions/SummaryCards/TransactionSummaryLayout' import { getNativeAddress } from 'wallet/src/constants/addresses' import { ChainId } from 'wallet/src/constants/chains' import { Chain, TokenDocument } from 'wallet/src/data/__generated__/types-and-hooks' import { FiatPurchaseSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/FiatPurchaseSummaryItem' +import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' import { FiatPurchaseTransactionInfo, TransactionDetails, diff --git a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/NFTApproveSummaryItem.stories.tsx b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/NFTApproveSummaryItem.stories.tsx index 045a4ebc112..7b597b5f257 100644 --- a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/NFTApproveSummaryItem.stories.tsx +++ b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/NFTApproveSummaryItem.stories.tsx @@ -1,8 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react' import React from 'react' -import TransactionSummaryLayout from 'src/features/transactions/SummaryCards/TransactionSummaryLayout' import { ChainId } from 'wallet/src/constants/chains' import { NFTApproveSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/NFTApproveSummaryItem' +import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' import { NFTApproveTransactionInfo, TransactionDetails, diff --git a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/NFTMintSummaryItem.stories.tsx b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/NFTMintSummaryItem.stories.tsx index 9b03d5ded40..7dcf9250cf7 100644 --- a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/NFTMintSummaryItem.stories.tsx +++ b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/NFTMintSummaryItem.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react' import React from 'react' -import TransactionSummaryLayout from 'src/features/transactions/SummaryCards/TransactionSummaryLayout' import { NFTMintSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/NFTMintSummaryItem' +import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' import { NFTMintTransactionInfo, TransactionDetails, diff --git a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/NFTTradeSummaryItem.stories.tsx b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/NFTTradeSummaryItem.stories.tsx index e74b62a9bd9..a69b9cbe88a 100644 --- a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/NFTTradeSummaryItem.stories.tsx +++ b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/NFTTradeSummaryItem.stories.tsx @@ -1,8 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react' import React from 'react' -import TransactionSummaryLayout from 'src/features/transactions/SummaryCards/TransactionSummaryLayout' import { ChainId } from 'wallet/src/constants/chains' import { NFTTradeSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/NFTTradeSummaryItem' +import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' import { NFTTradeTransactionInfo, NFTTradeType, diff --git a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/ReceiveSummaryItem.stories.tsx b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/ReceiveSummaryItem.stories.tsx index deaf5be004a..a6adf82c17b 100644 --- a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/ReceiveSummaryItem.stories.tsx +++ b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/ReceiveSummaryItem.stories.tsx @@ -1,10 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react' import React from 'react' -import TransactionSummaryLayout from 'src/features/transactions/SummaryCards/TransactionSummaryLayout' import { ChainId } from 'wallet/src/constants/chains' import { TokenDocument } from 'wallet/src/data/__generated__/types-and-hooks' import { AssetType } from 'wallet/src/entities/assets' import { ReceiveSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/ReceiveSummaryItem' +import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' import { ReceiveTokenTransactionInfo, TransactionDetails, diff --git a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/SendSummaryItem.stories.tsx b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/SendSummaryItem.stories.tsx index d32c27908f2..e8456450544 100644 --- a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/SendSummaryItem.stories.tsx +++ b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/SendSummaryItem.stories.tsx @@ -1,10 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react' import React from 'react' -import TransactionSummaryLayout from 'src/features/transactions/SummaryCards/TransactionSummaryLayout' import { ChainId } from 'wallet/src/constants/chains' import { TokenDocument } from 'wallet/src/data/__generated__/types-and-hooks' import { AssetType } from 'wallet/src/entities/assets' import { SendSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/SendSummaryItem' +import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' import { SendTokenTransactionInfo, TransactionDetails, diff --git a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/SwapSummaryItem.stories.tsx b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/SwapSummaryItem.stories.tsx index 20aadb994e1..d797cd97444 100644 --- a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/SwapSummaryItem.stories.tsx +++ b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/SwapSummaryItem.stories.tsx @@ -1,10 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react' import { TradeType } from '@uniswap/sdk-core' import React from 'react' -import TransactionSummaryLayout from 'src/features/transactions/SummaryCards/TransactionSummaryLayout' import { ChainId } from 'wallet/src/constants/chains' import { TokenDocument } from 'wallet/src/data/__generated__/types-and-hooks' import { SwapSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/SwapSummaryItem' +import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' import { ExactInputSwapTransactionInfo, ExactOutputSwapTransactionInfo, diff --git a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/UnknownSummaryItem.stories.tsx b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/UnknownSummaryItem.stories.tsx index 06977737042..790a0397217 100644 --- a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/UnknownSummaryItem.stories.tsx +++ b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/UnknownSummaryItem.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react' import React from 'react' -import TransactionSummaryLayout from 'src/features/transactions/SummaryCards/TransactionSummaryLayout' import { ChainId } from 'wallet/src/constants/chains' +import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' import { UnknownSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/UnknownSummaryItem' import { TransactionDetails, diff --git a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/WCSummaryItem.stories.tsx b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/WCSummaryItem.stories.tsx index 97d2f132849..ccd1833fa6e 100644 --- a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/WCSummaryItem.stories.tsx +++ b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/WCSummaryItem.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react' import React from 'react' -import TransactionSummaryLayout from 'src/features/transactions/SummaryCards/TransactionSummaryLayout' import { ChainId } from 'wallet/src/constants/chains' +import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' import { WCSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/WCSummaryItem' import { TransactionDetails, diff --git a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/WrapSummaryItem.stories.tsx b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/WrapSummaryItem.stories.tsx index 89b1e9a2a4e..858c31ee1e3 100644 --- a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/WrapSummaryItem.stories.tsx +++ b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/WrapSummaryItem.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react' import React from 'react' -import TransactionSummaryLayout from 'src/features/transactions/SummaryCards/TransactionSummaryLayout' import { TokenDocument } from 'wallet/src/data/__generated__/types-and-hooks' +import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' import { WrapSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/WrapSummaryItem' import { TransactionDetails, diff --git a/apps/mobile/src/features/transactions/TransactionFlow.tsx b/apps/mobile/src/features/transactions/TransactionFlow.tsx deleted file mode 100644 index 2be13123ac2..00000000000 --- a/apps/mobile/src/features/transactions/TransactionFlow.tsx +++ /dev/null @@ -1,399 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { TouchableWithoutFeedback } from 'react-native' -import { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated' -import { useShouldShowNativeKeyboard } from 'src/app/hooks' -import { Screen } from 'src/components/layout/Screen' -import Trace from 'src/components/Trace/Trace' -import { useBiometricAppSettings, useBiometricPrompt } from 'src/features/biometrics/hooks' -import { SwapForm } from 'src/features/transactions/swap/SwapForm' -import { SwapReview } from 'src/features/transactions/swap/SwapReview' -import { SwapStatus } from 'src/features/transactions/swap/SwapStatus' -import { HeaderContent } from 'src/features/transactions/TransactionFlowHeaderContent' -import { TransferStatus } from 'src/features/transactions/transfer/TransferStatus' -import { useWalletRestore } from 'src/features/wallet/hooks' -import { AnimatedFlex, Flex, useDeviceDimensions, useSporeColors } from 'ui/src' -import EyeIcon from 'ui/src/assets/icons/eye.svg' -import { iconSizes } from 'ui/src/theme' -import { useBottomSheetContext } from 'wallet/src/components/modals/BottomSheetContext' -import { HandleBar } from 'wallet/src/components/modals/HandleBar' -import { WarningModal } from 'wallet/src/components/modals/WarningModal/WarningModal' -import { GasFeeResult } from 'wallet/src/features/gas/types' -import { SwapSettingsModal } from 'wallet/src/features/transactions/swap/modals/SwapSettingsModal' -import { DerivedSwapInfo } from 'wallet/src/features/transactions/swap/types' -import { transactionStateActions } from 'wallet/src/features/transactions/transactionState/transactionState' -import { CurrencyField } from 'wallet/src/features/transactions/transactionState/types' -import { - useTransferERC20Callback, - useTransferNFTCallback, -} from 'wallet/src/features/transactions/transfer/hooks/useTransferCallback' -import { TransferReview } from 'wallet/src/features/transactions/transfer/TransferReview' -import { TransferTokenForm } from 'wallet/src/features/transactions/transfer/TransferTokenForm' -import { DerivedTransferInfo } from 'wallet/src/features/transactions/transfer/types' -import { TransactionFlowProps, TransactionStep } from 'wallet/src/features/transactions/types' -import { ANIMATE_SPRING_CONFIG } from 'wallet/src/features/transactions/utils' -import { WarningSeverity } from 'wallet/src/features/transactions/WarningModal/types' -import { ModalName, SectionName } from 'wallet/src/telemetry/constants' -import { currencyAddress } from 'wallet/src/utils/currencyId' - -type InnerContentProps = Pick< - TransactionFlowProps, - | 'derivedInfo' - | 'onClose' - | 'dispatch' - | 'gasFee' - | 'txRequest' - | 'approveTxRequest' - | 'warnings' - | 'exactValue' -> & { - step: number - setStep: (step: TransactionStep) => void - showingSelectorScreen: boolean - gasFee: GasFeeResult -} - -function isSwapInfo( - derivedInfo: DerivedTransferInfo | DerivedSwapInfo -): derivedInfo is DerivedSwapInfo { - return (derivedInfo as DerivedSwapInfo).trade !== undefined -} - -export function TransactionFlow({ - flowName, - showRecipientSelector, - recipientSelector, - derivedInfo, - approveTxRequest, - txRequest, - gasFee, - step, - setStep, - onClose, - dispatch, - warnings, - exactValue, - isFiatInput, - showFiatToggle, -}: TransactionFlowProps): JSX.Element { - const colors = useSporeColors() - const { t } = useTranslation() - const { fullWidth } = useDeviceDimensions() - - const { isSheetReady } = useBottomSheetContext() - - const [showViewOnlyModal, setShowViewOnlyModal] = useState(false) - const [showSettingsModal, setShowSettingsModal] = useState(false) - - const isSwap = isSwapInfo(derivedInfo) - const derivedSwapInfo = isSwap ? derivedInfo : undefined - const { customSlippageTolerance } = derivedSwapInfo ?? {} - - // optimization for not rendering InnerContent initially, - // when modal is opened with recipient or token selector presented - const [renderInnerContentRouter, setRenderInnerContentRouter] = useState(!showRecipientSelector) - useEffect(() => { - setRenderInnerContentRouter(renderInnerContentRouter || !showRecipientSelector) - }, [renderInnerContentRouter, showRecipientSelector]) - - const screenXOffset = useSharedValue(showRecipientSelector ? -fullWidth : 0) - useEffect(() => { - const screenOffset = showRecipientSelector ? 1 : 0 - screenXOffset.value = withSpring(-(fullWidth * screenOffset), ANIMATE_SPRING_CONFIG) - }, [screenXOffset, showRecipientSelector, fullWidth]) - - const wrapperStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: screenXOffset.value }], - })) - - const setCustomSlippageTolerance = useCallback( - (newCustomSlippageTolerance: number | undefined): void => - dispatch(transactionStateActions.setCustomSlippageTolerance(newCustomSlippageTolerance)), - [dispatch] - ) - - return ( - - - - - {/* Padding bottom must have a similar size to the handlebar - height as 100% height doesn't include the handlebar height */} - - {step !== TransactionStep.SUBMITTED && ( - - )} - {renderInnerContentRouter && isSheetReady && ( - - )} - - {showViewOnlyModal && ( - - } - modalName={ModalName.SwapWarning} - severity={WarningSeverity.Low} - title={t('This wallet is view-only')} - onClose={(): void => setShowViewOnlyModal(false)} - onConfirm={(): void => setShowViewOnlyModal(false)} - /> - )} - {isSwap && showSettingsModal ? ( - { - setShowSettingsModal(false) - }} - /> - ) : null} - - {showRecipientSelector && recipientSelector ? recipientSelector : null} - - - - ) -} - -function InnerContentRouter(props: InnerContentProps): JSX.Element { - const { derivedInfo, setStep } = props - const onFormNext = useCallback(() => setStep(TransactionStep.REVIEW), [setStep]) - const onReviewNext = useCallback(() => setStep(TransactionStep.SUBMITTED), [setStep]) - const onReviewPrev = useCallback(() => setStep(TransactionStep.FORM), [setStep]) - const onRetrySubmit = useCallback(() => setStep(TransactionStep.FORM), [setStep]) - - const isSwap = isSwapInfo(derivedInfo) - if (isSwap) { - return ( - - ) - } - return ( - - ) -} - -interface SwapInnerContentProps extends InnerContentProps { - derivedSwapInfo: DerivedSwapInfo - onFormNext: () => void - onReviewNext: () => void - onReviewPrev: () => void - onRetrySubmit: () => void -} - -function SwapInnerContent({ - derivedSwapInfo, - onClose, - dispatch, - gasFee, - approveTxRequest, - txRequest, - warnings, - onFormNext, - onReviewNext, - onReviewPrev, - onRetrySubmit, - step, - exactValue, - showingSelectorScreen, -}: SwapInnerContentProps): JSX.Element | null { - switch (step) { - case TransactionStep.SUBMITTED: - return ( - - - - ) - - case TransactionStep.FORM: - return ( - - - - ) - case TransactionStep.REVIEW: - // Removed trace from here as it doesn't fire for some reason. Event fires in the component itself, can investigate at a later date - return ( - - ) - default: - return null - } -} - -interface TransferInnerContentProps extends InnerContentProps { - derivedTransferInfo: DerivedTransferInfo - onFormNext: () => void - onReviewNext: () => void - onReviewPrev: () => void - onRetrySubmit: () => void -} - -function TransferInnerContent({ - showingSelectorScreen, - derivedTransferInfo, - onClose, - dispatch, - step, - gasFee, - txRequest, - warnings, - onFormNext, - onRetrySubmit, - onReviewNext, - onReviewPrev, -}: TransferInnerContentProps): JSX.Element | null { - // TODO: move this up in the tree to mobile specific flow - const { walletNeedsRestore, openWalletRestoreModal } = useWalletRestore() - const { showNativeKeyboard, onDecimalPadLayout, isLayoutPending, onInputPanelLayout } = - useShouldShowNativeKeyboard() - - const { currencyAmounts, recipient, currencyInInfo, nftIn, chainId, txId } = derivedTransferInfo - const transferERC20Callback = useTransferERC20Callback( - txId, - chainId, - recipient, - currencyInInfo ? currencyAddress(currencyInInfo.currency) : undefined, - currencyAmounts[CurrencyField.INPUT]?.quotient.toString(), - txRequest, - onReviewNext - ) - const transferNFTCallback = useTransferNFTCallback( - txId, - chainId, - recipient, - nftIn?.nftContract?.address, - nftIn?.tokenId, - txRequest, - onReviewNext - ) - - const onTransfer = (): void => { - onFormNext() - nftIn ? transferNFTCallback?.() : transferERC20Callback?.() - } - - const { trigger: biometricAuthAndTransfer } = useBiometricPrompt(onTransfer) - const { requiredForTransactions: biometricRequired } = useBiometricAppSettings() - - const onReviewSubmit = async (): Promise => { - if (biometricRequired) { - await biometricAuthAndTransfer() - } else { - onTransfer() - } - } - - switch (step) { - case TransactionStep.SUBMITTED: - return ( - - - - ) - case TransactionStep.FORM: - return ( - - - - ) - case TransactionStep.REVIEW: - return ( - - - - ) - default: - return null - } -} diff --git a/apps/mobile/src/features/transactions/TransactionPending/TransactionPending.tsx b/apps/mobile/src/features/transactions/TransactionPending/TransactionPending.tsx index 1fec18557c4..e80d94d96c5 100644 --- a/apps/mobile/src/features/transactions/TransactionPending/TransactionPending.tsx +++ b/apps/mobile/src/features/transactions/TransactionPending/TransactionPending.tsx @@ -4,9 +4,9 @@ import { StatusAnimation } from 'src/features/transactions/TransactionPending/St import { AnimatedFlex, Button, Flex, Text, TouchableArea } from 'ui/src' import { ChainId } from 'wallet/src/constants/chains' import { - isFinalizedTx, TransactionDetails, TransactionStatus, + isFinalizedTx, } from 'wallet/src/features/transactions/types' import { ElementName } from 'wallet/src/telemetry/constants' import { openTransactionLink } from 'wallet/src/utils/linking' diff --git a/apps/mobile/src/features/transactions/swap/SwapArrowButton.tsx b/apps/mobile/src/features/transactions/swap/SwapArrowButton.tsx deleted file mode 100644 index 328d7d84947..00000000000 --- a/apps/mobile/src/features/transactions/swap/SwapArrowButton.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react' -import { Flex, TouchableArea, TouchableAreaProps, useSporeColors } from 'ui/src' -import { iconSizes } from 'ui/src/theme' -import { Arrow } from 'wallet/src/components/icons/Arrow' - -type SwapArrowButtonProps = Pick< - TouchableAreaProps, - 'disabled' | 'testID' | 'onPress' | 'borderColor' | 'bg' -> & { size?: number } - -export function SwapArrowButton(props: SwapArrowButtonProps): JSX.Element { - const colors = useSporeColors() - const { testID, onPress, disabled, bg = '$surface2', size = iconSizes.icon20, ...rest } = props - return ( - - {/* hack to add 2px more padding without adjusting design system values */} - - - - - ) -} diff --git a/apps/mobile/src/features/transactions/swap/SwapFlow.tsx b/apps/mobile/src/features/transactions/swap/SwapFlow.tsx deleted file mode 100644 index 58b8329c8d4..00000000000 --- a/apps/mobile/src/features/transactions/swap/SwapFlow.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import React, { useEffect, useMemo, useReducer, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { TransactionFlow } from 'src/features/transactions/TransactionFlow' -import { - TokenSelectorModal, - TokenSelectorVariation, -} from 'wallet/src/components/TokenSelector/TokenSelector' -import { useSwapWarnings } from 'wallet/src/features/transactions/hooks/useSwapWarnings' -import { useTokenSelectorActionHandlers } from 'wallet/src/features/transactions/hooks/useTokenSelectorActionHandlers' -import { useTransactionGasWarning } from 'wallet/src/features/transactions/hooks/useTransactionGasWarning' -import { - useDerivedSwapInfo, - useSwapTxAndGasInfoLegacy, -} from 'wallet/src/features/transactions/swap/hooks' -import { - initialState as emptyState, - transactionStateReducer, -} from 'wallet/src/features/transactions/transactionState/transactionState' -import { - CurrencyField, - TransactionState, -} from 'wallet/src/features/transactions/transactionState/types' -import { TokenSelectorFlow } from 'wallet/src/features/transactions/transfer/types' -import { TransactionStep } from 'wallet/src/features/transactions/types' -import { WarningAction } from 'wallet/src/features/transactions/WarningModal/types' - -interface SwapFormProps { - prefilledState?: TransactionState - onClose: () => void -} - -function otherCurrencyField(field: CurrencyField): CurrencyField { - return field === CurrencyField.INPUT ? CurrencyField.OUTPUT : CurrencyField.INPUT -} - -export function SwapFlow({ prefilledState, onClose }: SwapFormProps): JSX.Element { - const { t } = useTranslation() - const [state, dispatch] = useReducer(transactionStateReducer, prefilledState || emptyState) - const derivedSwapInfo = useDerivedSwapInfo(state) - const { onSelectCurrency, onHideTokenSelector } = useTokenSelectorActionHandlers( - dispatch, - TokenSelectorFlow.Swap - ) - const { selectingCurrencyField, currencies } = derivedSwapInfo - const [step, setStep] = useState(TransactionStep.FORM) - - const warnings = useSwapWarnings(t, derivedSwapInfo) - - // Force this legacy swap flow to use the old routing api logic, as we're planning to remove this, and splitting the code is complex. - const { txRequest, approveTxRequest, gasFee } = useSwapTxAndGasInfoLegacy({ - derivedSwapInfo, - skipGasFeeQuery: - step === TransactionStep.SUBMITTED || - warnings.some((warning) => warning.action === WarningAction.DisableReview), - }) - - const gasWarning = useTransactionGasWarning({ - derivedInfo: derivedSwapInfo, - gasFee: gasFee.value, - }) - - const allWarnings = useMemo(() => { - return !gasWarning ? warnings : [...warnings, gasWarning] - }, [warnings, gasWarning]) - - // keep currencies list option as state so that rendered list remains stable through the slide animation - const [listVariation, setListVariation] = useState< - | TokenSelectorVariation.BalancesAndPopular - | TokenSelectorVariation.SuggestedAndFavoritesAndPopular - >(TokenSelectorVariation.BalancesAndPopular) - - useEffect(() => { - if (selectingCurrencyField) { - setListVariation( - selectingCurrencyField === CurrencyField.INPUT - ? TokenSelectorVariation.BalancesAndPopular - : TokenSelectorVariation.SuggestedAndFavoritesAndPopular - ) - } - }, [selectingCurrencyField]) - - const exactValue = state.isFiatInput ? state.exactAmountFiat : state.exactAmountToken - - const otherCurrencyChainId = selectingCurrencyField - ? currencies[otherCurrencyField(selectingCurrencyField)]?.currency.chainId - : undefined - - return ( - <> - - {!!selectingCurrencyField && ( - - )} - - ) -} diff --git a/apps/mobile/src/features/transactions/swap/SwapForm.tsx b/apps/mobile/src/features/transactions/swap/SwapForm.tsx deleted file mode 100644 index bea887d062a..00000000000 --- a/apps/mobile/src/features/transactions/swap/SwapForm.tsx +++ /dev/null @@ -1,491 +0,0 @@ -// TODO(MOB-203): reduce component complexity -/* eslint-disable complexity */ -import { AnyAction } from '@reduxjs/toolkit' -import React, { Dispatch, memo, useCallback, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { Keyboard, StyleSheet, TextInputProps } from 'react-native' -import { FadeIn, FadeOut, FadeOutDown } from 'react-native-reanimated' -import { useShouldShowNativeKeyboard } from 'src/app/hooks' -import Trace from 'src/components/Trace/Trace' -import { SwapArrowButton } from 'src/features/transactions/swap/SwapArrowButton' -import { useWalletRestore } from 'src/features/wallet/hooks' -import { AnimatedFlex, Button, Flex, Icons, Text, TouchableArea, useSporeColors } from 'ui/src' -import InfoCircleFilled from 'ui/src/assets/icons/info-circle-filled.svg' -import InfoCircle from 'ui/src/assets/icons/info-circle.svg' -import { iconSizes, spacing } from 'ui/src/theme' -import { CurrencyInputPanelLegacy } from 'wallet/src/components/legacy/CurrencyInputPanelLegacy' -import { DecimalPadLegacy } from 'wallet/src/components/legacy/DecimalPadLegacy' -import { SpinningLoader } from 'wallet/src/components/loading/SpinningLoader' -import { getAlertColor, WarningModal } from 'wallet/src/components/modals/WarningModal/WarningModal' -import { useSwapAnalytics } from 'wallet/src/features/transactions/swap/analytics' -import { BlockedAddressWarning } from 'wallet/src/features/trm/BlockedAddressWarning' -import { ModalName, SectionName } from 'wallet/src/telemetry/constants' -// eslint-disable-next-line no-restricted-imports -import { formatCurrencyAmount } from 'utilities/src/format/localeBased' -import { NumberType } from 'utilities/src/format/types' -import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' -import { useUSDCPrice } from 'wallet/src/features/routing/useUSDCPrice' -import { isPriceImpactWarning } from 'wallet/src/features/transactions/hooks/useSwapWarnings' -import { useTokenFormActionHandlers } from 'wallet/src/features/transactions/hooks/useTokenFormActionHandlers' -import { useTokenSelectorActionHandlers } from 'wallet/src/features/transactions/hooks/useTokenSelectorActionHandlers' -import { useShowSwapNetworkNotification } from 'wallet/src/features/transactions/swap/hooks' -import { DerivedSwapInfo } from 'wallet/src/features/transactions/swap/types' -import { getRateToDisplay, isWrapAction } from 'wallet/src/features/transactions/swap/utils' -import { CurrencyField } from 'wallet/src/features/transactions/transactionState/types' -import { TokenSelectorFlow } from 'wallet/src/features/transactions/transfer/types' -import { createTransactionId } from 'wallet/src/features/transactions/utils' -import { - Warning, - WarningAction, - WarningSeverity, -} from 'wallet/src/features/transactions/WarningModal/types' -import { useIsBlockedActiveAddress } from 'wallet/src/features/trm/hooks' -import { ElementName } from 'wallet/src/telemetry/constants' - -interface SwapFormProps { - dispatch: Dispatch - onNext: () => void - derivedSwapInfo: DerivedSwapInfo - warnings: Warning[] - exactValue: string - showingSelectorScreen: boolean -} - -function _SwapForm({ - dispatch, - onNext, - derivedSwapInfo, - warnings, - exactValue, - showingSelectorScreen, -}: SwapFormProps): JSX.Element { - const { t } = useTranslation() - const colors = useSporeColors() - - const formatter = useLocalizationContext() - const { convertFiatAmountFormatted } = formatter - - const { - chainId, - currencies, - currencyAmounts, - currencyAmountsUSDValue, - currencyBalances, - exactCurrencyField, - focusOnCurrencyField, - trade, - wrapType, - } = derivedSwapInfo - - const { - onFocusInput, - onFocusOutput, - onSwitchCurrencies, - onSetExactAmount, - onSetMax, - onCreateTxId, - } = useTokenFormActionHandlers(dispatch) - const { onShowTokenSelector } = useTokenSelectorActionHandlers(dispatch, TokenSelectorFlow.Swap) - - useShowSwapNetworkNotification(chainId) - - const { walletNeedsRestore, openWalletRestoreModal } = useWalletRestore() - - const onRestorePress = (): void => { - Keyboard.dismiss() - openWalletRestoreModal() - } - - const { isBlocked, isBlockedLoading } = useIsBlockedActiveAddress() - - const [showWarningModal, setShowWarningModal] = useState(false) - - const swapDataRefreshing = !isWrapAction(wrapType) && (trade.isFetching || trade.loading) - - const noValidSwap = !isWrapAction(wrapType) && !trade.trade - const blockingWarning = warnings.some((warning) => warning.action === WarningAction.DisableReview) - - const actionButtonDisabled = - noValidSwap || - blockingWarning || - swapDataRefreshing || - isBlocked || - isBlockedLoading || - walletNeedsRestore - - // We clear swap warnings while refreshing in order to show the loading indicator - const swapWarning = swapDataRefreshing - ? null - : warnings.find((warning) => warning.severity >= WarningSeverity.Low) - const swapWarningColor = getAlertColor(swapWarning?.severity) - - const onSwapWarningClick = (): void => { - Keyboard.dismiss() - setShowWarningModal(true) - } - - const onReview = useCallback((): void => { - const txId = createTransactionId() - onCreateTxId(txId) - onNext() - }, [onCreateTxId, onNext]) - - const [inputSelection, setInputSelection] = useState() - const [outputSelection, setOutputSelection] = useState() - - const selection = useMemo( - () => ({ - [CurrencyField.INPUT]: inputSelection, - [CurrencyField.OUTPUT]: outputSelection, - }), - [inputSelection, outputSelection] - ) - const resetSelection = useCallback( - (start: number, end?: number) => { - if (focusOnCurrencyField === CurrencyField.INPUT) { - setInputSelection({ start, end: end ?? start }) - } else if (focusOnCurrencyField === CurrencyField.OUTPUT) { - setOutputSelection({ start, end: end ?? start }) - } - }, - [focusOnCurrencyField] - ) - - const [showInverseRate, setShowInverseRate] = useState(false) - const price = trade.trade?.executionPrice - const rateUnitPrice = useUSDCPrice(showInverseRate ? price?.quoteCurrency : price?.baseCurrency) - const rateUnitPriceFormatted = convertFiatAmountFormatted( - rateUnitPrice?.toSignificant(), - NumberType.FiatTokenPrice - ) - const showRate = !swapWarning && (trade.trade || swapDataRefreshing) - - const derivedCurrencyField = - exactCurrencyField === CurrencyField.INPUT ? CurrencyField.OUTPUT : CurrencyField.INPUT - // Swap input requires numeric values, not localized ones - const formattedDerivedValue = formatCurrencyAmount({ - amount: currencyAmounts[derivedCurrencyField], - locale: 'en-US', - type: NumberType.SwapTradeAmount, - placeholder: '', - }) - - const { showNativeKeyboard, onDecimalPadLayout, isLayoutPending, onInputPanelLayout } = - useShouldShowNativeKeyboard() - - const SWAP_DIRECTION_BUTTON_SIZE = iconSizes.icon20 - const SWAP_DIRECTION_BUTTON_INNER_PADDING = spacing.spacing8 + spacing.spacing2 - const SWAP_DIRECTION_BUTTON_BORDER_WIDTH = spacing.spacing4 - - useSwapAnalytics(derivedSwapInfo) - const SwapWarningIcon = swapWarning?.icon ?? Icons.AlertTriangle - - const setValue = useCallback( - (value: string): void => { - if (!focusOnCurrencyField) { - return - } - onSetExactAmount(focusOnCurrencyField, value) - }, - - [focusOnCurrencyField, onSetExactAmount] - ) - - const onInputSelectionChange = useCallback( - (start: number, end: number) => setInputSelection({ start, end }), - [] - ) - const onOutputSelectionChange = useCallback( - (start: number, end: number) => setOutputSelection({ start, end }), - [] - ) - - const onSetExactAmountInput = useCallback( - (value: string): void => onSetExactAmount(CurrencyField.INPUT, value), - [onSetExactAmount] - ) - - const onSetExactAmountOutput = useCallback( - (value: string): void => onSetExactAmount(CurrencyField.OUTPUT, value), - [onSetExactAmount] - ) - - const onShowTokenSelectorInput = useCallback( - (): void => onShowTokenSelector(CurrencyField.INPUT), - [onShowTokenSelector] - ) - - const onShowTokenSelectorOutput = useCallback( - (): void => onShowTokenSelector(CurrencyField.OUTPUT), - [onShowTokenSelector] - ) - - return ( - <> - {showWarningModal && swapWarning?.title && ( - - } - modalName={ModalName.SwapWarning} - severity={swapWarning.severity} - title={swapWarning.title} - onClose={(): void => setShowWarningModal(false)} - onConfirm={(): void => setShowWarningModal(false)} - /> - )} - - - - - - - - - - - - - - - - - - - - - {walletNeedsRestore && ( - - - - - {t('Restore your wallet to swap')} - - - - )} - - - {/* Render an empty flex when nothing else is shown in order to properly calculate the space available for the DecimalPad */} - {!swapWarning && !isBlocked && !showRate && } - - {swapWarning && !isBlocked && ( - - - - - - {trade.trade && isPriceImpactWarning(swapWarning) - ? getRateToDisplay(formatter, trade.trade, showInverseRate) - : swapWarning.title} - - {isPriceImpactWarning(swapWarning) && ( - - {rateUnitPrice && ` (${rateUnitPriceFormatted})`} - - )} - - - - )} - - {isBlocked && ( - - )} - - {showRate && !isBlocked && ( - setShowInverseRate(!showInverseRate)}> - - {swapDataRefreshing ? ( - - ) : ( - - )} - - - {trade.trade - ? getRateToDisplay(formatter, trade.trade, showInverseRate) - : t('Fetching price...')} - - - {rateUnitPrice && ` (${rateUnitPriceFormatted})`} - - - - - )} - - - - - {!showNativeKeyboard && ( - - )} - - - - - - - ) -} - -export const SwapForm = memo(_SwapForm) diff --git a/apps/mobile/src/features/transactions/swap/SwapReview.tsx b/apps/mobile/src/features/transactions/swap/SwapReview.tsx deleted file mode 100644 index 06a585b8e7a..00000000000 --- a/apps/mobile/src/features/transactions/swap/SwapReview.tsx +++ /dev/null @@ -1,331 +0,0 @@ -/* eslint-disable complexity */ -import { providers } from 'ethers' -import { notificationAsync } from 'expo-haptics' -import React, { useCallback, useState } from 'react' -import { useTranslation } from 'react-i18next' -import Trace from 'src/components/Trace/Trace' -import { useBiometricAppSettings, useBiometricPrompt } from 'src/features/biometrics/hooks' -import { NumberType } from 'utilities/src/format/types' -import { WarningModal } from 'wallet/src/components/modals/WarningModal/WarningModal' -import { GasFeeResult } from 'wallet/src/features/gas/types' -import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' -import { - useAcceptedTrade, - useSwapCallback, - useWrapCallback, -} from 'wallet/src/features/transactions/swap/hooks' -import { FeeOnTransferInfoModal } from 'wallet/src/features/transactions/swap/modals/FeeOnTransferInfoModal' -import { NetworkFeeInfoModal } from 'wallet/src/features/transactions/swap/modals/NetworkFeeInfoModal' -import { SlippageInfoModal } from 'wallet/src/features/transactions/swap/modals/SlippageInfoModal' -import { SwapFeeInfoModal } from 'wallet/src/features/transactions/swap/modals/SwapFeeInfoModal' -import { SwapDetails } from 'wallet/src/features/transactions/swap/SwapDetails' -import { DerivedSwapInfo } from 'wallet/src/features/transactions/swap/types' -import { - getActionElementName, - getActionName, - isWrapAction, -} from 'wallet/src/features/transactions/swap/utils' -import { TransactionDetails } from 'wallet/src/features/transactions/TransactionDetails/TransactionDetails' -import { TransactionReview } from 'wallet/src/features/transactions/TransactionReview/TransactionReview' -import { CurrencyField } from 'wallet/src/features/transactions/transactionState/types' -import { - Warning, - WarningAction, - WarningSeverity, -} from 'wallet/src/features/transactions/WarningModal/types' -import { AccountType } from 'wallet/src/features/wallet/accounts/types' -import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' -import { ModalName, SectionName } from 'wallet/src/telemetry/constants' - -interface SwapFormProps { - onNext: () => void - onPrev: () => void - derivedSwapInfo: DerivedSwapInfo - approveTxRequest?: providers.TransactionRequest - txRequest?: providers.TransactionRequest - gasFee: GasFeeResult - warnings: Warning[] - exactValue: string -} - -export function SwapReview({ - onNext, - onPrev, - derivedSwapInfo, - approveTxRequest, - txRequest, - gasFee, - warnings, - exactValue, -}: SwapFormProps): JSX.Element | null { - const { t } = useTranslation() - const { formatNumberOrString, formatCurrencyAmount } = useLocalizationContext() - const account = useActiveAccountWithThrow() - const [showWarningModal, setShowWarningModal] = useState(false) - const [showNetworkFeeInfoModal, setShowNetworkFeeInfoModal] = useState(false) - const [showSwapFeeInfoModal, setShowSwapFeeInfoModal] = useState(false) - const [noSwapFee, setNoSwapFee] = useState(false) - const [showSlippageModal, setShowSlippageModal] = useState(false) - const [showFOTInfoModal, setShowFOTInfoModal] = useState(false) - const [warningAcknowledged, setWarningAcknowledged] = useState(false) - const [shouldSubmitTx, setShouldSubmitTx] = useState(false) - - const { - chainId, - currencies, - currencyAmounts, - trade: { trade: trade }, - wrapType, - exactCurrencyField, - txId, - currencyAmountsUSDValue, - autoSlippageTolerance, - customSlippageTolerance, - } = derivedSwapInfo - - const outputCurrencyPricePerUnitExact = - currencyAmountsUSDValue[CurrencyField.OUTPUT] && currencyAmounts[CurrencyField.OUTPUT] - ? ( - parseFloat(currencyAmountsUSDValue[CurrencyField.OUTPUT].toExact()) / - parseFloat(currencyAmounts[CurrencyField.OUTPUT].toExact()) - ).toString() - : undefined - - const swapWarning = warnings.find((warning) => warning.severity >= WarningSeverity.Medium) - - const { onAcceptTrade, acceptedDerivedSwapInfo, newTradeRequiresAcceptance } = useAcceptedTrade({ - derivedSwapInfo, - }) - const acceptedTrade = acceptedDerivedSwapInfo?.trade.trade - - const noValidSwap = !isWrapAction(wrapType) && !trade - const blockingWarning = warnings.some( - (warning) => - warning.action === WarningAction.DisableSubmit || - warning.action === WarningAction.DisableReview - ) - - const { wrapCallback: onWrap } = useWrapCallback( - currencyAmounts[CurrencyField.INPUT], - wrapType, - onNext, - txRequest, - txId - ) - - const onSwap = useSwapCallback( - approveTxRequest, - txRequest, - gasFee, - trade, - currencyAmountsUSDValue[CurrencyField.INPUT], - currencyAmountsUSDValue[CurrencyField.OUTPUT], - !customSlippageTolerance, - onNext, - txId - ) - - const onSwapOrWrap = useCallback(() => { - return isWrapAction(wrapType) ? onWrap() : onSwap() - }, [onSwap, onWrap, wrapType]) - - const { trigger: biometricAuthAndTransfer } = useBiometricPrompt(onSwapOrWrap) - const { requiredForTransactions: biometricRequired } = useBiometricAppSettings() - - const onAuthAndSubmitTxn = useCallback(async () => { - if (biometricRequired) { - await biometricAuthAndTransfer() - } else { - onSwapOrWrap() - } - }, [biometricAuthAndTransfer, biometricRequired, onSwapOrWrap]) - - const onPress = useCallback(async () => { - if (swapWarning && !showWarningModal && !warningAcknowledged) { - setShouldSubmitTx(true) - setShowWarningModal(true) - return - } - await notificationAsync() - await onAuthAndSubmitTxn() - }, [swapWarning, showWarningModal, warningAcknowledged, onAuthAndSubmitTxn]) - - const onConfirmWarning = useCallback(async () => { - setWarningAcknowledged(true) - setShowWarningModal(false) - - if (shouldSubmitTx) { - await onAuthAndSubmitTxn() - } - }, [shouldSubmitTx, onAuthAndSubmitTxn]) - - const onCancelWarning = useCallback(() => { - setShowWarningModal(false) - setWarningAcknowledged(false) - setShouldSubmitTx(false) - }, []) - - const onShowWarning = useCallback(() => { - setShowWarningModal(true) - }, []) - - const onCloseWarning = useCallback(() => { - setShowWarningModal(false) - }, []) - - const onShowSlippageModal = useCallback(() => { - setShowSlippageModal(true) - }, []) - - const onCloseSlippageModal = useCallback(() => { - setShowSlippageModal(false) - }, []) - - const onShowFOTInfo = useCallback(() => { - setShowFOTInfoModal(true) - }, []) - - const onCloseFOTInfo = useCallback(() => { - setShowFOTInfoModal(false) - }, []) - - const onShowNetworkFeeInfo = useCallback(() => { - setShowNetworkFeeInfoModal(true) - }, []) - - const onShowSwapFeeInfo = useCallback((noFee: boolean) => { - setShowSwapFeeInfoModal(true) - setNoSwapFee(noFee) - }, []) - - const onCloseNetworkFeeInfo = useCallback(() => { - setShowNetworkFeeInfoModal(false) - }, []) - - const onCloseSwapFeeInfo = useCallback(() => { - setShowSwapFeeInfoModal(false) - }, []) - - const actionButtonDisabled = - noValidSwap || - blockingWarning || - newTradeRequiresAcceptance || - !gasFee.value || - !!gasFee.error || - !txRequest || - account.type === AccountType.Readonly - - const actionButtonProps = { - disabled: actionButtonDisabled, - label: getActionName(t, wrapType), - name: getActionElementName(wrapType), - onPress, - } - - const getTransactionDetails = (): JSX.Element | null => { - if (isWrapAction(wrapType)) { - return ( - - ) - } - - if (!acceptedTrade || !trade) { - return null - } - - return ( - - ) - } - - const currencyInInfo = currencies[CurrencyField.INPUT] - const currencyOutInfo = currencies[CurrencyField.OUTPUT] - - if ( - !currencyInInfo || - !currencyOutInfo || - !currencyAmounts[CurrencyField.INPUT] || - !currencyAmounts[CurrencyField.OUTPUT] - ) { - return null - } - - const derivedCurrencyField = - exactCurrencyField === CurrencyField.INPUT ? CurrencyField.OUTPUT : CurrencyField.INPUT - const derivedAmount = formatCurrencyAmount({ - value: currencyAmounts[derivedCurrencyField], - type: NumberType.TokenTx, - }) - const formattedExactValue = formatNumberOrString({ - value: exactValue, - type: NumberType.TokenTx, - }) - const [amountIn, amountOut] = - exactCurrencyField === CurrencyField.INPUT - ? [formattedExactValue, derivedAmount] - : [derivedAmount, formattedExactValue] - - return ( - <> - {showWarningModal && swapWarning?.title && ( - - )} - {showSlippageModal && acceptedTrade && ( - - )} - {showFOTInfoModal && } - {showNetworkFeeInfoModal && } - {showSwapFeeInfoModal && } - - - - - ) -} diff --git a/apps/mobile/src/features/transactions/swap/SwapStatus.tsx b/apps/mobile/src/features/transactions/swap/SwapStatus.tsx deleted file mode 100644 index 9a33c13e367..00000000000 --- a/apps/mobile/src/features/transactions/swap/SwapStatus.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import { TradeType } from '@uniswap/sdk-core' -import React, { useMemo } from 'react' -import { useTranslation } from 'react-i18next' -import { TransactionPending } from 'src/features/transactions/TransactionPending/TransactionPending' -import { AppTFunction } from 'ui/src/i18n/types' -import { ChainId } from 'wallet/src/constants/chains' -import { toSupportedChainId } from 'wallet/src/features/chains/utils' -import { - LocalizationContextState, - useLocalizationContext, -} from 'wallet/src/features/language/LocalizationContext' -import { getAmountsFromTrade } from 'wallet/src/features/transactions/getAmountsFromTrade' -import { useSelectTransaction } from 'wallet/src/features/transactions/hooks' -import { DerivedSwapInfo } from 'wallet/src/features/transactions/swap/types' -import { CurrencyField } from 'wallet/src/features/transactions/transactionState/types' -import { - isConfirmedSwapTypeInfo, - TransactionDetails, - TransactionStatus, - TransactionType, - WrapType, -} from 'wallet/src/features/transactions/types' -import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' -import { getFormattedCurrencyAmount, getSymbolDisplayText } from 'wallet/src/utils/currency' - -type SwapStatusProps = { - derivedSwapInfo: DerivedSwapInfo - onNext: () => void - onTryAgain: () => void -} - -type SwapStatusText = { - title: string - description: string -} - -const getTextFromTxStatus = ( - t: AppTFunction, - derivedSwapInfo: DerivedSwapInfo, - formatter: LocalizationContextState, - transactionDetails?: TransactionDetails -): SwapStatusText => { - if (derivedSwapInfo.wrapType === WrapType.NotApplicable) { - return getTextFromSwapStatus(t, derivedSwapInfo, formatter, transactionDetails) - } - - return getTextFromWrapStatus(t, derivedSwapInfo, formatter, transactionDetails) -} - -const getTextFromWrapStatus = ( - t: AppTFunction, - derivedSwapInfo: DerivedSwapInfo, - formatter: LocalizationContextState, - transactionDetails?: TransactionDetails -): SwapStatusText => { - const { wrapType } = derivedSwapInfo - - // transactionDetails may not been added to the store yet - if (!transactionDetails || transactionDetails.status === TransactionStatus.Pending) { - if (wrapType === WrapType.Unwrap) { - return { - title: t('Unwrap pending'), - description: t('We’ll notify you once your unwrap is complete.'), - } - } - - return { - title: t('Wrap pending'), - description: t('We’ll notify you once your wrap is complete.'), - } - } - - if (transactionDetails.typeInfo.type !== TransactionType.Wrap) { - throw new Error('input to getTextFromWrapStatus must be a wrap transaction type') - } - - const status = transactionDetails.status - if (status === TransactionStatus.Success) { - const { typeInfo } = transactionDetails - const { currencies } = derivedSwapInfo - - // input and output amounts are the same for wraps/unwraps - const inputAmount = getFormattedCurrencyAmount( - currencies[CurrencyField.INPUT]?.currency, - typeInfo.currencyAmountRaw, - formatter - ) - - if (wrapType === WrapType.Unwrap) { - return { - title: t('Unwrap successful!'), - description: t( - 'You unwrapped {{ inputAmount }}{{ inputCurrency }} for {{ inputAmount }}{{ outputCurrency }}.', - { - inputAmount, - inputCurrency: currencies[CurrencyField.INPUT]?.currency.symbol, - outputCurrency: currencies[CurrencyField.OUTPUT]?.currency.symbol, - } - ), - } - } - - return { - title: t('Wrap successful!'), - description: t( - 'You wrapped {{ inputAmount }}{{ inputCurrency }} for {{ inputAmount }}{{ outputCurrency }}.', - { - inputAmount, - inputCurrency: currencies[CurrencyField.INPUT]?.currency.symbol, - outputCurrency: currencies[CurrencyField.OUTPUT]?.currency.symbol, - } - ), - } - } - - if (status === TransactionStatus.Failed) { - if (wrapType === WrapType.Unwrap) { - return { - title: t('Unwrap failed'), - description: t('Keep in mind that the network fee is still charged for failed unwraps.'), - } - } - - return { - title: t('Wrap failed'), - description: t('Keep in mind that the network fee is still charged for failed wraps.'), - } - } - - throw new Error('wrap transaction status is in an unhandled state') -} - -const getTextFromSwapStatus = ( - t: AppTFunction, - derivedSwapInfo: DerivedSwapInfo, - formatter: LocalizationContextState, - transactionDetails?: TransactionDetails -): SwapStatusText => { - // transactionDetails may not been added to the store yet - if (!transactionDetails || transactionDetails.status === TransactionStatus.Pending) { - return { - title: t('Swap pending'), - description: t('We’ll notify you once your swap is complete.'), - } - } - - if (transactionDetails.typeInfo.type !== TransactionType.Swap) { - throw new Error('input to getTextFromSwapStatus must be a swap transaction type') - } - - const status = transactionDetails.status - - if (status === TransactionStatus.Success) { - const { typeInfo } = transactionDetails - const { currencies } = derivedSwapInfo - const { inputCurrencyAmountRaw, outputCurrencyAmountRaw } = getAmountsFromTrade(typeInfo) - - const inputCurrency = currencies[CurrencyField.INPUT] - const outputCurrency = currencies[CurrencyField.OUTPUT] - - const inputAmount = getFormattedCurrencyAmount( - inputCurrency?.currency, - inputCurrencyAmountRaw, - formatter, - isConfirmedSwapTypeInfo(typeInfo) ? false : typeInfo.tradeType === TradeType.EXACT_OUTPUT - ) - - const outputAmount = getFormattedCurrencyAmount( - outputCurrency?.currency, - outputCurrencyAmountRaw, - formatter, - isConfirmedSwapTypeInfo(typeInfo) ? false : typeInfo.tradeType === TradeType.EXACT_INPUT - ) - - return { - title: t('Swap successful!'), - description: t( - 'You swapped {{ inputAmount }}{{ inputCurrency }} for {{ outputAmount }}{{ outputCurrency }}.', - { - inputAmount, - inputCurrency: getSymbolDisplayText(inputCurrency?.currency.symbol), - outputAmount, - outputCurrency: getSymbolDisplayText(outputCurrency?.currency.symbol), - } - ), - } - } - - if (status === TransactionStatus.Failed) { - return { - title: t('Swap failed'), - description: t('Keep in mind that the network fee is still charged for failed swaps.'), - } - } - - throw new Error('swap transaction status is in an unhandled state') -} - -export function SwapStatus({ derivedSwapInfo, onNext, onTryAgain }: SwapStatusProps): JSX.Element { - const { t } = useTranslation() - const { txId, currencies } = derivedSwapInfo - const chainId = - toSupportedChainId(currencies[CurrencyField.INPUT]?.currency.chainId) ?? ChainId.Mainnet - const activeAddress = useActiveAccountAddressWithThrow() - const transaction = useSelectTransaction(activeAddress, chainId, txId) - const formatter = useLocalizationContext() - - const { title, description } = useMemo(() => { - return getTextFromTxStatus(t, derivedSwapInfo, formatter, transaction) - }, [t, transaction, formatter, derivedSwapInfo]) - - return ( - - ) -} diff --git a/apps/mobile/src/features/transactions/swapRewrite/hooks/useOnCloseSendModal.tsx b/apps/mobile/src/features/transactions/swap/hooks/useOnCloseSendModal.tsx similarity index 100% rename from apps/mobile/src/features/transactions/swapRewrite/hooks/useOnCloseSendModal.tsx rename to apps/mobile/src/features/transactions/swap/hooks/useOnCloseSendModal.tsx diff --git a/apps/mobile/src/features/transactions/swapRewrite/utils.ts b/apps/mobile/src/features/transactions/swap/utils.ts similarity index 100% rename from apps/mobile/src/features/transactions/swapRewrite/utils.ts rename to apps/mobile/src/features/transactions/swap/utils.ts diff --git a/apps/mobile/src/features/transactions/transfer/TransferFlow.tsx b/apps/mobile/src/features/transactions/transfer/TransferFlow.tsx index 3d1098211cb..884d587521f 100644 --- a/apps/mobile/src/features/transactions/transfer/TransferFlow.tsx +++ b/apps/mobile/src/features/transactions/transfer/TransferFlow.tsx @@ -1,15 +1,30 @@ import { providers } from 'ethers' -import React, { useMemo, useReducer, useState } from 'react' +import { default as React, useCallback, useEffect, useMemo, useReducer, useState } from 'react' import { useTranslation } from 'react-i18next' +import { TouchableWithoutFeedback } from 'react-native' +import { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated' +import { useShouldShowNativeKeyboard } from 'src/app/hooks' import { RecipientSelect } from 'src/components/RecipientSelect/RecipientSelect' +import Trace from 'src/components/Trace/Trace' +import { Screen } from 'src/components/layout/Screen' +import { useBiometricAppSettings, useBiometricPrompt } from 'src/features/biometrics/hooks' import { useOnSendEmptyActionPress } from 'src/features/transactions/hooks/useOnSendEmptyActionPress' -import { TransactionFlow } from 'src/features/transactions/TransactionFlow' +import { TransferHeader } from 'src/features/transactions/transfer/TransferHeader' +import { TransferStatus } from 'src/features/transactions/transfer/TransferStatus' +import { useWalletRestore } from 'src/features/wallet/hooks' +import { AnimatedFlex, Flex, useDeviceDimensions, useDeviceInsets, useSporeColors } from 'ui/src' +import EyeIcon from 'ui/src/assets/icons/eye.svg' +import { iconSizes } from 'ui/src/theme' import { TokenSelectorModal, TokenSelectorVariation, } from 'wallet/src/components/TokenSelector/TokenSelector' +import { useBottomSheetContext } from 'wallet/src/components/modals/BottomSheetContext' +import { HandleBar } from 'wallet/src/components/modals/HandleBar' +import { WarningModal } from 'wallet/src/components/modals/WarningModal/WarningModal' import { useTransactionGasFee } from 'wallet/src/features/gas/hooks' -import { GasSpeed } from 'wallet/src/features/gas/types' +import { GasFeeResult, GasSpeed } from 'wallet/src/features/gas/types' +import { WarningAction, WarningSeverity } from 'wallet/src/features/transactions/WarningModal/types' import { useTokenSelectorActionHandlers } from 'wallet/src/features/transactions/hooks/useTokenSelectorActionHandlers' import { useTransactionGasWarning } from 'wallet/src/features/transactions/hooks/useTransactionGasWarning' import { @@ -20,14 +35,25 @@ import { CurrencyField, TransactionState, } from 'wallet/src/features/transactions/transactionState/types' +import { TransferReview } from 'wallet/src/features/transactions/transfer/TransferReview' +import { TransferTokenForm } from 'wallet/src/features/transactions/transfer/TransferTokenForm' import { useDerivedTransferInfo } from 'wallet/src/features/transactions/transfer/hooks/useDerivedTransferInfo' import { useOnSelectRecipient } from 'wallet/src/features/transactions/transfer/hooks/useOnSelectRecipient' import { useOnToggleShowRecipientSelector } from 'wallet/src/features/transactions/transfer/hooks/useOnToggleShowRecipientSelector' +import { + useTransferERC20Callback, + useTransferNFTCallback, +} from 'wallet/src/features/transactions/transfer/hooks/useTransferCallback' import { useTransferTransactionRequest } from 'wallet/src/features/transactions/transfer/hooks/useTransferTransactionRequest' import { useTransferWarnings } from 'wallet/src/features/transactions/transfer/hooks/useTransferWarnings' -import { TokenSelectorFlow } from 'wallet/src/features/transactions/transfer/types' -import { TransactionStep } from 'wallet/src/features/transactions/types' -import { WarningAction } from 'wallet/src/features/transactions/WarningModal/types' +import { + DerivedTransferInfo, + TokenSelectorFlow, +} from 'wallet/src/features/transactions/transfer/types' +import { TransactionStep, TransferFlowProps } from 'wallet/src/features/transactions/types' +import { ANIMATE_SPRING_CONFIG } from 'wallet/src/features/transactions/utils' +import { ModalName, SectionName } from 'wallet/src/telemetry/constants' +import { currencyAddress } from 'wallet/src/utils/currencyId' interface TransferFormProps { prefilledState?: TransactionState @@ -35,13 +61,23 @@ interface TransferFormProps { } export function TransferFlow({ prefilledState, onClose }: TransferFormProps): JSX.Element { - const [state, dispatch] = useReducer(transactionStateReducer, prefilledState || emptyState) + const insets = useDeviceInsets() + const colors = useSporeColors() const { t } = useTranslation() - const onSelectRecipient = useOnSelectRecipient(dispatch) - const onToggleShowRecipientSelector = useOnToggleShowRecipientSelector(dispatch) + const { fullWidth } = useDeviceDimensions() + const { isSheetReady } = useBottomSheetContext() + + const [state, dispatch] = useReducer(transactionStateReducer, prefilledState || emptyState) const derivedTransferInfo = useDerivedTransferInfo(state) - const { isFiatInput, exactAmountToken, exactAmountFiat } = derivedTransferInfo + const [showViewOnlyModal, setShowViewOnlyModal] = useState(false) const [step, setStep] = useState(TransactionStep.FORM) + + const { isFiatInput, exactAmountToken, exactAmountFiat } = derivedTransferInfo + const { showRecipientSelector } = state + + const onSelectRecipient = useOnSelectRecipient(dispatch) + const onToggleShowRecipientSelector = useOnToggleShowRecipientSelector(dispatch) + const txRequest = useTransferTransactionRequest(derivedTransferInfo) const warnings = useTransferWarnings(t, derivedTransferInfo) const gasFee = useTransactionGasFee( @@ -73,30 +109,101 @@ export function TransferFlow({ prefilledState, onClose }: TransferFormProps): JS ) const onSendEmptyActionPress = useOnSendEmptyActionPress() + // optimization for not rendering InnerContent initially, + // when modal is opened with recipient or token selector presented + const [renderInnerContentRouter, setRenderInnerContentRouter] = useState(!showRecipientSelector) + useEffect(() => { + setRenderInnerContentRouter(renderInnerContentRouter || !showRecipientSelector) + }, [renderInnerContentRouter, showRecipientSelector]) + + const screenXOffset = useSharedValue(showRecipientSelector ? -fullWidth : 0) + useEffect(() => { + const screenOffset = showRecipientSelector ? 1 : 0 + screenXOffset.value = withSpring(-(fullWidth * screenOffset), ANIMATE_SPRING_CONFIG) + }, [screenXOffset, showRecipientSelector, fullWidth]) + + const wrapperStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: screenXOffset.value }], + })) + + const onFormNext = useCallback(() => setStep(TransactionStep.REVIEW), [setStep]) + const onReviewNext = useCallback(() => setStep(TransactionStep.SUBMITTED), [setStep]) + const onReviewPrev = useCallback(() => setStep(TransactionStep.FORM), [setStep]) + const onRetrySubmit = useCallback(() => setStep(TransactionStep.FORM), [setStep]) + + const exactValue = isFiatInput ? exactAmountFiat : exactAmountToken + return ( <> - - } - setStep={setStep} - showRecipientSelector={state.showRecipientSelector} - step={step} - txRequest={transferTxWithGasSettings} - warnings={allWarnings} - onClose={onClose} - /> + + + + + {/* Padding bottom must have a similar size to the handlebar + height as 100% height doesn't include the handlebar height */} + + {step !== TransactionStep.SUBMITTED && ( + + )} + {renderInnerContentRouter && isSheetReady && ( + + )} + + + + {showRecipientSelector ? ( + + ) : null} + + + {showViewOnlyModal && ( + + } + modalName={ModalName.SwapWarning} + severity={WarningSeverity.Low} + title={t('This wallet is view-only')} + onClose={(): void => setShowViewOnlyModal(false)} + onConfirm={(): void => setShowViewOnlyModal(false)} + /> + )} + + + {!!state.selectingCurrencyField && ( ) } + +type TransferInnerContentProps = { + step: number + setStep: (step: TransactionStep) => void + showingSelectorScreen: boolean + gasFee: GasFeeResult + derivedTransferInfo: DerivedTransferInfo + onFormNext: () => void + onReviewNext: () => void + onReviewPrev: () => void + onRetrySubmit: () => void +} & Pick< + TransferFlowProps, + 'derivedInfo' | 'onClose' | 'dispatch' | 'gasFee' | 'txRequest' | 'warnings' | 'exactValue' +> + +function TransferInnerContent({ + showingSelectorScreen, + derivedTransferInfo, + onClose, + dispatch, + step, + gasFee, + txRequest, + warnings, + onFormNext, + onRetrySubmit, + onReviewNext, + onReviewPrev, +}: TransferInnerContentProps): JSX.Element | null { + // TODO: move this up in the tree to mobile specific flow + const { walletNeedsRestore, openWalletRestoreModal } = useWalletRestore() + const { showNativeKeyboard, onDecimalPadLayout, isLayoutPending, onInputPanelLayout } = + useShouldShowNativeKeyboard() + + const { currencyAmounts, recipient, currencyInInfo, nftIn, chainId, txId } = derivedTransferInfo + const transferERC20Callback = useTransferERC20Callback( + txId, + chainId, + recipient, + currencyInInfo ? currencyAddress(currencyInInfo.currency) : undefined, + currencyAmounts[CurrencyField.INPUT]?.quotient.toString(), + txRequest, + onReviewNext + ) + const transferNFTCallback = useTransferNFTCallback( + txId, + chainId, + recipient, + nftIn?.nftContract?.address, + nftIn?.tokenId, + txRequest, + onReviewNext + ) + + const onTransfer = (): void => { + onFormNext() + nftIn ? transferNFTCallback?.() : transferERC20Callback?.() + } + + const { trigger: biometricAuthAndTransfer } = useBiometricPrompt(onTransfer) + const { requiredForTransactions: biometricRequired } = useBiometricAppSettings() + + const onReviewSubmit = async (): Promise => { + if (biometricRequired) { + await biometricAuthAndTransfer() + } else { + onTransfer() + } + } + + switch (step) { + case TransactionStep.SUBMITTED: + return ( + + + + ) + case TransactionStep.FORM: + return ( + + + + ) + case TransactionStep.REVIEW: + return ( + + + + ) + default: + return null + } +} diff --git a/apps/mobile/src/features/transactions/TransactionFlowHeaderContent.tsx b/apps/mobile/src/features/transactions/transfer/TransferHeader.tsx similarity index 60% rename from apps/mobile/src/features/transactions/TransactionFlowHeaderContent.tsx rename to apps/mobile/src/features/transactions/transfer/TransferHeader.tsx index 30e2bd7ca7e..09dbe113fcd 100644 --- a/apps/mobile/src/features/transactions/TransactionFlowHeaderContent.tsx +++ b/apps/mobile/src/features/transactions/transfer/TransferHeader.tsx @@ -1,37 +1,27 @@ import React, { Dispatch, SetStateAction } from 'react' import { useTranslation } from 'react-i18next' -import { Keyboard } from 'react-native' import { Flex, Text, TouchableArea, useSporeColors } from 'ui/src' import EyeIcon from 'ui/src/assets/icons/eye.svg' -import SettingsIcon from 'ui/src/assets/icons/settings.svg' import { iconSizes } from 'ui/src/theme' import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks' -import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { useTokenFormActionHandlers } from 'wallet/src/features/transactions/hooks/useTokenFormActionHandlers' -import { TransactionFlowProps, TransactionStep } from 'wallet/src/features/transactions/types' +import { TransactionStep, TransferFlowProps } from 'wallet/src/features/transactions/types' import { AccountType } from 'wallet/src/features/wallet/accounts/types' import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' -import { ElementName } from 'wallet/src/telemetry/constants' type HeaderContentProps = Pick< - TransactionFlowProps, + TransferFlowProps, 'dispatch' | 'flowName' | 'step' | 'showFiatToggle' | 'isFiatInput' > & { - isSwap: boolean - customSlippageTolerance: number | undefined setShowViewOnlyModal: Dispatch> - setShowSettingsModal: Dispatch> } -export function HeaderContent({ +export function TransferHeader({ dispatch, - isSwap, - customSlippageTolerance, flowName, step, showFiatToggle, isFiatInput, - setShowSettingsModal, setShowViewOnlyModal, }: HeaderContentProps): JSX.Element { const colors = useSporeColors() @@ -39,12 +29,6 @@ export function HeaderContent({ const { t } = useTranslation() const { onToggleFiatInput } = useTokenFormActionHandlers(dispatch) const currency = useAppFiatCurrencyInfo() - const { formatPercent } = useLocalizationContext() - - const onPressSwapSettings = (): void => { - setShowSettingsModal(true) - Keyboard.dismiss() - } const isViewOnlyWallet = account?.type === AccountType.Readonly @@ -56,7 +40,7 @@ export function HeaderContent({ mt="$spacing8" pb="$spacing8" pl="$spacing12" - pr={customSlippageTolerance ? '$spacing8' : '$spacing16'}> + pr="$spacing16"> {flowName} @@ -64,7 +48,7 @@ export function HeaderContent({ {step === TransactionStep.FORM && showFiatToggle ? ( ) : null} - {step === TransactionStep.FORM && isSwap && !isViewOnlyWallet ? ( - - - {customSlippageTolerance ? ( - - {`${formatPercent(customSlippageTolerance)} ${t('slippage')}`} - - ) : null} - - - - ) : null} ) diff --git a/apps/mobile/src/features/transactions/swapRewrite/transfer/TransferFlow.tsx b/apps/mobile/src/features/transactions/transfer/transferRewrite/TransferFlow.tsx similarity index 92% rename from apps/mobile/src/features/transactions/swapRewrite/transfer/TransferFlow.tsx rename to apps/mobile/src/features/transactions/transfer/transferRewrite/TransferFlow.tsx index ecba034f200..e50720cd775 100644 --- a/apps/mobile/src/features/transactions/swapRewrite/transfer/TransferFlow.tsx +++ b/apps/mobile/src/features/transactions/transfer/transferRewrite/TransferFlow.tsx @@ -1,9 +1,9 @@ import { Dispatch, ReactNode, SetStateAction, useEffect, useMemo, useState } from 'react' import { useAppSelector } from 'src/app/hooks' import { selectModalState } from 'src/features/modals/selectModalState' -import { useOnCloseSendModal } from 'src/features/transactions/swapRewrite/hooks/useOnCloseSendModal' -import { TransferFormScreen } from 'src/features/transactions/swapRewrite/transfer/TransferFormScreen' -import { getFocusOnCurrencyFieldFromInitialState } from 'src/features/transactions/swapRewrite/utils' +import { useOnCloseSendModal } from 'src/features/transactions/swap/hooks/useOnCloseSendModal' +import { getFocusOnCurrencyFieldFromInitialState } from 'src/features/transactions/swap/utils' +import { TransferFormScreen } from 'src/features/transactions/transfer/transferRewrite/TransferFormScreen' import { useWalletRestore } from 'src/features/wallet/hooks' import { Trace } from 'utilities/src/telemetry/trace/Trace' import { @@ -18,6 +18,10 @@ import { import { TransactionModal } from 'wallet/src/features/transactions/swap/TransactionModal' import { ModalName, SectionName } from 'wallet/src/telemetry/constants' +/** + * @todo: The screens within this flow are not implemented. + * MOB-555 https://linear.app/uniswap/issue/MOB-555/implement-updated-send-flow + */ export function TransferFlow(): JSX.Element { // We need this additional `screen` state outside of the `SwapScreenContext` because the `TransferContextProvider` needs to be inside the `BottomSheetModal`'s `Container`. const [screen, setScreen] = useState(TransferScreen.TransferForm) diff --git a/apps/mobile/src/features/transactions/swapRewrite/transfer/TransferFormScreen.tsx b/apps/mobile/src/features/transactions/transfer/transferRewrite/TransferFormScreen.tsx similarity index 79% rename from apps/mobile/src/features/transactions/swapRewrite/transfer/TransferFormScreen.tsx rename to apps/mobile/src/features/transactions/transfer/transferRewrite/TransferFormScreen.tsx index af811294321..17e9456e859 100644 --- a/apps/mobile/src/features/transactions/swapRewrite/transfer/TransferFormScreen.tsx +++ b/apps/mobile/src/features/transactions/transfer/transferRewrite/TransferFormScreen.tsx @@ -5,14 +5,11 @@ import { useTransactionModalContext } from 'wallet/src/features/transactions/con import { TransactionModalInnerContainer } from 'wallet/src/features/transactions/swap/TransactionModal' export function TransferFormScreen({ hideContent }: { hideContent: boolean }): JSX.Element { - const { handleContentLayout, bottomSheetViewStyles } = useTransactionModalContext() + const { bottomSheetViewStyles } = useTransactionModalContext() const { selectingCurrencyField } = useSwapFormContext() return ( - + {!hideContent && !!selectingCurrencyField && } TODO: transfer form content diff --git a/apps/mobile/src/features/unitags/ChooseProfilePictureScreen.tsx b/apps/mobile/src/features/unitags/ChooseProfilePictureScreen.tsx index e8e8d2f13e4..2cb4040ca05 100644 --- a/apps/mobile/src/features/unitags/ChooseProfilePictureScreen.tsx +++ b/apps/mobile/src/features/unitags/ChooseProfilePictureScreen.tsx @@ -1,45 +1,31 @@ -import React, { useCallback, useState } from 'react' +import React, { useState } from 'react' import { useTranslation } from 'react-i18next' -import { getUniqueId } from 'react-native-device-info' +import { ActivityIndicator } from 'react-native' import { navigate } from 'src/app/navigation/rootNavigation' import { UnitagStackScreenProp } from 'src/app/navigation/types' +import { useAvatarSelectionHandler } from 'src/components/unitags/AvatarSelection' import { ChoosePhotoOptionsModal } from 'src/components/unitags/ChoosePhotoOptionsModal' import { UnitagProfilePicture } from 'src/components/unitags/UnitagProfilePicture' import { SafeKeyboardOnboardingScreen } from 'src/features/onboarding/SafeKeyboardOnboardingScreen' -import { isLocalFileUri, uploadAndUpdateAvatarAfterClaim } from 'src/features/unitags/avatars' import { OnboardingScreens, Screens, UnitagScreens } from 'src/screens/Screens' -import { AnimatedFlex, Button, Flex, Icons, Text } from 'ui/src' -import Unitag from 'ui/src/assets/graphics/unitag.svg' -import { iconSizes, imageSizes, spacing } from 'ui/src/theme' -import { logger } from 'utilities/src/logger/logger' -import { useAsyncData } from 'utilities/src/react/hooks' -import { pushNotification } from 'wallet/src/features/notifications/slice' -import { AppNotificationType } from 'wallet/src/features/notifications/types' +import { AnimatedFlex, Button, Flex, Icons, Text, useSporeColors } from 'ui/src' +import { fonts, iconSizes, imageSizes, spacing } from 'ui/src/theme' import { ImportType, OnboardingEntryPoint } from 'wallet/src/features/onboarding/types' -import { - useClaimUnitagMutation, - useUnitagUpdateMetadataMutation, -} from 'wallet/src/features/unitags/api' -import { parseUnitagErrorCode } from 'wallet/src/features/unitags/utils' -import { useActiveAccountAddress, usePendingAccounts } from 'wallet/src/features/wallet/hooks' -import { useAppDispatch } from 'wallet/src/state' +import { useClaimUnitag } from 'wallet/src/features/unitags/hooks' export function ChooseProfilePictureScreen({ route, }: UnitagStackScreenProp): JSX.Element { - const { entryPoint, unitag } = route.params - const activeAddress = useActiveAccountAddress() - const pendingAccountAddress = Object.values(usePendingAccounts())?.[0]?.address - const unitagAddress = activeAddress || pendingAccountAddress + const { entryPoint, unitag, address } = route.params const { t } = useTranslation() - const dispatch = useAppDispatch() + const colors = useSporeColors() + const claimUnitag = useClaimUnitag() + const [imageUri, setImageUri] = useState() const [showModal, setShowModal] = useState(false) const [claimError, setClaimError] = useState() - const [claimUnitag] = useClaimUnitagMutation() - const [updateUnitagMetadata] = useUnitagUpdateMetadataMutation(unitag) - const { data: deviceId } = useAsyncData(getUniqueId) + const [isClaiming, setIsClaiming] = useState(false) const openModal = (): void => { setShowModal(true) @@ -49,87 +35,55 @@ export function ChooseProfilePictureScreen({ setShowModal(false) } - const onPressContinue = async (): Promise => { - if (!deviceId) { - return // Should never hit this condition. Button is disabled if deviceId is undefined - } + const { avatarSelectionHandler, hasNFTs } = useAvatarSelectionHandler({ + address, + avatarImageUri: imageUri, + setAvatarImageUri: setImageUri, + showModal: openModal, + }) - // throw error if unitagAddress is falsey - if (!unitagAddress) { - const error = new Error('unitagAddress should never be null when claiming a unitag') - logger.error(error, { - tags: { file: 'ChooseProfilePictureScreen', function: 'onPressFinish' }, + const onPressContinue = async (): Promise => { + if (entryPoint === OnboardingScreens.Landing) { + // Handle case navigating from onboarding + navigate(Screens.OnboardingStack, { + screen: OnboardingScreens.WelcomeWallet, + params: { + importType: ImportType.CreateNew, + entryPoint: OnboardingEntryPoint.FreshInstallOrReplace, + unitagClaim: { + address, + username: unitag, + avatarUri: imageUri, + }, + }, }) - return + } else { + return attemptClaimUnitag() } + } - const { data: claimResponse } = await claimUnitag({ - address: unitagAddress, + const attemptClaimUnitag = async (): Promise => { + setIsClaiming(true) + const { claimError: attemptClaimError } = await claimUnitag({ + address, username: unitag, - deviceId, - metadata: { - avatar: imageUri && isLocalFileUri(imageUri) ? undefined : imageUri, - }, + avatarUri: imageUri, }) - if (claimResponse?.data.errorCode) { - setClaimError(parseUnitagErrorCode(t, unitag, claimResponse?.data.errorCode)) - return - } + setIsClaiming(false) + setClaimError(attemptClaimError) - if (claimResponse?.data.success) { - await onClaimSuccess() - return - } - } - - const onClaimSuccess = useCallback(async (): Promise => { - if (imageUri && isLocalFileUri(imageUri) && !!unitagAddress) { - // unitagAddress should always be defined here otherwise onPressContinue would've thrown an error - const { success: updateSuccess } = await uploadAndUpdateAvatarAfterClaim( - unitag, - unitagAddress, - imageUri, - updateUnitagMetadata - ) - if (!updateSuccess) { - dispatch( - pushNotification({ - type: AppNotificationType.Error, - errorMessage: t('Could not set avatar. Try again later.'), - }) - ) - } - } - - if (entryPoint === Screens.Home) { - if (!unitagAddress) { - const error = new Error( - 'unitagAddress should never be null when Unitag entryPoint is Home Screen' - ) - logger.error(error, { - tags: { file: 'ChooseProfilePictureScreen', function: 'onClaimSuccess' }, - }) - return - } + // Navigate to confirmation screen when a claim has been made + if (attemptClaimError === undefined) { navigate(Screens.UnitagStack, { screen: UnitagScreens.UnitagConfirmation, params: { unitag, - address: unitagAddress, + address, profilePictureUri: imageUri, }, }) - } else { - // entryPoint === OnboardingScreens.Landing - navigate(Screens.OnboardingStack, { - screen: OnboardingScreens.QRAnimation, - params: { - importType: ImportType.CreateNew, - entryPoint: OnboardingEntryPoint.FreshInstallOrReplace, - }, - }) } - }, [dispatch, entryPoint, imageUri, t, unitag, unitagAddress, updateUnitagMetadata]) + } return ( - - + + - + - - + right={-spacing.spacing4}> + + @@ -158,14 +116,13 @@ export function ChooseProfilePictureScreen({ row alignSelf="center" animation="lazy" - // eslint-disable-next-line react-native/no-inline-styles - enterStyle={{ o: 0 }} + enterStyle={{ opacity: 0 }} gap="$spacing20"> {unitag} - + {!!claimError && ( @@ -175,15 +132,22 @@ export function ChooseProfilePictureScreen({ )} {showModal && ( export function ClaimUnitagScreen({ navigation, route }: Props): JSX.Element { - const { entryPoint } = route.params + const { entryPoint, address } = route.params useAddBackButton(navigation) const { t } = useTranslation() const colors = useSporeColors() - const activeAddress = useActiveAccountAddress() + // In onboarding flow, delete pending accounts and create account actions happen right before navigation + // So pendingAccountAddress must be fetched in this component and can't be passed in params const pendingAccountAddress = Object.values(usePendingAccounts())?.[0]?.address - const unitagAddress = activeAddress || pendingAccountAddress + const unitagAddress = address || pendingAccountAddress const [showInfoModal, setShowInfoModal] = useState(false) const [showTextInputView, setShowTextInputView] = useState(true) const [unitagInputValue, setUnitagInputValue] = useState(undefined) + const [isCheckingUnitag, setIsCheckingUnitag] = useState(false) + const [unitagToCheck, setUnitagToCheck] = useState(undefined) const addressViewOpacity = useSharedValue(1) const unitagInputContainerTranslateY = useSharedValue(0) @@ -67,10 +76,8 @@ export function ClaimUnitagScreen({ navigation, route }: Props): JSX.Element { } }) - const debouncedInputValue = useDebounce(unitagInputValue, ONE_SECOND_MS) - const { unitagError, loading } = useUnitagError(unitagAddress, debouncedInputValue) - - const isUnitagValid = !unitagError && !loading && !!unitagInputValue + const { error: canClaimUnitagNameError, loading: loadingUnitagErrorCheck } = + useCanClaimUnitagName(unitagAddress, unitagToCheck) const { onLayout, fontSize, onSetFontSize } = useDynamicFontSizing( MAX_CHAR_PIXEL_WIDTH, @@ -80,15 +87,18 @@ export function ClaimUnitagScreen({ navigation, route }: Props): JSX.Element { useEffect(() => { const unsubscribe = navigation.addListener('focus', () => { + // Reset the Unitag to check + setUnitagToCheck(undefined) + // When returning back to this screen, handle animating the Unitag logo out and text input in if (showTextInputView) { return } unitagInputContainerTranslateY.value = withTiming( - unitagInputContainerTranslateY.value - imageSizes.image100 - spacing.spacing48, + unitagInputContainerTranslateY.value - UNITAG_NAME_ANIMATE_DISTANCE_Y, { - duration: 500, + duration: ONE_SECOND_MS / 2, } ) setTimeout(() => { @@ -112,7 +122,12 @@ export function ClaimUnitagScreen({ navigation, route }: Props): JSX.Element { return } - onSetFontSize(text) + if (text.length === 0) { + onSetFontSize(TEXT_INPUT_PLACEHOLDER + UNITAG_SUFFIX_CHARS_ONLY) + } else { + onSetFontSize(text + UNITAG_SUFFIX_CHARS_ONLY) + } + setUnitagInputValue(text?.trim()) }, [onSetFontSize, setUnitagInputValue] @@ -124,8 +139,9 @@ export function ClaimUnitagScreen({ navigation, route }: Props): JSX.Element { } const onPressMaybeLater = (): void => { + // Navigate to next screen if in onboarding navigate(Screens.OnboardingStack, { - screen: OnboardingScreens.EditName, + screen: OnboardingScreens.WelcomeWallet, params: { importType: ImportType.CreateNew, entryPoint: OnboardingEntryPoint.FreshInstallOrReplace, @@ -133,38 +149,77 @@ export function ClaimUnitagScreen({ navigation, route }: Props): JSX.Element { }) } - const onPressContinue = (): void => { - if (!unitagInputValue) { - return - } + const navigateWithAnimation = useCallback( + (unitag: string) => { + if (!unitagAddress) { + const err = new Error('unitagAddress should always be defined') + logger.error(err, { + tags: { file: 'ClaimUnitagScreen', function: 'navigateWithAnimation' }, + }) + throw err + } - // Animate the Unitag logo in and text input out - setShowTextInputView(false) - addressViewOpacity.value = withTiming(0, { duration: 500 }) - // Intentionally delay 1s to allow enter/exit animations to finish - unitagInputContainerTranslateY.value = withDelay( - ONE_SECOND_MS, - withTiming(unitagInputContainerTranslateY.value + imageSizes.image100 + spacing.spacing48, { - duration: 500, - }) - ) - // Navigate to ChooseProfilePicture screen after 1s delay to allow animations to finish - setTimeout(() => { - navigate( - entryPoint === OnboardingScreens.Landing ? Screens.OnboardingStack : Screens.UnitagStack, - { - screen: UnitagScreens.ChooseProfilePicture, - params: { entryPoint, unitag: unitagInputValue }, - } + // Animate the Unitag logo in and text input out + setShowTextInputView(false) + addressViewOpacity.value = withTiming(0, { duration: 500 }) + // Intentionally delay 1s to allow enter/exit animations to finish + unitagInputContainerTranslateY.value = withDelay( + ONE_SECOND_MS, + withTiming(unitagInputContainerTranslateY.value + UNITAG_NAME_ANIMATE_DISTANCE_Y, { + duration: ONE_SECOND_MS / 2, + }) ) - }, ONE_SECOND_MS) + // Navigate to ChooseProfilePicture screen after 1s delay to allow animations to finish + setTimeout(() => { + navigate( + entryPoint === OnboardingScreens.Landing ? Screens.OnboardingStack : Screens.UnitagStack, + { + screen: UnitagScreens.ChooseProfilePicture, + params: { unitag, entryPoint, address: unitagAddress }, + } + ) + }, ONE_SECOND_MS) + }, + [addressViewOpacity, entryPoint, unitagAddress, unitagInputContainerTranslateY] + ) + + // Handle when useUnitagError completes loading and returns a result after onPressContinue is called + useEffect(() => { + if (isCheckingUnitag && !!unitagToCheck && !loadingUnitagErrorCheck) { + setIsCheckingUnitag(false) + // If unitagError is defined, it's rendered in UI + if (!canClaimUnitagNameError) { + navigateWithAnimation(unitagToCheck) + } + } + }, [ + canClaimUnitagNameError, + loadingUnitagErrorCheck, + unitagToCheck, + isCheckingUnitag, + navigateWithAnimation, + ]) + + const onPressContinue = (): void => { + if (unitagInputValue !== unitagToCheck) { + setIsCheckingUnitag(true) + setUnitagToCheck(unitagInputValue) + } } + const title = entryPoint === Screens.Home ? t('Claim your username') : t('Choose your username') + return ( - + title={title}> + { + onLayout(event) + onSetFontSize(TEXT_INPUT_PLACEHOLDER + UNITAG_SUFFIX_CHARS_ONLY) + }}> {/* Fixed text that animates in when TextInput is animated out */} @@ -193,14 +246,12 @@ export function ClaimUnitagScreen({ navigation, route }: Props): JSX.Element { - + )} @@ -210,16 +261,12 @@ export function ClaimUnitagScreen({ navigation, route }: Props): JSX.Element { key="input-container" row animation="quick" - // eslint-disable-next-line react-native/no-inline-styles - enterStyle={{ o: 0, x: 40 }} - // eslint-disable-next-line react-native/no-inline-styles - exitStyle={{ o: 0, x: 40 }} - gap="$none" - onLayout={onLayout}> + enterStyle={{ opacity: 0, x: 40 }} + exitStyle={{ opacity: 0, x: 40 }} + gap="$none"> - {!loading && unitagError && ( + {canClaimUnitagNameError && unitagToCheck === unitagInputValue && ( - - {unitagError} + + {canClaimUnitagNameError} )} @@ -288,27 +333,31 @@ export function ClaimUnitagScreen({ navigation, route }: Props): JSX.Element { )} - {showInfoModal && ( - setShowInfoModal(false)} - /> + setShowInfoModal(false)} /> )} ) } const InfoModal = ({ - unitag, unitagAddress, onClose, }: { - unitag: string | undefined unitagAddress: string | undefined onClose: () => void }): JSX.Element => { @@ -343,11 +392,10 @@ const InfoModal = ({ px="$spacing12" shadowColor="$neutral3" shadowOpacity={0.4} - shadowRadius="$spacing4" - textVariant="buttonLabel4"> - - {unitag ? unitag : 'yourname'} - + shadowRadius="$spacing4"> + + {TEXT_INPUT_PLACEHOLDER} + {UNITAG_SUFFIX} diff --git a/apps/mobile/src/features/unitags/ConfirmationElements.tsx b/apps/mobile/src/features/unitags/ConfirmationElements.tsx index c1a31913f90..b9695bfc0a7 100644 --- a/apps/mobile/src/features/unitags/ConfirmationElements.tsx +++ b/apps/mobile/src/features/unitags/ConfirmationElements.tsx @@ -8,7 +8,7 @@ import { Arrow } from 'wallet/src/components/icons/Arrow' export const FroggyElement = (): JSX.Element => { return ( @@ -24,7 +24,7 @@ export const FroggyElement = (): JSX.Element => { export const OpenseaElement = (): JSX.Element => { return ( - + { export const TextElement = ({ text }: { text: string }): JSX.Element => { return ( diff --git a/apps/mobile/src/features/unitags/EditUnitagProfileScreen.tsx b/apps/mobile/src/features/unitags/EditUnitagProfileScreen.tsx index 2c497c77403..1eb01974753 100644 --- a/apps/mobile/src/features/unitags/EditUnitagProfileScreen.tsx +++ b/apps/mobile/src/features/unitags/EditUnitagProfileScreen.tsx @@ -1,4 +1,3 @@ -import { isEqual } from 'lodash' import React, { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Keyboard, KeyboardAvoidingView, StyleSheet } from 'react-native' @@ -6,11 +5,12 @@ import ContextMenu from 'react-native-context-menu-view' import { UnitagStackScreenProp } from 'src/app/navigation/types' import { BackHeader } from 'src/components/layout/BackHeader' import { Screen } from 'src/components/layout/Screen' +import { useAvatarSelectionHandler } from 'src/components/unitags/AvatarSelection' +import { ChangeUnitagModal } from 'src/components/unitags/ChangeUnitagModal' import { ChoosePhotoOptionsModal } from 'src/components/unitags/ChoosePhotoOptionsModal' import { DeleteUnitagModal } from 'src/components/unitags/DeleteUnitagModal' import { UnitagProfilePicture } from 'src/components/unitags/UnitagProfilePicture' import { HeaderRadial } from 'src/features/externalProfile/ProfileHeader' -import { tryUploadAvatar } from 'src/features/unitags/avatars' import { Screens, UnitagScreens } from 'src/screens/Screens' import { Button, @@ -23,18 +23,27 @@ import { useUniconColors, } from 'ui/src' import { borderRadii, fonts, iconSizes, imageSizes, spacing } from 'ui/src/theme' +import { logger } from 'utilities/src/logger/logger' +import { normalizeTwitterUsername } from 'utilities/src/primitives/string' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { DisplayNameText } from 'wallet/src/components/accounts/DisplayNameText' import { TextInput } from 'wallet/src/components/input/TextInput' import { ChainId } from 'wallet/src/constants/chains' import { useENS } from 'wallet/src/features/ens/useENS' import { pushNotification } from 'wallet/src/features/notifications/slice' import { AppNotificationType } from 'wallet/src/features/notifications/types' +import { getUnitagAvatarUploadUrl, updateUnitagMetadata } from 'wallet/src/features/unitags/api' +import { tryUploadAvatar } from 'wallet/src/features/unitags/avatars' +import { AVATAR_UPLOAD_CREDS_EXPIRY_SECONDS } from 'wallet/src/features/unitags/constants' +import { useUnitagUpdater } from 'wallet/src/features/unitags/context' +import { useUnitagByAddress } from 'wallet/src/features/unitags/hooks' import { - useUnitagGetAvatarUploadUrlQuery, - useUnitagUpdateMetadataMutation, -} from 'wallet/src/features/unitags/api' -import { UNITAG_SUFFIX } from 'wallet/src/features/unitags/constants' -import { useUnitag } from 'wallet/src/features/unitags/hooks' -import { ProfileMetadata } from 'wallet/src/features/unitags/types' + ProfileMetadata, + UnitagGetAvatarUploadUrlResponse, +} from 'wallet/src/features/unitags/types' +import { useWalletSigners } from 'wallet/src/features/wallet/context' +import { useAccount } from 'wallet/src/features/wallet/hooks' +import { DisplayNameType } from 'wallet/src/features/wallet/types' import { useAppDispatch } from 'wallet/src/state' import { shortenAddress } from 'wallet/src/utils/addresses' import { useExtractedColors } from 'wallet/src/utils/colors' @@ -47,7 +56,23 @@ const isProfileMetadataEdited = ( updatedMetadata: ProfileMetadata, initialMetadata?: ProfileMetadata ): boolean => { - return !loading && !isEqual(updatedMetadata, initialMetadata) + return ( + !loading && + (isFieldEdited(initialMetadata?.avatar, updatedMetadata.avatar) || + isFieldEdited(initialMetadata?.description, updatedMetadata.description) || + isFieldEdited(initialMetadata?.twitter, updatedMetadata.twitter)) + ) +} + +function isFieldEdited(a: string | undefined, b: string | undefined): boolean { + const aNonValue = a === undefined || a === '' + const bNonValue = b === undefined || b === '' + + if (aNonValue && bNonValue) { + return false + } else { + return a !== b + } } export function EditUnitagProfileScreen({ @@ -57,48 +82,88 @@ export function EditUnitagProfileScreen({ const { t } = useTranslation() const colors = useSporeColors() const dispatch = useAppDispatch() + const account = useAccount(address) + const signerManager = useWalletSigners() const { name: ensName } = useENS(ChainId.Mainnet, address) - const { unitag: retrievedUnitag, loading } = useUnitag(address) + const { triggerRefetchUnitags } = useUnitagUpdater() + const { unitag: retrievedUnitag, loading } = useUnitagByAddress(address) const unitagMetadata = retrievedUnitag?.metadata const [showAvatarModal, setShowAvatarModal] = useState(false) const [avatarImageUri, setAvatarImageUri] = useState() const [bioInput, setBioInput] = useState() - const [urlInput, setUrlInput] = useState() const [twitterInput, setTwitterInput] = useState() - const [showDeleteModal, setShowDeleteModal] = useState(false) + const [showDeleteUnitagModal, setShowDeleteUnitagModal] = useState(false) + const [showChangeUnitagModal, setShowChangeUnitagModal] = useState(false) + const [updateResponseLoading, setUpdateResponseLoading] = useState(false) + const [avatarUploadUrlLoading, setAvatarUploadUrlLoading] = useState(false) + const [avatarUploadUrlResponse, setAvatarUploadUrlResponse] = + useState() + + const onSetTwitterInput = (input: string): void => { + const normalizedInput = normalizeTwitterUsername(input) + setTwitterInput(normalizedInput) + } const updatedMetadata: ProfileMetadata = { - avatar: avatarImageUri, + ...(avatarImageUri ? { avatar: avatarImageUri } : {}), description: bioInput, - url: urlInput, twitter: twitterInput, } - const [ - updateUnitagMetadata, - { called: updateRequestMade, loading: updateResponseLoading, data: updateResponse }, - ] = useUnitagUpdateMetadataMutation(unitag) - - const { loading: avatarUploadUrlLoading, data: avatarUploadUrlResponse } = - useUnitagGetAvatarUploadUrlQuery({ username: retrievedUnitag?.username }) - const profileMetadataEdited = isProfileMetadataEdited( updateResponseLoading, updatedMetadata, - updateResponse?.metadata ?? unitagMetadata + unitagMetadata ) useEffect(() => { // Only want to set values on first time unitag loads, when we have not yet made the PUT request - if (!updateRequestMade && unitagMetadata) { + if (unitagMetadata) { setAvatarImageUri(unitagMetadata.avatar) setBioInput(unitagMetadata.description) - setUrlInput(unitagMetadata.url) setTwitterInput(unitagMetadata.twitter) + setUpdateResponseLoading(false) + } + }, [unitagMetadata]) + + // Re-fetch the avatar upload pre-signed URL every 110 seconds to ensure it's always fresh + useEffect(() => { + const fetchAvatarUploadUrl = async (): Promise => { + try { + setAvatarUploadUrlLoading(true) + const { data } = await getUnitagAvatarUploadUrl({ + username: unitag, // Assuming unitag is the username you're working with + account, + signerManager, + }) + setAvatarUploadUrlResponse(data) + } catch (e) { + logger.error(e, { + tags: { file: 'EditUnitagProfileScreen', function: 'fetchAvatarUploadUrl' }, + }) + } finally { + setAvatarUploadUrlLoading(false) + } } - }, [updateRequestMade, unitagMetadata]) + + // Call immediately on component mount + fetchAvatarUploadUrl().catch((e) => { + logger.error(e, { + tags: { file: 'EditUnitagProfileScreen', function: 'fetchAvatarUploadUrl' }, + }) + }) + + // Set up the interval to refetch creds 10 seconds before expiry + const intervalId = setInterval( + fetchAvatarUploadUrl, + (AVATAR_UPLOAD_CREDS_EXPIRY_SECONDS - 10) * ONE_SECOND_MS + ) + + // Clear the interval on component unmount + return () => clearInterval(intervalId) + }, [unitag, account, signerManager]) const { colors: avatarColors } = useExtractedColors(avatarImageUri) const { gradientStart: uniconGradientStart, gradientEnd: uniconGradientEnd } = @@ -123,54 +188,84 @@ export function EditUnitagProfileScreen({ setShowAvatarModal(false) } + const { avatarSelectionHandler, hasNFTs } = useAvatarSelectionHandler({ + address, + avatarImageUri, + setAvatarImageUri, + showModal: openAvatarModal, + }) + const onPressSaveChanges = async (): Promise => { Keyboard.dismiss() // Try to upload avatar or skip avatar upload if not needed - const { success, skipped } = await tryUploadAvatar({ - avatarImageUri, - avatarUploadUrlResponse, - avatarUploadUrlLoading, - }) + try { + const { success, skipped } = await tryUploadAvatar({ + avatarImageUri, + avatarUploadUrlResponse, + avatarUploadUrlLoading, + }) - // Display error if avatar upload failed - if (!success) { - displayErrorNotification() - return - } + // Display error if avatar upload failed + if (!success) { + handleUpdateError() + return + } - try { const uploadedNewAvatar = success && !skipped await updateProfileMetadata(uploadedNewAvatar) } catch (e) { - displayErrorNotification() + logger.error(e, { + tags: { file: 'EditUnitagProfileScreen', function: 'onPressSaveChanges' }, + }) + handleUpdateError() } } const updateProfileMetadata = async (uploadedNewAvatar: boolean): Promise => { // If new avatar was uploaded, update metadata.avatar to be the S3 file location const metadata = uploadedNewAvatar - ? { ...updatedMetadata, avatar: avatarUploadUrlResponse?.avatarUrl } + ? { + ...updatedMetadata, + // Add Date.now() to the end to ensure the resulting URL is not cached by devices + avatar: avatarUploadUrlResponse?.avatarUrl + ? avatarUploadUrlResponse.avatarUrl + `?${Date.now()}` + : undefined, + } : updatedMetadata - await updateUnitagMetadata({ address, metadata }) + setUpdateResponseLoading(true) + const { data: updateResponse } = await updateUnitagMetadata({ + username: unitag, + metadata, + clearAvatar: metadata.avatar === undefined, + account, + signerManager, + }) + + if (!updateResponse.success) { + handleUpdateError() + return + } + dispatch( pushNotification({ type: AppNotificationType.Success, title: t('Profile updated'), }) ) - + triggerRefetchUnitags() if (uploadedNewAvatar) { setAvatarImageUri(avatarUploadUrlResponse?.avatarUrl) } } - const displayErrorNotification = (): void => { + const handleUpdateError = (): void => { + setUpdateResponseLoading(false) dispatch( pushNotification({ type: AppNotificationType.Error, - errorMessage: t('Error updating profile. Please try again.'), + errorMessage: t('Could not update profile. Try again later.'), }) ) } @@ -187,6 +282,8 @@ export function EditUnitagProfileScreen({ {/* Necessary to handle different header configuration when navigating from SettingsStack vs. UnitagsStack */} {entryPoint === Screens.SettingsWallet ? ( @@ -197,24 +294,27 @@ export function EditUnitagProfileScreen({ dropdownMenuMode actions={menuActions} onPress={(e): void => { + Keyboard.dismiss() // Emitted index based on order of menu action array // Edit username if (e.nativeEvent.index === 0) { - return // TODO: implement change username + setShowChangeUnitagModal(true) } // Delete username if (e.nativeEvent.index === 1) { - setShowDeleteModal(true) + setShowDeleteUnitagModal(true) } }}> - + + + } p="$spacing16"> {t('Edit profile')} ) : ( - + {t('Edit profile')} @@ -226,7 +326,7 @@ export function EditUnitagProfileScreen({ {avatarImageUri && avatarColors?.primary ? ( - + ) : null} + onPress={avatarSelectionHandler}> - + - - - {unitag} - {UNITAG_SUFFIX} - + + {shortenAddress(address)} @@ -303,47 +403,29 @@ export function EditUnitagProfileScreen({ /> ) : null} - - - {t('Website')} - - {!loading ? ( - - ) : null} - {t('Twitter')} {!loading ? ( - + + @ + + ) : null} {ensName && ( @@ -371,17 +453,25 @@ export function EditUnitagProfileScreen({ {showAvatarModal && ( )} - {showDeleteModal && ( + {showDeleteUnitagModal && ( setShowDeleteModal(false)} + onClose={(): void => setShowDeleteUnitagModal(false)} + /> + )} + {showChangeUnitagModal && ( + setShowChangeUnitagModal(false)} /> )} diff --git a/apps/mobile/src/features/unitags/UnitagConfirmationScreen.tsx b/apps/mobile/src/features/unitags/UnitagConfirmationScreen.tsx index 8c045c62414..3dd16651ca2 100644 --- a/apps/mobile/src/features/unitags/UnitagConfirmationScreen.tsx +++ b/apps/mobile/src/features/unitags/UnitagConfirmationScreen.tsx @@ -1,10 +1,9 @@ -import { useHeaderHeight } from '@react-navigation/elements' import React, { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { navigate } from 'src/app/navigation/rootNavigation' import { UnitagStackScreenProp } from 'src/app/navigation/types' import { AnimateInOrder } from 'src/components/animation/AnimateInOrder' -import { Screen, SHORT_SCREEN_HEADER_HEIGHT_RATIO } from 'src/components/layout/Screen' +import { Screen } from 'src/components/layout/Screen' import { UnitagWithProfilePicture } from 'src/components/unitags/UnitagWithProfilePicture' import { EmojiElement, @@ -26,7 +25,6 @@ export function UnitagConfirmationScreen({ route, }: UnitagStackScreenProp): JSX.Element { const { unitag, address, profilePictureUri } = route.params - const headerHeight = useHeaderHeight() const dimensions = useDeviceDimensions() const insets = useDeviceInsets() const { t } = useTranslation() @@ -64,18 +62,13 @@ export function UnitagConfirmationScreen({ ) return ( - + ('') - const debouncedSearchQuery = useDebounce(searchQuery) + const debouncedSearchQuery = useDebounce(searchQuery).trim() const [isSearchMode, setIsSearchMode] = useState(false) const textInputRef = useRef(null) @@ -75,12 +75,11 @@ export function ExploreScreen(): JSX.Element { }, []) return ( - + function selectInitialQuote( - quotes: MeldQuote[] | undefined, - lastTransaction: MeldTransaction | undefined -): { quote: MeldQuote | undefined; type: InitialQuoteSelection | undefined } { + quotes: FORQuote[] | undefined, + lastTransaction: undefined +): { quote: FORQuote | undefined; type: InitialQuoteSelection | undefined } { if (lastTransaction) { // setting "Recently used" // TODO:https://linear.app/uniswap/issue/MOB-2533/implement-recently-used-logic @@ -55,7 +54,7 @@ function selectInitialQuote( const initialQuote = quotes && quotes.length && quotes[0] if (initialQuote) { return { - quote: quotes.reduce((prev, curr) => { + quote: quotes.reduce((prev, curr) => { return curr.destinationAmount > prev.destinationAmount ? curr : prev }, initialQuote), type: InitialQuoteSelection.Best, @@ -102,10 +101,10 @@ export function FiatOnRampScreen({ navigation }: Props): JSX.Element { useMeldFiatCurrencySupportInfo() const { - error: meldQuotesError, - loading: meldQuotesLoading, + error: quotesError, + loading: quotesLoading, quotes, - } = useMeldQuotes({ + } = useFiatOnRampQuotes({ baseCurrencyAmount: amount, baseCurrencyCode: meldSupportedFiatCurrency.code, quoteCurrencyCode: quoteCurrency.currencyInfo?.currency.symbol, @@ -113,12 +112,15 @@ export function FiatOnRampScreen({ navigation }: Props): JSX.Element { }) const { - currentData: serviceProviders, - isFetching: meldServiceProvidersLoading, - error: meldServiceProvidersError, + currentData: serviceProvidersResponse, + isFetching: serviceProvidersLoading, + error: serviceProvidersError, } = useFiatOnRampAggregatorServiceProvidersQuery() - const { errorText, errorColor } = useParseMeldError(meldQuotesError || meldServiceProvidersError) + const { errorText, errorColor } = useParseFiatOnRampError( + quotesError || serviceProvidersError, + meldSupportedFiatCurrency.code + ) const prevQuotes = usePrevious(quotes) useEffect(() => { @@ -137,18 +139,11 @@ export function FiatOnRampScreen({ navigation }: Props): JSX.Element { }, [prevQuotes, quotes, selectedQuote, setQuotesSections, setSelectedQuote, t]) useEffect(() => { - if (!quotes && (meldQuotesError || meldServiceProvidersError || !amount)) { + if (!quotes && (quotesError || serviceProvidersError || !amount)) { setQuotesSections(undefined) setSelectedQuote(undefined) } - }, [ - amount, - meldQuotesError, - meldServiceProvidersError, - quotes, - setQuotesSections, - setSelectedQuote, - ]) + }, [amount, quotesError, serviceProvidersError, quotes, setQuotesSections, setSelectedQuote]) const onSelectCountry: ComponentProps['onSelectCountry'] = ( country @@ -185,10 +180,10 @@ export function FiatOnRampScreen({ navigation }: Props): JSX.Element { // we only show loading when there are no errors and quote value is not empty const buttonDisabled = - meldServiceProvidersLoading || - !!meldServiceProvidersError || - meldQuotesLoading || - !!meldQuotesError || + serviceProvidersLoading || + !!serviceProvidersError || + quotesLoading || + !!quotesError || !selectedQuote?.destinationAmount const screenXOffset = useSharedValue(showTokenSelector ? -fullWidth : 0) @@ -201,9 +196,14 @@ export function FiatOnRampScreen({ navigation }: Props): JSX.Element { })) const onContinue = (): void => { - if (quotes && serviceProviders && quoteCurrency?.currencyInfo?.currency) { + if ( + quotes && + serviceProvidersResponse?.serviceProviders && + serviceProvidersResponse?.serviceProviders.length > 0 && + quoteCurrency?.currencyInfo?.currency + ) { setBaseCurrencyInfo(meldSupportedFiatCurrency) - setServiceProviders(serviceProviders) + setServiceProviders(serviceProvidersResponse.serviceProviders) navigation.navigate(FiatOnRampScreens.ServiceProviders) } } @@ -238,7 +238,7 @@ export function FiatOnRampScreen({ navigation }: Props): JSX.Element { inputRef={inputRef} quoteAmount={selectedQuote?.destinationAmount ?? 0} quoteCurrencyAmountReady={Boolean(amount && selectedQuote)} - selectTokenLoading={meldQuotesLoading} + selectTokenLoading={quotesLoading} setSelection={setSelection} showNativeKeyboard={showNativeKeyboard} showSoftInputOnFocus={showNativeKeyboard} diff --git a/apps/mobile/src/screens/FiatOnRampServiceProviders.tsx b/apps/mobile/src/screens/FiatOnRampServiceProviders.tsx index f7bc984b580..07e9c5dab00 100644 --- a/apps/mobile/src/screens/FiatOnRampServiceProviders.tsx +++ b/apps/mobile/src/screens/FiatOnRampServiceProviders.tsx @@ -9,20 +9,20 @@ import { BackButton } from 'src/components/buttons/BackButton' import { FORQuoteItem } from 'src/components/fiatOnRamp/QuoteItem' import { Screen } from 'src/components/layout/Screen' import { useFiatOnRampContext } from 'src/features/fiatOnRamp/FiatOnRampContext' -import { getServiceProviderForQuote } from 'src/features/fiatOnRamp/meldUtils' import { InitialQuoteSelection } from 'src/features/fiatOnRamp/types' +import { getServiceProviderForQuote } from 'src/features/fiatOnRamp/utils' import { MobileEventName } from 'src/features/telemetry/constants' import { FiatOnRampScreens } from 'src/screens/Screens' import { AnimatedFlex, Button, Flex, Icons, Inset, Separator, Text } from 'ui/src' import { Trace } from 'utilities/src/telemetry/trace/Trace' import { HandleBar } from 'wallet/src/components/modals/HandleBar' import { useBottomSheetFocusHook } from 'wallet/src/components/modals/hooks' -import { MeldQuote } from 'wallet/src/features/fiatOnRamp/meld' +import { FORQuote } from 'wallet/src/features/fiatOnRamp/types' import { ElementName } from 'wallet/src/telemetry/constants' type Props = NativeStackScreenProps -const key = (item: MeldQuote): string => item.serviceProvider +const key = (item: FORQuote): string => item.serviceProvider export function FiatOnRampServiceProvidersScreen({ navigation }: Props): JSX.Element { const { t } = useTranslation() @@ -35,7 +35,7 @@ export function FiatOnRampServiceProvidersScreen({ navigation }: Props): JSX.Ele serviceProviders, } = useFiatOnRampContext() - const renderItem = ({ item }: ListRenderItemInfo): JSX.Element => { + const renderItem = ({ item }: ListRenderItemInfo): JSX.Element => { return ( {baseCurrencyInfo && ( @@ -58,7 +58,7 @@ export function FiatOnRampServiceProvidersScreen({ navigation }: Props): JSX.Ele const renderSectionHeader = ({ section: { type }, }: { - section: SectionListData + section: SectionListData }): JSX.Element => { return ( diff --git a/apps/mobile/src/screens/HomeScreen.tsx b/apps/mobile/src/screens/HomeScreen.tsx index 01f11a4d07c..2268aa89bcb 100644 --- a/apps/mobile/src/screens/HomeScreen.tsx +++ b/apps/mobile/src/screens/HomeScreen.tsx @@ -8,9 +8,9 @@ import { useTranslation } from 'react-i18next' import { FlatList, StyleProp, View, ViewProps, ViewStyle } from 'react-native' import { TapGestureHandler, TapGestureHandlerGestureEvent } from 'react-native-gesture-handler' import Animated, { - cancelAnimation, FadeIn, FadeOut, + cancelAnimation, interpolateColor, runOnJS, useAnimatedGestureHandler, @@ -25,27 +25,26 @@ import { SceneRendererProps, TabBar } from 'react-native-tab-view' import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { NavBar, SWAP_BUTTON_HEIGHT } from 'src/app/navigation/NavBar' import { AppStackScreenProp } from 'src/app/navigation/types' +import { ScannerModalState } from 'src/components/QRCodeScanner/constants' +import Trace from 'src/components/Trace/Trace' +import TraceTabView from 'src/components/Trace/TraceTabView' import { AccountHeader } from 'src/components/accounts/AccountHeader' import { pulseAnimation } from 'src/components/buttons/utils' -import { ActivityTab, ACTIVITY_TAB_DATA_DEPENDENCIES } from 'src/components/home/ActivityTab' -import { FeedTab, FEED_TAB_DATA_DEPENDENCIES } from 'src/components/home/FeedTab' -import { NftsTab, NFTS_TAB_DATA_DEPENDENCIES } from 'src/components/home/NftsTab' -import { TokensTab, TOKENS_TAB_DATA_DEPENDENCIES } from 'src/components/home/TokensTab' +import { ACTIVITY_TAB_DATA_DEPENDENCIES, ActivityTab } from 'src/components/home/ActivityTab' +import { FEED_TAB_DATA_DEPENDENCIES, FeedTab } from 'src/components/home/FeedTab' +import { NFTS_TAB_DATA_DEPENDENCIES, NftsTab } from 'src/components/home/NftsTab' +import { TOKENS_TAB_DATA_DEPENDENCIES, TokensTab } from 'src/components/home/TokensTab' import { Screen } from 'src/components/layout/Screen' import { HeaderConfig, - renderTabLabel, ScrollPair, - TabContentProps, TAB_BAR_HEIGHT, TAB_STYLES, TAB_VIEW_SCROLL_THROTTLE, + TabContentProps, + renderTabLabel, useScrollSync, } from 'src/components/layout/TabHelpers' -import { ScannerModalState } from 'src/components/QRCodeScanner/constants' -import { TokenBalanceListRow } from 'src/components/TokenBalanceList/TokenBalanceListContext' -import Trace from 'src/components/Trace/Trace' -import TraceTabView from 'src/components/Trace/TraceTabView' import { UnitagBanner } from 'src/components/unitags/UnitagBanner' import { apolloClient } from 'src/data/usePersistedApolloClient' import { PortfolioBalance } from 'src/features/balances/PortfolioBalance' @@ -74,10 +73,13 @@ import SendIcon from 'ui/src/assets/icons/send-action.svg' import { iconSizes, spacing } from 'ui/src/theme' import { ONE_SECOND_MS } from 'utilities/src/time/time' import { useInterval, useTimeout } from 'utilities/src/time/timing' +import { selectHasSkippedUnitagPrompt } from 'wallet/src/features/behaviorHistory/selectors' import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants' import { useFeatureFlag } from 'wallet/src/features/experiments/hooks' import { useSelectAddressHasNotifications } from 'wallet/src/features/notifications/hooks' import { setNotificationStatus } from 'wallet/src/features/notifications/slice' +import { TokenBalanceListRow } from 'wallet/src/features/portfolio/TokenBalanceListContext' +import { useUnitagUpdater } from 'wallet/src/features/unitags/context' import { useCanActiveAddressClaimUnitag } from 'wallet/src/features/unitags/hooks' import { AccountType } from 'wallet/src/features/wallet/accounts/types' import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' @@ -109,6 +111,8 @@ export function HomeScreen(props?: AppStackScreenProp): JSX.Elemen const isModalOpen = useAppSelector(selectSomeModalOpen) const isHomeScreenBlur = !isFocused || isModalOpen + const hasSkippedUnitagPrompt = useAppSelector(selectHasSkippedUnitagPrompt) + const showFeedTab = useFeatureFlag(FEATURE_FLAGS.FeedTab) // opens the wallet restore modal if recovery phrase is missing after the app is opened useWalletRestore({ openModalImmediately: true }) @@ -314,13 +318,15 @@ export function HomeScreen(props?: AppStackScreenProp): JSX.Elemen ) }, [dispatch]) const onPressSend = useCallback(() => dispatch(openModal({ name: ModalName.Send })), [dispatch]) - const onPressReceive = useCallback( - () => + const onPressReceive = useCallback(() => { + if (forAggregatorEnabled) { + dispatch(openModal({ name: ModalName.ReceiveCryptoModal })) + } else { dispatch( openModal({ name: ModalName.WalletConnectScan, initialState: ScannerModalState.WalletQr }) - ), - [dispatch] - ) + ) + } + }, [dispatch, forAggregatorEnabled]) const onPressViewOnlyLabel = useCallback( () => dispatch(openModal({ name: ModalName.ViewOnlyExplainer })), [dispatch] @@ -380,11 +386,22 @@ export function HomeScreen(props?: AppStackScreenProp): JSX.Elemen ] ) - const hasClaimEligibility = useCanActiveAddressClaimUnitag() + const { refetchUnitagsCounter } = useUnitagUpdater() + const { canClaimUnitag, refetch: refetchCanActiveAddressClaimUnitag } = + useCanActiveAddressClaimUnitag() + + // Force refetch of canClaimUnitag if refetchUnitagsCounter changes + useEffect(() => { + refetchCanActiveAddressClaimUnitag?.() + }, [refetchUnitagsCounter, refetchCanActiveAddressClaimUnitag]) + + const shouldPromptUnitag = + activeAccount.type === AccountType.SignerMnemonic && !hasSkippedUnitagPrompt && canClaimUnitag + const viewOnlyLabel = t('This is a view-only wallet') const contentHeader = useMemo(() => { return ( - + @@ -393,16 +410,22 @@ export function HomeScreen(props?: AppStackScreenProp): JSX.Elemen ) : ( - + {viewOnlyLabel} )} - {hasClaimEligibility && ( + {shouldPromptUnitag && ( - + )} @@ -410,10 +433,10 @@ export function HomeScreen(props?: AppStackScreenProp): JSX.Elemen }, [ activeAccount.address, isSignerAccount, - viewOnlyLabel, actions, - hasClaimEligibility, onPressViewOnlyLabel, + viewOnlyLabel, + shouldPromptUnitag, ]) const contentContainerStyle = useMemo>( diff --git a/apps/mobile/src/screens/Import/SeedPhraseInputScreen.test.tsx b/apps/mobile/src/screens/Import/SeedPhraseInputScreen.test.tsx index ee21fad6eb8..37cb28d298e 100644 --- a/apps/mobile/src/screens/Import/SeedPhraseInputScreen.test.tsx +++ b/apps/mobile/src/screens/Import/SeedPhraseInputScreen.test.tsx @@ -23,7 +23,7 @@ const routeProp = { params: { importType: ImportType.CreateNew } } as RouteProp< > describe(SeedPhraseInputScreen, () => { - it('seed phrase initial screen rendering', async () => { + it.skip('seed phrase initial screen rendering', async () => { const tree = render() expect(tree.toJSON()).toMatchSnapshot() diff --git a/apps/mobile/src/screens/Import/WatchWalletScreen.tsx b/apps/mobile/src/screens/Import/WatchWalletScreen.tsx index 1be7e460ca8..0eaa2d62054 100644 --- a/apps/mobile/src/screens/Import/WatchWalletScreen.tsx +++ b/apps/mobile/src/screens/Import/WatchWalletScreen.tsx @@ -103,7 +103,7 @@ export function WatchWalletScreen({ navigation, route: { params } }: Props): JSX }) const isValidSmartContract = isSmartContractAddress && !!balancesById - const onCompleteOnboarding = useCompleteOnboardingCallback(params.entryPoint, params.importType) + const onCompleteOnboarding = useCompleteOnboardingCallback(params) // Form validation. const walletExists = diff --git a/apps/mobile/src/screens/Import/__snapshots__/RestoreCloudBackupPasswordScreen.test.tsx.snap b/apps/mobile/src/screens/Import/__snapshots__/RestoreCloudBackupPasswordScreen.test.tsx.snap index f3ce5e92181..b2323f692e4 100644 --- a/apps/mobile/src/screens/Import/__snapshots__/RestoreCloudBackupPasswordScreen.test.tsx.snap +++ b/apps/mobile/src/screens/Import/__snapshots__/RestoreCloudBackupPasswordScreen.test.tsx.snap @@ -5,7 +5,6 @@ exports[`RestoreCloudBackupPasswordScreen renders correctly 1`] = ` sentry-label="Screen" style={ { - "alignItems": "stretch", "backgroundColor": "#FFFFFF", "flex": 1, "flexDirection": "column", @@ -45,7 +44,6 @@ exports[`RestoreCloudBackupPasswordScreen renders correctly 1`] = ` exiting={[Function]} style={ { - "alignItems": "stretch", "flexDirection": "column", "flexGrow": 1, "gap": 16, @@ -106,7 +104,6 @@ exports[`RestoreCloudBackupPasswordScreen renders correctly 1`] = ` diff --git a/apps/mobile/src/screens/NFTItemScreen.tsx b/apps/mobile/src/screens/NFTItemScreen.tsx index 91af4af7ca1..5df34fa0f38 100644 --- a/apps/mobile/src/screens/NFTItemScreen.tsx +++ b/apps/mobile/src/screens/NFTItemScreen.tsx @@ -208,7 +208,7 @@ function NFTItemScreenContents({ imageUri={imageUrl} /> ) : ( - + )} {/* Content wrapper */} - + @@ -31,25 +31,34 @@ export function LandingScreen({ navigation }: Props): JSX.Element { const dispatch = useAppDispatch() const { t } = useTranslation() const isDarkMode = useIsDarkMode() - const canClaimUnitag = useCanAddressClaimUnitag() - const onPressCreateWallet = (): void => { + const unitagsFeatureFlagEnabled = useFeatureFlag(FEATURE_FLAGS.Unitags) + const { canClaimUnitag } = useCanAddressClaimUnitag() + + const onPressCreateWallet = useCallback((): void => { dispatch(pendingAccountActions.trigger(PendingAccountActions.Delete)) dispatch(createAccountActions.trigger()) - if (canClaimUnitag) { - navigate(Screens.OnboardingStack, { - screen: UnitagScreens.ClaimUnitag, - params: { + + if (unitagsFeatureFlagEnabled) { + if (canClaimUnitag) { + navigation.navigate(UnitagScreens.ClaimUnitag, { entryPoint: OnboardingScreens.Landing, - }, - }) + }) + } else { + // If can't claim, go direct to welcome screen + navigation.navigate(OnboardingScreens.WelcomeWallet, { + importType: ImportType.CreateNew, + entryPoint: OnboardingEntryPoint.FreshInstallOrReplace, + }) + } } else { + // use edit nickname screen still before launch of unitags navigation.navigate(OnboardingScreens.EditName, { importType: ImportType.CreateNew, entryPoint: OnboardingEntryPoint.FreshInstallOrReplace, }) } - } + }, [canClaimUnitag, dispatch, navigation, unitagsFeatureFlagEnabled]) const onPressImportWallet = (): void => { navigation.navigate(OnboardingScreens.ImportMethod, { @@ -64,7 +73,7 @@ export function LandingScreen({ navigation }: Props): JSX.Element { return ( // TODO(blocked by MOB-1082): delete bg prop // dark mode onboarding asset needs to be re-exported with #131313 (surface1) as background color - + @@ -106,25 +115,7 @@ export function LandingScreen({ navigation }: Props): JSX.Element { - - - By continuing, I agree to the{' '} - => openUri(uniswapUrls.termsOfServiceUrl)}> - Terms of Service - {' '} - and consent to the{' '} - => openUri(uniswapUrls.privacyPolicyUrl)}> - Privacy Policy - - . - - + diff --git a/apps/mobile/src/screens/Onboarding/NotificationsSetupScreen.tsx b/apps/mobile/src/screens/Onboarding/NotificationsSetupScreen.tsx index 87de4e84a56..4830c3cf7af 100644 --- a/apps/mobile/src/screens/Onboarding/NotificationsSetupScreen.tsx +++ b/apps/mobile/src/screens/Onboarding/NotificationsSetupScreen.tsx @@ -52,7 +52,7 @@ export function NotificationsSetupScreen({ navigation, route: { params } }: Prop const hasSeedPhrase = useNativeAccountExists() const { deviceSupportsBiometrics } = useBiometricContext() - const onCompleteOnboarding = useCompleteOnboardingCallback(params.entryPoint, params.importType) + const onCompleteOnboarding = useCompleteOnboardingCallback(params) const renderBackButton = useCallback( (nav: OnboardingScreens): JSX.Element => ( diff --git a/apps/mobile/src/screens/Onboarding/QRAnimation/QRAnimation.tsx b/apps/mobile/src/screens/Onboarding/QRAnimation/QRAnimation.tsx index ec8bf3515a5..7c443b6be6b 100644 --- a/apps/mobile/src/screens/Onboarding/QRAnimation/QRAnimation.tsx +++ b/apps/mobile/src/screens/Onboarding/QRAnimation/QRAnimation.tsx @@ -204,7 +204,7 @@ export function QRAnimation({ {t('Welcome to your new wallet')} {isNewWallet diff --git a/apps/mobile/src/screens/Onboarding/QRAnimationScreen.tsx b/apps/mobile/src/screens/Onboarding/QRAnimationScreen.tsx deleted file mode 100644 index b70de3ce56c..00000000000 --- a/apps/mobile/src/screens/Onboarding/QRAnimationScreen.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { CompositeScreenProps } from '@react-navigation/core' -import { NativeStackScreenProps } from '@react-navigation/native-stack' -import React from 'react' -import { AppStackParamList, OnboardingStackParamList } from 'src/app/navigation/types' -import { Screen } from 'src/components/layout/Screen' -import { QRAnimation } from 'src/screens/Onboarding/QRAnimation/QRAnimation' -import { OnboardingScreens, Screens } from 'src/screens/Screens' -import { ImportType } from 'wallet/src/features/onboarding/types' -import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' - -type Props = CompositeScreenProps< - NativeStackScreenProps, - NativeStackScreenProps -> - -export function QRAnimationScreen({ navigation, route: { params } }: Props): JSX.Element { - const activeAddress = useActiveAccountAddressWithThrow() - - const onPressNext = (): void => { - navigation.navigate({ - name: - params?.importType === ImportType.CreateNew - ? OnboardingScreens.Backup - : OnboardingScreens.Notifications, - merge: true, - params, - }) - } - - return ( - - - - ) -} diff --git a/apps/mobile/src/screens/Onboarding/SecuritySetupScreen.tsx b/apps/mobile/src/screens/Onboarding/SecuritySetupScreen.tsx index 1431060e7e1..ec7be471550 100644 --- a/apps/mobile/src/screens/Onboarding/SecuritySetupScreen.tsx +++ b/apps/mobile/src/screens/Onboarding/SecuritySetupScreen.tsx @@ -2,7 +2,7 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' import { BlurView } from 'expo-blur' import React, { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Alert, Image, Platform, StyleSheet } from 'react-native' +import { ActivityIndicator, Alert, Image, Platform, StyleSheet } from 'react-native' import { useAppDispatch } from 'src/app/hooks' import { OnboardingStackParamList } from 'src/app/navigation/types' import { BiometricAuthWarningModal } from 'src/components/Settings/BiometricAuthWarningModal' @@ -18,8 +18,8 @@ import { useDeviceSupportsBiometricAuth, } from 'src/features/biometrics/hooks' import { setRequiredForTransactions } from 'src/features/biometrics/slice' -import { useCompleteOnboardingCallback } from 'src/features/onboarding/hooks' import { OnboardingScreen } from 'src/features/onboarding/OnboardingScreen' +import { useCompleteOnboardingCallback } from 'src/features/onboarding/hooks' import { OnboardingScreens } from 'src/screens/Screens' import { Button, Flex, Text, TouchableArea, useIsDarkMode, useSporeColors } from 'ui/src' import { SECURITY_SCREEN_BACKGROUND_DARK, SECURITY_SCREEN_BACKGROUND_LIGHT } from 'ui/src/assets' @@ -40,16 +40,20 @@ export function SecuritySetupScreen({ route: { params } }: Props): JSX.Element { const dispatch = useAppDispatch() const isDarkMode = useIsDarkMode() + const [isLoadingAccount, setIsLoadingAccount] = useState(false) const [showWarningModal, setShowWarningModal] = useState(false) const { touchId: isTouchIdDevice } = useDeviceSupportsBiometricAuth() const authenticationTypeName = useBiometricName(isTouchIdDevice) - const onCompleteOnboarding = useCompleteOnboardingCallback(params.entryPoint, params.importType) + const onCompleteOnboarding = useCompleteOnboardingCallback(params) const onPressNext = useCallback(async () => { - setShowWarningModal(false) - await onCompleteOnboarding() - }, [onCompleteOnboarding]) + if (!isLoadingAccount) { + setShowWarningModal(false) + setIsLoadingAccount(true) + await onCompleteOnboarding() + } + }, [isLoadingAccount, onCompleteOnboarding]) const onMaybeLaterPressed = useCallback(async () => { if (params?.importType === ImportType.Watch) { @@ -103,6 +107,17 @@ export function SecuritySetupScreen({ route: { params } }: Props): JSX.Element { onConfirm={onPressNext} /> )} + {isLoadingAccount && ( + + + + )} + + By continuing, I agree to the{' '} + => openUri(uniswapUrls.termsOfServiceUrl)}> + Terms of Service + {' '} + and consent to the{' '} + => openUri(uniswapUrls.privacyPolicyUrl)}> + Privacy Policy + + . + + + ) +} diff --git a/apps/mobile/src/screens/Onboarding/WelcomeWalletScreen.tsx b/apps/mobile/src/screens/Onboarding/WelcomeWalletScreen.tsx new file mode 100644 index 00000000000..334e712dfa2 --- /dev/null +++ b/apps/mobile/src/screens/Onboarding/WelcomeWalletScreen.tsx @@ -0,0 +1,152 @@ +import { CompositeScreenProps } from '@react-navigation/core' +import { NativeStackScreenProps } from '@react-navigation/native-stack' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { AppStackParamList, OnboardingStackParamList } from 'src/app/navigation/types' +import AnimatedNumber from 'src/components/AnimatedNumber' +import { Screen } from 'src/components/layout/Screen' +import Trace from 'src/components/Trace/Trace' +import { UnitagProfilePicture } from 'src/components/unitags/UnitagProfilePicture' +import { OnboardingScreens, Screens } from 'src/screens/Screens' +import { useAddBackButton } from 'src/utils/useAddBackButton' +import { Button, Flex, Loader, Text, useMedia, useSporeColors } from 'ui/src' +import LockIcon from 'ui/src/assets/icons/lock.svg' +import { fonts, iconSizes, opacify } from 'ui/src/theme' +import { NumberType } from 'utilities/src/format/types' +import { AccountIcon } from 'wallet/src/components/accounts/AccountIcon' +import { DisplayNameText } from 'wallet/src/components/accounts/DisplayNameText' +import { Arrow } from 'wallet/src/components/icons/Arrow' +import { useENSAvatar } from 'wallet/src/features/ens/api' +import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' +import { OnboardingEntryPoint } from 'wallet/src/features/onboarding/types' +import { + PendingAccountActions, + pendingAccountActions, +} from 'wallet/src/features/wallet/create/pendingAccountsSaga' +import { useActiveAccountAddress, useDisplayName } from 'wallet/src/features/wallet/hooks' +import { DisplayNameType } from 'wallet/src/features/wallet/types' +import { useAppDispatch } from 'wallet/src/state' +import { ElementName } from 'wallet/src/telemetry/constants' + +type Props = CompositeScreenProps< + NativeStackScreenProps, + NativeStackScreenProps +> + +export function WelcomeWalletScreen({ navigation, route: { params } }: Props): JSX.Element { + useAddBackButton(navigation) + + const colors = useSporeColors() + const { t } = useTranslation() + const { convertFiatAmountFormatted } = useLocalizationContext() + const media = useMedia() + + const activeAddress = useActiveAccountAddress() + const walletName = useDisplayName(activeAddress) + const { data: avatar } = useENSAvatar(activeAddress) + const dispatch = useAppDispatch() + + const onPressNext = (): void => { + navigation.navigate({ + name: OnboardingScreens.Backup, + merge: true, + params, + }) + } + + // Ensure pending account is cleared before navigating away + navigation.addListener('beforeRemove', () => { + if (params.entryPoint === OnboardingEntryPoint.Sidebar) { + dispatch(pendingAccountActions.trigger(PendingAccountActions.Delete)) + } + }) + + const zeroBalance = convertFiatAmountFormatted(0, NumberType.PortfolioBalance) + + const displayName = params.unitagClaim + ? { type: DisplayNameType.Unitag, name: params.unitagClaim.username } + : walletName + + return ( + + + + {params.unitagClaim?.avatarUri ? ( + + ) : ( + + )} + + + + + + + {t('Welcome to your new wallet')} + + + {t( + 'This is your personal space for tokens, NFTs, and all of your trades. Finish setting it up to keep your funds safe.' + )} + + + + + )} diff --git a/apps/mobile/src/screens/SettingsWalletEdit.tsx b/apps/mobile/src/screens/SettingsWalletEdit.tsx index 59b439ad9a3..b89bd496ba6 100644 --- a/apps/mobile/src/screens/SettingsWalletEdit.tsx +++ b/apps/mobile/src/screens/SettingsWalletEdit.tsx @@ -1,7 +1,12 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' -import React, { useState } from 'react' +import React, { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Keyboard, KeyboardAvoidingView, StyleSheet } from 'react-native' +import { + Keyboard, + KeyboardAvoidingView, + TextInput as NativeTextInput, + StyleSheet, +} from 'react-native' import { useAppDispatch } from 'src/app/hooks' import { SettingsStackParamList } from 'src/app/navigation/types' import { BackHeader } from 'src/components/layout/BackHeader' @@ -11,16 +16,16 @@ import { Button, Flex, Icons, Text } from 'ui/src' import { fonts } from 'ui/src/theme' import { TextInput } from 'wallet/src/components/input/TextInput' import { NICKNAME_MAX_LENGTH } from 'wallet/src/constants/accounts' -import { ChainId } from 'wallet/src/constants/chains' -import { useENS } from 'wallet/src/features/ens/useENS' import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants' import { useFeatureFlag } from 'wallet/src/features/experiments/hooks' +import { useCanActiveAddressClaimUnitag } from 'wallet/src/features/unitags/hooks' import { EditAccountAction, editAccountActions, } from 'wallet/src/features/wallet/accounts/editAccountSaga' -import { useAccounts } from 'wallet/src/features/wallet/hooks' -import { shortenAddress } from 'wallet/src/utils/addresses' +import { AccountType } from 'wallet/src/features/wallet/accounts/types' +import { useAccounts, useDisplayName } from 'wallet/src/features/wallet/hooks' +import { DisplayNameType } from 'wallet/src/features/wallet/types' import { isIOS } from 'wallet/src/utils/platform' import { Screens } from './Screens' @@ -34,23 +39,33 @@ export function SettingsWalletEdit({ const { t } = useTranslation() const dispatch = useAppDispatch() const activeAccount = useAccounts()[address] - const ensName = useENS(ChainId.Mainnet, address)?.name - const [nickname, setNickname] = useState(ensName || activeAccount?.name) - const [initialNickname, setInitialNickname] = useState(ensName || activeAccount?.name) - const [showEditInput, setShowEditInput] = useState(false) + const displayName = useDisplayName(address) + const [nickname, setNickname] = useState(displayName?.name) + const [showEditButton, setShowEditButton] = useState(true) const unitagsFeatureFlagEnabled = useFeatureFlag(FEATURE_FLAGS.Unitags) + const { canClaimUnitag } = useCanActiveAddressClaimUnitag() + const showUnitagBanner = + unitagsFeatureFlagEnabled && + activeAccount?.type === AccountType.SignerMnemonic && + canClaimUnitag + + const accountNameIsEditable = + displayName?.type === DisplayNameType.Local || displayName?.type === DisplayNameType.Address - const onPressShowEditInput = (): void => { - setShowEditInput(true) + const inputRef = useRef(null) + + const onEditButtonPress = (): void => { + inputRef.current?.focus() + setShowEditButton(false) } const onFinishEditing = (): void => { Keyboard.dismiss() - setShowEditInput(false) + setShowEditButton(true) setNickname(nickname?.trim()) } - const handleNicknameUpdate = (): void => { + const onPressSaveChanges = (): void => { onFinishEditing() dispatch( editAccountActions.trigger({ @@ -61,11 +76,6 @@ export function SettingsWalletEdit({ ) } - const onPressSaveChanges = (): void => { - handleNicknameUpdate() - setInitialNickname(nickname) - } - return ( - {t('Nickname')} + {t('Edit label')} - {showEditInput ? ( - + {showEditButton && accountNameIsEditable && ( +