ci(release): publish latest release

parent 8465f684
IPFS hash of the deployment:
- CIDv0: `QmcH1YbyUfpwzwJxmFGZVnRddiveKnnpuxFTLxBeWPMxbU`
- CIDv1: `bafybeigpctng5nggznryk3h4m7jssnnw5uab46gd3n34v5wue33bnyjvnm`
- CIDv0: `QmfTNnuaGjKgsrCc3CrXy8JZCw1gDPaWCzmXwt7GvDRXNK`
- CIDv1: `bafybeih6j67orz7joqshoxrbzvxmqlipzfdlpuw7d252kjjqbpsjcpmnnq`
The latest release is always mirrored at [app.uniswap.org](https://app.uniswap.org).
......@@ -10,10 +10,10 @@ You can also access the Uniswap Interface from an IPFS gateway.
Your Uniswap settings are never remembered across different URLs.
IPFS gateways:
- https://bafybeigpctng5nggznryk3h4m7jssnnw5uab46gd3n34v5wue33bnyjvnm.ipfs.dweb.link/
- https://bafybeigpctng5nggznryk3h4m7jssnnw5uab46gd3n34v5wue33bnyjvnm.ipfs.cf-ipfs.com/
- [ipfs://QmcH1YbyUfpwzwJxmFGZVnRddiveKnnpuxFTLxBeWPMxbU/](ipfs://QmcH1YbyUfpwzwJxmFGZVnRddiveKnnpuxFTLxBeWPMxbU/)
- https://bafybeih6j67orz7joqshoxrbzvxmqlipzfdlpuw7d252kjjqbpsjcpmnnq.ipfs.dweb.link/
- https://bafybeih6j67orz7joqshoxrbzvxmqlipzfdlpuw7d252kjjqbpsjcpmnnq.ipfs.cf-ipfs.com/
- [ipfs://QmfTNnuaGjKgsrCc3CrXy8JZCw1gDPaWCzmXwt7GvDRXNK/](ipfs://QmfTNnuaGjKgsrCc3CrXy8JZCw1gDPaWCzmXwt7GvDRXNK/)
### 5.1.2 (2023-12-07)
### 5.2.1 (2023-12-13)
web/5.1.2
\ No newline at end of file
web/5.2.1
\ No newline at end of file
......@@ -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"
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"
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)
}
else if (price < 1000000000000000){
return "\(formatter.string(for: price/1000000000000)!)T"
}
else {
return "$>999T"
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))
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
}()
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 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_DECIMALS_USD: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.maximumFractionDigits = 3
formatter.minimumFractionDigits = 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 func fiatTokenDetailsFormatter(price: Double?) -> String {
public static func fiatTokenDetailsFormatter(price: Double?, locale: Locale, currencyCode: String) -> String {
let placeholder = "--.--"
guard let price = price else {
return "--.--"
return placeholder
}
if (price < 0.00000001) {
return "<$0.00000001"
}
else if (price < 0.01) {
return THREE_SIG_FIGS_USD.string(for: price)!
let formattedPrice = formatWithDecimals(number: price, fractionDigits: 8, locale: locale, currencyCode: currencyCode)
return "<\(formattedPrice)"
}
else if (price < 1.05) {
return THREE_DECIMALS_USD.string(for: price)!
}
else if (price < 1e6) {
return TWO_DECIMALS_USD.string(for: price)!
}
else {
return SHORTHAND_USD_TWO_DECIMALS(price: price)
if (price < 0.01) {
return formatWithSigFigs(number: price, sigFigsDigits: 3, locale: locale, currencyCode: currencyCode)
} else if (price < 1.05) {
return formatWithDecimals(number: price, fractionDigits: 3, locale: locale, currencyCode: currencyCode)
} else if (price < 1e6) {
return formatWithDecimals(number: price, fractionDigits: 2, locale: locale, currencyCode: currencyCode)
} else {
return formatShorthandWithDecimals(number: price, fractionDigits: 2, locale: locale, currencyCode: currencyCode, placeholder: placeholder)
}
}
}
......@@ -51,3 +51,8 @@ public struct PriceHistory {
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 />
......
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
</Button>
<Button size="small" onPress={clearEndpoint}>
Clear
<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>
</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
......
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
}
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>
)
}
})
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"
......
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]
)
}
/**
......
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}
/>
......
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
......
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(
......
import React, { memo, useMemo } from 'react'
import { FadeIn, FadeOut } from 'react-native-reanimated'
import { TokenFiatOnRampList } from 'src/components/TokenSelector/TokenFiatOnRampList'
import Trace from 'src/components/Trace/Trace'
import { useFiatOnRampSupportedTokens } from 'src/features/fiatOnRamp/hooks'
import { FiatOnRampCurrency } from 'src/features/fiatOnRamp/types'
import { ElementName, SectionName } from 'src/features/telemetry/constants'
import { AnimatedFlex } from 'ui/src'
import { useAllCommonBaseCurrencies } from 'src/components/TokenSelector/hooks'
import { fromMoonpayNetwork } from 'wallet/src/features/chains/utils'
import { CurrencyInfo, GqlResult } from 'wallet/src/features/dataApi/types'
import { MoonpayCurrency } from 'wallet/src/features/fiatOnRamp/types'
interface Props {
onBack: () => void
onSelectCurrency: (currency: FiatOnRampCurrency) => void
}
const findTokenOptionForMoonpayCurrency = (
commonBaseCurrencies: CurrencyInfo[] | undefined,
moonpayCurrency: MoonpayCurrency
): Maybe<CurrencyInfo> => {
return (commonBaseCurrencies || []).find((item) => {
const [code, network] = moonpayCurrency.code.split('_') ?? [undefined, undefined]
const chainId = fromMoonpayNetwork(network)
return (
item &&
code &&
code === item.currency.symbol?.toLowerCase() &&
chainId === item.currency.chainId
)
})
}
function useFiatOnRampTokenList(
supportedTokens: MoonpayCurrency[] | undefined
): GqlResult<FiatOnRampCurrency[]> {
const {
data: commonBaseCurrencies,
error: commonBaseCurrenciesError,
loading: commonBaseCurrenciesLoading,
refetch: refetchCommonBaseCurrencies,
} = useAllCommonBaseCurrencies()
const data = useMemo(
() =>
(supportedTokens || [])
.map((moonpayCurrency) => ({
currencyInfo: findTokenOptionForMoonpayCurrency(commonBaseCurrencies, moonpayCurrency),
moonpayCurrency,
}))
.filter((item) => !!item.currencyInfo),
[commonBaseCurrencies, supportedTokens]
)
return useMemo(
() => ({
data,
loading: commonBaseCurrenciesLoading,
error: commonBaseCurrenciesError,
refetch: refetchCommonBaseCurrencies,
}),
[commonBaseCurrenciesError, commonBaseCurrenciesLoading, data, refetchCommonBaseCurrencies]
)
}
function _FiatOnRampTokenSelector({ onSelectCurrency, onBack }: Props): JSX.Element {
const {
data: supportedTokens,
isLoading: supportedTokensLoading,
isError: supportedTokensQueryError,
refetch: supportedTokensQueryRefetch,
} = useFiatOnRampSupportedTokens()
const {
data: tokenList,
loading: tokenListLoading,
error: tokenListError,
refetch: tokenListRefetch,
} = useFiatOnRampTokenList(supportedTokens)
const loading = supportedTokensLoading || tokenListLoading
const error = Boolean(supportedTokensQueryError || tokenListError)
const onRetry = (): void => {
if (supportedTokensQueryError) {
supportedTokensQueryRefetch?.()
}
if (tokenListError) {
tokenListRefetch?.()
}
}
return (
<Trace
logImpression
element={ElementName.FiatOnRampTokenSelector}
section={SectionName.TokenSelector}>
<AnimatedFlex
entering={FadeIn}
exiting={FadeOut}
gap="$spacing12"
overflow="hidden"
px="$spacing16"
width="100%">
<TokenFiatOnRampList
error={error}
list={tokenList}
loading={loading}
onBack={onBack}
onRetry={onRetry}
onSelectCurrency={onSelectCurrency}
/>
</AnimatedFlex>
</Trace>
)
}
export const FiatOnRampTokenSelector = memo(_FiatOnRampTokenSelector)
import { skipToken } from '@reduxjs/toolkit/query/react'
import { Currency } from '@uniswap/sdk-core'
import { 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 }
}
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]
......
/* 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>
......
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",
......
# *
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
\ No newline at end of file
......@@ -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',
})
})
})
})
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)],
......
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
......
import { Trans } from '@lingui/macro'
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 { useHideAndroidAnnouncementBanner } from 'state/user/hooks'
import { useHideAppPromoBanner } 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 { isAndroid, isIOS, 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 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,
......@@ -21,32 +26,46 @@ import {
Thumbnail,
} from './styled'
export default function AndroidAnnouncementBanner() {
const [hideAndroidAnnouncementBanner, toggleHideAndroidAnnouncementBanner] = useHideAndroidAnnouncementBanner()
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(!hideAndroidAnnouncementBanner && !isLandingScreen)
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,
})
if (isMobileSafari) return null
return (
<PopupContainer show={shouldDisplay}>
<Container>
<Thumbnail src={isDarkMode ? darkAndroidThumbnail : lightAndroidThumbnail} alt="Android app thumbnail" />
<Thumbnail src={thumbnailSrc} alt={t`Wallet app promo banner thumbnail`} />
<TextContainer onClick={!screenSize['xs'] ? onClick : undefined}>
<ThemedText.BodySmall lineHeight="20px">
<Trans>Uniswap on Android</Trans>
<Trans>Get the app</Trans>
</ThemedText.BodySmall>
<ThemedText.LabelMicro>
<Trans>Available now - download from the Google Play Store today</Trans>
{isAndroid ? (
<Trans>Download the Uniswap mobile app from the Play Store</Trans>
) : isIOS ? (
<Trans>Download the Uniswap mobile app from the App Store</Trans>
) : (
<Trans>Download the Uniswap mobile app for iOS and Android</Trans>
)}
</ThemedText.LabelMicro>
<DownloadButton
onClick={(e) => {
......@@ -54,20 +73,11 @@ export default function AndroidAnnouncementBanner() {
onClick()
}}
>
<Trans>Download now</Trans>
{isAndroid || isIOS ? <Trans>Download now</Trans> : <Trans>Learn more</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()
}}
/>
<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()}
......
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}
/>
......
import userEvent from '@testing-library/user-event'
import { ChainId } from '@uniswap/sdk-core'
import { nativeOnChain } from 'constants/tokens'
import { TokenFromList } from 'state/lists/tokenFromList'
import { act, render, screen } from 'test-utils/render'
import { CurrentBreadcrumb } from './BreadcrumbNav'
jest.mock('featureFlags/flags/infoTDP', () => ({ useInfoTDPEnabled: () => true }))
describe('BreadcrumbNav', () => {
it('renders hover components correctly', async () => {
const currency = new TokenFromList({
chainId: 1,
address: '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599',
name: 'Wrapped BTC',
decimals: 18,
symbol: 'WBTC',
})
const { asFragment } = render(
<CurrentBreadcrumb address="0x2260fac5e5542a773aa44fbcfedf7c193bc2c599" currency={currency} />
)
expect(asFragment()).toMatchSnapshot()
await act(() => userEvent.hover(screen.getByTestId('current-breadcrumb')))
expect(screen.getByTestId('breadcrumb-hover-copy')).toBeInTheDocument()
await act(() => userEvent.unhover(screen.getByTestId('current-breadcrumb')))
expect(screen.queryByTestId('breadcrumb-hover-copy')).not.toBeInTheDocument()
})
it('does not display address hover for native tokens', async () => {
const ETH = nativeOnChain(ChainId.MAINNET)
const { asFragment } = render(<CurrentBreadcrumb address="NATIVE" currency={ETH} />)
expect(asFragment()).toMatchSnapshot()
await act(() => userEvent.hover(screen.getByTestId('current-breadcrumb')))
expect(screen.queryByTestId('breadcrumb-hover-copy')).not.toBeInTheDocument()
})
})
import { Trans } from '@lingui/macro'
import { Currency } from '@uniswap/sdk-core'
import Row from 'components/Row'
import useCopyClipboard from 'hooks/useCopyClipboard'
import { useScreenSize } from 'hooks/useScreenSize'
import { useCallback, useState } from 'react'
import { Copy } from 'react-feather'
import { Link } from 'react-router-dom'
import { useModalIsOpen } from 'state/application/hooks'
import { ApplicationModal } from 'state/application/reducer'
import styled, { css, useTheme } from 'styled-components'
import { ClickableStyle, ThemedText } from 'theme/components'
import { shortenAddress } from 'utils/addresses'
import ShareButton from './ShareButton'
export const BreadcrumbNavContainer = styled.nav<{ isInfoTDPEnabled?: boolean }>`
display: flex;
color: ${({ theme }) => theme.neutral1};
${({ isInfoTDPEnabled }) =>
isInfoTDPEnabled
? css`
font-size: 16px;
line-height: 24px;
`
: css`
font-size: 14px;
line-height: 20px;
`}
align-items: center;
gap: 4px;
margin-bottom: 16px;
width: fit-content;
`
export const BreadcrumbNavLink = styled(Link)`
display: flex;
align-items: center;
color: ${({ theme }) => theme.neutral2};
transition-duration: ${({ theme }) => theme.transition.duration.fast};
text-decoration: none;
&:hover {
color: ${({ theme }) => theme.neutral3};
}
`
const CurrentBreadcrumbContainer = styled(Row)`
gap: 6px;
`
// This must be an h1 to match the SEO title, and must be the first heading tag in code.
const PageTitleText = styled.h1`
font-weight: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
`
const TokenAddressHoverContainer = styled(Row)`
cursor: pointer;
gap: 10px;
white-space: nowrap;
`
const HoverActionsDivider = styled.div`
height: 16px;
width: 1px;
background-color: ${({ theme }) => theme.surface3};
`
const CopyIcon = styled(Copy)`
${ClickableStyle}
`
const StyledCopiedSuccess = styled(Row)`
gap: 4px;
`
const CopiedSuccess = () => {
const { success } = useTheme()
return (
<StyledCopiedSuccess>
<Copy width={16} height={16} color={success} />
<ThemedText.Caption color="success">
<Trans>Copied!</Trans>
</ThemedText.Caption>
</StyledCopiedSuccess>
)
}
export const CurrentBreadcrumb = ({ address, currency }: { address: string; currency: Currency }) => {
const { neutral2 } = useTheme()
const screenSize = useScreenSize()
const [hover, setHover] = useState(false)
const [isCopied, setCopied] = useCopyClipboard()
const copy = useCallback(() => {
setCopied(address)
}, [address, setCopied])
const isNative = currency.isNative
const tokenSymbolName = currency && (currency.symbol ?? <Trans>Symbol not found</Trans>)
const shareModalOpen = useModalIsOpen(ApplicationModal.SHARE)
const shouldShowActions = (screenSize['sm'] && hover && !isCopied) || shareModalOpen
return (
<CurrentBreadcrumbContainer
aria-current="page"
data-testid="current-breadcrumb"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
<PageTitleText>{tokenSymbolName}</PageTitleText>{' '}
{!isNative && (
<TokenAddressHoverContainer data-testid="breadcrumb-token-address" onClick={copy}>
( {shortenAddress(address)} )
{shouldShowActions && (
<>
<CopyIcon data-testid="breadcrumb-hover-copy" width={16} height={16} color={neutral2} />
<HoverActionsDivider />
</>
)}
{isCopied && <CopiedSuccess />}
</TokenAddressHoverContainer>
)}
{shouldShowActions && <ShareButton currency={currency} />}
</CurrentBreadcrumbContainer>
)
}
import { Link } from 'react-router-dom'
import styled, { css } from 'styled-components'
export const BreadcrumbNav = styled.div<{ isInfoTDPEnabled?: boolean }>`
display: flex;
color: ${({ theme }) => theme.neutral1};
${({ isInfoTDPEnabled }) =>
isInfoTDPEnabled
? css`
font-size: 16px;
line-height: 24px;
`
: css`
font-size: 14px;
line-height: 20px;
`}
align-items: center;
gap: 4px;
margin-bottom: 16px;
width: fit-content;
`
export const BreadcrumbNavLink = styled(Link)`
display: flex;
align-items: center;
color: ${({ theme }) => theme.neutral2};
transition-duration: ${({ theme }) => theme.transition.duration.fast};
text-decoration: none;
&:hover {
color: ${({ theme }) => theme.neutral3};
}
`
......@@ -2,6 +2,7 @@ import { Trans } from '@lingui/macro'
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>
......
// 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,
},
}}
>
......
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()
})
})
......@@ -5,11 +5,12 @@ import { applyOverrideIcon, isCoinbaseProviderDetail, isEIP6963ProviderDetail }
// TODO(WEB-3241) - Once Mutable<T> utility type is consolidated, use it here
type MutableInjectedProviderMap = Map<string, EIP6963ProviderDetail>
export type InjectedProviderMap = ReadonlyMap<string, EIP6963ProviderDetail>
type InjectedProviderMap = ReadonlyMap<string, EIP6963ProviderDetail>
class EIP6963ProviderManager {
public listeners = new Set<() => void>()
private _map: MutableInjectedProviderMap = new Map()
private _list: EIP6963ProviderDetail[] = []
constructor() {
window.addEventListener(EIP6963Event.ANNOUNCE_PROVIDER, this.onAnnounceProvider.bind(this) as EventListener)
......@@ -35,12 +36,17 @@ class EIP6963ProviderManager {
}
this._map.set(detail.info.rdns, detail)
this._list = [...this._list, detail] // re-create array to trigger re-render from useInjectedProviderDetails
this.listeners.forEach((listener) => listener())
}
public get map(): InjectedProviderMap {
return this._map
}
public get list(): readonly EIP6963ProviderDetail[] {
return this._list
}
}
export const EIP6963_PROVIDER_MANAGER = new EIP6963ProviderManager()
......@@ -50,11 +56,11 @@ function subscribeToProviderMap(listener: () => void): () => void {
return () => EIP6963_PROVIDER_MANAGER.listeners.delete(listener)
}
function getProviderMapSnapshot(): InjectedProviderMap {
return EIP6963_PROVIDER_MANAGER.map
function getProviderMapSnapshot(): readonly EIP6963ProviderDetail[] {
return EIP6963_PROVIDER_MANAGER.list
}
/** Returns an up-to-date map of announced eip6963 providers */
export function useInjectedProviderDetails(): InjectedProviderMap {
export function useInjectedProviderDetails(): readonly EIP6963ProviderDetail[] {
return useSyncExternalStore(subscribeToProviderMap, getProviderMapSnapshot)
}
......@@ -6,11 +6,10 @@ import LEDGER_ICON from 'assets/wallets/ledger-icon.svg'
import METAMASK_ICON from 'assets/wallets/metamask-icon.svg'
import RABBY_ICON from 'assets/wallets/rabby-icon.svg'
import TRUST_WALLET_ICON from 'assets/wallets/trustwallet-icon.svg'
import { EIP6963ProviderDetail } from 'connection/eip6963/types'
import { Connection, ConnectionType, ProviderInfo } from 'connection/types'
import { getInjectedMeta } from 'utils/walletMeta'
import { InjectedProviderMap } from './eip6963/providers'
export const getIsInjected = () => Boolean(window.ethereum)
type InjectedWalletKey = keyof NonNullable<Window['ethereum']>
......@@ -23,12 +22,12 @@ const InjectedWalletTable: { [key in InjectedWalletKey]?: ProviderInfo } = {
}
/** Returns boolean representing whether the app should still use the deprecated window.ethereum provider, based on eip6963 providers present */
export function shouldUseDeprecatedInjector(providerMap: InjectedProviderMap): boolean {
export function shouldUseDeprecatedInjector(providerDetails: readonly EIP6963ProviderDetail[]): boolean {
if (!window.ethereum) return false
const { name: deprecatedInjectionName } = getInjectedMeta(window.ethereum)
for (const injector of providerMap.values()) {
for (const injector of providerDetails) {
// Compares window.ethereum flags (isMetaMask) to corresponding flags on eip6963 providers
if (getInjectedMeta(injector.provider as ExternalProvider).name === deprecatedInjectionName) {
return false
......
import { BaseVariant, FeatureFlag, useBaseFlag } from '../index'
export function useGatewayDNSUpdateEnabledFlag(): BaseVariant {
return useBaseFlag(FeatureFlag.gatewayDNSUpdate)
}
export function useGatewayDNSUpdateEnabled(): boolean {
return useGatewayDNSUpdateEnabledFlag() === BaseVariant.Enabled
}
......@@ -21,6 +21,7 @@ export enum FeatureFlag {
feesEnabled = 'fees_enabled',
limitsEnabled = 'limits_enabled',
eip6963Enabled = 'eip6963_enabled',
gatewayDNSUpdate = 'gateway_dns_update',
}
interface FeatureFlagsContextType {
......
......@@ -136,6 +136,7 @@ fragment SwapOrderDetailsParts on SwapOrderDetails {
offerer
hash
orderStatus: status
expiry
inputToken {
...TokenAssetParts
}
......
import { SkipToken, skipToken } from '@reduxjs/toolkit/query/react'
import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core'
import { useGatewayDNSUpdateEnabled } from 'featureFlags/flags/gatewayDNSUpdate'
import { useUniswapXSyntheticQuoteEnabled } from 'featureFlags/flags/uniswapXUseSyntheticQuote'
import { useFeesEnabled } from 'featureFlags/flags/useFees'
import { useMemo } from 'react'
......@@ -27,7 +28,7 @@ export function useRoutingAPIArguments({
routerPreference: RouterPreference | typeof INTERNAL_ROUTER_PREFERENCE_PRICE
}): GetQuoteArgs | SkipToken {
const uniswapXForceSyntheticQuotes = useUniswapXSyntheticQuoteEnabled()
const gatewayDNSUpdateEnabled = useGatewayDNSUpdateEnabled()
const feesEnabled = useFeesEnabled()
// Don't enable fee logic if this is a quote for pricing
const sendPortionEnabled = routerPreference === INTERNAL_ROUTER_PREFERENCE_PRICE ? false : feesEnabled
......@@ -52,7 +53,18 @@ export function useRoutingAPIArguments({
needsWrapIfUniswapX: tokenIn.isNative,
uniswapXForceSyntheticQuotes,
sendPortionEnabled,
gatewayDNSUpdateEnabled,
},
[account, amount, routerPreference, tokenIn, tokenOut, tradeType, uniswapXForceSyntheticQuotes, sendPortionEnabled]
[
account,
amount,
routerPreference,
tokenIn,
tokenOut,
tradeType,
uniswapXForceSyntheticQuotes,
sendPortionEnabled,
gatewayDNSUpdateEnabled,
]
)
}
......@@ -68,7 +68,7 @@ import { OutputTaxTooltipBody } from './TaxTooltipBody'
interface SwapFormProps {
disableTokenInputs?: boolean
onCurrencyChange?: (selected: Pick<SwapState, Field.INPUT | Field.OUTPUT>) => void
onCurrencyChange?: (selected: Pick<SwapState, 'inputCurrencyId' | 'outputCurrencyId'>) => void
}
export function SwapForm({ disableTokenInputs = false, onCurrencyChange }: SwapFormProps) {
......@@ -84,8 +84,8 @@ export function SwapForm({ disableTokenInputs = false, onCurrencyChange }: SwapF
const prefilledCurrencies = useMemo(() => {
return queryParametersToSwapState(parsedQs)
}, [parsedQs])
const prefilledInputCurrency = useCurrency(prefilledCurrencies?.[Field.INPUT]?.currencyId, chainId)
const prefilledOutputCurrency = useCurrency(prefilledCurrencies?.[Field.OUTPUT]?.currencyId, chainId)
const prefilledInputCurrency = useCurrency(prefilledCurrencies?.inputCurrencyId, chainId)
const prefilledOutputCurrency = useCurrency(prefilledCurrencies?.outputCurrencyId, chainId)
const [loadedInputCurrency, setLoadedInputCurrency] = useState(prefilledInputCurrency)
const [loadedOutputCurrency, setLoadedOutputCurrency] = useState(prefilledOutputCurrency)
......@@ -266,22 +266,16 @@ export function SwapForm({ disableTokenInputs = false, onCurrencyChange }: SwapF
const combinedInitialState = { ...initialSwapState, ...prefilledState }
const chainChanged = previousConnectedChainId && previousConnectedChainId !== connectedChainId
const prefilledInputChanged =
previousPrefilledState &&
previousPrefilledState?.[Field.INPUT]?.currencyId !== prefilledState?.[Field.INPUT]?.currencyId
previousPrefilledState && previousPrefilledState?.inputCurrencyId !== prefilledState?.inputCurrencyId
const prefilledOutputChanged =
previousPrefilledState &&
previousPrefilledState?.[Field.OUTPUT]?.currencyId !== prefilledState?.[Field.OUTPUT]?.currencyId
previousPrefilledState && previousPrefilledState?.outputCurrencyId !== prefilledState?.outputCurrencyId
if (chainChanged || prefilledInputChanged || prefilledOutputChanged) {
setSwapState({
...initialSwapState,
...prefilledState,
independentField: combinedInitialState.independentField ?? Field.INPUT,
[Field.INPUT]: {
currencyId: combinedInitialState.INPUT.currencyId ?? undefined,
},
[Field.OUTPUT]: {
currencyId: combinedInitialState.OUTPUT.currencyId ?? undefined,
},
inputCurrencyId: combinedInitialState.inputCurrencyId ?? undefined,
outputCurrencyId: combinedInitialState.outputCurrencyId ?? undefined,
})
// reset local state
setSwapFormState({
......@@ -434,10 +428,8 @@ export function SwapForm({ disableTokenInputs = false, onCurrencyChange }: SwapF
(inputCurrency: Currency) => {
onCurrencySelection(Field.INPUT, inputCurrency)
onCurrencyChange?.({
[Field.INPUT]: {
currencyId: getSwapCurrencyId(inputCurrency),
},
[Field.OUTPUT]: swapState[Field.OUTPUT],
inputCurrencyId: getSwapCurrencyId(inputCurrency),
outputCurrencyId: swapState.outputCurrencyId,
})
maybeLogFirstSwapAction(trace)
},
......@@ -454,10 +446,8 @@ export function SwapForm({ disableTokenInputs = false, onCurrencyChange }: SwapF
(outputCurrency: Currency) => {
onCurrencySelection(Field.OUTPUT, outputCurrency)
onCurrencyChange?.({
[Field.INPUT]: swapState[Field.INPUT],
[Field.OUTPUT]: {
currencyId: getSwapCurrencyId(outputCurrency),
},
inputCurrencyId: swapState.inputCurrencyId,
outputCurrencyId: getSwapCurrencyId(outputCurrency),
})
maybeLogFirstSwapAction(trace)
},
......
......@@ -3,7 +3,7 @@ import { ChainId } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import { Trace } from 'analytics'
import { NetworkAlert } from 'components/NetworkAlert/NetworkAlert'
import { Field, SwapTab } from 'components/swap/constants'
import { SwapTab } from 'components/swap/constants'
import { PageWrapper, SwapWrapper } from 'components/swap/styled'
import SwapHeader from 'components/swap/SwapHeader'
import { SwitchLocaleLink } from 'components/SwitchLocaleLink'
......@@ -50,8 +50,8 @@ export default function SwapPage({ className }: { className?: string }) {
className={className}
chainId={supportedChainId ?? ChainId.MAINNET}
disableTokenInputs={supportedChainId === undefined}
initialInputCurrencyId={parsedSwapState?.[Field.INPUT]?.currencyId}
initialOutputCurrencyId={parsedSwapState?.[Field.OUTPUT]?.currencyId}
initialInputCurrencyId={parsedSwapState?.inputCurrencyId}
initialOutputCurrencyId={parsedSwapState?.outputCurrencyId}
/>
<NetworkAlert />
</PageWrapper>
......@@ -77,7 +77,7 @@ export function Swap({
}: {
className?: string
chainId?: ChainId
onCurrencyChange?: (selected: Pick<SwapState, Field.INPUT | Field.OUTPUT>) => void
onCurrencyChange?: (selected: Pick<SwapState, 'inputCurrencyId' | 'outputCurrencyId'>) => void
disableTokenInputs?: boolean
initialInputCurrencyId?: string | null
initialOutputCurrencyId?: string | null
......
......@@ -14,7 +14,7 @@ const defaultState = {
user: {},
_persist: {
rehydrated: true,
version: 6,
version: 7,
},
application: {
chainId: null,
......
......@@ -8,6 +8,7 @@ import { migration3 } from './migrations/3'
import { migration4 } from './migrations/4'
import { migration5 } from './migrations/5'
import { migration6 } from './migrations/6'
import { migration7 } from './migrations/7'
import { legacyLocalStorageMigration } from './migrations/legacy'
/**
......@@ -27,6 +28,7 @@ export const migrations: MigrationManifest = {
4: migration4,
5: migration5,
6: migration6,
7: migration7,
}
// We use a custom migration function for the initial state, because redux-persist
......
......@@ -15,7 +15,7 @@ const previousState: PersistAppStateV1 = {
tokens: {},
pairs: {},
timestamp: Date.now(),
hideAndroidAnnouncementBanner: false,
hideAppPromoBanner: false,
},
_persist: {
version: 0,
......
......@@ -17,7 +17,7 @@ const previousState: PersistAppStateV2 = {
tokens: {},
pairs: {},
timestamp: Date.now(),
hideAndroidAnnouncementBanner: false,
hideAppPromoBanner: false,
},
_persist: {
version: 1,
......
......@@ -38,7 +38,7 @@ const previousState: PersistAppStateV3 = {
},
pairs: {},
timestamp: Date.now(),
hideAndroidAnnouncementBanner: false,
hideAppPromoBanner: false,
},
_persist: {
version: 2,
......
......@@ -19,7 +19,7 @@ const previousState: PersistAppStateV4 = {
tokens: {},
pairs: {},
timestamp: Date.now(),
hideAndroidAnnouncementBanner: false,
hideAppPromoBanner: false,
},
_persist: {
version: 3,
......
......@@ -21,7 +21,7 @@ const previousState: PersistAppStateV5 = {
tokens: {},
pairs: {},
timestamp: Date.now(),
hideAndroidAnnouncementBanner: false,
hideAppPromoBanner: false,
},
_persist: {
version: 4,
......
......@@ -20,7 +20,7 @@ const persistUserState: PersistAppStateV6['user'] = {
tokens: {},
pairs: {},
timestamp: Date.now(),
hideAndroidAnnouncementBanner: false,
hideAppPromoBanner: false,
}
const previousStateUnselected: PersistAppStateV6 = {
......
import { createMigrate } from 'redux-persist'
import { RouterPreference } from 'state/routing/types'
import { SlippageTolerance } from 'state/user/types'
import { migration1 } from './1'
import { migration2 } from './2'
import { migration3 } from './3'
import { migration4 } from './4'
import { migration5 } from './5'
import { migration6 } from './6'
import { migration7, PersistAppStateV7 } from './7'
const previousState: PersistAppStateV7 = {
user: {
userRouterPreference: RouterPreference.API,
userLocale: null,
userHideClosedPositions: false,
userSlippageTolerance: SlippageTolerance.Auto,
userSlippageToleranceHasBeenMigratedToAuto: true,
userDeadline: 1800,
tokens: {},
pairs: {},
timestamp: Date.now(),
hideAppPromoBanner: false,
},
_persist: {
version: 6,
rehydrated: true,
},
}
describe('migration to v7', () => {
it('should migrate users who currently have `hideAndroidAnnouncementBanner` preference', async () => {
const migrator = createMigrate(
{
1: migration1,
2: migration2,
3: migration3,
4: migration4,
5: migration5,
6: migration6,
7: migration7,
},
{ debug: false }
)
const result: any = await migrator(previousState, 7)
expect(result?.user?.hideAndroidAnnouncementBanner).toBeUndefined()
expect(result?.user?.hideAppPromoBanner).toEqual(false)
expect(result?._persist.version).toEqual(7)
})
it('should not change hideAppPromoBanner value if user already hideAndroidAnnouncementBanner', async () => {
const migrator = createMigrate(
{
1: migration1,
2: migration2,
3: migration3,
4: migration4,
5: migration5,
6: migration6,
7: migration7,
},
{ debug: false }
)
const result: any = await migrator(
{
...previousState,
user: {
...previousState.user,
hideAndroidAnnouncementBanner: true,
},
} as PersistAppStateV7,
7
)
expect(result?.user?.hideAppPromoBanner).toEqual(true)
expect(result?.user?.hideAndroidAnnouncementBanner).toBeUndefined()
expect(result?._persist.version).toEqual(7)
})
it('should not migrate user if user does not exist', async () => {
const migrator = createMigrate(
{
1: migration1,
2: migration2,
3: migration3,
4: migration4,
5: migration5,
6: migration6,
7: migration7,
},
{ debug: false }
)
const result: any = await migrator(
{
...previousState,
user: undefined,
} as PersistAppStateV7,
7
)
expect(result?.user).toBeUndefined()
expect(result?._persist.version).toEqual(7)
})
})
import { PersistState } from 'redux-persist'
import { UserState } from 'state/user/reducer'
export type PersistAppStateV7 = {
_persist: PersistState
} & { user?: UserState & { hideAndroidAnnouncementBanner?: boolean } }
/**
* Migration to rename hideAndroidAnnouncementBanner to hideAppPromoBanner.
*/
export const migration7 = (state: PersistAppStateV7 | undefined) => {
if (!state) return state
const userHidAndroidAnnouncementBanner = state?.user?.hideAndroidAnnouncementBanner
if (state?.user && 'hideAndroidAnnouncementBanner' in state.user) {
delete state.user['hideAndroidAnnouncementBanner']
}
// If the the user has previously hidden the Android announcement banner, we respect that preference.
if (state?.user && userHidAndroidAnnouncementBanner) {
return {
...state,
user: {
...state.user,
hideAppPromoBanner: userHidAndroidAnnouncementBanner,
},
_persist: {
...state._persist,
version: 7,
},
}
}
return {
...state,
_persist: {
...state._persist,
version: 7,
},
}
}
......@@ -44,7 +44,7 @@ export type AppState = ReturnType<typeof appReducer>
const persistConfig: PersistConfig<AppState> = {
key: 'interface',
version: 6, // see migrations.ts for more details about this version
version: 7, // see migrations.ts for more details about this version
storage: localForage.createInstance({
name: 'redux',
}),
......
......@@ -87,7 +87,7 @@ interface ExpectedUserState {
}
}
timestamp: number
hideAndroidAnnouncementBanner: boolean
hideAppPromoBanner: boolean
showSurveyPopup?: boolean
originCountry?: string
}
......
......@@ -20,8 +20,9 @@ import {
import { isExactInput, transformQuoteToTrade } from './utils'
const UNISWAP_API_URL = process.env.REACT_APP_UNISWAP_API_URL
if (UNISWAP_API_URL === undefined) {
throw new Error(`UNISWAP_API_URL must be a defined environment variable`)
const UNISWAP_GATEWAY_DNS_URL = process.env.REACT_APP_UNISWAP_GATEWAY_DNS
if (UNISWAP_API_URL === undefined || UNISWAP_GATEWAY_DNS_URL === undefined) {
throw new Error(`UNISWAP_API_URL and UNISWAP_GATEWAY_DNS_URL must be defined environment variables`)
}
const CLIENT_PARAMS = {
......@@ -75,9 +76,7 @@ function getRoutingAPIConfig(args: GetQuoteArgs): RoutingConfig {
export const routingApi = createApi({
reducerPath: 'routingApi',
baseQuery: fetchBaseQuery({
baseUrl: UNISWAP_API_URL,
}),
baseQuery: fetchBaseQuery(),
endpoints: (build) => ({
getQuote: build.query<TradeResult, GetQuoteArgs>({
async onQueryStarted(args: GetQuoteArgs, { queryFulfilled }) {
......@@ -119,6 +118,7 @@ export const routingApi = createApi({
amount,
tradeType,
sendPortionEnabled,
gatewayDNSUpdateEnabled,
} = args
const requestBody = {
......@@ -133,10 +133,14 @@ export const routingApi = createApi({
configs: getRoutingAPIConfig(args),
}
const baseURL = gatewayDNSUpdateEnabled ? UNISWAP_GATEWAY_DNS_URL : UNISWAP_API_URL
const response = await fetch({
method: 'POST',
url: '/quote',
url: `${baseURL}/quote`,
body: JSON.stringify(requestBody),
headers: {
'x-request-source': 'uniswap-web',
},
})
if (response.error) {
......
......@@ -44,6 +44,7 @@ export interface GetQuoteArgs {
needsWrapIfUniswapX: boolean
uniswapXForceSyntheticQuotes: boolean
sendPortionEnabled: boolean
gatewayDNSUpdateEnabled: boolean
}
export type GetQuickQuoteArgs = {
......
......@@ -62,6 +62,7 @@ const MOCK_ARGS: GetQuoteArgs = {
needsWrapIfUniswapX: USDCAmount.currency.isNative,
uniswapXForceSyntheticQuotes: false,
sendPortionEnabled: true,
gatewayDNSUpdateEnabled: false,
}
describe('#useRoutingAPITrade ExactIn', () => {
......
......@@ -29,6 +29,7 @@ const BASE_ARGS = {
userOptedOutOfUniswapX: false,
isUniswapXDefaultEnabled: false,
sendPortionEnabled: true,
gatewayDNSUpdateEnabled: false,
}
function constructArgs(currencyIn: Currency, currencyOut: Currency): GetQuoteArgs {
......
......@@ -48,8 +48,13 @@ describe('signature reducer', () => {
},
})
// Adding a signature w/ same id should throw
expect(() => store.dispatch(addSignature(signature))).toThrow()
// Adding a signature w/ same id should be a no-op
store.dispatch(addSignature(signature))
expect(store.getState()).toStrictEqual({
[account]: {
[signature.id]: signature,
},
})
})
})
......
......@@ -13,7 +13,7 @@ const signatureSlice = createSlice({
initialState,
reducers: {
addSignature(signatures, { payload }: { payload: SignatureDetails }) {
if (signatures[payload.offerer]?.[payload.id]) throw Error('Attempted to add existing signature.')
if (signatures[payload.offerer]?.[payload.id]) return
const accountSignatures = signatures[payload.offerer] ?? {}
accountSignatures[payload.id] = payload
......
......@@ -44,22 +44,14 @@ describe('Swap Context', () => {
},
},
prefilledState: {
INPUT: {
currencyId: undefined,
},
OUTPUT: {
currencyId: undefined,
},
inputCurrencyId: undefined,
outputCurrencyId: undefined,
},
setCurrentTab: expect.any(Function),
setSwapState: expect.any(Function),
swapState: {
INPUT: {
currencyId: undefined,
},
OUTPUT: {
currencyId: undefined,
},
inputCurrencyId: undefined,
outputCurrencyId: undefined,
independentField: 'INPUT',
recipient: null,
typedValue: '',
......
......@@ -9,12 +9,8 @@ import { queryParametersToSwapState, SwapInfo, useDerivedSwapInfo } from './hook
export interface SwapState {
readonly independentField: Field
readonly typedValue: string
readonly [Field.INPUT]: {
readonly currencyId?: string | null
}
readonly [Field.OUTPUT]: {
readonly currencyId?: string | null
}
inputCurrencyId?: string | null
outputCurrencyId?: string | null
// the typed recipient address or ENS name, or null if swap should go to sender
readonly recipient: string | null
}
......@@ -24,12 +20,8 @@ export const initialSwapState: SwapState = queryParametersToSwapState(parsedQuer
type SwapContextType = {
swapState: SwapState
prefilledState: {
INPUT: {
currencyId?: string | null
}
OUTPUT: {
currencyId?: string | null
}
inputCurrencyId?: string | null
outputCurrencyId?: string | null
}
derivedSwapInfo: SwapInfo
setSwapState: Dispatch<SetStateAction<SwapState>>
......@@ -55,12 +47,8 @@ export const EMPTY_DERIVED_SWAP_INFO: SwapInfo = Object.freeze({
export const SwapContext = createContext<SwapContextType>({
swapState: initialSwapState,
prefilledState: {
INPUT: {
currencyId: undefined,
},
OUTPUT: {
currencyId: undefined,
},
inputCurrencyId: undefined,
outputCurrencyId: undefined,
},
chainId: ChainId.MAINNET,
derivedSwapInfo: EMPTY_DERIVED_SWAP_INFO,
......@@ -88,8 +76,8 @@ export function SwapContextProvider({
const prefilledState = useMemo(
() => ({
[Field.INPUT]: { currencyId: initialInputCurrencyId },
[Field.OUTPUT]: { currencyId: initialOutputCurrencyId },
inputCurrencyId: initialInputCurrencyId,
outputCurrencyId: initialOutputCurrencyId,
}),
[initialInputCurrencyId, initialOutputCurrencyId]
)
......
......@@ -15,8 +15,8 @@ describe('hooks', () => {
)
)
).toEqual({
[Field.OUTPUT]: { currencyId: '0x6B175474E89094C44Da98b954EedeAC495271d0F' },
[Field.INPUT]: { currencyId: 'ETH' },
outputCurrencyId: '0x6B175474E89094C44Da98b954EedeAC495271d0F',
inputCurrencyId: 'ETH',
typedValue: '20.5',
independentField: Field.OUTPUT,
recipient: null,
......@@ -27,8 +27,8 @@ describe('hooks', () => {
expect(
queryParametersToSwapState(parse('?outputCurrency=invalid', { parseArrays: false, ignoreQueryPrefix: true }))
).toEqual({
[Field.INPUT]: { currencyId: 'ETH' },
[Field.OUTPUT]: { currencyId: null },
inputCurrencyId: 'ETH',
outputCurrencyId: null,
typedValue: '',
independentField: Field.INPUT,
recipient: null,
......@@ -41,8 +41,8 @@ describe('hooks', () => {
parse('?outputCurrency=eth&exactAmount=20.5', { parseArrays: false, ignoreQueryPrefix: true })
)
).toEqual({
[Field.OUTPUT]: { currencyId: 'ETH' },
[Field.INPUT]: { currencyId: null },
outputCurrencyId: 'ETH',
inputCurrencyId: null,
typedValue: '20.5',
independentField: Field.INPUT,
recipient: null,
......@@ -55,8 +55,8 @@ describe('hooks', () => {
parse('?outputCurrency=eth&exactAmount=20.5&recipient=abc', { parseArrays: false, ignoreQueryPrefix: true })
)
).toEqual({
[Field.OUTPUT]: { currencyId: 'ETH' },
[Field.INPUT]: { currencyId: null },
outputCurrencyId: 'ETH',
inputCurrencyId: null,
typedValue: '20.5',
independentField: Field.INPUT,
recipient: null,
......@@ -72,8 +72,8 @@ describe('hooks', () => {
})
)
).toEqual({
[Field.OUTPUT]: { currencyId: 'ETH' },
[Field.INPUT]: { currencyId: null },
outputCurrencyId: 'ETH',
inputCurrencyId: null,
typedValue: '20.5',
independentField: Field.INPUT,
recipient: TEST_RECIPIENT_ADDRESS,
......@@ -88,8 +88,8 @@ describe('hooks', () => {
})
)
).toEqual({
[Field.OUTPUT]: { currencyId: 'ETH' },
[Field.INPUT]: { currencyId: null },
outputCurrencyId: 'ETH',
inputCurrencyId: null,
typedValue: '20.5',
independentField: Field.INPUT,
recipient: 'bob.argent.xyz',
......
......@@ -31,21 +31,22 @@ export function useSwapActionHandlers(): {
const onCurrencySelection = useCallback(
(field: Field, currency: Currency) => {
const currencyId = currency.isToken ? currency.address : currency.isNative ? 'ETH' : ''
const otherField = field === Field.INPUT ? Field.OUTPUT : Field.INPUT
const [currentCurrencyKey, otherCurrencyKey]: (keyof SwapState)[] =
field === Field.INPUT ? ['inputCurrencyId', 'outputCurrencyId'] : ['outputCurrencyId', 'inputCurrencyId']
setSwapState((state) => {
if (currencyId === state[otherField].currencyId) {
if (currencyId === state[otherCurrencyKey]) {
// the case where we have to swap the order
return {
...state,
independentField: state.independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT,
[field]: { currencyId },
[otherField]: { currencyId: state[field].currencyId },
[currentCurrencyKey]: currencyId,
[otherCurrencyKey]: state[currentCurrencyKey],
}
} else {
// the normal case
return {
...state,
[field]: { currencyId },
[currentCurrencyKey]: currencyId,
}
}
})
......@@ -60,8 +61,8 @@ export function useSwapActionHandlers(): {
// To prevent swaps with FOT tokens as exact-outputs, we leave it as an exact-in swap and use the previously estimated output amount as the new exact-in amount.
return {
...state,
[Field.INPUT]: { currencyId: state[Field.OUTPUT].currencyId },
[Field.OUTPUT]: { currencyId: state[Field.INPUT].currencyId },
inputCurrencyId: state.outputCurrencyId,
outputCurrencyId: state.inputCurrencyId,
typedValue: previouslyEstimatedOutput,
}
}
......@@ -69,8 +70,8 @@ export function useSwapActionHandlers(): {
return {
...state,
independentField: state.independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT,
[Field.INPUT]: { currencyId: state[Field.OUTPUT].currencyId },
[Field.OUTPUT]: { currencyId: state[Field.INPUT].currencyId },
inputCurrencyId: state.outputCurrencyId,
outputCurrencyId: state.inputCurrencyId,
}
})
},
......@@ -134,13 +135,7 @@ export type SwapInfo = {
export function useDerivedSwapInfo(state: SwapState, chainId: ChainId | undefined): SwapInfo {
const { account } = useWeb3React()
const {
independentField,
typedValue,
[Field.INPUT]: { currencyId: inputCurrencyId },
[Field.OUTPUT]: { currencyId: outputCurrencyId },
recipient,
} = state
const { independentField, typedValue, inputCurrencyId, outputCurrencyId, recipient } = state
const inputCurrency = useCurrency(inputCurrencyId, chainId)
const outputCurrency = useCurrency(outputCurrencyId, chainId)
......@@ -318,12 +313,8 @@ export function queryParametersToSwapState(parsedQs: ParsedQs): SwapState {
const recipient = validatedRecipient(parsedQs.recipient)
return {
[Field.INPUT]: {
currencyId: inputCurrency === '' ? null : inputCurrency ?? null,
},
[Field.OUTPUT]: {
currencyId: outputCurrency === '' ? null : outputCurrency ?? null,
},
inputCurrencyId: inputCurrency === '' ? null : inputCurrency ?? null,
outputCurrencyId: outputCurrency === '' ? null : outputCurrency ?? null,
typedValue,
independentField,
recipient,
......
......@@ -15,7 +15,7 @@ import { useDefaultActiveTokens } from '../../hooks/Tokens'
import {
addSerializedPair,
addSerializedToken,
updateHideAndroidAnnouncementBanner,
updateHideAppPromoBanner,
updateHideClosedPositions,
updateUserDeadline,
updateUserLocale,
......@@ -206,15 +206,15 @@ export function usePairAdder(): (pair: Pair) => void {
)
}
export function useHideAndroidAnnouncementBanner(): [boolean, () => void] {
export function useHideAppPromoBanner(): [boolean, () => void] {
const dispatch = useAppDispatch()
const hideAndroidAnnouncementBanner = useAppSelector((state) => state.user.hideAndroidAnnouncementBanner)
const hideAppPromoBanner = useAppSelector((state) => state.user.hideAppPromoBanner)
const toggleHideAndroidAnnouncementBanner = useCallback(() => {
dispatch(updateHideAndroidAnnouncementBanner({ hideAndroidAnnouncementBanner: true }))
const toggleHideAppPromoBanner = useCallback(() => {
dispatch(updateHideAppPromoBanner({ hideAppPromoBanner: true }))
}, [dispatch])
return [hideAndroidAnnouncementBanner, toggleHideAndroidAnnouncementBanner]
return [hideAppPromoBanner, toggleHideAppPromoBanner]
}
/**
......
......@@ -9,7 +9,7 @@ import reducer, {
clearRecentConnectionMeta,
initialState,
setRecentConnectionDisconnected,
updateHideAndroidAnnouncementBanner,
updateHideAppPromoBanner,
updateHideClosedPositions,
updateRecentConnectionMeta,
updateUserDeadline,
......@@ -100,10 +100,10 @@ describe('swap reducer', () => {
})
})
describe('updateHideAndroidAnnouncementBanner', () => {
it('updates the updateHideAndroidAnnouncementBanner', () => {
store.dispatch(updateHideAndroidAnnouncementBanner({ hideAndroidAnnouncementBanner: true }))
expect(store.getState().hideAndroidAnnouncementBanner).toEqual(true)
describe('updateHideAppPromoBanner', () => {
it('updates the updateHideAppPromoBanner', () => {
store.dispatch(updateHideAppPromoBanner({ hideAppPromoBanner: true }))
expect(store.getState().hideAppPromoBanner).toEqual(true)
})
})
......
......@@ -46,7 +46,7 @@ export interface UserState {
}
timestamp: number
hideAndroidAnnouncementBanner: boolean
hideAppPromoBanner: boolean
// undefined means has not gone through A/B split yet
showSurveyPopup?: boolean
......@@ -68,7 +68,7 @@ export const initialState: UserState = {
tokens: {},
pairs: {},
timestamp: currentTimestamp(),
hideAndroidAnnouncementBanner: false,
hideAppPromoBanner: false,
showSurveyPopup: undefined,
originCountry: undefined,
}
......@@ -112,8 +112,8 @@ const userSlice = createSlice({
updateHideClosedPositions(state, action) {
state.userHideClosedPositions = action.payload.userHideClosedPositions
},
updateHideAndroidAnnouncementBanner(state, action) {
state.hideAndroidAnnouncementBanner = action.payload.hideAndroidAnnouncementBanner
updateHideAppPromoBanner(state, action) {
state.hideAppPromoBanner = action.payload.hideAppPromoBanner
},
addSerializedToken(state, { payload: { serializedToken } }) {
if (!state.tokens) {
......@@ -152,6 +152,6 @@ export const {
updateUserDeadline,
updateUserLocale,
updateUserSlippageTolerance,
updateHideAndroidAnnouncementBanner,
updateHideAppPromoBanner,
} = userSlice.actions
export default userSlice.reducer
import { Currency } from '@uniswap/sdk-core'
export function currencyId(currency: Currency): string {
if (currency.isNative) return 'ETH'
if (currency.isToken) return currency.address
export function currencyId(currency?: Currency): string {
if (currency?.isNative) return 'ETH'
if (currency?.isToken) return currency.address
throw new Error('invalid currency')
}
......@@ -6,9 +6,16 @@ const complexityRules = {
'max-depth': ['error', 4], // prevent deeply nested code paths which are hard to read
'max-nested-callbacks': ['error', 3],
'max-lines': ['error', 500], // cap file length
complexity: ['error', 20], // restrict cyclomatic complexity (number of linearly independent paths )
complexity: ['error', 20], // restrict cyclomatic complexity (number of linearly independent paths)
}
// The ESLint browser environment defines all browser globals as valid,
// even though most people don't know some of them exist (e.g. `name` or `status`).
// This is dangerous as it hides accidentally undefined variables.
// We blacklist the globals that we deem potentially confusing.
// To use them, explicitly reference them, e.g. `window.name` or `window.status`.
const restrictedGlobals = require('confusing-browser-globals')
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
......@@ -46,6 +53,7 @@ module.exports = {
'no-extra-boolean-cast': 'error',
'no-ex-assign': 'error',
'no-console': 'warn',
'no-restricted-globals': ['error'].concat(restrictedGlobals),
"no-relative-import-paths/no-relative-import-paths": [
2,
{
......@@ -235,6 +243,13 @@ module.exports = {
"@jambit/typed-redux-saga/delegate-effects": "error"
}
},
// Allow more depth for testing files
{
"files": ["./**/*.test.ts", "./**/*.test.tsx"],
"rules": {
'max-nested-callbacks': ['error', 4],
}
},
{
files: ['*.json'],
rules: {
......@@ -302,6 +317,7 @@ module.exports = {
'Unicon',
'yourname',
'yourusername',
'Unitags',
],
},
......
......@@ -25,5 +25,6 @@ module.exports = {
UNISWAP_API_BASE_URL: 'https://api.uniswap.org',
UNISWAP_APP_URL: 'https://app.uniswap.org',
WALLETCONNECT_PROJECT_ID: 123,
UNITAGS_API_URL: 'https://api.uniswap.org/unitags',
},
};
......@@ -35,7 +35,7 @@ module.exports = {
},
modulePathIgnorePatterns: ['<rootDir>/node_modules'],
testPathIgnorePatterns: ['<rootDir>/node_modules'],
testMatch: ['<rootDir>/**/?(*.)+(spec|test).[jt]s?(x)'],
testMatch: ['<rootDir>/**/*.(spec|test).[jt]s?(x)'],
setupFilesAfterEnv: ['<rootDir>/../../config/jest-presets/jest/setup.js'],
// consider enabling for speed
// changedSince: 'master',
......
import { assert, errorToString, NotImplementedError } from 'utilities/src/errors'
describe('NotImplementedError', () => {
it('throws an error with the correct message', () => {
expect(() => {
throw new NotImplementedError('functionName')
}).toThrow('functionName() not implemented. Did you forget a platform override?')
})
})
describe('assert', () => {
it('throws an error if the predicate is false', () => {
expect(() => {
assert(false, 'error message')
}).toThrow('error message')
})
it('does nothing if the predicate is true', () => {
expect(() => {
assert(true, 'error message')
}).not.toThrow()
})
})
describe('errorToString', () => {
it('returns the error message if the error is an Error', () => {
expect(errorToString(new Error('error message'))).toBe('error message')
})
it('returns the error message if the error is a string', () => {
expect(errorToString('error message')).toBe('error message')
})
it('returns the error message if the error is a number', () => {
expect(errorToString(123)).toBe('Error code: 123')
})
it('returns the error message if the error is an object', () => {
expect(errorToString({ error: 'message' })).toBe('{"error":"message"}')
})
it('Trims error message if it is longer than maxLength', () => {
expect(errorToString('error message', 5)).toBe('error...')
})
})
import { Percent } from '@uniswap/sdk-core'
import { formatPriceImpact } from './formatPriceImpact'
describe('formatPriceImpact', () => {
it('returns negative price impact value formatted as percentage for positive values', () => {
expect(formatPriceImpact(new Percent(1))).toBe('-100.000%')
expect(formatPriceImpact(new Percent(1, 2))).toBe('-50.000%')
expect(formatPriceImpact(new Percent(2, 3))).toBe('-66.667%')
})
it('returns positive price impact value formatted as percentage for negative values', () => {
expect(formatPriceImpact(new Percent(-1))).toBe('100.000%')
expect(formatPriceImpact(new Percent(-1, 2))).toBe('50.000%')
expect(formatPriceImpact(new Percent(-2, 3))).toBe('66.667%')
})
})
import { render } from '@testing-library/react'
import { renderHook } from '@testing-library/react-hooks'
import { createRef, forwardRef, useRef } from 'react'
import { act } from 'react-test-renderer'
import { useAsyncData, useForwardRef, useMemoCompare, usePrevious } from './hooks'
describe('usePrevious', () => {
it('returns undefined on first render', () => {
const { result } = renderHook(() => usePrevious(1))
expect(result.current).toBe(undefined)
})
it('returns the previous value', () => {
const { result, rerender } = renderHook((props) => usePrevious(props), {
initialProps: 1,
})
rerender(2)
expect(result.current).toBe(1)
rerender(3)
expect(result.current).toBe(2)
})
})
describe('useAsyncData', () => {
const promise = new Promise((resolve) => {
setTimeout(() => {
resolve('data')
}, 1000)
})
it('returns undefined and isLoading set to true before data is loaded', async () => {
const asyncCallback = jest.fn().mockResolvedValue('data')
const { result, waitForNextUpdate } = renderHook(() => useAsyncData(asyncCallback))
expect(result.current).toEqual({ data: undefined, isLoading: true })
await waitForNextUpdate() // Removes warning about not waiting for an update
})
it('returns the data and isLoading set to false after data is loaded', async () => {
const asyncCallback = jest.fn().mockResolvedValue('data')
const { result, waitForNextUpdate, rerender } = renderHook(() => useAsyncData(asyncCallback))
expect(result.current).toEqual({ data: undefined, isLoading: true })
await act(async () => {
rerender()
await waitForNextUpdate()
})
expect(result.current).toEqual({ data: 'data', isLoading: false })
})
it('calls onCancel when the component is unmounted and the request is still pending', async () => {
const asyncCallback = jest.fn().mockResolvedValue('data')
const onCancel = jest.fn()
const { unmount } = renderHook(() => useAsyncData(asyncCallback, onCancel))
await act(() => {
unmount()
})
expect(onCancel).toHaveBeenCalled()
})
it("doesn't call onCancel when the component is unmounted and the request is not pending", async () => {
const asyncCallback = jest.fn().mockResolvedValue('data')
const onCancel = jest.fn()
const { unmount, rerender, waitForNextUpdate } = renderHook(() =>
useAsyncData(asyncCallback, onCancel)
)
await act(async () => {
rerender()
await waitForNextUpdate()
unmount()
})
expect(onCancel).not.toHaveBeenCalled()
})
it("doesn't cancel the callback when the hook is passed the same callback", async () => {
// Long async callback that won't finish before the new callback is passed
const initialCallback = jest.fn()
const cancel = jest.fn()
const { rerender, waitForNextUpdate } = renderHook(
({ asyncCallback, onCancel }) => useAsyncData(asyncCallback, onCancel),
{
initialProps: { asyncCallback: initialCallback, onCancel: cancel },
}
)
expect(initialCallback).toHaveBeenCalledTimes(1)
expect(cancel).not.toHaveBeenCalled()
await act(async () => {
rerender({ asyncCallback: initialCallback, onCancel: cancel })
await waitForNextUpdate()
})
// When the hook rerenders and the same callback is passed, it should not cancel the previous one
expect(initialCallback).toHaveBeenCalledTimes(1)
expect(cancel).not.toHaveBeenCalled()
})
it('cancels the old callback and calls the new one when the callback attribute changes', async () => {
// Long async callback that won't finish before the new callback is passed
const initialCallback = jest.fn().mockImplementation(() => promise)
const cancel = jest.fn()
const { rerender, waitForNextUpdate } = renderHook(
({ asyncCallback, onCancel }) => useAsyncData(asyncCallback, onCancel),
{
initialProps: { asyncCallback: initialCallback, onCancel: cancel },
}
)
const newCallback = jest.fn().mockResolvedValue('data')
expect(initialCallback).toHaveBeenCalledTimes(1)
expect(cancel).toHaveBeenCalledTimes(0)
await act(async () => {
rerender({ asyncCallback: newCallback, onCancel: cancel })
await waitForNextUpdate()
})
expect(initialCallback).toHaveBeenCalledTimes(1) // No more calls, the one previous remains
expect(newCallback).toHaveBeenCalledTimes(1)
expect(cancel).toHaveBeenCalledTimes(1)
})
it("doesn't cause additional re-renders when the callback attribute changes", async () => {
// Long async callback that won't finish before the new callback is passed
const onCancel = jest.fn().mockImplementation(() => promise)
let rendersCount = 0
const fn1 = jest.fn().mockImplementation(() => promise)
const { rerender, result, waitForNextUpdate } = renderHook(() => {
rendersCount += 1
return useAsyncData(fn1, onCancel)
})
expect(rendersCount).toBe(1)
const fn2 = jest.fn().mockImplementation(() => promise)
await act(async () => {
rerender({ asyncCallback: fn2, onCancel })
await waitForNextUpdate()
})
// The hook should only re-render because of the new async callback
// (it shouldn't update internal state to indicate loading)
expect(rendersCount).toBe(2)
expect(result.current).toEqual({ data: undefined, isLoading: true })
})
})
describe('useMemoCompare', () => {
it('returns the same value when the comparison function returns true', () => {
const initialValue = { a: 1 }
const { result, rerender } = renderHook(
(props) =>
useMemoCompare(
() => props,
() => true
),
{
initialProps: initialValue,
}
)
rerender({ a: 1 })
expect(result.current).toBe(initialValue) // Check that the reference is the same as the initial value
})
it('returns the new value when the comparison function returns false', () => {
const { result, rerender } = renderHook(
(props) =>
useMemoCompare(
() => props,
() => false
),
{
initialProps: { a: 1 },
}
)
const newValue = { a: 2 }
rerender(newValue)
expect(result.current).toEqual(newValue) // Check that the reference is the same as the new value
})
})
describe('useForwardRef', () => {
it('should forward localRef properties into forwardedRef', async () => {
const fn1 = jest.fn()
const refData = { prop1: 'value1', prop2: 'value2', fn1 }
type RefType = typeof refData
const TestComponent = forwardRef<RefType>(function TestComponent(_, ref) {
const localRef = useRef<RefType>(refData)
useForwardRef(ref, localRef)
return null
})
const forwardedRef = createRef<RefType>()
await act(async () => {
render(<TestComponent ref={forwardedRef} />)
})
// Now check if forwardRef has the properties from the initial object
expect(forwardedRef.current?.prop1).toEqual('value1')
expect(forwardedRef.current?.prop2).toEqual('value2')
expect(forwardedRef.current?.fn1).toEqual(fn1)
})
})
......@@ -15,8 +15,6 @@ export function usePrevious<T>(value: T): T | undefined {
return ref.current
}
// adapted from https://usehooks.com/useAsync/ but simplified
// above link contains example on how to add delayed execution if ever needed
export function useAsyncData<T>(
asyncCallback: () => Promise<T> | undefined,
onCancel?: () => void
......@@ -25,65 +23,55 @@ export function useAsyncData<T>(
data: T | undefined
} {
const [state, setState] = useState<{
data: {
res: T | undefined
input: () => Promise<T> | undefined
}
data: T | undefined
isLoading: boolean
}>({
data: {
res: undefined,
input: asyncCallback,
},
data: undefined,
isLoading: true,
})
const isMountedRef = useRef(true)
const onCancelRef = useRef(onCancel)
const lastCompletedAsyncCallbackRef = useRef(asyncCallback)
useEffect(() => {
return () => {
isMountedRef.current = false
}
}, [])
let isPending = false
useEffect(() => {
if (!state.isLoading) {
setState((currentState) => ({ ...currentState, isLoading: true }))
async function runCallback(): Promise<void> {
isPending = true
const data = await asyncCallback()
if (isPending) {
setState((prevState) => ({ ...prevState, data, isLoading: false }))
}
}
let isCancelled = false
async function runCallback(): Promise<void> {
const res = await asyncCallback()
// Prevent setting state if the component has unmounted (prevents memory leaks)
if (!isMountedRef.current) return
// Prevent setting state if the request was cancelled
if (isCancelled) return
setState({
isLoading: false,
data: {
res,
input: asyncCallback,
},
runCallback()
.catch(() => {
if (isPending) {
setState((prevState) => ({ ...prevState, isLoading: false }))
}
})
.finally(() => {
isPending = false
lastCompletedAsyncCallbackRef.current = asyncCallback
})
}
runCallback().catch(() => undefined)
const handleCancel = onCancelRef.current
return () => {
isCancelled = true
onCancel?.()
if (!isPending) return
isPending = false
if (handleCancel) {
handleCancel()
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [asyncCallback])
return useMemo(() => {
if (asyncCallback !== state.data.input) return { isLoading: true, data: undefined }
return { isLoading: state.isLoading, data: state.data.res }
}, [asyncCallback, state.isLoading, state.data])
if (asyncCallback !== lastCompletedAsyncCallbackRef.current) {
return { isLoading: true, data: undefined }
}
return state
}, [asyncCallback, state])
}
// modified from https://usehooks.com/useMemoCompare/
export function useMemoCompare<T>(next: () => T, compare: (a: T | undefined, b: T) => boolean): T {
// Ref for storing previous value
......@@ -98,11 +86,9 @@ export function useMemoCompare<T>(next: () => T, compare: (a: T | undefined, b:
// If not equal update previousRef to next value.
// We only update if not equal so that this hook continues to return
// the same old value if compare keeps returning true.
useEffect(() => {
if (!isEqual) {
previousRef.current = nextValue
}
})
if (!isEqual || !previous) {
previousRef.current = nextValue
}
// Finally, if equal then return the previous value if it's set
return isEqual && previous ? previous : nextValue
......
import { currentTimeInSeconds, inXMinutesUnix, isStale } from './time'
jest.useFakeTimers()
describe('isStale', () => {
it('returns true if lastUpdated is null', () => {
expect(isStale(null, 1000)).toBe(true)
})
it('returns true if the lastUpdated timestamp is older than the staleTime', () => {
const now = Date.now()
jest.spyOn(Date, 'now').mockReturnValue(now)
const lastUpdated = now - 2000
const staleTime = 1000
expect(isStale(lastUpdated, staleTime)).toBe(true)
})
it('returns false if the lastUpdated timestamp is newer than the staleTime', () => {
const now = Date.now()
jest.spyOn(Date, 'now').mockReturnValue(now)
const lastUpdated = now - 500
const staleTime = 1000
expect(isStale(lastUpdated, staleTime)).toBe(false)
})
it('returns false if the lastUpdated timestamp is equal to the staleTime', () => {
const now = Date.now()
jest.spyOn(Date, 'now').mockReturnValue(now)
const lastUpdated = now - 1000
const staleTime = 1000
expect(isStale(lastUpdated, staleTime)).toBe(false)
})
})
describe('currentTimeInSeconds', () => {
it('returns the current time in seconds', () => {
const now = Date.now()
jest.setSystemTime(now) // Ensures that dayjs and Date.now() return the same value
expect(currentTimeInSeconds()).toBe(Math.floor(now / 1000))
})
})
describe('inXMinutesUnix', () => {
it('returns current time advanced by x minutes in seconds', () => {
const now = Date.now()
jest.setSystemTime(now) // Ensures that dayjs and Date.now() return the same value
expect(inXMinutesUnix(5)).toBe(Math.floor((now + 5 * 60 * 1000) / 1000))
})
})
import { renderHook } from '@testing-library/react-hooks'
import { act } from 'react-test-renderer'
import { DEFAULT_DELAY, useDebounceWithStatus } from './timing'
import {
DEFAULT_DELAY,
promiseMinDelay,
promiseTimeout,
useDebounceWithStatus,
useInterval,
useTimeout,
} from './timing'
jest.useFakeTimers()
const timedPromise = (duration: number, shouldResolve = true): Promise<string> =>
new Promise((resolve, reject) =>
setTimeout(() => (shouldResolve ? resolve('resolve') : reject(new Error('reject'))), duration)
)
describe('promiseTimeout', () => {
it("returns null if the provided promise doesn't resolve or reject in time", async () => {
const promise = promiseTimeout(timedPromise(2000), 1000) // 2 seconds promise with 1 second timeout
jest.advanceTimersByTime(2000)
const result = await promise
expect(result).toBeNull()
})
it('returns the result of the provided promise if it resolves in time', async () => {
const promise = promiseTimeout(timedPromise(500), 1000) // 0.5 seconds promise with 1 second timeout
jest.advanceTimersByTime(1000)
const result = await promise
expect(result).toBe('resolve')
})
it('rejects if the provided promise rejects in time', async () => {
const promise = promiseTimeout(timedPromise(500, false), 1000) // 0.5 seconds promise with 1 second timeout
jest.advanceTimersByTime(1000)
await expect(promise).rejects.toThrow('reject')
})
})
describe('promiseMinDelay', () => {
it('returns result only after specified minimum delay time', async () => {
const promise = promiseMinDelay(timedPromise(500), 1000) // 0.5 seconds promise with 1 second min delay
jest.advanceTimersByTime(999)
const stillPending = 'still pending'
const promiseOrNull = Promise.race([Promise.resolve(stillPending), promise])
expect(await promiseOrNull).toBe(stillPending) // Shouldn't have resolved yet
jest.advanceTimersByTime(1)
const result = await promise
// Now should have resolved because 1000 ms have passed
expect(result).toBe('resolve')
})
it('returns result after the promise resolves if it resolves after the minimum timeout', async () => {
const promise = promiseMinDelay(timedPromise(2000), 1000) // 2 seconds promise with 1 second min delay
jest.advanceTimersByTime(1999)
const stillPending = 'still pending'
const promiseOrNull = Promise.race([Promise.resolve(stillPending), promise])
expect(await promiseOrNull).toBe(stillPending) // Shouldn't have resolved yet
jest.advanceTimersByTime(1)
const result = await promise
// Now should have resolved because the promise resolved after 2000 ms
expect(result).toBe('resolve')
})
it('rejects if the promise rejects before the minimum timeout', async () => {
const promise = promiseMinDelay(timedPromise(500, false), 1000) // 0.5 seconds promise with 1 second min delay
jest.advanceTimersByTime(1000)
await expect(promise).rejects.toThrow('reject')
})
it('rejects if the promise rejects after the minimum timeout', async () => {
const promise = promiseMinDelay(timedPromise(2000, false), 1000) // 2 seconds promise with 1 second min delay
jest.advanceTimersByTime(2000)
await expect(promise).rejects.toThrow('reject')
})
})
describe('useInterval', () => {
it('calls the callback with the specified interval', () => {
const callback = jest.fn()
renderHook(() => useInterval(callback, 1000))
expect(callback).not.toHaveBeenCalled()
jest.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalledTimes(1)
jest.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalledTimes(2)
})
it("doesn't call the callback if the delay is null", () => {
const callback = jest.fn()
renderHook(() => useInterval(callback, null))
expect(callback).not.toHaveBeenCalled()
jest.advanceTimersByTime(1000)
expect(callback).not.toHaveBeenCalled()
})
it('calls the callback immediately if immediateStart is true', () => {
const callback = jest.fn()
renderHook(() => useInterval(callback, 1000, true))
expect(callback).toHaveBeenCalledTimes(1)
jest.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalledTimes(2)
})
})
describe('useTimeout', () => {
it('calls the callback after the specified delay', () => {
const callback = jest.fn()
renderHook(() => useTimeout(callback, 1000))
expect(callback).not.toHaveBeenCalled()
jest.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalledTimes(1)
})
it('calls the timeout with the 0ms delay if no delay is specified', () => {
const callback = jest.fn()
renderHook(() => useTimeout(callback))
expect(callback).not.toHaveBeenCalled()
jest.advanceTimersByTime(0)
expect(callback).toHaveBeenCalledTimes(1)
})
})
describe('useDebounceWithStatus', () => {
it('correctly delays updating the value', async () => {
let value = 'first'
......
......@@ -17,6 +17,7 @@ import {
UNISWAP_API_BASE_URL,
UNISWAP_API_KEY,
UNISWAP_APP_URL,
UNITAGS_API_URL,
WALLETCONNECT_PROJECT_ID,
} from 'react-native-dotenv'
......@@ -40,6 +41,7 @@ export interface Config {
statSigProxyUrl: string
walletConnectProjectId: string
quicknodeBnbRpcUrl: string
unitagsApiUrl: string
}
const _config: Config = {
......@@ -62,6 +64,7 @@ const _config: Config = {
statSigProxyUrl: process.env.STATSIG_PROXY_URL || STATSIG_PROXY_URL,
walletConnectProjectId: process.env.WALLETCONNECT_PROJECT_ID || WALLETCONNECT_PROJECT_ID,
quicknodeBnbRpcUrl: process.env.QUICKNODE_BNB_RPC_URL || QUICKNODE_BNB_RPC_URL,
unitagsApiUrl: process.env.UNITAGS_API_URL || UNITAGS_API_URL,
}
export const config = Object.freeze(_config)
......
......@@ -30,6 +30,7 @@ export const uniswapUrls = {
privacyPolicyUrl: 'https://uniswap.org/privacy-policy',
appUrl: `https://${UNISWAP_APP_HOSTNAME}`,
interfaceUrl: `https://${UNISWAP_APP_HOSTNAME}/#/swap`,
unitagsApiUrl: getUnitagsApiUrl(),
}
function getUniswapApiBaseUrl(): string {
......@@ -59,3 +60,7 @@ function getUniswapAmplitudeProxyUrl(): string {
function getUniswapStatsigProxyUrl(): string {
return `${config.uniswapApiBaseUrl}/v1/statsig-proxy`
}
function getUnitagsApiUrl(): string {
return config.unitagsApiUrl
}
"""This directive allows results to be deferred during execution"""
directive @defer on FIELD
"""Tells the service which mutation triggers this subscription."""
directive @aws_subscribe(
"""
List of mutations which will trigger this subscription when they are called.
"""
mutations: [String]
) on FIELD_DEFINITION
"""
Tells the service this field/object has access authorized by an OIDC token.
Tells the service this field/object has access authorized by a Cognito User Pools token.
"""
directive @aws_oidc on OBJECT | FIELD_DEFINITION
directive @aws_cognito_user_pools(
"""List of cognito user pool groups which have access on this field"""
cognito_groups: [String]
) on OBJECT | FIELD_DEFINITION
"""
Tells the service which subscriptions will be published to when this mutation is
......@@ -25,34 +20,39 @@ directive @aws_publish(
subscriptions: [String]
) on FIELD_DEFINITION
"""Directs the schema to enforce authorization on a field"""
directive @aws_auth(
"""List of cognito user pool groups which have access on this field"""
cognito_groups: [String]
) on FIELD_DEFINITION
"""
Tells the service this field/object has access authorized by a Lambda Authorizer.
"""
directive @aws_lambda on OBJECT | FIELD_DEFINITION
"""
Tells the service this field/object has access authorized by an API key.
"""
directive @aws_api_key on OBJECT | FIELD_DEFINITION
"""Tells the service which mutation triggers this subscription."""
directive @aws_subscribe(
"""
List of mutations which will trigger this subscription when they are called.
"""
mutations: [String]
) on FIELD_DEFINITION
"""
Tells the service this field/object has access authorized by a Cognito User Pools token.
Tells the service this field/object has access authorized by an OIDC token.
"""
directive @aws_cognito_user_pools(
"""List of cognito user pool groups which have access on this field"""
cognito_groups: [String]
) on OBJECT | FIELD_DEFINITION
directive @aws_oidc on OBJECT | FIELD_DEFINITION
"""
Tells the service this field/object has access authorized by sigv4 signing.
"""
directive @aws_iam on OBJECT | FIELD_DEFINITION
"""Directs the schema to enforce authorization on a field"""
directive @aws_auth(
"""List of cognito user pool groups which have access on this field"""
cognito_groups: [String]
) on FIELD_DEFINITION
"""
Tells the service this field/object has access authorized by an API key.
"""
directive @aws_api_key on OBJECT | FIELD_DEFINITION
union ActivityDetails = TransactionDetails | SwapOrderDetails
......@@ -729,11 +729,26 @@ type Portfolio {
assetActivities(page: Int, pageSize: Int, includeOffChain: Boolean, chains: [Chain!], _fs: AssetActivitySwitch): [AssetActivity]
}
""" Specify how the portfolio value should be calculated for each `ownerAddress`.
"""
input PortfolioValueModifier {
ownerAddress: String!
tokenIncludeOverrides: [ContractInput!]
tokenExcludeOverrides: [ContractInput!]
includeSmallBalances: Boolean
includeSpamTokens: Boolean
}
enum PriceSource {
SUBGRAPH_V2
SUBGRAPH_V3
}
enum ProtocolVersion {
V2
V3
}
type PushNotification {
id: ID!
contents: AWSJSON!
......@@ -743,6 +758,8 @@ type PushNotification {
}
type Query {
""" returns top pools by total value locked"""
topV3Pools(chain: Chain!, first: Int!, tvlCursor: Float): [V3Pool!]
convert(fromAmount: AmountInput!, toCurrency: Currency!): Amount
tokens(contracts: [ContractInput!]!): [Token]
......@@ -754,7 +771,7 @@ type Query {
token(chain: Chain!, address: String): Token
tokenProjects(contracts: [ContractInput!]!): [TokenProject]
searchTokens(searchQuery: String!, chains: [Chain!]): [Token]
portfolios(ownerAddresses: [String!]!, chains: [Chain!], lookupTokens: [ContractInput!]): [Portfolio]
portfolios(ownerAddresses: [String!]!, chains: [Chain!], lookupTokens: [ContractInput!], valueModifiers: [PortfolioValueModifier!]): [Portfolio]
topTokens(chain: Chain, page: Int, pageSize: Int, orderBy: TokenSortableField): [Token]
topCollections(chains: [Chain!], orderBy: CollectionSortableField, duration: HistoryDuration, after: String, first: Int, cursor: String, limit: Int): NftCollectionConnection
nftAssets(chain: Chain, address: String!, orderBy: NftAssetSortableField, asc: Boolean, filter: NftAssetsFilterInput, after: String, first: Int, before: String, last: Int): NftAssetConnection
......@@ -794,6 +811,7 @@ type SwapOrderDetails {
inputTokenQuantity: String!
outputToken: Token!
outputTokenQuantity: String!
expiry: Int!
}
enum SwapOrderStatus {
......@@ -863,6 +881,7 @@ type TokenBalance {
ownerAddress: String!
token: Token
tokenProjectMarket: TokenProjectMarket
isHidden: Boolean
}
input TokenInput {
......@@ -878,6 +897,7 @@ type TokenMarket {
price: Amount
priceSource: PriceSource!
totalValueLocked: Amount
fullyDilutedValuation: Amount
volume(duration: HistoryDuration!): Amount
pricePercentChange(duration: HistoryDuration!): Amount
priceHistory(duration: HistoryDuration!): [TimestampedAmount]
......@@ -1048,3 +1068,19 @@ enum TransactionType {
WITHDRAW
}
type V3Pool {
id: ID!
protocolVersion: ProtocolVersion!
chain: Chain!
address: String!
createdAtTimestamp: Int
totalLiquidity: Amount
feeTier: Float
token0: Token
token0Supply: Float
token1: Token
token1Supply: Float
txCount: Int
cumulativeVolume(duration: HistoryDuration): Amount
}
......@@ -801,11 +801,25 @@ export type PortfolioTokensTotalDenominatedValueChangeArgs = {
duration?: InputMaybe<HistoryDuration>;
};
/** Specify how the portfolio value should be calculated for each `ownerAddress`. */
export type PortfolioValueModifier = {
includeSmallBalances?: InputMaybe<Scalars['Boolean']>;
includeSpamTokens?: InputMaybe<Scalars['Boolean']>;
ownerAddress: Scalars['String'];
tokenExcludeOverrides?: InputMaybe<Array<ContractInput>>;
tokenIncludeOverrides?: InputMaybe<Array<ContractInput>>;
};
export enum PriceSource {
SubgraphV2 = 'SUBGRAPH_V2',
SubgraphV3 = 'SUBGRAPH_V3'
}
export enum ProtocolVersion {
V2 = 'V2',
V3 = 'V3'
}
export type PushNotification = {
__typename?: 'PushNotification';
contents: Scalars['AWSJSON'];
......@@ -836,6 +850,8 @@ export type Query = {
tokens?: Maybe<Array<Maybe<Token>>>;
topCollections?: Maybe<NftCollectionConnection>;
topTokens?: Maybe<Array<Maybe<Token>>>;
/** returns top pools by total value locked */
topV3Pools?: Maybe<Array<V3Pool>>;
transactionNotification?: Maybe<TransactionNotification>;
};
......@@ -908,6 +924,7 @@ export type QueryPortfoliosArgs = {
chains?: InputMaybe<Array<Chain>>;
lookupTokens?: InputMaybe<Array<ContractInput>>;
ownerAddresses: Array<Scalars['String']>;
valueModifiers?: InputMaybe<Array<PortfolioValueModifier>>;
};
......@@ -952,6 +969,13 @@ export type QueryTopTokensArgs = {
};
export type QueryTopV3PoolsArgs = {
chain: Chain;
first: Scalars['Int'];
tvlCursor?: InputMaybe<Scalars['Float']>;
};
export type QueryTransactionNotificationArgs = {
address: Scalars['String'];
chain: Chain;
......@@ -988,6 +1012,7 @@ export enum SubscriptionType {
export type SwapOrderDetails = {
__typename?: 'SwapOrderDetails';
expiry: Scalars['Int'];
hash: Scalars['String'];
id: Scalars['ID'];
inputToken: Token;
......@@ -1070,6 +1095,7 @@ export type TokenBalance = {
blockTimestamp?: Maybe<Scalars['Int']>;
denominatedValue?: Maybe<Amount>;
id: Scalars['ID'];
isHidden?: Maybe<Scalars['Boolean']>;
ownerAddress: Scalars['String'];
quantity?: Maybe<Scalars['Float']>;
token?: Maybe<Token>;
......@@ -1085,6 +1111,7 @@ export type TokenInput = {
export type TokenMarket = {
__typename?: 'TokenMarket';
fullyDilutedValuation?: Maybe<Amount>;
id: Scalars['ID'];
price?: Maybe<Amount>;
priceHighLow?: Maybe<Amount>;
......@@ -1310,6 +1337,28 @@ export enum TransactionType {
Withdraw = 'WITHDRAW'
}
export type V3Pool = {
__typename?: 'V3Pool';
address: Scalars['String'];
chain: Chain;
createdAtTimestamp?: Maybe<Scalars['Int']>;
cumulativeVolume?: Maybe<Amount>;
feeTier?: Maybe<Scalars['Float']>;
id: Scalars['ID'];
protocolVersion: ProtocolVersion;
token0?: Maybe<Token>;
token0Supply?: Maybe<Scalars['Float']>;
token1?: Maybe<Token>;
token1Supply?: Maybe<Scalars['Float']>;
totalLiquidity?: Maybe<Amount>;
txCount?: Maybe<Scalars['Int']>;
};
export type V3PoolCumulativeVolumeArgs = {
duration?: InputMaybe<HistoryDuration>;
};
export type TokenPriceHistoryQueryVariables = Exact<{
contract: ContractInput;
duration?: InputMaybe<HistoryDuration>;
......@@ -1320,6 +1369,7 @@ export type TokenPriceHistoryQuery = { __typename?: 'Query', tokenProjects?: Arr
export type AccountListQueryVariables = Exact<{
addresses: Array<Scalars['String']> | Scalars['String'];
valueModifiers?: InputMaybe<Array<PortfolioValueModifier> | PortfolioValueModifier>;
}>;
......@@ -1372,17 +1422,19 @@ export type NftsTabQuery = { __typename?: 'Query', nftBalances?: { __typename?:
export type PortfolioBalancesQueryVariables = Exact<{
ownerAddress: Scalars['String'];
valueModifiers?: InputMaybe<Array<PortfolioValueModifier> | PortfolioValueModifier>;
}>;
export type PortfolioBalancesQuery = { __typename?: 'Query', portfolios?: Array<{ __typename?: 'Portfolio', id: string, tokensTotalDenominatedValue?: { __typename?: 'Amount', value: number } | null, tokensTotalDenominatedValueChange?: { __typename?: 'AmountChange', absolute?: { __typename?: 'Amount', value: number } | null, percentage?: { __typename?: 'Amount', value: number } | null } | null, tokenBalances?: Array<{ __typename?: 'TokenBalance', id: string, quantity?: number | null, denominatedValue?: { __typename?: 'Amount', currency?: Currency | null, value: number } | null, token?: { __typename?: 'Token', chain: Chain, address?: string | null, symbol?: string | null, decimals?: number | null, project?: { __typename?: 'TokenProject', id: string, name?: string | null, logoUrl?: string | null, safetyLevel?: SafetyLevel | null, isSpam?: boolean | null } | null } | null, tokenProjectMarket?: { __typename?: 'TokenProjectMarket', relativeChange24?: { __typename?: 'Amount', value: number } | null } | null } | null> | null } | null> | null };
export type PortfolioBalancesQuery = { __typename?: 'Query', portfolios?: Array<{ __typename?: 'Portfolio', id: string, tokensTotalDenominatedValue?: { __typename?: 'Amount', value: number } | null, tokensTotalDenominatedValueChange?: { __typename?: 'AmountChange', absolute?: { __typename?: 'Amount', value: number } | null, percentage?: { __typename?: 'Amount', value: number } | null } | null, tokenBalances?: Array<{ __typename?: 'TokenBalance', id: string, quantity?: number | null, isHidden?: boolean | null, denominatedValue?: { __typename?: 'Amount', currency?: Currency | null, value: number } | null, token?: { __typename?: 'Token', chain: Chain, address?: string | null, symbol?: string | null, decimals?: number | null, project?: { __typename?: 'TokenProject', id: string, name?: string | null, logoUrl?: string | null, safetyLevel?: SafetyLevel | null, isSpam?: boolean | null } | null } | null, tokenProjectMarket?: { __typename?: 'TokenProjectMarket', relativeChange24?: { __typename?: 'Amount', value: number } | null } | null } | null> | null } | null> | null };
export type MultiplePortfolioBalancesQueryVariables = Exact<{
ownerAddresses: Array<Scalars['String']> | Scalars['String'];
valueModifiers?: InputMaybe<Array<PortfolioValueModifier> | PortfolioValueModifier>;
}>;
export type MultiplePortfolioBalancesQuery = { __typename?: 'Query', portfolios?: Array<{ __typename?: 'Portfolio', id: string, tokensTotalDenominatedValue?: { __typename?: 'Amount', value: number } | null, tokensTotalDenominatedValueChange?: { __typename?: 'AmountChange', absolute?: { __typename?: 'Amount', value: number } | null, percentage?: { __typename?: 'Amount', value: number } | null } | null, tokenBalances?: Array<{ __typename?: 'TokenBalance', id: string, quantity?: number | null, denominatedValue?: { __typename?: 'Amount', currency?: Currency | null, value: number } | null, token?: { __typename?: 'Token', chain: Chain, address?: string | null, symbol?: string | null, decimals?: number | null, project?: { __typename?: 'TokenProject', id: string, name?: string | null, logoUrl?: string | null, safetyLevel?: SafetyLevel | null, isSpam?: boolean | null } | null } | null, tokenProjectMarket?: { __typename?: 'TokenProjectMarket', relativeChange24?: { __typename?: 'Amount', value: number } | null } | null } | null> | null } | null> | null };
export type MultiplePortfolioBalancesQuery = { __typename?: 'Query', portfolios?: Array<{ __typename?: 'Portfolio', id: string, tokensTotalDenominatedValue?: { __typename?: 'Amount', value: number } | null, tokensTotalDenominatedValueChange?: { __typename?: 'AmountChange', absolute?: { __typename?: 'Amount', value: number } | null, percentage?: { __typename?: 'Amount', value: number } | null } | null, tokenBalances?: Array<{ __typename?: 'TokenBalance', id: string, quantity?: number | null, isHidden?: boolean | null, denominatedValue?: { __typename?: 'Amount', currency?: Currency | null, value: number } | null, token?: { __typename?: 'Token', chain: Chain, address?: string | null, symbol?: string | null, decimals?: number | null, project?: { __typename?: 'TokenProject', id: string, name?: string | null, logoUrl?: string | null, safetyLevel?: SafetyLevel | null, isSpam?: boolean | null } | null } | null, tokenProjectMarket?: { __typename?: 'TokenProjectMarket', relativeChange24?: { __typename?: 'Amount', value: number } | null } | null } | null> | null } | null> | null };
export type SelectWalletScreenQueryVariables = Exact<{
ownerAddresses: Array<Scalars['String']> | Scalars['String'];
......@@ -1501,6 +1553,7 @@ export type ConvertQuery = { __typename?: 'Query', convert?: { __typename?: 'Amo
export type PortfolioBalanceQueryVariables = Exact<{
owner: Scalars['String'];
valueModifiers?: InputMaybe<Array<PortfolioValueModifier> | PortfolioValueModifier>;
}>;
......@@ -1617,10 +1670,11 @@ export type TokenPriceHistoryQueryHookResult = ReturnType<typeof useTokenPriceHi
export type TokenPriceHistoryLazyQueryHookResult = ReturnType<typeof useTokenPriceHistoryLazyQuery>;
export type TokenPriceHistoryQueryResult = Apollo.QueryResult<TokenPriceHistoryQuery, TokenPriceHistoryQueryVariables>;
export const AccountListDocument = gql`
query AccountList($addresses: [String!]!) {
query AccountList($addresses: [String!]!, $valueModifiers: [PortfolioValueModifier!]) {
portfolios(
ownerAddresses: $addresses
chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]
valueModifiers: $valueModifiers
) {
id
ownerAddress
......@@ -1644,6 +1698,7 @@ export const AccountListDocument = gql`
* const { data, loading, error } = useAccountListQuery({
* variables: {
* addresses: // value for 'addresses'
* valueModifiers: // value for 'valueModifiers'
* },
* });
*/
......@@ -2173,10 +2228,11 @@ export type NftsTabQueryHookResult = ReturnType<typeof useNftsTabQuery>;
export type NftsTabLazyQueryHookResult = ReturnType<typeof useNftsTabLazyQuery>;
export type NftsTabQueryResult = Apollo.QueryResult<NftsTabQuery, NftsTabQueryVariables>;
export const PortfolioBalancesDocument = gql`
query PortfolioBalances($ownerAddress: String!) {
query PortfolioBalances($ownerAddress: String!, $valueModifiers: [PortfolioValueModifier!]) {
portfolios(
ownerAddresses: [$ownerAddress]
chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]
valueModifiers: $valueModifiers
) {
id
tokensTotalDenominatedValue {
......@@ -2193,6 +2249,7 @@ export const PortfolioBalancesDocument = gql`
tokenBalances {
id
quantity
isHidden
denominatedValue {
currency
value
......@@ -2233,6 +2290,7 @@ export const PortfolioBalancesDocument = gql`
* const { data, loading, error } = usePortfolioBalancesQuery({
* variables: {
* ownerAddress: // value for 'ownerAddress'
* valueModifiers: // value for 'valueModifiers'
* },
* });
*/
......@@ -2248,10 +2306,11 @@ export type PortfolioBalancesQueryHookResult = ReturnType<typeof usePortfolioBal
export type PortfolioBalancesLazyQueryHookResult = ReturnType<typeof usePortfolioBalancesLazyQuery>;
export type PortfolioBalancesQueryResult = Apollo.QueryResult<PortfolioBalancesQuery, PortfolioBalancesQueryVariables>;
export const MultiplePortfolioBalancesDocument = gql`
query MultiplePortfolioBalances($ownerAddresses: [String!]!) {
query MultiplePortfolioBalances($ownerAddresses: [String!]!, $valueModifiers: [PortfolioValueModifier!]) {
portfolios(
ownerAddresses: $ownerAddresses
chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]
valueModifiers: $valueModifiers
) {
id
tokensTotalDenominatedValue {
......@@ -2268,6 +2327,7 @@ export const MultiplePortfolioBalancesDocument = gql`
tokenBalances {
id
quantity
isHidden
denominatedValue {
currency
value
......@@ -2308,6 +2368,7 @@ export const MultiplePortfolioBalancesDocument = gql`
* const { data, loading, error } = useMultiplePortfolioBalancesQuery({
* variables: {
* ownerAddresses: // value for 'ownerAddresses'
* valueModifiers: // value for 'valueModifiers'
* },
* });
*/
......@@ -3179,10 +3240,11 @@ export type ConvertQueryHookResult = ReturnType<typeof useConvertQuery>;
export type ConvertLazyQueryHookResult = ReturnType<typeof useConvertLazyQuery>;
export type ConvertQueryResult = Apollo.QueryResult<ConvertQuery, ConvertQueryVariables>;
export const PortfolioBalanceDocument = gql`
query PortfolioBalance($owner: String!) {
query PortfolioBalance($owner: String!, $valueModifiers: [PortfolioValueModifier!]) {
portfolios(
ownerAddresses: [$owner]
chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]
valueModifiers: $valueModifiers
) {
id
tokensTotalDenominatedValue {
......@@ -3213,6 +3275,7 @@ export const PortfolioBalanceDocument = gql`
* const { data, loading, error } = usePortfolioBalanceQuery({
* variables: {
* owner: // value for 'owner'
* valueModifiers: // value for 'valueModifiers'
* },
* });
*/
......
......@@ -48,6 +48,12 @@ export function setupCache(): InMemoryCache {
})
},
},
// Ignore `valueModifiers` when caching `portfolios`.
// IMPORTANT: This assumes that `valueModifiers` are always the same when querying `portfolios` across the entire app.
portfolios: {
keyArgs: ['chains', 'ownerAddresses'],
},
},
},
Token: {
......
......@@ -9,9 +9,12 @@ import {
getOnChainBalancesFetch,
STUB_ONCHAIN_BALANCES_ENDPOINT,
} from 'wallet/src/features/portfolio/api'
import { isAndroid, isIOS } from 'wallet/src/utils/platform'
const REST_API_URL = uniswapUrls.apiBaseUrl
const requestSource = isIOS ? 'uniswap-ios' : isAndroid ? 'uniswap-android' : 'uniswap-web'
// mapping from endpoint to custom fetcher, when needed
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ENDPOINT_TO_FETCHER: Record<string, (body: any) => Promise<Response>> = {
......@@ -40,6 +43,7 @@ export const getRestLink = (): ApolloLink => {
headers: {
'Content-Type': 'application/json',
'X-API-KEY': config.uniswapApiKey,
'x-request-source': requestSource,
Origin: config.uniswapAppUrl,
},
})
......@@ -56,6 +60,7 @@ export const getCustomGraphqlHttpLink = (endpoint: CustomEndpoint): ApolloLink =
headers: {
'Content-Type': 'application/json',
'X-API-KEY': endpoint.key,
'x-request-source': requestSource,
// TODO: [MOB-3883] remove once API gateway supports mobile origin URL
Origin: uniswapUrls.apiBaseUrl,
},
......@@ -67,6 +72,7 @@ export const getGraphqlHttpLink = (): ApolloLink =>
headers: {
'Content-Type': 'application/json',
'X-API-KEY': config.uniswapApiKey,
'x-request-source': requestSource,
// TODO: [MOB-3883] remove once API gateway supports mobile origin URL
Origin: uniswapUrls.apiBaseUrl,
},
......
......@@ -295,10 +295,14 @@ query NftsTab(
}
}
query PortfolioBalances($ownerAddress: String!) {
query PortfolioBalances(
$ownerAddress: String!
$valueModifiers: [PortfolioValueModifier!]
) {
portfolios(
ownerAddresses: [$ownerAddress]
chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]
valueModifiers: $valueModifiers
) {
id
# Total portfolio balance for header
......@@ -318,6 +322,7 @@ query PortfolioBalances($ownerAddress: String!) {
tokenBalances {
id
quantity
isHidden
denominatedValue {
currency
value
......@@ -344,10 +349,14 @@ query PortfolioBalances($ownerAddress: String!) {
}
}
query MultiplePortfolioBalances($ownerAddresses: [String!]!) {
query MultiplePortfolioBalances(
$ownerAddresses: [String!]!
$valueModifiers: [PortfolioValueModifier!]
) {
portfolios(
ownerAddresses: $ownerAddresses
chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]
valueModifiers: $valueModifiers
) {
id
......@@ -368,6 +377,7 @@ query MultiplePortfolioBalances($ownerAddresses: [String!]!) {
tokenBalances {
id
quantity
isHidden
denominatedValue {
currency
value
......
import {
ApolloClient,
FetchResult,
gql,
MutationHookOptions,
MutationResult,
NetworkStatus,
OperationVariables,
QueryHookOptions,
useApolloClient,
useMutation,
useQuery,
} from '@apollo/client'
import { useEffect, useMemo } from 'react'
......@@ -13,8 +17,7 @@ import { ROUTING_API_PATH } from 'wallet/src/features/routing/api'
/** Wrapper around Apollo client `useQuery` that calls REST APIs */
export function useRestQuery<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TData = any,
TData = unknown,
TVariables extends OperationVariables = OperationVariables
>(
// Relative URL path of the endpoint
......@@ -23,15 +26,20 @@ export function useRestQuery<
variables: TVariables,
// Fields requested from the endpoint
fields: string[],
options: Omit<
QueryHookOptions<{ data: TData }, { input: TVariables }>,
'variables' | 'fetchPolicy'
> & {
ttlMs: number
},
// When using `fetchPolicy: 'no-cache'`, you must omit `ttlMs`.
options:
| (Omit<
QueryHookOptions<{ data: TData }, { input: TVariables }>,
'variables' | 'fetchPolicy'
> & {
ttlMs: number
})
| (Omit<QueryHookOptions<{ data: TData }, { input: TVariables }>, 'variables'> & {
fetchPolicy: 'no-cache'
ttlMs?: undefined
}),
method: 'GET' | 'POST' = 'POST',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client?: ApolloClient<any>
client?: ApolloClient<unknown>
): GqlResult<TData> {
const document = gql`
query Query($input: REST!) {
......@@ -53,8 +61,9 @@ export function useRestQuery<
const queryResult = useQuery(document, queryOptions)
// @ts-expect-error timestamp is provided by useQuery
const lastFetchedTimestamp: number | undefined = queryResult.data?.data?.timestamp
// timestamp is injected by our `InMemoryCache` config (cache.ts)
const lastFetchedTimestamp = (queryResult.data?.data as undefined | { timestamp: number })
?.timestamp
const cacheExpired = getCacheExpired(lastFetchedTimestamp, options.ttlMs)
// re-export query result with easier data access
......@@ -77,9 +86,76 @@ export function useRestQuery<
return result
}
const getCacheExpired = (lastFetchedTimestamp: number | undefined, ttl: number): boolean => {
const getCacheExpired = (
lastFetchedTimestamp: number | undefined,
ttl: number | undefined
): boolean => {
// if there is no timestamp, then it's the first ever query and there is no cache to expire
if (!lastFetchedTimestamp) return false
// if there's no ttl, we just use apollo's fetch-policy and we do not want to manually refetch
if (ttl === undefined) return false
return Date.now() - lastFetchedTimestamp > ttl
}
/**
* Wrapper around Apollo client `useMutation` for making REST API calls.
*
* This function simplifies the process of executing RESTful operations such as POST, PUT, and DELETE
* through GraphQL mutations. It constructs a GraphQL mutation that integrates with the Apollo Client's `@rest` directive.
*
* @param path - The relative URL path of the REST API endpoint.
* @param fields - The fields to be returned in the response. These should match the structure of the expected REST API response.
* @param method - The HTTP method ('POST', 'PUT', 'DELETE') for the REST API call.
* @param options - Additional options for the mutation, excluding 'variables'.
* @param client - An optional ApolloClient instance. If not provided, the default client will be used.
* @returns A tuple where the first element is a simplified mutate function and the second element provides the mutation's status:
* - The mutate function directly accepts variables of type TVariables and handles the necessary structuring for the REST API call.
* - The status object includes fields such as 'data', 'loading', and 'error', providing information about the mutation execution.
*
* Example usage: `wallet/src/unitags/api.ts` and `mobile/src/feature/unitags/ChooseProfilePictureScreen.tsx`
*/
export function useRestMutation<
TData = unknown,
TVariables extends OperationVariables = OperationVariables
>(
path: string,
fields: string[],
options: Omit<MutationHookOptions<{ data: TData }, { input: TVariables }>, 'variables'>,
method: 'POST' | 'PUT' | 'DELETE',
client?: ApolloClient<object>
): [(variables: TVariables) => Promise<FetchResult<{ data: TData }>>, MutationResult<TData>] {
const document = gql`
mutation Mutation($input: REST!) {
data(input: $input) @rest(
type: "${path}Response",
path: "${path}",
method: "${method}"
) {
${fields.join('\n')}
}
}
`
const defaultClient = useApolloClient()
const mutationOptions: MutationHookOptions<{ data: TData }, { input: TVariables }> = {
...options,
client: client ?? defaultClient,
}
const [mutateFunction, mutationResult] = useMutation<{ data: TData }, { input: TVariables }>(
document,
mutationOptions
)
// Wrapper for the mutate function to simplify its usage
const wrappedMutateFunction = (variables: TVariables): ReturnType<typeof mutateFunction> => {
return mutateFunction({ variables: { input: variables } })
}
// Unwrap the response data
const modifiedMutationResult = {
...mutationResult,
data: mutationResult.data ? mutationResult.data.data : null,
}
return [wrappedMutateFunction, modifiedMutationResult]
}
import { useExperiment, useGate } from 'statsig-react-native'
import {
useExperiment,
useExperimentWithExposureLoggingDisabled,
useGate,
useGateWithExposureLoggingDisabled,
} from 'statsig-react-native'
import { EXPERIMENT_NAMES, EXPERIMENT_PARAMS, FEATURE_FLAGS } from './constants'
/**
* Returns feature flag (gate) value from Statsig
*/
export function useFeatureFlag(flagName: FEATURE_FLAGS): boolean {
const { value } = useGate(flagName)
return value
}
/**
* Returns if an experiment is enabled from Statsig
*/
export function useFeatureFlagWithExposureLoggingDisabled(flagName: FEATURE_FLAGS): boolean {
const { value } = useGateWithExposureLoggingDisabled(flagName)
return value
}
export function useExperimentEnabled(experimentName: EXPERIMENT_NAMES): boolean {
return useExperiment(experimentName).config.getValue(EXPERIMENT_PARAMS.Enabled) as boolean
}
export function useExperimentEnabledWithExposureLoggingDisabled(
experimentName: EXPERIMENT_NAMES
): boolean {
return useExperimentWithExposureLoggingDisabled(experimentName).config.getValue(
EXPERIMENT_PARAMS.Enabled
) as boolean
}
......@@ -210,7 +210,6 @@ export const fiatOnRampAggregatorApi = createApi({
return headers
},
}),
endpoints: (builder) => ({
fiatOnRampAggregatorCountryList: builder.query<MeldCountryPaymentMethodsResponse, void>({
query: () =>
......@@ -236,7 +235,7 @@ export const fiatOnRampAggregatorApi = createApi({
}),
transformResponse: (response: MeldCryptoQuoteResponse): Maybe<MeldQuote[]> =>
response?.quotes,
transformErrorResponse: (baseQueryReturnValue) => baseQueryReturnValue?.data,
keepUnusedDataFor: 0,
}),
fiatOnRampAggregatorServiceProviders: builder.query<MeldServiceProvidersResponse, void>({
query: () => '/service-providers/details?statuses=LIVE%2CRECENTLY_ADDED',
......@@ -251,7 +250,7 @@ export const fiatOnRampAggregatorApi = createApi({
// get all crypto currencies from all service providers that support the given fiat currency in the given country
Object.values(
response.reduce((acc: Record<string, MeldCryptoCurrency>, serviceProvider) => {
if (serviceProvider.crypto?.onRamp) return acc
if (!serviceProvider.crypto?.onRamp) return acc
const { countries, cryptoCurrencies } = serviceProvider.crypto.onRamp
if (countries.find((c) => c.countryCode === countryCode)) {
cryptoCurrencies.forEach((cryptoCurrency) => {
......
import { getCountryFlagSvgUrl } from './meld'
import { getCountryFlagSvgUrl, isMeldApiError } from './meld'
describe('getCountryFlagSvgUrl', () => {
test('should return the correct SVG URL for a given country code', () => {
......@@ -8,3 +8,26 @@ describe('getCountryFlagSvgUrl', () => {
expect(result).toBe(expectedUrl)
})
})
describe('isMeldApiError', () => {
test('returns true', () => {
const error = {
data: {
code: 'INVALID_AMOUNT_TOO_LOW',
message: 'Source amount is below the minimum allowed, which is 50.00 USD',
},
}
const result = isMeldApiError(error)
expect(result).toBe(true)
})
test('returns false', () => {
const error = {
data: {
message: 'Source amount is below the minimum allowed, which is 50.00 USD',
},
}
const result = isMeldApiError(error)
expect(result).toBe(false)
})
})
......@@ -114,6 +114,27 @@ export type MeldSupportedToken = {
export type MeldSupportedTokensResponse = MeldSupportedToken[]
export interface MeldApiError {
data: {
code: string
message: string
requestId?: string
timestamp?: string
}
}
export function getCountryFlagSvgUrl(countryCode: string): string {
return `https://images-country.meld.io/${countryCode}/flag.svg`
}
export function isMeldApiError(error: unknown): error is MeldApiError {
return (
typeof error === 'object' &&
error != null &&
'data' in error &&
typeof error.data === 'object' &&
error.data != null &&
'code' in error.data &&
'message' in error.data
)
}
query PortfolioBalance($owner: String!) {
portfolios(ownerAddresses: [$owner], chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]) {
query PortfolioBalance(
$owner: String!
$valueModifiers: [PortfolioValueModifier!]
) {
portfolios(
ownerAddresses: [$owner]
chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]
valueModifiers: $valueModifiers
) {
id
tokensTotalDenominatedValue {
value
......
import { ApolloClient, from, InMemoryCache } from '@apollo/client'
import { RetryLink } from '@apollo/client/link/retry'
import { RestLink } from 'apollo-link-rest'
import { uniswapUrls } from 'wallet/src/constants/urls'
import { useRestMutation, useRestQuery } from 'wallet/src/data/rest'
import {
UnitagAddressResponse,
UnitagClaimEligibilityParams,
UnitagClaimEligibilityResponse,
UnitagClaimResponse,
UnitagClaimUsernameRequestBody,
UnitagUsernameResponse,
} from 'wallet/src/features/unitags/types'
const restLink = new RestLink({
uri: `${uniswapUrls.unitagsApiUrl}`,
})
const retryLink = new RetryLink()
const apolloClient = new ApolloClient({
link: from([retryLink, restLink]),
cache: new InMemoryCache(),
defaultOptions: {
watchQuery: {
// ensures query is returning data even if some fields errored out
errorPolicy: 'all',
fetchPolicy: 'no-cache',
nextFetchPolicy: 'no-cache',
},
},
})
function addQueryParamsToEndpoint(
endpoint: string,
params: Record<string, string | number | boolean | undefined>
): string {
const url = new URL(endpoint, uniswapUrls.appBaseUrl) // dummy base URL, we only need the path with query params
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
// only add param if its value is not undefined
url.searchParams.append(key, String(value))
}
})
return url.pathname + url.search
}
export function useUnitagQuery(
username?: string
): ReturnType<typeof useRestQuery<UnitagUsernameResponse>> {
return useRestQuery<UnitagUsernameResponse, Record<string, never>>(
addQueryParamsToEndpoint('/username', { username }),
{},
['available', 'requiresEnsMatch', 'metadata', 'address'], // return all fields
{
skip: !username, // skip if username is not provided
fetchPolicy: 'no-cache',
},
'GET',
apolloClient
)
}
export function useUnitagByAddressQuery(
address?: Address
): ReturnType<typeof useRestQuery<UnitagAddressResponse>> {
return useRestQuery<UnitagAddressResponse, Record<string, never>>(
addQueryParamsToEndpoint('/address', { address }),
{},
['username', 'metadata'], // return all fields
{
skip: !address, // skip if address is not provided
fetchPolicy: 'no-cache',
},
'GET',
apolloClient
)
}
export function useClaimUnitagMutation(): ReturnType<
typeof useRestMutation<UnitagClaimResponse, UnitagClaimUsernameRequestBody>
> {
return useRestMutation<UnitagClaimResponse, UnitagClaimUsernameRequestBody>(
'/username',
['success', 'errorCode'], // return all fields
{},
'POST',
apolloClient
)
}
export function useUnitagClaimEligibilityQuery({
address,
deviceId,
skip,
}: UnitagClaimEligibilityParams & { skip?: boolean }): ReturnType<
typeof useRestQuery<UnitagClaimEligibilityResponse>
> {
return useRestQuery<UnitagClaimEligibilityResponse, Record<string, never>>(
addQueryParamsToEndpoint('/claim/eligibility', address ? { address, deviceId } : { deviceId }),
{},
['canClaim', 'errorCode', 'message'], // return all fields
{ skip, fetchPolicy: 'no-cache' },
'GET',
apolloClient
)
}
import { TFunction } from 'i18next'
import { useTranslation } from 'react-i18next'
import { getUniqueId } from 'react-native-device-info'
import { useAsyncData } from 'utilities/src/react/hooks'
import { ChainId } from 'wallet/src/constants/chains'
import { useENS } from 'wallet/src/features/ens/useENS'
import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants'
import { useFeatureFlag } from 'wallet/src/features/experiments/hooks'
import {
useUnitagByAddressQuery,
useUnitagClaimEligibilityQuery,
useUnitagQuery,
} from 'wallet/src/features/unitags/api'
import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks'
import { areAddressesEqual } from 'wallet/src/utils/addresses'
const MIN_UNITAG_LENGTH = 3
const MAX_UNITAG_LENGTH = 20
export const useCanActiveAddressClaimUnitag = (): boolean => {
const unitagsFeatureFlagEnabled = useFeatureFlag(FEATURE_FLAGS.Unitags)
const activeAddress = useActiveAccountAddressWithThrow()
const { data: deviceId } = useAsyncData(getUniqueId)
const { loading, data } = useUnitagClaimEligibilityQuery({
address: activeAddress,
deviceId: deviceId ?? '', // this is fine since we skip if deviceId is undefined
skip: !unitagsFeatureFlagEnabled || !deviceId,
})
return unitagsFeatureFlagEnabled && !loading && !!data?.canClaim
}
export const useCanAddressClaimUnitag = (address?: Address): boolean => {
const unitagsFeatureFlagEnabled = useFeatureFlag(FEATURE_FLAGS.Unitags)
const { data: deviceId } = useAsyncData(getUniqueId)
const { loading, data } = useUnitagClaimEligibilityQuery({
address,
deviceId: deviceId ?? '', // this is fine since we skip if deviceId is undefined
skip: !unitagsFeatureFlagEnabled || !deviceId,
})
return !loading && !!data?.canClaim
}
export const useUnitag = (address?: Address): string | undefined => {
const unitagsFeatureFlagEnabled = useFeatureFlag(FEATURE_FLAGS.Unitags)
const { data } = useUnitagByAddressQuery(unitagsFeatureFlagEnabled ? address : undefined)
return data?.username
}
// Helper function to enforce unitag length and alphanumeric characters
export const getUnitagFormatError = (unitag: string, t: TFunction): string | undefined => {
if (unitag.length < MIN_UNITAG_LENGTH) {
return t(`Unitags must be at least {{ minUnitagLength }} characters`, {
minUnitagLength: MIN_UNITAG_LENGTH,
})
} else if (unitag.length > MAX_UNITAG_LENGTH) {
return t(`Unitags cannot be more than {{ maxUnitagLength }} characters`, {
maxUnitagLength: MAX_UNITAG_LENGTH,
})
} else if (!/^[A-Za-z0-9]+$/.test(unitag)) {
return t('Unitags can only contain letters and numbers')
}
return undefined
}
export const useUnitagError = (
unitagAddress: Address | undefined,
unitag: string | undefined
): { unitagError: string | undefined; loading: boolean } => {
const { t } = useTranslation()
// Check for length and alphanumeric characters
let unitagError = unitag ? getUnitagFormatError(unitag, t) : undefined
// Skip the backend calls if we found an error
const unitagToSearch = unitagError ? undefined : unitag
const { loading: unitagLoading, data } = useUnitagQuery(unitagToSearch)
const { loading: ensLoading, address: ensAddress } = useENS(ChainId.Mainnet, unitagToSearch, true)
const loading = unitagLoading || ensLoading
// Check for availability and ENS match
const dataLoaded = !loading && !!data
const ensAddressMatchesUnitagAddress = areAddressesEqual(unitagAddress, ensAddress)
if (dataLoaded && !data.available) {
unitagError = t('This Unitag is not available')
}
if (dataLoaded && data.requiresEnsMatch && !ensAddressMatchesUnitagAddress) {
unitagError = t('To claim this Unitag you must own the {{ unitag }}.eth ENS', { unitag })
}
return { unitagError, loading }
}
export type UnitagUsernameResponse = {
available: boolean
requiresEnsMatch: boolean
metadata?: ProfileMetadata
address?: {
address: Address
}
}
export type UnitagAddressResponse = {
username?: string
metadata?: ProfileMetadata
}
export type UnitagClaimResponse = {
success: boolean
errorCode?: UnitagErrorCodes
}
export type UnitagClaimEligibilityResponse = {
canClaim: boolean
errorCode?: UnitagErrorCodes
}
export type ProfileMetadata = {
avatar?: string
description?: string
url?: string
twitter?: string
}
export type UnitagClaimUsernameRequestBody = {
address: Address
username: string
deviceId: string
metadata?: ProfileMetadata
}
export type UnitagClaimEligibilityParams = {
address?: Address
deviceId: string
}
// Copied enum from unitags backend code -- needs to be up-to-date
export enum UnitagErrorCodes {
UnitagNotAvailable = 'unitags-1',
RequiresENSMatch = 'unitags-2',
IPLimitReached = 'unitags-3',
AddressLimitReached = 'unitags-4',
DeviceLimitReached = 'unitags-5',
ExistingUnitagForDevice = 'unitags-6',
ExistingUnitagForAddress = 'unitags-7',
}
import { TFunction } from 'i18next'
import { UnitagErrorCodes } from 'wallet/src/features/unitags/types'
export function parseUnitagErrorCode(
t: TFunction,
unitag: string,
errorCode: UnitagErrorCodes
): string {
switch (errorCode) {
case UnitagErrorCodes.UnitagNotAvailable:
return t('This Unitag is not available')
case UnitagErrorCodes.RequiresENSMatch:
return t('To claim this Unitag you must own the {{ unitag }}.eth ENS', { unitag })
case UnitagErrorCodes.IPLimitReached:
case UnitagErrorCodes.AddressLimitReached:
case UnitagErrorCodes.DeviceLimitReached:
return t('Unable to claim Unitag')
case UnitagErrorCodes.ExistingUnitagForDevice:
return t('Existing unitag for this device')
case UnitagErrorCodes.ExistingUnitagForAddress:
return t('You already have a Unitag for this address')
default:
return t('Unknown error')
}
}
......@@ -5,8 +5,6 @@
"{{ prevTxnsCount }} previous transfer": "{{ prevTxnsCount }} previous transfer",
"{{ prevTxnsCount }} previous transfers": "{{ prevTxnsCount }} previous transfers",
"{{ token }} fee": "{{ token }} fee",
"{{amount}} maximum": "{{amount}} maximum",
"{{amount}} minimum": "{{amount}} minimum",
"{{assetName}} hidden": "{{assetName}} hidden",
"{{assetName}} unhidden": "{{assetName}} unhidden",
"{{authTypeCapitalized}} is disabled": "{{authTypeCapitalized}} is disabled",
......@@ -82,12 +80,10 @@
"Back up to iCloud": "Back up to iCloud",
"Back up your wallet": "Back up your wallet",
"Backed up": "Backed up",
"Backed up on:": "Backed up on:",
"Backed up to Google Drive": "Backed up to Google Drive",
"Backed up to iCloud": "Backed up to iCloud",
"Backing up to Google Drive...": "Backing up to Google Drive...",
"Backing up to iCloud...": "Backing up to iCloud...",
"Backup {{backupIndex}}": "Backup {{backupIndex}}",
"Backups let you restore your wallet if you delete the app or lose your device": "Backups let you restore your wallet if you delete the app or lose your device",
"Balance": "Balance",
"Balances on other networks": "Balances on other networks",
......@@ -223,6 +219,7 @@
"Error while checking transaction status": "Error while checking transaction status",
"Error while importing backups": "Error while importing backups",
"Euro": "Euro",
"Existing unitag for this device": "Existing unitag for this device",
"Failed to {{action}}": "Failed to {{action}}",
"Failed to approve {{currencySymbol}} for use with {{address}}.": "Failed to approve {{currencySymbol}} for use with {{address}}.",
"Failed to fetch token balances": "Failed to fetch token balances",
......@@ -340,8 +337,10 @@
"Market cap": "Market cap",
"Max": "Max",
"Max slippage": "Max slippage",
"Maximum {{amount}}": "Maximum {{amount}}",
"Maximum slippage": "Maximum slippage",
"Maybe later": "Maybe later",
"Minimum {{amount}}": "Minimum {{amount}}",
"mint": "mint",
"Minted": "Minted",
"Minting": "Minting",
......@@ -574,12 +573,14 @@
"This token isn’t traded on leading U.S. centralized exchanges. Always conduct your own research before trading.": "This token isn’t traded on leading U.S. centralized exchanges. Always conduct your own research before trading.",
"This trade cannot be completed right now": "This trade cannot be completed right now",
"This transaction is expected to fail": "This transaction is expected to fail",
"This Unitag is not available": "This Unitag is not available",
"This username is a simple, user-friendly way to use your address in transactions. Your current address remains unchanged and secure.": "This username is a simple, user-friendly way to use your address in transactions. Your current address remains unchanged and secure.",
"This wallet is blocked": "This wallet is blocked",
"This wallet is in view only mode": "This wallet is in view only mode",
"This wallet is view-only": "This wallet is view-only",
"This will remove your wallet from this device along with your recovery phrase.": "This will remove your wallet from this device along with your recovery phrase.",
"To": "To",
"To claim this Unitag you must own the {{ unitag }}.eth ENS": "To claim this Unitag you must own the {{ unitag }}.eth ENS",
"to create a public username and customizable profile.": "to create a public username and customizable profile.",
"To create more wallets, open the account switcher inside the extension popup, or reinstall the extension to start over": "To create more wallets, open the account switcher inside the extension popup, or reinstall the extension to start over",
"To receive notifications, turn on notifications for Uniswap Wallet in your device’s settings.": "To receive notifications, turn on notifications for Uniswap Wallet in your device’s settings.",
......@@ -618,6 +619,7 @@
"Unable to backup recovery phrase to Google Drive. Please ensure you have Google Drive enabled with available storage space and try again.": "Unable to backup recovery phrase to Google Drive. Please ensure you have Google Drive enabled with available storage space and try again.",
"Unable to backup recovery phrase to iCloud. Please ensure you have iCloud enabled with available storage space and try again.": "Unable to backup recovery phrase to iCloud. Please ensure you have iCloud enabled with available storage space and try again.",
"Unable to cancel transaction": "Unable to cancel transaction",
"Unable to claim Unitag": "Unable to claim Unitag",
"Unable to delete backup": "Unable to delete backup",
"Unable to replace transaction": "Unable to replace transaction",
"Unhide NFT": "Unhide NFT",
......@@ -627,8 +629,12 @@
"Uniswap usernames are built on top of ENS subdomains.": "Uniswap usernames are built on top of ENS subdomains.",
"Uniswap volume (24H)": "Uniswap volume (24H)",
"Uniswap Wallet currently supports {{ chains }}. Please only use \"{{ dappName }}\" on these chains": "Uniswap Wallet currently supports {{ chains }}. Please only use \"{{ dappName }}\" on these chains",
"Unitags can only contain letters and numbers": "Unitags can only contain letters and numbers",
"Unitags cannot be more than {{ maxUnitagLength }} characters": "Unitags cannot be more than {{ maxUnitagLength }} characters",
"Unitags must be at least {{ minUnitagLength }} characters": "Unitags must be at least {{ minUnitagLength }} characters",
"United States Dollar": "United States Dollar",
"Unknown": "Unknown",
"Unknown error": "Unknown error",
"unknown token": "unknown token",
"Unknown token": "Unknown token",
"Unlimited": "Unlimited",
......@@ -702,6 +708,7 @@
"Wrapping": "Wrapping",
"Write down your recovery phrase in order": "Write down your recovery phrase in order",
"Wrong recovery phrase": "Wrong recovery phrase",
"You already have a Unitag for this address": "You already have a Unitag for this address",
"You are in offline mode": "You are in offline mode",
"You can <1>enter</1> your recovery phrase on a new device <4>to restore your wallet</4> and its contents.": "You can <1>enter</1> your recovery phrase on a new device <4>to restore your wallet</4> and its contents.",
"You can also manually back up your recovery phrase by <2>writing it down</2> and storing it in a safe place.": "You can also manually back up your recovery phrase by <2>writing it down</2> and storing it in a safe place.",
......
......@@ -20,4 +20,5 @@ declare module 'react-native-dotenv' {
export const ONESIGNAL_APP_ID: string
export const WALLETCONNECT_PROJECT_ID: string
export const QUICKNODE_BNB_RPC_URL: string
export const UNITAGS_API_URL: string
}
{
"baseBranch": "origin/main",
"pipeline": {
"web:ignore-build": {
"comment": "this is used by vercel to cancel builds if not web"
},
"prepare": {
"inputs": [
"package.json"
......
{
"rewrites": [
{"source": "/(.*)", "destination": "/"}
]
}
......@@ -13478,13 +13478,6 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:13.13.5":
version: 13.13.5
resolution: "@types/node@npm:13.13.5"
checksum: 874bb45920a33d18619c19687425628ae536a28d3342c781f5c84e0ac41f3f6cbb5afda1b2d366e232a7862cbf44ba559c1775fc9db875388148d7f893f64ad0
languageName: node
linkType: hard
"@types/node@npm:16.9.1":
version: 16.9.1
resolution: "@types/node@npm:16.9.1"
......@@ -13492,6 +13485,13 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:18.16.0":
version: 18.16.0
resolution: "@types/node@npm:18.16.0"
checksum: 63e0042136663b9e85ce503a4c65406cc6621fdba63ea66c74b4b1364a9aa9bdb57cadcb76696abab177f38a819b0fa6ace9e7f1647dcb990aedb1b4bd01012f
languageName: node
linkType: hard
"@types/node@npm:^10.11.7, @types/node@npm:^10.12.18":
version: 10.17.60
resolution: "@types/node@npm:10.17.60"
......@@ -14515,7 +14515,7 @@ __metadata:
"@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
......@@ -14819,7 +14819,7 @@ __metadata:
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
......@@ -20623,7 +20623,7 @@ __metadata:
languageName: node
linkType: hard
"confusing-browser-globals@npm:^1.0.11":
"confusing-browser-globals@npm:1.0.11, confusing-browser-globals@npm:^1.0.11":
version: 1.0.11
resolution: "confusing-browser-globals@npm:1.0.11"
checksum: 3afc635abd37e566477f610e7978b15753f0e84025c25d49236f1f14d480117185516bdd40d2a2167e6bed8048641a9854964b9c067e3dcdfa6b5d0ad3c3a5ef
......@@ -25394,14 +25394,14 @@ __metadata:
languageName: node
linkType: hard
"expo-barcode-scanner@npm:12.3.2":
version: 12.3.2
resolution: "expo-barcode-scanner@npm:12.3.2"
"expo-barcode-scanner@npm:12.7.0":
version: 12.7.0
resolution: "expo-barcode-scanner@npm:12.7.0"
dependencies:
expo-image-loader: ~4.1.0
expo-image-loader: ~4.4.0
peerDependencies:
expo: "*"
checksum: b07831a8b7205c838be54268ac927ca4ce431065f55f551d37e9957e3b2bae0c6b208fee278d846a9f06d9cd9e71d54f3821818c80327fa8387a7b56ee1291af
checksum: 7672d68761eeb401188da5c5c85242f93940aa26acd2f989fb7af1a82e91264449c872376fdbe0acae07fbe8adb8dc712576c02ad166f36320664f0b605c86ed
languageName: node
linkType: hard
......@@ -25489,12 +25489,12 @@ __metadata:
languageName: node
linkType: hard
"expo-image-loader@npm:~4.1.0":
version: 4.1.1
resolution: "expo-image-loader@npm:4.1.1"
"expo-image-loader@npm:~4.4.0":
version: 4.4.0
resolution: "expo-image-loader@npm:4.4.0"
peerDependencies:
expo: "*"
checksum: 6ea7d49148d9126a79f00b1e9a2e648884d208daf8ac54c663eb941a7524d229aa907d6d45704598168c7d1efcb891bdc0bd91f077a53d3a270b18c269d43d99
checksum: e872e45a807cd867c5a8e8fc5665de33de5d467ea533222037b09b70268d0166012769bfcd340ab8922dbf826a3e4f0498ff73973ad62f21134764b4bc8b1f5a
languageName: node
linkType: hard
......@@ -46668,6 +46668,7 @@ __metadata:
"@types/react": ^18.0.15
"@typescript-eslint/eslint-plugin": 5.59.2
concurrently: ^8.0.1
confusing-browser-globals: 1.0.11
danger: 11.2.6
dotenv-cli: ^7.0.0
eslint: 8.44.0
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment