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]
......
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 { 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)
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment