ci(release): publish latest release

parent e295be7b
...@@ -8,6 +8,9 @@ node_modules ...@@ -8,6 +8,9 @@ node_modules
# testing # testing
coverage coverage
# utility script output
scripts/dist
# next.js # next.js
.next/ .next/
out/ out/
......
3.2.2
\ No newline at end of file
...@@ -17,3 +17,54 @@ index 4b5b90b7b478668fdff3fd12d5e028d423ada057..af30dc6f700b3b3cfde5c149bf1f8657 ...@@ -17,3 +17,54 @@ index 4b5b90b7b478668fdff3fd12d5e028d423ada057..af30dc6f700b3b3cfde5c149bf1f8657
}); });
} }
diff --git a/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuManager.java b/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuManager.java
index 81f7b4b58c946d1b2e14301f9b52ecffa1cd0643..403dac6450be24a8c4d26ffb8293b51a1485f6a8 100644
--- a/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuManager.java
+++ b/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuManager.java
@@ -45,6 +45,11 @@ public class ContextMenuManager extends ViewGroupManager<ContextMenuView> {
view.setDropdownMenuMode(enabled);
}
+ @ReactProp(name = "disabled")
+ public void setDisabled(ContextMenuView view, @Nullable boolean disabled) {
+ view.setDisabled(disabled);
+ }
+
@androidx.annotation.Nullable
@Override
public Map<String, Object> getExportedCustomDirectEventTypeConstants() {
diff --git a/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuView.java b/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuView.java
index af30dc6f700b3b3cfde5c149bf1f865786df3e27..aa04fe6d9458601fdcb9bb44f89e16bbc1ad9d39 100644
--- a/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuView.java
+++ b/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuView.java
@@ -43,6 +43,8 @@ public class ContextMenuView extends ReactViewGroup implements PopupMenu.OnMenuI
boolean cancelled = true;
+ private boolean disabled = false;
+
protected boolean dropdownMenuMode = false;
public ContextMenuView(final Context context) {
@@ -87,13 +89,18 @@ public class ContextMenuView extends ReactViewGroup implements PopupMenu.OnMenuI
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
- return true;
+ return disabled ? false : true;
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
- gestureDetector.onTouchEvent(ev);
- return true;
+ if (disabled) return false;
+ gestureDetector.onTouchEvent(ev);
+ return true;
+ }
+
+ public void setDisabled(boolean disabled) {
+ this.disabled = disabled;
}
public void setActions(@Nullable ReadableArray actions) {
...@@ -17,3 +17,18 @@ index 3738bd2c61e516fa431f61fda47f2474f72dba42..2b3266007b3c9412d99e7ceee205ee52 ...@@ -17,3 +17,18 @@ index 3738bd2c61e516fa431f61fda47f2474f72dba42..2b3266007b3c9412d99e7ceee205ee52
global.requestAnimationFrame = function (callback) { global.requestAnimationFrame = function (callback) {
return setTimeout(callback, 0); return setTimeout(callback, 0);
diff --git a/third-party-podspecs/boost.podspec b/third-party-podspecs/boost.podspec
index 3d9331c95d1217682a0b820a0d9440fdff074ae0..8276eb1a5854f945462363fe8db917e8270b3b6a 100644
--- a/third-party-podspecs/boost.podspec
+++ b/third-party-podspecs/boost.podspec
@@ -10,8 +10,8 @@ Pod::Spec.new do |spec|
spec.homepage = 'http://www.boost.org'
spec.summary = 'Boost provides free peer-reviewed portable C++ source libraries.'
spec.authors = 'Rene Rivera'
- spec.source = { :http => 'https://boostorg.jfrog.io/artifactory/main/release/1.76.0/source/boost_1_76_0.tar.bz2',
- :sha256 => 'f0397ba6e982c4450f27bf32a2a83292aba035b827a5623a14636ea583318c41' }
+ spec.source = { :http => 'https://sourceforge.net/projects/boost/files/boost/1.83.0/boost_1_83_0.tar.bz2',
+ :sha256 => '6478edfe2f3305127cffe8caf73ea0176c53769f4bf1585be237eb30798c3b8e' }
# Pinning to the same version as React.podspec.
spec.platforms = { :ios => '11.0' }
Here again with a new (sparse) update to our app! Check out what is new below: Here again with a new update to our app! Check out what is new below
- Bug fixes - Editing Favorite Tokens — We added the ability to drag, drop, and rearrange your favorited tokens. Keep your most important watched tokens close!
- Performance improvements on home and swap
- Improvements in language support - Hidden Token Balances — We updated our wallet to not include the value of hidden tokens in the total wallet balance. Toggle specific tokens to be hidden or shown, and your overall wallet balance will reflect the changes immediately. Out of sight, out of mind.
mobile/1.17.1 mobile/1.18
\ No newline at end of file \ No newline at end of file
* @Uniswap/mobile-release-admins
\ No newline at end of file
...@@ -85,6 +85,9 @@ Set this as your default version: ...@@ -85,6 +85,9 @@ Set this as your default version:
Install cocoapods: Install cocoapods:
`gem install cocoapods -v 1.13.0` `gem install cocoapods -v 1.13.0`
If you hit ruby errors around `ActiveSupport.deprecator`, downgrade your `activesupport` package by running:
`gem uninstall activesupport && gem install activesupport -v 7.0.8`
### Add Xcode Command Line Tools ### Add Xcode Command Line Tools
Open Xcode and go to: Open Xcode and go to:
......
...@@ -125,17 +125,17 @@ android { ...@@ -125,17 +125,17 @@ android {
dev { dev {
isDefault(true) isDefault(true)
applicationIdSuffix ".dev" applicationIdSuffix ".dev"
versionName "1.17" versionName "1.18"
dimension "variant" dimension "variant"
} }
beta { beta {
applicationIdSuffix ".beta" applicationIdSuffix ".beta"
versionName "1.17.1" versionName "1.18"
dimension "variant" dimension "variant"
} }
prod { prod {
dimension "variant" dimension "variant"
versionName "1.17.1" versionName "1.18"
} }
} }
......
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
<item name="android:itemBackground">@color/item_background</item> <item name="android:itemBackground">@color/item_background</item>
<item name="android:windowLightStatusBar">false</item> <item name="android:windowLightStatusBar">false</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splashscreen_icon</item> <item name="android:windowSplashScreenAnimatedIcon">@drawable/splashscreen_icon</item>
<item name="android:editTextBackground">@android:color/transparent</item>
</style> </style>
</resources> </resources>
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
<item name="android:windowLightStatusBar">true</item> <item name="android:windowLightStatusBar">true</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splashscreen_icon</item> <item name="android:windowSplashScreenAnimatedIcon">@drawable/splashscreen_icon</item>
<item name="android:navigationBarColor">@color/background_material_light</item> <item name="android:navigationBarColor">@color/background_material_light</item>
<item name="android:editTextBackground">@android:color/transparent</item>
</style> </style>
</resources> </resources>
...@@ -651,7 +651,7 @@ PODS: ...@@ -651,7 +651,7 @@ PODS:
- EXAV (13.4.1): - EXAV (13.4.1):
- ExpoModulesCore - ExpoModulesCore
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- EXBarCodeScanner (12.3.2): - EXBarCodeScanner (12.7.0):
- EXImageLoader - EXImageLoader
- ExpoModulesCore - ExpoModulesCore
- ZXingObjC/OneD - ZXingObjC/OneD
...@@ -660,7 +660,7 @@ PODS: ...@@ -660,7 +660,7 @@ PODS:
- ExpoModulesCore - ExpoModulesCore
- EXFont (11.1.1): - EXFont (11.1.1):
- ExpoModulesCore - ExpoModulesCore
- EXImageLoader (4.1.1): - EXImageLoader (4.4.0):
- ExpoModulesCore - ExpoModulesCore
- React-Core - React-Core
- EXLocalAuthentication (13.0.2): - EXLocalAuthentication (13.0.2):
...@@ -1689,10 +1689,10 @@ SPEC CHECKSUMS: ...@@ -1689,10 +1689,10 @@ SPEC CHECKSUMS:
EthersRS: 56b70e73d22d4e894b7e762eef1129159bcd3135 EthersRS: 56b70e73d22d4e894b7e762eef1129159bcd3135
EXApplication: d8f53a7eee90a870a75656280e8d4b85726ea903 EXApplication: d8f53a7eee90a870a75656280e8d4b85726ea903
EXAV: f393dfc0b28214d62855a31e06eb21d426d6e2da EXAV: f393dfc0b28214d62855a31e06eb21d426d6e2da
EXBarCodeScanner: 8e23fae8d267dbef9f04817833a494200f1fce35 EXBarCodeScanner: 296dd50f6c03928d1d71d37ea17473b304cfdb00
EXFileSystem: 0b4a2c08c2dc92146849772145d61c1773144283 EXFileSystem: 0b4a2c08c2dc92146849772145d61c1773144283
EXFont: 6ea3800df746be7233208d80fe379b8ed74f4272 EXFont: 6ea3800df746be7233208d80fe379b8ed74f4272
EXImageLoader: fd053169a8ee932dd83bf1fe5487a50c26d27c2b EXImageLoader: 03063370bc06ea1825713d3f55fe0455f7c88d04
EXLocalAuthentication: 78cc5a00b13745b41d16d189ab332b61a01299f9 EXLocalAuthentication: 78cc5a00b13745b41d16d189ab332b61a01299f9
Expo: 8448e3a2aa1b295f029c81551e1ab6d986517fdb Expo: 8448e3a2aa1b295f029c81551e1ab6d986517fdb
ExpoBlur: fac3c6318fdf409dd5740afd3313b2bd52a35bac ExpoBlur: fac3c6318fdf409dd5740afd3313b2bd52a35bac
......
...@@ -26,9 +26,31 @@ let placeholderPriceHistory = [ ...@@ -26,9 +26,31 @@ let placeholderPriceHistory = [
PriceHistory(timestamp: 1689794997, price: 2167), PriceHistory(timestamp: 1689794997, price: 2167),
PriceHistory(timestamp: 1689795264, price: 2165) PriceHistory(timestamp: 1689795264, price: 2165)
] ]
let previewEntry = TokenPriceEntry(date: Date(), configuration: TokenPriceConfigurationIntent(), spotPrice: 2165, pricePercentChange: -9.87, symbol: "ETH", logo: UIImage(url: URL(string: "https://token-icons.s3.amazonaws.com/eth.png")), backgroundColor: ColorExtraction.extractImageColorWithSpecialCase(imageURL: "https://token-icons.s3.amazonaws.com/eth.png"), tokenPriceHistory: TokenPriceHistoryResponse(priceHistory: placeholderPriceHistory)) let previewEntry = TokenPriceEntry(
date: Date(),
configuration: TokenPriceConfigurationIntent(),
currency: WidgetConstants.currencyUsd,
spotPrice: 2165,
pricePercentChange: -9.87,
symbol: "ETH",
logo: UIImage(url: URL(string: "https://token-icons.s3.amazonaws.com/eth.png")),
backgroundColor: ColorExtraction.extractImageColorWithSpecialCase(
imageURL: "https://token-icons.s3.amazonaws.com/eth.png"
),
tokenPriceHistory: TokenPriceHistoryResponse(priceHistory: placeholderPriceHistory)
)
let placeholderEntry = TokenPriceEntry(date: previewEntry.date, configuration: previewEntry.configuration, spotPrice: previewEntry.spotPrice, pricePercentChange: previewEntry.pricePercentChange, symbol: previewEntry.symbol, logo: nil, backgroundColor: nil, tokenPriceHistory: previewEntry.tokenPriceHistory) let placeholderEntry = TokenPriceEntry(
date: previewEntry.date,
configuration: previewEntry.configuration,
currency: previewEntry.currency,
spotPrice: previewEntry.spotPrice,
pricePercentChange: previewEntry.pricePercentChange,
symbol: previewEntry.symbol,
logo: nil,
backgroundColor: nil,
tokenPriceHistory: previewEntry.tokenPriceHistory
)
let refreshMinutes = 5 let refreshMinutes = 5
let displayName = "Token Prices" let displayName = "Token Prices"
...@@ -39,10 +61,16 @@ struct Provider: IntentTimelineProvider { ...@@ -39,10 +61,16 @@ struct Provider: IntentTimelineProvider {
func getEntry(configuration: TokenPriceConfigurationIntent, context: Context, isSnapshot: Bool) async throws -> TokenPriceEntry { func getEntry(configuration: TokenPriceConfigurationIntent, context: Context, isSnapshot: Bool) async throws -> TokenPriceEntry {
let entryDate = Date() let entryDate = Date()
let tokenPriceResponse = isSnapshot ? async let tokenPriceRequest = isSnapshot ?
try await DataQueries.fetchTokenPriceData(chain: WidgetConstants.ethereumChain, address: nil) : await DataQueries.fetchTokenPriceData(chain: WidgetConstants.ethereumChain, address: nil) :
try await DataQueries.fetchTokenPriceData(chain: configuration.selectedToken?.chain ?? "", address: configuration.selectedToken?.address) await DataQueries.fetchTokenPriceData(chain: configuration.selectedToken?.chain ?? "", address: configuration.selectedToken?.address)
let spotPrice = tokenPriceResponse.spotPrice async let conversionRequest = await DataQueries.fetchCurrencyConversion(
toCurrency: UniswapUserDefaults.readI18n().currency)
let (tokenPriceResponse, conversionResponse) = try await (tokenPriceRequest, conversionRequest)
let spotPrice = tokenPriceResponse.spotPrice != nil ?
tokenPriceResponse.spotPrice! * conversionResponse.conversionRate : nil
let pricePercentChange = tokenPriceResponse.pricePercentChange let pricePercentChange = tokenPriceResponse.pricePercentChange
let symbol = tokenPriceResponse.symbol let symbol = tokenPriceResponse.symbol
let logo = UIImage(url: URL(string: tokenPriceResponse.logoUrl ?? "")) let logo = UIImage(url: URL(string: tokenPriceResponse.logoUrl ?? ""))
...@@ -62,7 +90,17 @@ struct Provider: IntentTimelineProvider { ...@@ -62,7 +90,17 @@ struct Provider: IntentTimelineProvider {
address: configuration.selectedToken?.address) address: configuration.selectedToken?.address)
} }
return TokenPriceEntry(date: entryDate, configuration: configuration, spotPrice: spotPrice, pricePercentChange: pricePercentChange, symbol: symbol, logo: logo, backgroundColor: backgroundColor, tokenPriceHistory: tokenPriceHistory) return TokenPriceEntry(
date: entryDate,
configuration: configuration,
currency: conversionResponse.currency,
spotPrice: spotPrice,
pricePercentChange: pricePercentChange,
symbol: symbol,
logo: logo,
backgroundColor: backgroundColor,
tokenPriceHistory: tokenPriceHistory
)
} }
func placeholder(in context: Context) -> TokenPriceEntry { func placeholder(in context: Context) -> TokenPriceEntry {
...@@ -90,6 +128,7 @@ struct Provider: IntentTimelineProvider { ...@@ -90,6 +128,7 @@ struct Provider: IntentTimelineProvider {
struct TokenPriceEntry: TimelineEntry { struct TokenPriceEntry: TimelineEntry {
let date: Date let date: Date
let configuration: TokenPriceConfigurationIntent let configuration: TokenPriceConfigurationIntent
let currency: String
let spotPrice: Double? let spotPrice: Double?
let pricePercentChange: Double? let pricePercentChange: Double?
let symbol: String let symbol: String
...@@ -130,7 +169,14 @@ struct TokenPriceWidgetEntryView: View { ...@@ -130,7 +169,14 @@ struct TokenPriceWidgetEntryView: View {
func priceSection(isPlaceholder: Bool) -> some View { func priceSection(isPlaceholder: Bool) -> some View {
return VStack(alignment: .leading, spacing: 0) { return VStack(alignment: .leading, spacing: 0) {
if (!isPlaceholder && entry.spotPrice != nil && entry.pricePercentChange != nil) { if (!isPlaceholder && entry.spotPrice != nil && entry.pricePercentChange != nil) {
Text(NumberFormatter.fiatTokenDetailsFormatter(price: entry.spotPrice)) let i18nSettings = UniswapUserDefaults.readI18n()
Text(
NumberFormatter.fiatTokenDetailsFormatter(
price: entry.spotPrice,
locale: Locale(identifier: i18nSettings.locale),
currencyCode: entry.currency
)
)
.withHeading1Style() .withHeading1Style()
.frame(minHeight: 28) .frame(minHeight: 28)
.minimumScaleFactor(0.3) .minimumScaleFactor(0.3)
......
...@@ -12,6 +12,7 @@ public struct WidgetConstants { ...@@ -12,6 +12,7 @@ public struct WidgetConstants {
public static let ethereumChain = "ETHEREUM" public static let ethereumChain = "ETHEREUM"
public static let WETHAddress = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" public static let WETHAddress = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"
public static let ethereumSymbol = "ETH" public static let ethereumSymbol = "ETH"
public static let currencyUsd = "USD"
} }
// Needed to handle different bundle ids, cannot map directly but handles arbitrary bundle ids that conform to the existing convention // Needed to handle different bundle ids, cannot map directly but handles arbitrary bundle ids that conform to the existing convention
......
...@@ -10,9 +10,9 @@ import Apollo ...@@ -10,9 +10,9 @@ import Apollo
import OSLog import OSLog
public class DataQueries { public class DataQueries {
static let cachePolicy: CachePolicy = CachePolicy.fetchIgnoringCacheData static let cachePolicy: CachePolicy = CachePolicy.fetchIgnoringCacheData
public static func fetchTokensData(tokenInputs: [TokenInput]) async throws -> [TokenResponse] { public static func fetchTokensData(tokenInputs: [TokenInput]) async throws -> [TokenResponse] {
return try await withCheckedThrowingContinuation { continuation in return try await withCheckedThrowingContinuation { continuation in
let contractInputs = tokenInputs.map {MobileSchema.ContractInput(chain: GraphQLEnum(rawValue: $0.chain), address: $0.address == nil ? GraphQLNullable.null: GraphQLNullable(stringLiteral: $0.address!))} let contractInputs = tokenInputs.map {MobileSchema.ContractInput(chain: GraphQLEnum(rawValue: $0.chain), address: $0.address == nil ? GraphQLNullable.null: GraphQLNullable(stringLiteral: $0.address!))}
...@@ -34,7 +34,7 @@ public class DataQueries { ...@@ -34,7 +34,7 @@ public class DataQueries {
} }
} }
} }
public static func fetchTopTokensData() async throws -> [TokenResponse] { public static func fetchTopTokensData() async throws -> [TokenResponse] {
return try await withCheckedThrowingContinuation { continuation in return try await withCheckedThrowingContinuation { continuation in
Network.shared.apollo.fetch(query: MobileSchema.TopTokensQuery(chain: GraphQLNullable(MobileSchema.Chain.ethereum)), cachePolicy: cachePolicy) { result in Network.shared.apollo.fetch(query: MobileSchema.TopTokensQuery(chain: GraphQLNullable(MobileSchema.Chain.ethereum)), cachePolicy: cachePolicy) { result in
...@@ -55,7 +55,7 @@ public class DataQueries { ...@@ -55,7 +55,7 @@ public class DataQueries {
} }
} }
} }
public static func fetchTokenPriceData(chain: String, address: String?) async throws -> TokenPriceResponse { public static func fetchTokenPriceData(chain: String, address: String?) async throws -> TokenPriceResponse {
return try await withCheckedThrowingContinuation { continuation in return try await withCheckedThrowingContinuation { continuation in
Network.shared.apollo.fetch(query: MobileSchema.FavoriteTokenCardQuery(chain: GraphQLEnum(rawValue: chain), address: address == nil ? GraphQLNullable.null : GraphQLNullable(stringLiteral: address!)), cachePolicy: cachePolicy) { result in Network.shared.apollo.fetch(query: MobileSchema.FavoriteTokenCardQuery(chain: GraphQLEnum(rawValue: chain), address: address == nil ? GraphQLNullable.null : GraphQLNullable(stringLiteral: address!)), cachePolicy: cachePolicy) { result in
...@@ -76,7 +76,7 @@ public class DataQueries { ...@@ -76,7 +76,7 @@ public class DataQueries {
} }
} }
} }
public static func fetchTokenPriceHistoryData(chain: String, address: String?) async throws -> TokenPriceHistoryResponse { public static func fetchTokenPriceHistoryData(chain: String, address: String?) async throws -> TokenPriceHistoryResponse {
return try await withCheckedThrowingContinuation { continuation in return try await withCheckedThrowingContinuation { continuation in
Network.shared.apollo.fetch(query: MobileSchema.TokenPriceHistoryQuery(contract: MobileSchema.ContractInput(chain: GraphQLEnum(rawValue: chain), address: address == nil ? GraphQLNullable.null: GraphQLNullable(stringLiteral: address!))), cachePolicy: cachePolicy) { result in Network.shared.apollo.fetch(query: MobileSchema.TokenPriceHistoryQuery(contract: MobileSchema.ContractInput(chain: GraphQLEnum(rawValue: chain), address: address == nil ? GraphQLNullable.null: GraphQLNullable(stringLiteral: address!))), cachePolicy: cachePolicy) { result in
...@@ -96,10 +96,10 @@ public class DataQueries { ...@@ -96,10 +96,10 @@ public class DataQueries {
} }
} }
} }
public static func fetchWalletsTokensData(addresses: [String], maxLength: Int = 25) async throws -> [TokenResponse] { public static func fetchWalletsTokensData(addresses: [String], maxLength: Int = 25) async throws -> [TokenResponse] {
return try await withCheckedThrowingContinuation { continuation in return try await withCheckedThrowingContinuation { continuation in
Network.shared.apollo.fetch(query: MobileSchema.MultiplePortfolioBalancesQuery(ownerAddresses: addresses)){ result in Network.shared.apollo.fetch(query: MobileSchema.MultiplePortfolioBalancesQuery(ownerAddresses: addresses, valueModifiers: GraphQLNullable.null)){ result in
switch result { switch result {
case .success(let graphQLResult): case .success(let graphQLResult):
// Takes all the signer accounts and sums up the balances of the tokens, then sorts them by descending order, ignoring spam // Takes all the signer accounts and sums up the balances of the tokens, then sorts them by descending order, ignoring spam
...@@ -124,6 +124,40 @@ public class DataQueries { ...@@ -124,6 +124,40 @@ public class DataQueries {
} }
} }
} }
public static func fetchCurrencyConversion(toCurrency: String) async throws -> CurrencyConversionResponse {
return try await withCheckedThrowingContinuation { continuation in
let usdResponse = CurrencyConversionResponse(conversionRate: 1, currency: WidgetConstants.currencyUsd)
// Assuming all server currency amounts are in USD
if (toCurrency == WidgetConstants.currencyUsd) {
return continuation.resume(returning: usdResponse)
}
Network.shared.apollo.fetch(
query: MobileSchema.ConvertQuery(
fromCurrency: GraphQLEnum(MobileSchema.Currency.usd),
toCurrency: GraphQLEnum(rawValue: toCurrency)
)
) { result in
switch result {
case .success(let graphQLResult):
let conversionRate = graphQLResult.data?.convert?.value
let currency = graphQLResult.data?.convert?.currency?.rawValue
continuation.resume(
returning: conversionRate == nil || currency == nil ? usdResponse :
CurrencyConversionResponse(
conversionRate: conversionRate!,
currency: currency!
)
)
case .failure:
continuation.resume(returning: usdResponse)
}
}
}
}
} }
...@@ -6,80 +6,59 @@ ...@@ -6,80 +6,59 @@
// //
import Foundation import Foundation
// Based on https://www.notion.so/uniswaplabs/Number-standards-fbb9f533f10e4e22820722c2f66d23c0
// React native code: https://github.com/Uniswap/universe/blob/main/packages/wallet/src/utils/format.ts
extension NumberFormatter { extension NumberFormatter {
// Based on https://www.notion.so/uniswaplabs/Number-standards-fbb9f533f10e4e22820722c2f66d23c0 static func formatShorthandWithDecimals(number: Double, fractionDigits: Int, locale: Locale, currencyCode: String, placeholder: String) -> String {
// React native code: https://github.com/Uniswap/universe/blob/main/packages/wallet/src/utils/format.ts if (number < 1000000) {
public static func SHORTHAND_USD_TWO_DECIMALS(price: Double) -> String { return formatWithDecimals(number: number, fractionDigits: fractionDigits, locale: locale, currencyCode: currencyCode)
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.maximumFractionDigits = 2
formatter.minimumFractionDigits = 2
formatter.currencyCode = "USD"
if (price < 1000000){
return TWO_DECIMALS_USD.string(for: price)!
}
else if (price < 1000000000){
return "\(formatter.string(for: price/1000000)!)M"
}
else if (price < 1000000000000){
return "\(formatter.string(for: price/1000000000)!)B"
} }
else if (price < 1000000000000000){ let maxNumber = 1000000000000000.0
return "\(formatter.string(for: price/1000000000000)!)T" let maxed = number >= maxNumber
} let limitedNumber = maxed ? maxNumber : number
else {
return "$>999T" // Replace when Swift supports notation configuration for currency
// https://developer.apple.com/documentation/foundation/currencyformatstyleconfiguration
let compactFormatted = limitedNumber.formatted(.number.locale(locale).precision(.fractionLength(fractionDigits)).notation(.compactName))
let currencyFormatted = limitedNumber.formatted(.currency(code: currencyCode).locale(locale).precision(.fractionLength(fractionDigits)).grouping(.never))
guard let numberRegex = try? NSRegularExpression(pattern: "(\\d+(\\\(locale.decimalSeparator!)\\d+)?)") else {
return placeholder
} }
let output = numberRegex.stringByReplacingMatches(in: currencyFormatted, range: NSMakeRange(0, currencyFormatted.count), withTemplate: compactFormatted)
return maxed ? ">\(output)" : "\(output)"
} }
public static var TWO_DECIMALS_USD: NumberFormatter = { static func formatWithDecimals(number: Double, fractionDigits: Int, locale: Locale, currencyCode: String) -> String {
let formatter = NumberFormatter() return number.formatted(.currency(code: currencyCode).locale(locale).precision(.fractionLength(fractionDigits)))
formatter.numberStyle = .currency }
formatter.maximumFractionDigits = 2
formatter.minimumFractionDigits = 2
formatter.currencyCode = "USD"
return formatter
}()
public static var THREE_SIG_FIGS_USD: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.maximumSignificantDigits = 3
formatter.minimumSignificantDigits = 3
formatter.currencyCode = "USD"
return formatter
}()
public static var THREE_DECIMALS_USD: NumberFormatter = { static func formatWithSigFigs(number: Double, sigFigsDigits: Int, locale: Locale, currencyCode: String) -> String {
let formatter = NumberFormatter() return number.formatted(.currency(code: currencyCode).locale(locale).precision(.significantDigits(sigFigsDigits)))
formatter.numberStyle = .currency }
formatter.maximumFractionDigits = 3
formatter.minimumFractionDigits = 3
formatter.currencyCode = "USD"
return formatter
}()
public static func fiatTokenDetailsFormatter(price: Double?) -> String { public static func fiatTokenDetailsFormatter(price: Double?, locale: Locale, currencyCode: String) -> String {
let placeholder = "--.--"
guard let price = price else { guard let price = price else {
return "--.--" return placeholder
} }
if (price < 0.00000001) { if (price < 0.00000001) {
return "<$0.00000001" let formattedPrice = formatWithDecimals(number: price, fractionDigits: 8, locale: locale, currencyCode: currencyCode)
} return "<\(formattedPrice)"
else if (price < 0.01) {
return THREE_SIG_FIGS_USD.string(for: price)!
} }
else if (price < 1.05) {
return THREE_DECIMALS_USD.string(for: price)! if (price < 0.01) {
} return formatWithSigFigs(number: price, sigFigsDigits: 3, locale: locale, currencyCode: currencyCode)
else if (price < 1e6) { } else if (price < 1.05) {
return TWO_DECIMALS_USD.string(for: price)! return formatWithDecimals(number: price, fractionDigits: 3, locale: locale, currencyCode: currencyCode)
} } else if (price < 1e6) {
else { return formatWithDecimals(number: price, fractionDigits: 2, locale: locale, currencyCode: currencyCode)
return SHORTHAND_USD_TWO_DECIMALS(price: price) } else {
return formatShorthandWithDecimals(number: price, fractionDigits: 2, locale: locale, currencyCode: currencyCode, placeholder: placeholder)
} }
} }
} }
...@@ -51,3 +51,8 @@ public struct PriceHistory { ...@@ -51,3 +51,8 @@ public struct PriceHistory {
public let timestamp: Int public let timestamp: Int
public let price: Double public let price: Double
} }
public struct CurrencyConversionResponse {
public let conversionRate: Double
public let currency: String
}
...@@ -34,6 +34,17 @@ public struct WidgetDataAccounts: Decodable { ...@@ -34,6 +34,17 @@ public struct WidgetDataAccounts: Decodable {
public var accounts: [Account] public var accounts: [Account]
} }
public struct WidgetDataI18n: Decodable {
public init() {
self.locale = "en"
self.currency = WidgetConstants.currencyUsd
}
public var locale: String
public var currency: String
}
public struct Account: Decodable { public struct Account: Decodable {
public var address: String public var address: String
public var name: String? public var name: String?
...@@ -80,14 +91,14 @@ public enum Change: String, Codable { ...@@ -80,14 +91,14 @@ public enum Change: String, Codable {
case removed = "removed" case removed = "removed"
} }
public struct UniswapUserDefaults { public struct UniswapUserDefaults {
private static var buildString = getBuildVariantString(bundleId: Bundle.main.bundleIdentifier!) private static var buildString = getBuildVariantString(bundleId: Bundle.main.bundleIdentifier!)
static let eventsKey = buildString + ".widgets.configuration.events" static let keyEvents = buildString + ".widgets.configuration.events"
static let cacheKey = buildString + ".widgets.configuration.cache" static let keyCache = buildString + ".widgets.configuration.cache"
static let favoritesKey = buildString + ".widgets.favorites" static let keyFavorites = buildString + ".widgets.favorites"
static let accountsKey = buildString + ".widgets.accounts" static let keyAccounts = buildString + ".widgets.accounts"
static let keyI18n = buildString + ".widgets.i18n"
static let userDefaults = UserDefaults.init(suiteName: APP_GROUP) static let userDefaults = UserDefaults.init(suiteName: APP_GROUP)
...@@ -104,7 +115,7 @@ public struct UniswapUserDefaults { ...@@ -104,7 +115,7 @@ public struct UniswapUserDefaults {
} }
public static func readAccounts() -> WidgetDataAccounts { public static func readAccounts() -> WidgetDataAccounts {
let data = readData(key: accountsKey) let data = readData(key: keyAccounts)
guard let data = data else { guard let data = data else {
return WidgetDataAccounts([]) return WidgetDataAccounts([])
} }
...@@ -117,7 +128,7 @@ public struct UniswapUserDefaults { ...@@ -117,7 +128,7 @@ public struct UniswapUserDefaults {
} }
public static func readFavorites() -> WidgetDataFavorites { public static func readFavorites() -> WidgetDataFavorites {
let data = readData(key: favoritesKey) let data = readData(key: keyFavorites)
guard let data = data else { guard let data = data else {
return WidgetDataFavorites([]) return WidgetDataFavorites([])
} }
...@@ -129,8 +140,20 @@ public struct UniswapUserDefaults { ...@@ -129,8 +140,20 @@ public struct UniswapUserDefaults {
return parsedData return parsedData
} }
public static func readI18n() -> WidgetDataI18n {
let data = readData(key: keyI18n)
guard let data = data else {
return WidgetDataI18n()
}
let decoder = JSONDecoder()
guard let parsedData = try? decoder.decode(WidgetDataI18n.self, from: data) else {
return WidgetDataI18n()
}
return parsedData
}
public static func readConfiguration() -> WidgetDataConfiguration { public static func readConfiguration() -> WidgetDataConfiguration {
let data = readData(key: cacheKey) let data = readData(key: keyCache)
guard let data = data else { guard let data = data else {
return WidgetDataConfiguration([]) return WidgetDataConfiguration([])
} }
...@@ -147,12 +170,12 @@ public struct UniswapUserDefaults { ...@@ -147,12 +170,12 @@ public struct UniswapUserDefaults {
let encoder = JSONEncoder() let encoder = JSONEncoder()
let JSONdata = try! encoder.encode(data) let JSONdata = try! encoder.encode(data)
let json = String(data: JSONdata, encoding: String.Encoding.utf8) let json = String(data: JSONdata, encoding: String.Encoding.utf8)
userDefaults!.set(json, forKey: cacheKey) userDefaults!.set(json, forKey: keyCache)
} }
} }
public static func readEventChanges() -> WidgetEvents { public static func readEventChanges() -> WidgetEvents {
let data = readData(key: eventsKey) let data = readData(key: keyEvents)
guard let data = data else { guard let data = data else {
return WidgetEvents(events: []) return WidgetEvents(events: [])
} }
...@@ -169,7 +192,7 @@ public struct UniswapUserDefaults { ...@@ -169,7 +192,7 @@ public struct UniswapUserDefaults {
let encoder = JSONEncoder() let encoder = JSONEncoder()
let JSONdata = try! encoder.encode(data) let JSONdata = try! encoder.encode(data)
let json = String(data: JSONdata, encoding: String.Encoding.utf8) let json = String(data: JSONdata, encoding: String.Encoding.utf8)
userDefaults!.set(json, forKey: eventsKey) userDefaults!.set(json, forKey: keyEvents)
} }
} }
} }
...@@ -10,15 +10,97 @@ import WidgetsCore ...@@ -10,15 +10,97 @@ import WidgetsCore
final class FormatTests: XCTestCase { final class FormatTests: XCTestCase {
func testFiatTokenDetailsFormatter() throws { let localeEnglish = Locale(identifier: "en")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.05), "$0.050") let localeFrench = Locale(identifier: "fr-FR")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.056666666), "$0.057") let localeChinese = Locale(identifier: "zh-Hans")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 1234567.891), "$1.23M")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 1234.5678), "$1,234.57") let currencyCodeUsd = WidgetConstants.currencyUsd
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 1.048952), "$1.049") let currencyCodeEuro = "EUR"
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.001231), "$0.00123") let currencyCodeYuan = "CNY"
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.00001231), "$0.0000123")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.0000001234), "$0.000000123") struct TestCase {
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.000000009876), "<$0.00000001") public init(_ price: Double, _ output: String) {
self.price = price
self.output = output
}
public let price: Double
public let output: String
}
func testFormatterHandlesEnglish() throws {
let testCases = [
TestCase(0.05, "$0.050"),
TestCase(0.056666666, "$0.057"),
TestCase(1234567.891, "$1.23M"),
TestCase(1234.5678, "$1,234.57"),
TestCase(1.048952, "$1.049"),
TestCase(0.001231, "$0.00123"),
TestCase(0.00001231, "$0.0000123"),
TestCase(0.0000001234, "$0.000000123"),
TestCase(0.000000009876, "<$0.00000001"),
]
testCases.forEach { testCase in
XCTAssertEqual(
NumberFormatter.fiatTokenDetailsFormatter(
price: testCase.price,
locale: localeEnglish,
currencyCode: currencyCodeUsd
),
testCase.output
)
}
}
func testFormatterHandlesFrench() throws {
let testCases = [
TestCase(0.05, "0,050 €"),
TestCase(0.056666666, "0,057 €"),
TestCase(1234567.891, "1,23 M €"),
TestCase(123456.7890, "123 456,79 €"),
TestCase(1.048952, "1,049 €"),
TestCase(0.001231, "0,00123 €"),
TestCase(0.00001231, "0,0000123 €"),
TestCase(0.0000001234, "0,000000123 €"),
TestCase(0.000000009876, "<0,00000001 €"),
]
testCases.forEach { testCase in
XCTAssertEqual(
NumberFormatter.fiatTokenDetailsFormatter(
price: testCase.price,
locale: localeFrench,
currencyCode: currencyCodeEuro
),
testCase.output
)
}
}
func testFormatterHandlesChinese() throws {
let testCases = [
TestCase(0.05, "¥0.050"),
TestCase(0.056666666, "¥0.057"),
TestCase(1234567.891, "¥123.46万"),
TestCase(1234.5678, "¥1,234.57"),
TestCase(1.048952, "¥1.049"),
TestCase(0.001231, "¥0.00123"),
TestCase(0.00001231, "¥0.0000123"),
TestCase(0.0000001234, "¥0.000000123"),
TestCase(0.000000009876, "<¥0.00000001"),
]
testCases.forEach { testCase in
XCTAssertEqual(
NumberFormatter.fiatTokenDetailsFormatter(
price: testCase.price,
locale: localeChinese,
currencyCode: currencyCodeYuan
),
testCase.output
)
}
} }
} }
...@@ -3,9 +3,10 @@ ...@@ -3,9 +3,10 @@
import mockRNCNetInfo from '@react-native-community/netinfo/jest/netinfo-mock.js' import mockRNCNetInfo from '@react-native-community/netinfo/jest/netinfo-mock.js'
import 'core-js' // necessary so setImmediate works in tests import 'core-js' // necessary so setImmediate works in tests
import { localizeMock as mockRNLocalize } from 'react-native-localize/mock'
import { MockLocalizationContext } from 'wallet/src/test/utils' import { MockLocalizationContext } from 'wallet/src/test/utils'
// avoids polutting console in test runs, while keeping important log levels // avoids polluting console in test runs, while keeping important log levels
global.console = { global.console = {
...console, ...console,
// uncomment to ignore a specific log level // uncomment to ignore a specific log level
...@@ -84,3 +85,11 @@ global.__reanimatedWorkletInit = () => ({}) ...@@ -84,3 +85,11 @@ global.__reanimatedWorkletInit = () => ({})
jest.mock('react-native-reanimated', () => require('react-native-reanimated/mock')) jest.mock('react-native-reanimated', () => require('react-native-reanimated/mock'))
jest.mock('wallet/src/features/language/LocalizationContext', () => MockLocalizationContext) jest.mock('wallet/src/features/language/LocalizationContext', () => MockLocalizationContext)
jest.mock('react-native/Libraries/Share/Share', () => {
return {
share: jest.fn(),
}
})
jest.mock('react-native-localize', () => mockRNLocalize)
...@@ -109,7 +109,7 @@ ...@@ -109,7 +109,7 @@
"ethers": "5.7.2", "ethers": "5.7.2",
"expo": "48.0.19", "expo": "48.0.19",
"expo-av": "13.4.1", "expo-av": "13.4.1",
"expo-barcode-scanner": "12.3.2", "expo-barcode-scanner": "12.7.0",
"expo-blur": "12.2.2", "expo-blur": "12.2.2",
"expo-clipboard": "4.1.2", "expo-clipboard": "4.1.2",
"expo-haptics": "12.0.1", "expo-haptics": "12.0.1",
......
...@@ -33,6 +33,7 @@ import { ...@@ -33,6 +33,7 @@ import {
processWidgetEvents, processWidgetEvents,
setAccountAddressesUserDefaults, setAccountAddressesUserDefaults,
setFavoritesUserDefaults, setFavoritesUserDefaults,
setI18NUserDefaults,
} from 'src/features/widgets/widgets' } from 'src/features/widgets/widgets'
import { useAppStateTrigger } from 'src/utils/useAppStateTrigger' import { useAppStateTrigger } from 'src/utils/useAppStateTrigger'
import { getSentryEnvironment, getStatsigEnvironmentTier } from 'src/utils/version' import { getSentryEnvironment, getStatsigEnvironmentTier } from 'src/utils/version'
...@@ -46,6 +47,8 @@ import { uniswapUrls } from 'wallet/src/constants/urls' ...@@ -46,6 +47,8 @@ import { uniswapUrls } from 'wallet/src/constants/urls'
import { useCurrentAppearanceSetting, useIsDarkMode } from 'wallet/src/features/appearance/hooks' import { useCurrentAppearanceSetting, useIsDarkMode } from 'wallet/src/features/appearance/hooks'
import { EXPERIMENT_NAMES, FEATURE_FLAGS } from 'wallet/src/features/experiments/constants' import { EXPERIMENT_NAMES, FEATURE_FLAGS } from 'wallet/src/features/experiments/constants'
import { selectFavoriteTokens } from 'wallet/src/features/favorites/selectors' import { selectFavoriteTokens } from 'wallet/src/features/favorites/selectors'
import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks'
import { useCurrentLanguageInfo } from 'wallet/src/features/language/hooks'
import { LocalizationContextProvider } from 'wallet/src/features/language/LocalizationContext' import { LocalizationContextProvider } from 'wallet/src/features/language/LocalizationContext'
import { updateLanguage } from 'wallet/src/features/language/slice' import { updateLanguage } from 'wallet/src/features/language/slice'
import { Account } from 'wallet/src/features/wallet/accounts/types' import { Account } from 'wallet/src/features/wallet/accounts/types'
...@@ -144,14 +147,14 @@ function SentryTags({ children }: PropsWithChildren): JSX.Element { ...@@ -144,14 +147,14 @@ function SentryTags({ children }: PropsWithChildren): JSX.Element {
useEffect(() => { useEffect(() => {
Object.entries(FEATURE_FLAGS).map(([_, featureFlagName]) => { Object.entries(FEATURE_FLAGS).map(([_, featureFlagName]) => {
Sentry.setTag( Sentry.setTag(
`featureFlag:${featureFlagName}`, `featureFlag.${featureFlagName}`,
Statsig.checkGateWithExposureLoggingDisabled(featureFlagName) Statsig.checkGateWithExposureLoggingDisabled(featureFlagName)
) )
}) })
Object.entries(EXPERIMENT_NAMES).map(([_, experimentName]) => { Object.entries(EXPERIMENT_NAMES).map(([_, experimentName]) => {
Sentry.setTag( Sentry.setTag(
`experiment:${experimentName}`, `experiment.${experimentName}`,
Statsig.getExperimentWithExposureLoggingDisabled(experimentName).getGroupName() Statsig.getExperimentWithExposureLoggingDisabled(experimentName).getGroupName()
) )
}) })
...@@ -176,8 +179,8 @@ function AppOuter(): JSX.Element | null { ...@@ -176,8 +179,8 @@ function AppOuter(): JSX.Element | null {
<ApolloProvider client={client}> <ApolloProvider client={client}>
<PersistGate loading={null} persistor={persistor}> <PersistGate loading={null} persistor={persistor}>
<ErrorBoundary> <ErrorBoundary>
<GestureHandlerRootView style={flexStyles.fill}> <LocalizationContextProvider>
<LocalizationContextProvider> <GestureHandlerRootView style={flexStyles.fill}>
<WalletContextProvider> <WalletContextProvider>
<BiometricContextProvider> <BiometricContextProvider>
<LockScreenContextProvider> <LockScreenContextProvider>
...@@ -199,8 +202,8 @@ function AppOuter(): JSX.Element | null { ...@@ -199,8 +202,8 @@ function AppOuter(): JSX.Element | null {
</LockScreenContextProvider> </LockScreenContextProvider>
</BiometricContextProvider> </BiometricContextProvider>
</WalletContextProvider> </WalletContextProvider>
</LocalizationContextProvider> </GestureHandlerRootView>
</GestureHandlerRootView> </LocalizationContextProvider>
</ErrorBoundary> </ErrorBoundary>
</PersistGate> </PersistGate>
</ApolloProvider> </ApolloProvider>
...@@ -238,6 +241,8 @@ function AppInner(): JSX.Element { ...@@ -238,6 +241,8 @@ function AppInner(): JSX.Element {
function DataUpdaters(): JSX.Element { function DataUpdaters(): JSX.Element {
const favoriteTokens: CurrencyId[] = useAppSelector(selectFavoriteTokens) const favoriteTokens: CurrencyId[] = useAppSelector(selectFavoriteTokens)
const accountsMap: Record<string, Account> = useAccounts() const accountsMap: Record<string, Account> = useAccounts()
const { locale } = useCurrentLanguageInfo()
const { code } = useAppFiatCurrencyInfo()
// Refreshes widgets when bringing app to foreground // Refreshes widgets when bringing app to foreground
useAppStateTrigger('background', 'active', processWidgetEvents) useAppStateTrigger('background', 'active', processWidgetEvents)
...@@ -250,6 +255,10 @@ function DataUpdaters(): JSX.Element { ...@@ -250,6 +255,10 @@ function DataUpdaters(): JSX.Element {
setAccountAddressesUserDefaults(Object.values(accountsMap)) setAccountAddressesUserDefaults(Object.values(accountsMap))
}, [accountsMap]) }, [accountsMap])
useEffect(() => {
setI18NUserDefaults({ locale, currency: code })
}, [code, locale])
return ( return (
<> <>
<TraceUserProperties /> <TraceUserProperties />
......
import { renderHook } from '@testing-library/react-hooks'
import { LayoutChangeEvent } from 'react-native'
import { act } from 'react-test-renderer'
import { useDynamicFontSizing, useShouldShowNativeKeyboard } from './hooks'
describe(useShouldShowNativeKeyboard, () => {
it('returns false if layout calculation is pending', () => {
const { result } = renderHook(() => useShouldShowNativeKeyboard())
expect(result.current.showNativeKeyboard).toBe(false)
})
it('returns isLayoutPending as true if layout calculation is pending', () => {
const { result } = renderHook(() => useShouldShowNativeKeyboard())
expect(result.current.isLayoutPending).toBe(true)
})
it("shouldn't show native keyboard if decimal pad is rendered below the input panel", async () => {
const { result } = renderHook(() => useShouldShowNativeKeyboard())
await act(async () => {
result.current.onInputPanelLayout({
nativeEvent: { layout: { height: 100 } },
} as LayoutChangeEvent)
result.current.onDecimalPadLayout({
nativeEvent: { layout: { y: 200 } },
} as LayoutChangeEvent)
})
expect(result.current.showNativeKeyboard).toBe(false)
expect(result.current.maxContentHeight).toBeDefined()
expect(result.current.isLayoutPending).toBe(false)
})
it('should show native keyboard if decimal pad is rendered above the input panel', async () => {
const { result } = renderHook(() => useShouldShowNativeKeyboard())
await act(async () => {
result.current.onInputPanelLayout({
nativeEvent: { layout: { height: 100 } },
} as LayoutChangeEvent)
result.current.onDecimalPadLayout({
nativeEvent: { layout: { y: 50 } },
} as LayoutChangeEvent)
})
expect(result.current.showNativeKeyboard).toBe(true)
expect(result.current.maxContentHeight).not.toBeDefined()
expect(result.current.isLayoutPending).toBe(false)
})
})
const MAX_INPUT_FONT_SIZE = 42
const MIN_INPUT_FONT_SIZE = 28
const MAX_CHAR_PIXEL_WIDTH = 23
describe(useDynamicFontSizing, () => {
it('returns maxFontSize if text input element width is not set', () => {
const { result } = renderHook(() =>
useDynamicFontSizing(MAX_CHAR_PIXEL_WIDTH, MAX_INPUT_FONT_SIZE, MIN_INPUT_FONT_SIZE)
)
expect(result.current.fontSize).toBe(MAX_INPUT_FONT_SIZE)
})
it('returns maxFontSize as fontSize if text fits in the container', async () => {
const { result } = renderHook(() =>
useDynamicFontSizing(MAX_CHAR_PIXEL_WIDTH, MAX_INPUT_FONT_SIZE, MIN_INPUT_FONT_SIZE)
)
await act(() => {
result.current.onLayout({ nativeEvent: { layout: { width: 100 } } } as LayoutChangeEvent)
result.current.onSetFontSize('aaaa')
})
// 100 / 23 = 4.34 - 4 letters should fit in the container
expect(result.current.fontSize).toBe(MAX_INPUT_FONT_SIZE)
})
it('scales down font when text does not fit in the container', async () => {
const { result } = renderHook(() =>
useDynamicFontSizing(MAX_CHAR_PIXEL_WIDTH, MAX_INPUT_FONT_SIZE, MIN_INPUT_FONT_SIZE)
)
await act(() => {
result.current.onLayout({ nativeEvent: { layout: { width: 100 } } } as LayoutChangeEvent)
result.current.onSetFontSize('aaaaa')
})
// 100 / 23 = 4.34 - 5 letters should not fit in the container
expect(result.current.fontSize).toBeLessThan(MAX_INPUT_FONT_SIZE)
})
it("doesn't return font size less than minFontSize", async () => {
const { result } = renderHook(() =>
useDynamicFontSizing(MAX_CHAR_PIXEL_WIDTH, MAX_INPUT_FONT_SIZE, MIN_INPUT_FONT_SIZE)
)
await act(() => {
result.current.onLayout({ nativeEvent: { layout: { width: 100 } } } as LayoutChangeEvent)
result.current.onSetFontSize('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')
})
expect(result.current.fontSize).toBe(MIN_INPUT_FONT_SIZE)
})
})
import { ThunkDispatch } from '@reduxjs/toolkit' import { ThunkDispatch } from '@reduxjs/toolkit'
import { useCallback, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import { LayoutChangeEvent } from 'react-native' import { LayoutChangeEvent } from 'react-native'
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import type { AppDispatch } from 'src/app/store' import type { AppDispatch } from 'src/app/store'
...@@ -70,27 +70,24 @@ export function useDynamicFontSizing( ...@@ -70,27 +70,24 @@ export function useDynamicFontSizing(
onSetFontSize: (amount: string) => void onSetFontSize: (amount: string) => void
} { } {
const [fontSize, setFontSize] = useState(maxFontSize) const [fontSize, setFontSize] = useState(maxFontSize)
const [textInputElementWidth, setTextInputElementWidth] = useState<number>(0) const textInputElementWidthRef = useRef(0)
const onLayout = useCallback( const onLayout = useCallback((event: LayoutChangeEvent) => {
(event: LayoutChangeEvent) => { if (textInputElementWidthRef.current) return
if (textInputElementWidth) return
const width = event.nativeEvent.layout.width const width = event.nativeEvent.layout.width
setTextInputElementWidth(width) textInputElementWidthRef.current = width
}, }, [])
[setTextInputElementWidth, textInputElementWidth]
)
const onSetFontSize = useCallback( const onSetFontSize = useCallback(
(amount: string) => { (amount: string) => {
const stringWidth = getStringWidth(amount, maxCharWidthAtMaxFontSize, fontSize, maxFontSize) const stringWidth = getStringWidth(amount, maxCharWidthAtMaxFontSize, fontSize, maxFontSize)
const scaledSize = fontSize * (textInputElementWidth / stringWidth) const scaledSize = fontSize * (textInputElementWidthRef.current / stringWidth)
const scaledSizeWithMin = Math.max(scaledSize, minFontSize) const scaledSizeWithMin = Math.max(scaledSize, minFontSize)
const newFontSize = Math.round(Math.min(maxFontSize, scaledSizeWithMin)) const newFontSize = Math.round(Math.min(maxFontSize, scaledSizeWithMin))
setFontSize(newFontSize) setFontSize(newFontSize)
}, },
[fontSize, maxFontSize, minFontSize, maxCharWidthAtMaxFontSize, textInputElementWidth] [fontSize, maxFontSize, minFontSize, maxCharWidthAtMaxFontSize]
) )
return { onLayout, fontSize, onSetFontSize } return { onLayout, fontSize, onSetFontSize }
......
...@@ -53,6 +53,7 @@ import { ...@@ -53,6 +53,7 @@ import {
v51Schema, v51Schema,
v52Schema, v52Schema,
v53Schema, v53Schema,
v54Schema,
v5Schema, v5Schema,
v6Schema, v6Schema,
v7Schema, v7Schema,
...@@ -61,6 +62,7 @@ import { ...@@ -61,6 +62,7 @@ import {
} from 'src/app/schema' } from 'src/app/schema'
import { persistConfig } from 'src/app/store' import { persistConfig } from 'src/app/store'
import { ScannerModalState } from 'src/components/QRCodeScanner/constants' import { ScannerModalState } from 'src/components/QRCodeScanner/constants'
import { initialBehaviorHistoryState } from 'src/features/behaviorHistory/slice'
import { initialBiometricsSettingsState } from 'src/features/biometrics/slice' import { initialBiometricsSettingsState } from 'src/features/biometrics/slice'
import { initialCloudBackupState } from 'src/features/CloudBackup/cloudBackupSlice' import { initialCloudBackupState } from 'src/features/CloudBackup/cloudBackupSlice'
import { initialPasswordLockoutState } from 'src/features/CloudBackup/passwordLockoutSlice' import { initialPasswordLockoutState } from 'src/features/CloudBackup/passwordLockoutSlice'
...@@ -152,6 +154,7 @@ describe('Redux state migrations', () => { ...@@ -152,6 +154,7 @@ describe('Redux state migrations', () => {
modals: initialModalState, modals: initialModalState,
notifications: initialNotificationsState, notifications: initialNotificationsState,
passwordLockout: initialPasswordLockoutState, passwordLockout: initialPasswordLockoutState,
behaviorHistory: initialBehaviorHistoryState,
providers: { isInitialized: false }, providers: { isInitialized: false },
saga: {}, saga: {},
searchHistory: initialSearchHistoryState, searchHistory: initialSearchHistoryState,
...@@ -1240,4 +1243,11 @@ describe('Redux state migrations', () => { ...@@ -1240,4 +1243,11 @@ describe('Redux state migrations', () => {
expect(v54.telemetry.walletIsFunded).toBe(false) expect(v54.telemetry.walletIsFunded).toBe(false)
}) })
it('migrates from v54 to 55', () => {
const v54Stub = { ...v54Schema }
const v55 = migrations[55](v54Stub)
expect(v55.behaviorHistory.hasViewedReviewScreen).toBe(false)
})
}) })
...@@ -717,4 +717,15 @@ export const migrations = { ...@@ -717,4 +717,15 @@ export const migrations = {
return newState return newState
}, },
55: function addBehaviorHistory(state: any) {
const newState = { ...state }
newState.behaviorHistory = {
hasViewedReviewScreen: false,
hasSubmittedHoldToSwap: false,
}
return newState
},
} }
...@@ -8,7 +8,6 @@ import { AccountList } from 'src/components/accounts/AccountList' ...@@ -8,7 +8,6 @@ import { AccountList } from 'src/components/accounts/AccountList'
import { AddressDisplay } from 'src/components/AddressDisplay' import { AddressDisplay } from 'src/components/AddressDisplay'
import { ActionSheetModal, MenuItemProp } from 'src/components/modals/ActionSheetModal' import { ActionSheetModal, MenuItemProp } from 'src/components/modals/ActionSheetModal'
import { BottomSheetModal } from 'src/components/modals/BottomSheetModal' import { BottomSheetModal } from 'src/components/modals/BottomSheetModal'
import { IS_ANDROID } from 'src/constants/globals'
import { isCloudStorageAvailable } from 'src/features/CloudBackup/RNCloudStorageBackupsManager' import { isCloudStorageAvailable } from 'src/features/CloudBackup/RNCloudStorageBackupsManager'
import { closeModal, openModal } from 'src/features/modals/modalSlice' import { closeModal, openModal } from 'src/features/modals/modalSlice'
import { selectModalState } from 'src/features/modals/selectModalState' import { selectModalState } from 'src/features/modals/selectModalState'
...@@ -36,6 +35,7 @@ import { ...@@ -36,6 +35,7 @@ import {
import { useActiveAccountAddress, useNativeAccountExists } from 'wallet/src/features/wallet/hooks' import { useActiveAccountAddress, useNativeAccountExists } from 'wallet/src/features/wallet/hooks'
import { selectAllAccountsSorted } from 'wallet/src/features/wallet/selectors' import { selectAllAccountsSorted } from 'wallet/src/features/wallet/selectors'
import { setAccountAsActive } from 'wallet/src/features/wallet/slice' import { setAccountAsActive } from 'wallet/src/features/wallet/slice'
import { isAndroid } from 'wallet/src/utils/platform'
export function AccountSwitcherModal(): JSX.Element { export function AccountSwitcherModal(): JSX.Element {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
...@@ -151,8 +151,8 @@ export function AccountSwitcher({ onClose }: { onClose: () => void }): JSX.Eleme ...@@ -151,8 +151,8 @@ export function AccountSwitcher({ onClose }: { onClose: () => void }): JSX.Eleme
if (!cloudStorageAvailable) { if (!cloudStorageAvailable) {
Alert.alert( Alert.alert(
IS_ANDROID ? t('Google Drive not available') : t('iCloud Drive not available'), isAndroid ? t('Google Drive not available') : t('iCloud Drive not available'),
IS_ANDROID isAndroid
? t( ? t(
'Please verify that you are logged in to a Google account with Google Drive enabled on this device and try again.' 'Please verify that you are logged in to a Google account with Google Drive enabled on this device and try again.'
) )
...@@ -216,7 +216,7 @@ export function AccountSwitcher({ onClose }: { onClose: () => void }): JSX.Eleme ...@@ -216,7 +216,7 @@ export function AccountSwitcher({ onClose }: { onClose: () => void }): JSX.Eleme
render: () => ( render: () => (
<Flex alignItems="center" borderTopColor="$surface3" borderTopWidth={1} p="$spacing16"> <Flex alignItems="center" borderTopColor="$surface3" borderTopWidth={1} p="$spacing16">
<Text variant="body1"> <Text variant="body1">
{IS_ANDROID ? t('Restore from Google Drive') : t('Restore from iCloud')} {isAndroid ? t('Restore from Google Drive') : t('Restore from iCloud')}
</Text> </Text>
</Flex> </Flex>
), ),
......
...@@ -15,6 +15,7 @@ import { FiatOnRampAggregatorModal } from 'src/features/fiatOnRamp/FiatOnRampAgg ...@@ -15,6 +15,7 @@ import { FiatOnRampAggregatorModal } from 'src/features/fiatOnRamp/FiatOnRampAgg
import { FiatOnRampModal } from 'src/features/fiatOnRamp/FiatOnRampModal' import { FiatOnRampModal } from 'src/features/fiatOnRamp/FiatOnRampModal'
import { ModalName } from 'src/features/telemetry/constants' import { ModalName } from 'src/features/telemetry/constants'
import { SettingsFiatCurrencyModal } from 'src/screens/SettingsFiatCurrencyModal' import { SettingsFiatCurrencyModal } from 'src/screens/SettingsFiatCurrencyModal'
import { SettingsLanguageModal } from 'src/screens/SettingsLanguageModal'
export function AppModals(): JSX.Element { export function AppModals(): JSX.Element {
return ( return (
...@@ -61,6 +62,10 @@ export function AppModals(): JSX.Element { ...@@ -61,6 +62,10 @@ export function AppModals(): JSX.Element {
<RestoreWalletModal /> <RestoreWalletModal />
</LazyModalRenderer> </LazyModalRenderer>
<LazyModalRenderer name={ModalName.LanguageSelector}>
<SettingsLanguageModal />
</LazyModalRenderer>
<LazyModalRenderer name={ModalName.FiatCurrencySelector}> <LazyModalRenderer name={ModalName.FiatCurrencySelector}>
<SettingsFiatCurrencyModal /> <SettingsFiatCurrencyModal />
</LazyModalRenderer> </LazyModalRenderer>
......
import { useApolloClient } from '@apollo/client'
import React, { useState } from 'react' import React, { useState } from 'react'
import { ScrollView } from 'react-native-gesture-handler' import { ScrollView } from 'react-native-gesture-handler'
import { Action } from 'redux' import { Action } from 'redux'
...@@ -10,17 +11,20 @@ import { ModalName } from 'src/features/telemetry/constants' ...@@ -10,17 +11,20 @@ import { ModalName } from 'src/features/telemetry/constants'
import { selectCustomEndpoint } from 'src/features/tweaks/selectors' import { selectCustomEndpoint } from 'src/features/tweaks/selectors'
import { setCustomEndpoint } from 'src/features/tweaks/slice' import { setCustomEndpoint } from 'src/features/tweaks/slice'
import { Statsig } from 'statsig-react' import { Statsig } from 'statsig-react'
import { useExperiment } from 'statsig-react-native' import { useExperimentWithExposureLoggingDisabled } from 'statsig-react-native'
import { Button, Flex, Text, useDeviceInsets } from 'ui/src' import { Accordion } from 'tamagui'
import { Button, Flex, Icons, Text, useDeviceInsets } from 'ui/src'
import { spacing } from 'ui/src/theme' import { spacing } from 'ui/src/theme'
import { EXPERIMENT_NAMES, FEATURE_FLAGS } from 'wallet/src/features/experiments/constants' import { EXPERIMENT_NAMES, FEATURE_FLAGS } from 'wallet/src/features/experiments/constants'
import { useFeatureFlag } from 'wallet/src/features/experiments/hooks' import { useFeatureFlagWithExposureLoggingDisabled } from 'wallet/src/features/experiments/hooks'
export function ExperimentsModal(): JSX.Element { export function ExperimentsModal(): JSX.Element {
const insets = useDeviceInsets() const insets = useDeviceInsets()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const customEndpoint = useAppSelector(selectCustomEndpoint) const customEndpoint = useAppSelector(selectCustomEndpoint)
const apollo = useApolloClient()
const [url, setUrl] = useState<string>(customEndpoint?.url || '') const [url, setUrl] = useState<string>(customEndpoint?.url || '')
const [key, setKey] = useState<string>(customEndpoint?.key || '') const [key, setKey] = useState<string>(customEndpoint?.key || '')
...@@ -48,51 +52,113 @@ export function ExperimentsModal(): JSX.Element { ...@@ -48,51 +52,113 @@ export function ExperimentsModal(): JSX.Element {
renderBehindBottomInset renderBehindBottomInset
name={ModalName.Experiments} name={ModalName.Experiments}
onClose={(): Action => dispatch(closeModal({ name: ModalName.Experiments }))}> onClose={(): Action => dispatch(closeModal({ name: ModalName.Experiments }))}>
<ScrollView contentContainerStyle={{ paddingBottom: insets.bottom + spacing.spacing12 }}> <ScrollView
<Flex gap="$spacing16" justifyContent="flex-start" pt="$spacing12" px="$spacing24"> contentContainerStyle={{
<Flex gap="$spacing8"> paddingBottom: insets.bottom,
<Flex gap="$spacing16" my="$spacing16"> paddingRight: spacing.spacing24,
<Text variant="subheading1">⚙️ Custom GraphQL Endpoint</Text> paddingLeft: spacing.spacing24,
}}>
<Accordion type="single">
<Accordion.Item value="graphql-endpoint">
<AccordionHeader title="⚙️ Custom GraphQL Endpoint" />
<Accordion.Content>
<Text variant="body2"> <Text variant="body2">
You will need to restart the application to pick up any changes in this section. You will need to restart the application to pick up any changes in this section.
Beware of client side caching! Beware of client side caching!
</Text> </Text>
<Flex row alignItems="center" gap="$spacing16"> <Flex row alignItems="center" gap="$spacing16">
<Text variant="body2">URL</Text> <Text variant="body2">URL</Text>
<TextInput flex={1} value={url} onChangeText={setUrl} /> <TextInput flex={1} value={url} onChangeText={setUrl} />
</Flex> </Flex>
<Flex row alignItems="center" gap="$spacing16"> <Flex row alignItems="center" gap="$spacing16">
<Text variant="body2">Key</Text> <Text variant="body2">Key</Text>
<TextInput flex={1} value={key} onChangeText={setKey} /> <TextInput flex={1} value={key} onChangeText={setKey} />
</Flex> </Flex>
<Button size="small" onPress={setEndpoint}>
Set <Flex grow row alignItems="center" gap="$spacing16">
</Button> <Button flex={1} size="small" onPress={setEndpoint}>
<Button size="small" onPress={clearEndpoint}> Set
Clear </Button>
<Button flex={1} size="small" onPress={clearEndpoint}>
Clear
</Button>
</Flex>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="apollo-cache">
<AccordionHeader title="🚀 Apollo Cache" />
<Accordion.Content>
<Button
flex={1}
size="small"
onPress={async (): Promise<unknown> => await apollo.resetStore()}>
Reset Cache
</Button> </Button>
</Flex> </Accordion.Content>
<Text variant="subheading1">⛳️ Feature Flags</Text> </Accordion.Item>
<Text variant="body2">
Overridden feature flags are reset when the app is restarted <Accordion.Item value="feature-flags">
</Text> <AccordionHeader title="⛳️ Feature Flags" />
</Flex>
{Object.values(FEATURE_FLAGS).map((featureFlag) => { <Accordion.Content>
return <FeatureFlagRow key={featureFlag} featureFlag={featureFlag} /> <Text variant="body2">
})} Overridden feature flags are reset when the app is restarted
<Text variant="subheading1">🔬 Experiments</Text> </Text>
<Text variant="body2">Overridden experiments are reset when the app is restarted</Text>
{Object.values(EXPERIMENT_NAMES).map((experiment) => { <Flex gap="$spacing12" mt="$spacing12">
return <ExperimentRow key={experiment} name={experiment} /> {Object.values(FEATURE_FLAGS).map((featureFlag) => {
})} return <FeatureFlagRow key={featureFlag} featureFlag={featureFlag} />
</Flex> })}
</Flex>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="experiments">
<AccordionHeader title="🔬 Experiments" />
<Accordion.Content>
<Text variant="body2">
Overridden experiments are reset when the app is restarted
</Text>
<Flex gap="$spacing12" mt="$spacing12">
{Object.values(EXPERIMENT_NAMES).map((experiment) => {
return <ExperimentRow key={experiment} name={experiment} />
})}
</Flex>
</Accordion.Content>
</Accordion.Item>
</Accordion>
</ScrollView> </ScrollView>
</BottomSheetModal> </BottomSheetModal>
) )
} }
function AccordionHeader({ title }: { title: React.ReactNode }): JSX.Element {
return (
<Accordion.Header mt="$spacing12">
<Accordion.Trigger>
{({ open }: { open: boolean }): JSX.Element => (
<>
<Flex row justifyContent="space-between" width="100%">
<Text variant="subheading1">{title}</Text>
<Icons.RotatableChevron direction={open ? 'up' : 'down'} />
</Flex>
</>
)}
</Accordion.Trigger>
</Accordion.Header>
)
}
function FeatureFlagRow({ featureFlag }: { featureFlag: FEATURE_FLAGS }): JSX.Element { function FeatureFlagRow({ featureFlag }: { featureFlag: FEATURE_FLAGS }): JSX.Element {
const status = useFeatureFlag(featureFlag) const status = useFeatureFlagWithExposureLoggingDisabled(featureFlag)
return ( return (
<Flex row alignItems="center" gap="$spacing16" justifyContent="space-between"> <Flex row alignItems="center" gap="$spacing16" justifyContent="space-between">
...@@ -108,10 +174,7 @@ function FeatureFlagRow({ featureFlag }: { featureFlag: FEATURE_FLAGS }): JSX.El ...@@ -108,10 +174,7 @@ function FeatureFlagRow({ featureFlag }: { featureFlag: FEATURE_FLAGS }): JSX.El
} }
function ExperimentRow({ name }: { name: string }): JSX.Element { function ExperimentRow({ name }: { name: string }): JSX.Element {
const experiment = useExperiment(name) const experiment = useExperimentWithExposureLoggingDisabled(name)
// console.log('garydebug experiment row ' + JSON.stringify(experiment.config))
// const layer = useLayer(name)
// console.log('garydebug experiment row ' + JSON.stringify(layer))
const params = Object.entries(experiment.config.value).map(([key, value]) => ( const params = Object.entries(experiment.config.value).map(([key, value]) => (
<Flex <Flex
......
...@@ -8,15 +8,14 @@ import { updateSwapStartTimestamp } from 'src/features/telemetry/timing/slice' ...@@ -8,15 +8,14 @@ import { updateSwapStartTimestamp } from 'src/features/telemetry/timing/slice'
import { SwapFlow } from 'src/features/transactions/swap/SwapFlow' import { SwapFlow } from 'src/features/transactions/swap/SwapFlow'
import { SwapFlow as SwapFlowRewrite } from 'src/features/transactions/swapRewrite/SwapFlow' import { SwapFlow as SwapFlowRewrite } from 'src/features/transactions/swapRewrite/SwapFlow'
import { useSporeColors } from 'ui/src' import { useSporeColors } from 'ui/src'
import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants' import { useSwapRewriteEnabled } from 'wallet/src/features/experiments/hooks'
import { useFeatureFlag } from 'wallet/src/features/experiments/hooks'
export function SwapModal(): JSX.Element { export function SwapModal(): JSX.Element {
const colors = useSporeColors() const colors = useSporeColors()
const appDispatch = useAppDispatch() const appDispatch = useAppDispatch()
const modalState = useAppSelector(selectModalState(ModalName.Swap)) const modalState = useAppSelector(selectModalState(ModalName.Swap))
const shouldShowSwapRewrite = useFeatureFlag(FEATURE_FLAGS.SwapRewrite) const shouldShowSwapRewrite = useSwapRewriteEnabled()
const onClose = useCallback((): void => { const onClose = useCallback((): void => {
appDispatch(closeModal({ name: ModalName.Swap })) appDispatch(closeModal({ name: ModalName.Swap }))
......
...@@ -14,7 +14,6 @@ import { ...@@ -14,7 +14,6 @@ import {
} from 'react-native-reanimated' } from 'react-native-reanimated'
import { useAppDispatch } from 'src/app/hooks' import { useAppDispatch } from 'src/app/hooks'
import { pulseAnimation } from 'src/components/buttons/utils' import { pulseAnimation } from 'src/components/buttons/utils'
import { IS_ANDROID, IS_IOS } from 'src/constants/globals'
import { openModal } from 'src/features/modals/modalSlice' import { openModal } from 'src/features/modals/modalSlice'
import { sendMobileAnalyticsEvent } from 'src/features/telemetry' import { sendMobileAnalyticsEvent } from 'src/features/telemetry'
import { ElementName, ModalName } from 'src/features/telemetry/constants' import { ElementName, ModalName } from 'src/features/telemetry/constants'
...@@ -36,6 +35,7 @@ import { useIsDarkMode } from 'wallet/src/features/appearance/hooks' ...@@ -36,6 +35,7 @@ import { useIsDarkMode } from 'wallet/src/features/appearance/hooks'
import { useHighestBalanceNativeCurrencyId } from 'wallet/src/features/dataApi/balances' import { useHighestBalanceNativeCurrencyId } from 'wallet/src/features/dataApi/balances'
import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks'
import { opacify } from 'wallet/src/utils/colors' import { opacify } from 'wallet/src/utils/colors'
import { isAndroid, isIOS } from 'wallet/src/utils/platform'
export const NAV_BAR_HEIGHT_XS = 52 export const NAV_BAR_HEIGHT_XS = 52
export const NAV_BAR_HEIGHT_SM = 72 export const NAV_BAR_HEIGHT_SM = 72
...@@ -86,7 +86,7 @@ export function NavBar(): JSX.Element { ...@@ -86,7 +86,7 @@ export function NavBar(): JSX.Element {
alignItems="center" alignItems="center"
gap="$spacing12" gap="$spacing12"
justifyContent="space-between" justifyContent="space-between"
mb={IS_ANDROID ? '$spacing8' : '$none'} mb={isAndroid ? '$spacing8' : '$none'}
mx="$spacing24" mx="$spacing24"
pointerEvents="auto"> pointerEvents="auto">
<ExploreTabBarButton /> <ExploreTabBarButton />
...@@ -212,7 +212,7 @@ function ExploreTabBarButton({ activeScale = 0.98 }: ExploreTabBarButtonProps): ...@@ -212,7 +212,7 @@ function ExploreTabBarButton({ activeScale = 0.98 }: ExploreTabBarButtonProps):
}, },
}) })
const contentProps: FlexProps = IS_IOS const contentProps: FlexProps = isIOS
? { ? {
bg: '$surface2', bg: '$surface2',
opacity: isDarkMode ? 0.6 : 0.8, opacity: isDarkMode ? 0.6 : 0.8,
...@@ -233,7 +233,7 @@ function ExploreTabBarButton({ activeScale = 0.98 }: ExploreTabBarButtonProps): ...@@ -233,7 +233,7 @@ function ExploreTabBarButton({ activeScale = 0.98 }: ExploreTabBarButtonProps):
onPress={onPress}> onPress={onPress}>
<TapGestureHandler onGestureEvent={onGestureEvent}> <TapGestureHandler onGestureEvent={onGestureEvent}>
<AnimatedFlex borderRadius="$roundedFull" overflow="hidden" style={animatedStyle}> <AnimatedFlex borderRadius="$roundedFull" overflow="hidden" style={animatedStyle}>
<BlurView intensity={IS_IOS ? 100 : 0}> <BlurView intensity={isIOS ? 100 : 0}>
<Flex <Flex
{...contentProps} {...contentProps}
fill fill
......
import { combineReducers } from '@reduxjs/toolkit' import { combineReducers } from '@reduxjs/toolkit'
import { behaviorHistoryReducer } from 'src/features/behaviorHistory/slice'
import { biometricSettingsReducer } from 'src/features/biometrics/slice' import { biometricSettingsReducer } from 'src/features/biometrics/slice'
import { cloudBackupReducer } from 'src/features/CloudBackup/cloudBackupSlice' import { cloudBackupReducer } from 'src/features/CloudBackup/cloudBackupSlice'
import { passwordLockoutReducer } from 'src/features/CloudBackup/passwordLockoutSlice' import { passwordLockoutReducer } from 'src/features/CloudBackup/passwordLockoutSlice'
...@@ -14,6 +15,7 @@ import { monitoredSagaReducers } from './saga' ...@@ -14,6 +15,7 @@ import { monitoredSagaReducers } from './saga'
const reducers = { const reducers = {
...sharedReducers, ...sharedReducers,
behaviorHistory: behaviorHistoryReducer,
biometricSettings: biometricSettingsReducer, biometricSettings: biometricSettingsReducer,
cloudBackup: cloudBackupReducer, cloudBackup: cloudBackupReducer,
modals: modalsReducer, modals: modalsReducer,
......
...@@ -398,6 +398,14 @@ export const v54Schema = { ...@@ -398,6 +398,14 @@ export const v54Schema = {
}, },
} }
export const v55Schema = {
...v54Schema,
behaviorHistory: {
hasViewedReviewScreen: false,
hasSubmittedHoldToSwap: false,
},
}
// TODO: [MOB-201] use function with typed output when API reducers are removed from rootReducer // TODO: [MOB-201] use function with typed output when API reducers are removed from rootReducer
// export const getSchema = (): RootState => v0Schema // export const getSchema = (): RootState => v0Schema
export const getSchema = (): typeof v54Schema => v54Schema export const getSchema = (): typeof v54Schema => v54Schema
...@@ -55,6 +55,7 @@ const rtkQueryErrorLogger: Middleware = () => (next) => (action: PayloadAction<u ...@@ -55,6 +55,7 @@ const rtkQueryErrorLogger: Middleware = () => (next) => (action: PayloadAction<u
const whitelist: Array<ReducerNames | RootReducerNames> = [ const whitelist: Array<ReducerNames | RootReducerNames> = [
'appearanceSettings', 'appearanceSettings',
'behaviorHistory',
'biometricSettings', 'biometricSettings',
'favorites', 'favorites',
'notifications', 'notifications',
...@@ -74,7 +75,7 @@ export const persistConfig = { ...@@ -74,7 +75,7 @@ export const persistConfig = {
key: 'root', key: 'root',
storage: reduxStorage, storage: reduxStorage,
whitelist, whitelist,
version: 54, version: 55,
migrate: createMigrate(migrations), migrate: createMigrate(migrations),
} }
......
...@@ -17,10 +17,10 @@ import { TextLoaderWrapper } from 'ui/src/components/text/Text' ...@@ -17,10 +17,10 @@ import { TextLoaderWrapper } from 'ui/src/components/text/Text'
import { fonts } from 'ui/src/theme' import { fonts } from 'ui/src/theme'
import { usePrevious } from 'utilities/src/react/hooks' import { usePrevious } from 'utilities/src/react/hooks'
const NUMBER_ARRAY = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'] export const NUMBER_ARRAY = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
const NUMBER_WIDTH_ARRAY = [29, 20, 29, 29, 29, 29, 29, 29, 29, 29] // width of digits in a font export const NUMBER_WIDTH_ARRAY = [29, 20, 29, 29, 29, 29, 29, 29, 29, 29] // width of digits in a font
const DIGIT_HEIGHT = 44 export const DIGIT_HEIGHT = 44
const ADDITIONAL_WIDTH_FOR_ANIMATIONS = 10 export const ADDITIONAL_WIDTH_FOR_ANIMATIONS = 8
// TODO: remove need to manually define width of each character // TODO: remove need to manually define width of each character
const NUMBER_WIDTH_ARRAY_SCALED = NUMBER_WIDTH_ARRAY.map( const NUMBER_WIDTH_ARRAY_SCALED = NUMBER_WIDTH_ARRAY.map(
...@@ -171,6 +171,27 @@ function longestCommonPrefix(a: string, b: string): string { ...@@ -171,6 +171,27 @@ function longestCommonPrefix(a: string, b: string): string {
return a.substr(0, i) return a.substr(0, i)
} }
export const TopAndBottomGradient = (): JSX.Element => {
const colors = useSporeColors()
return (
<Svg height={DIGIT_HEIGHT} style={AnimatedNumberStyles.gradientStyle} width="100%">
<Defs>
<LinearGradient id="backgroundTop" x1="0%" x2="0%" y1="15%" y2="0%">
<Stop offset="0" stopColor={colors.surface1.val} stopOpacity="0" />
<Stop offset="1" stopColor={colors.surface1.val} stopOpacity="1" />
</LinearGradient>
<LinearGradient id="background" x1="0%" x2="0%" y1="85%" y2="100%">
<Stop offset="0" stopColor={colors.surface1.val} stopOpacity="0" />
<Stop offset="1" stopColor={colors.surface1.val} stopOpacity="1" />
</LinearGradient>
</Defs>
<Rect fill="url(#backgroundTop)" height={DIGIT_HEIGHT} opacity={1} width="100%" x="0" y="0" />
<Rect fill="url(#background)" height={DIGIT_HEIGHT} opacity={1} width="100%" x="0" y="0" />
</Svg>
)
}
const SCREEN_WIDTH_BUFFER = 50 const SCREEN_WIDTH_BUFFER = 50
// Used for initial layout larger than all screen sizes // Used for initial layout larger than all screen sizes
...@@ -274,34 +295,7 @@ const AnimatedNumber = ({ ...@@ -274,34 +295,7 @@ const AnimatedNumber = ({
backgroundColor="$surface1" backgroundColor="$surface1"
borderRadius="$rounded4" borderRadius="$rounded4"
width={MAX_DEVICE_WIDTH}> width={MAX_DEVICE_WIDTH}>
<Svg height={DIGIT_HEIGHT} style={AnimatedNumberStyles.gradientStyle} width="100%"> <TopAndBottomGradient />
<Defs>
<LinearGradient id="backgroundTop" x1="0%" x2="0%" y1="15%" y2="0%">
<Stop offset="0" stopColor={colors.surface1.val} stopOpacity="0" />
<Stop offset="1" stopColor={colors.surface1.val} stopOpacity="1" />
</LinearGradient>
<LinearGradient id="background" x1="0%" x2="0%" y1="85%" y2="100%">
<Stop offset="0" stopColor={colors.surface1.val} stopOpacity="0" />
<Stop offset="1" stopColor={colors.surface1.val} stopOpacity="1" />
</LinearGradient>
</Defs>
<Rect
fill="url(#backgroundTop)"
height={DIGIT_HEIGHT}
opacity={1}
width="100%"
x="0"
y="0"
/>
<Rect
fill="url(#background)"
height={DIGIT_HEIGHT}
opacity={1}
width="100%"
x="0"
y="0"
/>
</Svg>
<Shine disabled={!warmLoading}> <Shine disabled={!warmLoading}>
<AnimatedFlex row entering={FadeIn} width={MAX_DEVICE_WIDTH}> <AnimatedFlex row entering={FadeIn} width={MAX_DEVICE_WIDTH}>
{chars?.map((_, index) => ( {chars?.map((_, index) => (
...@@ -331,24 +325,24 @@ const AnimatedNumber = ({ ...@@ -331,24 +325,24 @@ const AnimatedNumber = ({
export default AnimatedNumber export default AnimatedNumber
const AnimatedNumberStyles = StyleSheet.create({ export const AnimatedNumberStyles = StyleSheet.create({
gradientStyle: { gradientStyle: {
position: 'absolute', position: 'absolute',
zIndex: 100, zIndex: 100,
}, },
}) })
const AnimatedCharStyles = StyleSheet.create({ export const AnimatedCharStyles = StyleSheet.create({
wrapperStyle: { wrapperStyle: {
overflow: 'hidden', overflow: 'hidden',
}, },
}) })
const AnimatedFontStyles = StyleSheet.create({ export const AnimatedFontStyles = StyleSheet.create({
fontStyle: { fontStyle: {
fontFamily: fonts.heading2.family, fontFamily: fonts.heading2.family,
fontSize: fonts.heading2.fontSize, fontSize: fonts.heading2.fontSize,
fontWeight: 500, fontWeight: '500',
lineHeight: fonts.heading2.lineHeight, lineHeight: fonts.heading2.lineHeight,
top: 1, top: 1,
}, },
......
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useAppDispatch } from 'src/app/hooks' import { useAppDispatch } from 'src/app/hooks'
import { CloudStorageMnemonicBackup } from 'src/features/CloudBackup/types'
import { pushNotification } from 'wallet/src/features/notifications/slice' import { pushNotification } from 'wallet/src/features/notifications/slice'
import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks'
...@@ -35,7 +36,7 @@ export const exampleSwapSuccess = { ...@@ -35,7 +36,7 @@ export const exampleSwapSuccess = {
} }
// easiest to use inside NotificationToastWrapper before any returns // easiest to use inside NotificationToastWrapper before any returns
export const useFakeNotification = (ms?: number): void => { export const useMockNotification = (ms?: number): void => {
const [sent, setSent] = useState(false) const [sent, setSent] = useState(false)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const activeAddress = useActiveAccountAddressWithThrow() const activeAddress = useActiveAccountAddressWithThrow()
...@@ -57,3 +58,30 @@ export const useFakeNotification = (ms?: number): void => { ...@@ -57,3 +58,30 @@ export const useFakeNotification = (ms?: number): void => {
} }
}, [activeAddress, dispatch, ms, sent]) }, [activeAddress, dispatch, ms, sent])
} }
const generateRandomId = (): string => {
let randomId = '0x'
for (let i = 0; i < 40; i++) {
randomId += Math.floor(Math.random() * 16).toString(16)
}
return randomId
}
const generateRandomDate = (): number => {
const start = new Date(2023, 4, 12)
const end = new Date()
return Math.floor(
new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())).getTime() / 1000
)
}
export const useMockCloudBackups = (numberOfBackups?: number): CloudStorageMnemonicBackup[] => {
const number = numberOfBackups ?? 1
const mockBackups = Array.from({ length: number }, () => ({
mnemonicId: generateRandomId(),
createdAt: generateRandomDate(),
}))
return mockBackups
}
import React, { useMemo } from 'react' import React, { memo, useMemo } from 'react'
import { useWindowDimensions } from 'react-native'
import { useAnimatedStyle, useDerivedValue } from 'react-native-reanimated' import { useAnimatedStyle, useDerivedValue } from 'react-native-reanimated'
import { AnimatedText } from 'src/components/text/AnimatedText' import { AnimatedText } from 'src/components/text/AnimatedText'
import { Flex, useSporeColors } from 'ui/src' import { Flex, useDeviceDimensions, useSporeColors } from 'ui/src'
import { TextVariantTokens } from 'ui/src/theme' import { fonts, TextVariantTokens } from 'ui/src/theme'
import { ValueAndFormatted } from './usePrice' import { ValueAndFormatted } from './usePrice'
type AnimatedDecimalNumberProps = { type AnimatedDecimalNumberProps = {
...@@ -13,12 +14,18 @@ type AnimatedDecimalNumberProps = { ...@@ -13,12 +14,18 @@ type AnimatedDecimalNumberProps = {
decimalPartColor?: string decimalPartColor?: string
decimalThreshold?: number // below this value (not including) decimal part would have wholePartColor too decimalThreshold?: number // below this value (not including) decimal part would have wholePartColor too
testID?: string testID?: string
maxWidth?: number
maxCharPixelWidth?: number
} }
// Utility component to display decimal numbers where the decimal part // Utility component to display decimal numbers where the decimal part
// is dimmed using AnimatedText // is dimmed using AnimatedText
export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.Element { export const AnimatedDecimalNumber = memo(function AnimatedDecimalNumber(
props: AnimatedDecimalNumberProps
): JSX.Element {
const colors = useSporeColors() const colors = useSporeColors()
const { fullWidth } = useDeviceDimensions()
const { fontScale } = useWindowDimensions()
const { const {
number, number,
...@@ -28,6 +35,8 @@ export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.El ...@@ -28,6 +35,8 @@ export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.El
decimalPartColor = colors.neutral3.val, decimalPartColor = colors.neutral3.val,
decimalThreshold = 1, decimalThreshold = 1,
testID, testID,
maxWidth = fullWidth,
maxCharPixelWidth: maxCharPixelWidthProp,
} = props } = props
const wholePart = useDerivedValue( const wholePart = useDerivedValue(
...@@ -51,12 +60,37 @@ export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.El ...@@ -51,12 +60,37 @@ export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.El
} }
}, [decimalThreshold, wholePartColor, decimalPartColor]) }, [decimalThreshold, wholePartColor, decimalPartColor])
const fontSize = fonts[variant].fontSize * fontScale
// Choose the arbitrary value that looks good for the font used
const maxCharPixelWidth = maxCharPixelWidthProp ?? (2 / 3) * fontSize
const adjustedFontSize = useDerivedValue(() => {
const value = number.formatted.value
const approxWidth = value.length * maxCharPixelWidth
if (approxWidth <= maxWidth) {
return fontSize
}
const scale = Math.min(1, maxWidth / approxWidth)
return fontSize * scale
})
const animatedStyle = useAnimatedStyle(() => ({
fontSize: adjustedFontSize.value,
}))
return ( return (
<Flex row testID={testID}> <Flex row testID={testID}>
<AnimatedText style={wholeStyle} testID="wholePart" text={wholePart} variant={variant} /> <AnimatedText
style={[wholeStyle, animatedStyle]}
testID="wholePart"
text={wholePart}
variant={variant}
/>
{decimalPart.value !== separator && ( {decimalPart.value !== separator && (
<AnimatedText <AnimatedText
style={decimalStyle} style={[decimalStyle, animatedStyle]}
testID="decimalPart" testID="decimalPart"
text={decimalPart} text={decimalPart}
variant={variant} variant={variant}
...@@ -64,4 +98,4 @@ export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.El ...@@ -64,4 +98,4 @@ export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.El
)} )}
</Flex> </Flex>
) )
} })
import { ImpactFeedbackStyle } from 'expo-haptics' import { ImpactFeedbackStyle } from 'expo-haptics'
import React, { useMemo } from 'react' import { memo, useEffect, useMemo, useState } from 'react'
import { I18nManager } from 'react-native' import { I18nManager } from 'react-native'
import { SharedValue } from 'react-native-reanimated' import { SharedValue } from 'react-native-reanimated'
import { import { LineChart, LineChartProvider, TLineChartDataProp } from 'react-native-wagmi-charts'
LineChart,
LineChartProvider,
TLineChartData,
TLineChartDataProp,
} from 'react-native-wagmi-charts'
import { Loader } from 'src/components/loading' import { Loader } from 'src/components/loading'
import { CURSOR_INNER_SIZE, CURSOR_SIZE } from 'src/components/PriceExplorer/constants' import { CURSOR_INNER_SIZE, CURSOR_SIZE } from 'src/components/PriceExplorer/constants'
import PriceExplorerAnimatedNumber from 'src/components/PriceExplorer/PriceExplorerAnimatedNumber'
import { PriceExplorerError } from 'src/components/PriceExplorer/PriceExplorerError' import { PriceExplorerError } from 'src/components/PriceExplorer/PriceExplorerError'
import { DatetimeText, PriceText, RelativeChangeText } from 'src/components/PriceExplorer/Text' import { DatetimeText, RelativeChangeText } from 'src/components/PriceExplorer/Text'
import { TimeRangeGroup } from 'src/components/PriceExplorer/TimeRangeGroup' import { TimeRangeGroup } from 'src/components/PriceExplorer/TimeRangeGroup'
import { useChartDimensions } from 'src/components/PriceExplorer/useChartDimensions' import { useChartDimensions } from 'src/components/PriceExplorer/useChartDimensions'
import { useLineChartPrice } from 'src/components/PriceExplorer/usePrice'
import { invokeImpact } from 'src/utils/haptic' import { invokeImpact } from 'src/utils/haptic'
import { Flex } from 'ui/src' import { Flex } from 'ui/src'
import { spacing } from 'ui/src/theme'
import { HistoryDuration } from 'wallet/src/data/__generated__/types-and-hooks' import { HistoryDuration } from 'wallet/src/data/__generated__/types-and-hooks'
import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks'
import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
import { CurrencyId } from 'wallet/src/utils/currencyId' import { CurrencyId } from 'wallet/src/utils/currencyId'
import { TokenSpotData, useTokenPriceHistory } from './usePriceHistory' import { PriceNumberOfDigits, TokenSpotData, useTokenPriceHistory } from './usePriceHistory'
type PriceTextProps = { type PriceTextProps = {
loading: boolean loading: boolean
relativeChange?: SharedValue<number> relativeChange?: SharedValue<number>
numberOfDigits: PriceNumberOfDigits
spotPrice?: number
} }
function PriceTextSection({ loading, relativeChange }: PriceTextProps): JSX.Element { function PriceTextSection({
loading,
relativeChange,
numberOfDigits,
spotPrice,
}: PriceTextProps): JSX.Element {
const price = useLineChartPrice(spotPrice)
const currency = useAppFiatCurrencyInfo()
const mx = spacing.spacing12
return ( return (
<Flex mx="$spacing12"> <Flex mx={mx}>
<PriceText loading={loading} /> {/* Specify maxWidth to allow text scalling. onLayout was sometimes called after more
than 5 seconds which is not acceptable so we have to provide the approximate width
of the PriceText component explicitly. */}
<PriceExplorerAnimatedNumber
currency={currency}
numberOfDigits={numberOfDigits}
price={price}
/>
<Flex row gap="$spacing4"> <Flex row gap="$spacing4">
<RelativeChangeText loading={loading} spotRelativeChange={relativeChange} /> <RelativeChangeText loading={loading} spotRelativeChange={relativeChange} />
<DatetimeText loading={loading} /> <DatetimeText loading={loading} />
...@@ -42,7 +59,7 @@ export type LineChartPriceAndDateTimeTextProps = { ...@@ -42,7 +59,7 @@ export type LineChartPriceAndDateTimeTextProps = {
currencyId: CurrencyId currencyId: CurrencyId
} }
export function PriceExplorer({ export const PriceExplorer = memo(function PriceExplorer({
currencyId, currencyId,
tokenColor, tokenColor,
forcePlaceholder, forcePlaceholder,
...@@ -53,22 +70,36 @@ export function PriceExplorer({ ...@@ -53,22 +70,36 @@ export function PriceExplorer({
forcePlaceholder?: boolean forcePlaceholder?: boolean
onRetry: () => void onRetry: () => void
}): JSX.Element { }): JSX.Element {
const { data, loading, error, refetch, setDuration, selectedDuration } = const [fetchComplete, setFetchComplete] = useState(false)
useTokenPriceHistory(currencyId) const onFetchComplete = (): void => {
setFetchComplete(true)
}
const { data, loading, error, refetch, setDuration, selectedDuration, numberOfDigits } =
useTokenPriceHistory(currencyId, onFetchComplete)
useEffect(() => {
if (loading && fetchComplete) {
setFetchComplete(false)
}
}, [loading, fetchComplete])
const { convertFiatAmount } = useLocalizationContext() const { convertFiatAmount } = useLocalizationContext()
const conversionRate = convertFiatAmount().amount const conversionRate = convertFiatAmount().amount
const shouldShowAnimatedDot = const shouldShowAnimatedDot =
selectedDuration === HistoryDuration.Day || selectedDuration === HistoryDuration.Hour selectedDuration === HistoryDuration.Day || selectedDuration === HistoryDuration.Hour
const additionalPadding = shouldShowAnimatedDot ? 40 : 0 const additionalPadding = shouldShowAnimatedDot ? 40 : 0
const lastPricePoint = data?.priceHistory ? data.priceHistory.length - 1 : 0
const { lastPricePoint, convertedPriceHistory } = useMemo(() => {
const convertedPriceHistory = useMemo( const priceHistory = data?.priceHistory?.map((point) => {
(): TLineChartData | undefined => return { ...point, value: point.value * conversionRate }
data?.priceHistory?.map((point) => { })
return { ...point, value: point.value * conversionRate }
}), const lastPoint = priceHistory ? priceHistory.length - 1 : 0
[data, conversionRate]
) return { lastPricePoint: lastPoint, convertedPriceHistory: priceHistory }
}, [data, conversionRate])
const convertedSpot = useMemo((): TokenSpotData | undefined => { const convertedSpot = useMemo((): TokenSpotData | undefined => {
return ( return (
data?.spot && { data?.spot && {
...@@ -92,21 +123,27 @@ export function PriceExplorer({ ...@@ -92,21 +123,27 @@ export function PriceExplorer({
let content: JSX.Element | null let content: JSX.Element | null
if (forcePlaceholder) { if (forcePlaceholder) {
content = <PriceExplorerPlaceholder loading={forcePlaceholder} /> content = (
<PriceExplorerPlaceholder loading={forcePlaceholder} numberOfDigits={numberOfDigits} />
)
} else if (convertedPriceHistory?.length) { } else if (convertedPriceHistory?.length) {
content = ( content = (
// TODO(MOB-2308): add better loading state
// <Flex opacity={fetchComplete ? 1 : 0.35}>
<PriceExplorerChart <PriceExplorerChart
additionalPadding={additionalPadding} additionalPadding={additionalPadding}
lastPricePoint={lastPricePoint} lastPricePoint={lastPricePoint}
loading={loading} loading={loading}
numberOfDigits={numberOfDigits}
priceHistory={convertedPriceHistory} priceHistory={convertedPriceHistory}
shouldShowAnimatedDot={shouldShowAnimatedDot} shouldShowAnimatedDot={shouldShowAnimatedDot}
spot={convertedSpot} spot={convertedSpot}
tokenColor={tokenColor} tokenColor={tokenColor}
/> />
// </Flex>
) )
} else { } else {
content = <PriceExplorerPlaceholder loading={loading} /> content = <PriceExplorerPlaceholder loading={loading} numberOfDigits={numberOfDigits} />
} }
return ( return (
...@@ -115,12 +152,18 @@ export function PriceExplorer({ ...@@ -115,12 +152,18 @@ export function PriceExplorer({
<TimeRangeGroup setDuration={setDuration} /> <TimeRangeGroup setDuration={setDuration} />
</Flex> </Flex>
) )
} })
function PriceExplorerPlaceholder({ loading }: { loading: boolean }): JSX.Element { function PriceExplorerPlaceholder({
loading,
numberOfDigits,
}: {
loading: boolean
numberOfDigits: PriceNumberOfDigits
}): JSX.Element {
return ( return (
<Flex gap="$spacing8"> <Flex gap="$spacing8">
<PriceTextSection loading={loading} /> <PriceTextSection loading={loading} numberOfDigits={numberOfDigits} />
<Flex my="$spacing24"> <Flex my="$spacing24">
<Loader.Graph /> <Loader.Graph />
</Flex> </Flex>
...@@ -136,6 +179,7 @@ function PriceExplorerChart({ ...@@ -136,6 +179,7 @@ function PriceExplorerChart({
additionalPadding, additionalPadding,
shouldShowAnimatedDot, shouldShowAnimatedDot,
lastPricePoint, lastPricePoint,
numberOfDigits,
}: { }: {
priceHistory: TLineChartDataProp priceHistory: TLineChartDataProp
spot?: TokenSpotData spot?: TokenSpotData
...@@ -144,6 +188,7 @@ function PriceExplorerChart({ ...@@ -144,6 +188,7 @@ function PriceExplorerChart({
additionalPadding: number additionalPadding: number
shouldShowAnimatedDot: boolean shouldShowAnimatedDot: boolean
lastPricePoint: number lastPricePoint: number
numberOfDigits: PriceNumberOfDigits
}): JSX.Element { }): JSX.Element {
const { chartHeight, chartWidth } = useChartDimensions() const { chartHeight, chartWidth } = useChartDimensions()
const isRTL = I18nManager.isRTL const isRTL = I18nManager.isRTL
...@@ -153,7 +198,12 @@ function PriceExplorerChart({ ...@@ -153,7 +198,12 @@ function PriceExplorerChart({
data={priceHistory} data={priceHistory}
onCurrentIndexChange={invokeImpact[ImpactFeedbackStyle.Light]}> onCurrentIndexChange={invokeImpact[ImpactFeedbackStyle.Light]}>
<Flex gap="$spacing8"> <Flex gap="$spacing8">
<PriceTextSection loading={loading} relativeChange={spot?.relativeChange} /> <PriceTextSection
loading={loading}
numberOfDigits={numberOfDigits}
relativeChange={spot?.relativeChange}
spotPrice={spot?.value?.value}
/>
{/* TODO(MOB-2166): remove forced LTR direction + scaleX horizontal flip technique once react-native-wagmi-charts fixes this: https://github.com/coinjar/react-native-wagmi-charts/issues/136 */} {/* TODO(MOB-2166): remove forced LTR direction + scaleX horizontal flip technique once react-native-wagmi-charts fixes this: https://github.com/coinjar/react-native-wagmi-charts/issues/136 */}
<Flex direction="ltr" my="$spacing24" style={{ transform: [{ scaleX: isRTL ? -1 : 1 }] }}> <Flex direction="ltr" my="$spacing24" style={{ transform: [{ scaleX: isRTL ? -1 : 1 }] }}>
<LineChart height={chartHeight} width={chartWidth - additionalPadding} yGutter={20}> <LineChart height={chartHeight} width={chartWidth - additionalPadding} yGutter={20}>
...@@ -162,7 +212,10 @@ function PriceExplorerChart({ ...@@ -162,7 +212,10 @@ function PriceExplorerChart({
<LineChart.Dot <LineChart.Dot
key={lastPricePoint} key={lastPricePoint}
hasPulse hasPulse
at={lastPricePoint} // Sometimes, the pulse dot doesn't appear on the end of
// the chart’s path, but on top of the container instead.
// A little shift backwards seems to solve this problem.
at={lastPricePoint - 0.1}
color={tokenColor} color={tokenColor}
inactiveColor="transparent" inactiveColor="transparent"
pulseBehaviour="while-inactive" pulseBehaviour="while-inactive"
...@@ -171,9 +224,10 @@ function PriceExplorerChart({ ...@@ -171,9 +224,10 @@ function PriceExplorerChart({
/> />
)} )}
</LineChart.Path> </LineChart.Path>
<LineChart.CursorLine color={tokenColor} /> <LineChart.CursorLine color={tokenColor} minDurationMs={150} />
<LineChart.CursorCrosshair <LineChart.CursorCrosshair
color={tokenColor} color={tokenColor}
minDurationMs={150}
outerSize={CURSOR_SIZE} outerSize={CURSOR_SIZE}
size={CURSOR_INNER_SIZE} size={CURSOR_INNER_SIZE}
onActivated={invokeImpact[ImpactFeedbackStyle.Light]} onActivated={invokeImpact[ImpactFeedbackStyle.Light]}
......
import _ from 'lodash'
import React, { useEffect, useState } from 'react'
import { StyleSheet, Text, View } from 'react-native'
import Animated, {
SharedValue,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated'
import {
ADDITIONAL_WIDTH_FOR_ANIMATIONS,
AnimatedCharStyles,
AnimatedFontStyles,
DIGIT_HEIGHT,
NUMBER_ARRAY,
NUMBER_WIDTH_ARRAY,
TopAndBottomGradient,
} from 'src/components/AnimatedNumber'
import { ValueAndFormatted } from 'src/components/PriceExplorer/usePrice'
import { PriceNumberOfDigits } from 'src/components/PriceExplorer/usePriceHistory'
import { useSporeColors } from 'ui/src'
import { TextLoaderWrapper } from 'ui/src/components/text/Text'
import { FiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks'
const NumbersMain = ({
color,
backgroundColor,
hidePlacehodler,
}: {
color: string
backgroundColor: string
hidePlacehodler(): void
}): JSX.Element | null => {
const [showNumers, setShowNumbers] = useState(false)
const hideNumbers = useSharedValue(true)
const animatedTextStyle = useAnimatedStyle(() => {
return {
opacity: hideNumbers.value ? 0 : 1,
}
})
useEffect(() => {
setTimeout(() => {
setShowNumbers(true)
}, 200)
}, [])
const onLayout = (): void => {
hidePlacehodler()
hideNumbers.value = false
}
if (showNumers) {
return (
<Animated.Text
allowFontScaling={false}
style={[
AnimatedFontStyles.fontStyle,
{
height: DIGIT_HEIGHT * 10,
color,
backgroundColor,
},
animatedTextStyle,
]}
onLayout={onLayout}>
{NUMBER_ARRAY}
</Animated.Text>
)
}
return null
}
const MemoizedNumbers = React.memo(NumbersMain)
const RollNumber = ({
chars,
index,
shouldAnimate,
decimalPlace,
hidePlacehodler,
commaIndex,
currency,
}: {
chars: SharedValue<string>
index: number
shouldAnimate: SharedValue<boolean>
decimalPlace: SharedValue<number>
hidePlacehodler(): void
commaIndex: number
currency: FiatCurrencyInfo
}): JSX.Element => {
const colors = useSporeColors()
const animatedDigit = useDerivedValue(() => {
const char = chars.value[index - (commaIndex - decimalPlace.value)]
const number = char ? parseFloat(char) : undefined
return Number.isNaN(number) ? undefined : number
}, [chars])
const animatedFontStyle = useAnimatedStyle(() => {
const color = index >= commaIndex ? colors.neutral3.val : colors.neutral1.val
return {
color,
}
})
const transformY = useDerivedValue(() => {
const endValue = animatedDigit.value !== undefined ? DIGIT_HEIGHT * -animatedDigit.value : 0
return shouldAnimate.value
? withSpring(endValue, {
mass: 1,
damping: 29,
stiffness: 164,
overshootClamping: false,
restDisplacementThreshold: 0.01,
restSpeedThreshold: 2,
})
: endValue
}, [shouldAnimate])
const animatedWrapperStyle = useAnimatedStyle(() => {
const digitWidth =
animatedDigit.value !== undefined ? NUMBER_WIDTH_ARRAY[animatedDigit.value] ?? 0 : 0
const rowWidth = digitWidth + ADDITIONAL_WIDTH_FOR_ANIMATIONS - 7
return {
transform: [
{
translateY: transformY.value,
},
],
width: shouldAnimate.value ? withTiming(rowWidth) : rowWidth,
}
})
if (index === commaIndex) {
return (
<Animated.Text
allowFontScaling={false}
style={[
animatedFontStyle,
AnimatedFontStyles.fontStyle,
{ height: DIGIT_HEIGHT, backgroundColor: colors.surface1.val },
]}>
{currency.decimalSeparator}
</Animated.Text>
)
}
if (
(index - commaIndex) % 4 === 0 &&
index - commaIndex < 0 &&
index > commaIndex - decimalPlace.value
) {
return (
<Animated.Text
allowFontScaling={false}
style={[
animatedFontStyle,
AnimatedFontStyles.fontStyle,
{ height: DIGIT_HEIGHT, backgroundColor: colors.surface1.val },
]}>
{currency.groupingSeparator}
</Animated.Text>
)
}
return (
<Animated.View
style={[
animatedWrapperStyle,
{
marginRight: -ADDITIONAL_WIDTH_FOR_ANIMATIONS,
},
]}>
<MemoizedNumbers
backgroundColor={colors.surface1.val}
color={index >= commaIndex ? colors.neutral3.val : colors.neutral1.val}
hidePlacehodler={hidePlacehodler}
/>
</Animated.View>
)
}
const Numbers = ({
price,
hidePlacehodler,
numberOfDigits,
currency,
}: {
price: ValueAndFormatted
hidePlacehodler(): void
numberOfDigits: PriceNumberOfDigits
currency: FiatCurrencyInfo
}): JSX.Element[] => {
const chars = useDerivedValue(() => {
return price.formatted.value
}, [price])
const decimalPlace = useDerivedValue(() => {
return price.formatted.value.indexOf(currency.decimalSeparator)
}, [price])
return _.times(
numberOfDigits.left + numberOfDigits.right + Math.floor(numberOfDigits.left / 3) + 1,
(index) => (
<Animated.View style={[{ height: DIGIT_HEIGHT }, AnimatedCharStyles.wrapperStyle]}>
<RollNumber
key={index === 0 ? `$sign` : `$_number_${numberOfDigits.left - 1 - index}`}
chars={chars}
commaIndex={numberOfDigits.left + Math.floor(numberOfDigits.left / 3)}
currency={currency}
decimalPlace={decimalPlace}
hidePlacehodler={hidePlacehodler}
index={index}
shouldAnimate={price.shouldAnimate}
/>
</Animated.View>
)
)
}
const LoadingWrapper = (): JSX.Element | null => {
return (
<TextLoaderWrapper loadingShimmer={false}>
<View style={Shimmer.shimmerSize} />
</TextLoaderWrapper>
)
}
const PriceExplorerAnimatedNumber = ({
price,
numberOfDigits,
currency,
}: {
price: ValueAndFormatted
numberOfDigits: PriceNumberOfDigits
currency: FiatCurrencyInfo
}): JSX.Element => {
const colors = useSporeColors()
const hideShimmer = useSharedValue(false)
const animatedWrapperStyle = useAnimatedStyle(() => {
return {
opacity: price.value.value > 0 && hideShimmer.value ? 0 : 1,
position: 'absolute',
zIndex: 1000,
backgroundColor: colors.surface1.val,
}
})
const hidePlacehodler = (): void => {
hideShimmer.value = true
}
const currencySymbol = (
<Text
allowFontScaling={false}
style={[AnimatedFontStyles.fontStyle, { height: DIGIT_HEIGHT, color: colors.neutral1.val }]}>
{currency.fullSymbol}
</Text>
)
return (
<>
<Animated.View style={animatedWrapperStyle}>
<LoadingWrapper />
</Animated.View>
<View style={RowWrapper.wrapperStyle}>
<TopAndBottomGradient />
{currency.symbolAtFront && currencySymbol}
{Numbers({ price, hidePlacehodler, numberOfDigits, currency })}
{!currency.symbolAtFront && currencySymbol}
</View>
</>
)
}
export default PriceExplorerAnimatedNumber
export const RowWrapper = StyleSheet.create({
wrapperStyle: {
flexDirection: 'row',
},
})
export const Shimmer = StyleSheet.create({
shimmerSize: {
height: DIGIT_HEIGHT,
width: 200,
},
})
...@@ -2,15 +2,21 @@ import React from 'react' ...@@ -2,15 +2,21 @@ import React from 'react'
import { SharedValue, useAnimatedStyle } from 'react-native-reanimated' import { SharedValue, useAnimatedStyle } from 'react-native-reanimated'
import { useLineChartDatetime } from 'react-native-wagmi-charts' import { useLineChartDatetime } from 'react-native-wagmi-charts'
import { AnimatedText } from 'src/components/text/AnimatedText' import { AnimatedText } from 'src/components/text/AnimatedText'
import { IS_ANDROID } from 'src/constants/globals'
import { Flex, Icons, useSporeColors } from 'ui/src' import { Flex, Icons, useSporeColors } from 'ui/src'
import { FiatCurrency } from 'wallet/src/features/fiatCurrency/constants' import { FiatCurrency } from 'wallet/src/features/fiatCurrency/constants'
import { useAppFiatCurrency, useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks' import { useAppFiatCurrency, useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks'
import { useCurrentLocale } from 'wallet/src/features/language/hooks' import { useCurrentLocale } from 'wallet/src/features/language/hooks'
import { isAndroid } from 'wallet/src/utils/platform'
import { AnimatedDecimalNumber } from './AnimatedDecimalNumber' import { AnimatedDecimalNumber } from './AnimatedDecimalNumber'
import { useLineChartPrice, useLineChartRelativeChange } from './usePrice' import { useLineChartPrice, useLineChartRelativeChange } from './usePrice'
export function PriceText({ loading }: { loading: boolean }): JSX.Element { export function PriceText({
loading,
maxWidth,
}: {
loading: boolean
maxWidth?: number
}): JSX.Element {
const price = useLineChartPrice() const price = useLineChartPrice()
const colors = useSporeColors() const colors = useSporeColors()
const currency = useAppFiatCurrency() const currency = useAppFiatCurrency()
...@@ -21,13 +27,15 @@ export function PriceText({ loading }: { loading: boolean }): JSX.Element { ...@@ -21,13 +27,15 @@ export function PriceText({ loading }: { loading: boolean }): JSX.Element {
(currency === FiatCurrency.UnitedStatesDollar || currency === FiatCurrency.Euro) && (currency === FiatCurrency.UnitedStatesDollar || currency === FiatCurrency.Euro) &&
symbolAtFront symbolAtFront
if (loading) { // TODO(MOB-2308): re-enable this when we have a better solution for handling the loading state
return <AnimatedText loading loadingPlaceholderText="$10,000" variant="heading1" /> // if (loading) {
} // return <AnimatedText loading loadingPlaceholderText="$10,000" variant="heading1" />
// }
return ( return (
<AnimatedDecimalNumber <AnimatedDecimalNumber
decimalPartColor={shouldFadePortfolioDecimals ? colors.neutral3.val : colors.neutral1.val} decimalPartColor={shouldFadePortfolioDecimals ? colors.neutral3.val : colors.neutral1.val}
maxWidth={maxWidth}
number={price} number={price}
separator={decimalSeparator} separator={decimalSeparator}
testID="price-text" testID="price-text"
...@@ -57,7 +65,7 @@ export function RelativeChangeText({ ...@@ -57,7 +65,7 @@ export function RelativeChangeText({
if (loading) { if (loading) {
return ( return (
<Flex mt={IS_ANDROID ? '$none' : '$spacing2'}> <Flex mt={isAndroid ? '$none' : '$spacing2'}>
<AnimatedText loading loadingPlaceholderText="00.00%" variant="body1" /> <AnimatedText loading loadingPlaceholderText="00.00%" variant="body1" />
</Flex> </Flex>
) )
...@@ -66,9 +74,9 @@ export function RelativeChangeText({ ...@@ -66,9 +74,9 @@ export function RelativeChangeText({
return ( return (
<Flex <Flex
row row
alignItems={IS_ANDROID ? 'center' : 'flex-end'} alignItems={isAndroid ? 'center' : 'flex-end'}
gap="$spacing2" gap="$spacing2"
mt={IS_ANDROID ? '$none' : '$spacing2'}> mt={isAndroid ? '$none' : '$spacing2'}>
<Icons.AnimatedCaretChange <Icons.AnimatedCaretChange
size="$icon.16" size="$icon.16"
strokeWidth={2} strokeWidth={2}
......
...@@ -33,115 +33,47 @@ exports[`DatetimeText renders without error 1`] = ` ...@@ -33,115 +33,47 @@ exports[`DatetimeText renders without error 1`] = `
exports[`PriceText renders loading state 1`] = ` exports[`PriceText renders loading state 1`] = `
<View <View
onLayout={[Function]}
style={ style={
{ {
"alignItems": "stretch", "alignItems": "stretch",
"flexDirection": "column", "flexDirection": "row",
"opacity": 0,
} }
} }
testID="price-text"
> >
<View <TextInput
style={ allowFontScaling={true}
animatedProps={
{ {
"alignItems": "center", "text": "-",
"flexDirection": "row",
} }
} }
> editable={false}
<View maxFontSizeMultiplier={1.2}
style={ style={
[
{ {
"alignItems": "center", "padding": 0,
"flexDirection": "row", },
"position": "relative", {
} "fontFamily": "Basel-Book",
} "fontSize": 53,
> "lineHeight": 60,
<View },
accessibilityElementsHidden={true} [
importantForAccessibility="no-hide-descendants"
>
<View
style={
{
"alignItems": "stretch",
"flexDirection": "row",
}
}
>
<TextInput
allowFontScaling={true}
editable={false}
maxFontSizeMultiplier={1.2}
style={
[
[
{
"padding": 0,
},
{
"fontFamily": "Basel-Book",
"fontSize": 53,
"lineHeight": 60,
},
undefined,
],
{
"marginHorizontal": 0,
"opacity": 0,
"paddingHorizontal": 0,
"width": 0,
},
]
}
underlineColorAndroid="transparent"
/>
<Text
style={
[
[
{
"padding": 0,
},
{
"fontFamily": "Basel-Book",
"fontSize": 53,
"lineHeight": 60,
},
undefined,
],
{
"opacity": 0,
},
]
}
>
$10,000
</Text>
</View>
</View>
<View
style={
{ {
"alignItems": "stretch", "color": "#222222",
"backgroundColor": "#F9F9F9", },
"borderBottomLeftRadius": 4, {
"borderBottomRightRadius": 4, "fontSize": 106,
"borderTopLeftRadius": 4, },
"borderTopRightRadius": 4, ],
"bottom": "5%", ]
"flexDirection": "column", }
"left": 0, testID="wholePart"
"position": "absolute", underlineColorAndroid="transparent"
"right": 0, value="-"
"top": "5%", />
}
}
/>
</View>
</View>
</View> </View>
`; `;
...@@ -174,9 +106,14 @@ exports[`PriceText renders without error 1`] = ` ...@@ -174,9 +106,14 @@ exports[`PriceText renders without error 1`] = `
"fontSize": 53, "fontSize": 53,
"lineHeight": 60, "lineHeight": 60,
}, },
{ [
"color": "#222222", {
}, "color": "#222222",
},
{
"fontSize": 106,
},
],
] ]
} }
testID="wholePart" testID="wholePart"
...@@ -202,9 +139,14 @@ exports[`PriceText renders without error 1`] = ` ...@@ -202,9 +139,14 @@ exports[`PriceText renders without error 1`] = `
"fontSize": 53, "fontSize": 53,
"lineHeight": 60, "lineHeight": 60,
}, },
{ [
"color": "#CECECE", {
}, "color": "#CECECE",
},
{
"fontSize": 106,
},
],
] ]
} }
testID="decimalPart" testID="decimalPart"
...@@ -243,9 +185,14 @@ exports[`PriceText renders without error less than a dollar 1`] = ` ...@@ -243,9 +185,14 @@ exports[`PriceText renders without error less than a dollar 1`] = `
"fontSize": 53, "fontSize": 53,
"lineHeight": 60, "lineHeight": 60,
}, },
{ [
"color": "#222222", {
}, "color": "#222222",
},
{
"fontSize": 106,
},
],
] ]
} }
testID="wholePart" testID="wholePart"
...@@ -271,9 +218,14 @@ exports[`PriceText renders without error less than a dollar 1`] = ` ...@@ -271,9 +218,14 @@ exports[`PriceText renders without error less than a dollar 1`] = `
"fontSize": 53, "fontSize": 53,
"lineHeight": 60, "lineHeight": 60,
}, },
{ [
"color": "#222222", {
}, "color": "#222222",
},
{
"fontSize": 106,
},
],
] ]
} }
testID="decimalPart" testID="decimalPart"
......
import { SharedValue, useDerivedValue } from 'react-native-reanimated' import { useMemo } from 'react'
import {
SharedValue,
useAnimatedReaction,
useDerivedValue,
useSharedValue,
} from 'react-native-reanimated'
import { import {
useLineChart, useLineChart,
useLineChartPrice as useRNWagmiChartLineChartPrice, useLineChartPrice as useRNWagmiChartLineChartPrice,
...@@ -7,21 +13,34 @@ import { numberToLocaleStringWorklet, numberToPercentWorklet } from 'src/utils/r ...@@ -7,21 +13,34 @@ import { numberToLocaleStringWorklet, numberToPercentWorklet } from 'src/utils/r
import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks' import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks'
import { useCurrentLocale } from 'wallet/src/features/language/hooks' import { useCurrentLocale } from 'wallet/src/features/language/hooks'
export type ValueAndFormatted<U = number, V = string> = { export type ValueAndFormatted<U = number, V = string, B = boolean> = {
value: Readonly<SharedValue<U>> value: Readonly<SharedValue<U>>
formatted: Readonly<SharedValue<V>> formatted: Readonly<SharedValue<V>>
shouldAnimate: Readonly<SharedValue<B>>
} }
/** /**
* Wrapper around react-native-wagmi-chart#useLineChartPrice * Wrapper around react-native-wagmi-chart#useLineChartPrice
* @returns latest price when not scrubbing and active price when scrubbing * @returns latest price when not scrubbing and active price when scrubbing
*/ */
export function useLineChartPrice(): ValueAndFormatted { export function useLineChartPrice(currentSpot?: number): ValueAndFormatted {
const { value: activeCursorPrice } = useRNWagmiChartLineChartPrice({ const { value: activeCursorPrice } = useRNWagmiChartLineChartPrice({
// do not round // do not round
precision: 18, precision: 18,
}) })
const { data } = useLineChart() const { data } = useLineChart()
const shouldAnimate = useSharedValue(true)
useAnimatedReaction(
() => {
return activeCursorPrice.value
},
(currentValue, previousValue) => {
if (previousValue && currentValue && shouldAnimate.value) {
shouldAnimate.value = false
}
}
)
const currencyInfo = useAppFiatCurrencyInfo() const currencyInfo = useAppFiatCurrencyInfo()
const locale = useCurrentLocale() const locale = useCurrentLocale()
...@@ -31,7 +50,9 @@ export function useLineChartPrice(): ValueAndFormatted { ...@@ -31,7 +50,9 @@ export function useLineChartPrice(): ValueAndFormatted {
return Number(activeCursorPrice.value) return Number(activeCursorPrice.value)
} }
return data[data.length - 1]?.value ?? 0 shouldAnimate.value = true
// show spot price when chart not scrubbing, or if not available, show the last price in the chart
return currentSpot ?? data[data.length - 1]?.value ?? 0
}) })
const priceFormatted = useDerivedValue(() => { const priceFormatted = useDerivedValue(() => {
return numberToLocaleStringWorklet( return numberToLocaleStringWorklet(
...@@ -44,10 +65,15 @@ export function useLineChartPrice(): ValueAndFormatted { ...@@ -44,10 +65,15 @@ export function useLineChartPrice(): ValueAndFormatted {
currencyInfo.symbol currencyInfo.symbol
) )
}) })
return {
value: price, return useMemo(
formatted: priceFormatted, () => ({
} value: price,
formatted: priceFormatted,
shouldAnimate,
}),
[price, priceFormatted, shouldAnimate]
)
} }
/** /**
...@@ -60,6 +86,7 @@ export function useLineChartRelativeChange({ ...@@ -60,6 +86,7 @@ export function useLineChartRelativeChange({
spotRelativeChange?: SharedValue<number> spotRelativeChange?: SharedValue<number>
}): ValueAndFormatted { }): ValueAndFormatted {
const { currentIndex, data, isActive } = useLineChart() const { currentIndex, data, isActive } = useLineChart()
const shouldAnimate = useSharedValue(false)
const relativeChange = useDerivedValue(() => { const relativeChange = useDerivedValue(() => {
if (!isActive.value && Boolean(spotRelativeChange)) { if (!isActive.value && Boolean(spotRelativeChange)) {
...@@ -93,5 +120,5 @@ export function useLineChartRelativeChange({ ...@@ -93,5 +120,5 @@ export function useLineChartRelativeChange({
return numberToPercentWorklet(relativeChange.value, { precision: 2, absolute: true }) return numberToPercentWorklet(relativeChange.value, { precision: 2, absolute: true })
}) })
return { value: relativeChange, formatted: relativeChangeFormattted } return { value: relativeChange, formatted: relativeChangeFormattted, shouldAnimate }
} }
import { maxBy } from 'lodash'
import { Dispatch, SetStateAction, useCallback, useMemo, useState } from 'react' import { Dispatch, SetStateAction, useCallback, useMemo, useState } from 'react'
import { SharedValue } from 'react-native-reanimated' import { SharedValue } from 'react-native-reanimated'
import { TLineChartData } from 'react-native-wagmi-charts' import { TLineChartData } from 'react-native-wagmi-charts'
...@@ -10,17 +11,24 @@ import { ...@@ -10,17 +11,24 @@ import {
useTokenPriceHistoryQuery, useTokenPriceHistoryQuery,
} from 'wallet/src/data/__generated__/types-and-hooks' } from 'wallet/src/data/__generated__/types-and-hooks'
import { currencyIdToContractInput } from 'wallet/src/features/dataApi/utils' import { currencyIdToContractInput } from 'wallet/src/features/dataApi/utils'
import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
export type TokenSpotData = { export type TokenSpotData = {
value: SharedValue<number> value: SharedValue<number>
relativeChange: SharedValue<number> relativeChange: SharedValue<number>
} }
export type PriceNumberOfDigits = {
left: number
right: number
}
/** /**
* @returns Token price history for requested duration * @returns Token price history for requested duration
*/ */
export function useTokenPriceHistory( export function useTokenPriceHistory(
currencyId: string, currencyId: string,
onCompleted?: () => void,
initialDuration: HistoryDuration = HistoryDuration.Day initialDuration: HistoryDuration = HistoryDuration.Day
): Omit< ): Omit<
GqlResult<{ GqlResult<{
...@@ -32,8 +40,10 @@ export function useTokenPriceHistory( ...@@ -32,8 +40,10 @@ export function useTokenPriceHistory(
setDuration: Dispatch<SetStateAction<HistoryDuration>> setDuration: Dispatch<SetStateAction<HistoryDuration>>
selectedDuration: HistoryDuration selectedDuration: HistoryDuration
error: boolean error: boolean
numberOfDigits: PriceNumberOfDigits
} { } {
const [duration, setDuration] = useState(initialDuration) const [duration, setDuration] = useState(initialDuration)
const { convertFiatAmount } = useLocalizationContext()
const { const {
data: priceData, data: priceData,
...@@ -46,7 +56,9 @@ export function useTokenPriceHistory( ...@@ -46,7 +56,9 @@ export function useTokenPriceHistory(
}, },
notifyOnNetworkStatusChange: true, notifyOnNetworkStatusChange: true,
pollInterval: PollingInterval.Normal, pollInterval: PollingInterval.Normal,
fetchPolicy: 'cache-first', onCompleted,
// TODO(MOB-2308): maybe update to network-only once we have a better loading state
fetchPolicy: 'cache-and-network',
}) })
const offChainData = priceData?.tokenProjects?.[0]?.markets?.[0] const offChainData = priceData?.tokenProjects?.[0]?.markets?.[0]
...@@ -73,13 +85,25 @@ export function useTokenPriceHistory( ...@@ -73,13 +85,25 @@ export function useTokenPriceHistory(
?.filter((x): x is TimestampedAmount => Boolean(x)) ?.filter((x): x is TimestampedAmount => Boolean(x))
.map((x) => ({ timestamp: x.timestamp * 1000, value: x.value })) .map((x) => ({ timestamp: x.timestamp * 1000, value: x.value }))
// adds the current price to the chart given we show spot price/24h change return formatted
if (formatted && spot?.value) { }, [priceHistory])
formatted?.push({ timestamp: Date.now(), value: spot.value.value })
const numberOfDigits = useMemo(() => {
const max = maxBy(priceHistory, 'value')
const convertedMaxValue = convertFiatAmount(max?.value).amount
if (max) {
return {
left: String(convertedMaxValue).split('.')[0]?.length || 10,
right: Number(String(convertedMaxValue).split('.')[0]) > 0 ? 2 : 10,
}
} }
return formatted return {
}, [priceHistory, spot?.value]) left: 0,
right: 0,
}
}, [convertFiatAmount, priceHistory])
const retry = useCallback(async () => { const retry = useCallback(async () => {
await refetch({ contract: currencyIdToContractInput(currencyId) }) await refetch({ contract: currencyIdToContractInput(currencyId) })
...@@ -89,14 +113,25 @@ export function useTokenPriceHistory( ...@@ -89,14 +113,25 @@ export function useTokenPriceHistory(
() => ({ () => ({
data: { data: {
priceHistory: formattedPriceHistory, priceHistory: formattedPriceHistory,
spot: duration === HistoryDuration.Day ? spot : undefined, spot,
}, },
loading: isNonPollingRequestInFlight(networkStatus), loading: isNonPollingRequestInFlight(networkStatus),
error: isError(networkStatus, !!priceData), error: isError(networkStatus, !!priceData),
refetch: retry, refetch: retry,
setDuration, setDuration,
selectedDuration: duration, selectedDuration: duration,
numberOfDigits,
onCompleted,
}), }),
[duration, formattedPriceHistory, networkStatus, priceData, retry, spot] [
duration,
formattedPriceHistory,
networkStatus,
priceData,
retry,
spot,
onCompleted,
numberOfDigits,
]
) )
} }
...@@ -3,9 +3,9 @@ import { ImageSourcePropType, StyleSheet } from 'react-native' ...@@ -3,9 +3,9 @@ import { ImageSourcePropType, StyleSheet } from 'react-native'
import QRCode from 'src/components/QRCodeScanner/custom-qr-code-generator' import QRCode from 'src/components/QRCodeScanner/custom-qr-code-generator'
import { Unicon } from 'src/components/unicons/Unicon' import { Unicon } from 'src/components/unicons/Unicon'
import { useUniconColors } from 'src/components/unicons/utils' import { useUniconColors } from 'src/components/unicons/utils'
import { IS_ANDROID } from 'src/constants/globals'
import { ColorTokens, Flex, useSporeColors } from 'ui/src' import { ColorTokens, Flex, useSporeColors } from 'ui/src'
import { borderRadii, opacify } from 'ui/src/theme' import { borderRadii, opacify } from 'ui/src/theme'
import { isAndroid } from 'wallet/src/utils/platform'
type AddressQRCodeProps = { type AddressQRCodeProps = {
address: Address address: Address
...@@ -65,7 +65,7 @@ export const AddressQRCode = ({ ...@@ -65,7 +65,7 @@ export const AddressQRCode = ({
enableLinearGradient: true, enableLinearGradient: true,
linearGradient: [gradientData.gradientStart, gradientData.gradientEnd], linearGradient: [gradientData.gradientStart, gradientData.gradientEnd],
color: gradientData.gradientStart, color: gradientData.gradientStart,
gradientDirection: ['0%', '0%', IS_ANDROID ? '150%' : '100%', '0%'], gradientDirection: ['0%', '0%', isAndroid ? '150%' : '100%', '0%'],
} }
} }
return gradientPropsObject return gradientPropsObject
......
...@@ -29,16 +29,17 @@ export function RecipientScanModal({ onSelectRecipient, onClose }: Props): JSX.E ...@@ -29,16 +29,17 @@ export function RecipientScanModal({ onSelectRecipient, onClose }: Props): JSX.E
const [currentScreenState, setCurrentScreenState] = useState<ScannerModalState>( const [currentScreenState, setCurrentScreenState] = useState<ScannerModalState>(
ScannerModalState.ScanQr ScannerModalState.ScanQr
) )
const [hasScanError, setHasScanError] = useState(false)
const [shouldFreezeCamera, setShouldFreezeCamera] = useState(false) const [shouldFreezeCamera, setShouldFreezeCamera] = useState(false)
const onScanCode = async (uri: string): Promise<void> => { const onScanCode = async (uri: string): Promise<void> => {
// don't scan any QR codes if there is an error popup open or camera is frozen // don't scan any QR codes if camera is frozen
if (hasScanError || shouldFreezeCamera) return if (shouldFreezeCamera) return
await selectionAsync() await selectionAsync()
setShouldFreezeCamera(true)
const supportedURI = await getSupportedURI(uri) const supportedURI = await getSupportedURI(uri)
if (supportedURI?.type === URIType.Address) { if (supportedURI?.type === URIType.Address) {
setShouldFreezeCamera(true)
onSelectRecipient(supportedURI.value) onSelectRecipient(supportedURI.value)
onClose() onClose()
} else { } else {
...@@ -49,7 +50,7 @@ export function RecipientScanModal({ onSelectRecipient, onClose }: Props): JSX.E ...@@ -49,7 +50,7 @@ export function RecipientScanModal({ onSelectRecipient, onClose }: Props): JSX.E
{ {
text: t('Try again'), text: t('Try again'),
onPress: (): void => { onPress: (): void => {
setHasScanError(false) setShouldFreezeCamera(false)
}, },
}, },
] ]
......
import React from 'react' import React, { useMemo } from 'react'
import { ScrollView, StyleSheet } from 'react-native' import { ScrollView, StyleSheet } from 'react-native'
import { useAccountList } from 'src/components/accounts/hooks'
import { AddressDisplay } from 'src/components/AddressDisplay' import { AddressDisplay } from 'src/components/AddressDisplay'
import { Flex, Text, useDeviceDimensions } from 'ui/src' import { Flex, Text, useDeviceDimensions } from 'ui/src'
import { spacing } from 'ui/src/theme' import { spacing } from 'ui/src/theme'
import { NumberType } from 'utilities/src/format/types' import { NumberType } from 'utilities/src/format/types'
import { import { AccountListQuery } from 'wallet/src/data/__generated__/types-and-hooks'
AccountListQuery,
useAccountListQuery,
} from 'wallet/src/data/__generated__/types-and-hooks'
import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
import { Account } from 'wallet/src/features/wallet/accounts/types' import { Account } from 'wallet/src/features/wallet/accounts/types'
...@@ -17,10 +15,9 @@ type Portfolio = NonNullable<NonNullable<NonNullable<AccountListQuery['portfolio ...@@ -17,10 +15,9 @@ type Portfolio = NonNullable<NonNullable<NonNullable<AccountListQuery['portfolio
function _AssociatedAccountsList({ accounts }: { accounts: Account[] }): JSX.Element { function _AssociatedAccountsList({ accounts }: { accounts: Account[] }): JSX.Element {
const { fullHeight } = useDeviceDimensions() const { fullHeight } = useDeviceDimensions()
const { data, loading } = useAccountListQuery({ const addresses = useMemo(() => accounts.map((account) => account.address), [accounts])
variables: { const { data, loading } = useAccountList({
addresses: accounts.map((account) => account.address), addresses,
},
notifyOnNetworkStatusChange: true, notifyOnNetworkStatusChange: true,
}) })
......
...@@ -151,54 +151,64 @@ export function RemoveWalletModal(): JSX.Element | null { ...@@ -151,54 +151,64 @@ export function RemoveWalletModal(): JSX.Element | null {
backgroundColor={colors.surface1.get()} backgroundColor={colors.surface1.get()}
name={ModalName.RemoveSeedPhraseWarningModal} name={ModalName.RemoveSeedPhraseWarningModal}
onClose={onClose}> onClose={onClose}>
<Flex centered gap="$spacing16" px="$spacing24" py="$spacing12"> <Flex gap="$spacing24" px="$spacing24" py="$spacing24">
<Flex <Flex centered gap="$spacing16">
centered <Flex
borderRadius="$rounded12" centered
p="$spacing12" borderRadius="$rounded12"
style={{ p="$spacing12"
backgroundColor: opacify(12, colors[labelColor].val), style={{
}}> backgroundColor: opacify(12, colors[labelColor].val),
<Icon color={colors[labelColor].val} height={iconSizes.icon24} width={iconSizes.icon24} /> }}>
<Icon
color={colors[labelColor].val}
height={iconSizes.icon24}
width={iconSizes.icon24}
/>
</Flex>
<Flex gap="$spacing8">
<Text textAlign="center" variant="body1">
{title}
</Text>
<Text color="$neutral2" textAlign="center" variant="body2">
{description}
</Text>
</Flex>
</Flex> </Flex>
<Text textAlign="center" variant="body1"> <Flex centered gap="$spacing24">
{title} {currentStep === RemoveWalletStep.Final && isRemovingRecoveryPhrase ? (
</Text> <>
<Text color="$neutral2" textAlign="center" variant="body2"> <AssociatedAccountsList accounts={associatedAccounts} />
{description} <RemoveLastMnemonicWalletFooter inProgress={inProgress} onPress={onPress} />
</Text> </>
{currentStep === RemoveWalletStep.Final && isRemovingRecoveryPhrase ? ( ) : (
<> <Flex centered row gap={inProgress ? '$none' : '$spacing12'} pt="$spacing12">
<AssociatedAccountsList accounts={associatedAccounts} /> {inProgress ? (
<RemoveLastMnemonicWalletFooter inProgress={inProgress} onPress={onPress} /> <AnimatedFlex style={animatedCancelButtonSpanStyles} />
</> ) : (
) : ( <Button fill disabled={inProgress} theme="outline" onPress={onClose}>
<Flex centered row gap={inProgress ? '$none' : '$spacing12'} pt="$spacing12"> {t('Cancel')}
{inProgress ? ( </Button>
<AnimatedFlex style={animatedCancelButtonSpanStyles} /> )}
) : ( <Button
<Button fill disabled={inProgress} theme="outline" onPress={onClose}> fill
{t('Cancel')} icon={
inProgress ? (
<SpinningLoader
// TODO(MOB-1420): clean up types (as ColorTokens)
color={`$${labelColor}` as ColorTokens}
/>
) : undefined
}
testID={isRemovingRecoveryPhrase ? ElementName.Continue : ElementName.Remove}
theme={actionButtonTheme}
width="100%"
onPress={onPress}>
{inProgress ? undefined : actionButtonLabel}
</Button> </Button>
)} </Flex>
<Button )}
fill </Flex>
icon={
inProgress ? (
<SpinningLoader
// TODO(MOB-1420): clean up types (as ColorTokens)
color={`$${labelColor}` as ColorTokens}
/>
) : undefined
}
testID={isRemovingRecoveryPhrase ? ElementName.Continue : ElementName.Remove}
theme={actionButtonTheme}
width="100%"
onPress={onPress}>
{inProgress ? undefined : actionButtonLabel}
</Button>
</Flex>
)}
</Flex> </Flex>
</BottomSheetModal> </BottomSheetModal>
) )
......
...@@ -2,7 +2,6 @@ import React, { useMemo } from 'react' ...@@ -2,7 +2,6 @@ import React, { useMemo } from 'react'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { SvgProps } from 'react-native-svg' import { SvgProps } from 'react-native-svg'
import { concatListOfAccountNames } from 'src/components/RemoveWallet/utils' import { concatListOfAccountNames } from 'src/components/RemoveWallet/utils'
import { IS_ANDROID } from 'src/constants/globals'
import { Text, ThemeKeys } from 'ui/src' import { Text, ThemeKeys } from 'ui/src'
import AlertTriangleIcon from 'ui/src/assets/icons/alert-triangle.svg' import AlertTriangleIcon from 'ui/src/assets/icons/alert-triangle.svg'
import TrashIcon from 'ui/src/assets/icons/trash.svg' import TrashIcon from 'ui/src/assets/icons/trash.svg'
...@@ -10,6 +9,7 @@ import WalletIcon from 'ui/src/assets/icons/wallet-filled.svg' ...@@ -10,6 +9,7 @@ import WalletIcon from 'ui/src/assets/icons/wallet-filled.svg'
import { ThemeNames } from 'ui/src/theme' import { ThemeNames } from 'ui/src/theme'
import { Account, AccountType } from 'wallet/src/features/wallet/accounts/types' import { Account, AccountType } from 'wallet/src/features/wallet/accounts/types'
import { useDisplayName } from 'wallet/src/features/wallet/hooks' import { useDisplayName } from 'wallet/src/features/wallet/hooks'
import { isAndroid } from 'wallet/src/utils/platform'
export enum RemoveWalletStep { export enum RemoveWalletStep {
Warning = 'warning', Warning = 'warning',
...@@ -95,7 +95,7 @@ export const useModalContent = ({ ...@@ -95,7 +95,7 @@ export const useModalContent = ({
</Text> </Text>
</Trans> </Trans>
), ),
description: IS_ANDROID ? ( description: isAndroid ? (
<Trans t={t}> <Trans t={t}>
Make sure you’ve written down your recovery phrase or backed it up on Google Drive.{' '} Make sure you’ve written down your recovery phrase or backed it up on Google Drive.{' '}
<Text color="$neutral2" maxFontSizeMultiplier={1.4} variant="buttonLabel3"> <Text color="$neutral2" maxFontSizeMultiplier={1.4} variant="buttonLabel3">
...@@ -136,7 +136,7 @@ export const useModalContent = ({ ...@@ -136,7 +136,7 @@ export const useModalContent = ({
description: ( description: (
<Trans t={t}> <Trans t={t}>
It shares the same recovery phrase as{' '} It shares the same recovery phrase as{' '}
<Text fontWeight="bold">{{ wallets: associatedAccountNames }}</Text>. Your recovery <Text color="$neutral1">{{ wallets: associatedAccountNames }}</Text>. Your recovery
phrase will remain stored until you delete all remaining wallets. phrase will remain stored until you delete all remaining wallets.
</Trans> </Trans>
), ),
......
...@@ -26,8 +26,7 @@ export interface SettingsSectionItemComponent { ...@@ -26,8 +26,7 @@ export interface SettingsSectionItemComponent {
component: JSX.Element component: JSX.Element
isHidden?: boolean isHidden?: boolean
} }
type SettingsModal = Extract<ModalName, ModalName.FiatCurrencySelector | ModalName.LanguageSelector>
type SettingsModal = Extract<ModalName, ModalName.FiatCurrencySelector>
export interface SettingsSectionItem { export interface SettingsSectionItem {
screen?: keyof SettingsStackParamList | typeof Screens.OnboardingStack screen?: keyof SettingsStackParamList | typeof Screens.OnboardingStack
modal?: SettingsModal modal?: SettingsModal
......
...@@ -11,15 +11,9 @@ export const TokenBalanceItemContextMenu = memo(function _TokenBalanceItem({ ...@@ -11,15 +11,9 @@ export const TokenBalanceItemContextMenu = memo(function _TokenBalanceItem({
portfolioBalance: PortfolioBalance portfolioBalance: PortfolioBalance
children: React.ReactNode children: React.ReactNode
}) { }) {
const { currencyInfo, balanceUSD } = portfolioBalance
const { currency, currencyId, isSpam } = currencyInfo
const { menuActions, onContextMenuPress } = useTokenContextMenu({ const { menuActions, onContextMenuPress } = useTokenContextMenu({
currencyId, currencyId: portfolioBalance.currencyInfo.currencyId,
isSpam, portfolioBalance,
balanceUSD,
isNative: currency.isNative,
accountHoldsToken: true,
}) })
const style = useMemo(() => ({ borderRadius: borderRadii.rounded16 }), []) const style = useMemo(() => ({ borderRadius: borderRadii.rounded16 }), [])
......
...@@ -21,7 +21,6 @@ import { ...@@ -21,7 +21,6 @@ import {
TokenBalanceListRow, TokenBalanceListRow,
useTokenBalanceListContext, useTokenBalanceListContext,
} from 'src/components/TokenBalanceList/TokenBalanceListContext' } from 'src/components/TokenBalanceList/TokenBalanceListContext'
import { IS_ANDROID } from 'src/constants/globals'
import { Screens } from 'src/screens/Screens' import { Screens } from 'src/screens/Screens'
import { AnimatedFlex, Flex, useDeviceDimensions, useDeviceInsets, useSporeColors } from 'ui/src' import { AnimatedFlex, Flex, useDeviceDimensions, useDeviceInsets, useSporeColors } from 'ui/src'
import { zIndices } from 'ui/src/theme' import { zIndices } from 'ui/src/theme'
...@@ -29,6 +28,7 @@ import { BaseCard } from 'wallet/src/components/BaseCard/BaseCard' ...@@ -29,6 +28,7 @@ import { BaseCard } from 'wallet/src/components/BaseCard/BaseCard'
import { isError, isNonPollingRequestInFlight } from 'wallet/src/data/utils' import { isError, isNonPollingRequestInFlight } from 'wallet/src/data/utils'
import { TokenBalanceItem } from 'wallet/src/features/portfolio/TokenBalanceItem' import { TokenBalanceItem } from 'wallet/src/features/portfolio/TokenBalanceItem'
import { CurrencyId } from 'wallet/src/utils/currencyId' import { CurrencyId } from 'wallet/src/utils/currencyId'
import { isAndroid } from 'wallet/src/utils/platform'
type TokenBalanceListProps = TabProps & { type TokenBalanceListProps = TabProps & {
empty?: JSX.Element | null empty?: JSX.Element | null
...@@ -127,7 +127,7 @@ export const TokenBalanceListInner = forwardRef< ...@@ -127,7 +127,7 @@ export const TokenBalanceListInner = forwardRef<
return ( return (
<RefreshControl <RefreshControl
progressViewOffset={ progressViewOffset={
insets.top + (IS_ANDROID && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0) insets.top + (isAndroid && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0)
} }
refreshing={refreshing ?? false} refreshing={refreshing ?? false}
tintColor={colors.neutral3.get()} tintColor={colors.neutral3.get()}
......
import { NetworkStatus } from '@apollo/client'
import { isEqual } from 'lodash' import { isEqual } from 'lodash'
import { import {
createContext, createContext,
...@@ -9,18 +10,22 @@ import { ...@@ -9,18 +10,22 @@ import {
useRef, useRef,
useState, useState,
} from 'react' } from 'react'
import { useTokenBalancesGroupedByVisibility } from 'src/features/balances/hooks' import { PollingInterval } from 'wallet/src/constants/misc'
import { isWarmLoadingStatus } from 'wallet/src/data/utils' import { isWarmLoadingStatus } from 'wallet/src/data/utils'
import { usePortfolioBalances } from 'wallet/src/features/dataApi/balances' import {
usePortfolioBalances,
useTokenBalancesGroupedByVisibility,
} from 'wallet/src/features/dataApi/balances'
import { PortfolioBalance } from 'wallet/src/features/dataApi/types'
type CurrencyId = string type CurrencyId = string
export const HIDDEN_TOKEN_BALANCES_ROW = 'HIDDEN_TOKEN_BALANCES_ROW' as const export const HIDDEN_TOKEN_BALANCES_ROW = 'HIDDEN_TOKEN_BALANCES_ROW' as const
export type TokenBalanceListRow = CurrencyId | typeof HIDDEN_TOKEN_BALANCES_ROW export type TokenBalanceListRow = CurrencyId | typeof HIDDEN_TOKEN_BALANCES_ROW
type TokenBalanceListContextState = { type TokenBalanceListContextState = {
balancesById: ReturnType<typeof usePortfolioBalances>['data'] balancesById: Record<string, PortfolioBalance> | undefined
networkStatus: ReturnType<typeof usePortfolioBalances>['networkStatus'] networkStatus: NetworkStatus
refetch: ReturnType<typeof usePortfolioBalances>['refetch'] refetch: (() => void) | undefined
hiddenTokensCount: number hiddenTokensCount: number
hiddenTokensExpanded: boolean hiddenTokensExpanded: boolean
isWarmLoading: boolean isWarmLoading: boolean
...@@ -49,7 +54,7 @@ export function TokenBalanceListContextProvider({ ...@@ -49,7 +54,7 @@ export function TokenBalanceListContextProvider({
refetch, refetch,
} = usePortfolioBalances({ } = usePortfolioBalances({
address: owner, address: owner,
shouldPoll: true, pollInterval: PollingInterval.KindaFast,
fetchPolicy: 'cache-and-network', fetchPolicy: 'cache-and-network',
}) })
......
...@@ -114,7 +114,6 @@ export function TokenDetailsStats({ ...@@ -114,7 +114,6 @@ export function TokenDetailsStats({
offChainData?.markets?.[0]?.priceHigh52W?.value ?? onChainData?.market?.priceHigh52W?.value offChainData?.markets?.[0]?.priceHigh52W?.value ?? onChainData?.market?.priceHigh52W?.value
const priceLow52W = const priceLow52W =
offChainData?.markets?.[0]?.priceLow52W?.value ?? onChainData?.market?.priceLow52W?.value offChainData?.markets?.[0]?.priceLow52W?.value ?? onChainData?.market?.priceLow52W?.value
const currentDescription = const currentDescription =
showTranslation && translatedDescription ? translatedDescription : description showTranslation && translatedDescription ? translatedDescription : description
......
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)
...@@ -4,8 +4,7 @@ import { Flex, Icons, Text, TouchableArea } from 'ui/src' ...@@ -4,8 +4,7 @@ import { Flex, Icons, Text, TouchableArea } from 'ui/src'
import { iconSizes } from 'ui/src/theme' import { iconSizes } from 'ui/src/theme'
import { CurrencyLogo } from 'wallet/src/components/CurrencyLogo/CurrencyLogo' import { CurrencyLogo } from 'wallet/src/components/CurrencyLogo/CurrencyLogo'
import { CurrencyInfo } from 'wallet/src/features/dataApi/types' import { CurrencyInfo } from 'wallet/src/features/dataApi/types'
import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants' import { useSwapRewriteEnabled } from 'wallet/src/features/experiments/hooks'
import { useFeatureFlag } from 'wallet/src/features/experiments/hooks'
import { getSymbolDisplayText } from 'wallet/src/utils/currency' import { getSymbolDisplayText } from 'wallet/src/utils/currency'
interface SelectTokenButtonProps { interface SelectTokenButtonProps {
...@@ -21,7 +20,7 @@ export function SelectTokenButton({ ...@@ -21,7 +20,7 @@ export function SelectTokenButton({
}: SelectTokenButtonProps): JSX.Element { }: SelectTokenButtonProps): JSX.Element {
const { t } = useTranslation() const { t } = useTranslation()
const isSwapRewriteFeatureEnabled = useFeatureFlag(FEATURE_FLAGS.SwapRewrite) const isSwapRewriteFeatureEnabled = useSwapRewriteEnabled()
if (isSwapRewriteFeatureEnabled) { if (isSwapRewriteFeatureEnabled) {
return ( return (
......
...@@ -3,70 +3,21 @@ import React, { memo, useCallback, useMemo, useRef } from 'react' ...@@ -3,70 +3,21 @@ import React, { memo, useCallback, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ListRenderItemInfo } from 'react-native' import { ListRenderItemInfo } from 'react-native'
import { Loader } from 'src/components/loading' import { Loader } from 'src/components/loading'
import { useAllCommonBaseCurrencies } from 'src/components/TokenSelector/hooks'
import { TokenOptionItem } from 'src/components/TokenSelector/TokenOptionItem' import { TokenOptionItem } from 'src/components/TokenSelector/TokenOptionItem'
import { useFiatOnRampSupportedTokens } from 'src/features/fiatOnRamp/hooks'
import { FiatOnRampCurrency } from 'src/features/fiatOnRamp/types' import { FiatOnRampCurrency } from 'src/features/fiatOnRamp/types'
import { ElementName } from 'src/features/telemetry/constants' import { ElementName } from 'src/features/telemetry/constants'
import { Flex, Icons, Inset, Text, TouchableArea } from 'ui/src' import { Flex, Icons, Inset, Text, TouchableArea } from 'ui/src'
import { BaseCard } from 'wallet/src/components/BaseCard/BaseCard' import { BaseCard } from 'wallet/src/components/BaseCard/BaseCard'
import { ChainId } from 'wallet/src/constants/chains' import { ChainId } from 'wallet/src/constants/chains'
import { fromMoonpayNetwork } from 'wallet/src/features/chains/utils'
import { CurrencyInfo, GqlResult } from 'wallet/src/features/dataApi/types'
import { MoonpayCurrency } from 'wallet/src/features/fiatOnRamp/types'
import { CurrencyId } from 'wallet/src/utils/currencyId' import { CurrencyId } from 'wallet/src/utils/currencyId'
interface Props { interface Props {
onSelectCurrency: (currency: FiatOnRampCurrency) => void onSelectCurrency: (currency: FiatOnRampCurrency) => void
onBack: () => void onBack: () => void
} onRetry: () => void
error: boolean
const findTokenOptionForMoonpayCurrency = ( loading: boolean
commonBaseCurrencies: CurrencyInfo[] | undefined, list: FiatOnRampCurrency[] | undefined
moonpayCurrency: MoonpayCurrency
): Maybe<CurrencyInfo> => {
return (commonBaseCurrencies || []).find((item) => {
const [code, network] = moonpayCurrency.code.split('_') ?? [undefined, undefined]
const chainId = fromMoonpayNetwork(network)
return (
item &&
code &&
code === item.currency.symbol?.toLowerCase() &&
chainId === item.currency.chainId
)
})
}
function useFiatOnRampTokenList(
supportedTokens: MoonpayCurrency[] | undefined
): GqlResult<FiatOnRampCurrency[]> {
const {
data: commonBaseCurrencies,
error: commonBaseCurrenciesError,
loading: commonBaseCurrenciesLoading,
refetch: refetchCommonBaseCurrencies,
} = useAllCommonBaseCurrencies()
const data = useMemo(
() =>
(supportedTokens || [])
.map((moonpayCurrency) => ({
currencyInfo: findTokenOptionForMoonpayCurrency(commonBaseCurrencies, moonpayCurrency),
moonpayCurrency,
}))
.filter((item) => !!item.currencyInfo),
[commonBaseCurrencies, supportedTokens]
)
return useMemo(
() => ({
data,
loading: commonBaseCurrenciesLoading,
error: commonBaseCurrenciesError,
refetch: refetchCommonBaseCurrencies,
}),
[commonBaseCurrenciesError, commonBaseCurrenciesLoading, data, refetchCommonBaseCurrencies]
)
} }
function TokenOptionItemWrapper({ function TokenOptionItemWrapper({
...@@ -100,20 +51,18 @@ function TokenOptionItemWrapper({ ...@@ -100,20 +51,18 @@ function TokenOptionItemWrapper({
) )
} }
function _TokenFiatOnRampList({ onSelectCurrency, onBack }: Props): JSX.Element { function _TokenFiatOnRampList({
onSelectCurrency,
onBack,
error,
onRetry,
list,
loading,
}: Props): JSX.Element {
const { t } = useTranslation() const { t } = useTranslation()
const flatListRef = useRef(null) const flatListRef = useRef(null)
const {
data: supportedTokens,
isLoading: supportedTokensLoading,
isError: supportedTokensQueryError,
refetch: supportedTokensQueryRefetch,
} = useFiatOnRampSupportedTokens()
const { data, loading, error, refetch } = useFiatOnRampTokenList(supportedTokens)
const renderItem = useCallback( const renderItem = useCallback(
({ item: currency }: ListRenderItemInfo<FiatOnRampCurrency>) => { ({ item: currency }: ListRenderItemInfo<FiatOnRampCurrency>) => {
return <TokenOptionItemWrapper currency={currency} onSelectCurrency={onSelectCurrency} /> return <TokenOptionItemWrapper currency={currency} onSelectCurrency={onSelectCurrency} />
...@@ -121,7 +70,7 @@ function _TokenFiatOnRampList({ onSelectCurrency, onBack }: Props): JSX.Element ...@@ -121,7 +70,7 @@ function _TokenFiatOnRampList({ onSelectCurrency, onBack }: Props): JSX.Element
[onSelectCurrency] [onSelectCurrency]
) )
if (supportedTokensQueryError || error) { if (error) {
return ( return (
<> <>
<Header onBack={onBack} /> <Header onBack={onBack} />
...@@ -129,21 +78,14 @@ function _TokenFiatOnRampList({ onSelectCurrency, onBack }: Props): JSX.Element ...@@ -129,21 +78,14 @@ function _TokenFiatOnRampList({ onSelectCurrency, onBack }: Props): JSX.Element
<BaseCard.ErrorState <BaseCard.ErrorState
retryButtonLabel="Retry" retryButtonLabel="Retry"
title={t('Couldn’t load tokens to buy')} title={t('Couldn’t load tokens to buy')}
onRetry={(): void => { onRetry={onRetry}
if (supportedTokensQueryError) {
supportedTokensQueryRefetch?.()
}
if (error) {
refetch?.()
}
}}
/> />
</Flex> </Flex>
</> </>
) )
} }
if (supportedTokensLoading || loading) { if (loading) {
return ( return (
<Flex> <Flex>
<Header onBack={onBack} /> <Header onBack={onBack} />
...@@ -159,7 +101,7 @@ function _TokenFiatOnRampList({ onSelectCurrency, onBack }: Props): JSX.Element ...@@ -159,7 +101,7 @@ function _TokenFiatOnRampList({ onSelectCurrency, onBack }: Props): JSX.Element
ref={flatListRef} ref={flatListRef}
ListEmptyComponent={<Flex />} ListEmptyComponent={<Flex />}
ListFooterComponent={<Inset all="$spacing36" />} ListFooterComponent={<Inset all="$spacing36" />}
data={data} data={list}
keyExtractor={key} keyExtractor={key}
keyboardDismissMode="on-drag" keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="always" keyboardShouldPersistTaps="always"
......
...@@ -3,7 +3,6 @@ import { useAppSelector } from 'src/app/hooks' ...@@ -3,7 +3,6 @@ import { useAppSelector } from 'src/app/hooks'
import { filter } from 'src/components/TokenSelector/filter' import { filter } from 'src/components/TokenSelector/filter'
import { TokenOption, TokenSelectorFlow } from 'src/components/TokenSelector/types' import { TokenOption, TokenSelectorFlow } from 'src/components/TokenSelector/types'
import { createEmptyBalanceOption } from 'src/components/TokenSelector/utils' import { createEmptyBalanceOption } from 'src/components/TokenSelector/utils'
import { useTokenBalancesGroupedByVisibility } from 'src/features/balances/hooks'
import { useTokenProjects } from 'src/features/dataApi/tokenProjects' import { useTokenProjects } from 'src/features/dataApi/tokenProjects'
import { usePopularTokens } from 'src/features/dataApi/topTokens' import { usePopularTokens } from 'src/features/dataApi/topTokens'
import { sendMobileAnalyticsEvent } from 'src/features/telemetry' import { sendMobileAnalyticsEvent } from 'src/features/telemetry'
...@@ -11,7 +10,11 @@ import { MobileEventName } from 'src/features/telemetry/constants' ...@@ -11,7 +10,11 @@ import { MobileEventName } from 'src/features/telemetry/constants'
import { BRIDGED_BASE_ADDRESSES } from 'wallet/src/constants/addresses' import { BRIDGED_BASE_ADDRESSES } from 'wallet/src/constants/addresses'
import { ChainId } from 'wallet/src/constants/chains' import { ChainId } from 'wallet/src/constants/chains'
import { DAI, USDC, USDT, WBTC } from 'wallet/src/constants/tokens' import { DAI, USDC, USDT, WBTC } from 'wallet/src/constants/tokens'
import { sortPortfolioBalances, usePortfolioBalances } from 'wallet/src/features/dataApi/balances' import {
sortPortfolioBalances,
usePortfolioBalances,
useTokenBalancesGroupedByVisibility,
} from 'wallet/src/features/dataApi/balances'
import { CurrencyInfo, GqlResult, PortfolioBalance } from 'wallet/src/features/dataApi/types' import { CurrencyInfo, GqlResult, PortfolioBalance } from 'wallet/src/features/dataApi/types'
import { usePersistedError } from 'wallet/src/features/dataApi/utils' import { usePersistedError } from 'wallet/src/features/dataApi/utils'
import { selectFavoriteTokens } from 'wallet/src/features/favorites/selectors' import { selectFavoriteTokens } from 'wallet/src/features/favorites/selectors'
...@@ -173,7 +176,6 @@ export function usePortfolioBalancesForAddressById( ...@@ -173,7 +176,6 @@ export function usePortfolioBalancesForAddressById(
loading, loading,
} = usePortfolioBalances({ } = usePortfolioBalances({
address, address,
shouldPoll: false, // Home tab's TokenBalanceList will poll portfolio balances for activeAccount
fetchPolicy: 'cache-first', // we want to avoid re-renders when token selector is opening fetchPolicy: 'cache-first', // we want to avoid re-renders when token selector is opening
}) })
......
import { useEffect } from 'react' import { useEffect } from 'react'
import { NativeModules } from 'react-native' import { NativeModules } from 'react-native'
import { IS_ANDROID } from 'src/constants/globals'
import { import {
useBiometricAppSettings, useBiometricAppSettings,
useDeviceSupportsBiometricAuth, useDeviceSupportsBiometricAuth,
...@@ -17,6 +16,7 @@ import { ...@@ -17,6 +16,7 @@ import {
useSwapProtectionSetting, useSwapProtectionSetting,
useViewOnlyAccounts, useViewOnlyAccounts,
} from 'wallet/src/features/wallet/hooks' } from 'wallet/src/features/wallet/hooks'
import { isAndroid } from 'wallet/src/utils/platform'
/** Component that tracks UserProperties during the lifetime of the app */ /** Component that tracks UserProperties during the lifetime of the app */
export function TraceUserProperties(): null { export function TraceUserProperties(): null {
...@@ -30,7 +30,7 @@ export function TraceUserProperties(): null { ...@@ -30,7 +30,7 @@ export function TraceUserProperties(): null {
useEffect(() => { useEffect(() => {
setUserProperty(UserPropertyName.AppVersion, getFullAppVersion()) setUserProperty(UserPropertyName.AppVersion, getFullAppVersion())
if (IS_ANDROID) { if (isAndroid) {
NativeModules.AndroidDeviceModule.getPerformanceClass().then((perfClass: number) => { NativeModules.AndroidDeviceModule.getPerformanceClass().then((perfClass: number) => {
setUserProperty(UserPropertyName.AndroidPerfClass, perfClass) setUserProperty(UserPropertyName.AndroidPerfClass, perfClass)
}) })
......
...@@ -236,7 +236,7 @@ export const PendingConnectionModal = ({ pendingSession, onClose }: Props): JSX. ...@@ -236,7 +236,7 @@ export const PendingConnectionModal = ({ pendingSession, onClose }: Props): JSX.
}, },
[activeAddress, dispatch, onClose, pendingSession, didOpenFromDeepLink] [activeAddress, dispatch, onClose, pendingSession, didOpenFromDeepLink]
) )
const dappName = pendingSession.dapp.name || pendingSession.dapp.url const dappName = pendingSession.dapp.name || pendingSession.dapp.url || ''
return ( return (
<BottomSheetModal name={ModalName.WCPendingConnection} onClose={onClose}> <BottomSheetModal name={ModalName.WCPendingConnection} onClose={onClose}>
......
...@@ -23,7 +23,9 @@ export const CUSTOM_UNI_QR_CODE_PREFIX = 'hello_uniwallet:' ...@@ -23,7 +23,9 @@ export const CUSTOM_UNI_QR_CODE_PREFIX = 'hello_uniwallet:'
const MAX_DAPP_NAME_LENGTH = 60 const MAX_DAPP_NAME_LENGTH = 60
export function truncateDappName(name: string): string { export function truncateDappName(name: string): string {
return name.length > MAX_DAPP_NAME_LENGTH ? `${name.slice(0, MAX_DAPP_NAME_LENGTH)}...` : name return name && name.length > MAX_DAPP_NAME_LENGTH
? `${name.slice(0, MAX_DAPP_NAME_LENGTH)}...`
: name
} }
export async function getSupportedURI(uri: string): Promise<URIFormat | undefined> { export async function getSupportedURI(uri: string): Promise<URIFormat | undefined> {
......
...@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next' ...@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import ContextMenu from 'react-native-context-menu-view' import ContextMenu from 'react-native-context-menu-view'
import { useAppDispatch } from 'src/app/hooks' import { useAppDispatch } from 'src/app/hooks'
import { navigate } from 'src/app/navigation/rootNavigation' import { navigate } from 'src/app/navigation/rootNavigation'
import { useAccountList } from 'src/components/accounts/hooks'
import { AddressDisplay } from 'src/components/AddressDisplay' import { AddressDisplay } from 'src/components/AddressDisplay'
import { closeModal, openModal } from 'src/features/modals/modalSlice' import { closeModal, openModal } from 'src/features/modals/modalSlice'
import { ModalName } from 'src/features/telemetry/constants' import { ModalName } from 'src/features/telemetry/constants'
...@@ -24,24 +25,39 @@ type AccountCardItemProps = { ...@@ -24,24 +25,39 @@ type AccountCardItemProps = {
} & PortfolioValueProps } & PortfolioValueProps
type PortfolioValueProps = { type PortfolioValueProps = {
address: Address
isPortfolioValueLoading: boolean isPortfolioValueLoading: boolean
portfolioValue: number | undefined portfolioValue: number | undefined
} }
function PortfolioValue({ function PortfolioValue({
address,
isPortfolioValueLoading, isPortfolioValueLoading,
portfolioValue, portfolioValue: providedPortfolioValue,
}: PortfolioValueProps): JSX.Element { }: PortfolioValueProps): JSX.Element {
const isLoading = isPortfolioValueLoading && portfolioValue === undefined const { t } = useTranslation()
const { convertFiatAmountFormatted } = useLocalizationContext() const { convertFiatAmountFormatted } = useLocalizationContext()
// When we add a new wallet, we'll make a new network request to fetch all accounts as a single request.
// Since we're adding a new wallet address to the `ownerAddresses` array, this will be a brand new query, which won't be cached.
// To avoid all wallets showing a "loading" state, we read directly from cache while we wait for the other query to complete.
const { data } = useAccountList({
fetchPolicy: 'cache-first',
addresses: address,
})
const cachedPortfolioValue = data?.portfolios?.[0]?.tokensTotalDenominatedValue?.value
const portfolioValue = providedPortfolioValue ?? cachedPortfolioValue
const isLoading = isPortfolioValueLoading && portfolioValue === undefined
return ( return (
<Text <Text color="$neutral2" loading={isLoading} variant="subheading2">
color="$neutral2" {portfolioValue
loading={isLoading} ? convertFiatAmountFormatted(portfolioValue, NumberType.PortfolioBalance)
loadingPlaceholderText="0000.00" : t('N/A')}
variant="subheading2">
{convertFiatAmountFormatted(portfolioValue, NumberType.PortfolioBalance)}
</Text> </Text>
) )
} }
...@@ -126,6 +142,7 @@ export function AccountCardItem({ ...@@ -126,6 +142,7 @@ export function AccountCardItem({
/> />
</Flex> </Flex>
<PortfolioValue <PortfolioValue
address={address}
isPortfolioValueLoading={isPortfolioValueLoading} isPortfolioValueLoading={isPortfolioValueLoading}
portfolioValue={portfolioValue} portfolioValue={portfolioValue}
/> />
......
query AccountList($addresses: [String!]!) { query AccountList(
portfolios(ownerAddresses: $addresses, chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]) { $addresses: [String!]!
$valueModifiers: [PortfolioValueModifier!]
) {
portfolios(
ownerAddresses: $addresses
chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]
valueModifiers: $valueModifiers
) {
id id
ownerAddress ownerAddress
tokensTotalDenominatedValue { tokensTotalDenominatedValue {
......
...@@ -20,6 +20,15 @@ const mock: MockedResponse<AccountListQuery> = { ...@@ -20,6 +20,15 @@ const mock: MockedResponse<AccountListQuery> = {
query: AccountListDocument, query: AccountListDocument,
variables: { variables: {
addresses: [account.address], addresses: [account.address],
valueModifiers: [
{
ownerAddress: account.address,
tokenIncludeOverrides: [],
tokenExcludeOverrides: [],
includeSmallBalances: false,
includeSpamTokens: false,
},
],
}, },
}, },
result: { result: {
......
...@@ -3,13 +3,13 @@ import { ComponentProps, default as React, useCallback, useMemo } from 'react' ...@@ -3,13 +3,13 @@ import { ComponentProps, default as React, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native' import { StyleSheet } from 'react-native'
import { AccountCardItem } from 'src/components/accounts/AccountCardItem' import { AccountCardItem } from 'src/components/accounts/AccountCardItem'
import { useAccountList } from 'src/components/accounts/hooks'
import { VirtualizedList } from 'src/components/layout/VirtualizedList' import { VirtualizedList } from 'src/components/layout/VirtualizedList'
import { Flex, Text, useSporeColors } from 'ui/src' import { Flex, Text, useSporeColors } from 'ui/src'
import { opacify, spacing } from 'ui/src/theme' import { opacify, spacing } from 'ui/src/theme'
import { useAsyncData } from 'utilities/src/react/hooks' import { useAsyncData } from 'utilities/src/react/hooks'
import { PollingInterval } from 'wallet/src/constants/misc' import { PollingInterval } from 'wallet/src/constants/misc'
import { isNonPollingRequestInFlight } from 'wallet/src/data/utils' import { isNonPollingRequestInFlight } from 'wallet/src/data/utils'
import { useAccountListQuery } from 'wallet/src/data/__generated__/types-and-hooks'
import { Account, AccountType } from 'wallet/src/features/wallet/accounts/types' import { Account, AccountType } from 'wallet/src/features/wallet/accounts/types'
// Most screens can fit more but this is set conservatively // Most screens can fit more but this is set conservatively
...@@ -50,10 +50,10 @@ const SignerHeader = (): JSX.Element => { ...@@ -50,10 +50,10 @@ const SignerHeader = (): JSX.Element => {
export function AccountList({ accounts, onPress, isVisible }: AccountListProps): JSX.Element { export function AccountList({ accounts, onPress, isVisible }: AccountListProps): JSX.Element {
const colors = useSporeColors() const colors = useSporeColors()
const addresses = accounts.map((a) => a.address) const addresses = useMemo(() => accounts.map((a) => a.address), [accounts])
const { data, networkStatus, refetch, startPolling, stopPolling } = useAccountListQuery({ const { data, networkStatus, refetch, startPolling, stopPolling } = useAccountList({
variables: { addresses }, addresses,
notifyOnNetworkStatusChange: true, notifyOnNetworkStatusChange: true,
}) })
...@@ -71,13 +71,13 @@ export function AccountList({ accounts, onPress, isVisible }: AccountListProps): ...@@ -71,13 +71,13 @@ export function AccountList({ accounts, onPress, isVisible }: AccountListProps):
const isPortfolioValueLoading = isNonPollingRequestInFlight(networkStatus) const isPortfolioValueLoading = isNonPollingRequestInFlight(networkStatus)
const accountsWithPortfolioValue = useMemo(() => { const accountsWithPortfolioValue: AccountWithPortfolioValue[] = useMemo(() => {
return accounts.map((account, i) => { return accounts.map((account, i) => {
return { return {
account, account,
isPortfolioValueLoading, isPortfolioValueLoading,
portfolioValue: data?.portfolios?.[i]?.tokensTotalDenominatedValue?.value, portfolioValue: data?.portfolios?.[i]?.tokensTotalDenominatedValue?.value,
} as AccountWithPortfolioValue }
}) })
}, [accounts, data, isPortfolioValueLoading]) }, [accounts, data, isPortfolioValueLoading])
...@@ -97,15 +97,18 @@ export function AccountList({ accounts, onPress, isVisible }: AccountListProps): ...@@ -97,15 +97,18 @@ export function AccountList({ accounts, onPress, isVisible }: AccountListProps):
const hasViewOnlyAccounts = viewOnlyAccounts.length > 0 const hasViewOnlyAccounts = viewOnlyAccounts.length > 0
const renderAccountCardItem = (item: AccountWithPortfolioValue): JSX.Element => ( const renderAccountCardItem = useCallback(
<AccountCardItem (item: AccountWithPortfolioValue): JSX.Element => (
key={item.account.address} <AccountCardItem
address={item.account.address} key={item.account.address}
isPortfolioValueLoading={item.isPortfolioValueLoading} address={item.account.address}
isViewOnly={item.account.type === AccountType.Readonly} isPortfolioValueLoading={item.isPortfolioValueLoading}
portfolioValue={item.portfolioValue} isViewOnly={item.account.type === AccountType.Readonly}
onPress={onPress} portfolioValue={item.portfolioValue}
/> onPress={onPress}
/>
),
[onPress]
) )
return ( return (
......
import { NetworkStatus, WatchQueryFetchPolicy } from '@apollo/client'
import {
AccountListQuery,
// eslint-disable-next-line no-restricted-imports
useAccountListQuery,
} from 'wallet/src/data/__generated__/types-and-hooks'
// eslint-disable-next-line no-restricted-imports
import { usePortfolioValueModifiers } from 'wallet/src/features/dataApi/balances'
import { GqlResult } from 'wallet/src/features/dataApi/types'
export function useAccountList({
addresses,
fetchPolicy,
notifyOnNetworkStatusChange,
}: {
addresses: Address | Address[]
fetchPolicy?: WatchQueryFetchPolicy
notifyOnNetworkStatusChange?: boolean | undefined
}): GqlResult<AccountListQuery> & {
startPolling: (pollInterval: number) => void
stopPolling: () => void
networkStatus: NetworkStatus
refetch: () => void
} {
const valueModifiers = usePortfolioValueModifiers(addresses)
const { data, loading, networkStatus, refetch, startPolling, stopPolling } = useAccountListQuery({
variables: { addresses, valueModifiers },
notifyOnNetworkStatusChange,
fetchPolicy,
})
return {
data,
loading,
networkStatus,
refetch,
startPolling,
stopPolling,
}
}
import React from 'react' import React from 'react'
import { Switch as BaseSwitch, SwitchProps, ViewProps } from 'react-native' import { Switch as BaseSwitch, SwitchProps, ViewProps } from 'react-native'
import { IS_ANDROID } from 'src/constants/globals'
import { Flex, useSporeColors } from 'ui/src' import { Flex, useSporeColors } from 'ui/src'
import { isAndroid } from 'wallet/src/utils/platform'
// TODO(MOB-1518) change to tamagui ui/src Switch // TODO(MOB-1518) change to tamagui ui/src Switch
...@@ -16,7 +16,7 @@ type Props = { ...@@ -16,7 +16,7 @@ type Props = {
export function Switch({ value, onValueChange, disabled, ...rest }: Props): JSX.Element { export function Switch({ value, onValueChange, disabled, ...rest }: Props): JSX.Element {
const colors = useSporeColors() const colors = useSporeColors()
const falseThumbColor = IS_ANDROID ? colors.neutral3.val : colors.surface1.val const falseThumbColor = isAndroid ? colors.neutral3.val : colors.surface1.val
const trackColor = colors.accentSoft.val const trackColor = colors.accentSoft.val
return ( return (
......
...@@ -5,9 +5,9 @@ import { runOnJS } from 'react-native-reanimated' ...@@ -5,9 +5,9 @@ import { runOnJS } from 'react-native-reanimated'
import { OnboardingStackBaseParams, useOnboardingStackNavigation } from 'src/app/navigation/types' import { OnboardingStackBaseParams, useOnboardingStackNavigation } from 'src/app/navigation/types'
import { CloseButton } from 'src/components/buttons/CloseButton' import { CloseButton } from 'src/components/buttons/CloseButton'
import { CarouselContext } from 'src/components/carousel/Carousel' import { CarouselContext } from 'src/components/carousel/Carousel'
import { IS_ANDROID } from 'src/constants/globals'
import { OnboardingScreens } from 'src/screens/Screens' import { OnboardingScreens } from 'src/screens/Screens'
import { Flex, Text, useDeviceDimensions } from 'ui/src' import { Flex, Text, useDeviceDimensions } from 'ui/src'
import { isAndroid } from 'wallet/src/utils/platform'
function Page({ function Page({
text, text,
...@@ -113,7 +113,7 @@ export const SeedPhraseEducationContent = (params: OnboardingStackBaseParams): J ...@@ -113,7 +113,7 @@ export const SeedPhraseEducationContent = (params: OnboardingStackBaseParams): J
params={params} params={params}
text={ text={
<CustomHeadingText> <CustomHeadingText>
{IS_ANDROID ? ( {isAndroid ? (
<Trans> <Trans>
Instead of memorizing your recovery phrase, you can{' '} Instead of memorizing your recovery phrase, you can{' '}
<CustomHeadingText color="$accent1">back it up to Google Drive</CustomHeadingText> and <CustomHeadingText color="$accent1">back it up to Google Drive</CustomHeadingText> and
......
import { NetworkStatus } from '@apollo/client' import { NetworkStatus } from '@apollo/client'
import { BottomSheetFlatList } from '@gorhom/bottom-sheet' import React, { useCallback, useMemo, useRef } from 'react'
import React, { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ListRenderItem, ListRenderItemInfo } from 'react-native' import { ListRenderItem, ListRenderItemInfo, StyleSheet, View } from 'react-native'
import { useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated'
import { useAppSelector } from 'src/app/hooks' import { useAppSelector } from 'src/app/hooks'
import { FavoriteTokensGrid } from 'src/components/explore/FavoriteTokensGrid' import { FavoriteTokensGrid } from 'src/components/explore/FavoriteTokensGrid'
import { FavoriteWalletsGrid } from 'src/components/explore/FavoriteWalletsGrid' import { FavoriteWalletsGrid } from 'src/components/explore/FavoriteWalletsGrid'
import { SortButton } from 'src/components/explore/SortButton' import { SortButton } from 'src/components/explore/SortButton'
import { TokenItem, TokenItemData } from 'src/components/explore/TokenItem' import { TokenItem, TokenItemData } from 'src/components/explore/TokenItem'
import { AnimatedBottomSheetFlatList } from 'src/components/layout/AnimatedFlatList'
import { Loader } from 'src/components/loading' import { Loader } from 'src/components/loading'
import { AutoScrollProps } from 'src/components/sortableGrid'
import { import {
getClientTokensOrderByCompareFn, getClientTokensOrderByCompareFn,
getTokenMetadataDisplayType, getTokenMetadataDisplayType,
...@@ -36,12 +38,15 @@ import { areAddressesEqual } from 'wallet/src/utils/addresses' ...@@ -36,12 +38,15 @@ import { areAddressesEqual } from 'wallet/src/utils/addresses'
import { buildCurrencyId, buildNativeCurrencyId } from 'wallet/src/utils/currencyId' import { buildCurrencyId, buildNativeCurrencyId } from 'wallet/src/utils/currencyId'
type ExploreSectionsProps = { type ExploreSectionsProps = {
listRef?: React.MutableRefObject<null> listRef: React.MutableRefObject<null>
} }
export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element { export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element {
const { t } = useTranslation() const { t } = useTranslation()
const insets = useDeviceInsets() const insets = useDeviceInsets()
const scrollY = useSharedValue(0)
const headerRef = useRef<View>(null)
const visibleListHeight = useSharedValue(0)
// Top tokens sorting // Top tokens sorting
const orderBy = useAppSelector(selectTokensOrderBy) const orderBy = useAppSelector(selectTokensOrderBy)
...@@ -120,6 +125,10 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element ...@@ -120,6 +125,10 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element
await refetch() await refetch()
}, [refetch]) }, [refetch])
const scrollHandler = useAnimatedScrollHandler((e) => {
scrollY.value = e.contentOffset.y
})
// Use showLoading for showing full screen loading state // Use showLoading for showing full screen loading state
// Used in each section to ensure loading state layout matches loaded state // Used in each section to ensure loading state layout matches loaded state
const showLoading = (!hasAllData && isLoading) || (!!error && isLoading) const showLoading = (!hasAllData && isLoading) || (!!error && isLoading)
...@@ -137,39 +146,60 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element ...@@ -137,39 +146,60 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element
} }
return ( return (
<BottomSheetFlatList // Pass onLayout callback to the list wrapper component as it returned
ref={listRef} // incorrect values when it was passed to the list itself
ListEmptyComponent={ <Flex
<Flex mx="$spacing24" my="$spacing12"> fill
<Loader.Token repeat={5} /> onLayout={({
</Flex> nativeEvent: {
} layout: { height },
ListHeaderComponent={ },
<> }): void => {
<FavoritesSection showLoading={showLoading} /> visibleListHeight.value = height
<Flex }}>
row <AnimatedBottomSheetFlatList
alignItems="center" ref={listRef}
justifyContent="space-between" ListEmptyComponent={
mb="$spacing8" <Flex mx="$spacing24" my="$spacing12">
ml="$spacing16" <Loader.Token repeat={5} />
mr="$spacing12"
mt="$spacing16"
pl="$spacing4">
<Text color="$neutral2" variant="subheading2">
{t('Top tokens')}
</Text>
<SortButton orderBy={orderBy} />
</Flex> </Flex>
</> }
} ListHeaderComponent={
contentContainerStyle={{ paddingBottom: insets.bottom }} <Flex ref={headerRef}>
data={showLoading ? undefined : topTokenItems} <FavoritesSection
keyExtractor={tokenKey} containerRef={headerRef}
renderItem={renderItem} scrollY={scrollY}
showsHorizontalScrollIndicator={false} scrollableRef={listRef}
showsVerticalScrollIndicator={false} showLoading={showLoading}
/> visibleHeight={visibleListHeight}
/>
<Flex
row
alignItems="center"
justifyContent="space-between"
mb="$spacing8"
ml="$spacing16"
mr="$spacing12"
mt="$spacing16"
pl="$spacing4">
<Text color="$neutral2" variant="subheading2">
{t('Top tokens')}
</Text>
<SortButton orderBy={orderBy} />
</Flex>
</Flex>
}
ListHeaderComponentStyle={styles.foreground}
contentContainerStyle={{ paddingBottom: insets.bottom }}
data={showLoading ? undefined : topTokenItems}
keyExtractor={tokenKey}
renderItem={renderItem}
scrollEventThrottle={16}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
onScroll={scrollHandler}
/>
</Flex>
) )
} }
...@@ -204,16 +234,32 @@ function gqlTokenToTokenItemData( ...@@ -204,16 +234,32 @@ function gqlTokenToTokenItemData(
} as TokenItemData } as TokenItemData
} }
function FavoritesSection({ showLoading }: { showLoading: boolean }): JSX.Element | null { type FavoritesSectionProps = AutoScrollProps & {
showLoading: boolean
}
function FavoritesSection(props: FavoritesSectionProps): JSX.Element | null {
const hasFavoritedTokens = useAppSelector(selectHasFavoriteTokens) const hasFavoritedTokens = useAppSelector(selectHasFavoriteTokens)
const hasFavoritedWallets = useAppSelector(selectHasWatchedWallets) const hasFavoritedWallets = useAppSelector(selectHasWatchedWallets)
if (!hasFavoritedTokens && !hasFavoritedWallets) return null if (!hasFavoritedTokens && !hasFavoritedWallets) return null
return ( return (
<Flex bg="$transparent" gap="$spacing12" pb="$spacing12" pt="$spacing8" px="$spacing12"> <Flex
{hasFavoritedTokens && <FavoriteTokensGrid showLoading={showLoading} />} bg="$transparent"
{hasFavoritedWallets && <FavoriteWalletsGrid showLoading={showLoading} />} gap="$spacing12"
pb="$spacing12"
pt="$spacing8"
px="$spacing12"
zIndex={1}>
{hasFavoritedTokens && <FavoriteTokensGrid {...props} />}
{hasFavoritedWallets && <FavoriteWalletsGrid showLoading={props.showLoading} />}
</Flex> </Flex>
) )
} }
const styles = StyleSheet.create({
foreground: {
zIndex: 1,
},
})
...@@ -2,7 +2,15 @@ import { ImpactFeedbackStyle } from 'expo-haptics' ...@@ -2,7 +2,15 @@ import { ImpactFeedbackStyle } from 'expo-haptics'
import React, { memo, useCallback } from 'react' import React, { memo, useCallback } from 'react'
import { ViewProps } from 'react-native' import { ViewProps } from 'react-native'
import ContextMenu from 'react-native-context-menu-view' import ContextMenu from 'react-native-context-menu-view'
import { FadeIn, FadeOut } from 'react-native-reanimated' import {
FadeIn,
FadeOut,
interpolate,
SharedValue,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
import { useAppDispatch } from 'src/app/hooks' import { useAppDispatch } from 'src/app/hooks'
import { useExploreTokenContextMenu } from 'src/components/explore/hooks' import { useExploreTokenContextMenu } from 'src/components/explore/hooks'
import RemoveButton from 'src/components/explore/RemoveButton' import RemoveButton from 'src/components/explore/RemoveButton'
...@@ -11,7 +19,7 @@ import { useTokenDetailsNavigation } from 'src/components/TokenDetails/hooks' ...@@ -11,7 +19,7 @@ import { useTokenDetailsNavigation } from 'src/components/TokenDetails/hooks'
import { SectionName } from 'src/features/telemetry/constants' import { SectionName } from 'src/features/telemetry/constants'
import { disableOnPress } from 'src/utils/disableOnPress' import { disableOnPress } from 'src/utils/disableOnPress'
import { usePollOnFocusOnly } from 'src/utils/hooks' import { usePollOnFocusOnly } from 'src/utils/hooks'
import { AnimatedTouchableArea, Flex, Text } from 'ui/src' import { AnimatedFlex, AnimatedTouchableArea, Flex, Text } from 'ui/src'
import { borderRadii, imageSizes } from 'ui/src/theme' import { borderRadii, imageSizes } from 'ui/src/theme'
import { NumberType } from 'utilities/src/format/types' import { NumberType } from 'utilities/src/format/types'
import { BaseCard } from 'wallet/src/components/BaseCard/BaseCard' import { BaseCard } from 'wallet/src/components/BaseCard/BaseCard'
...@@ -32,18 +40,24 @@ export const FAVORITE_TOKEN_CARD_LOADER_HEIGHT = 114 ...@@ -32,18 +40,24 @@ export const FAVORITE_TOKEN_CARD_LOADER_HEIGHT = 114
type FavoriteTokenCardProps = { type FavoriteTokenCardProps = {
currencyId: string currencyId: string
isEditing?: boolean isEditing?: boolean
isTouched: SharedValue<boolean>
dragActivationProgress: SharedValue<number>
setIsEditing: (update: boolean) => void setIsEditing: (update: boolean) => void
} & ViewProps } & ViewProps
function FavoriteTokenCard({ function FavoriteTokenCard({
currencyId, currencyId,
isEditing, isEditing,
isTouched,
dragActivationProgress,
setIsEditing, setIsEditing,
...rest ...rest
}: FavoriteTokenCardProps): JSX.Element { }: FavoriteTokenCardProps): JSX.Element {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const tokenDetailsNavigation = useTokenDetailsNavigation() const tokenDetailsNavigation = useTokenDetailsNavigation()
const { convertFiatAmountFormatted } = useLocalizationContext() const { convertFiatAmountFormatted } = useLocalizationContext()
const dragAnimationProgress = useSharedValue(0)
const wasTouched = useSharedValue(false)
const { data, networkStatus, startPolling, stopPolling } = useFavoriteTokenCardQuery({ const { data, networkStatus, startPolling, stopPolling } = useFavoriteTokenCardQuery({
variables: currencyIdToContractInput(currencyId), variables: currencyIdToContractInput(currencyId),
...@@ -88,60 +102,97 @@ function FavoriteTokenCard({ ...@@ -88,60 +102,97 @@ function FavoriteTokenCard({
tokenDetailsNavigation.navigate(currencyId) tokenDetailsNavigation.navigate(currencyId)
} }
useAnimatedReaction(
() => dragActivationProgress.value,
(activationProgress, prev) => {
const prevActivationProgress = prev ?? 0
// If the activation progress is increasing (the user is touching one of the cards)
if (activationProgress > prevActivationProgress) {
if (isTouched.value) {
// If the current card is the one being touched, reset the animation progress
wasTouched.value = true
dragAnimationProgress.value = 0
} else {
// Otherwise, animate the card
wasTouched.value = false
dragAnimationProgress.value = activationProgress
}
}
// If the activation progress is decreasing (the user is no longer touching one of the cards)
else {
if (isTouched.value || wasTouched.value) {
// If the current card is the one that was being touched, reset the animation progress
dragAnimationProgress.value = 0
} else {
// Otherwise, animate the card
dragAnimationProgress.value = activationProgress
}
}
}
)
const animatedStyle = useAnimatedStyle(() => ({
opacity: interpolate(dragAnimationProgress.value, [0, 1], [1, 0.5]),
}))
if (isNonPollingRequestInFlight(networkStatus)) { if (isNonPollingRequestInFlight(networkStatus)) {
return <Loader.Favorite height={FAVORITE_TOKEN_CARD_LOADER_HEIGHT} /> return <Loader.Favorite height={FAVORITE_TOKEN_CARD_LOADER_HEIGHT} />
} }
return ( return (
<ContextMenu <AnimatedFlex style={animatedStyle}>
actions={menuActions} <ContextMenu
disabled={isEditing} actions={menuActions}
style={{ borderRadius: borderRadii.rounded16 }} disabled={isEditing}
onPress={onContextMenuPress} style={{ borderRadius: borderRadii.rounded16 }}
{...rest}> onPress={onContextMenuPress}
<AnimatedTouchableArea {...rest}>
hapticFeedback <AnimatedTouchableArea
borderRadius="$rounded16" activeOpacity={isEditing ? 1 : undefined}
entering={FadeIn} bg="$surface2"
exiting={FadeOut} borderRadius="$rounded16"
hapticStyle={ImpactFeedbackStyle.Light} entering={FadeIn}
m="$spacing4" exiting={FadeOut}
testID={`token-box-${token?.symbol}`} hapticFeedback={!isEditing}
onLongPress={disableOnPress} hapticStyle={ImpactFeedbackStyle.Light}
onPress={onPress}> m="$spacing4"
<BaseCard.Shadow> testID={`token-box-${token?.symbol}`}
<Flex alignItems="flex-start" gap="$spacing8"> onLongPress={disableOnPress}
<Flex row gap="$spacing4" justifyContent="space-between"> onPress={onPress}>
<Flex grow row alignItems="center" gap="$spacing8"> <BaseCard.Shadow>
<TokenLogo <Flex alignItems="flex-start" gap="$spacing8">
chainId={chainId ?? undefined} <Flex row gap="$spacing4" justifyContent="space-between">
size={imageSizes.image20} <Flex grow row alignItems="center" gap="$spacing8">
symbol={token?.symbol ?? undefined} <TokenLogo
url={token?.project?.logoUrl ?? undefined} chainId={chainId ?? undefined}
size={imageSizes.image20}
symbol={token?.symbol ?? undefined}
url={token?.project?.logoUrl ?? undefined}
/>
<Text variant="body1">{getSymbolDisplayText(token?.symbol)}</Text>
</Flex>
{isEditing ? (
<RemoveButton onPress={onRemove} />
) : (
<Flex height={imageSizes.image24} />
)}
</Flex>
<Flex gap="$spacing2">
<Text adjustsFontSizeToFit numberOfLines={1} variant="heading3">
{price}
</Text>
<RelativeChange
arrowSize="$icon.16"
change={pricePercentChange ?? undefined}
semanticColor={true}
variant="subheading2"
/> />
<Text variant="body1">{getSymbolDisplayText(token?.symbol)}</Text>
</Flex> </Flex>
{isEditing ? (
<RemoveButton onPress={onRemove} />
) : (
<Flex height={imageSizes.image24} />
)}
</Flex>
<Flex gap="$spacing2">
<Text adjustsFontSizeToFit numberOfLines={1} variant="heading3">
{price}
</Text>
<RelativeChange
arrowSize="$icon.16"
change={pricePercentChange ?? undefined}
semanticColor={true}
variant="subheading2"
/>
</Flex> </Flex>
</Flex> </BaseCard.Shadow>
</BaseCard.Shadow> </AnimatedTouchableArea>
</AnimatedTouchableArea> </ContextMenu>
</ContextMenu> </AnimatedFlex>
) )
} }
......
import React, { useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { FadeIn } from 'react-native-reanimated' import { FadeIn, useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
import { useAppSelector } from 'src/app/hooks' import { useAppSelector } from 'src/app/hooks'
import { FavoriteHeaderRow } from 'src/components/explore/FavoriteHeaderRow' import { FavoriteHeaderRow } from 'src/components/explore/FavoriteHeaderRow'
import FavoriteTokenCard, { import FavoriteTokenCard, {
FAVORITE_TOKEN_CARD_LOADER_HEIGHT, FAVORITE_TOKEN_CARD_LOADER_HEIGHT,
} from 'src/components/explore/FavoriteTokenCard' } from 'src/components/explore/FavoriteTokenCard'
import { Loader } from 'src/components/loading' import { Loader } from 'src/components/loading'
import {
AutoScrollProps,
SortableGrid,
SortableGridChangeEvent,
SortableGridRenderItem,
} from 'src/components/sortableGrid'
import { AnimatedFlex, Flex } from 'ui/src' import { AnimatedFlex, Flex } from 'ui/src'
import { selectFavoriteTokens } from 'wallet/src/features/favorites/selectors' import { selectFavoriteTokens } from 'wallet/src/features/favorites/selectors'
import { setFavoriteTokens } from 'wallet/src/features/favorites/slice'
import { useAppDispatch } from 'wallet/src/state'
const NUM_COLUMNS = 2 const NUM_COLUMNS = 2
const ITEM_FLEX = { flex: 1 / NUM_COLUMNS } const ITEM_FLEX = { flex: 1 / NUM_COLUMNS }
const HALF_WIDTH = { width: '50%' }
type FavoriteTokensGridProps = AutoScrollProps & {
showLoading: boolean
}
/** Renders the favorite tokens section on the Explore tab */ /** Renders the favorite tokens section on the Explore tab */
export function FavoriteTokensGrid({ showLoading }: { showLoading: boolean }): JSX.Element | null { export function FavoriteTokensGrid({
showLoading,
...rest
}: FavoriteTokensGridProps): JSX.Element | null {
const { t } = useTranslation() const { t } = useTranslation()
const dispatch = useAppDispatch()
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const isTokenDragged = useSharedValue(false)
const favoriteCurrencyIds = useAppSelector(selectFavoriteTokens) const favoriteCurrencyIds = useAppSelector(selectFavoriteTokens)
// Reset edit mode when there are no favorite tokens // Reset edit mode when there are no favorite tokens
...@@ -28,8 +44,33 @@ export function FavoriteTokensGrid({ showLoading }: { showLoading: boolean }): J ...@@ -28,8 +44,33 @@ export function FavoriteTokensGrid({ showLoading }: { showLoading: boolean }): J
} }
}, [favoriteCurrencyIds.length]) }, [favoriteCurrencyIds.length])
const handleOrderChange = useCallback(
({ data }: SortableGridChangeEvent<string>) => {
dispatch(setFavoriteTokens({ currencyIds: data }))
},
[dispatch]
)
const renderItem = useCallback<SortableGridRenderItem<string>>(
({ item: currencyId, isTouched, dragActivationProgress }): JSX.Element => (
<FavoriteTokenCard
key={currencyId}
currencyId={currencyId}
dragActivationProgress={dragActivationProgress}
isEditing={isEditing}
isTouched={isTouched}
setIsEditing={setIsEditing}
/>
),
[isEditing]
)
const animatedStyle = useAnimatedStyle(() => ({
zIndex: isTokenDragged.value ? 1 : 0,
}))
return ( return (
<AnimatedFlex entering={FadeIn}> <AnimatedFlex entering={FadeIn} style={animatedStyle}>
<FavoriteHeaderRow <FavoriteHeaderRow
editingTitle={t('Edit favorite tokens')} editingTitle={t('Edit favorite tokens')}
isEditing={isEditing} isEditing={isEditing}
...@@ -39,17 +80,21 @@ export function FavoriteTokensGrid({ showLoading }: { showLoading: boolean }): J ...@@ -39,17 +80,21 @@ export function FavoriteTokensGrid({ showLoading }: { showLoading: boolean }): J
{showLoading ? ( {showLoading ? (
<FavoriteTokensGridLoader /> <FavoriteTokensGridLoader />
) : ( ) : (
<Flex row flexWrap="wrap"> <SortableGrid
{favoriteCurrencyIds.map((currencyId) => ( {...rest}
<FavoriteTokenCard activeItemOpacity={1}
key={currencyId} data={favoriteCurrencyIds}
currencyId={currencyId} editable={isEditing}
isEditing={isEditing} numColumns={NUM_COLUMNS}
setIsEditing={setIsEditing} renderItem={renderItem}
style={HALF_WIDTH} onChange={handleOrderChange}
/> onDragEnd={(): void => {
))} isTokenDragged.value = false
</Flex> }}
onDragStart={(): void => {
isTokenDragged.value = true
}}
/>
)} )}
</AnimatedFlex> </AnimatedFlex>
) )
......
...@@ -10,6 +10,7 @@ import { useTimeout } from 'utilities/src/time/timing' ...@@ -10,6 +10,7 @@ import { useTimeout } from 'utilities/src/time/timing'
import { useIsDarkMode } from 'wallet/src/features/appearance/hooks' import { useIsDarkMode } from 'wallet/src/features/appearance/hooks'
import { Language } from 'wallet/src/features/language/constants' import { Language } from 'wallet/src/features/language/constants'
import { useCurrentLanguage } from 'wallet/src/features/language/hooks' import { useCurrentLanguage } from 'wallet/src/features/language/hooks'
import { isAndroid } from 'wallet/src/utils/platform'
const stateMachineName = 'State Machine 1' const stateMachineName = 'State Machine 1'
...@@ -82,7 +83,7 @@ export const LandingBackground = (): JSX.Element | null => { ...@@ -82,7 +83,7 @@ export const LandingBackground = (): JSX.Element | null => {
} }
// Android 9 and 10 have issues with Rive, so we fallback on image // Android 9 and 10 have issues with Rive, so we fallback on image
if ((Platform.OS === 'android' && Platform.Version < 30) || language !== Language.English) { if ((isAndroid && Platform.Version < 30) || language !== Language.English) {
return <OnboardingStaticImage /> return <OnboardingStaticImage />
} }
......
...@@ -12,7 +12,6 @@ import { ...@@ -12,7 +12,6 @@ import {
import { TabProps, TAB_BAR_HEIGHT } from 'src/components/layout/TabHelpers' import { TabProps, TAB_BAR_HEIGHT } from 'src/components/layout/TabHelpers'
import { Loader } from 'src/components/loading' import { Loader } from 'src/components/loading'
import { ScannerModalState } from 'src/components/QRCodeScanner/constants' import { ScannerModalState } from 'src/components/QRCodeScanner/constants'
import { IS_ANDROID } from 'src/constants/globals'
import { openModal } from 'src/features/modals/modalSlice' import { openModal } from 'src/features/modals/modalSlice'
import { ModalName } from 'src/features/telemetry/constants' import { ModalName } from 'src/features/telemetry/constants'
import { import {
...@@ -33,6 +32,7 @@ import { ...@@ -33,6 +32,7 @@ import {
useActiveAccountWithThrow, useActiveAccountWithThrow,
useSelectAccountHideSpamTokens, useSelectAccountHideSpamTokens,
} from 'wallet/src/features/wallet/hooks' } from 'wallet/src/features/wallet/hooks'
import { isAndroid } from 'wallet/src/utils/platform'
export const ACTIVITY_TAB_DATA_DEPENDENCIES = [GQLQueries.TransactionList] export const ACTIVITY_TAB_DATA_DEPENDENCIES = [GQLQueries.TransactionList]
...@@ -148,7 +148,7 @@ export const ActivityTab = memo( ...@@ -148,7 +148,7 @@ export const ActivityTab = memo(
return ( return (
<RefreshControl <RefreshControl
progressViewOffset={ progressViewOffset={
insets.top + (IS_ANDROID && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0) insets.top + (isAndroid && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0)
} }
refreshing={refreshing ?? false} refreshing={refreshing ?? false}
tintColor={colors.neutral3.get()} tintColor={colors.neutral3.get()}
......
...@@ -9,7 +9,6 @@ import { AnimatedFlatList } from 'src/components/layout/AnimatedFlatList' ...@@ -9,7 +9,6 @@ import { AnimatedFlatList } from 'src/components/layout/AnimatedFlatList'
import { TabProps, TAB_BAR_HEIGHT } from 'src/components/layout/TabHelpers' import { TabProps, TAB_BAR_HEIGHT } from 'src/components/layout/TabHelpers'
import { Loader } from 'src/components/loading' import { Loader } from 'src/components/loading'
import { ScannerModalState } from 'src/components/QRCodeScanner/constants' import { ScannerModalState } from 'src/components/QRCodeScanner/constants'
import { IS_ANDROID } from 'src/constants/globals'
import { openModal } from 'src/features/modals/modalSlice' import { openModal } from 'src/features/modals/modalSlice'
import { ModalName } from 'src/features/telemetry/constants' import { ModalName } from 'src/features/telemetry/constants'
import TransactionSummaryLayout from 'src/features/transactions/SummaryCards/TransactionSummaryLayout' import TransactionSummaryLayout from 'src/features/transactions/SummaryCards/TransactionSummaryLayout'
...@@ -24,6 +23,7 @@ import { ...@@ -24,6 +23,7 @@ import {
useActiveAccountWithThrow, useActiveAccountWithThrow,
useSelectAccountHideSpamTokens, useSelectAccountHideSpamTokens,
} from 'wallet/src/features/wallet/hooks' } from 'wallet/src/features/wallet/hooks'
import { isAndroid } from 'wallet/src/utils/platform'
export const FEED_TAB_DATA_DEPENDENCIES = [GQLQueries.FeedTransactionList] export const FEED_TAB_DATA_DEPENDENCIES = [GQLQueries.FeedTransactionList]
...@@ -107,7 +107,7 @@ export const FeedTab = memo( ...@@ -107,7 +107,7 @@ export const FeedTab = memo(
return ( return (
<RefreshControl <RefreshControl
progressViewOffset={ progressViewOffset={
insets.top + (IS_ANDROID && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0) insets.top + (isAndroid && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0)
} }
refreshing={refreshing ?? false} refreshing={refreshing ?? false}
tintColor={colors.neutral3.get()} tintColor={colors.neutral3.get()}
......
...@@ -7,7 +7,6 @@ import { useAdaptiveFooter } from 'src/components/home/hooks' ...@@ -7,7 +7,6 @@ import { useAdaptiveFooter } from 'src/components/home/hooks'
import { TabProps, TAB_BAR_HEIGHT } from 'src/components/layout/TabHelpers' import { TabProps, TAB_BAR_HEIGHT } from 'src/components/layout/TabHelpers'
import { NftView } from 'src/components/NFT/NftView' import { NftView } from 'src/components/NFT/NftView'
import { ScannerModalState } from 'src/components/QRCodeScanner/constants' import { ScannerModalState } from 'src/components/QRCodeScanner/constants'
import { IS_ANDROID } from 'src/constants/globals'
import { openModal } from 'src/features/modals/modalSlice' import { openModal } from 'src/features/modals/modalSlice'
import { ModalName } from 'src/features/telemetry/constants' import { ModalName } from 'src/features/telemetry/constants'
import { removePendingSession } from 'src/features/walletConnect/walletConnectSlice' import { removePendingSession } from 'src/features/walletConnect/walletConnectSlice'
...@@ -16,6 +15,7 @@ import { Flex, useDeviceInsets, useSporeColors } from 'ui/src' ...@@ -16,6 +15,7 @@ import { Flex, useDeviceInsets, useSporeColors } from 'ui/src'
import { NftsList } from 'wallet/src/components/nfts/NftsList' import { NftsList } from 'wallet/src/components/nfts/NftsList'
import { GQLQueries } from 'wallet/src/data/queries' import { GQLQueries } from 'wallet/src/data/queries'
import { NFTItem } from 'wallet/src/features/nfts/types' import { NFTItem } from 'wallet/src/features/nfts/types'
import { isAndroid } from 'wallet/src/utils/platform'
export const NFTS_TAB_DATA_DEPENDENCIES = [GQLQueries.NftsTab] export const NFTS_TAB_DATA_DEPENDENCIES = [GQLQueries.NftsTab]
...@@ -71,7 +71,7 @@ export const NftsTab = memo( ...@@ -71,7 +71,7 @@ export const NftsTab = memo(
return ( return (
<RefreshControl <RefreshControl
progressViewOffset={ progressViewOffset={
insets.top + (IS_ANDROID && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0) insets.top + (isAndroid && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0)
} }
refreshing={refreshing ?? false} refreshing={refreshing ?? false}
tintColor={colors.neutral3.get()} tintColor={colors.neutral3.get()}
...@@ -95,7 +95,6 @@ export const NftsTab = memo( ...@@ -95,7 +95,6 @@ export const NftsTab = memo(
renderNFTItem={renderNFTItem} renderNFTItem={renderNFTItem}
renderedInModal={renderedInModal} renderedInModal={renderedInModal}
onContentSizeChange={onContentSizeChange} onContentSizeChange={onContentSizeChange}
onPressEmptyState={onPressScan}
onRefresh={onRefresh} onRefresh={onRefresh}
onScroll={scrollHandler} onScroll={scrollHandler}
{...containerProps} {...containerProps}
......
...@@ -1376,6 +1376,7 @@ exports[`ActivityTab renders without error 2`] = ` ...@@ -1376,6 +1376,7 @@ exports[`ActivityTab renders without error 2`] = `
{ {
"alignItems": "center", "alignItems": "center",
"flexDirection": "row", "flexDirection": "row",
"flexShrink": 1,
"gap": 4, "gap": 4,
} }
} }
...@@ -1732,6 +1733,7 @@ exports[`ActivityTab renders without error 2`] = ` ...@@ -1732,6 +1733,7 @@ exports[`ActivityTab renders without error 2`] = `
{ {
"alignItems": "center", "alignItems": "center",
"flexDirection": "row", "flexDirection": "row",
"flexShrink": 1,
"gap": 4, "gap": 4,
} }
} }
......
import React, { forwardRef, useCallback, useEffect, useMemo } from 'react' import React, { forwardRef, useCallback, useEffect, useMemo } from 'react'
import { AppState, Keyboard, KeyboardTypeOptions, TextInput as NativeTextInput } from 'react-native' import { AppState, Keyboard, KeyboardTypeOptions, TextInput as NativeTextInput } from 'react-native'
import { getNumberFormatSettings } from 'react-native-localize'
import { TextInput, TextInputProps } from 'src/components/input/TextInput' import { TextInput, TextInputProps } from 'src/components/input/TextInput'
import { useMoonpayFiatCurrencySupportInfo } from 'src/features/fiatOnRamp/hooks' import { useMoonpayFiatCurrencySupportInfo } from 'src/features/fiatOnRamp/hooks'
import { escapeRegExp } from 'utilities/src/primitives/string'
import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks' import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks'
import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
const inputRegex = RegExp('^\\d*(?:\\\\[.])?\\d*$') // match escaped "." characters via in a non-capturing group const numericInputRegex = RegExp('^\\d*(\\.\\d*)?$') // Matches only numeric values without commas
type Props = { type Props = {
showCurrencySign: boolean showCurrencySign: boolean
...@@ -21,64 +21,80 @@ export function replaceSeparators({ ...@@ -21,64 +21,80 @@ export function replaceSeparators({
decimalOverride, decimalOverride,
}: { }: {
value: string value: string
groupingSeparator: string groupingSeparator?: string
decimalSeparator: string decimalSeparator: string
groupingOverride: string groupingOverride?: string
decimalOverride: string decimalOverride: string
}): string { }): string {
return ( let outputParts = value.split(decimalSeparator)
value if (groupingSeparator && groupingOverride != null) {
.split(decimalSeparator) outputParts = outputParts.map((part) =>
// eslint-disable-next-line security/detect-non-literal-regexp // eslint-disable-next-line security/detect-non-literal-regexp
.map((part) => part.replace(new RegExp(groupingSeparator, 'g'), groupingOverride)) part.replace(new RegExp(`\\${groupingSeparator}`, 'g'), groupingOverride)
.join(decimalOverride) )
) }
return outputParts.join(decimalOverride)
} }
export const AmountInput = forwardRef<NativeTextInput, Props>(function _AmountInput( export const AmountInput = forwardRef<NativeTextInput, Props>(function _AmountInput(
{ onChangeText, value, showCurrencySign, dimTextColor, showSoftInputOnFocus, editable, ...rest }, { onChangeText, value, showCurrencySign, dimTextColor, showSoftInputOnFocus, ...rest },
ref ref
) { ) {
const { groupingSeparator, decimalSeparator } = useAppFiatCurrencyInfo() const { groupingSeparator, decimalSeparator } = useAppFiatCurrencyInfo()
const invalidInput = value && !numericInputRegex.test(value)
useEffect(() => {
// Resets input if non-numberic value is passed
if (invalidInput) {
onChangeText?.('')
}
}, [invalidInput, onChangeText, value])
const handleChange = useCallback( const handleChange = useCallback(
(text: string) => { (text: string) => {
const parsedText = replaceSeparators({ let parsedText = showCurrencySign ? text.substring(1) : text
value: showCurrencySign ? text.substring(1) : text, const { decimalSeparator: keyboardDecimalSeparator } = getNumberFormatSettings()
// TODO MOB-2385 replace this temporary solution for native keyboard
// Assuming showSoftInputOnFocus means that the native keyboard is used
if (showSoftInputOnFocus && keyboardDecimalSeparator !== decimalSeparator) {
parsedText = replaceSeparators({
value: parsedText,
decimalSeparator: keyboardDecimalSeparator,
decimalOverride: decimalSeparator,
})
}
parsedText = replaceSeparators({
value: parsedText,
groupingSeparator, groupingSeparator,
decimalSeparator, decimalSeparator,
groupingOverride: '', groupingOverride: '',
decimalOverride: '.', decimalOverride: '.',
}) })
if (parsedText === '' || inputRegex.test(escapeRegExp(parsedText))) { onChangeText?.(parsedText)
onChangeText?.(parsedText)
}
}, },
[decimalSeparator, groupingSeparator, onChangeText, showCurrencySign] [decimalSeparator, groupingSeparator, onChangeText, showCurrencySign, showSoftInputOnFocus]
) )
const { moonpaySupportedFiatCurrency: currency } = useMoonpayFiatCurrencySupportInfo() const { moonpaySupportedFiatCurrency: currency } = useMoonpayFiatCurrencySupportInfo()
const { addFiatSymbolToNumber } = useLocalizationContext() const { addFiatSymbolToNumber } = useLocalizationContext()
let formattedValue = showCurrencySign let formattedValue = replaceSeparators({
value: value ?? '',
groupingSeparator: ',',
decimalSeparator: '.',
groupingOverride: '',
decimalOverride: decimalSeparator,
})
formattedValue = showCurrencySign
? addFiatSymbolToNumber({ ? addFiatSymbolToNumber({
value, value: formattedValue,
currencyCode: currency.code, currencyCode: currency.code,
currencySymbol: currency.symbol, currencySymbol: currency.symbol,
}) })
: value : formattedValue
// TODO gary MOB-2028 replace temporary hack to handle different separators
formattedValue =
editable ?? true
? replaceSeparators({
value: formattedValue ?? '',
groupingSeparator: ',',
decimalSeparator: '.',
groupingOverride: groupingSeparator,
decimalOverride: decimalSeparator,
})
: formattedValue
const textInputProps: TextInputProps = useMemo( const textInputProps: TextInputProps = useMemo(
() => ({ () => ({
......
...@@ -64,25 +64,23 @@ export function SeedPhraseDisplay({ ...@@ -64,25 +64,23 @@ export function SeedPhraseDisplay({
return ( return (
<> <>
{showSeedPhrase ? ( <Flex grow mt="$spacing16">
<Flex grow mt="$spacing16"> {showSeedPhrase ? (
<Flex grow pt="$spacing16" px="$spacing16"> <Flex grow pt="$spacing16" px="$spacing16">
<MnemonicDisplay mnemonicId={mnemonicId} /> <MnemonicDisplay mnemonicId={mnemonicId} />
</Flex> </Flex>
<Flex borderTopColor="$surface3" borderTopWidth={1} pt="$spacing12" px="$spacing16"> ) : (
<Button <HiddenMnemonicWordView />
testID={ElementName.Next} )}
theme="secondary" </Flex>
onPress={(): void => { <Flex borderTopColor="$surface3" borderTopWidth={1} pt="$spacing12" px="$spacing16">
setShowSeedPhrase(false) <Button
}}> testID={ElementName.Next}
{t('Hide recovery phrase')} theme="secondary"
</Button> onPress={(): void => setShowSeedPhrase(!showSeedPhrase)}>
</Flex> {showSeedPhrase ? t('Hide recovery phrase') : t('Show recovery phrase')}
</Flex> </Button>
) : ( </Flex>
<HiddenMnemonicWordView />
)}
{showSeedPhraseViewWarningModal && ( {showSeedPhraseViewWarningModal && (
<WarningModal <WarningModal
......
...@@ -34,7 +34,6 @@ import Animated, { ...@@ -34,7 +34,6 @@ import Animated, {
import { BottomSheetContextProvider } from 'src/components/modals/BottomSheetContext' import { BottomSheetContextProvider } from 'src/components/modals/BottomSheetContext'
import { HandleBar } from 'src/components/modals/HandleBar' import { HandleBar } from 'src/components/modals/HandleBar'
import Trace from 'src/components/Trace/Trace' import Trace from 'src/components/Trace/Trace'
import { IS_ANDROID, IS_IOS } from 'src/constants/globals'
import { ModalName } from 'src/features/telemetry/constants' import { ModalName } from 'src/features/telemetry/constants'
import { useKeyboardLayout } from 'src/utils/useKeyboardLayout' import { useKeyboardLayout } from 'src/utils/useKeyboardLayout'
import { import {
...@@ -47,6 +46,7 @@ import { ...@@ -47,6 +46,7 @@ import {
} from 'ui/src' } from 'ui/src'
import { borderRadii, spacing } from 'ui/src/theme' import { borderRadii, spacing } from 'ui/src/theme'
import { useIsDarkMode } from 'wallet/src/features/appearance/hooks' import { useIsDarkMode } from 'wallet/src/features/appearance/hooks'
import { isAndroid, isIOS } from 'wallet/src/utils/platform'
/** /**
* (android only) * (android only)
...@@ -220,7 +220,7 @@ export const BottomSheetModal = forwardRef<BottomSheetModalRef, Props>(function ...@@ -220,7 +220,7 @@ export const BottomSheetModal = forwardRef<BottomSheetModalRef, Props>(function
const renderBlurredBg = useCallback( const renderBlurredBg = useCallback(
() => ( () => (
<Animated.View style={[blurViewStyle.base, animatedBorderRadius]}> <Animated.View style={[blurViewStyle.base, animatedBorderRadius]}>
{IS_IOS ? ( {isIOS ? (
<BlurView <BlurView
intensity={90} intensity={90}
style={blurViewStyle.base} style={blurViewStyle.base}
...@@ -313,7 +313,7 @@ export const BottomSheetModal = forwardRef<BottomSheetModalRef, Props>(function ...@@ -313,7 +313,7 @@ export const BottomSheetModal = forwardRef<BottomSheetModalRef, Props>(function
// This is required for android to make scrollable containers work // This is required for android to make scrollable containers work
// and allow closing the modal by dragging the content // and allow closing the modal by dragging the content
// (adding this property on iOS breaks closing the modal by dragging the content) // (adding this property on iOS breaks closing the modal by dragging the content)
activeOffsetY={IS_ANDROID ? [-DRAG_ACTIVATION_OFFSET, DRAG_ACTIVATION_OFFSET] : undefined} activeOffsetY={isAndroid ? [-DRAG_ACTIVATION_OFFSET, DRAG_ACTIVATION_OFFSET] : undefined}
animatedPosition={animatedPosition} animatedPosition={animatedPosition}
backgroundStyle={backgroundStyle} backgroundStyle={backgroundStyle}
containerComponent={containerComponent} containerComponent={containerComponent}
...@@ -392,7 +392,7 @@ export function BottomSheetDetachedModal({ ...@@ -392,7 +392,7 @@ export function BottomSheetDetachedModal({
// This is required for android to make scrollable containers work // This is required for android to make scrollable containers work
// and allow closing the modal by dragging the content // and allow closing the modal by dragging the content
// (adding this property on iOS breaks closing the modal by dragging the content) // (adding this property on iOS breaks closing the modal by dragging the content)
activeOffsetY={IS_ANDROID ? [-DRAG_ACTIVATION_OFFSET, DRAG_ACTIVATION_OFFSET] : undefined} activeOffsetY={isAndroid ? [-DRAG_ACTIVATION_OFFSET, DRAG_ACTIVATION_OFFSET] : undefined}
backdropComponent={Backdrop} backdropComponent={Backdrop}
backgroundStyle={backgroundStyle} backgroundStyle={backgroundStyle}
bottomInset={insets.bottom} bottomInset={insets.bottom}
......
import React from 'react' import React from 'react'
import { ColorValue, FlexStyle } from 'react-native' import { ColorValue, FlexStyle } from 'react-native'
import { IS_ANDROID } from 'src/constants/globals'
import { Flex, useSporeColors } from 'ui/src' import { Flex, useSporeColors } from 'ui/src'
import { spacing } from 'ui/src/theme' import { spacing } from 'ui/src/theme'
import { isAndroid } from 'wallet/src/utils/platform'
const HANDLEBAR_HEIGHT = spacing.spacing4 const HANDLEBAR_HEIGHT = spacing.spacing4
const HANDLEBAR_WIDTH = spacing.spacing36 const HANDLEBAR_WIDTH = spacing.spacing36
...@@ -21,7 +21,7 @@ export const HandleBar = ({ ...@@ -21,7 +21,7 @@ export const HandleBar = ({
const bg = hidden ? 'transparent' : backgroundColor ?? colors.surface1.get() const bg = hidden ? 'transparent' : backgroundColor ?? colors.surface1.get()
return ( return (
<Flex mt={IS_ANDROID ? '$spacing4' : '$none'}> <Flex mt={isAndroid ? '$spacing4' : '$none'}>
<Flex <Flex
alignItems="center" alignItems="center"
borderTopLeftRadius="$rounded24" borderTopLeftRadius="$rounded24"
......
import { PropsWithChildren } from 'react'
import { StyleSheet } from 'react-native'
import Animated, {
interpolate,
interpolateColor,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import { colors, opacify } from 'ui/src/theme'
import { useSortableGridContext } from './SortableGridProvider'
type ActiveItemDecorationProps = PropsWithChildren<{
renderIndex: number
}>
export default function ActiveItemDecoration({
renderIndex,
children,
}: ActiveItemDecorationProps): JSX.Element {
const {
touchedIndex,
activeItemScale,
previousActiveIndex,
activeItemOpacity,
activeItemShadowOpacity,
dragActivationProgress,
} = useSortableGridContext()
const pressProgress = useSharedValue(0)
useAnimatedReaction(
() => ({
isTouched: touchedIndex.value === renderIndex,
wasTouched: previousActiveIndex.value === renderIndex,
progress: dragActivationProgress.value,
}),
({ isTouched, wasTouched, progress }) => {
if (isTouched) {
// If the item is currently touched, we want to animate the press progress
// (change the decoration) based on the drag activation progress
pressProgress.value = Math.max(pressProgress.value, progress)
} else if (wasTouched) {
// If the item was touched (the user released the finger) and the item
// was previously touched, we want to animate it based on the decreasing
// press progress
pressProgress.value = Math.min(pressProgress.value, progress)
} else {
// For all other cases, we want to ensure that the press progress is reset
// and all non-touched items are not decorated
pressProgress.value = withTiming(0)
}
}
)
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{
scale: interpolate(pressProgress.value, [0, 1], [1, activeItemScale.value]),
},
],
opacity: interpolate(pressProgress.value, [0, 1], [1, activeItemOpacity.value]),
shadowColor: interpolateColor(
pressProgress.value,
[0, 1],
['transparent', opacify(100 * activeItemShadowOpacity.value, colors.black)]
),
}))
return <Animated.View style={[styles.shadow, animatedStyle]}>{children}</Animated.View>
}
const styles = StyleSheet.create({
shadow: {
borderRadius: 0,
elevation: 40,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.25,
shadowRadius: 5,
},
})
import { memo, useRef } from 'react'
import { LayoutChangeEvent, MeasureLayoutOnSuccessCallback, View } from 'react-native'
import { Flex, FlexProps } from 'ui/src'
import { useStableCallback } from './hooks'
import SortableGridItem from './SortableGridItem'
import SortableGridProvider, { useSortableGridContext } from './SortableGridProvider'
import { AutoScrollProps, SortableGridChangeEvent, SortableGridRenderItem } from './types'
import { defaultKeyExtractor } from './utils'
type SortableGridProps<I> = Omit<SortableGridInnerProps<I>, 'keyExtractor'> &
AutoScrollProps & {
onChange: (e: SortableGridChangeEvent<I>) => void
onDragStart?: () => void
onDragEnd?: () => void
keyExtractor?: (item: I, index: number) => string
editable?: boolean
activeItemScale?: number
activeItemOpacity?: number
activeItemShadowOpacity?: number
}
function SortableGrid<I>({
data,
onDragStart,
onDragEnd,
keyExtractor: keyExtractorProp,
activeItemScale,
activeItemOpacity,
activeItemShadowOpacity,
onChange: onChangeProp,
scrollableRef,
scrollY,
visibleHeight,
editable,
numColumns = 1,
...rest
}: SortableGridProps<I>): JSX.Element {
const keyExtractor = useStableCallback(keyExtractorProp ?? defaultKeyExtractor)
const onChange = useStableCallback(onChangeProp)
const providerProps = {
activeItemScale,
activeItemOpacity,
activeItemShadowOpacity,
data,
editable,
onChange,
scrollY,
scrollableRef,
visibleHeight,
onDragStart,
onDragEnd,
}
const gridProps = {
data,
keyExtractor,
numColumns,
scrollableRef,
...rest,
}
return (
<SortableGridProvider {...providerProps}>
<MemoSortableGridInner {...gridProps} />
</SortableGridProvider>
)
}
type SortableGridInnerProps<I> = FlexProps & {
keyExtractor: (item: I, index: number) => string
numColumns?: number
data: I[]
renderItem: SortableGridRenderItem<I>
containerRef?: React.RefObject<View>
}
function SortableGridInner<I>({
data,
renderItem,
numColumns = 1,
keyExtractor,
containerRef,
...flexProps
}: SortableGridInnerProps<I>): JSX.Element {
const { gridContainerRef, containerStartOffset, containerEndOffset, touchedIndex } =
useSortableGridContext()
const internalDataRef = useRef(data)
const measureContainer = useStableCallback((e: LayoutChangeEvent) => {
// If there is no parent element, assume the grid is the first child
// in the scrollable container
if (!containerRef?.current) {
containerEndOffset.value = e.nativeEvent.layout.height
return
}
// Otherwise, measure its offset relative to the scrollable container
const onSuccess: MeasureLayoutOnSuccessCallback = (x, y, w, h) => {
containerStartOffset.value = y
containerEndOffset.value = y + h
}
const parentNode = containerRef.current
const gridNode = gridContainerRef.current
if (gridNode) {
gridNode.measureLayout(parentNode, onSuccess)
}
})
// Update only if the user doesn't interact with the grid
// (we don't want to reorder items based on the input data
// while the user is dragging an item)
if (touchedIndex.value === null) {
internalDataRef.current = data
}
return (
<Flex ref={gridContainerRef} row flexWrap="wrap" onLayout={measureContainer} {...flexProps}>
{internalDataRef.current.map((item, index) => (
<SortableGridItem
key={keyExtractor(item, index)}
index={index}
item={item}
numColumns={numColumns}
renderItem={renderItem}
/>
))}
</Flex>
)
}
const MemoSortableGridInner = memo(SortableGridInner) as typeof SortableGridInner
export default SortableGrid
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import { MeasureLayoutOnSuccessCallback, StyleSheet } from 'react-native'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import Animated, {
runOnJS,
runOnUI,
useAnimatedReaction,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
useWorkletCallback,
withTiming,
} from 'react-native-reanimated'
import ActiveItemDecoration from './ActiveItemDecoration'
import { TIME_TO_ACTIVATE_PAN } from './constants'
import { useAnimatedZIndex, useItemOrderUpdater } from './hooks'
import { useSortableGridContext } from './SortableGridProvider'
import { SortableGridRenderItem } from './types'
type SortableGridItemProps<I> = {
item: I
index: number
renderItem: SortableGridRenderItem<I>
numColumns: number
}
function SortableGridItem<I>({
item,
index: renderIndex,
renderItem,
numColumns,
}: SortableGridItemProps<I>): JSX.Element {
const viewRef = useRef<Animated.View>(null)
// Current state
const {
gridContainerRef,
activeIndex,
activeTranslation: activeTranslationValue,
itemAtIndexMeasurements: itemAtIndexMeasurementsValue,
renderIndexToDisplayIndex,
touchedIndex,
editable,
dragActivationProgress,
setActiveIndex,
previousActiveIndex,
scrollOffsetDiff,
} = useSortableGridContext()
const isActive = activeIndex === renderIndex
const isActiveValue = useSharedValue(isActive)
const isTouched = useDerivedValue(() => touchedIndex.value === renderIndex)
useEffect(() => {
isActiveValue.value = isActive
}, [isActive, isActiveValue])
// Cell animations
const displayIndexValue = useDerivedValue(
() => renderIndexToDisplayIndex.value[renderIndex] ?? renderIndex
)
const contentHeight = useSharedValue(0)
// Translation based on cells reordering
// (e.g when the item is swapped with the active item)
const orderTranslateX = useSharedValue(0)
const orderTranslateY = useSharedValue(0)
// Reset order translation on re-render
orderTranslateX.value = 0
orderTranslateY.value = 0
// Translation based on the user dragging the item
// (we keep it separate to animate the dropped item to the target
// position without flickering when items are re-rendered in
// the new order and the drop animation has not finished yet)
const dragTranslateX = useSharedValue(0)
const dragTranslateY = useSharedValue(0)
const zIndex = useAnimatedZIndex(renderIndex)
useItemOrderUpdater(renderIndex, activeIndex, displayIndexValue, numColumns)
const updateCellMeasurements = useCallback(() => {
const onSuccess: MeasureLayoutOnSuccessCallback = (x, y, w, h) => {
runOnUI(() => {
const currentMeasurements = itemAtIndexMeasurementsValue.value
currentMeasurements[renderIndex] = { x, y, width: w, height: h }
itemAtIndexMeasurementsValue.value = [...currentMeasurements]
contentHeight.value = h
})()
}
const listContainerNode = gridContainerRef.current
const listItemNode = viewRef.current
if (listItemNode && listContainerNode) {
listItemNode.measureLayout(listContainerNode, onSuccess)
}
}, [gridContainerRef, itemAtIndexMeasurementsValue, renderIndex, contentHeight])
const getItemOrderTranslation = useWorkletCallback(() => {
const itemAtIndexMeasurements = itemAtIndexMeasurementsValue.value
const displayIndex = displayIndexValue.value
const renderMeasurements = itemAtIndexMeasurements[renderIndex]
const displayMeasurements = itemAtIndexMeasurements[displayIndex]
if (!renderMeasurements || !displayMeasurements) return { x: 0, y: 0 }
return {
x: displayMeasurements.x - renderMeasurements.x,
y: displayMeasurements.y - renderMeasurements.y,
}
}, [renderIndex])
const handleDragEnd = useWorkletCallback(() => {
dragActivationProgress.value = withTiming(0, { duration: TIME_TO_ACTIVATE_PAN })
touchedIndex.value = null
if (!isActiveValue.value) return
// Reset the active item
previousActiveIndex.value = renderIndex
// Reset this before state is updated to disable animated reactions
// earlier (the state is always updated with a delay)
isActiveValue.value = false
// Translate the previously active item to its target position
const orderTranslation = getItemOrderTranslation()
// Update the current order translation and modify the drag translation
// at the same time (this prevents flickering when items are re-rendered)
dragTranslateX.value = dragTranslateX.value - orderTranslation.x
dragTranslateY.value = dragTranslateY.value + scrollOffsetDiff.value - orderTranslation.y
orderTranslateX.value = orderTranslation.x
orderTranslateY.value = orderTranslation.y
// Animate the remaining translation
dragTranslateX.value = withTiming(0)
dragTranslateY.value = withTiming(0)
// Reset the active item index
runOnJS(setActiveIndex)(null)
}, [renderIndex, getItemOrderTranslation])
// Translates the currently active (dragged) item
useAnimatedReaction(
() => ({
activeTranslation: activeTranslationValue.value,
active: isActiveValue.value,
}),
({ active, activeTranslation }) => {
if (!active || touchedIndex.value === null) return
dragTranslateX.value = activeTranslation.x
dragTranslateY.value = activeTranslation.y
}
)
// Translates the item when it's not active and is swapped with the active item
useAnimatedReaction(
() => ({
displayIndex: displayIndexValue.value,
itemAtIndexMeasurements: itemAtIndexMeasurementsValue.value,
active: isActiveValue.value,
}),
({ displayIndex, active, itemAtIndexMeasurements }) => {
if (active) return
const renderMeasurements = itemAtIndexMeasurements[renderIndex]
const displayMeasurements = itemAtIndexMeasurements[displayIndex]
if (!renderMeasurements || !displayMeasurements) return
if (activeIndex !== null && touchedIndex.value !== null) {
// If the order changes as a result of the user dragging an item,
// translate the item to its new position with animation
orderTranslateX.value = withTiming(displayMeasurements.x - renderMeasurements.x)
orderTranslateY.value = withTiming(displayMeasurements.y - renderMeasurements.y)
} else if (renderIndex !== previousActiveIndex.value) {
// If the order changes as a result of the data change, reset
// the item position without animation (it re-renders in the new position,
// so the previously applied translation is no longer valid)
orderTranslateX.value = 0
orderTranslateY.value = 0
}
},
[renderIndex, activeIndex]
)
const panGesture = useMemo(
() =>
Gesture.Pan()
.activateAfterLongPress(TIME_TO_ACTIVATE_PAN)
.onTouchesDown(() => {
touchedIndex.value = renderIndex
previousActiveIndex.value = null
dragActivationProgress.value = withTiming(1, { duration: TIME_TO_ACTIVATE_PAN })
})
.onStart(() => {
if (touchedIndex.value !== renderIndex) return
activeTranslationValue.value = { x: 0, y: 0 }
dragActivationProgress.value = withTiming(1, { duration: TIME_TO_ACTIVATE_PAN })
runOnJS(setActiveIndex)(renderIndex)
})
.onUpdate((e) => {
if (!isActiveValue.value) return
activeTranslationValue.value = { x: e.translationX, y: e.translationY }
})
.onTouchesCancelled(handleDragEnd)
.onEnd(handleDragEnd)
.onTouchesUp(handleDragEnd)
.enabled(editable),
[
activeTranslationValue,
dragActivationProgress,
isActiveValue,
handleDragEnd,
previousActiveIndex,
touchedIndex,
renderIndex,
setActiveIndex,
editable,
]
)
const animatedCellStyle = useAnimatedStyle(() => ({
zIndex: zIndex.value,
height: contentHeight.value > 0 ? contentHeight.value : undefined,
}))
const animatedOrderStyle = useAnimatedStyle(() => ({
transform: [{ translateX: orderTranslateX.value }, { translateY: orderTranslateY.value }],
}))
const animatedDragStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: dragTranslateX.value },
{ translateY: dragTranslateY.value + (isActiveValue.value ? scrollOffsetDiff.value : 0) },
],
}))
const content = useMemo(
() =>
renderItem({
index: renderIndex,
item,
dragActivationProgress,
isTouched,
}),
[renderIndex, dragActivationProgress, item, renderItem, isTouched]
)
const cellStyle = {
width: `${100 / numColumns}%`,
}
return (
// The outer view is used to resize the cell to the size of the new item
// in case the new item height is different than the height of the previous one
<Animated.View pointerEvents="box-none" style={[cellStyle, animatedCellStyle]}>
<GestureDetector gesture={panGesture}>
{/* The inner view will be translated during grid items reordering */}
<Animated.View
ref={viewRef}
style={activeIndex !== null ? animatedOrderStyle : styles.noTranslation}>
<Animated.View style={animatedDragStyle} onLayout={updateCellMeasurements}>
<ActiveItemDecoration renderIndex={renderIndex}>{content}</ActiveItemDecoration>
</Animated.View>
</Animated.View>
</GestureDetector>
</Animated.View>
)
}
const styles = StyleSheet.create({
noTranslation: {
transform: [{ translateX: 0 }, { translateY: 0 }],
},
})
export default memo(SortableGridItem) as <I>(props: SortableGridItemProps<I>) => JSX.Element
export const TIME_TO_ACTIVATE_PAN = 300
export const TOUCH_SLOP = 10
export const AUTO_SCROLL_THRESHOLD = 50
This diff is collapsed.
export { default as SortableGrid } from './SortableGrid'
export * from './types'
import { FlatList, ScrollView, View } from 'react-native'
import { SharedValue } from 'react-native-reanimated'
export type Require<T, K extends keyof T = keyof T> = Required<Pick<T, K>> & Omit<T, K>
export type ItemMeasurements = {
height: number
width: number
x: number
y: number
}
export type AutoScrollProps = {
scrollableRef: React.RefObject<FlatList | ScrollView>
visibleHeight: SharedValue<number>
scrollY: SharedValue<number>
// The parent container inside the scrollable that wraps the grid
// (e.g. when the grid is rendered inside the FlatList header)
// if not provided, we assume that the grid is the first child in
// the scrollable container
containerRef?: React.RefObject<View>
}
export type SortableGridContextType = {
gridContainerRef: React.RefObject<View>
itemAtIndexMeasurements: SharedValue<ItemMeasurements[]>
dragActivationProgress: SharedValue<number>
activeIndex: number | null
previousActiveIndex: SharedValue<number | null>
activeTranslation: SharedValue<{ x: number; y: number }>
scrollOffsetDiff: SharedValue<number>
renderIndexToDisplayIndex: SharedValue<number[]>
setActiveIndex: (index: number | null) => void
onDragStart?: () => void
displayToRenderIndex: SharedValue<number[]>
activeItemScale: SharedValue<number>
visibleHeight: SharedValue<number>
activeItemOpacity: SharedValue<number>
activeItemShadowOpacity: SharedValue<number>
touchedIndex: SharedValue<number | null>
editable: boolean
containerStartOffset: SharedValue<number>
containerEndOffset: SharedValue<number>
}
export type SortableGridRenderItemInfo<I> = {
item: I
index: number
dragActivationProgress: SharedValue<number>
isTouched: SharedValue<boolean>
}
export type SortableGridRenderItem<I> = (info: SortableGridRenderItemInfo<I>) => JSX.Element
export type Vector = {
x: number
y: number
}
export type SortableGridChangeEvent<I> = {
data: I[]
fromIndex: number
toIndex: number
}
import { FlatList, ScrollView } from 'react-native'
const hasProp = <O extends object, P extends string>(
object: O,
prop: P
): object is O & Record<P, unknown> => {
return prop in object
}
export const defaultKeyExtractor = <I>(item: I, index: number): string => {
if (typeof item === 'string') return item
if (typeof item === 'object' && item !== null) {
if (hasProp(item, 'id')) return String(item.id)
if (hasProp(item, 'key')) return String(item.key)
}
return String(index)
}
export const isScrollView = (scrollable: ScrollView | FlatList): scrollable is ScrollView => {
return 'scrollTo' in scrollable
}
import React from 'react'
import { LongMarkdownText } from 'src/components/text/LongMarkdownText'
import { render } from 'src/test/test-utils'
it('renders a LongMarkdownText', () => {
const tree = render(<LongMarkdownText text="Some very long text" />)
expect(tree).toMatchSnapshot()
})
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment