ci(release): publish latest release

parent 8465f684
IPFS hash of the deployment: IPFS hash of the deployment:
- CIDv0: `QmcH1YbyUfpwzwJxmFGZVnRddiveKnnpuxFTLxBeWPMxbU` - CIDv0: `QmfTNnuaGjKgsrCc3CrXy8JZCw1gDPaWCzmXwt7GvDRXNK`
- CIDv1: `bafybeigpctng5nggznryk3h4m7jssnnw5uab46gd3n34v5wue33bnyjvnm` - CIDv1: `bafybeih6j67orz7joqshoxrbzvxmqlipzfdlpuw7d252kjjqbpsjcpmnnq`
The latest release is always mirrored at [app.uniswap.org](https://app.uniswap.org). The latest release is always mirrored at [app.uniswap.org](https://app.uniswap.org).
...@@ -10,10 +10,10 @@ You can also access the Uniswap Interface from an IPFS gateway. ...@@ -10,10 +10,10 @@ You can also access the Uniswap Interface from an IPFS gateway.
Your Uniswap settings are never remembered across different URLs. Your Uniswap settings are never remembered across different URLs.
IPFS gateways: IPFS gateways:
- https://bafybeigpctng5nggznryk3h4m7jssnnw5uab46gd3n34v5wue33bnyjvnm.ipfs.dweb.link/ - https://bafybeih6j67orz7joqshoxrbzvxmqlipzfdlpuw7d252kjjqbpsjcpmnnq.ipfs.dweb.link/
- https://bafybeigpctng5nggznryk3h4m7jssnnw5uab46gd3n34v5wue33bnyjvnm.ipfs.cf-ipfs.com/ - https://bafybeih6j67orz7joqshoxrbzvxmqlipzfdlpuw7d252kjjqbpsjcpmnnq.ipfs.cf-ipfs.com/
- [ipfs://QmcH1YbyUfpwzwJxmFGZVnRddiveKnnpuxFTLxBeWPMxbU/](ipfs://QmcH1YbyUfpwzwJxmFGZVnRddiveKnnpuxFTLxBeWPMxbU/) - [ipfs://QmfTNnuaGjKgsrCc3CrXy8JZCw1gDPaWCzmXwt7GvDRXNK/](ipfs://QmfTNnuaGjKgsrCc3CrXy8JZCw1gDPaWCzmXwt7GvDRXNK/)
### 5.1.2 (2023-12-07) ### 5.2.1 (2023-12-13)
web/5.1.2 web/5.2.1
\ No newline at end of file \ No newline at end of file
...@@ -651,7 +651,7 @@ PODS: ...@@ -651,7 +651,7 @@ PODS:
- EXAV (13.4.1): - EXAV (13.4.1):
- ExpoModulesCore - ExpoModulesCore
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- EXBarCodeScanner (12.3.2): - EXBarCodeScanner (12.7.0):
- EXImageLoader - EXImageLoader
- ExpoModulesCore - ExpoModulesCore
- ZXingObjC/OneD - ZXingObjC/OneD
...@@ -660,7 +660,7 @@ PODS: ...@@ -660,7 +660,7 @@ PODS:
- ExpoModulesCore - ExpoModulesCore
- EXFont (11.1.1): - EXFont (11.1.1):
- ExpoModulesCore - ExpoModulesCore
- EXImageLoader (4.1.1): - EXImageLoader (4.4.0):
- ExpoModulesCore - ExpoModulesCore
- React-Core - React-Core
- EXLocalAuthentication (13.0.2): - EXLocalAuthentication (13.0.2):
...@@ -1689,10 +1689,10 @@ SPEC CHECKSUMS: ...@@ -1689,10 +1689,10 @@ SPEC CHECKSUMS:
EthersRS: 56b70e73d22d4e894b7e762eef1129159bcd3135 EthersRS: 56b70e73d22d4e894b7e762eef1129159bcd3135
EXApplication: d8f53a7eee90a870a75656280e8d4b85726ea903 EXApplication: d8f53a7eee90a870a75656280e8d4b85726ea903
EXAV: f393dfc0b28214d62855a31e06eb21d426d6e2da EXAV: f393dfc0b28214d62855a31e06eb21d426d6e2da
EXBarCodeScanner: 8e23fae8d267dbef9f04817833a494200f1fce35 EXBarCodeScanner: 296dd50f6c03928d1d71d37ea17473b304cfdb00
EXFileSystem: 0b4a2c08c2dc92146849772145d61c1773144283 EXFileSystem: 0b4a2c08c2dc92146849772145d61c1773144283
EXFont: 6ea3800df746be7233208d80fe379b8ed74f4272 EXFont: 6ea3800df746be7233208d80fe379b8ed74f4272
EXImageLoader: fd053169a8ee932dd83bf1fe5487a50c26d27c2b EXImageLoader: 03063370bc06ea1825713d3f55fe0455f7c88d04
EXLocalAuthentication: 78cc5a00b13745b41d16d189ab332b61a01299f9 EXLocalAuthentication: 78cc5a00b13745b41d16d189ab332b61a01299f9
Expo: 8448e3a2aa1b295f029c81551e1ab6d986517fdb Expo: 8448e3a2aa1b295f029c81551e1ab6d986517fdb
ExpoBlur: fac3c6318fdf409dd5740afd3313b2bd52a35bac ExpoBlur: fac3c6318fdf409dd5740afd3313b2bd52a35bac
......
...@@ -131,10 +131,12 @@ ...@@ -131,10 +131,12 @@
07F136422A5763480067004F /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F136412A5763480067004F /* Network.swift */; }; 07F136422A5763480067004F /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F136412A5763480067004F /* Network.swift */; };
07F5CF712A6AD97D00C648A5 /* Chart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F5CF702A6AD97D00C648A5 /* Chart.swift */; }; 07F5CF712A6AD97D00C648A5 /* Chart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F5CF702A6AD97D00C648A5 /* Chart.swift */; };
07F5CF752A7020FD00C648A5 /* Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F5CF742A7020FD00C648A5 /* Format.swift */; }; 07F5CF752A7020FD00C648A5 /* Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F5CF742A7020FD00C648A5 /* Format.swift */; };
0DC6ADF02B1E2C100092909C /* PortfolioValueModifier.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DC6ADEF2B1E2C0F0092909C /* PortfolioValueModifier.graphql.swift */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
1440B371A1C9A42F3E91DAAE /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF5F26A06553EFDD4D99214 /* ExpoModulesProvider.swift */; }; 1440B371A1C9A42F3E91DAAE /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF5F26A06553EFDD4D99214 /* ExpoModulesProvider.swift */; };
1DA5339E6A1956F5FE24DB6C /* libPods-WidgetIntentExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FD21B73B081B800A44E7F682 /* libPods-WidgetIntentExtension.a */; }; 1DA5339E6A1956F5FE24DB6C /* libPods-WidgetIntentExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FD21B73B081B800A44E7F682 /* libPods-WidgetIntentExtension.a */; };
5EFB78362B1E585000E77EAC /* ConvertQuery.graphql.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EFB78352B1E585000E77EAC /* ConvertQuery.graphql.swift */; };
681301B12A3726EE00A5BF43 /* onboarding_dark.riv in Resources */ = {isa = PBXBuildFile; fileRef = 681301AD2A3726EE00A5BF43 /* onboarding_dark.riv */; }; 681301B12A3726EE00A5BF43 /* onboarding_dark.riv in Resources */ = {isa = PBXBuildFile; fileRef = 681301AD2A3726EE00A5BF43 /* onboarding_dark.riv */; };
681301B22A3726EE00A5BF43 /* pending_send.riv in Resources */ = {isa = PBXBuildFile; fileRef = 681301AE2A3726EE00A5BF43 /* pending_send.riv */; }; 681301B22A3726EE00A5BF43 /* pending_send.riv in Resources */ = {isa = PBXBuildFile; fileRef = 681301AE2A3726EE00A5BF43 /* pending_send.riv */; };
681301B32A3726EE00A5BF43 /* onboarding_light.riv in Resources */ = {isa = PBXBuildFile; fileRef = 681301AF2A3726EE00A5BF43 /* onboarding_light.riv */; }; 681301B32A3726EE00A5BF43 /* onboarding_light.riv in Resources */ = {isa = PBXBuildFile; fileRef = 681301AF2A3726EE00A5BF43 /* onboarding_light.riv */; };
...@@ -412,6 +414,7 @@ ...@@ -412,6 +414,7 @@
07F136412A5763480067004F /* Network.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Network.swift; sourceTree = "<group>"; }; 07F136412A5763480067004F /* Network.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Network.swift; sourceTree = "<group>"; };
07F5CF702A6AD97D00C648A5 /* Chart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chart.swift; sourceTree = "<group>"; }; 07F5CF702A6AD97D00C648A5 /* Chart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chart.swift; sourceTree = "<group>"; };
07F5CF742A7020FD00C648A5 /* Format.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Format.swift; sourceTree = "<group>"; }; 07F5CF742A7020FD00C648A5 /* Format.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Format.swift; sourceTree = "<group>"; };
0DC6ADEF2B1E2C0F0092909C /* PortfolioValueModifier.graphql.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PortfolioValueModifier.graphql.swift; path = WidgetsCore/MobileSchema/Schema/InputObjects/PortfolioValueModifier.graphql.swift; sourceTree = "<group>"; };
13B07F961A680F5B00A75B9A /* Uniswap.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Uniswap.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07F961A680F5B00A75B9A /* Uniswap.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Uniswap.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Uniswap/AppDelegate.h; sourceTree = "<group>"; }; 13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Uniswap/AppDelegate.h; sourceTree = "<group>"; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Uniswap/Images.xcassets; sourceTree = "<group>"; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Uniswap/Images.xcassets; sourceTree = "<group>"; };
...@@ -426,6 +429,7 @@ ...@@ -426,6 +429,7 @@
407451BE42C5147EBB181687 /* Pods-Uniswap-UniswapTests.dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Uniswap-UniswapTests.dev.xcconfig"; path = "Target Support Files/Pods-Uniswap-UniswapTests/Pods-Uniswap-UniswapTests.dev.xcconfig"; sourceTree = "<group>"; }; 407451BE42C5147EBB181687 /* Pods-Uniswap-UniswapTests.dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Uniswap-UniswapTests.dev.xcconfig"; path = "Target Support Files/Pods-Uniswap-UniswapTests/Pods-Uniswap-UniswapTests.dev.xcconfig"; sourceTree = "<group>"; };
4DF5F26A06553EFDD4D99214 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Uniswap/ExpoModulesProvider.swift"; sourceTree = "<group>"; }; 4DF5F26A06553EFDD4D99214 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Uniswap/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
4E788CB77B4CFEAC6E8FFB3A /* Pods-WidgetIntentExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetIntentExtension.debug.xcconfig"; path = "Target Support Files/Pods-WidgetIntentExtension/Pods-WidgetIntentExtension.debug.xcconfig"; sourceTree = "<group>"; }; 4E788CB77B4CFEAC6E8FFB3A /* Pods-WidgetIntentExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetIntentExtension.debug.xcconfig"; path = "Target Support Files/Pods-WidgetIntentExtension/Pods-WidgetIntentExtension.debug.xcconfig"; sourceTree = "<group>"; };
5EFB78352B1E585000E77EAC /* ConvertQuery.graphql.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConvertQuery.graphql.swift; sourceTree = "<group>"; };
63F7391CE0D5231AE38192F9 /* Pods-WidgetIntentExtension.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetIntentExtension.beta.xcconfig"; path = "Target Support Files/Pods-WidgetIntentExtension/Pods-WidgetIntentExtension.beta.xcconfig"; sourceTree = "<group>"; }; 63F7391CE0D5231AE38192F9 /* Pods-WidgetIntentExtension.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetIntentExtension.beta.xcconfig"; path = "Target Support Files/Pods-WidgetIntentExtension/Pods-WidgetIntentExtension.beta.xcconfig"; sourceTree = "<group>"; };
681301AD2A3726EE00A5BF43 /* onboarding_dark.riv */ = {isa = PBXFileReference; lastKnownFileType = file; path = onboarding_dark.riv; sourceTree = "<group>"; }; 681301AD2A3726EE00A5BF43 /* onboarding_dark.riv */ = {isa = PBXFileReference; lastKnownFileType = file; path = onboarding_dark.riv; sourceTree = "<group>"; };
681301AE2A3726EE00A5BF43 /* pending_send.riv */ = {isa = PBXFileReference; lastKnownFileType = file; path = pending_send.riv; sourceTree = "<group>"; }; 681301AE2A3726EE00A5BF43 /* pending_send.riv */ = {isa = PBXFileReference; lastKnownFileType = file; path = pending_send.riv; sourceTree = "<group>"; };
...@@ -686,6 +690,7 @@ ...@@ -686,6 +690,7 @@
0743218F2A83E3C900F8518D /* Queries */ = { 0743218F2A83E3C900F8518D /* Queries */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
5EFB78352B1E585000E77EAC /* ConvertQuery.graphql.swift */,
077E60382A85587800ABC4B9 /* TokensQuery.graphql.swift */, 077E60382A85587800ABC4B9 /* TokensQuery.graphql.swift */,
074321902A83E3C900F8518D /* TokenDetailsScreenQuery.graphql.swift */, 074321902A83E3C900F8518D /* TokenDetailsScreenQuery.graphql.swift */,
077E603A2A86D06100ABC4B9 /* MultiplePortfolioBalancesQuery.graphql.swift */, 077E603A2A86D06100ABC4B9 /* MultiplePortfolioBalancesQuery.graphql.swift */,
...@@ -977,6 +982,7 @@ ...@@ -977,6 +982,7 @@
83CBB9F61A601CBA00E9B192 = { 83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0DC6ADEF2B1E2C0F0092909C /* PortfolioValueModifier.graphql.swift */,
074321872A82BA2700F8518D /* Fonts */, 074321872A82BA2700F8518D /* Fonts */,
FD54D51C296C79A4007A37E9 /* GoogleServiceInfo */, FD54D51C296C79A4007A37E9 /* GoogleServiceInfo */,
13B07FAE1A68108700A75B9A /* Uniswap */, 13B07FAE1A68108700A75B9A /* Uniswap */,
...@@ -1774,6 +1780,7 @@ ...@@ -1774,6 +1780,7 @@
074322142A83E3CA00F8518D /* TimestampedAmount.graphql.swift in Sources */, 074322142A83E3CA00F8518D /* TimestampedAmount.graphql.swift in Sources */,
074322362A83E3CA00F8518D /* NftCollectionsFilterInput.graphql.swift in Sources */, 074322362A83E3CA00F8518D /* NftCollectionsFilterInput.graphql.swift in Sources */,
0743223B2A83E3CA00F8518D /* NftAssetsFilterInput.graphql.swift in Sources */, 0743223B2A83E3CA00F8518D /* NftAssetsFilterInput.graphql.swift in Sources */,
0DC6ADF02B1E2C100092909C /* PortfolioValueModifier.graphql.swift in Sources */,
074322282A83E3CA00F8518D /* TokenProjectMarket.graphql.swift in Sources */, 074322282A83E3CA00F8518D /* TokenProjectMarket.graphql.swift in Sources */,
0743220F2A83E3CA00F8518D /* NftApproveForAll.graphql.swift in Sources */, 0743220F2A83E3CA00F8518D /* NftApproveForAll.graphql.swift in Sources */,
0743223C2A83E3CA00F8518D /* SchemaMetadata.graphql.swift in Sources */, 0743223C2A83E3CA00F8518D /* SchemaMetadata.graphql.swift in Sources */,
...@@ -1817,6 +1824,7 @@ ...@@ -1817,6 +1824,7 @@
07F5CF752A7020FD00C648A5 /* Format.swift in Sources */, 07F5CF752A7020FD00C648A5 /* Format.swift in Sources */,
0783F7B42A619E7C009ED617 /* UIComponents.swift in Sources */, 0783F7B42A619E7C009ED617 /* UIComponents.swift in Sources */,
0743220B2A83E3CA00F8518D /* NftMarketplace.graphql.swift in Sources */, 0743220B2A83E3CA00F8518D /* NftMarketplace.graphql.swift in Sources */,
5EFB78362B1E585000E77EAC /* ConvertQuery.graphql.swift in Sources */,
074321F62A83E3CA00F8518D /* SearchTokensQuery.graphql.swift in Sources */, 074321F62A83E3CA00F8518D /* SearchTokensQuery.graphql.swift in Sources */,
0743223E2A83E3CA00F8518D /* IContract.graphql.swift in Sources */, 0743223E2A83E3CA00F8518D /* IContract.graphql.swift in Sources */,
074321EE2A83E3CA00F8518D /* NftsTabQuery.graphql.swift in Sources */, 074321EE2A83E3CA00F8518D /* NftsTabQuery.graphql.swift in Sources */,
......
...@@ -89,6 +89,12 @@ ...@@ -89,6 +89,12 @@
<string>$(PRODUCT_NAME) Wallet needs access to your Camera Roll to choose an avatar for your username</string> <string>$(PRODUCT_NAME) Wallet needs access to your Camera Roll to choose an avatar for your username</string>
<key>NSFaceIDUsageDescription</key> <key>NSFaceIDUsageDescription</key>
<string>Enabling Face ID helps $(PRODUCT_NAME) Wallet keep your assets secure.</string> <string>Enabling Face ID helps $(PRODUCT_NAME) Wallet keep your assets secure.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>$(PRODUCT_NAME) Wallet does not require access to your location.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>$(PRODUCT_NAME) Wallet does not require access to your location.</string>
<key>NSMicrophoneUsageDescription</key>
<string>$(PRODUCT_NAME) Wallet does not require access to the microphone.</string>
<key>NSUbiquitousContainers</key> <key>NSUbiquitousContainers</key>
<dict> <dict>
<key>iCloud.Uniswap</key> <key>iCloud.Uniswap</key>
......
...@@ -26,9 +26,31 @@ let placeholderPriceHistory = [ ...@@ -26,9 +26,31 @@ let placeholderPriceHistory = [
PriceHistory(timestamp: 1689794997, price: 2167), PriceHistory(timestamp: 1689794997, price: 2167),
PriceHistory(timestamp: 1689795264, price: 2165) PriceHistory(timestamp: 1689795264, price: 2165)
] ]
let previewEntry = TokenPriceEntry(date: Date(), configuration: TokenPriceConfigurationIntent(), spotPrice: 2165, pricePercentChange: -9.87, symbol: "ETH", logo: UIImage(url: URL(string: "https://token-icons.s3.amazonaws.com/eth.png")), backgroundColor: ColorExtraction.extractImageColorWithSpecialCase(imageURL: "https://token-icons.s3.amazonaws.com/eth.png"), tokenPriceHistory: TokenPriceHistoryResponse(priceHistory: placeholderPriceHistory)) let previewEntry = TokenPriceEntry(
date: Date(),
let placeholderEntry = TokenPriceEntry(date: previewEntry.date, configuration: previewEntry.configuration, spotPrice: previewEntry.spotPrice, pricePercentChange: previewEntry.pricePercentChange, symbol: previewEntry.symbol, logo: nil, backgroundColor: nil, tokenPriceHistory: previewEntry.tokenPriceHistory) configuration: TokenPriceConfigurationIntent(),
currency: WidgetConstants.currencyUsd,
spotPrice: 2165,
pricePercentChange: -9.87,
symbol: "ETH",
logo: UIImage(url: URL(string: "https://token-icons.s3.amazonaws.com/eth.png")),
backgroundColor: ColorExtraction.extractImageColorWithSpecialCase(
imageURL: "https://token-icons.s3.amazonaws.com/eth.png"
),
tokenPriceHistory: TokenPriceHistoryResponse(priceHistory: placeholderPriceHistory)
)
let placeholderEntry = TokenPriceEntry(
date: previewEntry.date,
configuration: previewEntry.configuration,
currency: previewEntry.currency,
spotPrice: previewEntry.spotPrice,
pricePercentChange: previewEntry.pricePercentChange,
symbol: previewEntry.symbol,
logo: nil,
backgroundColor: nil,
tokenPriceHistory: previewEntry.tokenPriceHistory
)
let refreshMinutes = 5 let refreshMinutes = 5
let displayName = "Token Prices" let displayName = "Token Prices"
...@@ -39,10 +61,16 @@ struct Provider: IntentTimelineProvider { ...@@ -39,10 +61,16 @@ struct Provider: IntentTimelineProvider {
func getEntry(configuration: TokenPriceConfigurationIntent, context: Context, isSnapshot: Bool) async throws -> TokenPriceEntry { func getEntry(configuration: TokenPriceConfigurationIntent, context: Context, isSnapshot: Bool) async throws -> TokenPriceEntry {
let entryDate = Date() let entryDate = Date()
let tokenPriceResponse = isSnapshot ? async let tokenPriceRequest = isSnapshot ?
try await DataQueries.fetchTokenPriceData(chain: WidgetConstants.ethereumChain, address: nil) : await DataQueries.fetchTokenPriceData(chain: WidgetConstants.ethereumChain, address: nil) :
try await DataQueries.fetchTokenPriceData(chain: configuration.selectedToken?.chain ?? "", address: configuration.selectedToken?.address) await DataQueries.fetchTokenPriceData(chain: configuration.selectedToken?.chain ?? "", address: configuration.selectedToken?.address)
let spotPrice = tokenPriceResponse.spotPrice async let conversionRequest = await DataQueries.fetchCurrencyConversion(
toCurrency: UniswapUserDefaults.readI18n().currency)
let (tokenPriceResponse, conversionResponse) = try await (tokenPriceRequest, conversionRequest)
let spotPrice = tokenPriceResponse.spotPrice != nil ?
tokenPriceResponse.spotPrice! * conversionResponse.conversionRate : nil
let pricePercentChange = tokenPriceResponse.pricePercentChange let pricePercentChange = tokenPriceResponse.pricePercentChange
let symbol = tokenPriceResponse.symbol let symbol = tokenPriceResponse.symbol
let logo = UIImage(url: URL(string: tokenPriceResponse.logoUrl ?? "")) let logo = UIImage(url: URL(string: tokenPriceResponse.logoUrl ?? ""))
...@@ -62,7 +90,17 @@ struct Provider: IntentTimelineProvider { ...@@ -62,7 +90,17 @@ struct Provider: IntentTimelineProvider {
address: configuration.selectedToken?.address) address: configuration.selectedToken?.address)
} }
return TokenPriceEntry(date: entryDate, configuration: configuration, spotPrice: spotPrice, pricePercentChange: pricePercentChange, symbol: symbol, logo: logo, backgroundColor: backgroundColor, tokenPriceHistory: tokenPriceHistory) return TokenPriceEntry(
date: entryDate,
configuration: configuration,
currency: conversionResponse.currency,
spotPrice: spotPrice,
pricePercentChange: pricePercentChange,
symbol: symbol,
logo: logo,
backgroundColor: backgroundColor,
tokenPriceHistory: tokenPriceHistory
)
} }
func placeholder(in context: Context) -> TokenPriceEntry { func placeholder(in context: Context) -> TokenPriceEntry {
...@@ -90,6 +128,7 @@ struct Provider: IntentTimelineProvider { ...@@ -90,6 +128,7 @@ struct Provider: IntentTimelineProvider {
struct TokenPriceEntry: TimelineEntry { struct TokenPriceEntry: TimelineEntry {
let date: Date let date: Date
let configuration: TokenPriceConfigurationIntent let configuration: TokenPriceConfigurationIntent
let currency: String
let spotPrice: Double? let spotPrice: Double?
let pricePercentChange: Double? let pricePercentChange: Double?
let symbol: String let symbol: String
...@@ -130,7 +169,14 @@ struct TokenPriceWidgetEntryView: View { ...@@ -130,7 +169,14 @@ struct TokenPriceWidgetEntryView: View {
func priceSection(isPlaceholder: Bool) -> some View { func priceSection(isPlaceholder: Bool) -> some View {
return VStack(alignment: .leading, spacing: 0) { return VStack(alignment: .leading, spacing: 0) {
if (!isPlaceholder && entry.spotPrice != nil && entry.pricePercentChange != nil) { if (!isPlaceholder && entry.spotPrice != nil && entry.pricePercentChange != nil) {
Text(NumberFormatter.fiatTokenDetailsFormatter(price: entry.spotPrice)) let i18nSettings = UniswapUserDefaults.readI18n()
Text(
NumberFormatter.fiatTokenDetailsFormatter(
price: entry.spotPrice,
locale: Locale(identifier: i18nSettings.locale),
currencyCode: entry.currency
)
)
.withHeading1Style() .withHeading1Style()
.frame(minHeight: 28) .frame(minHeight: 28)
.minimumScaleFactor(0.3) .minimumScaleFactor(0.3)
......
...@@ -12,6 +12,7 @@ public struct WidgetConstants { ...@@ -12,6 +12,7 @@ public struct WidgetConstants {
public static let ethereumChain = "ETHEREUM" public static let ethereumChain = "ETHEREUM"
public static let WETHAddress = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" public static let WETHAddress = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"
public static let ethereumSymbol = "ETH" public static let ethereumSymbol = "ETH"
public static let currencyUsd = "USD"
} }
// Needed to handle different bundle ids, cannot map directly but handles arbitrary bundle ids that conform to the existing convention // Needed to handle different bundle ids, cannot map directly but handles arbitrary bundle ids that conform to the existing convention
......
...@@ -99,7 +99,7 @@ public class DataQueries { ...@@ -99,7 +99,7 @@ public class DataQueries {
public static func fetchWalletsTokensData(addresses: [String], maxLength: Int = 25) async throws -> [TokenResponse] { public static func fetchWalletsTokensData(addresses: [String], maxLength: Int = 25) async throws -> [TokenResponse] {
return try await withCheckedThrowingContinuation { continuation in return try await withCheckedThrowingContinuation { continuation in
Network.shared.apollo.fetch(query: MobileSchema.MultiplePortfolioBalancesQuery(ownerAddresses: addresses)){ result in Network.shared.apollo.fetch(query: MobileSchema.MultiplePortfolioBalancesQuery(ownerAddresses: addresses, valueModifiers: GraphQLNullable.null)){ result in
switch result { switch result {
case .success(let graphQLResult): case .success(let graphQLResult):
// Takes all the signer accounts and sums up the balances of the tokens, then sorts them by descending order, ignoring spam // Takes all the signer accounts and sums up the balances of the tokens, then sorts them by descending order, ignoring spam
...@@ -124,6 +124,40 @@ public class DataQueries { ...@@ -124,6 +124,40 @@ public class DataQueries {
} }
} }
} }
public static func fetchCurrencyConversion(toCurrency: String) async throws -> CurrencyConversionResponse {
return try await withCheckedThrowingContinuation { continuation in
let usdResponse = CurrencyConversionResponse(conversionRate: 1, currency: WidgetConstants.currencyUsd)
// Assuming all server currency amounts are in USD
if (toCurrency == WidgetConstants.currencyUsd) {
return continuation.resume(returning: usdResponse)
}
Network.shared.apollo.fetch(
query: MobileSchema.ConvertQuery(
fromCurrency: GraphQLEnum(MobileSchema.Currency.usd),
toCurrency: GraphQLEnum(rawValue: toCurrency)
)
) { result in
switch result {
case .success(let graphQLResult):
let conversionRate = graphQLResult.data?.convert?.value
let currency = graphQLResult.data?.convert?.currency?.rawValue
continuation.resume(
returning: conversionRate == nil || currency == nil ? usdResponse :
CurrencyConversionResponse(
conversionRate: conversionRate!,
currency: currency!
)
)
case .failure:
continuation.resume(returning: usdResponse)
}
}
}
}
} }
...@@ -6,80 +6,59 @@ ...@@ -6,80 +6,59 @@
// //
import Foundation import Foundation
// Based on https://www.notion.so/uniswaplabs/Number-standards-fbb9f533f10e4e22820722c2f66d23c0
// React native code: https://github.com/Uniswap/universe/blob/main/packages/wallet/src/utils/format.ts
extension NumberFormatter { extension NumberFormatter {
// Based on https://www.notion.so/uniswaplabs/Number-standards-fbb9f533f10e4e22820722c2f66d23c0 static func formatShorthandWithDecimals(number: Double, fractionDigits: Int, locale: Locale, currencyCode: String, placeholder: String) -> String {
// React native code: https://github.com/Uniswap/universe/blob/main/packages/wallet/src/utils/format.ts if (number < 1000000) {
public static func SHORTHAND_USD_TWO_DECIMALS(price: Double) -> String { return formatWithDecimals(number: number, fractionDigits: fractionDigits, locale: locale, currencyCode: currencyCode)
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.maximumFractionDigits = 2
formatter.minimumFractionDigits = 2
formatter.currencyCode = "USD"
if (price < 1000000){
return TWO_DECIMALS_USD.string(for: price)!
}
else if (price < 1000000000){
return "\(formatter.string(for: price/1000000)!)M"
}
else if (price < 1000000000000){
return "\(formatter.string(for: price/1000000000)!)B"
}
else if (price < 1000000000000000){
return "\(formatter.string(for: price/1000000000000)!)T"
} }
else { let maxNumber = 1000000000000000.0
return "$>999T" let maxed = number >= maxNumber
let limitedNumber = maxed ? maxNumber : number
// Replace when Swift supports notation configuration for currency
// https://developer.apple.com/documentation/foundation/currencyformatstyleconfiguration
let compactFormatted = limitedNumber.formatted(.number.locale(locale).precision(.fractionLength(fractionDigits)).notation(.compactName))
let currencyFormatted = limitedNumber.formatted(.currency(code: currencyCode).locale(locale).precision(.fractionLength(fractionDigits)).grouping(.never))
guard let numberRegex = try? NSRegularExpression(pattern: "(\\d+(\\\(locale.decimalSeparator!)\\d+)?)") else {
return placeholder
} }
let output = numberRegex.stringByReplacingMatches(in: currencyFormatted, range: NSMakeRange(0, currencyFormatted.count), withTemplate: compactFormatted)
return maxed ? ">\(output)" : "\(output)"
} }
public static var TWO_DECIMALS_USD: NumberFormatter = { static func formatWithDecimals(number: Double, fractionDigits: Int, locale: Locale, currencyCode: String) -> String {
let formatter = NumberFormatter() return number.formatted(.currency(code: currencyCode).locale(locale).precision(.fractionLength(fractionDigits)))
formatter.numberStyle = .currency }
formatter.maximumFractionDigits = 2
formatter.minimumFractionDigits = 2
formatter.currencyCode = "USD"
return formatter
}()
public static var THREE_SIG_FIGS_USD: NumberFormatter = { static func formatWithSigFigs(number: Double, sigFigsDigits: Int, locale: Locale, currencyCode: String) -> String {
let formatter = NumberFormatter() return number.formatted(.currency(code: currencyCode).locale(locale).precision(.significantDigits(sigFigsDigits)))
formatter.numberStyle = .currency }
formatter.maximumSignificantDigits = 3
formatter.minimumSignificantDigits = 3
formatter.currencyCode = "USD"
return formatter
}()
public static var THREE_DECIMALS_USD: NumberFormatter = { public static func fiatTokenDetailsFormatter(price: Double?, locale: Locale, currencyCode: String) -> String {
let formatter = NumberFormatter() let placeholder = "--.--"
formatter.numberStyle = .currency
formatter.maximumFractionDigits = 3
formatter.minimumFractionDigits = 3
formatter.currencyCode = "USD"
return formatter
}()
public static func fiatTokenDetailsFormatter(price: Double?) -> String {
guard let price = price else { guard let price = price else {
return "--.--" return placeholder
} }
if (price < 0.00000001) { if (price < 0.00000001) {
return "<$0.00000001" let formattedPrice = formatWithDecimals(number: price, fractionDigits: 8, locale: locale, currencyCode: currencyCode)
} return "<\(formattedPrice)"
else if (price < 0.01) {
return THREE_SIG_FIGS_USD.string(for: price)!
}
else if (price < 1.05) {
return THREE_DECIMALS_USD.string(for: price)!
}
else if (price < 1e6) {
return TWO_DECIMALS_USD.string(for: price)!
}
else {
return SHORTHAND_USD_TWO_DECIMALS(price: price)
} }
if (price < 0.01) {
return formatWithSigFigs(number: price, sigFigsDigits: 3, locale: locale, currencyCode: currencyCode)
} else if (price < 1.05) {
return formatWithDecimals(number: price, fractionDigits: 3, locale: locale, currencyCode: currencyCode)
} else if (price < 1e6) {
return formatWithDecimals(number: price, fractionDigits: 2, locale: locale, currencyCode: currencyCode)
} else {
return formatShorthandWithDecimals(number: price, fractionDigits: 2, locale: locale, currencyCode: currencyCode, placeholder: placeholder)
}
} }
} }
...@@ -51,3 +51,8 @@ public struct PriceHistory { ...@@ -51,3 +51,8 @@ public struct PriceHistory {
public let timestamp: Int public let timestamp: Int
public let price: Double public let price: Double
} }
public struct CurrencyConversionResponse {
public let conversionRate: Double
public let currency: String
}
...@@ -34,6 +34,17 @@ public struct WidgetDataAccounts: Decodable { ...@@ -34,6 +34,17 @@ public struct WidgetDataAccounts: Decodable {
public var accounts: [Account] public var accounts: [Account]
} }
public struct WidgetDataI18n: Decodable {
public init() {
self.locale = "en"
self.currency = WidgetConstants.currencyUsd
}
public var locale: String
public var currency: String
}
public struct Account: Decodable { public struct Account: Decodable {
public var address: String public var address: String
public var name: String? public var name: String?
...@@ -80,14 +91,14 @@ public enum Change: String, Codable { ...@@ -80,14 +91,14 @@ public enum Change: String, Codable {
case removed = "removed" case removed = "removed"
} }
public struct UniswapUserDefaults { public struct UniswapUserDefaults {
private static var buildString = getBuildVariantString(bundleId: Bundle.main.bundleIdentifier!) private static var buildString = getBuildVariantString(bundleId: Bundle.main.bundleIdentifier!)
static let eventsKey = buildString + ".widgets.configuration.events" static let keyEvents = buildString + ".widgets.configuration.events"
static let cacheKey = buildString + ".widgets.configuration.cache" static let keyCache = buildString + ".widgets.configuration.cache"
static let favoritesKey = buildString + ".widgets.favorites" static let keyFavorites = buildString + ".widgets.favorites"
static let accountsKey = buildString + ".widgets.accounts" static let keyAccounts = buildString + ".widgets.accounts"
static let keyI18n = buildString + ".widgets.i18n"
static let userDefaults = UserDefaults.init(suiteName: APP_GROUP) static let userDefaults = UserDefaults.init(suiteName: APP_GROUP)
...@@ -104,7 +115,7 @@ public struct UniswapUserDefaults { ...@@ -104,7 +115,7 @@ public struct UniswapUserDefaults {
} }
public static func readAccounts() -> WidgetDataAccounts { public static func readAccounts() -> WidgetDataAccounts {
let data = readData(key: accountsKey) let data = readData(key: keyAccounts)
guard let data = data else { guard let data = data else {
return WidgetDataAccounts([]) return WidgetDataAccounts([])
} }
...@@ -117,7 +128,7 @@ public struct UniswapUserDefaults { ...@@ -117,7 +128,7 @@ public struct UniswapUserDefaults {
} }
public static func readFavorites() -> WidgetDataFavorites { public static func readFavorites() -> WidgetDataFavorites {
let data = readData(key: favoritesKey) let data = readData(key: keyFavorites)
guard let data = data else { guard let data = data else {
return WidgetDataFavorites([]) return WidgetDataFavorites([])
} }
...@@ -129,8 +140,20 @@ public struct UniswapUserDefaults { ...@@ -129,8 +140,20 @@ public struct UniswapUserDefaults {
return parsedData return parsedData
} }
public static func readI18n() -> WidgetDataI18n {
let data = readData(key: keyI18n)
guard let data = data else {
return WidgetDataI18n()
}
let decoder = JSONDecoder()
guard let parsedData = try? decoder.decode(WidgetDataI18n.self, from: data) else {
return WidgetDataI18n()
}
return parsedData
}
public static func readConfiguration() -> WidgetDataConfiguration { public static func readConfiguration() -> WidgetDataConfiguration {
let data = readData(key: cacheKey) let data = readData(key: keyCache)
guard let data = data else { guard let data = data else {
return WidgetDataConfiguration([]) return WidgetDataConfiguration([])
} }
...@@ -147,12 +170,12 @@ public struct UniswapUserDefaults { ...@@ -147,12 +170,12 @@ public struct UniswapUserDefaults {
let encoder = JSONEncoder() let encoder = JSONEncoder()
let JSONdata = try! encoder.encode(data) let JSONdata = try! encoder.encode(data)
let json = String(data: JSONdata, encoding: String.Encoding.utf8) let json = String(data: JSONdata, encoding: String.Encoding.utf8)
userDefaults!.set(json, forKey: cacheKey) userDefaults!.set(json, forKey: keyCache)
} }
} }
public static func readEventChanges() -> WidgetEvents { public static func readEventChanges() -> WidgetEvents {
let data = readData(key: eventsKey) let data = readData(key: keyEvents)
guard let data = data else { guard let data = data else {
return WidgetEvents(events: []) return WidgetEvents(events: [])
} }
...@@ -169,7 +192,7 @@ public struct UniswapUserDefaults { ...@@ -169,7 +192,7 @@ public struct UniswapUserDefaults {
let encoder = JSONEncoder() let encoder = JSONEncoder()
let JSONdata = try! encoder.encode(data) let JSONdata = try! encoder.encode(data)
let json = String(data: JSONdata, encoding: String.Encoding.utf8) let json = String(data: JSONdata, encoding: String.Encoding.utf8)
userDefaults!.set(json, forKey: eventsKey) userDefaults!.set(json, forKey: keyEvents)
} }
} }
} }
...@@ -10,15 +10,97 @@ import WidgetsCore ...@@ -10,15 +10,97 @@ import WidgetsCore
final class FormatTests: XCTestCase { final class FormatTests: XCTestCase {
func testFiatTokenDetailsFormatter() throws { let localeEnglish = Locale(identifier: "en")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.05), "$0.050") let localeFrench = Locale(identifier: "fr-FR")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.056666666), "$0.057") let localeChinese = Locale(identifier: "zh-Hans")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 1234567.891), "$1.23M")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 1234.5678), "$1,234.57") let currencyCodeUsd = WidgetConstants.currencyUsd
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 1.048952), "$1.049") let currencyCodeEuro = "EUR"
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.001231), "$0.00123") let currencyCodeYuan = "CNY"
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.00001231), "$0.0000123")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.0000001234), "$0.000000123") struct TestCase {
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.000000009876), "<$0.00000001") public init(_ price: Double, _ output: String) {
self.price = price
self.output = output
}
public let price: Double
public let output: String
}
func testFormatterHandlesEnglish() throws {
let testCases = [
TestCase(0.05, "$0.050"),
TestCase(0.056666666, "$0.057"),
TestCase(1234567.891, "$1.23M"),
TestCase(1234.5678, "$1,234.57"),
TestCase(1.048952, "$1.049"),
TestCase(0.001231, "$0.00123"),
TestCase(0.00001231, "$0.0000123"),
TestCase(0.0000001234, "$0.000000123"),
TestCase(0.000000009876, "<$0.00000001"),
]
testCases.forEach { testCase in
XCTAssertEqual(
NumberFormatter.fiatTokenDetailsFormatter(
price: testCase.price,
locale: localeEnglish,
currencyCode: currencyCodeUsd
),
testCase.output
)
}
}
func testFormatterHandlesFrench() throws {
let testCases = [
TestCase(0.05, "0,050 €"),
TestCase(0.056666666, "0,057 €"),
TestCase(1234567.891, "1,23 M €"),
TestCase(123456.7890, "123 456,79 €"),
TestCase(1.048952, "1,049 €"),
TestCase(0.001231, "0,00123 €"),
TestCase(0.00001231, "0,0000123 €"),
TestCase(0.0000001234, "0,000000123 €"),
TestCase(0.000000009876, "<0,00000001 €"),
]
testCases.forEach { testCase in
XCTAssertEqual(
NumberFormatter.fiatTokenDetailsFormatter(
price: testCase.price,
locale: localeFrench,
currencyCode: currencyCodeEuro
),
testCase.output
)
}
}
func testFormatterHandlesChinese() throws {
let testCases = [
TestCase(0.05, "¥0.050"),
TestCase(0.056666666, "¥0.057"),
TestCase(1234567.891, "¥123.46万"),
TestCase(1234.5678, "¥1,234.57"),
TestCase(1.048952, "¥1.049"),
TestCase(0.001231, "¥0.00123"),
TestCase(0.00001231, "¥0.0000123"),
TestCase(0.0000001234, "¥0.000000123"),
TestCase(0.000000009876, "<¥0.00000001"),
]
testCases.forEach { testCase in
XCTAssertEqual(
NumberFormatter.fiatTokenDetailsFormatter(
price: testCase.price,
locale: localeChinese,
currencyCode: currencyCodeYuan
),
testCase.output
)
}
} }
} }
...@@ -109,7 +109,7 @@ ...@@ -109,7 +109,7 @@
"ethers": "5.7.2", "ethers": "5.7.2",
"expo": "48.0.19", "expo": "48.0.19",
"expo-av": "13.4.1", "expo-av": "13.4.1",
"expo-barcode-scanner": "12.3.2", "expo-barcode-scanner": "12.7.0",
"expo-blur": "12.2.2", "expo-blur": "12.2.2",
"expo-clipboard": "4.1.2", "expo-clipboard": "4.1.2",
"expo-haptics": "12.0.1", "expo-haptics": "12.0.1",
......
...@@ -33,6 +33,7 @@ import { ...@@ -33,6 +33,7 @@ import {
processWidgetEvents, processWidgetEvents,
setAccountAddressesUserDefaults, setAccountAddressesUserDefaults,
setFavoritesUserDefaults, setFavoritesUserDefaults,
setI18NUserDefaults,
} from 'src/features/widgets/widgets' } from 'src/features/widgets/widgets'
import { useAppStateTrigger } from 'src/utils/useAppStateTrigger' import { useAppStateTrigger } from 'src/utils/useAppStateTrigger'
import { getSentryEnvironment, getStatsigEnvironmentTier } from 'src/utils/version' import { getSentryEnvironment, getStatsigEnvironmentTier } from 'src/utils/version'
...@@ -46,6 +47,8 @@ import { uniswapUrls } from 'wallet/src/constants/urls' ...@@ -46,6 +47,8 @@ import { uniswapUrls } from 'wallet/src/constants/urls'
import { useCurrentAppearanceSetting, useIsDarkMode } from 'wallet/src/features/appearance/hooks' import { useCurrentAppearanceSetting, useIsDarkMode } from 'wallet/src/features/appearance/hooks'
import { EXPERIMENT_NAMES, FEATURE_FLAGS } from 'wallet/src/features/experiments/constants' import { EXPERIMENT_NAMES, FEATURE_FLAGS } from 'wallet/src/features/experiments/constants'
import { selectFavoriteTokens } from 'wallet/src/features/favorites/selectors' import { selectFavoriteTokens } from 'wallet/src/features/favorites/selectors'
import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks'
import { useCurrentLanguageInfo } from 'wallet/src/features/language/hooks'
import { LocalizationContextProvider } from 'wallet/src/features/language/LocalizationContext' import { LocalizationContextProvider } from 'wallet/src/features/language/LocalizationContext'
import { updateLanguage } from 'wallet/src/features/language/slice' import { updateLanguage } from 'wallet/src/features/language/slice'
import { Account } from 'wallet/src/features/wallet/accounts/types' import { Account } from 'wallet/src/features/wallet/accounts/types'
...@@ -238,6 +241,8 @@ function AppInner(): JSX.Element { ...@@ -238,6 +241,8 @@ function AppInner(): JSX.Element {
function DataUpdaters(): JSX.Element { function DataUpdaters(): JSX.Element {
const favoriteTokens: CurrencyId[] = useAppSelector(selectFavoriteTokens) const favoriteTokens: CurrencyId[] = useAppSelector(selectFavoriteTokens)
const accountsMap: Record<string, Account> = useAccounts() const accountsMap: Record<string, Account> = useAccounts()
const { locale } = useCurrentLanguageInfo()
const { code } = useAppFiatCurrencyInfo()
// Refreshes widgets when bringing app to foreground // Refreshes widgets when bringing app to foreground
useAppStateTrigger('background', 'active', processWidgetEvents) useAppStateTrigger('background', 'active', processWidgetEvents)
...@@ -250,6 +255,10 @@ function DataUpdaters(): JSX.Element { ...@@ -250,6 +255,10 @@ function DataUpdaters(): JSX.Element {
setAccountAddressesUserDefaults(Object.values(accountsMap)) setAccountAddressesUserDefaults(Object.values(accountsMap))
}, [accountsMap]) }, [accountsMap])
useEffect(() => {
setI18NUserDefaults({ locale, currency: code })
}, [code, locale])
return ( return (
<> <>
<TraceUserProperties /> <TraceUserProperties />
......
import { useApolloClient } from '@apollo/client'
import React, { useState } from 'react' import React, { useState } from 'react'
import { ScrollView } from 'react-native-gesture-handler' import { ScrollView } from 'react-native-gesture-handler'
import { Action } from 'redux' import { Action } from 'redux'
...@@ -10,17 +11,20 @@ import { ModalName } from 'src/features/telemetry/constants' ...@@ -10,17 +11,20 @@ import { ModalName } from 'src/features/telemetry/constants'
import { selectCustomEndpoint } from 'src/features/tweaks/selectors' import { selectCustomEndpoint } from 'src/features/tweaks/selectors'
import { setCustomEndpoint } from 'src/features/tweaks/slice' import { setCustomEndpoint } from 'src/features/tweaks/slice'
import { Statsig } from 'statsig-react' import { Statsig } from 'statsig-react'
import { useExperiment } from 'statsig-react-native' import { useExperimentWithExposureLoggingDisabled } from 'statsig-react-native'
import { Button, Flex, Text, useDeviceInsets } from 'ui/src' import { Accordion } from 'tamagui'
import { Button, Flex, Icons, Text, useDeviceInsets } from 'ui/src'
import { spacing } from 'ui/src/theme' import { spacing } from 'ui/src/theme'
import { EXPERIMENT_NAMES, FEATURE_FLAGS } from 'wallet/src/features/experiments/constants' import { EXPERIMENT_NAMES, FEATURE_FLAGS } from 'wallet/src/features/experiments/constants'
import { useFeatureFlag } from 'wallet/src/features/experiments/hooks' import { useFeatureFlagWithExposureLoggingDisabled } from 'wallet/src/features/experiments/hooks'
export function ExperimentsModal(): JSX.Element { export function ExperimentsModal(): JSX.Element {
const insets = useDeviceInsets() const insets = useDeviceInsets()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const customEndpoint = useAppSelector(selectCustomEndpoint) const customEndpoint = useAppSelector(selectCustomEndpoint)
const apollo = useApolloClient()
const [url, setUrl] = useState<string>(customEndpoint?.url || '') const [url, setUrl] = useState<string>(customEndpoint?.url || '')
const [key, setKey] = useState<string>(customEndpoint?.key || '') const [key, setKey] = useState<string>(customEndpoint?.key || '')
...@@ -48,51 +52,113 @@ export function ExperimentsModal(): JSX.Element { ...@@ -48,51 +52,113 @@ export function ExperimentsModal(): JSX.Element {
renderBehindBottomInset renderBehindBottomInset
name={ModalName.Experiments} name={ModalName.Experiments}
onClose={(): Action => dispatch(closeModal({ name: ModalName.Experiments }))}> onClose={(): Action => dispatch(closeModal({ name: ModalName.Experiments }))}>
<ScrollView contentContainerStyle={{ paddingBottom: insets.bottom + spacing.spacing12 }}> <ScrollView
<Flex gap="$spacing16" justifyContent="flex-start" pt="$spacing12" px="$spacing24"> contentContainerStyle={{
<Flex gap="$spacing8"> paddingBottom: insets.bottom,
<Flex gap="$spacing16" my="$spacing16"> paddingRight: spacing.spacing24,
<Text variant="subheading1">⚙️ Custom GraphQL Endpoint</Text> paddingLeft: spacing.spacing24,
}}>
<Accordion type="single">
<Accordion.Item value="graphql-endpoint">
<AccordionHeader title="⚙️ Custom GraphQL Endpoint" />
<Accordion.Content>
<Text variant="body2"> <Text variant="body2">
You will need to restart the application to pick up any changes in this section. You will need to restart the application to pick up any changes in this section.
Beware of client side caching! Beware of client side caching!
</Text> </Text>
<Flex row alignItems="center" gap="$spacing16"> <Flex row alignItems="center" gap="$spacing16">
<Text variant="body2">URL</Text> <Text variant="body2">URL</Text>
<TextInput flex={1} value={url} onChangeText={setUrl} /> <TextInput flex={1} value={url} onChangeText={setUrl} />
</Flex> </Flex>
<Flex row alignItems="center" gap="$spacing16"> <Flex row alignItems="center" gap="$spacing16">
<Text variant="body2">Key</Text> <Text variant="body2">Key</Text>
<TextInput flex={1} value={key} onChangeText={setKey} /> <TextInput flex={1} value={key} onChangeText={setKey} />
</Flex> </Flex>
<Button size="small" onPress={setEndpoint}>
<Flex grow row alignItems="center" gap="$spacing16">
<Button flex={1} size="small" onPress={setEndpoint}>
Set Set
</Button> </Button>
<Button size="small" onPress={clearEndpoint}>
<Button flex={1} size="small" onPress={clearEndpoint}>
Clear Clear
</Button> </Button>
</Flex> </Flex>
<Text variant="subheading1">⛳️ Feature Flags</Text> </Accordion.Content>
</Accordion.Item>
<Accordion.Item value="apollo-cache">
<AccordionHeader title="🚀 Apollo Cache" />
<Accordion.Content>
<Button
flex={1}
size="small"
onPress={async (): Promise<unknown> => await apollo.resetStore()}>
Reset Cache
</Button>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="feature-flags">
<AccordionHeader title="⛳️ Feature Flags" />
<Accordion.Content>
<Text variant="body2"> <Text variant="body2">
Overridden feature flags are reset when the app is restarted Overridden feature flags are reset when the app is restarted
</Text> </Text>
</Flex>
<Flex gap="$spacing12" mt="$spacing12">
{Object.values(FEATURE_FLAGS).map((featureFlag) => { {Object.values(FEATURE_FLAGS).map((featureFlag) => {
return <FeatureFlagRow key={featureFlag} featureFlag={featureFlag} /> return <FeatureFlagRow key={featureFlag} featureFlag={featureFlag} />
})} })}
<Text variant="subheading1">🔬 Experiments</Text> </Flex>
<Text variant="body2">Overridden experiments are reset when the app is restarted</Text> </Accordion.Content>
</Accordion.Item>
<Accordion.Item value="experiments">
<AccordionHeader title="🔬 Experiments" />
<Accordion.Content>
<Text variant="body2">
Overridden experiments are reset when the app is restarted
</Text>
<Flex gap="$spacing12" mt="$spacing12">
{Object.values(EXPERIMENT_NAMES).map((experiment) => { {Object.values(EXPERIMENT_NAMES).map((experiment) => {
return <ExperimentRow key={experiment} name={experiment} /> return <ExperimentRow key={experiment} name={experiment} />
})} })}
</Flex> </Flex>
</Accordion.Content>
</Accordion.Item>
</Accordion>
</ScrollView> </ScrollView>
</BottomSheetModal> </BottomSheetModal>
) )
} }
function AccordionHeader({ title }: { title: React.ReactNode }): JSX.Element {
return (
<Accordion.Header mt="$spacing12">
<Accordion.Trigger>
{({ open }: { open: boolean }): JSX.Element => (
<>
<Flex row justifyContent="space-between" width="100%">
<Text variant="subheading1">{title}</Text>
<Icons.RotatableChevron direction={open ? 'up' : 'down'} />
</Flex>
</>
)}
</Accordion.Trigger>
</Accordion.Header>
)
}
function FeatureFlagRow({ featureFlag }: { featureFlag: FEATURE_FLAGS }): JSX.Element { function FeatureFlagRow({ featureFlag }: { featureFlag: FEATURE_FLAGS }): JSX.Element {
const status = useFeatureFlag(featureFlag) const status = useFeatureFlagWithExposureLoggingDisabled(featureFlag)
return ( return (
<Flex row alignItems="center" gap="$spacing16" justifyContent="space-between"> <Flex row alignItems="center" gap="$spacing16" justifyContent="space-between">
...@@ -108,10 +174,7 @@ function FeatureFlagRow({ featureFlag }: { featureFlag: FEATURE_FLAGS }): JSX.El ...@@ -108,10 +174,7 @@ function FeatureFlagRow({ featureFlag }: { featureFlag: FEATURE_FLAGS }): JSX.El
} }
function ExperimentRow({ name }: { name: string }): JSX.Element { function ExperimentRow({ name }: { name: string }): JSX.Element {
const experiment = useExperiment(name) const experiment = useExperimentWithExposureLoggingDisabled(name)
// console.log('garydebug experiment row ' + JSON.stringify(experiment.config))
// const layer = useLayer(name)
// console.log('garydebug experiment row ' + JSON.stringify(layer))
const params = Object.entries(experiment.config.value).map(([key, value]) => ( const params = Object.entries(experiment.config.value).map(([key, value]) => (
<Flex <Flex
......
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useAppDispatch } from 'src/app/hooks' import { useAppDispatch } from 'src/app/hooks'
import { CloudStorageMnemonicBackup } from 'src/features/CloudBackup/types'
import { pushNotification } from 'wallet/src/features/notifications/slice' import { pushNotification } from 'wallet/src/features/notifications/slice'
import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks'
...@@ -35,7 +36,7 @@ export const exampleSwapSuccess = { ...@@ -35,7 +36,7 @@ export const exampleSwapSuccess = {
} }
// easiest to use inside NotificationToastWrapper before any returns // easiest to use inside NotificationToastWrapper before any returns
export const useFakeNotification = (ms?: number): void => { export const useMockNotification = (ms?: number): void => {
const [sent, setSent] = useState(false) const [sent, setSent] = useState(false)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const activeAddress = useActiveAccountAddressWithThrow() const activeAddress = useActiveAccountAddressWithThrow()
...@@ -57,3 +58,30 @@ export const useFakeNotification = (ms?: number): void => { ...@@ -57,3 +58,30 @@ export const useFakeNotification = (ms?: number): void => {
} }
}, [activeAddress, dispatch, ms, sent]) }, [activeAddress, dispatch, ms, sent])
} }
const generateRandomId = (): string => {
let randomId = '0x'
for (let i = 0; i < 40; i++) {
randomId += Math.floor(Math.random() * 16).toString(16)
}
return randomId
}
const generateRandomDate = (): number => {
const start = new Date(2023, 4, 12)
const end = new Date()
return Math.floor(
new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())).getTime() / 1000
)
}
export const useMockCloudBackups = (numberOfBackups?: number): CloudStorageMnemonicBackup[] => {
const number = numberOfBackups ?? 1
const mockBackups = Array.from({ length: number }, () => ({
mnemonicId: generateRandomId(),
createdAt: generateRandomDate(),
}))
return mockBackups
}
import React, { useMemo } from 'react' import React, { memo, useMemo } from 'react'
import { useWindowDimensions } from 'react-native'
import { useAnimatedStyle, useDerivedValue } from 'react-native-reanimated' import { useAnimatedStyle, useDerivedValue } from 'react-native-reanimated'
import { AnimatedText } from 'src/components/text/AnimatedText' import { AnimatedText } from 'src/components/text/AnimatedText'
import { Flex, useSporeColors } from 'ui/src' import { Flex, useDeviceDimensions, useSporeColors } from 'ui/src'
import { TextVariantTokens } from 'ui/src/theme' import { fonts, TextVariantTokens } from 'ui/src/theme'
import { ValueAndFormatted } from './usePrice' import { ValueAndFormatted } from './usePrice'
type AnimatedDecimalNumberProps = { type AnimatedDecimalNumberProps = {
...@@ -13,12 +14,18 @@ type AnimatedDecimalNumberProps = { ...@@ -13,12 +14,18 @@ type AnimatedDecimalNumberProps = {
decimalPartColor?: string decimalPartColor?: string
decimalThreshold?: number // below this value (not including) decimal part would have wholePartColor too decimalThreshold?: number // below this value (not including) decimal part would have wholePartColor too
testID?: string testID?: string
maxWidth?: number
maxCharPixelWidth?: number
} }
// Utility component to display decimal numbers where the decimal part // Utility component to display decimal numbers where the decimal part
// is dimmed using AnimatedText // is dimmed using AnimatedText
export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.Element { export const AnimatedDecimalNumber = memo(function AnimatedDecimalNumber(
props: AnimatedDecimalNumberProps
): JSX.Element {
const colors = useSporeColors() const colors = useSporeColors()
const { fullWidth } = useDeviceDimensions()
const { fontScale } = useWindowDimensions()
const { const {
number, number,
...@@ -28,6 +35,8 @@ export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.El ...@@ -28,6 +35,8 @@ export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.El
decimalPartColor = colors.neutral3.val, decimalPartColor = colors.neutral3.val,
decimalThreshold = 1, decimalThreshold = 1,
testID, testID,
maxWidth = fullWidth,
maxCharPixelWidth: maxCharPixelWidthProp,
} = props } = props
const wholePart = useDerivedValue( const wholePart = useDerivedValue(
...@@ -51,12 +60,37 @@ export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.El ...@@ -51,12 +60,37 @@ export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.El
} }
}, [decimalThreshold, wholePartColor, decimalPartColor]) }, [decimalThreshold, wholePartColor, decimalPartColor])
const fontSize = fonts[variant].fontSize * fontScale
// Choose the arbitrary value that looks good for the font used
const maxCharPixelWidth = maxCharPixelWidthProp ?? (2 / 3) * fontSize
const adjustedFontSize = useDerivedValue(() => {
const value = number.formatted.value
const approxWidth = value.length * maxCharPixelWidth
if (approxWidth <= maxWidth) {
return fontSize
}
const scale = Math.min(1, maxWidth / approxWidth)
return fontSize * scale
})
const animatedStyle = useAnimatedStyle(() => ({
fontSize: adjustedFontSize.value,
}))
return ( return (
<Flex row testID={testID}> <Flex row testID={testID}>
<AnimatedText style={wholeStyle} testID="wholePart" text={wholePart} variant={variant} /> <AnimatedText
style={[wholeStyle, animatedStyle]}
testID="wholePart"
text={wholePart}
variant={variant}
/>
{decimalPart.value !== separator && ( {decimalPart.value !== separator && (
<AnimatedText <AnimatedText
style={decimalStyle} style={[decimalStyle, animatedStyle]}
testID="decimalPart" testID="decimalPart"
text={decimalPart} text={decimalPart}
variant={variant} variant={variant}
...@@ -64,4 +98,4 @@ export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.El ...@@ -64,4 +98,4 @@ export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.El
)} )}
</Flex> </Flex>
) )
} })
import { ImpactFeedbackStyle } from 'expo-haptics' import { ImpactFeedbackStyle } from 'expo-haptics'
import React, { useMemo } from 'react' import React, { memo, useMemo } from 'react'
import { I18nManager } from 'react-native' import { I18nManager } from 'react-native'
import { SharedValue } from 'react-native-reanimated' import { SharedValue } from 'react-native-reanimated'
import { import {
...@@ -15,7 +15,8 @@ import { DatetimeText, PriceText, RelativeChangeText } from 'src/components/Pric ...@@ -15,7 +15,8 @@ import { DatetimeText, PriceText, RelativeChangeText } from 'src/components/Pric
import { TimeRangeGroup } from 'src/components/PriceExplorer/TimeRangeGroup' import { TimeRangeGroup } from 'src/components/PriceExplorer/TimeRangeGroup'
import { useChartDimensions } from 'src/components/PriceExplorer/useChartDimensions' import { useChartDimensions } from 'src/components/PriceExplorer/useChartDimensions'
import { invokeImpact } from 'src/utils/haptic' import { invokeImpact } from 'src/utils/haptic'
import { Flex } from 'ui/src' import { Flex, useDeviceDimensions } from 'ui/src'
import { spacing } from 'ui/src/theme'
import { HistoryDuration } from 'wallet/src/data/__generated__/types-and-hooks' import { HistoryDuration } from 'wallet/src/data/__generated__/types-and-hooks'
import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
import { CurrencyId } from 'wallet/src/utils/currencyId' import { CurrencyId } from 'wallet/src/utils/currencyId'
...@@ -27,9 +28,15 @@ type PriceTextProps = { ...@@ -27,9 +28,15 @@ type PriceTextProps = {
} }
function PriceTextSection({ loading, relativeChange }: PriceTextProps): JSX.Element { function PriceTextSection({ loading, relativeChange }: PriceTextProps): JSX.Element {
const { fullWidth } = useDeviceDimensions()
const mx = spacing.spacing12
return ( return (
<Flex mx="$spacing12"> <Flex mx={mx}>
<PriceText loading={loading} /> {/* Specify maxWidth to allow text scalling. onLayout was sometimes called after more
than 5 seconds which is not acceptable so we have to provide the approximate width
of the PriceText component explicitly. */}
<PriceText loading={loading} maxWidth={fullWidth - 2 * mx} />
<Flex row gap="$spacing4"> <Flex row gap="$spacing4">
<RelativeChangeText loading={loading} spotRelativeChange={relativeChange} /> <RelativeChangeText loading={loading} spotRelativeChange={relativeChange} />
<DatetimeText loading={loading} /> <DatetimeText loading={loading} />
...@@ -42,7 +49,7 @@ export type LineChartPriceAndDateTimeTextProps = { ...@@ -42,7 +49,7 @@ export type LineChartPriceAndDateTimeTextProps = {
currencyId: CurrencyId currencyId: CurrencyId
} }
export function PriceExplorer({ export const PriceExplorer = memo(function PriceExplorer({
currencyId, currencyId,
tokenColor, tokenColor,
forcePlaceholder, forcePlaceholder,
...@@ -115,7 +122,7 @@ export function PriceExplorer({ ...@@ -115,7 +122,7 @@ export function PriceExplorer({
<TimeRangeGroup setDuration={setDuration} /> <TimeRangeGroup setDuration={setDuration} />
</Flex> </Flex>
) )
} })
function PriceExplorerPlaceholder({ loading }: { loading: boolean }): JSX.Element { function PriceExplorerPlaceholder({ loading }: { loading: boolean }): JSX.Element {
return ( return (
......
...@@ -10,7 +10,13 @@ import { isAndroid } from 'wallet/src/utils/platform' ...@@ -10,7 +10,13 @@ import { isAndroid } from 'wallet/src/utils/platform'
import { AnimatedDecimalNumber } from './AnimatedDecimalNumber' import { AnimatedDecimalNumber } from './AnimatedDecimalNumber'
import { useLineChartPrice, useLineChartRelativeChange } from './usePrice' import { useLineChartPrice, useLineChartRelativeChange } from './usePrice'
export function PriceText({ loading }: { loading: boolean }): JSX.Element { export function PriceText({
loading,
maxWidth,
}: {
loading: boolean
maxWidth?: number
}): JSX.Element {
const price = useLineChartPrice() const price = useLineChartPrice()
const colors = useSporeColors() const colors = useSporeColors()
const currency = useAppFiatCurrency() const currency = useAppFiatCurrency()
...@@ -28,6 +34,7 @@ export function PriceText({ loading }: { loading: boolean }): JSX.Element { ...@@ -28,6 +34,7 @@ export function PriceText({ loading }: { loading: boolean }): JSX.Element {
return ( return (
<AnimatedDecimalNumber <AnimatedDecimalNumber
decimalPartColor={shouldFadePortfolioDecimals ? colors.neutral3.val : colors.neutral1.val} decimalPartColor={shouldFadePortfolioDecimals ? colors.neutral3.val : colors.neutral1.val}
maxWidth={maxWidth}
number={price} number={price}
separator={decimalSeparator} separator={decimalSeparator}
testID="price-text" testID="price-text"
......
...@@ -174,9 +174,14 @@ exports[`PriceText renders without error 1`] = ` ...@@ -174,9 +174,14 @@ exports[`PriceText renders without error 1`] = `
"fontSize": 53, "fontSize": 53,
"lineHeight": 60, "lineHeight": 60,
}, },
[
{ {
"color": "#222222", "color": "#222222",
}, },
{
"fontSize": 106,
},
],
] ]
} }
testID="wholePart" testID="wholePart"
...@@ -202,9 +207,14 @@ exports[`PriceText renders without error 1`] = ` ...@@ -202,9 +207,14 @@ exports[`PriceText renders without error 1`] = `
"fontSize": 53, "fontSize": 53,
"lineHeight": 60, "lineHeight": 60,
}, },
[
{ {
"color": "#CECECE", "color": "#CECECE",
}, },
{
"fontSize": 106,
},
],
] ]
} }
testID="decimalPart" testID="decimalPart"
...@@ -243,9 +253,14 @@ exports[`PriceText renders without error less than a dollar 1`] = ` ...@@ -243,9 +253,14 @@ exports[`PriceText renders without error less than a dollar 1`] = `
"fontSize": 53, "fontSize": 53,
"lineHeight": 60, "lineHeight": 60,
}, },
[
{ {
"color": "#222222", "color": "#222222",
}, },
{
"fontSize": 106,
},
],
] ]
} }
testID="wholePart" testID="wholePart"
...@@ -271,9 +286,14 @@ exports[`PriceText renders without error less than a dollar 1`] = ` ...@@ -271,9 +286,14 @@ exports[`PriceText renders without error less than a dollar 1`] = `
"fontSize": 53, "fontSize": 53,
"lineHeight": 60, "lineHeight": 60,
}, },
[
{ {
"color": "#222222", "color": "#222222",
}, },
{
"fontSize": 106,
},
],
] ]
} }
testID="decimalPart" testID="decimalPart"
......
import { useMemo } from 'react'
import { SharedValue, useDerivedValue } from 'react-native-reanimated' import { SharedValue, useDerivedValue } from 'react-native-reanimated'
import { import {
useLineChart, useLineChart,
...@@ -44,10 +45,14 @@ export function useLineChartPrice(): ValueAndFormatted { ...@@ -44,10 +45,14 @@ export function useLineChartPrice(): ValueAndFormatted {
currencyInfo.symbol currencyInfo.symbol
) )
}) })
return {
return useMemo(
() => ({
value: price, value: price,
formatted: priceFormatted, formatted: priceFormatted,
} }),
[price, priceFormatted]
)
} }
/** /**
......
import React, { memo } from 'react'
import { FadeIn, FadeOut } from 'react-native-reanimated'
import { TokenFiatOnRampList } from 'src/components/TokenSelector/TokenFiatOnRampList'
import Trace from 'src/components/Trace/Trace'
import { FiatOnRampCurrency } from 'src/features/fiatOnRamp/types'
import { ElementName, SectionName } from 'src/features/telemetry/constants'
import { AnimatedFlex } from 'ui/src'
interface Props {
onBack: () => void
onSelectCurrency: (currency: FiatOnRampCurrency) => void
}
function _FiatOnRampTokenSelector({ onSelectCurrency, onBack }: Props): JSX.Element {
return (
<Trace
logImpression
element={ElementName.FiatOnRampTokenSelector}
section={SectionName.TokenSelector}>
<AnimatedFlex
entering={FadeIn}
exiting={FadeOut}
gap="$spacing12"
overflow="hidden"
px="$spacing16"
width="100%">
<TokenFiatOnRampList onBack={onBack} onSelectCurrency={onSelectCurrency} />
</AnimatedFlex>
</Trace>
)
}
export const FiatOnRampTokenSelector = memo(_FiatOnRampTokenSelector)
...@@ -3,70 +3,21 @@ import React, { memo, useCallback, useMemo, useRef } from 'react' ...@@ -3,70 +3,21 @@ import React, { memo, useCallback, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ListRenderItemInfo } from 'react-native' import { ListRenderItemInfo } from 'react-native'
import { Loader } from 'src/components/loading' import { Loader } from 'src/components/loading'
import { useAllCommonBaseCurrencies } from 'src/components/TokenSelector/hooks'
import { TokenOptionItem } from 'src/components/TokenSelector/TokenOptionItem' import { TokenOptionItem } from 'src/components/TokenSelector/TokenOptionItem'
import { useFiatOnRampSupportedTokens } from 'src/features/fiatOnRamp/hooks'
import { FiatOnRampCurrency } from 'src/features/fiatOnRamp/types' import { FiatOnRampCurrency } from 'src/features/fiatOnRamp/types'
import { ElementName } from 'src/features/telemetry/constants' import { ElementName } from 'src/features/telemetry/constants'
import { Flex, Icons, Inset, Text, TouchableArea } from 'ui/src' import { Flex, Icons, Inset, Text, TouchableArea } from 'ui/src'
import { BaseCard } from 'wallet/src/components/BaseCard/BaseCard' import { BaseCard } from 'wallet/src/components/BaseCard/BaseCard'
import { ChainId } from 'wallet/src/constants/chains' import { ChainId } from 'wallet/src/constants/chains'
import { fromMoonpayNetwork } from 'wallet/src/features/chains/utils'
import { CurrencyInfo, GqlResult } from 'wallet/src/features/dataApi/types'
import { MoonpayCurrency } from 'wallet/src/features/fiatOnRamp/types'
import { CurrencyId } from 'wallet/src/utils/currencyId' import { CurrencyId } from 'wallet/src/utils/currencyId'
interface Props { interface Props {
onSelectCurrency: (currency: FiatOnRampCurrency) => void onSelectCurrency: (currency: FiatOnRampCurrency) => void
onBack: () => void onBack: () => void
} onRetry: () => void
error: boolean
const findTokenOptionForMoonpayCurrency = ( loading: boolean
commonBaseCurrencies: CurrencyInfo[] | undefined, list: FiatOnRampCurrency[] | undefined
moonpayCurrency: MoonpayCurrency
): Maybe<CurrencyInfo> => {
return (commonBaseCurrencies || []).find((item) => {
const [code, network] = moonpayCurrency.code.split('_') ?? [undefined, undefined]
const chainId = fromMoonpayNetwork(network)
return (
item &&
code &&
code === item.currency.symbol?.toLowerCase() &&
chainId === item.currency.chainId
)
})
}
function useFiatOnRampTokenList(
supportedTokens: MoonpayCurrency[] | undefined
): GqlResult<FiatOnRampCurrency[]> {
const {
data: commonBaseCurrencies,
error: commonBaseCurrenciesError,
loading: commonBaseCurrenciesLoading,
refetch: refetchCommonBaseCurrencies,
} = useAllCommonBaseCurrencies()
const data = useMemo(
() =>
(supportedTokens || [])
.map((moonpayCurrency) => ({
currencyInfo: findTokenOptionForMoonpayCurrency(commonBaseCurrencies, moonpayCurrency),
moonpayCurrency,
}))
.filter((item) => !!item.currencyInfo),
[commonBaseCurrencies, supportedTokens]
)
return useMemo(
() => ({
data,
loading: commonBaseCurrenciesLoading,
error: commonBaseCurrenciesError,
refetch: refetchCommonBaseCurrencies,
}),
[commonBaseCurrenciesError, commonBaseCurrenciesLoading, data, refetchCommonBaseCurrencies]
)
} }
function TokenOptionItemWrapper({ function TokenOptionItemWrapper({
...@@ -100,20 +51,18 @@ function TokenOptionItemWrapper({ ...@@ -100,20 +51,18 @@ function TokenOptionItemWrapper({
) )
} }
function _TokenFiatOnRampList({ onSelectCurrency, onBack }: Props): JSX.Element { function _TokenFiatOnRampList({
onSelectCurrency,
onBack,
error,
onRetry,
list,
loading,
}: Props): JSX.Element {
const { t } = useTranslation() const { t } = useTranslation()
const flatListRef = useRef(null) const flatListRef = useRef(null)
const {
data: supportedTokens,
isLoading: supportedTokensLoading,
isError: supportedTokensQueryError,
refetch: supportedTokensQueryRefetch,
} = useFiatOnRampSupportedTokens()
const { data, loading, error, refetch } = useFiatOnRampTokenList(supportedTokens)
const renderItem = useCallback( const renderItem = useCallback(
({ item: currency }: ListRenderItemInfo<FiatOnRampCurrency>) => { ({ item: currency }: ListRenderItemInfo<FiatOnRampCurrency>) => {
return <TokenOptionItemWrapper currency={currency} onSelectCurrency={onSelectCurrency} /> return <TokenOptionItemWrapper currency={currency} onSelectCurrency={onSelectCurrency} />
...@@ -121,7 +70,7 @@ function _TokenFiatOnRampList({ onSelectCurrency, onBack }: Props): JSX.Element ...@@ -121,7 +70,7 @@ function _TokenFiatOnRampList({ onSelectCurrency, onBack }: Props): JSX.Element
[onSelectCurrency] [onSelectCurrency]
) )
if (supportedTokensQueryError || error) { if (error) {
return ( return (
<> <>
<Header onBack={onBack} /> <Header onBack={onBack} />
...@@ -129,21 +78,14 @@ function _TokenFiatOnRampList({ onSelectCurrency, onBack }: Props): JSX.Element ...@@ -129,21 +78,14 @@ function _TokenFiatOnRampList({ onSelectCurrency, onBack }: Props): JSX.Element
<BaseCard.ErrorState <BaseCard.ErrorState
retryButtonLabel="Retry" retryButtonLabel="Retry"
title={t('Couldn’t load tokens to buy')} title={t('Couldn’t load tokens to buy')}
onRetry={(): void => { onRetry={onRetry}
if (supportedTokensQueryError) {
supportedTokensQueryRefetch?.()
}
if (error) {
refetch?.()
}
}}
/> />
</Flex> </Flex>
</> </>
) )
} }
if (supportedTokensLoading || loading) { if (loading) {
return ( return (
<Flex> <Flex>
<Header onBack={onBack} /> <Header onBack={onBack} />
...@@ -159,7 +101,7 @@ function _TokenFiatOnRampList({ onSelectCurrency, onBack }: Props): JSX.Element ...@@ -159,7 +101,7 @@ function _TokenFiatOnRampList({ onSelectCurrency, onBack }: Props): JSX.Element
ref={flatListRef} ref={flatListRef}
ListEmptyComponent={<Flex />} ListEmptyComponent={<Flex />}
ListFooterComponent={<Inset all="$spacing36" />} ListFooterComponent={<Inset all="$spacing36" />}
data={data} data={list}
keyExtractor={key} keyExtractor={key}
keyboardDismissMode="on-drag" keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="always" keyboardShouldPersistTaps="always"
......
...@@ -13,6 +13,7 @@ import { disableOnPress } from 'src/utils/disableOnPress' ...@@ -13,6 +13,7 @@ import { disableOnPress } from 'src/utils/disableOnPress'
import { Flex, Text, TouchableArea } from 'ui/src' import { Flex, Text, TouchableArea } from 'ui/src'
import { iconSizes } from 'ui/src/theme' import { iconSizes } from 'ui/src/theme'
import { NumberType } from 'utilities/src/format/types' import { NumberType } from 'utilities/src/format/types'
import { useAccountListQuery } from 'wallet/src/data/__generated__/types-and-hooks'
import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
import { pushNotification } from 'wallet/src/features/notifications/slice' import { pushNotification } from 'wallet/src/features/notifications/slice'
import { AppNotificationType, CopyNotificationType } from 'wallet/src/features/notifications/types' import { AppNotificationType, CopyNotificationType } from 'wallet/src/features/notifications/types'
...@@ -24,24 +25,39 @@ type AccountCardItemProps = { ...@@ -24,24 +25,39 @@ type AccountCardItemProps = {
} & PortfolioValueProps } & PortfolioValueProps
type PortfolioValueProps = { type PortfolioValueProps = {
address: Address
isPortfolioValueLoading: boolean isPortfolioValueLoading: boolean
portfolioValue: number | undefined portfolioValue: number | undefined
} }
function PortfolioValue({ function PortfolioValue({
address,
isPortfolioValueLoading, isPortfolioValueLoading,
portfolioValue, portfolioValue: providedPortfolioValue,
}: PortfolioValueProps): JSX.Element { }: PortfolioValueProps): JSX.Element {
const isLoading = isPortfolioValueLoading && portfolioValue === undefined const { t } = useTranslation()
const { convertFiatAmountFormatted } = useLocalizationContext() const { convertFiatAmountFormatted } = useLocalizationContext()
// When we add a new wallet, we'll make a new network request to fetch all accounts as a single request.
// Since we're adding a new wallet address to the `ownerAddresses` array, this will be a brand new query, which won't be cached.
// To avoid all wallets showing a "loading" state, we read directly from cache while we wait for the other query to complete.
const { data } = useAccountListQuery({
fetchPolicy: 'cache-first',
variables: { addresses: address },
})
const cachedPortfolioValue = data?.portfolios?.[0]?.tokensTotalDenominatedValue?.value
const portfolioValue = providedPortfolioValue ?? cachedPortfolioValue
const isLoading = isPortfolioValueLoading && portfolioValue === undefined
return ( return (
<Text <Text color="$neutral2" loading={isLoading} variant="subheading2">
color="$neutral2" {portfolioValue
loading={isLoading} ? convertFiatAmountFormatted(portfolioValue, NumberType.PortfolioBalance)
loadingPlaceholderText="0000.00" : t('N/A')}
variant="subheading2">
{convertFiatAmountFormatted(portfolioValue, NumberType.PortfolioBalance)}
</Text> </Text>
) )
} }
...@@ -126,6 +142,7 @@ export function AccountCardItem({ ...@@ -126,6 +142,7 @@ export function AccountCardItem({
/> />
</Flex> </Flex>
<PortfolioValue <PortfolioValue
address={address}
isPortfolioValueLoading={isPortfolioValueLoading} isPortfolioValueLoading={isPortfolioValueLoading}
portfolioValue={portfolioValue} portfolioValue={portfolioValue}
/> />
......
query AccountList($addresses: [String!]!) { query AccountList(
portfolios(ownerAddresses: $addresses, chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]) { $addresses: [String!]!
$valueModifiers: [PortfolioValueModifier!]
) {
portfolios(
ownerAddresses: $addresses
chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]
valueModifiers: $valueModifiers
) {
id id
ownerAddress ownerAddress
tokensTotalDenominatedValue { tokensTotalDenominatedValue {
......
...@@ -97,7 +97,8 @@ export function AccountList({ accounts, onPress, isVisible }: AccountListProps): ...@@ -97,7 +97,8 @@ export function AccountList({ accounts, onPress, isVisible }: AccountListProps):
const hasViewOnlyAccounts = viewOnlyAccounts.length > 0 const hasViewOnlyAccounts = viewOnlyAccounts.length > 0
const renderAccountCardItem = (item: AccountWithPortfolioValue): JSX.Element => ( const renderAccountCardItem = useCallback(
(item: AccountWithPortfolioValue): JSX.Element => (
<AccountCardItem <AccountCardItem
key={item.account.address} key={item.account.address}
address={item.account.address} address={item.account.address}
...@@ -106,6 +107,8 @@ export function AccountList({ accounts, onPress, isVisible }: AccountListProps): ...@@ -106,6 +107,8 @@ export function AccountList({ accounts, onPress, isVisible }: AccountListProps):
portfolioValue={item.portfolioValue} portfolioValue={item.portfolioValue}
onPress={onPress} onPress={onPress}
/> />
),
[onPress]
) )
return ( return (
......
...@@ -60,7 +60,7 @@ export function LongText(props: LongTextProps): JSX.Element { ...@@ -60,7 +60,7 @@ export function LongText(props: LongTextProps): JSX.Element {
const onTextLayout = useCallback( const onTextLayout = useCallback(
(e: NativeSyntheticEvent<TextLayoutEventData>) => { (e: NativeSyntheticEvent<TextLayoutEventData>) => {
setTextLengthExceedsLimit(e.nativeEvent.lines.length >= initialDisplayedLines) setTextLengthExceedsLimit(e.nativeEvent.lines.length > initialDisplayedLines)
}, },
[initialDisplayedLines] [initialDisplayedLines]
) )
......
...@@ -48,6 +48,7 @@ export const usePersistedApolloClient = (): ApolloClient<NormalizedCacheObject> ...@@ -48,6 +48,7 @@ export const usePersistedApolloClient = (): ApolloClient<NormalizedCacheObject>
} }
const newClient = new ApolloClient({ const newClient = new ApolloClient({
assumeImmutableResults: true,
link: from([ link: from([
getErrorLink(), getErrorLink(),
// requires typing outside of wallet package // requires typing outside of wallet package
......
import React, { memo, useMemo } from 'react'
import { FadeIn, FadeOut } from 'react-native-reanimated'
import { TokenFiatOnRampList } from 'src/components/TokenSelector/TokenFiatOnRampList'
import Trace from 'src/components/Trace/Trace'
import { FiatOnRampCurrency } from 'src/features/fiatOnRamp/types'
import { ElementName, SectionName } from 'src/features/telemetry/constants'
import { AnimatedFlex } from 'ui/src'
import { useAllCommonBaseCurrencies } from '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'
interface Props {
onBack: () => void
onSelectCurrency: (currency: FiatOnRampCurrency) => void
sourceCurrencyCode: string
countryCode: string
}
const findTokenOptionForMeldCurrency = (
commonBaseCurrencies: CurrencyInfo[] | undefined,
meldCurrency: MeldCryptoCurrency
): Maybe<CurrencyInfo> => {
return (commonBaseCurrencies || []).find(
(item) =>
item &&
meldCurrency.cryptoCurrencyCode.toLowerCase() === item.currency.symbol?.toLowerCase() &&
meldCurrency.chainId === item.currency.chainId.toString()
)
}
function useFiatOnRampTokenList(
supportedTokens: MeldCryptoCurrency[] | undefined
): GqlResult<FiatOnRampCurrency[]> {
const {
data: commonBaseCurrencies,
error: commonBaseCurrenciesError,
loading: commonBaseCurrenciesLoading,
refetch: refetchCommonBaseCurrencies,
} = useAllCommonBaseCurrencies()
const data = useMemo(
() =>
(supportedTokens || [])
.map((meldCurrency) => ({
currencyInfo: findTokenOptionForMeldCurrency(commonBaseCurrencies, meldCurrency),
}))
.filter((item) => !!item.currencyInfo),
[commonBaseCurrencies, supportedTokens]
)
return {
data,
loading: commonBaseCurrenciesLoading,
error: commonBaseCurrenciesError,
refetch: refetchCommonBaseCurrencies,
}
}
function _FiatOnRampAggregatorTokenSelector({
onSelectCurrency,
onBack,
sourceCurrencyCode,
countryCode,
}: Props): JSX.Element {
const {
data: supportedTokens,
isLoading: supportedTokensLoading,
error: supportedTokensQueryError,
refetch: supportedTokensQueryRefetch,
} = useFiatOnRampAggregatorSupportedTokensQuery({ fiatCurrency: sourceCurrencyCode, countryCode })
const {
data: tokenList,
loading: tokenListLoading,
error: tokenListError,
refetch: tokenListRefetch,
} = useFiatOnRampTokenList(supportedTokens)
const loading = supportedTokensLoading || tokenListLoading
const error = Boolean(supportedTokensQueryError || tokenListError)
const onRetry = async (): Promise<void> => {
if (supportedTokensQueryError) {
await supportedTokensQueryRefetch?.()
}
if (tokenListError) {
tokenListRefetch?.()
}
}
return (
<Trace
logImpression
element={ElementName.FiatOnRampAggregatorTokenSelector}
section={SectionName.TokenSelector}>
<AnimatedFlex
entering={FadeIn}
exiting={FadeOut}
gap="$spacing12"
overflow="hidden"
px="$spacing16"
width="100%">
<TokenFiatOnRampList
error={error}
list={tokenList}
loading={loading}
onBack={onBack}
onRetry={onRetry}
onSelectCurrency={onSelectCurrency}
/>
</AnimatedFlex>
</Trace>
)
}
export const FiatOnRampAggregatorTokenSelector = memo(_FiatOnRampAggregatorTokenSelector)
...@@ -15,12 +15,12 @@ import { DecimalPad } from 'src/components/input/DecimalPad' ...@@ -15,12 +15,12 @@ import { DecimalPad } from 'src/components/input/DecimalPad'
import { TextInputProps } from 'src/components/input/TextInput' import { TextInputProps } from 'src/components/input/TextInput'
import { useBottomSheetContext } from 'src/components/modals/BottomSheetContext' import { useBottomSheetContext } from 'src/components/modals/BottomSheetContext'
import { BottomSheetModal } from 'src/components/modals/BottomSheetModal' import { BottomSheetModal } from 'src/components/modals/BottomSheetModal'
import { FiatOnRampTokenSelector } from 'src/components/TokenSelector/FiatOnRampTokenSelector'
import { FiatOnRampAmountSection } from 'src/features/fiatOnRamp/FiatOnRampAmountSection' import { FiatOnRampAmountSection } from 'src/features/fiatOnRamp/FiatOnRampAmountSection'
import { import {
FiatOnRampConnectingView, FiatOnRampConnectingView,
SERVICE_PROVIDER_ICON_SIZE, SERVICE_PROVIDER_ICON_SIZE,
} from 'src/features/fiatOnRamp/FiatOnRampConnecting' } from 'src/features/fiatOnRamp/FiatOnRampConnecting'
import { FiatOnRampTokenSelector } from 'src/features/fiatOnRamp/FiatOnRampTokenSelector'
import { import {
useMoonpayFiatCurrencySupportInfo, useMoonpayFiatCurrencySupportInfo,
useMoonpayFiatOnRamp, useMoonpayFiatOnRamp,
...@@ -97,15 +97,6 @@ function FiatOnRampContent({ onClose }: { onClose: () => void }): JSX.Element { ...@@ -97,15 +97,6 @@ function FiatOnRampContent({ onClose }: { onClose: () => void }): JSX.Element {
const [currency, setCurrency] = useState<FiatOnRampCurrency>({ const [currency, setCurrency] = useState<FiatOnRampCurrency>({
currencyInfo: ethCurrencyInfo, currencyInfo: ethCurrencyInfo,
moonpayCurrency: {
code: 'eth',
type: 'crypto',
id: '',
supportsLiveMode: true,
supportsTestMode: true,
isSupportedInUS: true,
notAllowedUSStates: [],
},
}) })
const { appFiatCurrencySupportedInMoonpay, moonpaySupportedFiatCurrency } = const { appFiatCurrencySupportedInMoonpay, moonpaySupportedFiatCurrency } =
...@@ -140,7 +131,7 @@ function FiatOnRampContent({ onClose }: { onClose: () => void }): JSX.Element { ...@@ -140,7 +131,7 @@ function FiatOnRampContent({ onClose }: { onClose: () => void }): JSX.Element {
errorColor, errorColor,
} = useMoonpayFiatOnRamp({ } = useMoonpayFiatOnRamp({
baseCurrencyAmount: value, baseCurrencyAmount: value,
quoteCurrencyCode: currency.moonpayCurrency.code, quoteCurrencyCode: currency.currencyInfo?.currency.symbol,
}) })
useTimeout( useTimeout(
......
import React, { memo, useMemo } from 'react'
import { FadeIn, FadeOut } from 'react-native-reanimated'
import { TokenFiatOnRampList } from 'src/components/TokenSelector/TokenFiatOnRampList'
import Trace from 'src/components/Trace/Trace'
import { useFiatOnRampSupportedTokens } from 'src/features/fiatOnRamp/hooks'
import { FiatOnRampCurrency } from 'src/features/fiatOnRamp/types'
import { ElementName, SectionName } from 'src/features/telemetry/constants'
import { AnimatedFlex } from 'ui/src'
import { useAllCommonBaseCurrencies } from 'src/components/TokenSelector/hooks'
import { fromMoonpayNetwork } from 'wallet/src/features/chains/utils'
import { CurrencyInfo, GqlResult } from 'wallet/src/features/dataApi/types'
import { MoonpayCurrency } from 'wallet/src/features/fiatOnRamp/types'
interface Props {
onBack: () => void
onSelectCurrency: (currency: FiatOnRampCurrency) => void
}
const findTokenOptionForMoonpayCurrency = (
commonBaseCurrencies: CurrencyInfo[] | undefined,
moonpayCurrency: MoonpayCurrency
): Maybe<CurrencyInfo> => {
return (commonBaseCurrencies || []).find((item) => {
const [code, network] = moonpayCurrency.code.split('_') ?? [undefined, undefined]
const chainId = fromMoonpayNetwork(network)
return (
item &&
code &&
code === item.currency.symbol?.toLowerCase() &&
chainId === item.currency.chainId
)
})
}
function useFiatOnRampTokenList(
supportedTokens: MoonpayCurrency[] | undefined
): GqlResult<FiatOnRampCurrency[]> {
const {
data: commonBaseCurrencies,
error: commonBaseCurrenciesError,
loading: commonBaseCurrenciesLoading,
refetch: refetchCommonBaseCurrencies,
} = useAllCommonBaseCurrencies()
const data = useMemo(
() =>
(supportedTokens || [])
.map((moonpayCurrency) => ({
currencyInfo: findTokenOptionForMoonpayCurrency(commonBaseCurrencies, moonpayCurrency),
moonpayCurrency,
}))
.filter((item) => !!item.currencyInfo),
[commonBaseCurrencies, supportedTokens]
)
return useMemo(
() => ({
data,
loading: commonBaseCurrenciesLoading,
error: commonBaseCurrenciesError,
refetch: refetchCommonBaseCurrencies,
}),
[commonBaseCurrenciesError, commonBaseCurrenciesLoading, data, refetchCommonBaseCurrencies]
)
}
function _FiatOnRampTokenSelector({ onSelectCurrency, onBack }: Props): JSX.Element {
const {
data: supportedTokens,
isLoading: supportedTokensLoading,
isError: supportedTokensQueryError,
refetch: supportedTokensQueryRefetch,
} = useFiatOnRampSupportedTokens()
const {
data: tokenList,
loading: tokenListLoading,
error: tokenListError,
refetch: tokenListRefetch,
} = useFiatOnRampTokenList(supportedTokens)
const loading = supportedTokensLoading || tokenListLoading
const error = Boolean(supportedTokensQueryError || tokenListError)
const onRetry = (): void => {
if (supportedTokensQueryError) {
supportedTokensQueryRefetch?.()
}
if (tokenListError) {
tokenListRefetch?.()
}
}
return (
<Trace
logImpression
element={ElementName.FiatOnRampTokenSelector}
section={SectionName.TokenSelector}>
<AnimatedFlex
entering={FadeIn}
exiting={FadeOut}
gap="$spacing12"
overflow="hidden"
px="$spacing16"
width="100%">
<TokenFiatOnRampList
error={error}
list={tokenList}
loading={loading}
onBack={onBack}
onRetry={onRetry}
onSelectCurrency={onSelectCurrency}
/>
</AnimatedFlex>
</Trace>
)
}
export const FiatOnRampTokenSelector = memo(_FiatOnRampTokenSelector)
import { skipToken } from '@reduxjs/toolkit/query/react'
import { Currency } from '@uniswap/sdk-core' import { Currency } from '@uniswap/sdk-core'
import { useCallback, useRef } from 'react' import { useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
...@@ -151,7 +152,7 @@ export function useMoonpayFiatOnRamp({ ...@@ -151,7 +152,7 @@ export function useMoonpayFiatOnRamp({
quoteCurrencyCode, quoteCurrencyCode,
}: { }: {
baseCurrencyAmount: string baseCurrencyAmount: string
quoteCurrencyCode: string quoteCurrencyCode: string | undefined
}): { }): {
eligible: boolean eligible: boolean
quoteAmount: number quoteAmount: number
...@@ -166,7 +167,6 @@ export function useMoonpayFiatOnRamp({ ...@@ -166,7 +167,6 @@ export function useMoonpayFiatOnRamp({
errorColor?: ColorTokens errorColor?: ColorTokens
} { } {
const colors = useSporeColors() const colors = useSporeColors()
const { t } = useTranslation()
const debouncedBaseCurrencyAmount = useDebounce(baseCurrencyAmount, Delay.Short) const debouncedBaseCurrencyAmount = useDebounce(baseCurrencyAmount, Delay.Short)
...@@ -185,11 +185,15 @@ export function useMoonpayFiatOnRamp({ ...@@ -185,11 +185,15 @@ export function useMoonpayFiatOnRamp({
data: limitsData, data: limitsData,
isLoading: limitsLoading, isLoading: limitsLoading,
isError: limitsLoadingQueryError, isError: limitsLoadingQueryError,
} = useFiatOnRampLimitsQuery({ } = useFiatOnRampLimitsQuery(
quoteCurrencyCode
? {
baseCurrencyCode, baseCurrencyCode,
quoteCurrencyCode, quoteCurrencyCode,
areFeesIncluded: MOONPAY_FEES_INCLUDED, areFeesIncluded: MOONPAY_FEES_INCLUDED,
}) }
: skipToken
)
const { maxBuyAmount } = limitsData?.baseCurrency ?? { const { maxBuyAmount } = limitsData?.baseCurrency ?? {
maxBuyAmount: Infinity, maxBuyAmount: Infinity,
...@@ -214,7 +218,8 @@ export function useMoonpayFiatOnRamp({ ...@@ -214,7 +218,8 @@ export function useMoonpayFiatOnRamp({
} = useFiatOnRampWidgetUrlQuery( } = useFiatOnRampWidgetUrlQuery(
// PERF: could consider skipping this call until eligibility in determined (ux tradeoffs) // PERF: could consider skipping this call until eligibility in determined (ux tradeoffs)
// as-is, avoids waterfalling requests => better ux // as-is, avoids waterfalling requests => better ux
{ quoteCurrencyCode
? {
ownerAddress: activeAccountAddress, ownerAddress: activeAccountAddress,
colorCode: colors.accent1.val, colorCode: colors.accent1.val,
externalTransactionId, externalTransactionId,
...@@ -225,26 +230,28 @@ export function useMoonpayFiatOnRamp({ ...@@ -225,26 +230,28 @@ export function useMoonpayFiatOnRamp({
isAndroid ? uniswapUrls.appUrl : uniswapUrls.appBaseUrl isAndroid ? uniswapUrls.appUrl : uniswapUrls.appBaseUrl
}/?screen=transaction&fiatOnRamp=true&userAddress=${activeAccountAddress}`, }/?screen=transaction&fiatOnRamp=true&userAddress=${activeAccountAddress}`,
} }
: skipToken
) )
const { const {
data: buyQuote, data: buyQuote,
isFetching: buyQuoteLoading, isFetching: buyQuoteLoading,
isError: buyQuoteLoadingQueryError, isError: buyQuoteLoadingQueryError,
} = useFiatOnRampBuyQuoteQuery( } = useFiatOnRampBuyQuoteQuery(
{
baseCurrencyCode,
baseCurrencyAmount: debouncedBaseCurrencyAmount,
quoteCurrencyCode,
areFeesIncluded: MOONPAY_FEES_INCLUDED,
},
{
// When isBaseCurrencyAmountValid is false and the user enters any digit, // When isBaseCurrencyAmountValid is false and the user enters any digit,
// isBaseCurrencyAmountValid becomes true. Since there were no prior calls to the API, // isBaseCurrencyAmountValid becomes true. Since there were no prior calls to the API,
// it takes the debouncedBaseCurrencyAmount and immediately calls an API. // it takes the debouncedBaseCurrencyAmount and immediately calls an API.
// This only truly matters in the beginning and in cases where the debouncedBaseCurrencyAmount // This only truly matters in the beginning and in cases where the debouncedBaseCurrencyAmount
// is changed while isBaseCurrencyAmountValid is false." // is changed while isBaseCurrencyAmountValid is false."
skip: !isBaseCurrencyAmountValid || debouncedBaseCurrencyAmount !== baseCurrencyAmount, quoteCurrencyCode &&
isBaseCurrencyAmountValid &&
debouncedBaseCurrencyAmount === baseCurrencyAmount
? {
baseCurrencyCode,
baseCurrencyAmount: debouncedBaseCurrencyAmount,
quoteCurrencyCode,
areFeesIncluded: MOONPAY_FEES_INCLUDED,
} }
: skipToken
) )
const quoteAmount = buyQuote?.quoteCurrencyAmount ?? 0 const quoteAmount = buyQuote?.quoteCurrencyAmount ?? 0
...@@ -281,17 +288,13 @@ export function useMoonpayFiatOnRamp({ ...@@ -281,17 +288,13 @@ export function useMoonpayFiatOnRamp({
currencySymbol: baseCurrencySymbol, currencySymbol: baseCurrencySymbol,
}) })
let errorText, errorColor: ColorTokens | undefined const { errorText, errorColor } = useMoonpayError(
if (isError) { isError,
errorText = t('Something went wrong.') amountIsTooSmall,
errorColor = '$DEP_accentWarning' amountIsTooLarge,
} else if (amountIsTooSmall) { minBuyAmountWithFiatSymbol,
errorText = t('{{amount}} minimum', { amount: minBuyAmountWithFiatSymbol }) maxBuyAmountWithFiatSymbol
errorColor = '$statusCritical' )
} else if (amountIsTooLarge) {
errorText = t('{{amount}} maximum', { amount: maxBuyAmountWithFiatSymbol })
errorColor = '$statusCritical'
}
return { return {
eligible, eligible,
...@@ -346,3 +349,31 @@ export function useFiatOnRampSupportedTokens(): { ...@@ -346,3 +349,31 @@ export function useFiatOnRampSupportedTokens(): {
}, },
} }
} }
function useMoonpayError(
hasError: boolean,
amountIsTooSmall: boolean,
amountIsTooLarge: boolean,
minBuyAmountWithFiatSymbol: string,
maxBuyAmountWithFiatSymbol: string
): {
errorText: string | undefined
errorColor: ColorTokens | undefined
} {
const { t } = useTranslation()
let errorText, errorColor: ColorTokens | undefined
if (hasError) {
errorText = t('Something went wrong.')
errorColor = '$DEP_accentWarning'
} else if (amountIsTooSmall) {
errorText = t('Minimum {{amount}}', { amount: minBuyAmountWithFiatSymbol })
errorColor = '$statusCritical'
} else if (amountIsTooLarge) {
errorText = t('Maximum {{amount}}', { amount: maxBuyAmountWithFiatSymbol })
errorColor = '$statusCritical'
}
return { errorText, errorColor }
}
import { CurrencyInfo } from 'wallet/src/features/dataApi/types' import { CurrencyInfo } from 'wallet/src/features/dataApi/types'
import { MoonpayCurrency } from 'wallet/src/features/fiatOnRamp/types'
export type FiatOnRampCurrency = { export type FiatOnRampCurrency = {
currencyInfo: Maybe<CurrencyInfo> currencyInfo: Maybe<CurrencyInfo>
moonpayCurrency: MoonpayCurrency
} }
...@@ -174,6 +174,7 @@ export const enum ElementName { ...@@ -174,6 +174,7 @@ export const enum ElementName {
EtherscanView = 'etherscan-view', EtherscanView = 'etherscan-view',
Favorite = 'favorite', Favorite = 'favorite',
FiatOnRampTokenSelector = 'fiat-on-ramp-token-selector', FiatOnRampTokenSelector = 'fiat-on-ramp-token-selector',
FiatOnRampAggregatorTokenSelector = 'fiat-on-ramp-aggregator-token-selector',
FiatOnRampWidgetButton = 'fiat-on-ramp-widget-button', FiatOnRampWidgetButton = 'fiat-on-ramp-widget-button',
FiatOnRampCountryPicker = 'fiat-on-ramp-country-picker', FiatOnRampCountryPicker = 'fiat-on-ramp-country-picker',
GetHelp = 'get-help', GetHelp = 'get-help',
......
...@@ -33,11 +33,11 @@ type CurrentInputPanelProps = { ...@@ -33,11 +33,11 @@ type CurrentInputPanelProps = {
autoFocus?: boolean autoFocus?: boolean
currencyAmount: Maybe<CurrencyAmount<Currency>> currencyAmount: Maybe<CurrencyAmount<Currency>>
currencyBalance: Maybe<CurrencyAmount<Currency>> currencyBalance: Maybe<CurrencyAmount<Currency>>
currencyField: CurrencyField
currencyInfo: Maybe<CurrencyInfo> currencyInfo: Maybe<CurrencyInfo>
isLoading?: boolean isLoading?: boolean
isCollapsed: boolean isCollapsed: boolean
focus?: boolean focus?: boolean
isOutput?: boolean
isFiatMode?: boolean isFiatMode?: boolean
onPressIn?: () => void onPressIn?: () => void
onSelectionChange?: (start: number, end: number) => void onSelectionChange?: (start: number, end: number) => void
...@@ -50,7 +50,7 @@ type CurrentInputPanelProps = { ...@@ -50,7 +50,7 @@ type CurrentInputPanelProps = {
showSoftInputOnFocus?: boolean showSoftInputOnFocus?: boolean
usdValue: Maybe<CurrencyAmount<Currency>> usdValue: Maybe<CurrencyAmount<Currency>>
value?: string value?: string
resetSelection: (start: number, end: number) => void resetSelection: (args: { start: number; end?: number; currencyField?: CurrencyField }) => void
} & FlexProps } & FlexProps
const MAX_INPUT_FONT_SIZE = 42 const MAX_INPUT_FONT_SIZE = 42
...@@ -68,11 +68,11 @@ export const CurrencyInputPanel = memo( ...@@ -68,11 +68,11 @@ export const CurrencyInputPanel = memo(
autoFocus, autoFocus,
currencyAmount, currencyAmount,
currencyBalance, currencyBalance,
currencyField,
currencyInfo, currencyInfo,
isLoading, isLoading,
isCollapsed, isCollapsed,
focus, focus,
isOutput = false,
isFiatMode = false, isFiatMode = false,
onPressIn, onPressIn,
onSelectionChange: selectionChange, onSelectionChange: selectionChange,
...@@ -98,6 +98,8 @@ export const CurrencyInputPanel = memo( ...@@ -98,6 +98,8 @@ export const CurrencyInputPanel = memo(
useForwardRef(forwardedRef, inputRef) useForwardRef(forwardedRef, inputRef)
const isOutput = currencyField === CurrencyField.OUTPUT
const showInsufficientBalanceWarning = const showInsufficientBalanceWarning =
!isOutput && !!currencyBalance && !!currencyAmount && currencyBalance.lessThan(currencyAmount) !isOutput && !!currencyBalance && !!currencyAmount && currencyBalance.lessThan(currencyAmount)
...@@ -116,11 +118,22 @@ export const CurrencyInputPanel = memo( ...@@ -116,11 +118,22 @@ export const CurrencyInputPanel = memo(
useEffect(() => { useEffect(() => {
if (focus && !isTextInputRefActuallyFocused) { if (focus && !isTextInputRefActuallyFocused) {
inputRef.current?.focus() inputRef.current?.focus()
resetSelection(value?.length ?? 0, value?.length ?? 0) resetSelection({
start: value?.length ?? 0,
end: value?.length ?? 0,
currencyField,
})
} else if (!focus && isTextInputRefActuallyFocused) { } else if (!focus && isTextInputRefActuallyFocused) {
inputRef.current?.blur() inputRef.current?.blur()
} }
}, [focus, inputRef, isTextInputRefActuallyFocused, resetSelection, value?.length]) }, [
currencyField,
focus,
inputRef,
isTextInputRefActuallyFocused,
resetSelection,
value?.length,
])
const { onLayout, fontSize, onSetFontSize } = useDynamicFontSizing( const { onLayout, fontSize, onSetFontSize } = useDynamicFontSizing(
MAX_CHAR_PIXEL_WIDTH, MAX_CHAR_PIXEL_WIDTH,
...@@ -198,7 +211,7 @@ export const CurrencyInputPanel = memo( ...@@ -198,7 +211,7 @@ export const CurrencyInputPanel = memo(
const loadingTextValue = previousValue && previousValue !== '' ? previousValue : '0' const loadingTextValue = previousValue && previousValue !== '' ? previousValue : '0'
const { animatedContainerStyle, animatedAmountInputStyle, animatedInfoRowStyle } = const { animatedContainerStyle, animatedAmountInputStyle, animatedInfoRowStyle } =
useAnimatedContainerStyles(isLoading, focus) useAnimatedContainerStyles(isLoading, isCollapsed)
const { symbol: fiatCurrencySymbol } = useAppFiatCurrencyInfo() const { symbol: fiatCurrencySymbol } = useAppFiatCurrencyInfo()
...@@ -323,10 +336,7 @@ export const CurrencyInputPanel = memo( ...@@ -323,10 +336,7 @@ export const CurrencyInputPanel = memo(
</Text> </Text>
)} )}
{showMaxButton && onSetMax && ( {showMaxButton && onSetMax && (
<MaxAmountButton <MaxAmountButton currencyField={currencyField} onSetMax={onSetMax} />
currencyField={isOutput ? CurrencyField.OUTPUT : CurrencyField.INPUT}
onSetMax={onSetMax}
/>
)} )}
</Flex> </Flex>
</> </>
...@@ -340,7 +350,7 @@ export const CurrencyInputPanel = memo( ...@@ -340,7 +350,7 @@ export const CurrencyInputPanel = memo(
function useAnimatedContainerStyles( function useAnimatedContainerStyles(
isLoading: boolean | undefined, isLoading: boolean | undefined,
focus: boolean | undefined isCollapsed: boolean | undefined
): { ): {
animatedContainerStyle: { animatedContainerStyle: {
paddingTop: number paddingTop: number
...@@ -355,14 +365,14 @@ function useAnimatedContainerStyles( ...@@ -355,14 +365,14 @@ function useAnimatedContainerStyles(
} { } {
const animatedContainerStyle = useAnimatedStyle(() => { const animatedContainerStyle = useAnimatedStyle(() => {
return { return {
paddingTop: withTiming(focus ? spacing.spacing24 : spacing.spacing16, { paddingTop: withTiming(isCollapsed ? spacing.spacing16 : spacing.spacing24, {
duration: 300, duration: 300,
}), }),
paddingBottom: withTiming(focus ? spacing.spacing48 : spacing.spacing16, { paddingBottom: withTiming(isCollapsed ? spacing.spacing16 : spacing.spacing48, {
duration: 300, duration: 300,
}), }),
} }
}, [focus]) }, [isCollapsed])
const loadingFlexProgress = useSharedValue(1) const loadingFlexProgress = useSharedValue(1)
loadingFlexProgress.value = withRepeat( loadingFlexProgress.value = withRepeat(
...@@ -382,11 +392,11 @@ function useAnimatedContainerStyles( ...@@ -382,11 +392,11 @@ function useAnimatedContainerStyles(
const animatedInfoRowStyle = useAnimatedStyle(() => { const animatedInfoRowStyle = useAnimatedStyle(() => {
return { return {
bottom: withTiming(focus ? spacing.spacing16 : -spacing.spacing24, { bottom: withTiming(isCollapsed ? -spacing.spacing24 : spacing.spacing16, {
duration: 300, duration: 300,
}), }),
} }
}, [focus]) }, [isCollapsed])
return { return {
animatedContainerStyle, animatedContainerStyle,
......
...@@ -16,8 +16,8 @@ type DecimalPadInputProps = { ...@@ -16,8 +16,8 @@ type DecimalPadInputProps = {
disabled?: boolean disabled?: boolean
hideDecimal?: boolean hideDecimal?: boolean
onReady: () => void onReady: () => void
resetSelection: (start: number, end?: number) => void resetSelection: (args: { start: number; end?: number }) => void
selectionRef?: React.MutableRefObject<TextInputProps['selection']> selectionRef: React.MutableRefObject<TextInputProps['selection']>
setValue: (newValue: string) => void setValue: (newValue: string) => void
valueRef: React.MutableRefObject<string> valueRef: React.MutableRefObject<string>
} }
...@@ -101,11 +101,11 @@ export const DecimalPadInput = memo( ...@@ -101,11 +101,11 @@ export const DecimalPadInput = memo(
(label: KeyLabel): void => { (label: KeyLabel): void => {
const { start, end } = getCurrentSelection() const { start, end } = getCurrentSelection()
if (start === undefined || end === undefined) { if (start === undefined || end === undefined) {
resetSelection(valueRef.current.length + 1, valueRef.current.length + 1) resetSelection({ start: valueRef.current.length + 1, end: valueRef.current.length + 1 })
// has no text selection, cursor is at the end of the text input // has no text selection, cursor is at the end of the text input
updateValue(valueRef.current + label) updateValue(valueRef.current + label)
} else { } else {
resetSelection(start + 1, start + 1) resetSelection({ start: start + 1, end: start + 1 })
updateValue(valueRef.current.slice(0, start) + label + valueRef.current.slice(end)) updateValue(valueRef.current.slice(0, start) + label + valueRef.current.slice(end))
} }
}, },
...@@ -115,15 +115,15 @@ export const DecimalPadInput = memo( ...@@ -115,15 +115,15 @@ export const DecimalPadInput = memo(
const handleDelete = useCallback((): void => { const handleDelete = useCallback((): void => {
const { start, end } = getCurrentSelection() const { start, end } = getCurrentSelection()
if (start === undefined || end === undefined) { if (start === undefined || end === undefined) {
resetSelection(valueRef.current.length - 1, valueRef.current.length - 1) resetSelection({ start: valueRef.current.length - 1, end: valueRef.current.length - 1 })
// has no text selection, cursor is at the end of the text input // has no text selection, cursor is at the end of the text input
updateValue(valueRef.current.slice(0, -1)) updateValue(valueRef.current.slice(0, -1))
} else if (start < end) { } else if (start < end) {
resetSelection(start, start) resetSelection({ start, end: start })
// has text part selected // has text part selected
updateValue(valueRef.current.slice(0, start) + valueRef.current.slice(end)) updateValue(valueRef.current.slice(0, start) + valueRef.current.slice(end))
} else if (start > 0) { } else if (start > 0) {
resetSelection(start - 1, start - 1) resetSelection({ start: start - 1, end: start - 1 })
// part of the text is not selected, but cursor moved // part of the text is not selected, but cursor moved
updateValue(valueRef.current.slice(0, start - 1) + valueRef.current.slice(start)) updateValue(valueRef.current.slice(0, start - 1) + valueRef.current.slice(start))
} }
...@@ -144,7 +144,7 @@ export const DecimalPadInput = memo( ...@@ -144,7 +144,7 @@ export const DecimalPadInput = memo(
const onLongPress = useCallback( const onLongPress = useCallback(
(_: KeyLabel, action: KeyAction) => { (_: KeyLabel, action: KeyAction) => {
if (disabled || action !== KeyAction.Delete) return if (disabled || action !== KeyAction.Delete) return
resetSelection(0, 0) resetSelection({ start: 0, end: 0 })
updateValue('') updateValue('')
}, },
[disabled, updateValue, resetSelection] [disabled, updateValue, resetSelection]
......
/* eslint-disable max-lines */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { LayoutChangeEvent, StyleSheet, TextInput, TextInputProps } from 'react-native' import { LayoutChangeEvent, StyleSheet, TextInput, TextInputProps } from 'react-native'
...@@ -100,11 +101,13 @@ function SwapFormContent(): JSX.Element { ...@@ -100,11 +101,13 @@ function SwapFormContent(): JSX.Element {
openWalletRestoreModal() openWalletRestoreModal()
} }
const focusFieldIsInput = focusOnCurrencyField === CurrencyField.INPUT
const focusFieldIsOutput = focusOnCurrencyField === CurrencyField.OUTPUT
const exactFieldIsInput = exactCurrencyField === CurrencyField.INPUT const exactFieldIsInput = exactCurrencyField === CurrencyField.INPUT
const exactFieldIsOutput = exactCurrencyField === CurrencyField.OUTPUT const exactFieldIsOutput = exactCurrencyField === CurrencyField.OUTPUT
const derivedCurrencyField = exactFieldIsInput ? CurrencyField.OUTPUT : CurrencyField.INPUT
// We want the `DecimalPad` to always control one of the 2 inputs even when no input is focused,
// which can happen after the user hits `Max`.
const decimalPadControlledField = focusOnCurrencyField ?? exactCurrencyField
// Quote is being fetched for first time // Quote is being fetched for first time
const isSwapDataLoading = !isWrapAction(wrapType) && trade.loading const isSwapDataLoading = !isWrapAction(wrapType) && trade.loading
...@@ -125,35 +128,77 @@ function SwapFormContent(): JSX.Element { ...@@ -125,35 +128,77 @@ function SwapFormContent(): JSX.Element {
) )
const resetSelection = useCallback( const resetSelection = useCallback(
(start: number, end?: number) => { ({
start,
end,
currencyField,
}: {
start: number
end?: number
currencyField?: CurrencyField
}) => {
// Update refs first to have the latest selection state available in the DecimalPadInput // Update refs first to have the latest selection state available in the DecimalPadInput
// component and property update disabled keys of the decimal pad. // component and properly update disabled keys of the decimal pad.
if (focusFieldIsInput) { // We reset the native selection on the next tick because we need to wait for the native input to be updated.
inputSelectionRef.current = { start, end }
} else if (focusFieldIsOutput) {
outputSelectionRef.current = { start, end }
} else return
// We reset the selection on the next tick because we need to wait for the native input to be updated.
// This is needed because of the combination of state (delayed update) + ref (instant update) to improve performance. // This is needed because of the combination of state (delayed update) + ref (instant update) to improve performance.
const _currencyField = currencyField ?? decimalPadControlledField
const selectionRef =
_currencyField === CurrencyField.INPUT ? inputSelectionRef : outputSelectionRef
const inputFieldRef = _currencyField === CurrencyField.INPUT ? inputRef : outputRef
selectionRef.current = { start, end }
setTimeout(() => { setTimeout(() => {
inputRef.current?.setNativeProps?.({ selection: { start, end } }) inputFieldRef.current?.setNativeProps?.({ selection: { start, end } })
}, 0) }, 0)
}, },
[focusFieldIsInput, focusFieldIsOutput] [decimalPadControlledField]
)
const moveCursorToEnd = useCallback(
(args?: { overrideIsFiatMode?: boolean }) => {
const _isFiatMode = args?.overrideIsFiatMode ?? isFiatMode
const amountRef =
decimalPadControlledField === derivedCurrencyField
? formattedDerivedValueRef
: _isFiatMode
? exactAmountFiatRef
: exactAmountTokenRef
if (_isFiatMode) {
resetSelection({
start: amountRef.current.length,
end: amountRef.current.length,
})
} else {
resetSelection({
start: amountRef.current.length,
end: amountRef.current.length,
})
}
},
[
decimalPadControlledField,
derivedCurrencyField,
exactAmountFiatRef,
exactAmountTokenRef,
isFiatMode,
resetSelection,
]
) )
const decimalPadSetValue = useCallback( const decimalPadSetValue = useCallback(
(value: string): void => { (value: string): void => {
if (!focusOnCurrencyField) {
return
}
updateSwapForm({ updateSwapForm({
exactAmountFiat: isFiatMode ? value : undefined, exactAmountFiat: isFiatMode ? value : undefined,
exactAmountToken: !isFiatMode ? value : undefined, exactAmountToken: !isFiatMode ? value : undefined,
exactCurrencyField: focusOnCurrencyField, exactCurrencyField: decimalPadControlledField,
focusOnCurrencyField: decimalPadControlledField,
}) })
}, },
[focusOnCurrencyField, isFiatMode, updateSwapForm] [decimalPadControlledField, isFiatMode, updateSwapForm]
) )
const [decimalPadReady, setDecimalPadReady] = useState(true) const [decimalPadReady, setDecimalPadReady] = useState(true)
...@@ -185,6 +230,7 @@ function SwapFormContent(): JSX.Element { ...@@ -185,6 +230,7 @@ function SwapFormContent(): JSX.Element {
}, },
[amountUpdatedTimeRef] [amountUpdatedTimeRef]
) )
const onOutputSelectionChange = useCallback( const onOutputSelectionChange = useCallback(
(start: number, end: number) => { (start: number, end: number) => {
if (Date.now() - amountUpdatedTimeRef.current < ON_SELECTION_CHANGE_WAIT_TIME_MS) { if (Date.now() - amountUpdatedTimeRef.current < ON_SELECTION_CHANGE_WAIT_TIME_MS) {
...@@ -247,25 +293,29 @@ function SwapFormContent(): JSX.Element { ...@@ -247,25 +293,29 @@ function SwapFormContent(): JSX.Element {
exactAmountFiat: undefined, exactAmountFiat: undefined,
exactAmountToken: amount, exactAmountToken: amount,
exactCurrencyField: CurrencyField.INPUT, exactCurrencyField: CurrencyField.INPUT,
focusOnCurrencyField: CurrencyField.INPUT, focusOnCurrencyField: undefined,
}) })
resetSelection(0, 0)
// We want this update to happen on the next tick, after the input value is updated.
setTimeout(() => {
moveCursorToEnd()
decimalPadRef.current?.updateDisabledKeys()
}, 0)
}, },
[resetSelection, updateSwapForm] [moveCursorToEnd, updateSwapForm]
) )
// Reset selection based the new input value (token, or fiat), and toggle fiat mode // Reset selection based the new input value (token, or fiat), and toggle fiat mode
const onToggleIsFiatMode = useCallback(() => { const onToggleIsFiatMode = useCallback(() => {
const newIsFiatMode = !isFiatMode
updateSwapForm({ updateSwapForm({
isFiatMode: !isFiatMode, isFiatMode: newIsFiatMode,
}) })
// Need to do the opposite of previous mode, as we're selecting the new value after mode update
if (!isFiatMode) { // We want this update to happen on the next tick, after the input value is updated.
resetSelection(exactAmountFiatRef.current.length, exactAmountFiatRef.current.length) setTimeout(() => moveCursorToEnd({ overrideIsFiatMode: newIsFiatMode }), 0)
} else { }, [isFiatMode, moveCursorToEnd, updateSwapForm])
resetSelection(exactAmountTokenRef.current.length, exactAmountTokenRef.current.length)
}
}, [exactAmountFiatRef, exactAmountTokenRef, isFiatMode, resetSelection, updateSwapForm])
const onSwitchCurrencies = useCallback(() => { const onSwitchCurrencies = useCallback(() => {
const newExactCurrencyField = exactFieldIsInput ? CurrencyField.OUTPUT : CurrencyField.INPUT const newExactCurrencyField = exactFieldIsInput ? CurrencyField.OUTPUT : CurrencyField.INPUT
...@@ -277,8 +327,6 @@ function SwapFormContent(): JSX.Element { ...@@ -277,8 +327,6 @@ function SwapFormContent(): JSX.Element {
}) })
}, [exactFieldIsInput, input, output, updateSwapForm]) }, [exactFieldIsInput, input, output, updateSwapForm])
const derivedCurrencyField = exactFieldIsInput ? CurrencyField.OUTPUT : CurrencyField.INPUT
// TODO gary MOB-2028 replace temporary hack to handle different separators // TODO gary MOB-2028 replace temporary hack to handle different separators
// Replace with localized version of formatter // Replace with localized version of formatter
const formattedDerivedValue = formatCurrencyAmount({ const formattedDerivedValue = formatCurrencyAmount({
...@@ -288,25 +336,39 @@ function SwapFormContent(): JSX.Element { ...@@ -288,25 +336,39 @@ function SwapFormContent(): JSX.Element {
placeholder: '', placeholder: '',
}) })
// TODO - improve this to update ref when calculating the derived state
// instead of assigning ref based on the derived state
const formattedDerivedValueRef = useRef(formattedDerivedValue) const formattedDerivedValueRef = useRef(formattedDerivedValue)
useEffect(() => {
formattedDerivedValueRef.current = formattedDerivedValue formattedDerivedValueRef.current = formattedDerivedValue
useEffect(() => {
if (decimalPadControlledField === exactCurrencyField) {
return
}
// When the `formattedDerivedValue` changes while the field that is not set as the `exactCurrencyField` is focused, we want to reset the cursor selection to the end of the input.
// This to prevent an issue that happens with the cursor selection getting out of sync when a user changes focus from one input to another while a quote request in in flight.
moveCursorToEnd()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formattedDerivedValue]) }, [formattedDerivedValue])
const exactValue = isFiatMode ? exactAmountFiat : exactAmountToken const exactValue = isFiatMode ? exactAmountFiat : exactAmountToken
const exactValueRef = isFiatMode ? exactAmountFiatRef : exactAmountTokenRef const exactValueRef = isFiatMode ? exactAmountFiatRef : exactAmountTokenRef
const decimalPadValueRef =
decimalPadControlledField === exactCurrencyField ? exactValueRef : formattedDerivedValueRef
// Animated background color on input panels based on focus // Animated background color on input panels based on focus
const colorTransitionProgress = useDerivedValue(() => { const inputColorTransitionProgress = useDerivedValue(() => {
return withTiming(focusFieldIsInput ? 0 : 1, { duration: 250 }) return withTiming(focusOnCurrencyField === CurrencyField.INPUT ? 0 : 1, { duration: 250 })
}, [focusFieldIsInput]) }, [focusOnCurrencyField])
const outputColorTransitionProgress = useDerivedValue(() => {
return withTiming(focusOnCurrencyField === CurrencyField.OUTPUT ? 0 : 1, { duration: 250 })
}, [focusOnCurrencyField])
const inputBackgroundStyle = useAnimatedStyle(() => { const inputBackgroundStyle = useAnimatedStyle(() => {
return { return {
backgroundColor: interpolateColor( backgroundColor: interpolateColor(
colorTransitionProgress.value, inputColorTransitionProgress.value,
[0, 1], [0, 1],
[colors.surface1.val, colors.surface2.val] [colors.surface1.val, colors.surface2.val]
), ),
...@@ -316,9 +378,9 @@ function SwapFormContent(): JSX.Element { ...@@ -316,9 +378,9 @@ function SwapFormContent(): JSX.Element {
const outputBackgroundStyle = useAnimatedStyle(() => { const outputBackgroundStyle = useAnimatedStyle(() => {
return { return {
backgroundColor: interpolateColor( backgroundColor: interpolateColor(
colorTransitionProgress.value, outputColorTransitionProgress.value,
[0, 1], [0, 1],
[colors.surface2.val, colors.surface1.val] [colors.surface1.val, colors.surface2.val]
), ),
} }
}) })
...@@ -337,9 +399,10 @@ function SwapFormContent(): JSX.Element { ...@@ -337,9 +399,10 @@ function SwapFormContent(): JSX.Element {
ref={inputRef} ref={inputRef}
currencyAmount={currencyAmounts[CurrencyField.INPUT]} currencyAmount={currencyAmounts[CurrencyField.INPUT]}
currencyBalance={currencyBalances[CurrencyField.INPUT]} currencyBalance={currencyBalances[CurrencyField.INPUT]}
currencyField={CurrencyField.INPUT}
currencyInfo={currencies[CurrencyField.INPUT]} currencyInfo={currencies[CurrencyField.INPUT]}
focus={focusFieldIsInput} focus={focusOnCurrencyField === CurrencyField.INPUT}
isCollapsed={focusOnCurrencyField ? !focusFieldIsInput : !exactFieldIsInput} isCollapsed={decimalPadControlledField !== CurrencyField.INPUT}
isFiatMode={isFiatMode && exactFieldIsInput} isFiatMode={isFiatMode && exactFieldIsInput}
isLoading={!exactFieldIsInput && isSwapDataLoading} isLoading={!exactFieldIsInput && isSwapDataLoading}
resetSelection={resetSelection} resetSelection={resetSelection}
...@@ -369,12 +432,12 @@ function SwapFormContent(): JSX.Element { ...@@ -369,12 +432,12 @@ function SwapFormContent(): JSX.Element {
style={outputBackgroundStyle}> style={outputBackgroundStyle}>
<CurrencyInputPanel <CurrencyInputPanel
ref={outputRef} ref={outputRef}
isOutput
currencyAmount={currencyAmounts[CurrencyField.OUTPUT]} currencyAmount={currencyAmounts[CurrencyField.OUTPUT]}
currencyBalance={currencyBalances[CurrencyField.OUTPUT]} currencyBalance={currencyBalances[CurrencyField.OUTPUT]}
currencyField={CurrencyField.OUTPUT}
currencyInfo={currencies[CurrencyField.OUTPUT]} currencyInfo={currencies[CurrencyField.OUTPUT]}
focus={focusFieldIsOutput} focus={focusOnCurrencyField === CurrencyField.OUTPUT}
isCollapsed={focusOnCurrencyField ? !focusFieldIsOutput : !exactFieldIsOutput} isCollapsed={decimalPadControlledField !== CurrencyField.OUTPUT}
isFiatMode={isFiatMode && exactFieldIsOutput} isFiatMode={isFiatMode && exactFieldIsOutput}
isLoading={!exactFieldIsOutput && isSwapDataLoading} isLoading={!exactFieldIsOutput && isSwapDataLoading}
resetSelection={resetSelection} resetSelection={resetSelection}
...@@ -436,20 +499,14 @@ function SwapFormContent(): JSX.Element { ...@@ -436,20 +499,14 @@ function SwapFormContent(): JSX.Element {
right={0} right={0}
style={decimalPadAndButtonAnimatedStyle}> style={decimalPadAndButtonAnimatedStyle}>
<Flex grow justifyContent="flex-end"> <Flex grow justifyContent="flex-end">
{focusOnCurrencyField && (
<DecimalPadInput <DecimalPadInput
ref={decimalPadRef} ref={decimalPadRef}
resetSelection={resetSelection} resetSelection={resetSelection}
selectionRef={focusOnCurrencyField ? selection[focusOnCurrencyField] : undefined} selectionRef={selection[decimalPadControlledField]}
setValue={decimalPadSetValue} setValue={decimalPadSetValue}
valueRef={ valueRef={decimalPadValueRef}
focusOnCurrencyField === exactCurrencyField
? exactValueRef
: formattedDerivedValueRef
}
onReady={onDecimalPadReady} onReady={onDecimalPadReady}
/> />
)}
</Flex> </Flex>
</AnimatedFlex> </AnimatedFlex>
</Flex> </Flex>
......
import React, { useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { getUniqueId } from 'react-native-device-info'
import { navigate } from 'src/app/navigation/rootNavigation' import { navigate } from 'src/app/navigation/rootNavigation'
import { UnitagStackScreenProp } from 'src/app/navigation/types' import { UnitagStackScreenProp } from 'src/app/navigation/types'
import { Screen } from 'src/components/layout/Screen' import { Screen } from 'src/components/layout/Screen'
...@@ -10,7 +11,10 @@ import { OnboardingScreens, Screens, UnitagScreens } from 'src/screens/Screens' ...@@ -10,7 +11,10 @@ import { OnboardingScreens, Screens, UnitagScreens } from 'src/screens/Screens'
import { Button, Flex, Icons, Text, useDeviceInsets } from 'ui/src' import { Button, Flex, Icons, Text, useDeviceInsets } from 'ui/src'
import Unitag from 'ui/src/assets/icons/unitag.svg' import Unitag from 'ui/src/assets/icons/unitag.svg'
import { fonts, iconSizes, imageSizes } from 'ui/src/theme' import { fonts, iconSizes, imageSizes } from 'ui/src/theme'
import { useAsyncData } from 'utilities/src/react/hooks'
import { ImportType, OnboardingEntryPoint } from 'wallet/src/features/onboarding/types' import { ImportType, OnboardingEntryPoint } from 'wallet/src/features/onboarding/types'
import { useClaimUnitagMutation } from 'wallet/src/features/unitags/api'
import { parseUnitagErrorCode } from 'wallet/src/features/unitags/utils'
import { useActiveAccountAddress, usePendingAccounts } from 'wallet/src/features/wallet/hooks' import { useActiveAccountAddress, usePendingAccounts } from 'wallet/src/features/wallet/hooks'
export function ChooseProfilePictureScreen({ export function ChooseProfilePictureScreen({
...@@ -25,6 +29,17 @@ export function ChooseProfilePictureScreen({ ...@@ -25,6 +29,17 @@ export function ChooseProfilePictureScreen({
const { t } = useTranslation() const { t } = useTranslation()
const [imageUri, setImageUri] = useState<string>() const [imageUri, setImageUri] = useState<string>()
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false)
const [claimError, setClaimError] = useState<string>()
const [
claimUnitag,
{
called: claimRequestMade,
loading: claimResponseLoading,
data: claimResponse,
reset: resetClaimResponse,
},
] = useClaimUnitagMutation()
const { data: deviceId } = useAsyncData(getUniqueId)
const openModal = (): void => { const openModal = (): void => {
setShowModal(true) setShowModal(true)
...@@ -34,7 +49,30 @@ export function ChooseProfilePictureScreen({ ...@@ -34,7 +49,30 @@ export function ChooseProfilePictureScreen({
setShowModal(false) setShowModal(false)
} }
const onPressFinish = (): void => { const onPressFinish = async (): Promise<void> => {
if (!deviceId) {
return // Should never hit this condition. Button is disabled if deviceId is undefined
}
// throw error if unitagAddress is falsey
if (!unitagAddress) {
throw new Error('unitagAddress should never be null when claiming a unitag')
}
await claimUnitag({
address: unitagAddress,
username: unitag,
deviceId,
metadata: {
avatar: imageUri ?? '', // TODO (MOB-2271): upload profile pic image to backend
description: '',
url: '',
twitter: '',
},
})
}
const onClaimSuccess = useCallback((): void => {
if (entryPoint === Screens.Home) { if (entryPoint === Screens.Home) {
if (!activeAddress) { if (!activeAddress) {
throw new Error('activeAddress should never be null when Unitag entryPoint is Home Screen') throw new Error('activeAddress should never be null when Unitag entryPoint is Home Screen')
...@@ -57,7 +95,30 @@ export function ChooseProfilePictureScreen({ ...@@ -57,7 +95,30 @@ export function ChooseProfilePictureScreen({
}, },
}) })
} }
}, [activeAddress, entryPoint, imageUri, unitag])
useEffect(() => {
if (claimRequestMade && !claimResponseLoading && !!claimResponse) {
// We POSTed to claim and got a response
if (claimResponse.success) {
onClaimSuccess()
return
}
if (claimResponse.errorCode) {
setClaimError(parseUnitagErrorCode(t, unitag, claimResponse.errorCode))
}
// Reset everything so called=false, claimResponse=undefined
resetClaimResponse()
} }
}, [
claimResponseLoading,
claimResponse,
onClaimSuccess,
unitag,
claimRequestMade,
resetClaimResponse,
t,
])
return ( return (
<Screen edges={['right', 'left']}> <Screen edges={['right', 'left']}>
...@@ -95,8 +156,17 @@ export function ChooseProfilePictureScreen({ ...@@ -95,8 +156,17 @@ export function ChooseProfilePictureScreen({
<Unitag height={iconSizes.icon24} width={iconSizes.icon24} /> <Unitag height={iconSizes.icon24} width={iconSizes.icon24} />
</Flex> </Flex>
</Flex> </Flex>
{!!claimError && (
<Text color="$statusCritical" variant="body2">
{claimError}
</Text>
)}
</Flex> </Flex>
<Button size="medium" theme="primary" onPress={onPressFinish}> <Button
disabled={!deviceId || !!claimError}
size="medium"
theme="primary"
onPress={onPressFinish}>
{entryPoint === Screens.Home ? t('Finish') : t('Create wallet')} {entryPoint === Screens.Home ? t('Finish') : t('Create wallet')}
</Button> </Button>
</Flex> </Flex>
......
...@@ -13,6 +13,7 @@ import { useKeyboardLayout } from 'src/utils/useKeyboardLayout' ...@@ -13,6 +13,7 @@ import { useKeyboardLayout } from 'src/utils/useKeyboardLayout'
import { Button, Flex, Icons, Text, useSporeColors } from 'ui/src' import { Button, Flex, Icons, Text, useSporeColors } from 'ui/src'
import { fonts, iconSizes } from 'ui/src/theme' import { fonts, iconSizes } from 'ui/src/theme'
import { ImportType, OnboardingEntryPoint } from 'wallet/src/features/onboarding/types' import { ImportType, OnboardingEntryPoint } from 'wallet/src/features/onboarding/types'
import { useUnitagError } from 'wallet/src/features/unitags/hooks'
import { useActiveAccountAddress, usePendingAccounts } from 'wallet/src/features/wallet/hooks' import { useActiveAccountAddress, usePendingAccounts } from 'wallet/src/features/wallet/hooks'
import { shortenAddress } from 'wallet/src/utils/addresses' import { shortenAddress } from 'wallet/src/utils/addresses'
...@@ -33,6 +34,9 @@ export function ChooseUnitag({ ...@@ -33,6 +34,9 @@ export function ChooseUnitag({
const unitagAddress = activeAddress || pendingAccountAddress const unitagAddress = activeAddress || pendingAccountAddress
const [unitag, setUnitag] = useState<string | undefined>(undefined) const [unitag, setUnitag] = useState<string | undefined>(undefined)
const [showLiveCheck, setShowLiveCheck] = useState(false) const [showLiveCheck, setShowLiveCheck] = useState(false)
const { unitagError, loading } = useUnitagError(unitagAddress, unitag)
const isUnitagValid = !unitagError && !loading && !!unitag
const showValidUnitagLogo = isUnitagValid && showLiveCheck
const onChange = (text: string | undefined): void => { const onChange = (text: string | undefined): void => {
if (unitag !== text?.trim()) { if (unitag !== text?.trim()) {
...@@ -116,11 +120,12 @@ export function ChooseUnitag({ ...@@ -116,11 +120,12 @@ export function ChooseUnitag({
<Flex fill justifyContent="space-between"> <Flex fill justifyContent="space-between">
<UnitagInput <UnitagInput
activeAddress={entryPoint === Screens.Home ? activeAddress : null} activeAddress={entryPoint === Screens.Home ? activeAddress : null}
errorMessage={undefined} // TODO (MOB-2105): GET /username/ from unitags backend and surface any errors errorMessage={unitagError}
inputSuffix={true ? UNITAG_SUFFIX : undefined} // TODO (MOB-2105) inputSuffix={!showValidUnitagLogo ? UNITAG_SUFFIX : undefined}
liveCheck={showLiveCheck} liveCheck={showLiveCheck}
loading={!!unitag && (loading || !showLiveCheck)}
placeholderLabel="yourname" placeholderLabel="yourname"
showUnitagLogo={false} // TODO (MOB-2125): add Unitag logo animation when continue button is pressed showUnitagLogo={showValidUnitagLogo} // TODO (MOB-2125): add Unitag logo animation when continue button is pressed
value={unitag} value={unitag}
onChange={onChange} onChange={onChange}
onSubmit={onSubmit} onSubmit={onSubmit}
...@@ -131,7 +136,11 @@ export function ChooseUnitag({ ...@@ -131,7 +136,11 @@ export function ChooseUnitag({
{t('Maybe later')} {t('Maybe later')}
</Button> </Button>
)} )}
<Button size="medium" theme="primary" onPress={onPressContinue}> <Button
disabled={!showValidUnitagLogo}
size="medium"
theme="primary"
onPress={onPressContinue}>
{t('Continue')} {t('Continue')}
</Button> </Button>
</Flex> </Flex>
......
...@@ -14,15 +14,14 @@ import { Button, Flex, Icons, Text, useDeviceInsets } from 'ui/src' ...@@ -14,15 +14,14 @@ import { Button, Flex, Icons, Text, useDeviceInsets } from 'ui/src'
import { iconSizes, imageSizes } from 'ui/src/theme' import { iconSizes, imageSizes } from 'ui/src/theme'
import { ChainId } from 'wallet/src/constants/chains' import { ChainId } from 'wallet/src/constants/chains'
import { useENS } from 'wallet/src/features/ens/useENS' import { useENS } from 'wallet/src/features/ens/useENS'
import { useUnitag } from 'wallet/src/features/unitags/hooks'
import { shortenAddress } from 'wallet/src/utils/addresses' import { shortenAddress } from 'wallet/src/utils/addresses'
export function EditProfileScreen({ export function EditProfileScreen({
route, route,
}: UnitagStackScreenProp<UnitagScreens.EditProfile>): JSX.Element { }: UnitagStackScreenProp<UnitagScreens.EditProfile>): JSX.Element {
// TODO (MOB-1314): add backend call to get unitag from address
const unitag = 'placeholder'
const { address } = route.params const { address } = route.params
const unitag = useUnitag(address)
const { name: ensName } = useENS(ChainId.Mainnet, address) const { name: ensName } = useENS(ChainId.Mainnet, address)
const navigation = useNavigation() const navigation = useNavigation()
const insets = useDeviceInsets() const insets = useDeviceInsets()
......
...@@ -8,6 +8,7 @@ import { ...@@ -8,6 +8,7 @@ import {
} from 'react-native' } from 'react-native'
import { FadeIn, FadeOut } from 'react-native-reanimated' import { FadeIn, FadeOut } from 'react-native-reanimated'
import { AddressDisplay } from 'src/components/AddressDisplay' import { AddressDisplay } from 'src/components/AddressDisplay'
import { SpinningLoader } from 'src/components/loading/SpinningLoader'
import { WalletSelectorModal } from 'src/components/unitags/WalletSelectorModal' import { WalletSelectorModal } from 'src/components/unitags/WalletSelectorModal'
import InputWithSuffix from 'src/features/import/InputWithSuffix' import InputWithSuffix from 'src/features/import/InputWithSuffix'
import { UNITAG_SUFFIX } from 'src/features/unitags/constants' import { UNITAG_SUFFIX } from 'src/features/unitags/constants'
...@@ -31,6 +32,7 @@ type UnitagInputProps = { ...@@ -31,6 +32,7 @@ type UnitagInputProps = {
onSubmit?: () => void onSubmit?: () => void
inputSuffix?: string inputSuffix?: string
liveCheck?: boolean liveCheck?: boolean
loading?: boolean
showUnitagLogo: boolean showUnitagLogo: boolean
onBlur?: () => void onBlur?: () => void
onFocus?: () => void onFocus?: () => void
...@@ -45,6 +47,7 @@ export function UnitagInput({ ...@@ -45,6 +47,7 @@ export function UnitagInput({
onSubmit, onSubmit,
onChange, onChange,
liveCheck, liveCheck,
loading,
placeholderLabel, placeholderLabel,
showUnitagLogo, showUnitagLogo,
errorMessage, errorMessage,
...@@ -129,7 +132,16 @@ export function UnitagInput({ ...@@ -129,7 +132,16 @@ export function UnitagInput({
onFocus={handleFocus} onFocus={handleFocus}
onSubmitEditing={handleSubmit} onSubmitEditing={handleSubmit}
/> />
{showUnitagLogo && <Unitag height={iconSizes.icon24} width={iconSizes.icon24} />} {loading && (
<AnimatedFlex centered entering={FadeIn} exiting={FadeOut}>
<SpinningLoader size={iconSizes.icon24} />
</AnimatedFlex>
)}
{showUnitagLogo && (
<AnimatedFlex centered entering={FadeIn} exiting={FadeOut}>
<Unitag height={iconSizes.icon24} width={iconSizes.icon24} />
</AnimatedFlex>
)}
</Flex> </Flex>
{!value && ( {!value && (
<AnimatedFlex <AnimatedFlex
......
...@@ -10,10 +10,11 @@ import { CurrencyId } from 'wallet/src/utils/currencyId' ...@@ -10,10 +10,11 @@ import { CurrencyId } from 'wallet/src/utils/currencyId'
import { isAndroid } from 'wallet/src/utils/platform' import { isAndroid } from 'wallet/src/utils/platform'
const APP_GROUP = 'group.com.uniswap.widgets' const APP_GROUP = 'group.com.uniswap.widgets'
const WIDGET_EVENTS_KEY = getBuildVariant() + '.widgets.configuration.events' const KEY_WIDGET_EVENTS = getBuildVariant() + '.widgets.configuration.events'
const WIDGET_CACHE_KEY = getBuildVariant() + '.widgets.configuration.cache' const KEY_WIDGET_CACHE = getBuildVariant() + '.widgets.configuration.cache'
const FAVORITE_WIDGETS_KEY = getBuildVariant() + '.widgets.favorites' const KEY_WIDGETS_FAVORITE = getBuildVariant() + '.widgets.favorites'
const ACCOUNTS_WIDGETS_KEY = getBuildVariant() + '.widgets.accounts' const KEY_WIDGETS_ACCOUNTS = getBuildVariant() + '.widgets.accounts'
const KEY_WIDGETS_I18N = getBuildVariant() + '.widgets.i18n'
const { RNWidgets } = NativeModules const { RNWidgets } = NativeModules
...@@ -40,6 +41,11 @@ export type WidgetConfiguration = { ...@@ -40,6 +41,11 @@ export type WidgetConfiguration = {
family: string family: string
} }
export type WidgetI18nSettings = {
locale: string
currency: string
}
export const setUserDefaults = async (data: object, key: string): Promise<void> => { export const setUserDefaults = async (data: object, key: string): Promise<void> => {
const dataJSON = JSON.stringify(data) const dataJSON = JSON.stringify(data)
await setItem(key, dataJSON, APP_GROUP) await setItem(key, dataJSON, APP_GROUP)
...@@ -55,7 +61,7 @@ export const setFavoritesUserDefaults = (currencyIds: CurrencyId[]): void => { ...@@ -55,7 +61,7 @@ export const setFavoritesUserDefaults = (currencyIds: CurrencyId[]): void => {
const data = { const data = {
favorites, favorites,
} }
setUserDefaults(data, FAVORITE_WIDGETS_KEY).catch(() => undefined) setUserDefaults(data, KEY_WIDGETS_FAVORITE).catch(() => undefined)
} }
export const setAccountAddressesUserDefaults = (accounts: Account[]): void => { export const setAccountAddressesUserDefaults = (accounts: Account[]): void => {
...@@ -70,7 +76,11 @@ export const setAccountAddressesUserDefaults = (accounts: Account[]): void => { ...@@ -70,7 +76,11 @@ export const setAccountAddressesUserDefaults = (accounts: Account[]): void => {
const data = { const data = {
accounts: userDefaultAccounts, accounts: userDefaultAccounts,
} }
setUserDefaults(data, ACCOUNTS_WIDGETS_KEY).catch(() => undefined) setUserDefaults(data, KEY_WIDGETS_ACCOUNTS).catch(() => undefined)
}
export const setI18NUserDefaults = (i18nSettings: WidgetI18nSettings): void => {
setUserDefaults(i18nSettings, KEY_WIDGETS_I18N).catch(() => undefined)
} }
// handles edge case where there is a widget left in the cache, // handles edge case where there is a widget left in the cache,
...@@ -79,7 +89,7 @@ export const setAccountAddressesUserDefaults = (accounts: Account[]): void => { ...@@ -79,7 +89,7 @@ export const setAccountAddressesUserDefaults = (accounts: Account[]): void => {
async function handleLastRemovalEvents(): Promise<void> { async function handleLastRemovalEvents(): Promise<void> {
const areWidgetsInstalled = await hasWidgetsInstalled() const areWidgetsInstalled = await hasWidgetsInstalled()
if (!areWidgetsInstalled) { if (!areWidgetsInstalled) {
const widgetCacheJSONString = await getItem(WIDGET_CACHE_KEY, APP_GROUP) const widgetCacheJSONString = await getItem(KEY_WIDGET_CACHE, APP_GROUP)
if (!widgetCacheJSONString) { if (!widgetCacheJSONString) {
return return
} }
...@@ -91,14 +101,14 @@ async function handleLastRemovalEvents(): Promise<void> { ...@@ -91,14 +101,14 @@ async function handleLastRemovalEvents(): Promise<void> {
change: 'removed', change: 'removed',
}) })
}) })
await setUserDefaults({ configuration: [] }, WIDGET_CACHE_KEY) await setUserDefaults({ configuration: [] }, KEY_WIDGET_CACHE)
} }
} }
export async function processWidgetEvents(): Promise<void> { export async function processWidgetEvents(): Promise<void> {
reloadAllTimelines() reloadAllTimelines()
await handleLastRemovalEvents() await handleLastRemovalEvents()
const widgetEventsJSONString = await getItem(WIDGET_EVENTS_KEY, APP_GROUP) const widgetEventsJSONString = await getItem(KEY_WIDGET_EVENTS, APP_GROUP)
if (!widgetEventsJSONString) { if (!widgetEventsJSONString) {
return return
...@@ -110,7 +120,7 @@ export async function processWidgetEvents(): Promise<void> { ...@@ -110,7 +120,7 @@ export async function processWidgetEvents(): Promise<void> {
if (widgetEvents.events.length > 0) { if (widgetEvents.events.length > 0) {
analytics.flushEvents() analytics.flushEvents()
await setUserDefaults({ events: [] }, WIDGET_EVENTS_KEY) await setUserDefaults({ events: [] }, KEY_WIDGET_EVENTS)
} }
} }
......
...@@ -10,6 +10,7 @@ import { TapGestureHandler, TapGestureHandlerGestureEvent } from 'react-native-g ...@@ -10,6 +10,7 @@ import { TapGestureHandler, TapGestureHandlerGestureEvent } from 'react-native-g
import Animated, { import Animated, {
cancelAnimation, cancelAnimation,
FadeIn, FadeIn,
FadeOut,
interpolateColor, interpolateColor,
runOnJS, runOnJS,
useAnimatedGestureHandler, useAnimatedGestureHandler,
...@@ -82,6 +83,7 @@ import { useInterval, useTimeout } from 'utilities/src/time/timing' ...@@ -82,6 +83,7 @@ import { useInterval, useTimeout } from 'utilities/src/time/timing'
import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants' import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants'
import { useFeatureFlag } from 'wallet/src/features/experiments/hooks' import { useFeatureFlag } from 'wallet/src/features/experiments/hooks'
import { setNotificationStatus } from 'wallet/src/features/notifications/slice' import { setNotificationStatus } from 'wallet/src/features/notifications/slice'
import { useCanActiveAddressClaimUnitag } from 'wallet/src/features/unitags/hooks'
import { AccountType } from 'wallet/src/features/wallet/accounts/types' import { AccountType } from 'wallet/src/features/wallet/accounts/types'
import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks'
import { HomeScreenTabIndex } from './HomeScreenTabIndex' import { HomeScreenTabIndex } from './HomeScreenTabIndex'
...@@ -366,7 +368,7 @@ export function HomeScreen(props?: AppStackScreenProp<Screens.Home>): JSX.Elemen ...@@ -366,7 +368,7 @@ export function HomeScreen(props?: AppStackScreenProp<Screens.Home>): JSX.Elemen
] ]
) )
const unitagsFeatureFlagEnabled = useFeatureFlag(FEATURE_FLAGS.Unitags) const hasClaimEligibility = useCanActiveAddressClaimUnitag()
const viewOnlyLabel = t('This is a view-only wallet') const viewOnlyLabel = t('This is a view-only wallet')
const contentHeader = useMemo(() => { const contentHeader = useMemo(() => {
return ( return (
...@@ -384,10 +386,14 @@ export function HomeScreen(props?: AppStackScreenProp<Screens.Home>): JSX.Elemen ...@@ -384,10 +386,14 @@ export function HomeScreen(props?: AppStackScreenProp<Screens.Home>): JSX.Elemen
</Text> </Text>
</Flex> </Flex>
)} )}
{unitagsFeatureFlagEnabled && <UnitagBanner />} {hasClaimEligibility && (
<AnimatedFlex entering={FadeIn} exiting={FadeOut}>
<UnitagBanner />
</AnimatedFlex>
)}
</Flex> </Flex>
) )
}, [activeAccount.address, isSignerAccount, viewOnlyLabel, actions, unitagsFeatureFlagEnabled]) }, [activeAccount.address, isSignerAccount, viewOnlyLabel, actions, hasClaimEligibility])
const contentContainerStyle = useMemo<StyleProp<ViewStyle>>( const contentContainerStyle = useMemo<StyleProp<ViewStyle>>(
() => ({ () => ({
......
...@@ -16,7 +16,7 @@ import { ...@@ -16,7 +16,7 @@ import {
PendingAccountActions, PendingAccountActions,
pendingAccountActions, pendingAccountActions,
} from 'wallet/src/features/wallet/create/pendingAccountsSaga' } from 'wallet/src/features/wallet/create/pendingAccountsSaga'
import { shortenAddress } from 'wallet/src/utils/addresses' import { sanitizeAddressText, shortenAddress } from 'wallet/src/utils/addresses'
import { isAndroid } from 'wallet/src/utils/platform' import { isAndroid } from 'wallet/src/utils/platform'
type Props = NativeStackScreenProps<OnboardingStackParamList, OnboardingScreens.RestoreCloudBackup> type Props = NativeStackScreenProps<OnboardingStackParamList, OnboardingScreens.RestoreCloudBackup>
...@@ -24,6 +24,7 @@ type Props = NativeStackScreenProps<OnboardingStackParamList, OnboardingScreens. ...@@ -24,6 +24,7 @@ type Props = NativeStackScreenProps<OnboardingStackParamList, OnboardingScreens.
export function RestoreCloudBackupScreen({ navigation, route: { params } }: Props): JSX.Element { export function RestoreCloudBackupScreen({ navigation, route: { params } }: Props): JSX.Element {
const { t } = useTranslation() const { t } = useTranslation()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
// const backups = useMockCloudBackups(4) // returns 4 mock backups with random mnemonicIds and createdAt dates
const backups = useCloudBackups() const backups = useCloudBackups()
const sortedBackups = backups.slice().sort((a, b) => b.createdAt - a.createdAt) const sortedBackups = backups.slice().sort((a, b) => b.createdAt - a.createdAt)
...@@ -50,7 +51,7 @@ export function RestoreCloudBackupScreen({ navigation, route: { params } }: Prop ...@@ -50,7 +51,7 @@ export function RestoreCloudBackupScreen({ navigation, route: { params } }: Prop
title={t('Select backup to restore')}> title={t('Select backup to restore')}>
<ScrollView> <ScrollView>
<Flex gap="$spacing8"> <Flex gap="$spacing8">
{sortedBackups.map((backup, index) => { {sortedBackups.map((backup) => {
const { mnemonicId, createdAt } = backup const { mnemonicId, createdAt } = backup
return ( return (
<TouchableArea <TouchableArea
...@@ -65,25 +66,15 @@ export function RestoreCloudBackupScreen({ navigation, route: { params } }: Prop ...@@ -65,25 +66,15 @@ export function RestoreCloudBackupScreen({ navigation, route: { params } }: Prop
<Flex centered row gap="$spacing12"> <Flex centered row gap="$spacing12">
<Unicon address={mnemonicId} size={32} /> <Unicon address={mnemonicId} size={32} />
<Flex> <Flex>
<Text numberOfLines={1} variant="subheading2"> <Text adjustsFontSizeToFit variant="subheading1">
{t('Backup {{backupIndex}}', { backupIndex: sortedBackups.length - index })} {sanitizeAddressText(shortenAddress(mnemonicId))}
</Text> </Text>
<Text color="$neutral2" variant="buttonLabel4"> <Text adjustsFontSizeToFit color="$neutral2" variant="buttonLabel4">
{shortenAddress(mnemonicId)}
</Text>
</Flex>
</Flex>
<Flex row gap="$spacing12">
<Flex alignItems="flex-end" gap="$spacing4">
<Text color="$neutral2" variant="buttonLabel4">
{t('Backed up on:')}
</Text>
<Text variant="buttonLabel4">
{dayjs.unix(createdAt).format('MMM D, YYYY, h:mma')} {dayjs.unix(createdAt).format('MMM D, YYYY, h:mma')}
</Text> </Text>
</Flex> </Flex>
<Icons.RotatableChevron color="$neutral1" direction="end" />
</Flex> </Flex>
<Icons.RotatableChevron color="$neutral2" direction="end" />
</Flex> </Flex>
</TouchableArea> </TouchableArea>
) )
......
...@@ -297,6 +297,7 @@ function NFTItemScreenContents({ ...@@ -297,6 +297,7 @@ function NFTItemScreenContents({
<Flex gap="$spacing12" px="$spacing24"> <Flex gap="$spacing12" px="$spacing24">
{listingPrice?.value ? ( {listingPrice?.value ? (
<AssetMetadata <AssetMetadata
color={accentTextColor}
title={t('Current price')} title={t('Current price')}
valueComponent={ valueComponent={
<PriceAmount <PriceAmount
...@@ -310,6 +311,7 @@ function NFTItemScreenContents({ ...@@ -310,6 +311,7 @@ function NFTItemScreenContents({
) : null} ) : null}
{lastSaleData?.price?.value ? ( {lastSaleData?.price?.value ? (
<AssetMetadata <AssetMetadata
color={accentTextColor}
title={t('Last sale price')} title={t('Last sale price')}
valueComponent={ valueComponent={
<PriceAmount <PriceAmount
...@@ -324,6 +326,7 @@ function NFTItemScreenContents({ ...@@ -324,6 +326,7 @@ function NFTItemScreenContents({
{owner && ( {owner && (
<AssetMetadata <AssetMetadata
color={accentTextColor}
title={t('Owned by')} title={t('Owned by')}
valueComponent={ valueComponent={
<TouchableArea <TouchableArea
...@@ -365,14 +368,17 @@ function NFTItemScreenContents({ ...@@ -365,14 +368,17 @@ function NFTItemScreenContents({
function AssetMetadata({ function AssetMetadata({
title, title,
valueComponent, valueComponent,
color,
}: { }: {
title: string title: string
valueComponent: JSX.Element valueComponent: JSX.Element
color: string
}): JSX.Element { }): JSX.Element {
const colors = useSporeColors()
return ( return (
<Flex row alignItems="center" justifyContent="space-between" pl="$spacing2"> <Flex row alignItems="center" justifyContent="space-between" pl="$spacing2">
<Flex row alignItems="center" gap="$spacing8" justifyContent="flex-start" maxWidth="40%"> <Flex row alignItems="center" gap="$spacing8" justifyContent="flex-start" maxWidth="40%">
<Text color="$neutral2" variant="body2"> <Text style={{ color: color ?? colors.neutral2.get() }} variant="body2">
{title} {title}
</Text> </Text>
</Flex> </Flex>
......
...@@ -18,9 +18,8 @@ import { Button, Flex, Text, TouchableArea } from 'ui/src' ...@@ -18,9 +18,8 @@ import { Button, Flex, Text, TouchableArea } from 'ui/src'
import { useTimeout } from 'utilities/src/time/timing' import { useTimeout } from 'utilities/src/time/timing'
import { uniswapUrls } from 'wallet/src/constants/urls' import { uniswapUrls } from 'wallet/src/constants/urls'
import { useIsDarkMode } from 'wallet/src/features/appearance/hooks' import { useIsDarkMode } from 'wallet/src/features/appearance/hooks'
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 { ImportType, OnboardingEntryPoint } from 'wallet/src/features/onboarding/types'
import { useCanAddressClaimUnitag } from 'wallet/src/features/unitags/hooks'
import { createAccountActions } from 'wallet/src/features/wallet/create/createAccountSaga' import { createAccountActions } from 'wallet/src/features/wallet/create/createAccountSaga'
import { import {
PendingAccountActions, PendingAccountActions,
...@@ -33,9 +32,7 @@ export function LandingScreen({ navigation }: Props): JSX.Element { ...@@ -33,9 +32,7 @@ export function LandingScreen({ navigation }: Props): JSX.Element {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { t } = useTranslation() const { t } = useTranslation()
const isDarkMode = useIsDarkMode() const isDarkMode = useIsDarkMode()
const unitagsFeatureFlagEnabled = useFeatureFlag(FEATURE_FLAGS.Unitags) const canClaimUnitag = useCanAddressClaimUnitag()
// TODO (MOB-1314): request /claim/eligibility/ from unitags backend
const canClaimUnitag = true && unitagsFeatureFlagEnabled
const onPressCreateWallet = (): void => { const onPressCreateWallet = (): void => {
dispatch(pendingAccountActions.trigger(PendingAccountActions.Delete)) dispatch(pendingAccountActions.trigger(PendingAccountActions.Delete))
......
...@@ -44,6 +44,7 @@ import { useENS } from 'wallet/src/features/ens/useENS' ...@@ -44,6 +44,7 @@ import { useENS } from 'wallet/src/features/ens/useENS'
import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants' import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants'
import { useFeatureFlag } from 'wallet/src/features/experiments/hooks' import { useFeatureFlag } from 'wallet/src/features/experiments/hooks'
import { ImportType, OnboardingEntryPoint } from 'wallet/src/features/onboarding/types' import { ImportType, OnboardingEntryPoint } from 'wallet/src/features/onboarding/types'
import { useUnitag } from 'wallet/src/features/unitags/hooks'
import { import {
EditAccountAction, EditAccountAction,
editAccountActions, editAccountActions,
...@@ -304,9 +305,7 @@ const renderItemSeparator = (): JSX.Element => <Flex pt="$spacing8" /> ...@@ -304,9 +305,7 @@ const renderItemSeparator = (): JSX.Element => <Flex pt="$spacing8" />
function AddressDisplayHeader({ address }: { address: Address }): JSX.Element { function AddressDisplayHeader({ address }: { address: Address }): JSX.Element {
const { t } = useTranslation() const { t } = useTranslation()
const ensName = useENS(ChainId.Mainnet, address)?.name const ensName = useENS(ChainId.Mainnet, address)?.name
const unitagsFeatureFlagEnabled = useFeatureFlag(FEATURE_FLAGS.Unitags) const hasUnitag = !!useUnitag(address)
// TODO (MOB-2122): GET /address from unitags backend to check for unitag
const hasUnitag = false && unitagsFeatureFlagEnabled
const onPressEditProfile = (): void => { const onPressEditProfile = (): void => {
if (hasUnitag) { if (hasUnitag) {
......
...@@ -13,4 +13,5 @@ REACT_APP_SENTRY_DSN="https://a3c62e400b8748b5a8d007150e2f38b7@o1037921.ingest.s ...@@ -13,4 +13,5 @@ REACT_APP_SENTRY_DSN="https://a3c62e400b8748b5a8d007150e2f38b7@o1037921.ingest.s
REACT_APP_STATSIG_PROXY_URL="https://api.uniswap.org/v1/statsig-proxy" REACT_APP_STATSIG_PROXY_URL="https://api.uniswap.org/v1/statsig-proxy"
REACT_APP_TEMP_API_URL="https://temp.api.uniswap.org/v1" REACT_APP_TEMP_API_URL="https://temp.api.uniswap.org/v1"
REACT_APP_UNISWAP_API_URL="https://api.uniswap.org/v2" REACT_APP_UNISWAP_API_URL="https://api.uniswap.org/v2"
REACT_APP_UNISWAP_GATEWAY_DNS="https://interface.gateway.uniswap.org/v2"
REACT_APP_WALLET_CONNECT_PROJECT_ID="c6c9bacd35afa3eb9e6cccf6d8464395" REACT_APP_WALLET_CONNECT_PROJECT_ID="c6c9bacd35afa3eb9e6cccf6d8464395"
...@@ -31,7 +31,8 @@ ...@@ -31,7 +31,8 @@
"test:cloud": "yarn jest functions --config=functions/jest.config.json", "test:cloud": "yarn jest functions --config=functions/jest.config.json",
"cypress:open": "cypress open --browser chrome --e2e", "cypress:open": "cypress open --browser chrome --e2e",
"cypress:run": "cypress run --browser chrome --e2e", "cypress:run": "cypress run --browser chrome --e2e",
"deduplicate": "yarn-deduplicate --strategy=highest" "deduplicate": "yarn-deduplicate --strategy=highest",
"web:ignore-build": "exit 1"
}, },
"husky": { "husky": {
"hooks": { "hooks": {
...@@ -100,7 +101,7 @@ ...@@ -100,7 +101,7 @@
"@types/lingui__react": "2.8.3", "@types/lingui__react": "2.8.3",
"@types/ms": "0.7.31", "@types/ms": "0.7.31",
"@types/multicodec": "1.0.0", "@types/multicodec": "1.0.0",
"@types/node": "13.13.5", "@types/node": "18.16.0",
"@types/qs": "6.9.2", "@types/qs": "6.9.2",
"@types/react": "^18.0.15", "@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.6",
......
# * # *
User-agent: * User-agent: *
Disallow: /static/js/ Disallow:
Allow: /
# Host
Host: https://app.uniswap.org
# Sitemaps # Sitemaps
Sitemap: https://app.uniswap.org/sitemap.xml Sitemap: https://app.uniswap.org/sitemap.xml
\ No newline at end of file
...@@ -43,7 +43,7 @@ function StatusIndicator({ activity: { status, timestamp } }: { activity: Activi ...@@ -43,7 +43,7 @@ function StatusIndicator({ activity: { status, timestamp } }: { activity: Activi
} }
export function ActivityRow({ activity }: { activity: Activity }) { export function ActivityRow({ activity }: { activity: Activity }) {
const { chainId, title, descriptor, logos, otherAccount, currencies, hash, prefixIconSrc, offchainOrderStatus } = const { chainId, title, descriptor, logos, otherAccount, currencies, hash, prefixIconSrc, offchainOrderDetails } =
activity activity
const openOffchainActivityModal = useOpenOffchainActivityModal() const openOffchainActivityModal = useOpenOffchainActivityModal()
...@@ -52,13 +52,13 @@ export function ActivityRow({ activity }: { activity: Activity }) { ...@@ -52,13 +52,13 @@ export function ActivityRow({ activity }: { activity: Activity }) {
const explorerUrl = getExplorerLink(chainId, hash, ExplorerDataType.TRANSACTION) const explorerUrl = getExplorerLink(chainId, hash, ExplorerDataType.TRANSACTION)
const onClick = useCallback(() => { const onClick = useCallback(() => {
if (offchainOrderStatus) { if (offchainOrderDetails) {
openOffchainActivityModal({ orderHash: hash, status: offchainOrderStatus }) openOffchainActivityModal(offchainOrderDetails)
return return
} }
window.open(getExplorerLink(chainId, hash, ExplorerDataType.TRANSACTION), '_blank') window.open(getExplorerLink(chainId, hash, ExplorerDataType.TRANSACTION), '_blank')
}, [offchainOrderStatus, chainId, hash, openOffchainActivityModal]) }, [chainId, hash, offchainOrderDetails, openOffchainActivityModal])
return ( return (
<TraceEvent <TraceEvent
......
...@@ -15,16 +15,15 @@ import { useCallback, useMemo } from 'react' ...@@ -15,16 +15,15 @@ import { useCallback, useMemo } from 'react'
import { X } from 'react-feather' import { X } from 'react-feather'
import { InterfaceTrade } from 'state/routing/types' import { InterfaceTrade } from 'state/routing/types'
import { useOrder } from 'state/signatures/hooks' import { useOrder } from 'state/signatures/hooks'
import { UniswapXOrderDetails } from 'state/signatures/types'
import styled from 'styled-components' import styled from 'styled-components'
import { ExternalLink, ThemedText } from 'theme/components' import { ExternalLink, ThemedText } from 'theme/components'
import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink'
import { OffchainOrderDetails } from './types'
type SelectedOrderInfo = { type SelectedOrderInfo = {
modalOpen?: boolean modalOpen?: boolean
orderHash: string order?: OffchainOrderDetails
status: UniswapXOrderStatus
details?: UniswapXOrderDetails
} }
const selectedOrderAtom = atom<SelectedOrderInfo | undefined>(undefined) const selectedOrderAtom = atom<SelectedOrderInfo | undefined>(undefined)
...@@ -32,10 +31,7 @@ const selectedOrderAtom = atom<SelectedOrderInfo | undefined>(undefined) ...@@ -32,10 +31,7 @@ const selectedOrderAtom = atom<SelectedOrderInfo | undefined>(undefined)
export function useOpenOffchainActivityModal() { export function useOpenOffchainActivityModal() {
const setSelectedOrder = useUpdateAtom(selectedOrderAtom) const setSelectedOrder = useUpdateAtom(selectedOrderAtom)
return useCallback( return useCallback((order: OffchainOrderDetails) => setSelectedOrder({ order, modalOpen: true }), [setSelectedOrder])
(order: { orderHash: string; status: UniswapXOrderStatus }) => setSelectedOrder({ ...order, modalOpen: true }),
[setSelectedOrder]
)
} }
const Wrapper = styled(AutoColumn).attrs({ gap: 'md', grow: true })` const Wrapper = styled(AutoColumn).attrs({ gap: 'md', grow: true })`
...@@ -89,19 +85,19 @@ const DescriptionText = styled(ThemedText.LabelMicro)` ...@@ -89,19 +85,19 @@ const DescriptionText = styled(ThemedText.LabelMicro)`
` `
function useOrderAmounts( function useOrderAmounts(
orderDetails?: UniswapXOrderDetails order?: OffchainOrderDetails
): Pick<InterfaceTrade, 'inputAmount' | 'outputAmount'> | undefined { ): Pick<InterfaceTrade, 'inputAmount' | 'outputAmount'> | undefined {
const inputCurrency = useCurrency(orderDetails?.swapInfo?.inputCurrencyId, orderDetails?.chainId) const inputCurrency = useCurrency(order?.swapInfo?.inputCurrencyId, order?.chainId)
const outputCurrency = useCurrency(orderDetails?.swapInfo?.outputCurrencyId, orderDetails?.chainId) const outputCurrency = useCurrency(order?.swapInfo?.outputCurrencyId, order?.chainId)
if (!orderDetails) return undefined if (!order || !order?.swapInfo) return undefined
if (!inputCurrency || !outputCurrency) { if (!inputCurrency || !outputCurrency) {
console.error(`Could not find token(s) for order ${orderDetails.orderHash}`) console.error(`Could not find token(s) for order ${order.txHash}`)
return undefined return undefined
} }
const { swapInfo } = orderDetails const { swapInfo } = order
if (swapInfo.tradeType === TradeType.EXACT_INPUT) { if (swapInfo.tradeType === TradeType.EXACT_INPUT) {
return { return {
...@@ -119,11 +115,11 @@ function useOrderAmounts( ...@@ -119,11 +115,11 @@ function useOrderAmounts(
} }
} }
export function OrderContent({ order }: { order: SelectedOrderInfo }) { export function OrderContent({ order }: { order: OffchainOrderDetails }) {
const amounts = useOrderAmounts(order.details) const amounts = useOrderAmounts(order)
const explorerLink = order?.details?.txHash const explorerLink = order?.txHash
? getExplorerLink(order.details.chainId, order.details.txHash, ExplorerDataType.TRANSACTION) ? getExplorerLink(order.chainId, order.txHash, ExplorerDataType.TRANSACTION)
: undefined : undefined
switch (order.status) { switch (order.status) {
...@@ -224,22 +220,34 @@ export function OrderContent({ order }: { order: SelectedOrderInfo }) { ...@@ -224,22 +220,34 @@ export function OrderContent({ order }: { order: SelectedOrderInfo }) {
} }
/* Returns the order currently selected in the UI synced with updates from order status polling */ /* Returns the order currently selected in the UI synced with updates from order status polling */
function useSyncedSelectedOrder(): SelectedOrderInfo | undefined { function useSyncedSelectedOrder(): OffchainOrderDetails | undefined {
const selectedOrder = useAtomValue(selectedOrderAtom) const selectedOrder = useAtomValue(selectedOrderAtom)
const localPendingOrder = useOrder(selectedOrder?.orderHash ?? '') const localPendingOrder = useOrder(selectedOrder?.order?.txHash ?? '')
return useMemo(() => { return useMemo(() => {
if (!selectedOrder) return undefined if (!selectedOrder?.order) return undefined
return { return {
...selectedOrder, ...selectedOrder.order,
status: localPendingOrder?.status ?? selectedOrder.status, ...localPendingOrder,
details: localPendingOrder,
} }
}, [localPendingOrder, selectedOrder]) }, [localPendingOrder, selectedOrder])
} }
/**
* This is the modal that appears when you click on an X order in the activity tab.
*
* It needs to handle multiple types of X orders:
* - Pending orders initiated locally i.e. UniswapXOrderDetails
* - Pending/expired/cancelled orders initiated remotely and tracked locally i.e. SwapOrderDetailsParts from the Activity query
* - Filled orders i.e. TransactionDetailsParts from the Activity query.
*
* Because of this, we try to converge the different cases into one type, OffchainOrderDetails,
* which can be passed around within the Activity in the case of remote records.
*/
export function OffchainActivityModal() { export function OffchainActivityModal() {
const selectedOrderAtomValue = useAtomValue(selectedOrderAtom)
const syncedSelectedOrder = useSyncedSelectedOrder() const syncedSelectedOrder = useSyncedSelectedOrder()
const setSelectedOrder = useUpdateAtom(selectedOrderAtom) const setSelectedOrder = useUpdateAtom(selectedOrderAtom)
...@@ -248,7 +256,7 @@ export function OffchainActivityModal() { ...@@ -248,7 +256,7 @@ export function OffchainActivityModal() {
}, [setSelectedOrder]) }, [setSelectedOrder])
return ( return (
<Modal isOpen={!!syncedSelectedOrder?.modalOpen} onDismiss={reset}> <Modal isOpen={!!selectedOrderAtomValue?.modalOpen} onDismiss={reset}>
<Wrapper data-testid="offchain-activity-modal"> <Wrapper data-testid="offchain-activity-modal">
<StyledXButton onClick={reset} /> <StyledXButton onClick={reset} />
{syncedSelectedOrder && <OrderContent order={syncedSelectedOrder} />} {syncedSelectedOrder && <OrderContent order={syncedSelectedOrder} />}
......
...@@ -100,7 +100,23 @@ Object { ...@@ -100,7 +100,23 @@ Object {
"someUrl", "someUrl",
"someUrl", "someUrl",
], ],
"offchainOrderStatus": "expired", "offchainOrderDetails": Object {
"chainId": 1,
"status": "expired",
"swapInfo": Object {
"expectedOutputCurrencyAmountRaw": "200",
"inputCurrencyAmountRaw": "100",
"inputCurrencyId": "0x6B175474E89094C44Da98b954EedeAC495271d0F",
"isUniswapXOrder": true,
"minimumOutputCurrencyAmountRaw": "200",
"outputCurrencyId": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
"settledOutputCurrencyAmountRaw": "200",
"tradeType": 0,
"type": 1,
},
"txHash": "someHash",
"type": "signUniswapXOrder",
},
"prefixIconSrc": "bolt.svg", "prefixIconSrc": "bolt.svg",
"status": "FAILED", "status": "FAILED",
"statusMessage": "Your swap could not be fulfilled at this time. Please try again.", "statusMessage": "Your swap could not be fulfilled at this time. Please try again.",
...@@ -375,6 +391,23 @@ Object { ...@@ -375,6 +391,23 @@ Object {
"logoUrl", "logoUrl",
], ],
"nonce": 12345, "nonce": 12345,
"offchainOrderDetails": Object {
"chainId": 1,
"status": "filled",
"swapInfo": Object {
"expectedOutputCurrencyAmountRaw": 100,
"inputCurrencyAmountRaw": 100,
"inputCurrencyId": "0x6B175474E89094C44Da98b954EedeAC495271d0F",
"isUniswapXOrder": true,
"minimumOutputCurrencyAmountRaw": 100,
"outputCurrencyId": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
"settledOutputCurrencyAmountRaw": 100,
"tradeType": 0,
"type": 1,
},
"txHash": "someHash",
"type": "signUniswapXOrder",
},
"prefixIconSrc": "bolt.svg", "prefixIconSrc": "bolt.svg",
"status": "CONFIRMED", "status": "CONFIRMED",
"timestamp": 10000, "timestamp": 10000,
......
...@@ -12,6 +12,7 @@ import { ...@@ -12,6 +12,7 @@ import {
TokenApprovalPartsFragment, TokenApprovalPartsFragment,
TokenStandard, TokenStandard,
TokenTransferPartsFragment, TokenTransferPartsFragment,
TransactionDetailsPartsFragment,
TransactionDirection, TransactionDirection,
TransactionStatus, TransactionStatus,
TransactionType, TransactionType,
...@@ -23,6 +24,18 @@ const MockOrderTimestamp = 10000 ...@@ -23,6 +24,18 @@ const MockOrderTimestamp = 10000
const MockRecipientAddress = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' const MockRecipientAddress = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'
export const MockSenderAddress = '0x50EC05ADe8280758E2077fcBC08D878D4aef79C3' export const MockSenderAddress = '0x50EC05ADe8280758E2077fcBC08D878D4aef79C3'
export const mockTransactionDetailsPartsFragment: TransactionDetailsPartsFragment = {
__typename: 'TransactionDetails',
id: 'tx123',
type: TransactionType.Swap,
from: '0xSenderAddress',
to: '0xRecipientAddress',
hash: '0xHashValue',
nonce: 123,
status: TransactionStatus.Confirmed,
assetChanges: [],
}
const mockAssetActivityPartsFragment = { const mockAssetActivityPartsFragment = {
__typename: 'AssetActivity', __typename: 'AssetActivity',
id: 'activityId', id: 'activityId',
...@@ -199,7 +212,7 @@ const mockSpamNftTransferPartsFragment: NftTransferPartsFragment = { ...@@ -199,7 +212,7 @@ const mockSpamNftTransferPartsFragment: NftTransferPartsFragment = {
}, },
} }
const mockTokenTransferOutPartsFragment: TokenTransferPartsFragment = { export const mockTokenTransferOutPartsFragment: TokenTransferPartsFragment = {
__typename: 'TokenTransfer', __typename: 'TokenTransfer',
id: 'tokenTransferId', id: 'tokenTransferId',
tokenStandard: TokenStandard.Erc20, tokenStandard: TokenStandard.Erc20,
...@@ -307,7 +320,7 @@ const mockWrappedEthTransferInPartsFragment: TokenTransferPartsFragment = { ...@@ -307,7 +320,7 @@ const mockWrappedEthTransferInPartsFragment: TokenTransferPartsFragment = {
}, },
} }
const mockTokenTransferInPartsFragment: TokenTransferPartsFragment = { export const mockTokenTransferInPartsFragment: TokenTransferPartsFragment = {
__typename: 'TokenTransfer', __typename: 'TokenTransfer',
id: 'tokenTransferId', id: 'tokenTransferId',
tokenStandard: TokenStandard.Erc20, tokenStandard: TokenStandard.Erc20,
......
...@@ -253,7 +253,13 @@ export function signatureToActivity( ...@@ -253,7 +253,13 @@ export function signatureToActivity(
chainId: signature.chainId, chainId: signature.chainId,
title, title,
status, status,
offchainOrderStatus: signature.status, offchainOrderDetails: {
txHash: signature.orderHash,
chainId: signature.chainId,
type: SignatureType.SIGN_UNISWAPX_ORDER,
status: signature.status,
swapInfo: signature.swapInfo,
},
timestamp: signature.addedTime / 1000, timestamp: signature.addedTime / 1000,
from: signature.offerer, from: signature.offerer,
statusMessage, statusMessage,
......
...@@ -19,9 +19,25 @@ import { ...@@ -19,9 +19,25 @@ import {
MockTokenApproval, MockTokenApproval,
MockTokenReceive, MockTokenReceive,
MockTokenSend, MockTokenSend,
mockTokenTransferInPartsFragment,
mockTokenTransferOutPartsFragment,
mockTransactionDetailsPartsFragment,
MockWrap, MockWrap,
} from './fixtures/activity' } from './fixtures/activity'
import { parseRemoteActivities, useTimeSince } from './parseRemote' import {
offchainOrderDetailsFromGraphQLTransactionActivity,
parseRemoteActivities,
parseSwapAmounts,
useTimeSince,
} from './parseRemote'
const swapOrderTokenChanges = {
TokenTransfer: [mockTokenTransferOutPartsFragment, mockTokenTransferInPartsFragment],
NftTransfer: [],
TokenApproval: [],
NftApproval: [],
NftApproveForAll: [],
}
describe('parseRemote', () => { describe('parseRemote', () => {
beforeEach(() => { beforeEach(() => {
...@@ -141,4 +157,68 @@ describe('parseRemote', () => { ...@@ -141,4 +157,68 @@ describe('parseRemote', () => {
expect(result.current).toBe('1m') expect(result.current).toBe('1m')
}) })
}) })
describe('parseSwapAmounts', () => {
it('should correctly parse amounts when both sent and received tokens are present', () => {
const result = parseSwapAmounts(swapOrderTokenChanges, jest.fn().mockReturnValue('100'))
expect(result).toEqual({
inputCurrencyId: '0x6B175474E89094C44Da98b954EedeAC495271d0F',
inputAmount: '100',
outputCurrencyId: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
outputAmount: '100',
sent: mockTokenTransferOutPartsFragment,
received: mockTokenTransferInPartsFragment,
})
})
it('should return undefined when sent token is missing', () => {
const result = parseSwapAmounts(
{
...swapOrderTokenChanges,
TokenTransfer: [mockTokenTransferOutPartsFragment],
},
jest.fn().mockReturnValue('100')
)
expect(result).toEqual(undefined)
})
})
describe('offchainOrderDetailsFromGraphQLTransactionActivity', () => {
it('should return undefined when the activity is not a swap order', () => {
const result = offchainOrderDetailsFromGraphQLTransactionActivity(
{ ...MockSwapOrder, details: { ...mockTransactionDetailsPartsFragment, __typename: 'TransactionDetails' } },
{
...swapOrderTokenChanges,
TokenTransfer: [],
}, // no token changes
jest.fn().mockReturnValue('100')
)
expect(result).toEqual(undefined)
})
it('should return the OffchainOrderDetails', () => {
const result = offchainOrderDetailsFromGraphQLTransactionActivity(
{ ...MockSwapOrder, details: { ...mockTransactionDetailsPartsFragment, __typename: 'TransactionDetails' } },
swapOrderTokenChanges,
jest.fn().mockReturnValue('100')
)
expect(result).toEqual({
chainId: 1,
status: 'filled',
swapInfo: {
expectedOutputCurrencyAmountRaw: '100',
inputCurrencyAmountRaw: '100',
inputCurrencyId: '0x6B175474E89094C44Da98b954EedeAC495271d0F',
isUniswapXOrder: true,
minimumOutputCurrencyAmountRaw: '100',
outputCurrencyId: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
settledOutputCurrencyAmountRaw: '100',
tradeType: 0,
type: 1,
},
txHash: '0xHashValue',
type: 'signUniswapXOrder',
})
})
})
}) })
import { t } from '@lingui/macro' import { t } from '@lingui/macro'
import { ChainId, Currency, NONFUNGIBLE_POSITION_MANAGER_ADDRESSES, UNI_ADDRESSES } from '@uniswap/sdk-core' import { ChainId, Currency, NONFUNGIBLE_POSITION_MANAGER_ADDRESSES, TradeType, UNI_ADDRESSES } from '@uniswap/sdk-core'
import UniswapXBolt from 'assets/svg/bolt.svg' import UniswapXBolt from 'assets/svg/bolt.svg'
import moonpayLogoSrc from 'assets/svg/moonpay.svg' import moonpayLogoSrc from 'assets/svg/moonpay.svg'
import { nativeOnChain } from 'constants/tokens' import { nativeOnChain } from 'constants/tokens'
...@@ -18,14 +18,21 @@ import { ...@@ -18,14 +18,21 @@ import {
TransactionType, TransactionType,
} from 'graphql/data/__generated__/types-and-hooks' } from 'graphql/data/__generated__/types-and-hooks'
import { gqlToCurrency, logSentryErrorForUnsupportedChain, supportedChainIdFromGQLChain } from 'graphql/data/util' import { gqlToCurrency, logSentryErrorForUnsupportedChain, supportedChainIdFromGQLChain } from 'graphql/data/util'
import { UniswapXOrderStatus } from 'lib/hooks/orders/types'
import tryParseCurrencyAmount from 'lib/utils/tryParseCurrencyAmount'
import ms from 'ms' import ms from 'ms'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import store from 'state'
import { addSignature } from 'state/signatures/reducer'
import { SignatureType } from 'state/signatures/types'
import { TransactionType as LocalTransactionType } from 'state/transactions/types'
import { isAddress } from 'utils' import { isAddress } from 'utils'
import { isSameAddress } from 'utils/addresses' import { isSameAddress } from 'utils/addresses'
import { currencyId } from 'utils/currencyId'
import { NumberType, useFormatter } from 'utils/formatNumbers' import { NumberType, useFormatter } from 'utils/formatNumbers'
import { MOONPAY_SENDER_ADDRESSES, OrderStatusTable, OrderTextTable } from '../constants' import { MOONPAY_SENDER_ADDRESSES, OrderStatusTable, OrderTextTable } from '../constants'
import { Activity } from './types' import { Activity, OffchainOrderDetails } from './types'
type TransactionChanges = { type TransactionChanges = {
NftTransfer: NftTransferPartsFragment[] NftTransfer: NftTransferPartsFragment[]
...@@ -155,6 +162,36 @@ function getTransactedValue(transactedValue: TokenTransferPartsFragment['transac ...@@ -155,6 +162,36 @@ function getTransactedValue(transactedValue: TokenTransferPartsFragment['transac
return price return price
} }
// exported for testing
// eslint-disable-next-line import/no-unused-modules
export function parseSwapAmounts(
changes: TransactionChanges,
formatNumberOrString: FormatNumberOrStringFunctionType
):
| {
inputAmount: string
inputCurrencyId: string
outputAmount: string
outputCurrencyId: string
sent: TokenTransferPartsFragment
received: TokenTransferPartsFragment
}
| undefined {
const sent = changes.TokenTransfer.find((t) => t.direction === 'OUT')
// Any leftover native token is refunded on exact_out swaps where the input token is native
const refund = changes.TokenTransfer.find(
(t) => t.direction === 'IN' && t.asset.id === sent?.asset.id && t.asset.standard === 'NATIVE'
)
const received = changes.TokenTransfer.find((t) => t.direction === 'IN' && t !== refund)
if (!sent || !received) return undefined
const inputCurrencyId = sent.asset.id
const outputCurrencyId = received.asset.id
const adjustedInput = parseFloat(sent.quantity) - parseFloat(refund?.quantity ?? '0')
const inputAmount = formatNumberOrString({ input: adjustedInput, type: NumberType.TokenNonTx })
const outputAmount = formatNumberOrString({ input: received.quantity, type: NumberType.TokenNonTx })
return { sent, received, inputAmount, outputAmount, inputCurrencyId, outputCurrencyId }
}
function parseSwap(changes: TransactionChanges, formatNumberOrString: FormatNumberOrStringFunctionType) { function parseSwap(changes: TransactionChanges, formatNumberOrString: FormatNumberOrStringFunctionType) {
if (changes.NftTransfer.length > 0 && changes.TokenTransfer.length === 1) { if (changes.NftTransfer.length > 0 && changes.TokenTransfer.length === 1) {
const collectionCounts = getCollectionCounts(changes.NftTransfer) const collectionCounts = getCollectionCounts(changes.NftTransfer)
...@@ -168,17 +205,10 @@ function parseSwap(changes: TransactionChanges, formatNumberOrString: FormatNumb ...@@ -168,17 +205,10 @@ function parseSwap(changes: TransactionChanges, formatNumberOrString: FormatNumb
} }
// Some swaps may have more than 2 transfers, e.g. swaps with fees on tranfer // Some swaps may have more than 2 transfers, e.g. swaps with fees on tranfer
if (changes.TokenTransfer.length >= 2) { if (changes.TokenTransfer.length >= 2) {
const sent = changes.TokenTransfer.find((t) => t.direction === 'OUT') const swapAmounts = parseSwapAmounts(changes, formatNumberOrString)
// Any leftover native token is refunded on exact_out swaps where the input token is native
const refund = changes.TokenTransfer.find(
(t) => t.direction === 'IN' && t.asset.id === sent?.asset.id && t.asset.standard === 'NATIVE'
)
const received = changes.TokenTransfer.find((t) => t.direction === 'IN' && t !== refund)
if (sent && received) { if (swapAmounts) {
const adjustedInput = parseFloat(sent.quantity) - parseFloat(refund?.quantity ?? '0') const { sent, received, inputAmount, outputAmount } = swapAmounts
const inputAmount = formatNumberOrString({ input: adjustedInput, type: NumberType.TokenNonTx })
const outputAmount = formatNumberOrString({ input: received.quantity, type: NumberType.TokenNonTx })
return { return {
title: getSwapTitle(sent, received), title: getSwapTitle(sent, received),
descriptor: getSwapDescriptor({ tokenIn: sent.asset, inputAmount, tokenOut: received.asset, outputAmount }), descriptor: getSwapDescriptor({ tokenIn: sent.asset, inputAmount, tokenOut: received.asset, outputAmount }),
...@@ -202,8 +232,55 @@ function parseLend(changes: TransactionChanges, formatNumberOrString: FormatNumb ...@@ -202,8 +232,55 @@ function parseLend(changes: TransactionChanges, formatNumberOrString: FormatNumb
return { title: t`Unknown Lend` } return { title: t`Unknown Lend` }
} }
function parseSwapOrder(changes: TransactionChanges, formatNumberOrString: FormatNumberOrStringFunctionType) { function parseSwapOrder(
return { ...parseSwap(changes, formatNumberOrString), prefixIconSrc: UniswapXBolt } changes: TransactionChanges,
formatNumberOrString: FormatNumberOrStringFunctionType,
assetActivity: TransactionActivity
) {
return {
...parseSwap(changes, formatNumberOrString),
prefixIconSrc: UniswapXBolt,
offchainOrderDetails: offchainOrderDetailsFromGraphQLTransactionActivity(
assetActivity,
changes,
formatNumberOrString
),
}
}
// exported for testing
// eslint-disable-next-line import/no-unused-modules
export function offchainOrderDetailsFromGraphQLTransactionActivity(
activity: AssetActivityPartsFragment & { details: TransactionDetailsPartsFragment },
changes: TransactionChanges,
formatNumberOrString: FormatNumberOrStringFunctionType
): OffchainOrderDetails | undefined {
const chainId = supportedChainIdFromGQLChain(activity.chain)
if (!activity || !activity.details || !chainId) return undefined
if (changes.TokenTransfer.length < 2) return undefined
const swapAmounts = parseSwapAmounts(changes, formatNumberOrString)
if (!swapAmounts) return undefined
const { inputCurrencyId, outputCurrencyId, inputAmount, outputAmount } = swapAmounts
return {
txHash: activity.details.hash,
chainId,
type: SignatureType.SIGN_UNISWAPX_ORDER,
status: UniswapXOrderStatus.FILLED,
swapInfo: {
isUniswapXOrder: true,
type: LocalTransactionType.SWAP,
tradeType: TradeType.EXACT_INPUT,
inputCurrencyId,
outputCurrencyId,
inputCurrencyAmountRaw: inputAmount,
expectedOutputCurrencyAmountRaw: outputAmount,
minimumOutputCurrencyAmountRaw: outputAmount,
settledOutputCurrencyAmountRaw: outputAmount,
},
}
} }
function parseApprove(changes: TransactionChanges) { function parseApprove(changes: TransactionChanges) {
...@@ -347,9 +424,46 @@ function getLogoSrcs(changes: TransactionChanges): Array<string | undefined> { ...@@ -347,9 +424,46 @@ function getLogoSrcs(changes: TransactionChanges): Array<string | undefined> {
} }
function parseUniswapXOrder({ details, chain, timestamp }: OrderActivity): Activity | undefined { function parseUniswapXOrder({ details, chain, timestamp }: OrderActivity): Activity | undefined {
// We currently only have a polling mechanism for locally-sent pending orders, so we hide remote pending orders since they won't update upon completion const supportedChain = supportedChainIdFromGQLChain(chain)
// TODO(WEB-2487): Add polling mechanism for remote orders to allow displaying remote pending orders if (!supportedChain) {
if (details.orderStatus === SwapOrderStatus.Open) return undefined logSentryErrorForUnsupportedChain({
extras: { details },
errorMessage: 'Invalid activity from unsupported chain received from GQL',
})
return undefined
}
if (details.orderStatus === SwapOrderStatus.Open) {
const inputCurrency = gqlToCurrency(details.inputToken)
const outputCurrency = gqlToCurrency(details.outputToken)
store.dispatch(
addSignature({
type: SignatureType.SIGN_UNISWAPX_ORDER,
offerer: details.offerer,
id: details.hash,
chainId: supportedChain,
orderHash: details.hash,
expiry: details.expiry,
swapInfo: {
type: LocalTransactionType.SWAP,
inputCurrencyId: currencyId(inputCurrency),
outputCurrencyId: currencyId(outputCurrency),
isUniswapXOrder: true,
// This doesn't affect the display, but we don't know this value from the remote activity.
tradeType: TradeType.EXACT_INPUT,
inputCurrencyAmountRaw:
tryParseCurrencyAmount(details.inputTokenQuantity, inputCurrency)?.quotient.toString() ?? '0',
expectedOutputCurrencyAmountRaw:
tryParseCurrencyAmount(details.outputTokenQuantity, outputCurrency)?.quotient.toString() ?? '0',
minimumOutputCurrencyAmountRaw:
tryParseCurrencyAmount(details.outputTokenQuantity, outputCurrency)?.quotient.toString() ?? '0',
},
status: UniswapXOrderStatus.OPEN,
addedTime: timestamp,
})
)
return undefined
}
const { inputToken, inputTokenQuantity, outputToken, outputTokenQuantity, orderStatus } = details const { inputToken, inputTokenQuantity, outputToken, outputTokenQuantity, orderStatus } = details
const uniswapXOrderStatus = OrderStatusTable[orderStatus] const uniswapXOrderStatus = OrderStatusTable[orderStatus]
...@@ -361,21 +475,28 @@ function parseUniswapXOrder({ details, chain, timestamp }: OrderActivity): Activ ...@@ -361,21 +475,28 @@ function parseUniswapXOrder({ details, chain, timestamp }: OrderActivity): Activ
outputAmount: outputTokenQuantity, outputAmount: outputTokenQuantity,
}) })
const supportedChain = supportedChainIdFromGQLChain(chain)
if (!supportedChain) {
logSentryErrorForUnsupportedChain({
extras: { details },
errorMessage: 'Invalid activity from unsupported chain received from GQL',
})
return undefined
}
return { return {
hash: details.hash, hash: details.hash,
chainId: supportedChain, chainId: supportedChain,
status, status,
statusMessage, statusMessage,
offchainOrderStatus: uniswapXOrderStatus, offchainOrderDetails: {
type: SignatureType.SIGN_UNISWAPX_ORDER,
txHash: details.hash,
chainId: supportedChain,
status: uniswapXOrderStatus,
swapInfo: {
isUniswapXOrder: true,
type: LocalTransactionType.SWAP,
tradeType: TradeType.EXACT_INPUT,
inputCurrencyId: inputToken.id,
outputCurrencyId: outputToken.id,
inputCurrencyAmountRaw: inputTokenQuantity,
expectedOutputCurrencyAmountRaw: outputTokenQuantity,
minimumOutputCurrencyAmountRaw: outputTokenQuantity,
settledOutputCurrencyAmountRaw: outputTokenQuantity,
},
},
timestamp, timestamp,
logos: [inputToken.project?.logo?.url, outputToken.project?.logo?.url], logos: [inputToken.project?.logo?.url, outputToken.project?.logo?.url],
currencies: [gqlToCurrency(inputToken), gqlToCurrency(outputToken)], currencies: [gqlToCurrency(inputToken), gqlToCurrency(outputToken)],
......
import { ChainId, Currency } from '@uniswap/sdk-core' import { ChainId, Currency } from '@uniswap/sdk-core'
import { TransactionStatus } from 'graphql/data/__generated__/types-and-hooks' import { TransactionStatus } from 'graphql/data/__generated__/types-and-hooks'
import { UniswapXOrderStatus } from 'lib/hooks/orders/types' import { UniswapXOrderDetails } from 'state/signatures/types'
/**
* TODO: refactor parsing / Activity so that all Activity Types can have a detail sheet.
*/
export type OffchainOrderDetails = Pick<UniswapXOrderDetails, 'txHash' | 'chainId' | 'type' | 'status' | 'swapInfo'>
export type Activity = { export type Activity = {
hash: string hash: string
chainId: ChainId chainId: ChainId
status: TransactionStatus status: TransactionStatus
// TODO (UniswapX): decouple Activity from UniswapXOrderStatus once we can link UniswapXScan instead of needing data for modal offchainOrderDetails?: OffchainOrderDetails
offchainOrderStatus?: UniswapXOrderStatus
statusMessage?: string statusMessage?: string
timestamp: number timestamp: number
title: string title: string
......
import { Trans } from '@lingui/macro' import { t, Trans } from '@lingui/macro'
import { InterfaceElementName } from '@uniswap/analytics-events' import { InterfaceElementName } from '@uniswap/analytics-events'
import { useScreenSize } from 'hooks/useScreenSize' import { useScreenSize } from 'hooks/useScreenSize'
import { useMemo } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { useHideAndroidAnnouncementBanner } from 'state/user/hooks' import { useHideAppPromoBanner } from 'state/user/hooks'
import { ThemedText } from 'theme/components' import { ThemedText } from 'theme/components'
import { useIsDarkMode } from 'theme/components/ThemeToggle' import { useIsDarkMode } from 'theme/components/ThemeToggle'
import { openDownloadApp } from 'utils/openDownloadApp' import { openDownloadApp } from 'utils/openDownloadApp'
import { isMobileSafari } from 'wallet/src/utils/platform' import { isAndroid, isIOS, isMobileSafari } from 'wallet/src/utils/platform'
import androidAnnouncementBannerQR from '../../../assets/images/androidAnnouncementBannerQR.png' import darkAndroidThumbnail from '../../../assets/images/app-promo-banner/AndroidWallet-Thumbnail-Dark.png'
import darkAndroidThumbnail from '../../../assets/images/AndroidWallet-Thumbnail-Dark.png' import lightAndroidThumbnail from '../../../assets/images/app-promo-banner/AndroidWallet-Thumbnail-Light.png'
import lightAndroidThumbnail from '../../../assets/images/AndroidWallet-Thumbnail-Light.png' import darkDesktopThumbnail from '../../../assets/images/app-promo-banner/DesktopWallet-Thumbnail-Dark.png'
import lightDesktopThumbnail from '../../../assets/images/app-promo-banner/DesktopWallet-Thumbnail-Light.png'
import darkIOSThumbnail from '../../../assets/images/app-promo-banner/iOSWallet-Thumbnail-Dark.png'
import lightIOSThumbnail from '../../../assets/images/app-promo-banner/iOSWallet-Thumbnail-Light.png'
import walletAppPromoBannerQR from '../../../assets/images/app-promo-banner/walletAnnouncementBannerQR.png'
import { import {
Container, Container,
DownloadButton, DownloadButton,
...@@ -21,32 +26,46 @@ import { ...@@ -21,32 +26,46 @@ import {
Thumbnail, Thumbnail,
} from './styled' } from './styled'
export default function AndroidAnnouncementBanner() { export default function WalletAppPromoBanner() {
const [hideAndroidAnnouncementBanner, toggleHideAndroidAnnouncementBanner] = useHideAndroidAnnouncementBanner() const [hideAppPromoBanner, toggleHideAppPromoBanner] = useHideAppPromoBanner()
const location = useLocation() const location = useLocation()
const isLandingScreen = location.search === '?intro=true' || location.pathname === '/' const isLandingScreen = location.search === '?intro=true' || location.pathname === '/'
const screenSize = useScreenSize() const screenSize = useScreenSize()
const shouldDisplay = Boolean(!hideAndroidAnnouncementBanner && !isLandingScreen) const shouldDisplay = Boolean(!hideAppPromoBanner && !isLandingScreen && !isMobileSafari)
const isDarkMode = useIsDarkMode() const isDarkMode = useIsDarkMode()
const thumbnailSrc = useMemo(() => {
if (isAndroid) {
return isDarkMode ? darkAndroidThumbnail : lightAndroidThumbnail
} else if (isIOS) {
return isDarkMode ? darkIOSThumbnail : lightIOSThumbnail
} else {
return isDarkMode ? darkDesktopThumbnail : lightDesktopThumbnail
}
}, [isDarkMode])
const onClick = () => const onClick = () =>
openDownloadApp({ openDownloadApp({
element: InterfaceElementName.UNISWAP_WALLET_BANNER_DOWNLOAD_BUTTON, element: InterfaceElementName.UNISWAP_WALLET_BANNER_DOWNLOAD_BUTTON,
}) })
if (isMobileSafari) return null
return ( return (
<PopupContainer show={shouldDisplay}> <PopupContainer show={shouldDisplay}>
<Container> <Container>
<Thumbnail src={isDarkMode ? darkAndroidThumbnail : lightAndroidThumbnail} alt="Android app thumbnail" /> <Thumbnail src={thumbnailSrc} alt={t`Wallet app promo banner thumbnail`} />
<TextContainer onClick={!screenSize['xs'] ? onClick : undefined}> <TextContainer onClick={!screenSize['xs'] ? onClick : undefined}>
<ThemedText.BodySmall lineHeight="20px"> <ThemedText.BodySmall lineHeight="20px">
<Trans>Uniswap on Android</Trans> <Trans>Get the app</Trans>
</ThemedText.BodySmall> </ThemedText.BodySmall>
<ThemedText.LabelMicro> <ThemedText.LabelMicro>
<Trans>Available now - download from the Google Play Store today</Trans> {isAndroid ? (
<Trans>Download the Uniswap mobile app from the Play Store</Trans>
) : isIOS ? (
<Trans>Download the Uniswap mobile app from the App Store</Trans>
) : (
<Trans>Download the Uniswap mobile app for iOS and Android</Trans>
)}
</ThemedText.LabelMicro> </ThemedText.LabelMicro>
<DownloadButton <DownloadButton
onClick={(e) => { onClick={(e) => {
...@@ -54,20 +73,11 @@ export default function AndroidAnnouncementBanner() { ...@@ -54,20 +73,11 @@ export default function AndroidAnnouncementBanner() {
onClick() onClick()
}} }}
> >
<Trans>Download now</Trans> {isAndroid || isIOS ? <Trans>Download now</Trans> : <Trans>Learn more</Trans>}
</DownloadButton> </DownloadButton>
</TextContainer> </TextContainer>
<StyledQrCode src={androidAnnouncementBannerQR} alt="App OneLink QR code" /> <StyledQrCode src={walletAppPromoBannerQR} alt="App OneLink QR code" />
<StyledXButton <StyledXButton data-testid="uniswap-wallet-banner" size={24} onClick={toggleHideAppPromoBanner} />
data-testid="uniswap-wallet-banner"
size={24}
onClick={(e) => {
// prevent click from bubbling to UI on the page underneath, i.e. clicking a token row
e.preventDefault()
e.stopPropagation()
toggleHideAndroidAnnouncementBanner()
}}
/>
</Container> </Container>
</PopupContainer> </PopupContainer>
) )
......
...@@ -114,11 +114,7 @@ export default function Pending({ ...@@ -114,11 +114,7 @@ export default function Pending({
uniswapXOrder.status !== UniswapXOrderStatus.OPEN && uniswapXOrder.status !== UniswapXOrderStatus.OPEN &&
uniswapXOrder.status !== UniswapXOrderStatus.FILLED uniswapXOrder.status !== UniswapXOrderStatus.FILLED
) { ) {
return ( return <OrderContent order={uniswapXOrder} />
<OrderContent
order={{ status: uniswapXOrder.status, orderHash: uniswapXOrder.orderHash, details: uniswapXOrder }}
/>
)
} }
return ( return (
......
...@@ -6,6 +6,7 @@ import { useQuickRouteChains } from 'featureFlags/dynamicConfig/quickRouteChains ...@@ -6,6 +6,7 @@ import { useQuickRouteChains } from 'featureFlags/dynamicConfig/quickRouteChains
import { useCurrencyConversionFlag } from 'featureFlags/flags/currencyConversion' import { useCurrencyConversionFlag } from 'featureFlags/flags/currencyConversion'
import { useEip6963EnabledFlag } from 'featureFlags/flags/eip6963' import { useEip6963EnabledFlag } from 'featureFlags/flags/eip6963'
import { useFallbackProviderEnabledFlag } from 'featureFlags/flags/fallbackProvider' import { useFallbackProviderEnabledFlag } from 'featureFlags/flags/fallbackProvider'
import { useGatewayDNSUpdateEnabledFlag } from 'featureFlags/flags/gatewayDNSUpdate'
import { useInfoExploreFlag } from 'featureFlags/flags/infoExplore' import { useInfoExploreFlag } from 'featureFlags/flags/infoExplore'
import { useInfoLiveViewsFlag } from 'featureFlags/flags/infoLiveViews' import { useInfoLiveViewsFlag } from 'featureFlags/flags/infoLiveViews'
import { useInfoPoolPageFlag } from 'featureFlags/flags/infoPoolPage' import { useInfoPoolPageFlag } from 'featureFlags/flags/infoPoolPage'
...@@ -266,6 +267,12 @@ export default function FeatureFlagModal() { ...@@ -266,6 +267,12 @@ export default function FeatureFlagModal() {
<X size={24} /> <X size={24} />
</CloseButton> </CloseButton>
</Header> </Header>
<FeatureFlagOption
variant={BaseVariant}
value={useGatewayDNSUpdateEnabledFlag()}
featureFlag={FeatureFlag.gatewayDNSUpdate}
label="Use gateway URL for routing api"
/>
<FeatureFlagOption <FeatureFlagOption
variant={BaseVariant} variant={BaseVariant}
value={useEip6963EnabledFlag()} value={useEip6963EnabledFlag()}
......
import { t } from '@lingui/macro'
import { ReactElement } from 'react' import { ReactElement } from 'react'
import { ReactComponent as WinterUni } from '../../assets/svg/winter-uni.svg' import { ReactComponent as WinterUni } from '../../assets/svg/winter-uni.svg'
import { SVGProps } from './UniIcon' import { SVGProps } from './UniIcon'
const MONTH_TO_HOLIDAY_UNI: { [date: string]: (props: SVGProps) => ReactElement } = { const MONTH_TO_HOLIDAY_UNI: { [date: string]: (props: SVGProps) => ReactElement } = {
'12': (props) => <WinterUni {...props} />, '12': (props) => <WinterUni title={t`Happy Holidays from the Uniswap team!`} {...props} />,
'1': (props) => <WinterUni {...props} />, '1': (props) => <WinterUni {...props} />,
} }
......
...@@ -162,7 +162,7 @@ export function UniswapXOrderPopupContent({ orderHash, onClose }: { orderHash: s ...@@ -162,7 +162,7 @@ export function UniswapXOrderPopupContent({ orderHash, onClose }: { orderHash: s
if (!activity) return null if (!activity) return null
const onClick = () => openOffchainActivityModal({ orderHash, status: order.status }) const onClick = () => openOffchainActivityModal(order)
return <ActivityPopupContent activity={activity} onClose={onClose} onClick={onClick} /> return <ActivityPopupContent activity={activity} onClose={onClose} onClick={onClick} />
} }
...@@ -40,17 +40,23 @@ export default function PrefetchBalancesWrapper({ ...@@ -40,17 +40,23 @@ export default function PrefetchBalancesWrapper({
// Use an atom to track unfetched state to avoid duplicating fetches if this component appears multiple times on the page. // Use an atom to track unfetched state to avoid duplicating fetches if this component appears multiple times on the page.
const [hasUnfetchedBalances, setHasUnfetchedBalances] = useAtom(hasUnfetchedBalancesAtom) const [hasUnfetchedBalances, setHasUnfetchedBalances] = useAtom(hasUnfetchedBalancesAtom)
const fetchBalances = useCallback(() => { const fetchBalances = useCallback(
(withDelay: boolean) => {
if (account) { if (account) {
// Backend takes <2sec to get the updated portfolio value after a transaction // Backend takes <2sec to get the updated portfolio value after a transaction
// This timeout is an interim solution while we're working on a websocket that'll ping the client when connected account gets changes // This timeout is an interim solution while we're working on a websocket that'll ping the client when connected account gets changes
// TODO(WEB-3131): remove this timeout after websocket is implemented // TODO(WEB-3131): remove this timeout after websocket is implemented
setTimeout(() => { setTimeout(
() => {
prefetchPortfolioBalances({ variables: { ownerAddress: account, chains: GQL_MAINNET_CHAINS } }) prefetchPortfolioBalances({ variables: { ownerAddress: account, chains: GQL_MAINNET_CHAINS } })
setHasUnfetchedBalances(false) setHasUnfetchedBalances(false)
}, ms('3.5s')) },
withDelay ? ms('3.5s') : 0
)
} }
}, [account, prefetchPortfolioBalances, setHasUnfetchedBalances]) },
[account, prefetchPortfolioBalances, setHasUnfetchedBalances]
)
const prevAccount = usePrevious(account) const prevAccount = usePrevious(account)
...@@ -62,7 +68,7 @@ export default function PrefetchBalancesWrapper({ ...@@ -62,7 +68,7 @@ export default function PrefetchBalancesWrapper({
// The parent configures whether these conditions should trigger an immediate fetch, // The parent configures whether these conditions should trigger an immediate fetch,
// if not, we set a flag to fetch on next hover. // if not, we set a flag to fetch on next hover.
if (shouldFetchOnAccountUpdate) { if (shouldFetchOnAccountUpdate) {
fetchBalances() fetchBalances(true)
} else { } else {
setHasUnfetchedBalances(true) setHasUnfetchedBalances(true)
} }
...@@ -72,11 +78,11 @@ export default function PrefetchBalancesWrapper({ ...@@ -72,11 +78,11 @@ export default function PrefetchBalancesWrapper({
// Temporary workaround to fix balances on TDP - this fetches balances if shouldFetchOnAccountUpdate becomes true while hasUnfetchedBalances is true // Temporary workaround to fix balances on TDP - this fetches balances if shouldFetchOnAccountUpdate becomes true while hasUnfetchedBalances is true
// TODO(WEB-3071) remove this logic once balance provider refactor is done // TODO(WEB-3071) remove this logic once balance provider refactor is done
useEffect(() => { useEffect(() => {
if (hasUnfetchedBalances && shouldFetchOnAccountUpdate) fetchBalances() if (hasUnfetchedBalances && shouldFetchOnAccountUpdate) fetchBalances(true)
}, [fetchBalances, hasUnfetchedBalances, shouldFetchOnAccountUpdate]) }, [fetchBalances, hasUnfetchedBalances, shouldFetchOnAccountUpdate])
const onHover = useCallback(() => { const onHover = useCallback(() => {
if (hasUnfetchedBalances) fetchBalances() if (hasUnfetchedBalances) fetchBalances(false)
}, [fetchBalances, hasUnfetchedBalances]) }, [fetchBalances, hasUnfetchedBalances])
return ( return (
......
...@@ -39,7 +39,6 @@ it('renders loading rows when isLoading is true', () => { ...@@ -39,7 +39,6 @@ it('renders loading rows when isLoading is true', () => {
height={10} height={10}
currencies={[]} currencies={[]}
otherListTokens={[]} otherListTokens={[]}
selectedCurrency={null}
onCurrencySelect={noOp} onCurrencySelect={noOp}
isLoading={true} isLoading={true}
searchQuery="" searchQuery=""
...@@ -59,7 +58,6 @@ it('renders currency rows correctly when currencies list is non-empty', () => { ...@@ -59,7 +58,6 @@ it('renders currency rows correctly when currencies list is non-empty', () => {
height={10} height={10}
currencies={[DAI, USDC_MAINNET, WBTC]} currencies={[DAI, USDC_MAINNET, WBTC]}
otherListTokens={[]} otherListTokens={[]}
selectedCurrency={null}
onCurrencySelect={noOp} onCurrencySelect={noOp}
isLoading={false} isLoading={false}
searchQuery="" searchQuery=""
...@@ -82,7 +80,6 @@ it('renders currency rows correctly with balances', () => { ...@@ -82,7 +80,6 @@ it('renders currency rows correctly with balances', () => {
height={10} height={10}
currencies={[DAI, USDC_MAINNET, WBTC]} currencies={[DAI, USDC_MAINNET, WBTC]}
otherListTokens={[]} otherListTokens={[]}
selectedCurrency={null}
onCurrencySelect={noOp} onCurrencySelect={noOp}
isLoading={false} isLoading={false}
searchQuery="" searchQuery=""
......
...@@ -146,10 +146,9 @@ export function CurrencyRow({ ...@@ -146,10 +146,9 @@ export function CurrencyRow({
tabIndex={0} tabIndex={0}
style={style} style={style}
className={`token-item-${key}`} className={`token-item-${key}`}
onKeyPress={(e) => (!isSelected && e.key === 'Enter' ? onSelect(!!warning) : null)} onKeyPress={(e) => (e.key === 'Enter' ? onSelect(!!warning) : null)}
onClick={() => (isSelected ? null : onSelect(!!warning))} onClick={() => onSelect(!!warning)}
disabled={isSelected} selected={otherSelected || isSelected}
selected={otherSelected}
dim={isBlockedToken} dim={isBlockedToken}
> >
<Column> <Column>
...@@ -275,10 +274,10 @@ export default function CurrencyList({ ...@@ -275,10 +274,10 @@ export default function CurrencyList({
<CurrencyRow <CurrencyRow
style={style} style={style}
currency={currency} currency={currency}
isSelected={isSelected}
onSelect={handleSelect} onSelect={handleSelect}
otherSelected={otherSelected} otherSelected={otherSelected}
showCurrencyAmount={showCurrencyAmount} isSelected={isSelected}
showCurrencyAmount={showCurrencyAmount && balance.greaterThan(0)}
eventProperties={formatAnalyticsEventProperties(token, index, data, searchQuery, isAddressSearch)} eventProperties={formatAnalyticsEventProperties(token, index, data, searchQuery, isAddressSearch)}
balance={balance} balance={balance}
/> />
......
import userEvent from '@testing-library/user-event'
import { ChainId } from '@uniswap/sdk-core'
import { nativeOnChain } from 'constants/tokens'
import { TokenFromList } from 'state/lists/tokenFromList'
import { act, render, screen } from 'test-utils/render'
import { CurrentBreadcrumb } from './BreadcrumbNav'
jest.mock('featureFlags/flags/infoTDP', () => ({ useInfoTDPEnabled: () => true }))
describe('BreadcrumbNav', () => {
it('renders hover components correctly', async () => {
const currency = new TokenFromList({
chainId: 1,
address: '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599',
name: 'Wrapped BTC',
decimals: 18,
symbol: 'WBTC',
})
const { asFragment } = render(
<CurrentBreadcrumb address="0x2260fac5e5542a773aa44fbcfedf7c193bc2c599" currency={currency} />
)
expect(asFragment()).toMatchSnapshot()
await act(() => userEvent.hover(screen.getByTestId('current-breadcrumb')))
expect(screen.getByTestId('breadcrumb-hover-copy')).toBeInTheDocument()
await act(() => userEvent.unhover(screen.getByTestId('current-breadcrumb')))
expect(screen.queryByTestId('breadcrumb-hover-copy')).not.toBeInTheDocument()
})
it('does not display address hover for native tokens', async () => {
const ETH = nativeOnChain(ChainId.MAINNET)
const { asFragment } = render(<CurrentBreadcrumb address="NATIVE" currency={ETH} />)
expect(asFragment()).toMatchSnapshot()
await act(() => userEvent.hover(screen.getByTestId('current-breadcrumb')))
expect(screen.queryByTestId('breadcrumb-hover-copy')).not.toBeInTheDocument()
})
})
import { Trans } from '@lingui/macro'
import { Currency } from '@uniswap/sdk-core'
import Row from 'components/Row'
import useCopyClipboard from 'hooks/useCopyClipboard'
import { useScreenSize } from 'hooks/useScreenSize'
import { useCallback, useState } from 'react'
import { Copy } from 'react-feather'
import { Link } from 'react-router-dom'
import { useModalIsOpen } from 'state/application/hooks'
import { ApplicationModal } from 'state/application/reducer'
import styled, { css, useTheme } from 'styled-components'
import { ClickableStyle, ThemedText } from 'theme/components'
import { shortenAddress } from 'utils/addresses'
import ShareButton from './ShareButton'
export const BreadcrumbNavContainer = styled.nav<{ isInfoTDPEnabled?: boolean }>`
display: flex;
color: ${({ theme }) => theme.neutral1};
${({ isInfoTDPEnabled }) =>
isInfoTDPEnabled
? css`
font-size: 16px;
line-height: 24px;
`
: css`
font-size: 14px;
line-height: 20px;
`}
align-items: center;
gap: 4px;
margin-bottom: 16px;
width: fit-content;
`
export const BreadcrumbNavLink = styled(Link)`
display: flex;
align-items: center;
color: ${({ theme }) => theme.neutral2};
transition-duration: ${({ theme }) => theme.transition.duration.fast};
text-decoration: none;
&:hover {
color: ${({ theme }) => theme.neutral3};
}
`
const CurrentBreadcrumbContainer = styled(Row)`
gap: 6px;
`
// This must be an h1 to match the SEO title, and must be the first heading tag in code.
const PageTitleText = styled.h1`
font-weight: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
`
const TokenAddressHoverContainer = styled(Row)`
cursor: pointer;
gap: 10px;
white-space: nowrap;
`
const HoverActionsDivider = styled.div`
height: 16px;
width: 1px;
background-color: ${({ theme }) => theme.surface3};
`
const CopyIcon = styled(Copy)`
${ClickableStyle}
`
const StyledCopiedSuccess = styled(Row)`
gap: 4px;
`
const CopiedSuccess = () => {
const { success } = useTheme()
return (
<StyledCopiedSuccess>
<Copy width={16} height={16} color={success} />
<ThemedText.Caption color="success">
<Trans>Copied!</Trans>
</ThemedText.Caption>
</StyledCopiedSuccess>
)
}
export const CurrentBreadcrumb = ({ address, currency }: { address: string; currency: Currency }) => {
const { neutral2 } = useTheme()
const screenSize = useScreenSize()
const [hover, setHover] = useState(false)
const [isCopied, setCopied] = useCopyClipboard()
const copy = useCallback(() => {
setCopied(address)
}, [address, setCopied])
const isNative = currency.isNative
const tokenSymbolName = currency && (currency.symbol ?? <Trans>Symbol not found</Trans>)
const shareModalOpen = useModalIsOpen(ApplicationModal.SHARE)
const shouldShowActions = (screenSize['sm'] && hover && !isCopied) || shareModalOpen
return (
<CurrentBreadcrumbContainer
aria-current="page"
data-testid="current-breadcrumb"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
<PageTitleText>{tokenSymbolName}</PageTitleText>{' '}
{!isNative && (
<TokenAddressHoverContainer data-testid="breadcrumb-token-address" onClick={copy}>
( {shortenAddress(address)} )
{shouldShowActions && (
<>
<CopyIcon data-testid="breadcrumb-hover-copy" width={16} height={16} color={neutral2} />
<HoverActionsDivider />
</>
)}
{isCopied && <CopiedSuccess />}
</TokenAddressHoverContainer>
)}
{shouldShowActions && <ShareButton currency={currency} />}
</CurrentBreadcrumbContainer>
)
}
import { Link } from 'react-router-dom'
import styled, { css } from 'styled-components'
export const BreadcrumbNav = styled.div<{ isInfoTDPEnabled?: boolean }>`
display: flex;
color: ${({ theme }) => theme.neutral1};
${({ isInfoTDPEnabled }) =>
isInfoTDPEnabled
? css`
font-size: 16px;
line-height: 24px;
`
: css`
font-size: 14px;
line-height: 20px;
`}
align-items: center;
gap: 4px;
margin-bottom: 16px;
width: fit-content;
`
export const BreadcrumbNavLink = styled(Link)`
display: flex;
align-items: center;
color: ${({ theme }) => theme.neutral2};
transition-duration: ${({ theme }) => theme.transition.duration.fast};
text-decoration: none;
&:hover {
color: ${({ theme }) => theme.neutral3};
}
`
...@@ -2,6 +2,7 @@ import { Trans } from '@lingui/macro' ...@@ -2,6 +2,7 @@ import { Trans } from '@lingui/macro'
import { Currency } from '@uniswap/sdk-core' import { Currency } from '@uniswap/sdk-core'
import { Share as ShareIcon } from 'components/Icons/Share' import { Share as ShareIcon } from 'components/Icons/Share'
import { NATIVE_CHAIN_ID } from 'constants/tokens' import { NATIVE_CHAIN_ID } from 'constants/tokens'
import { useInfoTDPEnabled } from 'featureFlags/flags/infoTDP'
import { chainIdToBackendName } from 'graphql/data/util' import { chainIdToBackendName } from 'graphql/data/util'
import useDisableScrolling from 'hooks/useDisableScrolling' import useDisableScrolling from 'hooks/useDisableScrolling'
import { useOnClickOutside } from 'hooks/useOnClickOutside' import { useOnClickOutside } from 'hooks/useOnClickOutside'
...@@ -9,7 +10,7 @@ import { useRef } from 'react' ...@@ -9,7 +10,7 @@ import { useRef } from 'react'
import { Link, Twitter } from 'react-feather' import { Link, Twitter } from 'react-feather'
import { useModalIsOpen, useToggleModal } from 'state/application/hooks' import { useModalIsOpen, useToggleModal } from 'state/application/hooks'
import { ApplicationModal } from 'state/application/reducer' import { ApplicationModal } from 'state/application/reducer'
import styled, { useTheme } from 'styled-components' import styled, { css, useTheme } from 'styled-components'
import { colors } from 'theme/colors' import { colors } from 'theme/colors'
import { ClickableStyle, CopyHelperRefType } from 'theme/components' import { ClickableStyle, CopyHelperRefType } from 'theme/components'
import { CopyHelper } from 'theme/components' import { CopyHelper } from 'theme/components'
...@@ -24,19 +25,27 @@ const ShareButtonDisplay = styled.div` ...@@ -24,19 +25,27 @@ const ShareButtonDisplay = styled.div`
position: relative; position: relative;
` `
const Share = styled(ShareIcon)<{ open: boolean }>` const Share = styled(ShareIcon)<{ open: boolean; $isInfoTDPEnabled?: boolean }>`
${({ $isInfoTDPEnabled }) =>
$isInfoTDPEnabled
? css`
height: 16px;
width: 16px;
`
: css`
height: 24px; height: 24px;
width: 24px; width: 24px;
`}
${ClickableStyle} ${ClickableStyle}
${({ open, theme }) => open && `opacity: ${theme.opacity.click} !important`}; ${({ open, theme }) => open && `opacity: ${theme.opacity.click} !important`};
` `
const ShareActions = styled.div` const ShareActions = styled.div<{ isInfoTDPEnabled?: boolean }>`
position: absolute; position: absolute;
z-index: ${Z_INDEX.dropdown}; z-index: ${Z_INDEX.dropdown};
width: 240px; width: 240px;
top: 36px; top: 36px;
right: 0px; ${({ isInfoTDPEnabled }) => (isInfoTDPEnabled ? 'left' : 'right')}: 0px;
justify-content: center; justify-content: center;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
...@@ -74,12 +83,14 @@ export default function ShareButton({ currency }: { currency: Currency }) { ...@@ -74,12 +83,14 @@ export default function ShareButton({ currency }: { currency: Currency }) {
const address = currency.isNative ? NATIVE_CHAIN_ID : currency.wrapped.address const address = currency.isNative ? NATIVE_CHAIN_ID : currency.wrapped.address
useDisableScrolling(open) useDisableScrolling(open)
const isInfoTDPEnabled = useInfoTDPEnabled()
const shareTweet = () => { const shareTweet = () => {
toggleShare() toggleShare()
window.open( window.open(
`https://twitter.com/intent/tweet?text=Check%20out%20${currency.name}%20(${ `https://twitter.com/intent/tweet?text=Check%20out%20${currency.name}%20(${
currency.symbol currency.symbol
})%20https://app.uniswap.org/%23/tokens/${chainIdToBackendName( })%20https://app.uniswap.org/${isInfoTDPEnabled ? 'explore/' : ''}tokens/${chainIdToBackendName(
currency.chainId currency.chainId
).toLowerCase()}/${address}%20via%20@uniswap`, ).toLowerCase()}/${address}%20via%20@uniswap`,
'newwindow', 'newwindow',
...@@ -91,9 +102,9 @@ export default function ShareButton({ currency }: { currency: Currency }) { ...@@ -91,9 +102,9 @@ export default function ShareButton({ currency }: { currency: Currency }) {
return ( return (
<ShareButtonDisplay ref={node}> <ShareButtonDisplay ref={node}>
<Share onClick={toggleShare} aria-label="ShareOptions" open={open} /> <Share onClick={toggleShare} aria-label="ShareOptions" open={open} $isInfoTDPEnabled={isInfoTDPEnabled} />
{open && ( {open && (
<ShareActions> <ShareActions isInfoTDPEnabled={isInfoTDPEnabled}>
<ShareAction onClick={() => copyHelperRef.current?.forceCopy()}> <ShareAction onClick={() => copyHelperRef.current?.forceCopy()}>
<CopyHelper <CopyHelper
InitialIcon={Link} InitialIcon={Link}
......
...@@ -10,7 +10,7 @@ import { textFadeIn } from 'theme/styles' ...@@ -10,7 +10,7 @@ import { textFadeIn } from 'theme/styles'
import { LoadingBubble } from '../loading' import { LoadingBubble } from '../loading'
import { AboutContainer, AboutHeader } from './About' import { AboutContainer, AboutHeader } from './About'
import { BreadcrumbNav, BreadcrumbNavLink } from './BreadcrumbNavLink' import { BreadcrumbNavContainer, BreadcrumbNavLink } from './BreadcrumbNav'
import { ChartContainer } from './ChartSection' import { ChartContainer } from './ChartSection'
import { StatPair, StatsWrapper, StatWrapper } from './StatsSection' import { StatPair, StatsWrapper, StatWrapper } from './StatsSection'
...@@ -236,20 +236,20 @@ export default function TokenDetailsSkeleton() { ...@@ -236,20 +236,20 @@ export default function TokenDetailsSkeleton() {
return ( return (
<LeftPanel> <LeftPanel>
{isInfoTDPEnabled ? ( {isInfoTDPEnabled ? (
<BreadcrumbNav isInfoTDPEnabled> <BreadcrumbNavContainer isInfoTDPEnabled>
<BreadcrumbNavLink to={`${isInfoExplorePageEnabled ? '/explore' : ''}/tokens/${chainName}`}> <BreadcrumbNavLink to={`${isInfoExplorePageEnabled ? '/explore' : ''}/tokens/${chainName}`}>
<Trans>Explore</Trans> <ChevronRight size={14} /> <Trans>Tokens</Trans> <ChevronRight size={14} /> <Trans>Explore</Trans> <ChevronRight size={14} /> <Trans>Tokens</Trans> <ChevronRight size={14} />
</BreadcrumbNavLink>{' '} </BreadcrumbNavLink>{' '}
<NavBubble /> <NavBubble />
</BreadcrumbNav> </BreadcrumbNavContainer>
) : ( ) : (
<BreadcrumbNav> <BreadcrumbNavContainer>
<BreadcrumbNavLink <BreadcrumbNavLink
to={(isInfoExplorePageEnabled ? '/explore' : '') + (chainName ? `/tokens/${chainName}` : `/tokens`)} to={(isInfoExplorePageEnabled ? '/explore' : '') + (chainName ? `/tokens/${chainName}` : `/tokens`)}
> >
<ArrowLeft size={14} /> Tokens <ArrowLeft size={14} /> Tokens
</BreadcrumbNavLink> </BreadcrumbNavLink>
</BreadcrumbNav> </BreadcrumbNavContainer>
)} )}
<TokenInfoContainer> <TokenInfoContainer>
<TokenNameCell> <TokenNameCell>
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BreadcrumbNav does not display address hover for native tokens 1`] = `
<DocumentFragment>
.c0 {
box-sizing: border-box;
margin: 0;
min-width: 0;
}
.c1 {
width: 100%;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
padding: 0;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
}
.c2 {
gap: 6px;
}
.c3 {
font-weight: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
}
<div
aria-current="page"
class="c0 c1 c2"
data-testid="current-breadcrumb"
>
<h1
class="c3"
>
ETH
</h1>
</div>
</DocumentFragment>
`;
exports[`BreadcrumbNav renders hover components correctly 1`] = `
<DocumentFragment>
.c0 {
box-sizing: border-box;
margin: 0;
min-width: 0;
}
.c1 {
width: 100%;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
padding: 0;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
}
.c2 {
gap: 6px;
}
.c3 {
font-weight: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
}
.c4 {
cursor: pointer;
gap: 10px;
white-space: nowrap;
}
<div
aria-current="page"
class="c0 c1 c2"
data-testid="current-breadcrumb"
>
<h1
class="c3"
>
WBTC
</h1>
<div
class="c0 c1 c4"
data-testid="breadcrumb-token-address"
>
( 0x2260...C599 )
</div>
</div>
</DocumentFragment>
`;
...@@ -5,10 +5,8 @@ import { Trace } from 'analytics' ...@@ -5,10 +5,8 @@ import { Trace } from 'analytics'
import { PortfolioLogo } from 'components/AccountDrawer/MiniPortfolio/PortfolioLogo' import { PortfolioLogo } from 'components/AccountDrawer/MiniPortfolio/PortfolioLogo'
import { ChartType, PriceChartType } from 'components/Charts/utils' import { ChartType, PriceChartType } from 'components/Charts/utils'
import { useCachedPortfolioBalancesQuery } from 'components/PrefetchBalancesWrapper/PrefetchBalancesWrapper' import { useCachedPortfolioBalancesQuery } from 'components/PrefetchBalancesWrapper/PrefetchBalancesWrapper'
import { Field } from 'components/swap/constants'
import { AboutSection } from 'components/Tokens/TokenDetails/About' import { AboutSection } from 'components/Tokens/TokenDetails/About'
import AddressSection from 'components/Tokens/TokenDetails/AddressSection' import AddressSection from 'components/Tokens/TokenDetails/AddressSection'
import { BreadcrumbNav, BreadcrumbNavLink } from 'components/Tokens/TokenDetails/BreadcrumbNavLink'
import ChartSection from 'components/Tokens/TokenDetails/ChartSection' import ChartSection from 'components/Tokens/TokenDetails/ChartSection'
import ShareButton from 'components/Tokens/TokenDetails/ShareButton' import ShareButton from 'components/Tokens/TokenDetails/ShareButton'
import TokenDetailsSkeleton, { import TokenDetailsSkeleton, {
...@@ -48,11 +46,12 @@ import { ArrowLeft, ChevronRight } from 'react-feather' ...@@ -48,11 +46,12 @@ import { ArrowLeft, ChevronRight } from 'react-feather'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { SwapState } from 'state/swap/SwapContext' import { SwapState } from 'state/swap/SwapContext'
import styled, { css } from 'styled-components' import styled, { css } from 'styled-components'
import { CopyContractAddress, EllipsisStyle } from 'theme/components' import { EllipsisStyle } from 'theme/components'
import { isAddress, shortenAddress } from 'utils' import { isAddress } from 'utils'
import { addressesAreEquivalent } from 'utils/addressesAreEquivalent' import { addressesAreEquivalent } from 'utils/addressesAreEquivalent'
import BalanceSummary from './BalanceSummary' import BalanceSummary from './BalanceSummary'
import { BreadcrumbNavContainer, BreadcrumbNavLink, CurrentBreadcrumb } from './BreadcrumbNav'
import { AdvancedPriceChartToggle } from './ChartTypeSelectors/AdvancedPriceChartToggle' import { AdvancedPriceChartToggle } from './ChartTypeSelectors/AdvancedPriceChartToggle'
import ChartTypeSelector from './ChartTypeSelectors/ChartTypeSelector' import ChartTypeSelector from './ChartTypeSelectors/ChartTypeSelector'
import InvalidTokenDetails from './InvalidTokenDetails' import InvalidTokenDetails from './InvalidTokenDetails'
...@@ -96,13 +95,6 @@ const TokenName = styled.span` ...@@ -96,13 +95,6 @@ const TokenName = styled.span`
${EllipsisStyle} ${EllipsisStyle}
min-width: 40px; min-width: 40px;
` `
// This must be an h1 to match the SEO title, and must be the first heading tag in code.
const PageTitleText = styled.h1`
font-weight: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
`
function useOnChainToken(address: string | undefined, skip: boolean) { function useOnChainToken(address: string | undefined, skip: boolean) {
const token = useTokenFromActiveNetwork(skip || !address ? undefined : address) const token = useTokenFromActiveNetwork(skip || !address ? undefined : address)
...@@ -218,15 +210,15 @@ export default function TokenDetails({ ...@@ -218,15 +210,15 @@ export default function TokenDetails({
useOnGlobalChainSwitch(navigateToTokenForChain) useOnGlobalChainSwitch(navigateToTokenForChain)
const handleCurrencyChange = useCallback( const handleCurrencyChange = useCallback(
(tokens: Pick<SwapState, Field.INPUT | Field.OUTPUT>) => { (tokens: Pick<SwapState, 'inputCurrencyId' | 'outputCurrencyId'>) => {
if ( if (
addressesAreEquivalent(tokens[Field.INPUT]?.currencyId, address) || addressesAreEquivalent(tokens.inputCurrencyId, address) ||
addressesAreEquivalent(tokens[Field.OUTPUT]?.currencyId, address) addressesAreEquivalent(tokens.outputCurrencyId, address)
) { ) {
return return
} }
const newDefaultTokenID = tokens[Field.OUTPUT]?.currencyId ?? tokens[Field.INPUT]?.currencyId const newDefaultTokenID = tokens.outputCurrencyId ?? tokens.inputCurrencyId
startTokenTransition(() => startTokenTransition(() =>
navigate( navigate(
getTokenDetailsURL({ getTokenDetailsURL({
...@@ -236,9 +228,7 @@ export default function TokenDetails({ ...@@ -236,9 +228,7 @@ export default function TokenDetails({
inputAddress: inputAddress:
// If only one token was selected before we navigate, then it was the default token and it's being replaced. // If only one token was selected before we navigate, then it was the default token and it's being replaced.
// On the new page, the *new* default token becomes the output, and we don't have another option to set as the input token. // On the new page, the *new* default token becomes the output, and we don't have another option to set as the input token.
tokens[Field.INPUT] && tokens[Field.INPUT]?.currencyId !== newDefaultTokenID tokens.inputCurrencyId !== newDefaultTokenID ? tokens.inputCurrencyId : null,
? tokens[Field.INPUT]?.currencyId
: null,
isInfoExplorePageEnabled, isInfoExplorePageEnabled,
}) })
) )
...@@ -277,29 +267,18 @@ export default function TokenDetails({ ...@@ -277,29 +267,18 @@ export default function TokenDetails({
{detailedToken && !isPending ? ( {detailedToken && !isPending ? (
<LeftPanel> <LeftPanel>
{isInfoTDPEnabled ? ( {isInfoTDPEnabled ? (
<BreadcrumbNav isInfoTDPEnabled> <BreadcrumbNavContainer aria-label="breadcrumb-nav">
<BreadcrumbNavLink to={`${isInfoExplorePageEnabled ? '/explore' : ''}/tokens/${chain.toLowerCase()}`}> <BreadcrumbNavLink to={`/explore/tokens/${chain.toLowerCase()}`}>
<Trans>Explore</Trans> <ChevronRight size={14} /> <Trans>Tokens</Trans> <ChevronRight size={14} /> <Trans>Explore</Trans> <ChevronRight size={14} /> <Trans>Tokens</Trans> <ChevronRight size={14} />
</BreadcrumbNavLink>{' '} </BreadcrumbNavLink>{' '}
<PageTitleText>{tokenSymbolName}</PageTitleText>{' '} <CurrentBreadcrumb address={address} currency={detailedToken} />
{!detailedToken.isNative && ( </BreadcrumbNavContainer>
<>
(
<CopyContractAddress
address={address}
showTruncatedOnly
truncatedAddress={shortenAddress(address)}
/>
)
</>
)}
</BreadcrumbNav>
) : ( ) : (
<BreadcrumbNav> <BreadcrumbNavContainer aria-label="breadcrumb-nav">
<BreadcrumbNavLink to={`${isInfoExplorePageEnabled ? '/explore' : ''}/tokens/${chain.toLowerCase()}`}> <BreadcrumbNavLink to={`${isInfoExplorePageEnabled ? '/explore' : ''}/tokens/${chain.toLowerCase()}`}>
<ArrowLeft data-testid="token-details-return-button" size={14} /> Tokens <ArrowLeft data-testid="token-details-return-button" size={14} /> Tokens
</BreadcrumbNavLink> </BreadcrumbNavLink>
</BreadcrumbNav> </BreadcrumbNavContainer>
)} )}
<TokenInfoContainer isInfoTDPEnabled={isInfoTDPEnabled} data-testid="token-info-container"> <TokenInfoContainer isInfoTDPEnabled={isInfoTDPEnabled} data-testid="token-info-container">
<TokenNameCell isInfoTDPEnabled={isInfoTDPEnabled}> <TokenNameCell isInfoTDPEnabled={isInfoTDPEnabled}>
......
...@@ -2,7 +2,7 @@ import { useWeb3React } from '@web3-react/core' ...@@ -2,7 +2,7 @@ import { useWeb3React } from '@web3-react/core'
import { OffchainActivityModal } from 'components/AccountDrawer/MiniPortfolio/Activity/OffchainActivityModal' import { OffchainActivityModal } from 'components/AccountDrawer/MiniPortfolio/Activity/OffchainActivityModal'
import UniwalletModal from 'components/AccountDrawer/UniwalletModal' import UniwalletModal from 'components/AccountDrawer/UniwalletModal'
import AirdropModal from 'components/AirdropModal' import AirdropModal from 'components/AirdropModal'
import AndroidAnnouncementBanner from 'components/Banner/AndroidAnnouncementBanner' import WalletAppPromoBanner from 'components/Banner/MobileAppAnnouncementBanner'
import AddressClaimModal from 'components/claim/AddressClaimModal' import AddressClaimModal from 'components/claim/AddressClaimModal'
import ConnectedAccountBlocked from 'components/ConnectedAccountBlocked' import ConnectedAccountBlocked from 'components/ConnectedAccountBlocked'
import FiatOnrampModal from 'components/FiatOnrampModal' import FiatOnrampModal from 'components/FiatOnrampModal'
...@@ -30,7 +30,7 @@ export default function TopLevelModals() { ...@@ -30,7 +30,7 @@ export default function TopLevelModals() {
<ConnectedAccountBlocked account={account} isOpen={accountBlocked} /> <ConnectedAccountBlocked account={account} isOpen={accountBlocked} />
<Bag /> <Bag />
<UniwalletModal /> <UniwalletModal />
<AndroidAnnouncementBanner /> <WalletAppPromoBanner />
<OffchainActivityModal /> <OffchainActivityModal />
<TransactionCompleteModal /> <TransactionCompleteModal />
<AirdropModal /> <AirdropModal />
......
...@@ -20,6 +20,8 @@ afterEach(() => { ...@@ -20,6 +20,8 @@ afterEach(() => {
// @ts-ignore // @ts-ignore
EIP6963_PROVIDER_MANAGER._map.clear() // reset the map after each test EIP6963_PROVIDER_MANAGER._map.clear() // reset the map after each test
// @ts-ignore
EIP6963_PROVIDER_MANAGER._list.length = 0 // reset the list after each test
}) })
function announceProvider(rdns: string, provider: MockEIP1193Provider) { function announceProvider(rdns: string, provider: MockEIP1193Provider) {
......
...@@ -9,20 +9,19 @@ import { useAppSelector } from 'state/hooks' ...@@ -9,20 +9,19 @@ import { useAppSelector } from 'state/hooks'
import Option from './Option' import Option from './Option'
function useEIP6963Connections() { function useEIP6963Connections() {
const injectedDetailsMap = useInjectedProviderDetails() const eip6963Injectors = useInjectedProviderDetails()
const eip6963Enabled = useEip6963Enabled() const eip6963Enabled = useEip6963Enabled()
return useMemo(() => { return useMemo(() => {
if (!eip6963Enabled) return { eip6963Connections: [], showDeprecatedMessage: false } if (!eip6963Enabled) return { eip6963Connections: [], showDeprecatedMessage: false }
const eip6963Injectors = Array.from(injectedDetailsMap.values())
const eip6963Connections = eip6963Injectors.flatMap((injector) => eip6963Connection.wrap(injector.info) ?? []) const eip6963Connections = eip6963Injectors.flatMap((injector) => eip6963Connection.wrap(injector.info) ?? [])
// Displays ui to activate window.ethereum for edge-case where we detect window.ethereum !== one of the eip6963 providers // Displays ui to activate window.ethereum for edge-case where we detect window.ethereum !== one of the eip6963 providers
const showDeprecatedMessage = eip6963Connections.length > 0 && shouldUseDeprecatedInjector(injectedDetailsMap) const showDeprecatedMessage = eip6963Connections.length > 0 && shouldUseDeprecatedInjector(eip6963Injectors)
return { eip6963Connections, showDeprecatedMessage } return { eip6963Connections, showDeprecatedMessage }
}, [injectedDetailsMap, eip6963Enabled]) }, [eip6963Injectors, eip6963Enabled])
} }
function mergeConnections(connections: Connection[], eip6963Connections: Connection[]) { function mergeConnections(connections: Connection[], eip6963Connections: Connection[]) {
......
...@@ -317,7 +317,7 @@ export function PendingModalContent({ ...@@ -317,7 +317,7 @@ export function PendingModalContent({
// Return finalized-order-specifc content if available // Return finalized-order-specifc content if available
if (order && order.status !== UniswapXOrderStatus.OPEN) { if (order && order.status !== UniswapXOrderStatus.OPEN) {
return <OrderContent order={{ status: order.status, orderHash: order.orderHash, details: order }} /> return <OrderContent order={order} />
} }
// On mainnet, we show a different icon when the transaction is submitted but pending confirmation. // On mainnet, we show a different icon when the transaction is submitted but pending confirmation.
......
...@@ -25,16 +25,12 @@ function Wrapper(props: PropsWithChildren<WrapperProps>) { ...@@ -25,16 +25,12 @@ function Wrapper(props: PropsWithChildren<WrapperProps>) {
independentField: Field.INPUT, independentField: Field.INPUT,
typedValue: '', typedValue: '',
recipient: '', recipient: '',
[Field.INPUT]: {}, inputCurrencyId: undefined,
[Field.OUTPUT]: {}, outputCurrencyId: undefined,
}, },
prefilledState: { prefilledState: {
INPUT: { inputCurrencyId: undefined,
currencyId: undefined, outputCurrencyId: undefined,
},
OUTPUT: {
currencyId: undefined,
},
}, },
}} }}
> >
......
import { MockEIP1193Provider } from '@web3-react/core' import { MockEIP1193Provider } from '@web3-react/core'
import METAMASK_ICON from 'assets/wallets/metamask-icon.svg' import METAMASK_ICON from 'assets/wallets/metamask-icon.svg'
import { renderHook } from 'test-utils/render' import { act, renderHook } from 'test-utils/render'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { EIP6963_PROVIDER_MANAGER, useInjectedProviderDetails } from './providers' import { EIP6963_PROVIDER_MANAGER, useInjectedProviderDetails } from './providers'
...@@ -17,6 +17,8 @@ afterEach(() => { ...@@ -17,6 +17,8 @@ afterEach(() => {
// @ts-ignore // @ts-ignore
EIP6963_PROVIDER_MANAGER._map.clear() // reset the map after each test EIP6963_PROVIDER_MANAGER._map.clear() // reset the map after each test
// @ts-ignore
EIP6963_PROVIDER_MANAGER._list.length = 0 // reset the list after each test
}) })
function announceProvider(rdns: string, provider: MockEIP1193Provider) { function announceProvider(rdns: string, provider: MockEIP1193Provider) {
...@@ -50,34 +52,34 @@ describe('EIP6963 Providers', () => { ...@@ -50,34 +52,34 @@ describe('EIP6963 Providers', () => {
announceProvider('mockExtension1', mockProvider1) announceProvider('mockExtension1', mockProvider1)
announceProvider('mockExtension2', mockProvider2) announceProvider('mockExtension2', mockProvider2)
expect(EIP6963_PROVIDER_MANAGER.map.size).toEqual(2) expect(EIP6963_PROVIDER_MANAGER.list.length).toEqual(2)
expect(EIP6963_PROVIDER_MANAGER.map.get('mockExtension1')).toBeDefined() expect(EIP6963_PROVIDER_MANAGER.list[0].info.rdns === 'mockExtension1').toBeTruthy()
expect(EIP6963_PROVIDER_MANAGER.map.get('mockExtension2')).toBeDefined() expect(EIP6963_PROVIDER_MANAGER.list[1].info.rdns === 'mockExtension2').toBeTruthy()
}) })
it('should ignore coinbase', () => { it('should ignore coinbase', () => {
announceProvider('com.coinbase.wallet', mockProvider1) announceProvider('com.coinbase.wallet', mockProvider1)
expect(EIP6963_PROVIDER_MANAGER.map.size).toEqual(0) expect(EIP6963_PROVIDER_MANAGER.list.length).toEqual(0)
}) })
it('should replace metamask logo', () => { it('should replace metamask logo', () => {
announceProvider('io.metamask', mockProvider1) announceProvider('io.metamask', mockProvider1)
expect(EIP6963_PROVIDER_MANAGER.map.size).toEqual(1) expect(EIP6963_PROVIDER_MANAGER.list.length).toEqual(1)
expect(EIP6963_PROVIDER_MANAGER.map.get('io.metamask')?.info.icon).toEqual(METAMASK_ICON) METAMASK_ICON
}) })
it('should ignore improperly formatted provider info', () => { it('should ignore improperly formatted provider info', () => {
announceProvider(undefined as any, mockProvider1) announceProvider(undefined as any, mockProvider1)
expect(EIP6963_PROVIDER_MANAGER.map.size).toEqual(0) expect(EIP6963_PROVIDER_MANAGER.list.length).toEqual(0)
}) })
it('should ignore improperly formatted providers', () => { it('should ignore improperly formatted providers', () => {
announceProvider('mockExtension1', {} as any) announceProvider('mockExtension1', {} as any)
expect(EIP6963_PROVIDER_MANAGER.map.size).toEqual(0) expect(EIP6963_PROVIDER_MANAGER.list.length).toEqual(0)
}) })
}) })
...@@ -86,12 +88,12 @@ describe('EIP6963 Providers', () => { ...@@ -86,12 +88,12 @@ describe('EIP6963 Providers', () => {
const test = renderHook(() => useInjectedProviderDetails()) const test = renderHook(() => useInjectedProviderDetails())
expect([test.result.current.values()].length).toEqual(1) expect([test.result.current.values()].length).toEqual(1)
expect(test.result.current.get('mockExtension1')).toBeDefined() expect(test.result.current[0].info.rdns === 'mockExtension1').toBeTruthy()
announceProvider('mockExtension2', mockProvider2) act(() => announceProvider('mockExtension2', mockProvider2))
expect(test.result.current.size).toEqual(2) expect(test.result.current.length).toEqual(2)
expect(test.result.current.get('mockExtension1')).toBeDefined() expect(test.result.current[0].info.rdns === 'mockExtension1').toBeTruthy()
expect(test.result.current.get('mockExtension2')).toBeDefined() expect(test.result.current[1].info.rdns === 'mockExtension2').toBeTruthy()
}) })
}) })
...@@ -5,11 +5,12 @@ import { applyOverrideIcon, isCoinbaseProviderDetail, isEIP6963ProviderDetail } ...@@ -5,11 +5,12 @@ import { applyOverrideIcon, isCoinbaseProviderDetail, isEIP6963ProviderDetail }
// TODO(WEB-3241) - Once Mutable<T> utility type is consolidated, use it here // TODO(WEB-3241) - Once Mutable<T> utility type is consolidated, use it here
type MutableInjectedProviderMap = Map<string, EIP6963ProviderDetail> type MutableInjectedProviderMap = Map<string, EIP6963ProviderDetail>
export type InjectedProviderMap = ReadonlyMap<string, EIP6963ProviderDetail> type InjectedProviderMap = ReadonlyMap<string, EIP6963ProviderDetail>
class EIP6963ProviderManager { class EIP6963ProviderManager {
public listeners = new Set<() => void>() public listeners = new Set<() => void>()
private _map: MutableInjectedProviderMap = new Map() private _map: MutableInjectedProviderMap = new Map()
private _list: EIP6963ProviderDetail[] = []
constructor() { constructor() {
window.addEventListener(EIP6963Event.ANNOUNCE_PROVIDER, this.onAnnounceProvider.bind(this) as EventListener) window.addEventListener(EIP6963Event.ANNOUNCE_PROVIDER, this.onAnnounceProvider.bind(this) as EventListener)
...@@ -35,12 +36,17 @@ class EIP6963ProviderManager { ...@@ -35,12 +36,17 @@ class EIP6963ProviderManager {
} }
this._map.set(detail.info.rdns, detail) this._map.set(detail.info.rdns, detail)
this._list = [...this._list, detail] // re-create array to trigger re-render from useInjectedProviderDetails
this.listeners.forEach((listener) => listener()) this.listeners.forEach((listener) => listener())
} }
public get map(): InjectedProviderMap { public get map(): InjectedProviderMap {
return this._map return this._map
} }
public get list(): readonly EIP6963ProviderDetail[] {
return this._list
}
} }
export const EIP6963_PROVIDER_MANAGER = new EIP6963ProviderManager() export const EIP6963_PROVIDER_MANAGER = new EIP6963ProviderManager()
...@@ -50,11 +56,11 @@ function subscribeToProviderMap(listener: () => void): () => void { ...@@ -50,11 +56,11 @@ function subscribeToProviderMap(listener: () => void): () => void {
return () => EIP6963_PROVIDER_MANAGER.listeners.delete(listener) return () => EIP6963_PROVIDER_MANAGER.listeners.delete(listener)
} }
function getProviderMapSnapshot(): InjectedProviderMap { function getProviderMapSnapshot(): readonly EIP6963ProviderDetail[] {
return EIP6963_PROVIDER_MANAGER.map return EIP6963_PROVIDER_MANAGER.list
} }
/** Returns an up-to-date map of announced eip6963 providers */ /** Returns an up-to-date map of announced eip6963 providers */
export function useInjectedProviderDetails(): InjectedProviderMap { export function useInjectedProviderDetails(): readonly EIP6963ProviderDetail[] {
return useSyncExternalStore(subscribeToProviderMap, getProviderMapSnapshot) return useSyncExternalStore(subscribeToProviderMap, getProviderMapSnapshot)
} }
...@@ -6,11 +6,10 @@ import LEDGER_ICON from 'assets/wallets/ledger-icon.svg' ...@@ -6,11 +6,10 @@ import LEDGER_ICON from 'assets/wallets/ledger-icon.svg'
import METAMASK_ICON from 'assets/wallets/metamask-icon.svg' import METAMASK_ICON from 'assets/wallets/metamask-icon.svg'
import RABBY_ICON from 'assets/wallets/rabby-icon.svg' import RABBY_ICON from 'assets/wallets/rabby-icon.svg'
import TRUST_WALLET_ICON from 'assets/wallets/trustwallet-icon.svg' import TRUST_WALLET_ICON from 'assets/wallets/trustwallet-icon.svg'
import { EIP6963ProviderDetail } from 'connection/eip6963/types'
import { Connection, ConnectionType, ProviderInfo } from 'connection/types' import { Connection, ConnectionType, ProviderInfo } from 'connection/types'
import { getInjectedMeta } from 'utils/walletMeta' import { getInjectedMeta } from 'utils/walletMeta'
import { InjectedProviderMap } from './eip6963/providers'
export const getIsInjected = () => Boolean(window.ethereum) export const getIsInjected = () => Boolean(window.ethereum)
type InjectedWalletKey = keyof NonNullable<Window['ethereum']> type InjectedWalletKey = keyof NonNullable<Window['ethereum']>
...@@ -23,12 +22,12 @@ const InjectedWalletTable: { [key in InjectedWalletKey]?: ProviderInfo } = { ...@@ -23,12 +22,12 @@ const InjectedWalletTable: { [key in InjectedWalletKey]?: ProviderInfo } = {
} }
/** Returns boolean representing whether the app should still use the deprecated window.ethereum provider, based on eip6963 providers present */ /** Returns boolean representing whether the app should still use the deprecated window.ethereum provider, based on eip6963 providers present */
export function shouldUseDeprecatedInjector(providerMap: InjectedProviderMap): boolean { export function shouldUseDeprecatedInjector(providerDetails: readonly EIP6963ProviderDetail[]): boolean {
if (!window.ethereum) return false if (!window.ethereum) return false
const { name: deprecatedInjectionName } = getInjectedMeta(window.ethereum) const { name: deprecatedInjectionName } = getInjectedMeta(window.ethereum)
for (const injector of providerMap.values()) { for (const injector of providerDetails) {
// Compares window.ethereum flags (isMetaMask) to corresponding flags on eip6963 providers // Compares window.ethereum flags (isMetaMask) to corresponding flags on eip6963 providers
if (getInjectedMeta(injector.provider as ExternalProvider).name === deprecatedInjectionName) { if (getInjectedMeta(injector.provider as ExternalProvider).name === deprecatedInjectionName) {
return false return false
......
import { BaseVariant, FeatureFlag, useBaseFlag } from '../index'
export function useGatewayDNSUpdateEnabledFlag(): BaseVariant {
return useBaseFlag(FeatureFlag.gatewayDNSUpdate)
}
export function useGatewayDNSUpdateEnabled(): boolean {
return useGatewayDNSUpdateEnabledFlag() === BaseVariant.Enabled
}
...@@ -21,6 +21,7 @@ export enum FeatureFlag { ...@@ -21,6 +21,7 @@ export enum FeatureFlag {
feesEnabled = 'fees_enabled', feesEnabled = 'fees_enabled',
limitsEnabled = 'limits_enabled', limitsEnabled = 'limits_enabled',
eip6963Enabled = 'eip6963_enabled', eip6963Enabled = 'eip6963_enabled',
gatewayDNSUpdate = 'gateway_dns_update',
} }
interface FeatureFlagsContextType { interface FeatureFlagsContextType {
......
...@@ -136,6 +136,7 @@ fragment SwapOrderDetailsParts on SwapOrderDetails { ...@@ -136,6 +136,7 @@ fragment SwapOrderDetailsParts on SwapOrderDetails {
offerer offerer
hash hash
orderStatus: status orderStatus: status
expiry
inputToken { inputToken {
...TokenAssetParts ...TokenAssetParts
} }
......
import { SkipToken, skipToken } from '@reduxjs/toolkit/query/react' import { SkipToken, skipToken } from '@reduxjs/toolkit/query/react'
import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core' import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core'
import { useGatewayDNSUpdateEnabled } from 'featureFlags/flags/gatewayDNSUpdate'
import { useUniswapXSyntheticQuoteEnabled } from 'featureFlags/flags/uniswapXUseSyntheticQuote' import { useUniswapXSyntheticQuoteEnabled } from 'featureFlags/flags/uniswapXUseSyntheticQuote'
import { useFeesEnabled } from 'featureFlags/flags/useFees' import { useFeesEnabled } from 'featureFlags/flags/useFees'
import { useMemo } from 'react' import { useMemo } from 'react'
...@@ -27,7 +28,7 @@ export function useRoutingAPIArguments({ ...@@ -27,7 +28,7 @@ export function useRoutingAPIArguments({
routerPreference: RouterPreference | typeof INTERNAL_ROUTER_PREFERENCE_PRICE routerPreference: RouterPreference | typeof INTERNAL_ROUTER_PREFERENCE_PRICE
}): GetQuoteArgs | SkipToken { }): GetQuoteArgs | SkipToken {
const uniswapXForceSyntheticQuotes = useUniswapXSyntheticQuoteEnabled() const uniswapXForceSyntheticQuotes = useUniswapXSyntheticQuoteEnabled()
const gatewayDNSUpdateEnabled = useGatewayDNSUpdateEnabled()
const feesEnabled = useFeesEnabled() const feesEnabled = useFeesEnabled()
// Don't enable fee logic if this is a quote for pricing // Don't enable fee logic if this is a quote for pricing
const sendPortionEnabled = routerPreference === INTERNAL_ROUTER_PREFERENCE_PRICE ? false : feesEnabled const sendPortionEnabled = routerPreference === INTERNAL_ROUTER_PREFERENCE_PRICE ? false : feesEnabled
...@@ -52,7 +53,18 @@ export function useRoutingAPIArguments({ ...@@ -52,7 +53,18 @@ export function useRoutingAPIArguments({
needsWrapIfUniswapX: tokenIn.isNative, needsWrapIfUniswapX: tokenIn.isNative,
uniswapXForceSyntheticQuotes, uniswapXForceSyntheticQuotes,
sendPortionEnabled, sendPortionEnabled,
gatewayDNSUpdateEnabled,
}, },
[account, amount, routerPreference, tokenIn, tokenOut, tradeType, uniswapXForceSyntheticQuotes, sendPortionEnabled] [
account,
amount,
routerPreference,
tokenIn,
tokenOut,
tradeType,
uniswapXForceSyntheticQuotes,
sendPortionEnabled,
gatewayDNSUpdateEnabled,
]
) )
} }
...@@ -68,7 +68,7 @@ import { OutputTaxTooltipBody } from './TaxTooltipBody' ...@@ -68,7 +68,7 @@ import { OutputTaxTooltipBody } from './TaxTooltipBody'
interface SwapFormProps { interface SwapFormProps {
disableTokenInputs?: boolean disableTokenInputs?: boolean
onCurrencyChange?: (selected: Pick<SwapState, Field.INPUT | Field.OUTPUT>) => void onCurrencyChange?: (selected: Pick<SwapState, 'inputCurrencyId' | 'outputCurrencyId'>) => void
} }
export function SwapForm({ disableTokenInputs = false, onCurrencyChange }: SwapFormProps) { export function SwapForm({ disableTokenInputs = false, onCurrencyChange }: SwapFormProps) {
...@@ -84,8 +84,8 @@ export function SwapForm({ disableTokenInputs = false, onCurrencyChange }: SwapF ...@@ -84,8 +84,8 @@ export function SwapForm({ disableTokenInputs = false, onCurrencyChange }: SwapF
const prefilledCurrencies = useMemo(() => { const prefilledCurrencies = useMemo(() => {
return queryParametersToSwapState(parsedQs) return queryParametersToSwapState(parsedQs)
}, [parsedQs]) }, [parsedQs])
const prefilledInputCurrency = useCurrency(prefilledCurrencies?.[Field.INPUT]?.currencyId, chainId) const prefilledInputCurrency = useCurrency(prefilledCurrencies?.inputCurrencyId, chainId)
const prefilledOutputCurrency = useCurrency(prefilledCurrencies?.[Field.OUTPUT]?.currencyId, chainId) const prefilledOutputCurrency = useCurrency(prefilledCurrencies?.outputCurrencyId, chainId)
const [loadedInputCurrency, setLoadedInputCurrency] = useState(prefilledInputCurrency) const [loadedInputCurrency, setLoadedInputCurrency] = useState(prefilledInputCurrency)
const [loadedOutputCurrency, setLoadedOutputCurrency] = useState(prefilledOutputCurrency) const [loadedOutputCurrency, setLoadedOutputCurrency] = useState(prefilledOutputCurrency)
...@@ -266,22 +266,16 @@ export function SwapForm({ disableTokenInputs = false, onCurrencyChange }: SwapF ...@@ -266,22 +266,16 @@ export function SwapForm({ disableTokenInputs = false, onCurrencyChange }: SwapF
const combinedInitialState = { ...initialSwapState, ...prefilledState } const combinedInitialState = { ...initialSwapState, ...prefilledState }
const chainChanged = previousConnectedChainId && previousConnectedChainId !== connectedChainId const chainChanged = previousConnectedChainId && previousConnectedChainId !== connectedChainId
const prefilledInputChanged = const prefilledInputChanged =
previousPrefilledState && previousPrefilledState && previousPrefilledState?.inputCurrencyId !== prefilledState?.inputCurrencyId
previousPrefilledState?.[Field.INPUT]?.currencyId !== prefilledState?.[Field.INPUT]?.currencyId
const prefilledOutputChanged = const prefilledOutputChanged =
previousPrefilledState && previousPrefilledState && previousPrefilledState?.outputCurrencyId !== prefilledState?.outputCurrencyId
previousPrefilledState?.[Field.OUTPUT]?.currencyId !== prefilledState?.[Field.OUTPUT]?.currencyId
if (chainChanged || prefilledInputChanged || prefilledOutputChanged) { if (chainChanged || prefilledInputChanged || prefilledOutputChanged) {
setSwapState({ setSwapState({
...initialSwapState, ...initialSwapState,
...prefilledState, ...prefilledState,
independentField: combinedInitialState.independentField ?? Field.INPUT, independentField: combinedInitialState.independentField ?? Field.INPUT,
[Field.INPUT]: { inputCurrencyId: combinedInitialState.inputCurrencyId ?? undefined,
currencyId: combinedInitialState.INPUT.currencyId ?? undefined, outputCurrencyId: combinedInitialState.outputCurrencyId ?? undefined,
},
[Field.OUTPUT]: {
currencyId: combinedInitialState.OUTPUT.currencyId ?? undefined,
},
}) })
// reset local state // reset local state
setSwapFormState({ setSwapFormState({
...@@ -434,10 +428,8 @@ export function SwapForm({ disableTokenInputs = false, onCurrencyChange }: SwapF ...@@ -434,10 +428,8 @@ export function SwapForm({ disableTokenInputs = false, onCurrencyChange }: SwapF
(inputCurrency: Currency) => { (inputCurrency: Currency) => {
onCurrencySelection(Field.INPUT, inputCurrency) onCurrencySelection(Field.INPUT, inputCurrency)
onCurrencyChange?.({ onCurrencyChange?.({
[Field.INPUT]: { inputCurrencyId: getSwapCurrencyId(inputCurrency),
currencyId: getSwapCurrencyId(inputCurrency), outputCurrencyId: swapState.outputCurrencyId,
},
[Field.OUTPUT]: swapState[Field.OUTPUT],
}) })
maybeLogFirstSwapAction(trace) maybeLogFirstSwapAction(trace)
}, },
...@@ -454,10 +446,8 @@ export function SwapForm({ disableTokenInputs = false, onCurrencyChange }: SwapF ...@@ -454,10 +446,8 @@ export function SwapForm({ disableTokenInputs = false, onCurrencyChange }: SwapF
(outputCurrency: Currency) => { (outputCurrency: Currency) => {
onCurrencySelection(Field.OUTPUT, outputCurrency) onCurrencySelection(Field.OUTPUT, outputCurrency)
onCurrencyChange?.({ onCurrencyChange?.({
[Field.INPUT]: swapState[Field.INPUT], inputCurrencyId: swapState.inputCurrencyId,
[Field.OUTPUT]: { outputCurrencyId: getSwapCurrencyId(outputCurrency),
currencyId: getSwapCurrencyId(outputCurrency),
},
}) })
maybeLogFirstSwapAction(trace) maybeLogFirstSwapAction(trace)
}, },
......
...@@ -3,7 +3,7 @@ import { ChainId } from '@uniswap/sdk-core' ...@@ -3,7 +3,7 @@ import { ChainId } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core' import { useWeb3React } from '@web3-react/core'
import { Trace } from 'analytics' import { Trace } from 'analytics'
import { NetworkAlert } from 'components/NetworkAlert/NetworkAlert' import { NetworkAlert } from 'components/NetworkAlert/NetworkAlert'
import { Field, SwapTab } from 'components/swap/constants' import { SwapTab } from 'components/swap/constants'
import { PageWrapper, SwapWrapper } from 'components/swap/styled' import { PageWrapper, SwapWrapper } from 'components/swap/styled'
import SwapHeader from 'components/swap/SwapHeader' import SwapHeader from 'components/swap/SwapHeader'
import { SwitchLocaleLink } from 'components/SwitchLocaleLink' import { SwitchLocaleLink } from 'components/SwitchLocaleLink'
...@@ -50,8 +50,8 @@ export default function SwapPage({ className }: { className?: string }) { ...@@ -50,8 +50,8 @@ export default function SwapPage({ className }: { className?: string }) {
className={className} className={className}
chainId={supportedChainId ?? ChainId.MAINNET} chainId={supportedChainId ?? ChainId.MAINNET}
disableTokenInputs={supportedChainId === undefined} disableTokenInputs={supportedChainId === undefined}
initialInputCurrencyId={parsedSwapState?.[Field.INPUT]?.currencyId} initialInputCurrencyId={parsedSwapState?.inputCurrencyId}
initialOutputCurrencyId={parsedSwapState?.[Field.OUTPUT]?.currencyId} initialOutputCurrencyId={parsedSwapState?.outputCurrencyId}
/> />
<NetworkAlert /> <NetworkAlert />
</PageWrapper> </PageWrapper>
...@@ -77,7 +77,7 @@ export function Swap({ ...@@ -77,7 +77,7 @@ export function Swap({
}: { }: {
className?: string className?: string
chainId?: ChainId chainId?: ChainId
onCurrencyChange?: (selected: Pick<SwapState, Field.INPUT | Field.OUTPUT>) => void onCurrencyChange?: (selected: Pick<SwapState, 'inputCurrencyId' | 'outputCurrencyId'>) => void
disableTokenInputs?: boolean disableTokenInputs?: boolean
initialInputCurrencyId?: string | null initialInputCurrencyId?: string | null
initialOutputCurrencyId?: string | null initialOutputCurrencyId?: string | null
......
...@@ -14,7 +14,7 @@ const defaultState = { ...@@ -14,7 +14,7 @@ const defaultState = {
user: {}, user: {},
_persist: { _persist: {
rehydrated: true, rehydrated: true,
version: 6, version: 7,
}, },
application: { application: {
chainId: null, chainId: null,
......
...@@ -8,6 +8,7 @@ import { migration3 } from './migrations/3' ...@@ -8,6 +8,7 @@ import { migration3 } from './migrations/3'
import { migration4 } from './migrations/4' import { migration4 } from './migrations/4'
import { migration5 } from './migrations/5' import { migration5 } from './migrations/5'
import { migration6 } from './migrations/6' import { migration6 } from './migrations/6'
import { migration7 } from './migrations/7'
import { legacyLocalStorageMigration } from './migrations/legacy' import { legacyLocalStorageMigration } from './migrations/legacy'
/** /**
...@@ -27,6 +28,7 @@ export const migrations: MigrationManifest = { ...@@ -27,6 +28,7 @@ export const migrations: MigrationManifest = {
4: migration4, 4: migration4,
5: migration5, 5: migration5,
6: migration6, 6: migration6,
7: migration7,
} }
// We use a custom migration function for the initial state, because redux-persist // We use a custom migration function for the initial state, because redux-persist
......
...@@ -15,7 +15,7 @@ const previousState: PersistAppStateV1 = { ...@@ -15,7 +15,7 @@ const previousState: PersistAppStateV1 = {
tokens: {}, tokens: {},
pairs: {}, pairs: {},
timestamp: Date.now(), timestamp: Date.now(),
hideAndroidAnnouncementBanner: false, hideAppPromoBanner: false,
}, },
_persist: { _persist: {
version: 0, version: 0,
......
...@@ -17,7 +17,7 @@ const previousState: PersistAppStateV2 = { ...@@ -17,7 +17,7 @@ const previousState: PersistAppStateV2 = {
tokens: {}, tokens: {},
pairs: {}, pairs: {},
timestamp: Date.now(), timestamp: Date.now(),
hideAndroidAnnouncementBanner: false, hideAppPromoBanner: false,
}, },
_persist: { _persist: {
version: 1, version: 1,
......
...@@ -38,7 +38,7 @@ const previousState: PersistAppStateV3 = { ...@@ -38,7 +38,7 @@ const previousState: PersistAppStateV3 = {
}, },
pairs: {}, pairs: {},
timestamp: Date.now(), timestamp: Date.now(),
hideAndroidAnnouncementBanner: false, hideAppPromoBanner: false,
}, },
_persist: { _persist: {
version: 2, version: 2,
......
...@@ -19,7 +19,7 @@ const previousState: PersistAppStateV4 = { ...@@ -19,7 +19,7 @@ const previousState: PersistAppStateV4 = {
tokens: {}, tokens: {},
pairs: {}, pairs: {},
timestamp: Date.now(), timestamp: Date.now(),
hideAndroidAnnouncementBanner: false, hideAppPromoBanner: false,
}, },
_persist: { _persist: {
version: 3, version: 3,
......
...@@ -21,7 +21,7 @@ const previousState: PersistAppStateV5 = { ...@@ -21,7 +21,7 @@ const previousState: PersistAppStateV5 = {
tokens: {}, tokens: {},
pairs: {}, pairs: {},
timestamp: Date.now(), timestamp: Date.now(),
hideAndroidAnnouncementBanner: false, hideAppPromoBanner: false,
}, },
_persist: { _persist: {
version: 4, version: 4,
......
...@@ -20,7 +20,7 @@ const persistUserState: PersistAppStateV6['user'] = { ...@@ -20,7 +20,7 @@ const persistUserState: PersistAppStateV6['user'] = {
tokens: {}, tokens: {},
pairs: {}, pairs: {},
timestamp: Date.now(), timestamp: Date.now(),
hideAndroidAnnouncementBanner: false, hideAppPromoBanner: false,
} }
const previousStateUnselected: PersistAppStateV6 = { const previousStateUnselected: PersistAppStateV6 = {
......
import { createMigrate } from 'redux-persist'
import { RouterPreference } from 'state/routing/types'
import { SlippageTolerance } from 'state/user/types'
import { migration1 } from './1'
import { migration2 } from './2'
import { migration3 } from './3'
import { migration4 } from './4'
import { migration5 } from './5'
import { migration6 } from './6'
import { migration7, PersistAppStateV7 } from './7'
const previousState: PersistAppStateV7 = {
user: {
userRouterPreference: RouterPreference.API,
userLocale: null,
userHideClosedPositions: false,
userSlippageTolerance: SlippageTolerance.Auto,
userSlippageToleranceHasBeenMigratedToAuto: true,
userDeadline: 1800,
tokens: {},
pairs: {},
timestamp: Date.now(),
hideAppPromoBanner: false,
},
_persist: {
version: 6,
rehydrated: true,
},
}
describe('migration to v7', () => {
it('should migrate users who currently have `hideAndroidAnnouncementBanner` preference', async () => {
const migrator = createMigrate(
{
1: migration1,
2: migration2,
3: migration3,
4: migration4,
5: migration5,
6: migration6,
7: migration7,
},
{ debug: false }
)
const result: any = await migrator(previousState, 7)
expect(result?.user?.hideAndroidAnnouncementBanner).toBeUndefined()
expect(result?.user?.hideAppPromoBanner).toEqual(false)
expect(result?._persist.version).toEqual(7)
})
it('should not change hideAppPromoBanner value if user already hideAndroidAnnouncementBanner', async () => {
const migrator = createMigrate(
{
1: migration1,
2: migration2,
3: migration3,
4: migration4,
5: migration5,
6: migration6,
7: migration7,
},
{ debug: false }
)
const result: any = await migrator(
{
...previousState,
user: {
...previousState.user,
hideAndroidAnnouncementBanner: true,
},
} as PersistAppStateV7,
7
)
expect(result?.user?.hideAppPromoBanner).toEqual(true)
expect(result?.user?.hideAndroidAnnouncementBanner).toBeUndefined()
expect(result?._persist.version).toEqual(7)
})
it('should not migrate user if user does not exist', async () => {
const migrator = createMigrate(
{
1: migration1,
2: migration2,
3: migration3,
4: migration4,
5: migration5,
6: migration6,
7: migration7,
},
{ debug: false }
)
const result: any = await migrator(
{
...previousState,
user: undefined,
} as PersistAppStateV7,
7
)
expect(result?.user).toBeUndefined()
expect(result?._persist.version).toEqual(7)
})
})
import { PersistState } from 'redux-persist'
import { UserState } from 'state/user/reducer'
export type PersistAppStateV7 = {
_persist: PersistState
} & { user?: UserState & { hideAndroidAnnouncementBanner?: boolean } }
/**
* Migration to rename hideAndroidAnnouncementBanner to hideAppPromoBanner.
*/
export const migration7 = (state: PersistAppStateV7 | undefined) => {
if (!state) return state
const userHidAndroidAnnouncementBanner = state?.user?.hideAndroidAnnouncementBanner
if (state?.user && 'hideAndroidAnnouncementBanner' in state.user) {
delete state.user['hideAndroidAnnouncementBanner']
}
// If the the user has previously hidden the Android announcement banner, we respect that preference.
if (state?.user && userHidAndroidAnnouncementBanner) {
return {
...state,
user: {
...state.user,
hideAppPromoBanner: userHidAndroidAnnouncementBanner,
},
_persist: {
...state._persist,
version: 7,
},
}
}
return {
...state,
_persist: {
...state._persist,
version: 7,
},
}
}
...@@ -44,7 +44,7 @@ export type AppState = ReturnType<typeof appReducer> ...@@ -44,7 +44,7 @@ export type AppState = ReturnType<typeof appReducer>
const persistConfig: PersistConfig<AppState> = { const persistConfig: PersistConfig<AppState> = {
key: 'interface', key: 'interface',
version: 6, // see migrations.ts for more details about this version version: 7, // see migrations.ts for more details about this version
storage: localForage.createInstance({ storage: localForage.createInstance({
name: 'redux', name: 'redux',
}), }),
......
...@@ -87,7 +87,7 @@ interface ExpectedUserState { ...@@ -87,7 +87,7 @@ interface ExpectedUserState {
} }
} }
timestamp: number timestamp: number
hideAndroidAnnouncementBanner: boolean hideAppPromoBanner: boolean
showSurveyPopup?: boolean showSurveyPopup?: boolean
originCountry?: string originCountry?: string
} }
......
...@@ -20,8 +20,9 @@ import { ...@@ -20,8 +20,9 @@ import {
import { isExactInput, transformQuoteToTrade } from './utils' import { isExactInput, transformQuoteToTrade } from './utils'
const UNISWAP_API_URL = process.env.REACT_APP_UNISWAP_API_URL const UNISWAP_API_URL = process.env.REACT_APP_UNISWAP_API_URL
if (UNISWAP_API_URL === undefined) { const UNISWAP_GATEWAY_DNS_URL = process.env.REACT_APP_UNISWAP_GATEWAY_DNS
throw new Error(`UNISWAP_API_URL must be a defined environment variable`) if (UNISWAP_API_URL === undefined || UNISWAP_GATEWAY_DNS_URL === undefined) {
throw new Error(`UNISWAP_API_URL and UNISWAP_GATEWAY_DNS_URL must be defined environment variables`)
} }
const CLIENT_PARAMS = { const CLIENT_PARAMS = {
...@@ -75,9 +76,7 @@ function getRoutingAPIConfig(args: GetQuoteArgs): RoutingConfig { ...@@ -75,9 +76,7 @@ function getRoutingAPIConfig(args: GetQuoteArgs): RoutingConfig {
export const routingApi = createApi({ export const routingApi = createApi({
reducerPath: 'routingApi', reducerPath: 'routingApi',
baseQuery: fetchBaseQuery({ baseQuery: fetchBaseQuery(),
baseUrl: UNISWAP_API_URL,
}),
endpoints: (build) => ({ endpoints: (build) => ({
getQuote: build.query<TradeResult, GetQuoteArgs>({ getQuote: build.query<TradeResult, GetQuoteArgs>({
async onQueryStarted(args: GetQuoteArgs, { queryFulfilled }) { async onQueryStarted(args: GetQuoteArgs, { queryFulfilled }) {
...@@ -119,6 +118,7 @@ export const routingApi = createApi({ ...@@ -119,6 +118,7 @@ export const routingApi = createApi({
amount, amount,
tradeType, tradeType,
sendPortionEnabled, sendPortionEnabled,
gatewayDNSUpdateEnabled,
} = args } = args
const requestBody = { const requestBody = {
...@@ -133,10 +133,14 @@ export const routingApi = createApi({ ...@@ -133,10 +133,14 @@ export const routingApi = createApi({
configs: getRoutingAPIConfig(args), configs: getRoutingAPIConfig(args),
} }
const baseURL = gatewayDNSUpdateEnabled ? UNISWAP_GATEWAY_DNS_URL : UNISWAP_API_URL
const response = await fetch({ const response = await fetch({
method: 'POST', method: 'POST',
url: '/quote', url: `${baseURL}/quote`,
body: JSON.stringify(requestBody), body: JSON.stringify(requestBody),
headers: {
'x-request-source': 'uniswap-web',
},
}) })
if (response.error) { if (response.error) {
......
...@@ -44,6 +44,7 @@ export interface GetQuoteArgs { ...@@ -44,6 +44,7 @@ export interface GetQuoteArgs {
needsWrapIfUniswapX: boolean needsWrapIfUniswapX: boolean
uniswapXForceSyntheticQuotes: boolean uniswapXForceSyntheticQuotes: boolean
sendPortionEnabled: boolean sendPortionEnabled: boolean
gatewayDNSUpdateEnabled: boolean
} }
export type GetQuickQuoteArgs = { export type GetQuickQuoteArgs = {
......
...@@ -62,6 +62,7 @@ const MOCK_ARGS: GetQuoteArgs = { ...@@ -62,6 +62,7 @@ const MOCK_ARGS: GetQuoteArgs = {
needsWrapIfUniswapX: USDCAmount.currency.isNative, needsWrapIfUniswapX: USDCAmount.currency.isNative,
uniswapXForceSyntheticQuotes: false, uniswapXForceSyntheticQuotes: false,
sendPortionEnabled: true, sendPortionEnabled: true,
gatewayDNSUpdateEnabled: false,
} }
describe('#useRoutingAPITrade ExactIn', () => { describe('#useRoutingAPITrade ExactIn', () => {
......
...@@ -29,6 +29,7 @@ const BASE_ARGS = { ...@@ -29,6 +29,7 @@ const BASE_ARGS = {
userOptedOutOfUniswapX: false, userOptedOutOfUniswapX: false,
isUniswapXDefaultEnabled: false, isUniswapXDefaultEnabled: false,
sendPortionEnabled: true, sendPortionEnabled: true,
gatewayDNSUpdateEnabled: false,
} }
function constructArgs(currencyIn: Currency, currencyOut: Currency): GetQuoteArgs { function constructArgs(currencyIn: Currency, currencyOut: Currency): GetQuoteArgs {
......
...@@ -48,8 +48,13 @@ describe('signature reducer', () => { ...@@ -48,8 +48,13 @@ describe('signature reducer', () => {
}, },
}) })
// Adding a signature w/ same id should throw // Adding a signature w/ same id should be a no-op
expect(() => store.dispatch(addSignature(signature))).toThrow() store.dispatch(addSignature(signature))
expect(store.getState()).toStrictEqual({
[account]: {
[signature.id]: signature,
},
})
}) })
}) })
......
...@@ -13,7 +13,7 @@ const signatureSlice = createSlice({ ...@@ -13,7 +13,7 @@ const signatureSlice = createSlice({
initialState, initialState,
reducers: { reducers: {
addSignature(signatures, { payload }: { payload: SignatureDetails }) { addSignature(signatures, { payload }: { payload: SignatureDetails }) {
if (signatures[payload.offerer]?.[payload.id]) throw Error('Attempted to add existing signature.') if (signatures[payload.offerer]?.[payload.id]) return
const accountSignatures = signatures[payload.offerer] ?? {} const accountSignatures = signatures[payload.offerer] ?? {}
accountSignatures[payload.id] = payload accountSignatures[payload.id] = payload
......
...@@ -44,22 +44,14 @@ describe('Swap Context', () => { ...@@ -44,22 +44,14 @@ describe('Swap Context', () => {
}, },
}, },
prefilledState: { prefilledState: {
INPUT: { inputCurrencyId: undefined,
currencyId: undefined, outputCurrencyId: undefined,
},
OUTPUT: {
currencyId: undefined,
},
}, },
setCurrentTab: expect.any(Function), setCurrentTab: expect.any(Function),
setSwapState: expect.any(Function), setSwapState: expect.any(Function),
swapState: { swapState: {
INPUT: { inputCurrencyId: undefined,
currencyId: undefined, outputCurrencyId: undefined,
},
OUTPUT: {
currencyId: undefined,
},
independentField: 'INPUT', independentField: 'INPUT',
recipient: null, recipient: null,
typedValue: '', typedValue: '',
......
...@@ -9,12 +9,8 @@ import { queryParametersToSwapState, SwapInfo, useDerivedSwapInfo } from './hook ...@@ -9,12 +9,8 @@ import { queryParametersToSwapState, SwapInfo, useDerivedSwapInfo } from './hook
export interface SwapState { export interface SwapState {
readonly independentField: Field readonly independentField: Field
readonly typedValue: string readonly typedValue: string
readonly [Field.INPUT]: { inputCurrencyId?: string | null
readonly currencyId?: string | null outputCurrencyId?: string | null
}
readonly [Field.OUTPUT]: {
readonly currencyId?: string | null
}
// the typed recipient address or ENS name, or null if swap should go to sender // the typed recipient address or ENS name, or null if swap should go to sender
readonly recipient: string | null readonly recipient: string | null
} }
...@@ -24,12 +20,8 @@ export const initialSwapState: SwapState = queryParametersToSwapState(parsedQuer ...@@ -24,12 +20,8 @@ export const initialSwapState: SwapState = queryParametersToSwapState(parsedQuer
type SwapContextType = { type SwapContextType = {
swapState: SwapState swapState: SwapState
prefilledState: { prefilledState: {
INPUT: { inputCurrencyId?: string | null
currencyId?: string | null outputCurrencyId?: string | null
}
OUTPUT: {
currencyId?: string | null
}
} }
derivedSwapInfo: SwapInfo derivedSwapInfo: SwapInfo
setSwapState: Dispatch<SetStateAction<SwapState>> setSwapState: Dispatch<SetStateAction<SwapState>>
...@@ -55,12 +47,8 @@ export const EMPTY_DERIVED_SWAP_INFO: SwapInfo = Object.freeze({ ...@@ -55,12 +47,8 @@ export const EMPTY_DERIVED_SWAP_INFO: SwapInfo = Object.freeze({
export const SwapContext = createContext<SwapContextType>({ export const SwapContext = createContext<SwapContextType>({
swapState: initialSwapState, swapState: initialSwapState,
prefilledState: { prefilledState: {
INPUT: { inputCurrencyId: undefined,
currencyId: undefined, outputCurrencyId: undefined,
},
OUTPUT: {
currencyId: undefined,
},
}, },
chainId: ChainId.MAINNET, chainId: ChainId.MAINNET,
derivedSwapInfo: EMPTY_DERIVED_SWAP_INFO, derivedSwapInfo: EMPTY_DERIVED_SWAP_INFO,
...@@ -88,8 +76,8 @@ export function SwapContextProvider({ ...@@ -88,8 +76,8 @@ export function SwapContextProvider({
const prefilledState = useMemo( const prefilledState = useMemo(
() => ({ () => ({
[Field.INPUT]: { currencyId: initialInputCurrencyId }, inputCurrencyId: initialInputCurrencyId,
[Field.OUTPUT]: { currencyId: initialOutputCurrencyId }, outputCurrencyId: initialOutputCurrencyId,
}), }),
[initialInputCurrencyId, initialOutputCurrencyId] [initialInputCurrencyId, initialOutputCurrencyId]
) )
......
...@@ -15,8 +15,8 @@ describe('hooks', () => { ...@@ -15,8 +15,8 @@ describe('hooks', () => {
) )
) )
).toEqual({ ).toEqual({
[Field.OUTPUT]: { currencyId: '0x6B175474E89094C44Da98b954EedeAC495271d0F' }, outputCurrencyId: '0x6B175474E89094C44Da98b954EedeAC495271d0F',
[Field.INPUT]: { currencyId: 'ETH' }, inputCurrencyId: 'ETH',
typedValue: '20.5', typedValue: '20.5',
independentField: Field.OUTPUT, independentField: Field.OUTPUT,
recipient: null, recipient: null,
...@@ -27,8 +27,8 @@ describe('hooks', () => { ...@@ -27,8 +27,8 @@ describe('hooks', () => {
expect( expect(
queryParametersToSwapState(parse('?outputCurrency=invalid', { parseArrays: false, ignoreQueryPrefix: true })) queryParametersToSwapState(parse('?outputCurrency=invalid', { parseArrays: false, ignoreQueryPrefix: true }))
).toEqual({ ).toEqual({
[Field.INPUT]: { currencyId: 'ETH' }, inputCurrencyId: 'ETH',
[Field.OUTPUT]: { currencyId: null }, outputCurrencyId: null,
typedValue: '', typedValue: '',
independentField: Field.INPUT, independentField: Field.INPUT,
recipient: null, recipient: null,
...@@ -41,8 +41,8 @@ describe('hooks', () => { ...@@ -41,8 +41,8 @@ describe('hooks', () => {
parse('?outputCurrency=eth&exactAmount=20.5', { parseArrays: false, ignoreQueryPrefix: true }) parse('?outputCurrency=eth&exactAmount=20.5', { parseArrays: false, ignoreQueryPrefix: true })
) )
).toEqual({ ).toEqual({
[Field.OUTPUT]: { currencyId: 'ETH' }, outputCurrencyId: 'ETH',
[Field.INPUT]: { currencyId: null }, inputCurrencyId: null,
typedValue: '20.5', typedValue: '20.5',
independentField: Field.INPUT, independentField: Field.INPUT,
recipient: null, recipient: null,
...@@ -55,8 +55,8 @@ describe('hooks', () => { ...@@ -55,8 +55,8 @@ describe('hooks', () => {
parse('?outputCurrency=eth&exactAmount=20.5&recipient=abc', { parseArrays: false, ignoreQueryPrefix: true }) parse('?outputCurrency=eth&exactAmount=20.5&recipient=abc', { parseArrays: false, ignoreQueryPrefix: true })
) )
).toEqual({ ).toEqual({
[Field.OUTPUT]: { currencyId: 'ETH' }, outputCurrencyId: 'ETH',
[Field.INPUT]: { currencyId: null }, inputCurrencyId: null,
typedValue: '20.5', typedValue: '20.5',
independentField: Field.INPUT, independentField: Field.INPUT,
recipient: null, recipient: null,
...@@ -72,8 +72,8 @@ describe('hooks', () => { ...@@ -72,8 +72,8 @@ describe('hooks', () => {
}) })
) )
).toEqual({ ).toEqual({
[Field.OUTPUT]: { currencyId: 'ETH' }, outputCurrencyId: 'ETH',
[Field.INPUT]: { currencyId: null }, inputCurrencyId: null,
typedValue: '20.5', typedValue: '20.5',
independentField: Field.INPUT, independentField: Field.INPUT,
recipient: TEST_RECIPIENT_ADDRESS, recipient: TEST_RECIPIENT_ADDRESS,
...@@ -88,8 +88,8 @@ describe('hooks', () => { ...@@ -88,8 +88,8 @@ describe('hooks', () => {
}) })
) )
).toEqual({ ).toEqual({
[Field.OUTPUT]: { currencyId: 'ETH' }, outputCurrencyId: 'ETH',
[Field.INPUT]: { currencyId: null }, inputCurrencyId: null,
typedValue: '20.5', typedValue: '20.5',
independentField: Field.INPUT, independentField: Field.INPUT,
recipient: 'bob.argent.xyz', recipient: 'bob.argent.xyz',
......
...@@ -31,21 +31,22 @@ export function useSwapActionHandlers(): { ...@@ -31,21 +31,22 @@ export function useSwapActionHandlers(): {
const onCurrencySelection = useCallback( const onCurrencySelection = useCallback(
(field: Field, currency: Currency) => { (field: Field, currency: Currency) => {
const currencyId = currency.isToken ? currency.address : currency.isNative ? 'ETH' : '' const currencyId = currency.isToken ? currency.address : currency.isNative ? 'ETH' : ''
const otherField = field === Field.INPUT ? Field.OUTPUT : Field.INPUT const [currentCurrencyKey, otherCurrencyKey]: (keyof SwapState)[] =
field === Field.INPUT ? ['inputCurrencyId', 'outputCurrencyId'] : ['outputCurrencyId', 'inputCurrencyId']
setSwapState((state) => { setSwapState((state) => {
if (currencyId === state[otherField].currencyId) { if (currencyId === state[otherCurrencyKey]) {
// the case where we have to swap the order // the case where we have to swap the order
return { return {
...state, ...state,
independentField: state.independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT, independentField: state.independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT,
[field]: { currencyId }, [currentCurrencyKey]: currencyId,
[otherField]: { currencyId: state[field].currencyId }, [otherCurrencyKey]: state[currentCurrencyKey],
} }
} else { } else {
// the normal case // the normal case
return { return {
...state, ...state,
[field]: { currencyId }, [currentCurrencyKey]: currencyId,
} }
} }
}) })
...@@ -60,8 +61,8 @@ export function useSwapActionHandlers(): { ...@@ -60,8 +61,8 @@ export function useSwapActionHandlers(): {
// To prevent swaps with FOT tokens as exact-outputs, we leave it as an exact-in swap and use the previously estimated output amount as the new exact-in amount. // To prevent swaps with FOT tokens as exact-outputs, we leave it as an exact-in swap and use the previously estimated output amount as the new exact-in amount.
return { return {
...state, ...state,
[Field.INPUT]: { currencyId: state[Field.OUTPUT].currencyId }, inputCurrencyId: state.outputCurrencyId,
[Field.OUTPUT]: { currencyId: state[Field.INPUT].currencyId }, outputCurrencyId: state.inputCurrencyId,
typedValue: previouslyEstimatedOutput, typedValue: previouslyEstimatedOutput,
} }
} }
...@@ -69,8 +70,8 @@ export function useSwapActionHandlers(): { ...@@ -69,8 +70,8 @@ export function useSwapActionHandlers(): {
return { return {
...state, ...state,
independentField: state.independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT, independentField: state.independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT,
[Field.INPUT]: { currencyId: state[Field.OUTPUT].currencyId }, inputCurrencyId: state.outputCurrencyId,
[Field.OUTPUT]: { currencyId: state[Field.INPUT].currencyId }, outputCurrencyId: state.inputCurrencyId,
} }
}) })
}, },
...@@ -134,13 +135,7 @@ export type SwapInfo = { ...@@ -134,13 +135,7 @@ export type SwapInfo = {
export function useDerivedSwapInfo(state: SwapState, chainId: ChainId | undefined): SwapInfo { export function useDerivedSwapInfo(state: SwapState, chainId: ChainId | undefined): SwapInfo {
const { account } = useWeb3React() const { account } = useWeb3React()
const { const { independentField, typedValue, inputCurrencyId, outputCurrencyId, recipient } = state
independentField,
typedValue,
[Field.INPUT]: { currencyId: inputCurrencyId },
[Field.OUTPUT]: { currencyId: outputCurrencyId },
recipient,
} = state
const inputCurrency = useCurrency(inputCurrencyId, chainId) const inputCurrency = useCurrency(inputCurrencyId, chainId)
const outputCurrency = useCurrency(outputCurrencyId, chainId) const outputCurrency = useCurrency(outputCurrencyId, chainId)
...@@ -318,12 +313,8 @@ export function queryParametersToSwapState(parsedQs: ParsedQs): SwapState { ...@@ -318,12 +313,8 @@ export function queryParametersToSwapState(parsedQs: ParsedQs): SwapState {
const recipient = validatedRecipient(parsedQs.recipient) const recipient = validatedRecipient(parsedQs.recipient)
return { return {
[Field.INPUT]: { inputCurrencyId: inputCurrency === '' ? null : inputCurrency ?? null,
currencyId: inputCurrency === '' ? null : inputCurrency ?? null, outputCurrencyId: outputCurrency === '' ? null : outputCurrency ?? null,
},
[Field.OUTPUT]: {
currencyId: outputCurrency === '' ? null : outputCurrency ?? null,
},
typedValue, typedValue,
independentField, independentField,
recipient, recipient,
......
...@@ -15,7 +15,7 @@ import { useDefaultActiveTokens } from '../../hooks/Tokens' ...@@ -15,7 +15,7 @@ import { useDefaultActiveTokens } from '../../hooks/Tokens'
import { import {
addSerializedPair, addSerializedPair,
addSerializedToken, addSerializedToken,
updateHideAndroidAnnouncementBanner, updateHideAppPromoBanner,
updateHideClosedPositions, updateHideClosedPositions,
updateUserDeadline, updateUserDeadline,
updateUserLocale, updateUserLocale,
...@@ -206,15 +206,15 @@ export function usePairAdder(): (pair: Pair) => void { ...@@ -206,15 +206,15 @@ export function usePairAdder(): (pair: Pair) => void {
) )
} }
export function useHideAndroidAnnouncementBanner(): [boolean, () => void] { export function useHideAppPromoBanner(): [boolean, () => void] {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const hideAndroidAnnouncementBanner = useAppSelector((state) => state.user.hideAndroidAnnouncementBanner) const hideAppPromoBanner = useAppSelector((state) => state.user.hideAppPromoBanner)
const toggleHideAndroidAnnouncementBanner = useCallback(() => { const toggleHideAppPromoBanner = useCallback(() => {
dispatch(updateHideAndroidAnnouncementBanner({ hideAndroidAnnouncementBanner: true })) dispatch(updateHideAppPromoBanner({ hideAppPromoBanner: true }))
}, [dispatch]) }, [dispatch])
return [hideAndroidAnnouncementBanner, toggleHideAndroidAnnouncementBanner] return [hideAppPromoBanner, toggleHideAppPromoBanner]
} }
/** /**
......
...@@ -9,7 +9,7 @@ import reducer, { ...@@ -9,7 +9,7 @@ import reducer, {
clearRecentConnectionMeta, clearRecentConnectionMeta,
initialState, initialState,
setRecentConnectionDisconnected, setRecentConnectionDisconnected,
updateHideAndroidAnnouncementBanner, updateHideAppPromoBanner,
updateHideClosedPositions, updateHideClosedPositions,
updateRecentConnectionMeta, updateRecentConnectionMeta,
updateUserDeadline, updateUserDeadline,
...@@ -100,10 +100,10 @@ describe('swap reducer', () => { ...@@ -100,10 +100,10 @@ describe('swap reducer', () => {
}) })
}) })
describe('updateHideAndroidAnnouncementBanner', () => { describe('updateHideAppPromoBanner', () => {
it('updates the updateHideAndroidAnnouncementBanner', () => { it('updates the updateHideAppPromoBanner', () => {
store.dispatch(updateHideAndroidAnnouncementBanner({ hideAndroidAnnouncementBanner: true })) store.dispatch(updateHideAppPromoBanner({ hideAppPromoBanner: true }))
expect(store.getState().hideAndroidAnnouncementBanner).toEqual(true) expect(store.getState().hideAppPromoBanner).toEqual(true)
}) })
}) })
......
...@@ -46,7 +46,7 @@ export interface UserState { ...@@ -46,7 +46,7 @@ export interface UserState {
} }
timestamp: number timestamp: number
hideAndroidAnnouncementBanner: boolean hideAppPromoBanner: boolean
// undefined means has not gone through A/B split yet // undefined means has not gone through A/B split yet
showSurveyPopup?: boolean showSurveyPopup?: boolean
...@@ -68,7 +68,7 @@ export const initialState: UserState = { ...@@ -68,7 +68,7 @@ export const initialState: UserState = {
tokens: {}, tokens: {},
pairs: {}, pairs: {},
timestamp: currentTimestamp(), timestamp: currentTimestamp(),
hideAndroidAnnouncementBanner: false, hideAppPromoBanner: false,
showSurveyPopup: undefined, showSurveyPopup: undefined,
originCountry: undefined, originCountry: undefined,
} }
...@@ -112,8 +112,8 @@ const userSlice = createSlice({ ...@@ -112,8 +112,8 @@ const userSlice = createSlice({
updateHideClosedPositions(state, action) { updateHideClosedPositions(state, action) {
state.userHideClosedPositions = action.payload.userHideClosedPositions state.userHideClosedPositions = action.payload.userHideClosedPositions
}, },
updateHideAndroidAnnouncementBanner(state, action) { updateHideAppPromoBanner(state, action) {
state.hideAndroidAnnouncementBanner = action.payload.hideAndroidAnnouncementBanner state.hideAppPromoBanner = action.payload.hideAppPromoBanner
}, },
addSerializedToken(state, { payload: { serializedToken } }) { addSerializedToken(state, { payload: { serializedToken } }) {
if (!state.tokens) { if (!state.tokens) {
...@@ -152,6 +152,6 @@ export const { ...@@ -152,6 +152,6 @@ export const {
updateUserDeadline, updateUserDeadline,
updateUserLocale, updateUserLocale,
updateUserSlippageTolerance, updateUserSlippageTolerance,
updateHideAndroidAnnouncementBanner, updateHideAppPromoBanner,
} = userSlice.actions } = userSlice.actions
export default userSlice.reducer export default userSlice.reducer
import { Currency } from '@uniswap/sdk-core' import { Currency } from '@uniswap/sdk-core'
export function currencyId(currency: Currency): string { export function currencyId(currency?: Currency): string {
if (currency.isNative) return 'ETH' if (currency?.isNative) return 'ETH'
if (currency.isToken) return currency.address if (currency?.isToken) return currency.address
throw new Error('invalid currency') throw new Error('invalid currency')
} }
...@@ -6,9 +6,16 @@ const complexityRules = { ...@@ -6,9 +6,16 @@ const complexityRules = {
'max-depth': ['error', 4], // prevent deeply nested code paths which are hard to read 'max-depth': ['error', 4], // prevent deeply nested code paths which are hard to read
'max-nested-callbacks': ['error', 3], 'max-nested-callbacks': ['error', 3],
'max-lines': ['error', 500], // cap file length 'max-lines': ['error', 500], // cap file length
complexity: ['error', 20], // restrict cyclomatic complexity (number of linearly independent paths ) complexity: ['error', 20], // restrict cyclomatic complexity (number of linearly independent paths)
} }
// The ESLint browser environment defines all browser globals as valid,
// even though most people don't know some of them exist (e.g. `name` or `status`).
// This is dangerous as it hides accidentally undefined variables.
// We blacklist the globals that we deem potentially confusing.
// To use them, explicitly reference them, e.g. `window.name` or `window.status`.
const restrictedGlobals = require('confusing-browser-globals')
module.exports = { module.exports = {
root: true, root: true,
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
...@@ -46,6 +53,7 @@ module.exports = { ...@@ -46,6 +53,7 @@ module.exports = {
'no-extra-boolean-cast': 'error', 'no-extra-boolean-cast': 'error',
'no-ex-assign': 'error', 'no-ex-assign': 'error',
'no-console': 'warn', 'no-console': 'warn',
'no-restricted-globals': ['error'].concat(restrictedGlobals),
"no-relative-import-paths/no-relative-import-paths": [ "no-relative-import-paths/no-relative-import-paths": [
2, 2,
{ {
...@@ -235,6 +243,13 @@ module.exports = { ...@@ -235,6 +243,13 @@ module.exports = {
"@jambit/typed-redux-saga/delegate-effects": "error" "@jambit/typed-redux-saga/delegate-effects": "error"
} }
}, },
// Allow more depth for testing files
{
"files": ["./**/*.test.ts", "./**/*.test.tsx"],
"rules": {
'max-nested-callbacks': ['error', 4],
}
},
{ {
files: ['*.json'], files: ['*.json'],
rules: { rules: {
...@@ -302,6 +317,7 @@ module.exports = { ...@@ -302,6 +317,7 @@ module.exports = {
'Unicon', 'Unicon',
'yourname', 'yourname',
'yourusername', 'yourusername',
'Unitags',
], ],
}, },
......
...@@ -25,5 +25,6 @@ module.exports = { ...@@ -25,5 +25,6 @@ module.exports = {
UNISWAP_API_BASE_URL: 'https://api.uniswap.org', UNISWAP_API_BASE_URL: 'https://api.uniswap.org',
UNISWAP_APP_URL: 'https://app.uniswap.org', UNISWAP_APP_URL: 'https://app.uniswap.org',
WALLETCONNECT_PROJECT_ID: 123, WALLETCONNECT_PROJECT_ID: 123,
UNITAGS_API_URL: 'https://api.uniswap.org/unitags',
}, },
}; };
...@@ -35,7 +35,7 @@ module.exports = { ...@@ -35,7 +35,7 @@ module.exports = {
}, },
modulePathIgnorePatterns: ['<rootDir>/node_modules'], modulePathIgnorePatterns: ['<rootDir>/node_modules'],
testPathIgnorePatterns: ['<rootDir>/node_modules'], testPathIgnorePatterns: ['<rootDir>/node_modules'],
testMatch: ['<rootDir>/**/?(*.)+(spec|test).[jt]s?(x)'], testMatch: ['<rootDir>/**/*.(spec|test).[jt]s?(x)'],
setupFilesAfterEnv: ['<rootDir>/../../config/jest-presets/jest/setup.js'], setupFilesAfterEnv: ['<rootDir>/../../config/jest-presets/jest/setup.js'],
// consider enabling for speed // consider enabling for speed
// changedSince: 'master', // changedSince: 'master',
......
import { assert, errorToString, NotImplementedError } from 'utilities/src/errors'
describe('NotImplementedError', () => {
it('throws an error with the correct message', () => {
expect(() => {
throw new NotImplementedError('functionName')
}).toThrow('functionName() not implemented. Did you forget a platform override?')
})
})
describe('assert', () => {
it('throws an error if the predicate is false', () => {
expect(() => {
assert(false, 'error message')
}).toThrow('error message')
})
it('does nothing if the predicate is true', () => {
expect(() => {
assert(true, 'error message')
}).not.toThrow()
})
})
describe('errorToString', () => {
it('returns the error message if the error is an Error', () => {
expect(errorToString(new Error('error message'))).toBe('error message')
})
it('returns the error message if the error is a string', () => {
expect(errorToString('error message')).toBe('error message')
})
it('returns the error message if the error is a number', () => {
expect(errorToString(123)).toBe('Error code: 123')
})
it('returns the error message if the error is an object', () => {
expect(errorToString({ error: 'message' })).toBe('{"error":"message"}')
})
it('Trims error message if it is longer than maxLength', () => {
expect(errorToString('error message', 5)).toBe('error...')
})
})
import { Percent } from '@uniswap/sdk-core'
import { formatPriceImpact } from './formatPriceImpact'
describe('formatPriceImpact', () => {
it('returns negative price impact value formatted as percentage for positive values', () => {
expect(formatPriceImpact(new Percent(1))).toBe('-100.000%')
expect(formatPriceImpact(new Percent(1, 2))).toBe('-50.000%')
expect(formatPriceImpact(new Percent(2, 3))).toBe('-66.667%')
})
it('returns positive price impact value formatted as percentage for negative values', () => {
expect(formatPriceImpact(new Percent(-1))).toBe('100.000%')
expect(formatPriceImpact(new Percent(-1, 2))).toBe('50.000%')
expect(formatPriceImpact(new Percent(-2, 3))).toBe('66.667%')
})
})
import { render } from '@testing-library/react'
import { renderHook } from '@testing-library/react-hooks'
import { createRef, forwardRef, useRef } from 'react'
import { act } from 'react-test-renderer'
import { useAsyncData, useForwardRef, useMemoCompare, usePrevious } from './hooks'
describe('usePrevious', () => {
it('returns undefined on first render', () => {
const { result } = renderHook(() => usePrevious(1))
expect(result.current).toBe(undefined)
})
it('returns the previous value', () => {
const { result, rerender } = renderHook((props) => usePrevious(props), {
initialProps: 1,
})
rerender(2)
expect(result.current).toBe(1)
rerender(3)
expect(result.current).toBe(2)
})
})
describe('useAsyncData', () => {
const promise = new Promise((resolve) => {
setTimeout(() => {
resolve('data')
}, 1000)
})
it('returns undefined and isLoading set to true before data is loaded', async () => {
const asyncCallback = jest.fn().mockResolvedValue('data')
const { result, waitForNextUpdate } = renderHook(() => useAsyncData(asyncCallback))
expect(result.current).toEqual({ data: undefined, isLoading: true })
await waitForNextUpdate() // Removes warning about not waiting for an update
})
it('returns the data and isLoading set to false after data is loaded', async () => {
const asyncCallback = jest.fn().mockResolvedValue('data')
const { result, waitForNextUpdate, rerender } = renderHook(() => useAsyncData(asyncCallback))
expect(result.current).toEqual({ data: undefined, isLoading: true })
await act(async () => {
rerender()
await waitForNextUpdate()
})
expect(result.current).toEqual({ data: 'data', isLoading: false })
})
it('calls onCancel when the component is unmounted and the request is still pending', async () => {
const asyncCallback = jest.fn().mockResolvedValue('data')
const onCancel = jest.fn()
const { unmount } = renderHook(() => useAsyncData(asyncCallback, onCancel))
await act(() => {
unmount()
})
expect(onCancel).toHaveBeenCalled()
})
it("doesn't call onCancel when the component is unmounted and the request is not pending", async () => {
const asyncCallback = jest.fn().mockResolvedValue('data')
const onCancel = jest.fn()
const { unmount, rerender, waitForNextUpdate } = renderHook(() =>
useAsyncData(asyncCallback, onCancel)
)
await act(async () => {
rerender()
await waitForNextUpdate()
unmount()
})
expect(onCancel).not.toHaveBeenCalled()
})
it("doesn't cancel the callback when the hook is passed the same callback", async () => {
// Long async callback that won't finish before the new callback is passed
const initialCallback = jest.fn()
const cancel = jest.fn()
const { rerender, waitForNextUpdate } = renderHook(
({ asyncCallback, onCancel }) => useAsyncData(asyncCallback, onCancel),
{
initialProps: { asyncCallback: initialCallback, onCancel: cancel },
}
)
expect(initialCallback).toHaveBeenCalledTimes(1)
expect(cancel).not.toHaveBeenCalled()
await act(async () => {
rerender({ asyncCallback: initialCallback, onCancel: cancel })
await waitForNextUpdate()
})
// When the hook rerenders and the same callback is passed, it should not cancel the previous one
expect(initialCallback).toHaveBeenCalledTimes(1)
expect(cancel).not.toHaveBeenCalled()
})
it('cancels the old callback and calls the new one when the callback attribute changes', async () => {
// Long async callback that won't finish before the new callback is passed
const initialCallback = jest.fn().mockImplementation(() => promise)
const cancel = jest.fn()
const { rerender, waitForNextUpdate } = renderHook(
({ asyncCallback, onCancel }) => useAsyncData(asyncCallback, onCancel),
{
initialProps: { asyncCallback: initialCallback, onCancel: cancel },
}
)
const newCallback = jest.fn().mockResolvedValue('data')
expect(initialCallback).toHaveBeenCalledTimes(1)
expect(cancel).toHaveBeenCalledTimes(0)
await act(async () => {
rerender({ asyncCallback: newCallback, onCancel: cancel })
await waitForNextUpdate()
})
expect(initialCallback).toHaveBeenCalledTimes(1) // No more calls, the one previous remains
expect(newCallback).toHaveBeenCalledTimes(1)
expect(cancel).toHaveBeenCalledTimes(1)
})
it("doesn't cause additional re-renders when the callback attribute changes", async () => {
// Long async callback that won't finish before the new callback is passed
const onCancel = jest.fn().mockImplementation(() => promise)
let rendersCount = 0
const fn1 = jest.fn().mockImplementation(() => promise)
const { rerender, result, waitForNextUpdate } = renderHook(() => {
rendersCount += 1
return useAsyncData(fn1, onCancel)
})
expect(rendersCount).toBe(1)
const fn2 = jest.fn().mockImplementation(() => promise)
await act(async () => {
rerender({ asyncCallback: fn2, onCancel })
await waitForNextUpdate()
})
// The hook should only re-render because of the new async callback
// (it shouldn't update internal state to indicate loading)
expect(rendersCount).toBe(2)
expect(result.current).toEqual({ data: undefined, isLoading: true })
})
})
describe('useMemoCompare', () => {
it('returns the same value when the comparison function returns true', () => {
const initialValue = { a: 1 }
const { result, rerender } = renderHook(
(props) =>
useMemoCompare(
() => props,
() => true
),
{
initialProps: initialValue,
}
)
rerender({ a: 1 })
expect(result.current).toBe(initialValue) // Check that the reference is the same as the initial value
})
it('returns the new value when the comparison function returns false', () => {
const { result, rerender } = renderHook(
(props) =>
useMemoCompare(
() => props,
() => false
),
{
initialProps: { a: 1 },
}
)
const newValue = { a: 2 }
rerender(newValue)
expect(result.current).toEqual(newValue) // Check that the reference is the same as the new value
})
})
describe('useForwardRef', () => {
it('should forward localRef properties into forwardedRef', async () => {
const fn1 = jest.fn()
const refData = { prop1: 'value1', prop2: 'value2', fn1 }
type RefType = typeof refData
const TestComponent = forwardRef<RefType>(function TestComponent(_, ref) {
const localRef = useRef<RefType>(refData)
useForwardRef(ref, localRef)
return null
})
const forwardedRef = createRef<RefType>()
await act(async () => {
render(<TestComponent ref={forwardedRef} />)
})
// Now check if forwardRef has the properties from the initial object
expect(forwardedRef.current?.prop1).toEqual('value1')
expect(forwardedRef.current?.prop2).toEqual('value2')
expect(forwardedRef.current?.fn1).toEqual(fn1)
})
})
...@@ -15,8 +15,6 @@ export function usePrevious<T>(value: T): T | undefined { ...@@ -15,8 +15,6 @@ export function usePrevious<T>(value: T): T | undefined {
return ref.current return ref.current
} }
// adapted from https://usehooks.com/useAsync/ but simplified
// above link contains example on how to add delayed execution if ever needed
export function useAsyncData<T>( export function useAsyncData<T>(
asyncCallback: () => Promise<T> | undefined, asyncCallback: () => Promise<T> | undefined,
onCancel?: () => void onCancel?: () => void
...@@ -25,65 +23,55 @@ export function useAsyncData<T>( ...@@ -25,65 +23,55 @@ export function useAsyncData<T>(
data: T | undefined data: T | undefined
} { } {
const [state, setState] = useState<{ const [state, setState] = useState<{
data: { data: T | undefined
res: T | undefined
input: () => Promise<T> | undefined
}
isLoading: boolean isLoading: boolean
}>({ }>({
data: { data: undefined,
res: undefined,
input: asyncCallback,
},
isLoading: true, isLoading: true,
}) })
const onCancelRef = useRef(onCancel)
const isMountedRef = useRef(true) const lastCompletedAsyncCallbackRef = useRef(asyncCallback)
useEffect(() => { useEffect(() => {
return () => { let isPending = false
isMountedRef.current = false
}
}, [])
useEffect(() => { async function runCallback(): Promise<void> {
if (!state.isLoading) { isPending = true
setState((currentState) => ({ ...currentState, isLoading: true })) const data = await asyncCallback()
if (isPending) {
setState((prevState) => ({ ...prevState, data, isLoading: false }))
}
} }
let isCancelled = false runCallback()
.catch(() => {
async function runCallback(): Promise<void> { if (isPending) {
const res = await asyncCallback() setState((prevState) => ({ ...prevState, isLoading: false }))
// Prevent setting state if the component has unmounted (prevents memory leaks)
if (!isMountedRef.current) return
// Prevent setting state if the request was cancelled
if (isCancelled) return
setState({
isLoading: false,
data: {
res,
input: asyncCallback,
},
})
} }
})
.finally(() => {
isPending = false
lastCompletedAsyncCallbackRef.current = asyncCallback
})
runCallback().catch(() => undefined) const handleCancel = onCancelRef.current
return () => { return () => {
isCancelled = true if (!isPending) return
onCancel?.() isPending = false
if (handleCancel) {
handleCancel()
}
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [asyncCallback]) }, [asyncCallback])
return useMemo(() => { return useMemo(() => {
if (asyncCallback !== state.data.input) return { isLoading: true, data: undefined } if (asyncCallback !== lastCompletedAsyncCallbackRef.current) {
return { isLoading: true, data: undefined }
return { isLoading: state.isLoading, data: state.data.res } }
}, [asyncCallback, state.isLoading, state.data]) return state
}, [asyncCallback, state])
} }
// modified from https://usehooks.com/useMemoCompare/ // modified from https://usehooks.com/useMemoCompare/
export function useMemoCompare<T>(next: () => T, compare: (a: T | undefined, b: T) => boolean): T { export function useMemoCompare<T>(next: () => T, compare: (a: T | undefined, b: T) => boolean): T {
// Ref for storing previous value // Ref for storing previous value
...@@ -98,11 +86,9 @@ export function useMemoCompare<T>(next: () => T, compare: (a: T | undefined, b: ...@@ -98,11 +86,9 @@ export function useMemoCompare<T>(next: () => T, compare: (a: T | undefined, b:
// If not equal update previousRef to next value. // If not equal update previousRef to next value.
// We only update if not equal so that this hook continues to return // We only update if not equal so that this hook continues to return
// the same old value if compare keeps returning true. // the same old value if compare keeps returning true.
useEffect(() => { if (!isEqual || !previous) {
if (!isEqual) {
previousRef.current = nextValue previousRef.current = nextValue
} }
})
// Finally, if equal then return the previous value if it's set // Finally, if equal then return the previous value if it's set
return isEqual && previous ? previous : nextValue return isEqual && previous ? previous : nextValue
......
import { currentTimeInSeconds, inXMinutesUnix, isStale } from './time'
jest.useFakeTimers()
describe('isStale', () => {
it('returns true if lastUpdated is null', () => {
expect(isStale(null, 1000)).toBe(true)
})
it('returns true if the lastUpdated timestamp is older than the staleTime', () => {
const now = Date.now()
jest.spyOn(Date, 'now').mockReturnValue(now)
const lastUpdated = now - 2000
const staleTime = 1000
expect(isStale(lastUpdated, staleTime)).toBe(true)
})
it('returns false if the lastUpdated timestamp is newer than the staleTime', () => {
const now = Date.now()
jest.spyOn(Date, 'now').mockReturnValue(now)
const lastUpdated = now - 500
const staleTime = 1000
expect(isStale(lastUpdated, staleTime)).toBe(false)
})
it('returns false if the lastUpdated timestamp is equal to the staleTime', () => {
const now = Date.now()
jest.spyOn(Date, 'now').mockReturnValue(now)
const lastUpdated = now - 1000
const staleTime = 1000
expect(isStale(lastUpdated, staleTime)).toBe(false)
})
})
describe('currentTimeInSeconds', () => {
it('returns the current time in seconds', () => {
const now = Date.now()
jest.setSystemTime(now) // Ensures that dayjs and Date.now() return the same value
expect(currentTimeInSeconds()).toBe(Math.floor(now / 1000))
})
})
describe('inXMinutesUnix', () => {
it('returns current time advanced by x minutes in seconds', () => {
const now = Date.now()
jest.setSystemTime(now) // Ensures that dayjs and Date.now() return the same value
expect(inXMinutesUnix(5)).toBe(Math.floor((now + 5 * 60 * 1000) / 1000))
})
})
import { renderHook } from '@testing-library/react-hooks' import { renderHook } from '@testing-library/react-hooks'
import { act } from 'react-test-renderer' import { act } from 'react-test-renderer'
import { DEFAULT_DELAY, useDebounceWithStatus } from './timing' import {
DEFAULT_DELAY,
promiseMinDelay,
promiseTimeout,
useDebounceWithStatus,
useInterval,
useTimeout,
} from './timing'
jest.useFakeTimers() jest.useFakeTimers()
const timedPromise = (duration: number, shouldResolve = true): Promise<string> =>
new Promise((resolve, reject) =>
setTimeout(() => (shouldResolve ? resolve('resolve') : reject(new Error('reject'))), duration)
)
describe('promiseTimeout', () => {
it("returns null if the provided promise doesn't resolve or reject in time", async () => {
const promise = promiseTimeout(timedPromise(2000), 1000) // 2 seconds promise with 1 second timeout
jest.advanceTimersByTime(2000)
const result = await promise
expect(result).toBeNull()
})
it('returns the result of the provided promise if it resolves in time', async () => {
const promise = promiseTimeout(timedPromise(500), 1000) // 0.5 seconds promise with 1 second timeout
jest.advanceTimersByTime(1000)
const result = await promise
expect(result).toBe('resolve')
})
it('rejects if the provided promise rejects in time', async () => {
const promise = promiseTimeout(timedPromise(500, false), 1000) // 0.5 seconds promise with 1 second timeout
jest.advanceTimersByTime(1000)
await expect(promise).rejects.toThrow('reject')
})
})
describe('promiseMinDelay', () => {
it('returns result only after specified minimum delay time', async () => {
const promise = promiseMinDelay(timedPromise(500), 1000) // 0.5 seconds promise with 1 second min delay
jest.advanceTimersByTime(999)
const stillPending = 'still pending'
const promiseOrNull = Promise.race([Promise.resolve(stillPending), promise])
expect(await promiseOrNull).toBe(stillPending) // Shouldn't have resolved yet
jest.advanceTimersByTime(1)
const result = await promise
// Now should have resolved because 1000 ms have passed
expect(result).toBe('resolve')
})
it('returns result after the promise resolves if it resolves after the minimum timeout', async () => {
const promise = promiseMinDelay(timedPromise(2000), 1000) // 2 seconds promise with 1 second min delay
jest.advanceTimersByTime(1999)
const stillPending = 'still pending'
const promiseOrNull = Promise.race([Promise.resolve(stillPending), promise])
expect(await promiseOrNull).toBe(stillPending) // Shouldn't have resolved yet
jest.advanceTimersByTime(1)
const result = await promise
// Now should have resolved because the promise resolved after 2000 ms
expect(result).toBe('resolve')
})
it('rejects if the promise rejects before the minimum timeout', async () => {
const promise = promiseMinDelay(timedPromise(500, false), 1000) // 0.5 seconds promise with 1 second min delay
jest.advanceTimersByTime(1000)
await expect(promise).rejects.toThrow('reject')
})
it('rejects if the promise rejects after the minimum timeout', async () => {
const promise = promiseMinDelay(timedPromise(2000, false), 1000) // 2 seconds promise with 1 second min delay
jest.advanceTimersByTime(2000)
await expect(promise).rejects.toThrow('reject')
})
})
describe('useInterval', () => {
it('calls the callback with the specified interval', () => {
const callback = jest.fn()
renderHook(() => useInterval(callback, 1000))
expect(callback).not.toHaveBeenCalled()
jest.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalledTimes(1)
jest.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalledTimes(2)
})
it("doesn't call the callback if the delay is null", () => {
const callback = jest.fn()
renderHook(() => useInterval(callback, null))
expect(callback).not.toHaveBeenCalled()
jest.advanceTimersByTime(1000)
expect(callback).not.toHaveBeenCalled()
})
it('calls the callback immediately if immediateStart is true', () => {
const callback = jest.fn()
renderHook(() => useInterval(callback, 1000, true))
expect(callback).toHaveBeenCalledTimes(1)
jest.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalledTimes(2)
})
})
describe('useTimeout', () => {
it('calls the callback after the specified delay', () => {
const callback = jest.fn()
renderHook(() => useTimeout(callback, 1000))
expect(callback).not.toHaveBeenCalled()
jest.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalledTimes(1)
})
it('calls the timeout with the 0ms delay if no delay is specified', () => {
const callback = jest.fn()
renderHook(() => useTimeout(callback))
expect(callback).not.toHaveBeenCalled()
jest.advanceTimersByTime(0)
expect(callback).toHaveBeenCalledTimes(1)
})
})
describe('useDebounceWithStatus', () => { describe('useDebounceWithStatus', () => {
it('correctly delays updating the value', async () => { it('correctly delays updating the value', async () => {
let value = 'first' let value = 'first'
......
...@@ -17,6 +17,7 @@ import { ...@@ -17,6 +17,7 @@ import {
UNISWAP_API_BASE_URL, UNISWAP_API_BASE_URL,
UNISWAP_API_KEY, UNISWAP_API_KEY,
UNISWAP_APP_URL, UNISWAP_APP_URL,
UNITAGS_API_URL,
WALLETCONNECT_PROJECT_ID, WALLETCONNECT_PROJECT_ID,
} from 'react-native-dotenv' } from 'react-native-dotenv'
...@@ -40,6 +41,7 @@ export interface Config { ...@@ -40,6 +41,7 @@ export interface Config {
statSigProxyUrl: string statSigProxyUrl: string
walletConnectProjectId: string walletConnectProjectId: string
quicknodeBnbRpcUrl: string quicknodeBnbRpcUrl: string
unitagsApiUrl: string
} }
const _config: Config = { const _config: Config = {
...@@ -62,6 +64,7 @@ const _config: Config = { ...@@ -62,6 +64,7 @@ const _config: Config = {
statSigProxyUrl: process.env.STATSIG_PROXY_URL || STATSIG_PROXY_URL, statSigProxyUrl: process.env.STATSIG_PROXY_URL || STATSIG_PROXY_URL,
walletConnectProjectId: process.env.WALLETCONNECT_PROJECT_ID || WALLETCONNECT_PROJECT_ID, walletConnectProjectId: process.env.WALLETCONNECT_PROJECT_ID || WALLETCONNECT_PROJECT_ID,
quicknodeBnbRpcUrl: process.env.QUICKNODE_BNB_RPC_URL || QUICKNODE_BNB_RPC_URL, quicknodeBnbRpcUrl: process.env.QUICKNODE_BNB_RPC_URL || QUICKNODE_BNB_RPC_URL,
unitagsApiUrl: process.env.UNITAGS_API_URL || UNITAGS_API_URL,
} }
export const config = Object.freeze(_config) export const config = Object.freeze(_config)
......
...@@ -30,6 +30,7 @@ export const uniswapUrls = { ...@@ -30,6 +30,7 @@ export const uniswapUrls = {
privacyPolicyUrl: 'https://uniswap.org/privacy-policy', privacyPolicyUrl: 'https://uniswap.org/privacy-policy',
appUrl: `https://${UNISWAP_APP_HOSTNAME}`, appUrl: `https://${UNISWAP_APP_HOSTNAME}`,
interfaceUrl: `https://${UNISWAP_APP_HOSTNAME}/#/swap`, interfaceUrl: `https://${UNISWAP_APP_HOSTNAME}/#/swap`,
unitagsApiUrl: getUnitagsApiUrl(),
} }
function getUniswapApiBaseUrl(): string { function getUniswapApiBaseUrl(): string {
...@@ -59,3 +60,7 @@ function getUniswapAmplitudeProxyUrl(): string { ...@@ -59,3 +60,7 @@ function getUniswapAmplitudeProxyUrl(): string {
function getUniswapStatsigProxyUrl(): string { function getUniswapStatsigProxyUrl(): string {
return `${config.uniswapApiBaseUrl}/v1/statsig-proxy` return `${config.uniswapApiBaseUrl}/v1/statsig-proxy`
} }
function getUnitagsApiUrl(): string {
return config.unitagsApiUrl
}
"""This directive allows results to be deferred during execution""" """This directive allows results to be deferred during execution"""
directive @defer on FIELD directive @defer on FIELD
"""Tells the service which mutation triggers this subscription."""
directive @aws_subscribe(
"""
List of mutations which will trigger this subscription when they are called.
"""
mutations: [String]
) on FIELD_DEFINITION
""" """
Tells the service this field/object has access authorized by an OIDC token. Tells the service this field/object has access authorized by a Cognito User Pools token.
""" """
directive @aws_oidc on OBJECT | FIELD_DEFINITION directive @aws_cognito_user_pools(
"""List of cognito user pool groups which have access on this field"""
cognito_groups: [String]
) on OBJECT | FIELD_DEFINITION
""" """
Tells the service which subscriptions will be published to when this mutation is Tells the service which subscriptions will be published to when this mutation is
...@@ -25,34 +20,39 @@ directive @aws_publish( ...@@ -25,34 +20,39 @@ directive @aws_publish(
subscriptions: [String] subscriptions: [String]
) on FIELD_DEFINITION ) on FIELD_DEFINITION
"""Directs the schema to enforce authorization on a field"""
directive @aws_auth(
"""List of cognito user pool groups which have access on this field"""
cognito_groups: [String]
) on FIELD_DEFINITION
""" """
Tells the service this field/object has access authorized by a Lambda Authorizer. Tells the service this field/object has access authorized by a Lambda Authorizer.
""" """
directive @aws_lambda on OBJECT | FIELD_DEFINITION directive @aws_lambda on OBJECT | FIELD_DEFINITION
""" """Tells the service which mutation triggers this subscription."""
Tells the service this field/object has access authorized by an API key. directive @aws_subscribe(
""" """
directive @aws_api_key on OBJECT | FIELD_DEFINITION List of mutations which will trigger this subscription when they are called.
"""
mutations: [String]
) on FIELD_DEFINITION
""" """
Tells the service this field/object has access authorized by a Cognito User Pools token. Tells the service this field/object has access authorized by an OIDC token.
""" """
directive @aws_cognito_user_pools( directive @aws_oidc on OBJECT | FIELD_DEFINITION
"""List of cognito user pool groups which have access on this field"""
cognito_groups: [String]
) on OBJECT | FIELD_DEFINITION
""" """
Tells the service this field/object has access authorized by sigv4 signing. Tells the service this field/object has access authorized by sigv4 signing.
""" """
directive @aws_iam on OBJECT | FIELD_DEFINITION directive @aws_iam on OBJECT | FIELD_DEFINITION
"""Directs the schema to enforce authorization on a field""" """
directive @aws_auth( Tells the service this field/object has access authorized by an API key.
"""List of cognito user pool groups which have access on this field""" """
cognito_groups: [String] directive @aws_api_key on OBJECT | FIELD_DEFINITION
) on FIELD_DEFINITION
union ActivityDetails = TransactionDetails | SwapOrderDetails union ActivityDetails = TransactionDetails | SwapOrderDetails
...@@ -729,11 +729,26 @@ type Portfolio { ...@@ -729,11 +729,26 @@ type Portfolio {
assetActivities(page: Int, pageSize: Int, includeOffChain: Boolean, chains: [Chain!], _fs: AssetActivitySwitch): [AssetActivity] assetActivities(page: Int, pageSize: Int, includeOffChain: Boolean, chains: [Chain!], _fs: AssetActivitySwitch): [AssetActivity]
} }
""" Specify how the portfolio value should be calculated for each `ownerAddress`.
"""
input PortfolioValueModifier {
ownerAddress: String!
tokenIncludeOverrides: [ContractInput!]
tokenExcludeOverrides: [ContractInput!]
includeSmallBalances: Boolean
includeSpamTokens: Boolean
}
enum PriceSource { enum PriceSource {
SUBGRAPH_V2 SUBGRAPH_V2
SUBGRAPH_V3 SUBGRAPH_V3
} }
enum ProtocolVersion {
V2
V3
}
type PushNotification { type PushNotification {
id: ID! id: ID!
contents: AWSJSON! contents: AWSJSON!
...@@ -743,6 +758,8 @@ type PushNotification { ...@@ -743,6 +758,8 @@ type PushNotification {
} }
type Query { type Query {
""" returns top pools by total value locked"""
topV3Pools(chain: Chain!, first: Int!, tvlCursor: Float): [V3Pool!]
convert(fromAmount: AmountInput!, toCurrency: Currency!): Amount convert(fromAmount: AmountInput!, toCurrency: Currency!): Amount
tokens(contracts: [ContractInput!]!): [Token] tokens(contracts: [ContractInput!]!): [Token]
...@@ -754,7 +771,7 @@ type Query { ...@@ -754,7 +771,7 @@ type Query {
token(chain: Chain!, address: String): Token token(chain: Chain!, address: String): Token
tokenProjects(contracts: [ContractInput!]!): [TokenProject] tokenProjects(contracts: [ContractInput!]!): [TokenProject]
searchTokens(searchQuery: String!, chains: [Chain!]): [Token] searchTokens(searchQuery: String!, chains: [Chain!]): [Token]
portfolios(ownerAddresses: [String!]!, chains: [Chain!], lookupTokens: [ContractInput!]): [Portfolio] portfolios(ownerAddresses: [String!]!, chains: [Chain!], lookupTokens: [ContractInput!], valueModifiers: [PortfolioValueModifier!]): [Portfolio]
topTokens(chain: Chain, page: Int, pageSize: Int, orderBy: TokenSortableField): [Token] topTokens(chain: Chain, page: Int, pageSize: Int, orderBy: TokenSortableField): [Token]
topCollections(chains: [Chain!], orderBy: CollectionSortableField, duration: HistoryDuration, after: String, first: Int, cursor: String, limit: Int): NftCollectionConnection topCollections(chains: [Chain!], orderBy: CollectionSortableField, duration: HistoryDuration, after: String, first: Int, cursor: String, limit: Int): NftCollectionConnection
nftAssets(chain: Chain, address: String!, orderBy: NftAssetSortableField, asc: Boolean, filter: NftAssetsFilterInput, after: String, first: Int, before: String, last: Int): NftAssetConnection nftAssets(chain: Chain, address: String!, orderBy: NftAssetSortableField, asc: Boolean, filter: NftAssetsFilterInput, after: String, first: Int, before: String, last: Int): NftAssetConnection
...@@ -794,6 +811,7 @@ type SwapOrderDetails { ...@@ -794,6 +811,7 @@ type SwapOrderDetails {
inputTokenQuantity: String! inputTokenQuantity: String!
outputToken: Token! outputToken: Token!
outputTokenQuantity: String! outputTokenQuantity: String!
expiry: Int!
} }
enum SwapOrderStatus { enum SwapOrderStatus {
...@@ -863,6 +881,7 @@ type TokenBalance { ...@@ -863,6 +881,7 @@ type TokenBalance {
ownerAddress: String! ownerAddress: String!
token: Token token: Token
tokenProjectMarket: TokenProjectMarket tokenProjectMarket: TokenProjectMarket
isHidden: Boolean
} }
input TokenInput { input TokenInput {
...@@ -878,6 +897,7 @@ type TokenMarket { ...@@ -878,6 +897,7 @@ type TokenMarket {
price: Amount price: Amount
priceSource: PriceSource! priceSource: PriceSource!
totalValueLocked: Amount totalValueLocked: Amount
fullyDilutedValuation: Amount
volume(duration: HistoryDuration!): Amount volume(duration: HistoryDuration!): Amount
pricePercentChange(duration: HistoryDuration!): Amount pricePercentChange(duration: HistoryDuration!): Amount
priceHistory(duration: HistoryDuration!): [TimestampedAmount] priceHistory(duration: HistoryDuration!): [TimestampedAmount]
...@@ -1048,3 +1068,19 @@ enum TransactionType { ...@@ -1048,3 +1068,19 @@ enum TransactionType {
WITHDRAW WITHDRAW
} }
type V3Pool {
id: ID!
protocolVersion: ProtocolVersion!
chain: Chain!
address: String!
createdAtTimestamp: Int
totalLiquidity: Amount
feeTier: Float
token0: Token
token0Supply: Float
token1: Token
token1Supply: Float
txCount: Int
cumulativeVolume(duration: HistoryDuration): Amount
}
...@@ -801,11 +801,25 @@ export type PortfolioTokensTotalDenominatedValueChangeArgs = { ...@@ -801,11 +801,25 @@ export type PortfolioTokensTotalDenominatedValueChangeArgs = {
duration?: InputMaybe<HistoryDuration>; duration?: InputMaybe<HistoryDuration>;
}; };
/** Specify how the portfolio value should be calculated for each `ownerAddress`. */
export type PortfolioValueModifier = {
includeSmallBalances?: InputMaybe<Scalars['Boolean']>;
includeSpamTokens?: InputMaybe<Scalars['Boolean']>;
ownerAddress: Scalars['String'];
tokenExcludeOverrides?: InputMaybe<Array<ContractInput>>;
tokenIncludeOverrides?: InputMaybe<Array<ContractInput>>;
};
export enum PriceSource { export enum PriceSource {
SubgraphV2 = 'SUBGRAPH_V2', SubgraphV2 = 'SUBGRAPH_V2',
SubgraphV3 = 'SUBGRAPH_V3' SubgraphV3 = 'SUBGRAPH_V3'
} }
export enum ProtocolVersion {
V2 = 'V2',
V3 = 'V3'
}
export type PushNotification = { export type PushNotification = {
__typename?: 'PushNotification'; __typename?: 'PushNotification';
contents: Scalars['AWSJSON']; contents: Scalars['AWSJSON'];
...@@ -836,6 +850,8 @@ export type Query = { ...@@ -836,6 +850,8 @@ export type Query = {
tokens?: Maybe<Array<Maybe<Token>>>; tokens?: Maybe<Array<Maybe<Token>>>;
topCollections?: Maybe<NftCollectionConnection>; topCollections?: Maybe<NftCollectionConnection>;
topTokens?: Maybe<Array<Maybe<Token>>>; topTokens?: Maybe<Array<Maybe<Token>>>;
/** returns top pools by total value locked */
topV3Pools?: Maybe<Array<V3Pool>>;
transactionNotification?: Maybe<TransactionNotification>; transactionNotification?: Maybe<TransactionNotification>;
}; };
...@@ -908,6 +924,7 @@ export type QueryPortfoliosArgs = { ...@@ -908,6 +924,7 @@ export type QueryPortfoliosArgs = {
chains?: InputMaybe<Array<Chain>>; chains?: InputMaybe<Array<Chain>>;
lookupTokens?: InputMaybe<Array<ContractInput>>; lookupTokens?: InputMaybe<Array<ContractInput>>;
ownerAddresses: Array<Scalars['String']>; ownerAddresses: Array<Scalars['String']>;
valueModifiers?: InputMaybe<Array<PortfolioValueModifier>>;
}; };
...@@ -952,6 +969,13 @@ export type QueryTopTokensArgs = { ...@@ -952,6 +969,13 @@ export type QueryTopTokensArgs = {
}; };
export type QueryTopV3PoolsArgs = {
chain: Chain;
first: Scalars['Int'];
tvlCursor?: InputMaybe<Scalars['Float']>;
};
export type QueryTransactionNotificationArgs = { export type QueryTransactionNotificationArgs = {
address: Scalars['String']; address: Scalars['String'];
chain: Chain; chain: Chain;
...@@ -988,6 +1012,7 @@ export enum SubscriptionType { ...@@ -988,6 +1012,7 @@ export enum SubscriptionType {
export type SwapOrderDetails = { export type SwapOrderDetails = {
__typename?: 'SwapOrderDetails'; __typename?: 'SwapOrderDetails';
expiry: Scalars['Int'];
hash: Scalars['String']; hash: Scalars['String'];
id: Scalars['ID']; id: Scalars['ID'];
inputToken: Token; inputToken: Token;
...@@ -1070,6 +1095,7 @@ export type TokenBalance = { ...@@ -1070,6 +1095,7 @@ export type TokenBalance = {
blockTimestamp?: Maybe<Scalars['Int']>; blockTimestamp?: Maybe<Scalars['Int']>;
denominatedValue?: Maybe<Amount>; denominatedValue?: Maybe<Amount>;
id: Scalars['ID']; id: Scalars['ID'];
isHidden?: Maybe<Scalars['Boolean']>;
ownerAddress: Scalars['String']; ownerAddress: Scalars['String'];
quantity?: Maybe<Scalars['Float']>; quantity?: Maybe<Scalars['Float']>;
token?: Maybe<Token>; token?: Maybe<Token>;
...@@ -1085,6 +1111,7 @@ export type TokenInput = { ...@@ -1085,6 +1111,7 @@ export type TokenInput = {
export type TokenMarket = { export type TokenMarket = {
__typename?: 'TokenMarket'; __typename?: 'TokenMarket';
fullyDilutedValuation?: Maybe<Amount>;
id: Scalars['ID']; id: Scalars['ID'];
price?: Maybe<Amount>; price?: Maybe<Amount>;
priceHighLow?: Maybe<Amount>; priceHighLow?: Maybe<Amount>;
...@@ -1310,6 +1337,28 @@ export enum TransactionType { ...@@ -1310,6 +1337,28 @@ export enum TransactionType {
Withdraw = 'WITHDRAW' Withdraw = 'WITHDRAW'
} }
export type V3Pool = {
__typename?: 'V3Pool';
address: Scalars['String'];
chain: Chain;
createdAtTimestamp?: Maybe<Scalars['Int']>;
cumulativeVolume?: Maybe<Amount>;
feeTier?: Maybe<Scalars['Float']>;
id: Scalars['ID'];
protocolVersion: ProtocolVersion;
token0?: Maybe<Token>;
token0Supply?: Maybe<Scalars['Float']>;
token1?: Maybe<Token>;
token1Supply?: Maybe<Scalars['Float']>;
totalLiquidity?: Maybe<Amount>;
txCount?: Maybe<Scalars['Int']>;
};
export type V3PoolCumulativeVolumeArgs = {
duration?: InputMaybe<HistoryDuration>;
};
export type TokenPriceHistoryQueryVariables = Exact<{ export type TokenPriceHistoryQueryVariables = Exact<{
contract: ContractInput; contract: ContractInput;
duration?: InputMaybe<HistoryDuration>; duration?: InputMaybe<HistoryDuration>;
...@@ -1320,6 +1369,7 @@ export type TokenPriceHistoryQuery = { __typename?: 'Query', tokenProjects?: Arr ...@@ -1320,6 +1369,7 @@ export type TokenPriceHistoryQuery = { __typename?: 'Query', tokenProjects?: Arr
export type AccountListQueryVariables = Exact<{ export type AccountListQueryVariables = Exact<{
addresses: Array<Scalars['String']> | Scalars['String']; addresses: Array<Scalars['String']> | Scalars['String'];
valueModifiers?: InputMaybe<Array<PortfolioValueModifier> | PortfolioValueModifier>;
}>; }>;
...@@ -1372,17 +1422,19 @@ export type NftsTabQuery = { __typename?: 'Query', nftBalances?: { __typename?: ...@@ -1372,17 +1422,19 @@ export type NftsTabQuery = { __typename?: 'Query', nftBalances?: { __typename?:
export type PortfolioBalancesQueryVariables = Exact<{ export type PortfolioBalancesQueryVariables = Exact<{
ownerAddress: Scalars['String']; ownerAddress: Scalars['String'];
valueModifiers?: InputMaybe<Array<PortfolioValueModifier> | PortfolioValueModifier>;
}>; }>;
export type PortfolioBalancesQuery = { __typename?: 'Query', portfolios?: Array<{ __typename?: 'Portfolio', id: string, tokensTotalDenominatedValue?: { __typename?: 'Amount', value: number } | null, tokensTotalDenominatedValueChange?: { __typename?: 'AmountChange', absolute?: { __typename?: 'Amount', value: number } | null, percentage?: { __typename?: 'Amount', value: number } | null } | null, tokenBalances?: Array<{ __typename?: 'TokenBalance', id: string, quantity?: number | null, denominatedValue?: { __typename?: 'Amount', currency?: Currency | null, value: number } | null, token?: { __typename?: 'Token', chain: Chain, address?: string | null, symbol?: string | null, decimals?: number | null, project?: { __typename?: 'TokenProject', id: string, name?: string | null, logoUrl?: string | null, safetyLevel?: SafetyLevel | null, isSpam?: boolean | null } | null } | null, tokenProjectMarket?: { __typename?: 'TokenProjectMarket', relativeChange24?: { __typename?: 'Amount', value: number } | null } | null } | null> | null } | null> | null }; export type PortfolioBalancesQuery = { __typename?: 'Query', portfolios?: Array<{ __typename?: 'Portfolio', id: string, tokensTotalDenominatedValue?: { __typename?: 'Amount', value: number } | null, tokensTotalDenominatedValueChange?: { __typename?: 'AmountChange', absolute?: { __typename?: 'Amount', value: number } | null, percentage?: { __typename?: 'Amount', value: number } | null } | null, tokenBalances?: Array<{ __typename?: 'TokenBalance', id: string, quantity?: number | null, isHidden?: boolean | null, denominatedValue?: { __typename?: 'Amount', currency?: Currency | null, value: number } | null, token?: { __typename?: 'Token', chain: Chain, address?: string | null, symbol?: string | null, decimals?: number | null, project?: { __typename?: 'TokenProject', id: string, name?: string | null, logoUrl?: string | null, safetyLevel?: SafetyLevel | null, isSpam?: boolean | null } | null } | null, tokenProjectMarket?: { __typename?: 'TokenProjectMarket', relativeChange24?: { __typename?: 'Amount', value: number } | null } | null } | null> | null } | null> | null };
export type MultiplePortfolioBalancesQueryVariables = Exact<{ export type MultiplePortfolioBalancesQueryVariables = Exact<{
ownerAddresses: Array<Scalars['String']> | Scalars['String']; ownerAddresses: Array<Scalars['String']> | Scalars['String'];
valueModifiers?: InputMaybe<Array<PortfolioValueModifier> | PortfolioValueModifier>;
}>; }>;
export type MultiplePortfolioBalancesQuery = { __typename?: 'Query', portfolios?: Array<{ __typename?: 'Portfolio', id: string, tokensTotalDenominatedValue?: { __typename?: 'Amount', value: number } | null, tokensTotalDenominatedValueChange?: { __typename?: 'AmountChange', absolute?: { __typename?: 'Amount', value: number } | null, percentage?: { __typename?: 'Amount', value: number } | null } | null, tokenBalances?: Array<{ __typename?: 'TokenBalance', id: string, quantity?: number | null, denominatedValue?: { __typename?: 'Amount', currency?: Currency | null, value: number } | null, token?: { __typename?: 'Token', chain: Chain, address?: string | null, symbol?: string | null, decimals?: number | null, project?: { __typename?: 'TokenProject', id: string, name?: string | null, logoUrl?: string | null, safetyLevel?: SafetyLevel | null, isSpam?: boolean | null } | null } | null, tokenProjectMarket?: { __typename?: 'TokenProjectMarket', relativeChange24?: { __typename?: 'Amount', value: number } | null } | null } | null> | null } | null> | null }; export type MultiplePortfolioBalancesQuery = { __typename?: 'Query', portfolios?: Array<{ __typename?: 'Portfolio', id: string, tokensTotalDenominatedValue?: { __typename?: 'Amount', value: number } | null, tokensTotalDenominatedValueChange?: { __typename?: 'AmountChange', absolute?: { __typename?: 'Amount', value: number } | null, percentage?: { __typename?: 'Amount', value: number } | null } | null, tokenBalances?: Array<{ __typename?: 'TokenBalance', id: string, quantity?: number | null, isHidden?: boolean | null, denominatedValue?: { __typename?: 'Amount', currency?: Currency | null, value: number } | null, token?: { __typename?: 'Token', chain: Chain, address?: string | null, symbol?: string | null, decimals?: number | null, project?: { __typename?: 'TokenProject', id: string, name?: string | null, logoUrl?: string | null, safetyLevel?: SafetyLevel | null, isSpam?: boolean | null } | null } | null, tokenProjectMarket?: { __typename?: 'TokenProjectMarket', relativeChange24?: { __typename?: 'Amount', value: number } | null } | null } | null> | null } | null> | null };
export type SelectWalletScreenQueryVariables = Exact<{ export type SelectWalletScreenQueryVariables = Exact<{
ownerAddresses: Array<Scalars['String']> | Scalars['String']; ownerAddresses: Array<Scalars['String']> | Scalars['String'];
...@@ -1501,6 +1553,7 @@ export type ConvertQuery = { __typename?: 'Query', convert?: { __typename?: 'Amo ...@@ -1501,6 +1553,7 @@ export type ConvertQuery = { __typename?: 'Query', convert?: { __typename?: 'Amo
export type PortfolioBalanceQueryVariables = Exact<{ export type PortfolioBalanceQueryVariables = Exact<{
owner: Scalars['String']; owner: Scalars['String'];
valueModifiers?: InputMaybe<Array<PortfolioValueModifier> | PortfolioValueModifier>;
}>; }>;
...@@ -1617,10 +1670,11 @@ export type TokenPriceHistoryQueryHookResult = ReturnType<typeof useTokenPriceHi ...@@ -1617,10 +1670,11 @@ export type TokenPriceHistoryQueryHookResult = ReturnType<typeof useTokenPriceHi
export type TokenPriceHistoryLazyQueryHookResult = ReturnType<typeof useTokenPriceHistoryLazyQuery>; export type TokenPriceHistoryLazyQueryHookResult = ReturnType<typeof useTokenPriceHistoryLazyQuery>;
export type TokenPriceHistoryQueryResult = Apollo.QueryResult<TokenPriceHistoryQuery, TokenPriceHistoryQueryVariables>; export type TokenPriceHistoryQueryResult = Apollo.QueryResult<TokenPriceHistoryQuery, TokenPriceHistoryQueryVariables>;
export const AccountListDocument = gql` export const AccountListDocument = gql`
query AccountList($addresses: [String!]!) { query AccountList($addresses: [String!]!, $valueModifiers: [PortfolioValueModifier!]) {
portfolios( portfolios(
ownerAddresses: $addresses ownerAddresses: $addresses
chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB] chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]
valueModifiers: $valueModifiers
) { ) {
id id
ownerAddress ownerAddress
...@@ -1644,6 +1698,7 @@ export const AccountListDocument = gql` ...@@ -1644,6 +1698,7 @@ export const AccountListDocument = gql`
* const { data, loading, error } = useAccountListQuery({ * const { data, loading, error } = useAccountListQuery({
* variables: { * variables: {
* addresses: // value for 'addresses' * addresses: // value for 'addresses'
* valueModifiers: // value for 'valueModifiers'
* }, * },
* }); * });
*/ */
...@@ -2173,10 +2228,11 @@ export type NftsTabQueryHookResult = ReturnType<typeof useNftsTabQuery>; ...@@ -2173,10 +2228,11 @@ export type NftsTabQueryHookResult = ReturnType<typeof useNftsTabQuery>;
export type NftsTabLazyQueryHookResult = ReturnType<typeof useNftsTabLazyQuery>; export type NftsTabLazyQueryHookResult = ReturnType<typeof useNftsTabLazyQuery>;
export type NftsTabQueryResult = Apollo.QueryResult<NftsTabQuery, NftsTabQueryVariables>; export type NftsTabQueryResult = Apollo.QueryResult<NftsTabQuery, NftsTabQueryVariables>;
export const PortfolioBalancesDocument = gql` export const PortfolioBalancesDocument = gql`
query PortfolioBalances($ownerAddress: String!) { query PortfolioBalances($ownerAddress: String!, $valueModifiers: [PortfolioValueModifier!]) {
portfolios( portfolios(
ownerAddresses: [$ownerAddress] ownerAddresses: [$ownerAddress]
chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB] chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]
valueModifiers: $valueModifiers
) { ) {
id id
tokensTotalDenominatedValue { tokensTotalDenominatedValue {
...@@ -2193,6 +2249,7 @@ export const PortfolioBalancesDocument = gql` ...@@ -2193,6 +2249,7 @@ export const PortfolioBalancesDocument = gql`
tokenBalances { tokenBalances {
id id
quantity quantity
isHidden
denominatedValue { denominatedValue {
currency currency
value value
...@@ -2233,6 +2290,7 @@ export const PortfolioBalancesDocument = gql` ...@@ -2233,6 +2290,7 @@ export const PortfolioBalancesDocument = gql`
* const { data, loading, error } = usePortfolioBalancesQuery({ * const { data, loading, error } = usePortfolioBalancesQuery({
* variables: { * variables: {
* ownerAddress: // value for 'ownerAddress' * ownerAddress: // value for 'ownerAddress'
* valueModifiers: // value for 'valueModifiers'
* }, * },
* }); * });
*/ */
...@@ -2248,10 +2306,11 @@ export type PortfolioBalancesQueryHookResult = ReturnType<typeof usePortfolioBal ...@@ -2248,10 +2306,11 @@ export type PortfolioBalancesQueryHookResult = ReturnType<typeof usePortfolioBal
export type PortfolioBalancesLazyQueryHookResult = ReturnType<typeof usePortfolioBalancesLazyQuery>; export type PortfolioBalancesLazyQueryHookResult = ReturnType<typeof usePortfolioBalancesLazyQuery>;
export type PortfolioBalancesQueryResult = Apollo.QueryResult<PortfolioBalancesQuery, PortfolioBalancesQueryVariables>; export type PortfolioBalancesQueryResult = Apollo.QueryResult<PortfolioBalancesQuery, PortfolioBalancesQueryVariables>;
export const MultiplePortfolioBalancesDocument = gql` export const MultiplePortfolioBalancesDocument = gql`
query MultiplePortfolioBalances($ownerAddresses: [String!]!) { query MultiplePortfolioBalances($ownerAddresses: [String!]!, $valueModifiers: [PortfolioValueModifier!]) {
portfolios( portfolios(
ownerAddresses: $ownerAddresses ownerAddresses: $ownerAddresses
chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB] chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]
valueModifiers: $valueModifiers
) { ) {
id id
tokensTotalDenominatedValue { tokensTotalDenominatedValue {
...@@ -2268,6 +2327,7 @@ export const MultiplePortfolioBalancesDocument = gql` ...@@ -2268,6 +2327,7 @@ export const MultiplePortfolioBalancesDocument = gql`
tokenBalances { tokenBalances {
id id
quantity quantity
isHidden
denominatedValue { denominatedValue {
currency currency
value value
...@@ -2308,6 +2368,7 @@ export const MultiplePortfolioBalancesDocument = gql` ...@@ -2308,6 +2368,7 @@ export const MultiplePortfolioBalancesDocument = gql`
* const { data, loading, error } = useMultiplePortfolioBalancesQuery({ * const { data, loading, error } = useMultiplePortfolioBalancesQuery({
* variables: { * variables: {
* ownerAddresses: // value for 'ownerAddresses' * ownerAddresses: // value for 'ownerAddresses'
* valueModifiers: // value for 'valueModifiers'
* }, * },
* }); * });
*/ */
...@@ -3179,10 +3240,11 @@ export type ConvertQueryHookResult = ReturnType<typeof useConvertQuery>; ...@@ -3179,10 +3240,11 @@ export type ConvertQueryHookResult = ReturnType<typeof useConvertQuery>;
export type ConvertLazyQueryHookResult = ReturnType<typeof useConvertLazyQuery>; export type ConvertLazyQueryHookResult = ReturnType<typeof useConvertLazyQuery>;
export type ConvertQueryResult = Apollo.QueryResult<ConvertQuery, ConvertQueryVariables>; export type ConvertQueryResult = Apollo.QueryResult<ConvertQuery, ConvertQueryVariables>;
export const PortfolioBalanceDocument = gql` export const PortfolioBalanceDocument = gql`
query PortfolioBalance($owner: String!) { query PortfolioBalance($owner: String!, $valueModifiers: [PortfolioValueModifier!]) {
portfolios( portfolios(
ownerAddresses: [$owner] ownerAddresses: [$owner]
chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB] chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]
valueModifiers: $valueModifiers
) { ) {
id id
tokensTotalDenominatedValue { tokensTotalDenominatedValue {
...@@ -3213,6 +3275,7 @@ export const PortfolioBalanceDocument = gql` ...@@ -3213,6 +3275,7 @@ export const PortfolioBalanceDocument = gql`
* const { data, loading, error } = usePortfolioBalanceQuery({ * const { data, loading, error } = usePortfolioBalanceQuery({
* variables: { * variables: {
* owner: // value for 'owner' * owner: // value for 'owner'
* valueModifiers: // value for 'valueModifiers'
* }, * },
* }); * });
*/ */
......
...@@ -48,6 +48,12 @@ export function setupCache(): InMemoryCache { ...@@ -48,6 +48,12 @@ export function setupCache(): InMemoryCache {
}) })
}, },
}, },
// Ignore `valueModifiers` when caching `portfolios`.
// IMPORTANT: This assumes that `valueModifiers` are always the same when querying `portfolios` across the entire app.
portfolios: {
keyArgs: ['chains', 'ownerAddresses'],
},
}, },
}, },
Token: { Token: {
......
...@@ -9,9 +9,12 @@ import { ...@@ -9,9 +9,12 @@ import {
getOnChainBalancesFetch, getOnChainBalancesFetch,
STUB_ONCHAIN_BALANCES_ENDPOINT, STUB_ONCHAIN_BALANCES_ENDPOINT,
} from 'wallet/src/features/portfolio/api' } from 'wallet/src/features/portfolio/api'
import { isAndroid, isIOS } from 'wallet/src/utils/platform'
const REST_API_URL = uniswapUrls.apiBaseUrl const REST_API_URL = uniswapUrls.apiBaseUrl
const requestSource = isIOS ? 'uniswap-ios' : isAndroid ? 'uniswap-android' : 'uniswap-web'
// mapping from endpoint to custom fetcher, when needed // mapping from endpoint to custom fetcher, when needed
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const ENDPOINT_TO_FETCHER: Record<string, (body: any) => Promise<Response>> = { const ENDPOINT_TO_FETCHER: Record<string, (body: any) => Promise<Response>> = {
...@@ -40,6 +43,7 @@ export const getRestLink = (): ApolloLink => { ...@@ -40,6 +43,7 @@ export const getRestLink = (): ApolloLink => {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-API-KEY': config.uniswapApiKey, 'X-API-KEY': config.uniswapApiKey,
'x-request-source': requestSource,
Origin: config.uniswapAppUrl, Origin: config.uniswapAppUrl,
}, },
}) })
...@@ -56,6 +60,7 @@ export const getCustomGraphqlHttpLink = (endpoint: CustomEndpoint): ApolloLink = ...@@ -56,6 +60,7 @@ export const getCustomGraphqlHttpLink = (endpoint: CustomEndpoint): ApolloLink =
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-API-KEY': endpoint.key, 'X-API-KEY': endpoint.key,
'x-request-source': requestSource,
// TODO: [MOB-3883] remove once API gateway supports mobile origin URL // TODO: [MOB-3883] remove once API gateway supports mobile origin URL
Origin: uniswapUrls.apiBaseUrl, Origin: uniswapUrls.apiBaseUrl,
}, },
...@@ -67,6 +72,7 @@ export const getGraphqlHttpLink = (): ApolloLink => ...@@ -67,6 +72,7 @@ export const getGraphqlHttpLink = (): ApolloLink =>
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-API-KEY': config.uniswapApiKey, 'X-API-KEY': config.uniswapApiKey,
'x-request-source': requestSource,
// TODO: [MOB-3883] remove once API gateway supports mobile origin URL // TODO: [MOB-3883] remove once API gateway supports mobile origin URL
Origin: uniswapUrls.apiBaseUrl, Origin: uniswapUrls.apiBaseUrl,
}, },
......
...@@ -295,10 +295,14 @@ query NftsTab( ...@@ -295,10 +295,14 @@ query NftsTab(
} }
} }
query PortfolioBalances($ownerAddress: String!) { query PortfolioBalances(
$ownerAddress: String!
$valueModifiers: [PortfolioValueModifier!]
) {
portfolios( portfolios(
ownerAddresses: [$ownerAddress] ownerAddresses: [$ownerAddress]
chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB] chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]
valueModifiers: $valueModifiers
) { ) {
id id
# Total portfolio balance for header # Total portfolio balance for header
...@@ -318,6 +322,7 @@ query PortfolioBalances($ownerAddress: String!) { ...@@ -318,6 +322,7 @@ query PortfolioBalances($ownerAddress: String!) {
tokenBalances { tokenBalances {
id id
quantity quantity
isHidden
denominatedValue { denominatedValue {
currency currency
value value
...@@ -344,10 +349,14 @@ query PortfolioBalances($ownerAddress: String!) { ...@@ -344,10 +349,14 @@ query PortfolioBalances($ownerAddress: String!) {
} }
} }
query MultiplePortfolioBalances($ownerAddresses: [String!]!) { query MultiplePortfolioBalances(
$ownerAddresses: [String!]!
$valueModifiers: [PortfolioValueModifier!]
) {
portfolios( portfolios(
ownerAddresses: $ownerAddresses ownerAddresses: $ownerAddresses
chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB] chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]
valueModifiers: $valueModifiers
) { ) {
id id
...@@ -368,6 +377,7 @@ query MultiplePortfolioBalances($ownerAddresses: [String!]!) { ...@@ -368,6 +377,7 @@ query MultiplePortfolioBalances($ownerAddresses: [String!]!) {
tokenBalances { tokenBalances {
id id
quantity quantity
isHidden
denominatedValue { denominatedValue {
currency currency
value value
......
import { import {
ApolloClient, ApolloClient,
FetchResult,
gql, gql,
MutationHookOptions,
MutationResult,
NetworkStatus, NetworkStatus,
OperationVariables, OperationVariables,
QueryHookOptions, QueryHookOptions,
useApolloClient, useApolloClient,
useMutation,
useQuery, useQuery,
} from '@apollo/client' } from '@apollo/client'
import { useEffect, useMemo } from 'react' import { useEffect, useMemo } from 'react'
...@@ -13,8 +17,7 @@ import { ROUTING_API_PATH } from 'wallet/src/features/routing/api' ...@@ -13,8 +17,7 @@ import { ROUTING_API_PATH } from 'wallet/src/features/routing/api'
/** Wrapper around Apollo client `useQuery` that calls REST APIs */ /** Wrapper around Apollo client `useQuery` that calls REST APIs */
export function useRestQuery< export function useRestQuery<
// eslint-disable-next-line @typescript-eslint/no-explicit-any TData = unknown,
TData = any,
TVariables extends OperationVariables = OperationVariables TVariables extends OperationVariables = OperationVariables
>( >(
// Relative URL path of the endpoint // Relative URL path of the endpoint
...@@ -23,15 +26,20 @@ export function useRestQuery< ...@@ -23,15 +26,20 @@ export function useRestQuery<
variables: TVariables, variables: TVariables,
// Fields requested from the endpoint // Fields requested from the endpoint
fields: string[], fields: string[],
options: Omit< // When using `fetchPolicy: 'no-cache'`, you must omit `ttlMs`.
options:
| (Omit<
QueryHookOptions<{ data: TData }, { input: TVariables }>, QueryHookOptions<{ data: TData }, { input: TVariables }>,
'variables' | 'fetchPolicy' 'variables' | 'fetchPolicy'
> & { > & {
ttlMs: number ttlMs: number
}, })
| (Omit<QueryHookOptions<{ data: TData }, { input: TVariables }>, 'variables'> & {
fetchPolicy: 'no-cache'
ttlMs?: undefined
}),
method: 'GET' | 'POST' = 'POST', method: 'GET' | 'POST' = 'POST',
// eslint-disable-next-line @typescript-eslint/no-explicit-any client?: ApolloClient<unknown>
client?: ApolloClient<any>
): GqlResult<TData> { ): GqlResult<TData> {
const document = gql` const document = gql`
query Query($input: REST!) { query Query($input: REST!) {
...@@ -53,8 +61,9 @@ export function useRestQuery< ...@@ -53,8 +61,9 @@ export function useRestQuery<
const queryResult = useQuery(document, queryOptions) const queryResult = useQuery(document, queryOptions)
// @ts-expect-error timestamp is provided by useQuery // timestamp is injected by our `InMemoryCache` config (cache.ts)
const lastFetchedTimestamp: number | undefined = queryResult.data?.data?.timestamp const lastFetchedTimestamp = (queryResult.data?.data as undefined | { timestamp: number })
?.timestamp
const cacheExpired = getCacheExpired(lastFetchedTimestamp, options.ttlMs) const cacheExpired = getCacheExpired(lastFetchedTimestamp, options.ttlMs)
// re-export query result with easier data access // re-export query result with easier data access
...@@ -77,9 +86,76 @@ export function useRestQuery< ...@@ -77,9 +86,76 @@ export function useRestQuery<
return result return result
} }
const getCacheExpired = (lastFetchedTimestamp: number | undefined, ttl: number): boolean => { const getCacheExpired = (
lastFetchedTimestamp: number | undefined,
ttl: number | undefined
): boolean => {
// if there is no timestamp, then it's the first ever query and there is no cache to expire // if there is no timestamp, then it's the first ever query and there is no cache to expire
if (!lastFetchedTimestamp) return false if (!lastFetchedTimestamp) return false
// if there's no ttl, we just use apollo's fetch-policy and we do not want to manually refetch
if (ttl === undefined) return false
return Date.now() - lastFetchedTimestamp > ttl return Date.now() - lastFetchedTimestamp > ttl
} }
/**
* Wrapper around Apollo client `useMutation` for making REST API calls.
*
* This function simplifies the process of executing RESTful operations such as POST, PUT, and DELETE
* through GraphQL mutations. It constructs a GraphQL mutation that integrates with the Apollo Client's `@rest` directive.
*
* @param path - The relative URL path of the REST API endpoint.
* @param fields - The fields to be returned in the response. These should match the structure of the expected REST API response.
* @param method - The HTTP method ('POST', 'PUT', 'DELETE') for the REST API call.
* @param options - Additional options for the mutation, excluding 'variables'.
* @param client - An optional ApolloClient instance. If not provided, the default client will be used.
* @returns A tuple where the first element is a simplified mutate function and the second element provides the mutation's status:
* - The mutate function directly accepts variables of type TVariables and handles the necessary structuring for the REST API call.
* - The status object includes fields such as 'data', 'loading', and 'error', providing information about the mutation execution.
*
* Example usage: `wallet/src/unitags/api.ts` and `mobile/src/feature/unitags/ChooseProfilePictureScreen.tsx`
*/
export function useRestMutation<
TData = unknown,
TVariables extends OperationVariables = OperationVariables
>(
path: string,
fields: string[],
options: Omit<MutationHookOptions<{ data: TData }, { input: TVariables }>, 'variables'>,
method: 'POST' | 'PUT' | 'DELETE',
client?: ApolloClient<object>
): [(variables: TVariables) => Promise<FetchResult<{ data: TData }>>, MutationResult<TData>] {
const document = gql`
mutation Mutation($input: REST!) {
data(input: $input) @rest(
type: "${path}Response",
path: "${path}",
method: "${method}"
) {
${fields.join('\n')}
}
}
`
const defaultClient = useApolloClient()
const mutationOptions: MutationHookOptions<{ data: TData }, { input: TVariables }> = {
...options,
client: client ?? defaultClient,
}
const [mutateFunction, mutationResult] = useMutation<{ data: TData }, { input: TVariables }>(
document,
mutationOptions
)
// Wrapper for the mutate function to simplify its usage
const wrappedMutateFunction = (variables: TVariables): ReturnType<typeof mutateFunction> => {
return mutateFunction({ variables: { input: variables } })
}
// Unwrap the response data
const modifiedMutationResult = {
...mutationResult,
data: mutationResult.data ? mutationResult.data.data : null,
}
return [wrappedMutateFunction, modifiedMutationResult]
}
import { useExperiment, useGate } from 'statsig-react-native' import {
useExperiment,
useExperimentWithExposureLoggingDisabled,
useGate,
useGateWithExposureLoggingDisabled,
} from 'statsig-react-native'
import { EXPERIMENT_NAMES, EXPERIMENT_PARAMS, FEATURE_FLAGS } from './constants' import { EXPERIMENT_NAMES, EXPERIMENT_PARAMS, FEATURE_FLAGS } from './constants'
/**
* Returns feature flag (gate) value from Statsig
*/
export function useFeatureFlag(flagName: FEATURE_FLAGS): boolean { export function useFeatureFlag(flagName: FEATURE_FLAGS): boolean {
const { value } = useGate(flagName) const { value } = useGate(flagName)
return value return value
} }
/** export function useFeatureFlagWithExposureLoggingDisabled(flagName: FEATURE_FLAGS): boolean {
* Returns if an experiment is enabled from Statsig const { value } = useGateWithExposureLoggingDisabled(flagName)
*/ return value
}
export function useExperimentEnabled(experimentName: EXPERIMENT_NAMES): boolean { export function useExperimentEnabled(experimentName: EXPERIMENT_NAMES): boolean {
return useExperiment(experimentName).config.getValue(EXPERIMENT_PARAMS.Enabled) as boolean return useExperiment(experimentName).config.getValue(EXPERIMENT_PARAMS.Enabled) as boolean
} }
export function useExperimentEnabledWithExposureLoggingDisabled(
experimentName: EXPERIMENT_NAMES
): boolean {
return useExperimentWithExposureLoggingDisabled(experimentName).config.getValue(
EXPERIMENT_PARAMS.Enabled
) as boolean
}
...@@ -210,7 +210,6 @@ export const fiatOnRampAggregatorApi = createApi({ ...@@ -210,7 +210,6 @@ export const fiatOnRampAggregatorApi = createApi({
return headers return headers
}, },
}), }),
endpoints: (builder) => ({ endpoints: (builder) => ({
fiatOnRampAggregatorCountryList: builder.query<MeldCountryPaymentMethodsResponse, void>({ fiatOnRampAggregatorCountryList: builder.query<MeldCountryPaymentMethodsResponse, void>({
query: () => query: () =>
...@@ -236,7 +235,7 @@ export const fiatOnRampAggregatorApi = createApi({ ...@@ -236,7 +235,7 @@ export const fiatOnRampAggregatorApi = createApi({
}), }),
transformResponse: (response: MeldCryptoQuoteResponse): Maybe<MeldQuote[]> => transformResponse: (response: MeldCryptoQuoteResponse): Maybe<MeldQuote[]> =>
response?.quotes, response?.quotes,
transformErrorResponse: (baseQueryReturnValue) => baseQueryReturnValue?.data, keepUnusedDataFor: 0,
}), }),
fiatOnRampAggregatorServiceProviders: builder.query<MeldServiceProvidersResponse, void>({ fiatOnRampAggregatorServiceProviders: builder.query<MeldServiceProvidersResponse, void>({
query: () => '/service-providers/details?statuses=LIVE%2CRECENTLY_ADDED', query: () => '/service-providers/details?statuses=LIVE%2CRECENTLY_ADDED',
...@@ -251,7 +250,7 @@ export const fiatOnRampAggregatorApi = createApi({ ...@@ -251,7 +250,7 @@ export const fiatOnRampAggregatorApi = createApi({
// get all crypto currencies from all service providers that support the given fiat currency in the given country // get all crypto currencies from all service providers that support the given fiat currency in the given country
Object.values( Object.values(
response.reduce((acc: Record<string, MeldCryptoCurrency>, serviceProvider) => { response.reduce((acc: Record<string, MeldCryptoCurrency>, serviceProvider) => {
if (serviceProvider.crypto?.onRamp) return acc if (!serviceProvider.crypto?.onRamp) return acc
const { countries, cryptoCurrencies } = serviceProvider.crypto.onRamp const { countries, cryptoCurrencies } = serviceProvider.crypto.onRamp
if (countries.find((c) => c.countryCode === countryCode)) { if (countries.find((c) => c.countryCode === countryCode)) {
cryptoCurrencies.forEach((cryptoCurrency) => { cryptoCurrencies.forEach((cryptoCurrency) => {
......
import { getCountryFlagSvgUrl } from './meld' import { getCountryFlagSvgUrl, isMeldApiError } from './meld'
describe('getCountryFlagSvgUrl', () => { describe('getCountryFlagSvgUrl', () => {
test('should return the correct SVG URL for a given country code', () => { test('should return the correct SVG URL for a given country code', () => {
...@@ -8,3 +8,26 @@ describe('getCountryFlagSvgUrl', () => { ...@@ -8,3 +8,26 @@ describe('getCountryFlagSvgUrl', () => {
expect(result).toBe(expectedUrl) expect(result).toBe(expectedUrl)
}) })
}) })
describe('isMeldApiError', () => {
test('returns true', () => {
const error = {
data: {
code: 'INVALID_AMOUNT_TOO_LOW',
message: 'Source amount is below the minimum allowed, which is 50.00 USD',
},
}
const result = isMeldApiError(error)
expect(result).toBe(true)
})
test('returns false', () => {
const error = {
data: {
message: 'Source amount is below the minimum allowed, which is 50.00 USD',
},
}
const result = isMeldApiError(error)
expect(result).toBe(false)
})
})
...@@ -114,6 +114,27 @@ export type MeldSupportedToken = { ...@@ -114,6 +114,27 @@ export type MeldSupportedToken = {
export type MeldSupportedTokensResponse = MeldSupportedToken[] export type MeldSupportedTokensResponse = MeldSupportedToken[]
export interface MeldApiError {
data: {
code: string
message: string
requestId?: string
timestamp?: string
}
}
export function getCountryFlagSvgUrl(countryCode: string): string { export function getCountryFlagSvgUrl(countryCode: string): string {
return `https://images-country.meld.io/${countryCode}/flag.svg` return `https://images-country.meld.io/${countryCode}/flag.svg`
} }
export function isMeldApiError(error: unknown): error is MeldApiError {
return (
typeof error === 'object' &&
error != null &&
'data' in error &&
typeof error.data === 'object' &&
error.data != null &&
'code' in error.data &&
'message' in error.data
)
}
query PortfolioBalance($owner: String!) { query PortfolioBalance(
portfolios(ownerAddresses: [$owner], chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]) { $owner: String!
$valueModifiers: [PortfolioValueModifier!]
) {
portfolios(
ownerAddresses: [$owner]
chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]
valueModifiers: $valueModifiers
) {
id id
tokensTotalDenominatedValue { tokensTotalDenominatedValue {
value value
......
import { ApolloClient, from, InMemoryCache } from '@apollo/client'
import { RetryLink } from '@apollo/client/link/retry'
import { RestLink } from 'apollo-link-rest'
import { uniswapUrls } from 'wallet/src/constants/urls'
import { useRestMutation, useRestQuery } from 'wallet/src/data/rest'
import {
UnitagAddressResponse,
UnitagClaimEligibilityParams,
UnitagClaimEligibilityResponse,
UnitagClaimResponse,
UnitagClaimUsernameRequestBody,
UnitagUsernameResponse,
} from 'wallet/src/features/unitags/types'
const restLink = new RestLink({
uri: `${uniswapUrls.unitagsApiUrl}`,
})
const retryLink = new RetryLink()
const apolloClient = new ApolloClient({
link: from([retryLink, restLink]),
cache: new InMemoryCache(),
defaultOptions: {
watchQuery: {
// ensures query is returning data even if some fields errored out
errorPolicy: 'all',
fetchPolicy: 'no-cache',
nextFetchPolicy: 'no-cache',
},
},
})
function addQueryParamsToEndpoint(
endpoint: string,
params: Record<string, string | number | boolean | undefined>
): string {
const url = new URL(endpoint, uniswapUrls.appBaseUrl) // dummy base URL, we only need the path with query params
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
// only add param if its value is not undefined
url.searchParams.append(key, String(value))
}
})
return url.pathname + url.search
}
export function useUnitagQuery(
username?: string
): ReturnType<typeof useRestQuery<UnitagUsernameResponse>> {
return useRestQuery<UnitagUsernameResponse, Record<string, never>>(
addQueryParamsToEndpoint('/username', { username }),
{},
['available', 'requiresEnsMatch', 'metadata', 'address'], // return all fields
{
skip: !username, // skip if username is not provided
fetchPolicy: 'no-cache',
},
'GET',
apolloClient
)
}
export function useUnitagByAddressQuery(
address?: Address
): ReturnType<typeof useRestQuery<UnitagAddressResponse>> {
return useRestQuery<UnitagAddressResponse, Record<string, never>>(
addQueryParamsToEndpoint('/address', { address }),
{},
['username', 'metadata'], // return all fields
{
skip: !address, // skip if address is not provided
fetchPolicy: 'no-cache',
},
'GET',
apolloClient
)
}
export function useClaimUnitagMutation(): ReturnType<
typeof useRestMutation<UnitagClaimResponse, UnitagClaimUsernameRequestBody>
> {
return useRestMutation<UnitagClaimResponse, UnitagClaimUsernameRequestBody>(
'/username',
['success', 'errorCode'], // return all fields
{},
'POST',
apolloClient
)
}
export function useUnitagClaimEligibilityQuery({
address,
deviceId,
skip,
}: UnitagClaimEligibilityParams & { skip?: boolean }): ReturnType<
typeof useRestQuery<UnitagClaimEligibilityResponse>
> {
return useRestQuery<UnitagClaimEligibilityResponse, Record<string, never>>(
addQueryParamsToEndpoint('/claim/eligibility', address ? { address, deviceId } : { deviceId }),
{},
['canClaim', 'errorCode', 'message'], // return all fields
{ skip, fetchPolicy: 'no-cache' },
'GET',
apolloClient
)
}
import { TFunction } from 'i18next'
import { useTranslation } from 'react-i18next'
import { getUniqueId } from 'react-native-device-info'
import { useAsyncData } from 'utilities/src/react/hooks'
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 {
useUnitagByAddressQuery,
useUnitagClaimEligibilityQuery,
useUnitagQuery,
} from 'wallet/src/features/unitags/api'
import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks'
import { areAddressesEqual } from 'wallet/src/utils/addresses'
const MIN_UNITAG_LENGTH = 3
const MAX_UNITAG_LENGTH = 20
export const useCanActiveAddressClaimUnitag = (): boolean => {
const unitagsFeatureFlagEnabled = useFeatureFlag(FEATURE_FLAGS.Unitags)
const activeAddress = useActiveAccountAddressWithThrow()
const { data: deviceId } = useAsyncData(getUniqueId)
const { loading, data } = useUnitagClaimEligibilityQuery({
address: activeAddress,
deviceId: deviceId ?? '', // this is fine since we skip if deviceId is undefined
skip: !unitagsFeatureFlagEnabled || !deviceId,
})
return unitagsFeatureFlagEnabled && !loading && !!data?.canClaim
}
export const useCanAddressClaimUnitag = (address?: Address): boolean => {
const unitagsFeatureFlagEnabled = useFeatureFlag(FEATURE_FLAGS.Unitags)
const { data: deviceId } = useAsyncData(getUniqueId)
const { loading, data } = useUnitagClaimEligibilityQuery({
address,
deviceId: deviceId ?? '', // this is fine since we skip if deviceId is undefined
skip: !unitagsFeatureFlagEnabled || !deviceId,
})
return !loading && !!data?.canClaim
}
export const useUnitag = (address?: Address): string | undefined => {
const unitagsFeatureFlagEnabled = useFeatureFlag(FEATURE_FLAGS.Unitags)
const { data } = useUnitagByAddressQuery(unitagsFeatureFlagEnabled ? address : undefined)
return data?.username
}
// Helper function to enforce unitag length and alphanumeric characters
export const getUnitagFormatError = (unitag: string, t: TFunction): string | undefined => {
if (unitag.length < MIN_UNITAG_LENGTH) {
return t(`Unitags must be at least {{ minUnitagLength }} characters`, {
minUnitagLength: MIN_UNITAG_LENGTH,
})
} else if (unitag.length > MAX_UNITAG_LENGTH) {
return t(`Unitags cannot be more than {{ maxUnitagLength }} characters`, {
maxUnitagLength: MAX_UNITAG_LENGTH,
})
} else if (!/^[A-Za-z0-9]+$/.test(unitag)) {
return t('Unitags can only contain letters and numbers')
}
return undefined
}
export const useUnitagError = (
unitagAddress: Address | undefined,
unitag: string | undefined
): { unitagError: string | undefined; loading: boolean } => {
const { t } = useTranslation()
// Check for length and alphanumeric characters
let unitagError = unitag ? getUnitagFormatError(unitag, t) : undefined
// Skip the backend calls if we found an error
const unitagToSearch = unitagError ? undefined : unitag
const { loading: unitagLoading, data } = useUnitagQuery(unitagToSearch)
const { loading: ensLoading, address: ensAddress } = useENS(ChainId.Mainnet, unitagToSearch, true)
const loading = unitagLoading || ensLoading
// Check for availability and ENS match
const dataLoaded = !loading && !!data
const ensAddressMatchesUnitagAddress = areAddressesEqual(unitagAddress, ensAddress)
if (dataLoaded && !data.available) {
unitagError = t('This Unitag is not available')
}
if (dataLoaded && data.requiresEnsMatch && !ensAddressMatchesUnitagAddress) {
unitagError = t('To claim this Unitag you must own the {{ unitag }}.eth ENS', { unitag })
}
return { unitagError, loading }
}
export type UnitagUsernameResponse = {
available: boolean
requiresEnsMatch: boolean
metadata?: ProfileMetadata
address?: {
address: Address
}
}
export type UnitagAddressResponse = {
username?: string
metadata?: ProfileMetadata
}
export type UnitagClaimResponse = {
success: boolean
errorCode?: UnitagErrorCodes
}
export type UnitagClaimEligibilityResponse = {
canClaim: boolean
errorCode?: UnitagErrorCodes
}
export type ProfileMetadata = {
avatar?: string
description?: string
url?: string
twitter?: string
}
export type UnitagClaimUsernameRequestBody = {
address: Address
username: string
deviceId: string
metadata?: ProfileMetadata
}
export type UnitagClaimEligibilityParams = {
address?: Address
deviceId: string
}
// Copied enum from unitags backend code -- needs to be up-to-date
export enum UnitagErrorCodes {
UnitagNotAvailable = 'unitags-1',
RequiresENSMatch = 'unitags-2',
IPLimitReached = 'unitags-3',
AddressLimitReached = 'unitags-4',
DeviceLimitReached = 'unitags-5',
ExistingUnitagForDevice = 'unitags-6',
ExistingUnitagForAddress = 'unitags-7',
}
import { TFunction } from 'i18next'
import { UnitagErrorCodes } from 'wallet/src/features/unitags/types'
export function parseUnitagErrorCode(
t: TFunction,
unitag: string,
errorCode: UnitagErrorCodes
): string {
switch (errorCode) {
case UnitagErrorCodes.UnitagNotAvailable:
return t('This Unitag is not available')
case UnitagErrorCodes.RequiresENSMatch:
return t('To claim this Unitag you must own the {{ unitag }}.eth ENS', { unitag })
case UnitagErrorCodes.IPLimitReached:
case UnitagErrorCodes.AddressLimitReached:
case UnitagErrorCodes.DeviceLimitReached:
return t('Unable to claim Unitag')
case UnitagErrorCodes.ExistingUnitagForDevice:
return t('Existing unitag for this device')
case UnitagErrorCodes.ExistingUnitagForAddress:
return t('You already have a Unitag for this address')
default:
return t('Unknown error')
}
}
...@@ -5,8 +5,6 @@ ...@@ -5,8 +5,6 @@
"{{ prevTxnsCount }} previous transfer": "{{ prevTxnsCount }} previous transfer", "{{ prevTxnsCount }} previous transfer": "{{ prevTxnsCount }} previous transfer",
"{{ prevTxnsCount }} previous transfers": "{{ prevTxnsCount }} previous transfers", "{{ prevTxnsCount }} previous transfers": "{{ prevTxnsCount }} previous transfers",
"{{ token }} fee": "{{ token }} fee", "{{ token }} fee": "{{ token }} fee",
"{{amount}} maximum": "{{amount}} maximum",
"{{amount}} minimum": "{{amount}} minimum",
"{{assetName}} hidden": "{{assetName}} hidden", "{{assetName}} hidden": "{{assetName}} hidden",
"{{assetName}} unhidden": "{{assetName}} unhidden", "{{assetName}} unhidden": "{{assetName}} unhidden",
"{{authTypeCapitalized}} is disabled": "{{authTypeCapitalized}} is disabled", "{{authTypeCapitalized}} is disabled": "{{authTypeCapitalized}} is disabled",
...@@ -82,12 +80,10 @@ ...@@ -82,12 +80,10 @@
"Back up to iCloud": "Back up to iCloud", "Back up to iCloud": "Back up to iCloud",
"Back up your wallet": "Back up your wallet", "Back up your wallet": "Back up your wallet",
"Backed up": "Backed up", "Backed up": "Backed up",
"Backed up on:": "Backed up on:",
"Backed up to Google Drive": "Backed up to Google Drive", "Backed up to Google Drive": "Backed up to Google Drive",
"Backed up to iCloud": "Backed up to iCloud", "Backed up to iCloud": "Backed up to iCloud",
"Backing up to Google Drive...": "Backing up to Google Drive...", "Backing up to Google Drive...": "Backing up to Google Drive...",
"Backing up to iCloud...": "Backing up to iCloud...", "Backing up to iCloud...": "Backing up to iCloud...",
"Backup {{backupIndex}}": "Backup {{backupIndex}}",
"Backups let you restore your wallet if you delete the app or lose your device": "Backups let you restore your wallet if you delete the app or lose your device", "Backups let you restore your wallet if you delete the app or lose your device": "Backups let you restore your wallet if you delete the app or lose your device",
"Balance": "Balance", "Balance": "Balance",
"Balances on other networks": "Balances on other networks", "Balances on other networks": "Balances on other networks",
...@@ -223,6 +219,7 @@ ...@@ -223,6 +219,7 @@
"Error while checking transaction status": "Error while checking transaction status", "Error while checking transaction status": "Error while checking transaction status",
"Error while importing backups": "Error while importing backups", "Error while importing backups": "Error while importing backups",
"Euro": "Euro", "Euro": "Euro",
"Existing unitag for this device": "Existing unitag for this device",
"Failed to {{action}}": "Failed to {{action}}", "Failed to {{action}}": "Failed to {{action}}",
"Failed to approve {{currencySymbol}} for use with {{address}}.": "Failed to approve {{currencySymbol}} for use with {{address}}.", "Failed to approve {{currencySymbol}} for use with {{address}}.": "Failed to approve {{currencySymbol}} for use with {{address}}.",
"Failed to fetch token balances": "Failed to fetch token balances", "Failed to fetch token balances": "Failed to fetch token balances",
...@@ -340,8 +337,10 @@ ...@@ -340,8 +337,10 @@
"Market cap": "Market cap", "Market cap": "Market cap",
"Max": "Max", "Max": "Max",
"Max slippage": "Max slippage", "Max slippage": "Max slippage",
"Maximum {{amount}}": "Maximum {{amount}}",
"Maximum slippage": "Maximum slippage", "Maximum slippage": "Maximum slippage",
"Maybe later": "Maybe later", "Maybe later": "Maybe later",
"Minimum {{amount}}": "Minimum {{amount}}",
"mint": "mint", "mint": "mint",
"Minted": "Minted", "Minted": "Minted",
"Minting": "Minting", "Minting": "Minting",
...@@ -574,12 +573,14 @@ ...@@ -574,12 +573,14 @@
"This token isn’t traded on leading U.S. centralized exchanges. Always conduct your own research before trading.": "This token isn’t traded on leading U.S. centralized exchanges. Always conduct your own research before trading.", "This token isn’t traded on leading U.S. centralized exchanges. Always conduct your own research before trading.": "This token isn’t traded on leading U.S. centralized exchanges. Always conduct your own research before trading.",
"This trade cannot be completed right now": "This trade cannot be completed right now", "This trade cannot be completed right now": "This trade cannot be completed right now",
"This transaction is expected to fail": "This transaction is expected to fail", "This transaction is expected to fail": "This transaction is expected to fail",
"This Unitag is not available": "This Unitag is not available",
"This username is a simple, user-friendly way to use your address in transactions. Your current address remains unchanged and secure.": "This username is a simple, user-friendly way to use your address in transactions. Your current address remains unchanged and secure.", "This username is a simple, user-friendly way to use your address in transactions. Your current address remains unchanged and secure.": "This username is a simple, user-friendly way to use your address in transactions. Your current address remains unchanged and secure.",
"This wallet is blocked": "This wallet is blocked", "This wallet is blocked": "This wallet is blocked",
"This wallet is in view only mode": "This wallet is in view only mode", "This wallet is in view only mode": "This wallet is in view only mode",
"This wallet is view-only": "This wallet is view-only", "This wallet is view-only": "This wallet is view-only",
"This will remove your wallet from this device along with your recovery phrase.": "This will remove your wallet from this device along with your recovery phrase.", "This will remove your wallet from this device along with your recovery phrase.": "This will remove your wallet from this device along with your recovery phrase.",
"To": "To", "To": "To",
"To claim this Unitag you must own the {{ unitag }}.eth ENS": "To claim this Unitag you must own the {{ unitag }}.eth ENS",
"to create a public username and customizable profile.": "to create a public username and customizable profile.", "to create a public username and customizable profile.": "to create a public username and customizable profile.",
"To create more wallets, open the account switcher inside the extension popup, or reinstall the extension to start over": "To create more wallets, open the account switcher inside the extension popup, or reinstall the extension to start over", "To create more wallets, open the account switcher inside the extension popup, or reinstall the extension to start over": "To create more wallets, open the account switcher inside the extension popup, or reinstall the extension to start over",
"To receive notifications, turn on notifications for Uniswap Wallet in your device’s settings.": "To receive notifications, turn on notifications for Uniswap Wallet in your device’s settings.", "To receive notifications, turn on notifications for Uniswap Wallet in your device’s settings.": "To receive notifications, turn on notifications for Uniswap Wallet in your device’s settings.",
...@@ -618,6 +619,7 @@ ...@@ -618,6 +619,7 @@
"Unable to backup recovery phrase to Google Drive. Please ensure you have Google Drive enabled with available storage space and try again.": "Unable to backup recovery phrase to Google Drive. Please ensure you have Google Drive enabled with available storage space and try again.", "Unable to backup recovery phrase to Google Drive. Please ensure you have Google Drive enabled with available storage space and try again.": "Unable to backup recovery phrase to Google Drive. Please ensure you have Google Drive enabled with available storage space and try again.",
"Unable to backup recovery phrase to iCloud. Please ensure you have iCloud enabled with available storage space and try again.": "Unable to backup recovery phrase to iCloud. Please ensure you have iCloud enabled with available storage space and try again.", "Unable to backup recovery phrase to iCloud. Please ensure you have iCloud enabled with available storage space and try again.": "Unable to backup recovery phrase to iCloud. Please ensure you have iCloud enabled with available storage space and try again.",
"Unable to cancel transaction": "Unable to cancel transaction", "Unable to cancel transaction": "Unable to cancel transaction",
"Unable to claim Unitag": "Unable to claim Unitag",
"Unable to delete backup": "Unable to delete backup", "Unable to delete backup": "Unable to delete backup",
"Unable to replace transaction": "Unable to replace transaction", "Unable to replace transaction": "Unable to replace transaction",
"Unhide NFT": "Unhide NFT", "Unhide NFT": "Unhide NFT",
...@@ -627,8 +629,12 @@ ...@@ -627,8 +629,12 @@
"Uniswap usernames are built on top of ENS subdomains.": "Uniswap usernames are built on top of ENS subdomains.", "Uniswap usernames are built on top of ENS subdomains.": "Uniswap usernames are built on top of ENS subdomains.",
"Uniswap volume (24H)": "Uniswap volume (24H)", "Uniswap volume (24H)": "Uniswap volume (24H)",
"Uniswap Wallet currently supports {{ chains }}. Please only use \"{{ dappName }}\" on these chains": "Uniswap Wallet currently supports {{ chains }}. Please only use \"{{ dappName }}\" on these chains", "Uniswap Wallet currently supports {{ chains }}. Please only use \"{{ dappName }}\" on these chains": "Uniswap Wallet currently supports {{ chains }}. Please only use \"{{ dappName }}\" on these chains",
"Unitags can only contain letters and numbers": "Unitags can only contain letters and numbers",
"Unitags cannot be more than {{ maxUnitagLength }} characters": "Unitags cannot be more than {{ maxUnitagLength }} characters",
"Unitags must be at least {{ minUnitagLength }} characters": "Unitags must be at least {{ minUnitagLength }} characters",
"United States Dollar": "United States Dollar", "United States Dollar": "United States Dollar",
"Unknown": "Unknown", "Unknown": "Unknown",
"Unknown error": "Unknown error",
"unknown token": "unknown token", "unknown token": "unknown token",
"Unknown token": "Unknown token", "Unknown token": "Unknown token",
"Unlimited": "Unlimited", "Unlimited": "Unlimited",
...@@ -702,6 +708,7 @@ ...@@ -702,6 +708,7 @@
"Wrapping": "Wrapping", "Wrapping": "Wrapping",
"Write down your recovery phrase in order": "Write down your recovery phrase in order", "Write down your recovery phrase in order": "Write down your recovery phrase in order",
"Wrong recovery phrase": "Wrong recovery phrase", "Wrong recovery phrase": "Wrong recovery phrase",
"You already have a Unitag for this address": "You already have a Unitag for this address",
"You are in offline mode": "You are in offline mode", "You are in offline mode": "You are in offline mode",
"You can <1>enter</1> your recovery phrase on a new device <4>to restore your wallet</4> and its contents.": "You can <1>enter</1> your recovery phrase on a new device <4>to restore your wallet</4> and its contents.", "You can <1>enter</1> your recovery phrase on a new device <4>to restore your wallet</4> and its contents.": "You can <1>enter</1> your recovery phrase on a new device <4>to restore your wallet</4> and its contents.",
"You can also manually back up your recovery phrase by <2>writing it down</2> and storing it in a safe place.": "You can also manually back up your recovery phrase by <2>writing it down</2> and storing it in a safe place.", "You can also manually back up your recovery phrase by <2>writing it down</2> and storing it in a safe place.": "You can also manually back up your recovery phrase by <2>writing it down</2> and storing it in a safe place.",
......
...@@ -20,4 +20,5 @@ declare module 'react-native-dotenv' { ...@@ -20,4 +20,5 @@ declare module 'react-native-dotenv' {
export const ONESIGNAL_APP_ID: string export const ONESIGNAL_APP_ID: string
export const WALLETCONNECT_PROJECT_ID: string export const WALLETCONNECT_PROJECT_ID: string
export const QUICKNODE_BNB_RPC_URL: string export const QUICKNODE_BNB_RPC_URL: string
export const UNITAGS_API_URL: string
} }
{ {
"baseBranch": "origin/main", "baseBranch": "origin/main",
"pipeline": { "pipeline": {
"web:ignore-build": {
"comment": "this is used by vercel to cancel builds if not web"
},
"prepare": { "prepare": {
"inputs": [ "inputs": [
"package.json" "package.json"
......
{
"rewrites": [
{"source": "/(.*)", "destination": "/"}
]
}
...@@ -13478,13 +13478,6 @@ __metadata: ...@@ -13478,13 +13478,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/node@npm:13.13.5":
version: 13.13.5
resolution: "@types/node@npm:13.13.5"
checksum: 874bb45920a33d18619c19687425628ae536a28d3342c781f5c84e0ac41f3f6cbb5afda1b2d366e232a7862cbf44ba559c1775fc9db875388148d7f893f64ad0
languageName: node
linkType: hard
"@types/node@npm:16.9.1": "@types/node@npm:16.9.1":
version: 16.9.1 version: 16.9.1
resolution: "@types/node@npm:16.9.1" resolution: "@types/node@npm:16.9.1"
...@@ -13492,6 +13485,13 @@ __metadata: ...@@ -13492,6 +13485,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/node@npm:18.16.0":
version: 18.16.0
resolution: "@types/node@npm:18.16.0"
checksum: 63e0042136663b9e85ce503a4c65406cc6621fdba63ea66c74b4b1364a9aa9bdb57cadcb76696abab177f38a819b0fa6ace9e7f1647dcb990aedb1b4bd01012f
languageName: node
linkType: hard
"@types/node@npm:^10.11.7, @types/node@npm:^10.12.18": "@types/node@npm:^10.11.7, @types/node@npm:^10.12.18":
version: 10.17.60 version: 10.17.60
resolution: "@types/node@npm:10.17.60" resolution: "@types/node@npm:10.17.60"
...@@ -14515,7 +14515,7 @@ __metadata: ...@@ -14515,7 +14515,7 @@ __metadata:
"@types/lingui__react": 2.8.3 "@types/lingui__react": 2.8.3
"@types/ms": 0.7.31 "@types/ms": 0.7.31
"@types/multicodec": 1.0.0 "@types/multicodec": 1.0.0
"@types/node": 13.13.5 "@types/node": 18.16.0
"@types/qs": 6.9.2 "@types/qs": 6.9.2
"@types/react": ^18.0.15 "@types/react": ^18.0.15
"@types/react-dom": ^18.0.6 "@types/react-dom": ^18.0.6
...@@ -14819,7 +14819,7 @@ __metadata: ...@@ -14819,7 +14819,7 @@ __metadata:
ethers: 5.7.2 ethers: 5.7.2
expo: 48.0.19 expo: 48.0.19
expo-av: 13.4.1 expo-av: 13.4.1
expo-barcode-scanner: 12.3.2 expo-barcode-scanner: 12.7.0
expo-blur: 12.2.2 expo-blur: 12.2.2
expo-clipboard: 4.1.2 expo-clipboard: 4.1.2
expo-haptics: 12.0.1 expo-haptics: 12.0.1
...@@ -20623,7 +20623,7 @@ __metadata: ...@@ -20623,7 +20623,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"confusing-browser-globals@npm:^1.0.11": "confusing-browser-globals@npm:1.0.11, confusing-browser-globals@npm:^1.0.11":
version: 1.0.11 version: 1.0.11
resolution: "confusing-browser-globals@npm:1.0.11" resolution: "confusing-browser-globals@npm:1.0.11"
checksum: 3afc635abd37e566477f610e7978b15753f0e84025c25d49236f1f14d480117185516bdd40d2a2167e6bed8048641a9854964b9c067e3dcdfa6b5d0ad3c3a5ef checksum: 3afc635abd37e566477f610e7978b15753f0e84025c25d49236f1f14d480117185516bdd40d2a2167e6bed8048641a9854964b9c067e3dcdfa6b5d0ad3c3a5ef
...@@ -25394,14 +25394,14 @@ __metadata: ...@@ -25394,14 +25394,14 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"expo-barcode-scanner@npm:12.3.2": "expo-barcode-scanner@npm:12.7.0":
version: 12.3.2 version: 12.7.0
resolution: "expo-barcode-scanner@npm:12.3.2" resolution: "expo-barcode-scanner@npm:12.7.0"
dependencies: dependencies:
expo-image-loader: ~4.1.0 expo-image-loader: ~4.4.0
peerDependencies: peerDependencies:
expo: "*" expo: "*"
checksum: b07831a8b7205c838be54268ac927ca4ce431065f55f551d37e9957e3b2bae0c6b208fee278d846a9f06d9cd9e71d54f3821818c80327fa8387a7b56ee1291af checksum: 7672d68761eeb401188da5c5c85242f93940aa26acd2f989fb7af1a82e91264449c872376fdbe0acae07fbe8adb8dc712576c02ad166f36320664f0b605c86ed
languageName: node languageName: node
linkType: hard linkType: hard
...@@ -25489,12 +25489,12 @@ __metadata: ...@@ -25489,12 +25489,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"expo-image-loader@npm:~4.1.0": "expo-image-loader@npm:~4.4.0":
version: 4.1.1 version: 4.4.0
resolution: "expo-image-loader@npm:4.1.1" resolution: "expo-image-loader@npm:4.4.0"
peerDependencies: peerDependencies:
expo: "*" expo: "*"
checksum: 6ea7d49148d9126a79f00b1e9a2e648884d208daf8ac54c663eb941a7524d229aa907d6d45704598168c7d1efcb891bdc0bd91f077a53d3a270b18c269d43d99 checksum: e872e45a807cd867c5a8e8fc5665de33de5d467ea533222037b09b70268d0166012769bfcd340ab8922dbf826a3e4f0498ff73973ad62f21134764b4bc8b1f5a
languageName: node languageName: node
linkType: hard linkType: hard
...@@ -46668,6 +46668,7 @@ __metadata: ...@@ -46668,6 +46668,7 @@ __metadata:
"@types/react": ^18.0.15 "@types/react": ^18.0.15
"@typescript-eslint/eslint-plugin": 5.59.2 "@typescript-eslint/eslint-plugin": 5.59.2
concurrently: ^8.0.1 concurrently: ^8.0.1
confusing-browser-globals: 1.0.11
danger: 11.2.6 danger: 11.2.6
dotenv-cli: ^7.0.0 dotenv-cli: ^7.0.0
eslint: 8.44.0 eslint: 8.44.0
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment