ci(release): publish latest release

parent 6609a783
...@@ -8,9 +8,6 @@ node_modules ...@@ -8,9 +8,6 @@ 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,54 +17,3 @@ index 4b5b90b7b478668fdff3fd12d5e028d423ada057..af30dc6f700b3b3cfde5c149bf1f8657 ...@@ -17,54 +17,3 @@ 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) {
* @uniswap/web-admins
IPFS hash of the deployment: Here again with a new (sparse) update to our app! Check out what is new below:
- CIDv0: `QmYMiHUXKwbifsieRsk6jexS5sS321rZtsoEtu5s266ewa`
- CIDv1: `bafybeieu3j7cdq5nz4r6z7qnoxcyqq7bk4wjiyxmelpzprkvoaedcb4uze`
The latest release is always mirrored at [app.uniswap.org](https://app.uniswap.org).
You can also access the Uniswap Interface from an IPFS gateway.
**BEWARE**: The Uniswap interface uses [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) to remember your settings, such as which tokens you have imported.
**You should always use an IPFS gateway that enforces origin separation**, or our hosted deployment of the latest release at [app.uniswap.org](https://app.uniswap.org).
Your Uniswap settings are never remembered across different URLs.
IPFS gateways:
- https://bafybeieu3j7cdq5nz4r6z7qnoxcyqq7bk4wjiyxmelpzprkvoaedcb4uze.ipfs.dweb.link/
- https://bafybeieu3j7cdq5nz4r6z7qnoxcyqq7bk4wjiyxmelpzprkvoaedcb4uze.ipfs.cf-ipfs.com/
- [ipfs://QmYMiHUXKwbifsieRsk6jexS5sS321rZtsoEtu5s266ewa/](ipfs://QmYMiHUXKwbifsieRsk6jexS5sS321rZtsoEtu5s266ewa/)
## 5.3.0 (2023-12-19)
### Features
* **web:** [info] add volume charts (#5235) 80c3996
* **web:** [info] hide About and Address section with flag on (#5464) 61d0397
* **web:** add info tables (#5274) adf64bd
* **web:** Add moonpay text to account drawer and moonpay modal (#5478) 0a09ce3
* **web:** updated page titles (#5390) 4b39985
* **web:** use cumulative value for unhovered bar chart header (#5432) 304c761
### Bug Fixes
* **web:** center confirmation modal icons (#5492) fe3688a
* **web:** disambiguate 3P ProviderRpcErrors (#5481) ca912dd
* **web:** fix fadepresence typecheck (#5466) 9d39fa6
* **web:** fix vercel ignore to actually ignore if no web changes (#5420) 45e0ee2
* **web:** fixes and improvements to token sorting / filtering (#5388) 8f740ce
* **web:** optional address for multichainmap (#5393) 6eb015f
* **web:** show default list tokens when searched (#5494) (#5498) d28e298
- Bug fixes
- Performance improvements on home and swap
- Improvements in language support
web/5.3.0 mobile/1.17.1
\ No newline at end of file \ No newline at end of file
* @Uniswap/mobile-release-admins
\ No newline at end of file
...@@ -85,9 +85,6 @@ Set this as your default version: ...@@ -85,9 +85,6 @@ 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.18" versionName "1.17"
dimension "variant" dimension "variant"
} }
beta { beta {
applicationIdSuffix ".beta" applicationIdSuffix ".beta"
versionName "1.18" versionName "1.17.1"
dimension "variant" dimension "variant"
} }
prod { prod {
dimension "variant" dimension "variant"
versionName "1.18" versionName "1.17.1"
} }
} }
......
...@@ -9,7 +9,6 @@ ...@@ -9,7 +9,6 @@
<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,7 +10,6 @@ ...@@ -10,7 +10,6 @@
<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.7.0): - EXBarCodeScanner (12.3.2):
- 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.4.0): - EXImageLoader (4.1.1):
- 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: 296dd50f6c03928d1d71d37ea17473b304cfdb00 EXBarCodeScanner: 8e23fae8d267dbef9f04817833a494200f1fce35
EXFileSystem: 0b4a2c08c2dc92146849772145d61c1773144283 EXFileSystem: 0b4a2c08c2dc92146849772145d61c1773144283
EXFont: 6ea3800df746be7233208d80fe379b8ed74f4272 EXFont: 6ea3800df746be7233208d80fe379b8ed74f4272
EXImageLoader: 03063370bc06ea1825713d3f55fe0455f7c88d04 EXImageLoader: fd053169a8ee932dd83bf1fe5487a50c26d27c2b
EXLocalAuthentication: 78cc5a00b13745b41d16d189ab332b61a01299f9 EXLocalAuthentication: 78cc5a00b13745b41d16d189ab332b61a01299f9
Expo: 8448e3a2aa1b295f029c81551e1ab6d986517fdb Expo: 8448e3a2aa1b295f029c81551e1ab6d986517fdb
ExpoBlur: fac3c6318fdf409dd5740afd3313b2bd52a35bac ExpoBlur: fac3c6318fdf409dd5740afd3313b2bd52a35bac
......
...@@ -26,31 +26,9 @@ let placeholderPriceHistory = [ ...@@ -26,31 +26,9 @@ 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( 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))
date: Date(),
configuration: TokenPriceConfigurationIntent(), 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)
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,
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"
...@@ -61,16 +39,10 @@ struct Provider: IntentTimelineProvider { ...@@ -61,16 +39,10 @@ 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()
async let tokenPriceRequest = isSnapshot ? let tokenPriceResponse = isSnapshot ?
await DataQueries.fetchTokenPriceData(chain: WidgetConstants.ethereumChain, address: nil) : try await DataQueries.fetchTokenPriceData(chain: WidgetConstants.ethereumChain, address: nil) :
await DataQueries.fetchTokenPriceData(chain: configuration.selectedToken?.chain ?? "", address: configuration.selectedToken?.address) try await DataQueries.fetchTokenPriceData(chain: configuration.selectedToken?.chain ?? "", address: configuration.selectedToken?.address)
async let conversionRequest = await DataQueries.fetchCurrencyConversion( let spotPrice = tokenPriceResponse.spotPrice
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 ?? ""))
...@@ -90,17 +62,7 @@ struct Provider: IntentTimelineProvider { ...@@ -90,17 +62,7 @@ struct Provider: IntentTimelineProvider {
address: configuration.selectedToken?.address) address: configuration.selectedToken?.address)
} }
return TokenPriceEntry( return TokenPriceEntry(date: entryDate, configuration: configuration, spotPrice: spotPrice, pricePercentChange: pricePercentChange, symbol: symbol, logo: logo, backgroundColor: backgroundColor, tokenPriceHistory: tokenPriceHistory)
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 {
...@@ -128,7 +90,6 @@ struct Provider: IntentTimelineProvider { ...@@ -128,7 +90,6 @@ 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
...@@ -169,14 +130,7 @@ struct TokenPriceWidgetEntryView: View { ...@@ -169,14 +130,7 @@ 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) {
let i18nSettings = UniswapUserDefaults.readI18n() Text(NumberFormatter.fiatTokenDetailsFormatter(price: entry.spotPrice))
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,7 +12,6 @@ public struct WidgetConstants { ...@@ -12,7 +12,6 @@ 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
......
...@@ -99,7 +99,7 @@ public class DataQueries { ...@@ -99,7 +99,7 @@ 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, valueModifiers: GraphQLNullable.null)){ result in Network.shared.apollo.fetch(query: MobileSchema.MultiplePortfolioBalancesQuery(ownerAddresses: addresses)){ 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,40 +124,6 @@ public class DataQueries { ...@@ -124,40 +124,6 @@ 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,59 +6,80 @@ ...@@ -6,59 +6,80 @@
// //
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 {
static func formatShorthandWithDecimals(number: Double, fractionDigits: Int, locale: Locale, currencyCode: String, placeholder: String) -> String { // Based on https://www.notion.so/uniswaplabs/Number-standards-fbb9f533f10e4e22820722c2f66d23c0
if (number < 1000000) { // React native code: https://github.com/Uniswap/universe/blob/main/packages/wallet/src/utils/format.ts
return formatWithDecimals(number: number, fractionDigits: fractionDigits, locale: locale, currencyCode: currencyCode) public static func SHORTHAND_USD_TWO_DECIMALS(price: Double) -> String {
} let formatter = NumberFormatter()
let maxNumber = 1000000000000000.0 formatter.numberStyle = .currency
let maxed = number >= maxNumber formatter.maximumFractionDigits = 2
let limitedNumber = maxed ? maxNumber : number formatter.minimumFractionDigits = 2
formatter.currencyCode = "USD"
// 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 { if (price < 1000000){
return placeholder return TWO_DECIMALS_USD.string(for: price)!
} }
let output = numberRegex.stringByReplacingMatches(in: currencyFormatted, range: NSMakeRange(0, currencyFormatted.count), withTemplate: compactFormatted) else if (price < 1000000000){
return "\(formatter.string(for: price/1000000)!)M"
return maxed ? ">\(output)" : "\(output)"
} }
else if (price < 1000000000000){
static func formatWithDecimals(number: Double, fractionDigits: Int, locale: Locale, currencyCode: String) -> String { return "\(formatter.string(for: price/1000000000)!)B"
return number.formatted(.currency(code: currencyCode).locale(locale).precision(.fractionLength(fractionDigits)))
} }
else if (price < 1000000000000000){
static func formatWithSigFigs(number: Double, sigFigsDigits: Int, locale: Locale, currencyCode: String) -> String { return "\(formatter.string(for: price/1000000000000)!)T"
return number.formatted(.currency(code: currencyCode).locale(locale).precision(.significantDigits(sigFigsDigits))) }
else {
return "$>999T"
} }
}
public static var TWO_DECIMALS_USD: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.maximumFractionDigits = 2
formatter.minimumFractionDigits = 2
formatter.currencyCode = "USD"
return formatter
}()
public static func fiatTokenDetailsFormatter(price: Double?, locale: Locale, currencyCode: String) -> String { public static var THREE_SIG_FIGS_USD: NumberFormatter = {
let placeholder = "--.--" let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.maximumSignificantDigits = 3
formatter.minimumSignificantDigits = 3
formatter.currencyCode = "USD"
return formatter
}()
public static var THREE_DECIMALS_USD: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.maximumFractionDigits = 3
formatter.minimumFractionDigits = 3
formatter.currencyCode = "USD"
return formatter
}()
public static func fiatTokenDetailsFormatter(price: Double?) -> String {
guard let price = price else { guard let price = price else {
return placeholder return "--.--"
} }
if (price < 0.00000001) { if (price < 0.00000001) {
let formattedPrice = formatWithDecimals(number: price, fractionDigits: 8, locale: locale, currencyCode: currencyCode) return "<$0.00000001"
return "<\(formattedPrice)"
} }
else if (price < 0.01) {
if (price < 0.01) { return THREE_SIG_FIGS_USD.string(for: price)!
return formatWithSigFigs(number: price, sigFigsDigits: 3, locale: locale, currencyCode: currencyCode) }
} else if (price < 1.05) { else if (price < 1.05) {
return formatWithDecimals(number: price, fractionDigits: 3, locale: locale, currencyCode: currencyCode) return THREE_DECIMALS_USD.string(for: price)!
} else if (price < 1e6) {
return formatWithDecimals(number: price, fractionDigits: 2, locale: locale, currencyCode: currencyCode)
} else {
return formatShorthandWithDecimals(number: price, fractionDigits: 2, locale: locale, currencyCode: currencyCode, placeholder: placeholder)
} }
else if (price < 1e6) {
return TWO_DECIMALS_USD.string(for: price)!
}
else {
return SHORTHAND_USD_TWO_DECIMALS(price: price)
}
} }
} }
...@@ -51,8 +51,3 @@ public struct PriceHistory { ...@@ -51,8 +51,3 @@ 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,17 +34,6 @@ public struct WidgetDataAccounts: Decodable { ...@@ -34,17 +34,6 @@ 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?
...@@ -91,14 +80,14 @@ public enum Change: String, Codable { ...@@ -91,14 +80,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 keyEvents = buildString + ".widgets.configuration.events" static let eventsKey = buildString + ".widgets.configuration.events"
static let keyCache = buildString + ".widgets.configuration.cache" static let cacheKey = buildString + ".widgets.configuration.cache"
static let keyFavorites = buildString + ".widgets.favorites" static let favoritesKey = buildString + ".widgets.favorites"
static let keyAccounts = buildString + ".widgets.accounts" static let accountsKey = 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)
...@@ -115,7 +104,7 @@ public struct UniswapUserDefaults { ...@@ -115,7 +104,7 @@ public struct UniswapUserDefaults {
} }
public static func readAccounts() -> WidgetDataAccounts { public static func readAccounts() -> WidgetDataAccounts {
let data = readData(key: keyAccounts) let data = readData(key: accountsKey)
guard let data = data else { guard let data = data else {
return WidgetDataAccounts([]) return WidgetDataAccounts([])
} }
...@@ -128,7 +117,7 @@ public struct UniswapUserDefaults { ...@@ -128,7 +117,7 @@ public struct UniswapUserDefaults {
} }
public static func readFavorites() -> WidgetDataFavorites { public static func readFavorites() -> WidgetDataFavorites {
let data = readData(key: keyFavorites) let data = readData(key: favoritesKey)
guard let data = data else { guard let data = data else {
return WidgetDataFavorites([]) return WidgetDataFavorites([])
} }
...@@ -140,20 +129,8 @@ public struct UniswapUserDefaults { ...@@ -140,20 +129,8 @@ 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: keyCache) let data = readData(key: cacheKey)
guard let data = data else { guard let data = data else {
return WidgetDataConfiguration([]) return WidgetDataConfiguration([])
} }
...@@ -170,12 +147,12 @@ public struct UniswapUserDefaults { ...@@ -170,12 +147,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: keyCache) userDefaults!.set(json, forKey: cacheKey)
} }
} }
public static func readEventChanges() -> WidgetEvents { public static func readEventChanges() -> WidgetEvents {
let data = readData(key: keyEvents) let data = readData(key: eventsKey)
guard let data = data else { guard let data = data else {
return WidgetEvents(events: []) return WidgetEvents(events: [])
} }
...@@ -192,7 +169,7 @@ public struct UniswapUserDefaults { ...@@ -192,7 +169,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: keyEvents) userDefaults!.set(json, forKey: eventsKey)
} }
} }
} }
...@@ -10,97 +10,15 @@ import WidgetsCore ...@@ -10,97 +10,15 @@ import WidgetsCore
final class FormatTests: XCTestCase { final class FormatTests: XCTestCase {
let localeEnglish = Locale(identifier: "en") func testFiatTokenDetailsFormatter() throws {
let localeFrench = Locale(identifier: "fr-FR") XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.05), "$0.050")
let localeChinese = Locale(identifier: "zh-Hans") XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.056666666), "$0.057")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 1234567.891), "$1.23M")
let currencyCodeUsd = WidgetConstants.currencyUsd XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 1234.5678), "$1,234.57")
let currencyCodeEuro = "EUR" XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 1.048952), "$1.049")
let currencyCodeYuan = "CNY" XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.001231), "$0.00123")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.00001231), "$0.0000123")
struct TestCase { XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.0000001234), "$0.000000123")
public init(_ price: Double, _ output: String) { XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.000000009876), "<$0.00000001")
self.price = price
self.output = output
}
public let price: Double
public let output: String
}
func testFormatterHandlesEnglish() throws {
let testCases = [
TestCase(0.05, "$0.050"),
TestCase(0.056666666, "$0.057"),
TestCase(1234567.891, "$1.23M"),
TestCase(1234.5678, "$1,234.57"),
TestCase(1.048952, "$1.049"),
TestCase(0.001231, "$0.00123"),
TestCase(0.00001231, "$0.0000123"),
TestCase(0.0000001234, "$0.000000123"),
TestCase(0.000000009876, "<$0.00000001"),
]
testCases.forEach { testCase in
XCTAssertEqual(
NumberFormatter.fiatTokenDetailsFormatter(
price: testCase.price,
locale: localeEnglish,
currencyCode: currencyCodeUsd
),
testCase.output
)
}
}
func testFormatterHandlesFrench() throws {
let testCases = [
TestCase(0.05, "0,050 €"),
TestCase(0.056666666, "0,057 €"),
TestCase(1234567.891, "1,23 M €"),
TestCase(123456.7890, "123 456,79 €"),
TestCase(1.048952, "1,049 €"),
TestCase(0.001231, "0,00123 €"),
TestCase(0.00001231, "0,0000123 €"),
TestCase(0.0000001234, "0,000000123 €"),
TestCase(0.000000009876, "<0,00000001 €"),
]
testCases.forEach { testCase in
XCTAssertEqual(
NumberFormatter.fiatTokenDetailsFormatter(
price: testCase.price,
locale: localeFrench,
currencyCode: currencyCodeEuro
),
testCase.output
)
}
}
func testFormatterHandlesChinese() throws {
let testCases = [
TestCase(0.05, "¥0.050"),
TestCase(0.056666666, "¥0.057"),
TestCase(1234567.891, "¥123.46万"),
TestCase(1234.5678, "¥1,234.57"),
TestCase(1.048952, "¥1.049"),
TestCase(0.001231, "¥0.00123"),
TestCase(0.00001231, "¥0.0000123"),
TestCase(0.0000001234, "¥0.000000123"),
TestCase(0.000000009876, "<¥0.00000001"),
]
testCases.forEach { testCase in
XCTAssertEqual(
NumberFormatter.fiatTokenDetailsFormatter(
price: testCase.price,
locale: localeChinese,
currencyCode: currencyCodeYuan
),
testCase.output
)
}
} }
} }
...@@ -109,7 +109,7 @@ ...@@ -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.7.0", "expo-barcode-scanner": "12.3.2",
"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,7 +33,6 @@ import { ...@@ -33,7 +33,6 @@ 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'
...@@ -47,8 +46,6 @@ import { uniswapUrls } from 'wallet/src/constants/urls' ...@@ -47,8 +46,6 @@ 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'
...@@ -179,8 +176,8 @@ function AppOuter(): JSX.Element | null { ...@@ -179,8 +176,8 @@ function AppOuter(): JSX.Element | null {
<ApolloProvider client={client}> <ApolloProvider client={client}>
<PersistGate loading={null} persistor={persistor}> <PersistGate loading={null} persistor={persistor}>
<ErrorBoundary> <ErrorBoundary>
<LocalizationContextProvider>
<GestureHandlerRootView style={flexStyles.fill}> <GestureHandlerRootView style={flexStyles.fill}>
<LocalizationContextProvider>
<WalletContextProvider> <WalletContextProvider>
<BiometricContextProvider> <BiometricContextProvider>
<LockScreenContextProvider> <LockScreenContextProvider>
...@@ -202,8 +199,8 @@ function AppOuter(): JSX.Element | null { ...@@ -202,8 +199,8 @@ function AppOuter(): JSX.Element | null {
</LockScreenContextProvider> </LockScreenContextProvider>
</BiometricContextProvider> </BiometricContextProvider>
</WalletContextProvider> </WalletContextProvider>
</GestureHandlerRootView>
</LocalizationContextProvider> </LocalizationContextProvider>
</GestureHandlerRootView>
</ErrorBoundary> </ErrorBoundary>
</PersistGate> </PersistGate>
</ApolloProvider> </ApolloProvider>
...@@ -241,8 +238,6 @@ function AppInner(): JSX.Element { ...@@ -241,8 +238,6 @@ 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)
...@@ -255,10 +250,6 @@ function DataUpdaters(): JSX.Element { ...@@ -255,10 +250,6 @@ 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, useRef, useState } from 'react' import { useCallback, 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,24 +70,27 @@ export function useDynamicFontSizing( ...@@ -70,24 +70,27 @@ export function useDynamicFontSizing(
onSetFontSize: (amount: string) => void onSetFontSize: (amount: string) => void
} { } {
const [fontSize, setFontSize] = useState(maxFontSize) const [fontSize, setFontSize] = useState(maxFontSize)
const textInputElementWidthRef = useRef(0) const [textInputElementWidth, setTextInputElementWidth] = useState<number>(0)
const onLayout = useCallback((event: LayoutChangeEvent) => { const onLayout = useCallback(
if (textInputElementWidthRef.current) return (event: LayoutChangeEvent) => {
if (textInputElementWidth) return
const width = event.nativeEvent.layout.width const width = event.nativeEvent.layout.width
textInputElementWidthRef.current = width setTextInputElementWidth(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 * (textInputElementWidthRef.current / stringWidth) const scaledSize = fontSize * (textInputElementWidth / 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] [fontSize, maxFontSize, minFontSize, maxCharWidthAtMaxFontSize, textInputElementWidth]
) )
return { onLayout, fontSize, onSetFontSize } return { onLayout, fontSize, onSetFontSize }
......
...@@ -53,7 +53,6 @@ import { ...@@ -53,7 +53,6 @@ import {
v51Schema, v51Schema,
v52Schema, v52Schema,
v53Schema, v53Schema,
v54Schema,
v5Schema, v5Schema,
v6Schema, v6Schema,
v7Schema, v7Schema,
...@@ -62,7 +61,6 @@ import { ...@@ -62,7 +61,6 @@ 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'
...@@ -154,7 +152,6 @@ describe('Redux state migrations', () => { ...@@ -154,7 +152,6 @@ 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,
...@@ -1243,11 +1240,4 @@ describe('Redux state migrations', () => { ...@@ -1243,11 +1240,4 @@ 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,15 +717,4 @@ export const migrations = { ...@@ -717,15 +717,4 @@ export const migrations = {
return newState return newState
}, },
55: function addBehaviorHistory(state: any) {
const newState = { ...state }
newState.behaviorHistory = {
hasViewedReviewScreen: false,
hasSubmittedHoldToSwap: false,
}
return newState
},
} }
...@@ -8,6 +8,7 @@ import { AccountList } from 'src/components/accounts/AccountList' ...@@ -8,6 +8,7 @@ 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'
...@@ -35,7 +36,6 @@ import { ...@@ -35,7 +36,6 @@ 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(
isAndroid ? t('Google Drive not available') : t('iCloud Drive not available'), IS_ANDROID ? t('Google Drive not available') : t('iCloud Drive not available'),
isAndroid IS_ANDROID
? 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">
{isAndroid ? t('Restore from Google Drive') : t('Restore from iCloud')} {IS_ANDROID ? t('Restore from Google Drive') : t('Restore from iCloud')}
</Text> </Text>
</Flex> </Flex>
), ),
......
...@@ -15,7 +15,6 @@ import { FiatOnRampAggregatorModal } from 'src/features/fiatOnRamp/FiatOnRampAgg ...@@ -15,7 +15,6 @@ 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 (
...@@ -62,10 +61,6 @@ export function AppModals(): JSX.Element { ...@@ -62,10 +61,6 @@ 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'
...@@ -11,20 +10,17 @@ import { ModalName } from 'src/features/telemetry/constants' ...@@ -11,20 +10,17 @@ 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 { useExperimentWithExposureLoggingDisabled } from 'statsig-react-native' import { useExperiment } from 'statsig-react-native'
import { Accordion } from 'tamagui' import { Button, Flex, Text, useDeviceInsets } from 'ui/src'
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 { useFeatureFlagWithExposureLoggingDisabled } from 'wallet/src/features/experiments/hooks' import { useFeatureFlag } 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 || '')
...@@ -52,113 +48,51 @@ export function ExperimentsModal(): JSX.Element { ...@@ -52,113 +48,51 @@ 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 <ScrollView contentContainerStyle={{ paddingBottom: insets.bottom + spacing.spacing12 }}>
contentContainerStyle={{ <Flex gap="$spacing16" justifyContent="flex-start" pt="$spacing12" px="$spacing24">
paddingBottom: insets.bottom, <Flex gap="$spacing8">
paddingRight: spacing.spacing24, <Flex gap="$spacing16" my="$spacing16">
paddingLeft: spacing.spacing24, <Text variant="subheading1">⚙️ Custom GraphQL Endpoint</Text>
}}>
<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}>
<Flex grow row alignItems="center" gap="$spacing16">
<Button flex={1} size="small" onPress={setEndpoint}>
Set Set
</Button> </Button>
<Button size="small" onPress={clearEndpoint}>
<Button flex={1} size="small" onPress={clearEndpoint}>
Clear Clear
</Button> </Button>
</Flex> </Flex>
</Accordion.Content> <Text variant="subheading1">⛳️ Feature Flags</Text>
</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>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="feature-flags">
<AccordionHeader title="⛳️ Feature Flags" />
<Accordion.Content>
<Text variant="body2"> <Text variant="body2">
Overridden feature flags are reset when the app is restarted Overridden feature flags are reset when the app is restarted
</Text> </Text>
</Flex>
<Flex gap="$spacing12" mt="$spacing12">
{Object.values(FEATURE_FLAGS).map((featureFlag) => { {Object.values(FEATURE_FLAGS).map((featureFlag) => {
return <FeatureFlagRow key={featureFlag} featureFlag={featureFlag} /> return <FeatureFlagRow key={featureFlag} featureFlag={featureFlag} />
})} })}
</Flex> <Text variant="subheading1">🔬 Experiments</Text>
</Accordion.Content> <Text variant="body2">Overridden experiments are reset when the app is restarted</Text>
</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) => { {Object.values(EXPERIMENT_NAMES).map((experiment) => {
return <ExperimentRow key={experiment} name={experiment} /> return <ExperimentRow key={experiment} name={experiment} />
})} })}
</Flex> </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 = useFeatureFlagWithExposureLoggingDisabled(featureFlag) const status = useFeatureFlag(featureFlag)
return ( return (
<Flex row alignItems="center" gap="$spacing16" justifyContent="space-between"> <Flex row alignItems="center" gap="$spacing16" justifyContent="space-between">
...@@ -174,7 +108,10 @@ function FeatureFlagRow({ featureFlag }: { featureFlag: FEATURE_FLAGS }): JSX.El ...@@ -174,7 +108,10 @@ function FeatureFlagRow({ featureFlag }: { featureFlag: FEATURE_FLAGS }): JSX.El
} }
function ExperimentRow({ name }: { name: string }): JSX.Element { function ExperimentRow({ name }: { name: string }): JSX.Element {
const experiment = useExperimentWithExposureLoggingDisabled(name) const experiment = useExperiment(name)
// console.log('garydebug experiment row ' + JSON.stringify(experiment.config))
// const layer = useLayer(name)
// console.log('garydebug experiment row ' + JSON.stringify(layer))
const params = Object.entries(experiment.config.value).map(([key, value]) => ( const params = Object.entries(experiment.config.value).map(([key, value]) => (
<Flex <Flex
......
...@@ -8,14 +8,15 @@ import { updateSwapStartTimestamp } from 'src/features/telemetry/timing/slice' ...@@ -8,14 +8,15 @@ 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 { useSwapRewriteEnabled } from 'wallet/src/features/experiments/hooks' import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants'
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 = useSwapRewriteEnabled() const shouldShowSwapRewrite = useFeatureFlag(FEATURE_FLAGS.SwapRewrite)
const onClose = useCallback((): void => { const onClose = useCallback((): void => {
appDispatch(closeModal({ name: ModalName.Swap })) appDispatch(closeModal({ name: ModalName.Swap }))
......
...@@ -14,6 +14,7 @@ import { ...@@ -14,6 +14,7 @@ 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'
...@@ -35,7 +36,6 @@ import { useIsDarkMode } from 'wallet/src/features/appearance/hooks' ...@@ -35,7 +36,6 @@ 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={isAndroid ? '$spacing8' : '$none'} mb={IS_ANDROID ? '$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 = isIOS const contentProps: FlexProps = IS_IOS
? { ? {
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={isIOS ? 100 : 0}> <BlurView intensity={IS_IOS ? 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'
...@@ -15,7 +14,6 @@ import { monitoredSagaReducers } from './saga' ...@@ -15,7 +14,6 @@ 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,14 +398,6 @@ export const v54Schema = { ...@@ -398,14 +398,6 @@ 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,7 +55,6 @@ const rtkQueryErrorLogger: Middleware = () => (next) => (action: PayloadAction<u ...@@ -55,7 +55,6 @@ 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',
...@@ -75,7 +74,7 @@ export const persistConfig = { ...@@ -75,7 +74,7 @@ export const persistConfig = {
key: 'root', key: 'root',
storage: reduxStorage, storage: reduxStorage,
whitelist, whitelist,
version: 55, version: 54,
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'
export const NUMBER_ARRAY = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'] const NUMBER_ARRAY = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
export const NUMBER_WIDTH_ARRAY = [29, 20, 29, 29, 29, 29, 29, 29, 29, 29] // width of digits in a font const NUMBER_WIDTH_ARRAY = [29, 20, 29, 29, 29, 29, 29, 29, 29, 29] // width of digits in a font
export const DIGIT_HEIGHT = 44 const DIGIT_HEIGHT = 44
export const ADDITIONAL_WIDTH_FOR_ANIMATIONS = 8 const ADDITIONAL_WIDTH_FOR_ANIMATIONS = 10
// 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,27 +171,6 @@ function longestCommonPrefix(a: string, b: string): string { ...@@ -171,27 +171,6 @@ 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
...@@ -295,7 +274,34 @@ const AnimatedNumber = ({ ...@@ -295,7 +274,34 @@ const AnimatedNumber = ({
backgroundColor="$surface1" backgroundColor="$surface1"
borderRadius="$rounded4" borderRadius="$rounded4"
width={MAX_DEVICE_WIDTH}> width={MAX_DEVICE_WIDTH}>
<TopAndBottomGradient /> <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>
<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) => (
...@@ -325,24 +331,24 @@ const AnimatedNumber = ({ ...@@ -325,24 +331,24 @@ const AnimatedNumber = ({
export default AnimatedNumber export default AnimatedNumber
export const AnimatedNumberStyles = StyleSheet.create({ const AnimatedNumberStyles = StyleSheet.create({
gradientStyle: { gradientStyle: {
position: 'absolute', position: 'absolute',
zIndex: 100, zIndex: 100,
}, },
}) })
export const AnimatedCharStyles = StyleSheet.create({ const AnimatedCharStyles = StyleSheet.create({
wrapperStyle: { wrapperStyle: {
overflow: 'hidden', overflow: 'hidden',
}, },
}) })
export const AnimatedFontStyles = StyleSheet.create({ 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'
...@@ -36,7 +35,7 @@ export const exampleSwapSuccess = { ...@@ -36,7 +35,7 @@ export const exampleSwapSuccess = {
} }
// easiest to use inside NotificationToastWrapper before any returns // easiest to use inside NotificationToastWrapper before any returns
export const useMockNotification = (ms?: number): void => { export const useFakeNotification = (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()
...@@ -58,30 +57,3 @@ export const useMockNotification = (ms?: number): void => { ...@@ -58,30 +57,3 @@ export const useMockNotification = (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, { memo, useMemo } from 'react' import React, { 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, useDeviceDimensions, useSporeColors } from 'ui/src' import { Flex, useSporeColors } from 'ui/src'
import { fonts, TextVariantTokens } from 'ui/src/theme' import { TextVariantTokens } from 'ui/src/theme'
import { ValueAndFormatted } from './usePrice' import { ValueAndFormatted } from './usePrice'
type AnimatedDecimalNumberProps = { type AnimatedDecimalNumberProps = {
...@@ -14,18 +13,12 @@ type AnimatedDecimalNumberProps = { ...@@ -14,18 +13,12 @@ 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 const AnimatedDecimalNumber = memo(function AnimatedDecimalNumber( export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.Element {
props: AnimatedDecimalNumberProps
): JSX.Element {
const colors = useSporeColors() const colors = useSporeColors()
const { fullWidth } = useDeviceDimensions()
const { fontScale } = useWindowDimensions()
const { const {
number, number,
...@@ -35,8 +28,6 @@ export const AnimatedDecimalNumber = memo(function AnimatedDecimalNumber( ...@@ -35,8 +28,6 @@ export const AnimatedDecimalNumber = memo(function AnimatedDecimalNumber(
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(
...@@ -60,37 +51,12 @@ export const AnimatedDecimalNumber = memo(function AnimatedDecimalNumber( ...@@ -60,37 +51,12 @@ export const AnimatedDecimalNumber = memo(function AnimatedDecimalNumber(
} }
}, [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 <AnimatedText style={wholeStyle} testID="wholePart" text={wholePart} variant={variant} />
style={[wholeStyle, animatedStyle]}
testID="wholePart"
text={wholePart}
variant={variant}
/>
{decimalPart.value !== separator && ( {decimalPart.value !== separator && (
<AnimatedText <AnimatedText
style={[decimalStyle, animatedStyle]} style={decimalStyle}
testID="decimalPart" testID="decimalPart"
text={decimalPart} text={decimalPart}
variant={variant} variant={variant}
...@@ -98,4 +64,4 @@ export const AnimatedDecimalNumber = memo(function AnimatedDecimalNumber( ...@@ -98,4 +64,4 @@ export const AnimatedDecimalNumber = memo(function AnimatedDecimalNumber(
)} )}
</Flex> </Flex>
) )
}) }
import { ImpactFeedbackStyle } from 'expo-haptics' import { ImpactFeedbackStyle } from 'expo-haptics'
import { memo, useEffect, useMemo, useState } from 'react' import React, { useMemo } 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 { LineChart, LineChartProvider, TLineChartDataProp } from 'react-native-wagmi-charts' import {
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, RelativeChangeText } from 'src/components/PriceExplorer/Text' import { DatetimeText, PriceText, 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 { 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 { PriceNumberOfDigits, TokenSpotData, useTokenPriceHistory } from './usePriceHistory' import { TokenSpotData, useTokenPriceHistory } from './usePriceHistory'
type PriceTextProps = { type PriceTextProps = {
loading: boolean loading: boolean
relativeChange?: SharedValue<number> relativeChange?: SharedValue<number>
numberOfDigits: PriceNumberOfDigits
} }
function PriceTextSection({ function PriceTextSection({ loading, relativeChange }: PriceTextProps): JSX.Element {
loading,
relativeChange,
numberOfDigits,
}: PriceTextProps): JSX.Element {
const price = useLineChartPrice()
const mx = spacing.spacing12
return ( return (
<Flex mx={mx}> <Flex mx="$spacing12">
{/* Specify maxWidth to allow text scalling. onLayout was sometimes called after more <PriceText loading={loading} />
than 5 seconds which is not acceptable so we have to provide the approximate width
of the PriceText component explicitly. */}
<PriceExplorerAnimatedNumber 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} />
...@@ -51,7 +42,7 @@ export type LineChartPriceAndDateTimeTextProps = { ...@@ -51,7 +42,7 @@ export type LineChartPriceAndDateTimeTextProps = {
currencyId: CurrencyId currencyId: CurrencyId
} }
export const PriceExplorer = memo(function PriceExplorer({ export function PriceExplorer({
currencyId, currencyId,
tokenColor, tokenColor,
forcePlaceholder, forcePlaceholder,
...@@ -62,36 +53,22 @@ export const PriceExplorer = memo(function PriceExplorer({ ...@@ -62,36 +53,22 @@ export const PriceExplorer = memo(function PriceExplorer({
forcePlaceholder?: boolean forcePlaceholder?: boolean
onRetry: () => void onRetry: () => void
}): JSX.Element { }): JSX.Element {
const [fetchComplete, setFetchComplete] = useState(false) const { data, loading, error, refetch, setDuration, selectedDuration } =
const onFetchComplete = (): void => { useTokenPriceHistory(currencyId)
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 =>
data?.priceHistory?.map((point) => {
return { ...point, value: point.value * conversionRate } return { ...point, value: point.value * conversionRate }
}) }),
[data, conversionRate]
const lastPoint = priceHistory ? priceHistory.length - 1 : 0 )
return { lastPricePoint: lastPoint, convertedPriceHistory: priceHistory }
}, [data, conversionRate])
const convertedSpot = useMemo((): TokenSpotData | undefined => { const convertedSpot = useMemo((): TokenSpotData | undefined => {
return ( return (
data?.spot && { data?.spot && {
...@@ -115,26 +92,21 @@ export const PriceExplorer = memo(function PriceExplorer({ ...@@ -115,26 +92,21 @@ export const PriceExplorer = memo(function PriceExplorer({
let content: JSX.Element | null let content: JSX.Element | null
if (forcePlaceholder) { if (forcePlaceholder) {
content = ( content = <PriceExplorerPlaceholder loading={forcePlaceholder} />
<PriceExplorerPlaceholder loading={forcePlaceholder} numberOfDigits={numberOfDigits} />
)
} else if (convertedPriceHistory?.length) { } else if (convertedPriceHistory?.length) {
content = ( content = (
<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} numberOfDigits={numberOfDigits} /> content = <PriceExplorerPlaceholder loading={loading} />
} }
return ( return (
...@@ -143,18 +115,12 @@ export const PriceExplorer = memo(function PriceExplorer({ ...@@ -143,18 +115,12 @@ export const PriceExplorer = memo(function PriceExplorer({
<TimeRangeGroup setDuration={setDuration} /> <TimeRangeGroup setDuration={setDuration} />
</Flex> </Flex>
) )
}) }
function PriceExplorerPlaceholder({ function PriceExplorerPlaceholder({ loading }: { loading: boolean }): JSX.Element {
loading,
numberOfDigits,
}: {
loading: boolean
numberOfDigits: PriceNumberOfDigits
}): JSX.Element {
return ( return (
<Flex gap="$spacing8"> <Flex gap="$spacing8">
<PriceTextSection loading={loading} numberOfDigits={numberOfDigits} /> <PriceTextSection loading={loading} />
<Flex my="$spacing24"> <Flex my="$spacing24">
<Loader.Graph /> <Loader.Graph />
</Flex> </Flex>
...@@ -170,7 +136,6 @@ function PriceExplorerChart({ ...@@ -170,7 +136,6 @@ function PriceExplorerChart({
additionalPadding, additionalPadding,
shouldShowAnimatedDot, shouldShowAnimatedDot,
lastPricePoint, lastPricePoint,
numberOfDigits,
}: { }: {
priceHistory: TLineChartDataProp priceHistory: TLineChartDataProp
spot?: TokenSpotData spot?: TokenSpotData
...@@ -179,7 +144,6 @@ function PriceExplorerChart({ ...@@ -179,7 +144,6 @@ 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
...@@ -189,11 +153,7 @@ function PriceExplorerChart({ ...@@ -189,11 +153,7 @@ function PriceExplorerChart({
data={priceHistory} data={priceHistory}
onCurrentIndexChange={invokeImpact[ImpactFeedbackStyle.Light]}> onCurrentIndexChange={invokeImpact[ImpactFeedbackStyle.Light]}>
<Flex gap="$spacing8"> <Flex gap="$spacing8">
<PriceTextSection <PriceTextSection loading={loading} relativeChange={spot?.relativeChange} />
loading={loading}
numberOfDigits={numberOfDigits}
relativeChange={spot?.relativeChange}
/>
{/* 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}>
...@@ -202,10 +162,7 @@ function PriceExplorerChart({ ...@@ -202,10 +162,7 @@ function PriceExplorerChart({
<LineChart.Dot <LineChart.Dot
key={lastPricePoint} key={lastPricePoint}
hasPulse hasPulse
// Sometimes, the pulse dot doesn't appear on the end of at={lastPricePoint}
// 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"
...@@ -214,10 +171,9 @@ function PriceExplorerChart({ ...@@ -214,10 +171,9 @@ function PriceExplorerChart({
/> />
)} )}
</LineChart.Path> </LineChart.Path>
<LineChart.CursorLine color={tokenColor} minDurationMs={150} /> <LineChart.CursorLine color={tokenColor} />
<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'
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,
}: {
chars: SharedValue<string>
index: number
shouldAnimate: SharedValue<boolean>
decimalPlace: SharedValue<number>
hidePlacehodler(): void
commaIndex: number
}): JSX.Element => {
const colors = useSporeColors()
const animatedDigit = useDerivedValue(() => {
return chars.value[index - (commaIndex - decimalPlace.value)]
}, [chars])
const animatedFontStyle = useAnimatedStyle(() => {
const color = index >= commaIndex ? colors.neutral3.val : colors.neutral1.val
return {
color,
}
})
const transformY = useDerivedValue(() => {
const endValue =
animatedDigit.value && Number(animatedDigit.value) >= 0
? 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 rowWidth =
(NUMBER_WIDTH_ARRAY[Number(animatedDigit.value)] || 0) + 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 },
]}>
.
</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 },
]}>
,
</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,
}: {
price: ValueAndFormatted
hidePlacehodler(): void
numberOfDigits: PriceNumberOfDigits
}): JSX.Element[] => {
const priceLength = useSharedValue(0)
const chars = useDerivedValue(() => {
priceLength.value = price.formatted.value.length
return price.formatted.value
}, [price])
const decimalPlace = useDerivedValue(() => {
return price.formatted.value.indexOf('.')
}, [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)}
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,
}: {
price: ValueAndFormatted
numberOfDigits: PriceNumberOfDigits
}): 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
}
return (
<>
<Animated.View style={animatedWrapperStyle}>
<LoadingWrapper />
</Animated.View>
<View style={RowWrapper.wrapperStyle}>
<TopAndBottomGradient />
<Text
allowFontScaling={false}
style={[
AnimatedFontStyles.fontStyle,
{ height: DIGIT_HEIGHT, color: colors.neutral1.val },
]}>
$
</Text>
{Numbers({ price, hidePlacehodler, numberOfDigits })}
</View>
</>
)
}
export default PriceExplorerAnimatedNumber
export const RowWrapper = StyleSheet.create({
wrapperStyle: {
flexDirection: 'row',
},
})
export const Shimmer = StyleSheet.create({
shimmerSize: {
height: DIGIT_HEIGHT,
width: 200,
},
})
...@@ -2,21 +2,15 @@ import React from 'react' ...@@ -2,21 +2,15 @@ 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({ export function PriceText({ loading }: { loading: boolean }): JSX.Element {
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()
...@@ -27,15 +21,13 @@ export function PriceText({ ...@@ -27,15 +21,13 @@ export function PriceText({
(currency === FiatCurrency.UnitedStatesDollar || currency === FiatCurrency.Euro) && (currency === FiatCurrency.UnitedStatesDollar || currency === FiatCurrency.Euro) &&
symbolAtFront symbolAtFront
// TODO(MOB-2308): re-enable this when we have a better solution for handling the loading state if (loading) {
// if (loading) { return <AnimatedText loading loadingPlaceholderText="$10,000" variant="heading1" />
// 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"
...@@ -65,7 +57,7 @@ export function RelativeChangeText({ ...@@ -65,7 +57,7 @@ export function RelativeChangeText({
if (loading) { if (loading) {
return ( return (
<Flex mt={isAndroid ? '$none' : '$spacing2'}> <Flex mt={IS_ANDROID ? '$none' : '$spacing2'}>
<AnimatedText loading loadingPlaceholderText="00.00%" variant="body1" /> <AnimatedText loading loadingPlaceholderText="00.00%" variant="body1" />
</Flex> </Flex>
) )
...@@ -74,9 +66,9 @@ export function RelativeChangeText({ ...@@ -74,9 +66,9 @@ export function RelativeChangeText({
return ( return (
<Flex <Flex
row row
alignItems={isAndroid ? 'center' : 'flex-end'} alignItems={IS_ANDROID ? 'center' : 'flex-end'}
gap="$spacing2" gap="$spacing2"
mt={isAndroid ? '$none' : '$spacing2'}> mt={IS_ANDROID ? '$none' : '$spacing2'}>
<Icons.AnimatedCaretChange <Icons.AnimatedCaretChange
size="$icon.16" size="$icon.16"
strokeWidth={2} strokeWidth={2}
......
...@@ -33,24 +33,50 @@ exports[`DatetimeText renders without error 1`] = ` ...@@ -33,24 +33,50 @@ 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": "row", "flexDirection": "column",
"opacity": 0,
} }
} }
testID="price-text"
> >
<TextInput <View
allowFontScaling={true} style={
animatedProps={ {
"alignItems": "center",
"flexDirection": "row",
}
}
>
<View
style={
{ {
"text": "-", "alignItems": "center",
"flexDirection": "row",
"position": "relative",
} }
} }
>
<View
accessibilityElementsHidden={true}
importantForAccessibility="no-hide-descendants"
>
<View
style={
{
"alignItems": "stretch",
"flexDirection": "row",
}
}
>
<TextInput
allowFontScaling={true}
editable={false} editable={false}
maxFontSizeMultiplier={1.2} maxFontSizeMultiplier={1.2}
style={ style={
[
[ [
{ {
"padding": 0, "padding": 0,
...@@ -60,20 +86,62 @@ exports[`PriceText renders loading state 1`] = ` ...@@ -60,20 +86,62 @@ exports[`PriceText renders loading state 1`] = `
"fontSize": 53, "fontSize": 53,
"lineHeight": 60, "lineHeight": 60,
}, },
undefined,
],
{
"marginHorizontal": 0,
"opacity": 0,
"paddingHorizontal": 0,
"width": 0,
},
]
}
underlineColorAndroid="transparent"
/>
<Text
style={
[
[ [
{ {
"color": "#222222", "padding": 0,
}, },
{ {
"fontSize": 106, "fontFamily": "Basel-Book",
"fontSize": 53,
"lineHeight": 60,
}, },
undefined,
], ],
{
"opacity": 0,
},
] ]
} }
testID="wholePart" >
underlineColorAndroid="transparent" $10,000
value="-" </Text>
</View>
</View>
<View
style={
{
"alignItems": "stretch",
"backgroundColor": "#F9F9F9",
"borderBottomLeftRadius": 4,
"borderBottomRightRadius": 4,
"borderTopLeftRadius": 4,
"borderTopRightRadius": 4,
"bottom": "5%",
"flexDirection": "column",
"left": 0,
"position": "absolute",
"right": 0,
"top": "5%",
}
}
/> />
</View>
</View>
</View> </View>
`; `;
...@@ -106,14 +174,9 @@ exports[`PriceText renders without error 1`] = ` ...@@ -106,14 +174,9 @@ exports[`PriceText renders without error 1`] = `
"fontSize": 53, "fontSize": 53,
"lineHeight": 60, "lineHeight": 60,
}, },
[
{ {
"color": "#222222", "color": "#222222",
}, },
{
"fontSize": 106,
},
],
] ]
} }
testID="wholePart" testID="wholePart"
...@@ -139,14 +202,9 @@ exports[`PriceText renders without error 1`] = ` ...@@ -139,14 +202,9 @@ exports[`PriceText renders without error 1`] = `
"fontSize": 53, "fontSize": 53,
"lineHeight": 60, "lineHeight": 60,
}, },
[
{ {
"color": "#CECECE", "color": "#CECECE",
}, },
{
"fontSize": 106,
},
],
] ]
} }
testID="decimalPart" testID="decimalPart"
...@@ -185,14 +243,9 @@ exports[`PriceText renders without error less than a dollar 1`] = ` ...@@ -185,14 +243,9 @@ 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"
...@@ -218,14 +271,9 @@ exports[`PriceText renders without error less than a dollar 1`] = ` ...@@ -218,14 +271,9 @@ 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 { useMemo } from 'react' import { SharedValue, useDerivedValue } from 'react-native-reanimated'
import {
SharedValue,
useAnimatedReaction,
useDerivedValue,
useSharedValue,
} from 'react-native-reanimated'
import { import {
useLineChart, useLineChart,
useLineChartPrice as useRNWagmiChartLineChartPrice, useLineChartPrice as useRNWagmiChartLineChartPrice,
...@@ -13,10 +7,9 @@ import { numberToLocaleStringWorklet, numberToPercentWorklet } from 'src/utils/r ...@@ -13,10 +7,9 @@ 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, B = boolean> = { export type ValueAndFormatted<U = number, V = string> = {
value: Readonly<SharedValue<U>> value: Readonly<SharedValue<U>>
formatted: Readonly<SharedValue<V>> formatted: Readonly<SharedValue<V>>
shouldAnimate: Readonly<SharedValue<B>>
} }
/** /**
...@@ -29,18 +22,6 @@ export function useLineChartPrice(): ValueAndFormatted { ...@@ -29,18 +22,6 @@ export function useLineChartPrice(): ValueAndFormatted {
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()
...@@ -50,7 +31,6 @@ export function useLineChartPrice(): ValueAndFormatted { ...@@ -50,7 +31,6 @@ export function useLineChartPrice(): ValueAndFormatted {
return Number(activeCursorPrice.value) return Number(activeCursorPrice.value)
} }
shouldAnimate.value = true
return data[data.length - 1]?.value ?? 0 return data[data.length - 1]?.value ?? 0
}) })
const priceFormatted = useDerivedValue(() => { const priceFormatted = useDerivedValue(() => {
...@@ -64,15 +44,10 @@ export function useLineChartPrice(): ValueAndFormatted { ...@@ -64,15 +44,10 @@ export function useLineChartPrice(): ValueAndFormatted {
currencyInfo.symbol currencyInfo.symbol
) )
}) })
return {
return useMemo(
() => ({
value: price, value: price,
formatted: priceFormatted, formatted: priceFormatted,
shouldAnimate, }
}),
[price, priceFormatted, shouldAnimate]
)
} }
/** /**
...@@ -85,7 +60,6 @@ export function useLineChartRelativeChange({ ...@@ -85,7 +60,6 @@ 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)) {
...@@ -119,5 +93,5 @@ export function useLineChartRelativeChange({ ...@@ -119,5 +93,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, shouldAnimate } return { value: relativeChange, formatted: relativeChangeFormattted }
} }
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'
...@@ -17,17 +16,11 @@ export type TokenSpotData = { ...@@ -17,17 +16,11 @@ export type TokenSpotData = {
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<{
...@@ -39,7 +32,6 @@ export function useTokenPriceHistory( ...@@ -39,7 +32,6 @@ 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)
...@@ -54,9 +46,7 @@ export function useTokenPriceHistory( ...@@ -54,9 +46,7 @@ export function useTokenPriceHistory(
}, },
notifyOnNetworkStatusChange: true, notifyOnNetworkStatusChange: true,
pollInterval: PollingInterval.Normal, pollInterval: PollingInterval.Normal,
onCompleted, fetchPolicy: 'cache-first',
// 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]
...@@ -83,24 +73,13 @@ export function useTokenPriceHistory( ...@@ -83,24 +73,13 @@ 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 }))
return formatted // adds the current price to the chart given we show spot price/24h change
}, [priceHistory]) if (formatted && spot?.value) {
formatted?.push({ timestamp: Date.now(), value: spot.value.value })
const numberOfDigits = useMemo(() => {
const max = maxBy(priceHistory, 'value')
if (max) {
return {
left: String(max.value).split('.')[0]?.length || 10,
right: Number(String(max.value).split('.')[0]) > 0 ? 2 : 10,
}
} }
return { return formatted
left: 0, }, [priceHistory, spot?.value])
right: 0,
}
}, [priceHistory])
const retry = useCallback(async () => { const retry = useCallback(async () => {
await refetch({ contract: currencyIdToContractInput(currencyId) }) await refetch({ contract: currencyIdToContractInput(currencyId) })
...@@ -117,18 +96,7 @@ export function useTokenPriceHistory( ...@@ -117,18 +96,7 @@ export function useTokenPriceHistory(
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%', isAndroid ? '150%' : '100%', '0%'], gradientDirection: ['0%', '0%', IS_ANDROID ? '150%' : '100%', '0%'],
} }
} }
return gradientPropsObject return gradientPropsObject
......
...@@ -29,17 +29,16 @@ export function RecipientScanModal({ onSelectRecipient, onClose }: Props): JSX.E ...@@ -29,17 +29,16 @@ 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 camera is frozen // don't scan any QR codes if there is an error popup open or camera is frozen
if (shouldFreezeCamera) return if (hasScanError || 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 {
...@@ -50,7 +49,7 @@ export function RecipientScanModal({ onSelectRecipient, onClose }: Props): JSX.E ...@@ -50,7 +49,7 @@ export function RecipientScanModal({ onSelectRecipient, onClose }: Props): JSX.E
{ {
text: t('Try again'), text: t('Try again'),
onPress: (): void => { onPress: (): void => {
setShouldFreezeCamera(false) setHasScanError(false)
}, },
}, },
] ]
......
import React, { useMemo } from 'react' import React 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 { AccountListQuery } from 'wallet/src/data/__generated__/types-and-hooks' import {
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'
...@@ -15,9 +17,10 @@ type Portfolio = NonNullable<NonNullable<NonNullable<AccountListQuery['portfolio ...@@ -15,9 +17,10 @@ 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 addresses = useMemo(() => accounts.map((account) => account.address), [accounts]) const { data, loading } = useAccountListQuery({
const { data, loading } = useAccountList({ variables: {
addresses, addresses: accounts.map((account) => account.address),
},
notifyOnNetworkStatusChange: true, notifyOnNetworkStatusChange: true,
}) })
......
...@@ -151,8 +151,7 @@ export function RemoveWalletModal(): JSX.Element | null { ...@@ -151,8 +151,7 @@ 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 gap="$spacing24" px="$spacing24" py="$spacing24"> <Flex centered gap="$spacing16" px="$spacing24" py="$spacing12">
<Flex centered gap="$spacing16">
<Flex <Flex
centered centered
borderRadius="$rounded12" borderRadius="$rounded12"
...@@ -160,22 +159,14 @@ export function RemoveWalletModal(): JSX.Element | null { ...@@ -160,22 +159,14 @@ export function RemoveWalletModal(): JSX.Element | null {
style={{ style={{
backgroundColor: opacify(12, colors[labelColor].val), backgroundColor: opacify(12, colors[labelColor].val),
}}> }}>
<Icon <Icon color={colors[labelColor].val} height={iconSizes.icon24} width={iconSizes.icon24} />
color={colors[labelColor].val}
height={iconSizes.icon24}
width={iconSizes.icon24}
/>
</Flex> </Flex>
<Flex gap="$spacing8">
<Text textAlign="center" variant="body1"> <Text textAlign="center" variant="body1">
{title} {title}
</Text> </Text>
<Text color="$neutral2" textAlign="center" variant="body2"> <Text color="$neutral2" textAlign="center" variant="body2">
{description} {description}
</Text> </Text>
</Flex>
</Flex>
<Flex centered gap="$spacing24">
{currentStep === RemoveWalletStep.Final && isRemovingRecoveryPhrase ? ( {currentStep === RemoveWalletStep.Final && isRemovingRecoveryPhrase ? (
<> <>
<AssociatedAccountsList accounts={associatedAccounts} /> <AssociatedAccountsList accounts={associatedAccounts} />
...@@ -209,7 +200,6 @@ export function RemoveWalletModal(): JSX.Element | null { ...@@ -209,7 +200,6 @@ export function RemoveWalletModal(): JSX.Element | null {
</Flex> </Flex>
)} )}
</Flex> </Flex>
</Flex>
</BottomSheetModal> </BottomSheetModal>
) )
} }
...@@ -2,6 +2,7 @@ import React, { useMemo } from 'react' ...@@ -2,6 +2,7 @@ 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'
...@@ -9,7 +10,6 @@ import WalletIcon from 'ui/src/assets/icons/wallet-filled.svg' ...@@ -9,7 +10,6 @@ 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: isAndroid ? ( description: IS_ANDROID ? (
<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 color="$neutral1">{{ wallets: associatedAccountNames }}</Text>. Your recovery <Text fontWeight="bold">{{ 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,7 +26,8 @@ export interface SettingsSectionItemComponent { ...@@ -26,7 +26,8 @@ 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,9 +11,15 @@ export const TokenBalanceItemContextMenu = memo(function _TokenBalanceItem({ ...@@ -11,9 +11,15 @@ 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: portfolioBalance.currencyInfo.currencyId, currencyId,
portfolioBalance, isSpam,
balanceUSD,
isNative: currency.isNative,
accountHoldsToken: true,
}) })
const style = useMemo(() => ({ borderRadius: borderRadii.rounded16 }), []) const style = useMemo(() => ({ borderRadius: borderRadii.rounded16 }), [])
......
...@@ -21,6 +21,7 @@ import { ...@@ -21,6 +21,7 @@ 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'
...@@ -28,7 +29,6 @@ import { BaseCard } from 'wallet/src/components/BaseCard/BaseCard' ...@@ -28,7 +29,6 @@ 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 + (isAndroid && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0) insets.top + (IS_ANDROID && 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,
...@@ -10,22 +9,18 @@ import { ...@@ -10,22 +9,18 @@ import {
useRef, useRef,
useState, useState,
} from 'react' } from 'react'
import { PollingInterval } from 'wallet/src/constants/misc' import { useTokenBalancesGroupedByVisibility } from 'src/features/balances/hooks'
import { isWarmLoadingStatus } from 'wallet/src/data/utils' import { isWarmLoadingStatus } from 'wallet/src/data/utils'
import { import { usePortfolioBalances } from 'wallet/src/features/dataApi/balances'
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: Record<string, PortfolioBalance> | undefined balancesById: ReturnType<typeof usePortfolioBalances>['data']
networkStatus: NetworkStatus networkStatus: ReturnType<typeof usePortfolioBalances>['networkStatus']
refetch: (() => void) | undefined refetch: ReturnType<typeof usePortfolioBalances>['refetch']
hiddenTokensCount: number hiddenTokensCount: number
hiddenTokensExpanded: boolean hiddenTokensExpanded: boolean
isWarmLoading: boolean isWarmLoading: boolean
...@@ -54,7 +49,7 @@ export function TokenBalanceListContextProvider({ ...@@ -54,7 +49,7 @@ export function TokenBalanceListContextProvider({
refetch, refetch,
} = usePortfolioBalances({ } = usePortfolioBalances({
address: owner, address: owner,
pollInterval: PollingInterval.KindaFast, shouldPoll: true,
fetchPolicy: 'cache-and-network', fetchPolicy: 'cache-and-network',
}) })
......
...@@ -114,6 +114,7 @@ export function TokenDetailsStats({ ...@@ -114,6 +114,7 @@ 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,7 +4,8 @@ import { Flex, Icons, Text, TouchableArea } from 'ui/src' ...@@ -4,7 +4,8 @@ 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 { useSwapRewriteEnabled } from 'wallet/src/features/experiments/hooks' import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants'
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 {
...@@ -20,7 +21,7 @@ export function SelectTokenButton({ ...@@ -20,7 +21,7 @@ export function SelectTokenButton({
}: SelectTokenButtonProps): JSX.Element { }: SelectTokenButtonProps): JSX.Element {
const { t } = useTranslation() const { t } = useTranslation()
const isSwapRewriteFeatureEnabled = useSwapRewriteEnabled() const isSwapRewriteFeatureEnabled = useFeatureFlag(FEATURE_FLAGS.SwapRewrite)
if (isSwapRewriteFeatureEnabled) { if (isSwapRewriteFeatureEnabled) {
return ( return (
......
...@@ -3,21 +3,70 @@ import React, { memo, useCallback, useMemo, useRef } from 'react' ...@@ -3,21 +3,70 @@ 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
loading: boolean const findTokenOptionForMoonpayCurrency = (
list: FiatOnRampCurrency[] | undefined commonBaseCurrencies: CurrencyInfo[] | undefined,
moonpayCurrency: MoonpayCurrency
): Maybe<CurrencyInfo> => {
return (commonBaseCurrencies || []).find((item) => {
const [code, network] = moonpayCurrency.code.split('_') ?? [undefined, undefined]
const chainId = fromMoonpayNetwork(network)
return (
item &&
code &&
code === item.currency.symbol?.toLowerCase() &&
chainId === item.currency.chainId
)
})
}
function useFiatOnRampTokenList(
supportedTokens: MoonpayCurrency[] | undefined
): GqlResult<FiatOnRampCurrency[]> {
const {
data: commonBaseCurrencies,
error: commonBaseCurrenciesError,
loading: commonBaseCurrenciesLoading,
refetch: refetchCommonBaseCurrencies,
} = useAllCommonBaseCurrencies()
const data = useMemo(
() =>
(supportedTokens || [])
.map((moonpayCurrency) => ({
currencyInfo: findTokenOptionForMoonpayCurrency(commonBaseCurrencies, moonpayCurrency),
moonpayCurrency,
}))
.filter((item) => !!item.currencyInfo),
[commonBaseCurrencies, supportedTokens]
)
return useMemo(
() => ({
data,
loading: commonBaseCurrenciesLoading,
error: commonBaseCurrenciesError,
refetch: refetchCommonBaseCurrencies,
}),
[commonBaseCurrenciesError, commonBaseCurrenciesLoading, data, refetchCommonBaseCurrencies]
)
} }
function TokenOptionItemWrapper({ function TokenOptionItemWrapper({
...@@ -51,18 +100,20 @@ function TokenOptionItemWrapper({ ...@@ -51,18 +100,20 @@ function TokenOptionItemWrapper({
) )
} }
function _TokenFiatOnRampList({ function _TokenFiatOnRampList({ onSelectCurrency, onBack }: Props): JSX.Element {
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} />
...@@ -70,7 +121,7 @@ function _TokenFiatOnRampList({ ...@@ -70,7 +121,7 @@ function _TokenFiatOnRampList({
[onSelectCurrency] [onSelectCurrency]
) )
if (error) { if (supportedTokensQueryError || error) {
return ( return (
<> <>
<Header onBack={onBack} /> <Header onBack={onBack} />
...@@ -78,14 +129,21 @@ function _TokenFiatOnRampList({ ...@@ -78,14 +129,21 @@ function _TokenFiatOnRampList({
<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={onRetry} onRetry={(): void => {
if (supportedTokensQueryError) {
supportedTokensQueryRefetch?.()
}
if (error) {
refetch?.()
}
}}
/> />
</Flex> </Flex>
</> </>
) )
} }
if (loading) { if (supportedTokensLoading || loading) {
return ( return (
<Flex> <Flex>
<Header onBack={onBack} /> <Header onBack={onBack} />
...@@ -101,7 +159,7 @@ function _TokenFiatOnRampList({ ...@@ -101,7 +159,7 @@ function _TokenFiatOnRampList({
ref={flatListRef} ref={flatListRef}
ListEmptyComponent={<Flex />} ListEmptyComponent={<Flex />}
ListFooterComponent={<Inset all="$spacing36" />} ListFooterComponent={<Inset all="$spacing36" />}
data={list} data={data}
keyExtractor={key} keyExtractor={key}
keyboardDismissMode="on-drag" keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="always" keyboardShouldPersistTaps="always"
......
...@@ -3,6 +3,7 @@ import { useAppSelector } from 'src/app/hooks' ...@@ -3,6 +3,7 @@ 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'
...@@ -10,11 +11,7 @@ import { MobileEventName } from 'src/features/telemetry/constants' ...@@ -10,11 +11,7 @@ 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 { import { sortPortfolioBalances, usePortfolioBalances } from 'wallet/src/features/dataApi/balances'
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'
...@@ -176,6 +173,7 @@ export function usePortfolioBalancesForAddressById( ...@@ -176,6 +173,7 @@ 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,
...@@ -16,7 +17,6 @@ import { ...@@ -16,7 +17,6 @@ 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 (isAndroid) { if (IS_ANDROID) {
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,9 +23,7 @@ export const CUSTOM_UNI_QR_CODE_PREFIX = 'hello_uniwallet:' ...@@ -23,9 +23,7 @@ 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 && name.length > MAX_DAPP_NAME_LENGTH return name.length > MAX_DAPP_NAME_LENGTH ? `${name.slice(0, MAX_DAPP_NAME_LENGTH)}...` : name
? `${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,7 +4,6 @@ import { useTranslation } from 'react-i18next' ...@@ -4,7 +4,6 @@ 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'
...@@ -25,39 +24,24 @@ type AccountCardItemProps = { ...@@ -25,39 +24,24 @@ 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: providedPortfolioValue, portfolioValue,
}: PortfolioValueProps): JSX.Element { }: PortfolioValueProps): JSX.Element {
const { t } = useTranslation()
const { convertFiatAmountFormatted } = useLocalizationContext()
// When we add a new wallet, we'll make a new network request to fetch all accounts as a single request.
// Since we're adding a new wallet address to the `ownerAddresses` array, this will be a brand new query, which won't be cached.
// To avoid all wallets showing a "loading" state, we read directly from cache while we wait for the other query to complete.
const { data } = useAccountList({
fetchPolicy: 'cache-first',
addresses: address,
})
const cachedPortfolioValue = data?.portfolios?.[0]?.tokensTotalDenominatedValue?.value
const portfolioValue = providedPortfolioValue ?? cachedPortfolioValue
const isLoading = isPortfolioValueLoading && portfolioValue === undefined const isLoading = isPortfolioValueLoading && portfolioValue === undefined
const { convertFiatAmountFormatted } = useLocalizationContext()
return ( return (
<Text color="$neutral2" loading={isLoading} variant="subheading2"> <Text
{portfolioValue color="$neutral2"
? convertFiatAmountFormatted(portfolioValue, NumberType.PortfolioBalance) loading={isLoading}
: t('N/A')} loadingPlaceholderText="0000.00"
variant="subheading2">
{convertFiatAmountFormatted(portfolioValue, NumberType.PortfolioBalance)}
</Text> </Text>
) )
} }
...@@ -142,7 +126,6 @@ export function AccountCardItem({ ...@@ -142,7 +126,6 @@ export function AccountCardItem({
/> />
</Flex> </Flex>
<PortfolioValue <PortfolioValue
address={address}
isPortfolioValueLoading={isPortfolioValueLoading} isPortfolioValueLoading={isPortfolioValueLoading}
portfolioValue={portfolioValue} portfolioValue={portfolioValue}
/> />
......
query AccountList( query AccountList($addresses: [String!]!) {
$addresses: [String!]! portfolios(ownerAddresses: $addresses, chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]) {
$valueModifiers: [PortfolioValueModifier!]
) {
portfolios(
ownerAddresses: $addresses
chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]
valueModifiers: $valueModifiers
) {
id id
ownerAddress ownerAddress
tokensTotalDenominatedValue { tokensTotalDenominatedValue {
......
...@@ -20,15 +20,6 @@ const mock: MockedResponse<AccountListQuery> = { ...@@ -20,15 +20,6 @@ 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 = useMemo(() => accounts.map((a) => a.address), [accounts]) const addresses = accounts.map((a) => a.address)
const { data, networkStatus, refetch, startPolling, stopPolling } = useAccountList({ const { data, networkStatus, refetch, startPolling, stopPolling } = useAccountListQuery({
addresses, variables: { 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: AccountWithPortfolioValue[] = useMemo(() => { const accountsWithPortfolioValue = 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,8 +97,7 @@ export function AccountList({ accounts, onPress, isVisible }: AccountListProps): ...@@ -97,8 +97,7 @@ export function AccountList({ accounts, onPress, isVisible }: AccountListProps):
const hasViewOnlyAccounts = viewOnlyAccounts.length > 0 const hasViewOnlyAccounts = viewOnlyAccounts.length > 0
const renderAccountCardItem = useCallback( const renderAccountCardItem = (item: AccountWithPortfolioValue): JSX.Element => (
(item: AccountWithPortfolioValue): JSX.Element => (
<AccountCardItem <AccountCardItem
key={item.account.address} key={item.account.address}
address={item.account.address} address={item.account.address}
...@@ -107,8 +106,6 @@ export function AccountList({ accounts, onPress, isVisible }: AccountListProps): ...@@ -107,8 +106,6 @@ export function AccountList({ accounts, onPress, isVisible }: AccountListProps):
portfolioValue={item.portfolioValue} portfolioValue={item.portfolioValue}
onPress={onPress} 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 = isAndroid ? colors.neutral3.val : colors.surface1.val const falseThumbColor = IS_ANDROID ? 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>
{isAndroid ? ( {IS_ANDROID ? (
<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 React, { useCallback, useMemo, useRef } from 'react' import { BottomSheetFlatList } from '@gorhom/bottom-sheet'
import React, { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ListRenderItem, ListRenderItemInfo, StyleSheet, View } from 'react-native' import { ListRenderItem, ListRenderItemInfo } 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,
...@@ -38,15 +36,12 @@ import { areAddressesEqual } from 'wallet/src/utils/addresses' ...@@ -38,15 +36,12 @@ 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)
...@@ -125,10 +120,6 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element ...@@ -125,10 +120,6 @@ 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)
...@@ -146,18 +137,7 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element ...@@ -146,18 +137,7 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element
} }
return ( return (
// Pass onLayout callback to the list wrapper component as it returned <BottomSheetFlatList
// incorrect values when it was passed to the list itself
<Flex
fill
onLayout={({
nativeEvent: {
layout: { height },
},
}): void => {
visibleListHeight.value = height
}}>
<AnimatedBottomSheetFlatList
ref={listRef} ref={listRef}
ListEmptyComponent={ ListEmptyComponent={
<Flex mx="$spacing24" my="$spacing12"> <Flex mx="$spacing24" my="$spacing12">
...@@ -165,14 +145,8 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element ...@@ -165,14 +145,8 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element
</Flex> </Flex>
} }
ListHeaderComponent={ ListHeaderComponent={
<Flex ref={headerRef}> <>
<FavoritesSection <FavoritesSection showLoading={showLoading} />
containerRef={headerRef}
scrollY={scrollY}
scrollableRef={listRef}
showLoading={showLoading}
visibleHeight={visibleListHeight}
/>
<Flex <Flex
row row
alignItems="center" alignItems="center"
...@@ -187,19 +161,15 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element ...@@ -187,19 +161,15 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element
</Text> </Text>
<SortButton orderBy={orderBy} /> <SortButton orderBy={orderBy} />
</Flex> </Flex>
</Flex> </>
} }
ListHeaderComponentStyle={styles.foreground}
contentContainerStyle={{ paddingBottom: insets.bottom }} contentContainerStyle={{ paddingBottom: insets.bottom }}
data={showLoading ? undefined : topTokenItems} data={showLoading ? undefined : topTokenItems}
keyExtractor={tokenKey} keyExtractor={tokenKey}
renderItem={renderItem} renderItem={renderItem}
scrollEventThrottle={16}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
onScroll={scrollHandler}
/> />
</Flex>
) )
} }
...@@ -234,32 +204,16 @@ function gqlTokenToTokenItemData( ...@@ -234,32 +204,16 @@ function gqlTokenToTokenItemData(
} as TokenItemData } as TokenItemData
} }
type FavoritesSectionProps = AutoScrollProps & { function FavoritesSection({ showLoading }: { showLoading: boolean }): JSX.Element | null {
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 <Flex bg="$transparent" gap="$spacing12" pb="$spacing12" pt="$spacing8" px="$spacing12">
bg="$transparent" {hasFavoritedTokens && <FavoriteTokensGrid showLoading={showLoading} />}
gap="$spacing12" {hasFavoritedWallets && <FavoriteWalletsGrid showLoading={showLoading} />}
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,15 +2,7 @@ import { ImpactFeedbackStyle } from 'expo-haptics' ...@@ -2,15 +2,7 @@ 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 { import { FadeIn, FadeOut } from 'react-native-reanimated'
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'
...@@ -19,7 +11,7 @@ import { useTokenDetailsNavigation } from 'src/components/TokenDetails/hooks' ...@@ -19,7 +11,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 { AnimatedFlex, AnimatedTouchableArea, Flex, Text } from 'ui/src' import { 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'
...@@ -40,24 +32,18 @@ export const FAVORITE_TOKEN_CARD_LOADER_HEIGHT = 114 ...@@ -40,24 +32,18 @@ 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),
...@@ -102,45 +88,11 @@ function FavoriteTokenCard({ ...@@ -102,45 +88,11 @@ 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 (
<AnimatedFlex style={animatedStyle}>
<ContextMenu <ContextMenu
actions={menuActions} actions={menuActions}
disabled={isEditing} disabled={isEditing}
...@@ -148,12 +100,10 @@ function FavoriteTokenCard({ ...@@ -148,12 +100,10 @@ function FavoriteTokenCard({
onPress={onContextMenuPress} onPress={onContextMenuPress}
{...rest}> {...rest}>
<AnimatedTouchableArea <AnimatedTouchableArea
activeOpacity={isEditing ? 1 : undefined} hapticFeedback
bg="$surface2"
borderRadius="$rounded16" borderRadius="$rounded16"
entering={FadeIn} entering={FadeIn}
exiting={FadeOut} exiting={FadeOut}
hapticFeedback={!isEditing}
hapticStyle={ImpactFeedbackStyle.Light} hapticStyle={ImpactFeedbackStyle.Light}
m="$spacing4" m="$spacing4"
testID={`token-box-${token?.symbol}`} testID={`token-box-${token?.symbol}`}
...@@ -192,7 +142,6 @@ function FavoriteTokenCard({ ...@@ -192,7 +142,6 @@ function FavoriteTokenCard({
</BaseCard.Shadow> </BaseCard.Shadow>
</AnimatedTouchableArea> </AnimatedTouchableArea>
</ContextMenu> </ContextMenu>
</AnimatedFlex>
) )
} }
......
import React, { useCallback, useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { FadeIn, useAnimatedStyle, useSharedValue } from 'react-native-reanimated' import { FadeIn } 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({ export function FavoriteTokensGrid({ showLoading }: { showLoading: boolean }): JSX.Element | null {
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
...@@ -44,33 +28,8 @@ export function FavoriteTokensGrid({ ...@@ -44,33 +28,8 @@ export function FavoriteTokensGrid({
} }
}, [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} style={animatedStyle}> <AnimatedFlex entering={FadeIn}>
<FavoriteHeaderRow <FavoriteHeaderRow
editingTitle={t('Edit favorite tokens')} editingTitle={t('Edit favorite tokens')}
isEditing={isEditing} isEditing={isEditing}
...@@ -80,21 +39,17 @@ export function FavoriteTokensGrid({ ...@@ -80,21 +39,17 @@ export function FavoriteTokensGrid({
{showLoading ? ( {showLoading ? (
<FavoriteTokensGridLoader /> <FavoriteTokensGridLoader />
) : ( ) : (
<SortableGrid <Flex row flexWrap="wrap">
{...rest} {favoriteCurrencyIds.map((currencyId) => (
activeItemOpacity={1} <FavoriteTokenCard
data={favoriteCurrencyIds} key={currencyId}
editable={isEditing} currencyId={currencyId}
numColumns={NUM_COLUMNS} isEditing={isEditing}
renderItem={renderItem} setIsEditing={setIsEditing}
onChange={handleOrderChange} style={HALF_WIDTH}
onDragEnd={(): void => {
isTokenDragged.value = false
}}
onDragStart={(): void => {
isTokenDragged.value = true
}}
/> />
))}
</Flex>
)} )}
</AnimatedFlex> </AnimatedFlex>
) )
......
...@@ -10,7 +10,6 @@ import { useTimeout } from 'utilities/src/time/timing' ...@@ -10,7 +10,6 @@ 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'
...@@ -83,7 +82,7 @@ export const LandingBackground = (): JSX.Element | null => { ...@@ -83,7 +82,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 ((isAndroid && Platform.Version < 30) || language !== Language.English) { if ((Platform.OS === 'android' && Platform.Version < 30) || language !== Language.English) {
return <OnboardingStaticImage /> return <OnboardingStaticImage />
} }
......
...@@ -12,6 +12,7 @@ import { ...@@ -12,6 +12,7 @@ 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 {
...@@ -32,7 +33,6 @@ import { ...@@ -32,7 +33,6 @@ 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 + (isAndroid && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0) insets.top + (IS_ANDROID && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0)
} }
refreshing={refreshing ?? false} refreshing={refreshing ?? false}
tintColor={colors.neutral3.get()} tintColor={colors.neutral3.get()}
......
...@@ -9,6 +9,7 @@ import { AnimatedFlatList } from 'src/components/layout/AnimatedFlatList' ...@@ -9,6 +9,7 @@ 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'
...@@ -23,7 +24,6 @@ import { ...@@ -23,7 +24,6 @@ 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 + (isAndroid && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0) insets.top + (IS_ANDROID && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0)
} }
refreshing={refreshing ?? false} refreshing={refreshing ?? false}
tintColor={colors.neutral3.get()} tintColor={colors.neutral3.get()}
......
...@@ -7,6 +7,7 @@ import { useAdaptiveFooter } from 'src/components/home/hooks' ...@@ -7,6 +7,7 @@ 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'
...@@ -15,7 +16,6 @@ import { Flex, useDeviceInsets, useSporeColors } from 'ui/src' ...@@ -15,7 +16,6 @@ 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 + (isAndroid && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0) insets.top + (IS_ANDROID && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0)
} }
refreshing={refreshing ?? false} refreshing={refreshing ?? false}
tintColor={colors.neutral3.get()} tintColor={colors.neutral3.get()}
......
...@@ -1376,7 +1376,6 @@ exports[`ActivityTab renders without error 2`] = ` ...@@ -1376,7 +1376,6 @@ exports[`ActivityTab renders without error 2`] = `
{ {
"alignItems": "center", "alignItems": "center",
"flexDirection": "row", "flexDirection": "row",
"flexShrink": 1,
"gap": 4, "gap": 4,
} }
} }
...@@ -1733,7 +1732,6 @@ exports[`ActivityTab renders without error 2`] = ` ...@@ -1733,7 +1732,6 @@ exports[`ActivityTab renders without error 2`] = `
{ {
"alignItems": "center", "alignItems": "center",
"flexDirection": "row", "flexDirection": "row",
"flexShrink": 1,
"gap": 4, "gap": 4,
} }
} }
......
...@@ -2,10 +2,11 @@ import React, { forwardRef, useCallback, useEffect, useMemo } from 'react' ...@@ -2,10 +2,11 @@ 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 { 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 numericInputRegex = RegExp('^\\d*(\\.\\d*)?$') // Matches only numeric values without commas const inputRegex = RegExp('^\\d*(?:\\\\[.])?\\d*$') // match escaped "." characters via in a non-capturing group
type Props = { type Props = {
showCurrencySign: boolean showCurrencySign: boolean
...@@ -35,18 +36,10 @@ export function replaceSeparators({ ...@@ -35,18 +36,10 @@ export function replaceSeparators({
} }
export const AmountInput = forwardRef<NativeTextInput, Props>(function _AmountInput( export const AmountInput = forwardRef<NativeTextInput, Props>(function _AmountInput(
{ onChangeText, value, showCurrencySign, dimTextColor, showSoftInputOnFocus, ...rest }, { onChangeText, value, showCurrencySign, dimTextColor, showSoftInputOnFocus, editable, ...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) => {
...@@ -58,7 +51,9 @@ export const AmountInput = forwardRef<NativeTextInput, Props>(function _AmountIn ...@@ -58,7 +51,9 @@ export const AmountInput = forwardRef<NativeTextInput, Props>(function _AmountIn
decimalOverride: '.', decimalOverride: '.',
}) })
if (parsedText === '' || inputRegex.test(escapeRegExp(parsedText))) {
onChangeText?.(parsedText) onChangeText?.(parsedText)
}
}, },
[decimalSeparator, groupingSeparator, onChangeText, showCurrencySign] [decimalSeparator, groupingSeparator, onChangeText, showCurrencySign]
) )
...@@ -66,19 +61,23 @@ export const AmountInput = forwardRef<NativeTextInput, Props>(function _AmountIn ...@@ -66,19 +61,23 @@ export const AmountInput = forwardRef<NativeTextInput, Props>(function _AmountIn
const { moonpaySupportedFiatCurrency: currency } = useMoonpayFiatCurrencySupportInfo() const { moonpaySupportedFiatCurrency: currency } = useMoonpayFiatCurrencySupportInfo()
const { addFiatSymbolToNumber } = useLocalizationContext() const { addFiatSymbolToNumber } = useLocalizationContext()
let formattedValue = replaceSeparators({ let formattedValue = showCurrencySign
value: value ?? '', ? addFiatSymbolToNumber({
value,
currencyCode: currency.code,
currencySymbol: currency.symbol,
})
: value
// TODO gary MOB-2028 replace temporary hack to handle different separators
formattedValue =
editable ?? true
? replaceSeparators({
value: formattedValue ?? '',
groupingSeparator: ',', groupingSeparator: ',',
decimalSeparator: '.', decimalSeparator: '.',
groupingOverride: groupingSeparator, groupingOverride: groupingSeparator,
decimalOverride: decimalSeparator, decimalOverride: decimalSeparator,
}) })
formattedValue = showCurrencySign
? addFiatSymbolToNumber({
value: formattedValue,
currencyCode: currency.code,
currencySymbol: currency.symbol,
})
: formattedValue : formattedValue
const textInputProps: TextInputProps = useMemo( const textInputProps: TextInputProps = useMemo(
......
...@@ -64,23 +64,25 @@ export function SeedPhraseDisplay({ ...@@ -64,23 +64,25 @@ export function SeedPhraseDisplay({
return ( return (
<> <>
<Flex grow mt="$spacing16">
{showSeedPhrase ? ( {showSeedPhrase ? (
<Flex grow mt="$spacing16">
<Flex grow pt="$spacing16" px="$spacing16"> <Flex grow pt="$spacing16" px="$spacing16">
<MnemonicDisplay mnemonicId={mnemonicId} /> <MnemonicDisplay mnemonicId={mnemonicId} />
</Flex> </Flex>
) : (
<HiddenMnemonicWordView />
)}
</Flex>
<Flex borderTopColor="$surface3" borderTopWidth={1} pt="$spacing12" px="$spacing16"> <Flex borderTopColor="$surface3" borderTopWidth={1} pt="$spacing12" px="$spacing16">
<Button <Button
testID={ElementName.Next} testID={ElementName.Next}
theme="secondary" theme="secondary"
onPress={(): void => setShowSeedPhrase(!showSeedPhrase)}> onPress={(): void => {
{showSeedPhrase ? t('Hide recovery phrase') : t('Show recovery phrase')} setShowSeedPhrase(false)
}}>
{t('Hide recovery phrase')}
</Button> </Button>
</Flex> </Flex>
</Flex>
) : (
<HiddenMnemonicWordView />
)}
{showSeedPhraseViewWarningModal && ( {showSeedPhraseViewWarningModal && (
<WarningModal <WarningModal
......
...@@ -34,6 +34,7 @@ import Animated, { ...@@ -34,6 +34,7 @@ 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 {
...@@ -46,7 +47,6 @@ import { ...@@ -46,7 +47,6 @@ 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]}>
{isIOS ? ( {IS_IOS ? (
<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={isAndroid ? [-DRAG_ACTIVATION_OFFSET, DRAG_ACTIVATION_OFFSET] : undefined} activeOffsetY={IS_ANDROID ? [-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={isAndroid ? [-DRAG_ACTIVATION_OFFSET, DRAG_ACTIVATION_OFFSET] : undefined} activeOffsetY={IS_ANDROID ? [-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={isAndroid ? '$spacing4' : '$none'}> <Flex mt={IS_ANDROID ? '$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
import { impactAsync, ImpactFeedbackStyle } from 'expo-haptics'
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { View } from 'react-native'
import {
useAnimatedReaction,
useDerivedValue,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import { TIME_TO_ACTIVATE_PAN } from './constants'
import { useAutoScroll, useStableCallback } from './hooks'
import {
AutoScrollProps,
ItemMeasurements,
SortableGridChangeEvent,
SortableGridContextType,
} from './types'
const SortableGridContext = createContext<SortableGridContextType | null>(null)
export function useSortableGridContext(): SortableGridContextType {
const context = useContext(SortableGridContext)
if (!context) {
throw new Error('useSortableGridContext must be used within a SortableGridProvider')
}
return context
}
type SortableGridProviderProps<I> = AutoScrollProps & {
data: I[]
children: React.ReactNode
activeItemScale?: number
activeItemOpacity?: number
activeItemShadowOpacity?: number
editable?: boolean
onDragStart?: () => void
onDragEnd?: () => void
onChange: (e: SortableGridChangeEvent<I>) => void
}
export default function SortableGridProvider<I>({
children,
onChange,
activeItemScale: activeItemScaleProp = 1.1,
activeItemOpacity: activeItemOpacityProp = 0.7,
activeItemShadowOpacity: activeItemShadowOpacityProp = 0.5,
editable = true,
visibleHeight,
onDragStart,
onDragEnd,
scrollableRef,
scrollY,
data,
}: SortableGridProviderProps<I>): JSX.Element {
const isInitialRenderRef = useRef(true)
const prevDataRef = useRef<I[]>([])
// Active cell settings
const activeItemScale = useDerivedValue(() => activeItemScaleProp)
const activeItemOpacity = useDerivedValue(() => activeItemOpacityProp)
const activeItemShadowOpacity = useDerivedValue(() => activeItemShadowOpacityProp)
// We have to use a state here because the activeIndex must be
// immediately set to null when the data changes (reanimated shared value
// updates are always delayed and can result in animation flickering)
const [activeIndexState, setActiveIndex] = useState<number | null>(null)
const previousActiveIndex = useSharedValue<number | null>(null)
const gridContainerRef = useRef<View>(null)
const touchedIndex = useSharedValue<number | null>(null)
const activeTranslation = useSharedValue({ x: 0, y: 0 })
const dragActivationProgress = useSharedValue(0)
const itemAtIndexMeasurements = useSharedValue<ItemMeasurements[]>([])
// Tells which item is currently displayed at each index
// (e.g. the item at index 0 in the data array was moved to the index 2
// in the displayed grid, so the render index of the item at index 2 is 0
// (the item displayed at index 2 is the item at index 0 in the data array))
const displayToRenderIndex = useSharedValue<number[]>(data.map((_, index) => index))
// Tells where the item rendered at each index was moved in the displayed grid
// (e.g. the item at index 0 in the data array was moved to the index 2
// in the displayed grid, so the display index of the item at index 0 is 2)
// (the reverse mapping of displayToRenderIndex)
const renderIndexToDisplayIndex = useDerivedValue(() => {
const result: number[] = []
const displayToRender = displayToRenderIndex.value
for (let i = 0; i < displayToRender.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
result[displayToRender[i]!] = i
}
return result
})
// Auto scroll settings
// Values used to scroll the container to the proper offset
// (updated from the SortableGridInner component)
const containerStartOffset = useSharedValue(0)
const containerEndOffset = useSharedValue(0)
const startScrollOffset = useSharedValue(0)
const scrollOffsetDiff = useDerivedValue(() => scrollY.value - startScrollOffset.value)
let activeIndex = activeIndexState
const dataChanged =
(!isInitialRenderRef.current && prevDataRef.current.length !== data.length) ||
prevDataRef.current.some((item, index) => item !== data[index])
if (dataChanged) {
prevDataRef.current = data
displayToRenderIndex.value = data.map((_, index) => index)
itemAtIndexMeasurements.value = itemAtIndexMeasurements.value.slice(0, data.length)
activeIndex = null
}
const isDragging = useDerivedValue(() => activeIndex !== null && touchedIndex.value !== null)
// Automatically scrolls the container when the active item is dragged
// out of the container bounds
useAutoScroll(
activeIndex,
touchedIndex,
itemAtIndexMeasurements,
activeTranslation,
scrollOffsetDiff,
containerStartOffset,
containerEndOffset,
visibleHeight,
scrollY,
scrollableRef
)
const handleOrderChange = useStableCallback((fromIndex: number) => {
const toIndex = renderIndexToDisplayIndex.value[fromIndex]
if (toIndex === undefined || toIndex === fromIndex) return
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const newData = displayToRenderIndex.value.map((displayIndex) => data[displayIndex]!)
onChange({ data: newData, fromIndex, toIndex })
})
const handleSetActiveIndex = useStableCallback((index: number | null) => {
// Because this function is run from worklet functions with runOnJS,
// it might be executed after the delay, when the item was released
// so we check if the item is still being dragged before setting the
// active index
if ((index === null || touchedIndex.value !== null) && index !== activeIndex) {
impactAsync(index === null ? ImpactFeedbackStyle.Light : ImpactFeedbackStyle.Medium).catch(
() => undefined
)
if (index !== null) {
onDragStart?.()
} else {
onDragEnd?.()
}
startScrollOffset.value = scrollY.value
setActiveIndex(index)
}
})
useEffect(() => {
const prevActiveIndex = previousActiveIndex.value
if (prevActiveIndex !== null) {
handleOrderChange(prevActiveIndex)
activeTranslation.value = { x: 0, y: 0 }
}
}, [
activeIndex,
previousActiveIndex,
handleOrderChange,
activeTranslation,
startScrollOffset,
scrollY,
])
useEffect(() => {
isInitialRenderRef.current = false
}, [])
useAnimatedReaction(
() => ({
isActive: activeIndex !== null,
offsetDiff: scrollOffsetDiff.value,
}),
({ isActive, offsetDiff }) => {
if (!isActive && Math.abs(offsetDiff) > 0) {
dragActivationProgress.value = withTiming(0, { duration: TIME_TO_ACTIVATE_PAN })
}
},
[activeIndex]
)
const contextValue = useMemo(
() => ({
activeTranslation,
gridContainerRef,
activeIndex,
editable,
scrollY,
itemAtIndexMeasurements,
renderIndexToDisplayIndex,
displayToRenderIndex,
setActiveIndex: handleSetActiveIndex,
previousActiveIndex,
touchedIndex,
activeItemScale,
isDragging,
dragActivationProgress,
scrollOffsetDiff,
activeItemOpacity,
visibleHeight,
activeItemShadowOpacity,
containerStartOffset,
containerEndOffset,
}),
[
activeIndex,
visibleHeight,
activeTranslation,
itemAtIndexMeasurements,
dragActivationProgress,
renderIndexToDisplayIndex,
handleSetActiveIndex,
displayToRenderIndex,
editable,
scrollY,
isDragging,
previousActiveIndex,
scrollOffsetDiff,
touchedIndex,
activeItemScale,
activeItemOpacity,
activeItemShadowOpacity,
containerStartOffset,
containerEndOffset,
]
)
return (
<SortableGridContext.Provider value={contextValue}>{children}</SortableGridContext.Provider>
)
}
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
}
...@@ -60,7 +60,7 @@ export function LongText(props: LongTextProps): JSX.Element { ...@@ -60,7 +60,7 @@ export function LongText(props: LongTextProps): JSX.Element {
const onTextLayout = useCallback( const onTextLayout = useCallback(
(e: NativeSyntheticEvent<TextLayoutEventData>) => { (e: NativeSyntheticEvent<TextLayoutEventData>) => {
setTextLengthExceedsLimit(e.nativeEvent.lines.length > initialDisplayedLines) setTextLengthExceedsLimit(e.nativeEvent.lines.length >= initialDisplayedLines)
}, },
[initialDisplayedLines] [initialDisplayedLines]
) )
......
import { Platform } from 'react-native'
export const IS_ANDROID = Platform.OS === 'android'
export const IS_IOS = Platform.OS === 'ios'
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.
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