ci(release): publish latest release

parent 19570349
......@@ -8,6 +8,9 @@ node_modules
# testing
coverage
# utility script output
scripts/dist
# next.js
.next/
out/
......
3.2.2
\ No newline at end of file
......@@ -17,3 +17,54 @@ index 4b5b90b7b478668fdff3fd12d5e028d423ada057..af30dc6f700b3b3cfde5c149bf1f8657
});
}
diff --git a/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuManager.java b/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuManager.java
index 81f7b4b58c946d1b2e14301f9b52ecffa1cd0643..403dac6450be24a8c4d26ffb8293b51a1485f6a8 100644
--- a/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuManager.java
+++ b/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuManager.java
@@ -45,6 +45,11 @@ public class ContextMenuManager extends ViewGroupManager<ContextMenuView> {
view.setDropdownMenuMode(enabled);
}
+ @ReactProp(name = "disabled")
+ public void setDisabled(ContextMenuView view, @Nullable boolean disabled) {
+ view.setDisabled(disabled);
+ }
+
@androidx.annotation.Nullable
@Override
public Map<String, Object> getExportedCustomDirectEventTypeConstants() {
diff --git a/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuView.java b/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuView.java
index af30dc6f700b3b3cfde5c149bf1f865786df3e27..aa04fe6d9458601fdcb9bb44f89e16bbc1ad9d39 100644
--- a/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuView.java
+++ b/android/src/main/java/com/mpiannucci/reactnativecontextmenu/ContextMenuView.java
@@ -43,6 +43,8 @@ public class ContextMenuView extends ReactViewGroup implements PopupMenu.OnMenuI
boolean cancelled = true;
+ private boolean disabled = false;
+
protected boolean dropdownMenuMode = false;
public ContextMenuView(final Context context) {
@@ -87,13 +89,18 @@ public class ContextMenuView extends ReactViewGroup implements PopupMenu.OnMenuI
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
- return true;
+ return disabled ? false : true;
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
- gestureDetector.onTouchEvent(ev);
- return true;
+ if (disabled) return false;
+ gestureDetector.onTouchEvent(ev);
+ return true;
+ }
+
+ public void setDisabled(boolean disabled) {
+ this.disabled = disabled;
}
public void setActions(@Nullable ReadableArray actions) {
IPFS hash of the deployment:
- CIDv0: `QmRfmKBiCcSDFdGRZuJKqPtyenCsCFasgiwNvTJQMrbjT6`
- CIDv1: `bafybeibrpcihc5cqqzntesoc5dtxyili3uvyrfmxi7fcx4uteto7ucpj6e`
- CIDv0: `QmYMiHUXKwbifsieRsk6jexS5sS321rZtsoEtu5s266ewa`
- CIDv1: `bafybeieu3j7cdq5nz4r6z7qnoxcyqq7bk4wjiyxmelpzprkvoaedcb4uze`
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.
Your Uniswap settings are never remembered across different URLs.
IPFS gateways:
- https://bafybeibrpcihc5cqqzntesoc5dtxyili3uvyrfmxi7fcx4uteto7ucpj6e.ipfs.dweb.link/
- https://bafybeibrpcihc5cqqzntesoc5dtxyili3uvyrfmxi7fcx4uteto7ucpj6e.ipfs.cf-ipfs.com/
- [ipfs://QmRfmKBiCcSDFdGRZuJKqPtyenCsCFasgiwNvTJQMrbjT6/](ipfs://QmRfmKBiCcSDFdGRZuJKqPtyenCsCFasgiwNvTJQMrbjT6/)
- https://bafybeieu3j7cdq5nz4r6z7qnoxcyqq7bk4wjiyxmelpzprkvoaedcb4uze.ipfs.dweb.link/
- https://bafybeieu3j7cdq5nz4r6z7qnoxcyqq7bk4wjiyxmelpzprkvoaedcb4uze.ipfs.cf-ipfs.com/
- [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
* **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
\ No newline at end of file
web/5.3.0
\ No newline at end of file
* @Uniswap/mobile-release-admins
\ No newline at end of file
......@@ -85,6 +85,9 @@ Set this as your default version:
Install cocoapods:
`gem install cocoapods -v 1.13.0`
If you hit ruby errors around `ActiveSupport.deprecator`, downgrade your `activesupport` package by running:
`gem uninstall activesupport && gem install activesupport -v 7.0.8`
### Add Xcode Command Line Tools
Open Xcode and go to:
......
......@@ -9,6 +9,7 @@
<item name="android:itemBackground">@color/item_background</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splashscreen_icon</item>
<item name="android:editTextBackground">@android:color/transparent</item>
</style>
</resources>
......@@ -10,6 +10,7 @@
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splashscreen_icon</item>
<item name="android:navigationBarColor">@color/background_material_light</item>
<item name="android:editTextBackground">@android:color/transparent</item>
</style>
</resources>
......@@ -179,8 +179,8 @@ function AppOuter(): JSX.Element | null {
<ApolloProvider client={client}>
<PersistGate loading={null} persistor={persistor}>
<ErrorBoundary>
<GestureHandlerRootView style={flexStyles.fill}>
<LocalizationContextProvider>
<GestureHandlerRootView style={flexStyles.fill}>
<WalletContextProvider>
<BiometricContextProvider>
<LockScreenContextProvider>
......@@ -202,8 +202,8 @@ function AppOuter(): JSX.Element | null {
</LockScreenContextProvider>
</BiometricContextProvider>
</WalletContextProvider>
</LocalizationContextProvider>
</GestureHandlerRootView>
</LocalizationContextProvider>
</ErrorBoundary>
</PersistGate>
</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 { useCallback, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { LayoutChangeEvent } from 'react-native'
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import type { AppDispatch } from 'src/app/store'
......@@ -70,27 +70,24 @@ export function useDynamicFontSizing(
onSetFontSize: (amount: string) => void
} {
const [fontSize, setFontSize] = useState(maxFontSize)
const [textInputElementWidth, setTextInputElementWidth] = useState<number>(0)
const textInputElementWidthRef = useRef(0)
const onLayout = useCallback(
(event: LayoutChangeEvent) => {
if (textInputElementWidth) return
const onLayout = useCallback((event: LayoutChangeEvent) => {
if (textInputElementWidthRef.current) return
const width = event.nativeEvent.layout.width
setTextInputElementWidth(width)
},
[setTextInputElementWidth, textInputElementWidth]
)
textInputElementWidthRef.current = width
}, [])
const onSetFontSize = useCallback(
(amount: string) => {
const stringWidth = getStringWidth(amount, maxCharWidthAtMaxFontSize, fontSize, maxFontSize)
const scaledSize = fontSize * (textInputElementWidth / stringWidth)
const scaledSize = fontSize * (textInputElementWidthRef.current / stringWidth)
const scaledSizeWithMin = Math.max(scaledSize, minFontSize)
const newFontSize = Math.round(Math.min(maxFontSize, scaledSizeWithMin))
setFontSize(newFontSize)
},
[fontSize, maxFontSize, minFontSize, maxCharWidthAtMaxFontSize, textInputElementWidth]
[fontSize, maxFontSize, minFontSize, maxCharWidthAtMaxFontSize]
)
return { onLayout, fontSize, onSetFontSize }
......
......@@ -53,6 +53,7 @@ import {
v51Schema,
v52Schema,
v53Schema,
v54Schema,
v5Schema,
v6Schema,
v7Schema,
......@@ -61,6 +62,7 @@ import {
} from 'src/app/schema'
import { persistConfig } from 'src/app/store'
import { ScannerModalState } from 'src/components/QRCodeScanner/constants'
import { initialBehaviorHistoryState } from 'src/features/behaviorHistory/slice'
import { initialBiometricsSettingsState } from 'src/features/biometrics/slice'
import { initialCloudBackupState } from 'src/features/CloudBackup/cloudBackupSlice'
import { initialPasswordLockoutState } from 'src/features/CloudBackup/passwordLockoutSlice'
......@@ -152,6 +154,7 @@ describe('Redux state migrations', () => {
modals: initialModalState,
notifications: initialNotificationsState,
passwordLockout: initialPasswordLockoutState,
behaviorHistory: initialBehaviorHistoryState,
providers: { isInitialized: false },
saga: {},
searchHistory: initialSearchHistoryState,
......@@ -1240,4 +1243,11 @@ describe('Redux state migrations', () => {
expect(v54.telemetry.walletIsFunded).toBe(false)
})
it('migrates from v54 to 55', () => {
const v54Stub = { ...v54Schema }
const v55 = migrations[55](v54Stub)
expect(v55.behaviorHistory.hasViewedReviewScreen).toBe(false)
})
})
......@@ -717,4 +717,15 @@ export const migrations = {
return newState
},
55: function addBehaviorHistory(state: any) {
const newState = { ...state }
newState.behaviorHistory = {
hasViewedReviewScreen: false,
hasSubmittedHoldToSwap: false,
}
return newState
},
}
......@@ -15,6 +15,7 @@ import { FiatOnRampAggregatorModal } from 'src/features/fiatOnRamp/FiatOnRampAgg
import { FiatOnRampModal } from 'src/features/fiatOnRamp/FiatOnRampModal'
import { ModalName } from 'src/features/telemetry/constants'
import { SettingsFiatCurrencyModal } from 'src/screens/SettingsFiatCurrencyModal'
import { SettingsLanguageModal } from 'src/screens/SettingsLanguageModal'
export function AppModals(): JSX.Element {
return (
......@@ -61,6 +62,10 @@ export function AppModals(): JSX.Element {
<RestoreWalletModal />
</LazyModalRenderer>
<LazyModalRenderer name={ModalName.LanguageSelector}>
<SettingsLanguageModal />
</LazyModalRenderer>
<LazyModalRenderer name={ModalName.FiatCurrencySelector}>
<SettingsFiatCurrencyModal />
</LazyModalRenderer>
......
......@@ -8,15 +8,14 @@ import { updateSwapStartTimestamp } from 'src/features/telemetry/timing/slice'
import { SwapFlow } from 'src/features/transactions/swap/SwapFlow'
import { SwapFlow as SwapFlowRewrite } from 'src/features/transactions/swapRewrite/SwapFlow'
import { useSporeColors } from 'ui/src'
import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants'
import { useFeatureFlag } from 'wallet/src/features/experiments/hooks'
import { useSwapRewriteEnabled } from 'wallet/src/features/experiments/hooks'
export function SwapModal(): JSX.Element {
const colors = useSporeColors()
const appDispatch = useAppDispatch()
const modalState = useAppSelector(selectModalState(ModalName.Swap))
const shouldShowSwapRewrite = useFeatureFlag(FEATURE_FLAGS.SwapRewrite)
const shouldShowSwapRewrite = useSwapRewriteEnabled()
const onClose = useCallback((): void => {
appDispatch(closeModal({ name: ModalName.Swap }))
......
import { combineReducers } from '@reduxjs/toolkit'
import { behaviorHistoryReducer } from 'src/features/behaviorHistory/slice'
import { biometricSettingsReducer } from 'src/features/biometrics/slice'
import { cloudBackupReducer } from 'src/features/CloudBackup/cloudBackupSlice'
import { passwordLockoutReducer } from 'src/features/CloudBackup/passwordLockoutSlice'
......@@ -14,6 +15,7 @@ import { monitoredSagaReducers } from './saga'
const reducers = {
...sharedReducers,
behaviorHistory: behaviorHistoryReducer,
biometricSettings: biometricSettingsReducer,
cloudBackup: cloudBackupReducer,
modals: modalsReducer,
......
......@@ -398,6 +398,14 @@ export const v54Schema = {
},
}
export const v55Schema = {
...v54Schema,
behaviorHistory: {
hasViewedReviewScreen: false,
hasSubmittedHoldToSwap: false,
},
}
// TODO: [MOB-201] use function with typed output when API reducers are removed from rootReducer
// export const getSchema = (): RootState => v0Schema
export const getSchema = (): typeof v54Schema => v54Schema
......@@ -55,6 +55,7 @@ const rtkQueryErrorLogger: Middleware = () => (next) => (action: PayloadAction<u
const whitelist: Array<ReducerNames | RootReducerNames> = [
'appearanceSettings',
'behaviorHistory',
'biometricSettings',
'favorites',
'notifications',
......@@ -74,7 +75,7 @@ export const persistConfig = {
key: 'root',
storage: reduxStorage,
whitelist,
version: 54,
version: 55,
migrate: createMigrate(migrations),
}
......
......@@ -17,10 +17,10 @@ import { TextLoaderWrapper } from 'ui/src/components/text/Text'
import { fonts } from 'ui/src/theme'
import { usePrevious } from 'utilities/src/react/hooks'
const NUMBER_ARRAY = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
const NUMBER_WIDTH_ARRAY = [29, 20, 29, 29, 29, 29, 29, 29, 29, 29] // width of digits in a font
const DIGIT_HEIGHT = 44
const ADDITIONAL_WIDTH_FOR_ANIMATIONS = 10
export const NUMBER_ARRAY = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
export const NUMBER_WIDTH_ARRAY = [29, 20, 29, 29, 29, 29, 29, 29, 29, 29] // width of digits in a font
export const DIGIT_HEIGHT = 44
export const ADDITIONAL_WIDTH_FOR_ANIMATIONS = 8
// TODO: remove need to manually define width of each character
const NUMBER_WIDTH_ARRAY_SCALED = NUMBER_WIDTH_ARRAY.map(
......@@ -171,6 +171,27 @@ function longestCommonPrefix(a: string, b: string): string {
return a.substr(0, i)
}
export const TopAndBottomGradient = (): JSX.Element => {
const colors = useSporeColors()
return (
<Svg height={DIGIT_HEIGHT} style={AnimatedNumberStyles.gradientStyle} width="100%">
<Defs>
<LinearGradient id="backgroundTop" x1="0%" x2="0%" y1="15%" y2="0%">
<Stop offset="0" stopColor={colors.surface1.val} stopOpacity="0" />
<Stop offset="1" stopColor={colors.surface1.val} stopOpacity="1" />
</LinearGradient>
<LinearGradient id="background" x1="0%" x2="0%" y1="85%" y2="100%">
<Stop offset="0" stopColor={colors.surface1.val} stopOpacity="0" />
<Stop offset="1" stopColor={colors.surface1.val} stopOpacity="1" />
</LinearGradient>
</Defs>
<Rect fill="url(#backgroundTop)" height={DIGIT_HEIGHT} opacity={1} width="100%" x="0" y="0" />
<Rect fill="url(#background)" height={DIGIT_HEIGHT} opacity={1} width="100%" x="0" y="0" />
</Svg>
)
}
const SCREEN_WIDTH_BUFFER = 50
// Used for initial layout larger than all screen sizes
......@@ -274,34 +295,7 @@ const AnimatedNumber = ({
backgroundColor="$surface1"
borderRadius="$rounded4"
width={MAX_DEVICE_WIDTH}>
<Svg height={DIGIT_HEIGHT} style={AnimatedNumberStyles.gradientStyle} width="100%">
<Defs>
<LinearGradient id="backgroundTop" x1="0%" x2="0%" y1="15%" y2="0%">
<Stop offset="0" stopColor={colors.surface1.val} stopOpacity="0" />
<Stop offset="1" stopColor={colors.surface1.val} stopOpacity="1" />
</LinearGradient>
<LinearGradient id="background" x1="0%" x2="0%" y1="85%" y2="100%">
<Stop offset="0" stopColor={colors.surface1.val} stopOpacity="0" />
<Stop offset="1" stopColor={colors.surface1.val} stopOpacity="1" />
</LinearGradient>
</Defs>
<Rect
fill="url(#backgroundTop)"
height={DIGIT_HEIGHT}
opacity={1}
width="100%"
x="0"
y="0"
/>
<Rect
fill="url(#background)"
height={DIGIT_HEIGHT}
opacity={1}
width="100%"
x="0"
y="0"
/>
</Svg>
<TopAndBottomGradient />
<Shine disabled={!warmLoading}>
<AnimatedFlex row entering={FadeIn} width={MAX_DEVICE_WIDTH}>
{chars?.map((_, index) => (
......@@ -331,24 +325,24 @@ const AnimatedNumber = ({
export default AnimatedNumber
const AnimatedNumberStyles = StyleSheet.create({
export const AnimatedNumberStyles = StyleSheet.create({
gradientStyle: {
position: 'absolute',
zIndex: 100,
},
})
const AnimatedCharStyles = StyleSheet.create({
export const AnimatedCharStyles = StyleSheet.create({
wrapperStyle: {
overflow: 'hidden',
},
})
const AnimatedFontStyles = StyleSheet.create({
export const AnimatedFontStyles = StyleSheet.create({
fontStyle: {
fontFamily: fonts.heading2.family,
fontSize: fonts.heading2.fontSize,
fontWeight: 500,
fontWeight: '500',
lineHeight: fonts.heading2.lineHeight,
top: 1,
},
......
import { ImpactFeedbackStyle } from 'expo-haptics'
import React, { memo, useMemo } from 'react'
import { memo, useEffect, useMemo, useState } from 'react'
import { I18nManager } from 'react-native'
import { SharedValue } from 'react-native-reanimated'
import {
LineChart,
LineChartProvider,
TLineChartData,
TLineChartDataProp,
} from 'react-native-wagmi-charts'
import { LineChart, LineChartProvider, TLineChartDataProp } from 'react-native-wagmi-charts'
import { Loader } from 'src/components/loading'
import { CURSOR_INNER_SIZE, CURSOR_SIZE } from 'src/components/PriceExplorer/constants'
import PriceExplorerAnimatedNumber from 'src/components/PriceExplorer/PriceExplorerAnimatedNumber'
import { PriceExplorerError } from 'src/components/PriceExplorer/PriceExplorerError'
import { DatetimeText, PriceText, RelativeChangeText } from 'src/components/PriceExplorer/Text'
import { DatetimeText, RelativeChangeText } from 'src/components/PriceExplorer/Text'
import { TimeRangeGroup } from 'src/components/PriceExplorer/TimeRangeGroup'
import { useChartDimensions } from 'src/components/PriceExplorer/useChartDimensions'
import { useLineChartPrice } from 'src/components/PriceExplorer/usePrice'
import { invokeImpact } from 'src/utils/haptic'
import { Flex, useDeviceDimensions } from 'ui/src'
import { Flex } from 'ui/src'
import { spacing } from 'ui/src/theme'
import { HistoryDuration } from 'wallet/src/data/__generated__/types-and-hooks'
import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
import { CurrencyId } from 'wallet/src/utils/currencyId'
import { TokenSpotData, useTokenPriceHistory } from './usePriceHistory'
import { PriceNumberOfDigits, TokenSpotData, useTokenPriceHistory } from './usePriceHistory'
type PriceTextProps = {
loading: boolean
relativeChange?: SharedValue<number>
numberOfDigits: PriceNumberOfDigits
}
function PriceTextSection({ loading, relativeChange }: PriceTextProps): JSX.Element {
const { fullWidth } = useDeviceDimensions()
function PriceTextSection({
loading,
relativeChange,
numberOfDigits,
}: PriceTextProps): JSX.Element {
const price = useLineChartPrice()
const mx = spacing.spacing12
return (
......@@ -36,7 +38,7 @@ function PriceTextSection({ loading, relativeChange }: PriceTextProps): JSX.Elem
{/* Specify maxWidth to allow text scalling. onLayout was sometimes called after more
than 5 seconds which is not acceptable so we have to provide the approximate width
of the PriceText component explicitly. */}
<PriceText loading={loading} maxWidth={fullWidth - 2 * mx} />
<PriceExplorerAnimatedNumber numberOfDigits={numberOfDigits} price={price} />
<Flex row gap="$spacing4">
<RelativeChangeText loading={loading} spotRelativeChange={relativeChange} />
<DatetimeText loading={loading} />
......@@ -60,22 +62,36 @@ export const PriceExplorer = memo(function PriceExplorer({
forcePlaceholder?: boolean
onRetry: () => void
}): JSX.Element {
const { data, loading, error, refetch, setDuration, selectedDuration } =
useTokenPriceHistory(currencyId)
const [fetchComplete, setFetchComplete] = useState(false)
const onFetchComplete = (): void => {
setFetchComplete(true)
}
const { data, loading, error, refetch, setDuration, selectedDuration, numberOfDigits } =
useTokenPriceHistory(currencyId, onFetchComplete)
useEffect(() => {
if (loading && fetchComplete) {
setFetchComplete(false)
}
}, [loading, fetchComplete])
const { convertFiatAmount } = useLocalizationContext()
const conversionRate = convertFiatAmount().amount
const shouldShowAnimatedDot =
selectedDuration === HistoryDuration.Day || selectedDuration === HistoryDuration.Hour
const additionalPadding = shouldShowAnimatedDot ? 40 : 0
const lastPricePoint = data?.priceHistory ? data.priceHistory.length - 1 : 0
const convertedPriceHistory = useMemo(
(): TLineChartData | undefined =>
data?.priceHistory?.map((point) => {
const { lastPricePoint, convertedPriceHistory } = useMemo(() => {
const priceHistory = data?.priceHistory?.map((point) => {
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 => {
return (
data?.spot && {
......@@ -99,21 +115,26 @@ export const PriceExplorer = memo(function PriceExplorer({
let content: JSX.Element | null
if (forcePlaceholder) {
content = <PriceExplorerPlaceholder loading={forcePlaceholder} />
content = (
<PriceExplorerPlaceholder loading={forcePlaceholder} numberOfDigits={numberOfDigits} />
)
} else if (convertedPriceHistory?.length) {
content = (
<Flex opacity={fetchComplete ? 1 : 0.35}>
<PriceExplorerChart
additionalPadding={additionalPadding}
lastPricePoint={lastPricePoint}
loading={loading}
numberOfDigits={numberOfDigits}
priceHistory={convertedPriceHistory}
shouldShowAnimatedDot={shouldShowAnimatedDot}
spot={convertedSpot}
tokenColor={tokenColor}
/>
</Flex>
)
} else {
content = <PriceExplorerPlaceholder loading={loading} />
content = <PriceExplorerPlaceholder loading={loading} numberOfDigits={numberOfDigits} />
}
return (
......@@ -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 (
<Flex gap="$spacing8">
<PriceTextSection loading={loading} />
<PriceTextSection loading={loading} numberOfDigits={numberOfDigits} />
<Flex my="$spacing24">
<Loader.Graph />
</Flex>
......@@ -143,6 +170,7 @@ function PriceExplorerChart({
additionalPadding,
shouldShowAnimatedDot,
lastPricePoint,
numberOfDigits,
}: {
priceHistory: TLineChartDataProp
spot?: TokenSpotData
......@@ -151,6 +179,7 @@ function PriceExplorerChart({
additionalPadding: number
shouldShowAnimatedDot: boolean
lastPricePoint: number
numberOfDigits: PriceNumberOfDigits
}): JSX.Element {
const { chartHeight, chartWidth } = useChartDimensions()
const isRTL = I18nManager.isRTL
......@@ -160,7 +189,11 @@ function PriceExplorerChart({
data={priceHistory}
onCurrentIndexChange={invokeImpact[ImpactFeedbackStyle.Light]}>
<Flex gap="$spacing8">
<PriceTextSection loading={loading} relativeChange={spot?.relativeChange} />
<PriceTextSection
loading={loading}
numberOfDigits={numberOfDigits}
relativeChange={spot?.relativeChange}
/>
{/* TODO(MOB-2166): remove forced LTR direction + scaleX horizontal flip technique once react-native-wagmi-charts fixes this: https://github.com/coinjar/react-native-wagmi-charts/issues/136 */}
<Flex direction="ltr" my="$spacing24" style={{ transform: [{ scaleX: isRTL ? -1 : 1 }] }}>
<LineChart height={chartHeight} width={chartWidth - additionalPadding} yGutter={20}>
......
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({
(currency === FiatCurrency.UnitedStatesDollar || currency === FiatCurrency.Euro) &&
symbolAtFront
if (loading) {
return <AnimatedText loading loadingPlaceholderText="$10,000" variant="heading1" />
}
// TODO(MOB-2308): re-enable this when we have a better solution for handling the loading state
// if (loading) {
// return <AnimatedText loading loadingPlaceholderText="$10,000" variant="heading1" />
// }
return (
<AnimatedDecimalNumber
......
......@@ -33,50 +33,24 @@ exports[`DatetimeText renders without error 1`] = `
exports[`PriceText renders loading state 1`] = `
<View
onLayout={[Function]}
style={
{
"alignItems": "stretch",
"flexDirection": "column",
"opacity": 0,
}
}
>
<View
style={
{
"alignItems": "center",
"flexDirection": "row",
}
}
>
<View
style={
{
"alignItems": "center",
"flexDirection": "row",
"position": "relative",
}
}
>
<View
accessibilityElementsHidden={true}
importantForAccessibility="no-hide-descendants"
>
<View
style={
testID="price-text"
>
<TextInput
allowFontScaling={true}
animatedProps={
{
"alignItems": "stretch",
"flexDirection": "row",
"text": "-",
}
}
>
<TextInput
allowFontScaling={true}
editable={false}
maxFontSizeMultiplier={1.2}
style={
[
[
{
"padding": 0,
......@@ -86,62 +60,20 @@ exports[`PriceText renders loading state 1`] = `
"fontSize": 53,
"lineHeight": 60,
},
undefined,
],
{
"marginHorizontal": 0,
"opacity": 0,
"paddingHorizontal": 0,
"width": 0,
},
]
}
underlineColorAndroid="transparent"
/>
<Text
style={
[
[
{
"padding": 0,
"color": "#222222",
},
{
"fontFamily": "Basel-Book",
"fontSize": 53,
"lineHeight": 60,
"fontSize": 106,
},
undefined,
],
{
"opacity": 0,
},
]
}
>
$10,000
</Text>
</View>
</View>
<View
style={
{
"alignItems": "stretch",
"backgroundColor": "#F9F9F9",
"borderBottomLeftRadius": 4,
"borderBottomRightRadius": 4,
"borderTopLeftRadius": 4,
"borderTopRightRadius": 4,
"bottom": "5%",
"flexDirection": "column",
"left": 0,
"position": "absolute",
"right": 0,
"top": "5%",
}
}
testID="wholePart"
underlineColorAndroid="transparent"
value="-"
/>
</View>
</View>
</View>
`;
......
import { useMemo } from 'react'
import { SharedValue, useDerivedValue } from 'react-native-reanimated'
import {
SharedValue,
useAnimatedReaction,
useDerivedValue,
useSharedValue,
} from 'react-native-reanimated'
import {
useLineChart,
useLineChartPrice as useRNWagmiChartLineChartPrice,
......@@ -8,9 +13,10 @@ import { numberToLocaleStringWorklet, numberToPercentWorklet } from 'src/utils/r
import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks'
import { useCurrentLocale } from 'wallet/src/features/language/hooks'
export type ValueAndFormatted<U = number, V = string> = {
export type ValueAndFormatted<U = number, V = string, B = boolean> = {
value: Readonly<SharedValue<U>>
formatted: Readonly<SharedValue<V>>
shouldAnimate: Readonly<SharedValue<B>>
}
/**
......@@ -23,6 +29,18 @@ export function useLineChartPrice(): ValueAndFormatted {
precision: 18,
})
const { data } = useLineChart()
const shouldAnimate = useSharedValue(true)
useAnimatedReaction(
() => {
return activeCursorPrice.value
},
(currentValue, previousValue) => {
if (previousValue && currentValue && shouldAnimate.value) {
shouldAnimate.value = false
}
}
)
const currencyInfo = useAppFiatCurrencyInfo()
const locale = useCurrentLocale()
......@@ -32,6 +50,7 @@ export function useLineChartPrice(): ValueAndFormatted {
return Number(activeCursorPrice.value)
}
shouldAnimate.value = true
return data[data.length - 1]?.value ?? 0
})
const priceFormatted = useDerivedValue(() => {
......@@ -50,8 +69,9 @@ export function useLineChartPrice(): ValueAndFormatted {
() => ({
value: price,
formatted: priceFormatted,
shouldAnimate,
}),
[price, priceFormatted]
[price, priceFormatted, shouldAnimate]
)
}
......@@ -65,6 +85,7 @@ export function useLineChartRelativeChange({
spotRelativeChange?: SharedValue<number>
}): ValueAndFormatted {
const { currentIndex, data, isActive } = useLineChart()
const shouldAnimate = useSharedValue(false)
const relativeChange = useDerivedValue(() => {
if (!isActive.value && Boolean(spotRelativeChange)) {
......@@ -98,5 +119,5 @@ export function useLineChartRelativeChange({
return numberToPercentWorklet(relativeChange.value, { precision: 2, absolute: true })
})
return { value: relativeChange, formatted: relativeChangeFormattted }
return { value: relativeChange, formatted: relativeChangeFormattted, shouldAnimate }
}
import { maxBy } from 'lodash'
import { Dispatch, SetStateAction, useCallback, useMemo, useState } from 'react'
import { SharedValue } from 'react-native-reanimated'
import { TLineChartData } from 'react-native-wagmi-charts'
......@@ -16,11 +17,17 @@ export type TokenSpotData = {
relativeChange: SharedValue<number>
}
export type PriceNumberOfDigits = {
left: number
right: number
}
/**
* @returns Token price history for requested duration
*/
export function useTokenPriceHistory(
currencyId: string,
onCompleted?: () => void,
initialDuration: HistoryDuration = HistoryDuration.Day
): Omit<
GqlResult<{
......@@ -32,6 +39,7 @@ export function useTokenPriceHistory(
setDuration: Dispatch<SetStateAction<HistoryDuration>>
selectedDuration: HistoryDuration
error: boolean
numberOfDigits: PriceNumberOfDigits
} {
const [duration, setDuration] = useState(initialDuration)
......@@ -46,7 +54,9 @@ export function useTokenPriceHistory(
},
notifyOnNetworkStatusChange: true,
pollInterval: PollingInterval.Normal,
fetchPolicy: 'cache-first',
onCompleted,
// TODO(MOB-2308): maybe update to network-only once we have a better loading state
fetchPolicy: 'cache-and-network',
})
const offChainData = priceData?.tokenProjects?.[0]?.markets?.[0]
......@@ -73,13 +83,24 @@ export function useTokenPriceHistory(
?.filter((x): x is TimestampedAmount => Boolean(x))
.map((x) => ({ timestamp: x.timestamp * 1000, value: x.value }))
// adds the current price to the chart given we show spot price/24h change
if (formatted && spot?.value) {
formatted?.push({ timestamp: Date.now(), value: spot.value.value })
return formatted
}, [priceHistory])
const numberOfDigits = useMemo(() => {
const max = maxBy(priceHistory, 'value')
if (max) {
return {
left: String(max.value).split('.')[0]?.length || 10,
right: Number(String(max.value).split('.')[0]) > 0 ? 2 : 10,
}
}
return formatted
}, [priceHistory, spot?.value])
return {
left: 0,
right: 0,
}
}, [priceHistory])
const retry = useCallback(async () => {
await refetch({ contract: currencyIdToContractInput(currencyId) })
......@@ -96,7 +117,18 @@ export function useTokenPriceHistory(
refetch: retry,
setDuration,
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
const [currentScreenState, setCurrentScreenState] = useState<ScannerModalState>(
ScannerModalState.ScanQr
)
const [hasScanError, setHasScanError] = useState(false)
const [shouldFreezeCamera, setShouldFreezeCamera] = useState(false)
const onScanCode = async (uri: string): Promise<void> => {
// don't scan any QR codes if there is an error popup open or camera is frozen
if (hasScanError || shouldFreezeCamera) return
// don't scan any QR codes if camera is frozen
if (shouldFreezeCamera) return
await selectionAsync()
setShouldFreezeCamera(true)
const supportedURI = await getSupportedURI(uri)
if (supportedURI?.type === URIType.Address) {
setShouldFreezeCamera(true)
onSelectRecipient(supportedURI.value)
onClose()
} else {
......@@ -49,7 +50,7 @@ export function RecipientScanModal({ onSelectRecipient, onClose }: Props): JSX.E
{
text: t('Try again'),
onPress: (): void => {
setHasScanError(false)
setShouldFreezeCamera(false)
},
},
]
......
import React from 'react'
import React, { useMemo } from 'react'
import { ScrollView, StyleSheet } from 'react-native'
import { useAccountList } from 'src/components/accounts/hooks'
import { AddressDisplay } from 'src/components/AddressDisplay'
import { Flex, Text, useDeviceDimensions } from 'ui/src'
import { spacing } from 'ui/src/theme'
import { NumberType } from 'utilities/src/format/types'
import {
AccountListQuery,
useAccountListQuery,
} from 'wallet/src/data/__generated__/types-and-hooks'
import { AccountListQuery } from 'wallet/src/data/__generated__/types-and-hooks'
import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
import { Account } from 'wallet/src/features/wallet/accounts/types'
......@@ -17,10 +15,9 @@ type Portfolio = NonNullable<NonNullable<NonNullable<AccountListQuery['portfolio
function _AssociatedAccountsList({ accounts }: { accounts: Account[] }): JSX.Element {
const { fullHeight } = useDeviceDimensions()
const { data, loading } = useAccountListQuery({
variables: {
addresses: accounts.map((account) => account.address),
},
const addresses = useMemo(() => accounts.map((account) => account.address), [accounts])
const { data, loading } = useAccountList({
addresses,
notifyOnNetworkStatusChange: true,
})
......
......@@ -151,7 +151,8 @@ export function RemoveWalletModal(): JSX.Element | null {
backgroundColor={colors.surface1.get()}
name={ModalName.RemoveSeedPhraseWarningModal}
onClose={onClose}>
<Flex centered gap="$spacing16" px="$spacing24" py="$spacing12">
<Flex gap="$spacing24" px="$spacing24" py="$spacing24">
<Flex centered gap="$spacing16">
<Flex
centered
borderRadius="$rounded12"
......@@ -159,14 +160,22 @@ export function RemoveWalletModal(): JSX.Element | null {
style={{
backgroundColor: opacify(12, colors[labelColor].val),
}}>
<Icon color={colors[labelColor].val} height={iconSizes.icon24} width={iconSizes.icon24} />
<Icon
color={colors[labelColor].val}
height={iconSizes.icon24}
width={iconSizes.icon24}
/>
</Flex>
<Flex gap="$spacing8">
<Text textAlign="center" variant="body1">
{title}
</Text>
<Text color="$neutral2" textAlign="center" variant="body2">
{description}
</Text>
</Flex>
</Flex>
<Flex centered gap="$spacing24">
{currentStep === RemoveWalletStep.Final && isRemovingRecoveryPhrase ? (
<>
<AssociatedAccountsList accounts={associatedAccounts} />
......@@ -200,6 +209,7 @@ export function RemoveWalletModal(): JSX.Element | null {
</Flex>
)}
</Flex>
</Flex>
</BottomSheetModal>
)
}
......@@ -136,7 +136,7 @@ export const useModalContent = ({
description: (
<Trans t={t}>
It shares the same recovery phrase as{' '}
<Text fontWeight="bold">{{ wallets: associatedAccountNames }}</Text>. Your recovery
<Text color="$neutral1">{{ wallets: associatedAccountNames }}</Text>. Your recovery
phrase will remain stored until you delete all remaining wallets.
</Trans>
),
......
......@@ -26,8 +26,7 @@ export interface SettingsSectionItemComponent {
component: JSX.Element
isHidden?: boolean
}
type SettingsModal = Extract<ModalName, ModalName.FiatCurrencySelector>
type SettingsModal = Extract<ModalName, ModalName.FiatCurrencySelector | ModalName.LanguageSelector>
export interface SettingsSectionItem {
screen?: keyof SettingsStackParamList | typeof Screens.OnboardingStack
modal?: SettingsModal
......
......@@ -11,15 +11,9 @@ export const TokenBalanceItemContextMenu = memo(function _TokenBalanceItem({
portfolioBalance: PortfolioBalance
children: React.ReactNode
}) {
const { currencyInfo, balanceUSD } = portfolioBalance
const { currency, currencyId, isSpam } = currencyInfo
const { menuActions, onContextMenuPress } = useTokenContextMenu({
currencyId,
isSpam,
balanceUSD,
isNative: currency.isNative,
accountHoldsToken: true,
currencyId: portfolioBalance.currencyInfo.currencyId,
portfolioBalance,
})
const style = useMemo(() => ({ borderRadius: borderRadii.rounded16 }), [])
......
......@@ -271,7 +271,14 @@ const TokenBalanceItemRow = memo(function TokenBalanceItemRow({
const portfolioBalance = balancesById?.[item]
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 (
......
import { NetworkStatus } from '@apollo/client'
import { isEqual } from 'lodash'
import {
createContext,
......@@ -9,18 +10,22 @@ import {
useRef,
useState,
} from 'react'
import { useTokenBalancesGroupedByVisibility } from 'src/features/balances/hooks'
import { PollingInterval } from 'wallet/src/constants/misc'
import { isWarmLoadingStatus } from 'wallet/src/data/utils'
import { usePortfolioBalances } from 'wallet/src/features/dataApi/balances'
import {
usePortfolioBalances,
useTokenBalancesGroupedByVisibility,
} from 'wallet/src/features/dataApi/balances'
import { PortfolioBalance } from 'wallet/src/features/dataApi/types'
type CurrencyId = string
export const HIDDEN_TOKEN_BALANCES_ROW = 'HIDDEN_TOKEN_BALANCES_ROW' as const
export type TokenBalanceListRow = CurrencyId | typeof HIDDEN_TOKEN_BALANCES_ROW
type TokenBalanceListContextState = {
balancesById: ReturnType<typeof usePortfolioBalances>['data']
networkStatus: ReturnType<typeof usePortfolioBalances>['networkStatus']
refetch: ReturnType<typeof usePortfolioBalances>['refetch']
balancesById: Record<string, PortfolioBalance> | undefined
networkStatus: NetworkStatus
refetch: (() => void) | undefined
hiddenTokensCount: number
hiddenTokensExpanded: boolean
isWarmLoading: boolean
......@@ -49,7 +54,7 @@ export function TokenBalanceListContextProvider({
refetch,
} = usePortfolioBalances({
address: owner,
shouldPoll: true,
pollInterval: PollingInterval.KindaFast,
fetchPolicy: 'cache-and-network',
})
......
......@@ -114,7 +114,6 @@ export function TokenDetailsStats({
offChainData?.markets?.[0]?.priceHigh52W?.value ?? onChainData?.market?.priceHigh52W?.value
const priceLow52W =
offChainData?.markets?.[0]?.priceLow52W?.value ?? onChainData?.market?.priceLow52W?.value
const currentDescription =
showTranslation && translatedDescription ? translatedDescription : description
......
......@@ -4,8 +4,7 @@ import { Flex, Icons, Text, TouchableArea } from 'ui/src'
import { iconSizes } from 'ui/src/theme'
import { CurrencyLogo } from 'wallet/src/components/CurrencyLogo/CurrencyLogo'
import { CurrencyInfo } from 'wallet/src/features/dataApi/types'
import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants'
import { useFeatureFlag } from 'wallet/src/features/experiments/hooks'
import { useSwapRewriteEnabled } from 'wallet/src/features/experiments/hooks'
import { getSymbolDisplayText } from 'wallet/src/utils/currency'
interface SelectTokenButtonProps {
......@@ -21,7 +20,7 @@ export function SelectTokenButton({
}: SelectTokenButtonProps): JSX.Element {
const { t } = useTranslation()
const isSwapRewriteFeatureEnabled = useFeatureFlag(FEATURE_FLAGS.SwapRewrite)
const isSwapRewriteFeatureEnabled = useSwapRewriteEnabled()
if (isSwapRewriteFeatureEnabled) {
return (
......
......@@ -3,7 +3,6 @@ import { useAppSelector } from 'src/app/hooks'
import { filter } from 'src/components/TokenSelector/filter'
import { TokenOption, TokenSelectorFlow } from 'src/components/TokenSelector/types'
import { createEmptyBalanceOption } from 'src/components/TokenSelector/utils'
import { useTokenBalancesGroupedByVisibility } from 'src/features/balances/hooks'
import { useTokenProjects } from 'src/features/dataApi/tokenProjects'
import { usePopularTokens } from 'src/features/dataApi/topTokens'
import { sendMobileAnalyticsEvent } from 'src/features/telemetry'
......@@ -11,7 +10,11 @@ import { MobileEventName } from 'src/features/telemetry/constants'
import { BRIDGED_BASE_ADDRESSES } from 'wallet/src/constants/addresses'
import { ChainId } from 'wallet/src/constants/chains'
import { DAI, USDC, USDT, WBTC } from 'wallet/src/constants/tokens'
import { sortPortfolioBalances, usePortfolioBalances } from 'wallet/src/features/dataApi/balances'
import {
sortPortfolioBalances,
usePortfolioBalances,
useTokenBalancesGroupedByVisibility,
} from 'wallet/src/features/dataApi/balances'
import { CurrencyInfo, GqlResult, PortfolioBalance } from 'wallet/src/features/dataApi/types'
import { usePersistedError } from 'wallet/src/features/dataApi/utils'
import { selectFavoriteTokens } from 'wallet/src/features/favorites/selectors'
......@@ -173,7 +176,6 @@ export function usePortfolioBalancesForAddressById(
loading,
} = usePortfolioBalances({
address,
shouldPoll: false, // Home tab's TokenBalanceList will poll portfolio balances for activeAccount
fetchPolicy: 'cache-first', // we want to avoid re-renders when token selector is opening
})
......
......@@ -236,7 +236,7 @@ export const PendingConnectionModal = ({ pendingSession, onClose }: Props): JSX.
},
[activeAddress, dispatch, onClose, pendingSession, didOpenFromDeepLink]
)
const dappName = pendingSession.dapp.name || pendingSession.dapp.url
const dappName = pendingSession.dapp.name || pendingSession.dapp.url || ''
return (
<BottomSheetModal name={ModalName.WCPendingConnection} onClose={onClose}>
......
......@@ -23,7 +23,9 @@ export const CUSTOM_UNI_QR_CODE_PREFIX = 'hello_uniwallet:'
const MAX_DAPP_NAME_LENGTH = 60
export function truncateDappName(name: string): string {
return name.length > MAX_DAPP_NAME_LENGTH ? `${name.slice(0, MAX_DAPP_NAME_LENGTH)}...` : name
return name && name.length > MAX_DAPP_NAME_LENGTH
? `${name.slice(0, MAX_DAPP_NAME_LENGTH)}...`
: name
}
export async function getSupportedURI(uri: string): Promise<URIFormat | undefined> {
......
......@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import ContextMenu from 'react-native-context-menu-view'
import { useAppDispatch } from 'src/app/hooks'
import { navigate } from 'src/app/navigation/rootNavigation'
import { useAccountList } from 'src/components/accounts/hooks'
import { AddressDisplay } from 'src/components/AddressDisplay'
import { closeModal, openModal } from 'src/features/modals/modalSlice'
import { ModalName } from 'src/features/telemetry/constants'
......@@ -13,7 +14,6 @@ import { disableOnPress } from 'src/utils/disableOnPress'
import { Flex, Text, TouchableArea } from 'ui/src'
import { iconSizes } from 'ui/src/theme'
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 { pushNotification } from 'wallet/src/features/notifications/slice'
import { AppNotificationType, CopyNotificationType } from 'wallet/src/features/notifications/types'
......@@ -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.
// 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',
variables: { addresses: address },
addresses: address,
})
const cachedPortfolioValue = data?.portfolios?.[0]?.tokensTotalDenominatedValue?.value
......
......@@ -20,6 +20,15 @@ const mock: MockedResponse<AccountListQuery> = {
query: AccountListDocument,
variables: {
addresses: [account.address],
valueModifiers: [
{
ownerAddress: account.address,
tokenIncludeOverrides: [],
tokenExcludeOverrides: [],
includeSmallBalances: false,
includeSpamTokens: false,
},
],
},
},
result: {
......
......@@ -3,13 +3,13 @@ import { ComponentProps, default as React, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native'
import { AccountCardItem } from 'src/components/accounts/AccountCardItem'
import { useAccountList } from 'src/components/accounts/hooks'
import { VirtualizedList } from 'src/components/layout/VirtualizedList'
import { Flex, Text, useSporeColors } from 'ui/src'
import { opacify, spacing } from 'ui/src/theme'
import { useAsyncData } from 'utilities/src/react/hooks'
import { PollingInterval } from 'wallet/src/constants/misc'
import { isNonPollingRequestInFlight } from 'wallet/src/data/utils'
import { useAccountListQuery } from 'wallet/src/data/__generated__/types-and-hooks'
import { Account, AccountType } from 'wallet/src/features/wallet/accounts/types'
// Most screens can fit more but this is set conservatively
......@@ -50,10 +50,10 @@ const SignerHeader = (): JSX.Element => {
export function AccountList({ accounts, onPress, isVisible }: AccountListProps): JSX.Element {
const colors = useSporeColors()
const addresses = accounts.map((a) => a.address)
const addresses = useMemo(() => accounts.map((a) => a.address), [accounts])
const { data, networkStatus, refetch, startPolling, stopPolling } = useAccountListQuery({
variables: { addresses },
const { data, networkStatus, refetch, startPolling, stopPolling } = useAccountList({
addresses,
notifyOnNetworkStatusChange: true,
})
......
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 { BottomSheetFlatList } from '@gorhom/bottom-sheet'
import React, { useCallback, useMemo } from 'react'
import React, { useCallback, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { ListRenderItem, ListRenderItemInfo } from 'react-native'
import { ListRenderItem, ListRenderItemInfo, StyleSheet, View } from 'react-native'
import { useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated'
import { useAppSelector } from 'src/app/hooks'
import { FavoriteTokensGrid } from 'src/components/explore/FavoriteTokensGrid'
import { FavoriteWalletsGrid } from 'src/components/explore/FavoriteWalletsGrid'
import { SortButton } from 'src/components/explore/SortButton'
import { TokenItem, TokenItemData } from 'src/components/explore/TokenItem'
import { AnimatedBottomSheetFlatList } from 'src/components/layout/AnimatedFlatList'
import { Loader } from 'src/components/loading'
import { AutoScrollProps } from 'src/components/sortableGrid'
import {
getClientTokensOrderByCompareFn,
getTokenMetadataDisplayType,
......@@ -36,12 +38,15 @@ import { areAddressesEqual } from 'wallet/src/utils/addresses'
import { buildCurrencyId, buildNativeCurrencyId } from 'wallet/src/utils/currencyId'
type ExploreSectionsProps = {
listRef?: React.MutableRefObject<null>
listRef: React.MutableRefObject<null>
}
export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element {
const { t } = useTranslation()
const insets = useDeviceInsets()
const scrollY = useSharedValue(0)
const headerRef = useRef<View>(null)
const visibleListHeight = useSharedValue(0)
// Top tokens sorting
const orderBy = useAppSelector(selectTokensOrderBy)
......@@ -120,6 +125,10 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element
await refetch()
}, [refetch])
const scrollHandler = useAnimatedScrollHandler((e) => {
scrollY.value = e.contentOffset.y
})
// Use showLoading for showing full screen loading state
// Used in each section to ensure loading state layout matches loaded state
const showLoading = (!hasAllData && isLoading) || (!!error && isLoading)
......@@ -137,7 +146,18 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element
}
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}
ListEmptyComponent={
<Flex mx="$spacing24" my="$spacing12">
......@@ -145,8 +165,14 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element
</Flex>
}
ListHeaderComponent={
<>
<FavoritesSection showLoading={showLoading} />
<Flex ref={headerRef}>
<FavoritesSection
containerRef={headerRef}
scrollY={scrollY}
scrollableRef={listRef}
showLoading={showLoading}
visibleHeight={visibleListHeight}
/>
<Flex
row
alignItems="center"
......@@ -161,15 +187,19 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element
</Text>
<SortButton orderBy={orderBy} />
</Flex>
</>
</Flex>
}
ListHeaderComponentStyle={styles.foreground}
contentContainerStyle={{ paddingBottom: insets.bottom }}
data={showLoading ? undefined : topTokenItems}
keyExtractor={tokenKey}
renderItem={renderItem}
scrollEventThrottle={16}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
onScroll={scrollHandler}
/>
</Flex>
)
}
......@@ -204,16 +234,32 @@ function gqlTokenToTokenItemData(
} as TokenItemData
}
function FavoritesSection({ showLoading }: { showLoading: boolean }): JSX.Element | null {
type FavoritesSectionProps = AutoScrollProps & {
showLoading: boolean
}
function FavoritesSection(props: FavoritesSectionProps): JSX.Element | null {
const hasFavoritedTokens = useAppSelector(selectHasFavoriteTokens)
const hasFavoritedWallets = useAppSelector(selectHasWatchedWallets)
if (!hasFavoritedTokens && !hasFavoritedWallets) return null
return (
<Flex bg="$transparent" gap="$spacing12" pb="$spacing12" pt="$spacing8" px="$spacing12">
{hasFavoritedTokens && <FavoriteTokensGrid showLoading={showLoading} />}
{hasFavoritedWallets && <FavoriteWalletsGrid showLoading={showLoading} />}
<Flex
bg="$transparent"
gap="$spacing12"
pb="$spacing12"
pt="$spacing8"
px="$spacing12"
zIndex={1}>
{hasFavoritedTokens && <FavoriteTokensGrid {...props} />}
{hasFavoritedWallets && <FavoriteWalletsGrid showLoading={props.showLoading} />}
</Flex>
)
}
const styles = StyleSheet.create({
foreground: {
zIndex: 1,
},
})
......@@ -2,7 +2,15 @@ import { ImpactFeedbackStyle } from 'expo-haptics'
import React, { memo, useCallback } from 'react'
import { ViewProps } from 'react-native'
import ContextMenu from 'react-native-context-menu-view'
import { FadeIn, FadeOut } from 'react-native-reanimated'
import {
FadeIn,
FadeOut,
interpolate,
SharedValue,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
import { useAppDispatch } from 'src/app/hooks'
import { useExploreTokenContextMenu } from 'src/components/explore/hooks'
import RemoveButton from 'src/components/explore/RemoveButton'
......@@ -11,7 +19,7 @@ import { useTokenDetailsNavigation } from 'src/components/TokenDetails/hooks'
import { SectionName } from 'src/features/telemetry/constants'
import { disableOnPress } from 'src/utils/disableOnPress'
import { usePollOnFocusOnly } from 'src/utils/hooks'
import { AnimatedTouchableArea, Flex, Text } from 'ui/src'
import { AnimatedFlex, AnimatedTouchableArea, Flex, Text } from 'ui/src'
import { borderRadii, imageSizes } from 'ui/src/theme'
import { NumberType } from 'utilities/src/format/types'
import { BaseCard } from 'wallet/src/components/BaseCard/BaseCard'
......@@ -32,18 +40,24 @@ export const FAVORITE_TOKEN_CARD_LOADER_HEIGHT = 114
type FavoriteTokenCardProps = {
currencyId: string
isEditing?: boolean
isTouched: SharedValue<boolean>
dragActivationProgress: SharedValue<number>
setIsEditing: (update: boolean) => void
} & ViewProps
function FavoriteTokenCard({
currencyId,
isEditing,
isTouched,
dragActivationProgress,
setIsEditing,
...rest
}: FavoriteTokenCardProps): JSX.Element {
const dispatch = useAppDispatch()
const tokenDetailsNavigation = useTokenDetailsNavigation()
const { convertFiatAmountFormatted } = useLocalizationContext()
const dragAnimationProgress = useSharedValue(0)
const wasTouched = useSharedValue(false)
const { data, networkStatus, startPolling, stopPolling } = useFavoriteTokenCardQuery({
variables: currencyIdToContractInput(currencyId),
......@@ -88,11 +102,45 @@ function FavoriteTokenCard({
tokenDetailsNavigation.navigate(currencyId)
}
useAnimatedReaction(
() => dragActivationProgress.value,
(activationProgress, prev) => {
const prevActivationProgress = prev ?? 0
// If the activation progress is increasing (the user is touching one of the cards)
if (activationProgress > prevActivationProgress) {
if (isTouched.value) {
// If the current card is the one being touched, reset the animation progress
wasTouched.value = true
dragAnimationProgress.value = 0
} else {
// Otherwise, animate the card
wasTouched.value = false
dragAnimationProgress.value = activationProgress
}
}
// If the activation progress is decreasing (the user is no longer touching one of the cards)
else {
if (isTouched.value || wasTouched.value) {
// If the current card is the one that was being touched, reset the animation progress
dragAnimationProgress.value = 0
} else {
// Otherwise, animate the card
dragAnimationProgress.value = activationProgress
}
}
}
)
const animatedStyle = useAnimatedStyle(() => ({
opacity: interpolate(dragAnimationProgress.value, [0, 1], [1, 0.5]),
}))
if (isNonPollingRequestInFlight(networkStatus)) {
return <Loader.Favorite height={FAVORITE_TOKEN_CARD_LOADER_HEIGHT} />
}
return (
<AnimatedFlex style={animatedStyle}>
<ContextMenu
actions={menuActions}
disabled={isEditing}
......@@ -100,10 +148,12 @@ function FavoriteTokenCard({
onPress={onContextMenuPress}
{...rest}>
<AnimatedTouchableArea
hapticFeedback
activeOpacity={isEditing ? 1 : undefined}
bg="$surface2"
borderRadius="$rounded16"
entering={FadeIn}
exiting={FadeOut}
hapticFeedback={!isEditing}
hapticStyle={ImpactFeedbackStyle.Light}
m="$spacing4"
testID={`token-box-${token?.symbol}`}
......@@ -142,6 +192,7 @@ function FavoriteTokenCard({
</BaseCard.Shadow>
</AnimatedTouchableArea>
</ContextMenu>
</AnimatedFlex>
)
}
......
import React, { useEffect, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FadeIn } from 'react-native-reanimated'
import { FadeIn, useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
import { useAppSelector } from 'src/app/hooks'
import { FavoriteHeaderRow } from 'src/components/explore/FavoriteHeaderRow'
import FavoriteTokenCard, {
FAVORITE_TOKEN_CARD_LOADER_HEIGHT,
} from 'src/components/explore/FavoriteTokenCard'
import { Loader } from 'src/components/loading'
import {
AutoScrollProps,
SortableGrid,
SortableGridChangeEvent,
SortableGridRenderItem,
} from 'src/components/sortableGrid'
import { AnimatedFlex, Flex } from 'ui/src'
import { selectFavoriteTokens } from 'wallet/src/features/favorites/selectors'
import { setFavoriteTokens } from 'wallet/src/features/favorites/slice'
import { useAppDispatch } from 'wallet/src/state'
const NUM_COLUMNS = 2
const ITEM_FLEX = { flex: 1 / NUM_COLUMNS }
const HALF_WIDTH = { width: '50%' }
type FavoriteTokensGridProps = AutoScrollProps & {
showLoading: boolean
}
/** Renders the favorite tokens section on the Explore tab */
export function FavoriteTokensGrid({ showLoading }: { showLoading: boolean }): JSX.Element | null {
export function FavoriteTokensGrid({
showLoading,
...rest
}: FavoriteTokensGridProps): JSX.Element | null {
const { t } = useTranslation()
const dispatch = useAppDispatch()
const [isEditing, setIsEditing] = useState(false)
const isTokenDragged = useSharedValue(false)
const favoriteCurrencyIds = useAppSelector(selectFavoriteTokens)
// Reset edit mode when there are no favorite tokens
......@@ -28,8 +44,33 @@ export function FavoriteTokensGrid({ showLoading }: { showLoading: boolean }): J
}
}, [favoriteCurrencyIds.length])
const handleOrderChange = useCallback(
({ data }: SortableGridChangeEvent<string>) => {
dispatch(setFavoriteTokens({ currencyIds: data }))
},
[dispatch]
)
const renderItem = useCallback<SortableGridRenderItem<string>>(
({ item: currencyId, isTouched, dragActivationProgress }): JSX.Element => (
<FavoriteTokenCard
key={currencyId}
currencyId={currencyId}
dragActivationProgress={dragActivationProgress}
isEditing={isEditing}
isTouched={isTouched}
setIsEditing={setIsEditing}
/>
),
[isEditing]
)
const animatedStyle = useAnimatedStyle(() => ({
zIndex: isTokenDragged.value ? 1 : 0,
}))
return (
<AnimatedFlex entering={FadeIn}>
<AnimatedFlex entering={FadeIn} style={animatedStyle}>
<FavoriteHeaderRow
editingTitle={t('Edit favorite tokens')}
isEditing={isEditing}
......@@ -39,17 +80,21 @@ export function FavoriteTokensGrid({ showLoading }: { showLoading: boolean }): J
{showLoading ? (
<FavoriteTokensGridLoader />
) : (
<Flex row flexWrap="wrap">
{favoriteCurrencyIds.map((currencyId) => (
<FavoriteTokenCard
key={currencyId}
currencyId={currencyId}
isEditing={isEditing}
setIsEditing={setIsEditing}
style={HALF_WIDTH}
<SortableGrid
{...rest}
activeItemOpacity={1}
data={favoriteCurrencyIds}
editable={isEditing}
numColumns={NUM_COLUMNS}
renderItem={renderItem}
onChange={handleOrderChange}
onDragEnd={(): void => {
isTokenDragged.value = false
}}
onDragStart={(): void => {
isTokenDragged.value = true
}}
/>
))}
</Flex>
)}
</AnimatedFlex>
)
......
......@@ -1376,6 +1376,7 @@ exports[`ActivityTab renders without error 2`] = `
{
"alignItems": "center",
"flexDirection": "row",
"flexShrink": 1,
"gap": 4,
}
}
......@@ -1732,6 +1733,7 @@ exports[`ActivityTab renders without error 2`] = `
{
"alignItems": "center",
"flexDirection": "row",
"flexShrink": 1,
"gap": 4,
}
}
......
......@@ -2,11 +2,10 @@ import React, { forwardRef, useCallback, useEffect, useMemo } from 'react'
import { AppState, Keyboard, KeyboardTypeOptions, TextInput as NativeTextInput } from 'react-native'
import { TextInput, TextInputProps } from 'src/components/input/TextInput'
import { useMoonpayFiatCurrencySupportInfo } from 'src/features/fiatOnRamp/hooks'
import { escapeRegExp } from 'utilities/src/primitives/string'
import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks'
import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
const inputRegex = RegExp('^\\d*(?:\\\\[.])?\\d*$') // match escaped "." characters via in a non-capturing group
const numericInputRegex = RegExp('^\\d*(\\.\\d*)?$') // Matches only numeric values without commas
type Props = {
showCurrencySign: boolean
......@@ -36,10 +35,18 @@ export function replaceSeparators({
}
export const AmountInput = forwardRef<NativeTextInput, Props>(function _AmountInput(
{ onChangeText, value, showCurrencySign, dimTextColor, showSoftInputOnFocus, editable, ...rest },
{ onChangeText, value, showCurrencySign, dimTextColor, showSoftInputOnFocus, ...rest },
ref
) {
const { groupingSeparator, decimalSeparator } = useAppFiatCurrencyInfo()
const invalidInput = value && !numericInputRegex.test(value)
useEffect(() => {
// Resets input if non-numberic value is passed
if (invalidInput) {
onChangeText?.('')
}
}, [invalidInput, onChangeText, value])
const handleChange = useCallback(
(text: string) => {
......@@ -51,9 +58,7 @@ export const AmountInput = forwardRef<NativeTextInput, Props>(function _AmountIn
decimalOverride: '.',
})
if (parsedText === '' || inputRegex.test(escapeRegExp(parsedText))) {
onChangeText?.(parsedText)
}
},
[decimalSeparator, groupingSeparator, onChangeText, showCurrencySign]
)
......@@ -61,23 +66,19 @@ export const AmountInput = forwardRef<NativeTextInput, Props>(function _AmountIn
const { moonpaySupportedFiatCurrency: currency } = useMoonpayFiatCurrencySupportInfo()
const { addFiatSymbolToNumber } = useLocalizationContext()
let formattedValue = showCurrencySign
? addFiatSymbolToNumber({
value,
currencyCode: currency.code,
currencySymbol: currency.symbol,
})
: value
// TODO gary MOB-2028 replace temporary hack to handle different separators
formattedValue =
editable ?? true
? replaceSeparators({
value: formattedValue ?? '',
let formattedValue = replaceSeparators({
value: value ?? '',
groupingSeparator: ',',
decimalSeparator: '.',
groupingOverride: groupingSeparator,
decimalOverride: decimalSeparator,
})
formattedValue = showCurrencySign
? addFiatSymbolToNumber({
value: formattedValue,
currencyCode: currency.code,
currencySymbol: currency.symbol,
})
: formattedValue
const textInputProps: TextInputProps = useMemo(
......
......@@ -64,25 +64,23 @@ export function SeedPhraseDisplay({
return (
<>
{showSeedPhrase ? (
<Flex grow mt="$spacing16">
{showSeedPhrase ? (
<Flex grow pt="$spacing16" px="$spacing16">
<MnemonicDisplay mnemonicId={mnemonicId} />
</Flex>
) : (
<HiddenMnemonicWordView />
)}
</Flex>
<Flex borderTopColor="$surface3" borderTopWidth={1} pt="$spacing12" px="$spacing16">
<Button
testID={ElementName.Next}
theme="secondary"
onPress={(): void => {
setShowSeedPhrase(false)
}}>
{t('Hide recovery phrase')}
onPress={(): void => setShowSeedPhrase(!showSeedPhrase)}>
{showSeedPhrase ? t('Hide recovery phrase') : t('Show recovery phrase')}
</Button>
</Flex>
</Flex>
) : (
<HiddenMnemonicWordView />
)}
{showSeedPhraseViewWarningModal && (
<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'
import { isNonJestDev } from 'utilities/src/environment'
import { logger } from 'utilities/src/logger/logger'
import { useAsyncData } from 'utilities/src/react/hooks'
import { uniswapUrls } from 'wallet/src/constants/urls'
import {
getCustomGraphqlHttpLink,
getErrorLink,
......@@ -17,6 +18,8 @@ import {
getPerformanceLink,
getRestLink,
} 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
......@@ -30,6 +33,7 @@ if (isNonJestDev()) {
export const usePersistedApolloClient = (): ApolloClient<NormalizedCacheObject> | undefined => {
const [client, setClient] = useState<ApolloClient<NormalizedCacheObject>>()
const customEndpoint = useAppSelector(selectCustomEndpoint)
const cloudflareGatewayEnabled = useFeatureFlag(FEATURE_FLAGS.CloudflareGateway)
const apolloLink = customEndpoint
? getCustomGraphqlHttpLink(customEndpoint)
......@@ -47,6 +51,10 @@ export const usePersistedApolloClient = (): ApolloClient<NormalizedCacheObject>
)
}
const restLink = cloudflareGatewayEnabled
? getRestLink(uniswapUrls.apiBaseUrlCloudflare)
: getRestLink()
const newClient = new ApolloClient({
assumeImmutableResults: true,
link: from([
......@@ -56,7 +64,7 @@ export const usePersistedApolloClient = (): ApolloClient<NormalizedCacheObject>
getPerformanceLink((args: any) =>
sendMobileAnalyticsEvent(MobileEventName.PerformanceGraphql, args)
),
getRestLink(),
restLink,
apolloLink,
]),
cache,
......@@ -76,7 +84,7 @@ export const usePersistedApolloClient = (): ApolloClient<NormalizedCacheObject>
setClient(newClient)
// 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
}, [])
......
......@@ -13,6 +13,15 @@ const mock: MockedResponse<PortfolioBalancesQuery> = {
query: PortfolioBalancesDocument,
variables: {
ownerAddress: Portfolios[0].ownerAddress,
valueModifiers: [
{
ownerAddress: Portfolios[0].ownerAddress,
tokenIncludeOverrides: undefined,
tokenExcludeOverrides: undefined,
includeSmallBalances: false,
includeSpamTokens: false,
},
],
},
},
result: {
......
......@@ -5,7 +5,7 @@ import { NumberType } from 'utilities/src/format/types'
import { RelativeChange } from 'wallet/src/components/text/RelativeChange'
import { PollingInterval } from 'wallet/src/constants/misc'
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 { useAppFiatCurrency, useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks'
import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
......@@ -15,12 +15,11 @@ interface PortfolioBalanceProps {
}
export function PortfolioBalance({ owner }: PortfolioBalanceProps): JSX.Element {
const { data, loading, networkStatus } = usePortfolioBalancesQuery({
variables: { ownerAddress: owner },
const { data, loading, networkStatus } = usePortfolioTotalValue({
address: owner,
// TransactionHistoryUpdater will refetch this query on new transaction.
// No need to be super aggressive with polling here.
pollInterval: PollingInterval.Normal,
notifyOnNetworkStatusChange: true,
})
const currency = useAppFiatCurrency()
const currencyComponents = useAppFiatCurrencyInfo()
......@@ -29,14 +28,10 @@ export function PortfolioBalance({ owner }: PortfolioBalanceProps): JSX.Element
const isLoading = loading && !data
const isWarmLoading = !!data && isWarmLoadingStatus(networkStatus)
const portfolioBalance = data?.portfolios?.[0]
const portfolioChange = portfolioBalance?.tokensTotalDenominatedValueChange
const { percentChange, absoluteChangeUSD, balanceUSD } = data || {}
const totalBalance = convertFiatAmountFormatted(
portfolioBalance?.tokensTotalDenominatedValue?.value,
NumberType.PortfolioBalance
)
const { amount: absoluteChange } = convertFiatAmount(portfolioChange?.absolute?.value)
const totalBalance = convertFiatAmountFormatted(balanceUSD, NumberType.PortfolioBalance)
const { amount: absoluteChange } = convertFiatAmount(absoluteChangeUSD)
// TODO gary re-enabling this for USD/Euros only, replace with more scalable approach
const shouldFadePortfolioDecimals =
(currency === FiatCurrency.UnitedStatesDollar || currency === FiatCurrency.Euro) &&
......@@ -57,7 +52,7 @@ export function PortfolioBalance({ owner }: PortfolioBalanceProps): JSX.Element
<RelativeChange
absoluteChange={absoluteChange}
arrowSize="$icon.16"
change={portfolioChange?.percentage?.value}
change={percentChange}
loading={isLoading}
negativeChangeColor={isWarmLoading ? '$neutral2' : '$statusCritical'}
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
const address = useActiveAccountAddressWithThrow()
const { data: balances } = usePortfolioBalances({
address,
shouldPoll: false,
fetchPolicy: 'cache-and-network',
})
......
......@@ -16,6 +16,7 @@ export interface ModalsState {
[ModalName.FiatCurrencySelector]: AppModalState<undefined>
[ModalName.FiatOnRamp]: AppModalState<undefined>
[ModalName.FiatOnRampAggregator]: AppModalState<undefined>
[ModalName.LanguageSelector]: AppModalState<undefined>
[ModalName.RemoveWallet]: AppModalState<RemoveWalletModalState>
[ModalName.RestoreWallet]: AppModalState<undefined>
[ModalName.Send]: AppModalState<TransactionState>
......
......@@ -24,6 +24,12 @@ type FiatOnRampAggregatorModalParams = {
name: ModalName.FiatOnRampAggregator
initialState?: undefined
}
type LanguageSelectorModalParams = {
name: ModalName.LanguageSelector
initialState?: undefined
}
type RemoveWalletModalParams = {
name: ModalName.RemoveWallet
initialState?: RemoveWalletModalState
......@@ -49,6 +55,7 @@ export type OpenModalParams =
| FiatCurrencySelectorParams
| FiatOnRampModalParams
| FiatOnRampAggregatorModalParams
| LanguageSelectorModalParams
| RemoveWalletModalParams
| SendModalParams
| SwapModalParams
......@@ -99,6 +106,10 @@ export const initialModalState: ModalsState = {
isOpen: false,
initialState: undefined,
},
[ModalName.LanguageSelector]: {
isOpen: false,
initialState: undefined,
},
[ModalName.FiatCurrencySelector]: {
isOpen: false,
initialState: undefined,
......
......@@ -25,7 +25,7 @@ export function buildReceiveNotification(
const { typeInfo, status, chainId, hash, id } = transactionDetails
// 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
}
......
......@@ -101,6 +101,7 @@ export const enum ModalName {
AddWallet = 'add-wallet-modal',
BlockedAddress = 'blocked-address',
ChooseProfilePhoto = 'choose-profile-photo-modal',
CloudBackupInfo = 'cloud-backup-info-modal',
Experiments = 'experiments',
Explore = 'explore-modal',
FaceIDWarning = 'face-id-warning',
......@@ -110,7 +111,7 @@ export const enum ModalName {
FiatOnRampAggregator = 'fiat-on-ramp-aggregator',
FiatOnRampCountryList = 'fiat-on-ramp-country-list',
ForceUpgradeModal = 'force-upgrade-modal',
CloudBackupInfo = 'cloud-backup-info-modal',
LanguageSelector = 'language-selector-modal',
NetworkFeeInfo = 'network-fee-info',
NetworkSelector = 'network-selector-modal',
NftCollection = 'nft-collection',
......@@ -188,6 +189,7 @@ export const enum ElementName {
OnboardingImportBackup = 'onboarding-import-backup',
OnboardingImportSeedPhrase = 'onboarding-import-seed-phrase',
OnboardingImportWatchedAccount = 'onboarding-import-watched-account',
OpenDeviceLanguageSettings = 'open-device-language-settings',
OpenCameraRoll = 'open-camera-roll',
OpenNftsList = 'open-nfts-list',
QRCodeModalToggle = 'qr-code-modal-toggle',
......
import { useCallback, useMemo } from 'react'
import { useAppDispatch, useAppSelector } from 'src/app/hooks'
import { useAccountList } from 'src/components/accounts/hooks'
import { sendMobileAnalyticsEvent } from 'src/features/telemetry'
import { MobileEventName } from 'src/features/telemetry/constants'
import {
......@@ -13,7 +14,6 @@ import {
shouldReportBalances,
} from 'src/features/telemetry/slice'
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 { useAccounts } from 'wallet/src/features/wallet/hooks'
import { sendWalletAppsFlyerEvent } from 'wallet/src/telemetry'
......@@ -33,8 +33,8 @@ export function useLastBalancesReporter(): () => void {
.map((a) => a.address)
}, [accounts])
const { data } = useAccountListQuery({
variables: { addresses: signerAccountAddresses },
const { data } = useAccountList({
addresses: signerAccountAddresses,
fetchPolicy: 'cache-first',
})
......
......@@ -135,6 +135,7 @@ export type MobileEventProperties = {
swap_quote_block_number?: string
swap_flow_duration_milliseconds?: number
is_hold_to_swap?: boolean
is_fiat_input_mode?: boolean
} & SwapTradeBaseProperties
[SwapEventName.SWAP_ESTIMATE_GAS_CALL_FAILED]: {
error?: ApolloError | FetchBaseQueryError | SerializedError | Error | string
......
......@@ -134,7 +134,7 @@ function TransactionSummaryLayout({
<Flex grow shrink>
<Flex grow>
<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 ? (
<Text color="$accent1" numberOfLines={1} variant="body1">
{walletDisplayName.name}
......
......@@ -8,7 +8,6 @@ import { useAppDispatch, useAppSelector } from 'src/app/hooks'
import { apolloClient } from 'src/data/usePersistedApolloClient'
import { buildReceiveNotification } from 'src/features/notifications/buildReceiveNotification'
import { selectLastTxNotificationUpdate } from 'src/features/notifications/selectors'
import { useSelectAddressTransactions } from 'src/features/transactions/hooks'
import { ONE_SECOND_MS } from 'utilities/src/time/time'
import { PollingInterval } from 'wallet/src/constants/misc'
import { GQLQueries } from 'wallet/src/data/queries'
......@@ -24,6 +23,7 @@ import {
setNotificationStatus,
} from 'wallet/src/features/notifications/slice'
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 {
useAccounts,
......
......@@ -33,7 +33,7 @@ export function TransactionPending({
const onPressViewTransaction = async (): Promise<void> => {
if (transaction) {
await openTransactionLink(transaction.hash, transaction.chainId)
await openTransactionLink(transaction?.hash, transaction.chainId)
}
}
......
......@@ -12,14 +12,12 @@ import {
createWrapFormFromTxDetails,
} from 'src/features/transactions/swap/createSwapFormFromTxDetails'
import { transactionStateActions } from 'src/features/transactions/transactionState/transactionState'
import { logger } from 'utilities/src/logger/logger'
import { ChainId } from 'wallet/src/constants/chains'
import { AssetType } from 'wallet/src/entities/assets'
import { useCurrencyInfo } from 'wallet/src/features/tokens/useCurrencyInfo'
import {
makeSelectAddressTransactions,
makeSelectLocalTxCurrencyIds,
makeSelectTransaction,
useSelectAddressTransactions,
} from 'wallet/src/features/transactions/selectors'
import { finalizeTransaction } from 'wallet/src/features/transactions/slice'
import {
......@@ -73,18 +71,6 @@ export function useSelectTransaction(
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(
address: Address | undefined,
chainId: ChainId | undefined,
......@@ -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.
*/
export function useMergeLocalAndRemoteTransactions(
address: string,
address: Address,
remoteTransactions: TransactionDetails[] | undefined
): TransactionDetails[] | undefined {
const dispatch = useAppDispatch()
......@@ -285,6 +271,7 @@ export function useMergeLocalAndRemoteTransactions(
if (!localTransactions?.length) return remoteTransactions
const txHashes = new Set<string>()
const fiatOnRampTxs: TransactionDetails[] = []
const remoteTxMap: Map<string, TransactionDetails> = new Map()
remoteTransactions.forEach((tx) => {
......@@ -293,10 +280,7 @@ export function useMergeLocalAndRemoteTransactions(
remoteTxMap.set(txHash, tx)
txHashes.add(txHash)
} else {
logger.error(new Error('Remote transaction is missing hash '), {
tags: { file: 'transactions/hooks', function: 'useMergeLocalAndRemoteTransactions' },
extra: { tx },
})
fiatOnRampTxs.push(tx)
}
})
......@@ -307,15 +291,11 @@ export function useMergeLocalAndRemoteTransactions(
localTxMap.set(txHash, tx)
txHashes.add(txHash)
} else {
// TODO(MOB-1737): Figure out why transactions are missing a hash and fix root issue
logger.error(new Error('Local transaction is missing hash '), {
tags: { file: 'transactions/hooks', function: 'useMergeLocalAndRemoteTransactions' },
extra: { tx },
})
fiatOnRampTxs.push(tx)
}
})
const deDupedTxs: TransactionDetails[] = []
const deDupedTxs: TransactionDetails[] = [...fiatOnRampTxs]
for (const txHash of [...txHashes]) {
const remoteTx = remoteTxMap.get(txHash)
......@@ -407,7 +387,7 @@ export function useLowestPendingNonce(): BigNumberish | undefined {
*/
export function useAllTransactionsBetweenAddresses(
sender: Address,
recipient: string | undefined | null
recipient: Maybe<Address>
): TransactionDetails[] | undefined {
const txnsToSearch = useSelectAddressTransactions(sender)
return useMemo(() => {
......
......@@ -14,8 +14,7 @@ import { TransactionDetails } from 'src/features/transactions/TransactionDetails
import { Flex, Text, TouchableArea } from 'ui/src'
import { InfoCircleFilled } from 'ui/src/components/icons'
import { NumberType } from 'utilities/src/format/types'
import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants'
import { useFeatureFlag } from 'wallet/src/features/experiments/hooks'
import { useSwapRewriteEnabled } from 'wallet/src/features/experiments/hooks'
import { GasFeeResult } from 'wallet/src/features/gas/types'
import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
import { useUSDCPrice } from 'wallet/src/features/routing/useUSDCPrice'
......@@ -82,7 +81,7 @@ export function SwapDetails({
const formatter = useLocalizationContext()
const { convertFiatAmountFormatted } = useLocalizationContext()
const shouldShowSwapRewrite = useFeatureFlag(FEATURE_FLAGS.SwapRewrite)
const shouldShowSwapRewrite = useSwapRewriteEnabled()
const trade = derivedSwapInfo.trade.trade
const acceptedTrade = acceptedDerivedSwapInfo.trade.trade
......@@ -241,7 +240,7 @@ function AcceptNewQuoteRow({
const { t } = useTranslation()
const { formatCurrencyAmount } = useLocalizationContext()
const shouldShowSwapRewrite = useFeatureFlag(FEATURE_FLAGS.SwapRewrite)
const shouldShowSwapRewrite = useSwapRewriteEnabled()
const derivedCurrencyField =
derivedSwapInfo.exactCurrencyField === CurrencyField.INPUT
......
......@@ -159,8 +159,7 @@ function _SwapForm({
const derivedCurrencyField =
exactCurrencyField === CurrencyField.INPUT ? CurrencyField.OUTPUT : CurrencyField.INPUT
// TODO gary MOB-2028 replace temporary hack to handle different separators
// Replace with localized version of formatter
// Swap input requires numeric values, not localized ones
const formattedDerivedValue = formatCurrencyAmount({
amount: currencyAmounts[derivedCurrencyField],
locale: 'en-US',
......
......@@ -10,6 +10,7 @@ import { providers } from 'ethers'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AnyAction } from 'redux'
import { useAppDispatch, useAppSelector } from 'src/app/hooks'
import { setHasSubmittedHoldToSwap } from 'src/features/behaviorHistory/slice'
import { sendMobileAnalyticsEvent } from 'src/features/telemetry'
import { selectSwapStartTimestamp } from 'src/features/telemetry/timing/selectors'
import { updateSwapStartTimestamp } from 'src/features/telemetry/timing/slice'
......@@ -732,7 +733,8 @@ export function useSwapCallback(
isAutoSlippage: boolean,
onSubmit: () => void,
txId?: string,
isHoldToSwap?: boolean
isHoldToSwap?: boolean,
isFiatInputMode?: boolean
): () => void {
const appDispatch = useAppDispatch()
const account = useActiveAccount()
......@@ -784,10 +786,16 @@ export function useSwapCallback(
? Date.now() - swapStartTimestamp
: undefined,
is_hold_to_swap: isHoldToSwap,
is_fiat_input_mode: isFiatInputMode,
})
// Reset swap start timestamp now that the swap has been submitted
appDispatch(updateSwapStartTimestamp({ timestamp: undefined }))
// Mark hold to swap persisted user behavior
if (isHoldToSwap) {
appDispatch(setHasSubmittedHoldToSwap(true))
}
}
}, [
account,
......@@ -804,6 +812,7 @@ export function useSwapCallback(
isAutoSlippage,
swapStartTimestamp,
isHoldToSwap,
isFiatInputMode,
])
}
......
......@@ -12,8 +12,7 @@ import {
import { DerivedSwapInfo } from 'src/features/transactions/swap/types'
import { formatPriceImpact } from 'utilities/src/format/formatPriceImpact'
import { useMemoCompare } from 'utilities/src/react/hooks'
import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants'
import { useFeatureFlag } from 'wallet/src/features/experiments/hooks'
import { useSwapRewriteEnabled } from 'wallet/src/features/experiments/hooks'
import {
API_RATE_LIMIT_ERROR,
NO_QUOTE_DATA,
......@@ -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
const offline = isOffline(networkStatus)
const isSwapRewriteFeatureEnabled = useFeatureFlag(FEATURE_FLAGS.SwapRewrite)
const isSwapRewriteFeatureEnabled = useSwapRewriteEnabled()
return useMemoCompare(
() => getSwapWarnings(t, derivedSwapInfo, offline, isSwapRewriteFeatureEnabled),
......
......@@ -10,6 +10,7 @@ import { BlockedAddressWarning } from 'src/features/trm/BlockedAddressWarning'
import { AnimatedFlex, Flex, Icons, Text, TouchableArea, useSporeColors } from 'ui/src'
import { iconSizes } from 'ui/src/theme'
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 { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
import { useIsBlockedActiveAddress } from 'wallet/src/features/trm/hooks'
......@@ -33,6 +34,12 @@ export function GasAndWarningRows({ renderEmptyRows }: { renderEmptyRows: boolea
const gasFeeUSD = useUSDValue(chainId, gasFee?.value)
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(() => {
if (!formScreenWarning?.warning.message) {
// Do not show the modal if the warning doesn't have a message.
......@@ -74,7 +81,7 @@ export function GasAndWarningRows({ renderEmptyRows }: { renderEmptyRows: boolea
/>
)}
{gasFeeUSD && (
{showGasFee && (
<TouchableArea hapticFeedback onPress={(): void => setShowGasInfoModal(true)}>
<AnimatedFlex centered row entering={FadeIn} gap="$spacing4">
<Icons.Gas color={colors.neutral2.val} size="$icon.16" />
......
import React, { useCallback, useMemo } from 'react'
import { TFunction, useTranslation } from 'react-i18next'
import { useAppSelector as useMobileAppSelector } from 'src/app/hooks'
import Trace from 'src/components/Trace/Trace'
import {
selectHasSubmittedHoldToSwap,
selectHasViewedReviewScreen,
} from 'src/features/behaviorHistory/selectors'
import { ElementName } from 'src/features/telemetry/constants'
import { isWrapAction } from 'src/features/transactions/swap/utils'
import { useSwapFormContext } from 'src/features/transactions/swapRewrite/contexts/SwapFormContext'
......@@ -18,11 +23,14 @@ import { Button, Flex, Icons, Text } from 'ui/src'
import { WrapType } from 'wallet/src/features/transactions/types'
import { createTransactionId } from 'wallet/src/features/transactions/utils'
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 function SwapFormButton(): JSX.Element {
const { t } = useTranslation()
const activeAccount = useActiveAccountWithThrow()
const { screen, setScreen } = useSwapScreenContext()
const { derivedSwapInfo, isSubmitting, updateSwapForm } = useSwapFormContext()
......@@ -47,6 +55,15 @@ export function SwapFormButton(): JSX.Element {
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(
(nextScreen: SwapScreen) => {
updateSwapForm({ txId: createTransactionId() })
......@@ -60,8 +77,10 @@ export function SwapFormButton(): JSX.Element {
}, [onReview])
const onLongPressHoldToSwap = useCallback(() => {
if (enableHoldToSwap) {
onReview(SwapScreen.SwapReviewHoldingToSwap)
}, [onReview])
}
}, [enableHoldToSwap, onReview])
const onReleaseHoldToSwap = useCallback(() => {
if (isHoldToSwapPressed && !isSubmitting) {
......@@ -73,7 +92,7 @@ export function SwapFormButton(): JSX.Element {
return (
<Flex alignItems="center" gap="$spacing16">
{!isHoldToSwapPressed && <HoldToInstantSwapRow />}
{!isHoldToSwapPressed && showHoldToSwapTip && <HoldToInstantSwapRow />}
<Trace logPress element={ElementName.SwapReview}>
<Button
......@@ -116,9 +135,9 @@ function HoldToInstantSwapRow(): JSX.Element {
return (
<Flex centered row gap="$spacing4">
<Icons.Lightning color="$neutral3" size="$icon.12" />
<Icons.GraduationCap color="$neutral3" size="$icon.16" />
<Text color="$neutral3" variant="body3">
{t('Hold to instant swap')}
{t('Tip: Hold to instant swap')}
</Text>
</Flex>
)
......
......@@ -327,8 +327,7 @@ function SwapFormContent(): JSX.Element {
})
}, [exactFieldIsInput, input, output, updateSwapForm])
// TODO gary MOB-2028 replace temporary hack to handle different separators
// Replace with localized version of formatter
// Swap input requires numeric values, not localized ones
const formattedDerivedValue = formatCurrencyAmount({
amount: currencyAmounts[derivedCurrencyField],
locale: 'en-US',
......
......@@ -2,12 +2,14 @@ import { notificationAsync } from 'expo-haptics'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
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 { BiometricsIcon } from 'src/components/icons/BiometricsIcon'
import { SpinningLoader } from 'src/components/loading/SpinningLoader'
import WarningModal from 'src/components/modals/WarningModal/WarningModal'
import { OnShowSwapFeeInfo } from 'src/components/SwapFee/SwapFee'
import { selectHasViewedReviewScreen } from 'src/features/behaviorHistory/selectors'
import { setHasViewedReviewScreen } from 'src/features/behaviorHistory/slice'
import {
useBiometricAppSettings,
useBiometricPrompt,
......@@ -78,6 +80,7 @@ export function SwapReviewScreen({ hideContent }: { hideContent: boolean }): JSX
isSubmitting,
onClose,
updateSwapForm,
isFiatMode,
} = useSwapFormContext()
const {
......@@ -156,7 +159,8 @@ export function SwapReviewScreen({ hideContent }: { hideContent: boolean }): JSX
!customSlippageTolerance,
navigateToNextScreen,
txId,
screen === SwapScreen.SwapReviewHoldingToSwap
screen === SwapScreen.SwapReviewHoldingToSwap,
isFiatMode
)
const submitTransaction = useCallback(() => {
......@@ -316,6 +320,12 @@ export function SwapReviewScreen({ hideContent }: { hideContent: boolean }): JSX
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))) {
// 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.
......
......@@ -33,12 +33,12 @@ import {
AnimatedFlex,
Button,
Flex,
Icons,
Text,
TouchableArea,
useDeviceDimensions,
useSporeColors,
} 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 { iconSizes, spacing } from 'ui/src/theme'
import { usePrevious } from 'utilities/src/react/hooks'
......@@ -209,7 +209,7 @@ export function TransferTokenForm({
const TRANSFER_DIRECTION_BUTTON_SIZE = iconSizes.icon20
const TRANSFER_DIRECTION_BUTTON_INNER_PADDING = spacing.spacing12
const TRANSFER_DIRECTION_BUTTON_BORDER_WIDTH = spacing.spacing4
const SendWarningIcon = transferWarning?.icon ?? AlertTriangleIcon
const SendWarningIcon = transferWarning?.icon ?? Icons.AlertCircle
return (
<>
......@@ -356,7 +356,7 @@ export function TransferTokenForm({
strokeWidth={1.5}
width={iconSizes.icon16}
/>
<Text color={transferWarningColor.text} variant="subheading2">
<Text adjustsFontSizeToFit color={transferWarningColor.text} variant="body3">
{transferWarning.title}
</Text>
</Flex>
......
......@@ -8,8 +8,7 @@ import {
WarningSeverity,
} from 'src/components/modals/WarningModal/types'
import { DerivedTransferInfo } from 'src/features/transactions/transfer/hooks'
import { FEATURE_FLAGS } from 'wallet/src/features/experiments/constants'
import { useFeatureFlag } from 'wallet/src/features/experiments/hooks'
import { useSwapRewriteEnabled } from 'wallet/src/features/experiments/hooks'
import { useOnChainNativeCurrencyBalance } from 'wallet/src/features/portfolio/api'
import { CurrencyField } from 'wallet/src/features/transactions/transactionState/types'
import { hasSufficientFundsIncludingGas } from 'wallet/src/features/transactions/utils'
......@@ -42,7 +41,7 @@ export function useTransactionGasWarning({
})
const balanceInsufficient = currencyAmountIn && currencyBalanceIn?.lessThan(currencyAmountIn)
const isSwapRewriteFeatureEnabled = useFeatureFlag(FEATURE_FLAGS.SwapRewrite)
const isSwapRewriteFeatureEnabled = useSwapRewriteEnabled()
return useMemo(() => {
// 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
// Allow smart contracts with non-null balances
const { data: balancesById } = usePortfolioBalances({
address: isSmartContractAddress ? (isAddress || resolvedAddress) ?? undefined : undefined,
shouldPoll: false,
fetchPolicy: 'cache-and-network',
})
const isValidSmartContract = isSmartContractAddress && !!balancesById
......
......@@ -136,13 +136,31 @@ function NFTItemScreenContents({
const inModal = useAppSelector(selectModalState(ModalName.Explore)).isOpen
const traceProperties = useMemo(
() =>
asset?.collection?.name
? { owner, address, tokenId, collectionName: asset?.collection?.name }
: undefined,
[address, asset?.collection?.name, owner, tokenId]
)
const traceProperties: Record<string, Maybe<string | boolean>> = useMemo(() => {
const baseProps = {
owner,
address,
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)
// 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" />
function AddressDisplayHeader({ address }: { address: Address }): JSX.Element {
const { t } = useTranslation()
const ensName = useENS(ChainId.Mainnet, address)?.name
const hasUnitag = !!useUnitag(address)
const hasUnitag = !!useUnitag(address)?.username
const onPressEditProfile = (): void => {
if (hasUnitag) {
......
......@@ -384,10 +384,8 @@ function HeaderRightElement({
const { menuActions, onContextMenuPress } = useTokenContextMenu({
currencyId,
isSpam: currentChainBalance?.currencyInfo.isSpam,
isNative: currentChainBalance?.currencyInfo.currency.isNative,
balanceUSD: currentChainBalance?.balanceUSD,
tokenSymbolForNotification: data?.token?.symbol,
portfolioBalance: currentChainBalance,
})
// Should be the same color as heart icon in not favorited state next to it
......
......@@ -64,7 +64,11 @@ export function dismissInAppBrowser(): void {
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)
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