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(),
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, spotPrice: previewEntry.spotPrice, pricePercentChange: previewEntry.pricePercentChange, symbol: previewEntry.symbol, logo: nil, backgroundColor: nil, tokenPriceHistory: previewEntry.tokenPriceHistory) 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
......
...@@ -10,9 +10,9 @@ import Apollo ...@@ -10,9 +10,9 @@ import Apollo
import OSLog import OSLog
public class DataQueries { public class DataQueries {
static let cachePolicy: CachePolicy = CachePolicy.fetchIgnoringCacheData static let cachePolicy: CachePolicy = CachePolicy.fetchIgnoringCacheData
public static func fetchTokensData(tokenInputs: [TokenInput]) async throws -> [TokenResponse] { public static func fetchTokensData(tokenInputs: [TokenInput]) async throws -> [TokenResponse] {
return try await withCheckedThrowingContinuation { continuation in return try await withCheckedThrowingContinuation { continuation in
let contractInputs = tokenInputs.map {MobileSchema.ContractInput(chain: GraphQLEnum(rawValue: $0.chain), address: $0.address == nil ? GraphQLNullable.null: GraphQLNullable(stringLiteral: $0.address!))} let contractInputs = tokenInputs.map {MobileSchema.ContractInput(chain: GraphQLEnum(rawValue: $0.chain), address: $0.address == nil ? GraphQLNullable.null: GraphQLNullable(stringLiteral: $0.address!))}
...@@ -34,7 +34,7 @@ public class DataQueries { ...@@ -34,7 +34,7 @@ public class DataQueries {
} }
} }
} }
public static func fetchTopTokensData() async throws -> [TokenResponse] { public static func fetchTopTokensData() async throws -> [TokenResponse] {
return try await withCheckedThrowingContinuation { continuation in return try await withCheckedThrowingContinuation { continuation in
Network.shared.apollo.fetch(query: MobileSchema.TopTokensQuery(chain: GraphQLNullable(MobileSchema.Chain.ethereum)), cachePolicy: cachePolicy) { result in Network.shared.apollo.fetch(query: MobileSchema.TopTokensQuery(chain: GraphQLNullable(MobileSchema.Chain.ethereum)), cachePolicy: cachePolicy) { result in
...@@ -55,7 +55,7 @@ public class DataQueries { ...@@ -55,7 +55,7 @@ public class DataQueries {
} }
} }
} }
public static func fetchTokenPriceData(chain: String, address: String?) async throws -> TokenPriceResponse { public static func fetchTokenPriceData(chain: String, address: String?) async throws -> TokenPriceResponse {
return try await withCheckedThrowingContinuation { continuation in return try await withCheckedThrowingContinuation { continuation in
Network.shared.apollo.fetch(query: MobileSchema.FavoriteTokenCardQuery(chain: GraphQLEnum(rawValue: chain), address: address == nil ? GraphQLNullable.null : GraphQLNullable(stringLiteral: address!)), cachePolicy: cachePolicy) { result in Network.shared.apollo.fetch(query: MobileSchema.FavoriteTokenCardQuery(chain: GraphQLEnum(rawValue: chain), address: address == nil ? GraphQLNullable.null : GraphQLNullable(stringLiteral: address!)), cachePolicy: cachePolicy) { result in
...@@ -76,7 +76,7 @@ public class DataQueries { ...@@ -76,7 +76,7 @@ public class DataQueries {
} }
} }
} }
public static func fetchTokenPriceHistoryData(chain: String, address: String?) async throws -> TokenPriceHistoryResponse { public static func fetchTokenPriceHistoryData(chain: String, address: String?) async throws -> TokenPriceHistoryResponse {
return try await withCheckedThrowingContinuation { continuation in return try await withCheckedThrowingContinuation { continuation in
Network.shared.apollo.fetch(query: MobileSchema.TokenPriceHistoryQuery(contract: MobileSchema.ContractInput(chain: GraphQLEnum(rawValue: chain), address: address == nil ? GraphQLNullable.null: GraphQLNullable(stringLiteral: address!))), cachePolicy: cachePolicy) { result in Network.shared.apollo.fetch(query: MobileSchema.TokenPriceHistoryQuery(contract: MobileSchema.ContractInput(chain: GraphQLEnum(rawValue: chain), address: address == nil ? GraphQLNullable.null: GraphQLNullable(stringLiteral: address!))), cachePolicy: cachePolicy) { result in
...@@ -96,10 +96,10 @@ public class DataQueries { ...@@ -96,10 +96,10 @@ 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){ let maxNumber = 1000000000000000.0
return "\(formatter.string(for: price/1000000000000)!)T" let maxed = number >= maxNumber
} let limitedNumber = maxed ? maxNumber : number
else {
return "$>999T" // 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 = {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.maximumSignificantDigits = 3
formatter.minimumSignificantDigits = 3
formatter.currencyCode = "USD"
return formatter
}()
public static var THREE_DECIMALS_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.maximumFractionDigits = 3
formatter.minimumFractionDigits = 3
formatter.currencyCode = "USD"
return formatter
}()
public static func fiatTokenDetailsFormatter(price: Double?) -> String { public static func fiatTokenDetailsFormatter(price: Double?, locale: Locale, currencyCode: String) -> String {
let placeholder = "--.--"
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)! if (price < 0.01) {
} return formatWithSigFigs(number: price, sigFigsDigits: 3, locale: locale, currencyCode: currencyCode)
else if (price < 1e6) { } else if (price < 1.05) {
return TWO_DECIMALS_USD.string(for: price)! return formatWithDecimals(number: price, fractionDigits: 3, locale: locale, currencyCode: currencyCode)
} } else if (price < 1e6) {
else { return formatWithDecimals(number: price, fractionDigits: 2, locale: locale, currencyCode: currencyCode)
return SHORTHAND_USD_TWO_DECIMALS(price: price) } 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}>
Set <Flex grow row alignItems="center" gap="$spacing16">
</Button> <Button flex={1} size="small" onPress={setEndpoint}>
<Button size="small" onPress={clearEndpoint}> Set
Clear </Button>
<Button flex={1} size="small" onPress={clearEndpoint}>
Clear
</Button>
</Flex>
</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> </Button>
</Flex> </Accordion.Content>
<Text variant="subheading1">⛳️ Feature Flags</Text> </Accordion.Item>
<Text variant="body2">
Overridden feature flags are reset when the app is restarted <Accordion.Item value="feature-flags">
</Text> <AccordionHeader title="⛳️ Feature Flags" />
</Flex>
{Object.values(FEATURE_FLAGS).map((featureFlag) => { <Accordion.Content>
return <FeatureFlagRow key={featureFlag} featureFlag={featureFlag} /> <Text variant="body2">
})} Overridden feature flags are reset when the app is restarted
<Text variant="subheading1">🔬 Experiments</Text> </Text>
<Text variant="body2">Overridden experiments are reset when the app is restarted</Text>
{Object.values(EXPERIMENT_NAMES).map((experiment) => { <Flex gap="$spacing12" mt="$spacing12">
return <ExperimentRow key={experiment} name={experiment} /> {Object.values(FEATURE_FLAGS).map((featureFlag) => {
})} return <FeatureFlagRow key={featureFlag} featureFlag={featureFlag} />
</Flex> })}
</Flex>
</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) => {
return <ExperimentRow key={experiment} name={experiment} />
})}
</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 {
value: price, return useMemo(
formatted: priceFormatted, () => ({
} value: price,
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,15 +97,18 @@ export function AccountList({ accounts, onPress, isVisible }: AccountListProps): ...@@ -97,15 +97,18 @@ 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(
<AccountCardItem (item: AccountWithPortfolioValue): JSX.Element => (
key={item.account.address} <AccountCardItem
address={item.account.address} key={item.account.address}
isPortfolioValueLoading={item.isPortfolioValueLoading} address={item.account.address}
isViewOnly={item.account.type === AccountType.Readonly} isPortfolioValueLoading={item.isPortfolioValueLoading}
portfolioValue={item.portfolioValue} isViewOnly={item.account.type === AccountType.Readonly}
onPress={onPress} portfolioValue={item.portfolioValue}
/> 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(
baseCurrencyCode, quoteCurrencyCode
quoteCurrencyCode, ? {
areFeesIncluded: MOONPAY_FEES_INCLUDED, baseCurrencyCode,
}) quoteCurrencyCode,
areFeesIncluded: MOONPAY_FEES_INCLUDED,
}
: skipToken
)
const { maxBuyAmount } = limitsData?.baseCurrency ?? { const { maxBuyAmount } = limitsData?.baseCurrency ?? {
maxBuyAmount: Infinity, maxBuyAmount: Infinity,
...@@ -214,37 +218,40 @@ export function useMoonpayFiatOnRamp({ ...@@ -214,37 +218,40 @@ 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, ? {
colorCode: colors.accent1.val, ownerAddress: activeAccountAddress,
externalTransactionId, colorCode: colors.accent1.val,
amount: baseCurrencyAmount, externalTransactionId,
currencyCode: quoteCurrencyCode, amount: baseCurrencyAmount,
baseCurrencyCode, currencyCode: quoteCurrencyCode,
redirectUrl: `${ baseCurrencyCode,
isAndroid ? uniswapUrls.appUrl : uniswapUrls.appBaseUrl redirectUrl: `${
}/?screen=transaction&fiatOnRamp=true&userAddress=${activeAccountAddress}`, isAndroid ? uniswapUrls.appUrl : uniswapUrls.appBaseUrl
} }/?screen=transaction&fiatOnRamp=true&userAddress=${activeAccountAddress}`,
}
: skipToken
) )
const { const {
data: buyQuote, data: buyQuote,
isFetching: buyQuoteLoading, isFetching: buyQuoteLoading,
isError: buyQuoteLoadingQueryError, isError: buyQuoteLoadingQueryError,
} = useFiatOnRampBuyQuoteQuery( } = useFiatOnRampBuyQuoteQuery(
{ // When isBaseCurrencyAmountValid is false and the user enters any digit,
baseCurrencyCode, // isBaseCurrencyAmountValid becomes true. Since there were no prior calls to the API,
baseCurrencyAmount: debouncedBaseCurrencyAmount, // it takes the debouncedBaseCurrencyAmount and immediately calls an API.
quoteCurrencyCode, // This only truly matters in the beginning and in cases where the debouncedBaseCurrencyAmount
areFeesIncluded: MOONPAY_FEES_INCLUDED, // is changed while isBaseCurrencyAmountValid is false."
}, quoteCurrencyCode &&
{ isBaseCurrencyAmountValid &&
// When isBaseCurrencyAmountValid is false and the user enters any digit, debouncedBaseCurrencyAmount === baseCurrencyAmount
// isBaseCurrencyAmountValid becomes true. Since there were no prior calls to the API, ? {
// it takes the debouncedBaseCurrencyAmount and immediately calls an API. baseCurrencyCode,
// This only truly matters in the beginning and in cases where the debouncedBaseCurrencyAmount baseCurrencyAmount: debouncedBaseCurrencyAmount,
// is changed while isBaseCurrencyAmountValid is false." quoteCurrencyCode,
skip: !isBaseCurrencyAmountValid || debouncedBaseCurrencyAmount !== baseCurrencyAmount, 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]
......
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 { 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(
if (account) { (withDelay: boolean) => {
// Backend takes <2sec to get the updated portfolio value after a transaction if (account) {
// This timeout is an interim solution while we're working on a websocket that'll ping the client when connected account gets changes // Backend takes <2sec to get the updated portfolio value after a transaction
// TODO(WEB-3131): remove this timeout after websocket is implemented // This timeout is an interim solution while we're working on a websocket that'll ping the client when connected account gets changes
setTimeout(() => { // TODO(WEB-3131): remove this timeout after websocket is implemented
prefetchPortfolioBalances({ variables: { ownerAddress: account, chains: GQL_MAINNET_CHAINS } }) setTimeout(
setHasUnfetchedBalances(false) () => {
}, ms('3.5s')) prefetchPortfolioBalances({ variables: { ownerAddress: account, chains: GQL_MAINNET_CHAINS } })
} setHasUnfetchedBalances(false)
}, [account, prefetchPortfolioBalances, setHasUnfetchedBalances]) },
withDelay ? ms('3.5s') : 0
)
}
},
[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 }>`
height: 24px; ${({ $isInfoTDPEnabled }) =>
width: 24px; $isInfoTDPEnabled
? css`
height: 16px;
width: 16px;
`
: css`
height: 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)
} }
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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