Compare commits

...

3 Commits

Author SHA1 Message Date
Uniswap Labs Service Account
195703495c ci(release): publish latest release 2023-12-15 21:42:35 +00:00
Uniswap Labs Service Account
690d2970cd ci(release): publish latest release 2023-12-13 20:51:43 +00:00
Uniswap Labs Service Account
8465f6848c ci(release): publish latest release 2023-12-07 22:16:26 +00:00
171 changed files with 3541 additions and 1082 deletions

14
RELEASE
View File

@@ -1,6 +1,6 @@
IPFS hash of the deployment:
- CIDv0: `Qme44H1PdJPvgSyV94D33wyy895YsHH5fKWAuHN8jrexmq`
- CIDv1: `bafybeihjplb4afnfihydwdlkzfymtd26u3tqz64r6eighq5kdqu7s6x5hq`
- CIDv0: `QmRfmKBiCcSDFdGRZuJKqPtyenCsCFasgiwNvTJQMrbjT6`
- CIDv1: `bafybeibrpcihc5cqqzntesoc5dtxyili3uvyrfmxi7fcx4uteto7ucpj6e`
The latest release is always mirrored at [app.uniswap.org](https://app.uniswap.org).
@@ -10,15 +10,15 @@ You can also access the Uniswap Interface from an IPFS gateway.
Your Uniswap settings are never remembered across different URLs.
IPFS gateways:
- https://bafybeihjplb4afnfihydwdlkzfymtd26u3tqz64r6eighq5kdqu7s6x5hq.ipfs.dweb.link/
- https://bafybeihjplb4afnfihydwdlkzfymtd26u3tqz64r6eighq5kdqu7s6x5hq.ipfs.cf-ipfs.com/
- [ipfs://Qme44H1PdJPvgSyV94D33wyy895YsHH5fKWAuHN8jrexmq/](ipfs://Qme44H1PdJPvgSyV94D33wyy895YsHH5fKWAuHN8jrexmq/)
- https://bafybeibrpcihc5cqqzntesoc5dtxyili3uvyrfmxi7fcx4uteto7ucpj6e.ipfs.dweb.link/
- https://bafybeibrpcihc5cqqzntesoc5dtxyili3uvyrfmxi7fcx4uteto7ucpj6e.ipfs.cf-ipfs.com/
- [ipfs://QmRfmKBiCcSDFdGRZuJKqPtyenCsCFasgiwNvTJQMrbjT6/](ipfs://QmRfmKBiCcSDFdGRZuJKqPtyenCsCFasgiwNvTJQMrbjT6/)
### 5.1.1 (2023-12-06)
### 5.2.2 (2023-12-15)
### Bug Fixes
* **web:** optional address for multichainbalance - prod hotfix (#5392) 99d4e33
* **web:** disambiguate 3P ProviderRpcErrors (#5482) 0f8a086

View File

@@ -1 +1 @@
web/5.1.1
web/5.2.2

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View 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

View File

@@ -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 */,

View File

@@ -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>

View File

@@ -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)

View File

@@ -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

View File

@@ -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)
}
}
}
}
}

View File

@@ -6,80 +6,59 @@
//
import Foundation
// Based on https://www.notion.so/uniswaplabs/Number-standards-fbb9f533f10e4e22820722c2f66d23c0
// React native code: https://github.com/Uniswap/universe/blob/main/packages/wallet/src/utils/format.ts
extension NumberFormatter {
// Based on https://www.notion.so/uniswaplabs/Number-standards-fbb9f533f10e4e22820722c2f66d23c0
// React native code: https://github.com/Uniswap/universe/blob/main/packages/wallet/src/utils/format.ts
public static func SHORTHAND_USD_TWO_DECIMALS(price: Double) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.maximumFractionDigits = 2
formatter.minimumFractionDigits = 2
formatter.currencyCode = "USD"
static func formatShorthandWithDecimals(number: Double, fractionDigits: Int, locale: Locale, currencyCode: String, placeholder: String) -> String {
if (number < 1000000) {
return formatWithDecimals(number: number, fractionDigits: fractionDigits, locale: locale, currencyCode: currencyCode)
}
let maxNumber = 1000000000000000.0
let maxed = number >= maxNumber
let limitedNumber = maxed ? maxNumber : number
// Replace when Swift supports notation configuration for currency
// https://developer.apple.com/documentation/foundation/currencyformatstyleconfiguration
let compactFormatted = limitedNumber.formatted(.number.locale(locale).precision(.fractionLength(fractionDigits)).notation(.compactName))
let currencyFormatted = limitedNumber.formatted(.currency(code: currencyCode).locale(locale).precision(.fractionLength(fractionDigits)).grouping(.never))
if (price < 1000000){
return TWO_DECIMALS_USD.string(for: price)!
}
else if (price < 1000000000){
return "\(formatter.string(for: price/1000000)!)M"
}
else if (price < 1000000000000){
return "\(formatter.string(for: price/1000000000)!)B"
}
else if (price < 1000000000000000){
return "\(formatter.string(for: price/1000000000000)!)T"
}
else {
return "$>999T"
guard let numberRegex = try? NSRegularExpression(pattern: "(\\d+(\\\(locale.decimalSeparator!)\\d+)?)") else {
return placeholder
}
let output = numberRegex.stringByReplacingMatches(in: currencyFormatted, range: NSMakeRange(0, currencyFormatted.count), withTemplate: compactFormatted)
return maxed ? ">\(output)" : "\(output)"
}
public static var TWO_DECIMALS_USD: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.maximumFractionDigits = 2
formatter.minimumFractionDigits = 2
formatter.currencyCode = "USD"
return formatter
}()
static func formatWithDecimals(number: Double, fractionDigits: Int, locale: Locale, currencyCode: String) -> String {
return number.formatted(.currency(code: currencyCode).locale(locale).precision(.fractionLength(fractionDigits)))
}
public static var THREE_SIG_FIGS_USD: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.maximumSignificantDigits = 3
formatter.minimumSignificantDigits = 3
formatter.currencyCode = "USD"
return formatter
}()
static func formatWithSigFigs(number: Double, sigFigsDigits: Int, locale: Locale, currencyCode: String) -> String {
return number.formatted(.currency(code: currencyCode).locale(locale).precision(.significantDigits(sigFigsDigits)))
}
public static var THREE_DECIMALS_USD: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.maximumFractionDigits = 3
formatter.minimumFractionDigits = 3
formatter.currencyCode = "USD"
return formatter
}()
public static func fiatTokenDetailsFormatter(price: Double?) -> String {
public static func fiatTokenDetailsFormatter(price: Double?, locale: Locale, currencyCode: String) -> String {
let placeholder = "--.--"
guard let price = price else {
return "--.--"
}
if (price < 0.00000001) {
return "<$0.00000001"
}
else if (price < 0.01) {
return THREE_SIG_FIGS_USD.string(for: price)!
}
else if (price < 1.05) {
return THREE_DECIMALS_USD.string(for: price)!
}
else if (price < 1e6) {
return TWO_DECIMALS_USD.string(for: price)!
}
else {
return SHORTHAND_USD_TWO_DECIMALS(price: price)
return placeholder
}
if (price < 0.00000001) {
let formattedPrice = formatWithDecimals(number: price, fractionDigits: 8, locale: locale, currencyCode: currencyCode)
return "<\(formattedPrice)"
}
if (price < 0.01) {
return formatWithSigFigs(number: price, sigFigsDigits: 3, locale: locale, currencyCode: currencyCode)
} else if (price < 1.05) {
return formatWithDecimals(number: price, fractionDigits: 3, locale: locale, currencyCode: currencyCode)
} else if (price < 1e6) {
return formatWithDecimals(number: price, fractionDigits: 2, locale: locale, currencyCode: currencyCode)
} else {
return formatShorthandWithDecimals(number: price, fractionDigits: 2, locale: locale, currencyCode: currencyCode, placeholder: placeholder)
}
}
}

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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, "123456,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
)
}
}
}

View File

@@ -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",

View File

@@ -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 />

View File

@@ -1,3 +1,4 @@
import { useApolloClient } from '@apollo/client'
import React, { useState } from 'react'
import { ScrollView } from 'react-native-gesture-handler'
import { Action } from 'redux'
@@ -10,17 +11,20 @@ import { ModalName } from 'src/features/telemetry/constants'
import { selectCustomEndpoint } from 'src/features/tweaks/selectors'
import { setCustomEndpoint } from 'src/features/tweaks/slice'
import { Statsig } from 'statsig-react'
import { useExperiment } from 'statsig-react-native'
import { Button, Flex, Text, useDeviceInsets } from 'ui/src'
import { useExperimentWithExposureLoggingDisabled } from 'statsig-react-native'
import { Accordion } from 'tamagui'
import { Button, Flex, Icons, Text, useDeviceInsets } from 'ui/src'
import { spacing } from 'ui/src/theme'
import { EXPERIMENT_NAMES, FEATURE_FLAGS } from 'wallet/src/features/experiments/constants'
import { useFeatureFlag } from 'wallet/src/features/experiments/hooks'
import { useFeatureFlagWithExposureLoggingDisabled } from 'wallet/src/features/experiments/hooks'
export function ExperimentsModal(): JSX.Element {
const insets = useDeviceInsets()
const dispatch = useAppDispatch()
const customEndpoint = useAppSelector(selectCustomEndpoint)
const apollo = useApolloClient()
const [url, setUrl] = useState<string>(customEndpoint?.url || '')
const [key, setKey] = useState<string>(customEndpoint?.key || '')
@@ -48,51 +52,113 @@ export function ExperimentsModal(): JSX.Element {
renderBehindBottomInset
name={ModalName.Experiments}
onClose={(): Action => dispatch(closeModal({ name: ModalName.Experiments }))}>
<ScrollView contentContainerStyle={{ paddingBottom: insets.bottom + spacing.spacing12 }}>
<Flex gap="$spacing16" justifyContent="flex-start" pt="$spacing12" px="$spacing24">
<Flex gap="$spacing8">
<Flex gap="$spacing16" my="$spacing16">
<Text variant="subheading1"> Custom GraphQL Endpoint</Text>
<ScrollView
contentContainerStyle={{
paddingBottom: insets.bottom,
paddingRight: spacing.spacing24,
paddingLeft: spacing.spacing24,
}}>
<Accordion type="single">
<Accordion.Item value="graphql-endpoint">
<AccordionHeader title="⚙️ Custom GraphQL Endpoint" />
<Accordion.Content>
<Text variant="body2">
You will need to restart the application to pick up any changes in this section.
Beware of client side caching!
</Text>
<Flex row alignItems="center" gap="$spacing16">
<Text variant="body2">URL</Text>
<TextInput flex={1} value={url} onChangeText={setUrl} />
</Flex>
<Flex row alignItems="center" gap="$spacing16">
<Text variant="body2">Key</Text>
<TextInput flex={1} value={key} onChangeText={setKey} />
</Flex>
<Button size="small" onPress={setEndpoint}>
Set
<Flex grow row alignItems="center" gap="$spacing16">
<Button flex={1} size="small" onPress={setEndpoint}>
Set
</Button>
<Button flex={1} size="small" onPress={clearEndpoint}>
Clear
</Button>
</Flex>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="apollo-cache">
<AccordionHeader title="🚀 Apollo Cache" />
<Accordion.Content>
<Button
flex={1}
size="small"
onPress={async (): Promise<unknown> => await apollo.resetStore()}>
Reset Cache
</Button>
<Button size="small" onPress={clearEndpoint}>
Clear
</Button>
</Flex>
<Text variant="subheading1"> Feature Flags</Text>
<Text variant="body2">
Overridden feature flags are reset when the app is restarted
</Text>
</Flex>
{Object.values(FEATURE_FLAGS).map((featureFlag) => {
return <FeatureFlagRow key={featureFlag} featureFlag={featureFlag} />
})}
<Text variant="subheading1">🔬 Experiments</Text>
<Text variant="body2">Overridden experiments are reset when the app is restarted</Text>
{Object.values(EXPERIMENT_NAMES).map((experiment) => {
return <ExperimentRow key={experiment} name={experiment} />
})}
</Flex>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="feature-flags">
<AccordionHeader title="⛳️ Feature Flags" />
<Accordion.Content>
<Text variant="body2">
Overridden feature flags are reset when the app is restarted
</Text>
<Flex gap="$spacing12" mt="$spacing12">
{Object.values(FEATURE_FLAGS).map((featureFlag) => {
return <FeatureFlagRow key={featureFlag} featureFlag={featureFlag} />
})}
</Flex>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="experiments">
<AccordionHeader title="🔬 Experiments" />
<Accordion.Content>
<Text variant="body2">
Overridden experiments are reset when the app is restarted
</Text>
<Flex gap="$spacing12" mt="$spacing12">
{Object.values(EXPERIMENT_NAMES).map((experiment) => {
return <ExperimentRow key={experiment} name={experiment} />
})}
</Flex>
</Accordion.Content>
</Accordion.Item>
</Accordion>
</ScrollView>
</BottomSheetModal>
)
}
function AccordionHeader({ title }: { title: React.ReactNode }): JSX.Element {
return (
<Accordion.Header mt="$spacing12">
<Accordion.Trigger>
{({ open }: { open: boolean }): JSX.Element => (
<>
<Flex row justifyContent="space-between" width="100%">
<Text variant="subheading1">{title}</Text>
<Icons.RotatableChevron direction={open ? 'up' : 'down'} />
</Flex>
</>
)}
</Accordion.Trigger>
</Accordion.Header>
)
}
function FeatureFlagRow({ featureFlag }: { featureFlag: FEATURE_FLAGS }): JSX.Element {
const status = useFeatureFlag(featureFlag)
const status = useFeatureFlagWithExposureLoggingDisabled(featureFlag)
return (
<Flex row alignItems="center" gap="$spacing16" justifyContent="space-between">
@@ -108,10 +174,7 @@ function FeatureFlagRow({ featureFlag }: { featureFlag: FEATURE_FLAGS }): JSX.El
}
function ExperimentRow({ name }: { name: string }): JSX.Element {
const experiment = useExperiment(name)
// console.log('garydebug experiment row ' + JSON.stringify(experiment.config))
// const layer = useLayer(name)
// console.log('garydebug experiment row ' + JSON.stringify(layer))
const experiment = useExperimentWithExposureLoggingDisabled(name)
const params = Object.entries(experiment.config.value).map(([key, value]) => (
<Flex

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { useAppDispatch } from 'src/app/hooks'
import { CloudStorageMnemonicBackup } from 'src/features/CloudBackup/types'
import { pushNotification } from 'wallet/src/features/notifications/slice'
import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks'
@@ -35,7 +36,7 @@ export const exampleSwapSuccess = {
}
// easiest to use inside NotificationToastWrapper before any returns
export const useFakeNotification = (ms?: number): void => {
export const useMockNotification = (ms?: number): void => {
const [sent, setSent] = useState(false)
const dispatch = useAppDispatch()
const activeAddress = useActiveAccountAddressWithThrow()
@@ -57,3 +58,30 @@ export const useFakeNotification = (ms?: number): void => {
}
}, [activeAddress, dispatch, ms, sent])
}
const generateRandomId = (): string => {
let randomId = '0x'
for (let i = 0; i < 40; i++) {
randomId += Math.floor(Math.random() * 16).toString(16)
}
return randomId
}
const generateRandomDate = (): number => {
const start = new Date(2023, 4, 12)
const end = new Date()
return Math.floor(
new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())).getTime() / 1000
)
}
export const useMockCloudBackups = (numberOfBackups?: number): CloudStorageMnemonicBackup[] => {
const number = numberOfBackups ?? 1
const mockBackups = Array.from({ length: number }, () => ({
mnemonicId: generateRandomId(),
createdAt: generateRandomDate(),
}))
return mockBackups
}

View File

@@ -1,8 +1,9 @@
import React, { useMemo } from 'react'
import React, { memo, useMemo } from 'react'
import { useWindowDimensions } from 'react-native'
import { useAnimatedStyle, useDerivedValue } from 'react-native-reanimated'
import { AnimatedText } from 'src/components/text/AnimatedText'
import { Flex, useSporeColors } from 'ui/src'
import { TextVariantTokens } from 'ui/src/theme'
import { Flex, useDeviceDimensions, useSporeColors } from 'ui/src'
import { fonts, TextVariantTokens } from 'ui/src/theme'
import { ValueAndFormatted } from './usePrice'
type AnimatedDecimalNumberProps = {
@@ -13,12 +14,18 @@ type AnimatedDecimalNumberProps = {
decimalPartColor?: string
decimalThreshold?: number // below this value (not including) decimal part would have wholePartColor too
testID?: string
maxWidth?: number
maxCharPixelWidth?: number
}
// Utility component to display decimal numbers where the decimal part
// is dimmed using AnimatedText
export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.Element {
export const AnimatedDecimalNumber = memo(function AnimatedDecimalNumber(
props: AnimatedDecimalNumberProps
): JSX.Element {
const colors = useSporeColors()
const { fullWidth } = useDeviceDimensions()
const { fontScale } = useWindowDimensions()
const {
number,
@@ -28,6 +35,8 @@ export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.El
decimalPartColor = colors.neutral3.val,
decimalThreshold = 1,
testID,
maxWidth = fullWidth,
maxCharPixelWidth: maxCharPixelWidthProp,
} = props
const wholePart = useDerivedValue(
@@ -51,12 +60,37 @@ export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.El
}
}, [decimalThreshold, wholePartColor, decimalPartColor])
const fontSize = fonts[variant].fontSize * fontScale
// Choose the arbitrary value that looks good for the font used
const maxCharPixelWidth = maxCharPixelWidthProp ?? (2 / 3) * fontSize
const adjustedFontSize = useDerivedValue(() => {
const value = number.formatted.value
const approxWidth = value.length * maxCharPixelWidth
if (approxWidth <= maxWidth) {
return fontSize
}
const scale = Math.min(1, maxWidth / approxWidth)
return fontSize * scale
})
const animatedStyle = useAnimatedStyle(() => ({
fontSize: adjustedFontSize.value,
}))
return (
<Flex row testID={testID}>
<AnimatedText style={wholeStyle} testID="wholePart" text={wholePart} variant={variant} />
<AnimatedText
style={[wholeStyle, animatedStyle]}
testID="wholePart"
text={wholePart}
variant={variant}
/>
{decimalPart.value !== separator && (
<AnimatedText
style={decimalStyle}
style={[decimalStyle, animatedStyle]}
testID="decimalPart"
text={decimalPart}
variant={variant}
@@ -64,4 +98,4 @@ export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.El
)}
</Flex>
)
}
})

View File

@@ -1,5 +1,5 @@
import { ImpactFeedbackStyle } from 'expo-haptics'
import React, { useMemo } from 'react'
import React, { memo, useMemo } from 'react'
import { I18nManager } from 'react-native'
import { SharedValue } from 'react-native-reanimated'
import {
@@ -15,7 +15,8 @@ import { DatetimeText, PriceText, RelativeChangeText } from 'src/components/Pric
import { TimeRangeGroup } from 'src/components/PriceExplorer/TimeRangeGroup'
import { useChartDimensions } from 'src/components/PriceExplorer/useChartDimensions'
import { invokeImpact } from 'src/utils/haptic'
import { Flex } from 'ui/src'
import { Flex, useDeviceDimensions } from 'ui/src'
import { spacing } from 'ui/src/theme'
import { HistoryDuration } from 'wallet/src/data/__generated__/types-and-hooks'
import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
import { CurrencyId } from 'wallet/src/utils/currencyId'
@@ -27,9 +28,15 @@ type PriceTextProps = {
}
function PriceTextSection({ loading, relativeChange }: PriceTextProps): JSX.Element {
const { fullWidth } = useDeviceDimensions()
const mx = spacing.spacing12
return (
<Flex mx="$spacing12">
<PriceText loading={loading} />
<Flex mx={mx}>
{/* Specify maxWidth to allow text scalling. onLayout was sometimes called after more
than 5 seconds which is not acceptable so we have to provide the approximate width
of the PriceText component explicitly. */}
<PriceText loading={loading} maxWidth={fullWidth - 2 * mx} />
<Flex row gap="$spacing4">
<RelativeChangeText loading={loading} spotRelativeChange={relativeChange} />
<DatetimeText loading={loading} />
@@ -42,7 +49,7 @@ export type LineChartPriceAndDateTimeTextProps = {
currencyId: CurrencyId
}
export function PriceExplorer({
export const PriceExplorer = memo(function PriceExplorer({
currencyId,
tokenColor,
forcePlaceholder,
@@ -115,7 +122,7 @@ export function PriceExplorer({
<TimeRangeGroup setDuration={setDuration} />
</Flex>
)
}
})
function PriceExplorerPlaceholder({ loading }: { loading: boolean }): JSX.Element {
return (

View File

@@ -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"

View File

@@ -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"

View File

@@ -1,3 +1,4 @@
import { useMemo } from 'react'
import { SharedValue, useDerivedValue } from 'react-native-reanimated'
import {
useLineChart,
@@ -44,10 +45,14 @@ export function useLineChartPrice(): ValueAndFormatted {
currencyInfo.symbol
)
})
return {
value: price,
formatted: priceFormatted,
}
return useMemo(
() => ({
value: price,
formatted: priceFormatted,
}),
[price, priceFormatted]
)
}
/**

View File

@@ -1,33 +0,0 @@
import React, { memo } from 'react'
import { FadeIn, FadeOut } from 'react-native-reanimated'
import { TokenFiatOnRampList } from 'src/components/TokenSelector/TokenFiatOnRampList'
import Trace from 'src/components/Trace/Trace'
import { FiatOnRampCurrency } from 'src/features/fiatOnRamp/types'
import { ElementName, SectionName } from 'src/features/telemetry/constants'
import { AnimatedFlex } from 'ui/src'
interface Props {
onBack: () => void
onSelectCurrency: (currency: FiatOnRampCurrency) => void
}
function _FiatOnRampTokenSelector({ onSelectCurrency, onBack }: Props): JSX.Element {
return (
<Trace
logImpression
element={ElementName.FiatOnRampTokenSelector}
section={SectionName.TokenSelector}>
<AnimatedFlex
entering={FadeIn}
exiting={FadeOut}
gap="$spacing12"
overflow="hidden"
px="$spacing16"
width="100%">
<TokenFiatOnRampList onBack={onBack} onSelectCurrency={onSelectCurrency} />
</AnimatedFlex>
</Trace>
)
}
export const FiatOnRampTokenSelector = memo(_FiatOnRampTokenSelector)

View File

@@ -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('Couldnt 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"

View File

@@ -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}
/>

View File

@@ -1,5 +1,12 @@
query AccountList($addresses: [String!]!) {
portfolios(ownerAddresses: $addresses, chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]) {
query AccountList(
$addresses: [String!]!
$valueModifiers: [PortfolioValueModifier!]
) {
portfolios(
ownerAddresses: $addresses
chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]
valueModifiers: $valueModifiers
) {
id
ownerAddress
tokensTotalDenominatedValue {

View File

@@ -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 (

View File

@@ -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]
)

View File

@@ -48,6 +48,7 @@ export const usePersistedApolloClient = (): ApolloClient<NormalizedCacheObject>
}
const newClient = new ApolloClient({
assumeImmutableResults: true,
link: from([
getErrorLink(),
// requires typing outside of wallet package

View File

@@ -0,0 +1,117 @@
import React, { memo, useMemo } from 'react'
import { FadeIn, FadeOut } from 'react-native-reanimated'
import { TokenFiatOnRampList } from 'src/components/TokenSelector/TokenFiatOnRampList'
import Trace from 'src/components/Trace/Trace'
import { FiatOnRampCurrency } from 'src/features/fiatOnRamp/types'
import { ElementName, SectionName } from 'src/features/telemetry/constants'
import { AnimatedFlex } from 'ui/src'
import { useAllCommonBaseCurrencies } from 'src/components/TokenSelector/hooks'
import { CurrencyInfo, GqlResult } from 'wallet/src/features/dataApi/types'
import { useFiatOnRampAggregatorSupportedTokensQuery } from 'wallet/src/features/fiatOnRamp/api'
import { MeldCryptoCurrency } from 'wallet/src/features/fiatOnRamp/meld'
interface Props {
onBack: () => void
onSelectCurrency: (currency: FiatOnRampCurrency) => void
sourceCurrencyCode: string
countryCode: string
}
const findTokenOptionForMeldCurrency = (
commonBaseCurrencies: CurrencyInfo[] | undefined,
meldCurrency: MeldCryptoCurrency
): Maybe<CurrencyInfo> => {
return (commonBaseCurrencies || []).find(
(item) =>
item &&
meldCurrency.cryptoCurrencyCode.toLowerCase() === item.currency.symbol?.toLowerCase() &&
meldCurrency.chainId === item.currency.chainId.toString()
)
}
function useFiatOnRampTokenList(
supportedTokens: MeldCryptoCurrency[] | undefined
): GqlResult<FiatOnRampCurrency[]> {
const {
data: commonBaseCurrencies,
error: commonBaseCurrenciesError,
loading: commonBaseCurrenciesLoading,
refetch: refetchCommonBaseCurrencies,
} = useAllCommonBaseCurrencies()
const data = useMemo(
() =>
(supportedTokens || [])
.map((meldCurrency) => ({
currencyInfo: findTokenOptionForMeldCurrency(commonBaseCurrencies, meldCurrency),
}))
.filter((item) => !!item.currencyInfo),
[commonBaseCurrencies, supportedTokens]
)
return {
data,
loading: commonBaseCurrenciesLoading,
error: commonBaseCurrenciesError,
refetch: refetchCommonBaseCurrencies,
}
}
function _FiatOnRampAggregatorTokenSelector({
onSelectCurrency,
onBack,
sourceCurrencyCode,
countryCode,
}: Props): JSX.Element {
const {
data: supportedTokens,
isLoading: supportedTokensLoading,
error: supportedTokensQueryError,
refetch: supportedTokensQueryRefetch,
} = useFiatOnRampAggregatorSupportedTokensQuery({ fiatCurrency: sourceCurrencyCode, countryCode })
const {
data: tokenList,
loading: tokenListLoading,
error: tokenListError,
refetch: tokenListRefetch,
} = useFiatOnRampTokenList(supportedTokens)
const loading = supportedTokensLoading || tokenListLoading
const error = Boolean(supportedTokensQueryError || tokenListError)
const onRetry = async (): Promise<void> => {
if (supportedTokensQueryError) {
await supportedTokensQueryRefetch?.()
}
if (tokenListError) {
tokenListRefetch?.()
}
}
return (
<Trace
logImpression
element={ElementName.FiatOnRampAggregatorTokenSelector}
section={SectionName.TokenSelector}>
<AnimatedFlex
entering={FadeIn}
exiting={FadeOut}
gap="$spacing12"
overflow="hidden"
px="$spacing16"
width="100%">
<TokenFiatOnRampList
error={error}
list={tokenList}
loading={loading}
onBack={onBack}
onRetry={onRetry}
onSelectCurrency={onSelectCurrency}
/>
</AnimatedFlex>
</Trace>
)
}
export const FiatOnRampAggregatorTokenSelector = memo(_FiatOnRampAggregatorTokenSelector)

View File

@@ -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(

View File

@@ -0,0 +1,119 @@
import React, { memo, useMemo } from 'react'
import { FadeIn, FadeOut } from 'react-native-reanimated'
import { TokenFiatOnRampList } from 'src/components/TokenSelector/TokenFiatOnRampList'
import Trace from 'src/components/Trace/Trace'
import { useFiatOnRampSupportedTokens } from 'src/features/fiatOnRamp/hooks'
import { FiatOnRampCurrency } from 'src/features/fiatOnRamp/types'
import { ElementName, SectionName } from 'src/features/telemetry/constants'
import { AnimatedFlex } from 'ui/src'
import { useAllCommonBaseCurrencies } from 'src/components/TokenSelector/hooks'
import { fromMoonpayNetwork } from 'wallet/src/features/chains/utils'
import { CurrencyInfo, GqlResult } from 'wallet/src/features/dataApi/types'
import { MoonpayCurrency } from 'wallet/src/features/fiatOnRamp/types'
interface Props {
onBack: () => void
onSelectCurrency: (currency: FiatOnRampCurrency) => void
}
const findTokenOptionForMoonpayCurrency = (
commonBaseCurrencies: CurrencyInfo[] | undefined,
moonpayCurrency: MoonpayCurrency
): Maybe<CurrencyInfo> => {
return (commonBaseCurrencies || []).find((item) => {
const [code, network] = moonpayCurrency.code.split('_') ?? [undefined, undefined]
const chainId = fromMoonpayNetwork(network)
return (
item &&
code &&
code === item.currency.symbol?.toLowerCase() &&
chainId === item.currency.chainId
)
})
}
function useFiatOnRampTokenList(
supportedTokens: MoonpayCurrency[] | undefined
): GqlResult<FiatOnRampCurrency[]> {
const {
data: commonBaseCurrencies,
error: commonBaseCurrenciesError,
loading: commonBaseCurrenciesLoading,
refetch: refetchCommonBaseCurrencies,
} = useAllCommonBaseCurrencies()
const data = useMemo(
() =>
(supportedTokens || [])
.map((moonpayCurrency) => ({
currencyInfo: findTokenOptionForMoonpayCurrency(commonBaseCurrencies, moonpayCurrency),
moonpayCurrency,
}))
.filter((item) => !!item.currencyInfo),
[commonBaseCurrencies, supportedTokens]
)
return useMemo(
() => ({
data,
loading: commonBaseCurrenciesLoading,
error: commonBaseCurrenciesError,
refetch: refetchCommonBaseCurrencies,
}),
[commonBaseCurrenciesError, commonBaseCurrenciesLoading, data, refetchCommonBaseCurrencies]
)
}
function _FiatOnRampTokenSelector({ onSelectCurrency, onBack }: Props): JSX.Element {
const {
data: supportedTokens,
isLoading: supportedTokensLoading,
isError: supportedTokensQueryError,
refetch: supportedTokensQueryRefetch,
} = useFiatOnRampSupportedTokens()
const {
data: tokenList,
loading: tokenListLoading,
error: tokenListError,
refetch: tokenListRefetch,
} = useFiatOnRampTokenList(supportedTokens)
const loading = supportedTokensLoading || tokenListLoading
const error = Boolean(supportedTokensQueryError || tokenListError)
const onRetry = (): void => {
if (supportedTokensQueryError) {
supportedTokensQueryRefetch?.()
}
if (tokenListError) {
tokenListRefetch?.()
}
}
return (
<Trace
logImpression
element={ElementName.FiatOnRampTokenSelector}
section={SectionName.TokenSelector}>
<AnimatedFlex
entering={FadeIn}
exiting={FadeOut}
gap="$spacing12"
overflow="hidden"
px="$spacing16"
width="100%">
<TokenFiatOnRampList
error={error}
list={tokenList}
loading={loading}
onBack={onBack}
onRetry={onRetry}
onSelectCurrency={onSelectCurrency}
/>
</AnimatedFlex>
</Trace>
)
}
export const FiatOnRampTokenSelector = memo(_FiatOnRampTokenSelector)

View File

@@ -1,3 +1,4 @@
import { skipToken } from '@reduxjs/toolkit/query/react'
import { Currency } from '@uniswap/sdk-core'
import { useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
@@ -151,7 +152,7 @@ export function useMoonpayFiatOnRamp({
quoteCurrencyCode,
}: {
baseCurrencyAmount: string
quoteCurrencyCode: string
quoteCurrencyCode: string | undefined
}): {
eligible: boolean
quoteAmount: number
@@ -166,7 +167,6 @@ export function useMoonpayFiatOnRamp({
errorColor?: ColorTokens
} {
const colors = useSporeColors()
const { t } = useTranslation()
const debouncedBaseCurrencyAmount = useDebounce(baseCurrencyAmount, Delay.Short)
@@ -185,11 +185,15 @@ export function useMoonpayFiatOnRamp({
data: limitsData,
isLoading: limitsLoading,
isError: limitsLoadingQueryError,
} = useFiatOnRampLimitsQuery({
baseCurrencyCode,
quoteCurrencyCode,
areFeesIncluded: MOONPAY_FEES_INCLUDED,
})
} = useFiatOnRampLimitsQuery(
quoteCurrencyCode
? {
baseCurrencyCode,
quoteCurrencyCode,
areFeesIncluded: MOONPAY_FEES_INCLUDED,
}
: skipToken
)
const { maxBuyAmount } = limitsData?.baseCurrency ?? {
maxBuyAmount: Infinity,
@@ -214,37 +218,40 @@ export function useMoonpayFiatOnRamp({
} = useFiatOnRampWidgetUrlQuery(
// PERF: could consider skipping this call until eligibility in determined (ux tradeoffs)
// as-is, avoids waterfalling requests => better ux
{
ownerAddress: activeAccountAddress,
colorCode: colors.accent1.val,
externalTransactionId,
amount: baseCurrencyAmount,
currencyCode: quoteCurrencyCode,
baseCurrencyCode,
redirectUrl: `${
isAndroid ? uniswapUrls.appUrl : uniswapUrls.appBaseUrl
}/?screen=transaction&fiatOnRamp=true&userAddress=${activeAccountAddress}`,
}
quoteCurrencyCode
? {
ownerAddress: activeAccountAddress,
colorCode: colors.accent1.val,
externalTransactionId,
amount: baseCurrencyAmount,
currencyCode: quoteCurrencyCode,
baseCurrencyCode,
redirectUrl: `${
isAndroid ? uniswapUrls.appUrl : uniswapUrls.appBaseUrl
}/?screen=transaction&fiatOnRamp=true&userAddress=${activeAccountAddress}`,
}
: skipToken
)
const {
data: buyQuote,
isFetching: buyQuoteLoading,
isError: buyQuoteLoadingQueryError,
} = useFiatOnRampBuyQuoteQuery(
{
baseCurrencyCode,
baseCurrencyAmount: debouncedBaseCurrencyAmount,
quoteCurrencyCode,
areFeesIncluded: MOONPAY_FEES_INCLUDED,
},
{
// When isBaseCurrencyAmountValid is false and the user enters any digit,
// isBaseCurrencyAmountValid becomes true. Since there were no prior calls to the API,
// it takes the debouncedBaseCurrencyAmount and immediately calls an API.
// This only truly matters in the beginning and in cases where the debouncedBaseCurrencyAmount
// is changed while isBaseCurrencyAmountValid is false."
skip: !isBaseCurrencyAmountValid || debouncedBaseCurrencyAmount !== baseCurrencyAmount,
}
// When isBaseCurrencyAmountValid is false and the user enters any digit,
// isBaseCurrencyAmountValid becomes true. Since there were no prior calls to the API,
// it takes the debouncedBaseCurrencyAmount and immediately calls an API.
// This only truly matters in the beginning and in cases where the debouncedBaseCurrencyAmount
// is changed while isBaseCurrencyAmountValid is false."
quoteCurrencyCode &&
isBaseCurrencyAmountValid &&
debouncedBaseCurrencyAmount === baseCurrencyAmount
? {
baseCurrencyCode,
baseCurrencyAmount: debouncedBaseCurrencyAmount,
quoteCurrencyCode,
areFeesIncluded: MOONPAY_FEES_INCLUDED,
}
: skipToken
)
const quoteAmount = buyQuote?.quoteCurrencyAmount ?? 0
@@ -281,17 +288,13 @@ export function useMoonpayFiatOnRamp({
currencySymbol: baseCurrencySymbol,
})
let errorText, errorColor: ColorTokens | undefined
if (isError) {
errorText = t('Something went wrong.')
errorColor = '$DEP_accentWarning'
} else if (amountIsTooSmall) {
errorText = t('{{amount}} minimum', { amount: minBuyAmountWithFiatSymbol })
errorColor = '$statusCritical'
} else if (amountIsTooLarge) {
errorText = t('{{amount}} maximum', { amount: maxBuyAmountWithFiatSymbol })
errorColor = '$statusCritical'
}
const { errorText, errorColor } = useMoonpayError(
isError,
amountIsTooSmall,
amountIsTooLarge,
minBuyAmountWithFiatSymbol,
maxBuyAmountWithFiatSymbol
)
return {
eligible,
@@ -346,3 +349,31 @@ export function useFiatOnRampSupportedTokens(): {
},
}
}
function useMoonpayError(
hasError: boolean,
amountIsTooSmall: boolean,
amountIsTooLarge: boolean,
minBuyAmountWithFiatSymbol: string,
maxBuyAmountWithFiatSymbol: string
): {
errorText: string | undefined
errorColor: ColorTokens | undefined
} {
const { t } = useTranslation()
let errorText, errorColor: ColorTokens | undefined
if (hasError) {
errorText = t('Something went wrong.')
errorColor = '$DEP_accentWarning'
} else if (amountIsTooSmall) {
errorText = t('Minimum {{amount}}', { amount: minBuyAmountWithFiatSymbol })
errorColor = '$statusCritical'
} else if (amountIsTooLarge) {
errorText = t('Maximum {{amount}}', { amount: maxBuyAmountWithFiatSymbol })
errorColor = '$statusCritical'
}
return { errorText, errorColor }
}

View File

@@ -1,7 +1,5 @@
import { CurrencyInfo } from 'wallet/src/features/dataApi/types'
import { MoonpayCurrency } from 'wallet/src/features/fiatOnRamp/types'
export type FiatOnRampCurrency = {
currencyInfo: Maybe<CurrencyInfo>
moonpayCurrency: MoonpayCurrency
}

View File

@@ -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',

View File

@@ -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,

View File

@@ -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]

View File

@@ -1,3 +1,4 @@
/* eslint-disable max-lines */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { LayoutChangeEvent, StyleSheet, TextInput, TextInputProps } from 'react-native'
@@ -100,11 +101,13 @@ function SwapFormContent(): JSX.Element {
openWalletRestoreModal()
}
const focusFieldIsInput = focusOnCurrencyField === CurrencyField.INPUT
const focusFieldIsOutput = focusOnCurrencyField === CurrencyField.OUTPUT
const exactFieldIsInput = exactCurrencyField === CurrencyField.INPUT
const exactFieldIsOutput = exactCurrencyField === CurrencyField.OUTPUT
const derivedCurrencyField = exactFieldIsInput ? CurrencyField.OUTPUT : CurrencyField.INPUT
// We want the `DecimalPad` to always control one of the 2 inputs even when no input is focused,
// which can happen after the user hits `Max`.
const decimalPadControlledField = focusOnCurrencyField ?? exactCurrencyField
// Quote is being fetched for first time
const isSwapDataLoading = !isWrapAction(wrapType) && trade.loading
@@ -125,35 +128,77 @@ function SwapFormContent(): JSX.Element {
)
const resetSelection = useCallback(
(start: number, end?: number) => {
({
start,
end,
currencyField,
}: {
start: number
end?: number
currencyField?: CurrencyField
}) => {
// Update refs first to have the latest selection state available in the DecimalPadInput
// component and property update disabled keys of the decimal pad.
if (focusFieldIsInput) {
inputSelectionRef.current = { start, end }
} else if (focusFieldIsOutput) {
outputSelectionRef.current = { start, end }
} else return
// We reset the selection on the next tick because we need to wait for the native input to be updated.
// component and properly update disabled keys of the decimal pad.
// We reset the native selection on the next tick because we need to wait for the native input to be updated.
// This is needed because of the combination of state (delayed update) + ref (instant update) to improve performance.
const _currencyField = currencyField ?? decimalPadControlledField
const selectionRef =
_currencyField === CurrencyField.INPUT ? inputSelectionRef : outputSelectionRef
const inputFieldRef = _currencyField === CurrencyField.INPUT ? inputRef : outputRef
selectionRef.current = { start, end }
setTimeout(() => {
inputRef.current?.setNativeProps?.({ selection: { start, end } })
inputFieldRef.current?.setNativeProps?.({ selection: { start, end } })
}, 0)
},
[focusFieldIsInput, focusFieldIsOutput]
[decimalPadControlledField]
)
const moveCursorToEnd = useCallback(
(args?: { overrideIsFiatMode?: boolean }) => {
const _isFiatMode = args?.overrideIsFiatMode ?? isFiatMode
const amountRef =
decimalPadControlledField === derivedCurrencyField
? formattedDerivedValueRef
: _isFiatMode
? exactAmountFiatRef
: exactAmountTokenRef
if (_isFiatMode) {
resetSelection({
start: amountRef.current.length,
end: amountRef.current.length,
})
} else {
resetSelection({
start: amountRef.current.length,
end: amountRef.current.length,
})
}
},
[
decimalPadControlledField,
derivedCurrencyField,
exactAmountFiatRef,
exactAmountTokenRef,
isFiatMode,
resetSelection,
]
)
const decimalPadSetValue = useCallback(
(value: string): void => {
if (!focusOnCurrencyField) {
return
}
updateSwapForm({
exactAmountFiat: isFiatMode ? value : undefined,
exactAmountToken: !isFiatMode ? value : undefined,
exactCurrencyField: focusOnCurrencyField,
exactCurrencyField: decimalPadControlledField,
focusOnCurrencyField: decimalPadControlledField,
})
},
[focusOnCurrencyField, isFiatMode, updateSwapForm]
[decimalPadControlledField, isFiatMode, updateSwapForm]
)
const [decimalPadReady, setDecimalPadReady] = useState(true)
@@ -185,6 +230,7 @@ function SwapFormContent(): JSX.Element {
},
[amountUpdatedTimeRef]
)
const onOutputSelectionChange = useCallback(
(start: number, end: number) => {
if (Date.now() - amountUpdatedTimeRef.current < ON_SELECTION_CHANGE_WAIT_TIME_MS) {
@@ -247,25 +293,29 @@ function SwapFormContent(): JSX.Element {
exactAmountFiat: undefined,
exactAmountToken: amount,
exactCurrencyField: CurrencyField.INPUT,
focusOnCurrencyField: CurrencyField.INPUT,
focusOnCurrencyField: undefined,
})
resetSelection(0, 0)
// We want this update to happen on the next tick, after the input value is updated.
setTimeout(() => {
moveCursorToEnd()
decimalPadRef.current?.updateDisabledKeys()
}, 0)
},
[resetSelection, updateSwapForm]
[moveCursorToEnd, updateSwapForm]
)
// Reset selection based the new input value (token, or fiat), and toggle fiat mode
const onToggleIsFiatMode = useCallback(() => {
const newIsFiatMode = !isFiatMode
updateSwapForm({
isFiatMode: !isFiatMode,
isFiatMode: newIsFiatMode,
})
// Need to do the opposite of previous mode, as we're selecting the new value after mode update
if (!isFiatMode) {
resetSelection(exactAmountFiatRef.current.length, exactAmountFiatRef.current.length)
} else {
resetSelection(exactAmountTokenRef.current.length, exactAmountTokenRef.current.length)
}
}, [exactAmountFiatRef, exactAmountTokenRef, isFiatMode, resetSelection, updateSwapForm])
// We want this update to happen on the next tick, after the input value is updated.
setTimeout(() => moveCursorToEnd({ overrideIsFiatMode: newIsFiatMode }), 0)
}, [isFiatMode, moveCursorToEnd, updateSwapForm])
const onSwitchCurrencies = useCallback(() => {
const newExactCurrencyField = exactFieldIsInput ? CurrencyField.OUTPUT : CurrencyField.INPUT
@@ -277,8 +327,6 @@ function SwapFormContent(): JSX.Element {
})
}, [exactFieldIsInput, input, output, updateSwapForm])
const derivedCurrencyField = exactFieldIsInput ? CurrencyField.OUTPUT : CurrencyField.INPUT
// TODO gary MOB-2028 replace temporary hack to handle different separators
// Replace with localized version of formatter
const formattedDerivedValue = formatCurrencyAmount({
@@ -288,25 +336,39 @@ function SwapFormContent(): JSX.Element {
placeholder: '',
})
// TODO - improve this to update ref when calculating the derived state
// instead of assigning ref based on the derived state
const formattedDerivedValueRef = useRef(formattedDerivedValue)
formattedDerivedValueRef.current = formattedDerivedValue
useEffect(() => {
formattedDerivedValueRef.current = formattedDerivedValue
if (decimalPadControlledField === exactCurrencyField) {
return
}
// When the `formattedDerivedValue` changes while the field that is not set as the `exactCurrencyField` is focused, we want to reset the cursor selection to the end of the input.
// This to prevent an issue that happens with the cursor selection getting out of sync when a user changes focus from one input to another while a quote request in in flight.
moveCursorToEnd()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formattedDerivedValue])
const exactValue = isFiatMode ? exactAmountFiat : exactAmountToken
const exactValueRef = isFiatMode ? exactAmountFiatRef : exactAmountTokenRef
const decimalPadValueRef =
decimalPadControlledField === exactCurrencyField ? exactValueRef : formattedDerivedValueRef
// Animated background color on input panels based on focus
const colorTransitionProgress = useDerivedValue(() => {
return withTiming(focusFieldIsInput ? 0 : 1, { duration: 250 })
}, [focusFieldIsInput])
const inputColorTransitionProgress = useDerivedValue(() => {
return withTiming(focusOnCurrencyField === CurrencyField.INPUT ? 0 : 1, { duration: 250 })
}, [focusOnCurrencyField])
const outputColorTransitionProgress = useDerivedValue(() => {
return withTiming(focusOnCurrencyField === CurrencyField.OUTPUT ? 0 : 1, { duration: 250 })
}, [focusOnCurrencyField])
const inputBackgroundStyle = useAnimatedStyle(() => {
return {
backgroundColor: interpolateColor(
colorTransitionProgress.value,
inputColorTransitionProgress.value,
[0, 1],
[colors.surface1.val, colors.surface2.val]
),
@@ -316,9 +378,9 @@ function SwapFormContent(): JSX.Element {
const outputBackgroundStyle = useAnimatedStyle(() => {
return {
backgroundColor: interpolateColor(
colorTransitionProgress.value,
outputColorTransitionProgress.value,
[0, 1],
[colors.surface2.val, colors.surface1.val]
[colors.surface1.val, colors.surface2.val]
),
}
})
@@ -337,9 +399,10 @@ function SwapFormContent(): JSX.Element {
ref={inputRef}
currencyAmount={currencyAmounts[CurrencyField.INPUT]}
currencyBalance={currencyBalances[CurrencyField.INPUT]}
currencyField={CurrencyField.INPUT}
currencyInfo={currencies[CurrencyField.INPUT]}
focus={focusFieldIsInput}
isCollapsed={focusOnCurrencyField ? !focusFieldIsInput : !exactFieldIsInput}
focus={focusOnCurrencyField === CurrencyField.INPUT}
isCollapsed={decimalPadControlledField !== CurrencyField.INPUT}
isFiatMode={isFiatMode && exactFieldIsInput}
isLoading={!exactFieldIsInput && isSwapDataLoading}
resetSelection={resetSelection}
@@ -369,12 +432,12 @@ function SwapFormContent(): JSX.Element {
style={outputBackgroundStyle}>
<CurrencyInputPanel
ref={outputRef}
isOutput
currencyAmount={currencyAmounts[CurrencyField.OUTPUT]}
currencyBalance={currencyBalances[CurrencyField.OUTPUT]}
currencyField={CurrencyField.OUTPUT}
currencyInfo={currencies[CurrencyField.OUTPUT]}
focus={focusFieldIsOutput}
isCollapsed={focusOnCurrencyField ? !focusFieldIsOutput : !exactFieldIsOutput}
focus={focusOnCurrencyField === CurrencyField.OUTPUT}
isCollapsed={decimalPadControlledField !== CurrencyField.OUTPUT}
isFiatMode={isFiatMode && exactFieldIsOutput}
isLoading={!exactFieldIsOutput && isSwapDataLoading}
resetSelection={resetSelection}
@@ -436,20 +499,14 @@ function SwapFormContent(): JSX.Element {
right={0}
style={decimalPadAndButtonAnimatedStyle}>
<Flex grow justifyContent="flex-end">
{focusOnCurrencyField && (
<DecimalPadInput
ref={decimalPadRef}
resetSelection={resetSelection}
selectionRef={focusOnCurrencyField ? selection[focusOnCurrencyField] : undefined}
setValue={decimalPadSetValue}
valueRef={
focusOnCurrencyField === exactCurrencyField
? exactValueRef
: formattedDerivedValueRef
}
onReady={onDecimalPadReady}
/>
)}
<DecimalPadInput
ref={decimalPadRef}
resetSelection={resetSelection}
selectionRef={selection[decimalPadControlledField]}
setValue={decimalPadSetValue}
valueRef={decimalPadValueRef}
onReady={onDecimalPadReady}
/>
</Flex>
</AnimatedFlex>
</Flex>

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getUniqueId } from 'react-native-device-info'
import { navigate } from 'src/app/navigation/rootNavigation'
import { UnitagStackScreenProp } from 'src/app/navigation/types'
import { Screen } from 'src/components/layout/Screen'
@@ -10,7 +11,10 @@ import { OnboardingScreens, Screens, UnitagScreens } from 'src/screens/Screens'
import { Button, Flex, Icons, Text, useDeviceInsets } from 'ui/src'
import Unitag from 'ui/src/assets/icons/unitag.svg'
import { fonts, iconSizes, imageSizes } from 'ui/src/theme'
import { useAsyncData } from 'utilities/src/react/hooks'
import { ImportType, OnboardingEntryPoint } from 'wallet/src/features/onboarding/types'
import { useClaimUnitagMutation } from 'wallet/src/features/unitags/api'
import { parseUnitagErrorCode } from 'wallet/src/features/unitags/utils'
import { useActiveAccountAddress, usePendingAccounts } from 'wallet/src/features/wallet/hooks'
export function ChooseProfilePictureScreen({
@@ -25,6 +29,17 @@ export function ChooseProfilePictureScreen({
const { t } = useTranslation()
const [imageUri, setImageUri] = useState<string>()
const [showModal, setShowModal] = useState(false)
const [claimError, setClaimError] = useState<string>()
const [
claimUnitag,
{
called: claimRequestMade,
loading: claimResponseLoading,
data: claimResponse,
reset: resetClaimResponse,
},
] = useClaimUnitagMutation()
const { data: deviceId } = useAsyncData(getUniqueId)
const openModal = (): void => {
setShowModal(true)
@@ -34,7 +49,30 @@ export function ChooseProfilePictureScreen({
setShowModal(false)
}
const onPressFinish = (): void => {
const onPressFinish = async (): Promise<void> => {
if (!deviceId) {
return // Should never hit this condition. Button is disabled if deviceId is undefined
}
// throw error if unitagAddress is falsey
if (!unitagAddress) {
throw new Error('unitagAddress should never be null when claiming a unitag')
}
await claimUnitag({
address: unitagAddress,
username: unitag,
deviceId,
metadata: {
avatar: imageUri ?? '', // TODO (MOB-2271): upload profile pic image to backend
description: '',
url: '',
twitter: '',
},
})
}
const onClaimSuccess = useCallback((): void => {
if (entryPoint === Screens.Home) {
if (!activeAddress) {
throw new Error('activeAddress should never be null when Unitag entryPoint is Home Screen')
@@ -57,7 +95,30 @@ export function ChooseProfilePictureScreen({
},
})
}
}
}, [activeAddress, entryPoint, imageUri, unitag])
useEffect(() => {
if (claimRequestMade && !claimResponseLoading && !!claimResponse) {
// We POSTed to claim and got a response
if (claimResponse.success) {
onClaimSuccess()
return
}
if (claimResponse.errorCode) {
setClaimError(parseUnitagErrorCode(t, unitag, claimResponse.errorCode))
}
// Reset everything so called=false, claimResponse=undefined
resetClaimResponse()
}
}, [
claimResponseLoading,
claimResponse,
onClaimSuccess,
unitag,
claimRequestMade,
resetClaimResponse,
t,
])
return (
<Screen edges={['right', 'left']}>
@@ -95,8 +156,17 @@ export function ChooseProfilePictureScreen({
<Unitag height={iconSizes.icon24} width={iconSizes.icon24} />
</Flex>
</Flex>
{!!claimError && (
<Text color="$statusCritical" variant="body2">
{claimError}
</Text>
)}
</Flex>
<Button size="medium" theme="primary" onPress={onPressFinish}>
<Button
disabled={!deviceId || !!claimError}
size="medium"
theme="primary"
onPress={onPressFinish}>
{entryPoint === Screens.Home ? t('Finish') : t('Create wallet')}
</Button>
</Flex>

View File

@@ -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>

View File

@@ -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()

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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>>(
() => ({

View File

@@ -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>
)

View File

@@ -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>

View File

@@ -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))

View File

@@ -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) {

View File

@@ -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"

View File

@@ -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",

View File

@@ -1,10 +1,6 @@
# *
User-agent: *
Disallow: /static/js/
Allow: /
# Host
Host: https://app.uniswap.org
Disallow:
# Sitemaps
Sitemap: https://app.uniswap.org/sitemap.xml
Sitemap: https://app.uniswap.org/sitemap.xml

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View 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

View File

@@ -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} />}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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',
})
})
})
})

View File

@@ -1,5 +1,5 @@
import { t } from '@lingui/macro'
import { ChainId, Currency, NONFUNGIBLE_POSITION_MANAGER_ADDRESSES, UNI_ADDRESSES } from '@uniswap/sdk-core'
import { ChainId, Currency, NONFUNGIBLE_POSITION_MANAGER_ADDRESSES, TradeType, UNI_ADDRESSES } from '@uniswap/sdk-core'
import UniswapXBolt from 'assets/svg/bolt.svg'
import moonpayLogoSrc from 'assets/svg/moonpay.svg'
import { nativeOnChain } from 'constants/tokens'
@@ -18,14 +18,21 @@ import {
TransactionType,
} from 'graphql/data/__generated__/types-and-hooks'
import { gqlToCurrency, logSentryErrorForUnsupportedChain, supportedChainIdFromGQLChain } from 'graphql/data/util'
import { UniswapXOrderStatus } from 'lib/hooks/orders/types'
import tryParseCurrencyAmount from 'lib/utils/tryParseCurrencyAmount'
import ms from 'ms'
import { useEffect, useState } from 'react'
import store from 'state'
import { addSignature } from 'state/signatures/reducer'
import { SignatureType } from 'state/signatures/types'
import { TransactionType as LocalTransactionType } from 'state/transactions/types'
import { isAddress } from 'utils'
import { isSameAddress } from 'utils/addresses'
import { currencyId } from 'utils/currencyId'
import { NumberType, useFormatter } from 'utils/formatNumbers'
import { MOONPAY_SENDER_ADDRESSES, OrderStatusTable, OrderTextTable } from '../constants'
import { Activity } from './types'
import { Activity, OffchainOrderDetails } from './types'
type TransactionChanges = {
NftTransfer: NftTransferPartsFragment[]
@@ -155,6 +162,36 @@ function getTransactedValue(transactedValue: TokenTransferPartsFragment['transac
return price
}
// exported for testing
// eslint-disable-next-line import/no-unused-modules
export function parseSwapAmounts(
changes: TransactionChanges,
formatNumberOrString: FormatNumberOrStringFunctionType
):
| {
inputAmount: string
inputCurrencyId: string
outputAmount: string
outputCurrencyId: string
sent: TokenTransferPartsFragment
received: TokenTransferPartsFragment
}
| undefined {
const sent = changes.TokenTransfer.find((t) => t.direction === 'OUT')
// Any leftover native token is refunded on exact_out swaps where the input token is native
const refund = changes.TokenTransfer.find(
(t) => t.direction === 'IN' && t.asset.id === sent?.asset.id && t.asset.standard === 'NATIVE'
)
const received = changes.TokenTransfer.find((t) => t.direction === 'IN' && t !== refund)
if (!sent || !received) return undefined
const inputCurrencyId = sent.asset.id
const outputCurrencyId = received.asset.id
const adjustedInput = parseFloat(sent.quantity) - parseFloat(refund?.quantity ?? '0')
const inputAmount = formatNumberOrString({ input: adjustedInput, type: NumberType.TokenNonTx })
const outputAmount = formatNumberOrString({ input: received.quantity, type: NumberType.TokenNonTx })
return { sent, received, inputAmount, outputAmount, inputCurrencyId, outputCurrencyId }
}
function parseSwap(changes: TransactionChanges, formatNumberOrString: FormatNumberOrStringFunctionType) {
if (changes.NftTransfer.length > 0 && changes.TokenTransfer.length === 1) {
const collectionCounts = getCollectionCounts(changes.NftTransfer)
@@ -168,17 +205,10 @@ function parseSwap(changes: TransactionChanges, formatNumberOrString: FormatNumb
}
// Some swaps may have more than 2 transfers, e.g. swaps with fees on tranfer
if (changes.TokenTransfer.length >= 2) {
const sent = changes.TokenTransfer.find((t) => t.direction === 'OUT')
// Any leftover native token is refunded on exact_out swaps where the input token is native
const refund = changes.TokenTransfer.find(
(t) => t.direction === 'IN' && t.asset.id === sent?.asset.id && t.asset.standard === 'NATIVE'
)
const received = changes.TokenTransfer.find((t) => t.direction === 'IN' && t !== refund)
const swapAmounts = parseSwapAmounts(changes, formatNumberOrString)
if (sent && received) {
const adjustedInput = parseFloat(sent.quantity) - parseFloat(refund?.quantity ?? '0')
const inputAmount = formatNumberOrString({ input: adjustedInput, type: NumberType.TokenNonTx })
const outputAmount = formatNumberOrString({ input: received.quantity, type: NumberType.TokenNonTx })
if (swapAmounts) {
const { sent, received, inputAmount, outputAmount } = swapAmounts
return {
title: getSwapTitle(sent, received),
descriptor: getSwapDescriptor({ tokenIn: sent.asset, inputAmount, tokenOut: received.asset, outputAmount }),
@@ -202,8 +232,55 @@ function parseLend(changes: TransactionChanges, formatNumberOrString: FormatNumb
return { title: t`Unknown Lend` }
}
function parseSwapOrder(changes: TransactionChanges, formatNumberOrString: FormatNumberOrStringFunctionType) {
return { ...parseSwap(changes, formatNumberOrString), prefixIconSrc: UniswapXBolt }
function parseSwapOrder(
changes: TransactionChanges,
formatNumberOrString: FormatNumberOrStringFunctionType,
assetActivity: TransactionActivity
) {
return {
...parseSwap(changes, formatNumberOrString),
prefixIconSrc: UniswapXBolt,
offchainOrderDetails: offchainOrderDetailsFromGraphQLTransactionActivity(
assetActivity,
changes,
formatNumberOrString
),
}
}
// exported for testing
// eslint-disable-next-line import/no-unused-modules
export function offchainOrderDetailsFromGraphQLTransactionActivity(
activity: AssetActivityPartsFragment & { details: TransactionDetailsPartsFragment },
changes: TransactionChanges,
formatNumberOrString: FormatNumberOrStringFunctionType
): OffchainOrderDetails | undefined {
const chainId = supportedChainIdFromGQLChain(activity.chain)
if (!activity || !activity.details || !chainId) return undefined
if (changes.TokenTransfer.length < 2) return undefined
const swapAmounts = parseSwapAmounts(changes, formatNumberOrString)
if (!swapAmounts) return undefined
const { inputCurrencyId, outputCurrencyId, inputAmount, outputAmount } = swapAmounts
return {
txHash: activity.details.hash,
chainId,
type: SignatureType.SIGN_UNISWAPX_ORDER,
status: UniswapXOrderStatus.FILLED,
swapInfo: {
isUniswapXOrder: true,
type: LocalTransactionType.SWAP,
tradeType: TradeType.EXACT_INPUT,
inputCurrencyId,
outputCurrencyId,
inputCurrencyAmountRaw: inputAmount,
expectedOutputCurrencyAmountRaw: outputAmount,
minimumOutputCurrencyAmountRaw: outputAmount,
settledOutputCurrencyAmountRaw: outputAmount,
},
}
}
function parseApprove(changes: TransactionChanges) {
@@ -347,9 +424,46 @@ function getLogoSrcs(changes: TransactionChanges): Array<string | undefined> {
}
function parseUniswapXOrder({ details, chain, timestamp }: OrderActivity): Activity | undefined {
// We currently only have a polling mechanism for locally-sent pending orders, so we hide remote pending orders since they won't update upon completion
// TODO(WEB-2487): Add polling mechanism for remote orders to allow displaying remote pending orders
if (details.orderStatus === SwapOrderStatus.Open) return undefined
const supportedChain = supportedChainIdFromGQLChain(chain)
if (!supportedChain) {
logSentryErrorForUnsupportedChain({
extras: { details },
errorMessage: 'Invalid activity from unsupported chain received from GQL',
})
return undefined
}
if (details.orderStatus === SwapOrderStatus.Open) {
const inputCurrency = gqlToCurrency(details.inputToken)
const outputCurrency = gqlToCurrency(details.outputToken)
store.dispatch(
addSignature({
type: SignatureType.SIGN_UNISWAPX_ORDER,
offerer: details.offerer,
id: details.hash,
chainId: supportedChain,
orderHash: details.hash,
expiry: details.expiry,
swapInfo: {
type: LocalTransactionType.SWAP,
inputCurrencyId: currencyId(inputCurrency),
outputCurrencyId: currencyId(outputCurrency),
isUniswapXOrder: true,
// This doesn't affect the display, but we don't know this value from the remote activity.
tradeType: TradeType.EXACT_INPUT,
inputCurrencyAmountRaw:
tryParseCurrencyAmount(details.inputTokenQuantity, inputCurrency)?.quotient.toString() ?? '0',
expectedOutputCurrencyAmountRaw:
tryParseCurrencyAmount(details.outputTokenQuantity, outputCurrency)?.quotient.toString() ?? '0',
minimumOutputCurrencyAmountRaw:
tryParseCurrencyAmount(details.outputTokenQuantity, outputCurrency)?.quotient.toString() ?? '0',
},
status: UniswapXOrderStatus.OPEN,
addedTime: timestamp,
})
)
return undefined
}
const { inputToken, inputTokenQuantity, outputToken, outputTokenQuantity, orderStatus } = details
const uniswapXOrderStatus = OrderStatusTable[orderStatus]
@@ -361,21 +475,28 @@ function parseUniswapXOrder({ details, chain, timestamp }: OrderActivity): Activ
outputAmount: outputTokenQuantity,
})
const supportedChain = supportedChainIdFromGQLChain(chain)
if (!supportedChain) {
logSentryErrorForUnsupportedChain({
extras: { details },
errorMessage: 'Invalid activity from unsupported chain received from GQL',
})
return undefined
}
return {
hash: details.hash,
chainId: supportedChain,
status,
statusMessage,
offchainOrderStatus: uniswapXOrderStatus,
offchainOrderDetails: {
type: SignatureType.SIGN_UNISWAPX_ORDER,
txHash: details.hash,
chainId: supportedChain,
status: uniswapXOrderStatus,
swapInfo: {
isUniswapXOrder: true,
type: LocalTransactionType.SWAP,
tradeType: TradeType.EXACT_INPUT,
inputCurrencyId: inputToken.id,
outputCurrencyId: outputToken.id,
inputCurrencyAmountRaw: inputTokenQuantity,
expectedOutputCurrencyAmountRaw: outputTokenQuantity,
minimumOutputCurrencyAmountRaw: outputTokenQuantity,
settledOutputCurrencyAmountRaw: outputTokenQuantity,
},
},
timestamp,
logos: [inputToken.project?.logo?.url, outputToken.project?.logo?.url],
currencies: [gqlToCurrency(inputToken), gqlToCurrency(outputToken)],

View File

@@ -1,13 +1,18 @@
import { ChainId, Currency } from '@uniswap/sdk-core'
import { TransactionStatus } from 'graphql/data/__generated__/types-and-hooks'
import { UniswapXOrderStatus } from 'lib/hooks/orders/types'
import { UniswapXOrderDetails } from 'state/signatures/types'
/**
* TODO: refactor parsing / Activity so that all Activity Types can have a detail sheet.
*/
export type OffchainOrderDetails = Pick<UniswapXOrderDetails, 'txHash' | 'chainId' | 'type' | 'status' | 'swapInfo'>
export type Activity = {
hash: string
chainId: ChainId
status: TransactionStatus
// TODO (UniswapX): decouple Activity from UniswapXOrderStatus once we can link UniswapXScan instead of needing data for modal
offchainOrderStatus?: UniswapXOrderStatus
offchainOrderDetails?: OffchainOrderDetails
statusMessage?: string
timestamp: number
title: string

View File

@@ -1,74 +0,0 @@
import { Trans } from '@lingui/macro'
import { InterfaceElementName } from '@uniswap/analytics-events'
import { useScreenSize } from 'hooks/useScreenSize'
import { useLocation } from 'react-router-dom'
import { useHideAndroidAnnouncementBanner } from 'state/user/hooks'
import { ThemedText } from 'theme/components'
import { useIsDarkMode } from 'theme/components/ThemeToggle'
import { openDownloadApp } from 'utils/openDownloadApp'
import { isMobileSafari } from 'wallet/src/utils/platform'
import androidAnnouncementBannerQR from '../../../assets/images/androidAnnouncementBannerQR.png'
import darkAndroidThumbnail from '../../../assets/images/AndroidWallet-Thumbnail-Dark.png'
import lightAndroidThumbnail from '../../../assets/images/AndroidWallet-Thumbnail-Light.png'
import {
Container,
DownloadButton,
PopupContainer,
StyledQrCode,
StyledXButton,
TextContainer,
Thumbnail,
} from './styled'
export default function AndroidAnnouncementBanner() {
const [hideAndroidAnnouncementBanner, toggleHideAndroidAnnouncementBanner] = useHideAndroidAnnouncementBanner()
const location = useLocation()
const isLandingScreen = location.search === '?intro=true' || location.pathname === '/'
const screenSize = useScreenSize()
const shouldDisplay = Boolean(!hideAndroidAnnouncementBanner && !isLandingScreen)
const isDarkMode = useIsDarkMode()
const onClick = () =>
openDownloadApp({
element: InterfaceElementName.UNISWAP_WALLET_BANNER_DOWNLOAD_BUTTON,
})
if (isMobileSafari) return null
return (
<PopupContainer show={shouldDisplay}>
<Container>
<Thumbnail src={isDarkMode ? darkAndroidThumbnail : lightAndroidThumbnail} alt="Android app thumbnail" />
<TextContainer onClick={!screenSize['xs'] ? onClick : undefined}>
<ThemedText.BodySmall lineHeight="20px">
<Trans>Uniswap on Android</Trans>
</ThemedText.BodySmall>
<ThemedText.LabelMicro>
<Trans>Available now - download from the Google Play Store today</Trans>
</ThemedText.LabelMicro>
<DownloadButton
onClick={(e) => {
e.stopPropagation()
onClick()
}}
>
<Trans>Download now</Trans>
</DownloadButton>
</TextContainer>
<StyledQrCode src={androidAnnouncementBannerQR} alt="App OneLink QR code" />
<StyledXButton
data-testid="uniswap-wallet-banner"
size={24}
onClick={(e) => {
// prevent click from bubbling to UI on the page underneath, i.e. clicking a token row
e.preventDefault()
e.stopPropagation()
toggleHideAndroidAnnouncementBanner()
}}
/>
</Container>
</PopupContainer>
)
}

View File

@@ -0,0 +1,84 @@
import { t, Trans } from '@lingui/macro'
import { InterfaceElementName } from '@uniswap/analytics-events'
import { useScreenSize } from 'hooks/useScreenSize'
import { useMemo } from 'react'
import { useLocation } from 'react-router-dom'
import { useHideAppPromoBanner } from 'state/user/hooks'
import { ThemedText } from 'theme/components'
import { useIsDarkMode } from 'theme/components/ThemeToggle'
import { openDownloadApp } from 'utils/openDownloadApp'
import { isAndroid, isIOS, isMobileSafari } from 'wallet/src/utils/platform'
import darkAndroidThumbnail from '../../../assets/images/app-promo-banner/AndroidWallet-Thumbnail-Dark.png'
import lightAndroidThumbnail from '../../../assets/images/app-promo-banner/AndroidWallet-Thumbnail-Light.png'
import darkDesktopThumbnail from '../../../assets/images/app-promo-banner/DesktopWallet-Thumbnail-Dark.png'
import lightDesktopThumbnail from '../../../assets/images/app-promo-banner/DesktopWallet-Thumbnail-Light.png'
import darkIOSThumbnail from '../../../assets/images/app-promo-banner/iOSWallet-Thumbnail-Dark.png'
import lightIOSThumbnail from '../../../assets/images/app-promo-banner/iOSWallet-Thumbnail-Light.png'
import walletAppPromoBannerQR from '../../../assets/images/app-promo-banner/walletAnnouncementBannerQR.png'
import {
Container,
DownloadButton,
PopupContainer,
StyledQrCode,
StyledXButton,
TextContainer,
Thumbnail,
} from './styled'
export default function WalletAppPromoBanner() {
const [hideAppPromoBanner, toggleHideAppPromoBanner] = useHideAppPromoBanner()
const location = useLocation()
const isLandingScreen = location.search === '?intro=true' || location.pathname === '/'
const screenSize = useScreenSize()
const shouldDisplay = Boolean(!hideAppPromoBanner && !isLandingScreen && !isMobileSafari)
const isDarkMode = useIsDarkMode()
const thumbnailSrc = useMemo(() => {
if (isAndroid) {
return isDarkMode ? darkAndroidThumbnail : lightAndroidThumbnail
} else if (isIOS) {
return isDarkMode ? darkIOSThumbnail : lightIOSThumbnail
} else {
return isDarkMode ? darkDesktopThumbnail : lightDesktopThumbnail
}
}, [isDarkMode])
const onClick = () =>
openDownloadApp({
element: InterfaceElementName.UNISWAP_WALLET_BANNER_DOWNLOAD_BUTTON,
})
return (
<PopupContainer show={shouldDisplay}>
<Container>
<Thumbnail src={thumbnailSrc} alt={t`Wallet app promo banner thumbnail`} />
<TextContainer onClick={!screenSize['xs'] ? onClick : undefined}>
<ThemedText.BodySmall lineHeight="20px">
<Trans>Get the app</Trans>
</ThemedText.BodySmall>
<ThemedText.LabelMicro>
{isAndroid ? (
<Trans>Download the Uniswap mobile app from the Play Store</Trans>
) : isIOS ? (
<Trans>Download the Uniswap mobile app from the App Store</Trans>
) : (
<Trans>Download the Uniswap mobile app for iOS and Android</Trans>
)}
</ThemedText.LabelMicro>
<DownloadButton
onClick={(e) => {
e.stopPropagation()
onClick()
}}
>
{isAndroid || isIOS ? <Trans>Download now</Trans> : <Trans>Learn more</Trans>}
</DownloadButton>
</TextContainer>
<StyledQrCode src={walletAppPromoBannerQR} alt="App OneLink QR code" />
<StyledXButton data-testid="uniswap-wallet-banner" size={24} onClick={toggleHideAppPromoBanner} />
</Container>
</PopupContainer>
)
}

View File

@@ -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 (

View File

@@ -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()}

View File

@@ -1,10 +1,11 @@
import { t } from '@lingui/macro'
import { ReactElement } from 'react'
import { ReactComponent as WinterUni } from '../../assets/svg/winter-uni.svg'
import { SVGProps } from './UniIcon'
const MONTH_TO_HOLIDAY_UNI: { [date: string]: (props: SVGProps) => ReactElement } = {
'12': (props) => <WinterUni {...props} />,
'12': (props) => <WinterUni title={t`Happy Holidays from the Uniswap team!`} {...props} />,
'1': (props) => <WinterUni {...props} />,
}

View File

@@ -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} />
}

View File

@@ -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 (

View File

@@ -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=""

View File

@@ -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}
/>

View File

@@ -0,0 +1,39 @@
import userEvent from '@testing-library/user-event'
import { ChainId } from '@uniswap/sdk-core'
import { nativeOnChain } from 'constants/tokens'
import { TokenFromList } from 'state/lists/tokenFromList'
import { act, render, screen } from 'test-utils/render'
import { CurrentBreadcrumb } from './BreadcrumbNav'
jest.mock('featureFlags/flags/infoTDP', () => ({ useInfoTDPEnabled: () => true }))
describe('BreadcrumbNav', () => {
it('renders hover components correctly', async () => {
const currency = new TokenFromList({
chainId: 1,
address: '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599',
name: 'Wrapped BTC',
decimals: 18,
symbol: 'WBTC',
})
const { asFragment } = render(
<CurrentBreadcrumb address="0x2260fac5e5542a773aa44fbcfedf7c193bc2c599" currency={currency} />
)
expect(asFragment()).toMatchSnapshot()
await act(() => userEvent.hover(screen.getByTestId('current-breadcrumb')))
expect(screen.getByTestId('breadcrumb-hover-copy')).toBeInTheDocument()
await act(() => userEvent.unhover(screen.getByTestId('current-breadcrumb')))
expect(screen.queryByTestId('breadcrumb-hover-copy')).not.toBeInTheDocument()
})
it('does not display address hover for native tokens', async () => {
const ETH = nativeOnChain(ChainId.MAINNET)
const { asFragment } = render(<CurrentBreadcrumb address="NATIVE" currency={ETH} />)
expect(asFragment()).toMatchSnapshot()
await act(() => userEvent.hover(screen.getByTestId('current-breadcrumb')))
expect(screen.queryByTestId('breadcrumb-hover-copy')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,129 @@
import { Trans } from '@lingui/macro'
import { Currency } from '@uniswap/sdk-core'
import Row from 'components/Row'
import useCopyClipboard from 'hooks/useCopyClipboard'
import { useScreenSize } from 'hooks/useScreenSize'
import { useCallback, useState } from 'react'
import { Copy } from 'react-feather'
import { Link } from 'react-router-dom'
import { useModalIsOpen } from 'state/application/hooks'
import { ApplicationModal } from 'state/application/reducer'
import styled, { css, useTheme } from 'styled-components'
import { ClickableStyle, ThemedText } from 'theme/components'
import { shortenAddress } from 'utils/addresses'
import ShareButton from './ShareButton'
export const BreadcrumbNavContainer = styled.nav<{ isInfoTDPEnabled?: boolean }>`
display: flex;
color: ${({ theme }) => theme.neutral1};
${({ isInfoTDPEnabled }) =>
isInfoTDPEnabled
? css`
font-size: 16px;
line-height: 24px;
`
: css`
font-size: 14px;
line-height: 20px;
`}
align-items: center;
gap: 4px;
margin-bottom: 16px;
width: fit-content;
`
export const BreadcrumbNavLink = styled(Link)`
display: flex;
align-items: center;
color: ${({ theme }) => theme.neutral2};
transition-duration: ${({ theme }) => theme.transition.duration.fast};
text-decoration: none;
&:hover {
color: ${({ theme }) => theme.neutral3};
}
`
const CurrentBreadcrumbContainer = styled(Row)`
gap: 6px;
`
// This must be an h1 to match the SEO title, and must be the first heading tag in code.
const PageTitleText = styled.h1`
font-weight: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
`
const TokenAddressHoverContainer = styled(Row)`
cursor: pointer;
gap: 10px;
white-space: nowrap;
`
const HoverActionsDivider = styled.div`
height: 16px;
width: 1px;
background-color: ${({ theme }) => theme.surface3};
`
const CopyIcon = styled(Copy)`
${ClickableStyle}
`
const StyledCopiedSuccess = styled(Row)`
gap: 4px;
`
const CopiedSuccess = () => {
const { success } = useTheme()
return (
<StyledCopiedSuccess>
<Copy width={16} height={16} color={success} />
<ThemedText.Caption color="success">
<Trans>Copied!</Trans>
</ThemedText.Caption>
</StyledCopiedSuccess>
)
}
export const CurrentBreadcrumb = ({ address, currency }: { address: string; currency: Currency }) => {
const { neutral2 } = useTheme()
const screenSize = useScreenSize()
const [hover, setHover] = useState(false)
const [isCopied, setCopied] = useCopyClipboard()
const copy = useCallback(() => {
setCopied(address)
}, [address, setCopied])
const isNative = currency.isNative
const tokenSymbolName = currency && (currency.symbol ?? <Trans>Symbol not found</Trans>)
const shareModalOpen = useModalIsOpen(ApplicationModal.SHARE)
const shouldShowActions = (screenSize['sm'] && hover && !isCopied) || shareModalOpen
return (
<CurrentBreadcrumbContainer
aria-current="page"
data-testid="current-breadcrumb"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
<PageTitleText>{tokenSymbolName}</PageTitleText>{' '}
{!isNative && (
<TokenAddressHoverContainer data-testid="breadcrumb-token-address" onClick={copy}>
( {shortenAddress(address)} )
{shouldShowActions && (
<>
<CopyIcon data-testid="breadcrumb-hover-copy" width={16} height={16} color={neutral2} />
<HoverActionsDivider />
</>
)}
{isCopied && <CopiedSuccess />}
</TokenAddressHoverContainer>
)}
{shouldShowActions && <ShareButton currency={currency} />}
</CurrentBreadcrumbContainer>
)
}

View File

@@ -1,33 +0,0 @@
import { Link } from 'react-router-dom'
import styled, { css } from 'styled-components'
export const BreadcrumbNav = styled.div<{ isInfoTDPEnabled?: boolean }>`
display: flex;
color: ${({ theme }) => theme.neutral1};
${({ isInfoTDPEnabled }) =>
isInfoTDPEnabled
? css`
font-size: 16px;
line-height: 24px;
`
: css`
font-size: 14px;
line-height: 20px;
`}
align-items: center;
gap: 4px;
margin-bottom: 16px;
width: fit-content;
`
export const BreadcrumbNavLink = styled(Link)`
display: flex;
align-items: center;
color: ${({ theme }) => theme.neutral2};
transition-duration: ${({ theme }) => theme.transition.duration.fast};
text-decoration: none;
&:hover {
color: ${({ theme }) => theme.neutral3};
}
`

View File

@@ -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}

View File

@@ -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>

View File

@@ -0,0 +1,115 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BreadcrumbNav does not display address hover for native tokens 1`] = `
<DocumentFragment>
.c0 {
box-sizing: border-box;
margin: 0;
min-width: 0;
}
.c1 {
width: 100%;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
padding: 0;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
}
.c2 {
gap: 6px;
}
.c3 {
font-weight: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
}
<div
aria-current="page"
class="c0 c1 c2"
data-testid="current-breadcrumb"
>
<h1
class="c3"
>
ETH
</h1>
</div>
</DocumentFragment>
`;
exports[`BreadcrumbNav renders hover components correctly 1`] = `
<DocumentFragment>
.c0 {
box-sizing: border-box;
margin: 0;
min-width: 0;
}
.c1 {
width: 100%;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
padding: 0;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
}
.c2 {
gap: 6px;
}
.c3 {
font-weight: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
}
.c4 {
cursor: pointer;
gap: 10px;
white-space: nowrap;
}
<div
aria-current="page"
class="c0 c1 c2"
data-testid="current-breadcrumb"
>
<h1
class="c3"
>
WBTC
</h1>
<div
class="c0 c1 c4"
data-testid="breadcrumb-token-address"
>
( 0x2260...C599 )
</div>
</div>
</DocumentFragment>
`;

View File

@@ -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}>

View File

@@ -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 />

View File

@@ -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) {

View File

@@ -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[]) {

View File

@@ -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.

View File

@@ -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,
},
}}
>

View File

@@ -1,6 +1,6 @@
import { MockEIP1193Provider } from '@web3-react/core'
import METAMASK_ICON from 'assets/wallets/metamask-icon.svg'
import { renderHook } from 'test-utils/render'
import { act, renderHook } from 'test-utils/render'
import { v4 as uuidv4 } from 'uuid'
import { EIP6963_PROVIDER_MANAGER, useInjectedProviderDetails } from './providers'
@@ -17,6 +17,8 @@ afterEach(() => {
// @ts-ignore
EIP6963_PROVIDER_MANAGER._map.clear() // reset the map after each test
// @ts-ignore
EIP6963_PROVIDER_MANAGER._list.length = 0 // reset the list after each test
})
function announceProvider(rdns: string, provider: MockEIP1193Provider) {
@@ -50,34 +52,34 @@ describe('EIP6963 Providers', () => {
announceProvider('mockExtension1', mockProvider1)
announceProvider('mockExtension2', mockProvider2)
expect(EIP6963_PROVIDER_MANAGER.map.size).toEqual(2)
expect(EIP6963_PROVIDER_MANAGER.map.get('mockExtension1')).toBeDefined()
expect(EIP6963_PROVIDER_MANAGER.map.get('mockExtension2')).toBeDefined()
expect(EIP6963_PROVIDER_MANAGER.list.length).toEqual(2)
expect(EIP6963_PROVIDER_MANAGER.list[0].info.rdns === 'mockExtension1').toBeTruthy()
expect(EIP6963_PROVIDER_MANAGER.list[1].info.rdns === 'mockExtension2').toBeTruthy()
})
it('should ignore coinbase', () => {
announceProvider('com.coinbase.wallet', mockProvider1)
expect(EIP6963_PROVIDER_MANAGER.map.size).toEqual(0)
expect(EIP6963_PROVIDER_MANAGER.list.length).toEqual(0)
})
it('should replace metamask logo', () => {
announceProvider('io.metamask', mockProvider1)
expect(EIP6963_PROVIDER_MANAGER.map.size).toEqual(1)
expect(EIP6963_PROVIDER_MANAGER.map.get('io.metamask')?.info.icon).toEqual(METAMASK_ICON)
expect(EIP6963_PROVIDER_MANAGER.list.length).toEqual(1)
METAMASK_ICON
})
it('should ignore improperly formatted provider info', () => {
announceProvider(undefined as any, mockProvider1)
expect(EIP6963_PROVIDER_MANAGER.map.size).toEqual(0)
expect(EIP6963_PROVIDER_MANAGER.list.length).toEqual(0)
})
it('should ignore improperly formatted providers', () => {
announceProvider('mockExtension1', {} as any)
expect(EIP6963_PROVIDER_MANAGER.map.size).toEqual(0)
expect(EIP6963_PROVIDER_MANAGER.list.length).toEqual(0)
})
})
@@ -86,12 +88,12 @@ describe('EIP6963 Providers', () => {
const test = renderHook(() => useInjectedProviderDetails())
expect([test.result.current.values()].length).toEqual(1)
expect(test.result.current.get('mockExtension1')).toBeDefined()
expect(test.result.current[0].info.rdns === 'mockExtension1').toBeTruthy()
announceProvider('mockExtension2', mockProvider2)
act(() => announceProvider('mockExtension2', mockProvider2))
expect(test.result.current.size).toEqual(2)
expect(test.result.current.get('mockExtension1')).toBeDefined()
expect(test.result.current.get('mockExtension2')).toBeDefined()
expect(test.result.current.length).toEqual(2)
expect(test.result.current[0].info.rdns === 'mockExtension1').toBeTruthy()
expect(test.result.current[1].info.rdns === 'mockExtension2').toBeTruthy()
})
})

Some files were not shown because too many files have changed in this diff Show More