ci(release): publish latest release

parent 19570349
...@@ -8,6 +8,9 @@ node_modules ...@@ -8,6 +8,9 @@ node_modules
# testing # testing
coverage coverage
# utility script output
scripts/dist
# next.js # next.js
.next/ .next/
out/ out/
......
3.2.2
\ No newline at end of file
...@@ -17,3 +17,54 @@ index 4b5b90b7b478668fdff3fd12d5e028d423ada057..af30dc6f700b3b3cfde5c149bf1f8657 ...@@ -17,3 +17,54 @@ index 4b5b90b7b478668fdff3fd12d5e028d423ada057..af30dc6f700b3b3cfde5c149bf1f8657
}); });
} }
diff --git a/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuManager.java b/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuManager.java
index 81f7b4b58c946d1b2e14301f9b52ecffa1cd0643..403dac6450be24a8c4d26ffb8293b51a1485f6a8 100644
--- a/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuManager.java
+++ b/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuManager.java
@@ -45,6 +45,11 @@ public class ContextMenuManager extends ViewGroupManager<ContextMenuView> {
view.setDropdownMenuMode(enabled);
}
+ @ReactProp(name = "disabled")
+ public void setDisabled(ContextMenuView view, @Nullable boolean disabled) {
+ view.setDisabled(disabled);
+ }
+
@androidx.annotation.Nullable
@Override
public Map<String, Object> getExportedCustomDirectEventTypeConstants() {
diff --git a/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuView.java b/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuView.java
index af30dc6f700b3b3cfde5c149bf1f865786df3e27..aa04fe6d9458601fdcb9bb44f89e16bbc1ad9d39 100644
--- a/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuView.java
+++ b/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuView.java
@@ -43,6 +43,8 @@ public class ContextMenuView extends ReactViewGroup implements PopupMenu.OnMenuI
boolean cancelled = true;
+ private boolean disabled = false;
+
protected boolean dropdownMenuMode = false;
public ContextMenuView(final Context context) {
@@ -87,13 +89,18 @@ public class ContextMenuView extends ReactViewGroup implements PopupMenu.OnMenuI
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
- return true;
+ return disabled ? false : true;
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
- gestureDetector.onTouchEvent(ev);
- return true;
+ if (disabled) return false;
+ gestureDetector.onTouchEvent(ev);
+ return true;
+ }
+
+ public void setDisabled(boolean disabled) {
+ this.disabled = disabled;
}
public void setActions(@Nullable ReadableArray actions) {
IPFS hash of the deployment: IPFS hash of the deployment:
- CIDv0: `QmRfmKBiCcSDFdGRZuJKqPtyenCsCFasgiwNvTJQMrbjT6` - CIDv0: `QmYMiHUXKwbifsieRsk6jexS5sS321rZtsoEtu5s266ewa`
- CIDv1: `bafybeibrpcihc5cqqzntesoc5dtxyili3uvyrfmxi7fcx4uteto7ucpj6e` - CIDv1: `bafybeieu3j7cdq5nz4r6z7qnoxcyqq7bk4wjiyxmelpzprkvoaedcb4uze`
The latest release is always mirrored at [app.uniswap.org](https://app.uniswap.org). The latest release is always mirrored at [app.uniswap.org](https://app.uniswap.org).
...@@ -10,15 +10,31 @@ You can also access the Uniswap Interface from an IPFS gateway. ...@@ -10,15 +10,31 @@ You can also access the Uniswap Interface from an IPFS gateway.
Your Uniswap settings are never remembered across different URLs. Your Uniswap settings are never remembered across different URLs.
IPFS gateways: IPFS gateways:
- https://bafybeibrpcihc5cqqzntesoc5dtxyili3uvyrfmxi7fcx4uteto7ucpj6e.ipfs.dweb.link/ - https://bafybeieu3j7cdq5nz4r6z7qnoxcyqq7bk4wjiyxmelpzprkvoaedcb4uze.ipfs.dweb.link/
- https://bafybeibrpcihc5cqqzntesoc5dtxyili3uvyrfmxi7fcx4uteto7ucpj6e.ipfs.cf-ipfs.com/ - https://bafybeieu3j7cdq5nz4r6z7qnoxcyqq7bk4wjiyxmelpzprkvoaedcb4uze.ipfs.cf-ipfs.com/
- [ipfs://QmRfmKBiCcSDFdGRZuJKqPtyenCsCFasgiwNvTJQMrbjT6/](ipfs://QmRfmKBiCcSDFdGRZuJKqPtyenCsCFasgiwNvTJQMrbjT6/) - [ipfs://QmYMiHUXKwbifsieRsk6jexS5sS321rZtsoEtu5s266ewa/](ipfs://QmYMiHUXKwbifsieRsk6jexS5sS321rZtsoEtu5s266ewa/)
### 5.2.2 (2023-12-15) ## 5.3.0 (2023-12-19)
### Features
* **web:** [info] add volume charts (#5235) 80c3996
* **web:** [info] hide About and Address section with flag on (#5464) 61d0397
* **web:** add info tables (#5274) adf64bd
* **web:** Add moonpay text to account drawer and moonpay modal (#5478) 0a09ce3
* **web:** updated page titles (#5390) 4b39985
* **web:** use cumulative value for unhovered bar chart header (#5432) 304c761
### Bug Fixes ### Bug Fixes
* **web:** disambiguate 3P ProviderRpcErrors (#5482) 0f8a086 * **web:** center confirmation modal icons (#5492) fe3688a
* **web:** disambiguate 3P ProviderRpcErrors (#5481) ca912dd
* **web:** fix fadepresence typecheck (#5466) 9d39fa6
* **web:** fix vercel ignore to actually ignore if no web changes (#5420) 45e0ee2
* **web:** fixes and improvements to token sorting / filtering (#5388) 8f740ce
* **web:** optional address for multichainmap (#5393) 6eb015f
* **web:** show default list tokens when searched (#5494) (#5498) d28e298
web/5.2.2 web/5.3.0
\ No newline at end of file \ No newline at end of file
* @Uniswap/mobile-release-admins
\ No newline at end of file
...@@ -85,6 +85,9 @@ Set this as your default version: ...@@ -85,6 +85,9 @@ Set this as your default version:
Install cocoapods: Install cocoapods:
`gem install cocoapods -v 1.13.0` `gem install cocoapods -v 1.13.0`
If you hit ruby errors around `ActiveSupport.deprecator`, downgrade your `activesupport` package by running:
`gem uninstall activesupport && gem install activesupport -v 7.0.8`
### Add Xcode Command Line Tools ### Add Xcode Command Line Tools
Open Xcode and go to: Open Xcode and go to:
......
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
<item name="android:itemBackground">@color/item_background</item> <item name="android:itemBackground">@color/item_background</item>
<item name="android:windowLightStatusBar">false</item> <item name="android:windowLightStatusBar">false</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splashscreen_icon</item> <item name="android:windowSplashScreenAnimatedIcon">@drawable/splashscreen_icon</item>
<item name="android:editTextBackground">@android:color/transparent</item>
</style> </style>
</resources> </resources>
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
<item name="android:windowLightStatusBar">true</item> <item name="android:windowLightStatusBar">true</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splashscreen_icon</item> <item name="android:windowSplashScreenAnimatedIcon">@drawable/splashscreen_icon</item>
<item name="android:navigationBarColor">@color/background_material_light</item> <item name="android:navigationBarColor">@color/background_material_light</item>
<item name="android:editTextBackground">@android:color/transparent</item>
</style> </style>
</resources> </resources>
...@@ -179,8 +179,8 @@ function AppOuter(): JSX.Element | null { ...@@ -179,8 +179,8 @@ function AppOuter(): JSX.Element | null {
<ApolloProvider client={client}> <ApolloProvider client={client}>
<PersistGate loading={null} persistor={persistor}> <PersistGate loading={null} persistor={persistor}>
<ErrorBoundary> <ErrorBoundary>
<GestureHandlerRootView style={flexStyles.fill}>
<LocalizationContextProvider> <LocalizationContextProvider>
<GestureHandlerRootView style={flexStyles.fill}>
<WalletContextProvider> <WalletContextProvider>
<BiometricContextProvider> <BiometricContextProvider>
<LockScreenContextProvider> <LockScreenContextProvider>
...@@ -202,8 +202,8 @@ function AppOuter(): JSX.Element | null { ...@@ -202,8 +202,8 @@ function AppOuter(): JSX.Element | null {
</LockScreenContextProvider> </LockScreenContextProvider>
</BiometricContextProvider> </BiometricContextProvider>
</WalletContextProvider> </WalletContextProvider>
</LocalizationContextProvider>
</GestureHandlerRootView> </GestureHandlerRootView>
</LocalizationContextProvider>
</ErrorBoundary> </ErrorBoundary>
</PersistGate> </PersistGate>
</ApolloProvider> </ApolloProvider>
......
import { renderHook } from '@testing-library/react-hooks'
import { LayoutChangeEvent } from 'react-native'
import { act } from 'react-test-renderer'
import { useDynamicFontSizing, useShouldShowNativeKeyboard } from './hooks'
describe(useShouldShowNativeKeyboard, () => {
it('returns false if layout calculation is pending', () => {
const { result } = renderHook(() => useShouldShowNativeKeyboard())
expect(result.current.showNativeKeyboard).toBe(false)
})
it('returns isLayoutPending as true if layout calculation is pending', () => {
const { result } = renderHook(() => useShouldShowNativeKeyboard())
expect(result.current.isLayoutPending).toBe(true)
})
it("shouldn't show native keyboard if decimal pad is rendered below the input panel", async () => {
const { result } = renderHook(() => useShouldShowNativeKeyboard())
await act(async () => {
result.current.onInputPanelLayout({
nativeEvent: { layout: { height: 100 } },
} as LayoutChangeEvent)
result.current.onDecimalPadLayout({
nativeEvent: { layout: { y: 200 } },
} as LayoutChangeEvent)
})
expect(result.current.showNativeKeyboard).toBe(false)
expect(result.current.maxContentHeight).toBeDefined()
expect(result.current.isLayoutPending).toBe(false)
})
it('should show native keyboard if decimal pad is rendered above the input panel', async () => {
const { result } = renderHook(() => useShouldShowNativeKeyboard())
await act(async () => {
result.current.onInputPanelLayout({
nativeEvent: { layout: { height: 100 } },
} as LayoutChangeEvent)
result.current.onDecimalPadLayout({
nativeEvent: { layout: { y: 50 } },
} as LayoutChangeEvent)
})
expect(result.current.showNativeKeyboard).toBe(true)
expect(result.current.maxContentHeight).not.toBeDefined()
expect(result.current.isLayoutPending).toBe(false)
})
})
const MAX_INPUT_FONT_SIZE = 42
const MIN_INPUT_FONT_SIZE = 28
const MAX_CHAR_PIXEL_WIDTH = 23
describe(useDynamicFontSizing, () => {
it('returns maxFontSize if text input element width is not set', () => {
const { result } = renderHook(() =>
useDynamicFontSizing(MAX_CHAR_PIXEL_WIDTH, MAX_INPUT_FONT_SIZE, MIN_INPUT_FONT_SIZE)
)
expect(result.current.fontSize).toBe(MAX_INPUT_FONT_SIZE)
})
it('returns maxFontSize as fontSize if text fits in the container', async () => {
const { result } = renderHook(() =>
useDynamicFontSizing(MAX_CHAR_PIXEL_WIDTH, MAX_INPUT_FONT_SIZE, MIN_INPUT_FONT_SIZE)
)
await act(() => {
result.current.onLayout({ nativeEvent: { layout: { width: 100 } } } as LayoutChangeEvent)
result.current.onSetFontSize('aaaa')
})
// 100 / 23 = 4.34 - 4 letters should fit in the container
expect(result.current.fontSize).toBe(MAX_INPUT_FONT_SIZE)
})
it('scales down font when text does not fit in the container', async () => {
const { result } = renderHook(() =>
useDynamicFontSizing(MAX_CHAR_PIXEL_WIDTH, MAX_INPUT_FONT_SIZE, MIN_INPUT_FONT_SIZE)
)
await act(() => {
result.current.onLayout({ nativeEvent: { layout: { width: 100 } } } as LayoutChangeEvent)
result.current.onSetFontSize('aaaaa')
})
// 100 / 23 = 4.34 - 5 letters should not fit in the container
expect(result.current.fontSize).toBeLessThan(MAX_INPUT_FONT_SIZE)
})
it("doesn't return font size less than minFontSize", async () => {
const { result } = renderHook(() =>
useDynamicFontSizing(MAX_CHAR_PIXEL_WIDTH, MAX_INPUT_FONT_SIZE, MIN_INPUT_FONT_SIZE)
)
await act(() => {
result.current.onLayout({ nativeEvent: { layout: { width: 100 } } } as LayoutChangeEvent)
result.current.onSetFontSize('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')
})
expect(result.current.fontSize).toBe(MIN_INPUT_FONT_SIZE)
})
})
import { ThunkDispatch } from '@reduxjs/toolkit' import { ThunkDispatch } from '@reduxjs/toolkit'
import { useCallback, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import { LayoutChangeEvent } from 'react-native' import { LayoutChangeEvent } from 'react-native'
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import type { AppDispatch } from 'src/app/store' import type { AppDispatch } from 'src/app/store'
...@@ -70,27 +70,24 @@ export function useDynamicFontSizing( ...@@ -70,27 +70,24 @@ export function useDynamicFontSizing(
onSetFontSize: (amount: string) => void onSetFontSize: (amount: string) => void
} { } {
const [fontSize, setFontSize] = useState(maxFontSize) const [fontSize, setFontSize] = useState(maxFontSize)
const [textInputElementWidth, setTextInputElementWidth] = useState<number>(0) const textInputElementWidthRef = useRef(0)
const onLayout = useCallback( const onLayout = useCallback((event: LayoutChangeEvent) => {
(event: LayoutChangeEvent) => { if (textInputElementWidthRef.current) return
if (textInputElementWidth) return
const width = event.nativeEvent.layout.width const width = event.nativeEvent.layout.width
setTextInputElementWidth(width) textInputElementWidthRef.current = width
}, }, [])
[setTextInputElementWidth, textInputElementWidth]
)
const onSetFontSize = useCallback( const onSetFontSize = useCallback(
(amount: string) => { (amount: string) => {
const stringWidth = getStringWidth(amount, maxCharWidthAtMaxFontSize, fontSize, maxFontSize) const stringWidth = getStringWidth(amount, maxCharWidthAtMaxFontSize, fontSize, maxFontSize)
const scaledSize = fontSize * (textInputElementWidth / stringWidth) const scaledSize = fontSize * (textInputElementWidthRef.current / stringWidth)
const scaledSizeWithMin = Math.max(scaledSize, minFontSize) const scaledSizeWithMin = Math.max(scaledSize, minFontSize)
const newFontSize = Math.round(Math.min(maxFontSize, scaledSizeWithMin)) const newFontSize = Math.round(Math.min(maxFontSize, scaledSizeWithMin))
setFontSize(newFontSize) setFontSize(newFontSize)
}, },
[fontSize, maxFontSize, minFontSize, maxCharWidthAtMaxFontSize, textInputElementWidth] [fontSize, maxFontSize, minFontSize, maxCharWidthAtMaxFontSize]
) )
return { onLayout, fontSize, onSetFontSize } return { onLayout, fontSize, onSetFontSize }
......
...@@ -53,6 +53,7 @@ import { ...@@ -53,6 +53,7 @@ import {
v51Schema, v51Schema,
v52Schema, v52Schema,
v53Schema, v53Schema,
v54Schema,
v5Schema, v5Schema,
v6Schema, v6Schema,
v7Schema, v7Schema,
...@@ -61,6 +62,7 @@ import { ...@@ -61,6 +62,7 @@ import {
} from 'src/app/schema' } from 'src/app/schema'
import { persistConfig } from 'src/app/store' import { persistConfig } from 'src/app/store'
import { ScannerModalState } from 'src/components/QRCodeScanner/constants' import { ScannerModalState } from 'src/components/QRCodeScanner/constants'
import { initialBehaviorHistoryState } from 'src/features/behaviorHistory/slice'
import { initialBiometricsSettingsState } from 'src/features/biometrics/slice' import { initialBiometricsSettingsState } from 'src/features/biometrics/slice'
import { initialCloudBackupState } from 'src/features/CloudBackup/cloudBackupSlice' import { initialCloudBackupState } from 'src/features/CloudBackup/cloudBackupSlice'
import { initialPasswordLockoutState } from 'src/features/CloudBackup/passwordLockoutSlice' import { initialPasswordLockoutState } from 'src/features/CloudBackup/passwordLockoutSlice'
...@@ -152,6 +154,7 @@ describe('Redux state migrations', () => { ...@@ -152,6 +154,7 @@ describe('Redux state migrations', () => {
modals: initialModalState, modals: initialModalState,
notifications: initialNotificationsState, notifications: initialNotificationsState,
passwordLockout: initialPasswordLockoutState, passwordLockout: initialPasswordLockoutState,
behaviorHistory: initialBehaviorHistoryState,
providers: { isInitialized: false }, providers: { isInitialized: false },
saga: {}, saga: {},
searchHistory: initialSearchHistoryState, searchHistory: initialSearchHistoryState,
...@@ -1240,4 +1243,11 @@ describe('Redux state migrations', () => { ...@@ -1240,4 +1243,11 @@ describe('Redux state migrations', () => {
expect(v54.telemetry.walletIsFunded).toBe(false) expect(v54.telemetry.walletIsFunded).toBe(false)
}) })
it('migrates from v54 to 55', () => {
const v54Stub = { ...v54Schema }
const v55 = migrations[55](v54Stub)
expect(v55.behaviorHistory.hasViewedReviewScreen).toBe(false)
})
}) })
...@@ -717,4 +717,15 @@ export const migrations = { ...@@ -717,4 +717,15 @@ export const migrations = {
return newState return newState
}, },
55: function addBehaviorHistory(state: any) {
const newState = { ...state }
newState.behaviorHistory = {
hasViewedReviewScreen: false,
hasSubmittedHoldToSwap: false,
}
return newState
},
} }
...@@ -15,6 +15,7 @@ import { FiatOnRampAggregatorModal } from 'src/features/fiatOnRamp/FiatOnRampAgg ...@@ -15,6 +15,7 @@ import { FiatOnRampAggregatorModal } from 'src/features/fiatOnRamp/FiatOnRampAgg
import { FiatOnRampModal } from 'src/features/fiatOnRamp/FiatOnRampModal' import { FiatOnRampModal } from 'src/features/fiatOnRamp/FiatOnRampModal'
import { ModalName } from 'src/features/telemetry/constants' import { ModalName } from 'src/features/telemetry/constants'
import { SettingsFiatCurrencyModal } from 'src/screens/SettingsFiatCurrencyModal' import { SettingsFiatCurrencyModal } from 'src/screens/SettingsFiatCurrencyModal'
import { SettingsLanguageModal } from 'src/screens/SettingsLanguageModal'
export function AppModals(): JSX.Element { export function AppModals(): JSX.Element {
return ( return (
...@@ -61,6 +62,10 @@ export function AppModals(): JSX.Element { ...@@ -61,6 +62,10 @@ export function AppModals(): JSX.Element {
<RestoreWalletModal /> <RestoreWalletModal />
</LazyModalRenderer> </LazyModalRenderer>
<LazyModalRenderer name={ModalName.LanguageSelector}>
<SettingsLanguageModal />
</LazyModalRenderer>
<LazyModalRenderer name={ModalName.FiatCurrencySelector}> <LazyModalRenderer name={ModalName.FiatCurrencySelector}>
<SettingsFiatCurrencyModal /> <SettingsFiatCurrencyModal />
</LazyModalRenderer> </LazyModalRenderer>
......
...@@ -8,15 +8,14 @@ import { updateSwapStartTimestamp } from 'src/features/telemetry/timing/slice' ...@@ -8,15 +8,14 @@ import { updateSwapStartTimestamp } from 'src/features/telemetry/timing/slice'
import { SwapFlow } from 'src/features/transactions/swap/SwapFlow' import { SwapFlow } from 'src/features/transactions/swap/SwapFlow'
import { SwapFlow as SwapFlowRewrite } from 'src/features/transactions/swapRewrite/SwapFlow' import { SwapFlow as SwapFlowRewrite } from 'src/features/transactions/swapRewrite/SwapFlow'
import { useSporeColors } from 'ui/src' import { useSporeColors } from 'ui/src'
import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants' import { useSwapRewriteEnabled } from 'wallet/src/features/experiments/hooks'
import { useFeatureFlag } from 'wallet/src/features/experiments/hooks'
export function SwapModal(): JSX.Element { export function SwapModal(): JSX.Element {
const colors = useSporeColors() const colors = useSporeColors()
const appDispatch = useAppDispatch() const appDispatch = useAppDispatch()
const modalState = useAppSelector(selectModalState(ModalName.Swap)) const modalState = useAppSelector(selectModalState(ModalName.Swap))
const shouldShowSwapRewrite = useFeatureFlag(FEATURE_FLAGS.SwapRewrite) const shouldShowSwapRewrite = useSwapRewriteEnabled()
const onClose = useCallback((): void => { const onClose = useCallback((): void => {
appDispatch(closeModal({ name: ModalName.Swap })) appDispatch(closeModal({ name: ModalName.Swap }))
......
import { combineReducers } from '@reduxjs/toolkit' import { combineReducers } from '@reduxjs/toolkit'
import { behaviorHistoryReducer } from 'src/features/behaviorHistory/slice'
import { biometricSettingsReducer } from 'src/features/biometrics/slice' import { biometricSettingsReducer } from 'src/features/biometrics/slice'
import { cloudBackupReducer } from 'src/features/CloudBackup/cloudBackupSlice' import { cloudBackupReducer } from 'src/features/CloudBackup/cloudBackupSlice'
import { passwordLockoutReducer } from 'src/features/CloudBackup/passwordLockoutSlice' import { passwordLockoutReducer } from 'src/features/CloudBackup/passwordLockoutSlice'
...@@ -14,6 +15,7 @@ import { monitoredSagaReducers } from './saga' ...@@ -14,6 +15,7 @@ import { monitoredSagaReducers } from './saga'
const reducers = { const reducers = {
...sharedReducers, ...sharedReducers,
behaviorHistory: behaviorHistoryReducer,
biometricSettings: biometricSettingsReducer, biometricSettings: biometricSettingsReducer,
cloudBackup: cloudBackupReducer, cloudBackup: cloudBackupReducer,
modals: modalsReducer, modals: modalsReducer,
......
...@@ -398,6 +398,14 @@ export const v54Schema = { ...@@ -398,6 +398,14 @@ export const v54Schema = {
}, },
} }
export const v55Schema = {
...v54Schema,
behaviorHistory: {
hasViewedReviewScreen: false,
hasSubmittedHoldToSwap: false,
},
}
// TODO: [MOB-201] use function with typed output when API reducers are removed from rootReducer // TODO: [MOB-201] use function with typed output when API reducers are removed from rootReducer
// export const getSchema = (): RootState => v0Schema // export const getSchema = (): RootState => v0Schema
export const getSchema = (): typeof v54Schema => v54Schema export const getSchema = (): typeof v54Schema => v54Schema
...@@ -55,6 +55,7 @@ const rtkQueryErrorLogger: Middleware = () => (next) => (action: PayloadAction<u ...@@ -55,6 +55,7 @@ const rtkQueryErrorLogger: Middleware = () => (next) => (action: PayloadAction<u
const whitelist: Array<ReducerNames | RootReducerNames> = [ const whitelist: Array<ReducerNames | RootReducerNames> = [
'appearanceSettings', 'appearanceSettings',
'behaviorHistory',
'biometricSettings', 'biometricSettings',
'favorites', 'favorites',
'notifications', 'notifications',
...@@ -74,7 +75,7 @@ export const persistConfig = { ...@@ -74,7 +75,7 @@ export const persistConfig = {
key: 'root', key: 'root',
storage: reduxStorage, storage: reduxStorage,
whitelist, whitelist,
version: 54, version: 55,
migrate: createMigrate(migrations), migrate: createMigrate(migrations),
} }
......
...@@ -17,10 +17,10 @@ import { TextLoaderWrapper } from 'ui/src/components/text/Text' ...@@ -17,10 +17,10 @@ import { TextLoaderWrapper } from 'ui/src/components/text/Text'
import { fonts } from 'ui/src/theme' import { fonts } from 'ui/src/theme'
import { usePrevious } from 'utilities/src/react/hooks' import { usePrevious } from 'utilities/src/react/hooks'
const NUMBER_ARRAY = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'] export const NUMBER_ARRAY = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
const NUMBER_WIDTH_ARRAY = [29, 20, 29, 29, 29, 29, 29, 29, 29, 29] // width of digits in a font export const NUMBER_WIDTH_ARRAY = [29, 20, 29, 29, 29, 29, 29, 29, 29, 29] // width of digits in a font
const DIGIT_HEIGHT = 44 export const DIGIT_HEIGHT = 44
const ADDITIONAL_WIDTH_FOR_ANIMATIONS = 10 export const ADDITIONAL_WIDTH_FOR_ANIMATIONS = 8
// TODO: remove need to manually define width of each character // TODO: remove need to manually define width of each character
const NUMBER_WIDTH_ARRAY_SCALED = NUMBER_WIDTH_ARRAY.map( const NUMBER_WIDTH_ARRAY_SCALED = NUMBER_WIDTH_ARRAY.map(
...@@ -171,6 +171,27 @@ function longestCommonPrefix(a: string, b: string): string { ...@@ -171,6 +171,27 @@ function longestCommonPrefix(a: string, b: string): string {
return a.substr(0, i) return a.substr(0, i)
} }
export const TopAndBottomGradient = (): JSX.Element => {
const colors = useSporeColors()
return (
<Svg height={DIGIT_HEIGHT} style={AnimatedNumberStyles.gradientStyle} width="100%">
<Defs>
<LinearGradient id="backgroundTop" x1="0%" x2="0%" y1="15%" y2="0%">
<Stop offset="0" stopColor={colors.surface1.val} stopOpacity="0" />
<Stop offset="1" stopColor={colors.surface1.val} stopOpacity="1" />
</LinearGradient>
<LinearGradient id="background" x1="0%" x2="0%" y1="85%" y2="100%">
<Stop offset="0" stopColor={colors.surface1.val} stopOpacity="0" />
<Stop offset="1" stopColor={colors.surface1.val} stopOpacity="1" />
</LinearGradient>
</Defs>
<Rect fill="url(#backgroundTop)" height={DIGIT_HEIGHT} opacity={1} width="100%" x="0" y="0" />
<Rect fill="url(#background)" height={DIGIT_HEIGHT} opacity={1} width="100%" x="0" y="0" />
</Svg>
)
}
const SCREEN_WIDTH_BUFFER = 50 const SCREEN_WIDTH_BUFFER = 50
// Used for initial layout larger than all screen sizes // Used for initial layout larger than all screen sizes
...@@ -274,34 +295,7 @@ const AnimatedNumber = ({ ...@@ -274,34 +295,7 @@ const AnimatedNumber = ({
backgroundColor="$surface1" backgroundColor="$surface1"
borderRadius="$rounded4" borderRadius="$rounded4"
width={MAX_DEVICE_WIDTH}> width={MAX_DEVICE_WIDTH}>
<Svg height={DIGIT_HEIGHT} style={AnimatedNumberStyles.gradientStyle} width="100%"> <TopAndBottomGradient />
<Defs>
<LinearGradient id="backgroundTop" x1="0%" x2="0%" y1="15%" y2="0%">
<Stop offset="0" stopColor={colors.surface1.val} stopOpacity="0" />
<Stop offset="1" stopColor={colors.surface1.val} stopOpacity="1" />
</LinearGradient>
<LinearGradient id="background" x1="0%" x2="0%" y1="85%" y2="100%">
<Stop offset="0" stopColor={colors.surface1.val} stopOpacity="0" />
<Stop offset="1" stopColor={colors.surface1.val} stopOpacity="1" />
</LinearGradient>
</Defs>
<Rect
fill="url(#backgroundTop)"
height={DIGIT_HEIGHT}
opacity={1}
width="100%"
x="0"
y="0"
/>
<Rect
fill="url(#background)"
height={DIGIT_HEIGHT}
opacity={1}
width="100%"
x="0"
y="0"
/>
</Svg>
<Shine disabled={!warmLoading}> <Shine disabled={!warmLoading}>
<AnimatedFlex row entering={FadeIn} width={MAX_DEVICE_WIDTH}> <AnimatedFlex row entering={FadeIn} width={MAX_DEVICE_WIDTH}>
{chars?.map((_, index) => ( {chars?.map((_, index) => (
...@@ -331,24 +325,24 @@ const AnimatedNumber = ({ ...@@ -331,24 +325,24 @@ const AnimatedNumber = ({
export default AnimatedNumber export default AnimatedNumber
const AnimatedNumberStyles = StyleSheet.create({ export const AnimatedNumberStyles = StyleSheet.create({
gradientStyle: { gradientStyle: {
position: 'absolute', position: 'absolute',
zIndex: 100, zIndex: 100,
}, },
}) })
const AnimatedCharStyles = StyleSheet.create({ export const AnimatedCharStyles = StyleSheet.create({
wrapperStyle: { wrapperStyle: {
overflow: 'hidden', overflow: 'hidden',
}, },
}) })
const AnimatedFontStyles = StyleSheet.create({ export const AnimatedFontStyles = StyleSheet.create({
fontStyle: { fontStyle: {
fontFamily: fonts.heading2.family, fontFamily: fonts.heading2.family,
fontSize: fonts.heading2.fontSize, fontSize: fonts.heading2.fontSize,
fontWeight: 500, fontWeight: '500',
lineHeight: fonts.heading2.lineHeight, lineHeight: fonts.heading2.lineHeight,
top: 1, top: 1,
}, },
......
import { ImpactFeedbackStyle } from 'expo-haptics' import { ImpactFeedbackStyle } from 'expo-haptics'
import React, { memo, useMemo } from 'react' import { memo, useEffect, useMemo, useState } from 'react'
import { I18nManager } from 'react-native' import { I18nManager } from 'react-native'
import { SharedValue } from 'react-native-reanimated' import { SharedValue } from 'react-native-reanimated'
import { import { LineChart, LineChartProvider, TLineChartDataProp } from 'react-native-wagmi-charts'
LineChart,
LineChartProvider,
TLineChartData,
TLineChartDataProp,
} from 'react-native-wagmi-charts'
import { Loader } from 'src/components/loading' import { Loader } from 'src/components/loading'
import { CURSOR_INNER_SIZE, CURSOR_SIZE } from 'src/components/PriceExplorer/constants' import { CURSOR_INNER_SIZE, CURSOR_SIZE } from 'src/components/PriceExplorer/constants'
import PriceExplorerAnimatedNumber from 'src/components/PriceExplorer/PriceExplorerAnimatedNumber'
import { PriceExplorerError } from 'src/components/PriceExplorer/PriceExplorerError' import { PriceExplorerError } from 'src/components/PriceExplorer/PriceExplorerError'
import { DatetimeText, PriceText, RelativeChangeText } from 'src/components/PriceExplorer/Text' import { DatetimeText, RelativeChangeText } from 'src/components/PriceExplorer/Text'
import { TimeRangeGroup } from 'src/components/PriceExplorer/TimeRangeGroup' import { TimeRangeGroup } from 'src/components/PriceExplorer/TimeRangeGroup'
import { useChartDimensions } from 'src/components/PriceExplorer/useChartDimensions' import { useChartDimensions } from 'src/components/PriceExplorer/useChartDimensions'
import { useLineChartPrice } from 'src/components/PriceExplorer/usePrice'
import { invokeImpact } from 'src/utils/haptic' import { invokeImpact } from 'src/utils/haptic'
import { Flex, useDeviceDimensions } from 'ui/src' import { Flex } from 'ui/src'
import { spacing } from 'ui/src/theme' import { spacing } from 'ui/src/theme'
import { HistoryDuration } from 'wallet/src/data/__generated__/types-and-hooks' import { HistoryDuration } from 'wallet/src/data/__generated__/types-and-hooks'
import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
import { CurrencyId } from 'wallet/src/utils/currencyId' import { CurrencyId } from 'wallet/src/utils/currencyId'
import { TokenSpotData, useTokenPriceHistory } from './usePriceHistory' import { PriceNumberOfDigits, TokenSpotData, useTokenPriceHistory } from './usePriceHistory'
type PriceTextProps = { type PriceTextProps = {
loading: boolean loading: boolean
relativeChange?: SharedValue<number> relativeChange?: SharedValue<number>
numberOfDigits: PriceNumberOfDigits
} }
function PriceTextSection({ loading, relativeChange }: PriceTextProps): JSX.Element { function PriceTextSection({
const { fullWidth } = useDeviceDimensions() loading,
relativeChange,
numberOfDigits,
}: PriceTextProps): JSX.Element {
const price = useLineChartPrice()
const mx = spacing.spacing12 const mx = spacing.spacing12
return ( return (
...@@ -36,7 +38,7 @@ function PriceTextSection({ loading, relativeChange }: PriceTextProps): JSX.Elem ...@@ -36,7 +38,7 @@ function PriceTextSection({ loading, relativeChange }: PriceTextProps): JSX.Elem
{/* Specify maxWidth to allow text scalling. onLayout was sometimes called after more {/* 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 than 5 seconds which is not acceptable so we have to provide the approximate width
of the PriceText component explicitly. */} of the PriceText component explicitly. */}
<PriceText loading={loading} maxWidth={fullWidth - 2 * mx} /> <PriceExplorerAnimatedNumber numberOfDigits={numberOfDigits} price={price} />
<Flex row gap="$spacing4"> <Flex row gap="$spacing4">
<RelativeChangeText loading={loading} spotRelativeChange={relativeChange} /> <RelativeChangeText loading={loading} spotRelativeChange={relativeChange} />
<DatetimeText loading={loading} /> <DatetimeText loading={loading} />
...@@ -60,22 +62,36 @@ export const PriceExplorer = memo(function PriceExplorer({ ...@@ -60,22 +62,36 @@ export const PriceExplorer = memo(function PriceExplorer({
forcePlaceholder?: boolean forcePlaceholder?: boolean
onRetry: () => void onRetry: () => void
}): JSX.Element { }): JSX.Element {
const { data, loading, error, refetch, setDuration, selectedDuration } = const [fetchComplete, setFetchComplete] = useState(false)
useTokenPriceHistory(currencyId) const onFetchComplete = (): void => {
setFetchComplete(true)
}
const { data, loading, error, refetch, setDuration, selectedDuration, numberOfDigits } =
useTokenPriceHistory(currencyId, onFetchComplete)
useEffect(() => {
if (loading && fetchComplete) {
setFetchComplete(false)
}
}, [loading, fetchComplete])
const { convertFiatAmount } = useLocalizationContext() const { convertFiatAmount } = useLocalizationContext()
const conversionRate = convertFiatAmount().amount const conversionRate = convertFiatAmount().amount
const shouldShowAnimatedDot = const shouldShowAnimatedDot =
selectedDuration === HistoryDuration.Day || selectedDuration === HistoryDuration.Hour selectedDuration === HistoryDuration.Day || selectedDuration === HistoryDuration.Hour
const additionalPadding = shouldShowAnimatedDot ? 40 : 0 const additionalPadding = shouldShowAnimatedDot ? 40 : 0
const lastPricePoint = data?.priceHistory ? data.priceHistory.length - 1 : 0
const convertedPriceHistory = useMemo( const { lastPricePoint, convertedPriceHistory } = useMemo(() => {
(): TLineChartData | undefined => const priceHistory = data?.priceHistory?.map((point) => {
data?.priceHistory?.map((point) => {
return { ...point, value: point.value * conversionRate } return { ...point, value: point.value * conversionRate }
}), })
[data, conversionRate]
) const lastPoint = priceHistory ? priceHistory.length - 1 : 0
return { lastPricePoint: lastPoint, convertedPriceHistory: priceHistory }
}, [data, conversionRate])
const convertedSpot = useMemo((): TokenSpotData | undefined => { const convertedSpot = useMemo((): TokenSpotData | undefined => {
return ( return (
data?.spot && { data?.spot && {
...@@ -99,21 +115,26 @@ export const PriceExplorer = memo(function PriceExplorer({ ...@@ -99,21 +115,26 @@ export const PriceExplorer = memo(function PriceExplorer({
let content: JSX.Element | null let content: JSX.Element | null
if (forcePlaceholder) { if (forcePlaceholder) {
content = <PriceExplorerPlaceholder loading={forcePlaceholder} /> content = (
<PriceExplorerPlaceholder loading={forcePlaceholder} numberOfDigits={numberOfDigits} />
)
} else if (convertedPriceHistory?.length) { } else if (convertedPriceHistory?.length) {
content = ( content = (
<Flex opacity={fetchComplete ? 1 : 0.35}>
<PriceExplorerChart <PriceExplorerChart
additionalPadding={additionalPadding} additionalPadding={additionalPadding}
lastPricePoint={lastPricePoint} lastPricePoint={lastPricePoint}
loading={loading} loading={loading}
numberOfDigits={numberOfDigits}
priceHistory={convertedPriceHistory} priceHistory={convertedPriceHistory}
shouldShowAnimatedDot={shouldShowAnimatedDot} shouldShowAnimatedDot={shouldShowAnimatedDot}
spot={convertedSpot} spot={convertedSpot}
tokenColor={tokenColor} tokenColor={tokenColor}
/> />
</Flex>
) )
} else { } else {
content = <PriceExplorerPlaceholder loading={loading} /> content = <PriceExplorerPlaceholder loading={loading} numberOfDigits={numberOfDigits} />
} }
return ( return (
...@@ -124,10 +145,16 @@ export const PriceExplorer = memo(function PriceExplorer({ ...@@ -124,10 +145,16 @@ export const PriceExplorer = memo(function PriceExplorer({
) )
}) })
function PriceExplorerPlaceholder({ loading }: { loading: boolean }): JSX.Element { function PriceExplorerPlaceholder({
loading,
numberOfDigits,
}: {
loading: boolean
numberOfDigits: PriceNumberOfDigits
}): JSX.Element {
return ( return (
<Flex gap="$spacing8"> <Flex gap="$spacing8">
<PriceTextSection loading={loading} /> <PriceTextSection loading={loading} numberOfDigits={numberOfDigits} />
<Flex my="$spacing24"> <Flex my="$spacing24">
<Loader.Graph /> <Loader.Graph />
</Flex> </Flex>
...@@ -143,6 +170,7 @@ function PriceExplorerChart({ ...@@ -143,6 +170,7 @@ function PriceExplorerChart({
additionalPadding, additionalPadding,
shouldShowAnimatedDot, shouldShowAnimatedDot,
lastPricePoint, lastPricePoint,
numberOfDigits,
}: { }: {
priceHistory: TLineChartDataProp priceHistory: TLineChartDataProp
spot?: TokenSpotData spot?: TokenSpotData
...@@ -151,6 +179,7 @@ function PriceExplorerChart({ ...@@ -151,6 +179,7 @@ function PriceExplorerChart({
additionalPadding: number additionalPadding: number
shouldShowAnimatedDot: boolean shouldShowAnimatedDot: boolean
lastPricePoint: number lastPricePoint: number
numberOfDigits: PriceNumberOfDigits
}): JSX.Element { }): JSX.Element {
const { chartHeight, chartWidth } = useChartDimensions() const { chartHeight, chartWidth } = useChartDimensions()
const isRTL = I18nManager.isRTL const isRTL = I18nManager.isRTL
...@@ -160,7 +189,11 @@ function PriceExplorerChart({ ...@@ -160,7 +189,11 @@ function PriceExplorerChart({
data={priceHistory} data={priceHistory}
onCurrentIndexChange={invokeImpact[ImpactFeedbackStyle.Light]}> onCurrentIndexChange={invokeImpact[ImpactFeedbackStyle.Light]}>
<Flex gap="$spacing8"> <Flex gap="$spacing8">
<PriceTextSection loading={loading} relativeChange={spot?.relativeChange} /> <PriceTextSection
loading={loading}
numberOfDigits={numberOfDigits}
relativeChange={spot?.relativeChange}
/>
{/* TODO(MOB-2166): remove forced LTR direction + scaleX horizontal flip technique once react-native-wagmi-charts fixes this: https://github.com/coinjar/react-native-wagmi-charts/issues/136 */} {/* TODO(MOB-2166): remove forced LTR direction + scaleX horizontal flip technique once react-native-wagmi-charts fixes this: https://github.com/coinjar/react-native-wagmi-charts/issues/136 */}
<Flex direction="ltr" my="$spacing24" style={{ transform: [{ scaleX: isRTL ? -1 : 1 }] }}> <Flex direction="ltr" my="$spacing24" style={{ transform: [{ scaleX: isRTL ? -1 : 1 }] }}>
<LineChart height={chartHeight} width={chartWidth - additionalPadding} yGutter={20}> <LineChart height={chartHeight} width={chartWidth - additionalPadding} yGutter={20}>
......
import _ from 'lodash'
import React, { useEffect, useState } from 'react'
import { StyleSheet, Text, View } from 'react-native'
import Animated, {
SharedValue,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated'
import {
ADDITIONAL_WIDTH_FOR_ANIMATIONS,
AnimatedCharStyles,
AnimatedFontStyles,
DIGIT_HEIGHT,
NUMBER_ARRAY,
NUMBER_WIDTH_ARRAY,
TopAndBottomGradient,
} from 'src/components/AnimatedNumber'
import { ValueAndFormatted } from 'src/components/PriceExplorer/usePrice'
import { PriceNumberOfDigits } from 'src/components/PriceExplorer/usePriceHistory'
import { useSporeColors } from 'ui/src'
import { TextLoaderWrapper } from 'ui/src/components/text/Text'
const NumbersMain = ({
color,
backgroundColor,
hidePlacehodler,
}: {
color: string
backgroundColor: string
hidePlacehodler(): void
}): JSX.Element | null => {
const [showNumers, setShowNumbers] = useState(false)
const hideNumbers = useSharedValue(true)
const animatedTextStyle = useAnimatedStyle(() => {
return {
opacity: hideNumbers.value ? 0 : 1,
}
})
useEffect(() => {
setTimeout(() => {
setShowNumbers(true)
}, 200)
}, [])
const onLayout = (): void => {
hidePlacehodler()
hideNumbers.value = false
}
if (showNumers) {
return (
<Animated.Text
allowFontScaling={false}
style={[
AnimatedFontStyles.fontStyle,
{
height: DIGIT_HEIGHT * 10,
color,
backgroundColor,
},
animatedTextStyle,
]}
onLayout={onLayout}>
{NUMBER_ARRAY}
</Animated.Text>
)
}
return null
}
const MemoizedNumbers = React.memo(NumbersMain)
const RollNumber = ({
chars,
index,
shouldAnimate,
decimalPlace,
hidePlacehodler,
commaIndex,
}: {
chars: SharedValue<string>
index: number
shouldAnimate: SharedValue<boolean>
decimalPlace: SharedValue<number>
hidePlacehodler(): void
commaIndex: number
}): JSX.Element => {
const colors = useSporeColors()
const animatedDigit = useDerivedValue(() => {
return chars.value[index - (commaIndex - decimalPlace.value)]
}, [chars])
const animatedFontStyle = useAnimatedStyle(() => {
const color = index >= commaIndex ? colors.neutral3.val : colors.neutral1.val
return {
color,
}
})
const transformY = useDerivedValue(() => {
const endValue =
animatedDigit.value && Number(animatedDigit.value) >= 0
? DIGIT_HEIGHT * -animatedDigit.value
: 0
return shouldAnimate.value
? withSpring(endValue, {
mass: 1,
damping: 29,
stiffness: 164,
overshootClamping: false,
restDisplacementThreshold: 0.01,
restSpeedThreshold: 2,
})
: endValue
}, [shouldAnimate])
const animatedWrapperStyle = useAnimatedStyle(() => {
const rowWidth =
(NUMBER_WIDTH_ARRAY[Number(animatedDigit.value)] || 0) + ADDITIONAL_WIDTH_FOR_ANIMATIONS - 7
return {
transform: [
{
translateY: transformY.value,
},
],
width: shouldAnimate.value ? withTiming(rowWidth) : rowWidth,
}
})
if (index === commaIndex) {
return (
<Animated.Text
allowFontScaling={false}
style={[
animatedFontStyle,
AnimatedFontStyles.fontStyle,
{ height: DIGIT_HEIGHT, backgroundColor: colors.surface1.val },
]}>
.
</Animated.Text>
)
}
if (
(index - commaIndex) % 4 === 0 &&
index - commaIndex < 0 &&
index > commaIndex - decimalPlace.value
) {
return (
<Animated.Text
allowFontScaling={false}
style={[
animatedFontStyle,
AnimatedFontStyles.fontStyle,
{ height: DIGIT_HEIGHT, backgroundColor: colors.surface1.val },
]}>
,
</Animated.Text>
)
}
return (
<Animated.View
style={[
animatedWrapperStyle,
{
marginRight: -ADDITIONAL_WIDTH_FOR_ANIMATIONS,
},
]}>
<MemoizedNumbers
backgroundColor={colors.surface1.val}
color={index >= commaIndex ? colors.neutral3.val : colors.neutral1.val}
hidePlacehodler={hidePlacehodler}
/>
</Animated.View>
)
}
const Numbers = ({
price,
hidePlacehodler,
numberOfDigits,
}: {
price: ValueAndFormatted
hidePlacehodler(): void
numberOfDigits: PriceNumberOfDigits
}): JSX.Element[] => {
const priceLength = useSharedValue(0)
const chars = useDerivedValue(() => {
priceLength.value = price.formatted.value.length
return price.formatted.value
}, [price])
const decimalPlace = useDerivedValue(() => {
return price.formatted.value.indexOf('.')
}, [price])
return _.times(
numberOfDigits.left + numberOfDigits.right + Math.floor(numberOfDigits.left / 3) + 1,
(index) => (
<Animated.View style={[{ height: DIGIT_HEIGHT }, AnimatedCharStyles.wrapperStyle]}>
<RollNumber
key={index === 0 ? `$sign` : `$_number_${numberOfDigits.left - 1 - index}`}
chars={chars}
commaIndex={numberOfDigits.left + Math.floor(numberOfDigits.left / 3)}
decimalPlace={decimalPlace}
hidePlacehodler={hidePlacehodler}
index={index}
shouldAnimate={price.shouldAnimate}
/>
</Animated.View>
)
)
}
const LoadingWrapper = (): JSX.Element | null => {
return (
<TextLoaderWrapper loadingShimmer={false}>
<View style={Shimmer.shimmerSize} />
</TextLoaderWrapper>
)
}
const PriceExplorerAnimatedNumber = ({
price,
numberOfDigits,
}: {
price: ValueAndFormatted
numberOfDigits: PriceNumberOfDigits
}): JSX.Element => {
const colors = useSporeColors()
const hideShimmer = useSharedValue(false)
const animatedWrapperStyle = useAnimatedStyle(() => {
return {
opacity: price.value.value > 0 && hideShimmer.value ? 0 : 1,
position: 'absolute',
zIndex: 1000,
backgroundColor: colors.surface1.val,
}
})
const hidePlacehodler = (): void => {
hideShimmer.value = true
}
return (
<>
<Animated.View style={animatedWrapperStyle}>
<LoadingWrapper />
</Animated.View>
<View style={RowWrapper.wrapperStyle}>
<TopAndBottomGradient />
<Text
allowFontScaling={false}
style={[
AnimatedFontStyles.fontStyle,
{ height: DIGIT_HEIGHT, color: colors.neutral1.val },
]}>
$
</Text>
{Numbers({ price, hidePlacehodler, numberOfDigits })}
</View>
</>
)
}
export default PriceExplorerAnimatedNumber
export const RowWrapper = StyleSheet.create({
wrapperStyle: {
flexDirection: 'row',
},
})
export const Shimmer = StyleSheet.create({
shimmerSize: {
height: DIGIT_HEIGHT,
width: 200,
},
})
...@@ -27,9 +27,10 @@ export function PriceText({ ...@@ -27,9 +27,10 @@ export function PriceText({
(currency === FiatCurrency.UnitedStatesDollar || currency === FiatCurrency.Euro) && (currency === FiatCurrency.UnitedStatesDollar || currency === FiatCurrency.Euro) &&
symbolAtFront symbolAtFront
if (loading) { // TODO(MOB-2308): re-enable this when we have a better solution for handling the loading state
return <AnimatedText loading loadingPlaceholderText="$10,000" variant="heading1" /> // if (loading) {
} // return <AnimatedText loading loadingPlaceholderText="$10,000" variant="heading1" />
// }
return ( return (
<AnimatedDecimalNumber <AnimatedDecimalNumber
......
...@@ -33,50 +33,24 @@ exports[`DatetimeText renders without error 1`] = ` ...@@ -33,50 +33,24 @@ exports[`DatetimeText renders without error 1`] = `
exports[`PriceText renders loading state 1`] = ` exports[`PriceText renders loading state 1`] = `
<View <View
onLayout={[Function]}
style={ style={
{ {
"alignItems": "stretch", "alignItems": "stretch",
"flexDirection": "column",
"opacity": 0,
}
}
>
<View
style={
{
"alignItems": "center",
"flexDirection": "row",
}
}
>
<View
style={
{
"alignItems": "center",
"flexDirection": "row", "flexDirection": "row",
"position": "relative",
} }
} }
> testID="price-text"
<View >
accessibilityElementsHidden={true} <TextInput
importantForAccessibility="no-hide-descendants" allowFontScaling={true}
> animatedProps={
<View
style={
{ {
"alignItems": "stretch", "text": "-",
"flexDirection": "row",
} }
} }
>
<TextInput
allowFontScaling={true}
editable={false} editable={false}
maxFontSizeMultiplier={1.2} maxFontSizeMultiplier={1.2}
style={ style={
[
[ [
{ {
"padding": 0, "padding": 0,
...@@ -86,62 +60,20 @@ exports[`PriceText renders loading state 1`] = ` ...@@ -86,62 +60,20 @@ exports[`PriceText renders loading state 1`] = `
"fontSize": 53, "fontSize": 53,
"lineHeight": 60, "lineHeight": 60,
}, },
undefined,
],
{
"marginHorizontal": 0,
"opacity": 0,
"paddingHorizontal": 0,
"width": 0,
},
]
}
underlineColorAndroid="transparent"
/>
<Text
style={
[
[ [
{ {
"padding": 0, "color": "#222222",
}, },
{ {
"fontFamily": "Basel-Book", "fontSize": 106,
"fontSize": 53,
"lineHeight": 60,
}, },
undefined,
], ],
{
"opacity": 0,
},
] ]
} }
> testID="wholePart"
$10,000 underlineColorAndroid="transparent"
</Text> value="-"
</View>
</View>
<View
style={
{
"alignItems": "stretch",
"backgroundColor": "#F9F9F9",
"borderBottomLeftRadius": 4,
"borderBottomRightRadius": 4,
"borderTopLeftRadius": 4,
"borderTopRightRadius": 4,
"bottom": "5%",
"flexDirection": "column",
"left": 0,
"position": "absolute",
"right": 0,
"top": "5%",
}
}
/> />
</View>
</View>
</View> </View>
`; `;
......
import { useMemo } from 'react' import { useMemo } from 'react'
import { SharedValue, useDerivedValue } from 'react-native-reanimated' import {
SharedValue,
useAnimatedReaction,
useDerivedValue,
useSharedValue,
} from 'react-native-reanimated'
import { import {
useLineChart, useLineChart,
useLineChartPrice as useRNWagmiChartLineChartPrice, useLineChartPrice as useRNWagmiChartLineChartPrice,
...@@ -8,9 +13,10 @@ import { numberToLocaleStringWorklet, numberToPercentWorklet } from 'src/utils/r ...@@ -8,9 +13,10 @@ import { numberToLocaleStringWorklet, numberToPercentWorklet } from 'src/utils/r
import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks' import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks'
import { useCurrentLocale } from 'wallet/src/features/language/hooks' import { useCurrentLocale } from 'wallet/src/features/language/hooks'
export type ValueAndFormatted<U = number, V = string> = { export type ValueAndFormatted<U = number, V = string, B = boolean> = {
value: Readonly<SharedValue<U>> value: Readonly<SharedValue<U>>
formatted: Readonly<SharedValue<V>> formatted: Readonly<SharedValue<V>>
shouldAnimate: Readonly<SharedValue<B>>
} }
/** /**
...@@ -23,6 +29,18 @@ export function useLineChartPrice(): ValueAndFormatted { ...@@ -23,6 +29,18 @@ export function useLineChartPrice(): ValueAndFormatted {
precision: 18, precision: 18,
}) })
const { data } = useLineChart() const { data } = useLineChart()
const shouldAnimate = useSharedValue(true)
useAnimatedReaction(
() => {
return activeCursorPrice.value
},
(currentValue, previousValue) => {
if (previousValue && currentValue && shouldAnimate.value) {
shouldAnimate.value = false
}
}
)
const currencyInfo = useAppFiatCurrencyInfo() const currencyInfo = useAppFiatCurrencyInfo()
const locale = useCurrentLocale() const locale = useCurrentLocale()
...@@ -32,6 +50,7 @@ export function useLineChartPrice(): ValueAndFormatted { ...@@ -32,6 +50,7 @@ export function useLineChartPrice(): ValueAndFormatted {
return Number(activeCursorPrice.value) return Number(activeCursorPrice.value)
} }
shouldAnimate.value = true
return data[data.length - 1]?.value ?? 0 return data[data.length - 1]?.value ?? 0
}) })
const priceFormatted = useDerivedValue(() => { const priceFormatted = useDerivedValue(() => {
...@@ -50,8 +69,9 @@ export function useLineChartPrice(): ValueAndFormatted { ...@@ -50,8 +69,9 @@ export function useLineChartPrice(): ValueAndFormatted {
() => ({ () => ({
value: price, value: price,
formatted: priceFormatted, formatted: priceFormatted,
shouldAnimate,
}), }),
[price, priceFormatted] [price, priceFormatted, shouldAnimate]
) )
} }
...@@ -65,6 +85,7 @@ export function useLineChartRelativeChange({ ...@@ -65,6 +85,7 @@ export function useLineChartRelativeChange({
spotRelativeChange?: SharedValue<number> spotRelativeChange?: SharedValue<number>
}): ValueAndFormatted { }): ValueAndFormatted {
const { currentIndex, data, isActive } = useLineChart() const { currentIndex, data, isActive } = useLineChart()
const shouldAnimate = useSharedValue(false)
const relativeChange = useDerivedValue(() => { const relativeChange = useDerivedValue(() => {
if (!isActive.value && Boolean(spotRelativeChange)) { if (!isActive.value && Boolean(spotRelativeChange)) {
...@@ -98,5 +119,5 @@ export function useLineChartRelativeChange({ ...@@ -98,5 +119,5 @@ export function useLineChartRelativeChange({
return numberToPercentWorklet(relativeChange.value, { precision: 2, absolute: true }) return numberToPercentWorklet(relativeChange.value, { precision: 2, absolute: true })
}) })
return { value: relativeChange, formatted: relativeChangeFormattted } return { value: relativeChange, formatted: relativeChangeFormattted, shouldAnimate }
} }
import { maxBy } from 'lodash'
import { Dispatch, SetStateAction, useCallback, useMemo, useState } from 'react' import { Dispatch, SetStateAction, useCallback, useMemo, useState } from 'react'
import { SharedValue } from 'react-native-reanimated' import { SharedValue } from 'react-native-reanimated'
import { TLineChartData } from 'react-native-wagmi-charts' import { TLineChartData } from 'react-native-wagmi-charts'
...@@ -16,11 +17,17 @@ export type TokenSpotData = { ...@@ -16,11 +17,17 @@ export type TokenSpotData = {
relativeChange: SharedValue<number> relativeChange: SharedValue<number>
} }
export type PriceNumberOfDigits = {
left: number
right: number
}
/** /**
* @returns Token price history for requested duration * @returns Token price history for requested duration
*/ */
export function useTokenPriceHistory( export function useTokenPriceHistory(
currencyId: string, currencyId: string,
onCompleted?: () => void,
initialDuration: HistoryDuration = HistoryDuration.Day initialDuration: HistoryDuration = HistoryDuration.Day
): Omit< ): Omit<
GqlResult<{ GqlResult<{
...@@ -32,6 +39,7 @@ export function useTokenPriceHistory( ...@@ -32,6 +39,7 @@ export function useTokenPriceHistory(
setDuration: Dispatch<SetStateAction<HistoryDuration>> setDuration: Dispatch<SetStateAction<HistoryDuration>>
selectedDuration: HistoryDuration selectedDuration: HistoryDuration
error: boolean error: boolean
numberOfDigits: PriceNumberOfDigits
} { } {
const [duration, setDuration] = useState(initialDuration) const [duration, setDuration] = useState(initialDuration)
...@@ -46,7 +54,9 @@ export function useTokenPriceHistory( ...@@ -46,7 +54,9 @@ export function useTokenPriceHistory(
}, },
notifyOnNetworkStatusChange: true, notifyOnNetworkStatusChange: true,
pollInterval: PollingInterval.Normal, pollInterval: PollingInterval.Normal,
fetchPolicy: 'cache-first', onCompleted,
// TODO(MOB-2308): maybe update to network-only once we have a better loading state
fetchPolicy: 'cache-and-network',
}) })
const offChainData = priceData?.tokenProjects?.[0]?.markets?.[0] const offChainData = priceData?.tokenProjects?.[0]?.markets?.[0]
...@@ -73,13 +83,24 @@ export function useTokenPriceHistory( ...@@ -73,13 +83,24 @@ export function useTokenPriceHistory(
?.filter((x): x is TimestampedAmount => Boolean(x)) ?.filter((x): x is TimestampedAmount => Boolean(x))
.map((x) => ({ timestamp: x.timestamp * 1000, value: x.value })) .map((x) => ({ timestamp: x.timestamp * 1000, value: x.value }))
// adds the current price to the chart given we show spot price/24h change return formatted
if (formatted && spot?.value) { }, [priceHistory])
formatted?.push({ timestamp: Date.now(), value: spot.value.value })
const numberOfDigits = useMemo(() => {
const max = maxBy(priceHistory, 'value')
if (max) {
return {
left: String(max.value).split('.')[0]?.length || 10,
right: Number(String(max.value).split('.')[0]) > 0 ? 2 : 10,
}
} }
return formatted return {
}, [priceHistory, spot?.value]) left: 0,
right: 0,
}
}, [priceHistory])
const retry = useCallback(async () => { const retry = useCallback(async () => {
await refetch({ contract: currencyIdToContractInput(currencyId) }) await refetch({ contract: currencyIdToContractInput(currencyId) })
...@@ -96,7 +117,18 @@ export function useTokenPriceHistory( ...@@ -96,7 +117,18 @@ export function useTokenPriceHistory(
refetch: retry, refetch: retry,
setDuration, setDuration,
selectedDuration: duration, selectedDuration: duration,
numberOfDigits,
onCompleted,
}), }),
[duration, formattedPriceHistory, networkStatus, priceData, retry, spot] [
duration,
formattedPriceHistory,
networkStatus,
priceData,
retry,
spot,
onCompleted,
numberOfDigits,
]
) )
} }
...@@ -29,16 +29,17 @@ export function RecipientScanModal({ onSelectRecipient, onClose }: Props): JSX.E ...@@ -29,16 +29,17 @@ export function RecipientScanModal({ onSelectRecipient, onClose }: Props): JSX.E
const [currentScreenState, setCurrentScreenState] = useState<ScannerModalState>( const [currentScreenState, setCurrentScreenState] = useState<ScannerModalState>(
ScannerModalState.ScanQr ScannerModalState.ScanQr
) )
const [hasScanError, setHasScanError] = useState(false)
const [shouldFreezeCamera, setShouldFreezeCamera] = useState(false) const [shouldFreezeCamera, setShouldFreezeCamera] = useState(false)
const onScanCode = async (uri: string): Promise<void> => { const onScanCode = async (uri: string): Promise<void> => {
// don't scan any QR codes if there is an error popup open or camera is frozen // don't scan any QR codes if camera is frozen
if (hasScanError || shouldFreezeCamera) return if (shouldFreezeCamera) return
await selectionAsync() await selectionAsync()
setShouldFreezeCamera(true)
const supportedURI = await getSupportedURI(uri) const supportedURI = await getSupportedURI(uri)
if (supportedURI?.type === URIType.Address) { if (supportedURI?.type === URIType.Address) {
setShouldFreezeCamera(true)
onSelectRecipient(supportedURI.value) onSelectRecipient(supportedURI.value)
onClose() onClose()
} else { } else {
...@@ -49,7 +50,7 @@ export function RecipientScanModal({ onSelectRecipient, onClose }: Props): JSX.E ...@@ -49,7 +50,7 @@ export function RecipientScanModal({ onSelectRecipient, onClose }: Props): JSX.E
{ {
text: t('Try again'), text: t('Try again'),
onPress: (): void => { onPress: (): void => {
setHasScanError(false) setShouldFreezeCamera(false)
}, },
}, },
] ]
......
import React from 'react' import React, { useMemo } from 'react'
import { ScrollView, StyleSheet } from 'react-native' import { ScrollView, StyleSheet } from 'react-native'
import { useAccountList } from 'src/components/accounts/hooks'
import { AddressDisplay } from 'src/components/AddressDisplay' import { AddressDisplay } from 'src/components/AddressDisplay'
import { Flex, Text, useDeviceDimensions } from 'ui/src' import { Flex, Text, useDeviceDimensions } from 'ui/src'
import { spacing } from 'ui/src/theme' import { spacing } from 'ui/src/theme'
import { NumberType } from 'utilities/src/format/types' import { NumberType } from 'utilities/src/format/types'
import { import { AccountListQuery } from 'wallet/src/data/__generated__/types-and-hooks'
AccountListQuery,
useAccountListQuery,
} from 'wallet/src/data/__generated__/types-and-hooks'
import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
import { Account } from 'wallet/src/features/wallet/accounts/types' import { Account } from 'wallet/src/features/wallet/accounts/types'
...@@ -17,10 +15,9 @@ type Portfolio = NonNullable<NonNullable<NonNullable<AccountListQuery['portfolio ...@@ -17,10 +15,9 @@ type Portfolio = NonNullable<NonNullable<NonNullable<AccountListQuery['portfolio
function _AssociatedAccountsList({ accounts }: { accounts: Account[] }): JSX.Element { function _AssociatedAccountsList({ accounts }: { accounts: Account[] }): JSX.Element {
const { fullHeight } = useDeviceDimensions() const { fullHeight } = useDeviceDimensions()
const { data, loading } = useAccountListQuery({ const addresses = useMemo(() => accounts.map((account) => account.address), [accounts])
variables: { const { data, loading } = useAccountList({
addresses: accounts.map((account) => account.address), addresses,
},
notifyOnNetworkStatusChange: true, notifyOnNetworkStatusChange: true,
}) })
......
...@@ -151,7 +151,8 @@ export function RemoveWalletModal(): JSX.Element | null { ...@@ -151,7 +151,8 @@ export function RemoveWalletModal(): JSX.Element | null {
backgroundColor={colors.surface1.get()} backgroundColor={colors.surface1.get()}
name={ModalName.RemoveSeedPhraseWarningModal} name={ModalName.RemoveSeedPhraseWarningModal}
onClose={onClose}> onClose={onClose}>
<Flex centered gap="$spacing16" px="$spacing24" py="$spacing12"> <Flex gap="$spacing24" px="$spacing24" py="$spacing24">
<Flex centered gap="$spacing16">
<Flex <Flex
centered centered
borderRadius="$rounded12" borderRadius="$rounded12"
...@@ -159,14 +160,22 @@ export function RemoveWalletModal(): JSX.Element | null { ...@@ -159,14 +160,22 @@ export function RemoveWalletModal(): JSX.Element | null {
style={{ style={{
backgroundColor: opacify(12, colors[labelColor].val), backgroundColor: opacify(12, colors[labelColor].val),
}}> }}>
<Icon color={colors[labelColor].val} height={iconSizes.icon24} width={iconSizes.icon24} /> <Icon
color={colors[labelColor].val}
height={iconSizes.icon24}
width={iconSizes.icon24}
/>
</Flex> </Flex>
<Flex gap="$spacing8">
<Text textAlign="center" variant="body1"> <Text textAlign="center" variant="body1">
{title} {title}
</Text> </Text>
<Text color="$neutral2" textAlign="center" variant="body2"> <Text color="$neutral2" textAlign="center" variant="body2">
{description} {description}
</Text> </Text>
</Flex>
</Flex>
<Flex centered gap="$spacing24">
{currentStep === RemoveWalletStep.Final && isRemovingRecoveryPhrase ? ( {currentStep === RemoveWalletStep.Final && isRemovingRecoveryPhrase ? (
<> <>
<AssociatedAccountsList accounts={associatedAccounts} /> <AssociatedAccountsList accounts={associatedAccounts} />
...@@ -200,6 +209,7 @@ export function RemoveWalletModal(): JSX.Element | null { ...@@ -200,6 +209,7 @@ export function RemoveWalletModal(): JSX.Element | null {
</Flex> </Flex>
)} )}
</Flex> </Flex>
</Flex>
</BottomSheetModal> </BottomSheetModal>
) )
} }
...@@ -136,7 +136,7 @@ export const useModalContent = ({ ...@@ -136,7 +136,7 @@ export const useModalContent = ({
description: ( description: (
<Trans t={t}> <Trans t={t}>
It shares the same recovery phrase as{' '} It shares the same recovery phrase as{' '}
<Text fontWeight="bold">{{ wallets: associatedAccountNames }}</Text>. Your recovery <Text color="$neutral1">{{ wallets: associatedAccountNames }}</Text>. Your recovery
phrase will remain stored until you delete all remaining wallets. phrase will remain stored until you delete all remaining wallets.
</Trans> </Trans>
), ),
......
...@@ -26,8 +26,7 @@ export interface SettingsSectionItemComponent { ...@@ -26,8 +26,7 @@ export interface SettingsSectionItemComponent {
component: JSX.Element component: JSX.Element
isHidden?: boolean isHidden?: boolean
} }
type SettingsModal = Extract<ModalName, ModalName.FiatCurrencySelector | ModalName.LanguageSelector>
type SettingsModal = Extract<ModalName, ModalName.FiatCurrencySelector>
export interface SettingsSectionItem { export interface SettingsSectionItem {
screen?: keyof SettingsStackParamList | typeof Screens.OnboardingStack screen?: keyof SettingsStackParamList | typeof Screens.OnboardingStack
modal?: SettingsModal modal?: SettingsModal
......
...@@ -11,15 +11,9 @@ export const TokenBalanceItemContextMenu = memo(function _TokenBalanceItem({ ...@@ -11,15 +11,9 @@ export const TokenBalanceItemContextMenu = memo(function _TokenBalanceItem({
portfolioBalance: PortfolioBalance portfolioBalance: PortfolioBalance
children: React.ReactNode children: React.ReactNode
}) { }) {
const { currencyInfo, balanceUSD } = portfolioBalance
const { currency, currencyId, isSpam } = currencyInfo
const { menuActions, onContextMenuPress } = useTokenContextMenu({ const { menuActions, onContextMenuPress } = useTokenContextMenu({
currencyId, currencyId: portfolioBalance.currencyInfo.currencyId,
isSpam, portfolioBalance,
balanceUSD,
isNative: currency.isNative,
accountHoldsToken: true,
}) })
const style = useMemo(() => ({ borderRadius: borderRadii.rounded16 }), []) const style = useMemo(() => ({ borderRadius: borderRadii.rounded16 }), [])
......
...@@ -271,7 +271,14 @@ const TokenBalanceItemRow = memo(function TokenBalanceItemRow({ ...@@ -271,7 +271,14 @@ const TokenBalanceItemRow = memo(function TokenBalanceItemRow({
const portfolioBalance = balancesById?.[item] const portfolioBalance = balancesById?.[item]
if (!portfolioBalance) { if (!portfolioBalance) {
throw new Error(`portfolioBalance not found in balancesById for currencyId "${item}"`) // This can happen when the view is out of focus and the user sells/sends 100% of a token's balance.
// In that case, the token is removed from the `balancesById` object, but the FlatList is still using the cached array of IDs until the view comes back into focus.
// As soon as the view comes back into focus, the FlatList will re-render with the latest data, so users won't really see this Skeleton for more than a few milliseconds when this happens.
return (
<Flex height={ESTIMATED_TOKEN_ITEM_HEIGHT} px="$spacing24">
<Loader.Token />
</Flex>
)
} }
return ( return (
......
import { NetworkStatus } from '@apollo/client'
import { isEqual } from 'lodash' import { isEqual } from 'lodash'
import { import {
createContext, createContext,
...@@ -9,18 +10,22 @@ import { ...@@ -9,18 +10,22 @@ import {
useRef, useRef,
useState, useState,
} from 'react' } from 'react'
import { useTokenBalancesGroupedByVisibility } from 'src/features/balances/hooks' import { PollingInterval } from 'wallet/src/constants/misc'
import { isWarmLoadingStatus } from 'wallet/src/data/utils' import { isWarmLoadingStatus } from 'wallet/src/data/utils'
import { usePortfolioBalances } from 'wallet/src/features/dataApi/balances' import {
usePortfolioBalances,
useTokenBalancesGroupedByVisibility,
} from 'wallet/src/features/dataApi/balances'
import { PortfolioBalance } from 'wallet/src/features/dataApi/types'
type CurrencyId = string type CurrencyId = string
export const HIDDEN_TOKEN_BALANCES_ROW = 'HIDDEN_TOKEN_BALANCES_ROW' as const export const HIDDEN_TOKEN_BALANCES_ROW = 'HIDDEN_TOKEN_BALANCES_ROW' as const
export type TokenBalanceListRow = CurrencyId | typeof HIDDEN_TOKEN_BALANCES_ROW export type TokenBalanceListRow = CurrencyId | typeof HIDDEN_TOKEN_BALANCES_ROW
type TokenBalanceListContextState = { type TokenBalanceListContextState = {
balancesById: ReturnType<typeof usePortfolioBalances>['data'] balancesById: Record<string, PortfolioBalance> | undefined
networkStatus: ReturnType<typeof usePortfolioBalances>['networkStatus'] networkStatus: NetworkStatus
refetch: ReturnType<typeof usePortfolioBalances>['refetch'] refetch: (() => void) | undefined
hiddenTokensCount: number hiddenTokensCount: number
hiddenTokensExpanded: boolean hiddenTokensExpanded: boolean
isWarmLoading: boolean isWarmLoading: boolean
...@@ -49,7 +54,7 @@ export function TokenBalanceListContextProvider({ ...@@ -49,7 +54,7 @@ export function TokenBalanceListContextProvider({
refetch, refetch,
} = usePortfolioBalances({ } = usePortfolioBalances({
address: owner, address: owner,
shouldPoll: true, pollInterval: PollingInterval.KindaFast,
fetchPolicy: 'cache-and-network', fetchPolicy: 'cache-and-network',
}) })
......
...@@ -114,7 +114,6 @@ export function TokenDetailsStats({ ...@@ -114,7 +114,6 @@ export function TokenDetailsStats({
offChainData?.markets?.[0]?.priceHigh52W?.value ?? onChainData?.market?.priceHigh52W?.value offChainData?.markets?.[0]?.priceHigh52W?.value ?? onChainData?.market?.priceHigh52W?.value
const priceLow52W = const priceLow52W =
offChainData?.markets?.[0]?.priceLow52W?.value ?? onChainData?.market?.priceLow52W?.value offChainData?.markets?.[0]?.priceLow52W?.value ?? onChainData?.market?.priceLow52W?.value
const currentDescription = const currentDescription =
showTranslation && translatedDescription ? translatedDescription : description showTranslation && translatedDescription ? translatedDescription : description
......
...@@ -4,8 +4,7 @@ import { Flex, Icons, Text, TouchableArea } from 'ui/src' ...@@ -4,8 +4,7 @@ import { Flex, Icons, Text, TouchableArea } from 'ui/src'
import { iconSizes } from 'ui/src/theme' import { iconSizes } from 'ui/src/theme'
import { CurrencyLogo } from 'wallet/src/components/CurrencyLogo/CurrencyLogo' import { CurrencyLogo } from 'wallet/src/components/CurrencyLogo/CurrencyLogo'
import { CurrencyInfo } from 'wallet/src/features/dataApi/types' import { CurrencyInfo } from 'wallet/src/features/dataApi/types'
import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants' import { useSwapRewriteEnabled } from 'wallet/src/features/experiments/hooks'
import { useFeatureFlag } from 'wallet/src/features/experiments/hooks'
import { getSymbolDisplayText } from 'wallet/src/utils/currency' import { getSymbolDisplayText } from 'wallet/src/utils/currency'
interface SelectTokenButtonProps { interface SelectTokenButtonProps {
...@@ -21,7 +20,7 @@ export function SelectTokenButton({ ...@@ -21,7 +20,7 @@ export function SelectTokenButton({
}: SelectTokenButtonProps): JSX.Element { }: SelectTokenButtonProps): JSX.Element {
const { t } = useTranslation() const { t } = useTranslation()
const isSwapRewriteFeatureEnabled = useFeatureFlag(FEATURE_FLAGS.SwapRewrite) const isSwapRewriteFeatureEnabled = useSwapRewriteEnabled()
if (isSwapRewriteFeatureEnabled) { if (isSwapRewriteFeatureEnabled) {
return ( return (
......
...@@ -3,7 +3,6 @@ import { useAppSelector } from 'src/app/hooks' ...@@ -3,7 +3,6 @@ import { useAppSelector } from 'src/app/hooks'
import { filter } from 'src/components/TokenSelector/filter' import { filter } from 'src/components/TokenSelector/filter'
import { TokenOption, TokenSelectorFlow } from 'src/components/TokenSelector/types' import { TokenOption, TokenSelectorFlow } from 'src/components/TokenSelector/types'
import { createEmptyBalanceOption } from 'src/components/TokenSelector/utils' import { createEmptyBalanceOption } from 'src/components/TokenSelector/utils'
import { useTokenBalancesGroupedByVisibility } from 'src/features/balances/hooks'
import { useTokenProjects } from 'src/features/dataApi/tokenProjects' import { useTokenProjects } from 'src/features/dataApi/tokenProjects'
import { usePopularTokens } from 'src/features/dataApi/topTokens' import { usePopularTokens } from 'src/features/dataApi/topTokens'
import { sendMobileAnalyticsEvent } from 'src/features/telemetry' import { sendMobileAnalyticsEvent } from 'src/features/telemetry'
...@@ -11,7 +10,11 @@ import { MobileEventName } from 'src/features/telemetry/constants' ...@@ -11,7 +10,11 @@ import { MobileEventName } from 'src/features/telemetry/constants'
import { BRIDGED_BASE_ADDRESSES } from 'wallet/src/constants/addresses' import { BRIDGED_BASE_ADDRESSES } from 'wallet/src/constants/addresses'
import { ChainId } from 'wallet/src/constants/chains' import { ChainId } from 'wallet/src/constants/chains'
import { DAI, USDC, USDT, WBTC } from 'wallet/src/constants/tokens' import { DAI, USDC, USDT, WBTC } from 'wallet/src/constants/tokens'
import { sortPortfolioBalances, usePortfolioBalances } from 'wallet/src/features/dataApi/balances' import {
sortPortfolioBalances,
usePortfolioBalances,
useTokenBalancesGroupedByVisibility,
} from 'wallet/src/features/dataApi/balances'
import { CurrencyInfo, GqlResult, PortfolioBalance } from 'wallet/src/features/dataApi/types' import { CurrencyInfo, GqlResult, PortfolioBalance } from 'wallet/src/features/dataApi/types'
import { usePersistedError } from 'wallet/src/features/dataApi/utils' import { usePersistedError } from 'wallet/src/features/dataApi/utils'
import { selectFavoriteTokens } from 'wallet/src/features/favorites/selectors' import { selectFavoriteTokens } from 'wallet/src/features/favorites/selectors'
...@@ -173,7 +176,6 @@ export function usePortfolioBalancesForAddressById( ...@@ -173,7 +176,6 @@ export function usePortfolioBalancesForAddressById(
loading, loading,
} = usePortfolioBalances({ } = usePortfolioBalances({
address, address,
shouldPoll: false, // Home tab's TokenBalanceList will poll portfolio balances for activeAccount
fetchPolicy: 'cache-first', // we want to avoid re-renders when token selector is opening fetchPolicy: 'cache-first', // we want to avoid re-renders when token selector is opening
}) })
......
...@@ -236,7 +236,7 @@ export const PendingConnectionModal = ({ pendingSession, onClose }: Props): JSX. ...@@ -236,7 +236,7 @@ export const PendingConnectionModal = ({ pendingSession, onClose }: Props): JSX.
}, },
[activeAddress, dispatch, onClose, pendingSession, didOpenFromDeepLink] [activeAddress, dispatch, onClose, pendingSession, didOpenFromDeepLink]
) )
const dappName = pendingSession.dapp.name || pendingSession.dapp.url const dappName = pendingSession.dapp.name || pendingSession.dapp.url || ''
return ( return (
<BottomSheetModal name={ModalName.WCPendingConnection} onClose={onClose}> <BottomSheetModal name={ModalName.WCPendingConnection} onClose={onClose}>
......
...@@ -23,7 +23,9 @@ export const CUSTOM_UNI_QR_CODE_PREFIX = 'hello_uniwallet:' ...@@ -23,7 +23,9 @@ export const CUSTOM_UNI_QR_CODE_PREFIX = 'hello_uniwallet:'
const MAX_DAPP_NAME_LENGTH = 60 const MAX_DAPP_NAME_LENGTH = 60
export function truncateDappName(name: string): string { export function truncateDappName(name: string): string {
return name.length > MAX_DAPP_NAME_LENGTH ? `${name.slice(0, MAX_DAPP_NAME_LENGTH)}...` : name return name && name.length > MAX_DAPP_NAME_LENGTH
? `${name.slice(0, MAX_DAPP_NAME_LENGTH)}...`
: name
} }
export async function getSupportedURI(uri: string): Promise<URIFormat | undefined> { export async function getSupportedURI(uri: string): Promise<URIFormat | undefined> {
......
...@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next' ...@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import ContextMenu from 'react-native-context-menu-view' import ContextMenu from 'react-native-context-menu-view'
import { useAppDispatch } from 'src/app/hooks' import { useAppDispatch } from 'src/app/hooks'
import { navigate } from 'src/app/navigation/rootNavigation' import { navigate } from 'src/app/navigation/rootNavigation'
import { useAccountList } from 'src/components/accounts/hooks'
import { AddressDisplay } from 'src/components/AddressDisplay' import { AddressDisplay } from 'src/components/AddressDisplay'
import { closeModal, openModal } from 'src/features/modals/modalSlice' import { closeModal, openModal } from 'src/features/modals/modalSlice'
import { ModalName } from 'src/features/telemetry/constants' import { ModalName } from 'src/features/telemetry/constants'
...@@ -13,7 +14,6 @@ import { disableOnPress } from 'src/utils/disableOnPress' ...@@ -13,7 +14,6 @@ import { disableOnPress } from 'src/utils/disableOnPress'
import { Flex, Text, TouchableArea } from 'ui/src' import { Flex, Text, TouchableArea } from 'ui/src'
import { iconSizes } from 'ui/src/theme' import { iconSizes } from 'ui/src/theme'
import { NumberType } from 'utilities/src/format/types' import { NumberType } from 'utilities/src/format/types'
import { useAccountListQuery } from 'wallet/src/data/__generated__/types-and-hooks'
import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
import { pushNotification } from 'wallet/src/features/notifications/slice' import { pushNotification } from 'wallet/src/features/notifications/slice'
import { AppNotificationType, CopyNotificationType } from 'wallet/src/features/notifications/types' import { AppNotificationType, CopyNotificationType } from 'wallet/src/features/notifications/types'
...@@ -42,9 +42,9 @@ function PortfolioValue({ ...@@ -42,9 +42,9 @@ function PortfolioValue({
// Since we're adding a new wallet address to the `ownerAddresses` array, this will be a brand new query, which won't be cached. // 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. // To avoid all wallets showing a "loading" state, we read directly from cache while we wait for the other query to complete.
const { data } = useAccountListQuery({ const { data } = useAccountList({
fetchPolicy: 'cache-first', fetchPolicy: 'cache-first',
variables: { addresses: address }, addresses: address,
}) })
const cachedPortfolioValue = data?.portfolios?.[0]?.tokensTotalDenominatedValue?.value const cachedPortfolioValue = data?.portfolios?.[0]?.tokensTotalDenominatedValue?.value
......
...@@ -20,6 +20,15 @@ const mock: MockedResponse<AccountListQuery> = { ...@@ -20,6 +20,15 @@ const mock: MockedResponse<AccountListQuery> = {
query: AccountListDocument, query: AccountListDocument,
variables: { variables: {
addresses: [account.address], addresses: [account.address],
valueModifiers: [
{
ownerAddress: account.address,
tokenIncludeOverrides: [],
tokenExcludeOverrides: [],
includeSmallBalances: false,
includeSpamTokens: false,
},
],
}, },
}, },
result: { result: {
......
...@@ -3,13 +3,13 @@ import { ComponentProps, default as React, useCallback, useMemo } from 'react' ...@@ -3,13 +3,13 @@ import { ComponentProps, default as React, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native' import { StyleSheet } from 'react-native'
import { AccountCardItem } from 'src/components/accounts/AccountCardItem' import { AccountCardItem } from 'src/components/accounts/AccountCardItem'
import { useAccountList } from 'src/components/accounts/hooks'
import { VirtualizedList } from 'src/components/layout/VirtualizedList' import { VirtualizedList } from 'src/components/layout/VirtualizedList'
import { Flex, Text, useSporeColors } from 'ui/src' import { Flex, Text, useSporeColors } from 'ui/src'
import { opacify, spacing } from 'ui/src/theme' import { opacify, spacing } from 'ui/src/theme'
import { useAsyncData } from 'utilities/src/react/hooks' import { useAsyncData } from 'utilities/src/react/hooks'
import { PollingInterval } from 'wallet/src/constants/misc' import { PollingInterval } from 'wallet/src/constants/misc'
import { isNonPollingRequestInFlight } from 'wallet/src/data/utils' import { isNonPollingRequestInFlight } from 'wallet/src/data/utils'
import { useAccountListQuery } from 'wallet/src/data/__generated__/types-and-hooks'
import { Account, AccountType } from 'wallet/src/features/wallet/accounts/types' import { Account, AccountType } from 'wallet/src/features/wallet/accounts/types'
// Most screens can fit more but this is set conservatively // Most screens can fit more but this is set conservatively
...@@ -50,10 +50,10 @@ const SignerHeader = (): JSX.Element => { ...@@ -50,10 +50,10 @@ const SignerHeader = (): JSX.Element => {
export function AccountList({ accounts, onPress, isVisible }: AccountListProps): JSX.Element { export function AccountList({ accounts, onPress, isVisible }: AccountListProps): JSX.Element {
const colors = useSporeColors() const colors = useSporeColors()
const addresses = accounts.map((a) => a.address) const addresses = useMemo(() => accounts.map((a) => a.address), [accounts])
const { data, networkStatus, refetch, startPolling, stopPolling } = useAccountListQuery({ const { data, networkStatus, refetch, startPolling, stopPolling } = useAccountList({
variables: { addresses }, addresses,
notifyOnNetworkStatusChange: true, notifyOnNetworkStatusChange: true,
}) })
......
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 { NetworkStatus } from '@apollo/client' import { NetworkStatus } from '@apollo/client'
import { BottomSheetFlatList } from '@gorhom/bottom-sheet' import React, { useCallback, useMemo, useRef } from 'react'
import React, { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ListRenderItem, ListRenderItemInfo } from 'react-native' import { ListRenderItem, ListRenderItemInfo, StyleSheet, View } from 'react-native'
import { useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated'
import { useAppSelector } from 'src/app/hooks' import { useAppSelector } from 'src/app/hooks'
import { FavoriteTokensGrid } from 'src/components/explore/FavoriteTokensGrid' import { FavoriteTokensGrid } from 'src/components/explore/FavoriteTokensGrid'
import { FavoriteWalletsGrid } from 'src/components/explore/FavoriteWalletsGrid' import { FavoriteWalletsGrid } from 'src/components/explore/FavoriteWalletsGrid'
import { SortButton } from 'src/components/explore/SortButton' import { SortButton } from 'src/components/explore/SortButton'
import { TokenItem, TokenItemData } from 'src/components/explore/TokenItem' import { TokenItem, TokenItemData } from 'src/components/explore/TokenItem'
import { AnimatedBottomSheetFlatList } from 'src/components/layout/AnimatedFlatList'
import { Loader } from 'src/components/loading' import { Loader } from 'src/components/loading'
import { AutoScrollProps } from 'src/components/sortableGrid'
import { import {
getClientTokensOrderByCompareFn, getClientTokensOrderByCompareFn,
getTokenMetadataDisplayType, getTokenMetadataDisplayType,
...@@ -36,12 +38,15 @@ import { areAddressesEqual } from 'wallet/src/utils/addresses' ...@@ -36,12 +38,15 @@ import { areAddressesEqual } from 'wallet/src/utils/addresses'
import { buildCurrencyId, buildNativeCurrencyId } from 'wallet/src/utils/currencyId' import { buildCurrencyId, buildNativeCurrencyId } from 'wallet/src/utils/currencyId'
type ExploreSectionsProps = { type ExploreSectionsProps = {
listRef?: React.MutableRefObject<null> listRef: React.MutableRefObject<null>
} }
export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element { export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element {
const { t } = useTranslation() const { t } = useTranslation()
const insets = useDeviceInsets() const insets = useDeviceInsets()
const scrollY = useSharedValue(0)
const headerRef = useRef<View>(null)
const visibleListHeight = useSharedValue(0)
// Top tokens sorting // Top tokens sorting
const orderBy = useAppSelector(selectTokensOrderBy) const orderBy = useAppSelector(selectTokensOrderBy)
...@@ -120,6 +125,10 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element ...@@ -120,6 +125,10 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element
await refetch() await refetch()
}, [refetch]) }, [refetch])
const scrollHandler = useAnimatedScrollHandler((e) => {
scrollY.value = e.contentOffset.y
})
// Use showLoading for showing full screen loading state // Use showLoading for showing full screen loading state
// Used in each section to ensure loading state layout matches loaded state // Used in each section to ensure loading state layout matches loaded state
const showLoading = (!hasAllData && isLoading) || (!!error && isLoading) const showLoading = (!hasAllData && isLoading) || (!!error && isLoading)
...@@ -137,7 +146,18 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element ...@@ -137,7 +146,18 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element
} }
return ( return (
<BottomSheetFlatList // 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} ref={listRef}
ListEmptyComponent={ ListEmptyComponent={
<Flex mx="$spacing24" my="$spacing12"> <Flex mx="$spacing24" my="$spacing12">
...@@ -145,8 +165,14 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element ...@@ -145,8 +165,14 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element
</Flex> </Flex>
} }
ListHeaderComponent={ ListHeaderComponent={
<> <Flex ref={headerRef}>
<FavoritesSection showLoading={showLoading} /> <FavoritesSection
containerRef={headerRef}
scrollY={scrollY}
scrollableRef={listRef}
showLoading={showLoading}
visibleHeight={visibleListHeight}
/>
<Flex <Flex
row row
alignItems="center" alignItems="center"
...@@ -161,15 +187,19 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element ...@@ -161,15 +187,19 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element
</Text> </Text>
<SortButton orderBy={orderBy} /> <SortButton orderBy={orderBy} />
</Flex> </Flex>
</> </Flex>
} }
ListHeaderComponentStyle={styles.foreground}
contentContainerStyle={{ paddingBottom: insets.bottom }} contentContainerStyle={{ paddingBottom: insets.bottom }}
data={showLoading ? undefined : topTokenItems} data={showLoading ? undefined : topTokenItems}
keyExtractor={tokenKey} keyExtractor={tokenKey}
renderItem={renderItem} renderItem={renderItem}
scrollEventThrottle={16}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
onScroll={scrollHandler}
/> />
</Flex>
) )
} }
...@@ -204,16 +234,32 @@ function gqlTokenToTokenItemData( ...@@ -204,16 +234,32 @@ function gqlTokenToTokenItemData(
} as TokenItemData } as TokenItemData
} }
function FavoritesSection({ showLoading }: { showLoading: boolean }): JSX.Element | null { type FavoritesSectionProps = AutoScrollProps & {
showLoading: boolean
}
function FavoritesSection(props: FavoritesSectionProps): JSX.Element | null {
const hasFavoritedTokens = useAppSelector(selectHasFavoriteTokens) const hasFavoritedTokens = useAppSelector(selectHasFavoriteTokens)
const hasFavoritedWallets = useAppSelector(selectHasWatchedWallets) const hasFavoritedWallets = useAppSelector(selectHasWatchedWallets)
if (!hasFavoritedTokens && !hasFavoritedWallets) return null if (!hasFavoritedTokens && !hasFavoritedWallets) return null
return ( return (
<Flex bg="$transparent" gap="$spacing12" pb="$spacing12" pt="$spacing8" px="$spacing12"> <Flex
{hasFavoritedTokens && <FavoriteTokensGrid showLoading={showLoading} />} bg="$transparent"
{hasFavoritedWallets && <FavoriteWalletsGrid showLoading={showLoading} />} gap="$spacing12"
pb="$spacing12"
pt="$spacing8"
px="$spacing12"
zIndex={1}>
{hasFavoritedTokens && <FavoriteTokensGrid {...props} />}
{hasFavoritedWallets && <FavoriteWalletsGrid showLoading={props.showLoading} />}
</Flex> </Flex>
) )
} }
const styles = StyleSheet.create({
foreground: {
zIndex: 1,
},
})
...@@ -2,7 +2,15 @@ import { ImpactFeedbackStyle } from 'expo-haptics' ...@@ -2,7 +2,15 @@ import { ImpactFeedbackStyle } from 'expo-haptics'
import React, { memo, useCallback } from 'react' import React, { memo, useCallback } from 'react'
import { ViewProps } from 'react-native' import { ViewProps } from 'react-native'
import ContextMenu from 'react-native-context-menu-view' import ContextMenu from 'react-native-context-menu-view'
import { FadeIn, FadeOut } from 'react-native-reanimated' import {
FadeIn,
FadeOut,
interpolate,
SharedValue,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
import { useAppDispatch } from 'src/app/hooks' import { useAppDispatch } from 'src/app/hooks'
import { useExploreTokenContextMenu } from 'src/components/explore/hooks' import { useExploreTokenContextMenu } from 'src/components/explore/hooks'
import RemoveButton from 'src/components/explore/RemoveButton' import RemoveButton from 'src/components/explore/RemoveButton'
...@@ -11,7 +19,7 @@ import { useTokenDetailsNavigation } from 'src/components/TokenDetails/hooks' ...@@ -11,7 +19,7 @@ import { useTokenDetailsNavigation } from 'src/components/TokenDetails/hooks'
import { SectionName } from 'src/features/telemetry/constants' import { SectionName } from 'src/features/telemetry/constants'
import { disableOnPress } from 'src/utils/disableOnPress' import { disableOnPress } from 'src/utils/disableOnPress'
import { usePollOnFocusOnly } from 'src/utils/hooks' import { usePollOnFocusOnly } from 'src/utils/hooks'
import { AnimatedTouchableArea, Flex, Text } from 'ui/src' import { AnimatedFlex, AnimatedTouchableArea, Flex, Text } from 'ui/src'
import { borderRadii, imageSizes } from 'ui/src/theme' import { borderRadii, imageSizes } from 'ui/src/theme'
import { NumberType } from 'utilities/src/format/types' import { NumberType } from 'utilities/src/format/types'
import { BaseCard } from 'wallet/src/components/BaseCard/BaseCard' import { BaseCard } from 'wallet/src/components/BaseCard/BaseCard'
...@@ -32,18 +40,24 @@ export const FAVORITE_TOKEN_CARD_LOADER_HEIGHT = 114 ...@@ -32,18 +40,24 @@ export const FAVORITE_TOKEN_CARD_LOADER_HEIGHT = 114
type FavoriteTokenCardProps = { type FavoriteTokenCardProps = {
currencyId: string currencyId: string
isEditing?: boolean isEditing?: boolean
isTouched: SharedValue<boolean>
dragActivationProgress: SharedValue<number>
setIsEditing: (update: boolean) => void setIsEditing: (update: boolean) => void
} & ViewProps } & ViewProps
function FavoriteTokenCard({ function FavoriteTokenCard({
currencyId, currencyId,
isEditing, isEditing,
isTouched,
dragActivationProgress,
setIsEditing, setIsEditing,
...rest ...rest
}: FavoriteTokenCardProps): JSX.Element { }: FavoriteTokenCardProps): JSX.Element {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const tokenDetailsNavigation = useTokenDetailsNavigation() const tokenDetailsNavigation = useTokenDetailsNavigation()
const { convertFiatAmountFormatted } = useLocalizationContext() const { convertFiatAmountFormatted } = useLocalizationContext()
const dragAnimationProgress = useSharedValue(0)
const wasTouched = useSharedValue(false)
const { data, networkStatus, startPolling, stopPolling } = useFavoriteTokenCardQuery({ const { data, networkStatus, startPolling, stopPolling } = useFavoriteTokenCardQuery({
variables: currencyIdToContractInput(currencyId), variables: currencyIdToContractInput(currencyId),
...@@ -88,11 +102,45 @@ function FavoriteTokenCard({ ...@@ -88,11 +102,45 @@ function FavoriteTokenCard({
tokenDetailsNavigation.navigate(currencyId) tokenDetailsNavigation.navigate(currencyId)
} }
useAnimatedReaction(
() => dragActivationProgress.value,
(activationProgress, prev) => {
const prevActivationProgress = prev ?? 0
// If the activation progress is increasing (the user is touching one of the cards)
if (activationProgress > prevActivationProgress) {
if (isTouched.value) {
// If the current card is the one being touched, reset the animation progress
wasTouched.value = true
dragAnimationProgress.value = 0
} else {
// Otherwise, animate the card
wasTouched.value = false
dragAnimationProgress.value = activationProgress
}
}
// If the activation progress is decreasing (the user is no longer touching one of the cards)
else {
if (isTouched.value || wasTouched.value) {
// If the current card is the one that was being touched, reset the animation progress
dragAnimationProgress.value = 0
} else {
// Otherwise, animate the card
dragAnimationProgress.value = activationProgress
}
}
}
)
const animatedStyle = useAnimatedStyle(() => ({
opacity: interpolate(dragAnimationProgress.value, [0, 1], [1, 0.5]),
}))
if (isNonPollingRequestInFlight(networkStatus)) { if (isNonPollingRequestInFlight(networkStatus)) {
return <Loader.Favorite height={FAVORITE_TOKEN_CARD_LOADER_HEIGHT} /> return <Loader.Favorite height={FAVORITE_TOKEN_CARD_LOADER_HEIGHT} />
} }
return ( return (
<AnimatedFlex style={animatedStyle}>
<ContextMenu <ContextMenu
actions={menuActions} actions={menuActions}
disabled={isEditing} disabled={isEditing}
...@@ -100,10 +148,12 @@ function FavoriteTokenCard({ ...@@ -100,10 +148,12 @@ function FavoriteTokenCard({
onPress={onContextMenuPress} onPress={onContextMenuPress}
{...rest}> {...rest}>
<AnimatedTouchableArea <AnimatedTouchableArea
hapticFeedback activeOpacity={isEditing ? 1 : undefined}
bg="$surface2"
borderRadius="$rounded16" borderRadius="$rounded16"
entering={FadeIn} entering={FadeIn}
exiting={FadeOut} exiting={FadeOut}
hapticFeedback={!isEditing}
hapticStyle={ImpactFeedbackStyle.Light} hapticStyle={ImpactFeedbackStyle.Light}
m="$spacing4" m="$spacing4"
testID={`token-box-${token?.symbol}`} testID={`token-box-${token?.symbol}`}
...@@ -142,6 +192,7 @@ function FavoriteTokenCard({ ...@@ -142,6 +192,7 @@ function FavoriteTokenCard({
</BaseCard.Shadow> </BaseCard.Shadow>
</AnimatedTouchableArea> </AnimatedTouchableArea>
</ContextMenu> </ContextMenu>
</AnimatedFlex>
) )
} }
......
import React, { useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { FadeIn } from 'react-native-reanimated' import { FadeIn, useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
import { useAppSelector } from 'src/app/hooks' import { useAppSelector } from 'src/app/hooks'
import { FavoriteHeaderRow } from 'src/components/explore/FavoriteHeaderRow' import { FavoriteHeaderRow } from 'src/components/explore/FavoriteHeaderRow'
import FavoriteTokenCard, { import FavoriteTokenCard, {
FAVORITE_TOKEN_CARD_LOADER_HEIGHT, FAVORITE_TOKEN_CARD_LOADER_HEIGHT,
} from 'src/components/explore/FavoriteTokenCard' } from 'src/components/explore/FavoriteTokenCard'
import { Loader } from 'src/components/loading' import { Loader } from 'src/components/loading'
import {
AutoScrollProps,
SortableGrid,
SortableGridChangeEvent,
SortableGridRenderItem,
} from 'src/components/sortableGrid'
import { AnimatedFlex, Flex } from 'ui/src' import { AnimatedFlex, Flex } from 'ui/src'
import { selectFavoriteTokens } from 'wallet/src/features/favorites/selectors' import { selectFavoriteTokens } from 'wallet/src/features/favorites/selectors'
import { setFavoriteTokens } from 'wallet/src/features/favorites/slice'
import { useAppDispatch } from 'wallet/src/state'
const NUM_COLUMNS = 2 const NUM_COLUMNS = 2
const ITEM_FLEX = { flex: 1 / NUM_COLUMNS } const ITEM_FLEX = { flex: 1 / NUM_COLUMNS }
const HALF_WIDTH = { width: '50%' }
type FavoriteTokensGridProps = AutoScrollProps & {
showLoading: boolean
}
/** Renders the favorite tokens section on the Explore tab */ /** Renders the favorite tokens section on the Explore tab */
export function FavoriteTokensGrid({ showLoading }: { showLoading: boolean }): JSX.Element | null { export function FavoriteTokensGrid({
showLoading,
...rest
}: FavoriteTokensGridProps): JSX.Element | null {
const { t } = useTranslation() const { t } = useTranslation()
const dispatch = useAppDispatch()
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const isTokenDragged = useSharedValue(false)
const favoriteCurrencyIds = useAppSelector(selectFavoriteTokens) const favoriteCurrencyIds = useAppSelector(selectFavoriteTokens)
// Reset edit mode when there are no favorite tokens // Reset edit mode when there are no favorite tokens
...@@ -28,8 +44,33 @@ export function FavoriteTokensGrid({ showLoading }: { showLoading: boolean }): J ...@@ -28,8 +44,33 @@ export function FavoriteTokensGrid({ showLoading }: { showLoading: boolean }): J
} }
}, [favoriteCurrencyIds.length]) }, [favoriteCurrencyIds.length])
const handleOrderChange = useCallback(
({ data }: SortableGridChangeEvent<string>) => {
dispatch(setFavoriteTokens({ currencyIds: data }))
},
[dispatch]
)
const renderItem = useCallback<SortableGridRenderItem<string>>(
({ item: currencyId, isTouched, dragActivationProgress }): JSX.Element => (
<FavoriteTokenCard
key={currencyId}
currencyId={currencyId}
dragActivationProgress={dragActivationProgress}
isEditing={isEditing}
isTouched={isTouched}
setIsEditing={setIsEditing}
/>
),
[isEditing]
)
const animatedStyle = useAnimatedStyle(() => ({
zIndex: isTokenDragged.value ? 1 : 0,
}))
return ( return (
<AnimatedFlex entering={FadeIn}> <AnimatedFlex entering={FadeIn} style={animatedStyle}>
<FavoriteHeaderRow <FavoriteHeaderRow
editingTitle={t('Edit favorite tokens')} editingTitle={t('Edit favorite tokens')}
isEditing={isEditing} isEditing={isEditing}
...@@ -39,17 +80,21 @@ export function FavoriteTokensGrid({ showLoading }: { showLoading: boolean }): J ...@@ -39,17 +80,21 @@ export function FavoriteTokensGrid({ showLoading }: { showLoading: boolean }): J
{showLoading ? ( {showLoading ? (
<FavoriteTokensGridLoader /> <FavoriteTokensGridLoader />
) : ( ) : (
<Flex row flexWrap="wrap"> <SortableGrid
{favoriteCurrencyIds.map((currencyId) => ( {...rest}
<FavoriteTokenCard activeItemOpacity={1}
key={currencyId} data={favoriteCurrencyIds}
currencyId={currencyId} editable={isEditing}
isEditing={isEditing} numColumns={NUM_COLUMNS}
setIsEditing={setIsEditing} renderItem={renderItem}
style={HALF_WIDTH} onChange={handleOrderChange}
onDragEnd={(): void => {
isTokenDragged.value = false
}}
onDragStart={(): void => {
isTokenDragged.value = true
}}
/> />
))}
</Flex>
)} )}
</AnimatedFlex> </AnimatedFlex>
) )
......
...@@ -1376,6 +1376,7 @@ exports[`ActivityTab renders without error 2`] = ` ...@@ -1376,6 +1376,7 @@ exports[`ActivityTab renders without error 2`] = `
{ {
"alignItems": "center", "alignItems": "center",
"flexDirection": "row", "flexDirection": "row",
"flexShrink": 1,
"gap": 4, "gap": 4,
} }
} }
...@@ -1732,6 +1733,7 @@ exports[`ActivityTab renders without error 2`] = ` ...@@ -1732,6 +1733,7 @@ exports[`ActivityTab renders without error 2`] = `
{ {
"alignItems": "center", "alignItems": "center",
"flexDirection": "row", "flexDirection": "row",
"flexShrink": 1,
"gap": 4, "gap": 4,
} }
} }
......
...@@ -2,11 +2,10 @@ import React, { forwardRef, useCallback, useEffect, useMemo } from 'react' ...@@ -2,11 +2,10 @@ import React, { forwardRef, useCallback, useEffect, useMemo } from 'react'
import { AppState, Keyboard, KeyboardTypeOptions, TextInput as NativeTextInput } from 'react-native' import { AppState, Keyboard, KeyboardTypeOptions, TextInput as NativeTextInput } from 'react-native'
import { TextInput, TextInputProps } from 'src/components/input/TextInput' import { TextInput, TextInputProps } from 'src/components/input/TextInput'
import { useMoonpayFiatCurrencySupportInfo } from 'src/features/fiatOnRamp/hooks' import { useMoonpayFiatCurrencySupportInfo } from 'src/features/fiatOnRamp/hooks'
import { escapeRegExp } from 'utilities/src/primitives/string'
import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks' import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks'
import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
const inputRegex = RegExp('^\\d*(?:\\\\[.])?\\d*$') // match escaped "." characters via in a non-capturing group const numericInputRegex = RegExp('^\\d*(\\.\\d*)?$') // Matches only numeric values without commas
type Props = { type Props = {
showCurrencySign: boolean showCurrencySign: boolean
...@@ -36,10 +35,18 @@ export function replaceSeparators({ ...@@ -36,10 +35,18 @@ export function replaceSeparators({
} }
export const AmountInput = forwardRef<NativeTextInput, Props>(function _AmountInput( export const AmountInput = forwardRef<NativeTextInput, Props>(function _AmountInput(
{ onChangeText, value, showCurrencySign, dimTextColor, showSoftInputOnFocus, editable, ...rest }, { onChangeText, value, showCurrencySign, dimTextColor, showSoftInputOnFocus, ...rest },
ref ref
) { ) {
const { groupingSeparator, decimalSeparator } = useAppFiatCurrencyInfo() const { groupingSeparator, decimalSeparator } = useAppFiatCurrencyInfo()
const invalidInput = value && !numericInputRegex.test(value)
useEffect(() => {
// Resets input if non-numberic value is passed
if (invalidInput) {
onChangeText?.('')
}
}, [invalidInput, onChangeText, value])
const handleChange = useCallback( const handleChange = useCallback(
(text: string) => { (text: string) => {
...@@ -51,9 +58,7 @@ export const AmountInput = forwardRef<NativeTextInput, Props>(function _AmountIn ...@@ -51,9 +58,7 @@ export const AmountInput = forwardRef<NativeTextInput, Props>(function _AmountIn
decimalOverride: '.', decimalOverride: '.',
}) })
if (parsedText === '' || inputRegex.test(escapeRegExp(parsedText))) {
onChangeText?.(parsedText) onChangeText?.(parsedText)
}
}, },
[decimalSeparator, groupingSeparator, onChangeText, showCurrencySign] [decimalSeparator, groupingSeparator, onChangeText, showCurrencySign]
) )
...@@ -61,23 +66,19 @@ export const AmountInput = forwardRef<NativeTextInput, Props>(function _AmountIn ...@@ -61,23 +66,19 @@ export const AmountInput = forwardRef<NativeTextInput, Props>(function _AmountIn
const { moonpaySupportedFiatCurrency: currency } = useMoonpayFiatCurrencySupportInfo() const { moonpaySupportedFiatCurrency: currency } = useMoonpayFiatCurrencySupportInfo()
const { addFiatSymbolToNumber } = useLocalizationContext() const { addFiatSymbolToNumber } = useLocalizationContext()
let formattedValue = showCurrencySign let formattedValue = replaceSeparators({
? addFiatSymbolToNumber({ value: value ?? '',
value,
currencyCode: currency.code,
currencySymbol: currency.symbol,
})
: value
// TODO gary MOB-2028 replace temporary hack to handle different separators
formattedValue =
editable ?? true
? replaceSeparators({
value: formattedValue ?? '',
groupingSeparator: ',', groupingSeparator: ',',
decimalSeparator: '.', decimalSeparator: '.',
groupingOverride: groupingSeparator, groupingOverride: groupingSeparator,
decimalOverride: decimalSeparator, decimalOverride: decimalSeparator,
}) })
formattedValue = showCurrencySign
? addFiatSymbolToNumber({
value: formattedValue,
currencyCode: currency.code,
currencySymbol: currency.symbol,
})
: formattedValue : formattedValue
const textInputProps: TextInputProps = useMemo( const textInputProps: TextInputProps = useMemo(
......
...@@ -64,25 +64,23 @@ export function SeedPhraseDisplay({ ...@@ -64,25 +64,23 @@ export function SeedPhraseDisplay({
return ( return (
<> <>
{showSeedPhrase ? (
<Flex grow mt="$spacing16"> <Flex grow mt="$spacing16">
{showSeedPhrase ? (
<Flex grow pt="$spacing16" px="$spacing16"> <Flex grow pt="$spacing16" px="$spacing16">
<MnemonicDisplay mnemonicId={mnemonicId} /> <MnemonicDisplay mnemonicId={mnemonicId} />
</Flex> </Flex>
) : (
<HiddenMnemonicWordView />
)}
</Flex>
<Flex borderTopColor="$surface3" borderTopWidth={1} pt="$spacing12" px="$spacing16"> <Flex borderTopColor="$surface3" borderTopWidth={1} pt="$spacing12" px="$spacing16">
<Button <Button
testID={ElementName.Next} testID={ElementName.Next}
theme="secondary" theme="secondary"
onPress={(): void => { onPress={(): void => setShowSeedPhrase(!showSeedPhrase)}>
setShowSeedPhrase(false) {showSeedPhrase ? t('Hide recovery phrase') : t('Show recovery phrase')}
}}>
{t('Hide recovery phrase')}
</Button> </Button>
</Flex> </Flex>
</Flex>
) : (
<HiddenMnemonicWordView />
)}
{showSeedPhraseViewWarningModal && ( {showSeedPhraseViewWarningModal && (
<WarningModal <WarningModal
......
import { PropsWithChildren } from 'react'
import { StyleSheet } from 'react-native'
import Animated, {
interpolate,
interpolateColor,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import { colors, opacify } from 'ui/src/theme'
import { useSortableGridContext } from './SortableGridProvider'
type ActiveItemDecorationProps = PropsWithChildren<{
renderIndex: number
}>
export default function ActiveItemDecoration({
renderIndex,
children,
}: ActiveItemDecorationProps): JSX.Element {
const {
touchedIndex,
activeItemScale,
previousActiveIndex,
activeItemOpacity,
activeItemShadowOpacity,
dragActivationProgress,
} = useSortableGridContext()
const pressProgress = useSharedValue(0)
useAnimatedReaction(
() => ({
isTouched: touchedIndex.value === renderIndex,
wasTouched: previousActiveIndex.value === renderIndex,
progress: dragActivationProgress.value,
}),
({ isTouched, wasTouched, progress }) => {
if (isTouched) {
// If the item is currently touched, we want to animate the press progress
// (change the decoration) based on the drag activation progress
pressProgress.value = Math.max(pressProgress.value, progress)
} else if (wasTouched) {
// If the item was touched (the user released the finger) and the item
// was previously touched, we want to animate it based on the decreasing
// press progress
pressProgress.value = Math.min(pressProgress.value, progress)
} else {
// For all other cases, we want to ensure that the press progress is reset
// and all non-touched items are not decorated
pressProgress.value = withTiming(0)
}
}
)
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{
scale: interpolate(pressProgress.value, [0, 1], [1, activeItemScale.value]),
},
],
opacity: interpolate(pressProgress.value, [0, 1], [1, activeItemOpacity.value]),
shadowColor: interpolateColor(
pressProgress.value,
[0, 1],
['transparent', opacify(100 * activeItemShadowOpacity.value, colors.black)]
),
}))
return <Animated.View style={[styles.shadow, animatedStyle]}>{children}</Animated.View>
}
const styles = StyleSheet.create({
shadow: {
borderRadius: 0,
elevation: 40,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.25,
shadowRadius: 5,
},
})
import { memo, useRef } from 'react'
import { LayoutChangeEvent, MeasureLayoutOnSuccessCallback, View } from 'react-native'
import { Flex, FlexProps } from 'ui/src'
import { useStableCallback } from './hooks'
import SortableGridItem from './SortableGridItem'
import SortableGridProvider, { useSortableGridContext } from './SortableGridProvider'
import { AutoScrollProps, SortableGridChangeEvent, SortableGridRenderItem } from './types'
import { defaultKeyExtractor } from './utils'
type SortableGridProps<I> = Omit<SortableGridInnerProps<I>, 'keyExtractor'> &
AutoScrollProps & {
onChange: (e: SortableGridChangeEvent<I>) => void
onDragStart?: () => void
onDragEnd?: () => void
keyExtractor?: (item: I, index: number) => string
editable?: boolean
activeItemScale?: number
activeItemOpacity?: number
activeItemShadowOpacity?: number
}
function SortableGrid<I>({
data,
onDragStart,
onDragEnd,
keyExtractor: keyExtractorProp,
activeItemScale,
activeItemOpacity,
activeItemShadowOpacity,
onChange: onChangeProp,
scrollableRef,
scrollY,
visibleHeight,
editable,
numColumns = 1,
...rest
}: SortableGridProps<I>): JSX.Element {
const keyExtractor = useStableCallback(keyExtractorProp ?? defaultKeyExtractor)
const onChange = useStableCallback(onChangeProp)
const providerProps = {
activeItemScale,
activeItemOpacity,
activeItemShadowOpacity,
data,
editable,
onChange,
scrollY,
scrollableRef,
visibleHeight,
onDragStart,
onDragEnd,
}
const gridProps = {
data,
keyExtractor,
numColumns,
scrollableRef,
...rest,
}
return (
<SortableGridProvider {...providerProps}>
<MemoSortableGridInner {...gridProps} />
</SortableGridProvider>
)
}
type SortableGridInnerProps<I> = FlexProps & {
keyExtractor: (item: I, index: number) => string
numColumns?: number
data: I[]
renderItem: SortableGridRenderItem<I>
containerRef?: React.RefObject<View>
}
function SortableGridInner<I>({
data,
renderItem,
numColumns = 1,
keyExtractor,
containerRef,
...flexProps
}: SortableGridInnerProps<I>): JSX.Element {
const { gridContainerRef, containerStartOffset, containerEndOffset, touchedIndex } =
useSortableGridContext()
const internalDataRef = useRef(data)
const measureContainer = useStableCallback((e: LayoutChangeEvent) => {
// If there is no parent element, assume the grid is the first child
// in the scrollable container
if (!containerRef?.current) {
containerEndOffset.value = e.nativeEvent.layout.height
return
}
// Otherwise, measure its offset relative to the scrollable container
const onSuccess: MeasureLayoutOnSuccessCallback = (x, y, w, h) => {
containerStartOffset.value = y
containerEndOffset.value = y + h
}
const parentNode = containerRef.current
const gridNode = gridContainerRef.current
if (gridNode) {
gridNode.measureLayout(parentNode, onSuccess)
}
})
// Update only if the user doesn't interact with the grid
// (we don't want to reorder items based on the input data
// while the user is dragging an item)
if (touchedIndex.value === null) {
internalDataRef.current = data
}
return (
<Flex ref={gridContainerRef} row flexWrap="wrap" onLayout={measureContainer} {...flexProps}>
{internalDataRef.current.map((item, index) => (
<SortableGridItem
key={keyExtractor(item, index)}
index={index}
item={item}
numColumns={numColumns}
renderItem={renderItem}
/>
))}
</Flex>
)
}
const MemoSortableGridInner = memo(SortableGridInner) as typeof SortableGridInner
export default SortableGrid
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import { MeasureLayoutOnSuccessCallback, StyleSheet } from 'react-native'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import Animated, {
runOnJS,
runOnUI,
useAnimatedReaction,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
useWorkletCallback,
withTiming,
} from 'react-native-reanimated'
import ActiveItemDecoration from './ActiveItemDecoration'
import { TIME_TO_ACTIVATE_PAN } from './constants'
import { useAnimatedZIndex, useItemOrderUpdater } from './hooks'
import { useSortableGridContext } from './SortableGridProvider'
import { SortableGridRenderItem } from './types'
type SortableGridItemProps<I> = {
item: I
index: number
renderItem: SortableGridRenderItem<I>
numColumns: number
}
function SortableGridItem<I>({
item,
index: renderIndex,
renderItem,
numColumns,
}: SortableGridItemProps<I>): JSX.Element {
const viewRef = useRef<Animated.View>(null)
// Current state
const {
gridContainerRef,
activeIndex,
activeTranslation: activeTranslationValue,
itemAtIndexMeasurements: itemAtIndexMeasurementsValue,
renderIndexToDisplayIndex,
touchedIndex,
editable,
dragActivationProgress,
setActiveIndex,
previousActiveIndex,
scrollOffsetDiff,
} = useSortableGridContext()
const isActive = activeIndex === renderIndex
const isActiveValue = useSharedValue(isActive)
const isTouched = useDerivedValue(() => touchedIndex.value === renderIndex)
useEffect(() => {
isActiveValue.value = isActive
}, [isActive, isActiveValue])
// Cell animations
const displayIndexValue = useDerivedValue(
() => renderIndexToDisplayIndex.value[renderIndex] ?? renderIndex
)
const contentHeight = useSharedValue(0)
// Translation based on cells reordering
// (e.g when the item is swapped with the active item)
const orderTranslateX = useSharedValue(0)
const orderTranslateY = useSharedValue(0)
// Reset order translation on re-render
orderTranslateX.value = 0
orderTranslateY.value = 0
// Translation based on the user dragging the item
// (we keep it separate to animate the dropped item to the target
// position without flickering when items are re-rendered in
// the new order and the drop animation has not finished yet)
const dragTranslateX = useSharedValue(0)
const dragTranslateY = useSharedValue(0)
const zIndex = useAnimatedZIndex(renderIndex)
useItemOrderUpdater(renderIndex, activeIndex, displayIndexValue, numColumns)
const updateCellMeasurements = useCallback(() => {
const onSuccess: MeasureLayoutOnSuccessCallback = (x, y, w, h) => {
runOnUI(() => {
const currentMeasurements = itemAtIndexMeasurementsValue.value
currentMeasurements[renderIndex] = { x, y, width: w, height: h }
itemAtIndexMeasurementsValue.value = [...currentMeasurements]
contentHeight.value = h
})()
}
const listContainerNode = gridContainerRef.current
const listItemNode = viewRef.current
if (listItemNode && listContainerNode) {
listItemNode.measureLayout(listContainerNode, onSuccess)
}
}, [gridContainerRef, itemAtIndexMeasurementsValue, renderIndex, contentHeight])
const getItemOrderTranslation = useWorkletCallback(() => {
const itemAtIndexMeasurements = itemAtIndexMeasurementsValue.value
const displayIndex = displayIndexValue.value
const renderMeasurements = itemAtIndexMeasurements[renderIndex]
const displayMeasurements = itemAtIndexMeasurements[displayIndex]
if (!renderMeasurements || !displayMeasurements) return { x: 0, y: 0 }
return {
x: displayMeasurements.x - renderMeasurements.x,
y: displayMeasurements.y - renderMeasurements.y,
}
}, [renderIndex])
const handleDragEnd = useWorkletCallback(() => {
dragActivationProgress.value = withTiming(0, { duration: TIME_TO_ACTIVATE_PAN })
touchedIndex.value = null
if (!isActiveValue.value) return
// Reset the active item
previousActiveIndex.value = renderIndex
// Reset this before state is updated to disable animated reactions
// earlier (the state is always updated with a delay)
isActiveValue.value = false
// Translate the previously active item to its target position
const orderTranslation = getItemOrderTranslation()
// Update the current order translation and modify the drag translation
// at the same time (this prevents flickering when items are re-rendered)
dragTranslateX.value = dragTranslateX.value - orderTranslation.x
dragTranslateY.value = dragTranslateY.value + scrollOffsetDiff.value - orderTranslation.y
orderTranslateX.value = orderTranslation.x
orderTranslateY.value = orderTranslation.y
// Animate the remaining translation
dragTranslateX.value = withTiming(0)
dragTranslateY.value = withTiming(0)
// Reset the active item index
runOnJS(setActiveIndex)(null)
}, [renderIndex, getItemOrderTranslation])
// Translates the currently active (dragged) item
useAnimatedReaction(
() => ({
activeTranslation: activeTranslationValue.value,
active: isActiveValue.value,
}),
({ active, activeTranslation }) => {
if (!active || touchedIndex.value === null) return
dragTranslateX.value = activeTranslation.x
dragTranslateY.value = activeTranslation.y
}
)
// Translates the item when it's not active and is swapped with the active item
useAnimatedReaction(
() => ({
displayIndex: displayIndexValue.value,
itemAtIndexMeasurements: itemAtIndexMeasurementsValue.value,
active: isActiveValue.value,
}),
({ displayIndex, active, itemAtIndexMeasurements }) => {
if (active) return
const renderMeasurements = itemAtIndexMeasurements[renderIndex]
const displayMeasurements = itemAtIndexMeasurements[displayIndex]
if (!renderMeasurements || !displayMeasurements) return
if (activeIndex !== null && touchedIndex.value !== null) {
// If the order changes as a result of the user dragging an item,
// translate the item to its new position with animation
orderTranslateX.value = withTiming(displayMeasurements.x - renderMeasurements.x)
orderTranslateY.value = withTiming(displayMeasurements.y - renderMeasurements.y)
} else if (renderIndex !== previousActiveIndex.value) {
// If the order changes as a result of the data change, reset
// the item position without animation (it re-renders in the new position,
// so the previously applied translation is no longer valid)
orderTranslateX.value = 0
orderTranslateY.value = 0
}
},
[renderIndex, activeIndex]
)
const panGesture = useMemo(
() =>
Gesture.Pan()
.activateAfterLongPress(TIME_TO_ACTIVATE_PAN)
.onTouchesDown(() => {
touchedIndex.value = renderIndex
previousActiveIndex.value = null
dragActivationProgress.value = withTiming(1, { duration: TIME_TO_ACTIVATE_PAN })
})
.onStart(() => {
if (touchedIndex.value !== renderIndex) return
activeTranslationValue.value = { x: 0, y: 0 }
dragActivationProgress.value = withTiming(1, { duration: TIME_TO_ACTIVATE_PAN })
runOnJS(setActiveIndex)(renderIndex)
})
.onUpdate((e) => {
if (!isActiveValue.value) return
activeTranslationValue.value = { x: e.translationX, y: e.translationY }
})
.onTouchesCancelled(handleDragEnd)
.onEnd(handleDragEnd)
.onTouchesUp(handleDragEnd)
.enabled(editable),
[
activeTranslationValue,
dragActivationProgress,
isActiveValue,
handleDragEnd,
previousActiveIndex,
touchedIndex,
renderIndex,
setActiveIndex,
editable,
]
)
const animatedCellStyle = useAnimatedStyle(() => ({
zIndex: zIndex.value,
height: contentHeight.value > 0 ? contentHeight.value : undefined,
}))
const animatedOrderStyle = useAnimatedStyle(() => ({
transform: [{ translateX: orderTranslateX.value }, { translateY: orderTranslateY.value }],
}))
const animatedDragStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: dragTranslateX.value },
{ translateY: dragTranslateY.value + (isActiveValue.value ? scrollOffsetDiff.value : 0) },
],
}))
const content = useMemo(
() =>
renderItem({
index: renderIndex,
item,
dragActivationProgress,
isTouched,
}),
[renderIndex, dragActivationProgress, item, renderItem, isTouched]
)
const cellStyle = {
width: `${100 / numColumns}%`,
}
return (
// The outer view is used to resize the cell to the size of the new item
// in case the new item height is different than the height of the previous one
<Animated.View pointerEvents="box-none" style={[cellStyle, animatedCellStyle]}>
<GestureDetector gesture={panGesture}>
{/* The inner view will be translated during grid items reordering */}
<Animated.View
ref={viewRef}
style={activeIndex !== null ? animatedOrderStyle : styles.noTranslation}>
<Animated.View style={animatedDragStyle} onLayout={updateCellMeasurements}>
<ActiveItemDecoration renderIndex={renderIndex}>{content}</ActiveItemDecoration>
</Animated.View>
</Animated.View>
</GestureDetector>
</Animated.View>
)
}
const styles = StyleSheet.create({
noTranslation: {
transform: [{ translateX: 0 }, { translateY: 0 }],
},
})
export default memo(SortableGridItem) as <I>(props: SortableGridItemProps<I>) => JSX.Element
import { impactAsync, ImpactFeedbackStyle } from 'expo-haptics'
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { View } from 'react-native'
import {
useAnimatedReaction,
useDerivedValue,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import { TIME_TO_ACTIVATE_PAN } from './constants'
import { useAutoScroll, useStableCallback } from './hooks'
import {
AutoScrollProps,
ItemMeasurements,
SortableGridChangeEvent,
SortableGridContextType,
} from './types'
const SortableGridContext = createContext<SortableGridContextType | null>(null)
export function useSortableGridContext(): SortableGridContextType {
const context = useContext(SortableGridContext)
if (!context) {
throw new Error('useSortableGridContext must be used within a SortableGridProvider')
}
return context
}
type SortableGridProviderProps<I> = AutoScrollProps & {
data: I[]
children: React.ReactNode
activeItemScale?: number
activeItemOpacity?: number
activeItemShadowOpacity?: number
editable?: boolean
onDragStart?: () => void
onDragEnd?: () => void
onChange: (e: SortableGridChangeEvent<I>) => void
}
export default function SortableGridProvider<I>({
children,
onChange,
activeItemScale: activeItemScaleProp = 1.1,
activeItemOpacity: activeItemOpacityProp = 0.7,
activeItemShadowOpacity: activeItemShadowOpacityProp = 0.5,
editable = true,
visibleHeight,
onDragStart,
onDragEnd,
scrollableRef,
scrollY,
data,
}: SortableGridProviderProps<I>): JSX.Element {
const isInitialRenderRef = useRef(true)
const prevDataRef = useRef<I[]>([])
// Active cell settings
const activeItemScale = useDerivedValue(() => activeItemScaleProp)
const activeItemOpacity = useDerivedValue(() => activeItemOpacityProp)
const activeItemShadowOpacity = useDerivedValue(() => activeItemShadowOpacityProp)
// We have to use a state here because the activeIndex must be
// immediately set to null when the data changes (reanimated shared value
// updates are always delayed and can result in animation flickering)
const [activeIndexState, setActiveIndex] = useState<number | null>(null)
const previousActiveIndex = useSharedValue<number | null>(null)
const gridContainerRef = useRef<View>(null)
const touchedIndex = useSharedValue<number | null>(null)
const activeTranslation = useSharedValue({ x: 0, y: 0 })
const dragActivationProgress = useSharedValue(0)
const itemAtIndexMeasurements = useSharedValue<ItemMeasurements[]>([])
// Tells which item is currently displayed at each index
// (e.g. the item at index 0 in the data array was moved to the index 2
// in the displayed grid, so the render index of the item at index 2 is 0
// (the item displayed at index 2 is the item at index 0 in the data array))
const displayToRenderIndex = useSharedValue<number[]>(data.map((_, index) => index))
// Tells where the item rendered at each index was moved in the displayed grid
// (e.g. the item at index 0 in the data array was moved to the index 2
// in the displayed grid, so the display index of the item at index 0 is 2)
// (the reverse mapping of displayToRenderIndex)
const renderIndexToDisplayIndex = useDerivedValue(() => {
const result: number[] = []
const displayToRender = displayToRenderIndex.value
for (let i = 0; i < displayToRender.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
result[displayToRender[i]!] = i
}
return result
})
// Auto scroll settings
// Values used to scroll the container to the proper offset
// (updated from the SortableGridInner component)
const containerStartOffset = useSharedValue(0)
const containerEndOffset = useSharedValue(0)
const startScrollOffset = useSharedValue(0)
const scrollOffsetDiff = useDerivedValue(() => scrollY.value - startScrollOffset.value)
let activeIndex = activeIndexState
const dataChanged =
(!isInitialRenderRef.current && prevDataRef.current.length !== data.length) ||
prevDataRef.current.some((item, index) => item !== data[index])
if (dataChanged) {
prevDataRef.current = data
displayToRenderIndex.value = data.map((_, index) => index)
itemAtIndexMeasurements.value = itemAtIndexMeasurements.value.slice(0, data.length)
activeIndex = null
}
const isDragging = useDerivedValue(() => activeIndex !== null && touchedIndex.value !== null)
// Automatically scrolls the container when the active item is dragged
// out of the container bounds
useAutoScroll(
activeIndex,
touchedIndex,
itemAtIndexMeasurements,
activeTranslation,
scrollOffsetDiff,
containerStartOffset,
containerEndOffset,
visibleHeight,
scrollY,
scrollableRef
)
const handleOrderChange = useStableCallback((fromIndex: number) => {
const toIndex = renderIndexToDisplayIndex.value[fromIndex]
if (toIndex === undefined || toIndex === fromIndex) return
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const newData = displayToRenderIndex.value.map((displayIndex) => data[displayIndex]!)
onChange({ data: newData, fromIndex, toIndex })
})
const handleSetActiveIndex = useStableCallback((index: number | null) => {
// Because this function is run from worklet functions with runOnJS,
// it might be executed after the delay, when the item was released
// so we check if the item is still being dragged before setting the
// active index
if ((index === null || touchedIndex.value !== null) && index !== activeIndex) {
impactAsync(index === null ? ImpactFeedbackStyle.Light : ImpactFeedbackStyle.Medium).catch(
() => undefined
)
if (index !== null) {
onDragStart?.()
} else {
onDragEnd?.()
}
startScrollOffset.value = scrollY.value
setActiveIndex(index)
}
})
useEffect(() => {
const prevActiveIndex = previousActiveIndex.value
if (prevActiveIndex !== null) {
handleOrderChange(prevActiveIndex)
activeTranslation.value = { x: 0, y: 0 }
}
}, [
activeIndex,
previousActiveIndex,
handleOrderChange,
activeTranslation,
startScrollOffset,
scrollY,
])
useEffect(() => {
isInitialRenderRef.current = false
}, [])
useAnimatedReaction(
() => ({
isActive: activeIndex !== null,
offsetDiff: scrollOffsetDiff.value,
}),
({ isActive, offsetDiff }) => {
if (!isActive && Math.abs(offsetDiff) > 0) {
dragActivationProgress.value = withTiming(0, { duration: TIME_TO_ACTIVATE_PAN })
}
},
[activeIndex]
)
const contextValue = useMemo(
() => ({
activeTranslation,
gridContainerRef,
activeIndex,
editable,
scrollY,
itemAtIndexMeasurements,
renderIndexToDisplayIndex,
displayToRenderIndex,
setActiveIndex: handleSetActiveIndex,
previousActiveIndex,
touchedIndex,
activeItemScale,
isDragging,
dragActivationProgress,
scrollOffsetDiff,
activeItemOpacity,
visibleHeight,
activeItemShadowOpacity,
containerStartOffset,
containerEndOffset,
}),
[
activeIndex,
visibleHeight,
activeTranslation,
itemAtIndexMeasurements,
dragActivationProgress,
renderIndexToDisplayIndex,
handleSetActiveIndex,
displayToRenderIndex,
editable,
scrollY,
isDragging,
previousActiveIndex,
scrollOffsetDiff,
touchedIndex,
activeItemScale,
activeItemOpacity,
activeItemShadowOpacity,
containerStartOffset,
containerEndOffset,
]
)
return (
<SortableGridContext.Provider value={contextValue}>{children}</SortableGridContext.Provider>
)
}
export const TIME_TO_ACTIVATE_PAN = 300
export const TOUCH_SLOP = 10
export const AUTO_SCROLL_THRESHOLD = 50
This diff is collapsed.
export { default as SortableGrid } from './SortableGrid'
export * from './types'
import { FlatList, ScrollView, View } from 'react-native'
import { SharedValue } from 'react-native-reanimated'
export type Require<T, K extends keyof T = keyof T> = Required<Pick<T, K>> & Omit<T, K>
export type ItemMeasurements = {
height: number
width: number
x: number
y: number
}
export type AutoScrollProps = {
scrollableRef: React.RefObject<FlatList | ScrollView>
visibleHeight: SharedValue<number>
scrollY: SharedValue<number>
// The parent container inside the scrollable that wraps the grid
// (e.g. when the grid is rendered inside the FlatList header)
// if not provided, we assume that the grid is the first child in
// the scrollable container
containerRef?: React.RefObject<View>
}
export type SortableGridContextType = {
gridContainerRef: React.RefObject<View>
itemAtIndexMeasurements: SharedValue<ItemMeasurements[]>
dragActivationProgress: SharedValue<number>
activeIndex: number | null
previousActiveIndex: SharedValue<number | null>
activeTranslation: SharedValue<{ x: number; y: number }>
scrollOffsetDiff: SharedValue<number>
renderIndexToDisplayIndex: SharedValue<number[]>
setActiveIndex: (index: number | null) => void
onDragStart?: () => void
displayToRenderIndex: SharedValue<number[]>
activeItemScale: SharedValue<number>
visibleHeight: SharedValue<number>
activeItemOpacity: SharedValue<number>
activeItemShadowOpacity: SharedValue<number>
touchedIndex: SharedValue<number | null>
editable: boolean
containerStartOffset: SharedValue<number>
containerEndOffset: SharedValue<number>
}
export type SortableGridRenderItemInfo<I> = {
item: I
index: number
dragActivationProgress: SharedValue<number>
isTouched: SharedValue<boolean>
}
export type SortableGridRenderItem<I> = (info: SortableGridRenderItemInfo<I>) => JSX.Element
export type Vector = {
x: number
y: number
}
export type SortableGridChangeEvent<I> = {
data: I[]
fromIndex: number
toIndex: number
}
import { FlatList, ScrollView } from 'react-native'
const hasProp = <O extends object, P extends string>(
object: O,
prop: P
): object is O & Record<P, unknown> => {
return prop in object
}
export const defaultKeyExtractor = <I>(item: I, index: number): string => {
if (typeof item === 'string') return item
if (typeof item === 'object' && item !== null) {
if (hasProp(item, 'id')) return String(item.id)
if (hasProp(item, 'key')) return String(item.key)
}
return String(index)
}
export const isScrollView = (scrollable: ScrollView | FlatList): scrollable is ScrollView => {
return 'scrollTo' in scrollable
}
...@@ -10,6 +10,7 @@ import { selectCustomEndpoint } from 'src/features/tweaks/selectors' ...@@ -10,6 +10,7 @@ import { selectCustomEndpoint } from 'src/features/tweaks/selectors'
import { isNonJestDev } from 'utilities/src/environment' import { isNonJestDev } from 'utilities/src/environment'
import { logger } from 'utilities/src/logger/logger' import { logger } from 'utilities/src/logger/logger'
import { useAsyncData } from 'utilities/src/react/hooks' import { useAsyncData } from 'utilities/src/react/hooks'
import { uniswapUrls } from 'wallet/src/constants/urls'
import { import {
getCustomGraphqlHttpLink, getCustomGraphqlHttpLink,
getErrorLink, getErrorLink,
...@@ -17,6 +18,8 @@ import { ...@@ -17,6 +18,8 @@ import {
getPerformanceLink, getPerformanceLink,
getRestLink, getRestLink,
} from 'wallet/src/data/links' } from 'wallet/src/data/links'
import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants'
import { useFeatureFlag } from 'wallet/src/features/experiments/hooks'
export let apolloClient: ApolloClient<NormalizedCacheObject> | null = null export let apolloClient: ApolloClient<NormalizedCacheObject> | null = null
...@@ -30,6 +33,7 @@ if (isNonJestDev()) { ...@@ -30,6 +33,7 @@ if (isNonJestDev()) {
export const usePersistedApolloClient = (): ApolloClient<NormalizedCacheObject> | undefined => { export const usePersistedApolloClient = (): ApolloClient<NormalizedCacheObject> | undefined => {
const [client, setClient] = useState<ApolloClient<NormalizedCacheObject>>() const [client, setClient] = useState<ApolloClient<NormalizedCacheObject>>()
const customEndpoint = useAppSelector(selectCustomEndpoint) const customEndpoint = useAppSelector(selectCustomEndpoint)
const cloudflareGatewayEnabled = useFeatureFlag(FEATURE_FLAGS.CloudflareGateway)
const apolloLink = customEndpoint const apolloLink = customEndpoint
? getCustomGraphqlHttpLink(customEndpoint) ? getCustomGraphqlHttpLink(customEndpoint)
...@@ -47,6 +51,10 @@ export const usePersistedApolloClient = (): ApolloClient<NormalizedCacheObject> ...@@ -47,6 +51,10 @@ export const usePersistedApolloClient = (): ApolloClient<NormalizedCacheObject>
) )
} }
const restLink = cloudflareGatewayEnabled
? getRestLink(uniswapUrls.apiBaseUrlCloudflare)
: getRestLink()
const newClient = new ApolloClient({ const newClient = new ApolloClient({
assumeImmutableResults: true, assumeImmutableResults: true,
link: from([ link: from([
...@@ -56,7 +64,7 @@ export const usePersistedApolloClient = (): ApolloClient<NormalizedCacheObject> ...@@ -56,7 +64,7 @@ export const usePersistedApolloClient = (): ApolloClient<NormalizedCacheObject>
getPerformanceLink((args: any) => getPerformanceLink((args: any) =>
sendMobileAnalyticsEvent(MobileEventName.PerformanceGraphql, args) sendMobileAnalyticsEvent(MobileEventName.PerformanceGraphql, args)
), ),
getRestLink(), restLink,
apolloLink, apolloLink,
]), ]),
cache, cache,
...@@ -76,7 +84,7 @@ export const usePersistedApolloClient = (): ApolloClient<NormalizedCacheObject> ...@@ -76,7 +84,7 @@ export const usePersistedApolloClient = (): ApolloClient<NormalizedCacheObject>
setClient(newClient) setClient(newClient)
// Ensure this callback only is computed once even if apolloLink changes, // Ensure this callback only is computed once even if apolloLink changes,
// otherwise this will cause a rendering loop reinitializing the client // otherwise this will cause a rendering loop re-initializing the client
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
......
...@@ -13,6 +13,15 @@ const mock: MockedResponse<PortfolioBalancesQuery> = { ...@@ -13,6 +13,15 @@ const mock: MockedResponse<PortfolioBalancesQuery> = {
query: PortfolioBalancesDocument, query: PortfolioBalancesDocument,
variables: { variables: {
ownerAddress: Portfolios[0].ownerAddress, ownerAddress: Portfolios[0].ownerAddress,
valueModifiers: [
{
ownerAddress: Portfolios[0].ownerAddress,
tokenIncludeOverrides: undefined,
tokenExcludeOverrides: undefined,
includeSmallBalances: false,
includeSpamTokens: false,
},
],
}, },
}, },
result: { result: {
......
...@@ -5,7 +5,7 @@ import { NumberType } from 'utilities/src/format/types' ...@@ -5,7 +5,7 @@ import { NumberType } from 'utilities/src/format/types'
import { RelativeChange } from 'wallet/src/components/text/RelativeChange' import { RelativeChange } from 'wallet/src/components/text/RelativeChange'
import { PollingInterval } from 'wallet/src/constants/misc' import { PollingInterval } from 'wallet/src/constants/misc'
import { isWarmLoadingStatus } from 'wallet/src/data/utils' import { isWarmLoadingStatus } from 'wallet/src/data/utils'
import { usePortfolioBalancesQuery } from 'wallet/src/data/__generated__/types-and-hooks' import { usePortfolioTotalValue } from 'wallet/src/features/dataApi/balances'
import { FiatCurrency } from 'wallet/src/features/fiatCurrency/constants' import { FiatCurrency } from 'wallet/src/features/fiatCurrency/constants'
import { useAppFiatCurrency, useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks' import { useAppFiatCurrency, useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks'
import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
...@@ -15,12 +15,11 @@ interface PortfolioBalanceProps { ...@@ -15,12 +15,11 @@ interface PortfolioBalanceProps {
} }
export function PortfolioBalance({ owner }: PortfolioBalanceProps): JSX.Element { export function PortfolioBalance({ owner }: PortfolioBalanceProps): JSX.Element {
const { data, loading, networkStatus } = usePortfolioBalancesQuery({ const { data, loading, networkStatus } = usePortfolioTotalValue({
variables: { ownerAddress: owner }, address: owner,
// TransactionHistoryUpdater will refetch this query on new transaction. // TransactionHistoryUpdater will refetch this query on new transaction.
// No need to be super aggressive with polling here. // No need to be super aggressive with polling here.
pollInterval: PollingInterval.Normal, pollInterval: PollingInterval.Normal,
notifyOnNetworkStatusChange: true,
}) })
const currency = useAppFiatCurrency() const currency = useAppFiatCurrency()
const currencyComponents = useAppFiatCurrencyInfo() const currencyComponents = useAppFiatCurrencyInfo()
...@@ -29,14 +28,10 @@ export function PortfolioBalance({ owner }: PortfolioBalanceProps): JSX.Element ...@@ -29,14 +28,10 @@ export function PortfolioBalance({ owner }: PortfolioBalanceProps): JSX.Element
const isLoading = loading && !data const isLoading = loading && !data
const isWarmLoading = !!data && isWarmLoadingStatus(networkStatus) const isWarmLoading = !!data && isWarmLoadingStatus(networkStatus)
const portfolioBalance = data?.portfolios?.[0] const { percentChange, absoluteChangeUSD, balanceUSD } = data || {}
const portfolioChange = portfolioBalance?.tokensTotalDenominatedValueChange
const totalBalance = convertFiatAmountFormatted( const totalBalance = convertFiatAmountFormatted(balanceUSD, NumberType.PortfolioBalance)
portfolioBalance?.tokensTotalDenominatedValue?.value, const { amount: absoluteChange } = convertFiatAmount(absoluteChangeUSD)
NumberType.PortfolioBalance
)
const { amount: absoluteChange } = convertFiatAmount(portfolioChange?.absolute?.value)
// TODO gary re-enabling this for USD/Euros only, replace with more scalable approach // TODO gary re-enabling this for USD/Euros only, replace with more scalable approach
const shouldFadePortfolioDecimals = const shouldFadePortfolioDecimals =
(currency === FiatCurrency.UnitedStatesDollar || currency === FiatCurrency.Euro) && (currency === FiatCurrency.UnitedStatesDollar || currency === FiatCurrency.Euro) &&
...@@ -57,7 +52,7 @@ export function PortfolioBalance({ owner }: PortfolioBalanceProps): JSX.Element ...@@ -57,7 +52,7 @@ export function PortfolioBalance({ owner }: PortfolioBalanceProps): JSX.Element
<RelativeChange <RelativeChange
absoluteChange={absoluteChange} absoluteChange={absoluteChange}
arrowSize="$icon.16" arrowSize="$icon.16"
change={portfolioChange?.percentage?.value} change={percentChange}
loading={isLoading} loading={isLoading}
negativeChangeColor={isWarmLoading ? '$neutral2' : '$statusCritical'} negativeChangeColor={isWarmLoading ? '$neutral2' : '$statusCritical'}
positiveChangeColor={isWarmLoading ? '$neutral2' : '$statusSuccess'} positiveChangeColor={isWarmLoading ? '$neutral2' : '$statusSuccess'}
......
This diff is collapsed.
import { MobileState } from 'src/app/reducer'
export const selectHasViewedReviewScreen = (state: MobileState): boolean =>
state.behaviorHistory.hasViewedReviewScreen
export const selectHasSubmittedHoldToSwap = (state: MobileState): boolean =>
state.behaviorHistory.hasSubmittedHoldToSwap
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
/**
* Used to store persisted info about a users interactions with UI.
* We use this to show conditional UI, usually only for the first time a user views a new feature.
*/
export interface BehaviorHistoryState {
hasViewedReviewScreen: boolean // used for hold to swap tip on swap UI
hasSubmittedHoldToSwap: boolean
}
export const initialBehaviorHistoryState: BehaviorHistoryState = {
hasViewedReviewScreen: false,
hasSubmittedHoldToSwap: false,
}
const slice = createSlice({
name: 'behaviorHistory',
initialState: initialBehaviorHistoryState,
reducers: {
setHasViewedReviewScreen: (state, action: PayloadAction<boolean>) => {
state.hasViewedReviewScreen = action.payload
},
setHasSubmittedHoldToSwap: (state, action: PayloadAction<boolean>) => {
state.hasSubmittedHoldToSwap = action.payload
},
},
})
export const { setHasViewedReviewScreen, setHasSubmittedHoldToSwap } = slice.actions
export const behaviorHistoryReducer = slice.reducer
...@@ -9,7 +9,6 @@ export function useBalances(currencies: CurrencyId[] | undefined): PortfolioBala ...@@ -9,7 +9,6 @@ export function useBalances(currencies: CurrencyId[] | undefined): PortfolioBala
const address = useActiveAccountAddressWithThrow() const address = useActiveAccountAddressWithThrow()
const { data: balances } = usePortfolioBalances({ const { data: balances } = usePortfolioBalances({
address, address,
shouldPoll: false,
fetchPolicy: 'cache-and-network', fetchPolicy: 'cache-and-network',
}) })
......
...@@ -16,6 +16,7 @@ export interface ModalsState { ...@@ -16,6 +16,7 @@ export interface ModalsState {
[ModalName.FiatCurrencySelector]: AppModalState<undefined> [ModalName.FiatCurrencySelector]: AppModalState<undefined>
[ModalName.FiatOnRamp]: AppModalState<undefined> [ModalName.FiatOnRamp]: AppModalState<undefined>
[ModalName.FiatOnRampAggregator]: AppModalState<undefined> [ModalName.FiatOnRampAggregator]: AppModalState<undefined>
[ModalName.LanguageSelector]: AppModalState<undefined>
[ModalName.RemoveWallet]: AppModalState<RemoveWalletModalState> [ModalName.RemoveWallet]: AppModalState<RemoveWalletModalState>
[ModalName.RestoreWallet]: AppModalState<undefined> [ModalName.RestoreWallet]: AppModalState<undefined>
[ModalName.Send]: AppModalState<TransactionState> [ModalName.Send]: AppModalState<TransactionState>
......
...@@ -24,6 +24,12 @@ type FiatOnRampAggregatorModalParams = { ...@@ -24,6 +24,12 @@ type FiatOnRampAggregatorModalParams = {
name: ModalName.FiatOnRampAggregator name: ModalName.FiatOnRampAggregator
initialState?: undefined initialState?: undefined
} }
type LanguageSelectorModalParams = {
name: ModalName.LanguageSelector
initialState?: undefined
}
type RemoveWalletModalParams = { type RemoveWalletModalParams = {
name: ModalName.RemoveWallet name: ModalName.RemoveWallet
initialState?: RemoveWalletModalState initialState?: RemoveWalletModalState
...@@ -49,6 +55,7 @@ export type OpenModalParams = ...@@ -49,6 +55,7 @@ export type OpenModalParams =
| FiatCurrencySelectorParams | FiatCurrencySelectorParams
| FiatOnRampModalParams | FiatOnRampModalParams
| FiatOnRampAggregatorModalParams | FiatOnRampAggregatorModalParams
| LanguageSelectorModalParams
| RemoveWalletModalParams | RemoveWalletModalParams
| SendModalParams | SendModalParams
| SwapModalParams | SwapModalParams
...@@ -99,6 +106,10 @@ export const initialModalState: ModalsState = { ...@@ -99,6 +106,10 @@ export const initialModalState: ModalsState = {
isOpen: false, isOpen: false,
initialState: undefined, initialState: undefined,
}, },
[ModalName.LanguageSelector]: {
isOpen: false,
initialState: undefined,
},
[ModalName.FiatCurrencySelector]: { [ModalName.FiatCurrencySelector]: {
isOpen: false, isOpen: false,
initialState: undefined, initialState: undefined,
......
...@@ -25,7 +25,7 @@ export function buildReceiveNotification( ...@@ -25,7 +25,7 @@ export function buildReceiveNotification(
const { typeInfo, status, chainId, hash, id } = transactionDetails const { typeInfo, status, chainId, hash, id } = transactionDetails
// Only build notification object on successful receive transactions. // Only build notification object on successful receive transactions.
if (status !== TransactionStatus.Success || typeInfo.type !== TransactionType.Receive) { if (status !== TransactionStatus.Success || typeInfo.type !== TransactionType.Receive || !hash) {
return undefined return undefined
} }
......
...@@ -101,6 +101,7 @@ export const enum ModalName { ...@@ -101,6 +101,7 @@ export const enum ModalName {
AddWallet = 'add-wallet-modal', AddWallet = 'add-wallet-modal',
BlockedAddress = 'blocked-address', BlockedAddress = 'blocked-address',
ChooseProfilePhoto = 'choose-profile-photo-modal', ChooseProfilePhoto = 'choose-profile-photo-modal',
CloudBackupInfo = 'cloud-backup-info-modal',
Experiments = 'experiments', Experiments = 'experiments',
Explore = 'explore-modal', Explore = 'explore-modal',
FaceIDWarning = 'face-id-warning', FaceIDWarning = 'face-id-warning',
...@@ -110,7 +111,7 @@ export const enum ModalName { ...@@ -110,7 +111,7 @@ export const enum ModalName {
FiatOnRampAggregator = 'fiat-on-ramp-aggregator', FiatOnRampAggregator = 'fiat-on-ramp-aggregator',
FiatOnRampCountryList = 'fiat-on-ramp-country-list', FiatOnRampCountryList = 'fiat-on-ramp-country-list',
ForceUpgradeModal = 'force-upgrade-modal', ForceUpgradeModal = 'force-upgrade-modal',
CloudBackupInfo = 'cloud-backup-info-modal', LanguageSelector = 'language-selector-modal',
NetworkFeeInfo = 'network-fee-info', NetworkFeeInfo = 'network-fee-info',
NetworkSelector = 'network-selector-modal', NetworkSelector = 'network-selector-modal',
NftCollection = 'nft-collection', NftCollection = 'nft-collection',
...@@ -188,6 +189,7 @@ export const enum ElementName { ...@@ -188,6 +189,7 @@ export const enum ElementName {
OnboardingImportBackup = 'onboarding-import-backup', OnboardingImportBackup = 'onboarding-import-backup',
OnboardingImportSeedPhrase = 'onboarding-import-seed-phrase', OnboardingImportSeedPhrase = 'onboarding-import-seed-phrase',
OnboardingImportWatchedAccount = 'onboarding-import-watched-account', OnboardingImportWatchedAccount = 'onboarding-import-watched-account',
OpenDeviceLanguageSettings = 'open-device-language-settings',
OpenCameraRoll = 'open-camera-roll', OpenCameraRoll = 'open-camera-roll',
OpenNftsList = 'open-nfts-list', OpenNftsList = 'open-nfts-list',
QRCodeModalToggle = 'qr-code-modal-toggle', QRCodeModalToggle = 'qr-code-modal-toggle',
......
import { useCallback, useMemo } from 'react' import { useCallback, useMemo } from 'react'
import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { useAppDispatch, useAppSelector } from 'src/app/hooks'
import { useAccountList } from 'src/components/accounts/hooks'
import { sendMobileAnalyticsEvent } from 'src/features/telemetry' import { sendMobileAnalyticsEvent } from 'src/features/telemetry'
import { MobileEventName } from 'src/features/telemetry/constants' import { MobileEventName } from 'src/features/telemetry/constants'
import { import {
...@@ -13,7 +14,6 @@ import { ...@@ -13,7 +14,6 @@ import {
shouldReportBalances, shouldReportBalances,
} from 'src/features/telemetry/slice' } from 'src/features/telemetry/slice'
import { useAsyncData } from 'utilities/src/react/hooks' import { useAsyncData } from 'utilities/src/react/hooks'
import { useAccountListQuery } from 'wallet/src/data/__generated__/types-and-hooks'
import { Account, AccountType } from 'wallet/src/features/wallet/accounts/types' import { Account, AccountType } from 'wallet/src/features/wallet/accounts/types'
import { useAccounts } from 'wallet/src/features/wallet/hooks' import { useAccounts } from 'wallet/src/features/wallet/hooks'
import { sendWalletAppsFlyerEvent } from 'wallet/src/telemetry' import { sendWalletAppsFlyerEvent } from 'wallet/src/telemetry'
...@@ -33,8 +33,8 @@ export function useLastBalancesReporter(): () => void { ...@@ -33,8 +33,8 @@ export function useLastBalancesReporter(): () => void {
.map((a) => a.address) .map((a) => a.address)
}, [accounts]) }, [accounts])
const { data } = useAccountListQuery({ const { data } = useAccountList({
variables: { addresses: signerAccountAddresses }, addresses: signerAccountAddresses,
fetchPolicy: 'cache-first', fetchPolicy: 'cache-first',
}) })
......
...@@ -135,6 +135,7 @@ export type MobileEventProperties = { ...@@ -135,6 +135,7 @@ export type MobileEventProperties = {
swap_quote_block_number?: string swap_quote_block_number?: string
swap_flow_duration_milliseconds?: number swap_flow_duration_milliseconds?: number
is_hold_to_swap?: boolean is_hold_to_swap?: boolean
is_fiat_input_mode?: boolean
} & SwapTradeBaseProperties } & SwapTradeBaseProperties
[SwapEventName.SWAP_ESTIMATE_GAS_CALL_FAILED]: { [SwapEventName.SWAP_ESTIMATE_GAS_CALL_FAILED]: {
error?: ApolloError | FetchBaseQueryError | SerializedError | Error | string error?: ApolloError | FetchBaseQueryError | SerializedError | Error | string
......
...@@ -134,7 +134,7 @@ function TransactionSummaryLayout({ ...@@ -134,7 +134,7 @@ function TransactionSummaryLayout({
<Flex grow shrink> <Flex grow shrink>
<Flex grow> <Flex grow>
<Flex grow row alignItems="center" gap="$spacing4" justifyContent="space-between"> <Flex grow row alignItems="center" gap="$spacing4" justifyContent="space-between">
<Flex row alignItems="center" gap="$spacing4"> <Flex row shrink alignItems="center" gap="$spacing4">
{walletDisplayName?.name ? ( {walletDisplayName?.name ? (
<Text color="$accent1" numberOfLines={1} variant="body1"> <Text color="$accent1" numberOfLines={1} variant="body1">
{walletDisplayName.name} {walletDisplayName.name}
......
...@@ -8,7 +8,6 @@ import { useAppDispatch, useAppSelector } from 'src/app/hooks' ...@@ -8,7 +8,6 @@ import { useAppDispatch, useAppSelector } from 'src/app/hooks'
import { apolloClient } from 'src/data/usePersistedApolloClient' import { apolloClient } from 'src/data/usePersistedApolloClient'
import { buildReceiveNotification } from 'src/features/notifications/buildReceiveNotification' import { buildReceiveNotification } from 'src/features/notifications/buildReceiveNotification'
import { selectLastTxNotificationUpdate } from 'src/features/notifications/selectors' import { selectLastTxNotificationUpdate } from 'src/features/notifications/selectors'
import { useSelectAddressTransactions } from 'src/features/transactions/hooks'
import { ONE_SECOND_MS } from 'utilities/src/time/time' import { ONE_SECOND_MS } from 'utilities/src/time/time'
import { PollingInterval } from 'wallet/src/constants/misc' import { PollingInterval } from 'wallet/src/constants/misc'
import { GQLQueries } from 'wallet/src/data/queries' import { GQLQueries } from 'wallet/src/data/queries'
...@@ -24,6 +23,7 @@ import { ...@@ -24,6 +23,7 @@ import {
setNotificationStatus, setNotificationStatus,
} from 'wallet/src/features/notifications/slice' } from 'wallet/src/features/notifications/slice'
import { parseDataResponseToTransactionDetails } from 'wallet/src/features/transactions/history/utils' import { parseDataResponseToTransactionDetails } from 'wallet/src/features/transactions/history/utils'
import { useSelectAddressTransactions } from 'wallet/src/features/transactions/selectors'
import { TransactionStatus, TransactionType } from 'wallet/src/features/transactions/types' import { TransactionStatus, TransactionType } from 'wallet/src/features/transactions/types'
import { import {
useAccounts, useAccounts,
......
...@@ -33,7 +33,7 @@ export function TransactionPending({ ...@@ -33,7 +33,7 @@ export function TransactionPending({
const onPressViewTransaction = async (): Promise<void> => { const onPressViewTransaction = async (): Promise<void> => {
if (transaction) { if (transaction) {
await openTransactionLink(transaction.hash, transaction.chainId) await openTransactionLink(transaction?.hash, transaction.chainId)
} }
} }
......
...@@ -12,14 +12,12 @@ import { ...@@ -12,14 +12,12 @@ import {
createWrapFormFromTxDetails, createWrapFormFromTxDetails,
} from 'src/features/transactions/swap/createSwapFormFromTxDetails' } from 'src/features/transactions/swap/createSwapFormFromTxDetails'
import { transactionStateActions } from 'src/features/transactions/transactionState/transactionState' import { transactionStateActions } from 'src/features/transactions/transactionState/transactionState'
import { logger } from 'utilities/src/logger/logger'
import { ChainId } from 'wallet/src/constants/chains' import { ChainId } from 'wallet/src/constants/chains'
import { AssetType } from 'wallet/src/entities/assets' import { AssetType } from 'wallet/src/entities/assets'
import { useCurrencyInfo } from 'wallet/src/features/tokens/useCurrencyInfo' import { useCurrencyInfo } from 'wallet/src/features/tokens/useCurrencyInfo'
import { import {
makeSelectAddressTransactions,
makeSelectLocalTxCurrencyIds,
makeSelectTransaction, makeSelectTransaction,
useSelectAddressTransactions,
} from 'wallet/src/features/transactions/selectors' } from 'wallet/src/features/transactions/selectors'
import { finalizeTransaction } from 'wallet/src/features/transactions/slice' import { finalizeTransaction } from 'wallet/src/features/transactions/slice'
import { import {
...@@ -73,18 +71,6 @@ export function useSelectTransaction( ...@@ -73,18 +71,6 @@ export function useSelectTransaction(
return useAppSelector((state) => selectTransaction(state, { address, chainId, txId })) return useAppSelector((state) => selectTransaction(state, { address, chainId, txId }))
} }
export function useSelectAddressTransactions(
address: Address | null
): TransactionDetails[] | undefined {
const selectAddressTransactions = useMemo(makeSelectAddressTransactions, [])
return useAppSelector((state) => selectAddressTransactions(state, address))
}
export function useSelectLocalTxCurrencyIds(address: Address | null): Record<string, boolean> {
const selectLocalTxCurrencyIds = useMemo(makeSelectLocalTxCurrencyIds, [])
return useAppSelector((state) => selectLocalTxCurrencyIds(state, address))
}
export function useCreateSwapFormState( export function useCreateSwapFormState(
address: Address | undefined, address: Address | undefined,
chainId: ChainId | undefined, chainId: ChainId | undefined,
...@@ -273,7 +259,7 @@ export function useTokenFormActionHandlers(dispatch: React.Dispatch<AnyAction>): ...@@ -273,7 +259,7 @@ export function useTokenFormActionHandlers(dispatch: React.Dispatch<AnyAction>):
* Merge local and remote transactions. If duplicated hash found use data from local store. * Merge local and remote transactions. If duplicated hash found use data from local store.
*/ */
export function useMergeLocalAndRemoteTransactions( export function useMergeLocalAndRemoteTransactions(
address: string, address: Address,
remoteTransactions: TransactionDetails[] | undefined remoteTransactions: TransactionDetails[] | undefined
): TransactionDetails[] | undefined { ): TransactionDetails[] | undefined {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
...@@ -285,6 +271,7 @@ export function useMergeLocalAndRemoteTransactions( ...@@ -285,6 +271,7 @@ export function useMergeLocalAndRemoteTransactions(
if (!localTransactions?.length) return remoteTransactions if (!localTransactions?.length) return remoteTransactions
const txHashes = new Set<string>() const txHashes = new Set<string>()
const fiatOnRampTxs: TransactionDetails[] = []
const remoteTxMap: Map<string, TransactionDetails> = new Map() const remoteTxMap: Map<string, TransactionDetails> = new Map()
remoteTransactions.forEach((tx) => { remoteTransactions.forEach((tx) => {
...@@ -293,10 +280,7 @@ export function useMergeLocalAndRemoteTransactions( ...@@ -293,10 +280,7 @@ export function useMergeLocalAndRemoteTransactions(
remoteTxMap.set(txHash, tx) remoteTxMap.set(txHash, tx)
txHashes.add(txHash) txHashes.add(txHash)
} else { } else {
logger.error(new Error('Remote transaction is missing hash '), { fiatOnRampTxs.push(tx)
tags: { file: 'transactions/hooks', function: 'useMergeLocalAndRemoteTransactions' },
extra: { tx },
})
} }
}) })
...@@ -307,15 +291,11 @@ export function useMergeLocalAndRemoteTransactions( ...@@ -307,15 +291,11 @@ export function useMergeLocalAndRemoteTransactions(
localTxMap.set(txHash, tx) localTxMap.set(txHash, tx)
txHashes.add(txHash) txHashes.add(txHash)
} else { } else {
// TODO(MOB-1737): Figure out why transactions are missing a hash and fix root issue fiatOnRampTxs.push(tx)
logger.error(new Error('Local transaction is missing hash '), {
tags: { file: 'transactions/hooks', function: 'useMergeLocalAndRemoteTransactions' },
extra: { tx },
})
} }
}) })
const deDupedTxs: TransactionDetails[] = [] const deDupedTxs: TransactionDetails[] = [...fiatOnRampTxs]
for (const txHash of [...txHashes]) { for (const txHash of [...txHashes]) {
const remoteTx = remoteTxMap.get(txHash) const remoteTx = remoteTxMap.get(txHash)
...@@ -407,7 +387,7 @@ export function useLowestPendingNonce(): BigNumberish | undefined { ...@@ -407,7 +387,7 @@ export function useLowestPendingNonce(): BigNumberish | undefined {
*/ */
export function useAllTransactionsBetweenAddresses( export function useAllTransactionsBetweenAddresses(
sender: Address, sender: Address,
recipient: string | undefined | null recipient: Maybe<Address>
): TransactionDetails[] | undefined { ): TransactionDetails[] | undefined {
const txnsToSearch = useSelectAddressTransactions(sender) const txnsToSearch = useSelectAddressTransactions(sender)
return useMemo(() => { return useMemo(() => {
......
...@@ -14,8 +14,7 @@ import { TransactionDetails } from 'src/features/transactions/TransactionDetails ...@@ -14,8 +14,7 @@ import { TransactionDetails } from 'src/features/transactions/TransactionDetails
import { Flex, Text, TouchableArea } from 'ui/src' import { Flex, Text, TouchableArea } from 'ui/src'
import { InfoCircleFilled } from 'ui/src/components/icons' import { InfoCircleFilled } from 'ui/src/components/icons'
import { NumberType } from 'utilities/src/format/types' import { NumberType } from 'utilities/src/format/types'
import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants' import { useSwapRewriteEnabled } from 'wallet/src/features/experiments/hooks'
import { useFeatureFlag } from 'wallet/src/features/experiments/hooks'
import { GasFeeResult } from 'wallet/src/features/gas/types' import { GasFeeResult } from 'wallet/src/features/gas/types'
import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
import { useUSDCPrice } from 'wallet/src/features/routing/useUSDCPrice' import { useUSDCPrice } from 'wallet/src/features/routing/useUSDCPrice'
...@@ -82,7 +81,7 @@ export function SwapDetails({ ...@@ -82,7 +81,7 @@ export function SwapDetails({
const formatter = useLocalizationContext() const formatter = useLocalizationContext()
const { convertFiatAmountFormatted } = useLocalizationContext() const { convertFiatAmountFormatted } = useLocalizationContext()
const shouldShowSwapRewrite = useFeatureFlag(FEATURE_FLAGS.SwapRewrite) const shouldShowSwapRewrite = useSwapRewriteEnabled()
const trade = derivedSwapInfo.trade.trade const trade = derivedSwapInfo.trade.trade
const acceptedTrade = acceptedDerivedSwapInfo.trade.trade const acceptedTrade = acceptedDerivedSwapInfo.trade.trade
...@@ -241,7 +240,7 @@ function AcceptNewQuoteRow({ ...@@ -241,7 +240,7 @@ function AcceptNewQuoteRow({
const { t } = useTranslation() const { t } = useTranslation()
const { formatCurrencyAmount } = useLocalizationContext() const { formatCurrencyAmount } = useLocalizationContext()
const shouldShowSwapRewrite = useFeatureFlag(FEATURE_FLAGS.SwapRewrite) const shouldShowSwapRewrite = useSwapRewriteEnabled()
const derivedCurrencyField = const derivedCurrencyField =
derivedSwapInfo.exactCurrencyField === CurrencyField.INPUT derivedSwapInfo.exactCurrencyField === CurrencyField.INPUT
......
...@@ -159,8 +159,7 @@ function _SwapForm({ ...@@ -159,8 +159,7 @@ function _SwapForm({
const derivedCurrencyField = const derivedCurrencyField =
exactCurrencyField === CurrencyField.INPUT ? CurrencyField.OUTPUT : CurrencyField.INPUT exactCurrencyField === CurrencyField.INPUT ? CurrencyField.OUTPUT : CurrencyField.INPUT
// TODO gary MOB-2028 replace temporary hack to handle different separators // Swap input requires numeric values, not localized ones
// Replace with localized version of formatter
const formattedDerivedValue = formatCurrencyAmount({ const formattedDerivedValue = formatCurrencyAmount({
amount: currencyAmounts[derivedCurrencyField], amount: currencyAmounts[derivedCurrencyField],
locale: 'en-US', locale: 'en-US',
......
...@@ -10,6 +10,7 @@ import { providers } from 'ethers' ...@@ -10,6 +10,7 @@ import { providers } from 'ethers'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AnyAction } from 'redux' import { AnyAction } from 'redux'
import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { useAppDispatch, useAppSelector } from 'src/app/hooks'
import { setHasSubmittedHoldToSwap } from 'src/features/behaviorHistory/slice'
import { sendMobileAnalyticsEvent } from 'src/features/telemetry' import { sendMobileAnalyticsEvent } from 'src/features/telemetry'
import { selectSwapStartTimestamp } from 'src/features/telemetry/timing/selectors' import { selectSwapStartTimestamp } from 'src/features/telemetry/timing/selectors'
import { updateSwapStartTimestamp } from 'src/features/telemetry/timing/slice' import { updateSwapStartTimestamp } from 'src/features/telemetry/timing/slice'
...@@ -732,7 +733,8 @@ export function useSwapCallback( ...@@ -732,7 +733,8 @@ export function useSwapCallback(
isAutoSlippage: boolean, isAutoSlippage: boolean,
onSubmit: () => void, onSubmit: () => void,
txId?: string, txId?: string,
isHoldToSwap?: boolean isHoldToSwap?: boolean,
isFiatInputMode?: boolean
): () => void { ): () => void {
const appDispatch = useAppDispatch() const appDispatch = useAppDispatch()
const account = useActiveAccount() const account = useActiveAccount()
...@@ -784,10 +786,16 @@ export function useSwapCallback( ...@@ -784,10 +786,16 @@ export function useSwapCallback(
? Date.now() - swapStartTimestamp ? Date.now() - swapStartTimestamp
: undefined, : undefined,
is_hold_to_swap: isHoldToSwap, is_hold_to_swap: isHoldToSwap,
is_fiat_input_mode: isFiatInputMode,
}) })
// Reset swap start timestamp now that the swap has been submitted // Reset swap start timestamp now that the swap has been submitted
appDispatch(updateSwapStartTimestamp({ timestamp: undefined })) appDispatch(updateSwapStartTimestamp({ timestamp: undefined }))
// Mark hold to swap persisted user behavior
if (isHoldToSwap) {
appDispatch(setHasSubmittedHoldToSwap(true))
}
} }
}, [ }, [
account, account,
...@@ -804,6 +812,7 @@ export function useSwapCallback( ...@@ -804,6 +812,7 @@ export function useSwapCallback(
isAutoSlippage, isAutoSlippage,
swapStartTimestamp, swapStartTimestamp,
isHoldToSwap, isHoldToSwap,
isFiatInputMode,
]) ])
} }
......
...@@ -12,8 +12,7 @@ import { ...@@ -12,8 +12,7 @@ import {
import { DerivedSwapInfo } from 'src/features/transactions/swap/types' import { DerivedSwapInfo } from 'src/features/transactions/swap/types'
import { formatPriceImpact } from 'utilities/src/format/formatPriceImpact' import { formatPriceImpact } from 'utilities/src/format/formatPriceImpact'
import { useMemoCompare } from 'utilities/src/react/hooks' import { useMemoCompare } from 'utilities/src/react/hooks'
import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants' import { useSwapRewriteEnabled } from 'wallet/src/features/experiments/hooks'
import { useFeatureFlag } from 'wallet/src/features/experiments/hooks'
import { import {
API_RATE_LIMIT_ERROR, API_RATE_LIMIT_ERROR,
NO_QUOTE_DATA, NO_QUOTE_DATA,
...@@ -143,7 +142,7 @@ export function useSwapWarnings(t: TFunction, derivedSwapInfo: DerivedSwapInfo): ...@@ -143,7 +142,7 @@ export function useSwapWarnings(t: TFunction, derivedSwapInfo: DerivedSwapInfo):
// See for more here: https://github.com/react-native-netinfo/react-native-netinfo/pull/444 // See for more here: https://github.com/react-native-netinfo/react-native-netinfo/pull/444
const offline = isOffline(networkStatus) const offline = isOffline(networkStatus)
const isSwapRewriteFeatureEnabled = useFeatureFlag(FEATURE_FLAGS.SwapRewrite) const isSwapRewriteFeatureEnabled = useSwapRewriteEnabled()
return useMemoCompare( return useMemoCompare(
() => getSwapWarnings(t, derivedSwapInfo, offline, isSwapRewriteFeatureEnabled), () => getSwapWarnings(t, derivedSwapInfo, offline, isSwapRewriteFeatureEnabled),
......
...@@ -10,6 +10,7 @@ import { BlockedAddressWarning } from 'src/features/trm/BlockedAddressWarning' ...@@ -10,6 +10,7 @@ import { BlockedAddressWarning } from 'src/features/trm/BlockedAddressWarning'
import { AnimatedFlex, Flex, Icons, Text, TouchableArea, useSporeColors } from 'ui/src' import { AnimatedFlex, Flex, Icons, Text, TouchableArea, useSporeColors } from 'ui/src'
import { iconSizes } from 'ui/src/theme' import { iconSizes } from 'ui/src/theme'
import { NumberType } from 'utilities/src/format/types' import { NumberType } from 'utilities/src/format/types'
import { SwapRewriteVariant, useSwapRewriteVariant } from 'wallet/src/features/experiments/hooks'
import { useUSDValue } from 'wallet/src/features/gas/hooks' import { useUSDValue } from 'wallet/src/features/gas/hooks'
import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
import { useIsBlockedActiveAddress } from 'wallet/src/features/trm/hooks' import { useIsBlockedActiveAddress } from 'wallet/src/features/trm/hooks'
...@@ -33,6 +34,12 @@ export function GasAndWarningRows({ renderEmptyRows }: { renderEmptyRows: boolea ...@@ -33,6 +34,12 @@ export function GasAndWarningRows({ renderEmptyRows }: { renderEmptyRows: boolea
const gasFeeUSD = useUSDValue(chainId, gasFee?.value) const gasFeeUSD = useUSDValue(chainId, gasFee?.value)
const gasFeeFormatted = convertFiatAmountFormatted(gasFeeUSD, NumberType.FiatGasPrice) const gasFeeFormatted = convertFiatAmountFormatted(gasFeeUSD, NumberType.FiatGasPrice)
const swapRewriteVariant = useSwapRewriteVariant()
const hideGasInfoForTest = swapRewriteVariant === SwapRewriteVariant.RewriteNoGas
// only show the gas fee icon and price if we have a valid fee, and not hidden by experiment variant
const showGasFee = Boolean(gasFeeUSD && !hideGasInfoForTest)
const onSwapWarningClick = useCallback(() => { const onSwapWarningClick = useCallback(() => {
if (!formScreenWarning?.warning.message) { if (!formScreenWarning?.warning.message) {
// Do not show the modal if the warning doesn't have a message. // Do not show the modal if the warning doesn't have a message.
...@@ -74,7 +81,7 @@ export function GasAndWarningRows({ renderEmptyRows }: { renderEmptyRows: boolea ...@@ -74,7 +81,7 @@ export function GasAndWarningRows({ renderEmptyRows }: { renderEmptyRows: boolea
/> />
)} )}
{gasFeeUSD && ( {showGasFee && (
<TouchableArea hapticFeedback onPress={(): void => setShowGasInfoModal(true)}> <TouchableArea hapticFeedback onPress={(): void => setShowGasInfoModal(true)}>
<AnimatedFlex centered row entering={FadeIn} gap="$spacing4"> <AnimatedFlex centered row entering={FadeIn} gap="$spacing4">
<Icons.Gas color={colors.neutral2.val} size="$icon.16" /> <Icons.Gas color={colors.neutral2.val} size="$icon.16" />
......
import React, { useCallback, useMemo } from 'react' import React, { useCallback, useMemo } from 'react'
import { TFunction, useTranslation } from 'react-i18next' import { TFunction, useTranslation } from 'react-i18next'
import { useAppSelector as useMobileAppSelector } from 'src/app/hooks'
import Trace from 'src/components/Trace/Trace' import Trace from 'src/components/Trace/Trace'
import {
selectHasSubmittedHoldToSwap,
selectHasViewedReviewScreen,
} from 'src/features/behaviorHistory/selectors'
import { ElementName } from 'src/features/telemetry/constants' import { ElementName } from 'src/features/telemetry/constants'
import { isWrapAction } from 'src/features/transactions/swap/utils' import { isWrapAction } from 'src/features/transactions/swap/utils'
import { useSwapFormContext } from 'src/features/transactions/swapRewrite/contexts/SwapFormContext' import { useSwapFormContext } from 'src/features/transactions/swapRewrite/contexts/SwapFormContext'
...@@ -18,11 +23,14 @@ import { Button, Flex, Icons, Text } from 'ui/src' ...@@ -18,11 +23,14 @@ import { Button, Flex, Icons, Text } from 'ui/src'
import { WrapType } from 'wallet/src/features/transactions/types' import { WrapType } from 'wallet/src/features/transactions/types'
import { createTransactionId } from 'wallet/src/features/transactions/utils' import { createTransactionId } from 'wallet/src/features/transactions/utils'
import { useIsBlockedActiveAddress } from 'wallet/src/features/trm/hooks' import { useIsBlockedActiveAddress } from 'wallet/src/features/trm/hooks'
import { AccountType } from 'wallet/src/features/wallet/accounts/types'
import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks'
export const HOLD_TO_SWAP_TIMEOUT = 3000 export const HOLD_TO_SWAP_TIMEOUT = 3000
export function SwapFormButton(): JSX.Element { export function SwapFormButton(): JSX.Element {
const { t } = useTranslation() const { t } = useTranslation()
const activeAccount = useActiveAccountWithThrow()
const { screen, setScreen } = useSwapScreenContext() const { screen, setScreen } = useSwapScreenContext()
const { derivedSwapInfo, isSubmitting, updateSwapForm } = useSwapFormContext() const { derivedSwapInfo, isSubmitting, updateSwapForm } = useSwapFormContext()
...@@ -47,6 +55,15 @@ export function SwapFormButton(): JSX.Element { ...@@ -47,6 +55,15 @@ export function SwapFormButton(): JSX.Element {
const isHoldToSwapPressed = screen === SwapScreen.SwapReviewHoldingToSwap || isSubmitting const isHoldToSwapPressed = screen === SwapScreen.SwapReviewHoldingToSwap || isSubmitting
const hasViewedReviewScreen = useMobileAppSelector(selectHasViewedReviewScreen)
const hasSubmittedHoldToSwap = useMobileAppSelector(selectHasSubmittedHoldToSwap)
const showHoldToSwapTip =
hasViewedReviewScreen && !hasSubmittedHoldToSwap && activeAccount.type !== AccountType.Readonly
// Force users to view regular review screen before enabling hold to swap
// Disable for view only because onSwap action will fail
const enableHoldToSwap = hasViewedReviewScreen && activeAccount.type !== AccountType.Readonly
const onReview = useCallback( const onReview = useCallback(
(nextScreen: SwapScreen) => { (nextScreen: SwapScreen) => {
updateSwapForm({ txId: createTransactionId() }) updateSwapForm({ txId: createTransactionId() })
...@@ -60,8 +77,10 @@ export function SwapFormButton(): JSX.Element { ...@@ -60,8 +77,10 @@ export function SwapFormButton(): JSX.Element {
}, [onReview]) }, [onReview])
const onLongPressHoldToSwap = useCallback(() => { const onLongPressHoldToSwap = useCallback(() => {
if (enableHoldToSwap) {
onReview(SwapScreen.SwapReviewHoldingToSwap) onReview(SwapScreen.SwapReviewHoldingToSwap)
}, [onReview]) }
}, [enableHoldToSwap, onReview])
const onReleaseHoldToSwap = useCallback(() => { const onReleaseHoldToSwap = useCallback(() => {
if (isHoldToSwapPressed && !isSubmitting) { if (isHoldToSwapPressed && !isSubmitting) {
...@@ -73,7 +92,7 @@ export function SwapFormButton(): JSX.Element { ...@@ -73,7 +92,7 @@ export function SwapFormButton(): JSX.Element {
return ( return (
<Flex alignItems="center" gap="$spacing16"> <Flex alignItems="center" gap="$spacing16">
{!isHoldToSwapPressed && <HoldToInstantSwapRow />} {!isHoldToSwapPressed && showHoldToSwapTip && <HoldToInstantSwapRow />}
<Trace logPress element={ElementName.SwapReview}> <Trace logPress element={ElementName.SwapReview}>
<Button <Button
...@@ -116,9 +135,9 @@ function HoldToInstantSwapRow(): JSX.Element { ...@@ -116,9 +135,9 @@ function HoldToInstantSwapRow(): JSX.Element {
return ( return (
<Flex centered row gap="$spacing4"> <Flex centered row gap="$spacing4">
<Icons.Lightning color="$neutral3" size="$icon.12" /> <Icons.GraduationCap color="$neutral3" size="$icon.16" />
<Text color="$neutral3" variant="body3"> <Text color="$neutral3" variant="body3">
{t('Hold to instant swap')} {t('Tip: Hold to instant swap')}
</Text> </Text>
</Flex> </Flex>
) )
......
...@@ -327,8 +327,7 @@ function SwapFormContent(): JSX.Element { ...@@ -327,8 +327,7 @@ function SwapFormContent(): JSX.Element {
}) })
}, [exactFieldIsInput, input, output, updateSwapForm]) }, [exactFieldIsInput, input, output, updateSwapForm])
// TODO gary MOB-2028 replace temporary hack to handle different separators // Swap input requires numeric values, not localized ones
// Replace with localized version of formatter
const formattedDerivedValue = formatCurrencyAmount({ const formattedDerivedValue = formatCurrencyAmount({
amount: currencyAmounts[derivedCurrencyField], amount: currencyAmounts[derivedCurrencyField],
locale: 'en-US', locale: 'en-US',
......
...@@ -2,12 +2,14 @@ import { notificationAsync } from 'expo-haptics' ...@@ -2,12 +2,14 @@ import { notificationAsync } from 'expo-haptics'
import React, { useCallback, useEffect, useRef, useState } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { FadeIn } from 'react-native-reanimated' import { FadeIn } from 'react-native-reanimated'
import { useAppDispatch } from 'src/app/hooks' import { useAppDispatch, useAppSelector } from 'src/app/hooks'
import { Arrow } from 'src/components/icons/Arrow' import { Arrow } from 'src/components/icons/Arrow'
import { BiometricsIcon } from 'src/components/icons/BiometricsIcon' import { BiometricsIcon } from 'src/components/icons/BiometricsIcon'
import { SpinningLoader } from 'src/components/loading/SpinningLoader' import { SpinningLoader } from 'src/components/loading/SpinningLoader'
import WarningModal from 'src/components/modals/WarningModal/WarningModal' import WarningModal from 'src/components/modals/WarningModal/WarningModal'
import { OnShowSwapFeeInfo } from 'src/components/SwapFee/SwapFee' import { OnShowSwapFeeInfo } from 'src/components/SwapFee/SwapFee'
import { selectHasViewedReviewScreen } from 'src/features/behaviorHistory/selectors'
import { setHasViewedReviewScreen } from 'src/features/behaviorHistory/slice'
import { import {
useBiometricAppSettings, useBiometricAppSettings,
useBiometricPrompt, useBiometricPrompt,
...@@ -78,6 +80,7 @@ export function SwapReviewScreen({ hideContent }: { hideContent: boolean }): JSX ...@@ -78,6 +80,7 @@ export function SwapReviewScreen({ hideContent }: { hideContent: boolean }): JSX
isSubmitting, isSubmitting,
onClose, onClose,
updateSwapForm, updateSwapForm,
isFiatMode,
} = useSwapFormContext() } = useSwapFormContext()
const { const {
...@@ -156,7 +159,8 @@ export function SwapReviewScreen({ hideContent }: { hideContent: boolean }): JSX ...@@ -156,7 +159,8 @@ export function SwapReviewScreen({ hideContent }: { hideContent: boolean }): JSX
!customSlippageTolerance, !customSlippageTolerance,
navigateToNextScreen, navigateToNextScreen,
txId, txId,
screen === SwapScreen.SwapReviewHoldingToSwap screen === SwapScreen.SwapReviewHoldingToSwap,
isFiatMode
) )
const submitTransaction = useCallback(() => { const submitTransaction = useCallback(() => {
...@@ -316,6 +320,12 @@ export function SwapReviewScreen({ hideContent }: { hideContent: boolean }): JSX ...@@ -316,6 +320,12 @@ export function SwapReviewScreen({ hideContent }: { hideContent: boolean }): JSX
setShowSwapFeeInfoModal(false) setShowSwapFeeInfoModal(false)
}, []) }, [])
// Flag review screen user behavior, used to show hold to swap tip
const hasViewedReviewScreen = useAppSelector(selectHasViewedReviewScreen)
useEffect(() => {
if (!hasViewedReviewScreen) dispatch(setHasViewedReviewScreen(true))
}, [dispatch, hasViewedReviewScreen])
if (hideContent || !acceptedDerivedSwapInfo || (!isWrap && (!acceptedTrade || !trade))) { if (hideContent || !acceptedDerivedSwapInfo || (!isWrap && (!acceptedTrade || !trade))) {
// We forcefully hide the content via `hideContent` to allow the bottom sheet to animate faster while still allowing all API requests to trigger ASAP. // We forcefully hide the content via `hideContent` to allow the bottom sheet to animate faster while still allowing all API requests to trigger ASAP.
// A missing `acceptedTrade` or `trade` can happen when the user leaves the app and comes back to the review screen after 1 minute when the TTL for the quote has expired. // A missing `acceptedTrade` or `trade` can happen when the user leaves the app and comes back to the review screen after 1 minute when the TTL for the quote has expired.
......
...@@ -33,12 +33,12 @@ import { ...@@ -33,12 +33,12 @@ import {
AnimatedFlex, AnimatedFlex,
Button, Button,
Flex, Flex,
Icons,
Text, Text,
TouchableArea, TouchableArea,
useDeviceDimensions, useDeviceDimensions,
useSporeColors, useSporeColors,
} from 'ui/src' } from 'ui/src'
import AlertTriangleIcon from 'ui/src/assets/icons/alert-triangle.svg'
import InfoCircleFilled from 'ui/src/assets/icons/info-circle-filled.svg' import InfoCircleFilled from 'ui/src/assets/icons/info-circle-filled.svg'
import { iconSizes, spacing } from 'ui/src/theme' import { iconSizes, spacing } from 'ui/src/theme'
import { usePrevious } from 'utilities/src/react/hooks' import { usePrevious } from 'utilities/src/react/hooks'
...@@ -209,7 +209,7 @@ export function TransferTokenForm({ ...@@ -209,7 +209,7 @@ export function TransferTokenForm({
const TRANSFER_DIRECTION_BUTTON_SIZE = iconSizes.icon20 const TRANSFER_DIRECTION_BUTTON_SIZE = iconSizes.icon20
const TRANSFER_DIRECTION_BUTTON_INNER_PADDING = spacing.spacing12 const TRANSFER_DIRECTION_BUTTON_INNER_PADDING = spacing.spacing12
const TRANSFER_DIRECTION_BUTTON_BORDER_WIDTH = spacing.spacing4 const TRANSFER_DIRECTION_BUTTON_BORDER_WIDTH = spacing.spacing4
const SendWarningIcon = transferWarning?.icon ?? AlertTriangleIcon const SendWarningIcon = transferWarning?.icon ?? Icons.AlertCircle
return ( return (
<> <>
...@@ -356,7 +356,7 @@ export function TransferTokenForm({ ...@@ -356,7 +356,7 @@ export function TransferTokenForm({
strokeWidth={1.5} strokeWidth={1.5}
width={iconSizes.icon16} width={iconSizes.icon16}
/> />
<Text color={transferWarningColor.text} variant="subheading2"> <Text adjustsFontSizeToFit color={transferWarningColor.text} variant="body3">
{transferWarning.title} {transferWarning.title}
</Text> </Text>
</Flex> </Flex>
......
...@@ -8,8 +8,7 @@ import { ...@@ -8,8 +8,7 @@ import {
WarningSeverity, WarningSeverity,
} from 'src/components/modals/WarningModal/types' } from 'src/components/modals/WarningModal/types'
import { DerivedTransferInfo } from 'src/features/transactions/transfer/hooks' import { DerivedTransferInfo } from 'src/features/transactions/transfer/hooks'
import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants' import { useSwapRewriteEnabled } from 'wallet/src/features/experiments/hooks'
import { useFeatureFlag } from 'wallet/src/features/experiments/hooks'
import { useOnChainNativeCurrencyBalance } from 'wallet/src/features/portfolio/api' import { useOnChainNativeCurrencyBalance } from 'wallet/src/features/portfolio/api'
import { CurrencyField } from 'wallet/src/features/transactions/transactionState/types' import { CurrencyField } from 'wallet/src/features/transactions/transactionState/types'
import { hasSufficientFundsIncludingGas } from 'wallet/src/features/transactions/utils' import { hasSufficientFundsIncludingGas } from 'wallet/src/features/transactions/utils'
...@@ -42,7 +41,7 @@ export function useTransactionGasWarning({ ...@@ -42,7 +41,7 @@ export function useTransactionGasWarning({
}) })
const balanceInsufficient = currencyAmountIn && currencyBalanceIn?.lessThan(currencyAmountIn) const balanceInsufficient = currencyAmountIn && currencyBalanceIn?.lessThan(currencyAmountIn)
const isSwapRewriteFeatureEnabled = useFeatureFlag(FEATURE_FLAGS.SwapRewrite) const isSwapRewriteFeatureEnabled = useSwapRewriteEnabled()
return useMemo(() => { return useMemo(() => {
// if balance is already insufficient, dont need to show warning about network fee // if balance is already insufficient, dont need to show warning about network fee
......
...@@ -99,7 +99,6 @@ export function WatchWalletScreen({ navigation, route: { params } }: Props): JSX ...@@ -99,7 +99,6 @@ export function WatchWalletScreen({ navigation, route: { params } }: Props): JSX
// Allow smart contracts with non-null balances // Allow smart contracts with non-null balances
const { data: balancesById } = usePortfolioBalances({ const { data: balancesById } = usePortfolioBalances({
address: isSmartContractAddress ? (isAddress || resolvedAddress) ?? undefined : undefined, address: isSmartContractAddress ? (isAddress || resolvedAddress) ?? undefined : undefined,
shouldPoll: false,
fetchPolicy: 'cache-and-network', fetchPolicy: 'cache-and-network',
}) })
const isValidSmartContract = isSmartContractAddress && !!balancesById const isValidSmartContract = isSmartContractAddress && !!balancesById
......
...@@ -136,13 +136,31 @@ function NFTItemScreenContents({ ...@@ -136,13 +136,31 @@ function NFTItemScreenContents({
const inModal = useAppSelector(selectModalState(ModalName.Explore)).isOpen const inModal = useAppSelector(selectModalState(ModalName.Explore)).isOpen
const traceProperties = useMemo( const traceProperties: Record<string, Maybe<string | boolean>> = useMemo(() => {
() => const baseProps = {
asset?.collection?.name owner,
? { owner, address, tokenId, collectionName: asset?.collection?.name } address,
: undefined, tokenId,
[address, asset?.collection?.name, owner, tokenId] }
)
if (asset?.collection?.name) {
return {
...baseProps,
collectionName: asset?.collection?.name,
isMissingData: false,
}
}
if (fallbackData) {
return {
...baseProps,
collectionName: fallbackData.collectionName,
isMissingData: true,
}
}
return { ...baseProps, isMissingData: true }
}, [address, asset?.collection?.name, fallbackData, owner, tokenId])
const { colorLight, colorDark } = useNearestThemeColorFromImageUri(imageUrl) const { colorLight, colorDark } = useNearestThemeColorFromImageUri(imageUrl)
// check if colorLight passes contrast against card bg color, if not use fallback // check if colorLight passes contrast against card bg color, if not use fallback
......
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Linking } from 'react-native'
import { Action } from 'redux'
import { useAppDispatch } from 'src/app/hooks'
import { BottomSheetModal } from 'src/components/modals/BottomSheetModal'
import { closeModal } from 'src/features/modals/modalSlice'
import { ElementName, ModalName } from 'src/features/telemetry/constants'
import { Button, Flex, Icons, Text } from 'ui/src'
import { isAndroid } from 'wallet/src/utils/platform'
// TODO(MOB-1190): this is DEP_blue_300 at 10% opacity, remove when we have a named color for this
const LIGHT_BLUE = '#4C82FB1A'
const openLanguageSettings = async (): Promise<void> => {
if (isAndroid) {
await Linking.openSettings()
} else {
await Linking.openURL('app-settings:')
}
}
export function SettingsLanguageModal(): JSX.Element {
const dispatch = useAppDispatch()
const { t } = useTranslation()
const onClose = useCallback(
(): Action => dispatch(closeModal({ name: ModalName.LanguageSelector })),
[dispatch]
)
return (
<BottomSheetModal name={ModalName.LanguageSelector} onClose={onClose}>
<Flex centered mt="$spacing16">
<Flex borderRadius="$rounded12" p="$spacing12" style={{ backgroundColor: LIGHT_BLUE }}>
<Icons.Language color="$DEP_blue300" size="$icon.24" strokeWidth={1.5} />
</Flex>
</Flex>
<Flex gap="$spacing24" pt="$spacing24" px="$spacing24">
<Flex gap="$spacing8">
<Text textAlign="center" variant="subheading1">
{t('Change preferred language')}
</Text>
<Text color="$neutral2" textAlign="center" variant="body3">
{t(
'Uniswap defaults to your device‘s language settings. To change your preferred language, go to “Uniswap” in your device settings and tap on “Language”'
)}
</Text>
</Flex>
<Button
testID={ElementName.OpenDeviceLanguageSettings}
theme="tertiary"
onPress={openLanguageSettings}>
{t('Go to settings')}
</Button>
</Flex>
</BottomSheetModal>
)
}
This diff is collapsed.
...@@ -305,7 +305,7 @@ const renderItemSeparator = (): JSX.Element => <Flex pt="$spacing8" /> ...@@ -305,7 +305,7 @@ const renderItemSeparator = (): JSX.Element => <Flex pt="$spacing8" />
function AddressDisplayHeader({ address }: { address: Address }): JSX.Element { function AddressDisplayHeader({ address }: { address: Address }): JSX.Element {
const { t } = useTranslation() const { t } = useTranslation()
const ensName = useENS(ChainId.Mainnet, address)?.name const ensName = useENS(ChainId.Mainnet, address)?.name
const hasUnitag = !!useUnitag(address) const hasUnitag = !!useUnitag(address)?.username
const onPressEditProfile = (): void => { const onPressEditProfile = (): void => {
if (hasUnitag) { if (hasUnitag) {
......
...@@ -384,10 +384,8 @@ function HeaderRightElement({ ...@@ -384,10 +384,8 @@ function HeaderRightElement({
const { menuActions, onContextMenuPress } = useTokenContextMenu({ const { menuActions, onContextMenuPress } = useTokenContextMenu({
currencyId, currencyId,
isSpam: currentChainBalance?.currencyInfo.isSpam,
isNative: currentChainBalance?.currencyInfo.currency.isNative,
balanceUSD: currentChainBalance?.balanceUSD,
tokenSymbolForNotification: data?.token?.symbol, tokenSymbolForNotification: data?.token?.symbol,
portfolioBalance: currentChainBalance,
}) })
// Should be the same color as heart icon in not favorited state next to it // Should be the same color as heart icon in not favorited state next to it
......
...@@ -64,7 +64,11 @@ export function dismissInAppBrowser(): void { ...@@ -64,7 +64,11 @@ export function dismissInAppBrowser(): void {
WebBrowser.dismissBrowser() WebBrowser.dismissBrowser()
} }
export async function openTransactionLink(hash: string, chainId: ChainId): Promise<void> { export async function openTransactionLink(
hash: string | undefined,
chainId: ChainId
): Promise<void> {
if (!hash) return
const explorerUrl = getExplorerLink(chainId, hash, ExplorerDataType.TRANSACTION) const explorerUrl = getExplorerLink(chainId, hash, ExplorerDataType.TRANSACTION)
return openUri(explorerUrl) return openUri(explorerUrl)
} }
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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