ci(release): publish latest release

parent e295be7b
......@@ -8,6 +8,9 @@ node_modules
# testing
coverage
# utility script output
scripts/dist
# next.js
.next/
out/
......
3.2.2
\ No newline at end of file
......@@ -17,3 +17,54 @@ index 4b5b90b7b478668fdff3fd12d5e028d423ada057..af30dc6f700b3b3cfde5c149bf1f8657
});
}
diff --git a/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuManager.java b/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuManager.java
index 81f7b4b58c946d1b2e14301f9b52ecffa1cd0643..403dac6450be24a8c4d26ffb8293b51a1485f6a8 100644
--- a/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuManager.java
+++ b/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuManager.java
@@ -45,6 +45,11 @@ public class ContextMenuManager extends ViewGroupManager<ContextMenuView> {
view.setDropdownMenuMode(enabled);
}
+ @ReactProp(name = "disabled")
+ public void setDisabled(ContextMenuView view, @Nullable boolean disabled) {
+ view.setDisabled(disabled);
+ }
+
@androidx.annotation.Nullable
@Override
public Map<String, Object> getExportedCustomDirectEventTypeConstants() {
diff --git a/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuView.java b/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuView.java
index af30dc6f700b3b3cfde5c149bf1f865786df3e27..aa04fe6d9458601fdcb9bb44f89e16bbc1ad9d39 100644
--- a/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuView.java
+++ b/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuView.java
@@ -43,6 +43,8 @@ public class ContextMenuView extends ReactViewGroup implements PopupMenu.OnMenuI
boolean cancelled = true;
+ private boolean disabled = false;
+
protected boolean dropdownMenuMode = false;
public ContextMenuView(final Context context) {
@@ -87,13 +89,18 @@ public class ContextMenuView extends ReactViewGroup implements PopupMenu.OnMenuI
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
- return true;
+ return disabled ? false : true;
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
- gestureDetector.onTouchEvent(ev);
- return true;
+ if (disabled) return false;
+ gestureDetector.onTouchEvent(ev);
+ return true;
+ }
+
+ public void setDisabled(boolean disabled) {
+ this.disabled = disabled;
}
public void setActions(@Nullable ReadableArray actions) {
......@@ -17,3 +17,18 @@ index 3738bd2c61e516fa431f61fda47f2474f72dba42..2b3266007b3c9412d99e7ceee205ee52
global.requestAnimationFrame = function (callback) {
return setTimeout(callback, 0);
diff --git a/third-party-podspecs/boost.podspec b/third-party-podspecs/boost.podspec
index 3d9331c95d1217682a0b820a0d9440fdff074ae0..8276eb1a5854f945462363fe8db917e8270b3b6a 100644
--- a/third-party-podspecs/boost.podspec
+++ b/third-party-podspecs/boost.podspec
@@ -10,8 +10,8 @@ Pod::Spec.new do |spec|
spec.homepage = 'http://www.boost.org'
spec.summary = 'Boost provides free peer-reviewed portable C++ source libraries.'
spec.authors = 'Rene Rivera'
- spec.source = { :http => 'https://boostorg.jfrog.io/artifactory/main/release/1.76.0/source/boost_1_76_0.tar.bz2',
- :sha256 => 'f0397ba6e982c4450f27bf32a2a83292aba035b827a5623a14636ea583318c41' }
+ spec.source = { :http => 'https://sourceforge.net/projects/boost/files/boost/1.83.0/boost_1_83_0.tar.bz2',
+ :sha256 => '6478edfe2f3305127cffe8caf73ea0176c53769f4bf1585be237eb30798c3b8e' }
# Pinning to the same version as React.podspec.
spec.platforms = { :ios => '11.0' }
Here again with a new (sparse) update to our app! Check out what is new below:
Here again with a new update to our app! Check out what is new below
- Bug fixes
- Performance improvements on home and swap
- Improvements in language support
- Editing Favorite Tokens — We added the ability to drag, drop, and rearrange your favorited tokens. Keep your most important watched tokens close!
- Hidden Token Balances — We updated our wallet to not include the value of hidden tokens in the total wallet balance. Toggle specific tokens to be hidden or shown, and your overall wallet balance will reflect the changes immediately. Out of sight, out of mind.
mobile/1.17.1
\ No newline at end of file
mobile/1.18
\ No newline at end of file
* @Uniswap/mobile-release-admins
\ No newline at end of file
......@@ -85,6 +85,9 @@ Set this as your default version:
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.17"
versionName "1.18"
dimension "variant"
}
beta {
applicationIdSuffix ".beta"
versionName "1.17.1"
versionName "1.18"
dimension "variant"
}
prod {
dimension "variant"
versionName "1.17.1"
versionName "1.18"
}
}
......
......@@ -9,6 +9,7 @@
<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,6 +10,7 @@
<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.3.2):
- EXBarCodeScanner (12.7.0):
- EXImageLoader
- ExpoModulesCore
- ZXingObjC/OneD
......@@ -660,7 +660,7 @@ PODS:
- ExpoModulesCore
- EXFont (11.1.1):
- ExpoModulesCore
- EXImageLoader (4.1.1):
- EXImageLoader (4.4.0):
- ExpoModulesCore
- React-Core
- EXLocalAuthentication (13.0.2):
......@@ -1689,10 +1689,10 @@ SPEC CHECKSUMS:
EthersRS: 56b70e73d22d4e894b7e762eef1129159bcd3135
EXApplication: d8f53a7eee90a870a75656280e8d4b85726ea903
EXAV: f393dfc0b28214d62855a31e06eb21d426d6e2da
EXBarCodeScanner: 8e23fae8d267dbef9f04817833a494200f1fce35
EXBarCodeScanner: 296dd50f6c03928d1d71d37ea17473b304cfdb00
EXFileSystem: 0b4a2c08c2dc92146849772145d61c1773144283
EXFont: 6ea3800df746be7233208d80fe379b8ed74f4272
EXImageLoader: fd053169a8ee932dd83bf1fe5487a50c26d27c2b
EXImageLoader: 03063370bc06ea1825713d3f55fe0455f7c88d04
EXLocalAuthentication: 78cc5a00b13745b41d16d189ab332b61a01299f9
Expo: 8448e3a2aa1b295f029c81551e1ab6d986517fdb
ExpoBlur: fac3c6318fdf409dd5740afd3313b2bd52a35bac
......
......@@ -26,9 +26,31 @@ let placeholderPriceHistory = [
PriceHistory(timestamp: 1689794997, price: 2167),
PriceHistory(timestamp: 1689795264, price: 2165)
]
let previewEntry = TokenPriceEntry(date: Date(), configuration: TokenPriceConfigurationIntent(), spotPrice: 2165, pricePercentChange: -9.87, symbol: "ETH", logo: UIImage(url: URL(string: "https://token-icons.s3.amazonaws.com/eth.png")), backgroundColor: ColorExtraction.extractImageColorWithSpecialCase(imageURL: "https://token-icons.s3.amazonaws.com/eth.png"), tokenPriceHistory: TokenPriceHistoryResponse(priceHistory: placeholderPriceHistory))
let previewEntry = TokenPriceEntry(
date: Date(),
configuration: TokenPriceConfigurationIntent(),
currency: WidgetConstants.currencyUsd,
spotPrice: 2165,
pricePercentChange: -9.87,
symbol: "ETH",
logo: UIImage(url: URL(string: "https://token-icons.s3.amazonaws.com/eth.png")),
backgroundColor: ColorExtraction.extractImageColorWithSpecialCase(
imageURL: "https://token-icons.s3.amazonaws.com/eth.png"
),
tokenPriceHistory: TokenPriceHistoryResponse(priceHistory: placeholderPriceHistory)
)
let placeholderEntry = TokenPriceEntry(date: previewEntry.date, configuration: previewEntry.configuration, spotPrice: previewEntry.spotPrice, pricePercentChange: previewEntry.pricePercentChange, symbol: previewEntry.symbol, logo: nil, backgroundColor: nil, tokenPriceHistory: previewEntry.tokenPriceHistory)
let placeholderEntry = TokenPriceEntry(
date: previewEntry.date,
configuration: previewEntry.configuration,
currency: previewEntry.currency,
spotPrice: previewEntry.spotPrice,
pricePercentChange: previewEntry.pricePercentChange,
symbol: previewEntry.symbol,
logo: nil,
backgroundColor: nil,
tokenPriceHistory: previewEntry.tokenPriceHistory
)
let refreshMinutes = 5
let displayName = "Token Prices"
......@@ -39,10 +61,16 @@ struct Provider: IntentTimelineProvider {
func getEntry(configuration: TokenPriceConfigurationIntent, context: Context, isSnapshot: Bool) async throws -> TokenPriceEntry {
let entryDate = Date()
let tokenPriceResponse = isSnapshot ?
try await DataQueries.fetchTokenPriceData(chain: WidgetConstants.ethereumChain, address: nil) :
try await DataQueries.fetchTokenPriceData(chain: configuration.selectedToken?.chain ?? "", address: configuration.selectedToken?.address)
let spotPrice = tokenPriceResponse.spotPrice
async let tokenPriceRequest = isSnapshot ?
await DataQueries.fetchTokenPriceData(chain: WidgetConstants.ethereumChain, address: nil) :
await DataQueries.fetchTokenPriceData(chain: configuration.selectedToken?.chain ?? "", address: configuration.selectedToken?.address)
async let conversionRequest = await DataQueries.fetchCurrencyConversion(
toCurrency: UniswapUserDefaults.readI18n().currency)
let (tokenPriceResponse, conversionResponse) = try await (tokenPriceRequest, conversionRequest)
let spotPrice = tokenPriceResponse.spotPrice != nil ?
tokenPriceResponse.spotPrice! * conversionResponse.conversionRate : nil
let pricePercentChange = tokenPriceResponse.pricePercentChange
let symbol = tokenPriceResponse.symbol
let logo = UIImage(url: URL(string: tokenPriceResponse.logoUrl ?? ""))
......@@ -62,7 +90,17 @@ struct Provider: IntentTimelineProvider {
address: configuration.selectedToken?.address)
}
return TokenPriceEntry(date: entryDate, configuration: configuration, spotPrice: spotPrice, pricePercentChange: pricePercentChange, symbol: symbol, logo: logo, backgroundColor: backgroundColor, tokenPriceHistory: tokenPriceHistory)
return TokenPriceEntry(
date: entryDate,
configuration: configuration,
currency: conversionResponse.currency,
spotPrice: spotPrice,
pricePercentChange: pricePercentChange,
symbol: symbol,
logo: logo,
backgroundColor: backgroundColor,
tokenPriceHistory: tokenPriceHistory
)
}
func placeholder(in context: Context) -> TokenPriceEntry {
......@@ -90,6 +128,7 @@ struct Provider: IntentTimelineProvider {
struct TokenPriceEntry: TimelineEntry {
let date: Date
let configuration: TokenPriceConfigurationIntent
let currency: String
let spotPrice: Double?
let pricePercentChange: Double?
let symbol: String
......@@ -130,7 +169,14 @@ struct TokenPriceWidgetEntryView: View {
func priceSection(isPlaceholder: Bool) -> some View {
return VStack(alignment: .leading, spacing: 0) {
if (!isPlaceholder && entry.spotPrice != nil && entry.pricePercentChange != nil) {
Text(NumberFormatter.fiatTokenDetailsFormatter(price: entry.spotPrice))
let i18nSettings = UniswapUserDefaults.readI18n()
Text(
NumberFormatter.fiatTokenDetailsFormatter(
price: entry.spotPrice,
locale: Locale(identifier: i18nSettings.locale),
currencyCode: entry.currency
)
)
.withHeading1Style()
.frame(minHeight: 28)
.minimumScaleFactor(0.3)
......
......@@ -12,6 +12,7 @@ public struct WidgetConstants {
public static let ethereumChain = "ETHEREUM"
public static let WETHAddress = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"
public static let ethereumSymbol = "ETH"
public static let currencyUsd = "USD"
}
// Needed to handle different bundle ids, cannot map directly but handles arbitrary bundle ids that conform to the existing convention
......
......@@ -10,9 +10,9 @@ import Apollo
import OSLog
public class DataQueries {
static let cachePolicy: CachePolicy = CachePolicy.fetchIgnoringCacheData
public static func fetchTokensData(tokenInputs: [TokenInput]) async throws -> [TokenResponse] {
return try await withCheckedThrowingContinuation { continuation in
let contractInputs = tokenInputs.map {MobileSchema.ContractInput(chain: GraphQLEnum(rawValue: $0.chain), address: $0.address == nil ? GraphQLNullable.null: GraphQLNullable(stringLiteral: $0.address!))}
......@@ -34,7 +34,7 @@ public class DataQueries {
}
}
}
public static func fetchTopTokensData() async throws -> [TokenResponse] {
return try await withCheckedThrowingContinuation { continuation in
Network.shared.apollo.fetch(query: MobileSchema.TopTokensQuery(chain: GraphQLNullable(MobileSchema.Chain.ethereum)), cachePolicy: cachePolicy) { result in
......@@ -55,7 +55,7 @@ public class DataQueries {
}
}
}
public static func fetchTokenPriceData(chain: String, address: String?) async throws -> TokenPriceResponse {
return try await withCheckedThrowingContinuation { continuation in
Network.shared.apollo.fetch(query: MobileSchema.FavoriteTokenCardQuery(chain: GraphQLEnum(rawValue: chain), address: address == nil ? GraphQLNullable.null : GraphQLNullable(stringLiteral: address!)), cachePolicy: cachePolicy) { result in
......@@ -76,7 +76,7 @@ public class DataQueries {
}
}
}
public static func fetchTokenPriceHistoryData(chain: String, address: String?) async throws -> TokenPriceHistoryResponse {
return try await withCheckedThrowingContinuation { continuation in
Network.shared.apollo.fetch(query: MobileSchema.TokenPriceHistoryQuery(contract: MobileSchema.ContractInput(chain: GraphQLEnum(rawValue: chain), address: address == nil ? GraphQLNullable.null: GraphQLNullable(stringLiteral: address!))), cachePolicy: cachePolicy) { result in
......@@ -96,10 +96,10 @@ public class DataQueries {
}
}
}
public static func fetchWalletsTokensData(addresses: [String], maxLength: Int = 25) async throws -> [TokenResponse] {
return try await withCheckedThrowingContinuation { continuation in
Network.shared.apollo.fetch(query: MobileSchema.MultiplePortfolioBalancesQuery(ownerAddresses: addresses)){ result in
Network.shared.apollo.fetch(query: MobileSchema.MultiplePortfolioBalancesQuery(ownerAddresses: addresses, valueModifiers: GraphQLNullable.null)){ result in
switch result {
case .success(let graphQLResult):
// Takes all the signer accounts and sums up the balances of the tokens, then sorts them by descending order, ignoring spam
......@@ -124,6 +124,40 @@ public class DataQueries {
}
}
}
public static func fetchCurrencyConversion(toCurrency: String) async throws -> CurrencyConversionResponse {
return try await withCheckedThrowingContinuation { continuation in
let usdResponse = CurrencyConversionResponse(conversionRate: 1, currency: WidgetConstants.currencyUsd)
// Assuming all server currency amounts are in USD
if (toCurrency == WidgetConstants.currencyUsd) {
return continuation.resume(returning: usdResponse)
}
Network.shared.apollo.fetch(
query: MobileSchema.ConvertQuery(
fromCurrency: GraphQLEnum(MobileSchema.Currency.usd),
toCurrency: GraphQLEnum(rawValue: toCurrency)
)
) { result in
switch result {
case .success(let graphQLResult):
let conversionRate = graphQLResult.data?.convert?.value
let currency = graphQLResult.data?.convert?.currency?.rawValue
continuation.resume(
returning: conversionRate == nil || currency == nil ? usdResponse :
CurrencyConversionResponse(
conversionRate: conversionRate!,
currency: currency!
)
)
case .failure:
continuation.resume(returning: usdResponse)
}
}
}
}
}
......@@ -6,80 +6,59 @@
//
import Foundation
// Based on https://www.notion.so/uniswaplabs/Number-standards-fbb9f533f10e4e22820722c2f66d23c0
// React native code: https://github.com/Uniswap/universe/blob/main/packages/wallet/src/utils/format.ts
extension NumberFormatter {
// Based on https://www.notion.so/uniswaplabs/Number-standards-fbb9f533f10e4e22820722c2f66d23c0
// React native code: https://github.com/Uniswap/universe/blob/main/packages/wallet/src/utils/format.ts
public static func SHORTHAND_USD_TWO_DECIMALS(price: Double) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.maximumFractionDigits = 2
formatter.minimumFractionDigits = 2
formatter.currencyCode = "USD"
if (price < 1000000){
return TWO_DECIMALS_USD.string(for: price)!
}
else if (price < 1000000000){
return "\(formatter.string(for: price/1000000)!)M"
}
else if (price < 1000000000000){
return "\(formatter.string(for: price/1000000000)!)B"
static func formatShorthandWithDecimals(number: Double, fractionDigits: Int, locale: Locale, currencyCode: String, placeholder: String) -> String {
if (number < 1000000) {
return formatWithDecimals(number: number, fractionDigits: fractionDigits, locale: locale, currencyCode: currencyCode)
}
else if (price < 1000000000000000){
return "\(formatter.string(for: price/1000000000000)!)T"
}
else {
return "$>999T"
let maxNumber = 1000000000000000.0
let maxed = number >= maxNumber
let limitedNumber = maxed ? maxNumber : number
// Replace when Swift supports notation configuration for currency
// https://developer.apple.com/documentation/foundation/currencyformatstyleconfiguration
let compactFormatted = limitedNumber.formatted(.number.locale(locale).precision(.fractionLength(fractionDigits)).notation(.compactName))
let currencyFormatted = limitedNumber.formatted(.currency(code: currencyCode).locale(locale).precision(.fractionLength(fractionDigits)).grouping(.never))
guard let numberRegex = try? NSRegularExpression(pattern: "(\\d+(\\\(locale.decimalSeparator!)\\d+)?)") else {
return placeholder
}
let output = numberRegex.stringByReplacingMatches(in: currencyFormatted, range: NSMakeRange(0, currencyFormatted.count), withTemplate: compactFormatted)
return maxed ? ">\(output)" : "\(output)"
}
public static var TWO_DECIMALS_USD: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.maximumFractionDigits = 2
formatter.minimumFractionDigits = 2
formatter.currencyCode = "USD"
return formatter
}()
public static var THREE_SIG_FIGS_USD: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.maximumSignificantDigits = 3
formatter.minimumSignificantDigits = 3
formatter.currencyCode = "USD"
return formatter
}()
static func formatWithDecimals(number: Double, fractionDigits: Int, locale: Locale, currencyCode: String) -> String {
return number.formatted(.currency(code: currencyCode).locale(locale).precision(.fractionLength(fractionDigits)))
}
public static var THREE_DECIMALS_USD: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.maximumFractionDigits = 3
formatter.minimumFractionDigits = 3
formatter.currencyCode = "USD"
return formatter
}()
static func formatWithSigFigs(number: Double, sigFigsDigits: Int, locale: Locale, currencyCode: String) -> String {
return number.formatted(.currency(code: currencyCode).locale(locale).precision(.significantDigits(sigFigsDigits)))
}
public static func fiatTokenDetailsFormatter(price: Double?) -> String {
public static func fiatTokenDetailsFormatter(price: Double?, locale: Locale, currencyCode: String) -> String {
let placeholder = "--.--"
guard let price = price else {
return "--.--"
return placeholder
}
if (price < 0.00000001) {
return "<$0.00000001"
}
else if (price < 0.01) {
return THREE_SIG_FIGS_USD.string(for: price)!
let formattedPrice = formatWithDecimals(number: price, fractionDigits: 8, locale: locale, currencyCode: currencyCode)
return "<\(formattedPrice)"
}
else if (price < 1.05) {
return THREE_DECIMALS_USD.string(for: price)!
}
else if (price < 1e6) {
return TWO_DECIMALS_USD.string(for: price)!
}
else {
return SHORTHAND_USD_TWO_DECIMALS(price: price)
if (price < 0.01) {
return formatWithSigFigs(number: price, sigFigsDigits: 3, locale: locale, currencyCode: currencyCode)
} else if (price < 1.05) {
return formatWithDecimals(number: price, fractionDigits: 3, locale: locale, currencyCode: currencyCode)
} else if (price < 1e6) {
return formatWithDecimals(number: price, fractionDigits: 2, locale: locale, currencyCode: currencyCode)
} else {
return formatShorthandWithDecimals(number: price, fractionDigits: 2, locale: locale, currencyCode: currencyCode, placeholder: placeholder)
}
}
}
......@@ -51,3 +51,8 @@ public struct PriceHistory {
public let timestamp: Int
public let price: Double
}
public struct CurrencyConversionResponse {
public let conversionRate: Double
public let currency: String
}
......@@ -34,6 +34,17 @@ public struct WidgetDataAccounts: Decodable {
public var accounts: [Account]
}
public struct WidgetDataI18n: Decodable {
public init() {
self.locale = "en"
self.currency = WidgetConstants.currencyUsd
}
public var locale: String
public var currency: String
}
public struct Account: Decodable {
public var address: String
public var name: String?
......@@ -80,14 +91,14 @@ public enum Change: String, Codable {
case removed = "removed"
}
public struct UniswapUserDefaults {
private static var buildString = getBuildVariantString(bundleId: Bundle.main.bundleIdentifier!)
static let eventsKey = buildString + ".widgets.configuration.events"
static let cacheKey = buildString + ".widgets.configuration.cache"
static let favoritesKey = buildString + ".widgets.favorites"
static let accountsKey = buildString + ".widgets.accounts"
static let keyEvents = buildString + ".widgets.configuration.events"
static let keyCache = buildString + ".widgets.configuration.cache"
static let keyFavorites = buildString + ".widgets.favorites"
static let keyAccounts = buildString + ".widgets.accounts"
static let keyI18n = buildString + ".widgets.i18n"
static let userDefaults = UserDefaults.init(suiteName: APP_GROUP)
......@@ -104,7 +115,7 @@ public struct UniswapUserDefaults {
}
public static func readAccounts() -> WidgetDataAccounts {
let data = readData(key: accountsKey)
let data = readData(key: keyAccounts)
guard let data = data else {
return WidgetDataAccounts([])
}
......@@ -117,7 +128,7 @@ public struct UniswapUserDefaults {
}
public static func readFavorites() -> WidgetDataFavorites {
let data = readData(key: favoritesKey)
let data = readData(key: keyFavorites)
guard let data = data else {
return WidgetDataFavorites([])
}
......@@ -129,8 +140,20 @@ public struct UniswapUserDefaults {
return parsedData
}
public static func readI18n() -> WidgetDataI18n {
let data = readData(key: keyI18n)
guard let data = data else {
return WidgetDataI18n()
}
let decoder = JSONDecoder()
guard let parsedData = try? decoder.decode(WidgetDataI18n.self, from: data) else {
return WidgetDataI18n()
}
return parsedData
}
public static func readConfiguration() -> WidgetDataConfiguration {
let data = readData(key: cacheKey)
let data = readData(key: keyCache)
guard let data = data else {
return WidgetDataConfiguration([])
}
......@@ -147,12 +170,12 @@ public struct UniswapUserDefaults {
let encoder = JSONEncoder()
let JSONdata = try! encoder.encode(data)
let json = String(data: JSONdata, encoding: String.Encoding.utf8)
userDefaults!.set(json, forKey: cacheKey)
userDefaults!.set(json, forKey: keyCache)
}
}
public static func readEventChanges() -> WidgetEvents {
let data = readData(key: eventsKey)
let data = readData(key: keyEvents)
guard let data = data else {
return WidgetEvents(events: [])
}
......@@ -169,7 +192,7 @@ public struct UniswapUserDefaults {
let encoder = JSONEncoder()
let JSONdata = try! encoder.encode(data)
let json = String(data: JSONdata, encoding: String.Encoding.utf8)
userDefaults!.set(json, forKey: eventsKey)
userDefaults!.set(json, forKey: keyEvents)
}
}
}
......@@ -10,15 +10,97 @@ import WidgetsCore
final class FormatTests: XCTestCase {
func testFiatTokenDetailsFormatter() throws {
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.05), "$0.050")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.056666666), "$0.057")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 1234567.891), "$1.23M")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 1234.5678), "$1,234.57")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 1.048952), "$1.049")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.001231), "$0.00123")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.00001231), "$0.0000123")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.0000001234), "$0.000000123")
XCTAssertEqual(NumberFormatter.fiatTokenDetailsFormatter(price: 0.000000009876), "<$0.00000001")
let localeEnglish = Locale(identifier: "en")
let localeFrench = Locale(identifier: "fr-FR")
let localeChinese = Locale(identifier: "zh-Hans")
let currencyCodeUsd = WidgetConstants.currencyUsd
let currencyCodeEuro = "EUR"
let currencyCodeYuan = "CNY"
struct TestCase {
public init(_ price: Double, _ output: String) {
self.price = price
self.output = output
}
public let price: Double
public let output: String
}
func testFormatterHandlesEnglish() throws {
let testCases = [
TestCase(0.05, "$0.050"),
TestCase(0.056666666, "$0.057"),
TestCase(1234567.891, "$1.23M"),
TestCase(1234.5678, "$1,234.57"),
TestCase(1.048952, "$1.049"),
TestCase(0.001231, "$0.00123"),
TestCase(0.00001231, "$0.0000123"),
TestCase(0.0000001234, "$0.000000123"),
TestCase(0.000000009876, "<$0.00000001"),
]
testCases.forEach { testCase in
XCTAssertEqual(
NumberFormatter.fiatTokenDetailsFormatter(
price: testCase.price,
locale: localeEnglish,
currencyCode: currencyCodeUsd
),
testCase.output
)
}
}
func testFormatterHandlesFrench() throws {
let testCases = [
TestCase(0.05, "0,050 €"),
TestCase(0.056666666, "0,057 €"),
TestCase(1234567.891, "1,23 M €"),
TestCase(123456.7890, "123 456,79 €"),
TestCase(1.048952, "1,049 €"),
TestCase(0.001231, "0,00123 €"),
TestCase(0.00001231, "0,0000123 €"),
TestCase(0.0000001234, "0,000000123 €"),
TestCase(0.000000009876, "<0,00000001 €"),
]
testCases.forEach { testCase in
XCTAssertEqual(
NumberFormatter.fiatTokenDetailsFormatter(
price: testCase.price,
locale: localeFrench,
currencyCode: currencyCodeEuro
),
testCase.output
)
}
}
func testFormatterHandlesChinese() throws {
let testCases = [
TestCase(0.05, "¥0.050"),
TestCase(0.056666666, "¥0.057"),
TestCase(1234567.891, "¥123.46万"),
TestCase(1234.5678, "¥1,234.57"),
TestCase(1.048952, "¥1.049"),
TestCase(0.001231, "¥0.00123"),
TestCase(0.00001231, "¥0.0000123"),
TestCase(0.0000001234, "¥0.000000123"),
TestCase(0.000000009876, "<¥0.00000001"),
]
testCases.forEach { testCase in
XCTAssertEqual(
NumberFormatter.fiatTokenDetailsFormatter(
price: testCase.price,
locale: localeChinese,
currencyCode: currencyCodeYuan
),
testCase.output
)
}
}
}
......@@ -3,9 +3,10 @@
import mockRNCNetInfo from '@react-native-community/netinfo/jest/netinfo-mock.js'
import 'core-js' // necessary so setImmediate works in tests
import { localizeMock as mockRNLocalize } from 'react-native-localize/mock'
import { MockLocalizationContext } from 'wallet/src/test/utils'
// avoids polutting console in test runs, while keeping important log levels
// avoids polluting console in test runs, while keeping important log levels
global.console = {
...console,
// uncomment to ignore a specific log level
......@@ -84,3 +85,11 @@ global.__reanimatedWorkletInit = () => ({})
jest.mock('react-native-reanimated', () => require('react-native-reanimated/mock'))
jest.mock('wallet/src/features/language/LocalizationContext', () => MockLocalizationContext)
jest.mock('react-native/Libraries/Share/Share', () => {
return {
share: jest.fn(),
}
})
jest.mock('react-native-localize', () => mockRNLocalize)
......@@ -109,7 +109,7 @@
"ethers": "5.7.2",
"expo": "48.0.19",
"expo-av": "13.4.1",
"expo-barcode-scanner": "12.3.2",
"expo-barcode-scanner": "12.7.0",
"expo-blur": "12.2.2",
"expo-clipboard": "4.1.2",
"expo-haptics": "12.0.1",
......
......@@ -33,6 +33,7 @@ import {
processWidgetEvents,
setAccountAddressesUserDefaults,
setFavoritesUserDefaults,
setI18NUserDefaults,
} from 'src/features/widgets/widgets'
import { useAppStateTrigger } from 'src/utils/useAppStateTrigger'
import { getSentryEnvironment, getStatsigEnvironmentTier } from 'src/utils/version'
......@@ -46,6 +47,8 @@ import { uniswapUrls } from 'wallet/src/constants/urls'
import { useCurrentAppearanceSetting, useIsDarkMode } from 'wallet/src/features/appearance/hooks'
import { EXPERIMENT_NAMES, FEATURE_FLAGS } from 'wallet/src/features/experiments/constants'
import { selectFavoriteTokens } from 'wallet/src/features/favorites/selectors'
import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks'
import { useCurrentLanguageInfo } from 'wallet/src/features/language/hooks'
import { LocalizationContextProvider } from 'wallet/src/features/language/LocalizationContext'
import { updateLanguage } from 'wallet/src/features/language/slice'
import { Account } from 'wallet/src/features/wallet/accounts/types'
......@@ -144,14 +147,14 @@ function SentryTags({ children }: PropsWithChildren): JSX.Element {
useEffect(() => {
Object.entries(FEATURE_FLAGS).map(([_, featureFlagName]) => {
Sentry.setTag(
`featureFlag:${featureFlagName}`,
`featureFlag.${featureFlagName}`,
Statsig.checkGateWithExposureLoggingDisabled(featureFlagName)
)
})
Object.entries(EXPERIMENT_NAMES).map(([_, experimentName]) => {
Sentry.setTag(
`experiment:${experimentName}`,
`experiment.${experimentName}`,
Statsig.getExperimentWithExposureLoggingDisabled(experimentName).getGroupName()
)
})
......@@ -176,8 +179,8 @@ function AppOuter(): JSX.Element | null {
<ApolloProvider client={client}>
<PersistGate loading={null} persistor={persistor}>
<ErrorBoundary>
<GestureHandlerRootView style={flexStyles.fill}>
<LocalizationContextProvider>
<LocalizationContextProvider>
<GestureHandlerRootView style={flexStyles.fill}>
<WalletContextProvider>
<BiometricContextProvider>
<LockScreenContextProvider>
......@@ -199,8 +202,8 @@ function AppOuter(): JSX.Element | null {
</LockScreenContextProvider>
</BiometricContextProvider>
</WalletContextProvider>
</LocalizationContextProvider>
</GestureHandlerRootView>
</GestureHandlerRootView>
</LocalizationContextProvider>
</ErrorBoundary>
</PersistGate>
</ApolloProvider>
......@@ -238,6 +241,8 @@ function AppInner(): JSX.Element {
function DataUpdaters(): JSX.Element {
const favoriteTokens: CurrencyId[] = useAppSelector(selectFavoriteTokens)
const accountsMap: Record<string, Account> = useAccounts()
const { locale } = useCurrentLanguageInfo()
const { code } = useAppFiatCurrencyInfo()
// Refreshes widgets when bringing app to foreground
useAppStateTrigger('background', 'active', processWidgetEvents)
......@@ -250,6 +255,10 @@ function DataUpdaters(): JSX.Element {
setAccountAddressesUserDefaults(Object.values(accountsMap))
}, [accountsMap])
useEffect(() => {
setI18NUserDefaults({ locale, currency: code })
}, [code, locale])
return (
<>
<TraceUserProperties />
......
import { 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, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { LayoutChangeEvent } from 'react-native'
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import type { AppDispatch } from 'src/app/store'
......@@ -70,27 +70,24 @@ export function useDynamicFontSizing(
onSetFontSize: (amount: string) => void
} {
const [fontSize, setFontSize] = useState(maxFontSize)
const [textInputElementWidth, setTextInputElementWidth] = useState<number>(0)
const textInputElementWidthRef = useRef(0)
const onLayout = useCallback(
(event: LayoutChangeEvent) => {
if (textInputElementWidth) return
const onLayout = useCallback((event: LayoutChangeEvent) => {
if (textInputElementWidthRef.current) return
const width = event.nativeEvent.layout.width
setTextInputElementWidth(width)
},
[setTextInputElementWidth, textInputElementWidth]
)
const width = event.nativeEvent.layout.width
textInputElementWidthRef.current = width
}, [])
const onSetFontSize = useCallback(
(amount: string) => {
const stringWidth = getStringWidth(amount, maxCharWidthAtMaxFontSize, fontSize, maxFontSize)
const scaledSize = fontSize * (textInputElementWidth / stringWidth)
const scaledSize = fontSize * (textInputElementWidthRef.current / stringWidth)
const scaledSizeWithMin = Math.max(scaledSize, minFontSize)
const newFontSize = Math.round(Math.min(maxFontSize, scaledSizeWithMin))
setFontSize(newFontSize)
},
[fontSize, maxFontSize, minFontSize, maxCharWidthAtMaxFontSize, textInputElementWidth]
[fontSize, maxFontSize, minFontSize, maxCharWidthAtMaxFontSize]
)
return { onLayout, fontSize, onSetFontSize }
......
......@@ -53,6 +53,7 @@ import {
v51Schema,
v52Schema,
v53Schema,
v54Schema,
v5Schema,
v6Schema,
v7Schema,
......@@ -61,6 +62,7 @@ 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'
......@@ -152,6 +154,7 @@ describe('Redux state migrations', () => {
modals: initialModalState,
notifications: initialNotificationsState,
passwordLockout: initialPasswordLockoutState,
behaviorHistory: initialBehaviorHistoryState,
providers: { isInitialized: false },
saga: {},
searchHistory: initialSearchHistoryState,
......@@ -1240,4 +1243,11 @@ 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,4 +717,15 @@ export const migrations = {
return newState
},
55: function addBehaviorHistory(state: any) {
const newState = { ...state }
newState.behaviorHistory = {
hasViewedReviewScreen: false,
hasSubmittedHoldToSwap: false,
}
return newState
},
}
......@@ -8,7 +8,6 @@ import { AccountList } from 'src/components/accounts/AccountList'
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'
......@@ -36,6 +35,7 @@ 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(
IS_ANDROID ? t('Google Drive not available') : t('iCloud Drive not available'),
IS_ANDROID
isAndroid ? t('Google Drive not available') : t('iCloud Drive not available'),
isAndroid
? 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">
{IS_ANDROID ? t('Restore from Google Drive') : t('Restore from iCloud')}
{isAndroid ? t('Restore from Google Drive') : t('Restore from iCloud')}
</Text>
</Flex>
),
......
......@@ -15,6 +15,7 @@ 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 (
......@@ -61,6 +62,10 @@ 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'
......@@ -10,17 +11,20 @@ import { ModalName } from 'src/features/telemetry/constants'
import { selectCustomEndpoint } from 'src/features/tweaks/selectors'
import { setCustomEndpoint } from 'src/features/tweaks/slice'
import { Statsig } from 'statsig-react'
import { useExperiment } from 'statsig-react-native'
import { Button, Flex, Text, useDeviceInsets } from 'ui/src'
import { useExperimentWithExposureLoggingDisabled } from 'statsig-react-native'
import { Accordion } from 'tamagui'
import { Button, Flex, Icons, Text, useDeviceInsets } from 'ui/src'
import { spacing } from 'ui/src/theme'
import { EXPERIMENT_NAMES, FEATURE_FLAGS } from 'wallet/src/features/experiments/constants'
import { useFeatureFlag } from 'wallet/src/features/experiments/hooks'
import { useFeatureFlagWithExposureLoggingDisabled } from 'wallet/src/features/experiments/hooks'
export function ExperimentsModal(): JSX.Element {
const insets = useDeviceInsets()
const dispatch = useAppDispatch()
const customEndpoint = useAppSelector(selectCustomEndpoint)
const apollo = useApolloClient()
const [url, setUrl] = useState<string>(customEndpoint?.url || '')
const [key, setKey] = useState<string>(customEndpoint?.key || '')
......@@ -48,51 +52,113 @@ export function ExperimentsModal(): JSX.Element {
renderBehindBottomInset
name={ModalName.Experiments}
onClose={(): Action => dispatch(closeModal({ name: ModalName.Experiments }))}>
<ScrollView contentContainerStyle={{ paddingBottom: insets.bottom + spacing.spacing12 }}>
<Flex gap="$spacing16" justifyContent="flex-start" pt="$spacing12" px="$spacing24">
<Flex gap="$spacing8">
<Flex gap="$spacing16" my="$spacing16">
<Text variant="subheading1">⚙️ Custom GraphQL Endpoint</Text>
<ScrollView
contentContainerStyle={{
paddingBottom: insets.bottom,
paddingRight: spacing.spacing24,
paddingLeft: spacing.spacing24,
}}>
<Accordion type="single">
<Accordion.Item value="graphql-endpoint">
<AccordionHeader title="⚙️ Custom GraphQL Endpoint" />
<Accordion.Content>
<Text variant="body2">
You will need to restart the application to pick up any changes in this section.
Beware of client side caching!
</Text>
<Flex row alignItems="center" gap="$spacing16">
<Text variant="body2">URL</Text>
<TextInput flex={1} value={url} onChangeText={setUrl} />
</Flex>
<Flex row alignItems="center" gap="$spacing16">
<Text variant="body2">Key</Text>
<TextInput flex={1} value={key} onChangeText={setKey} />
</Flex>
<Button size="small" onPress={setEndpoint}>
Set
</Button>
<Button size="small" onPress={clearEndpoint}>
Clear
<Flex grow row alignItems="center" gap="$spacing16">
<Button flex={1} size="small" onPress={setEndpoint}>
Set
</Button>
<Button flex={1} size="small" onPress={clearEndpoint}>
Clear
</Button>
</Flex>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="apollo-cache">
<AccordionHeader title="🚀 Apollo Cache" />
<Accordion.Content>
<Button
flex={1}
size="small"
onPress={async (): Promise<unknown> => await apollo.resetStore()}>
Reset Cache
</Button>
</Flex>
<Text variant="subheading1">⛳️ Feature Flags</Text>
<Text variant="body2">
Overridden feature flags are reset when the app is restarted
</Text>
</Flex>
{Object.values(FEATURE_FLAGS).map((featureFlag) => {
return <FeatureFlagRow key={featureFlag} featureFlag={featureFlag} />
})}
<Text variant="subheading1">🔬 Experiments</Text>
<Text variant="body2">Overridden experiments are reset when the app is restarted</Text>
{Object.values(EXPERIMENT_NAMES).map((experiment) => {
return <ExperimentRow key={experiment} name={experiment} />
})}
</Flex>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="feature-flags">
<AccordionHeader title="⛳️ Feature Flags" />
<Accordion.Content>
<Text variant="body2">
Overridden feature flags are reset when the app is restarted
</Text>
<Flex gap="$spacing12" mt="$spacing12">
{Object.values(FEATURE_FLAGS).map((featureFlag) => {
return <FeatureFlagRow key={featureFlag} featureFlag={featureFlag} />
})}
</Flex>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="experiments">
<AccordionHeader title="🔬 Experiments" />
<Accordion.Content>
<Text variant="body2">
Overridden experiments are reset when the app is restarted
</Text>
<Flex gap="$spacing12" mt="$spacing12">
{Object.values(EXPERIMENT_NAMES).map((experiment) => {
return <ExperimentRow key={experiment} name={experiment} />
})}
</Flex>
</Accordion.Content>
</Accordion.Item>
</Accordion>
</ScrollView>
</BottomSheetModal>
)
}
function AccordionHeader({ title }: { title: React.ReactNode }): JSX.Element {
return (
<Accordion.Header mt="$spacing12">
<Accordion.Trigger>
{({ open }: { open: boolean }): JSX.Element => (
<>
<Flex row justifyContent="space-between" width="100%">
<Text variant="subheading1">{title}</Text>
<Icons.RotatableChevron direction={open ? 'up' : 'down'} />
</Flex>
</>
)}
</Accordion.Trigger>
</Accordion.Header>
)
}
function FeatureFlagRow({ featureFlag }: { featureFlag: FEATURE_FLAGS }): JSX.Element {
const status = useFeatureFlag(featureFlag)
const status = useFeatureFlagWithExposureLoggingDisabled(featureFlag)
return (
<Flex row alignItems="center" gap="$spacing16" justifyContent="space-between">
......@@ -108,10 +174,7 @@ function FeatureFlagRow({ featureFlag }: { featureFlag: FEATURE_FLAGS }): JSX.El
}
function ExperimentRow({ name }: { name: string }): JSX.Element {
const experiment = useExperiment(name)
// console.log('garydebug experiment row ' + JSON.stringify(experiment.config))
// const layer = useLayer(name)
// console.log('garydebug experiment row ' + JSON.stringify(layer))
const experiment = useExperimentWithExposureLoggingDisabled(name)
const params = Object.entries(experiment.config.value).map(([key, value]) => (
<Flex
......
......@@ -8,15 +8,14 @@ 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 { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants'
import { useFeatureFlag } from 'wallet/src/features/experiments/hooks'
import { useSwapRewriteEnabled } 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 = useFeatureFlag(FEATURE_FLAGS.SwapRewrite)
const shouldShowSwapRewrite = useSwapRewriteEnabled()
const onClose = useCallback((): void => {
appDispatch(closeModal({ name: ModalName.Swap }))
......
......@@ -14,7 +14,6 @@ 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'
......@@ -36,6 +35,7 @@ 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={IS_ANDROID ? '$spacing8' : '$none'}
mb={isAndroid ? '$spacing8' : '$none'}
mx="$spacing24"
pointerEvents="auto">
<ExploreTabBarButton />
......@@ -212,7 +212,7 @@ function ExploreTabBarButton({ activeScale = 0.98 }: ExploreTabBarButtonProps):
},
})
const contentProps: FlexProps = IS_IOS
const contentProps: FlexProps = isIOS
? {
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={IS_IOS ? 100 : 0}>
<BlurView intensity={isIOS ? 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'
......@@ -14,6 +15,7 @@ import { monitoredSagaReducers } from './saga'
const reducers = {
...sharedReducers,
behaviorHistory: behaviorHistoryReducer,
biometricSettings: biometricSettingsReducer,
cloudBackup: cloudBackupReducer,
modals: modalsReducer,
......
......@@ -398,6 +398,14 @@ export const v54Schema = {
},
}
export const v55Schema = {
...v54Schema,
behaviorHistory: {
hasViewedReviewScreen: false,
hasSubmittedHoldToSwap: false,
},
}
// TODO: [MOB-201] use function with typed output when API reducers are removed from rootReducer
// export const getSchema = (): RootState => v0Schema
export const getSchema = (): typeof v54Schema => v54Schema
......@@ -55,6 +55,7 @@ const rtkQueryErrorLogger: Middleware = () => (next) => (action: PayloadAction<u
const whitelist: Array<ReducerNames | RootReducerNames> = [
'appearanceSettings',
'behaviorHistory',
'biometricSettings',
'favorites',
'notifications',
......@@ -74,7 +75,7 @@ export const persistConfig = {
key: 'root',
storage: reduxStorage,
whitelist,
version: 54,
version: 55,
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'
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
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
// TODO: remove need to manually define width of each character
const NUMBER_WIDTH_ARRAY_SCALED = NUMBER_WIDTH_ARRAY.map(
......@@ -171,6 +171,27 @@ 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
......@@ -274,34 +295,7 @@ const AnimatedNumber = ({
backgroundColor="$surface1"
borderRadius="$rounded4"
width={MAX_DEVICE_WIDTH}>
<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>
<TopAndBottomGradient />
<Shine disabled={!warmLoading}>
<AnimatedFlex row entering={FadeIn} width={MAX_DEVICE_WIDTH}>
{chars?.map((_, index) => (
......@@ -331,24 +325,24 @@ const AnimatedNumber = ({
export default AnimatedNumber
const AnimatedNumberStyles = StyleSheet.create({
export const AnimatedNumberStyles = StyleSheet.create({
gradientStyle: {
position: 'absolute',
zIndex: 100,
},
})
const AnimatedCharStyles = StyleSheet.create({
export const AnimatedCharStyles = StyleSheet.create({
wrapperStyle: {
overflow: 'hidden',
},
})
const AnimatedFontStyles = StyleSheet.create({
export 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'
......@@ -35,7 +36,7 @@ export const exampleSwapSuccess = {
}
// easiest to use inside NotificationToastWrapper before any returns
export const useFakeNotification = (ms?: number): void => {
export const useMockNotification = (ms?: number): void => {
const [sent, setSent] = useState(false)
const dispatch = useAppDispatch()
const activeAddress = useActiveAccountAddressWithThrow()
......@@ -57,3 +58,30 @@ export const useFakeNotification = (ms?: number): void => {
}
}, [activeAddress, dispatch, ms, sent])
}
const generateRandomId = (): string => {
let randomId = '0x'
for (let i = 0; i < 40; i++) {
randomId += Math.floor(Math.random() * 16).toString(16)
}
return randomId
}
const generateRandomDate = (): number => {
const start = new Date(2023, 4, 12)
const end = new Date()
return Math.floor(
new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())).getTime() / 1000
)
}
export const useMockCloudBackups = (numberOfBackups?: number): CloudStorageMnemonicBackup[] => {
const number = numberOfBackups ?? 1
const mockBackups = Array.from({ length: number }, () => ({
mnemonicId: generateRandomId(),
createdAt: generateRandomDate(),
}))
return mockBackups
}
import React, { useMemo } from 'react'
import React, { memo, useMemo } from 'react'
import { useWindowDimensions } from 'react-native'
import { useAnimatedStyle, useDerivedValue } from 'react-native-reanimated'
import { AnimatedText } from 'src/components/text/AnimatedText'
import { Flex, useSporeColors } from 'ui/src'
import { TextVariantTokens } from 'ui/src/theme'
import { Flex, useDeviceDimensions, useSporeColors } from 'ui/src'
import { fonts, TextVariantTokens } from 'ui/src/theme'
import { ValueAndFormatted } from './usePrice'
type AnimatedDecimalNumberProps = {
......@@ -13,12 +14,18 @@ type AnimatedDecimalNumberProps = {
decimalPartColor?: string
decimalThreshold?: number // below this value (not including) decimal part would have wholePartColor too
testID?: string
maxWidth?: number
maxCharPixelWidth?: number
}
// Utility component to display decimal numbers where the decimal part
// is dimmed using AnimatedText
export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.Element {
export const AnimatedDecimalNumber = memo(function AnimatedDecimalNumber(
props: AnimatedDecimalNumberProps
): JSX.Element {
const colors = useSporeColors()
const { fullWidth } = useDeviceDimensions()
const { fontScale } = useWindowDimensions()
const {
number,
......@@ -28,6 +35,8 @@ export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.El
decimalPartColor = colors.neutral3.val,
decimalThreshold = 1,
testID,
maxWidth = fullWidth,
maxCharPixelWidth: maxCharPixelWidthProp,
} = props
const wholePart = useDerivedValue(
......@@ -51,12 +60,37 @@ export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.El
}
}, [decimalThreshold, wholePartColor, decimalPartColor])
const fontSize = fonts[variant].fontSize * fontScale
// Choose the arbitrary value that looks good for the font used
const maxCharPixelWidth = maxCharPixelWidthProp ?? (2 / 3) * fontSize
const adjustedFontSize = useDerivedValue(() => {
const value = number.formatted.value
const approxWidth = value.length * maxCharPixelWidth
if (approxWidth <= maxWidth) {
return fontSize
}
const scale = Math.min(1, maxWidth / approxWidth)
return fontSize * scale
})
const animatedStyle = useAnimatedStyle(() => ({
fontSize: adjustedFontSize.value,
}))
return (
<Flex row testID={testID}>
<AnimatedText style={wholeStyle} testID="wholePart" text={wholePart} variant={variant} />
<AnimatedText
style={[wholeStyle, animatedStyle]}
testID="wholePart"
text={wholePart}
variant={variant}
/>
{decimalPart.value !== separator && (
<AnimatedText
style={decimalStyle}
style={[decimalStyle, animatedStyle]}
testID="decimalPart"
text={decimalPart}
variant={variant}
......@@ -64,4 +98,4 @@ export function AnimatedDecimalNumber(props: AnimatedDecimalNumberProps): JSX.El
)}
</Flex>
)
}
})
import { ImpactFeedbackStyle } from 'expo-haptics'
import React, { useMemo } from 'react'
import { memo, useEffect, useMemo, useState } from 'react'
import { I18nManager } from 'react-native'
import { SharedValue } from 'react-native-reanimated'
import {
LineChart,
LineChartProvider,
TLineChartData,
TLineChartDataProp,
} from 'react-native-wagmi-charts'
import { LineChart, LineChartProvider, 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, PriceText, RelativeChangeText } from 'src/components/PriceExplorer/Text'
import { DatetimeText, 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 { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks'
import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
import { CurrencyId } from 'wallet/src/utils/currencyId'
import { TokenSpotData, useTokenPriceHistory } from './usePriceHistory'
import { PriceNumberOfDigits, TokenSpotData, useTokenPriceHistory } from './usePriceHistory'
type PriceTextProps = {
loading: boolean
relativeChange?: SharedValue<number>
numberOfDigits: PriceNumberOfDigits
spotPrice?: number
}
function PriceTextSection({ loading, relativeChange }: PriceTextProps): JSX.Element {
function PriceTextSection({
loading,
relativeChange,
numberOfDigits,
spotPrice,
}: PriceTextProps): JSX.Element {
const price = useLineChartPrice(spotPrice)
const currency = useAppFiatCurrencyInfo()
const mx = spacing.spacing12
return (
<Flex mx="$spacing12">
<PriceText loading={loading} />
<Flex mx={mx}>
{/* Specify maxWidth to allow text scalling. onLayout was sometimes called after more
than 5 seconds which is not acceptable so we have to provide the approximate width
of the PriceText component explicitly. */}
<PriceExplorerAnimatedNumber
currency={currency}
numberOfDigits={numberOfDigits}
price={price}
/>
<Flex row gap="$spacing4">
<RelativeChangeText loading={loading} spotRelativeChange={relativeChange} />
<DatetimeText loading={loading} />
......@@ -42,7 +59,7 @@ export type LineChartPriceAndDateTimeTextProps = {
currencyId: CurrencyId
}
export function PriceExplorer({
export const PriceExplorer = memo(function PriceExplorer({
currencyId,
tokenColor,
forcePlaceholder,
......@@ -53,22 +70,36 @@ export function PriceExplorer({
forcePlaceholder?: boolean
onRetry: () => void
}): JSX.Element {
const { data, loading, error, refetch, setDuration, selectedDuration } =
useTokenPriceHistory(currencyId)
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 { convertFiatAmount } = useLocalizationContext()
const conversionRate = convertFiatAmount().amount
const shouldShowAnimatedDot =
selectedDuration === HistoryDuration.Day || selectedDuration === HistoryDuration.Hour
const additionalPadding = shouldShowAnimatedDot ? 40 : 0
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 { 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 convertedSpot = useMemo((): TokenSpotData | undefined => {
return (
data?.spot && {
......@@ -92,21 +123,27 @@ export function PriceExplorer({
let content: JSX.Element | null
if (forcePlaceholder) {
content = <PriceExplorerPlaceholder loading={forcePlaceholder} />
content = (
<PriceExplorerPlaceholder loading={forcePlaceholder} numberOfDigits={numberOfDigits} />
)
} else if (convertedPriceHistory?.length) {
content = (
// TODO(MOB-2308): add better loading state
// <Flex opacity={fetchComplete ? 1 : 0.35}>
<PriceExplorerChart
additionalPadding={additionalPadding}
lastPricePoint={lastPricePoint}
loading={loading}
numberOfDigits={numberOfDigits}
priceHistory={convertedPriceHistory}
shouldShowAnimatedDot={shouldShowAnimatedDot}
spot={convertedSpot}
tokenColor={tokenColor}
/>
// </Flex>
)
} else {
content = <PriceExplorerPlaceholder loading={loading} />
content = <PriceExplorerPlaceholder loading={loading} numberOfDigits={numberOfDigits} />
}
return (
......@@ -115,12 +152,18 @@ export function PriceExplorer({
<TimeRangeGroup setDuration={setDuration} />
</Flex>
)
}
})
function PriceExplorerPlaceholder({ loading }: { loading: boolean }): JSX.Element {
function PriceExplorerPlaceholder({
loading,
numberOfDigits,
}: {
loading: boolean
numberOfDigits: PriceNumberOfDigits
}): JSX.Element {
return (
<Flex gap="$spacing8">
<PriceTextSection loading={loading} />
<PriceTextSection loading={loading} numberOfDigits={numberOfDigits} />
<Flex my="$spacing24">
<Loader.Graph />
</Flex>
......@@ -136,6 +179,7 @@ function PriceExplorerChart({
additionalPadding,
shouldShowAnimatedDot,
lastPricePoint,
numberOfDigits,
}: {
priceHistory: TLineChartDataProp
spot?: TokenSpotData
......@@ -144,6 +188,7 @@ function PriceExplorerChart({
additionalPadding: number
shouldShowAnimatedDot: boolean
lastPricePoint: number
numberOfDigits: PriceNumberOfDigits
}): JSX.Element {
const { chartHeight, chartWidth } = useChartDimensions()
const isRTL = I18nManager.isRTL
......@@ -153,7 +198,12 @@ function PriceExplorerChart({
data={priceHistory}
onCurrentIndexChange={invokeImpact[ImpactFeedbackStyle.Light]}>
<Flex gap="$spacing8">
<PriceTextSection loading={loading} relativeChange={spot?.relativeChange} />
<PriceTextSection
loading={loading}
numberOfDigits={numberOfDigits}
relativeChange={spot?.relativeChange}
spotPrice={spot?.value?.value}
/>
{/* TODO(MOB-2166): remove forced LTR direction + scaleX horizontal flip technique once react-native-wagmi-charts fixes this: https://github.com/coinjar/react-native-wagmi-charts/issues/136 */}
<Flex direction="ltr" my="$spacing24" style={{ transform: [{ scaleX: isRTL ? -1 : 1 }] }}>
<LineChart height={chartHeight} width={chartWidth - additionalPadding} yGutter={20}>
......@@ -162,7 +212,10 @@ function PriceExplorerChart({
<LineChart.Dot
key={lastPricePoint}
hasPulse
at={lastPricePoint}
// Sometimes, the pulse dot doesn't appear on the end of
// the chart’s path, but on top of the container instead.
// A little shift backwards seems to solve this problem.
at={lastPricePoint - 0.1}
color={tokenColor}
inactiveColor="transparent"
pulseBehaviour="while-inactive"
......@@ -171,9 +224,10 @@ function PriceExplorerChart({
/>
)}
</LineChart.Path>
<LineChart.CursorLine color={tokenColor} />
<LineChart.CursorLine color={tokenColor} minDurationMs={150} />
<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'
import { FiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks'
const NumbersMain = ({
color,
backgroundColor,
hidePlacehodler,
}: {
color: string
backgroundColor: string
hidePlacehodler(): void
}): JSX.Element | null => {
const [showNumers, setShowNumbers] = useState(false)
const hideNumbers = useSharedValue(true)
const animatedTextStyle = useAnimatedStyle(() => {
return {
opacity: hideNumbers.value ? 0 : 1,
}
})
useEffect(() => {
setTimeout(() => {
setShowNumbers(true)
}, 200)
}, [])
const onLayout = (): void => {
hidePlacehodler()
hideNumbers.value = false
}
if (showNumers) {
return (
<Animated.Text
allowFontScaling={false}
style={[
AnimatedFontStyles.fontStyle,
{
height: DIGIT_HEIGHT * 10,
color,
backgroundColor,
},
animatedTextStyle,
]}
onLayout={onLayout}>
{NUMBER_ARRAY}
</Animated.Text>
)
}
return null
}
const MemoizedNumbers = React.memo(NumbersMain)
const RollNumber = ({
chars,
index,
shouldAnimate,
decimalPlace,
hidePlacehodler,
commaIndex,
currency,
}: {
chars: SharedValue<string>
index: number
shouldAnimate: SharedValue<boolean>
decimalPlace: SharedValue<number>
hidePlacehodler(): void
commaIndex: number
currency: FiatCurrencyInfo
}): JSX.Element => {
const colors = useSporeColors()
const animatedDigit = useDerivedValue(() => {
const char = chars.value[index - (commaIndex - decimalPlace.value)]
const number = char ? parseFloat(char) : undefined
return Number.isNaN(number) ? undefined : number
}, [chars])
const animatedFontStyle = useAnimatedStyle(() => {
const color = index >= commaIndex ? colors.neutral3.val : colors.neutral1.val
return {
color,
}
})
const transformY = useDerivedValue(() => {
const endValue = animatedDigit.value !== undefined ? DIGIT_HEIGHT * -animatedDigit.value : 0
return shouldAnimate.value
? withSpring(endValue, {
mass: 1,
damping: 29,
stiffness: 164,
overshootClamping: false,
restDisplacementThreshold: 0.01,
restSpeedThreshold: 2,
})
: endValue
}, [shouldAnimate])
const animatedWrapperStyle = useAnimatedStyle(() => {
const digitWidth =
animatedDigit.value !== undefined ? NUMBER_WIDTH_ARRAY[animatedDigit.value] ?? 0 : 0
const rowWidth = digitWidth + ADDITIONAL_WIDTH_FOR_ANIMATIONS - 7
return {
transform: [
{
translateY: transformY.value,
},
],
width: shouldAnimate.value ? withTiming(rowWidth) : rowWidth,
}
})
if (index === commaIndex) {
return (
<Animated.Text
allowFontScaling={false}
style={[
animatedFontStyle,
AnimatedFontStyles.fontStyle,
{ height: DIGIT_HEIGHT, backgroundColor: colors.surface1.val },
]}>
{currency.decimalSeparator}
</Animated.Text>
)
}
if (
(index - commaIndex) % 4 === 0 &&
index - commaIndex < 0 &&
index > commaIndex - decimalPlace.value
) {
return (
<Animated.Text
allowFontScaling={false}
style={[
animatedFontStyle,
AnimatedFontStyles.fontStyle,
{ height: DIGIT_HEIGHT, backgroundColor: colors.surface1.val },
]}>
{currency.groupingSeparator}
</Animated.Text>
)
}
return (
<Animated.View
style={[
animatedWrapperStyle,
{
marginRight: -ADDITIONAL_WIDTH_FOR_ANIMATIONS,
},
]}>
<MemoizedNumbers
backgroundColor={colors.surface1.val}
color={index >= commaIndex ? colors.neutral3.val : colors.neutral1.val}
hidePlacehodler={hidePlacehodler}
/>
</Animated.View>
)
}
const Numbers = ({
price,
hidePlacehodler,
numberOfDigits,
currency,
}: {
price: ValueAndFormatted
hidePlacehodler(): void
numberOfDigits: PriceNumberOfDigits
currency: FiatCurrencyInfo
}): JSX.Element[] => {
const chars = useDerivedValue(() => {
return price.formatted.value
}, [price])
const decimalPlace = useDerivedValue(() => {
return price.formatted.value.indexOf(currency.decimalSeparator)
}, [price])
return _.times(
numberOfDigits.left + numberOfDigits.right + Math.floor(numberOfDigits.left / 3) + 1,
(index) => (
<Animated.View style={[{ height: DIGIT_HEIGHT }, AnimatedCharStyles.wrapperStyle]}>
<RollNumber
key={index === 0 ? `$sign` : `$_number_${numberOfDigits.left - 1 - index}`}
chars={chars}
commaIndex={numberOfDigits.left + Math.floor(numberOfDigits.left / 3)}
currency={currency}
decimalPlace={decimalPlace}
hidePlacehodler={hidePlacehodler}
index={index}
shouldAnimate={price.shouldAnimate}
/>
</Animated.View>
)
)
}
const LoadingWrapper = (): JSX.Element | null => {
return (
<TextLoaderWrapper loadingShimmer={false}>
<View style={Shimmer.shimmerSize} />
</TextLoaderWrapper>
)
}
const PriceExplorerAnimatedNumber = ({
price,
numberOfDigits,
currency,
}: {
price: ValueAndFormatted
numberOfDigits: PriceNumberOfDigits
currency: FiatCurrencyInfo
}): JSX.Element => {
const colors = useSporeColors()
const hideShimmer = useSharedValue(false)
const animatedWrapperStyle = useAnimatedStyle(() => {
return {
opacity: price.value.value > 0 && hideShimmer.value ? 0 : 1,
position: 'absolute',
zIndex: 1000,
backgroundColor: colors.surface1.val,
}
})
const hidePlacehodler = (): void => {
hideShimmer.value = true
}
const currencySymbol = (
<Text
allowFontScaling={false}
style={[AnimatedFontStyles.fontStyle, { height: DIGIT_HEIGHT, color: colors.neutral1.val }]}>
{currency.fullSymbol}
</Text>
)
return (
<>
<Animated.View style={animatedWrapperStyle}>
<LoadingWrapper />
</Animated.View>
<View style={RowWrapper.wrapperStyle}>
<TopAndBottomGradient />
{currency.symbolAtFront && currencySymbol}
{Numbers({ price, hidePlacehodler, numberOfDigits, currency })}
{!currency.symbolAtFront && currencySymbol}
</View>
</>
)
}
export default PriceExplorerAnimatedNumber
export const RowWrapper = StyleSheet.create({
wrapperStyle: {
flexDirection: 'row',
},
})
export const Shimmer = StyleSheet.create({
shimmerSize: {
height: DIGIT_HEIGHT,
width: 200,
},
})
......@@ -2,15 +2,21 @@ import React from 'react'
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 }: { loading: boolean }): JSX.Element {
export function PriceText({
loading,
maxWidth,
}: {
loading: boolean
maxWidth?: number
}): JSX.Element {
const price = useLineChartPrice()
const colors = useSporeColors()
const currency = useAppFiatCurrency()
......@@ -21,13 +27,15 @@ export function PriceText({ loading }: { loading: boolean }): JSX.Element {
(currency === FiatCurrency.UnitedStatesDollar || currency === FiatCurrency.Euro) &&
symbolAtFront
if (loading) {
return <AnimatedText loading loadingPlaceholderText="$10,000" variant="heading1" />
}
// 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" />
// }
return (
<AnimatedDecimalNumber
decimalPartColor={shouldFadePortfolioDecimals ? colors.neutral3.val : colors.neutral1.val}
maxWidth={maxWidth}
number={price}
separator={decimalSeparator}
testID="price-text"
......@@ -57,7 +65,7 @@ export function RelativeChangeText({
if (loading) {
return (
<Flex mt={IS_ANDROID ? '$none' : '$spacing2'}>
<Flex mt={isAndroid ? '$none' : '$spacing2'}>
<AnimatedText loading loadingPlaceholderText="00.00%" variant="body1" />
</Flex>
)
......@@ -66,9 +74,9 @@ export function RelativeChangeText({
return (
<Flex
row
alignItems={IS_ANDROID ? 'center' : 'flex-end'}
alignItems={isAndroid ? 'center' : 'flex-end'}
gap="$spacing2"
mt={IS_ANDROID ? '$none' : '$spacing2'}>
mt={isAndroid ? '$none' : '$spacing2'}>
<Icons.AnimatedCaretChange
size="$icon.16"
strokeWidth={2}
......
......@@ -33,115 +33,47 @@ exports[`DatetimeText renders without error 1`] = `
exports[`PriceText renders loading state 1`] = `
<View
onLayout={[Function]}
style={
{
"alignItems": "stretch",
"flexDirection": "column",
"opacity": 0,
"flexDirection": "row",
}
}
testID="price-text"
>
<View
style={
<TextInput
allowFontScaling={true}
animatedProps={
{
"alignItems": "center",
"flexDirection": "row",
"text": "-",
}
}
>
<View
style={
editable={false}
maxFontSizeMultiplier={1.2}
style={
[
{
"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={
"padding": 0,
},
{
"fontFamily": "Basel-Book",
"fontSize": 53,
"lineHeight": 60,
},
[
{
"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>
"color": "#222222",
},
{
"fontSize": 106,
},
],
]
}
testID="wholePart"
underlineColorAndroid="transparent"
value="-"
/>
</View>
`;
......@@ -174,9 +106,14 @@ exports[`PriceText renders without error 1`] = `
"fontSize": 53,
"lineHeight": 60,
},
{
"color": "#222222",
},
[
{
"color": "#222222",
},
{
"fontSize": 106,
},
],
]
}
testID="wholePart"
......@@ -202,9 +139,14 @@ exports[`PriceText renders without error 1`] = `
"fontSize": 53,
"lineHeight": 60,
},
{
"color": "#CECECE",
},
[
{
"color": "#CECECE",
},
{
"fontSize": 106,
},
],
]
}
testID="decimalPart"
......@@ -243,9 +185,14 @@ exports[`PriceText renders without error less than a dollar 1`] = `
"fontSize": 53,
"lineHeight": 60,
},
{
"color": "#222222",
},
[
{
"color": "#222222",
},
{
"fontSize": 106,
},
],
]
}
testID="wholePart"
......@@ -271,9 +218,14 @@ exports[`PriceText renders without error less than a dollar 1`] = `
"fontSize": 53,
"lineHeight": 60,
},
{
"color": "#222222",
},
[
{
"color": "#222222",
},
{
"fontSize": 106,
},
],
]
}
testID="decimalPart"
......
import { SharedValue, useDerivedValue } from 'react-native-reanimated'
import { useMemo } from 'react'
import {
SharedValue,
useAnimatedReaction,
useDerivedValue,
useSharedValue,
} from 'react-native-reanimated'
import {
useLineChart,
useLineChartPrice as useRNWagmiChartLineChartPrice,
......@@ -7,21 +13,34 @@ 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> = {
export type ValueAndFormatted<U = number, V = string, B = boolean> = {
value: Readonly<SharedValue<U>>
formatted: Readonly<SharedValue<V>>
shouldAnimate: Readonly<SharedValue<B>>
}
/**
* Wrapper around react-native-wagmi-chart#useLineChartPrice
* @returns latest price when not scrubbing and active price when scrubbing
*/
export function useLineChartPrice(): ValueAndFormatted {
export function useLineChartPrice(currentSpot?: number): ValueAndFormatted {
const { value: activeCursorPrice } = useRNWagmiChartLineChartPrice({
// do not round
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()
......@@ -31,7 +50,9 @@ export function useLineChartPrice(): ValueAndFormatted {
return Number(activeCursorPrice.value)
}
return data[data.length - 1]?.value ?? 0
shouldAnimate.value = true
// show spot price when chart not scrubbing, or if not available, show the last price in the chart
return currentSpot ?? data[data.length - 1]?.value ?? 0
})
const priceFormatted = useDerivedValue(() => {
return numberToLocaleStringWorklet(
......@@ -44,10 +65,15 @@ export function useLineChartPrice(): ValueAndFormatted {
currencyInfo.symbol
)
})
return {
value: price,
formatted: priceFormatted,
}
return useMemo(
() => ({
value: price,
formatted: priceFormatted,
shouldAnimate,
}),
[price, priceFormatted, shouldAnimate]
)
}
/**
......@@ -60,6 +86,7 @@ export function useLineChartRelativeChange({
spotRelativeChange?: SharedValue<number>
}): ValueAndFormatted {
const { currentIndex, data, isActive } = useLineChart()
const shouldAnimate = useSharedValue(false)
const relativeChange = useDerivedValue(() => {
if (!isActive.value && Boolean(spotRelativeChange)) {
......@@ -93,5 +120,5 @@ export function useLineChartRelativeChange({
return numberToPercentWorklet(relativeChange.value, { precision: 2, absolute: true })
})
return { value: relativeChange, formatted: relativeChangeFormattted }
return { value: relativeChange, formatted: relativeChangeFormattted, shouldAnimate }
}
import { maxBy } from 'lodash'
import { Dispatch, SetStateAction, useCallback, useMemo, useState } from 'react'
import { SharedValue } from 'react-native-reanimated'
import { TLineChartData } from 'react-native-wagmi-charts'
......@@ -10,17 +11,24 @@ import {
useTokenPriceHistoryQuery,
} from 'wallet/src/data/__generated__/types-and-hooks'
import { currencyIdToContractInput } from 'wallet/src/features/dataApi/utils'
import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
export type TokenSpotData = {
value: SharedValue<number>
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<{
......@@ -32,8 +40,10 @@ export function useTokenPriceHistory(
setDuration: Dispatch<SetStateAction<HistoryDuration>>
selectedDuration: HistoryDuration
error: boolean
numberOfDigits: PriceNumberOfDigits
} {
const [duration, setDuration] = useState(initialDuration)
const { convertFiatAmount } = useLocalizationContext()
const {
data: priceData,
......@@ -46,7 +56,9 @@ export function useTokenPriceHistory(
},
notifyOnNetworkStatusChange: true,
pollInterval: PollingInterval.Normal,
fetchPolicy: 'cache-first',
onCompleted,
// TODO(MOB-2308): maybe update to network-only once we have a better loading state
fetchPolicy: 'cache-and-network',
})
const offChainData = priceData?.tokenProjects?.[0]?.markets?.[0]
......@@ -73,13 +85,25 @@ export function useTokenPriceHistory(
?.filter((x): x is TimestampedAmount => Boolean(x))
.map((x) => ({ timestamp: x.timestamp * 1000, value: x.value }))
// 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 formatted
}, [priceHistory])
const numberOfDigits = useMemo(() => {
const max = maxBy(priceHistory, 'value')
const convertedMaxValue = convertFiatAmount(max?.value).amount
if (max) {
return {
left: String(convertedMaxValue).split('.')[0]?.length || 10,
right: Number(String(convertedMaxValue).split('.')[0]) > 0 ? 2 : 10,
}
}
return formatted
}, [priceHistory, spot?.value])
return {
left: 0,
right: 0,
}
}, [convertFiatAmount, priceHistory])
const retry = useCallback(async () => {
await refetch({ contract: currencyIdToContractInput(currencyId) })
......@@ -89,14 +113,25 @@ export function useTokenPriceHistory(
() => ({
data: {
priceHistory: formattedPriceHistory,
spot: duration === HistoryDuration.Day ? spot : undefined,
spot,
},
loading: isNonPollingRequestInFlight(networkStatus),
error: isError(networkStatus, !!priceData),
refetch: retry,
setDuration,
selectedDuration: duration,
numberOfDigits,
onCompleted,
}),
[duration, formattedPriceHistory, networkStatus, priceData, retry, spot]
[
duration,
formattedPriceHistory,
networkStatus,
priceData,
retry,
spot,
onCompleted,
numberOfDigits,
]
)
}
......@@ -3,9 +3,9 @@ import { ImageSourcePropType, StyleSheet } from 'react-native'
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%', IS_ANDROID ? '150%' : '100%', '0%'],
gradientDirection: ['0%', '0%', isAndroid ? '150%' : '100%', '0%'],
}
}
return gradientPropsObject
......
......@@ -29,16 +29,17 @@ 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 there is an error popup open or camera is frozen
if (hasScanError || shouldFreezeCamera) return
// don't scan any QR codes if camera is frozen
if (shouldFreezeCamera) return
await selectionAsync()
setShouldFreezeCamera(true)
const supportedURI = await getSupportedURI(uri)
if (supportedURI?.type === URIType.Address) {
setShouldFreezeCamera(true)
onSelectRecipient(supportedURI.value)
onClose()
} else {
......@@ -49,7 +50,7 @@ export function RecipientScanModal({ onSelectRecipient, onClose }: Props): JSX.E
{
text: t('Try again'),
onPress: (): void => {
setHasScanError(false)
setShouldFreezeCamera(false)
},
},
]
......
import React from 'react'
import React, { useMemo } 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,
useAccountListQuery,
} from 'wallet/src/data/__generated__/types-and-hooks'
import { AccountListQuery } 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'
......@@ -17,10 +15,9 @@ type Portfolio = NonNullable<NonNullable<NonNullable<AccountListQuery['portfolio
function _AssociatedAccountsList({ accounts }: { accounts: Account[] }): JSX.Element {
const { fullHeight } = useDeviceDimensions()
const { data, loading } = useAccountListQuery({
variables: {
addresses: accounts.map((account) => account.address),
},
const addresses = useMemo(() => accounts.map((account) => account.address), [accounts])
const { data, loading } = useAccountList({
addresses,
notifyOnNetworkStatusChange: true,
})
......
......@@ -151,54 +151,64 @@ export function RemoveWalletModal(): JSX.Element | null {
backgroundColor={colors.surface1.get()}
name={ModalName.RemoveSeedPhraseWarningModal}
onClose={onClose}>
<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 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>
<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')}
<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}
</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}
</Button>
</Flex>
)}
</Flex>
)}
</Flex>
</Flex>
</BottomSheetModal>
)
......
......@@ -2,7 +2,6 @@ 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'
......@@ -10,6 +9,7 @@ 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: IS_ANDROID ? (
description: isAndroid ? (
<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 fontWeight="bold">{{ wallets: associatedAccountNames }}</Text>. Your recovery
<Text color="$neutral1">{{ wallets: associatedAccountNames }}</Text>. Your recovery
phrase will remain stored until you delete all remaining wallets.
</Trans>
),
......
......@@ -26,8 +26,7 @@ export interface SettingsSectionItemComponent {
component: JSX.Element
isHidden?: boolean
}
type SettingsModal = Extract<ModalName, ModalName.FiatCurrencySelector>
type SettingsModal = Extract<ModalName, ModalName.FiatCurrencySelector | ModalName.LanguageSelector>
export interface SettingsSectionItem {
screen?: keyof SettingsStackParamList | typeof Screens.OnboardingStack
modal?: SettingsModal
......
......@@ -11,15 +11,9 @@ 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,
isSpam,
balanceUSD,
isNative: currency.isNative,
accountHoldsToken: true,
currencyId: portfolioBalance.currencyInfo.currencyId,
portfolioBalance,
})
const style = useMemo(() => ({ borderRadius: borderRadii.rounded16 }), [])
......
......@@ -21,7 +21,6 @@ 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'
......@@ -29,6 +28,7 @@ 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 + (IS_ANDROID && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0)
insets.top + (isAndroid && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0)
}
refreshing={refreshing ?? false}
tintColor={colors.neutral3.get()}
......
import { NetworkStatus } from '@apollo/client'
import { isEqual } from 'lodash'
import {
createContext,
......@@ -9,18 +10,22 @@ import {
useRef,
useState,
} from 'react'
import { useTokenBalancesGroupedByVisibility } from 'src/features/balances/hooks'
import { PollingInterval } from 'wallet/src/constants/misc'
import { isWarmLoadingStatus } from 'wallet/src/data/utils'
import { usePortfolioBalances } from 'wallet/src/features/dataApi/balances'
import {
usePortfolioBalances,
useTokenBalancesGroupedByVisibility,
} from 'wallet/src/features/dataApi/balances'
import { PortfolioBalance } from 'wallet/src/features/dataApi/types'
type CurrencyId = string
export const HIDDEN_TOKEN_BALANCES_ROW = 'HIDDEN_TOKEN_BALANCES_ROW' as const
export type TokenBalanceListRow = CurrencyId | typeof HIDDEN_TOKEN_BALANCES_ROW
type TokenBalanceListContextState = {
balancesById: ReturnType<typeof usePortfolioBalances>['data']
networkStatus: ReturnType<typeof usePortfolioBalances>['networkStatus']
refetch: ReturnType<typeof usePortfolioBalances>['refetch']
balancesById: Record<string, PortfolioBalance> | undefined
networkStatus: NetworkStatus
refetch: (() => void) | undefined
hiddenTokensCount: number
hiddenTokensExpanded: boolean
isWarmLoading: boolean
......@@ -49,7 +54,7 @@ export function TokenBalanceListContextProvider({
refetch,
} = usePortfolioBalances({
address: owner,
shouldPoll: true,
pollInterval: PollingInterval.KindaFast,
fetchPolicy: 'cache-and-network',
})
......
......@@ -114,7 +114,6 @@ 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,8 +4,7 @@ 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 { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants'
import { useFeatureFlag } from 'wallet/src/features/experiments/hooks'
import { useSwapRewriteEnabled } from 'wallet/src/features/experiments/hooks'
import { getSymbolDisplayText } from 'wallet/src/utils/currency'
interface SelectTokenButtonProps {
......@@ -21,7 +20,7 @@ export function SelectTokenButton({
}: SelectTokenButtonProps): JSX.Element {
const { t } = useTranslation()
const isSwapRewriteFeatureEnabled = useFeatureFlag(FEATURE_FLAGS.SwapRewrite)
const isSwapRewriteFeatureEnabled = useSwapRewriteEnabled()
if (isSwapRewriteFeatureEnabled) {
return (
......
......@@ -3,70 +3,21 @@ import React, { memo, useCallback, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { ListRenderItemInfo } from 'react-native'
import { Loader } from 'src/components/loading'
import { useAllCommonBaseCurrencies } from 'src/components/TokenSelector/hooks'
import { TokenOptionItem } from 'src/components/TokenSelector/TokenOptionItem'
import { useFiatOnRampSupportedTokens } from 'src/features/fiatOnRamp/hooks'
import { FiatOnRampCurrency } from 'src/features/fiatOnRamp/types'
import { ElementName } from 'src/features/telemetry/constants'
import { Flex, Icons, Inset, Text, TouchableArea } from 'ui/src'
import { BaseCard } from 'wallet/src/components/BaseCard/BaseCard'
import { ChainId } from 'wallet/src/constants/chains'
import { fromMoonpayNetwork } from 'wallet/src/features/chains/utils'
import { CurrencyInfo, GqlResult } from 'wallet/src/features/dataApi/types'
import { MoonpayCurrency } from 'wallet/src/features/fiatOnRamp/types'
import { CurrencyId } from 'wallet/src/utils/currencyId'
interface Props {
onSelectCurrency: (currency: FiatOnRampCurrency) => void
onBack: () => void
}
const findTokenOptionForMoonpayCurrency = (
commonBaseCurrencies: CurrencyInfo[] | undefined,
moonpayCurrency: MoonpayCurrency
): Maybe<CurrencyInfo> => {
return (commonBaseCurrencies || []).find((item) => {
const [code, network] = moonpayCurrency.code.split('_') ?? [undefined, undefined]
const chainId = fromMoonpayNetwork(network)
return (
item &&
code &&
code === item.currency.symbol?.toLowerCase() &&
chainId === item.currency.chainId
)
})
}
function useFiatOnRampTokenList(
supportedTokens: MoonpayCurrency[] | undefined
): GqlResult<FiatOnRampCurrency[]> {
const {
data: commonBaseCurrencies,
error: commonBaseCurrenciesError,
loading: commonBaseCurrenciesLoading,
refetch: refetchCommonBaseCurrencies,
} = useAllCommonBaseCurrencies()
const data = useMemo(
() =>
(supportedTokens || [])
.map((moonpayCurrency) => ({
currencyInfo: findTokenOptionForMoonpayCurrency(commonBaseCurrencies, moonpayCurrency),
moonpayCurrency,
}))
.filter((item) => !!item.currencyInfo),
[commonBaseCurrencies, supportedTokens]
)
return useMemo(
() => ({
data,
loading: commonBaseCurrenciesLoading,
error: commonBaseCurrenciesError,
refetch: refetchCommonBaseCurrencies,
}),
[commonBaseCurrenciesError, commonBaseCurrenciesLoading, data, refetchCommonBaseCurrencies]
)
onRetry: () => void
error: boolean
loading: boolean
list: FiatOnRampCurrency[] | undefined
}
function TokenOptionItemWrapper({
......@@ -100,20 +51,18 @@ function TokenOptionItemWrapper({
)
}
function _TokenFiatOnRampList({ onSelectCurrency, onBack }: Props): JSX.Element {
function _TokenFiatOnRampList({
onSelectCurrency,
onBack,
error,
onRetry,
list,
loading,
}: Props): JSX.Element {
const { t } = useTranslation()
const flatListRef = useRef(null)
const {
data: supportedTokens,
isLoading: supportedTokensLoading,
isError: supportedTokensQueryError,
refetch: supportedTokensQueryRefetch,
} = useFiatOnRampSupportedTokens()
const { data, loading, error, refetch } = useFiatOnRampTokenList(supportedTokens)
const renderItem = useCallback(
({ item: currency }: ListRenderItemInfo<FiatOnRampCurrency>) => {
return <TokenOptionItemWrapper currency={currency} onSelectCurrency={onSelectCurrency} />
......@@ -121,7 +70,7 @@ function _TokenFiatOnRampList({ onSelectCurrency, onBack }: Props): JSX.Element
[onSelectCurrency]
)
if (supportedTokensQueryError || error) {
if (error) {
return (
<>
<Header onBack={onBack} />
......@@ -129,21 +78,14 @@ function _TokenFiatOnRampList({ onSelectCurrency, onBack }: Props): JSX.Element
<BaseCard.ErrorState
retryButtonLabel="Retry"
title={t('Couldn’t load tokens to buy')}
onRetry={(): void => {
if (supportedTokensQueryError) {
supportedTokensQueryRefetch?.()
}
if (error) {
refetch?.()
}
}}
onRetry={onRetry}
/>
</Flex>
</>
)
}
if (supportedTokensLoading || loading) {
if (loading) {
return (
<Flex>
<Header onBack={onBack} />
......@@ -159,7 +101,7 @@ function _TokenFiatOnRampList({ onSelectCurrency, onBack }: Props): JSX.Element
ref={flatListRef}
ListEmptyComponent={<Flex />}
ListFooterComponent={<Inset all="$spacing36" />}
data={data}
data={list}
keyExtractor={key}
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="always"
......
......@@ -3,7 +3,6 @@ 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'
......@@ -11,7 +10,11 @@ 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 } from 'wallet/src/features/dataApi/balances'
import {
sortPortfolioBalances,
usePortfolioBalances,
useTokenBalancesGroupedByVisibility,
} from 'wallet/src/features/dataApi/balances'
import { CurrencyInfo, GqlResult, PortfolioBalance } from 'wallet/src/features/dataApi/types'
import { usePersistedError } from 'wallet/src/features/dataApi/utils'
import { selectFavoriteTokens } from 'wallet/src/features/favorites/selectors'
......@@ -173,7 +176,6 @@ 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,
......@@ -17,6 +16,7 @@ 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 (IS_ANDROID) {
if (isAndroid) {
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,7 +23,9 @@ export const CUSTOM_UNI_QR_CODE_PREFIX = 'hello_uniwallet:'
const MAX_DAPP_NAME_LENGTH = 60
export function truncateDappName(name: string): string {
return name.length > MAX_DAPP_NAME_LENGTH ? `${name.slice(0, MAX_DAPP_NAME_LENGTH)}...` : name
return name && name.length > MAX_DAPP_NAME_LENGTH
? `${name.slice(0, MAX_DAPP_NAME_LENGTH)}...`
: name
}
export async function getSupportedURI(uri: string): Promise<URIFormat | undefined> {
......
......@@ -4,6 +4,7 @@ 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'
......@@ -24,24 +25,39 @@ type AccountCardItemProps = {
} & PortfolioValueProps
type PortfolioValueProps = {
address: Address
isPortfolioValueLoading: boolean
portfolioValue: number | undefined
}
function PortfolioValue({
address,
isPortfolioValueLoading,
portfolioValue,
portfolioValue: providedPortfolioValue,
}: PortfolioValueProps): JSX.Element {
const isLoading = isPortfolioValueLoading && portfolioValue === undefined
const { t } = useTranslation()
const { convertFiatAmountFormatted } = useLocalizationContext()
// When we add a new wallet, we'll make a new network request to fetch all accounts as a single request.
// Since we're adding a new wallet address to the `ownerAddresses` array, this will be a brand new query, which won't be cached.
// To avoid all wallets showing a "loading" state, we read directly from cache while we wait for the other query to complete.
const { data } = useAccountList({
fetchPolicy: 'cache-first',
addresses: address,
})
const cachedPortfolioValue = data?.portfolios?.[0]?.tokensTotalDenominatedValue?.value
const portfolioValue = providedPortfolioValue ?? cachedPortfolioValue
const isLoading = isPortfolioValueLoading && portfolioValue === undefined
return (
<Text
color="$neutral2"
loading={isLoading}
loadingPlaceholderText="0000.00"
variant="subheading2">
{convertFiatAmountFormatted(portfolioValue, NumberType.PortfolioBalance)}
<Text color="$neutral2" loading={isLoading} variant="subheading2">
{portfolioValue
? convertFiatAmountFormatted(portfolioValue, NumberType.PortfolioBalance)
: t('N/A')}
</Text>
)
}
......@@ -126,6 +142,7 @@ export function AccountCardItem({
/>
</Flex>
<PortfolioValue
address={address}
isPortfolioValueLoading={isPortfolioValueLoading}
portfolioValue={portfolioValue}
/>
......
query AccountList($addresses: [String!]!) {
portfolios(ownerAddresses: $addresses, chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]) {
query AccountList(
$addresses: [String!]!
$valueModifiers: [PortfolioValueModifier!]
) {
portfolios(
ownerAddresses: $addresses
chains: [ETHEREUM, POLYGON, ARBITRUM, OPTIMISM, BASE, BNB]
valueModifiers: $valueModifiers
) {
id
ownerAddress
tokensTotalDenominatedValue {
......
......@@ -20,6 +20,15 @@ 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 = accounts.map((a) => a.address)
const addresses = useMemo(() => accounts.map((a) => a.address), [accounts])
const { data, networkStatus, refetch, startPolling, stopPolling } = useAccountListQuery({
variables: { addresses },
const { data, networkStatus, refetch, startPolling, stopPolling } = useAccountList({
addresses,
notifyOnNetworkStatusChange: true,
})
......@@ -71,13 +71,13 @@ export function AccountList({ accounts, onPress, isVisible }: AccountListProps):
const isPortfolioValueLoading = isNonPollingRequestInFlight(networkStatus)
const accountsWithPortfolioValue = useMemo(() => {
const accountsWithPortfolioValue: AccountWithPortfolioValue[] = useMemo(() => {
return accounts.map((account, i) => {
return {
account,
isPortfolioValueLoading,
portfolioValue: data?.portfolios?.[i]?.tokensTotalDenominatedValue?.value,
} as AccountWithPortfolioValue
}
})
}, [accounts, data, isPortfolioValueLoading])
......@@ -97,15 +97,18 @@ export function AccountList({ accounts, onPress, isVisible }: AccountListProps):
const hasViewOnlyAccounts = viewOnlyAccounts.length > 0
const renderAccountCardItem = (item: AccountWithPortfolioValue): JSX.Element => (
<AccountCardItem
key={item.account.address}
address={item.account.address}
isPortfolioValueLoading={item.isPortfolioValueLoading}
isViewOnly={item.account.type === AccountType.Readonly}
portfolioValue={item.portfolioValue}
onPress={onPress}
/>
const renderAccountCardItem = useCallback(
(item: AccountWithPortfolioValue): JSX.Element => (
<AccountCardItem
key={item.account.address}
address={item.account.address}
isPortfolioValueLoading={item.isPortfolioValueLoading}
isViewOnly={item.account.type === AccountType.Readonly}
portfolioValue={item.portfolioValue}
onPress={onPress}
/>
),
[onPress]
)
return (
......
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 = IS_ANDROID ? colors.neutral3.val : colors.surface1.val
const falseThumbColor = isAndroid ? 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>
{IS_ANDROID ? (
{isAndroid ? (
<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 { BottomSheetFlatList } from '@gorhom/bottom-sheet'
import React, { useCallback, useMemo } from 'react'
import React, { useCallback, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { ListRenderItem, ListRenderItemInfo } from 'react-native'
import { ListRenderItem, ListRenderItemInfo, StyleSheet, View } from 'react-native'
import { useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated'
import { useAppSelector } from 'src/app/hooks'
import { 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,
......@@ -36,12 +38,15 @@ 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)
......@@ -120,6 +125,10 @@ 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)
......@@ -137,39 +146,60 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element
}
return (
<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} />
// 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} />
</Flex>
</>
}
contentContainerStyle={{ paddingBottom: insets.bottom }}
data={showLoading ? undefined : topTokenItems}
keyExtractor={tokenKey}
renderItem={renderItem}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
/>
}
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>
)
}
......@@ -204,16 +234,32 @@ function gqlTokenToTokenItemData(
} as TokenItemData
}
function FavoritesSection({ showLoading }: { showLoading: boolean }): JSX.Element | null {
type FavoritesSectionProps = AutoScrollProps & {
showLoading: boolean
}
function FavoritesSection(props: FavoritesSectionProps): JSX.Element | null {
const hasFavoritedTokens = useAppSelector(selectHasFavoriteTokens)
const hasFavoritedWallets = useAppSelector(selectHasWatchedWallets)
if (!hasFavoritedTokens && !hasFavoritedWallets) return null
return (
<Flex bg="$transparent" gap="$spacing12" pb="$spacing12" pt="$spacing8" px="$spacing12">
{hasFavoritedTokens && <FavoriteTokensGrid showLoading={showLoading} />}
{hasFavoritedWallets && <FavoriteWalletsGrid showLoading={showLoading} />}
<Flex
bg="$transparent"
gap="$spacing12"
pb="$spacing12"
pt="$spacing8"
px="$spacing12"
zIndex={1}>
{hasFavoritedTokens && <FavoriteTokensGrid {...props} />}
{hasFavoritedWallets && <FavoriteWalletsGrid showLoading={props.showLoading} />}
</Flex>
)
}
const styles = StyleSheet.create({
foreground: {
zIndex: 1,
},
})
......@@ -2,7 +2,15 @@ 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 } from 'react-native-reanimated'
import {
FadeIn,
FadeOut,
interpolate,
SharedValue,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
import { useAppDispatch } from 'src/app/hooks'
import { useExploreTokenContextMenu } from 'src/components/explore/hooks'
import RemoveButton from 'src/components/explore/RemoveButton'
......@@ -11,7 +19,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 { AnimatedTouchableArea, Flex, Text } from 'ui/src'
import { AnimatedFlex, 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'
......@@ -32,18 +40,24 @@ 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),
......@@ -88,60 +102,97 @@ 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 (
<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}
<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"
/>
<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>
</Flex>
</BaseCard.Shadow>
</AnimatedTouchableArea>
</ContextMenu>
</BaseCard.Shadow>
</AnimatedTouchableArea>
</ContextMenu>
</AnimatedFlex>
)
}
......
import React, { useEffect, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FadeIn } from 'react-native-reanimated'
import { FadeIn, useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
import { useAppSelector } from 'src/app/hooks'
import { 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 }
const HALF_WIDTH = { width: '50%' }
type FavoriteTokensGridProps = AutoScrollProps & {
showLoading: boolean
}
/** Renders the favorite tokens section on the Explore tab */
export function FavoriteTokensGrid({ showLoading }: { showLoading: boolean }): JSX.Element | null {
export function FavoriteTokensGrid({
showLoading,
...rest
}: FavoriteTokensGridProps): JSX.Element | null {
const { t } = useTranslation()
const dispatch = useAppDispatch()
const [isEditing, setIsEditing] = useState(false)
const isTokenDragged = useSharedValue(false)
const favoriteCurrencyIds = useAppSelector(selectFavoriteTokens)
// Reset edit mode when there are no favorite tokens
......@@ -28,8 +44,33 @@ export function FavoriteTokensGrid({ showLoading }: { showLoading: boolean }): J
}
}, [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}>
<AnimatedFlex entering={FadeIn} style={animatedStyle}>
<FavoriteHeaderRow
editingTitle={t('Edit favorite tokens')}
isEditing={isEditing}
......@@ -39,17 +80,21 @@ export function FavoriteTokensGrid({ showLoading }: { showLoading: boolean }): J
{showLoading ? (
<FavoriteTokensGridLoader />
) : (
<Flex row flexWrap="wrap">
{favoriteCurrencyIds.map((currencyId) => (
<FavoriteTokenCard
key={currencyId}
currencyId={currencyId}
isEditing={isEditing}
setIsEditing={setIsEditing}
style={HALF_WIDTH}
/>
))}
</Flex>
<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
}}
/>
)}
</AnimatedFlex>
)
......
......@@ -10,6 +10,7 @@ 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'
......@@ -82,7 +83,7 @@ export const LandingBackground = (): JSX.Element | null => {
}
// Android 9 and 10 have issues with Rive, so we fallback on image
if ((Platform.OS === 'android' && Platform.Version < 30) || language !== Language.English) {
if ((isAndroid && Platform.Version < 30) || language !== Language.English) {
return <OnboardingStaticImage />
}
......
......@@ -12,7 +12,6 @@ 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 {
......@@ -33,6 +32,7 @@ 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 + (IS_ANDROID && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0)
insets.top + (isAndroid && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0)
}
refreshing={refreshing ?? false}
tintColor={colors.neutral3.get()}
......
......@@ -9,7 +9,6 @@ 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'
......@@ -24,6 +23,7 @@ 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 + (IS_ANDROID && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0)
insets.top + (isAndroid && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0)
}
refreshing={refreshing ?? false}
tintColor={colors.neutral3.get()}
......
......@@ -7,7 +7,6 @@ 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'
......@@ -16,6 +15,7 @@ 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 + (IS_ANDROID && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0)
insets.top + (isAndroid && headerHeight ? headerHeight + TAB_BAR_HEIGHT : 0)
}
refreshing={refreshing ?? false}
tintColor={colors.neutral3.get()}
......@@ -95,7 +95,6 @@ export const NftsTab = memo(
renderNFTItem={renderNFTItem}
renderedInModal={renderedInModal}
onContentSizeChange={onContentSizeChange}
onPressEmptyState={onPressScan}
onRefresh={onRefresh}
onScroll={scrollHandler}
{...containerProps}
......
......@@ -1376,6 +1376,7 @@ exports[`ActivityTab renders without error 2`] = `
{
"alignItems": "center",
"flexDirection": "row",
"flexShrink": 1,
"gap": 4,
}
}
......@@ -1732,6 +1733,7 @@ exports[`ActivityTab renders without error 2`] = `
{
"alignItems": "center",
"flexDirection": "row",
"flexShrink": 1,
"gap": 4,
}
}
......
import React, { forwardRef, useCallback, useEffect, useMemo } from 'react'
import { AppState, Keyboard, KeyboardTypeOptions, TextInput as NativeTextInput } from 'react-native'
import { getNumberFormatSettings } from 'react-native-localize'
import { TextInput, TextInputProps } from 'src/components/input/TextInput'
import { 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 inputRegex = RegExp('^\\d*(?:\\\\[.])?\\d*$') // match escaped "." characters via in a non-capturing group
const numericInputRegex = RegExp('^\\d*(\\.\\d*)?$') // Matches only numeric values without commas
type Props = {
showCurrencySign: boolean
......@@ -21,64 +21,80 @@ export function replaceSeparators({
decimalOverride,
}: {
value: string
groupingSeparator: string
groupingSeparator?: string
decimalSeparator: string
groupingOverride: string
groupingOverride?: string
decimalOverride: string
}): string {
return (
value
.split(decimalSeparator)
let outputParts = value.split(decimalSeparator)
if (groupingSeparator && groupingOverride != null) {
outputParts = outputParts.map((part) =>
// eslint-disable-next-line security/detect-non-literal-regexp
.map((part) => part.replace(new RegExp(groupingSeparator, 'g'), groupingOverride))
.join(decimalOverride)
)
part.replace(new RegExp(`\\${groupingSeparator}`, 'g'), groupingOverride)
)
}
return outputParts.join(decimalOverride)
}
export const AmountInput = forwardRef<NativeTextInput, Props>(function _AmountInput(
{ onChangeText, value, showCurrencySign, dimTextColor, showSoftInputOnFocus, editable, ...rest },
{ onChangeText, value, showCurrencySign, dimTextColor, showSoftInputOnFocus, ...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) => {
const parsedText = replaceSeparators({
value: showCurrencySign ? text.substring(1) : text,
let parsedText = showCurrencySign ? text.substring(1) : text
const { decimalSeparator: keyboardDecimalSeparator } = getNumberFormatSettings()
// TODO MOB-2385 replace this temporary solution for native keyboard
// Assuming showSoftInputOnFocus means that the native keyboard is used
if (showSoftInputOnFocus && keyboardDecimalSeparator !== decimalSeparator) {
parsedText = replaceSeparators({
value: parsedText,
decimalSeparator: keyboardDecimalSeparator,
decimalOverride: decimalSeparator,
})
}
parsedText = replaceSeparators({
value: parsedText,
groupingSeparator,
decimalSeparator,
groupingOverride: '',
decimalOverride: '.',
})
if (parsedText === '' || inputRegex.test(escapeRegExp(parsedText))) {
onChangeText?.(parsedText)
}
onChangeText?.(parsedText)
},
[decimalSeparator, groupingSeparator, onChangeText, showCurrencySign]
[decimalSeparator, groupingSeparator, onChangeText, showCurrencySign, showSoftInputOnFocus]
)
const { moonpaySupportedFiatCurrency: currency } = useMoonpayFiatCurrencySupportInfo()
const { addFiatSymbolToNumber } = useLocalizationContext()
let formattedValue = showCurrencySign
let formattedValue = replaceSeparators({
value: value ?? '',
groupingSeparator: ',',
decimalSeparator: '.',
groupingOverride: '',
decimalOverride: decimalSeparator,
})
formattedValue = showCurrencySign
? addFiatSymbolToNumber({
value,
value: formattedValue,
currencyCode: currency.code,
currencySymbol: currency.symbol,
})
: value
// TODO gary MOB-2028 replace temporary hack to handle different separators
formattedValue =
editable ?? true
? replaceSeparators({
value: formattedValue ?? '',
groupingSeparator: ',',
decimalSeparator: '.',
groupingOverride: groupingSeparator,
decimalOverride: decimalSeparator,
})
: formattedValue
: formattedValue
const textInputProps: TextInputProps = useMemo(
() => ({
......
......@@ -64,25 +64,23 @@ export function SeedPhraseDisplay({
return (
<>
{showSeedPhrase ? (
<Flex grow mt="$spacing16">
<Flex grow mt="$spacing16">
{showSeedPhrase ? (
<Flex grow pt="$spacing16" px="$spacing16">
<MnemonicDisplay mnemonicId={mnemonicId} />
</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 />
)}
) : (
<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>
{showSeedPhraseViewWarningModal && (
<WarningModal
......
......@@ -34,7 +34,6 @@ 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 {
......@@ -47,6 +46,7 @@ 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]}>
{IS_IOS ? (
{isIOS ? (
<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={IS_ANDROID ? [-DRAG_ACTIVATION_OFFSET, DRAG_ACTIVATION_OFFSET] : undefined}
activeOffsetY={isAndroid ? [-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={IS_ANDROID ? [-DRAG_ACTIVATION_OFFSET, DRAG_ACTIVATION_OFFSET] : undefined}
activeOffsetY={isAndroid ? [-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={IS_ANDROID ? '$spacing4' : '$none'}>
<Flex mt={isAndroid ? '$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
export const TIME_TO_ACTIVATE_PAN = 300
export const TOUCH_SLOP = 10
export const AUTO_SCROLL_THRESHOLD = 50
This diff is collapsed.
export { default as SortableGrid } from './SortableGrid'
export * from './types'
import { FlatList, ScrollView, View } from 'react-native'
import { SharedValue } from 'react-native-reanimated'
export type Require<T, K extends keyof T = keyof T> = Required<Pick<T, K>> & Omit<T, K>
export type ItemMeasurements = {
height: number
width: number
x: number
y: number
}
export type AutoScrollProps = {
scrollableRef: React.RefObject<FlatList | ScrollView>
visibleHeight: SharedValue<number>
scrollY: SharedValue<number>
// The parent container inside the scrollable that wraps the grid
// (e.g. when the grid is rendered inside the FlatList header)
// if not provided, we assume that the grid is the first child in
// the scrollable container
containerRef?: React.RefObject<View>
}
export type SortableGridContextType = {
gridContainerRef: React.RefObject<View>
itemAtIndexMeasurements: SharedValue<ItemMeasurements[]>
dragActivationProgress: SharedValue<number>
activeIndex: number | null
previousActiveIndex: SharedValue<number | null>
activeTranslation: SharedValue<{ x: number; y: number }>
scrollOffsetDiff: SharedValue<number>
renderIndexToDisplayIndex: SharedValue<number[]>
setActiveIndex: (index: number | null) => void
onDragStart?: () => void
displayToRenderIndex: SharedValue<number[]>
activeItemScale: SharedValue<number>
visibleHeight: SharedValue<number>
activeItemOpacity: SharedValue<number>
activeItemShadowOpacity: SharedValue<number>
touchedIndex: SharedValue<number | null>
editable: boolean
containerStartOffset: SharedValue<number>
containerEndOffset: SharedValue<number>
}
export type SortableGridRenderItemInfo<I> = {
item: I
index: number
dragActivationProgress: SharedValue<number>
isTouched: SharedValue<boolean>
}
export type SortableGridRenderItem<I> = (info: SortableGridRenderItemInfo<I>) => JSX.Element
export type Vector = {
x: number
y: number
}
export type SortableGridChangeEvent<I> = {
data: I[]
fromIndex: number
toIndex: number
}
import { FlatList, ScrollView } from 'react-native'
const hasProp = <O extends object, P extends string>(
object: O,
prop: P
): object is O & Record<P, unknown> => {
return prop in object
}
export const defaultKeyExtractor = <I>(item: I, index: number): string => {
if (typeof item === 'string') return item
if (typeof item === 'object' && item !== null) {
if (hasProp(item, 'id')) return String(item.id)
if (hasProp(item, 'key')) return String(item.key)
}
return String(index)
}
export const isScrollView = (scrollable: ScrollView | FlatList): scrollable is ScrollView => {
return 'scrollTo' in scrollable
}
import React from 'react'
import { LongMarkdownText } from 'src/components/text/LongMarkdownText'
import { render } from 'src/test/test-utils'
it('renders a LongMarkdownText', () => {
const tree = render(<LongMarkdownText text="Some very long text" />)
expect(tree).toMatchSnapshot()
})
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment