ci(release): publish latest release

parent 6609a783
......@@ -8,9 +8,6 @@ node_modules
# testing
coverage
# utility script output
scripts/dist
# next.js
.next/
out/
......
3.2.2
\ No newline at end of file
......@@ -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:
- 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
Here again with a new (sparse) update to our app! Check out what is new below:
- Bug fixes
- Performance improvements on home and swap
- Improvements in language support
web/5.3.0
\ No newline at end of file
mobile/1.17.1
\ 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:
Install cocoapods:
`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
Open Xcode and go to:
......
......@@ -125,17 +125,17 @@ android {
dev {
isDefault(true)
applicationIdSuffix ".dev"
versionName "1.18"
versionName "1.17"
dimension "variant"
}
beta {
applicationIdSuffix ".beta"
versionName "1.18"
versionName "1.17.1"
dimension "variant"
}
prod {
dimension "variant"
versionName "1.18"
versionName "1.17.1"
}
}
......
......@@ -9,7 +9,6 @@
<item name="android:itemBackground">@color/item_background</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splashscreen_icon</item>
<item name="android:editTextBackground">@android:color/transparent</item>
</style>
</resources>
......@@ -10,7 +10,6 @@
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splashscreen_icon</item>
<item name="android:navigationBarColor">@color/background_material_light</item>
<item name="android:editTextBackground">@android:color/transparent</item>
</style>
</resources>
......@@ -651,7 +651,7 @@ PODS:
- EXAV (13.4.1):
- ExpoModulesCore
- ReactCommon/turbomodule/core
- EXBarCodeScanner (12.7.0):
- EXBarCodeScanner (12.3.2):
- EXImageLoader
- ExpoModulesCore
- ZXingObjC/OneD
......@@ -660,7 +660,7 @@ PODS:
- ExpoModulesCore
- EXFont (11.1.1):
- ExpoModulesCore
- EXImageLoader (4.4.0):
- EXImageLoader (4.1.1):
- ExpoModulesCore
- React-Core
- EXLocalAuthentication (13.0.2):
......@@ -1689,10 +1689,10 @@ SPEC CHECKSUMS:
EthersRS: 56b70e73d22d4e894b7e762eef1129159bcd3135
EXApplication: d8f53a7eee90a870a75656280e8d4b85726ea903
EXAV: f393dfc0b28214d62855a31e06eb21d426d6e2da
EXBarCodeScanner: 296dd50f6c03928d1d71d37ea17473b304cfdb00
EXBarCodeScanner: 8e23fae8d267dbef9f04817833a494200f1fce35
EXFileSystem: 0b4a2c08c2dc92146849772145d61c1773144283
EXFont: 6ea3800df746be7233208d80fe379b8ed74f4272
EXImageLoader: 03063370bc06ea1825713d3f55fe0455f7c88d04
EXImageLoader: fd053169a8ee932dd83bf1fe5487a50c26d27c2b
EXLocalAuthentication: 78cc5a00b13745b41d16d189ab332b61a01299f9
Expo: 8448e3a2aa1b295f029c81551e1ab6d986517fdb
ExpoBlur: fac3c6318fdf409dd5740afd3313b2bd52a35bac
......
......@@ -26,31 +26,9 @@ let placeholderPriceHistory = [
PriceHistory(timestamp: 1689794997, price: 2167),
PriceHistory(timestamp: 1689795264, price: 2165)
]
let previewEntry = TokenPriceEntry(
date: Date(),
configuration: TokenPriceConfigurationIntent(),
currency: WidgetConstants.currencyUsd,
spotPrice: 2165,
pricePercentChange: -9.87,
symbol: "ETH",
logo: UIImage(url: URL(string: "https://token-icons.s3.amazonaws.com/eth.png")),
backgroundColor: ColorExtraction.extractImageColorWithSpecialCase(
imageURL: "https://token-icons.s3.amazonaws.com/eth.png"
),
tokenPriceHistory: TokenPriceHistoryResponse(priceHistory: placeholderPriceHistory)
)
let previewEntry = TokenPriceEntry(date: Date(), configuration: TokenPriceConfigurationIntent(), spotPrice: 2165, pricePercentChange: -9.87, symbol: "ETH", logo: UIImage(url: URL(string: "https://token-icons.s3.amazonaws.com/eth.png")), backgroundColor: ColorExtraction.extractImageColorWithSpecialCase(imageURL: "https://token-icons.s3.amazonaws.com/eth.png"), tokenPriceHistory: TokenPriceHistoryResponse(priceHistory: placeholderPriceHistory))
let 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 placeholderEntry = TokenPriceEntry(date: previewEntry.date, configuration: previewEntry.configuration, spotPrice: previewEntry.spotPrice, pricePercentChange: previewEntry.pricePercentChange, symbol: previewEntry.symbol, logo: nil, backgroundColor: nil, tokenPriceHistory: previewEntry.tokenPriceHistory)
let refreshMinutes = 5
let displayName = "Token Prices"
......@@ -61,16 +39,10 @@ struct Provider: IntentTimelineProvider {
func getEntry(configuration: TokenPriceConfigurationIntent, context: Context, isSnapshot: Bool) async throws -> TokenPriceEntry {
let entryDate = Date()
async let tokenPriceRequest = isSnapshot ?
await DataQueries.fetchTokenPriceData(chain: WidgetConstants.ethereumChain, address: nil) :
await DataQueries.fetchTokenPriceData(chain: configuration.selectedToken?.chain ?? "", address: configuration.selectedToken?.address)
async let conversionRequest = await DataQueries.fetchCurrencyConversion(
toCurrency: UniswapUserDefaults.readI18n().currency)
let (tokenPriceResponse, conversionResponse) = try await (tokenPriceRequest, conversionRequest)
let spotPrice = tokenPriceResponse.spotPrice != nil ?
tokenPriceResponse.spotPrice! * conversionResponse.conversionRate : nil
let tokenPriceResponse = isSnapshot ?
try await DataQueries.fetchTokenPriceData(chain: WidgetConstants.ethereumChain, address: nil) :
try await DataQueries.fetchTokenPriceData(chain: configuration.selectedToken?.chain ?? "", address: configuration.selectedToken?.address)
let spotPrice = tokenPriceResponse.spotPrice
let pricePercentChange = tokenPriceResponse.pricePercentChange
let symbol = tokenPriceResponse.symbol
let logo = UIImage(url: URL(string: tokenPriceResponse.logoUrl ?? ""))
......@@ -90,17 +62,7 @@ struct Provider: IntentTimelineProvider {
address: configuration.selectedToken?.address)
}
return TokenPriceEntry(
date: entryDate,
configuration: configuration,
currency: conversionResponse.currency,
spotPrice: spotPrice,
pricePercentChange: pricePercentChange,
symbol: symbol,
logo: logo,
backgroundColor: backgroundColor,
tokenPriceHistory: tokenPriceHistory
)
return TokenPriceEntry(date: entryDate, configuration: configuration, spotPrice: spotPrice, pricePercentChange: pricePercentChange, symbol: symbol, logo: logo, backgroundColor: backgroundColor, tokenPriceHistory: tokenPriceHistory)
}
func placeholder(in context: Context) -> TokenPriceEntry {
......@@ -128,7 +90,6 @@ struct Provider: IntentTimelineProvider {
struct TokenPriceEntry: TimelineEntry {
let date: Date
let configuration: TokenPriceConfigurationIntent
let currency: String
let spotPrice: Double?
let pricePercentChange: Double?
let symbol: String
......@@ -169,14 +130,7 @@ struct TokenPriceWidgetEntryView: View {
func priceSection(isPlaceholder: Bool) -> some View {
return VStack(alignment: .leading, spacing: 0) {
if (!isPlaceholder && entry.spotPrice != nil && entry.pricePercentChange != nil) {
let i18nSettings = UniswapUserDefaults.readI18n()
Text(
NumberFormatter.fiatTokenDetailsFormatter(
price: entry.spotPrice,
locale: Locale(identifier: i18nSettings.locale),
currencyCode: entry.currency
)
)
Text(NumberFormatter.fiatTokenDetailsFormatter(price: entry.spotPrice))
.withHeading1Style()
.frame(minHeight: 28)
.minimumScaleFactor(0.3)
......
......@@ -12,7 +12,6 @@ public struct WidgetConstants {
public static let ethereumChain = "ETHEREUM"
public static let WETHAddress = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"
public static let ethereumSymbol = "ETH"
public static let currencyUsd = "USD"
}
// Needed to handle different bundle ids, cannot map directly but handles arbitrary bundle ids that conform to the existing convention
......
......@@ -10,9 +10,9 @@ import Apollo
import OSLog
public class DataQueries {
static let cachePolicy: CachePolicy = CachePolicy.fetchIgnoringCacheData
public static func fetchTokensData(tokenInputs: [TokenInput]) async throws -> [TokenResponse] {
return try await withCheckedThrowingContinuation { continuation in
let contractInputs = tokenInputs.map {MobileSchema.ContractInput(chain: GraphQLEnum(rawValue: $0.chain), address: $0.address == nil ? GraphQLNullable.null: GraphQLNullable(stringLiteral: $0.address!))}
......@@ -34,7 +34,7 @@ public class DataQueries {
}
}
}
public static func fetchTopTokensData() async throws -> [TokenResponse] {
return try await withCheckedThrowingContinuation { continuation in
Network.shared.apollo.fetch(query: MobileSchema.TopTokensQuery(chain: GraphQLNullable(MobileSchema.Chain.ethereum)), cachePolicy: cachePolicy) { result in
......@@ -55,7 +55,7 @@ public class DataQueries {
}
}
}
public static func fetchTokenPriceData(chain: String, address: String?) async throws -> TokenPriceResponse {
return try await withCheckedThrowingContinuation { continuation in
Network.shared.apollo.fetch(query: MobileSchema.FavoriteTokenCardQuery(chain: GraphQLEnum(rawValue: chain), address: address == nil ? GraphQLNullable.null : GraphQLNullable(stringLiteral: address!)), cachePolicy: cachePolicy) { result in
......@@ -76,7 +76,7 @@ public class DataQueries {
}
}
}
public static func fetchTokenPriceHistoryData(chain: String, address: String?) async throws -> TokenPriceHistoryResponse {
return try await withCheckedThrowingContinuation { continuation in
Network.shared.apollo.fetch(query: MobileSchema.TokenPriceHistoryQuery(contract: MobileSchema.ContractInput(chain: GraphQLEnum(rawValue: chain), address: address == nil ? GraphQLNullable.null: GraphQLNullable(stringLiteral: address!))), cachePolicy: cachePolicy) { result in
......@@ -96,10 +96,10 @@ public class DataQueries {
}
}
}
public static func fetchWalletsTokensData(addresses: [String], maxLength: Int = 25) async throws -> [TokenResponse] {
return try await withCheckedThrowingContinuation { continuation in
Network.shared.apollo.fetch(query: MobileSchema.MultiplePortfolioBalancesQuery(ownerAddresses: addresses, valueModifiers: GraphQLNullable.null)){ result in
Network.shared.apollo.fetch(query: MobileSchema.MultiplePortfolioBalancesQuery(ownerAddresses: addresses)){ result in
switch result {
case .success(let graphQLResult):
// Takes all the signer accounts and sums up the balances of the tokens, then sorts them by descending order, ignoring spam
......@@ -124,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 @@
//
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 {
static func formatShorthandWithDecimals(number: Double, fractionDigits: Int, locale: Locale, currencyCode: String, placeholder: String) -> String {
if (number < 1000000) {
return formatWithDecimals(number: number, fractionDigits: fractionDigits, locale: locale, currencyCode: currencyCode)
}
let maxNumber = 1000000000000000.0
let maxed = number >= maxNumber
let limitedNumber = maxed ? maxNumber : number
// Replace when Swift supports notation configuration for currency
// https://developer.apple.com/documentation/foundation/currencyformatstyleconfiguration
let compactFormatted = limitedNumber.formatted(.number.locale(locale).precision(.fractionLength(fractionDigits)).notation(.compactName))
let currencyFormatted = limitedNumber.formatted(.currency(code: currencyCode).locale(locale).precision(.fractionLength(fractionDigits)).grouping(.never))
// Based on https://www.notion.so/uniswaplabs/Number-standards-fbb9f533f10e4e22820722c2f66d23c0
// React native code: https://github.com/Uniswap/universe/blob/main/packages/wallet/src/utils/format.ts
public static func SHORTHAND_USD_TWO_DECIMALS(price: Double) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.maximumFractionDigits = 2
formatter.minimumFractionDigits = 2
formatter.currencyCode = "USD"
guard let numberRegex = try? NSRegularExpression(pattern: "(\\d+(\\\(locale.decimalSeparator!)\\d+)?)") else {
return placeholder
if (price < 1000000){
return TWO_DECIMALS_USD.string(for: price)!
}
else if (price < 1000000000){
return "\(formatter.string(for: price/1000000)!)M"
}
else if (price < 1000000000000){
return "\(formatter.string(for: price/1000000000)!)B"
}
else if (price < 1000000000000000){
return "\(formatter.string(for: price/1000000000000)!)T"
}
else {
return "$>999T"
}
let output = numberRegex.stringByReplacingMatches(in: currencyFormatted, range: NSMakeRange(0, currencyFormatted.count), withTemplate: compactFormatted)
return maxed ? ">\(output)" : "\(output)"
}
static func formatWithDecimals(number: Double, fractionDigits: Int, locale: Locale, currencyCode: String) -> String {
return number.formatted(.currency(code: currencyCode).locale(locale).precision(.fractionLength(fractionDigits)))
}
public static var TWO_DECIMALS_USD: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.maximumFractionDigits = 2
formatter.minimumFractionDigits = 2
formatter.currencyCode = "USD"
return formatter
}()
static func formatWithSigFigs(number: Double, sigFigsDigits: Int, locale: Locale, currencyCode: String) -> String {
return number.formatted(.currency(code: currencyCode).locale(locale).precision(.significantDigits(sigFigsDigits)))
}
public static var THREE_SIG_FIGS_USD: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.maximumSignificantDigits = 3
formatter.minimumSignificantDigits = 3
formatter.currencyCode = "USD"
return formatter
}()
public static func fiatTokenDetailsFormatter(price: Double?, locale: Locale, currencyCode: String) -> String {
let placeholder = "--.--"
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 {
return placeholder
return "--.--"
}
if (price < 0.00000001) {
let formattedPrice = formatWithDecimals(number: price, fractionDigits: 8, locale: locale, currencyCode: currencyCode)
return "<\(formattedPrice)"
return "<$0.00000001"
}
if (price < 0.01) {
return formatWithSigFigs(number: price, sigFigsDigits: 3, locale: locale, currencyCode: currencyCode)
} else if (price < 1.05) {
return formatWithDecimals(number: price, fractionDigits: 3, locale: locale, currencyCode: currencyCode)
} else if (price < 1e6) {
return formatWithDecimals(number: price, fractionDigits: 2, locale: locale, currencyCode: currencyCode)
} else {
return formatShorthandWithDecimals(number: price, fractionDigits: 2, locale: locale, currencyCode: currencyCode, placeholder: placeholder)
else if (price < 0.01) {
return THREE_SIG_FIGS_USD.string(for: price)!
}
else if (price < 1.05) {
return THREE_DECIMALS_USD.string(for: price)!
}
else if (price < 1e6) {
return TWO_DECIMALS_USD.string(for: price)!
}
else {
return SHORTHAND_USD_TWO_DECIMALS(price: price)
}
}
}
......@@ -51,8 +51,3 @@ public struct PriceHistory {
public let timestamp: Int
public let price: Double
}
public struct CurrencyConversionResponse {
public let conversionRate: Double
public let currency: String
}
......@@ -34,17 +34,6 @@ public struct WidgetDataAccounts: Decodable {
public var accounts: [Account]
}
public struct WidgetDataI18n: Decodable {
public init() {
self.locale = "en"
self.currency = WidgetConstants.currencyUsd
}
public var locale: String
public var currency: String
}
public struct Account: Decodable {
public var address: String
public var name: String?
......@@ -91,14 +80,14 @@ public enum Change: String, Codable {
case removed = "removed"
}
public struct UniswapUserDefaults {
private static var buildString = getBuildVariantString(bundleId: Bundle.main.bundleIdentifier!)
static let keyEvents = buildString + ".widgets.configuration.events"
static let keyCache = buildString + ".widgets.configuration.cache"
static let keyFavorites = buildString + ".widgets.favorites"
static let keyAccounts = buildString + ".widgets.accounts"
static let keyI18n = buildString + ".widgets.i18n"
static let eventsKey = buildString + ".widgets.configuration.events"
static let cacheKey = buildString + ".widgets.configuration.cache"
static let favoritesKey = buildString + ".widgets.favorites"
static let accountsKey = buildString + ".widgets.accounts"
static let userDefaults = UserDefaults.init(suiteName: APP_GROUP)
......@@ -115,7 +104,7 @@ public struct UniswapUserDefaults {
}
public static func readAccounts() -> WidgetDataAccounts {
let data = readData(key: keyAccounts)
let data = readData(key: accountsKey)
guard let data = data else {
return WidgetDataAccounts([])
}
......@@ -128,7 +117,7 @@ public struct UniswapUserDefaults {
}
public static func readFavorites() -> WidgetDataFavorites {
let data = readData(key: keyFavorites)
let data = readData(key: favoritesKey)
guard let data = data else {
return WidgetDataFavorites([])
}
......@@ -140,20 +129,8 @@ public struct UniswapUserDefaults {
return parsedData
}
public static func readI18n() -> WidgetDataI18n {
let data = readData(key: keyI18n)
guard let data = data else {
return WidgetDataI18n()
}
let decoder = JSONDecoder()
guard let parsedData = try? decoder.decode(WidgetDataI18n.self, from: data) else {
return WidgetDataI18n()
}
return parsedData
}
public static func readConfiguration() -> WidgetDataConfiguration {
let data = readData(key: keyCache)
let data = readData(key: cacheKey)
guard let data = data else {
return WidgetDataConfiguration([])
}
......@@ -170,12 +147,12 @@ public struct UniswapUserDefaults {
let encoder = JSONEncoder()
let JSONdata = try! encoder.encode(data)
let json = String(data: JSONdata, encoding: String.Encoding.utf8)
userDefaults!.set(json, forKey: keyCache)
userDefaults!.set(json, forKey: cacheKey)
}
}
public static func readEventChanges() -> WidgetEvents {
let data = readData(key: keyEvents)
let data = readData(key: eventsKey)
guard let data = data else {
return WidgetEvents(events: [])
}
......@@ -192,7 +169,7 @@ public struct UniswapUserDefaults {
let encoder = JSONEncoder()
let JSONdata = try! encoder.encode(data)
let json = String(data: JSONdata, encoding: String.Encoding.utf8)
userDefaults!.set(json, forKey: keyEvents)
userDefaults!.set(json, forKey: eventsKey)
}
}
}
......@@ -10,97 +10,15 @@ import WidgetsCore
final class FormatTests: XCTestCase {
let localeEnglish = Locale(identifier: "en")
let localeFrench = Locale(identifier: "fr-FR")
let localeChinese = Locale(identifier: "zh-Hans")
let currencyCodeUsd = WidgetConstants.currencyUsd
let currencyCodeEuro = "EUR"
let currencyCodeYuan = "CNY"
struct TestCase {
public init(_ price: Double, _ output: String) {
self.price = price
self.output = output
}
public let price: Double
public let output: String
}
func testFormatterHandlesEnglish() throws {
let testCases = [
TestCase(0.05, "$0.050"),
TestCase(0.056666666, "$0.057"),
TestCase(1234567.891, "$1.23M"),
TestCase(1234.5678, "$1,234.57"),
TestCase(1.048952, "$1.049"),
TestCase(0.001231, "$0.00123"),
TestCase(0.00001231, "$0.0000123"),
TestCase(0.0000001234, "$0.000000123"),
TestCase(0.000000009876, "<$0.00000001"),
]
testCases.forEach { testCase in
XCTAssertEqual(
NumberFormatter.fiatTokenDetailsFormatter(
price: testCase.price,
locale: localeEnglish,
currencyCode: currencyCodeUsd
),
testCase.output
)
}
}
func testFormatterHandlesFrench() throws {
let testCases = [
TestCase(0.05, "0,050 €"),
TestCase(0.056666666, "0,057 €"),
TestCase(1234567.891, "1,23 M €"),
TestCase(123456.7890, "123 456,79 €"),
TestCase(1.048952, "1,049 €"),
TestCase(0.001231, "0,00123 €"),
TestCase(0.00001231, "0,0000123 €"),
TestCase(0.0000001234, "0,000000123 €"),
TestCase(0.000000009876, "<0,00000001 €"),
]
testCases.forEach { testCase in
XCTAssertEqual(
NumberFormatter.fiatTokenDetailsFormatter(
price: testCase.price,
locale: localeFrench,
currencyCode: currencyCodeEuro
),
testCase.output
)
}
}
func testFormatterHandlesChinese() throws {
let testCases = [
TestCase(0.05, "¥0.050"),
TestCase(0.056666666, "¥0.057"),
TestCase(1234567.891, "¥123.46万"),
TestCase(1234.5678, "¥1,234.57"),
TestCase(1.048952, "¥1.049"),
TestCase(0.001231, "¥0.00123"),
TestCase(0.00001231, "¥0.0000123"),
TestCase(0.0000001234, "¥0.000000123"),
TestCase(0.000000009876, "<¥0.00000001"),
]
testCases.forEach { testCase in
XCTAssertEqual(
NumberFormatter.fiatTokenDetailsFormatter(
price: testCase.price,
locale: localeChinese,
currencyCode: currencyCodeYuan
),
testCase.output
)
}
func testFiatTokenDetailsFormatter() throws {
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.05), "$0.050")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.056666666), "$0.057")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 1234567.891), "$1.23M")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 1234.5678), "$1,234.57")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 1.048952), "$1.049")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.001231), "$0.00123")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.00001231), "$0.0000123")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.0000001234), "$0.000000123")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.000000009876), "<$0.00000001")
}
}
......@@ -109,7 +109,7 @@
"ethers": "5.7.2",
"expo": "48.0.19",
"expo-av": "13.4.1",
"expo-barcode-scanner": "12.7.0",
"expo-barcode-scanner": "12.3.2",
"expo-blur": "12.2.2",
"expo-clipboard": "4.1.2",
"expo-haptics": "12.0.1",
......
......@@ -33,7 +33,6 @@ import {
processWidgetEvents,
setAccountAddressesUserDefaults,
setFavoritesUserDefaults,
setI18NUserDefaults,
} from 'src/features/widgets/widgets'
import { useAppStateTrigger } from 'src/utils/useAppStateTrigger'
import { getSentryEnvironment, getStatsigEnvironmentTier } from 'src/utils/version'
......@@ -47,8 +46,6 @@ import { uniswapUrls } from 'wallet/src/constants/urls'
import { useCurrentAppearanceSetting, useIsDarkMode } from 'wallet/src/features/appearance/hooks'
import { EXPERIMENT_NAMES, FEATURE_FLAGS } from 'wallet/src/features/experiments/constants'
import { selectFavoriteTokens } from 'wallet/src/features/favorites/selectors'
import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks'
import { useCurrentLanguageInfo } from 'wallet/src/features/language/hooks'
import { LocalizationContextProvider } from 'wallet/src/features/language/LocalizationContext'
import { updateLanguage } from 'wallet/src/features/language/slice'
import { Account } from 'wallet/src/features/wallet/accounts/types'
......@@ -179,8 +176,8 @@ function AppOuter(): JSX.Element | null {
<ApolloProvider client={client}>
<PersistGate loading={null} persistor={persistor}>
<ErrorBoundary>
<LocalizationContextProvider>
<GestureHandlerRootView style={flexStyles.fill}>
<GestureHandlerRootView style={flexStyles.fill}>
<LocalizationContextProvider>
<WalletContextProvider>
<BiometricContextProvider>
<LockScreenContextProvider>
......@@ -202,8 +199,8 @@ function AppOuter(): JSX.Element | null {
</LockScreenContextProvider>
</BiometricContextProvider>
</WalletContextProvider>
</GestureHandlerRootView>
</LocalizationContextProvider>
</LocalizationContextProvider>
</GestureHandlerRootView>
</ErrorBoundary>
</PersistGate>
</ApolloProvider>
......@@ -241,8 +238,6 @@ function AppInner(): JSX.Element {
function DataUpdaters(): JSX.Element {
const favoriteTokens: CurrencyId[] = useAppSelector(selectFavoriteTokens)
const accountsMap: Record<string, Account> = useAccounts()
const { locale } = useCurrentLanguageInfo()
const { code } = useAppFiatCurrencyInfo()
// Refreshes widgets when bringing app to foreground
useAppStateTrigger('background', 'active', processWidgetEvents)
......@@ -255,10 +250,6 @@ function DataUpdaters(): JSX.Element {
setAccountAddressesUserDefaults(Object.values(accountsMap))
}, [accountsMap])
useEffect(() => {
setI18NUserDefaults({ locale, currency: code })
}, [code, locale])
return (
<>
<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 { useCallback, useRef, useState } from 'react'
import { useCallback, useState } from 'react'
import { LayoutChangeEvent } from 'react-native'
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import type { AppDispatch } from 'src/app/store'
......@@ -70,24 +70,27 @@ export function useDynamicFontSizing(
onSetFontSize: (amount: string) => void
} {
const [fontSize, setFontSize] = useState(maxFontSize)
const textInputElementWidthRef = useRef(0)
const [textInputElementWidth, setTextInputElementWidth] = useState<number>(0)
const onLayout = useCallback((event: LayoutChangeEvent) => {
if (textInputElementWidthRef.current) return
const onLayout = useCallback(
(event: LayoutChangeEvent) => {
if (textInputElementWidth) return
const width = event.nativeEvent.layout.width
textInputElementWidthRef.current = width
}, [])
const width = event.nativeEvent.layout.width
setTextInputElementWidth(width)
},
[setTextInputElementWidth, textInputElementWidth]
)
const onSetFontSize = useCallback(
(amount: string) => {
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 newFontSize = Math.round(Math.min(maxFontSize, scaledSizeWithMin))
setFontSize(newFontSize)
},
[fontSize, maxFontSize, minFontSize, maxCharWidthAtMaxFontSize]
[fontSize, maxFontSize, minFontSize, maxCharWidthAtMaxFontSize, textInputElementWidth]
)
return { onLayout, fontSize, onSetFontSize }
......
......@@ -53,7 +53,6 @@ import {
v51Schema,
v52Schema,
v53Schema,
v54Schema,
v5Schema,
v6Schema,
v7Schema,
......@@ -62,7 +61,6 @@ import {
} from 'src/app/schema'
import { persistConfig } from 'src/app/store'
import { ScannerModalState } from 'src/components/QRCodeScanner/constants'
import { initialBehaviorHistoryState } from 'src/features/behaviorHistory/slice'
import { initialBiometricsSettingsState } from 'src/features/biometrics/slice'
import { initialCloudBackupState } from 'src/features/CloudBackup/cloudBackupSlice'
import { initialPasswordLockoutState } from 'src/features/CloudBackup/passwordLockoutSlice'
......@@ -154,7 +152,6 @@ describe('Redux state migrations', () => {
modals: initialModalState,
notifications: initialNotificationsState,
passwordLockout: initialPasswordLockoutState,
behaviorHistory: initialBehaviorHistoryState,
providers: { isInitialized: false },
saga: {},
searchHistory: initialSearchHistoryState,
......@@ -1243,11 +1240,4 @@ describe('Redux state migrations', () => {
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 = {
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'
import { AddressDisplay } from 'src/components/AddressDisplay'
import { ActionSheetModal, MenuItemProp } from 'src/components/modals/ActionSheetModal'
import { BottomSheetModal } from 'src/components/modals/BottomSheetModal'
import { IS_ANDROID } from 'src/constants/globals'
import { isCloudStorageAvailable } from 'src/features/CloudBackup/RNCloudStorageBackupsManager'
import { closeModal, openModal } from 'src/features/modals/modalSlice'
import { selectModalState } from 'src/features/modals/selectModalState'
......@@ -35,7 +36,6 @@ import {
import { useActiveAccountAddress, useNativeAccountExists } from 'wallet/src/features/wallet/hooks'
import { selectAllAccountsSorted } from 'wallet/src/features/wallet/selectors'
import { setAccountAsActive } from 'wallet/src/features/wallet/slice'
import { isAndroid } from 'wallet/src/utils/platform'
export function AccountSwitcherModal(): JSX.Element {
const dispatch = useAppDispatch()
......@@ -151,8 +151,8 @@ export function AccountSwitcher({ onClose }: { onClose: () => void }): JSX.Eleme
if (!cloudStorageAvailable) {
Alert.alert(
isAndroid ? t('Google Drive not available') : t('iCloud Drive not available'),
isAndroid
IS_ANDROID ? t('Google Drive not available') : t('iCloud Drive not available'),
IS_ANDROID
? t(
'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
render: () => (
<Flex alignItems="center" borderTopColor="$surface3" borderTopWidth={1} p="$spacing16">
<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>
</Flex>
),
......
......@@ -15,7 +15,6 @@ import { FiatOnRampAggregatorModal } from 'src/features/fiatOnRamp/FiatOnRampAgg
import { FiatOnRampModal } from 'src/features/fiatOnRamp/FiatOnRampModal'
import { ModalName } from 'src/features/telemetry/constants'
import { SettingsFiatCurrencyModal } from 'src/screens/SettingsFiatCurrencyModal'
import { SettingsLanguageModal } from 'src/screens/SettingsLanguageModal'
export function AppModals(): JSX.Element {
return (
......@@ -62,10 +61,6 @@ export function AppModals(): JSX.Element {
<RestoreWalletModal />
</LazyModalRenderer>
<LazyModalRenderer name={ModalName.LanguageSelector}>
<SettingsLanguageModal />
</LazyModalRenderer>
<LazyModalRenderer name={ModalName.FiatCurrencySelector}>
<SettingsFiatCurrencyModal />
</LazyModalRenderer>
......
import { useApolloClient } from '@apollo/client'
import React, { useState } from 'react'
import { ScrollView } from 'react-native-gesture-handler'
import { Action } from 'redux'
......@@ -11,20 +10,17 @@ import { ModalName } from 'src/features/telemetry/constants'
import { selectCustomEndpoint } from 'src/features/tweaks/selectors'
import { setCustomEndpoint } from 'src/features/tweaks/slice'
import { Statsig } from 'statsig-react'
import { useExperimentWithExposureLoggingDisabled } from 'statsig-react-native'
import { Accordion } from 'tamagui'
import { Button, Flex, Icons, Text, useDeviceInsets } from 'ui/src'
import { useExperiment } from 'statsig-react-native'
import { Button, Flex, Text, useDeviceInsets } from 'ui/src'
import { spacing } from 'ui/src/theme'
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 {
const insets = useDeviceInsets()
const dispatch = useAppDispatch()
const customEndpoint = useAppSelector(selectCustomEndpoint)
const apollo = useApolloClient()
const [url, setUrl] = useState<string>(customEndpoint?.url || '')
const [key, setKey] = useState<string>(customEndpoint?.key || '')
......@@ -52,113 +48,51 @@ export function ExperimentsModal(): JSX.Element {
renderBehindBottomInset
name={ModalName.Experiments}
onClose={(): Action => dispatch(closeModal({ name: ModalName.Experiments }))}>
<ScrollView
contentContainerStyle={{
paddingBottom: insets.bottom,
paddingRight: spacing.spacing24,
paddingLeft: spacing.spacing24,
}}>
<Accordion type="single">
<Accordion.Item value="graphql-endpoint">
<AccordionHeader title="⚙️ Custom GraphQL Endpoint" />
<Accordion.Content>
<ScrollView contentContainerStyle={{ paddingBottom: insets.bottom + spacing.spacing12 }}>
<Flex gap="$spacing16" justifyContent="flex-start" pt="$spacing12" px="$spacing24">
<Flex gap="$spacing8">
<Flex gap="$spacing16" my="$spacing16">
<Text variant="subheading1">⚙️ Custom GraphQL Endpoint</Text>
<Text variant="body2">
You will need to restart the application to pick up any changes in this section.
Beware of client side caching!
</Text>
<Flex row alignItems="center" gap="$spacing16">
<Text variant="body2">URL</Text>
<TextInput flex={1} value={url} onChangeText={setUrl} />
</Flex>
<Flex row alignItems="center" gap="$spacing16">
<Text variant="body2">Key</Text>
<TextInput flex={1} value={key} onChangeText={setKey} />
</Flex>
<Flex grow row alignItems="center" gap="$spacing16">
<Button flex={1} size="small" onPress={setEndpoint}>
Set
</Button>
<Button flex={1} size="small" onPress={clearEndpoint}>
Clear
</Button>
</Flex>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="apollo-cache">
<AccordionHeader title="🚀 Apollo Cache" />
<Accordion.Content>
<Button
flex={1}
size="small"
onPress={async (): Promise<unknown> => await apollo.resetStore()}>
Reset Cache
<Button size="small" onPress={setEndpoint}>
Set
</Button>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="feature-flags">
<AccordionHeader title="⛳️ Feature Flags" />
<Accordion.Content>
<Text variant="body2">
Overridden feature flags are reset when the app is restarted
</Text>
<Flex gap="$spacing12" mt="$spacing12">
{Object.values(FEATURE_FLAGS).map((featureFlag) => {
return <FeatureFlagRow key={featureFlag} featureFlag={featureFlag} />
})}
</Flex>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="experiments">
<AccordionHeader title="🔬 Experiments" />
<Accordion.Content>
<Text variant="body2">
Overridden experiments are reset when the app is restarted
</Text>
<Flex gap="$spacing12" mt="$spacing12">
{Object.values(EXPERIMENT_NAMES).map((experiment) => {
return <ExperimentRow key={experiment} name={experiment} />
})}
</Flex>
</Accordion.Content>
</Accordion.Item>
</Accordion>
<Button size="small" onPress={clearEndpoint}>
Clear
</Button>
</Flex>
<Text variant="subheading1">⛳️ Feature Flags</Text>
<Text variant="body2">
Overridden feature flags are reset when the app is restarted
</Text>
</Flex>
{Object.values(FEATURE_FLAGS).map((featureFlag) => {
return <FeatureFlagRow key={featureFlag} featureFlag={featureFlag} />
})}
<Text variant="subheading1">🔬 Experiments</Text>
<Text variant="body2">Overridden experiments are reset when the app is restarted</Text>
{Object.values(EXPERIMENT_NAMES).map((experiment) => {
return <ExperimentRow key={experiment} name={experiment} />
})}
</Flex>
</ScrollView>
</BottomSheetModal>
)
}
function AccordionHeader({ title }: { title: React.ReactNode }): JSX.Element {
return (
<Accordion.Header mt="$spacing12">
<Accordion.Trigger>
{({ open }: { open: boolean }): JSX.Element => (
<>
<Flex row justifyContent="space-between" width="100%">
<Text variant="subheading1">{title}</Text>
<Icons.RotatableChevron direction={open ? 'up' : 'down'} />
</Flex>
</>
)}
</Accordion.Trigger>
</Accordion.Header>
)
}
function FeatureFlagRow({ featureFlag }: { featureFlag: FEATURE_FLAGS }): JSX.Element {
const status = useFeatureFlagWithExposureLoggingDisabled(featureFlag)
const status = useFeatureFlag(featureFlag)
return (
<Flex row alignItems="center" gap="$spacing16" justifyContent="space-between">
......@@ -174,7 +108,10 @@ function FeatureFlagRow({ featureFlag }: { featureFlag: FEATURE_FLAGS }): JSX.El
}
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]) => (
<Flex
......
......@@ -8,14 +8,15 @@ import { updateSwapStartTimestamp } from 'src/features/telemetry/timing/slice'
import { SwapFlow } from 'src/features/transactions/swap/SwapFlow'
import { SwapFlow as SwapFlowRewrite } from 'src/features/transactions/swapRewrite/SwapFlow'
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 {
const colors = useSporeColors()
const appDispatch = useAppDispatch()
const modalState = useAppSelector(selectModalState(ModalName.Swap))
const shouldShowSwapRewrite = useSwapRewriteEnabled()
const shouldShowSwapRewrite = useFeatureFlag(FEATURE_FLAGS.SwapRewrite)
const onClose = useCallback((): void => {
appDispatch(closeModal({ name: ModalName.Swap }))
......
......@@ -14,6 +14,7 @@ import {
} from 'react-native-reanimated'
import { useAppDispatch } from 'src/app/hooks'
import { pulseAnimation } from 'src/components/buttons/utils'
import { IS_ANDROID, IS_IOS } from 'src/constants/globals'
import { openModal } from 'src/features/modals/modalSlice'
import { sendMobileAnalyticsEvent } from 'src/features/telemetry'
import { ElementName, ModalName } from 'src/features/telemetry/constants'
......@@ -35,7 +36,6 @@ import { useIsDarkMode } from 'wallet/src/features/appearance/hooks'
import { useHighestBalanceNativeCurrencyId } from 'wallet/src/features/dataApi/balances'
import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks'
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_SM = 72
......@@ -86,7 +86,7 @@ export function NavBar(): JSX.Element {
alignItems="center"
gap="$spacing12"
justifyContent="space-between"
mb={isAndroid ? '$spacing8' : '$none'}
mb={IS_ANDROID ? '$spacing8' : '$none'}
mx="$spacing24"
pointerEvents="auto">
<ExploreTabBarButton />
......@@ -212,7 +212,7 @@ function ExploreTabBarButton({ activeScale = 0.98 }: ExploreTabBarButtonProps):
},
})
const contentProps: FlexProps = isIOS
const contentProps: FlexProps = IS_IOS
? {
bg: '$surface2',
opacity: isDarkMode ? 0.6 : 0.8,
......@@ -233,7 +233,7 @@ function ExploreTabBarButton({ activeScale = 0.98 }: ExploreTabBarButtonProps):
onPress={onPress}>
<TapGestureHandler onGestureEvent={onGestureEvent}>
<AnimatedFlex borderRadius="$roundedFull" overflow="hidden" style={animatedStyle}>
<BlurView intensity={isIOS ? 100 : 0}>
<BlurView intensity={IS_IOS ? 100 : 0}>
<Flex
{...contentProps}
fill
......
import { combineReducers } from '@reduxjs/toolkit'
import { behaviorHistoryReducer } from 'src/features/behaviorHistory/slice'
import { biometricSettingsReducer } from 'src/features/biometrics/slice'
import { cloudBackupReducer } from 'src/features/CloudBackup/cloudBackupSlice'
import { passwordLockoutReducer } from 'src/features/CloudBackup/passwordLockoutSlice'
......@@ -15,7 +14,6 @@ import { monitoredSagaReducers } from './saga'
const reducers = {
...sharedReducers,
behaviorHistory: behaviorHistoryReducer,
biometricSettings: biometricSettingsReducer,
cloudBackup: cloudBackupReducer,
modals: modalsReducer,
......
......@@ -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
// export const getSchema = (): RootState => v0Schema
export const getSchema = (): typeof v54Schema => v54Schema
......@@ -55,7 +55,6 @@ const rtkQueryErrorLogger: Middleware = () => (next) => (action: PayloadAction<u
const whitelist: Array<ReducerNames | RootReducerNames> = [
'appearanceSettings',
'behaviorHistory',
'biometricSettings',
'favorites',
'notifications',
......@@ -75,7 +74,7 @@ export const persistConfig = {
key: 'root',
storage: reduxStorage,
whitelist,
version: 55,
version: 54,
migrate: createMigrate(migrations),
}
......
......@@ -17,10 +17,10 @@ import { TextLoaderWrapper } from 'ui/src/components/text/Text'
import { fonts } from 'ui/src/theme'
import { usePrevious } from 'utilities/src/react/hooks'
export 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
export const DIGIT_HEIGHT = 44
export const ADDITIONAL_WIDTH_FOR_ANIMATIONS = 8
const NUMBER_ARRAY = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
const NUMBER_WIDTH_ARRAY = [29, 20, 29, 29, 29, 29, 29, 29, 29, 29] // width of digits in a font
const DIGIT_HEIGHT = 44
const ADDITIONAL_WIDTH_FOR_ANIMATIONS = 10
// TODO: remove need to manually define width of each character
const NUMBER_WIDTH_ARRAY_SCALED = NUMBER_WIDTH_ARRAY.map(
......@@ -171,27 +171,6 @@ function longestCommonPrefix(a: string, b: string): string {
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
// Used for initial layout larger than all screen sizes
......@@ -295,7 +274,34 @@ const AnimatedNumber = ({
backgroundColor="$surface1"
borderRadius="$rounded4"
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}>
<AnimatedFlex row entering={FadeIn} width={MAX_DEVICE_WIDTH}>
{chars?.map((_, index) => (
......@@ -325,24 +331,24 @@ const AnimatedNumber = ({
export default AnimatedNumber
export const AnimatedNumberStyles = StyleSheet.create({
const AnimatedNumberStyles = StyleSheet.create({
gradientStyle: {
position: 'absolute',
zIndex: 100,
},
})
export const AnimatedCharStyles = StyleSheet.create({
const AnimatedCharStyles = StyleSheet.create({
wrapperStyle: {
overflow: 'hidden',
},
})
export const AnimatedFontStyles = StyleSheet.create({
const AnimatedFontStyles = StyleSheet.create({
fontStyle: {
fontFamily: fonts.heading2.family,
fontSize: fonts.heading2.fontSize,
fontWeight: '500',
fontWeight: 500,
lineHeight: fonts.heading2.lineHeight,
top: 1,
},
......
import { useEffect, useState } from 'react'
import { useAppDispatch } from 'src/app/hooks'
import { CloudStorageMnemonicBackup } from 'src/features/CloudBackup/types'
import { pushNotification } from 'wallet/src/features/notifications/slice'
import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks'
......@@ -36,7 +35,7 @@ export const exampleSwapSuccess = {
}
// 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 dispatch = useAppDispatch()
const activeAddress = useActiveAccountAddressWithThrow()
......@@ -58,30 +57,3 @@ export const useMockNotification = (ms?: number): void => {
}
}, [activeAddress, dispatch, ms, sent])
}
const generateRandomId = (): string => {
let randomId = '0x'
for (let i = 0; i < 40; i++) {
randomId += Math.floor(Math.random() * 16).toString(16)
}
return randomId
}
const generateRandomDate = (): number => {
const start = new Date(2023, 4, 12)
const end = new Date()
return Math.floor(
new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())).getTime() / 1000
)
}
export const useMockCloudBackups = (numberOfBackups?: number): CloudStorageMnemonicBackup[] => {
const number = numberOfBackups ?? 1
const mockBackups = Array.from({ length: number }, () => ({
mnemonicId: generateRandomId(),
createdAt: generateRandomDate(),
}))
return mockBackups
}
import React, { memo, useMemo } from 'react'
import { useWindowDimensions } from 'react-native'
import React, { useMemo } from 'react'
import { useAnimatedStyle, useDerivedValue } from 'react-native-reanimated'
import { AnimatedText } from 'src/components/text/AnimatedText'
import { Flex, useDeviceDimensions, useSporeColors } from 'ui/src'
import { fonts, TextVariantTokens } from 'ui/src/theme'
import { Flex, useSporeColors } from 'ui/src'
import { TextVariantTokens } from 'ui/src/theme'
import { ValueAndFormatted } from './usePrice'
type AnimatedDecimalNumberProps = {
......@@ -14,18 +13,12 @@ type AnimatedDecimalNumberProps = {
decimalPartColor?: string
decimalThreshold?: number // below this value (not including) decimal part would have wholePartColor too
testID?: string
maxWidth?: number
maxCharPixelWidth?: number
}
// Utility component to display decimal numbers where the decimal part
// is dimmed using AnimatedText
export const AnimatedDecimalNumber = memo(function AnimatedDecimalNumber(
props: AnimatedDecimalNumberProps
): JSX.Element {
export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.Element {
const colors = useSporeColors()
const { fullWidth } = useDeviceDimensions()
const { fontScale } = useWindowDimensions()
const {
number,
......@@ -35,8 +28,6 @@ export const AnimatedDecimalNumber = memo(function AnimatedDecimalNumber(
decimalPartColor = colors.neutral3.val,
decimalThreshold = 1,
testID,
maxWidth = fullWidth,
maxCharPixelWidth: maxCharPixelWidthProp,
} = props
const wholePart = useDerivedValue(
......@@ -60,37 +51,12 @@ export const AnimatedDecimalNumber = memo(function AnimatedDecimalNumber(
}
}, [decimalThreshold, wholePartColor, decimalPartColor])
const fontSize = fonts[variant].fontSize * fontScale
// Choose the arbitrary value that looks good for the font used
const maxCharPixelWidth = maxCharPixelWidthProp ?? (2 / 3) * fontSize
const adjustedFontSize = useDerivedValue(() => {
const value = number.formatted.value
const approxWidth = value.length * maxCharPixelWidth
if (approxWidth <= maxWidth) {
return fontSize
}
const scale = Math.min(1, maxWidth / approxWidth)
return fontSize * scale
})
const animatedStyle = useAnimatedStyle(() => ({
fontSize: adjustedFontSize.value,
}))
return (
<Flex row testID={testID}>
<AnimatedText
style={[wholeStyle, animatedStyle]}
testID="wholePart"
text={wholePart}
variant={variant}
/>
<AnimatedText style={wholeStyle} testID="wholePart" text={wholePart} variant={variant} />
{decimalPart.value !== separator && (
<AnimatedText
style={[decimalStyle, animatedStyle]}
style={decimalStyle}
testID="decimalPart"
text={decimalPart}
variant={variant}
......@@ -98,4 +64,4 @@ export const AnimatedDecimalNumber = memo(function AnimatedDecimalNumber(
)}
</Flex>
)
})
}
import { ImpactFeedbackStyle } from 'expo-haptics'
import { memo, useEffect, useMemo, useState } from 'react'
import React, { useMemo } from 'react'
import { I18nManager } from 'react-native'
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 { 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 { DatetimeText, RelativeChangeText } from 'src/components/PriceExplorer/Text'
import { DatetimeText, PriceText, RelativeChangeText } from 'src/components/PriceExplorer/Text'
import { TimeRangeGroup } from 'src/components/PriceExplorer/TimeRangeGroup'
import { useChartDimensions } from 'src/components/PriceExplorer/useChartDimensions'
import { useLineChartPrice } from 'src/components/PriceExplorer/usePrice'
import { invokeImpact } from 'src/utils/haptic'
import { Flex } from 'ui/src'
import { spacing } from 'ui/src/theme'
import { HistoryDuration } from 'wallet/src/data/__generated__/types-and-hooks'
import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
import { CurrencyId } from 'wallet/src/utils/currencyId'
import { PriceNumberOfDigits, TokenSpotData, useTokenPriceHistory } from './usePriceHistory'
import { TokenSpotData, useTokenPriceHistory } from './usePriceHistory'
type PriceTextProps = {
loading: boolean
relativeChange?: SharedValue<number>
numberOfDigits: PriceNumberOfDigits
}
function PriceTextSection({
loading,
relativeChange,
numberOfDigits,
}: PriceTextProps): JSX.Element {
const price = useLineChartPrice()
const mx = spacing.spacing12
function PriceTextSection({ loading, relativeChange }: PriceTextProps): JSX.Element {
return (
<Flex mx={mx}>
{/* Specify maxWidth to allow text scalling. onLayout was sometimes called after more
than 5 seconds which is not acceptable so we have to provide the approximate width
of the PriceText component explicitly. */}
<PriceExplorerAnimatedNumber numberOfDigits={numberOfDigits} price={price} />
<Flex mx="$spacing12">
<PriceText loading={loading} />
<Flex row gap="$spacing4">
<RelativeChangeText loading={loading} spotRelativeChange={relativeChange} />
<DatetimeText loading={loading} />
......@@ -51,7 +42,7 @@ export type LineChartPriceAndDateTimeTextProps = {
currencyId: CurrencyId
}
export const PriceExplorer = memo(function PriceExplorer({
export function PriceExplorer({
currencyId,
tokenColor,
forcePlaceholder,
......@@ -62,36 +53,22 @@ export const PriceExplorer = memo(function PriceExplorer({
forcePlaceholder?: boolean
onRetry: () => void
}): JSX.Element {
const [fetchComplete, setFetchComplete] = useState(false)
const onFetchComplete = (): void => {
setFetchComplete(true)
}
const { data, loading, error, refetch, setDuration, selectedDuration, numberOfDigits } =
useTokenPriceHistory(currencyId, onFetchComplete)
useEffect(() => {
if (loading && fetchComplete) {
setFetchComplete(false)
}
}, [loading, fetchComplete])
const { data, loading, error, refetch, setDuration, selectedDuration } =
useTokenPriceHistory(currencyId)
const { convertFiatAmount } = useLocalizationContext()
const conversionRate = convertFiatAmount().amount
const shouldShowAnimatedDot =
selectedDuration === HistoryDuration.Day || selectedDuration === HistoryDuration.Hour
const additionalPadding = shouldShowAnimatedDot ? 40 : 0
const { lastPricePoint, convertedPriceHistory } = useMemo(() => {
const priceHistory = data?.priceHistory?.map((point) => {
return { ...point, value: point.value * conversionRate }
})
const lastPoint = priceHistory ? priceHistory.length - 1 : 0
return { lastPricePoint: lastPoint, convertedPriceHistory: priceHistory }
}, [data, conversionRate])
const lastPricePoint = data?.priceHistory ? data.priceHistory.length - 1 : 0
const convertedPriceHistory = useMemo(
(): TLineChartData | undefined =>
data?.priceHistory?.map((point) => {
return { ...point, value: point.value * conversionRate }
}),
[data, conversionRate]
)
const convertedSpot = useMemo((): TokenSpotData | undefined => {
return (
data?.spot && {
......@@ -115,26 +92,21 @@ export const PriceExplorer = memo(function PriceExplorer({
let content: JSX.Element | null
if (forcePlaceholder) {
content = (
<PriceExplorerPlaceholder loading={forcePlaceholder} numberOfDigits={numberOfDigits} />
)
content = <PriceExplorerPlaceholder loading={forcePlaceholder} />
} else if (convertedPriceHistory?.length) {
content = (
<Flex opacity={fetchComplete ? 1 : 0.35}>
<PriceExplorerChart
additionalPadding={additionalPadding}
lastPricePoint={lastPricePoint}
loading={loading}
numberOfDigits={numberOfDigits}
priceHistory={convertedPriceHistory}
shouldShowAnimatedDot={shouldShowAnimatedDot}
spot={convertedSpot}
tokenColor={tokenColor}
/>
</Flex>
<PriceExplorerChart
additionalPadding={additionalPadding}
lastPricePoint={lastPricePoint}
loading={loading}
priceHistory={convertedPriceHistory}
shouldShowAnimatedDot={shouldShowAnimatedDot}
spot={convertedSpot}
tokenColor={tokenColor}
/>
)
} else {
content = <PriceExplorerPlaceholder loading={loading} numberOfDigits={numberOfDigits} />
content = <PriceExplorerPlaceholder loading={loading} />
}
return (
......@@ -143,18 +115,12 @@ export const PriceExplorer = memo(function PriceExplorer({
<TimeRangeGroup setDuration={setDuration} />
</Flex>
)
})
}
function PriceExplorerPlaceholder({
loading,
numberOfDigits,
}: {
loading: boolean
numberOfDigits: PriceNumberOfDigits
}): JSX.Element {
function PriceExplorerPlaceholder({ loading }: { loading: boolean }): JSX.Element {
return (
<Flex gap="$spacing8">
<PriceTextSection loading={loading} numberOfDigits={numberOfDigits} />
<PriceTextSection loading={loading} />
<Flex my="$spacing24">
<Loader.Graph />
</Flex>
......@@ -170,7 +136,6 @@ function PriceExplorerChart({
additionalPadding,
shouldShowAnimatedDot,
lastPricePoint,
numberOfDigits,
}: {
priceHistory: TLineChartDataProp
spot?: TokenSpotData
......@@ -179,7 +144,6 @@ function PriceExplorerChart({
additionalPadding: number
shouldShowAnimatedDot: boolean
lastPricePoint: number
numberOfDigits: PriceNumberOfDigits
}): JSX.Element {
const { chartHeight, chartWidth } = useChartDimensions()
const isRTL = I18nManager.isRTL
......@@ -189,11 +153,7 @@ function PriceExplorerChart({
data={priceHistory}
onCurrentIndexChange={invokeImpact[ImpactFeedbackStyle.Light]}>
<Flex gap="$spacing8">
<PriceTextSection
loading={loading}
numberOfDigits={numberOfDigits}
relativeChange={spot?.relativeChange}
/>
<PriceTextSection loading={loading} 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 */}
<Flex direction="ltr" my="$spacing24" style={{ transform: [{ scaleX: isRTL ? -1 : 1 }] }}>
<LineChart height={chartHeight} width={chartWidth - additionalPadding} yGutter={20}>
......@@ -202,10 +162,7 @@ function PriceExplorerChart({
<LineChart.Dot
key={lastPricePoint}
hasPulse
// Sometimes, the pulse dot doesn't appear on the end of
// the chart’s path, but on top of the container instead.
// A little shift backwards seems to solve this problem.
at={lastPricePoint - 0.1}
at={lastPricePoint}
color={tokenColor}
inactiveColor="transparent"
pulseBehaviour="while-inactive"
......@@ -214,10 +171,9 @@ function PriceExplorerChart({
/>
)}
</LineChart.Path>
<LineChart.CursorLine color={tokenColor} minDurationMs={150} />
<LineChart.CursorLine color={tokenColor} />
<LineChart.CursorCrosshair
color={tokenColor}
minDurationMs={150}
outerSize={CURSOR_SIZE}
size={CURSOR_INNER_SIZE}
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'
import { SharedValue, useAnimatedStyle } from 'react-native-reanimated'
import { useLineChartDatetime } from 'react-native-wagmi-charts'
import { AnimatedText } from 'src/components/text/AnimatedText'
import { IS_ANDROID } from 'src/constants/globals'
import { Flex, Icons, useSporeColors } from 'ui/src'
import { FiatCurrency } from 'wallet/src/features/fiatCurrency/constants'
import { useAppFiatCurrency, useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks'
import { useCurrentLocale } from 'wallet/src/features/language/hooks'
import { isAndroid } from 'wallet/src/utils/platform'
import { AnimatedDecimalNumber } from './AnimatedDecimalNumber'
import { useLineChartPrice, useLineChartRelativeChange } from './usePrice'
export function PriceText({
loading,
maxWidth,
}: {
loading: boolean
maxWidth?: number
}): JSX.Element {
export function PriceText({ loading }: { loading: boolean }): JSX.Element {
const price = useLineChartPrice()
const colors = useSporeColors()
const currency = useAppFiatCurrency()
......@@ -27,15 +21,13 @@ export function PriceText({
(currency === FiatCurrency.UnitedStatesDollar || currency === FiatCurrency.Euro) &&
symbolAtFront
// TODO(MOB-2308): re-enable this when we have a better solution for handling the loading state
// if (loading) {
// return <AnimatedText loading loadingPlaceholderText="$10,000" variant="heading1" />
// }
if (loading) {
return <AnimatedText loading loadingPlaceholderText="$10,000" variant="heading1" />
}
return (
<AnimatedDecimalNumber
decimalPartColor={shouldFadePortfolioDecimals ? colors.neutral3.val : colors.neutral1.val}
maxWidth={maxWidth}
number={price}
separator={decimalSeparator}
testID="price-text"
......@@ -65,7 +57,7 @@ export function RelativeChangeText({
if (loading) {
return (
<Flex mt={isAndroid ? '$none' : '$spacing2'}>
<Flex mt={IS_ANDROID ? '$none' : '$spacing2'}>
<AnimatedText loading loadingPlaceholderText="00.00%" variant="body1" />
</Flex>
)
......@@ -74,9 +66,9 @@ export function RelativeChangeText({
return (
<Flex
row
alignItems={isAndroid ? 'center' : 'flex-end'}
alignItems={IS_ANDROID ? 'center' : 'flex-end'}
gap="$spacing2"
mt={isAndroid ? '$none' : '$spacing2'}>
mt={IS_ANDROID ? '$none' : '$spacing2'}>
<Icons.AnimatedCaretChange
size="$icon.16"
strokeWidth={2}
......
......@@ -33,47 +33,115 @@ exports[`DatetimeText renders without error 1`] = `
exports[`PriceText renders loading state 1`] = `
<View
onLayout={[Function]}
style={
{
"alignItems": "stretch",
"flexDirection": "row",
"flexDirection": "column",
"opacity": 0,
}
}
testID="price-text"
>
<TextInput
allowFontScaling={true}
animatedProps={
<View
style={
{
"text": "-",
"alignItems": "center",
"flexDirection": "row",
}
}
editable={false}
maxFontSizeMultiplier={1.2}
style={
[
{
"padding": 0,
},
>
<View
style={
{
"fontFamily": "Basel-Book",
"fontSize": 53,
"lineHeight": 60,
},
[
{
"color": "#222222",
},
"alignItems": "center",
"flexDirection": "row",
"position": "relative",
}
}
>
<View
accessibilityElementsHidden={true}
importantForAccessibility="no-hide-descendants"
>
<View
style={
{
"alignItems": "stretch",
"flexDirection": "row",
}
}
>
<TextInput
allowFontScaling={true}
editable={false}
maxFontSizeMultiplier={1.2}
style={
[
[
{
"padding": 0,
},
{
"fontFamily": "Basel-Book",
"fontSize": 53,
"lineHeight": 60,
},
undefined,
],
{
"marginHorizontal": 0,
"opacity": 0,
"paddingHorizontal": 0,
"width": 0,
},
]
}
underlineColorAndroid="transparent"
/>
<Text
style={
[
[
{
"padding": 0,
},
{
"fontFamily": "Basel-Book",
"fontSize": 53,
"lineHeight": 60,
},
undefined,
],
{
"opacity": 0,
},
]
}
>
$10,000
</Text>
</View>
</View>
<View
style={
{
"fontSize": 106,
},
],
]
}
testID="wholePart"
underlineColorAndroid="transparent"
value="-"
/>
"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>
`;
......@@ -106,14 +174,9 @@ exports[`PriceText renders without error 1`] = `
"fontSize": 53,
"lineHeight": 60,
},
[
{
"color": "#222222",
},
{
"fontSize": 106,
},
],
{
"color": "#222222",
},
]
}
testID="wholePart"
......@@ -139,14 +202,9 @@ exports[`PriceText renders without error 1`] = `
"fontSize": 53,
"lineHeight": 60,
},
[
{
"color": "#CECECE",
},
{
"fontSize": 106,
},
],
{
"color": "#CECECE",
},
]
}
testID="decimalPart"
......@@ -185,14 +243,9 @@ exports[`PriceText renders without error less than a dollar 1`] = `
"fontSize": 53,
"lineHeight": 60,
},
[
{
"color": "#222222",
},
{
"fontSize": 106,
},
],
{
"color": "#222222",
},
]
}
testID="wholePart"
......@@ -218,14 +271,9 @@ exports[`PriceText renders without error less than a dollar 1`] = `
"fontSize": 53,
"lineHeight": 60,
},
[
{
"color": "#222222",
},
{
"fontSize": 106,
},
],
{
"color": "#222222",
},
]
}
testID="decimalPart"
......
import { useMemo } from 'react'
import {
SharedValue,
useAnimatedReaction,
useDerivedValue,
useSharedValue,
} from 'react-native-reanimated'
import { SharedValue, useDerivedValue } from 'react-native-reanimated'
import {
useLineChart,
useLineChartPrice as useRNWagmiChartLineChartPrice,
......@@ -13,10 +7,9 @@ import { numberToLocaleStringWorklet, numberToPercentWorklet } from 'src/utils/r
import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/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>>
formatted: Readonly<SharedValue<V>>
shouldAnimate: Readonly<SharedValue<B>>
}
/**
......@@ -29,18 +22,6 @@ export function useLineChartPrice(): ValueAndFormatted {
precision: 18,
})
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 locale = useCurrentLocale()
......@@ -50,7 +31,6 @@ export function useLineChartPrice(): ValueAndFormatted {
return Number(activeCursorPrice.value)
}
shouldAnimate.value = true
return data[data.length - 1]?.value ?? 0
})
const priceFormatted = useDerivedValue(() => {
......@@ -64,15 +44,10 @@ export function useLineChartPrice(): ValueAndFormatted {
currencyInfo.symbol
)
})
return useMemo(
() => ({
value: price,
formatted: priceFormatted,
shouldAnimate,
}),
[price, priceFormatted, shouldAnimate]
)
return {
value: price,
formatted: priceFormatted,
}
}
/**
......@@ -85,7 +60,6 @@ export function useLineChartRelativeChange({
spotRelativeChange?: SharedValue<number>
}): ValueAndFormatted {
const { currentIndex, data, isActive } = useLineChart()
const shouldAnimate = useSharedValue(false)
const relativeChange = useDerivedValue(() => {
if (!isActive.value && Boolean(spotRelativeChange)) {
......@@ -119,5 +93,5 @@ export function useLineChartRelativeChange({
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 { SharedValue } from 'react-native-reanimated'
import { TLineChartData } from 'react-native-wagmi-charts'
......@@ -17,17 +16,11 @@ export type TokenSpotData = {
relativeChange: SharedValue<number>
}
export type PriceNumberOfDigits = {
left: number
right: number
}
/**
* @returns Token price history for requested duration
*/
export function useTokenPriceHistory(
currencyId: string,
onCompleted?: () => void,
initialDuration: HistoryDuration = HistoryDuration.Day
): Omit<
GqlResult<{
......@@ -39,7 +32,6 @@ export function useTokenPriceHistory(
setDuration: Dispatch<SetStateAction<HistoryDuration>>
selectedDuration: HistoryDuration
error: boolean
numberOfDigits: PriceNumberOfDigits
} {
const [duration, setDuration] = useState(initialDuration)
......@@ -54,9 +46,7 @@ export function useTokenPriceHistory(
},
notifyOnNetworkStatusChange: true,
pollInterval: PollingInterval.Normal,
onCompleted,
// TODO(MOB-2308): maybe update to network-only once we have a better loading state
fetchPolicy: 'cache-and-network',
fetchPolicy: 'cache-first',
})
const offChainData = priceData?.tokenProjects?.[0]?.markets?.[0]
......@@ -83,24 +73,13 @@ export function useTokenPriceHistory(
?.filter((x): x is TimestampedAmount => Boolean(x))
.map((x) => ({ timestamp: x.timestamp * 1000, value: x.value }))
return formatted
}, [priceHistory])
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,
}
// adds the current price to the chart given we show spot price/24h change
if (formatted && spot?.value) {
formatted?.push({ timestamp: Date.now(), value: spot.value.value })
}
return {
left: 0,
right: 0,
}
}, [priceHistory])
return formatted
}, [priceHistory, spot?.value])
const retry = useCallback(async () => {
await refetch({ contract: currencyIdToContractInput(currencyId) })
......@@ -117,18 +96,7 @@ export function useTokenPriceHistory(
refetch: retry,
setDuration,
selectedDuration: duration,
numberOfDigits,
onCompleted,
}),
[
duration,
formattedPriceHistory,
networkStatus,
priceData,
retry,
spot,
onCompleted,
numberOfDigits,
]
[duration, formattedPriceHistory, networkStatus, priceData, retry, spot]
)
}
......@@ -3,9 +3,9 @@ import { ImageSourcePropType, StyleSheet } from 'react-native'
import QRCode from 'src/components/QRCodeScanner/custom-qr-code-generator'
import { Unicon } from 'src/components/unicons/Unicon'
import { useUniconColors } from 'src/components/unicons/utils'
import { IS_ANDROID } from 'src/constants/globals'
import { ColorTokens, Flex, useSporeColors } from 'ui/src'
import { borderRadii, opacify } from 'ui/src/theme'
import { isAndroid } from 'wallet/src/utils/platform'
type AddressQRCodeProps = {
address: Address
......@@ -65,7 +65,7 @@ export const AddressQRCode = ({
enableLinearGradient: true,
linearGradient: [gradientData.gradientStart, gradientData.gradientEnd],
color: gradientData.gradientStart,
gradientDirection: ['0%', '0%', isAndroid ? '150%' : '100%', '0%'],
gradientDirection: ['0%', '0%', IS_ANDROID ? '150%' : '100%', '0%'],
}
}
return gradientPropsObject
......
......@@ -29,17 +29,16 @@ export function RecipientScanModal({ onSelectRecipient, onClose }: Props): JSX.E
const [currentScreenState, setCurrentScreenState] = useState<ScannerModalState>(
ScannerModalState.ScanQr
)
const [hasScanError, setHasScanError] = useState(false)
const [shouldFreezeCamera, setShouldFreezeCamera] = useState(false)
const onScanCode = async (uri: string): Promise<void> => {
// don't scan any QR codes if camera is frozen
if (shouldFreezeCamera) return
// don't scan any QR codes if there is an error popup open or camera is frozen
if (hasScanError || shouldFreezeCamera) return
await selectionAsync()
setShouldFreezeCamera(true)
const supportedURI = await getSupportedURI(uri)
if (supportedURI?.type === URIType.Address) {
setShouldFreezeCamera(true)
onSelectRecipient(supportedURI.value)
onClose()
} else {
......@@ -50,7 +49,7 @@ export function RecipientScanModal({ onSelectRecipient, onClose }: Props): JSX.E
{
text: t('Try again'),
onPress: (): void => {
setShouldFreezeCamera(false)
setHasScanError(false)
},
},
]
......
import React, { useMemo } from 'react'
import React from 'react'
import { ScrollView, StyleSheet } from 'react-native'
import { useAccountList } from 'src/components/accounts/hooks'
import { AddressDisplay } from 'src/components/AddressDisplay'
import { Flex, Text, useDeviceDimensions } from 'ui/src'
import { spacing } from 'ui/src/theme'
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 { Account } from 'wallet/src/features/wallet/accounts/types'
......@@ -15,9 +17,10 @@ type Portfolio = NonNullable<NonNullable<NonNullable<AccountListQuery['portfolio
function _AssociatedAccountsList({ accounts }: { accounts: Account[] }): JSX.Element {
const { fullHeight } = useDeviceDimensions()
const addresses = useMemo(() => accounts.map((account) => account.address), [accounts])
const { data, loading } = useAccountList({
addresses,
const { data, loading } = useAccountListQuery({
variables: {
addresses: accounts.map((account) => account.address),
},
notifyOnNetworkStatusChange: true,
})
......
......@@ -151,64 +151,54 @@ export function RemoveWalletModal(): JSX.Element | null {
backgroundColor={colors.surface1.get()}
name={ModalName.RemoveSeedPhraseWarningModal}
onClose={onClose}>
<Flex gap="$spacing24" px="$spacing24" py="$spacing24">
<Flex centered gap="$spacing16">
<Flex
centered
borderRadius="$rounded12"
p="$spacing12"
style={{
backgroundColor: opacify(12, colors[labelColor].val),
}}>
<Icon
color={colors[labelColor].val}
height={iconSizes.icon24}
width={iconSizes.icon24}
/>
</Flex>
<Flex gap="$spacing8">
<Text textAlign="center" variant="body1">
{title}
</Text>
<Text color="$neutral2" textAlign="center" variant="body2">
{description}
</Text>
</Flex>
<Flex centered gap="$spacing16" px="$spacing24" py="$spacing12">
<Flex
centered
borderRadius="$rounded12"
p="$spacing12"
style={{
backgroundColor: opacify(12, colors[labelColor].val),
}}>
<Icon color={colors[labelColor].val} height={iconSizes.icon24} width={iconSizes.icon24} />
</Flex>
<Flex centered gap="$spacing24">
{currentStep === RemoveWalletStep.Final && isRemovingRecoveryPhrase ? (
<>
<AssociatedAccountsList accounts={associatedAccounts} />
<RemoveLastMnemonicWalletFooter inProgress={inProgress} onPress={onPress} />
</>
) : (
<Flex centered row gap={inProgress ? '$none' : '$spacing12'} pt="$spacing12">
{inProgress ? (
<AnimatedFlex style={animatedCancelButtonSpanStyles} />
) : (
<Button fill disabled={inProgress} theme="outline" onPress={onClose}>
{t('Cancel')}
</Button>
)}
<Button
fill
icon={
inProgress ? (
<SpinningLoader
// TODO(MOB-1420): clean up types (as ColorTokens)
color={`$${labelColor}` as ColorTokens}
/>
) : undefined
}
testID={isRemovingRecoveryPhrase ? ElementName.Continue : ElementName.Remove}
theme={actionButtonTheme}
width="100%"
onPress={onPress}>
{inProgress ? undefined : actionButtonLabel}
<Text textAlign="center" variant="body1">
{title}
</Text>
<Text color="$neutral2" textAlign="center" variant="body2">
{description}
</Text>
{currentStep === RemoveWalletStep.Final && isRemovingRecoveryPhrase ? (
<>
<AssociatedAccountsList accounts={associatedAccounts} />
<RemoveLastMnemonicWalletFooter inProgress={inProgress} onPress={onPress} />
</>
) : (
<Flex centered row gap={inProgress ? '$none' : '$spacing12'} pt="$spacing12">
{inProgress ? (
<AnimatedFlex style={animatedCancelButtonSpanStyles} />
) : (
<Button fill disabled={inProgress} theme="outline" onPress={onClose}>
{t('Cancel')}
</Button>
</Flex>
)}
</Flex>
)}
<Button
fill
icon={
inProgress ? (
<SpinningLoader
// TODO(MOB-1420): clean up types (as ColorTokens)
color={`$${labelColor}` as ColorTokens}
/>
) : undefined
}
testID={isRemovingRecoveryPhrase ? ElementName.Continue : ElementName.Remove}
theme={actionButtonTheme}
width="100%"
onPress={onPress}>
{inProgress ? undefined : actionButtonLabel}
</Button>
</Flex>
)}
</Flex>
</BottomSheetModal>
)
......
......@@ -2,6 +2,7 @@ import React, { useMemo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { SvgProps } from 'react-native-svg'
import { concatListOfAccountNames } from 'src/components/RemoveWallet/utils'
import { IS_ANDROID } from 'src/constants/globals'
import { Text, ThemeKeys } from 'ui/src'
import AlertTriangleIcon from 'ui/src/assets/icons/alert-triangle.svg'
import TrashIcon from 'ui/src/assets/icons/trash.svg'
......@@ -9,7 +10,6 @@ import WalletIcon from 'ui/src/assets/icons/wallet-filled.svg'
import { ThemeNames } from 'ui/src/theme'
import { Account, AccountType } from 'wallet/src/features/wallet/accounts/types'
import { useDisplayName } from 'wallet/src/features/wallet/hooks'
import { isAndroid } from 'wallet/src/utils/platform'
export enum RemoveWalletStep {
Warning = 'warning',
......@@ -95,7 +95,7 @@ export const useModalContent = ({
</Text>
</Trans>
),
description: isAndroid ? (
description: IS_ANDROID ? (
<Trans t={t}>
Make sure you’ve written down your recovery phrase or backed it up on Google Drive.{' '}
<Text color="$neutral2" maxFontSizeMultiplier={1.4} variant="buttonLabel3">
......@@ -136,7 +136,7 @@ export const useModalContent = ({
description: (
<Trans t={t}>
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.
</Trans>
),
......
......@@ -26,7 +26,8 @@ export interface SettingsSectionItemComponent {
component: JSX.Element
isHidden?: boolean
}
type SettingsModal = Extract<ModalName, ModalName.FiatCurrencySelector | ModalName.LanguageSelector>
type SettingsModal = Extract<ModalName, ModalName.FiatCurrencySelector>
export interface SettingsSectionItem {
screen?: keyof SettingsStackParamList | typeof Screens.OnboardingStack
modal?: SettingsModal
......
......@@ -11,9 +11,15 @@ export const TokenBalanceItemContextMenu = memo(function _TokenBalanceItem({
portfolioBalance: PortfolioBalance
children: React.ReactNode
}) {
const { currencyInfo, balanceUSD } = portfolioBalance
const { currency, currencyId, isSpam } = currencyInfo
const { menuActions, onContextMenuPress } = useTokenContextMenu({
currencyId: portfolioBalance.currencyInfo.currencyId,
portfolioBalance,
currencyId,
isSpam,
balanceUSD,
isNative: currency.isNative,
accountHoldsToken: true,
})
const style = useMemo(() => ({ borderRadius: borderRadii.rounded16 }), [])
......
......@@ -21,6 +21,7 @@ import {
TokenBalanceListRow,
useTokenBalanceListContext,
} from 'src/components/TokenBalanceList/TokenBalanceListContext'
import { IS_ANDROID } from 'src/constants/globals'
import { Screens } from 'src/screens/Screens'
import { AnimatedFlex, Flex, useDeviceDimensions, useDeviceInsets, useSporeColors } from 'ui/src'
import { zIndices } from 'ui/src/theme'
......@@ -28,7 +29,6 @@ import { BaseCard } from 'wallet/src/components/BaseCard/BaseCard'
import { isError, isNonPollingRequestInFlight } from 'wallet/src/data/utils'
import { TokenBalanceItem } from 'wallet/src/features/portfolio/TokenBalanceItem'
import { CurrencyId } from 'wallet/src/utils/currencyId'
import { isAndroid } from 'wallet/src/utils/platform'
type TokenBalanceListProps = TabProps & {
empty?: JSX.Element | null
......@@ -127,7 +127,7 @@ export const TokenBalanceListInner = forwardRef<
return (
<RefreshControl
progressViewOffset={
insets.top + (isAndroid && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0)
insets.top + (IS_ANDROID && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0)
}
refreshing={refreshing ?? false}
tintColor={colors.neutral3.get()}
......
import { NetworkStatus } from '@apollo/client'
import { isEqual } from 'lodash'
import {
createContext,
......@@ -10,22 +9,18 @@ import {
useRef,
useState,
} from 'react'
import { PollingInterval } from 'wallet/src/constants/misc'
import { useTokenBalancesGroupedByVisibility } from 'src/features/balances/hooks'
import { isWarmLoadingStatus } from 'wallet/src/data/utils'
import {
usePortfolioBalances,
useTokenBalancesGroupedByVisibility,
} from 'wallet/src/features/dataApi/balances'
import { PortfolioBalance } from 'wallet/src/features/dataApi/types'
import { usePortfolioBalances } from 'wallet/src/features/dataApi/balances'
type CurrencyId = string
export const HIDDEN_TOKEN_BALANCES_ROW = 'HIDDEN_TOKEN_BALANCES_ROW' as const
export type TokenBalanceListRow = CurrencyId | typeof HIDDEN_TOKEN_BALANCES_ROW
type TokenBalanceListContextState = {
balancesById: Record<string, PortfolioBalance> | undefined
networkStatus: NetworkStatus
refetch: (() => void) | undefined
balancesById: ReturnType<typeof usePortfolioBalances>['data']
networkStatus: ReturnType<typeof usePortfolioBalances>['networkStatus']
refetch: ReturnType<typeof usePortfolioBalances>['refetch']
hiddenTokensCount: number
hiddenTokensExpanded: boolean
isWarmLoading: boolean
......@@ -54,7 +49,7 @@ export function TokenBalanceListContextProvider({
refetch,
} = usePortfolioBalances({
address: owner,
pollInterval: PollingInterval.KindaFast,
shouldPoll: true,
fetchPolicy: 'cache-and-network',
})
......
......@@ -114,6 +114,7 @@ export function TokenDetailsStats({
offChainData?.markets?.[0]?.priceHigh52W?.value ?? onChainData?.market?.priceHigh52W?.value
const priceLow52W =
offChainData?.markets?.[0]?.priceLow52W?.value ?? onChainData?.market?.priceLow52W?.value
const currentDescription =
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'
import { iconSizes } from 'ui/src/theme'
import { CurrencyLogo } from 'wallet/src/components/CurrencyLogo/CurrencyLogo'
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'
interface SelectTokenButtonProps {
......@@ -20,7 +21,7 @@ export function SelectTokenButton({
}: SelectTokenButtonProps): JSX.Element {
const { t } = useTranslation()
const isSwapRewriteFeatureEnabled = useSwapRewriteEnabled()
const isSwapRewriteFeatureEnabled = useFeatureFlag(FEATURE_FLAGS.SwapRewrite)
if (isSwapRewriteFeatureEnabled) {
return (
......
......@@ -3,21 +3,70 @@ import React, { memo, useCallback, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { ListRenderItemInfo } from 'react-native'
import { Loader } from 'src/components/loading'
import { useAllCommonBaseCurrencies } from 'src/components/TokenSelector/hooks'
import { TokenOptionItem } from 'src/components/TokenSelector/TokenOptionItem'
import { useFiatOnRampSupportedTokens } from 'src/features/fiatOnRamp/hooks'
import { FiatOnRampCurrency } from 'src/features/fiatOnRamp/types'
import { ElementName } from 'src/features/telemetry/constants'
import { Flex, Icons, Inset, Text, TouchableArea } from 'ui/src'
import { BaseCard } from 'wallet/src/components/BaseCard/BaseCard'
import { ChainId } from 'wallet/src/constants/chains'
import { fromMoonpayNetwork } from 'wallet/src/features/chains/utils'
import { CurrencyInfo, GqlResult } from 'wallet/src/features/dataApi/types'
import { MoonpayCurrency } from 'wallet/src/features/fiatOnRamp/types'
import { CurrencyId } from 'wallet/src/utils/currencyId'
interface Props {
onSelectCurrency: (currency: FiatOnRampCurrency) => void
onBack: () => void
onRetry: () => void
error: boolean
loading: boolean
list: FiatOnRampCurrency[] | undefined
}
const findTokenOptionForMoonpayCurrency = (
commonBaseCurrencies: CurrencyInfo[] | undefined,
moonpayCurrency: MoonpayCurrency
): Maybe<CurrencyInfo> => {
return (commonBaseCurrencies || []).find((item) => {
const [code, network] = moonpayCurrency.code.split('_') ?? [undefined, undefined]
const chainId = fromMoonpayNetwork(network)
return (
item &&
code &&
code === item.currency.symbol?.toLowerCase() &&
chainId === item.currency.chainId
)
})
}
function useFiatOnRampTokenList(
supportedTokens: MoonpayCurrency[] | undefined
): GqlResult<FiatOnRampCurrency[]> {
const {
data: commonBaseCurrencies,
error: commonBaseCurrenciesError,
loading: commonBaseCurrenciesLoading,
refetch: refetchCommonBaseCurrencies,
} = useAllCommonBaseCurrencies()
const data = useMemo(
() =>
(supportedTokens || [])
.map((moonpayCurrency) => ({
currencyInfo: findTokenOptionForMoonpayCurrency(commonBaseCurrencies, moonpayCurrency),
moonpayCurrency,
}))
.filter((item) => !!item.currencyInfo),
[commonBaseCurrencies, supportedTokens]
)
return useMemo(
() => ({
data,
loading: commonBaseCurrenciesLoading,
error: commonBaseCurrenciesError,
refetch: refetchCommonBaseCurrencies,
}),
[commonBaseCurrenciesError, commonBaseCurrenciesLoading, data, refetchCommonBaseCurrencies]
)
}
function TokenOptionItemWrapper({
......@@ -51,18 +100,20 @@ function TokenOptionItemWrapper({
)
}
function _TokenFiatOnRampList({
onSelectCurrency,
onBack,
error,
onRetry,
list,
loading,
}: Props): JSX.Element {
function _TokenFiatOnRampList({ onSelectCurrency, onBack }: Props): JSX.Element {
const { t } = useTranslation()
const flatListRef = useRef(null)
const {
data: supportedTokens,
isLoading: supportedTokensLoading,
isError: supportedTokensQueryError,
refetch: supportedTokensQueryRefetch,
} = useFiatOnRampSupportedTokens()
const { data, loading, error, refetch } = useFiatOnRampTokenList(supportedTokens)
const renderItem = useCallback(
({ item: currency }: ListRenderItemInfo<FiatOnRampCurrency>) => {
return <TokenOptionItemWrapper currency={currency} onSelectCurrency={onSelectCurrency} />
......@@ -70,7 +121,7 @@ function _TokenFiatOnRampList({
[onSelectCurrency]
)
if (error) {
if (supportedTokensQueryError || error) {
return (
<>
<Header onBack={onBack} />
......@@ -78,14 +129,21 @@ function _TokenFiatOnRampList({
<BaseCard.ErrorState
retryButtonLabel="Retry"
title={t('Couldn’t load tokens to buy')}
onRetry={onRetry}
onRetry={(): void => {
if (supportedTokensQueryError) {
supportedTokensQueryRefetch?.()
}
if (error) {
refetch?.()
}
}}
/>
</Flex>
</>
)
}
if (loading) {
if (supportedTokensLoading || loading) {
return (
<Flex>
<Header onBack={onBack} />
......@@ -101,7 +159,7 @@ function _TokenFiatOnRampList({
ref={flatListRef}
ListEmptyComponent={<Flex />}
ListFooterComponent={<Inset all="$spacing36" />}
data={list}
data={data}
keyExtractor={key}
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="always"
......
......@@ -3,6 +3,7 @@ import { useAppSelector } from 'src/app/hooks'
import { filter } from 'src/components/TokenSelector/filter'
import { TokenOption, TokenSelectorFlow } from 'src/components/TokenSelector/types'
import { createEmptyBalanceOption } from 'src/components/TokenSelector/utils'
import { useTokenBalancesGroupedByVisibility } from 'src/features/balances/hooks'
import { useTokenProjects } from 'src/features/dataApi/tokenProjects'
import { usePopularTokens } from 'src/features/dataApi/topTokens'
import { sendMobileAnalyticsEvent } from 'src/features/telemetry'
......@@ -10,11 +11,7 @@ import { MobileEventName } from 'src/features/telemetry/constants'
import { BRIDGED_BASE_ADDRESSES } from 'wallet/src/constants/addresses'
import { ChainId } from 'wallet/src/constants/chains'
import { DAI, USDC, USDT, WBTC } from 'wallet/src/constants/tokens'
import {
sortPortfolioBalances,
usePortfolioBalances,
useTokenBalancesGroupedByVisibility,
} from 'wallet/src/features/dataApi/balances'
import { sortPortfolioBalances, usePortfolioBalances } from 'wallet/src/features/dataApi/balances'
import { CurrencyInfo, GqlResult, PortfolioBalance } from 'wallet/src/features/dataApi/types'
import { usePersistedError } from 'wallet/src/features/dataApi/utils'
import { selectFavoriteTokens } from 'wallet/src/features/favorites/selectors'
......@@ -176,6 +173,7 @@ export function usePortfolioBalancesForAddressById(
loading,
} = usePortfolioBalances({
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
})
......
import { useEffect } from 'react'
import { NativeModules } from 'react-native'
import { IS_ANDROID } from 'src/constants/globals'
import {
useBiometricAppSettings,
useDeviceSupportsBiometricAuth,
......@@ -16,7 +17,6 @@ import {
useSwapProtectionSetting,
useViewOnlyAccounts,
} from 'wallet/src/features/wallet/hooks'
import { isAndroid } from 'wallet/src/utils/platform'
/** Component that tracks UserProperties during the lifetime of the app */
export function TraceUserProperties(): null {
......@@ -30,7 +30,7 @@ export function TraceUserProperties(): null {
useEffect(() => {
setUserProperty(UserPropertyName.AppVersion, getFullAppVersion())
if (isAndroid) {
if (IS_ANDROID) {
NativeModules.AndroidDeviceModule.getPerformanceClass().then((perfClass: number) => {
setUserProperty(UserPropertyName.AndroidPerfClass, perfClass)
})
......
......@@ -236,7 +236,7 @@ export const PendingConnectionModal = ({ pendingSession, onClose }: Props): JSX.
},
[activeAddress, dispatch, onClose, pendingSession, didOpenFromDeepLink]
)
const dappName = pendingSession.dapp.name || pendingSession.dapp.url || ''
const dappName = pendingSession.dapp.name || pendingSession.dapp.url
return (
<BottomSheetModal name={ModalName.WCPendingConnection} onClose={onClose}>
......
......@@ -23,9 +23,7 @@ export const CUSTOM_UNI_QR_CODE_PREFIX = 'hello_uniwallet:'
const MAX_DAPP_NAME_LENGTH = 60
export function truncateDappName(name: string): string {
return name && name.length > MAX_DAPP_NAME_LENGTH
? `${name.slice(0, MAX_DAPP_NAME_LENGTH)}...`
: name
return name.length > MAX_DAPP_NAME_LENGTH ? `${name.slice(0, MAX_DAPP_NAME_LENGTH)}...` : name
}
export async function getSupportedURI(uri: string): Promise<URIFormat | undefined> {
......
......@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next'
import ContextMenu from 'react-native-context-menu-view'
import { useAppDispatch } from 'src/app/hooks'
import { navigate } from 'src/app/navigation/rootNavigation'
import { useAccountList } from 'src/components/accounts/hooks'
import { AddressDisplay } from 'src/components/AddressDisplay'
import { closeModal, openModal } from 'src/features/modals/modalSlice'
import { ModalName } from 'src/features/telemetry/constants'
......@@ -25,39 +24,24 @@ type AccountCardItemProps = {
} & PortfolioValueProps
type PortfolioValueProps = {
address: Address
isPortfolioValueLoading: boolean
portfolioValue: number | undefined
}
function PortfolioValue({
address,
isPortfolioValueLoading,
portfolioValue: providedPortfolioValue,
portfolioValue,
}: 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 { convertFiatAmountFormatted } = useLocalizationContext()
return (
<Text color="$neutral2" loading={isLoading} variant="subheading2">
{portfolioValue
? convertFiatAmountFormatted(portfolioValue, NumberType.PortfolioBalance)
: t('N/A')}
<Text
color="$neutral2"
loading={isLoading}
loadingPlaceholderText="0000.00"
variant="subheading2">
{convertFiatAmountFormatted(portfolioValue, NumberType.PortfolioBalance)}
</Text>
)
}
......@@ -142,7 +126,6 @@ export function AccountCardItem({
/>
</Flex>
<PortfolioValue
address={address}
isPortfolioValueLoading={isPortfolioValueLoading}
portfolioValue={portfolioValue}
/>
......
query AccountList(
$addresses: [String!]!
$valueModifiers: [PortfolioValueModifier!]
) {
portfolios(
ownerAddresses: $addresses
chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]
valueModifiers: $valueModifiers
) {
query AccountList($addresses: [String!]!) {
portfolios(ownerAddresses: $addresses, chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]) {
id
ownerAddress
tokensTotalDenominatedValue {
......
......@@ -20,15 +20,6 @@ const mock: MockedResponse<AccountListQuery> = {
query: AccountListDocument,
variables: {
addresses: [account.address],
valueModifiers: [
{
ownerAddress: account.address,
tokenIncludeOverrides: [],
tokenExcludeOverrides: [],
includeSmallBalances: false,
includeSpamTokens: false,
},
],
},
},
result: {
......
......@@ -3,13 +3,13 @@ import { ComponentProps, default as React, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native'
import { AccountCardItem } from 'src/components/accounts/AccountCardItem'
import { useAccountList } from 'src/components/accounts/hooks'
import { VirtualizedList } from 'src/components/layout/VirtualizedList'
import { Flex, Text, useSporeColors } from 'ui/src'
import { opacify, spacing } from 'ui/src/theme'
import { useAsyncData } from 'utilities/src/react/hooks'
import { PollingInterval } from 'wallet/src/constants/misc'
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'
// Most screens can fit more but this is set conservatively
......@@ -50,10 +50,10 @@ const SignerHeader = (): JSX.Element => {
export function AccountList({ accounts, onPress, isVisible }: AccountListProps): JSX.Element {
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({
addresses,
const { data, networkStatus, refetch, startPolling, stopPolling } = useAccountListQuery({
variables: { addresses },
notifyOnNetworkStatusChange: true,
})
......@@ -71,13 +71,13 @@ export function AccountList({ accounts, onPress, isVisible }: AccountListProps):
const isPortfolioValueLoading = isNonPollingRequestInFlight(networkStatus)
const accountsWithPortfolioValue: AccountWithPortfolioValue[] = useMemo(() => {
const accountsWithPortfolioValue = useMemo(() => {
return accounts.map((account, i) => {
return {
account,
isPortfolioValueLoading,
portfolioValue: data?.portfolios?.[i]?.tokensTotalDenominatedValue?.value,
}
} as AccountWithPortfolioValue
})
}, [accounts, data, isPortfolioValueLoading])
......@@ -97,18 +97,15 @@ export function AccountList({ accounts, onPress, isVisible }: AccountListProps):
const hasViewOnlyAccounts = viewOnlyAccounts.length > 0
const renderAccountCardItem = useCallback(
(item: AccountWithPortfolioValue): JSX.Element => (
<AccountCardItem
key={item.account.address}
address={item.account.address}
isPortfolioValueLoading={item.isPortfolioValueLoading}
isViewOnly={item.account.type === AccountType.Readonly}
portfolioValue={item.portfolioValue}
onPress={onPress}
/>
),
[onPress]
const renderAccountCardItem = (item: AccountWithPortfolioValue): JSX.Element => (
<AccountCardItem
key={item.account.address}
address={item.account.address}
isPortfolioValueLoading={item.isPortfolioValueLoading}
isViewOnly={item.account.type === AccountType.Readonly}
portfolioValue={item.portfolioValue}
onPress={onPress}
/>
)
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 { Switch as BaseSwitch, SwitchProps, ViewProps } from 'react-native'
import { IS_ANDROID } from 'src/constants/globals'
import { Flex, useSporeColors } from 'ui/src'
import { isAndroid } from 'wallet/src/utils/platform'
// TODO(MOB-1518) change to tamagui ui/src Switch
......@@ -16,7 +16,7 @@ type Props = {
export function Switch({ value, onValueChange, disabled, ...rest }: Props): JSX.Element {
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
return (
......
......@@ -5,9 +5,9 @@ import { runOnJS } from 'react-native-reanimated'
import { OnboardingStackBaseParams, useOnboardingStackNavigation } from 'src/app/navigation/types'
import { CloseButton } from 'src/components/buttons/CloseButton'
import { CarouselContext } from 'src/components/carousel/Carousel'
import { IS_ANDROID } from 'src/constants/globals'
import { OnboardingScreens } from 'src/screens/Screens'
import { Flex, Text, useDeviceDimensions } from 'ui/src'
import { isAndroid } from 'wallet/src/utils/platform'
function Page({
text,
......@@ -113,7 +113,7 @@ export const SeedPhraseEducationContent = (params: OnboardingStackBaseParams): J
params={params}
text={
<CustomHeadingText>
{isAndroid ? (
{IS_ANDROID ? (
<Trans>
Instead of memorizing your recovery phrase, you can{' '}
<CustomHeadingText color="$accent1">back it up to Google Drive</CustomHeadingText> and
......
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 { ListRenderItem, ListRenderItemInfo, StyleSheet, View } from 'react-native'
import { useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated'
import { ListRenderItem, ListRenderItemInfo } from 'react-native'
import { useAppSelector } from 'src/app/hooks'
import { FavoriteTokensGrid } from 'src/components/explore/FavoriteTokensGrid'
import { FavoriteWalletsGrid } from 'src/components/explore/FavoriteWalletsGrid'
import { SortButton } from 'src/components/explore/SortButton'
import { TokenItem, TokenItemData } from 'src/components/explore/TokenItem'
import { AnimatedBottomSheetFlatList } from 'src/components/layout/AnimatedFlatList'
import { Loader } from 'src/components/loading'
import { AutoScrollProps } from 'src/components/sortableGrid'
import {
getClientTokensOrderByCompareFn,
getTokenMetadataDisplayType,
......@@ -38,15 +36,12 @@ import { areAddressesEqual } from 'wallet/src/utils/addresses'
import { buildCurrencyId, buildNativeCurrencyId } from 'wallet/src/utils/currencyId'
type ExploreSectionsProps = {
listRef: React.MutableRefObject<null>
listRef?: React.MutableRefObject<null>
}
export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element {
const { t } = useTranslation()
const insets = useDeviceInsets()
const scrollY = useSharedValue(0)
const headerRef = useRef<View>(null)
const visibleListHeight = useSharedValue(0)
// Top tokens sorting
const orderBy = useAppSelector(selectTokensOrderBy)
......@@ -125,10 +120,6 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element
await refetch()
}, [refetch])
const scrollHandler = useAnimatedScrollHandler((e) => {
scrollY.value = e.contentOffset.y
})
// Use showLoading for showing full screen loading state
// Used in each section to ensure loading state layout matches loaded state
const showLoading = (!hasAllData && isLoading) || (!!error && isLoading)
......@@ -146,60 +137,39 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element
}
return (
// Pass onLayout callback to the list wrapper component as it returned
// incorrect values when it was passed to the list itself
<Flex
fill
onLayout={({
nativeEvent: {
layout: { height },
},
}): void => {
visibleListHeight.value = height
}}>
<AnimatedBottomSheetFlatList
ref={listRef}
ListEmptyComponent={
<Flex mx="$spacing24" my="$spacing12">
<Loader.Token repeat={5} />
<BottomSheetFlatList
ref={listRef}
ListEmptyComponent={
<Flex mx="$spacing24" my="$spacing12">
<Loader.Token repeat={5} />
</Flex>
}
ListHeaderComponent={
<>
<FavoritesSection showLoading={showLoading} />
<Flex
row
alignItems="center"
justifyContent="space-between"
mb="$spacing8"
ml="$spacing16"
mr="$spacing12"
mt="$spacing16"
pl="$spacing4">
<Text color="$neutral2" variant="subheading2">
{t('Top tokens')}
</Text>
<SortButton orderBy={orderBy} />
</Flex>
}
ListHeaderComponent={
<Flex ref={headerRef}>
<FavoritesSection
containerRef={headerRef}
scrollY={scrollY}
scrollableRef={listRef}
showLoading={showLoading}
visibleHeight={visibleListHeight}
/>
<Flex
row
alignItems="center"
justifyContent="space-between"
mb="$spacing8"
ml="$spacing16"
mr="$spacing12"
mt="$spacing16"
pl="$spacing4">
<Text color="$neutral2" variant="subheading2">
{t('Top tokens')}
</Text>
<SortButton orderBy={orderBy} />
</Flex>
</Flex>
}
ListHeaderComponentStyle={styles.foreground}
contentContainerStyle={{ paddingBottom: insets.bottom }}
data={showLoading ? undefined : topTokenItems}
keyExtractor={tokenKey}
renderItem={renderItem}
scrollEventThrottle={16}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
onScroll={scrollHandler}
/>
</Flex>
</>
}
contentContainerStyle={{ paddingBottom: insets.bottom }}
data={showLoading ? undefined : topTokenItems}
keyExtractor={tokenKey}
renderItem={renderItem}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
/>
)
}
......@@ -234,32 +204,16 @@ function gqlTokenToTokenItemData(
} as TokenItemData
}
type FavoritesSectionProps = AutoScrollProps & {
showLoading: boolean
}
function FavoritesSection(props: FavoritesSectionProps): JSX.Element | null {
function FavoritesSection({ showLoading }: { showLoading: boolean }): JSX.Element | null {
const hasFavoritedTokens = useAppSelector(selectHasFavoriteTokens)
const hasFavoritedWallets = useAppSelector(selectHasWatchedWallets)
if (!hasFavoritedTokens && !hasFavoritedWallets) return null
return (
<Flex
bg="$transparent"
gap="$spacing12"
pb="$spacing12"
pt="$spacing8"
px="$spacing12"
zIndex={1}>
{hasFavoritedTokens && <FavoriteTokensGrid {...props} />}
{hasFavoritedWallets && <FavoriteWalletsGrid showLoading={props.showLoading} />}
<Flex bg="$transparent" gap="$spacing12" pb="$spacing12" pt="$spacing8" px="$spacing12">
{hasFavoritedTokens && <FavoriteTokensGrid showLoading={showLoading} />}
{hasFavoritedWallets && <FavoriteWalletsGrid showLoading={showLoading} />}
</Flex>
)
}
const styles = StyleSheet.create({
foreground: {
zIndex: 1,
},
})
......@@ -2,15 +2,7 @@ import { ImpactFeedbackStyle } from 'expo-haptics'
import React, { memo, useCallback } from 'react'
import { ViewProps } from 'react-native'
import ContextMenu from 'react-native-context-menu-view'
import {
FadeIn,
FadeOut,
interpolate,
SharedValue,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
import { FadeIn, FadeOut } from 'react-native-reanimated'
import { useAppDispatch } from 'src/app/hooks'
import { useExploreTokenContextMenu } from 'src/components/explore/hooks'
import RemoveButton from 'src/components/explore/RemoveButton'
......@@ -19,7 +11,7 @@ import { useTokenDetailsNavigation } from 'src/components/TokenDetails/hooks'
import { SectionName } from 'src/features/telemetry/constants'
import { disableOnPress } from 'src/utils/disableOnPress'
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 { NumberType } from 'utilities/src/format/types'
import { BaseCard } from 'wallet/src/components/BaseCard/BaseCard'
......@@ -40,24 +32,18 @@ export const FAVORITE_TOKEN_CARD_LOADER_HEIGHT = 114
type FavoriteTokenCardProps = {
currencyId: string
isEditing?: boolean
isTouched: SharedValue<boolean>
dragActivationProgress: SharedValue<number>
setIsEditing: (update: boolean) => void
} & ViewProps
function FavoriteTokenCard({
currencyId,
isEditing,
isTouched,
dragActivationProgress,
setIsEditing,
...rest
}: FavoriteTokenCardProps): JSX.Element {
const dispatch = useAppDispatch()
const tokenDetailsNavigation = useTokenDetailsNavigation()
const { convertFiatAmountFormatted } = useLocalizationContext()
const dragAnimationProgress = useSharedValue(0)
const wasTouched = useSharedValue(false)
const { data, networkStatus, startPolling, stopPolling } = useFavoriteTokenCardQuery({
variables: currencyIdToContractInput(currencyId),
......@@ -102,97 +88,60 @@ function FavoriteTokenCard({
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)) {
return <Loader.Favorite height={FAVORITE_TOKEN_CARD_LOADER_HEIGHT} />
}
return (
<AnimatedFlex style={animatedStyle}>
<ContextMenu
actions={menuActions}
disabled={isEditing}
style={{ borderRadius: borderRadii.rounded16 }}
onPress={onContextMenuPress}
{...rest}>
<AnimatedTouchableArea
activeOpacity={isEditing ? 1 : undefined}
bg="$surface2"
borderRadius="$rounded16"
entering={FadeIn}
exiting={FadeOut}
hapticFeedback={!isEditing}
hapticStyle={ImpactFeedbackStyle.Light}
m="$spacing4"
testID={`token-box-${token?.symbol}`}
onLongPress={disableOnPress}
onPress={onPress}>
<BaseCard.Shadow>
<Flex alignItems="flex-start" gap="$spacing8">
<Flex row gap="$spacing4" justifyContent="space-between">
<Flex grow row alignItems="center" gap="$spacing8">
<TokenLogo
chainId={chainId ?? undefined}
size={imageSizes.image20}
symbol={token?.symbol ?? undefined}
url={token?.project?.logoUrl ?? undefined}
/>
<Text variant="body1">{getSymbolDisplayText(token?.symbol)}</Text>
</Flex>
{isEditing ? (
<RemoveButton onPress={onRemove} />
) : (
<Flex height={imageSizes.image24} />
)}
</Flex>
<Flex gap="$spacing2">
<Text adjustsFontSizeToFit numberOfLines={1} variant="heading3">
{price}
</Text>
<RelativeChange
arrowSize="$icon.16"
change={pricePercentChange ?? undefined}
semanticColor={true}
variant="subheading2"
<ContextMenu
actions={menuActions}
disabled={isEditing}
style={{ borderRadius: borderRadii.rounded16 }}
onPress={onContextMenuPress}
{...rest}>
<AnimatedTouchableArea
hapticFeedback
borderRadius="$rounded16"
entering={FadeIn}
exiting={FadeOut}
hapticStyle={ImpactFeedbackStyle.Light}
m="$spacing4"
testID={`token-box-${token?.symbol}`}
onLongPress={disableOnPress}
onPress={onPress}>
<BaseCard.Shadow>
<Flex alignItems="flex-start" gap="$spacing8">
<Flex row gap="$spacing4" justifyContent="space-between">
<Flex grow row alignItems="center" gap="$spacing8">
<TokenLogo
chainId={chainId ?? undefined}
size={imageSizes.image20}
symbol={token?.symbol ?? undefined}
url={token?.project?.logoUrl ?? undefined}
/>
<Text variant="body1">{getSymbolDisplayText(token?.symbol)}</Text>
</Flex>
{isEditing ? (
<RemoveButton onPress={onRemove} />
) : (
<Flex height={imageSizes.image24} />
)}
</Flex>
<Flex gap="$spacing2">
<Text adjustsFontSizeToFit numberOfLines={1} variant="heading3">
{price}
</Text>
<RelativeChange
arrowSize="$icon.16"
change={pricePercentChange ?? undefined}
semanticColor={true}
variant="subheading2"
/>
</Flex>
</BaseCard.Shadow>
</AnimatedTouchableArea>
</ContextMenu>
</AnimatedFlex>
</Flex>
</BaseCard.Shadow>
</AnimatedTouchableArea>
</ContextMenu>
)
}
......
import React, { useCallback, useEffect, useState } from 'react'
import React, { useEffect, useState } from 'react'
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 { FavoriteHeaderRow } from 'src/components/explore/FavoriteHeaderRow'
import FavoriteTokenCard, {
FAVORITE_TOKEN_CARD_LOADER_HEIGHT,
} from 'src/components/explore/FavoriteTokenCard'
import { Loader } from 'src/components/loading'
import {
AutoScrollProps,
SortableGrid,
SortableGridChangeEvent,
SortableGridRenderItem,
} from 'src/components/sortableGrid'
import { AnimatedFlex, Flex } from 'ui/src'
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 ITEM_FLEX = { flex: 1 / NUM_COLUMNS }
type FavoriteTokensGridProps = AutoScrollProps & {
showLoading: boolean
}
const HALF_WIDTH = { width: '50%' }
/** Renders the favorite tokens section on the Explore tab */
export function FavoriteTokensGrid({
showLoading,
...rest
}: FavoriteTokensGridProps): JSX.Element | null {
export function FavoriteTokensGrid({ showLoading }: { showLoading: boolean }): JSX.Element | null {
const { t } = useTranslation()
const dispatch = useAppDispatch()
const [isEditing, setIsEditing] = useState(false)
const isTokenDragged = useSharedValue(false)
const favoriteCurrencyIds = useAppSelector(selectFavoriteTokens)
// Reset edit mode when there are no favorite tokens
......@@ -44,33 +28,8 @@ export function FavoriteTokensGrid({
}
}, [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 (
<AnimatedFlex entering={FadeIn} style={animatedStyle}>
<AnimatedFlex entering={FadeIn}>
<FavoriteHeaderRow
editingTitle={t('Edit favorite tokens')}
isEditing={isEditing}
......@@ -80,21 +39,17 @@ export function FavoriteTokensGrid({
{showLoading ? (
<FavoriteTokensGridLoader />
) : (
<SortableGrid
{...rest}
activeItemOpacity={1}
data={favoriteCurrencyIds}
editable={isEditing}
numColumns={NUM_COLUMNS}
renderItem={renderItem}
onChange={handleOrderChange}
onDragEnd={(): void => {
isTokenDragged.value = false
}}
onDragStart={(): void => {
isTokenDragged.value = true
}}
/>
<Flex row flexWrap="wrap">
{favoriteCurrencyIds.map((currencyId) => (
<FavoriteTokenCard
key={currencyId}
currencyId={currencyId}
isEditing={isEditing}
setIsEditing={setIsEditing}
style={HALF_WIDTH}
/>
))}
</Flex>
)}
</AnimatedFlex>
)
......
......@@ -10,7 +10,6 @@ import { useTimeout } from 'utilities/src/time/timing'
import { useIsDarkMode } from 'wallet/src/features/appearance/hooks'
import { Language } from 'wallet/src/features/language/constants'
import { useCurrentLanguage } from 'wallet/src/features/language/hooks'
import { isAndroid } from 'wallet/src/utils/platform'
const stateMachineName = 'State Machine 1'
......@@ -83,7 +82,7 @@ export const LandingBackground = (): JSX.Element | null => {
}
// 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 />
}
......
......@@ -12,6 +12,7 @@ import {
import { TabProps, TAB_BAR_HEIGHT } from 'src/components/layout/TabHelpers'
import { Loader } from 'src/components/loading'
import { ScannerModalState } from 'src/components/QRCodeScanner/constants'
import { IS_ANDROID } from 'src/constants/globals'
import { openModal } from 'src/features/modals/modalSlice'
import { ModalName } from 'src/features/telemetry/constants'
import {
......@@ -32,7 +33,6 @@ import {
useActiveAccountWithThrow,
useSelectAccountHideSpamTokens,
} from 'wallet/src/features/wallet/hooks'
import { isAndroid } from 'wallet/src/utils/platform'
export const ACTIVITY_TAB_DATA_DEPENDENCIES = [GQLQueries.TransactionList]
......@@ -148,7 +148,7 @@ export const ActivityTab = memo(
return (
<RefreshControl
progressViewOffset={
insets.top + (isAndroid && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0)
insets.top + (IS_ANDROID && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0)
}
refreshing={refreshing ?? false}
tintColor={colors.neutral3.get()}
......
......@@ -9,6 +9,7 @@ import { AnimatedFlatList } from 'src/components/layout/AnimatedFlatList'
import { TabProps, TAB_BAR_HEIGHT } from 'src/components/layout/TabHelpers'
import { Loader } from 'src/components/loading'
import { ScannerModalState } from 'src/components/QRCodeScanner/constants'
import { IS_ANDROID } from 'src/constants/globals'
import { openModal } from 'src/features/modals/modalSlice'
import { ModalName } from 'src/features/telemetry/constants'
import TransactionSummaryLayout from 'src/features/transactions/SummaryCards/TransactionSummaryLayout'
......@@ -23,7 +24,6 @@ import {
useActiveAccountWithThrow,
useSelectAccountHideSpamTokens,
} from 'wallet/src/features/wallet/hooks'
import { isAndroid } from 'wallet/src/utils/platform'
export const FEED_TAB_DATA_DEPENDENCIES = [GQLQueries.FeedTransactionList]
......@@ -107,7 +107,7 @@ export const FeedTab = memo(
return (
<RefreshControl
progressViewOffset={
insets.top + (isAndroid && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0)
insets.top + (IS_ANDROID && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0)
}
refreshing={refreshing ?? false}
tintColor={colors.neutral3.get()}
......
......@@ -7,6 +7,7 @@ import { useAdaptiveFooter } from 'src/components/home/hooks'
import { TabProps, TAB_BAR_HEIGHT } from 'src/components/layout/TabHelpers'
import { NftView } from 'src/components/NFT/NftView'
import { ScannerModalState } from 'src/components/QRCodeScanner/constants'
import { IS_ANDROID } from 'src/constants/globals'
import { openModal } from 'src/features/modals/modalSlice'
import { ModalName } from 'src/features/telemetry/constants'
import { removePendingSession } from 'src/features/walletConnect/walletConnectSlice'
......@@ -15,7 +16,6 @@ import { Flex, useDeviceInsets, useSporeColors } from 'ui/src'
import { NftsList } from 'wallet/src/components/nfts/NftsList'
import { GQLQueries } from 'wallet/src/data/queries'
import { NFTItem } from 'wallet/src/features/nfts/types'
import { isAndroid } from 'wallet/src/utils/platform'
export const NFTS_TAB_DATA_DEPENDENCIES = [GQLQueries.NftsTab]
......@@ -71,7 +71,7 @@ export const NftsTab = memo(
return (
<RefreshControl
progressViewOffset={
insets.top + (isAndroid && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0)
insets.top + (IS_ANDROID && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0)
}
refreshing={refreshing ?? false}
tintColor={colors.neutral3.get()}
......
......@@ -1376,7 +1376,6 @@ exports[`ActivityTab renders without error 2`] = `
{
"alignItems": "center",
"flexDirection": "row",
"flexShrink": 1,
"gap": 4,
}
}
......@@ -1733,7 +1732,6 @@ exports[`ActivityTab renders without error 2`] = `
{
"alignItems": "center",
"flexDirection": "row",
"flexShrink": 1,
"gap": 4,
}
}
......
......@@ -2,10 +2,11 @@ import React, { forwardRef, useCallback, useEffect, useMemo } from 'react'
import { AppState, Keyboard, KeyboardTypeOptions, TextInput as NativeTextInput } from 'react-native'
import { TextInput, TextInputProps } from 'src/components/input/TextInput'
import { useMoonpayFiatCurrencySupportInfo } from 'src/features/fiatOnRamp/hooks'
import { escapeRegExp } from 'utilities/src/primitives/string'
import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks'
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 = {
showCurrencySign: boolean
......@@ -35,18 +36,10 @@ export function replaceSeparators({
}
export const AmountInput = forwardRef<NativeTextInput, Props>(function _AmountInput(
{ onChangeText, value, showCurrencySign, dimTextColor, showSoftInputOnFocus, ...rest },
{ onChangeText, value, showCurrencySign, dimTextColor, showSoftInputOnFocus, editable, ...rest },
ref
) {
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(
(text: string) => {
......@@ -58,7 +51,9 @@ export const AmountInput = forwardRef<NativeTextInput, Props>(function _AmountIn
decimalOverride: '.',
})
onChangeText?.(parsedText)
if (parsedText === '' || inputRegex.test(escapeRegExp(parsedText))) {
onChangeText?.(parsedText)
}
},
[decimalSeparator, groupingSeparator, onChangeText, showCurrencySign]
)
......@@ -66,20 +61,24 @@ export const AmountInput = forwardRef<NativeTextInput, Props>(function _AmountIn
const { moonpaySupportedFiatCurrency: currency } = useMoonpayFiatCurrencySupportInfo()
const { addFiatSymbolToNumber } = useLocalizationContext()
let formattedValue = replaceSeparators({
value: value ?? '',
groupingSeparator: ',',
decimalSeparator: '.',
groupingOverride: groupingSeparator,
decimalOverride: decimalSeparator,
})
formattedValue = showCurrencySign
let formattedValue = showCurrencySign
? addFiatSymbolToNumber({
value: formattedValue,
value,
currencyCode: currency.code,
currencySymbol: currency.symbol,
})
: formattedValue
: value
// TODO gary MOB-2028 replace temporary hack to handle different separators
formattedValue =
editable ?? true
? replaceSeparators({
value: formattedValue ?? '',
groupingSeparator: ',',
decimalSeparator: '.',
groupingOverride: groupingSeparator,
decimalOverride: decimalSeparator,
})
: formattedValue
const textInputProps: TextInputProps = useMemo(
() => ({
......
......@@ -64,23 +64,25 @@ export function SeedPhraseDisplay({
return (
<>
<Flex grow mt="$spacing16">
{showSeedPhrase ? (
{showSeedPhrase ? (
<Flex grow mt="$spacing16">
<Flex grow pt="$spacing16" px="$spacing16">
<MnemonicDisplay mnemonicId={mnemonicId} />
</Flex>
) : (
<HiddenMnemonicWordView />
)}
</Flex>
<Flex borderTopColor="$surface3" borderTopWidth={1} pt="$spacing12" px="$spacing16">
<Button
testID={ElementName.Next}
theme="secondary"
onPress={(): void => setShowSeedPhrase(!showSeedPhrase)}>
{showSeedPhrase ? t('Hide recovery phrase') : t('Show recovery phrase')}
</Button>
</Flex>
<Flex borderTopColor="$surface3" borderTopWidth={1} pt="$spacing12" px="$spacing16">
<Button
testID={ElementName.Next}
theme="secondary"
onPress={(): void => {
setShowSeedPhrase(false)
}}>
{t('Hide recovery phrase')}
</Button>
</Flex>
</Flex>
) : (
<HiddenMnemonicWordView />
)}
{showSeedPhraseViewWarningModal && (
<WarningModal
......
......@@ -34,6 +34,7 @@ import Animated, {
import { BottomSheetContextProvider } from 'src/components/modals/BottomSheetContext'
import { HandleBar } from 'src/components/modals/HandleBar'
import Trace from 'src/components/Trace/Trace'
import { IS_ANDROID, IS_IOS } from 'src/constants/globals'
import { ModalName } from 'src/features/telemetry/constants'
import { useKeyboardLayout } from 'src/utils/useKeyboardLayout'
import {
......@@ -46,7 +47,6 @@ import {
} from 'ui/src'
import { borderRadii, spacing } from 'ui/src/theme'
import { useIsDarkMode } from 'wallet/src/features/appearance/hooks'
import { isAndroid, isIOS } from 'wallet/src/utils/platform'
/**
* (android only)
......@@ -220,7 +220,7 @@ export const BottomSheetModal = forwardRef<BottomSheetModalRef, Props>(function
const renderBlurredBg = useCallback(
() => (
<Animated.View style={[blurViewStyle.base, animatedBorderRadius]}>
{isIOS ? (
{IS_IOS ? (
<BlurView
intensity={90}
style={blurViewStyle.base}
......@@ -313,7 +313,7 @@ export const BottomSheetModal = forwardRef<BottomSheetModalRef, Props>(function
// This is required for android to make scrollable containers work
// and allow 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}
backgroundStyle={backgroundStyle}
containerComponent={containerComponent}
......@@ -392,7 +392,7 @@ export function BottomSheetDetachedModal({
// This is required for android to make scrollable containers work
// and allow 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}
backgroundStyle={backgroundStyle}
bottomInset={insets.bottom}
......
import React from 'react'
import { ColorValue, FlexStyle } from 'react-native'
import { IS_ANDROID } from 'src/constants/globals'
import { Flex, useSporeColors } from 'ui/src'
import { spacing } from 'ui/src/theme'
import { isAndroid } from 'wallet/src/utils/platform'
const HANDLEBAR_HEIGHT = spacing.spacing4
const HANDLEBAR_WIDTH = spacing.spacing36
......@@ -21,7 +21,7 @@ export const HandleBar = ({
const bg = hidden ? 'transparent' : backgroundColor ?? colors.surface1.get()
return (
<Flex mt={isAndroid ? '$spacing4' : '$none'}>
<Flex mt={IS_ANDROID ? '$spacing4' : '$none'}>
<Flex
alignItems="center"
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
}
This diff is collapsed.
......@@ -60,7 +60,7 @@ export function LongText(props: LongTextProps): JSX.Element {
const onTextLayout = useCallback(
(e: NativeSyntheticEvent<TextLayoutEventData>) => {
setTextLengthExceedsLimit(e.nativeEvent.lines.length > initialDisplayedLines)
setTextLengthExceedsLimit(e.nativeEvent.lines.length >= initialDisplayedLines)
},
[initialDisplayedLines]
)
......
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