Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
195703495c | ||
|
|
690d2970cd | ||
|
|
8465f6848c |
14
RELEASE
@@ -1,6 +1,6 @@
|
||||
IPFS hash of the deployment:
|
||||
- CIDv0: `Qme44H1PdJPvgSyV94D33wyy895YsHH5fKWAuHN8jrexmq`
|
||||
- CIDv1: `bafybeihjplb4afnfihydwdlkzfymtd26u3tqz64r6eighq5kdqu7s6x5hq`
|
||||
- CIDv0: `QmRfmKBiCcSDFdGRZuJKqPtyenCsCFasgiwNvTJQMrbjT6`
|
||||
- CIDv1: `bafybeibrpcihc5cqqzntesoc5dtxyili3uvyrfmxi7fcx4uteto7ucpj6e`
|
||||
|
||||
The latest release is always mirrored at [app.uniswap.org](https://app.uniswap.org).
|
||||
|
||||
@@ -10,15 +10,15 @@ You can also access the Uniswap Interface from an IPFS gateway.
|
||||
Your Uniswap settings are never remembered across different URLs.
|
||||
|
||||
IPFS gateways:
|
||||
- https://bafybeihjplb4afnfihydwdlkzfymtd26u3tqz64r6eighq5kdqu7s6x5hq.ipfs.dweb.link/
|
||||
- https://bafybeihjplb4afnfihydwdlkzfymtd26u3tqz64r6eighq5kdqu7s6x5hq.ipfs.cf-ipfs.com/
|
||||
- [ipfs://Qme44H1PdJPvgSyV94D33wyy895YsHH5fKWAuHN8jrexmq/](ipfs://Qme44H1PdJPvgSyV94D33wyy895YsHH5fKWAuHN8jrexmq/)
|
||||
- https://bafybeibrpcihc5cqqzntesoc5dtxyili3uvyrfmxi7fcx4uteto7ucpj6e.ipfs.dweb.link/
|
||||
- https://bafybeibrpcihc5cqqzntesoc5dtxyili3uvyrfmxi7fcx4uteto7ucpj6e.ipfs.cf-ipfs.com/
|
||||
- [ipfs://QmRfmKBiCcSDFdGRZuJKqPtyenCsCFasgiwNvTJQMrbjT6/](ipfs://QmRfmKBiCcSDFdGRZuJKqPtyenCsCFasgiwNvTJQMrbjT6/)
|
||||
|
||||
### 5.1.1 (2023-12-06)
|
||||
### 5.2.2 (2023-12-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **web:** optional address for multichainbalance - prod hotfix (#5392) 99d4e33
|
||||
* **web:** disambiguate 3P ProviderRpcErrors (#5482) 0f8a086
|
||||
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -651,7 +651,7 @@ PODS:
|
||||
- EXAV (13.4.1):
|
||||
- ExpoModulesCore
|
||||
- ReactCommon/turbomodule/core
|
||||
- EXBarCodeScanner (12.3.2):
|
||||
- EXBarCodeScanner (12.7.0):
|
||||
- EXImageLoader
|
||||
- ExpoModulesCore
|
||||
- ZXingObjC/OneD
|
||||
@@ -660,7 +660,7 @@ PODS:
|
||||
- ExpoModulesCore
|
||||
- EXFont (11.1.1):
|
||||
- ExpoModulesCore
|
||||
- EXImageLoader (4.1.1):
|
||||
- EXImageLoader (4.4.0):
|
||||
- ExpoModulesCore
|
||||
- React-Core
|
||||
- EXLocalAuthentication (13.0.2):
|
||||
@@ -1689,10 +1689,10 @@ SPEC CHECKSUMS:
|
||||
EthersRS: 56b70e73d22d4e894b7e762eef1129159bcd3135
|
||||
EXApplication: d8f53a7eee90a870a75656280e8d4b85726ea903
|
||||
EXAV: f393dfc0b28214d62855a31e06eb21d426d6e2da
|
||||
EXBarCodeScanner: 8e23fae8d267dbef9f04817833a494200f1fce35
|
||||
EXBarCodeScanner: 296dd50f6c03928d1d71d37ea17473b304cfdb00
|
||||
EXFileSystem: 0b4a2c08c2dc92146849772145d61c1773144283
|
||||
EXFont: 6ea3800df746be7233208d80fe379b8ed74f4272
|
||||
EXImageLoader: fd053169a8ee932dd83bf1fe5487a50c26d27c2b
|
||||
EXImageLoader: 03063370bc06ea1825713d3f55fe0455f7c88d04
|
||||
EXLocalAuthentication: 78cc5a00b13745b41d16d189ab332b61a01299f9
|
||||
Expo: 8448e3a2aa1b295f029c81551e1ab6d986517fdb
|
||||
ExpoBlur: fac3c6318fdf409dd5740afd3313b2bd52a35bac
|
||||
|
||||
@@ -131,10 +131,12 @@
|
||||
07F136422A5763480067004F /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F136412A5763480067004F /* Network.swift */; };
|
||||
07F5CF712A6AD97D00C648A5 /* Chart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F5CF702A6AD97D00C648A5 /* Chart.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 */; };
|
||||
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
|
||||
1440B371A1C9A42F3E91DAAE /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF5F26A06553EFDD4D99214 /* ExpoModulesProvider.swift */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
@@ -412,6 +414,7 @@
|
||||
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>"; };
|
||||
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; };
|
||||
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>"; };
|
||||
@@ -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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -686,6 +690,7 @@
|
||||
0743218F2A83E3C900F8518D /* Queries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5EFB78352B1E585000E77EAC /* ConvertQuery.graphql.swift */,
|
||||
077E60382A85587800ABC4B9 /* TokensQuery.graphql.swift */,
|
||||
074321902A83E3C900F8518D /* TokenDetailsScreenQuery.graphql.swift */,
|
||||
077E603A2A86D06100ABC4B9 /* MultiplePortfolioBalancesQuery.graphql.swift */,
|
||||
@@ -977,6 +982,7 @@
|
||||
83CBB9F61A601CBA00E9B192 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0DC6ADEF2B1E2C0F0092909C /* PortfolioValueModifier.graphql.swift */,
|
||||
074321872A82BA2700F8518D /* Fonts */,
|
||||
FD54D51C296C79A4007A37E9 /* GoogleServiceInfo */,
|
||||
13B07FAE1A68108700A75B9A /* Uniswap */,
|
||||
@@ -1774,6 +1780,7 @@
|
||||
074322142A83E3CA00F8518D /* TimestampedAmount.graphql.swift in Sources */,
|
||||
074322362A83E3CA00F8518D /* NftCollectionsFilterInput.graphql.swift in Sources */,
|
||||
0743223B2A83E3CA00F8518D /* NftAssetsFilterInput.graphql.swift in Sources */,
|
||||
0DC6ADF02B1E2C100092909C /* PortfolioValueModifier.graphql.swift in Sources */,
|
||||
074322282A83E3CA00F8518D /* TokenProjectMarket.graphql.swift in Sources */,
|
||||
0743220F2A83E3CA00F8518D /* NftApproveForAll.graphql.swift in Sources */,
|
||||
0743223C2A83E3CA00F8518D /* SchemaMetadata.graphql.swift in Sources */,
|
||||
@@ -1817,6 +1824,7 @@
|
||||
07F5CF752A7020FD00C648A5 /* Format.swift in Sources */,
|
||||
0783F7B42A619E7C009ED617 /* UIComponents.swift in Sources */,
|
||||
0743220B2A83E3CA00F8518D /* NftMarketplace.graphql.swift in Sources */,
|
||||
5EFB78362B1E585000E77EAC /* ConvertQuery.graphql.swift in Sources */,
|
||||
074321F62A83E3CA00F8518D /* SearchTokensQuery.graphql.swift in Sources */,
|
||||
0743223E2A83E3CA00F8518D /* IContract.graphql.swift in Sources */,
|
||||
074321EE2A83E3CA00F8518D /* NftsTabQuery.graphql.swift in Sources */,
|
||||
|
||||
@@ -89,6 +89,12 @@
|
||||
<string>$(PRODUCT_NAME) Wallet needs access to your Camera Roll to choose an avatar for your username</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<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>
|
||||
<dict>
|
||||
<key>iCloud.Uniswap</key>
|
||||
|
||||
@@ -26,9 +26,31 @@ let placeholderPriceHistory = [
|
||||
PriceHistory(timestamp: 1689794997, price: 2167),
|
||||
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 displayName = "Token Prices"
|
||||
@@ -39,10 +61,16 @@ struct Provider: IntentTimelineProvider {
|
||||
|
||||
func getEntry(configuration: TokenPriceConfigurationIntent, context: Context, isSnapshot: Bool) async throws -> TokenPriceEntry {
|
||||
let entryDate = Date()
|
||||
let tokenPriceResponse = isSnapshot ?
|
||||
try await DataQueries.fetchTokenPriceData(chain: WidgetConstants.ethereumChain, address: nil) :
|
||||
try await DataQueries.fetchTokenPriceData(chain: configuration.selectedToken?.chain ?? "", address: configuration.selectedToken?.address)
|
||||
let spotPrice = tokenPriceResponse.spotPrice
|
||||
async let tokenPriceRequest = isSnapshot ?
|
||||
await DataQueries.fetchTokenPriceData(chain: WidgetConstants.ethereumChain, address: nil) :
|
||||
await DataQueries.fetchTokenPriceData(chain: configuration.selectedToken?.chain ?? "", address: configuration.selectedToken?.address)
|
||||
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 symbol = tokenPriceResponse.symbol
|
||||
let logo = UIImage(url: URL(string: tokenPriceResponse.logoUrl ?? ""))
|
||||
@@ -62,7 +90,17 @@ struct Provider: IntentTimelineProvider {
|
||||
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 {
|
||||
@@ -90,6 +128,7 @@ struct Provider: IntentTimelineProvider {
|
||||
struct TokenPriceEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let configuration: TokenPriceConfigurationIntent
|
||||
let currency: String
|
||||
let spotPrice: Double?
|
||||
let pricePercentChange: Double?
|
||||
let symbol: String
|
||||
@@ -130,7 +169,14 @@ struct TokenPriceWidgetEntryView: View {
|
||||
func priceSection(isPlaceholder: Bool) -> some View {
|
||||
return VStack(alignment: .leading, spacing: 0) {
|
||||
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()
|
||||
.frame(minHeight: 28)
|
||||
.minimumScaleFactor(0.3)
|
||||
|
||||
@@ -12,6 +12,7 @@ public struct WidgetConstants {
|
||||
public static let ethereumChain = "ETHEREUM"
|
||||
public static let WETHAddress = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"
|
||||
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
|
||||
|
||||
@@ -10,9 +10,9 @@ import Apollo
|
||||
import OSLog
|
||||
|
||||
public class DataQueries {
|
||||
|
||||
|
||||
static let cachePolicy: CachePolicy = CachePolicy.fetchIgnoringCacheData
|
||||
|
||||
|
||||
public static func fetchTokensData(tokenInputs: [TokenInput]) async throws -> [TokenResponse] {
|
||||
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!))}
|
||||
@@ -34,7 +34,7 @@ public class DataQueries {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static func fetchTopTokensData() async throws -> [TokenResponse] {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
Network.shared.apollo.fetch(query: MobileSchema.TopTokensQuery(chain: GraphQLNullable(MobileSchema.Chain.ethereum)), cachePolicy: cachePolicy) { result in
|
||||
@@ -55,7 +55,7 @@ public class DataQueries {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static func fetchTokenPriceData(chain: String, address: String?) async throws -> TokenPriceResponse {
|
||||
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
|
||||
@@ -76,7 +76,7 @@ public class DataQueries {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static func fetchTokenPriceHistoryData(chain: String, address: String?) async throws -> TokenPriceHistoryResponse {
|
||||
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
|
||||
@@ -96,10 +96,10 @@ public class DataQueries {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static func fetchWalletsTokensData(addresses: [String], maxLength: Int = 25) async throws -> [TokenResponse] {
|
||||
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 {
|
||||
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
|
||||
@@ -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 @@
|
||||
//
|
||||
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 {
|
||||
|
||||
// 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
|
||||
public static func SHORTHAND_USD_TWO_DECIMALS(price: Double) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.maximumFractionDigits = 2
|
||||
formatter.minimumFractionDigits = 2
|
||||
formatter.currencyCode = "USD"
|
||||
static func formatShorthandWithDecimals(number: Double, fractionDigits: Int, locale: Locale, currencyCode: String, placeholder: String) -> String {
|
||||
if (number < 1000000) {
|
||||
return formatWithDecimals(number: number, fractionDigits: fractionDigits, locale: locale, currencyCode: currencyCode)
|
||||
}
|
||||
let maxNumber = 1000000000000000.0
|
||||
let maxed = number >= maxNumber
|
||||
let limitedNumber = maxed ? maxNumber : number
|
||||
|
||||
// Replace when Swift supports notation configuration for currency
|
||||
// https://developer.apple.com/documentation/foundation/currencyformatstyleconfiguration
|
||||
let compactFormatted = limitedNumber.formatted(.number.locale(locale).precision(.fractionLength(fractionDigits)).notation(.compactName))
|
||||
let currencyFormatted = limitedNumber.formatted(.currency(code: currencyCode).locale(locale).precision(.fractionLength(fractionDigits)).grouping(.never))
|
||||
|
||||
if (price < 1000000){
|
||||
return TWO_DECIMALS_USD.string(for: price)!
|
||||
}
|
||||
else if (price < 1000000000){
|
||||
return "\(formatter.string(for: price/1000000)!)M"
|
||||
}
|
||||
else if (price < 1000000000000){
|
||||
return "\(formatter.string(for: price/1000000000)!)B"
|
||||
}
|
||||
else if (price < 1000000000000000){
|
||||
return "\(formatter.string(for: price/1000000000000)!)T"
|
||||
}
|
||||
else {
|
||||
return "$>999T"
|
||||
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 = {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.maximumFractionDigits = 2
|
||||
formatter.minimumFractionDigits = 2
|
||||
formatter.currencyCode = "USD"
|
||||
return formatter
|
||||
}()
|
||||
static func formatWithDecimals(number: Double, fractionDigits: Int, locale: Locale, currencyCode: String) -> String {
|
||||
return number.formatted(.currency(code: currencyCode).locale(locale).precision(.fractionLength(fractionDigits)))
|
||||
}
|
||||
|
||||
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
|
||||
}()
|
||||
static func formatWithSigFigs(number: Double, sigFigsDigits: Int, locale: Locale, currencyCode: String) -> String {
|
||||
return number.formatted(.currency(code: currencyCode).locale(locale).precision(.significantDigits(sigFigsDigits)))
|
||||
}
|
||||
|
||||
public static var THREE_DECIMALS_USD: NumberFormatter = {
|
||||
let formatter = NumberFormatter()
|
||||
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 {
|
||||
return "--.--"
|
||||
}
|
||||
if (price < 0.00000001) {
|
||||
return "<$0.00000001"
|
||||
}
|
||||
else if (price < 0.01) {
|
||||
return THREE_SIG_FIGS_USD.string(for: price)!
|
||||
}
|
||||
else if (price < 1.05) {
|
||||
return THREE_DECIMALS_USD.string(for: price)!
|
||||
}
|
||||
else if (price < 1e6) {
|
||||
return TWO_DECIMALS_USD.string(for: price)!
|
||||
}
|
||||
else {
|
||||
return SHORTHAND_USD_TWO_DECIMALS(price: price)
|
||||
return placeholder
|
||||
}
|
||||
|
||||
if (price < 0.00000001) {
|
||||
let formattedPrice = formatWithDecimals(number: price, fractionDigits: 8, locale: locale, currencyCode: currencyCode)
|
||||
return "<\(formattedPrice)"
|
||||
}
|
||||
|
||||
if (price < 0.01) {
|
||||
return formatWithSigFigs(number: price, sigFigsDigits: 3, locale: locale, currencyCode: currencyCode)
|
||||
} else if (price < 1.05) {
|
||||
return formatWithDecimals(number: price, fractionDigits: 3, locale: locale, currencyCode: currencyCode)
|
||||
} else if (price < 1e6) {
|
||||
return formatWithDecimals(number: price, fractionDigits: 2, locale: locale, currencyCode: currencyCode)
|
||||
} else {
|
||||
return formatShorthandWithDecimals(number: price, fractionDigits: 2, locale: locale, currencyCode: currencyCode, placeholder: placeholder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,3 +51,8 @@ public struct PriceHistory {
|
||||
public let timestamp: Int
|
||||
public let price: Double
|
||||
}
|
||||
|
||||
public struct CurrencyConversionResponse {
|
||||
public let conversionRate: Double
|
||||
public let currency: String
|
||||
}
|
||||
|
||||
@@ -34,6 +34,17 @@ public struct WidgetDataAccounts: Decodable {
|
||||
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 var address: String
|
||||
public var name: String?
|
||||
@@ -80,14 +91,14 @@ public enum Change: String, Codable {
|
||||
case removed = "removed"
|
||||
}
|
||||
|
||||
|
||||
public struct UniswapUserDefaults {
|
||||
private static var buildString = getBuildVariantString(bundleId: Bundle.main.bundleIdentifier!)
|
||||
|
||||
static let eventsKey = buildString + ".widgets.configuration.events"
|
||||
static let cacheKey = buildString + ".widgets.configuration.cache"
|
||||
static let favoritesKey = buildString + ".widgets.favorites"
|
||||
static let accountsKey = buildString + ".widgets.accounts"
|
||||
static let keyEvents = buildString + ".widgets.configuration.events"
|
||||
static let keyCache = buildString + ".widgets.configuration.cache"
|
||||
static let keyFavorites = buildString + ".widgets.favorites"
|
||||
static let keyAccounts = buildString + ".widgets.accounts"
|
||||
static let keyI18n = buildString + ".widgets.i18n"
|
||||
|
||||
static let userDefaults = UserDefaults.init(suiteName: APP_GROUP)
|
||||
|
||||
@@ -104,7 +115,7 @@ public struct UniswapUserDefaults {
|
||||
}
|
||||
|
||||
public static func readAccounts() -> WidgetDataAccounts {
|
||||
let data = readData(key: accountsKey)
|
||||
let data = readData(key: keyAccounts)
|
||||
guard let data = data else {
|
||||
return WidgetDataAccounts([])
|
||||
}
|
||||
@@ -117,7 +128,7 @@ public struct UniswapUserDefaults {
|
||||
}
|
||||
|
||||
public static func readFavorites() -> WidgetDataFavorites {
|
||||
let data = readData(key: favoritesKey)
|
||||
let data = readData(key: keyFavorites)
|
||||
guard let data = data else {
|
||||
return WidgetDataFavorites([])
|
||||
}
|
||||
@@ -129,8 +140,20 @@ public struct UniswapUserDefaults {
|
||||
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 {
|
||||
let data = readData(key: cacheKey)
|
||||
let data = readData(key: keyCache)
|
||||
guard let data = data else {
|
||||
return WidgetDataConfiguration([])
|
||||
}
|
||||
@@ -147,12 +170,12 @@ public struct UniswapUserDefaults {
|
||||
let encoder = JSONEncoder()
|
||||
let JSONdata = try! encoder.encode(data)
|
||||
let json = String(data: JSONdata, encoding: String.Encoding.utf8)
|
||||
userDefaults!.set(json, forKey: cacheKey)
|
||||
userDefaults!.set(json, forKey: keyCache)
|
||||
}
|
||||
}
|
||||
|
||||
public static func readEventChanges() -> WidgetEvents {
|
||||
let data = readData(key: eventsKey)
|
||||
let data = readData(key: keyEvents)
|
||||
guard let data = data else {
|
||||
return WidgetEvents(events: [])
|
||||
}
|
||||
@@ -169,7 +192,7 @@ public struct UniswapUserDefaults {
|
||||
let encoder = JSONEncoder()
|
||||
let JSONdata = try! encoder.encode(data)
|
||||
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
|
||||
|
||||
final class FormatTests: XCTestCase {
|
||||
|
||||
func testFiatTokenDetailsFormatter() throws {
|
||||
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.05), "$0.050")
|
||||
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.056666666), "$0.057")
|
||||
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 1234567.891), "$1.23M")
|
||||
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 1234.5678), "$1,234.57")
|
||||
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 1.048952), "$1.049")
|
||||
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.001231), "$0.00123")
|
||||
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.00001231), "$0.0000123")
|
||||
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.0000001234), "$0.000000123")
|
||||
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.000000009876), "<$0.00000001")
|
||||
let localeEnglish = Locale(identifier: "en")
|
||||
let localeFrench = Locale(identifier: "fr-FR")
|
||||
let localeChinese = Locale(identifier: "zh-Hans")
|
||||
|
||||
let currencyCodeUsd = WidgetConstants.currencyUsd
|
||||
let currencyCodeEuro = "EUR"
|
||||
let currencyCodeYuan = "CNY"
|
||||
|
||||
struct TestCase {
|
||||
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 @@
|
||||
"ethers": "5.7.2",
|
||||
"expo": "48.0.19",
|
||||
"expo-av": "13.4.1",
|
||||
"expo-barcode-scanner": "12.3.2",
|
||||
"expo-barcode-scanner": "12.7.0",
|
||||
"expo-blur": "12.2.2",
|
||||
"expo-clipboard": "4.1.2",
|
||||
"expo-haptics": "12.0.1",
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
processWidgetEvents,
|
||||
setAccountAddressesUserDefaults,
|
||||
setFavoritesUserDefaults,
|
||||
setI18NUserDefaults,
|
||||
} from 'src/features/widgets/widgets'
|
||||
import { useAppStateTrigger } from 'src/utils/useAppStateTrigger'
|
||||
import { getSentryEnvironment, getStatsigEnvironmentTier } from 'src/utils/version'
|
||||
@@ -46,6 +47,8 @@ import { uniswapUrls } from 'wallet/src/constants/urls'
|
||||
import { useCurrentAppearanceSetting, useIsDarkMode } from 'wallet/src/features/appearance/hooks'
|
||||
import { EXPERIMENT_NAMES, FEATURE_FLAGS } from 'wallet/src/features/experiments/constants'
|
||||
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 { updateLanguage } from 'wallet/src/features/language/slice'
|
||||
import { Account } from 'wallet/src/features/wallet/accounts/types'
|
||||
@@ -238,6 +241,8 @@ function AppInner(): JSX.Element {
|
||||
function DataUpdaters(): JSX.Element {
|
||||
const favoriteTokens: CurrencyId[] = useAppSelector(selectFavoriteTokens)
|
||||
const accountsMap: Record<string, Account> = useAccounts()
|
||||
const { locale } = useCurrentLanguageInfo()
|
||||
const { code } = useAppFiatCurrencyInfo()
|
||||
|
||||
// Refreshes widgets when bringing app to foreground
|
||||
useAppStateTrigger('background', 'active', processWidgetEvents)
|
||||
@@ -250,6 +255,10 @@ function DataUpdaters(): JSX.Element {
|
||||
setAccountAddressesUserDefaults(Object.values(accountsMap))
|
||||
}, [accountsMap])
|
||||
|
||||
useEffect(() => {
|
||||
setI18NUserDefaults({ locale, currency: code })
|
||||
}, [code, locale])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TraceUserProperties />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useApolloClient } from '@apollo/client'
|
||||
import React, { useState } from 'react'
|
||||
import { ScrollView } from 'react-native-gesture-handler'
|
||||
import { Action } from 'redux'
|
||||
@@ -10,17 +11,20 @@ import { ModalName } from 'src/features/telemetry/constants'
|
||||
import { selectCustomEndpoint } from 'src/features/tweaks/selectors'
|
||||
import { setCustomEndpoint } from 'src/features/tweaks/slice'
|
||||
import { Statsig } from 'statsig-react'
|
||||
import { useExperiment } from 'statsig-react-native'
|
||||
import { Button, Flex, Text, useDeviceInsets } from 'ui/src'
|
||||
import { useExperimentWithExposureLoggingDisabled } from 'statsig-react-native'
|
||||
import { Accordion } from 'tamagui'
|
||||
import { Button, Flex, Icons, Text, useDeviceInsets } from 'ui/src'
|
||||
import { spacing } from 'ui/src/theme'
|
||||
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 {
|
||||
const insets = useDeviceInsets()
|
||||
const dispatch = useAppDispatch()
|
||||
const customEndpoint = useAppSelector(selectCustomEndpoint)
|
||||
|
||||
const apollo = useApolloClient()
|
||||
|
||||
const [url, setUrl] = useState<string>(customEndpoint?.url || '')
|
||||
const [key, setKey] = useState<string>(customEndpoint?.key || '')
|
||||
|
||||
@@ -48,51 +52,113 @@ export function ExperimentsModal(): JSX.Element {
|
||||
renderBehindBottomInset
|
||||
name={ModalName.Experiments}
|
||||
onClose={(): Action => dispatch(closeModal({ name: ModalName.Experiments }))}>
|
||||
<ScrollView contentContainerStyle={{ paddingBottom: insets.bottom + spacing.spacing12 }}>
|
||||
<Flex gap="$spacing16" justifyContent="flex-start" pt="$spacing12" px="$spacing24">
|
||||
<Flex gap="$spacing8">
|
||||
<Flex gap="$spacing16" my="$spacing16">
|
||||
<Text variant="subheading1">⚙️ Custom GraphQL Endpoint</Text>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom,
|
||||
paddingRight: spacing.spacing24,
|
||||
paddingLeft: spacing.spacing24,
|
||||
}}>
|
||||
<Accordion type="single">
|
||||
<Accordion.Item value="graphql-endpoint">
|
||||
<AccordionHeader title="⚙️ Custom GraphQL Endpoint" />
|
||||
|
||||
<Accordion.Content>
|
||||
<Text variant="body2">
|
||||
You will need to restart the application to pick up any changes in this section.
|
||||
Beware of client side caching!
|
||||
</Text>
|
||||
|
||||
<Flex row alignItems="center" gap="$spacing16">
|
||||
<Text variant="body2">URL</Text>
|
||||
<TextInput flex={1} value={url} onChangeText={setUrl} />
|
||||
</Flex>
|
||||
|
||||
<Flex row alignItems="center" gap="$spacing16">
|
||||
<Text variant="body2">Key</Text>
|
||||
<TextInput flex={1} value={key} onChangeText={setKey} />
|
||||
</Flex>
|
||||
<Button size="small" onPress={setEndpoint}>
|
||||
Set
|
||||
|
||||
<Flex grow row alignItems="center" gap="$spacing16">
|
||||
<Button flex={1} size="small" onPress={setEndpoint}>
|
||||
Set
|
||||
</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 size="small" onPress={clearEndpoint}>
|
||||
Clear
|
||||
</Button>
|
||||
</Flex>
|
||||
<Text variant="subheading1">⛳️ Feature Flags</Text>
|
||||
<Text variant="body2">
|
||||
Overridden feature flags are reset when the app is restarted
|
||||
</Text>
|
||||
</Flex>
|
||||
{Object.values(FEATURE_FLAGS).map((featureFlag) => {
|
||||
return <FeatureFlagRow key={featureFlag} featureFlag={featureFlag} />
|
||||
})}
|
||||
<Text variant="subheading1">🔬 Experiments</Text>
|
||||
<Text variant="body2">Overridden experiments are reset when the app is restarted</Text>
|
||||
{Object.values(EXPERIMENT_NAMES).map((experiment) => {
|
||||
return <ExperimentRow key={experiment} name={experiment} />
|
||||
})}
|
||||
</Flex>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="feature-flags">
|
||||
<AccordionHeader title="⛳️ Feature Flags" />
|
||||
|
||||
<Accordion.Content>
|
||||
<Text variant="body2">
|
||||
Overridden feature flags are reset when the app is restarted
|
||||
</Text>
|
||||
|
||||
<Flex gap="$spacing12" mt="$spacing12">
|
||||
{Object.values(FEATURE_FLAGS).map((featureFlag) => {
|
||||
return <FeatureFlagRow key={featureFlag} featureFlag={featureFlag} />
|
||||
})}
|
||||
</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>
|
||||
</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 {
|
||||
const status = useFeatureFlag(featureFlag)
|
||||
const status = useFeatureFlagWithExposureLoggingDisabled(featureFlag)
|
||||
|
||||
return (
|
||||
<Flex row alignItems="center" gap="$spacing16" justifyContent="space-between">
|
||||
@@ -108,10 +174,7 @@ function FeatureFlagRow({ featureFlag }: { featureFlag: FEATURE_FLAGS }): JSX.El
|
||||
}
|
||||
|
||||
function ExperimentRow({ name }: { name: string }): JSX.Element {
|
||||
const experiment = useExperiment(name)
|
||||
// console.log('garydebug experiment row ' + JSON.stringify(experiment.config))
|
||||
// const layer = useLayer(name)
|
||||
// console.log('garydebug experiment row ' + JSON.stringify(layer))
|
||||
const experiment = useExperimentWithExposureLoggingDisabled(name)
|
||||
|
||||
const params = Object.entries(experiment.config.value).map(([key, value]) => (
|
||||
<Flex
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAppDispatch } from 'src/app/hooks'
|
||||
import { CloudStorageMnemonicBackup } from 'src/features/CloudBackup/types'
|
||||
import { pushNotification } from 'wallet/src/features/notifications/slice'
|
||||
import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks'
|
||||
|
||||
@@ -35,7 +36,7 @@ export const exampleSwapSuccess = {
|
||||
}
|
||||
|
||||
// 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 dispatch = useAppDispatch()
|
||||
const activeAddress = useActiveAccountAddressWithThrow()
|
||||
@@ -57,3 +58,30 @@ export const useFakeNotification = (ms?: number): void => {
|
||||
}
|
||||
}, [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
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import React, { memo, useMemo } from 'react'
|
||||
import { useWindowDimensions } from 'react-native'
|
||||
import { useAnimatedStyle, useDerivedValue } from 'react-native-reanimated'
|
||||
import { AnimatedText } from 'src/components/text/AnimatedText'
|
||||
import { Flex, useSporeColors } from 'ui/src'
|
||||
import { TextVariantTokens } from 'ui/src/theme'
|
||||
import { Flex, useDeviceDimensions, useSporeColors } from 'ui/src'
|
||||
import { fonts, TextVariantTokens } from 'ui/src/theme'
|
||||
import { ValueAndFormatted } from './usePrice'
|
||||
|
||||
type AnimatedDecimalNumberProps = {
|
||||
@@ -13,12 +14,18 @@ type AnimatedDecimalNumberProps = {
|
||||
decimalPartColor?: string
|
||||
decimalThreshold?: number // below this value (not including) decimal part would have wholePartColor too
|
||||
testID?: string
|
||||
maxWidth?: number
|
||||
maxCharPixelWidth?: number
|
||||
}
|
||||
|
||||
// Utility component to display decimal numbers where the decimal part
|
||||
// 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 { fullWidth } = useDeviceDimensions()
|
||||
const { fontScale } = useWindowDimensions()
|
||||
|
||||
const {
|
||||
number,
|
||||
@@ -28,6 +35,8 @@ export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.El
|
||||
decimalPartColor = colors.neutral3.val,
|
||||
decimalThreshold = 1,
|
||||
testID,
|
||||
maxWidth = fullWidth,
|
||||
maxCharPixelWidth: maxCharPixelWidthProp,
|
||||
} = props
|
||||
|
||||
const wholePart = useDerivedValue(
|
||||
@@ -51,12 +60,37 @@ export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.El
|
||||
}
|
||||
}, [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 (
|
||||
<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 && (
|
||||
<AnimatedText
|
||||
style={decimalStyle}
|
||||
style={[decimalStyle, animatedStyle]}
|
||||
testID="decimalPart"
|
||||
text={decimalPart}
|
||||
variant={variant}
|
||||
@@ -64,4 +98,4 @@ export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.El
|
||||
)}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ImpactFeedbackStyle } from 'expo-haptics'
|
||||
import React, { useMemo } from 'react'
|
||||
import React, { memo, useMemo } from 'react'
|
||||
import { I18nManager } from 'react-native'
|
||||
import { SharedValue } from 'react-native-reanimated'
|
||||
import {
|
||||
@@ -15,7 +15,8 @@ import { DatetimeText, PriceText, RelativeChangeText } from 'src/components/Pric
|
||||
import { TimeRangeGroup } from 'src/components/PriceExplorer/TimeRangeGroup'
|
||||
import { useChartDimensions } from 'src/components/PriceExplorer/useChartDimensions'
|
||||
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 { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
|
||||
import { CurrencyId } from 'wallet/src/utils/currencyId'
|
||||
@@ -27,9 +28,15 @@ type PriceTextProps = {
|
||||
}
|
||||
|
||||
function PriceTextSection({ loading, relativeChange }: PriceTextProps): JSX.Element {
|
||||
const { fullWidth } = useDeviceDimensions()
|
||||
const mx = spacing.spacing12
|
||||
|
||||
return (
|
||||
<Flex mx="$spacing12">
|
||||
<PriceText loading={loading} />
|
||||
<Flex mx={mx}>
|
||||
{/* 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">
|
||||
<RelativeChangeText loading={loading} spotRelativeChange={relativeChange} />
|
||||
<DatetimeText loading={loading} />
|
||||
@@ -42,7 +49,7 @@ export type LineChartPriceAndDateTimeTextProps = {
|
||||
currencyId: CurrencyId
|
||||
}
|
||||
|
||||
export function PriceExplorer({
|
||||
export const PriceExplorer = memo(function PriceExplorer({
|
||||
currencyId,
|
||||
tokenColor,
|
||||
forcePlaceholder,
|
||||
@@ -115,7 +122,7 @@ export function PriceExplorer({
|
||||
<TimeRangeGroup setDuration={setDuration} />
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function PriceExplorerPlaceholder({ loading }: { loading: boolean }): JSX.Element {
|
||||
return (
|
||||
|
||||
@@ -10,7 +10,13 @@ import { isAndroid } from 'wallet/src/utils/platform'
|
||||
import { AnimatedDecimalNumber } from './AnimatedDecimalNumber'
|
||||
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 colors = useSporeColors()
|
||||
const currency = useAppFiatCurrency()
|
||||
@@ -28,6 +34,7 @@ export function PriceText({ loading }: { loading: boolean }): JSX.Element {
|
||||
return (
|
||||
<AnimatedDecimalNumber
|
||||
decimalPartColor={shouldFadePortfolioDecimals ? colors.neutral3.val : colors.neutral1.val}
|
||||
maxWidth={maxWidth}
|
||||
number={price}
|
||||
separator={decimalSeparator}
|
||||
testID="price-text"
|
||||
|
||||
@@ -174,9 +174,14 @@ exports[`PriceText renders without error 1`] = `
|
||||
"fontSize": 53,
|
||||
"lineHeight": 60,
|
||||
},
|
||||
{
|
||||
"color": "#222222",
|
||||
},
|
||||
[
|
||||
{
|
||||
"color": "#222222",
|
||||
},
|
||||
{
|
||||
"fontSize": 106,
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
testID="wholePart"
|
||||
@@ -202,9 +207,14 @@ exports[`PriceText renders without error 1`] = `
|
||||
"fontSize": 53,
|
||||
"lineHeight": 60,
|
||||
},
|
||||
{
|
||||
"color": "#CECECE",
|
||||
},
|
||||
[
|
||||
{
|
||||
"color": "#CECECE",
|
||||
},
|
||||
{
|
||||
"fontSize": 106,
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
testID="decimalPart"
|
||||
@@ -243,9 +253,14 @@ exports[`PriceText renders without error less than a dollar 1`] = `
|
||||
"fontSize": 53,
|
||||
"lineHeight": 60,
|
||||
},
|
||||
{
|
||||
"color": "#222222",
|
||||
},
|
||||
[
|
||||
{
|
||||
"color": "#222222",
|
||||
},
|
||||
{
|
||||
"fontSize": 106,
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
testID="wholePart"
|
||||
@@ -271,9 +286,14 @@ exports[`PriceText renders without error less than a dollar 1`] = `
|
||||
"fontSize": 53,
|
||||
"lineHeight": 60,
|
||||
},
|
||||
{
|
||||
"color": "#222222",
|
||||
},
|
||||
[
|
||||
{
|
||||
"color": "#222222",
|
||||
},
|
||||
{
|
||||
"fontSize": 106,
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
testID="decimalPart"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react'
|
||||
import { SharedValue, useDerivedValue } from 'react-native-reanimated'
|
||||
import {
|
||||
useLineChart,
|
||||
@@ -44,10 +45,14 @@ export function useLineChartPrice(): ValueAndFormatted {
|
||||
currencyInfo.symbol
|
||||
)
|
||||
})
|
||||
return {
|
||||
value: price,
|
||||
formatted: priceFormatted,
|
||||
}
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
value: price,
|
||||
formatted: priceFormatted,
|
||||
}),
|
||||
[price, priceFormatted]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ListRenderItemInfo } from 'react-native'
|
||||
import { Loader } from 'src/components/loading'
|
||||
import { useAllCommonBaseCurrencies } from 'src/components/TokenSelector/hooks'
|
||||
import { TokenOptionItem } from 'src/components/TokenSelector/TokenOptionItem'
|
||||
import { useFiatOnRampSupportedTokens } from 'src/features/fiatOnRamp/hooks'
|
||||
import { FiatOnRampCurrency } from 'src/features/fiatOnRamp/types'
|
||||
import { ElementName } from 'src/features/telemetry/constants'
|
||||
import { Flex, Icons, Inset, Text, TouchableArea } from 'ui/src'
|
||||
import { BaseCard } from 'wallet/src/components/BaseCard/BaseCard'
|
||||
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'
|
||||
|
||||
interface Props {
|
||||
onSelectCurrency: (currency: FiatOnRampCurrency) => void
|
||||
onBack: () => 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]
|
||||
)
|
||||
onRetry: () => void
|
||||
error: boolean
|
||||
loading: boolean
|
||||
list: FiatOnRampCurrency[] | undefined
|
||||
}
|
||||
|
||||
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 flatListRef = useRef(null)
|
||||
|
||||
const {
|
||||
data: supportedTokens,
|
||||
isLoading: supportedTokensLoading,
|
||||
isError: supportedTokensQueryError,
|
||||
refetch: supportedTokensQueryRefetch,
|
||||
} = useFiatOnRampSupportedTokens()
|
||||
|
||||
const { data, loading, error, refetch } = useFiatOnRampTokenList(supportedTokens)
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item: currency }: ListRenderItemInfo<FiatOnRampCurrency>) => {
|
||||
return <TokenOptionItemWrapper currency={currency} onSelectCurrency={onSelectCurrency} />
|
||||
@@ -121,7 +70,7 @@ function _TokenFiatOnRampList({ onSelectCurrency, onBack }: Props): JSX.Element
|
||||
[onSelectCurrency]
|
||||
)
|
||||
|
||||
if (supportedTokensQueryError || error) {
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<Header onBack={onBack} />
|
||||
@@ -129,21 +78,14 @@ function _TokenFiatOnRampList({ onSelectCurrency, onBack }: Props): JSX.Element
|
||||
<BaseCard.ErrorState
|
||||
retryButtonLabel="Retry"
|
||||
title={t('Couldn’t load tokens to buy')}
|
||||
onRetry={(): void => {
|
||||
if (supportedTokensQueryError) {
|
||||
supportedTokensQueryRefetch?.()
|
||||
}
|
||||
if (error) {
|
||||
refetch?.()
|
||||
}
|
||||
}}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (supportedTokensLoading || loading) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Flex>
|
||||
<Header onBack={onBack} />
|
||||
@@ -159,7 +101,7 @@ function _TokenFiatOnRampList({ onSelectCurrency, onBack }: Props): JSX.Element
|
||||
ref={flatListRef}
|
||||
ListEmptyComponent={<Flex />}
|
||||
ListFooterComponent={<Inset all="$spacing36" />}
|
||||
data={data}
|
||||
data={list}
|
||||
keyExtractor={key}
|
||||
keyboardDismissMode="on-drag"
|
||||
keyboardShouldPersistTaps="always"
|
||||
|
||||
@@ -13,6 +13,7 @@ import { disableOnPress } from 'src/utils/disableOnPress'
|
||||
import { Flex, Text, TouchableArea } from 'ui/src'
|
||||
import { iconSizes } from 'ui/src/theme'
|
||||
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 { pushNotification } from 'wallet/src/features/notifications/slice'
|
||||
import { AppNotificationType, CopyNotificationType } from 'wallet/src/features/notifications/types'
|
||||
@@ -24,24 +25,39 @@ type AccountCardItemProps = {
|
||||
} & PortfolioValueProps
|
||||
|
||||
type PortfolioValueProps = {
|
||||
address: Address
|
||||
isPortfolioValueLoading: boolean
|
||||
portfolioValue: number | undefined
|
||||
}
|
||||
|
||||
function PortfolioValue({
|
||||
address,
|
||||
isPortfolioValueLoading,
|
||||
portfolioValue,
|
||||
portfolioValue: providedPortfolioValue,
|
||||
}: PortfolioValueProps): JSX.Element {
|
||||
const isLoading = isPortfolioValueLoading && portfolioValue === undefined
|
||||
const { t } = useTranslation()
|
||||
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 (
|
||||
<Text
|
||||
color="$neutral2"
|
||||
loading={isLoading}
|
||||
loadingPlaceholderText="0000.00"
|
||||
variant="subheading2">
|
||||
{convertFiatAmountFormatted(portfolioValue, NumberType.PortfolioBalance)}
|
||||
<Text color="$neutral2" loading={isLoading} variant="subheading2">
|
||||
{portfolioValue
|
||||
? convertFiatAmountFormatted(portfolioValue, NumberType.PortfolioBalance)
|
||||
: t('N/A')}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -126,6 +142,7 @@ export function AccountCardItem({
|
||||
/>
|
||||
</Flex>
|
||||
<PortfolioValue
|
||||
address={address}
|
||||
isPortfolioValueLoading={isPortfolioValueLoading}
|
||||
portfolioValue={portfolioValue}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
query AccountList($addresses: [String!]!) {
|
||||
portfolios(ownerAddresses: $addresses, chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]) {
|
||||
query AccountList(
|
||||
$addresses: [String!]!
|
||||
$valueModifiers: [PortfolioValueModifier!]
|
||||
) {
|
||||
portfolios(
|
||||
ownerAddresses: $addresses
|
||||
chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]
|
||||
valueModifiers: $valueModifiers
|
||||
) {
|
||||
id
|
||||
ownerAddress
|
||||
tokensTotalDenominatedValue {
|
||||
|
||||
@@ -97,15 +97,18 @@ export function AccountList({ accounts, onPress, isVisible }: AccountListProps):
|
||||
|
||||
const hasViewOnlyAccounts = viewOnlyAccounts.length > 0
|
||||
|
||||
const renderAccountCardItem = (item: AccountWithPortfolioValue): JSX.Element => (
|
||||
<AccountCardItem
|
||||
key={item.account.address}
|
||||
address={item.account.address}
|
||||
isPortfolioValueLoading={item.isPortfolioValueLoading}
|
||||
isViewOnly={item.account.type === AccountType.Readonly}
|
||||
portfolioValue={item.portfolioValue}
|
||||
onPress={onPress}
|
||||
/>
|
||||
const renderAccountCardItem = useCallback(
|
||||
(item: AccountWithPortfolioValue): JSX.Element => (
|
||||
<AccountCardItem
|
||||
key={item.account.address}
|
||||
address={item.account.address}
|
||||
isPortfolioValueLoading={item.isPortfolioValueLoading}
|
||||
isViewOnly={item.account.type === AccountType.Readonly}
|
||||
portfolioValue={item.portfolioValue}
|
||||
onPress={onPress}
|
||||
/>
|
||||
),
|
||||
[onPress]
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -60,7 +60,7 @@ export function LongText(props: LongTextProps): JSX.Element {
|
||||
|
||||
const onTextLayout = useCallback(
|
||||
(e: NativeSyntheticEvent<TextLayoutEventData>) => {
|
||||
setTextLengthExceedsLimit(e.nativeEvent.lines.length >= initialDisplayedLines)
|
||||
setTextLengthExceedsLimit(e.nativeEvent.lines.length > initialDisplayedLines)
|
||||
},
|
||||
[initialDisplayedLines]
|
||||
)
|
||||
|
||||
@@ -48,6 +48,7 @@ export const usePersistedApolloClient = (): ApolloClient<NormalizedCacheObject>
|
||||
}
|
||||
|
||||
const newClient = new ApolloClient({
|
||||
assumeImmutableResults: true,
|
||||
link: from([
|
||||
getErrorLink(),
|
||||
// requires typing outside of wallet package
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
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'
|
||||
import { TextInputProps } from 'src/components/input/TextInput'
|
||||
import { useBottomSheetContext } from 'src/components/modals/BottomSheetContext'
|
||||
import { BottomSheetModal } from 'src/components/modals/BottomSheetModal'
|
||||
import { FiatOnRampTokenSelector } from 'src/components/TokenSelector/FiatOnRampTokenSelector'
|
||||
import { FiatOnRampAmountSection } from 'src/features/fiatOnRamp/FiatOnRampAmountSection'
|
||||
import {
|
||||
FiatOnRampConnectingView,
|
||||
SERVICE_PROVIDER_ICON_SIZE,
|
||||
} from 'src/features/fiatOnRamp/FiatOnRampConnecting'
|
||||
import { FiatOnRampTokenSelector } from 'src/features/fiatOnRamp/FiatOnRampTokenSelector'
|
||||
import {
|
||||
useMoonpayFiatCurrencySupportInfo,
|
||||
useMoonpayFiatOnRamp,
|
||||
@@ -97,15 +97,6 @@ function FiatOnRampContent({ onClose }: { onClose: () => void }): JSX.Element {
|
||||
|
||||
const [currency, setCurrency] = useState<FiatOnRampCurrency>({
|
||||
currencyInfo: ethCurrencyInfo,
|
||||
moonpayCurrency: {
|
||||
code: 'eth',
|
||||
type: 'crypto',
|
||||
id: '',
|
||||
supportsLiveMode: true,
|
||||
supportsTestMode: true,
|
||||
isSupportedInUS: true,
|
||||
notAllowedUSStates: [],
|
||||
},
|
||||
})
|
||||
|
||||
const { appFiatCurrencySupportedInMoonpay, moonpaySupportedFiatCurrency } =
|
||||
@@ -140,7 +131,7 @@ function FiatOnRampContent({ onClose }: { onClose: () => void }): JSX.Element {
|
||||
errorColor,
|
||||
} = useMoonpayFiatOnRamp({
|
||||
baseCurrencyAmount: value,
|
||||
quoteCurrencyCode: currency.moonpayCurrency.code,
|
||||
quoteCurrencyCode: currency.currencyInfo?.currency.symbol,
|
||||
})
|
||||
|
||||
useTimeout(
|
||||
|
||||
119
apps/mobile/src/features/fiatOnRamp/FiatOnRampTokenSelector.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
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)
|
||||
@@ -1,3 +1,4 @@
|
||||
import { skipToken } from '@reduxjs/toolkit/query/react'
|
||||
import { Currency } from '@uniswap/sdk-core'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -151,7 +152,7 @@ export function useMoonpayFiatOnRamp({
|
||||
quoteCurrencyCode,
|
||||
}: {
|
||||
baseCurrencyAmount: string
|
||||
quoteCurrencyCode: string
|
||||
quoteCurrencyCode: string | undefined
|
||||
}): {
|
||||
eligible: boolean
|
||||
quoteAmount: number
|
||||
@@ -166,7 +167,6 @@ export function useMoonpayFiatOnRamp({
|
||||
errorColor?: ColorTokens
|
||||
} {
|
||||
const colors = useSporeColors()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const debouncedBaseCurrencyAmount = useDebounce(baseCurrencyAmount, Delay.Short)
|
||||
|
||||
@@ -185,11 +185,15 @@ export function useMoonpayFiatOnRamp({
|
||||
data: limitsData,
|
||||
isLoading: limitsLoading,
|
||||
isError: limitsLoadingQueryError,
|
||||
} = useFiatOnRampLimitsQuery({
|
||||
baseCurrencyCode,
|
||||
quoteCurrencyCode,
|
||||
areFeesIncluded: MOONPAY_FEES_INCLUDED,
|
||||
})
|
||||
} = useFiatOnRampLimitsQuery(
|
||||
quoteCurrencyCode
|
||||
? {
|
||||
baseCurrencyCode,
|
||||
quoteCurrencyCode,
|
||||
areFeesIncluded: MOONPAY_FEES_INCLUDED,
|
||||
}
|
||||
: skipToken
|
||||
)
|
||||
|
||||
const { maxBuyAmount } = limitsData?.baseCurrency ?? {
|
||||
maxBuyAmount: Infinity,
|
||||
@@ -214,37 +218,40 @@ export function useMoonpayFiatOnRamp({
|
||||
} = useFiatOnRampWidgetUrlQuery(
|
||||
// PERF: could consider skipping this call until eligibility in determined (ux tradeoffs)
|
||||
// as-is, avoids waterfalling requests => better ux
|
||||
{
|
||||
ownerAddress: activeAccountAddress,
|
||||
colorCode: colors.accent1.val,
|
||||
externalTransactionId,
|
||||
amount: baseCurrencyAmount,
|
||||
currencyCode: quoteCurrencyCode,
|
||||
baseCurrencyCode,
|
||||
redirectUrl: `${
|
||||
isAndroid ? uniswapUrls.appUrl : uniswapUrls.appBaseUrl
|
||||
}/?screen=transaction&fiatOnRamp=true&userAddress=${activeAccountAddress}`,
|
||||
}
|
||||
quoteCurrencyCode
|
||||
? {
|
||||
ownerAddress: activeAccountAddress,
|
||||
colorCode: colors.accent1.val,
|
||||
externalTransactionId,
|
||||
amount: baseCurrencyAmount,
|
||||
currencyCode: quoteCurrencyCode,
|
||||
baseCurrencyCode,
|
||||
redirectUrl: `${
|
||||
isAndroid ? uniswapUrls.appUrl : uniswapUrls.appBaseUrl
|
||||
}/?screen=transaction&fiatOnRamp=true&userAddress=${activeAccountAddress}`,
|
||||
}
|
||||
: skipToken
|
||||
)
|
||||
const {
|
||||
data: buyQuote,
|
||||
isFetching: buyQuoteLoading,
|
||||
isError: buyQuoteLoadingQueryError,
|
||||
} = useFiatOnRampBuyQuoteQuery(
|
||||
{
|
||||
baseCurrencyCode,
|
||||
baseCurrencyAmount: debouncedBaseCurrencyAmount,
|
||||
quoteCurrencyCode,
|
||||
areFeesIncluded: MOONPAY_FEES_INCLUDED,
|
||||
},
|
||||
{
|
||||
// When isBaseCurrencyAmountValid is false and the user enters any digit,
|
||||
// isBaseCurrencyAmountValid becomes true. Since there were no prior calls to the API,
|
||||
// it takes the debouncedBaseCurrencyAmount and immediately calls an API.
|
||||
// This only truly matters in the beginning and in cases where the debouncedBaseCurrencyAmount
|
||||
// is changed while isBaseCurrencyAmountValid is false."
|
||||
skip: !isBaseCurrencyAmountValid || debouncedBaseCurrencyAmount !== baseCurrencyAmount,
|
||||
}
|
||||
// When isBaseCurrencyAmountValid is false and the user enters any digit,
|
||||
// isBaseCurrencyAmountValid becomes true. Since there were no prior calls to the API,
|
||||
// it takes the debouncedBaseCurrencyAmount and immediately calls an API.
|
||||
// This only truly matters in the beginning and in cases where the debouncedBaseCurrencyAmount
|
||||
// is changed while isBaseCurrencyAmountValid is false."
|
||||
quoteCurrencyCode &&
|
||||
isBaseCurrencyAmountValid &&
|
||||
debouncedBaseCurrencyAmount === baseCurrencyAmount
|
||||
? {
|
||||
baseCurrencyCode,
|
||||
baseCurrencyAmount: debouncedBaseCurrencyAmount,
|
||||
quoteCurrencyCode,
|
||||
areFeesIncluded: MOONPAY_FEES_INCLUDED,
|
||||
}
|
||||
: skipToken
|
||||
)
|
||||
|
||||
const quoteAmount = buyQuote?.quoteCurrencyAmount ?? 0
|
||||
@@ -281,17 +288,13 @@ export function useMoonpayFiatOnRamp({
|
||||
currencySymbol: baseCurrencySymbol,
|
||||
})
|
||||
|
||||
let errorText, errorColor: ColorTokens | undefined
|
||||
if (isError) {
|
||||
errorText = t('Something went wrong.')
|
||||
errorColor = '$DEP_accentWarning'
|
||||
} else if (amountIsTooSmall) {
|
||||
errorText = t('{{amount}} minimum', { amount: minBuyAmountWithFiatSymbol })
|
||||
errorColor = '$statusCritical'
|
||||
} else if (amountIsTooLarge) {
|
||||
errorText = t('{{amount}} maximum', { amount: maxBuyAmountWithFiatSymbol })
|
||||
errorColor = '$statusCritical'
|
||||
}
|
||||
const { errorText, errorColor } = useMoonpayError(
|
||||
isError,
|
||||
amountIsTooSmall,
|
||||
amountIsTooLarge,
|
||||
minBuyAmountWithFiatSymbol,
|
||||
maxBuyAmountWithFiatSymbol
|
||||
)
|
||||
|
||||
return {
|
||||
eligible,
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { CurrencyInfo } from 'wallet/src/features/dataApi/types'
|
||||
import { MoonpayCurrency } from 'wallet/src/features/fiatOnRamp/types'
|
||||
|
||||
export type FiatOnRampCurrency = {
|
||||
currencyInfo: Maybe<CurrencyInfo>
|
||||
moonpayCurrency: MoonpayCurrency
|
||||
}
|
||||
|
||||
@@ -174,6 +174,7 @@ export const enum ElementName {
|
||||
EtherscanView = 'etherscan-view',
|
||||
Favorite = 'favorite',
|
||||
FiatOnRampTokenSelector = 'fiat-on-ramp-token-selector',
|
||||
FiatOnRampAggregatorTokenSelector = 'fiat-on-ramp-aggregator-token-selector',
|
||||
FiatOnRampWidgetButton = 'fiat-on-ramp-widget-button',
|
||||
FiatOnRampCountryPicker = 'fiat-on-ramp-country-picker',
|
||||
GetHelp = 'get-help',
|
||||
|
||||
@@ -33,11 +33,11 @@ type CurrentInputPanelProps = {
|
||||
autoFocus?: boolean
|
||||
currencyAmount: Maybe<CurrencyAmount<Currency>>
|
||||
currencyBalance: Maybe<CurrencyAmount<Currency>>
|
||||
currencyField: CurrencyField
|
||||
currencyInfo: Maybe<CurrencyInfo>
|
||||
isLoading?: boolean
|
||||
isCollapsed: boolean
|
||||
focus?: boolean
|
||||
isOutput?: boolean
|
||||
isFiatMode?: boolean
|
||||
onPressIn?: () => void
|
||||
onSelectionChange?: (start: number, end: number) => void
|
||||
@@ -50,7 +50,7 @@ type CurrentInputPanelProps = {
|
||||
showSoftInputOnFocus?: boolean
|
||||
usdValue: Maybe<CurrencyAmount<Currency>>
|
||||
value?: string
|
||||
resetSelection: (start: number, end: number) => void
|
||||
resetSelection: (args: { start: number; end?: number; currencyField?: CurrencyField }) => void
|
||||
} & FlexProps
|
||||
|
||||
const MAX_INPUT_FONT_SIZE = 42
|
||||
@@ -68,11 +68,11 @@ export const CurrencyInputPanel = memo(
|
||||
autoFocus,
|
||||
currencyAmount,
|
||||
currencyBalance,
|
||||
currencyField,
|
||||
currencyInfo,
|
||||
isLoading,
|
||||
isCollapsed,
|
||||
focus,
|
||||
isOutput = false,
|
||||
isFiatMode = false,
|
||||
onPressIn,
|
||||
onSelectionChange: selectionChange,
|
||||
@@ -98,6 +98,8 @@ export const CurrencyInputPanel = memo(
|
||||
|
||||
useForwardRef(forwardedRef, inputRef)
|
||||
|
||||
const isOutput = currencyField === CurrencyField.OUTPUT
|
||||
|
||||
const showInsufficientBalanceWarning =
|
||||
!isOutput && !!currencyBalance && !!currencyAmount && currencyBalance.lessThan(currencyAmount)
|
||||
|
||||
@@ -116,11 +118,22 @@ export const CurrencyInputPanel = memo(
|
||||
useEffect(() => {
|
||||
if (focus && !isTextInputRefActuallyFocused) {
|
||||
inputRef.current?.focus()
|
||||
resetSelection(value?.length ?? 0, value?.length ?? 0)
|
||||
resetSelection({
|
||||
start: value?.length ?? 0,
|
||||
end: value?.length ?? 0,
|
||||
currencyField,
|
||||
})
|
||||
} else if (!focus && isTextInputRefActuallyFocused) {
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}, [focus, inputRef, isTextInputRefActuallyFocused, resetSelection, value?.length])
|
||||
}, [
|
||||
currencyField,
|
||||
focus,
|
||||
inputRef,
|
||||
isTextInputRefActuallyFocused,
|
||||
resetSelection,
|
||||
value?.length,
|
||||
])
|
||||
|
||||
const { onLayout, fontSize, onSetFontSize } = useDynamicFontSizing(
|
||||
MAX_CHAR_PIXEL_WIDTH,
|
||||
@@ -198,7 +211,7 @@ export const CurrencyInputPanel = memo(
|
||||
const loadingTextValue = previousValue && previousValue !== '' ? previousValue : '0'
|
||||
|
||||
const { animatedContainerStyle, animatedAmountInputStyle, animatedInfoRowStyle } =
|
||||
useAnimatedContainerStyles(isLoading, focus)
|
||||
useAnimatedContainerStyles(isLoading, isCollapsed)
|
||||
|
||||
const { symbol: fiatCurrencySymbol } = useAppFiatCurrencyInfo()
|
||||
|
||||
@@ -323,10 +336,7 @@ export const CurrencyInputPanel = memo(
|
||||
</Text>
|
||||
)}
|
||||
{showMaxButton && onSetMax && (
|
||||
<MaxAmountButton
|
||||
currencyField={isOutput ? CurrencyField.OUTPUT : CurrencyField.INPUT}
|
||||
onSetMax={onSetMax}
|
||||
/>
|
||||
<MaxAmountButton currencyField={currencyField} onSetMax={onSetMax} />
|
||||
)}
|
||||
</Flex>
|
||||
</>
|
||||
@@ -340,7 +350,7 @@ export const CurrencyInputPanel = memo(
|
||||
|
||||
function useAnimatedContainerStyles(
|
||||
isLoading: boolean | undefined,
|
||||
focus: boolean | undefined
|
||||
isCollapsed: boolean | undefined
|
||||
): {
|
||||
animatedContainerStyle: {
|
||||
paddingTop: number
|
||||
@@ -355,14 +365,14 @@ function useAnimatedContainerStyles(
|
||||
} {
|
||||
const animatedContainerStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
paddingTop: withTiming(focus ? spacing.spacing24 : spacing.spacing16, {
|
||||
paddingTop: withTiming(isCollapsed ? spacing.spacing16 : spacing.spacing24, {
|
||||
duration: 300,
|
||||
}),
|
||||
paddingBottom: withTiming(focus ? spacing.spacing48 : spacing.spacing16, {
|
||||
paddingBottom: withTiming(isCollapsed ? spacing.spacing16 : spacing.spacing48, {
|
||||
duration: 300,
|
||||
}),
|
||||
}
|
||||
}, [focus])
|
||||
}, [isCollapsed])
|
||||
|
||||
const loadingFlexProgress = useSharedValue(1)
|
||||
loadingFlexProgress.value = withRepeat(
|
||||
@@ -382,11 +392,11 @@ function useAnimatedContainerStyles(
|
||||
|
||||
const animatedInfoRowStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
bottom: withTiming(focus ? spacing.spacing16 : -spacing.spacing24, {
|
||||
bottom: withTiming(isCollapsed ? -spacing.spacing24 : spacing.spacing16, {
|
||||
duration: 300,
|
||||
}),
|
||||
}
|
||||
}, [focus])
|
||||
}, [isCollapsed])
|
||||
|
||||
return {
|
||||
animatedContainerStyle,
|
||||
|
||||
@@ -16,8 +16,8 @@ type DecimalPadInputProps = {
|
||||
disabled?: boolean
|
||||
hideDecimal?: boolean
|
||||
onReady: () => void
|
||||
resetSelection: (start: number, end?: number) => void
|
||||
selectionRef?: React.MutableRefObject<TextInputProps['selection']>
|
||||
resetSelection: (args: { start: number; end?: number }) => void
|
||||
selectionRef: React.MutableRefObject<TextInputProps['selection']>
|
||||
setValue: (newValue: string) => void
|
||||
valueRef: React.MutableRefObject<string>
|
||||
}
|
||||
@@ -101,11 +101,11 @@ export const DecimalPadInput = memo(
|
||||
(label: KeyLabel): void => {
|
||||
const { start, end } = getCurrentSelection()
|
||||
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
|
||||
updateValue(valueRef.current + label)
|
||||
} else {
|
||||
resetSelection(start + 1, start + 1)
|
||||
resetSelection({ start: start + 1, end: start + 1 })
|
||||
updateValue(valueRef.current.slice(0, start) + label + valueRef.current.slice(end))
|
||||
}
|
||||
},
|
||||
@@ -115,15 +115,15 @@ export const DecimalPadInput = memo(
|
||||
const handleDelete = useCallback((): void => {
|
||||
const { start, end } = getCurrentSelection()
|
||||
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
|
||||
updateValue(valueRef.current.slice(0, -1))
|
||||
} else if (start < end) {
|
||||
resetSelection(start, start)
|
||||
resetSelection({ start, end: start })
|
||||
// has text part selected
|
||||
updateValue(valueRef.current.slice(0, start) + valueRef.current.slice(end))
|
||||
} 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
|
||||
updateValue(valueRef.current.slice(0, start - 1) + valueRef.current.slice(start))
|
||||
}
|
||||
@@ -144,7 +144,7 @@ export const DecimalPadInput = memo(
|
||||
const onLongPress = useCallback(
|
||||
(_: KeyLabel, action: KeyAction) => {
|
||||
if (disabled || action !== KeyAction.Delete) return
|
||||
resetSelection(0, 0)
|
||||
resetSelection({ start: 0, end: 0 })
|
||||
updateValue('')
|
||||
},
|
||||
[disabled, updateValue, resetSelection]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable max-lines */
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LayoutChangeEvent, StyleSheet, TextInput, TextInputProps } from 'react-native'
|
||||
@@ -100,11 +101,13 @@ function SwapFormContent(): JSX.Element {
|
||||
openWalletRestoreModal()
|
||||
}
|
||||
|
||||
const focusFieldIsInput = focusOnCurrencyField === CurrencyField.INPUT
|
||||
const focusFieldIsOutput = focusOnCurrencyField === CurrencyField.OUTPUT
|
||||
|
||||
const exactFieldIsInput = exactCurrencyField === CurrencyField.INPUT
|
||||
const exactFieldIsOutput = exactCurrencyField === CurrencyField.OUTPUT
|
||||
const derivedCurrencyField = exactFieldIsInput ? CurrencyField.OUTPUT : CurrencyField.INPUT
|
||||
|
||||
// We want the `DecimalPad` to always control one of the 2 inputs even when no input is focused,
|
||||
// which can happen after the user hits `Max`.
|
||||
const decimalPadControlledField = focusOnCurrencyField ?? exactCurrencyField
|
||||
|
||||
// Quote is being fetched for first time
|
||||
const isSwapDataLoading = !isWrapAction(wrapType) && trade.loading
|
||||
@@ -125,35 +128,77 @@ function SwapFormContent(): JSX.Element {
|
||||
)
|
||||
|
||||
const resetSelection = useCallback(
|
||||
(start: number, end?: number) => {
|
||||
({
|
||||
start,
|
||||
end,
|
||||
currencyField,
|
||||
}: {
|
||||
start: number
|
||||
end?: number
|
||||
currencyField?: CurrencyField
|
||||
}) => {
|
||||
// Update refs first to have the latest selection state available in the DecimalPadInput
|
||||
// component and property update disabled keys of the decimal pad.
|
||||
if (focusFieldIsInput) {
|
||||
inputSelectionRef.current = { start, end }
|
||||
} else if (focusFieldIsOutput) {
|
||||
outputSelectionRef.current = { start, end }
|
||||
} else return
|
||||
// We reset the selection on the next tick because we need to wait for the native input to be updated.
|
||||
// component and properly update disabled keys of the decimal pad.
|
||||
// We reset the native selection on the next tick because we need to wait for the native input to be updated.
|
||||
// This is needed because of the combination of state (delayed update) + ref (instant update) to improve performance.
|
||||
|
||||
const _currencyField = currencyField ?? decimalPadControlledField
|
||||
const selectionRef =
|
||||
_currencyField === CurrencyField.INPUT ? inputSelectionRef : outputSelectionRef
|
||||
const inputFieldRef = _currencyField === CurrencyField.INPUT ? inputRef : outputRef
|
||||
|
||||
selectionRef.current = { start, end }
|
||||
|
||||
setTimeout(() => {
|
||||
inputRef.current?.setNativeProps?.({ selection: { start, end } })
|
||||
inputFieldRef.current?.setNativeProps?.({ selection: { start, end } })
|
||||
}, 0)
|
||||
},
|
||||
[focusFieldIsInput, focusFieldIsOutput]
|
||||
[decimalPadControlledField]
|
||||
)
|
||||
|
||||
const moveCursorToEnd = useCallback(
|
||||
(args?: { overrideIsFiatMode?: boolean }) => {
|
||||
const _isFiatMode = args?.overrideIsFiatMode ?? isFiatMode
|
||||
|
||||
const amountRef =
|
||||
decimalPadControlledField === derivedCurrencyField
|
||||
? formattedDerivedValueRef
|
||||
: _isFiatMode
|
||||
? exactAmountFiatRef
|
||||
: exactAmountTokenRef
|
||||
|
||||
if (_isFiatMode) {
|
||||
resetSelection({
|
||||
start: amountRef.current.length,
|
||||
end: amountRef.current.length,
|
||||
})
|
||||
} else {
|
||||
resetSelection({
|
||||
start: amountRef.current.length,
|
||||
end: amountRef.current.length,
|
||||
})
|
||||
}
|
||||
},
|
||||
[
|
||||
decimalPadControlledField,
|
||||
derivedCurrencyField,
|
||||
exactAmountFiatRef,
|
||||
exactAmountTokenRef,
|
||||
isFiatMode,
|
||||
resetSelection,
|
||||
]
|
||||
)
|
||||
|
||||
const decimalPadSetValue = useCallback(
|
||||
(value: string): void => {
|
||||
if (!focusOnCurrencyField) {
|
||||
return
|
||||
}
|
||||
updateSwapForm({
|
||||
exactAmountFiat: isFiatMode ? value : undefined,
|
||||
exactAmountToken: !isFiatMode ? value : undefined,
|
||||
exactCurrencyField: focusOnCurrencyField,
|
||||
exactCurrencyField: decimalPadControlledField,
|
||||
focusOnCurrencyField: decimalPadControlledField,
|
||||
})
|
||||
},
|
||||
[focusOnCurrencyField, isFiatMode, updateSwapForm]
|
||||
[decimalPadControlledField, isFiatMode, updateSwapForm]
|
||||
)
|
||||
|
||||
const [decimalPadReady, setDecimalPadReady] = useState(true)
|
||||
@@ -185,6 +230,7 @@ function SwapFormContent(): JSX.Element {
|
||||
},
|
||||
[amountUpdatedTimeRef]
|
||||
)
|
||||
|
||||
const onOutputSelectionChange = useCallback(
|
||||
(start: number, end: number) => {
|
||||
if (Date.now() - amountUpdatedTimeRef.current < ON_SELECTION_CHANGE_WAIT_TIME_MS) {
|
||||
@@ -247,25 +293,29 @@ function SwapFormContent(): JSX.Element {
|
||||
exactAmountFiat: undefined,
|
||||
exactAmountToken: amount,
|
||||
exactCurrencyField: CurrencyField.INPUT,
|
||||
focusOnCurrencyField: CurrencyField.INPUT,
|
||||
focusOnCurrencyField: undefined,
|
||||
})
|
||||
resetSelection(0, 0)
|
||||
|
||||
// We want this update to happen on the next tick, after the input value is updated.
|
||||
setTimeout(() => {
|
||||
moveCursorToEnd()
|
||||
decimalPadRef.current?.updateDisabledKeys()
|
||||
}, 0)
|
||||
},
|
||||
[resetSelection, updateSwapForm]
|
||||
[moveCursorToEnd, updateSwapForm]
|
||||
)
|
||||
|
||||
// Reset selection based the new input value (token, or fiat), and toggle fiat mode
|
||||
const onToggleIsFiatMode = useCallback(() => {
|
||||
const newIsFiatMode = !isFiatMode
|
||||
|
||||
updateSwapForm({
|
||||
isFiatMode: !isFiatMode,
|
||||
isFiatMode: newIsFiatMode,
|
||||
})
|
||||
// Need to do the opposite of previous mode, as we're selecting the new value after mode update
|
||||
if (!isFiatMode) {
|
||||
resetSelection(exactAmountFiatRef.current.length, exactAmountFiatRef.current.length)
|
||||
} else {
|
||||
resetSelection(exactAmountTokenRef.current.length, exactAmountTokenRef.current.length)
|
||||
}
|
||||
}, [exactAmountFiatRef, exactAmountTokenRef, isFiatMode, resetSelection, updateSwapForm])
|
||||
|
||||
// We want this update to happen on the next tick, after the input value is updated.
|
||||
setTimeout(() => moveCursorToEnd({ overrideIsFiatMode: newIsFiatMode }), 0)
|
||||
}, [isFiatMode, moveCursorToEnd, updateSwapForm])
|
||||
|
||||
const onSwitchCurrencies = useCallback(() => {
|
||||
const newExactCurrencyField = exactFieldIsInput ? CurrencyField.OUTPUT : CurrencyField.INPUT
|
||||
@@ -277,8 +327,6 @@ function SwapFormContent(): JSX.Element {
|
||||
})
|
||||
}, [exactFieldIsInput, input, output, updateSwapForm])
|
||||
|
||||
const derivedCurrencyField = exactFieldIsInput ? CurrencyField.OUTPUT : CurrencyField.INPUT
|
||||
|
||||
// TODO gary MOB-2028 replace temporary hack to handle different separators
|
||||
// Replace with localized version of formatter
|
||||
const formattedDerivedValue = formatCurrencyAmount({
|
||||
@@ -288,25 +336,39 @@ function SwapFormContent(): JSX.Element {
|
||||
placeholder: '',
|
||||
})
|
||||
|
||||
// TODO - improve this to update ref when calculating the derived state
|
||||
// instead of assigning ref based on the derived state
|
||||
const formattedDerivedValueRef = useRef(formattedDerivedValue)
|
||||
formattedDerivedValueRef.current = formattedDerivedValue
|
||||
|
||||
useEffect(() => {
|
||||
formattedDerivedValueRef.current = formattedDerivedValue
|
||||
if (decimalPadControlledField === exactCurrencyField) {
|
||||
return
|
||||
}
|
||||
|
||||
// When the `formattedDerivedValue` changes while the field that is not set as the `exactCurrencyField` is focused, we want to reset the cursor selection to the end of the input.
|
||||
// This to prevent an issue that happens with the cursor selection getting out of sync when a user changes focus from one input to another while a quote request in in flight.
|
||||
moveCursorToEnd()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formattedDerivedValue])
|
||||
|
||||
const exactValue = isFiatMode ? exactAmountFiat : exactAmountToken
|
||||
const exactValueRef = isFiatMode ? exactAmountFiatRef : exactAmountTokenRef
|
||||
|
||||
const decimalPadValueRef =
|
||||
decimalPadControlledField === exactCurrencyField ? exactValueRef : formattedDerivedValueRef
|
||||
|
||||
// Animated background color on input panels based on focus
|
||||
const colorTransitionProgress = useDerivedValue(() => {
|
||||
return withTiming(focusFieldIsInput ? 0 : 1, { duration: 250 })
|
||||
}, [focusFieldIsInput])
|
||||
const inputColorTransitionProgress = useDerivedValue(() => {
|
||||
return withTiming(focusOnCurrencyField === CurrencyField.INPUT ? 0 : 1, { duration: 250 })
|
||||
}, [focusOnCurrencyField])
|
||||
|
||||
const outputColorTransitionProgress = useDerivedValue(() => {
|
||||
return withTiming(focusOnCurrencyField === CurrencyField.OUTPUT ? 0 : 1, { duration: 250 })
|
||||
}, [focusOnCurrencyField])
|
||||
|
||||
const inputBackgroundStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
backgroundColor: interpolateColor(
|
||||
colorTransitionProgress.value,
|
||||
inputColorTransitionProgress.value,
|
||||
[0, 1],
|
||||
[colors.surface1.val, colors.surface2.val]
|
||||
),
|
||||
@@ -316,9 +378,9 @@ function SwapFormContent(): JSX.Element {
|
||||
const outputBackgroundStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
backgroundColor: interpolateColor(
|
||||
colorTransitionProgress.value,
|
||||
outputColorTransitionProgress.value,
|
||||
[0, 1],
|
||||
[colors.surface2.val, colors.surface1.val]
|
||||
[colors.surface1.val, colors.surface2.val]
|
||||
),
|
||||
}
|
||||
})
|
||||
@@ -337,9 +399,10 @@ function SwapFormContent(): JSX.Element {
|
||||
ref={inputRef}
|
||||
currencyAmount={currencyAmounts[CurrencyField.INPUT]}
|
||||
currencyBalance={currencyBalances[CurrencyField.INPUT]}
|
||||
currencyField={CurrencyField.INPUT}
|
||||
currencyInfo={currencies[CurrencyField.INPUT]}
|
||||
focus={focusFieldIsInput}
|
||||
isCollapsed={focusOnCurrencyField ? !focusFieldIsInput : !exactFieldIsInput}
|
||||
focus={focusOnCurrencyField === CurrencyField.INPUT}
|
||||
isCollapsed={decimalPadControlledField !== CurrencyField.INPUT}
|
||||
isFiatMode={isFiatMode && exactFieldIsInput}
|
||||
isLoading={!exactFieldIsInput && isSwapDataLoading}
|
||||
resetSelection={resetSelection}
|
||||
@@ -369,12 +432,12 @@ function SwapFormContent(): JSX.Element {
|
||||
style={outputBackgroundStyle}>
|
||||
<CurrencyInputPanel
|
||||
ref={outputRef}
|
||||
isOutput
|
||||
currencyAmount={currencyAmounts[CurrencyField.OUTPUT]}
|
||||
currencyBalance={currencyBalances[CurrencyField.OUTPUT]}
|
||||
currencyField={CurrencyField.OUTPUT}
|
||||
currencyInfo={currencies[CurrencyField.OUTPUT]}
|
||||
focus={focusFieldIsOutput}
|
||||
isCollapsed={focusOnCurrencyField ? !focusFieldIsOutput : !exactFieldIsOutput}
|
||||
focus={focusOnCurrencyField === CurrencyField.OUTPUT}
|
||||
isCollapsed={decimalPadControlledField !== CurrencyField.OUTPUT}
|
||||
isFiatMode={isFiatMode && exactFieldIsOutput}
|
||||
isLoading={!exactFieldIsOutput && isSwapDataLoading}
|
||||
resetSelection={resetSelection}
|
||||
@@ -436,20 +499,14 @@ function SwapFormContent(): JSX.Element {
|
||||
right={0}
|
||||
style={decimalPadAndButtonAnimatedStyle}>
|
||||
<Flex grow justifyContent="flex-end">
|
||||
{focusOnCurrencyField && (
|
||||
<DecimalPadInput
|
||||
ref={decimalPadRef}
|
||||
resetSelection={resetSelection}
|
||||
selectionRef={focusOnCurrencyField ? selection[focusOnCurrencyField] : undefined}
|
||||
setValue={decimalPadSetValue}
|
||||
valueRef={
|
||||
focusOnCurrencyField === exactCurrencyField
|
||||
? exactValueRef
|
||||
: formattedDerivedValueRef
|
||||
}
|
||||
onReady={onDecimalPadReady}
|
||||
/>
|
||||
)}
|
||||
<DecimalPadInput
|
||||
ref={decimalPadRef}
|
||||
resetSelection={resetSelection}
|
||||
selectionRef={selection[decimalPadControlledField]}
|
||||
setValue={decimalPadSetValue}
|
||||
valueRef={decimalPadValueRef}
|
||||
onReady={onDecimalPadReady}
|
||||
/>
|
||||
</Flex>
|
||||
</AnimatedFlex>
|
||||
</Flex>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getUniqueId } from 'react-native-device-info'
|
||||
import { navigate } from 'src/app/navigation/rootNavigation'
|
||||
import { UnitagStackScreenProp } from 'src/app/navigation/types'
|
||||
import { Screen } from 'src/components/layout/Screen'
|
||||
@@ -10,7 +11,10 @@ import { OnboardingScreens, Screens, UnitagScreens } from 'src/screens/Screens'
|
||||
import { Button, Flex, Icons, Text, useDeviceInsets } from 'ui/src'
|
||||
import Unitag from 'ui/src/assets/icons/unitag.svg'
|
||||
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 { useClaimUnitagMutation } from 'wallet/src/features/unitags/api'
|
||||
import { parseUnitagErrorCode } from 'wallet/src/features/unitags/utils'
|
||||
import { useActiveAccountAddress, usePendingAccounts } from 'wallet/src/features/wallet/hooks'
|
||||
|
||||
export function ChooseProfilePictureScreen({
|
||||
@@ -25,6 +29,17 @@ export function ChooseProfilePictureScreen({
|
||||
const { t } = useTranslation()
|
||||
const [imageUri, setImageUri] = useState<string>()
|
||||
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 => {
|
||||
setShowModal(true)
|
||||
@@ -34,7 +49,30 @@ export function ChooseProfilePictureScreen({
|
||||
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 (!activeAddress) {
|
||||
throw new Error('activeAddress should never be null when Unitag entryPoint is Home Screen')
|
||||
@@ -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 (
|
||||
<Screen edges={['right', 'left']}>
|
||||
@@ -95,8 +156,17 @@ export function ChooseProfilePictureScreen({
|
||||
<Unitag height={iconSizes.icon24} width={iconSizes.icon24} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
{!!claimError && (
|
||||
<Text color="$statusCritical" variant="body2">
|
||||
{claimError}
|
||||
</Text>
|
||||
)}
|
||||
</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')}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useKeyboardLayout } from 'src/utils/useKeyboardLayout'
|
||||
import { Button, Flex, Icons, Text, useSporeColors } from 'ui/src'
|
||||
import { fonts, iconSizes } from 'ui/src/theme'
|
||||
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 { shortenAddress } from 'wallet/src/utils/addresses'
|
||||
|
||||
@@ -33,6 +34,9 @@ export function ChooseUnitag({
|
||||
const unitagAddress = activeAddress || pendingAccountAddress
|
||||
const [unitag, setUnitag] = useState<string | undefined>(undefined)
|
||||
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 => {
|
||||
if (unitag !== text?.trim()) {
|
||||
@@ -116,11 +120,12 @@ export function ChooseUnitag({
|
||||
<Flex fill justifyContent="space-between">
|
||||
<UnitagInput
|
||||
activeAddress={entryPoint === Screens.Home ? activeAddress : null}
|
||||
errorMessage={undefined} // TODO (MOB-2105): GET /username/ from unitags backend and surface any errors
|
||||
inputSuffix={true ? UNITAG_SUFFIX : undefined} // TODO (MOB-2105)
|
||||
errorMessage={unitagError}
|
||||
inputSuffix={!showValidUnitagLogo ? UNITAG_SUFFIX : undefined}
|
||||
liveCheck={showLiveCheck}
|
||||
loading={!!unitag && (loading || !showLiveCheck)}
|
||||
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}
|
||||
onChange={onChange}
|
||||
onSubmit={onSubmit}
|
||||
@@ -131,7 +136,11 @@ export function ChooseUnitag({
|
||||
{t('Maybe later')}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="medium" theme="primary" onPress={onPressContinue}>
|
||||
<Button
|
||||
disabled={!showValidUnitagLogo}
|
||||
size="medium"
|
||||
theme="primary"
|
||||
onPress={onPressContinue}>
|
||||
{t('Continue')}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
@@ -14,15 +14,14 @@ import { Button, Flex, Icons, Text, useDeviceInsets } from 'ui/src'
|
||||
import { iconSizes, imageSizes } from 'ui/src/theme'
|
||||
import { ChainId } from 'wallet/src/constants/chains'
|
||||
import { useENS } from 'wallet/src/features/ens/useENS'
|
||||
import { useUnitag } from 'wallet/src/features/unitags/hooks'
|
||||
import { shortenAddress } from 'wallet/src/utils/addresses'
|
||||
|
||||
export function EditProfileScreen({
|
||||
route,
|
||||
}: UnitagStackScreenProp<UnitagScreens.EditProfile>): JSX.Element {
|
||||
// TODO (MOB-1314): add backend call to get unitag from address
|
||||
const unitag = 'placeholder'
|
||||
|
||||
const { address } = route.params
|
||||
const unitag = useUnitag(address)
|
||||
const { name: ensName } = useENS(ChainId.Mainnet, address)
|
||||
const navigation = useNavigation()
|
||||
const insets = useDeviceInsets()
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'react-native'
|
||||
import { FadeIn, FadeOut } from 'react-native-reanimated'
|
||||
import { AddressDisplay } from 'src/components/AddressDisplay'
|
||||
import { SpinningLoader } from 'src/components/loading/SpinningLoader'
|
||||
import { WalletSelectorModal } from 'src/components/unitags/WalletSelectorModal'
|
||||
import InputWithSuffix from 'src/features/import/InputWithSuffix'
|
||||
import { UNITAG_SUFFIX } from 'src/features/unitags/constants'
|
||||
@@ -31,6 +32,7 @@ type UnitagInputProps = {
|
||||
onSubmit?: () => void
|
||||
inputSuffix?: string
|
||||
liveCheck?: boolean
|
||||
loading?: boolean
|
||||
showUnitagLogo: boolean
|
||||
onBlur?: () => void
|
||||
onFocus?: () => void
|
||||
@@ -45,6 +47,7 @@ export function UnitagInput({
|
||||
onSubmit,
|
||||
onChange,
|
||||
liveCheck,
|
||||
loading,
|
||||
placeholderLabel,
|
||||
showUnitagLogo,
|
||||
errorMessage,
|
||||
@@ -129,7 +132,16 @@ export function UnitagInput({
|
||||
onFocus={handleFocus}
|
||||
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>
|
||||
{!value && (
|
||||
<AnimatedFlex
|
||||
|
||||
@@ -10,10 +10,11 @@ import { CurrencyId } from 'wallet/src/utils/currencyId'
|
||||
import { isAndroid } from 'wallet/src/utils/platform'
|
||||
|
||||
const APP_GROUP = 'group.com.uniswap.widgets'
|
||||
const WIDGET_EVENTS_KEY = getBuildVariant() + '.widgets.configuration.events'
|
||||
const WIDGET_CACHE_KEY = getBuildVariant() + '.widgets.configuration.cache'
|
||||
const FAVORITE_WIDGETS_KEY = getBuildVariant() + '.widgets.favorites'
|
||||
const ACCOUNTS_WIDGETS_KEY = getBuildVariant() + '.widgets.accounts'
|
||||
const KEY_WIDGET_EVENTS = getBuildVariant() + '.widgets.configuration.events'
|
||||
const KEY_WIDGET_CACHE = getBuildVariant() + '.widgets.configuration.cache'
|
||||
const KEY_WIDGETS_FAVORITE = getBuildVariant() + '.widgets.favorites'
|
||||
const KEY_WIDGETS_ACCOUNTS = getBuildVariant() + '.widgets.accounts'
|
||||
const KEY_WIDGETS_I18N = getBuildVariant() + '.widgets.i18n'
|
||||
|
||||
const { RNWidgets } = NativeModules
|
||||
|
||||
@@ -40,6 +41,11 @@ export type WidgetConfiguration = {
|
||||
family: string
|
||||
}
|
||||
|
||||
export type WidgetI18nSettings = {
|
||||
locale: string
|
||||
currency: string
|
||||
}
|
||||
|
||||
export const setUserDefaults = async (data: object, key: string): Promise<void> => {
|
||||
const dataJSON = JSON.stringify(data)
|
||||
await setItem(key, dataJSON, APP_GROUP)
|
||||
@@ -55,7 +61,7 @@ export const setFavoritesUserDefaults = (currencyIds: CurrencyId[]): void => {
|
||||
const data = {
|
||||
favorites,
|
||||
}
|
||||
setUserDefaults(data, FAVORITE_WIDGETS_KEY).catch(() => undefined)
|
||||
setUserDefaults(data, KEY_WIDGETS_FAVORITE).catch(() => undefined)
|
||||
}
|
||||
|
||||
export const setAccountAddressesUserDefaults = (accounts: Account[]): void => {
|
||||
@@ -70,7 +76,11 @@ export const setAccountAddressesUserDefaults = (accounts: Account[]): void => {
|
||||
const data = {
|
||||
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,
|
||||
@@ -79,7 +89,7 @@ export const setAccountAddressesUserDefaults = (accounts: Account[]): void => {
|
||||
async function handleLastRemovalEvents(): Promise<void> {
|
||||
const areWidgetsInstalled = await hasWidgetsInstalled()
|
||||
if (!areWidgetsInstalled) {
|
||||
const widgetCacheJSONString = await getItem(WIDGET_CACHE_KEY, APP_GROUP)
|
||||
const widgetCacheJSONString = await getItem(KEY_WIDGET_CACHE, APP_GROUP)
|
||||
if (!widgetCacheJSONString) {
|
||||
return
|
||||
}
|
||||
@@ -91,14 +101,14 @@ async function handleLastRemovalEvents(): Promise<void> {
|
||||
change: 'removed',
|
||||
})
|
||||
})
|
||||
await setUserDefaults({ configuration: [] }, WIDGET_CACHE_KEY)
|
||||
await setUserDefaults({ configuration: [] }, KEY_WIDGET_CACHE)
|
||||
}
|
||||
}
|
||||
|
||||
export async function processWidgetEvents(): Promise<void> {
|
||||
reloadAllTimelines()
|
||||
await handleLastRemovalEvents()
|
||||
const widgetEventsJSONString = await getItem(WIDGET_EVENTS_KEY, APP_GROUP)
|
||||
const widgetEventsJSONString = await getItem(KEY_WIDGET_EVENTS, APP_GROUP)
|
||||
|
||||
if (!widgetEventsJSONString) {
|
||||
return
|
||||
@@ -110,7 +120,7 @@ export async function processWidgetEvents(): Promise<void> {
|
||||
|
||||
if (widgetEvents.events.length > 0) {
|
||||
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
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
interpolateColor,
|
||||
runOnJS,
|
||||
useAnimatedGestureHandler,
|
||||
@@ -82,6 +83,7 @@ import { useInterval, useTimeout } from 'utilities/src/time/timing'
|
||||
import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants'
|
||||
import { useFeatureFlag } from 'wallet/src/features/experiments/hooks'
|
||||
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 { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks'
|
||||
import { HomeScreenTabIndex } from './HomeScreenTabIndex'
|
||||
@@ -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 contentHeader = useMemo(() => {
|
||||
return (
|
||||
@@ -384,10 +386,14 @@ export function HomeScreen(props?: AppStackScreenProp<Screens.Home>): JSX.Elemen
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
{unitagsFeatureFlagEnabled && <UnitagBanner />}
|
||||
{hasClaimEligibility && (
|
||||
<AnimatedFlex entering={FadeIn} exiting={FadeOut}>
|
||||
<UnitagBanner />
|
||||
</AnimatedFlex>
|
||||
)}
|
||||
</Flex>
|
||||
)
|
||||
}, [activeAccount.address, isSignerAccount, viewOnlyLabel, actions, unitagsFeatureFlagEnabled])
|
||||
}, [activeAccount.address, isSignerAccount, viewOnlyLabel, actions, hasClaimEligibility])
|
||||
|
||||
const contentContainerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => ({
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
PendingAccountActions,
|
||||
pendingAccountActions,
|
||||
} 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'
|
||||
|
||||
type Props = NativeStackScreenProps<OnboardingStackParamList, OnboardingScreens.RestoreCloudBackup>
|
||||
@@ -24,6 +24,7 @@ type Props = NativeStackScreenProps<OnboardingStackParamList, OnboardingScreens.
|
||||
export function RestoreCloudBackupScreen({ navigation, route: { params } }: Props): JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const dispatch = useAppDispatch()
|
||||
// const backups = useMockCloudBackups(4) // returns 4 mock backups with random mnemonicIds and createdAt dates
|
||||
const backups = useCloudBackups()
|
||||
const sortedBackups = backups.slice().sort((a, b) => b.createdAt - a.createdAt)
|
||||
|
||||
@@ -50,7 +51,7 @@ export function RestoreCloudBackupScreen({ navigation, route: { params } }: Prop
|
||||
title={t('Select backup to restore')}>
|
||||
<ScrollView>
|
||||
<Flex gap="$spacing8">
|
||||
{sortedBackups.map((backup, index) => {
|
||||
{sortedBackups.map((backup) => {
|
||||
const { mnemonicId, createdAt } = backup
|
||||
return (
|
||||
<TouchableArea
|
||||
@@ -65,25 +66,15 @@ export function RestoreCloudBackupScreen({ navigation, route: { params } }: Prop
|
||||
<Flex centered row gap="$spacing12">
|
||||
<Unicon address={mnemonicId} size={32} />
|
||||
<Flex>
|
||||
<Text numberOfLines={1} variant="subheading2">
|
||||
{t('Backup {{backupIndex}}', { backupIndex: sortedBackups.length - index })}
|
||||
<Text adjustsFontSizeToFit variant="subheading1">
|
||||
{sanitizeAddressText(shortenAddress(mnemonicId))}
|
||||
</Text>
|
||||
<Text 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">
|
||||
<Text adjustsFontSizeToFit color="$neutral2" variant="buttonLabel4">
|
||||
{dayjs.unix(createdAt).format('MMM D, YYYY, h:mma')}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Icons.RotatableChevron color="$neutral1" direction="end" />
|
||||
</Flex>
|
||||
<Icons.RotatableChevron color="$neutral2" direction="end" />
|
||||
</Flex>
|
||||
</TouchableArea>
|
||||
)
|
||||
|
||||
@@ -297,6 +297,7 @@ function NFTItemScreenContents({
|
||||
<Flex gap="$spacing12" px="$spacing24">
|
||||
{listingPrice?.value ? (
|
||||
<AssetMetadata
|
||||
color={accentTextColor}
|
||||
title={t('Current price')}
|
||||
valueComponent={
|
||||
<PriceAmount
|
||||
@@ -310,6 +311,7 @@ function NFTItemScreenContents({
|
||||
) : null}
|
||||
{lastSaleData?.price?.value ? (
|
||||
<AssetMetadata
|
||||
color={accentTextColor}
|
||||
title={t('Last sale price')}
|
||||
valueComponent={
|
||||
<PriceAmount
|
||||
@@ -324,6 +326,7 @@ function NFTItemScreenContents({
|
||||
|
||||
{owner && (
|
||||
<AssetMetadata
|
||||
color={accentTextColor}
|
||||
title={t('Owned by')}
|
||||
valueComponent={
|
||||
<TouchableArea
|
||||
@@ -365,14 +368,17 @@ function NFTItemScreenContents({
|
||||
function AssetMetadata({
|
||||
title,
|
||||
valueComponent,
|
||||
color,
|
||||
}: {
|
||||
title: string
|
||||
valueComponent: JSX.Element
|
||||
color: string
|
||||
}): JSX.Element {
|
||||
const colors = useSporeColors()
|
||||
return (
|
||||
<Flex row alignItems="center" justifyContent="space-between" pl="$spacing2">
|
||||
<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}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
@@ -18,9 +18,8 @@ import { Button, Flex, Text, TouchableArea } from 'ui/src'
|
||||
import { useTimeout } from 'utilities/src/time/timing'
|
||||
import { uniswapUrls } from 'wallet/src/constants/urls'
|
||||
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 { useCanAddressClaimUnitag } from 'wallet/src/features/unitags/hooks'
|
||||
import { createAccountActions } from 'wallet/src/features/wallet/create/createAccountSaga'
|
||||
import {
|
||||
PendingAccountActions,
|
||||
@@ -33,9 +32,7 @@ export function LandingScreen({ navigation }: Props): JSX.Element {
|
||||
const dispatch = useAppDispatch()
|
||||
const { t } = useTranslation()
|
||||
const isDarkMode = useIsDarkMode()
|
||||
const unitagsFeatureFlagEnabled = useFeatureFlag(FEATURE_FLAGS.Unitags)
|
||||
// TODO (MOB-1314): request /claim/eligibility/ from unitags backend
|
||||
const canClaimUnitag = true && unitagsFeatureFlagEnabled
|
||||
const canClaimUnitag = useCanAddressClaimUnitag()
|
||||
|
||||
const onPressCreateWallet = (): void => {
|
||||
dispatch(pendingAccountActions.trigger(PendingAccountActions.Delete))
|
||||
|
||||
@@ -44,6 +44,7 @@ import { useENS } from 'wallet/src/features/ens/useENS'
|
||||
import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants'
|
||||
import { useFeatureFlag } from 'wallet/src/features/experiments/hooks'
|
||||
import { ImportType, OnboardingEntryPoint } from 'wallet/src/features/onboarding/types'
|
||||
import { useUnitag } from 'wallet/src/features/unitags/hooks'
|
||||
import {
|
||||
EditAccountAction,
|
||||
editAccountActions,
|
||||
@@ -304,9 +305,7 @@ const renderItemSeparator = (): JSX.Element => <Flex pt="$spacing8" />
|
||||
function AddressDisplayHeader({ address }: { address: Address }): JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const ensName = useENS(ChainId.Mainnet, address)?.name
|
||||
const unitagsFeatureFlagEnabled = useFeatureFlag(FEATURE_FLAGS.Unitags)
|
||||
// TODO (MOB-2122): GET /address from unitags backend to check for unitag
|
||||
const hasUnitag = false && unitagsFeatureFlagEnabled
|
||||
const hasUnitag = !!useUnitag(address)
|
||||
|
||||
const onPressEditProfile = (): void => {
|
||||
if (hasUnitag) {
|
||||
|
||||
@@ -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_TEMP_API_URL="https://temp.api.uniswap.org/v1"
|
||||
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"
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
"test:cloud": "yarn jest functions --config=functions/jest.config.json",
|
||||
"cypress:open": "cypress open --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": {
|
||||
"hooks": {
|
||||
@@ -100,7 +101,7 @@
|
||||
"@types/lingui__react": "2.8.3",
|
||||
"@types/ms": "0.7.31",
|
||||
"@types/multicodec": "1.0.0",
|
||||
"@types/node": "13.13.5",
|
||||
"@types/node": "18.16.0",
|
||||
"@types/qs": "6.9.2",
|
||||
"@types/react": "^18.0.15",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
# *
|
||||
User-agent: *
|
||||
Disallow: /static/js/
|
||||
Allow: /
|
||||
|
||||
# Host
|
||||
Host: https://app.uniswap.org
|
||||
Disallow:
|
||||
|
||||
# Sitemaps
|
||||
Sitemap: https://app.uniswap.org/sitemap.xml
|
||||
Sitemap: https://app.uniswap.org/sitemap.xml
|
||||
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
@@ -43,7 +43,7 @@ function StatusIndicator({ activity: { status, timestamp } }: { activity: Activi
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
const openOffchainActivityModal = useOpenOffchainActivityModal()
|
||||
@@ -52,13 +52,13 @@ export function ActivityRow({ activity }: { activity: Activity }) {
|
||||
const explorerUrl = getExplorerLink(chainId, hash, ExplorerDataType.TRANSACTION)
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
if (offchainOrderStatus) {
|
||||
openOffchainActivityModal({ orderHash: hash, status: offchainOrderStatus })
|
||||
if (offchainOrderDetails) {
|
||||
openOffchainActivityModal(offchainOrderDetails)
|
||||
return
|
||||
}
|
||||
|
||||
window.open(getExplorerLink(chainId, hash, ExplorerDataType.TRANSACTION), '_blank')
|
||||
}, [offchainOrderStatus, chainId, hash, openOffchainActivityModal])
|
||||
}, [chainId, hash, offchainOrderDetails, openOffchainActivityModal])
|
||||
|
||||
return (
|
||||
<TraceEvent
|
||||
|
||||
@@ -15,16 +15,15 @@ import { useCallback, useMemo } from 'react'
|
||||
import { X } from 'react-feather'
|
||||
import { InterfaceTrade } from 'state/routing/types'
|
||||
import { useOrder } from 'state/signatures/hooks'
|
||||
import { UniswapXOrderDetails } from 'state/signatures/types'
|
||||
import styled from 'styled-components'
|
||||
import { ExternalLink, ThemedText } from 'theme/components'
|
||||
import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink'
|
||||
|
||||
import { OffchainOrderDetails } from './types'
|
||||
|
||||
type SelectedOrderInfo = {
|
||||
modalOpen?: boolean
|
||||
orderHash: string
|
||||
status: UniswapXOrderStatus
|
||||
details?: UniswapXOrderDetails
|
||||
order?: OffchainOrderDetails
|
||||
}
|
||||
|
||||
const selectedOrderAtom = atom<SelectedOrderInfo | undefined>(undefined)
|
||||
@@ -32,10 +31,7 @@ const selectedOrderAtom = atom<SelectedOrderInfo | undefined>(undefined)
|
||||
export function useOpenOffchainActivityModal() {
|
||||
const setSelectedOrder = useUpdateAtom(selectedOrderAtom)
|
||||
|
||||
return useCallback(
|
||||
(order: { orderHash: string; status: UniswapXOrderStatus }) => setSelectedOrder({ ...order, modalOpen: true }),
|
||||
[setSelectedOrder]
|
||||
)
|
||||
return useCallback((order: OffchainOrderDetails) => setSelectedOrder({ order, modalOpen: true }), [setSelectedOrder])
|
||||
}
|
||||
|
||||
const Wrapper = styled(AutoColumn).attrs({ gap: 'md', grow: true })`
|
||||
@@ -89,19 +85,19 @@ const DescriptionText = styled(ThemedText.LabelMicro)`
|
||||
`
|
||||
|
||||
function useOrderAmounts(
|
||||
orderDetails?: UniswapXOrderDetails
|
||||
order?: OffchainOrderDetails
|
||||
): Pick<InterfaceTrade, 'inputAmount' | 'outputAmount'> | undefined {
|
||||
const inputCurrency = useCurrency(orderDetails?.swapInfo?.inputCurrencyId, orderDetails?.chainId)
|
||||
const outputCurrency = useCurrency(orderDetails?.swapInfo?.outputCurrencyId, orderDetails?.chainId)
|
||||
const inputCurrency = useCurrency(order?.swapInfo?.inputCurrencyId, order?.chainId)
|
||||
const outputCurrency = useCurrency(order?.swapInfo?.outputCurrencyId, order?.chainId)
|
||||
|
||||
if (!orderDetails) return undefined
|
||||
if (!order || !order?.swapInfo) return undefined
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const { swapInfo } = orderDetails
|
||||
const { swapInfo } = order
|
||||
|
||||
if (swapInfo.tradeType === TradeType.EXACT_INPUT) {
|
||||
return {
|
||||
@@ -119,11 +115,11 @@ function useOrderAmounts(
|
||||
}
|
||||
}
|
||||
|
||||
export function OrderContent({ order }: { order: SelectedOrderInfo }) {
|
||||
const amounts = useOrderAmounts(order.details)
|
||||
export function OrderContent({ order }: { order: OffchainOrderDetails }) {
|
||||
const amounts = useOrderAmounts(order)
|
||||
|
||||
const explorerLink = order?.details?.txHash
|
||||
? getExplorerLink(order.details.chainId, order.details.txHash, ExplorerDataType.TRANSACTION)
|
||||
const explorerLink = order?.txHash
|
||||
? getExplorerLink(order.chainId, order.txHash, ExplorerDataType.TRANSACTION)
|
||||
: undefined
|
||||
|
||||
switch (order.status) {
|
||||
@@ -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 */
|
||||
function useSyncedSelectedOrder(): SelectedOrderInfo | undefined {
|
||||
function useSyncedSelectedOrder(): OffchainOrderDetails | undefined {
|
||||
const selectedOrder = useAtomValue(selectedOrderAtom)
|
||||
const localPendingOrder = useOrder(selectedOrder?.orderHash ?? '')
|
||||
const localPendingOrder = useOrder(selectedOrder?.order?.txHash ?? '')
|
||||
|
||||
return useMemo(() => {
|
||||
if (!selectedOrder) return undefined
|
||||
if (!selectedOrder?.order) return undefined
|
||||
|
||||
return {
|
||||
...selectedOrder,
|
||||
status: localPendingOrder?.status ?? selectedOrder.status,
|
||||
details: localPendingOrder,
|
||||
...selectedOrder.order,
|
||||
...localPendingOrder,
|
||||
}
|
||||
}, [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() {
|
||||
const selectedOrderAtomValue = useAtomValue(selectedOrderAtom)
|
||||
|
||||
const syncedSelectedOrder = useSyncedSelectedOrder()
|
||||
const setSelectedOrder = useUpdateAtom(selectedOrderAtom)
|
||||
|
||||
@@ -248,7 +256,7 @@ export function OffchainActivityModal() {
|
||||
}, [setSelectedOrder])
|
||||
|
||||
return (
|
||||
<Modal isOpen={!!syncedSelectedOrder?.modalOpen} onDismiss={reset}>
|
||||
<Modal isOpen={!!selectedOrderAtomValue?.modalOpen} onDismiss={reset}>
|
||||
<Wrapper data-testid="offchain-activity-modal">
|
||||
<StyledXButton onClick={reset} />
|
||||
{syncedSelectedOrder && <OrderContent order={syncedSelectedOrder} />}
|
||||
|
||||
@@ -100,7 +100,23 @@ Object {
|
||||
"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",
|
||||
"status": "FAILED",
|
||||
"statusMessage": "Your swap could not be fulfilled at this time. Please try again.",
|
||||
@@ -375,6 +391,23 @@ Object {
|
||||
"logoUrl",
|
||||
],
|
||||
"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",
|
||||
"status": "CONFIRMED",
|
||||
"timestamp": 10000,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
TokenApprovalPartsFragment,
|
||||
TokenStandard,
|
||||
TokenTransferPartsFragment,
|
||||
TransactionDetailsPartsFragment,
|
||||
TransactionDirection,
|
||||
TransactionStatus,
|
||||
TransactionType,
|
||||
@@ -23,6 +24,18 @@ const MockOrderTimestamp = 10000
|
||||
const MockRecipientAddress = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'
|
||||
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 = {
|
||||
__typename: 'AssetActivity',
|
||||
id: 'activityId',
|
||||
@@ -199,7 +212,7 @@ const mockSpamNftTransferPartsFragment: NftTransferPartsFragment = {
|
||||
},
|
||||
}
|
||||
|
||||
const mockTokenTransferOutPartsFragment: TokenTransferPartsFragment = {
|
||||
export const mockTokenTransferOutPartsFragment: TokenTransferPartsFragment = {
|
||||
__typename: 'TokenTransfer',
|
||||
id: 'tokenTransferId',
|
||||
tokenStandard: TokenStandard.Erc20,
|
||||
@@ -307,7 +320,7 @@ const mockWrappedEthTransferInPartsFragment: TokenTransferPartsFragment = {
|
||||
},
|
||||
}
|
||||
|
||||
const mockTokenTransferInPartsFragment: TokenTransferPartsFragment = {
|
||||
export const mockTokenTransferInPartsFragment: TokenTransferPartsFragment = {
|
||||
__typename: 'TokenTransfer',
|
||||
id: 'tokenTransferId',
|
||||
tokenStandard: TokenStandard.Erc20,
|
||||
|
||||
@@ -253,7 +253,13 @@ export function signatureToActivity(
|
||||
chainId: signature.chainId,
|
||||
title,
|
||||
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,
|
||||
from: signature.offerer,
|
||||
statusMessage,
|
||||
|
||||
@@ -19,9 +19,25 @@ import {
|
||||
MockTokenApproval,
|
||||
MockTokenReceive,
|
||||
MockTokenSend,
|
||||
mockTokenTransferInPartsFragment,
|
||||
mockTokenTransferOutPartsFragment,
|
||||
mockTransactionDetailsPartsFragment,
|
||||
MockWrap,
|
||||
} 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', () => {
|
||||
beforeEach(() => {
|
||||
@@ -141,4 +157,68 @@ describe('parseRemote', () => {
|
||||
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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from '@lingui/macro'
|
||||
import { ChainId, Currency, NONFUNGIBLE_POSITION_MANAGER_ADDRESSES, UNI_ADDRESSES } from '@uniswap/sdk-core'
|
||||
import { ChainId, Currency, NONFUNGIBLE_POSITION_MANAGER_ADDRESSES, TradeType, UNI_ADDRESSES } from '@uniswap/sdk-core'
|
||||
import UniswapXBolt from 'assets/svg/bolt.svg'
|
||||
import moonpayLogoSrc from 'assets/svg/moonpay.svg'
|
||||
import { nativeOnChain } from 'constants/tokens'
|
||||
@@ -18,14 +18,21 @@ import {
|
||||
TransactionType,
|
||||
} from 'graphql/data/__generated__/types-and-hooks'
|
||||
import { gqlToCurrency, logSentryErrorForUnsupportedChain, supportedChainIdFromGQLChain } from 'graphql/data/util'
|
||||
import { UniswapXOrderStatus } from 'lib/hooks/orders/types'
|
||||
import tryParseCurrencyAmount from 'lib/utils/tryParseCurrencyAmount'
|
||||
import ms from 'ms'
|
||||
import { useEffect, useState } from 'react'
|
||||
import store from 'state'
|
||||
import { addSignature } from 'state/signatures/reducer'
|
||||
import { SignatureType } from 'state/signatures/types'
|
||||
import { TransactionType as LocalTransactionType } from 'state/transactions/types'
|
||||
import { isAddress } from 'utils'
|
||||
import { isSameAddress } from 'utils/addresses'
|
||||
import { currencyId } from 'utils/currencyId'
|
||||
import { NumberType, useFormatter } from 'utils/formatNumbers'
|
||||
|
||||
import { MOONPAY_SENDER_ADDRESSES, OrderStatusTable, OrderTextTable } from '../constants'
|
||||
import { Activity } from './types'
|
||||
import { Activity, OffchainOrderDetails } from './types'
|
||||
|
||||
type TransactionChanges = {
|
||||
NftTransfer: NftTransferPartsFragment[]
|
||||
@@ -155,6 +162,36 @@ function getTransactedValue(transactedValue: TokenTransferPartsFragment['transac
|
||||
return price
|
||||
}
|
||||
|
||||
// exported for testing
|
||||
// eslint-disable-next-line import/no-unused-modules
|
||||
export function parseSwapAmounts(
|
||||
changes: TransactionChanges,
|
||||
formatNumberOrString: FormatNumberOrStringFunctionType
|
||||
):
|
||||
| {
|
||||
inputAmount: string
|
||||
inputCurrencyId: string
|
||||
outputAmount: string
|
||||
outputCurrencyId: string
|
||||
sent: TokenTransferPartsFragment
|
||||
received: TokenTransferPartsFragment
|
||||
}
|
||||
| undefined {
|
||||
const sent = changes.TokenTransfer.find((t) => t.direction === 'OUT')
|
||||
// Any leftover native token is refunded on exact_out swaps where the input token is native
|
||||
const refund = changes.TokenTransfer.find(
|
||||
(t) => t.direction === 'IN' && t.asset.id === sent?.asset.id && t.asset.standard === 'NATIVE'
|
||||
)
|
||||
const received = changes.TokenTransfer.find((t) => t.direction === 'IN' && t !== refund)
|
||||
if (!sent || !received) return undefined
|
||||
const inputCurrencyId = sent.asset.id
|
||||
const outputCurrencyId = received.asset.id
|
||||
const adjustedInput = parseFloat(sent.quantity) - parseFloat(refund?.quantity ?? '0')
|
||||
const inputAmount = formatNumberOrString({ input: adjustedInput, type: NumberType.TokenNonTx })
|
||||
const outputAmount = formatNumberOrString({ input: received.quantity, type: NumberType.TokenNonTx })
|
||||
return { sent, received, inputAmount, outputAmount, inputCurrencyId, outputCurrencyId }
|
||||
}
|
||||
|
||||
function parseSwap(changes: TransactionChanges, formatNumberOrString: FormatNumberOrStringFunctionType) {
|
||||
if (changes.NftTransfer.length > 0 && changes.TokenTransfer.length === 1) {
|
||||
const collectionCounts = getCollectionCounts(changes.NftTransfer)
|
||||
@@ -168,17 +205,10 @@ function parseSwap(changes: TransactionChanges, formatNumberOrString: FormatNumb
|
||||
}
|
||||
// Some swaps may have more than 2 transfers, e.g. swaps with fees on tranfer
|
||||
if (changes.TokenTransfer.length >= 2) {
|
||||
const sent = changes.TokenTransfer.find((t) => t.direction === 'OUT')
|
||||
// Any leftover native token is refunded on exact_out swaps where the input token is native
|
||||
const refund = changes.TokenTransfer.find(
|
||||
(t) => t.direction === 'IN' && t.asset.id === sent?.asset.id && t.asset.standard === 'NATIVE'
|
||||
)
|
||||
const received = changes.TokenTransfer.find((t) => t.direction === 'IN' && t !== refund)
|
||||
const swapAmounts = parseSwapAmounts(changes, formatNumberOrString)
|
||||
|
||||
if (sent && received) {
|
||||
const adjustedInput = parseFloat(sent.quantity) - parseFloat(refund?.quantity ?? '0')
|
||||
const inputAmount = formatNumberOrString({ input: adjustedInput, type: NumberType.TokenNonTx })
|
||||
const outputAmount = formatNumberOrString({ input: received.quantity, type: NumberType.TokenNonTx })
|
||||
if (swapAmounts) {
|
||||
const { sent, received, inputAmount, outputAmount } = swapAmounts
|
||||
return {
|
||||
title: getSwapTitle(sent, received),
|
||||
descriptor: getSwapDescriptor({ tokenIn: sent.asset, inputAmount, tokenOut: received.asset, outputAmount }),
|
||||
@@ -202,8 +232,55 @@ function parseLend(changes: TransactionChanges, formatNumberOrString: FormatNumb
|
||||
return { title: t`Unknown Lend` }
|
||||
}
|
||||
|
||||
function parseSwapOrder(changes: TransactionChanges, formatNumberOrString: FormatNumberOrStringFunctionType) {
|
||||
return { ...parseSwap(changes, formatNumberOrString), prefixIconSrc: UniswapXBolt }
|
||||
function parseSwapOrder(
|
||||
changes: TransactionChanges,
|
||||
formatNumberOrString: FormatNumberOrStringFunctionType,
|
||||
assetActivity: TransactionActivity
|
||||
) {
|
||||
return {
|
||||
...parseSwap(changes, formatNumberOrString),
|
||||
prefixIconSrc: UniswapXBolt,
|
||||
offchainOrderDetails: offchainOrderDetailsFromGraphQLTransactionActivity(
|
||||
assetActivity,
|
||||
changes,
|
||||
formatNumberOrString
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// exported for testing
|
||||
// eslint-disable-next-line import/no-unused-modules
|
||||
export function offchainOrderDetailsFromGraphQLTransactionActivity(
|
||||
activity: AssetActivityPartsFragment & { details: TransactionDetailsPartsFragment },
|
||||
changes: TransactionChanges,
|
||||
formatNumberOrString: FormatNumberOrStringFunctionType
|
||||
): OffchainOrderDetails | undefined {
|
||||
const chainId = supportedChainIdFromGQLChain(activity.chain)
|
||||
if (!activity || !activity.details || !chainId) return undefined
|
||||
if (changes.TokenTransfer.length < 2) return undefined
|
||||
|
||||
const swapAmounts = parseSwapAmounts(changes, formatNumberOrString)
|
||||
if (!swapAmounts) return undefined
|
||||
|
||||
const { inputCurrencyId, outputCurrencyId, inputAmount, outputAmount } = swapAmounts
|
||||
|
||||
return {
|
||||
txHash: activity.details.hash,
|
||||
chainId,
|
||||
type: SignatureType.SIGN_UNISWAPX_ORDER,
|
||||
status: UniswapXOrderStatus.FILLED,
|
||||
swapInfo: {
|
||||
isUniswapXOrder: true,
|
||||
type: LocalTransactionType.SWAP,
|
||||
tradeType: TradeType.EXACT_INPUT,
|
||||
inputCurrencyId,
|
||||
outputCurrencyId,
|
||||
inputCurrencyAmountRaw: inputAmount,
|
||||
expectedOutputCurrencyAmountRaw: outputAmount,
|
||||
minimumOutputCurrencyAmountRaw: outputAmount,
|
||||
settledOutputCurrencyAmountRaw: outputAmount,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function parseApprove(changes: TransactionChanges) {
|
||||
@@ -347,9 +424,46 @@ function getLogoSrcs(changes: TransactionChanges): Array<string | undefined> {
|
||||
}
|
||||
|
||||
function parseUniswapXOrder({ details, chain, timestamp }: OrderActivity): Activity | undefined {
|
||||
// We currently only have a polling mechanism for locally-sent pending orders, so we hide remote pending orders since they won't update upon completion
|
||||
// TODO(WEB-2487): Add polling mechanism for remote orders to allow displaying remote pending orders
|
||||
if (details.orderStatus === SwapOrderStatus.Open) return undefined
|
||||
const supportedChain = supportedChainIdFromGQLChain(chain)
|
||||
if (!supportedChain) {
|
||||
logSentryErrorForUnsupportedChain({
|
||||
extras: { details },
|
||||
errorMessage: 'Invalid activity from unsupported chain received from GQL',
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (details.orderStatus === SwapOrderStatus.Open) {
|
||||
const inputCurrency = gqlToCurrency(details.inputToken)
|
||||
const outputCurrency = gqlToCurrency(details.outputToken)
|
||||
store.dispatch(
|
||||
addSignature({
|
||||
type: SignatureType.SIGN_UNISWAPX_ORDER,
|
||||
offerer: details.offerer,
|
||||
id: details.hash,
|
||||
chainId: supportedChain,
|
||||
orderHash: details.hash,
|
||||
expiry: details.expiry,
|
||||
swapInfo: {
|
||||
type: LocalTransactionType.SWAP,
|
||||
inputCurrencyId: currencyId(inputCurrency),
|
||||
outputCurrencyId: currencyId(outputCurrency),
|
||||
isUniswapXOrder: true,
|
||||
// This doesn't affect the display, but we don't know this value from the remote activity.
|
||||
tradeType: TradeType.EXACT_INPUT,
|
||||
inputCurrencyAmountRaw:
|
||||
tryParseCurrencyAmount(details.inputTokenQuantity, inputCurrency)?.quotient.toString() ?? '0',
|
||||
expectedOutputCurrencyAmountRaw:
|
||||
tryParseCurrencyAmount(details.outputTokenQuantity, outputCurrency)?.quotient.toString() ?? '0',
|
||||
minimumOutputCurrencyAmountRaw:
|
||||
tryParseCurrencyAmount(details.outputTokenQuantity, outputCurrency)?.quotient.toString() ?? '0',
|
||||
},
|
||||
status: UniswapXOrderStatus.OPEN,
|
||||
addedTime: timestamp,
|
||||
})
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const { inputToken, inputTokenQuantity, outputToken, outputTokenQuantity, orderStatus } = details
|
||||
const uniswapXOrderStatus = OrderStatusTable[orderStatus]
|
||||
@@ -361,21 +475,28 @@ function parseUniswapXOrder({ details, chain, timestamp }: OrderActivity): Activ
|
||||
outputAmount: outputTokenQuantity,
|
||||
})
|
||||
|
||||
const supportedChain = supportedChainIdFromGQLChain(chain)
|
||||
if (!supportedChain) {
|
||||
logSentryErrorForUnsupportedChain({
|
||||
extras: { details },
|
||||
errorMessage: 'Invalid activity from unsupported chain received from GQL',
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
hash: details.hash,
|
||||
chainId: supportedChain,
|
||||
status,
|
||||
statusMessage,
|
||||
offchainOrderStatus: uniswapXOrderStatus,
|
||||
offchainOrderDetails: {
|
||||
type: SignatureType.SIGN_UNISWAPX_ORDER,
|
||||
txHash: details.hash,
|
||||
chainId: supportedChain,
|
||||
status: uniswapXOrderStatus,
|
||||
swapInfo: {
|
||||
isUniswapXOrder: true,
|
||||
type: LocalTransactionType.SWAP,
|
||||
tradeType: TradeType.EXACT_INPUT,
|
||||
inputCurrencyId: inputToken.id,
|
||||
outputCurrencyId: outputToken.id,
|
||||
inputCurrencyAmountRaw: inputTokenQuantity,
|
||||
expectedOutputCurrencyAmountRaw: outputTokenQuantity,
|
||||
minimumOutputCurrencyAmountRaw: outputTokenQuantity,
|
||||
settledOutputCurrencyAmountRaw: outputTokenQuantity,
|
||||
},
|
||||
},
|
||||
timestamp,
|
||||
logos: [inputToken.project?.logo?.url, outputToken.project?.logo?.url],
|
||||
currencies: [gqlToCurrency(inputToken), gqlToCurrency(outputToken)],
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { ChainId, Currency } from '@uniswap/sdk-core'
|
||||
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 = {
|
||||
hash: string
|
||||
chainId: ChainId
|
||||
status: TransactionStatus
|
||||
// TODO (UniswapX): decouple Activity from UniswapXOrderStatus once we can link UniswapXScan instead of needing data for modal
|
||||
offchainOrderStatus?: UniswapXOrderStatus
|
||||
offchainOrderDetails?: OffchainOrderDetails
|
||||
statusMessage?: string
|
||||
timestamp: number
|
||||
title: string
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
import { InterfaceElementName } from '@uniswap/analytics-events'
|
||||
import { useScreenSize } from 'hooks/useScreenSize'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useHideAndroidAnnouncementBanner } from 'state/user/hooks'
|
||||
import { ThemedText } from 'theme/components'
|
||||
import { useIsDarkMode } from 'theme/components/ThemeToggle'
|
||||
import { openDownloadApp } from 'utils/openDownloadApp'
|
||||
import { isMobileSafari } from 'wallet/src/utils/platform'
|
||||
|
||||
import androidAnnouncementBannerQR from '../../../assets/images/androidAnnouncementBannerQR.png'
|
||||
import darkAndroidThumbnail from '../../../assets/images/AndroidWallet-Thumbnail-Dark.png'
|
||||
import lightAndroidThumbnail from '../../../assets/images/AndroidWallet-Thumbnail-Light.png'
|
||||
import {
|
||||
Container,
|
||||
DownloadButton,
|
||||
PopupContainer,
|
||||
StyledQrCode,
|
||||
StyledXButton,
|
||||
TextContainer,
|
||||
Thumbnail,
|
||||
} from './styled'
|
||||
|
||||
export default function AndroidAnnouncementBanner() {
|
||||
const [hideAndroidAnnouncementBanner, toggleHideAndroidAnnouncementBanner] = useHideAndroidAnnouncementBanner()
|
||||
const location = useLocation()
|
||||
const isLandingScreen = location.search === '?intro=true' || location.pathname === '/'
|
||||
const screenSize = useScreenSize()
|
||||
|
||||
const shouldDisplay = Boolean(!hideAndroidAnnouncementBanner && !isLandingScreen)
|
||||
const isDarkMode = useIsDarkMode()
|
||||
|
||||
const onClick = () =>
|
||||
openDownloadApp({
|
||||
element: InterfaceElementName.UNISWAP_WALLET_BANNER_DOWNLOAD_BUTTON,
|
||||
})
|
||||
|
||||
if (isMobileSafari) return null
|
||||
|
||||
return (
|
||||
<PopupContainer show={shouldDisplay}>
|
||||
<Container>
|
||||
<Thumbnail src={isDarkMode ? darkAndroidThumbnail : lightAndroidThumbnail} alt="Android app thumbnail" />
|
||||
<TextContainer onClick={!screenSize['xs'] ? onClick : undefined}>
|
||||
<ThemedText.BodySmall lineHeight="20px">
|
||||
<Trans>Uniswap on Android</Trans>
|
||||
</ThemedText.BodySmall>
|
||||
<ThemedText.LabelMicro>
|
||||
<Trans>Available now - download from the Google Play Store today</Trans>
|
||||
</ThemedText.LabelMicro>
|
||||
<DownloadButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClick()
|
||||
}}
|
||||
>
|
||||
<Trans>Download now</Trans>
|
||||
</DownloadButton>
|
||||
</TextContainer>
|
||||
<StyledQrCode src={androidAnnouncementBannerQR} alt="App OneLink QR code" />
|
||||
<StyledXButton
|
||||
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>
|
||||
</PopupContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { t, Trans } from '@lingui/macro'
|
||||
import { InterfaceElementName } from '@uniswap/analytics-events'
|
||||
import { useScreenSize } from 'hooks/useScreenSize'
|
||||
import { useMemo } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useHideAppPromoBanner } from 'state/user/hooks'
|
||||
import { ThemedText } from 'theme/components'
|
||||
import { useIsDarkMode } from 'theme/components/ThemeToggle'
|
||||
import { openDownloadApp } from 'utils/openDownloadApp'
|
||||
import { isAndroid, isIOS, isMobileSafari } from 'wallet/src/utils/platform'
|
||||
|
||||
import darkAndroidThumbnail from '../../../assets/images/app-promo-banner/AndroidWallet-Thumbnail-Dark.png'
|
||||
import lightAndroidThumbnail from '../../../assets/images/app-promo-banner/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 {
|
||||
Container,
|
||||
DownloadButton,
|
||||
PopupContainer,
|
||||
StyledQrCode,
|
||||
StyledXButton,
|
||||
TextContainer,
|
||||
Thumbnail,
|
||||
} from './styled'
|
||||
|
||||
export default function WalletAppPromoBanner() {
|
||||
const [hideAppPromoBanner, toggleHideAppPromoBanner] = useHideAppPromoBanner()
|
||||
const location = useLocation()
|
||||
const isLandingScreen = location.search === '?intro=true' || location.pathname === '/'
|
||||
const screenSize = useScreenSize()
|
||||
|
||||
const shouldDisplay = Boolean(!hideAppPromoBanner && !isLandingScreen && !isMobileSafari)
|
||||
|
||||
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 = () =>
|
||||
openDownloadApp({
|
||||
element: InterfaceElementName.UNISWAP_WALLET_BANNER_DOWNLOAD_BUTTON,
|
||||
})
|
||||
|
||||
return (
|
||||
<PopupContainer show={shouldDisplay}>
|
||||
<Container>
|
||||
<Thumbnail src={thumbnailSrc} alt={t`Wallet app promo banner thumbnail`} />
|
||||
<TextContainer onClick={!screenSize['xs'] ? onClick : undefined}>
|
||||
<ThemedText.BodySmall lineHeight="20px">
|
||||
<Trans>Get the app</Trans>
|
||||
</ThemedText.BodySmall>
|
||||
<ThemedText.LabelMicro>
|
||||
{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>
|
||||
<DownloadButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClick()
|
||||
}}
|
||||
>
|
||||
{isAndroid || isIOS ? <Trans>Download now</Trans> : <Trans>Learn more</Trans>}
|
||||
</DownloadButton>
|
||||
</TextContainer>
|
||||
<StyledQrCode src={walletAppPromoBannerQR} alt="App OneLink QR code" />
|
||||
<StyledXButton data-testid="uniswap-wallet-banner" size={24} onClick={toggleHideAppPromoBanner} />
|
||||
</Container>
|
||||
</PopupContainer>
|
||||
)
|
||||
}
|
||||
@@ -114,11 +114,7 @@ export default function Pending({
|
||||
uniswapXOrder.status !== UniswapXOrderStatus.OPEN &&
|
||||
uniswapXOrder.status !== UniswapXOrderStatus.FILLED
|
||||
) {
|
||||
return (
|
||||
<OrderContent
|
||||
order={{ status: uniswapXOrder.status, orderHash: uniswapXOrder.orderHash, details: uniswapXOrder }}
|
||||
/>
|
||||
)
|
||||
return <OrderContent order={uniswapXOrder} />
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useQuickRouteChains } from 'featureFlags/dynamicConfig/quickRouteChains
|
||||
import { useCurrencyConversionFlag } from 'featureFlags/flags/currencyConversion'
|
||||
import { useEip6963EnabledFlag } from 'featureFlags/flags/eip6963'
|
||||
import { useFallbackProviderEnabledFlag } from 'featureFlags/flags/fallbackProvider'
|
||||
import { useGatewayDNSUpdateEnabledFlag } from 'featureFlags/flags/gatewayDNSUpdate'
|
||||
import { useInfoExploreFlag } from 'featureFlags/flags/infoExplore'
|
||||
import { useInfoLiveViewsFlag } from 'featureFlags/flags/infoLiveViews'
|
||||
import { useInfoPoolPageFlag } from 'featureFlags/flags/infoPoolPage'
|
||||
@@ -266,6 +267,12 @@ export default function FeatureFlagModal() {
|
||||
<X size={24} />
|
||||
</CloseButton>
|
||||
</Header>
|
||||
<FeatureFlagOption
|
||||
variant={BaseVariant}
|
||||
value={useGatewayDNSUpdateEnabledFlag()}
|
||||
featureFlag={FeatureFlag.gatewayDNSUpdate}
|
||||
label="Use gateway URL for routing api"
|
||||
/>
|
||||
<FeatureFlagOption
|
||||
variant={BaseVariant}
|
||||
value={useEip6963EnabledFlag()}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { t } from '@lingui/macro'
|
||||
import { ReactElement } from 'react'
|
||||
|
||||
import { ReactComponent as WinterUni } from '../../assets/svg/winter-uni.svg'
|
||||
import { SVGProps } from './UniIcon'
|
||||
|
||||
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} />,
|
||||
}
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ export function UniswapXOrderPopupContent({ orderHash, onClose }: { orderHash: s
|
||||
|
||||
if (!activity) return null
|
||||
|
||||
const onClick = () => openOffchainActivityModal({ orderHash, status: order.status })
|
||||
const onClick = () => openOffchainActivityModal(order)
|
||||
|
||||
return <ActivityPopupContent activity={activity} onClose={onClose} onClick={onClick} />
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
const [hasUnfetchedBalances, setHasUnfetchedBalances] = useAtom(hasUnfetchedBalancesAtom)
|
||||
const fetchBalances = useCallback(() => {
|
||||
if (account) {
|
||||
// Backend takes <2sec to get the updated portfolio value after a transaction
|
||||
// This timeout is an interim solution while we're working on a websocket that'll ping the client when connected account gets changes
|
||||
// TODO(WEB-3131): remove this timeout after websocket is implemented
|
||||
setTimeout(() => {
|
||||
prefetchPortfolioBalances({ variables: { ownerAddress: account, chains: GQL_MAINNET_CHAINS } })
|
||||
setHasUnfetchedBalances(false)
|
||||
}, ms('3.5s'))
|
||||
}
|
||||
}, [account, prefetchPortfolioBalances, setHasUnfetchedBalances])
|
||||
const fetchBalances = useCallback(
|
||||
(withDelay: boolean) => {
|
||||
if (account) {
|
||||
// Backend takes <2sec to get the updated portfolio value after a transaction
|
||||
// This timeout is an interim solution while we're working on a websocket that'll ping the client when connected account gets changes
|
||||
// TODO(WEB-3131): remove this timeout after websocket is implemented
|
||||
setTimeout(
|
||||
() => {
|
||||
prefetchPortfolioBalances({ variables: { ownerAddress: account, chains: GQL_MAINNET_CHAINS } })
|
||||
setHasUnfetchedBalances(false)
|
||||
},
|
||||
withDelay ? ms('3.5s') : 0
|
||||
)
|
||||
}
|
||||
},
|
||||
[account, prefetchPortfolioBalances, setHasUnfetchedBalances]
|
||||
)
|
||||
|
||||
const prevAccount = usePrevious(account)
|
||||
|
||||
@@ -62,7 +68,7 @@ export default function PrefetchBalancesWrapper({
|
||||
// The parent configures whether these conditions should trigger an immediate fetch,
|
||||
// if not, we set a flag to fetch on next hover.
|
||||
if (shouldFetchOnAccountUpdate) {
|
||||
fetchBalances()
|
||||
fetchBalances(true)
|
||||
} else {
|
||||
setHasUnfetchedBalances(true)
|
||||
}
|
||||
@@ -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
|
||||
// TODO(WEB-3071) remove this logic once balance provider refactor is done
|
||||
useEffect(() => {
|
||||
if (hasUnfetchedBalances && shouldFetchOnAccountUpdate) fetchBalances()
|
||||
if (hasUnfetchedBalances && shouldFetchOnAccountUpdate) fetchBalances(true)
|
||||
}, [fetchBalances, hasUnfetchedBalances, shouldFetchOnAccountUpdate])
|
||||
|
||||
const onHover = useCallback(() => {
|
||||
if (hasUnfetchedBalances) fetchBalances()
|
||||
if (hasUnfetchedBalances) fetchBalances(false)
|
||||
}, [fetchBalances, hasUnfetchedBalances])
|
||||
|
||||
return (
|
||||
|
||||
@@ -39,7 +39,6 @@ it('renders loading rows when isLoading is true', () => {
|
||||
height={10}
|
||||
currencies={[]}
|
||||
otherListTokens={[]}
|
||||
selectedCurrency={null}
|
||||
onCurrencySelect={noOp}
|
||||
isLoading={true}
|
||||
searchQuery=""
|
||||
@@ -59,7 +58,6 @@ it('renders currency rows correctly when currencies list is non-empty', () => {
|
||||
height={10}
|
||||
currencies={[DAI, USDC_MAINNET, WBTC]}
|
||||
otherListTokens={[]}
|
||||
selectedCurrency={null}
|
||||
onCurrencySelect={noOp}
|
||||
isLoading={false}
|
||||
searchQuery=""
|
||||
@@ -82,7 +80,6 @@ it('renders currency rows correctly with balances', () => {
|
||||
height={10}
|
||||
currencies={[DAI, USDC_MAINNET, WBTC]}
|
||||
otherListTokens={[]}
|
||||
selectedCurrency={null}
|
||||
onCurrencySelect={noOp}
|
||||
isLoading={false}
|
||||
searchQuery=""
|
||||
|
||||
@@ -146,10 +146,9 @@ export function CurrencyRow({
|
||||
tabIndex={0}
|
||||
style={style}
|
||||
className={`token-item-${key}`}
|
||||
onKeyPress={(e) => (!isSelected && e.key === 'Enter' ? onSelect(!!warning) : null)}
|
||||
onClick={() => (isSelected ? null : onSelect(!!warning))}
|
||||
disabled={isSelected}
|
||||
selected={otherSelected}
|
||||
onKeyPress={(e) => (e.key === 'Enter' ? onSelect(!!warning) : null)}
|
||||
onClick={() => onSelect(!!warning)}
|
||||
selected={otherSelected || isSelected}
|
||||
dim={isBlockedToken}
|
||||
>
|
||||
<Column>
|
||||
@@ -275,10 +274,10 @@ export default function CurrencyList({
|
||||
<CurrencyRow
|
||||
style={style}
|
||||
currency={currency}
|
||||
isSelected={isSelected}
|
||||
onSelect={handleSelect}
|
||||
otherSelected={otherSelected}
|
||||
showCurrencyAmount={showCurrencyAmount}
|
||||
isSelected={isSelected}
|
||||
showCurrencyAmount={showCurrencyAmount && balance.greaterThan(0)}
|
||||
eventProperties={formatAnalyticsEventProperties(token, index, data, searchQuery, isAddressSearch)}
|
||||
balance={balance}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
129
apps/web/src/components/Tokens/TokenDetails/BreadcrumbNav.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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'
|
||||
import { Currency } from '@uniswap/sdk-core'
|
||||
import { Share as ShareIcon } from 'components/Icons/Share'
|
||||
import { NATIVE_CHAIN_ID } from 'constants/tokens'
|
||||
import { useInfoTDPEnabled } from 'featureFlags/flags/infoTDP'
|
||||
import { chainIdToBackendName } from 'graphql/data/util'
|
||||
import useDisableScrolling from 'hooks/useDisableScrolling'
|
||||
import { useOnClickOutside } from 'hooks/useOnClickOutside'
|
||||
@@ -9,7 +10,7 @@ import { useRef } from 'react'
|
||||
import { Link, Twitter } from 'react-feather'
|
||||
import { useModalIsOpen, useToggleModal } from 'state/application/hooks'
|
||||
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 { ClickableStyle, CopyHelperRefType } from 'theme/components'
|
||||
import { CopyHelper } from 'theme/components'
|
||||
@@ -24,19 +25,27 @@ const ShareButtonDisplay = styled.div`
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const Share = styled(ShareIcon)<{ open: boolean }>`
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
const Share = styled(ShareIcon)<{ open: boolean; $isInfoTDPEnabled?: boolean }>`
|
||||
${({ $isInfoTDPEnabled }) =>
|
||||
$isInfoTDPEnabled
|
||||
? css`
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
`
|
||||
: css`
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
`}
|
||||
${ClickableStyle}
|
||||
${({ open, theme }) => open && `opacity: ${theme.opacity.click} !important`};
|
||||
`
|
||||
|
||||
const ShareActions = styled.div`
|
||||
const ShareActions = styled.div<{ isInfoTDPEnabled?: boolean }>`
|
||||
position: absolute;
|
||||
z-index: ${Z_INDEX.dropdown};
|
||||
width: 240px;
|
||||
top: 36px;
|
||||
right: 0px;
|
||||
${({ isInfoTDPEnabled }) => (isInfoTDPEnabled ? 'left' : 'right')}: 0px;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -74,12 +83,14 @@ export default function ShareButton({ currency }: { currency: Currency }) {
|
||||
const address = currency.isNative ? NATIVE_CHAIN_ID : currency.wrapped.address
|
||||
useDisableScrolling(open)
|
||||
|
||||
const isInfoTDPEnabled = useInfoTDPEnabled()
|
||||
|
||||
const shareTweet = () => {
|
||||
toggleShare()
|
||||
window.open(
|
||||
`https://twitter.com/intent/tweet?text=Check%20out%20${currency.name}%20(${
|
||||
currency.symbol
|
||||
})%20https://app.uniswap.org/%23/tokens/${chainIdToBackendName(
|
||||
})%20https://app.uniswap.org/${isInfoTDPEnabled ? 'explore/' : ''}tokens/${chainIdToBackendName(
|
||||
currency.chainId
|
||||
).toLowerCase()}/${address}%20via%20@uniswap`,
|
||||
'newwindow',
|
||||
@@ -91,9 +102,9 @@ export default function ShareButton({ currency }: { currency: Currency }) {
|
||||
|
||||
return (
|
||||
<ShareButtonDisplay ref={node}>
|
||||
<Share onClick={toggleShare} aria-label="ShareOptions" open={open} />
|
||||
<Share onClick={toggleShare} aria-label="ShareOptions" open={open} $isInfoTDPEnabled={isInfoTDPEnabled} />
|
||||
{open && (
|
||||
<ShareActions>
|
||||
<ShareActions isInfoTDPEnabled={isInfoTDPEnabled}>
|
||||
<ShareAction onClick={() => copyHelperRef.current?.forceCopy()}>
|
||||
<CopyHelper
|
||||
InitialIcon={Link}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { textFadeIn } from 'theme/styles'
|
||||
|
||||
import { LoadingBubble } from '../loading'
|
||||
import { AboutContainer, AboutHeader } from './About'
|
||||
import { BreadcrumbNav, BreadcrumbNavLink } from './BreadcrumbNavLink'
|
||||
import { BreadcrumbNavContainer, BreadcrumbNavLink } from './BreadcrumbNav'
|
||||
import { ChartContainer } from './ChartSection'
|
||||
import { StatPair, StatsWrapper, StatWrapper } from './StatsSection'
|
||||
|
||||
@@ -236,20 +236,20 @@ export default function TokenDetailsSkeleton() {
|
||||
return (
|
||||
<LeftPanel>
|
||||
{isInfoTDPEnabled ? (
|
||||
<BreadcrumbNav isInfoTDPEnabled>
|
||||
<BreadcrumbNavContainer isInfoTDPEnabled>
|
||||
<BreadcrumbNavLink to={`${isInfoExplorePageEnabled ? '/explore' : ''}/tokens/${chainName}`}>
|
||||
<Trans>Explore</Trans> <ChevronRight size={14} /> <Trans>Tokens</Trans> <ChevronRight size={14} />
|
||||
</BreadcrumbNavLink>{' '}
|
||||
<NavBubble />
|
||||
</BreadcrumbNav>
|
||||
</BreadcrumbNavContainer>
|
||||
) : (
|
||||
<BreadcrumbNav>
|
||||
<BreadcrumbNavContainer>
|
||||
<BreadcrumbNavLink
|
||||
to={(isInfoExplorePageEnabled ? '/explore' : '') + (chainName ? `/tokens/${chainName}` : `/tokens`)}
|
||||
>
|
||||
<ArrowLeft size={14} /> Tokens
|
||||
</BreadcrumbNavLink>
|
||||
</BreadcrumbNav>
|
||||
</BreadcrumbNavContainer>
|
||||
)}
|
||||
<TokenInfoContainer>
|
||||
<TokenNameCell>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// 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'
|
||||
import { PortfolioLogo } from 'components/AccountDrawer/MiniPortfolio/PortfolioLogo'
|
||||
import { ChartType, PriceChartType } from 'components/Charts/utils'
|
||||
import { useCachedPortfolioBalancesQuery } from 'components/PrefetchBalancesWrapper/PrefetchBalancesWrapper'
|
||||
import { Field } from 'components/swap/constants'
|
||||
import { AboutSection } from 'components/Tokens/TokenDetails/About'
|
||||
import AddressSection from 'components/Tokens/TokenDetails/AddressSection'
|
||||
import { BreadcrumbNav, BreadcrumbNavLink } from 'components/Tokens/TokenDetails/BreadcrumbNavLink'
|
||||
import ChartSection from 'components/Tokens/TokenDetails/ChartSection'
|
||||
import ShareButton from 'components/Tokens/TokenDetails/ShareButton'
|
||||
import TokenDetailsSkeleton, {
|
||||
@@ -48,11 +46,12 @@ import { ArrowLeft, ChevronRight } from 'react-feather'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { SwapState } from 'state/swap/SwapContext'
|
||||
import styled, { css } from 'styled-components'
|
||||
import { CopyContractAddress, EllipsisStyle } from 'theme/components'
|
||||
import { isAddress, shortenAddress } from 'utils'
|
||||
import { EllipsisStyle } from 'theme/components'
|
||||
import { isAddress } from 'utils'
|
||||
import { addressesAreEquivalent } from 'utils/addressesAreEquivalent'
|
||||
|
||||
import BalanceSummary from './BalanceSummary'
|
||||
import { BreadcrumbNavContainer, BreadcrumbNavLink, CurrentBreadcrumb } from './BreadcrumbNav'
|
||||
import { AdvancedPriceChartToggle } from './ChartTypeSelectors/AdvancedPriceChartToggle'
|
||||
import ChartTypeSelector from './ChartTypeSelectors/ChartTypeSelector'
|
||||
import InvalidTokenDetails from './InvalidTokenDetails'
|
||||
@@ -96,13 +95,6 @@ const TokenName = styled.span`
|
||||
${EllipsisStyle}
|
||||
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) {
|
||||
const token = useTokenFromActiveNetwork(skip || !address ? undefined : address)
|
||||
@@ -218,15 +210,15 @@ export default function TokenDetails({
|
||||
useOnGlobalChainSwitch(navigateToTokenForChain)
|
||||
|
||||
const handleCurrencyChange = useCallback(
|
||||
(tokens: Pick<SwapState, Field.INPUT | Field.OUTPUT>) => {
|
||||
(tokens: Pick<SwapState, 'inputCurrencyId' | 'outputCurrencyId'>) => {
|
||||
if (
|
||||
addressesAreEquivalent(tokens[Field.INPUT]?.currencyId, address) ||
|
||||
addressesAreEquivalent(tokens[Field.OUTPUT]?.currencyId, address)
|
||||
addressesAreEquivalent(tokens.inputCurrencyId, address) ||
|
||||
addressesAreEquivalent(tokens.outputCurrencyId, address)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const newDefaultTokenID = tokens[Field.OUTPUT]?.currencyId ?? tokens[Field.INPUT]?.currencyId
|
||||
const newDefaultTokenID = tokens.outputCurrencyId ?? tokens.inputCurrencyId
|
||||
startTokenTransition(() =>
|
||||
navigate(
|
||||
getTokenDetailsURL({
|
||||
@@ -236,9 +228,7 @@ export default function TokenDetails({
|
||||
inputAddress:
|
||||
// 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.
|
||||
tokens[Field.INPUT] && tokens[Field.INPUT]?.currencyId !== newDefaultTokenID
|
||||
? tokens[Field.INPUT]?.currencyId
|
||||
: null,
|
||||
tokens.inputCurrencyId !== newDefaultTokenID ? tokens.inputCurrencyId : null,
|
||||
isInfoExplorePageEnabled,
|
||||
})
|
||||
)
|
||||
@@ -277,29 +267,18 @@ export default function TokenDetails({
|
||||
{detailedToken && !isPending ? (
|
||||
<LeftPanel>
|
||||
{isInfoTDPEnabled ? (
|
||||
<BreadcrumbNav isInfoTDPEnabled>
|
||||
<BreadcrumbNavLink to={`${isInfoExplorePageEnabled ? '/explore' : ''}/tokens/${chain.toLowerCase()}`}>
|
||||
<BreadcrumbNavContainer aria-label="breadcrumb-nav">
|
||||
<BreadcrumbNavLink to={`/explore/tokens/${chain.toLowerCase()}`}>
|
||||
<Trans>Explore</Trans> <ChevronRight size={14} /> <Trans>Tokens</Trans> <ChevronRight size={14} />
|
||||
</BreadcrumbNavLink>{' '}
|
||||
<PageTitleText>{tokenSymbolName}</PageTitleText>{' '}
|
||||
{!detailedToken.isNative && (
|
||||
<>
|
||||
(
|
||||
<CopyContractAddress
|
||||
address={address}
|
||||
showTruncatedOnly
|
||||
truncatedAddress={shortenAddress(address)}
|
||||
/>
|
||||
)
|
||||
</>
|
||||
)}
|
||||
</BreadcrumbNav>
|
||||
<CurrentBreadcrumb address={address} currency={detailedToken} />
|
||||
</BreadcrumbNavContainer>
|
||||
) : (
|
||||
<BreadcrumbNav>
|
||||
<BreadcrumbNavContainer aria-label="breadcrumb-nav">
|
||||
<BreadcrumbNavLink to={`${isInfoExplorePageEnabled ? '/explore' : ''}/tokens/${chain.toLowerCase()}`}>
|
||||
<ArrowLeft data-testid="token-details-return-button" size={14} /> Tokens
|
||||
</BreadcrumbNavLink>
|
||||
</BreadcrumbNav>
|
||||
</BreadcrumbNavContainer>
|
||||
)}
|
||||
<TokenInfoContainer isInfoTDPEnabled={isInfoTDPEnabled} data-testid="token-info-container">
|
||||
<TokenNameCell isInfoTDPEnabled={isInfoTDPEnabled}>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useWeb3React } from '@web3-react/core'
|
||||
import { OffchainActivityModal } from 'components/AccountDrawer/MiniPortfolio/Activity/OffchainActivityModal'
|
||||
import UniwalletModal from 'components/AccountDrawer/UniwalletModal'
|
||||
import AirdropModal from 'components/AirdropModal'
|
||||
import AndroidAnnouncementBanner from 'components/Banner/AndroidAnnouncementBanner'
|
||||
import WalletAppPromoBanner from 'components/Banner/MobileAppAnnouncementBanner'
|
||||
import AddressClaimModal from 'components/claim/AddressClaimModal'
|
||||
import ConnectedAccountBlocked from 'components/ConnectedAccountBlocked'
|
||||
import FiatOnrampModal from 'components/FiatOnrampModal'
|
||||
@@ -30,7 +30,7 @@ export default function TopLevelModals() {
|
||||
<ConnectedAccountBlocked account={account} isOpen={accountBlocked} />
|
||||
<Bag />
|
||||
<UniwalletModal />
|
||||
<AndroidAnnouncementBanner />
|
||||
<WalletAppPromoBanner />
|
||||
<OffchainActivityModal />
|
||||
<TransactionCompleteModal />
|
||||
<AirdropModal />
|
||||
|
||||
@@ -20,6 +20,8 @@ afterEach(() => {
|
||||
|
||||
// @ts-ignore
|
||||
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) {
|
||||
|
||||
@@ -9,20 +9,19 @@ import { useAppSelector } from 'state/hooks'
|
||||
import Option from './Option'
|
||||
|
||||
function useEIP6963Connections() {
|
||||
const injectedDetailsMap = useInjectedProviderDetails()
|
||||
const eip6963Injectors = useInjectedProviderDetails()
|
||||
const eip6963Enabled = useEip6963Enabled()
|
||||
|
||||
return useMemo(() => {
|
||||
if (!eip6963Enabled) return { eip6963Connections: [], showDeprecatedMessage: false }
|
||||
|
||||
const eip6963Injectors = Array.from(injectedDetailsMap.values())
|
||||
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
|
||||
const showDeprecatedMessage = eip6963Connections.length > 0 && shouldUseDeprecatedInjector(injectedDetailsMap)
|
||||
const showDeprecatedMessage = eip6963Connections.length > 0 && shouldUseDeprecatedInjector(eip6963Injectors)
|
||||
|
||||
return { eip6963Connections, showDeprecatedMessage }
|
||||
}, [injectedDetailsMap, eip6963Enabled])
|
||||
}, [eip6963Injectors, eip6963Enabled])
|
||||
}
|
||||
|
||||
function mergeConnections(connections: Connection[], eip6963Connections: Connection[]) {
|
||||
|
||||
@@ -317,7 +317,7 @@ export function PendingModalContent({
|
||||
|
||||
// Return finalized-order-specifc content if available
|
||||
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.
|
||||
|
||||
@@ -25,16 +25,12 @@ function Wrapper(props: PropsWithChildren<WrapperProps>) {
|
||||
independentField: Field.INPUT,
|
||||
typedValue: '',
|
||||
recipient: '',
|
||||
[Field.INPUT]: {},
|
||||
[Field.OUTPUT]: {},
|
||||
inputCurrencyId: undefined,
|
||||
outputCurrencyId: undefined,
|
||||
},
|
||||
prefilledState: {
|
||||
INPUT: {
|
||||
currencyId: undefined,
|
||||
},
|
||||
OUTPUT: {
|
||||
currencyId: undefined,
|
||||
},
|
||||
inputCurrencyId: undefined,
|
||||
outputCurrencyId: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MockEIP1193Provider } from '@web3-react/core'
|
||||
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 { EIP6963_PROVIDER_MANAGER, useInjectedProviderDetails } from './providers'
|
||||
@@ -17,6 +17,8 @@ afterEach(() => {
|
||||
|
||||
// @ts-ignore
|
||||
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) {
|
||||
@@ -50,34 +52,34 @@ describe('EIP6963 Providers', () => {
|
||||
announceProvider('mockExtension1', mockProvider1)
|
||||
announceProvider('mockExtension2', mockProvider2)
|
||||
|
||||
expect(EIP6963_PROVIDER_MANAGER.map.size).toEqual(2)
|
||||
expect(EIP6963_PROVIDER_MANAGER.map.get('mockExtension1')).toBeDefined()
|
||||
expect(EIP6963_PROVIDER_MANAGER.map.get('mockExtension2')).toBeDefined()
|
||||
expect(EIP6963_PROVIDER_MANAGER.list.length).toEqual(2)
|
||||
expect(EIP6963_PROVIDER_MANAGER.list[0].info.rdns === 'mockExtension1').toBeTruthy()
|
||||
expect(EIP6963_PROVIDER_MANAGER.list[1].info.rdns === 'mockExtension2').toBeTruthy()
|
||||
})
|
||||
|
||||
it('should ignore coinbase', () => {
|
||||
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', () => {
|
||||
announceProvider('io.metamask', mockProvider1)
|
||||
|
||||
expect(EIP6963_PROVIDER_MANAGER.map.size).toEqual(1)
|
||||
expect(EIP6963_PROVIDER_MANAGER.map.get('io.metamask')?.info.icon).toEqual(METAMASK_ICON)
|
||||
expect(EIP6963_PROVIDER_MANAGER.list.length).toEqual(1)
|
||||
METAMASK_ICON
|
||||
})
|
||||
|
||||
it('should ignore improperly formatted provider info', () => {
|
||||
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', () => {
|
||||
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', () => {
|
||||
const test = renderHook(() => useInjectedProviderDetails())
|
||||
|
||||
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.get('mockExtension1')).toBeDefined()
|
||||
expect(test.result.current.get('mockExtension2')).toBeDefined()
|
||||
expect(test.result.current.length).toEqual(2)
|
||||
expect(test.result.current[0].info.rdns === 'mockExtension1').toBeTruthy()
|
||||
expect(test.result.current[1].info.rdns === 'mockExtension2').toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||