ci(release): publish latest release

parent 1bf6943a
IPFS hash of the deployment:
- CIDv0: `QmSHXp5oNAxME6KY7LJy4ndnuBfL3ujQ67aMiPuTUuQ66g`
- CIDv1: `bafybeib2ui2plf3zbinsp24o4d5ir66yr4a3qlg55kswt2rmlgkoomvigu`
- CIDv0: `QmbeaM3MCweTaV4GCYLVMEKm5ErB89zu5bD6JaZ1Eoxjgc`
- CIDv1: `bafybeigfx5zxz364o5wjk7wwfil27fg27o6morrlgfik4cc3gohrlezape`
The latest release is always mirrored at [app.uniswap.org](https://app.uniswap.org).
......@@ -10,14 +10,65 @@ You can also access the Uniswap Interface from an IPFS gateway.
Your Uniswap settings are never remembered across different URLs.
IPFS gateways:
- https://bafybeib2ui2plf3zbinsp24o4d5ir66yr4a3qlg55kswt2rmlgkoomvigu.ipfs.dweb.link/
- [ipfs://QmSHXp5oNAxME6KY7LJy4ndnuBfL3ujQ67aMiPuTUuQ66g/](ipfs://QmSHXp5oNAxME6KY7LJy4ndnuBfL3ujQ67aMiPuTUuQ66g/)
- https://bafybeigfx5zxz364o5wjk7wwfil27fg27o6morrlgfik4cc3gohrlezape.ipfs.dweb.link/
- [ipfs://QmbeaM3MCweTaV4GCYLVMEKm5ErB89zu5bD6JaZ1Eoxjgc/](ipfs://QmbeaM3MCweTaV4GCYLVMEKm5ErB89zu5bD6JaZ1Eoxjgc/)
## 5.73.0 (2025-02-26)
## 5.74.0 (2025-02-27)
### Features
* **web:** reduce monad testnet quote polling interval (#16719) c99cc6c
* **web:** add unichain default rpc - main (#16124) 95aff86
* **web:** implement new design for the position detail page (#15895) df9fe66
* **web:** remove unichain beta toggle - main (#16168) (#16186) f479a33
### Bug Fixes
* **web:** [styled-components] migrate SparklineChart/index.tsx (#16557) 66fefda
* **web:** add space at bottom of Swap page (#16149) 600d027
* **web:** align New Position page in center on med-small screens (#16512) 45905e6
* **web:** analytics for 'swap submit button clicked' native eth transactions (#15914) bfe6da6
* **web:** create position spacing and alignment issues (#16470) 83ece23
* **web:** fix bug to showing blockaid logo (#16471) a0f3bdb
* **web:** landing page input output initial value (#16597) 26660cf
* **web:** LiquidityPositionRangeChart inverted prices bug (#16550) 8b658e8
* **web:** migrate FiatValue to tamagui (#16342) aea37e7
* **web:** migrate LoadingBubble to tamagui (#16341) 761a362
* **web:** migrate styled components usage in ukDisclaimerModal (#16422) c39acb0
* **web:** migrate styled-components usage in TaxTooltipBody (#16425) c346b04
* **web:** playwright browser cache (#16256) 4afc1bb
* **web:** positioning of loading liquidity bars in the range chart (#16222) dda5ff8
* **web:** re-add position page old design, use feature flag (#16583) cc5b299
* **web:** regression affecting Android keyboard opening (#16403) ab0fbed
* **web:** set loading status to false if no pagination result (#16296) 90f7a50
* **web:** sync table head and container scrollables (#16548) f83d68a
### Styles
* **web:** fix holiday nav icon size and use accent1 var from theme (#16558) 39e530a
### Continuous Integration
* **web:** Increase JS heap for web quality checks (#16395) fe4a390
* **web:** update sitemaps dae503c
### Code Refactoring
* **web:** add alignRight prop (#16142) e68d57f
* **web:** create mockMediaSize test util (#16414) b33ae5f
* **web:** don't adapt to sheet by default (#16139) c3aa280
* **web:** dropdown use children instead of internalMenuItems (#16130) 7ad312c
* **web:** empty wallet content deprecate styled components (#16440) 46fc931
* **web:** kill useSingleContractMultipleData (#15058) e908b3c
* **web:** split out AdaptiveDropdown (#16132) ad16962
* **web:** update AccountDrawer and use WebBottomSheet for small screens (#16340) 0b5615b
* **web:** use DropdownSelector for positions dropdown (#16191) b70cc11
* **web:** useReadContract instead of useSingleCallResult in block timestamp hooks (#14745) a5dde3f
* **web:** useReadContract instead of useSingleCallResult in migrate v2 hooks (#14746) ee407d6
* **web:** useReadContract instead of useSingleCallResult in misc hooks (#14717) f70fad4
web/5.73.0
\ No newline at end of file
web/5.74.0
\ No newline at end of file
......@@ -20,6 +20,7 @@
"dotenv-webpack": "8.0.1",
"ethers": "5.7.2",
"eventemitter3": "5.0.1",
"framer-motion": "10.17.6",
"i18next": "23.10.0",
"node-polyfill-webpack-plugin": "2.0.1",
"react": "18.3.1",
......
import { render } from '@testing-library/react'
import UnitagClaimApp from 'src/app/UnitagClaimApp'
import { initializeReduxStore } from 'src/store/store'
describe('UnitagClaimApp', () => {
it('renders without error', async () => {
await initializeReduxStore()
render(<UnitagClaimApp />)
})
})
import { PropsWithChildren } from 'react'
import { I18nextProvider } from 'react-i18next'
import { GraphqlProvider } from 'src/app/apollo'
import { TraceUserProperties } from 'src/app/components/Trace/TraceUserProperties'
import { ExtensionStatsigProvider } from 'src/app/core/StatsigProvider'
import { DatadogAppNameTag } from 'src/app/datadog'
import { getReduxStore } from 'src/store/store'
import { BlankUrlProvider } from 'uniswap/src/contexts/UrlContext'
import { LocalizationContextProvider } from 'uniswap/src/features/language/LocalizationContext'
import Trace from 'uniswap/src/features/telemetry/Trace'
import i18n from 'uniswap/src/i18n'
import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary'
import { SharedWalletProvider } from 'wallet/src/providers/SharedWalletProvider'
export function BaseAppContainer({
children,
appName,
}: PropsWithChildren<{ appName: DatadogAppNameTag }>): JSX.Element {
return (
<Trace>
<ExtensionStatsigProvider appName={appName}>
<I18nextProvider i18n={i18n}>
<SharedWalletProvider reduxStore={getReduxStore()}>
<ErrorBoundary>
<GraphqlProvider>
<BlankUrlProvider>
<LocalizationContextProvider>
<TraceUserProperties />
{children}
</LocalizationContextProvider>
</BlankUrlProvider>
</GraphqlProvider>
</ErrorBoundary>
</SharedWalletProvider>
</I18nextProvider>
</ExtensionStatsigProvider>
</Trace>
)
}
import { render } from '@testing-library/react'
import OnboardingApp from 'src/app/OnboardingApp'
import OnboardingApp from 'src/app/core/OnboardingApp'
import { initializeReduxStore } from 'src/store/store'
describe('OnboardingApp', () => {
......
......@@ -3,12 +3,10 @@ import 'src/app/Global.css'
import 'symbol-observable' // Needed by `reduxed-chrome-storage` as polyfill, order matters
import { useEffect } from 'react'
import { I18nextProvider } from 'react-i18next'
import { RouteObject, RouterProvider, createHashRouter } from 'react-router-dom'
import { PersistGate } from 'redux-persist/integration/react'
import { ExtensionStatsigProvider } from 'src/app/StatsigProvider'
import { GraphqlProvider } from 'src/app/apollo'
import { ErrorElement } from 'src/app/components/ErrorElement'
import { BaseAppContainer } from 'src/app/core/BaseAppContainer'
import { DatadogAppNameTag } from 'src/app/datadog'
import { ClaimUnitagScreen } from 'src/app/features/onboarding/ClaimUnitagScreen'
import { Complete } from 'src/app/features/onboarding/Complete'
......@@ -38,17 +36,11 @@ import { setRouter, setRouterState } from 'src/app/navigation/state'
import { initExtensionAnalytics } from 'src/app/utils/analytics'
import { checksIfSupportsSidePanel } from 'src/app/utils/chrome'
import { PrimaryAppInstanceDebuggerLazy } from 'src/store/PrimaryAppInstanceDebuggerLazy'
import { getReduxPersistor, getReduxStore } from 'src/store/store'
import { BlankUrlProvider } from 'uniswap/src/contexts/UrlContext'
import { LocalizationContextProvider } from 'uniswap/src/features/language/LocalizationContext'
import Trace from 'uniswap/src/features/telemetry/Trace'
import { getReduxPersistor } from 'src/store/store'
import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { UnitagUpdaterContextProvider } from 'uniswap/src/features/unitags/context'
import i18n from 'uniswap/src/i18n'
import { ExtensionOnboardingFlow } from 'uniswap/src/types/screens/extension'
import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary'
import { SharedWalletProvider } from 'wallet/src/providers/SharedWalletProvider'
const supportsSidePanel = checksIfSupportsSidePanel()
......@@ -186,27 +178,13 @@ export default function OnboardingApp(): JSX.Element {
}, [])
return (
<Trace>
<PersistGate persistor={getReduxPersistor()}>
<ExtensionStatsigProvider appName={DatadogAppNameTag.Onboarding}>
<I18nextProvider i18n={i18n}>
<SharedWalletProvider reduxStore={getReduxStore()}>
<ErrorBoundary>
<GraphqlProvider>
<BlankUrlProvider>
<LocalizationContextProvider>
<UnitagUpdaterContextProvider>
<PrimaryAppInstanceDebuggerLazy />
<RouterProvider router={router} />
</UnitagUpdaterContextProvider>
</LocalizationContextProvider>
</BlankUrlProvider>
</GraphqlProvider>
</ErrorBoundary>
</SharedWalletProvider>
</I18nextProvider>
</ExtensionStatsigProvider>
</PersistGate>
</Trace>
<PersistGate persistor={getReduxPersistor()}>
<BaseAppContainer appName={DatadogAppNameTag.Onboarding}>
<UnitagUpdaterContextProvider>
<PrimaryAppInstanceDebuggerLazy />
<RouterProvider router={router} />
</UnitagUpdaterContextProvider>
</BaseAppContainer>
</PersistGate>
)
}
......@@ -2,32 +2,21 @@ import '@tamagui/core/reset.css'
import 'src/app/Global.css'
import { useEffect } from 'react'
import { I18nextProvider, useTranslation } from 'react-i18next'
import { useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux'
import { RouterProvider, createHashRouter } from 'react-router-dom'
import { PersistGate } from 'redux-persist/integration/react'
import { ExtensionStatsigProvider } from 'src/app/StatsigProvider'
import { GraphqlProvider } from 'src/app/apollo'
import { ErrorElement } from 'src/app/components/ErrorElement'
import { TraceUserProperties } from 'src/app/components/Trace/TraceUserProperties'
import { BaseAppContainer } from 'src/app/core/BaseAppContainer'
import { DatadogAppNameTag } from 'src/app/datadog'
import { DappContextProvider } from 'src/app/features/dapp/DappContext'
import { initExtensionAnalytics } from 'src/app/utils/analytics'
import { getReduxPersistor, getReduxStore } from 'src/store/store'
import { DeprecatedButton, Flex, Image, Text } from 'ui/src'
import { CHROME_LOGO, UNISWAP_LOGO } from 'ui/src/assets'
import { iconSizes, spacing } from 'ui/src/theme'
import { BlankUrlProvider } from 'uniswap/src/contexts/UrlContext'
import { LocalizationContextProvider } from 'uniswap/src/features/language/LocalizationContext'
import { syncAppWithDeviceLanguage } from 'uniswap/src/features/settings/slice'
import Trace from 'uniswap/src/features/telemetry/Trace'
import { ElementName } from 'uniswap/src/features/telemetry/constants'
import { UnitagUpdaterContextProvider } from 'uniswap/src/features/unitags/context'
import i18n from 'uniswap/src/i18n'
import { ExtensionScreens } from 'uniswap/src/types/screens/extension'
import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary'
import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testnetMode/hooks'
import { SharedWalletProvider } from 'wallet/src/providers/SharedWalletProvider'
import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testnetMode/hooks/useTestnetModeForLoggingAndAnalytics'
const router = createHashRouter([
{
......@@ -114,29 +103,8 @@ export default function PopupApp(): JSX.Element {
}, [])
return (
<Trace>
<PersistGate persistor={getReduxPersistor()}>
<ExtensionStatsigProvider appName={DatadogAppNameTag.Popup}>
<I18nextProvider i18n={i18n}>
<SharedWalletProvider reduxStore={getReduxStore()}>
<ErrorBoundary>
<GraphqlProvider>
<BlankUrlProvider>
<LocalizationContextProvider>
<UnitagUpdaterContextProvider>
<TraceUserProperties />
<DappContextProvider>
<RouterProvider router={router} />
</DappContextProvider>
</UnitagUpdaterContextProvider>
</LocalizationContextProvider>
</BlankUrlProvider>
</GraphqlProvider>
</ErrorBoundary>
</SharedWalletProvider>
</I18nextProvider>
</ExtensionStatsigProvider>
</PersistGate>
</Trace>
<BaseAppContainer appName={DatadogAppNameTag.Popup}>
<RouterProvider router={router} />
</BaseAppContainer>
)
}
......@@ -3,14 +3,11 @@ import 'src/app/Global.css'
import { SharedEventName } from '@uniswap/analytics-events'
import { useEffect, useRef, useState } from 'react'
import { I18nextProvider } from 'react-i18next'
import { useDispatch } from 'react-redux'
import { RouterProvider, createHashRouter } from 'react-router-dom'
import { PersistGate } from 'redux-persist/integration/react'
import { ExtensionStatsigProvider } from 'src/app/StatsigProvider'
import { GraphqlProvider } from 'src/app/apollo'
import { ErrorElement } from 'src/app/components/ErrorElement'
import { TraceUserProperties } from 'src/app/components/Trace/TraceUserProperties'
import { BaseAppContainer } from 'src/app/core/BaseAppContainer'
import { DatadogAppNameTag } from 'src/app/datadog'
import { AccountSwitcherScreen } from 'src/app/features/accounts/AccountSwitcherScreen'
import { DappContextProvider } from 'src/app/features/dapp/DappContext'
......@@ -39,22 +36,16 @@ import {
} from 'src/background/messagePassing/messageChannels'
import { BackgroundToSidePanelRequestType } from 'src/background/messagePassing/types/requests'
import { PrimaryAppInstanceDebuggerLazy } from 'src/store/PrimaryAppInstanceDebuggerLazy'
import { getReduxPersistor, getReduxStore } from 'src/store/store'
import { BlankUrlProvider } from 'uniswap/src/contexts/UrlContext'
import { LocalizationContextProvider } from 'uniswap/src/features/language/LocalizationContext'
import { getReduxPersistor } from 'src/store/store'
import { syncAppWithDeviceLanguage } from 'uniswap/src/features/settings/slice'
import Trace from 'uniswap/src/features/telemetry/Trace'
import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { UnitagUpdaterContextProvider, useUnitagUpdater } from 'uniswap/src/features/unitags/context'
import i18n from 'uniswap/src/i18n'
import { isDevEnv } from 'utilities/src/environment/env'
import { logger } from 'utilities/src/logger/logger'
import { ONE_SECOND_MS } from 'utilities/src/time/time'
import { useInterval } from 'utilities/src/time/timing'
import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary'
import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testnetMode/hooks'
import { SharedWalletProvider } from 'wallet/src/providers/SharedWalletProvider'
import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testnetMode/hooks/useTestnetModeForLoggingAndAnalytics'
const router = createHashRouter([
{
......@@ -248,30 +239,15 @@ export default function SidebarApp(): JSX.Element {
}, [isLoggedIn])
return (
<Trace>
<PersistGate persistor={getReduxPersistor()}>
<ExtensionStatsigProvider appName={DatadogAppNameTag.Sidebar}>
<I18nextProvider i18n={i18n}>
<SharedWalletProvider reduxStore={getReduxStore()}>
<ErrorBoundary>
<GraphqlProvider>
<BlankUrlProvider>
<LocalizationContextProvider>
<UnitagUpdaterContextProvider>
<TraceUserProperties />
<DappContextProvider>
<PrimaryAppInstanceDebuggerLazy />
<RouterProvider router={router} />
</DappContextProvider>
</UnitagUpdaterContextProvider>
</LocalizationContextProvider>
</BlankUrlProvider>
</GraphqlProvider>
</ErrorBoundary>
</SharedWalletProvider>
</I18nextProvider>
</ExtensionStatsigProvider>
</PersistGate>
</Trace>
<PersistGate persistor={getReduxPersistor()}>
<BaseAppContainer appName={DatadogAppNameTag.Sidebar}>
<UnitagUpdaterContextProvider>
<DappContextProvider>
<PrimaryAppInstanceDebuggerLazy />
<RouterProvider router={router} />
</DappContextProvider>
</UnitagUpdaterContextProvider>
</BaseAppContainer>
</PersistGate>
)
}
......@@ -2,13 +2,9 @@ import '@tamagui/core/reset.css'
import 'src/app/Global.css'
import { PropsWithChildren, useEffect } from 'react'
import { I18nextProvider } from 'react-i18next'
import { Outlet, RouterProvider, createHashRouter, useSearchParams } from 'react-router-dom'
import { PersistGate } from 'redux-persist/integration/react'
import { ExtensionStatsigProvider } from 'src/app/StatsigProvider'
import { GraphqlProvider } from 'src/app/apollo'
import { ErrorElement } from 'src/app/components/ErrorElement'
import { TraceUserProperties } from 'src/app/components/Trace/TraceUserProperties'
import { BaseAppContainer } from 'src/app/core/BaseAppContainer'
import { DatadogAppNameTag } from 'src/app/datadog'
import {
ClaimUnitagSteps,
......@@ -25,19 +21,12 @@ import { UnitagIntroScreen } from 'src/app/features/unitags/UnitagIntroScreen'
import { UnitagClaimRoutes } from 'src/app/navigation/constants'
import { setRouter, setRouterState } from 'src/app/navigation/state'
import { initExtensionAnalytics } from 'src/app/utils/analytics'
import { getReduxPersistor, getReduxStore } from 'src/store/store'
import { Flex } from 'ui/src'
import { BlankUrlProvider } from 'uniswap/src/contexts/UrlContext'
import { LocalizationContextProvider } from 'uniswap/src/features/language/LocalizationContext'
import Trace from 'uniswap/src/features/telemetry/Trace'
import { UnitagUpdaterContextProvider } from 'uniswap/src/features/unitags/context'
import i18n from 'uniswap/src/i18n'
import { logger } from 'utilities/src/logger/logger'
import { usePrevious } from 'utilities/src/react/hooks'
import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary'
import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testnetMode/hooks'
import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testnetMode/hooks/useTestnetModeForLoggingAndAnalytics'
import { useAccountAddressFromUrlWithThrow } from 'wallet/src/features/wallet/hooks'
import { SharedWalletProvider } from 'wallet/src/providers/SharedWalletProvider'
const router = createHashRouter([
{
......@@ -149,27 +138,10 @@ export default function UnitagClaimApp(): JSX.Element {
}, [])
return (
<Trace>
<PersistGate persistor={getReduxPersistor()}>
<ExtensionStatsigProvider appName={DatadogAppNameTag.UnitagClaim}>
<I18nextProvider i18n={i18n}>
<SharedWalletProvider reduxStore={getReduxStore()}>
<ErrorBoundary>
<GraphqlProvider>
<BlankUrlProvider>
<LocalizationContextProvider>
<UnitagUpdaterContextProvider>
<TraceUserProperties />
<RouterProvider router={router} />
</UnitagUpdaterContextProvider>
</LocalizationContextProvider>
</BlankUrlProvider>
</GraphqlProvider>
</ErrorBoundary>
</SharedWalletProvider>
</I18nextProvider>
</ExtensionStatsigProvider>
</PersistGate>
</Trace>
<BaseAppContainer appName={DatadogAppNameTag.UnitagClaim}>
<UnitagUpdaterContextProvider>
<RouterProvider router={router} />
</UnitagUpdaterContextProvider>
</BaseAppContainer>
)
}
......@@ -5,7 +5,7 @@ import { useInterfaceBuyNavigator } from 'src/app/features/for/utils'
import { AppRoutes } from 'src/app/navigation/constants'
import { navigate } from 'src/app/navigation/state'
import { Flex, Text, getTokenValue, useMedia } from 'ui/src'
import { ArrowDownCircle, Buy, CoinConvert, SendAction } from 'ui/src/components/icons'
import { ArrowDownCircle, Bank, CoinConvert, SendAction } from 'ui/src/components/icons'
import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains'
import { ElementName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
......@@ -124,7 +124,7 @@ export const PortfolioActionButtons = memo(function _PortfolioActionButtons(): J
/>
<Flex row shrink gap="$spacing8" width={isGrid ? '100%' : '50%'}>
<ActionButton Icon={<CoinConvert />} label={t('home.label.swap')} onClick={onSwapClick} />
<ActionButton Icon={<Buy />} label={t('home.label.buy')} onClick={onBuyClick} />
<ActionButton Icon={<Bank />} label={t('home.label.buy')} onClick={onBuyClick} />
</Flex>
<Flex row shrink gap="$spacing8" width={isGrid ? '100%' : '50%'}>
<ActionButton Icon={<SendAction />} label={t('home.label.send')} onClick={onSendClick} />
......
......@@ -8,8 +8,7 @@ import { ShieldCheck } from 'ui/src/components/icons'
import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard'
import { InfoLinkModal } from 'uniswap/src/components/modals/InfoLinkModal'
import { uniswapUrls } from 'uniswap/src/constants/urls'
import { SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks'
import { PortfolioBalance } from 'uniswap/src/features/dataApi/types'
import { PortfolioBalance, TokenList } from 'uniswap/src/features/dataApi/types'
import { ElementName, ModalName, WalletEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { InformationBanner } from 'wallet/src/components/banners/InformationBanner'
......@@ -211,7 +210,7 @@ function TokenContextMenu({
}>): JSX.Element {
const contextMenu = useTokenContextMenu({
currencyId: portfolioBalance.currencyInfo.currencyId,
isBlocked: portfolioBalance.currencyInfo.safetyLevel === SafetyLevel.Blocked,
isBlocked: portfolioBalance.currencyInfo.safetyInfo?.tokenList === TokenList.Blocked,
tokenSymbolForNotification: portfolioBalance?.currencyInfo?.currency?.symbol,
portfolioBalance,
})
......
......@@ -8,7 +8,7 @@ import { OnboardingMessageType } from 'src/background/messagePassing/types/Exten
import { Flex, Image, useIsDarkMode } from 'ui/src'
import { syncAppWithDeviceLanguage } from 'uniswap/src/features/settings/slice'
import { OnboardingContextProvider } from 'wallet/src/features/onboarding/OnboardingContext'
import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testnetMode/hooks'
import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testnetMode/hooks/useTestnetModeForLoggingAndAnalytics'
export function OnboardingWrapper(): JSX.Element {
const isDarkMode = useIsDarkMode()
......
import { useCallback, useMemo, useRef } from 'react'
import { AnimatePresence, Variants, motion } from 'framer-motion'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSelector } from 'react-redux'
import { NavigationType, Outlet, ScrollRestoration, useLocation } from 'react-router-dom'
import { DappRequestQueue } from 'src/app/features/dappRequests/DappRequestQueue'
......@@ -10,10 +11,10 @@ import { useIsWalletUnlocked } from 'src/app/hooks/useIsWalletUnlocked'
import { HideContentsWhenSidebarBecomesInactive } from 'src/app/navigation/HideContentsWhenSidebarBecomesInactive'
import { SideBarNavigationProvider } from 'src/app/navigation/SideBarNavigationProvider'
import { AppRoutes } from 'src/app/navigation/constants'
import { useRouterState } from 'src/app/navigation/state'
import { RouterState, subscribeToRouterState, useRouterState } from 'src/app/navigation/state'
import { focusOrCreateOnboardingTab } from 'src/app/navigation/utils'
import { isOnboardedSelector } from 'src/app/utils/isOnboardedSelector'
import { AnimatePresence, Flex, SpinningLoader, styled } from 'ui/src'
import { Flex, SpinningLoader, styled } from 'ui/src'
import { TestnetModeBanner } from 'uniswap/src/components/banners/TestnetModeBanner'
import { useIsChromeWindowFocusedWithTimeout } from 'uniswap/src/extension/useIsChromeWindowFocused'
import { useAsyncData, usePrevious } from 'utilities/src/react/hooks'
......@@ -71,7 +72,29 @@ const getAppRouteFromPathName = (pathname: string): AppRoutes | null => {
return null
}
const animationVariant: Variants = {
initial: (dir: Direction) => ({
x: isVertical(dir) ? 0 : dir === 'right' ? -30 : 30,
y: !isVertical(dir) ? 0 : dir === 'down' ? -15 : 15,
opacity: 0,
zIndex: 1,
}),
animate: {
x: 0,
y: 0,
opacity: 1,
zIndex: 1,
},
exit: (dir: Direction) => ({
x: isVertical(dir) ? 0 : dir === 'left' ? -30 : 30,
y: !isVertical(dir) ? 0 : dir === 'up' ? -15 : 15,
opacity: 0,
zIndex: 0,
}),
}
export function WebNavigation(): JSX.Element {
const [isTransitioning, setIsTransitioning] = useState(false)
const isLoggedIn = useIsWalletUnlocked()
const { pathname } = useLocation()
const history = useRef<string[]>([]).current
......@@ -96,36 +119,49 @@ export function WebNavigation(): JSX.Element {
const prevPathname = usePrevious(pathname)
const shouldRestoreScroll = pathname !== prevPathname
useEffect(() => {
// We're using subscribeToRouterState subscriber to detect, whether we will
// navigate to another page, which will lead to the start of the animation.
subscribeToRouterState(({ historyAction, location }: RouterState) => {
const trimmedPathname = location.pathname.replace('/', '') as AppRoutes
if (historyAction !== NavigationType.Replace && Object.values(AppRoutes).includes(trimmedPathname)) {
setIsTransitioning(true)
}
})
}, [])
const childrenMemo = useMemo(() => {
return (
<AnimatePresence custom={{ towards }} initial={false}>
<AnimatedPane
key={pathname}
animation={[
isVertical(towards) ? 'quicker' : '100ms',
{
opacity: {
overshootClamping: true,
},
},
]}
>
<Flex fill grow overflow="visible">
<TestnetModeBanner />
{isLoggedIn === null ? (
<Loading />
) : isLoggedIn === true ? (
<HideContentsWhenSidebarBecomesInactive>
<LoggedIn />
</HideContentsWhenSidebarBecomesInactive>
) : (
<LoggedOut />
)}
</Flex>
</AnimatedPane>
</AnimatePresence>
<OverflowControlledFlex isTransitioning={isTransitioning}>
<AnimatePresence initial={false}>
<MotionFlex
key={pathname}
variants={animationVariant}
custom={towards}
initial="initial"
animate="animate"
exit="exit"
onAnimationComplete={() => {
setIsTransitioning(false)
}}
>
<Flex fill grow overflow="visible">
<TestnetModeBanner />
{isLoggedIn === null ? (
<Loading />
) : isLoggedIn === true ? (
<HideContentsWhenSidebarBecomesInactive>
<LoggedIn />
</HideContentsWhenSidebarBecomesInactive>
) : (
<LoggedOut />
)}
</Flex>
</MotionFlex>
</AnimatePresence>
</OverflowControlledFlex>
)
}, [isLoggedIn, pathname, towards])
}, [isLoggedIn, pathname, towards, isTransitioning])
return (
<SideBarNavigationProvider>
......@@ -138,16 +174,7 @@ export function WebNavigation(): JSX.Element {
)
}
// TODO(EXT-994): improve this loading screen.
function Loading(): JSX.Element {
return (
<Flex centered grow>
<SpinningLoader />
</Flex>
)
}
const AnimatedPane = styled(Flex, {
const MotionFlex = styled(motion(Flex), {
zIndex: 1,
fill: true,
position: 'absolute',
......@@ -158,25 +185,31 @@ const AnimatedPane = styled(Flex, {
minHeight: '100vh',
mx: 'auto',
width: '100%',
variants: {
towards: (dir: Direction) => ({
enterStyle: {
x: isVertical(dir) ? 0 : dir === 'right' ? 30 : -30,
y: !isVertical(dir) ? 0 : dir === 'down' ? 15 : -15,
opacity: 0,
zIndex: 1,
},
exitStyle: {
zIndex: 0,
x: isVertical(dir) ? 0 : dir === 'left' ? 30 : -30,
y: !isVertical(dir) ? 0 : dir === 'up' ? 15 : -15,
opacity: 0,
},
}),
} as const,
})
function OverflowControlledFlex({
children,
isTransitioning,
}: React.PropsWithChildren & { isTransitioning: boolean }): JSX.Element {
if (!isTransitioning) {
return <Flex fill>{children}</Flex>
}
return (
<Flex fill overflow="hidden">
{children}
</Flex>
)
}
// TODO(EXT-994): improve this loading screen.
function Loading(): JSX.Element {
return (
<Flex centered grow>
<SpinningLoader />
</Flex>
)
}
const isVertical = (dir: Direction): boolean => dir === 'up' || dir === 'down'
function useConstant<A>(c: A): A {
......
import { useEffect, useState } from 'react'
import { Location, NavigationType, Router, createHashRouter } from 'react-router-dom'
interface RouterState {
export interface RouterState {
historyAction: NavigationType
location: Location
}
......
import 'symbol-observable' // Needed by `reduxed-chrome-storage` as polyfill, order matters
import { initStatSigForBrowserScripts } from 'src/app/StatsigProvider'
import { initStatSigForBrowserScripts } from 'src/app/core/StatsigProvider'
import { focusOrCreateOnboardingTab } from 'src/app/navigation/utils'
import { initExtensionAnalytics } from 'src/app/utils/analytics'
import { initMessageBridge } from 'src/background/backgroundDappRequests'
......
import { focusOrCreateDappRequestWindow } from 'src/app/navigation/utils'
import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { logger } from 'utilities/src/logger/logger'
export async function openSidePanel(tabId: number | undefined, windowId: number): Promise<void> {
let hasError = false
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
await chrome.sidePanel.open({
......@@ -13,12 +16,15 @@ export async function openSidePanel(tabId: number | undefined, windowId: number)
// Consider removing this once the issue is resolved or leaving as fallback
await focusOrCreateDappRequestWindow(tabId, windowId)
hasError = true
logger.error(error, {
tags: {
file: 'background/background.ts',
function: 'openSidebar',
},
})
} finally {
sendAnalyticsEvent(ExtensionEventName.BackgroundAttemptedToOpenSidebar, { hasError })
}
}
......
......@@ -3,7 +3,7 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import OnboardingApp from 'src/app/OnboardingApp'
import OnboardingApp from 'src/app/core/OnboardingApp'
import { initializeReduxStore } from 'src/store/store'
import { ExtensionAppLocation, StoreSynchronization } from 'src/store/storeSynchronization'
import { logger } from 'utilities/src/logger/logger'
......
......@@ -3,7 +3,7 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import PopupApp from 'src/app/PopupApp'
import PopupApp from 'src/app/core/PopupApp'
import { initializeReduxStore } from 'src/store/store'
import { logger } from 'utilities/src/logger/logger'
;(globalThis as any).regeneratorRuntime = undefined // eslint-disable-line @typescript-eslint/no-explicit-any
......
......@@ -6,7 +6,7 @@ import 'symbol-observable' // Needed by `reduxed-chrome-storage` as polyfill, or
import React from 'react'
import { createRoot } from 'react-dom/client'
import SidebarApp from 'src/app/SidebarApp'
import SidebarApp from 'src/app/core/SidebarApp'
import { onboardingMessageChannel } from 'src/background/messagePassing/messageChannels'
import { OnboardingMessageType } from 'src/background/messagePassing/types/ExtensionMessages'
import { initializeReduxStore } from 'src/store/store'
......
......@@ -3,7 +3,7 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import UnitagClaimApp from 'src/app/UnitagClaimApp'
import UnitagClaimApp from 'src/app/core/UnitagClaimApp'
import { initializeReduxStore } from 'src/store/store'
import { logger } from 'utilities/src/logger/logger'
;(globalThis as any).regeneratorRuntime = undefined // eslint-disable-line @typescript-eslint/no-explicit-any
......
......@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Uniswap Extension",
"description": "The Uniswap Extension is a self-custody crypto wallet that's built for swapping.",
"version": "1.16.0",
"version": "1.17.0",
"minimum_chrome_version": "116",
"icons": {
"16": "assets/icon16.png",
......
......@@ -12,7 +12,7 @@ import {
readDeprecatedReduxedChromeStorage,
} from 'src/store/reduxedChromeStorageToReduxPersistMigration'
import { fiatOnRampAggregatorApi } from 'uniswap/src/features/fiatOnRamp/api'
import { createDatadogReduxEnhancer } from 'utilities/src/logger/Datadog'
import { createDatadogReduxEnhancer } from 'utilities/src/logger/datadog/Datadog'
import { createStore } from 'wallet/src/state'
import { createMigrate } from 'wallet/src/state/createMigrate'
......
......@@ -18,7 +18,6 @@ ignores: [
## React Native Usage
'@amplitude/analytics-react-native',
'@react-native-masked-view/masked-view',
'@react-native-firebase/app-check',
'@shopify/react-native-skia',
'react-native-image-colors',
'react-native-restart',
......
import { StorybookConfig } from '@storybook/react-native'
import type { StorybookConfig } from '@storybook/react-native'
const main: StorybookConfig = {
stories: ['../src/**/*.stories.?(ts|tsx|js|jsx)', '../../../packages/ui/src/**/*.stories.?(ts|tsx|js|jsx)'],
const config: StorybookConfig = {
stories: [
'../src/**/*.stories.?(ts|tsx|js|jsx)',
'../../../packages/ui/src/**/*.stories.?(ts|tsx|js|jsx)',
'../../../packages/uniswap/src/**/*.stories.?(ts|tsx|js|jsx)',
],
addons: ['@storybook/addon-ondevice-controls'],
}
export default main
export default config
......@@ -31,6 +31,19 @@ const normalizedStories = [
/^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/
),
},
{
titlePrefix: "",
directory: "../../packages/uniswap/src",
files: "**/*.stories.?(ts|tsx|js|jsx)",
importPathMatcher:
/^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/,
// @ts-ignore
req: require.context(
"../../../packages/uniswap/src",
true,
/^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/
),
},
];
declare global {
......
......@@ -72,9 +72,9 @@ if (isCI && datadogPropertiesAvailable && !isE2E) {
apply from: "../../../../node_modules/@datadog/mobile-react-native/datadog-sourcemaps.gradle"
}
def devVersionName = "1.46"
def betaVersionName = "1.46"
def prodVersionName = "1.46"
def devVersionName = "1.47"
def betaVersionName = "1.47"
def prodVersionName = "1.47"
android {
ndkVersion rootProject.ext.ndkVersion
......
......@@ -1150,10 +1150,6 @@ PODS:
- Apollo (1.2.1):
- Apollo/Core (= 1.2.1)
- Apollo/Core (1.2.1)
- AppCheckCore (11.2.0):
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- PromisesObjC (~> 2.4)
- AppsFlyerFramework (6.13.1):
- AppsFlyerFramework/Main (= 6.13.1)
- AppsFlyerFramework/Main (6.13.1)
......@@ -1248,9 +1244,6 @@ PODS:
- ExpoWebBrowser (13.0.3):
- ExpoModulesCore
- FBLazyVector (0.76.6)
- Firebase/AppCheck (11.2.0):
- Firebase/CoreOnly
- FirebaseAppCheck (~> 11.2.0)
- Firebase/Auth (11.2.0):
- Firebase/CoreOnly
- FirebaseAuth (~> 11.2.0)
......@@ -1259,12 +1252,6 @@ PODS:
- Firebase/Firestore (11.2.0):
- Firebase/CoreOnly
- FirebaseFirestore (~> 11.2.0)
- FirebaseAppCheck (11.2.0):
- AppCheckCore (~> 11.0)
- FirebaseAppCheckInterop (~> 11.0)
- FirebaseCore (~> 11.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- FirebaseAppCheckInterop (11.7.0)
- FirebaseAuth (11.2.0):
- FirebaseAppCheckInterop (~> 11.0)
......@@ -1328,9 +1315,6 @@ PODS:
- GoogleUtilities/Reachability (8.0.2):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/UserDefaults (8.0.2):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- "gRPC-C++ (1.65.5)":
- "gRPC-C++/Implementation (= 1.65.5)"
- "gRPC-C++/Interface (= 1.65.5)"
......@@ -1458,7 +1442,6 @@ PODS:
- OneSignalXCFramework/OneSignalCore
- OpenTelemetrySwiftApi (1.6.0)
- PLCrashReporter (1.11.2)
- PromisesObjC (2.4.0)
- RCT-Folly (2024.01.01.00):
- boost
- DoubleConversion
......@@ -2697,7 +2680,7 @@ PODS:
- react-native-appsflyer (6.13.1):
- AppsFlyerFramework (= 6.13.1)
- React
- react-native-compat (2.17.1):
- react-native-compat (2.18.0):
- DoubleConversion
- glog
- hermes-engine
......@@ -3149,10 +3132,6 @@ PODS:
- RNFBApp (21.0.0):
- Firebase/CoreOnly (= 11.2.0)
- React-Core
- RNFBAppCheck (21.0.0):
- Firebase/AppCheck (= 11.2.0)
- React-Core
- RNFBApp
- RNFBAuth (21.0.0):
- Firebase/Auth (= 11.2.0)
- React-Core
......@@ -3209,6 +3188,9 @@ PODS:
- React-Core
- RNPermissions (4.1.5):
- React-Core
- RNQrGenerator (1.4.3):
- React
- ZXingObjC
- RNReanimated (3.16.7):
- DoubleConversion
- glog
......@@ -3332,6 +3314,9 @@ PODS:
- Statsig (1.49.0)
- UIImageColors (2.1.0)
- Yoga (0.0.0)
- ZXingObjC (3.6.9):
- ZXingObjC/All (= 3.6.9)
- ZXingObjC/All (3.6.9)
- ZXingObjC/Core (3.6.9)
- ZXingObjC/OneD (3.6.9):
- ZXingObjC/Core
......@@ -3449,7 +3434,6 @@ DEPENDENCIES:
- RNDeviceInfo (from `../../../node_modules/react-native-device-info`)
- RNFastImage (from `../../../node_modules/react-native-fast-image`)
- "RNFBApp (from `../../../node_modules/@react-native-firebase/app`)"
- "RNFBAppCheck (from `../../../node_modules/@react-native-firebase/app-check`)"
- "RNFBAuth (from `../../../node_modules/@react-native-firebase/auth`)"
- "RNFBFirestore (from `../../../node_modules/@react-native-firebase/firestore`)"
- "RNFlashList (from `../../../node_modules/@shopify/flash-list`)"
......@@ -3457,6 +3441,7 @@ DEPENDENCIES:
- RNImageColors (from `../../../node_modules/react-native-image-colors`)
- RNLocalize (from `../../../node_modules/react-native-localize`)
- RNPermissions (from `../../../node_modules/react-native-permissions`)
- RNQrGenerator (from `../../../node_modules/rn-qr-generator`)
- RNReanimated (from `../../../node_modules/react-native-reanimated`)
- RNScreens (from `../../../node_modules/react-native-screens`)
- RNSVG (from `../../../node_modules/react-native-svg`)
......@@ -3469,7 +3454,6 @@ SPEC REPOS:
trunk:
- abseil
- Apollo
- AppCheckCore
- AppsFlyerFramework
- Argon2Swift
- BoringSSL-GRPC
......@@ -3481,7 +3465,6 @@ SPEC REPOS:
- DatadogTrace
- DatadogWebViewTracking
- Firebase
- FirebaseAppCheck
- FirebaseAppCheckInterop
- FirebaseAuth
- FirebaseAuthInterop
......@@ -3503,7 +3486,6 @@ SPEC REPOS:
- OneSignalXCFramework
- OpenTelemetrySwiftApi
- PLCrashReporter
- PromisesObjC
- RecaptchaInterop
- SDWebImage
- SDWebImageWebPCoder
......@@ -3724,8 +3706,6 @@ EXTERNAL SOURCES:
:path: "../../../node_modules/react-native-fast-image"
RNFBApp:
:path: "../../../node_modules/@react-native-firebase/app"
RNFBAppCheck:
:path: "../../../node_modules/@react-native-firebase/app-check"
RNFBAuth:
:path: "../../../node_modules/@react-native-firebase/auth"
RNFBFirestore:
......@@ -3740,6 +3720,8 @@ EXTERNAL SOURCES:
:path: "../../../node_modules/react-native-localize"
RNPermissions:
:path: "../../../node_modules/react-native-permissions"
RNQrGenerator:
:path: "../../../node_modules/rn-qr-generator"
RNReanimated:
:path: "../../../node_modules/react-native-reanimated"
RNScreens:
......@@ -3755,7 +3737,6 @@ SPEC CHECKSUMS:
abseil: d121da9ef7e2ff4cab7666e76c5a3e0915ae08c3
amplitude-react-native: 9d57e1bcc4175039e36283390aa3daeaea9441a5
Apollo: fe380f40e55e501a2499dd5885fab0cdf082b2bb
AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f
AppsFlyerFramework: 971521cf5b890c2afeab2f2c91734547b8b169ca
Argon2Swift: 99482c1b8122a03524b61e41c4903a9548e7c33b
boost: 1dca942403ed9342f98334bf4c3621f011aa7946
......@@ -3789,7 +3770,6 @@ SPEC CHECKSUMS:
ExpoWebBrowser: 7595ccac6938eb65b076385fd23d035db9ecdc8e
FBLazyVector: be509404b5de73a64a74284edcaf73a5d1e128b1
Firebase: 98e6bf5278170668a7983e12971a66b2cd57fc8c
FirebaseAppCheck: a6a1c1ca169d795212b9e70b5cfb880083a28e7c
FirebaseAppCheckInterop: 2376d3ec5cb4267facad4fe754ab4f301a5a519b
FirebaseAuth: 2a198b8cdbbbd457f08d74df7040feb0a0e7777a
FirebaseAuthInterop: a6973d72aa242ea88ffb6be9c9b06c65455071da
......@@ -3814,7 +3794,6 @@ SPEC CHECKSUMS:
OneSignalXCFramework: ff1c970b7aeb4ac0fe48fb35393eb5d8bf378135
OpenTelemetrySwiftApi: 657da8071c2908caecce11548e006f779924ff9c
PLCrashReporter: 499c53b0104f95c302d94fd723ebb03c56d9bac8
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
RCT-Folly: bf5c0376ffe4dd2cf438dcf86db385df9fdce648
RCTDeprecation: 063fc281b30b7dc944c98fe53a7e266dab1a8706
RCTRequired: 8eda2a5a745f6081157a4f34baac40b65fe02b31
......@@ -3845,7 +3824,7 @@ SPEC CHECKSUMS:
React-Mapbuffer: eab34f6d54d26931c5f70eb19da1e36162d87bbd
React-microtasksnativemodule: 9866981c8f1e80bb819e34f4ea45870cb3e6afaa
react-native-appsflyer: 2cc1f96348065fc23e976fc7a27e371789fb349e
react-native-compat: ea7b6b73dbd2503594a287d7fb41660ee4f83f40
react-native-compat: f493f09bd0e990f72188d628dfc0a89cbb3122ed
react-native-context-menu-view: dcec18eb8882e20596dbb75802e7d19cb87dac02
react-native-get-random-values: 21325b2244dfa6b58878f51f9aa42821e7ba3d06
react-native-image-picker: 00f0e4aae2710ad1ffbc72f65dfe0e396f6b6508
......@@ -3895,7 +3874,6 @@ SPEC CHECKSUMS:
RNDeviceInfo: 0a7c1d2532aa7691f9b9925a27e43af006db4dae
RNFastImage: 246de6b52d7642992cfd01e2005dda36d00a6660
RNFBApp: 4122dd41d8d7ff017b6ecf777a6224f5b349ca04
RNFBAppCheck: 2625a4cd0bcb11b409cbe47186e29104603d4d34
RNFBAuth: 1632cefd787a43ba952fa52ff016e7b69fe355cb
RNFBFirestore: 5f110e37b7f7f3d6e03c85044dd4cf3ebacec38b
RNFlashList: 997257a094906e3e254183ef90fcf7a5a6a2612d
......@@ -3903,6 +3881,7 @@ SPEC CHECKSUMS:
RNImageColors: 9ac05083b52d5c350e6972650ae3ba0e556466c1
RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81
RNPermissions: 87aac13521bea6dcb6dfd60b03ac69741ccef2b4
RNQrGenerator: ac6a6c766e80dd3625038929ed2b13e2f3edcafb
RNReanimated: 283b723ad4ac5295f1513519c938cb6c282c508f
RNScreens: 906192367b418a8d644090d7375d4657d5a5aab0
RNSVG: 7ff26379b2d1871b8571e6f9bc9630de6baf9bdf
......
......@@ -2226,7 +2226,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.46;
MARKETING_VERSION = 1.47;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
......@@ -2279,7 +2279,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.46;
MARKETING_VERSION = 1.47;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = schemes.WidgetsCore;
......@@ -2332,7 +2332,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.46;
MARKETING_VERSION = 1.47;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = schemes.WidgetsCore;
......@@ -2385,7 +2385,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.46;
MARKETING_VERSION = 1.47;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = schemes.WidgetsCore;
......@@ -2423,7 +2423,7 @@
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
MARKETING_VERSION = 1.46;
MARKETING_VERSION = 1.47;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
......@@ -2459,7 +2459,7 @@
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
MARKETING_VERSION = 1.46;
MARKETING_VERSION = 1.47;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = schemes.WidgetsCoreTests;
......@@ -2494,7 +2494,7 @@
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
MARKETING_VERSION = 1.46;
MARKETING_VERSION = 1.47;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = schemes.WidgetsCoreTests;
......@@ -2529,7 +2529,7 @@
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
MARKETING_VERSION = 1.46;
MARKETING_VERSION = 1.47;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = schemes.WidgetsCoreTests;
......@@ -2576,7 +2576,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.46;
MARKETING_VERSION = 1.47;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
......@@ -2622,7 +2622,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.46;
MARKETING_VERSION = 1.47;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.widgets;
......@@ -2668,7 +2668,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.46;
MARKETING_VERSION = 1.47;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.dev.widgets;
......@@ -2714,7 +2714,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.46;
MARKETING_VERSION = 1.47;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.beta.widgets;
......@@ -2756,7 +2756,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.46;
MARKETING_VERSION = 1.47;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
......@@ -2799,7 +2799,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.46;
MARKETING_VERSION = 1.47;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.WidgetIntentExtension;
......@@ -2842,7 +2842,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.46;
MARKETING_VERSION = 1.47;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.dev.WidgetIntentExtension;
......@@ -2885,7 +2885,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.46;
MARKETING_VERSION = 1.47;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.beta.WidgetIntentExtension;
......@@ -2921,7 +2921,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.46;
MARKETING_VERSION = 1.47;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
......@@ -2959,7 +2959,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.46;
MARKETING_VERSION = 1.47;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
......@@ -3161,7 +3161,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.46;
MARKETING_VERSION = 1.47;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
......@@ -3206,7 +3206,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.46;
MARKETING_VERSION = 1.47;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.OneSignalNotificationServiceExtension;
......@@ -3317,7 +3317,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.46;
MARKETING_VERSION = 1.47;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
......@@ -3389,7 +3389,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.46;
MARKETING_VERSION = 1.47;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.beta.OneSignalNotificationServiceExtension;
......@@ -3500,7 +3500,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.46;
MARKETING_VERSION = 1.47;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
......@@ -3572,7 +3572,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.46;
MARKETING_VERSION = 1.47;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.dev.OneSignalNotificationServiceExtension;
......
#import "AppDelegate.h"
#import "RNFBAppCheckModule.h"
#import <Firebase.h>
#import "Uniswap-Swift.h"
......@@ -17,8 +16,6 @@
// Must be first line in startup routine
[ReactNativePerformance onAppStarted];
// Must be before [FIRApp configure], initializes RNFBAppCheckModule
[RNFBAppCheckModule sharedInstance];
[FIRApp configure];
// This is needed so universal links opened from OneSignal notifications navigate to the proper page.
......
......@@ -73,14 +73,6 @@ jest.mock('@react-native-firebase/auth', () => () => ({
signInAnonymously: jest.fn(),
}))
jest.mock('@react-native-firebase/app-check', () => () => ({
appCheck: jest.fn(),
newReactNativeFirebaseAppCheckProvider: jest.fn(() => ({
configure: jest.fn(),
})),
initializeAppCheck: jest.fn().mockReturnValue(Promise.resolve()), // Return a resolved Promise
}))
jest.mock('react-native/Libraries/Linking/Linking', () => ({
openURL: jest.fn(),
addEventListener: jest.fn(),
......
......@@ -71,7 +71,6 @@
"@react-native-community/cli-platform-ios": "15.0.1",
"@react-native-community/netinfo": "11.4.1",
"@react-native-firebase/app": "21.0.0",
"@react-native-firebase/app-check": "21.0.0",
"@react-native-firebase/auth": "21.0.0",
"@react-native-firebase/firestore": "21.0.0",
"@react-native-masked-view/masked-view": "0.3.2",
......@@ -93,9 +92,9 @@
"@uniswap/client-explore": "0.0.15",
"@uniswap/ethers-rs-mobile": "0.0.5",
"@uniswap/sdk-core": "7.5.0",
"@walletconnect/core": "2.17.1",
"@walletconnect/react-native-compat": "2.17.1",
"@walletconnect/utils": "2.17.1",
"@walletconnect/core": "2.18.0",
"@walletconnect/react-native-compat": "2.18.0",
"@walletconnect/utils": "2.18.0",
"apollo3-cache-persist": "0.14.1",
"babel-plugin-transform-inline-environment-variables": "0.4.4",
"babel-plugin-transform-remove-console": "6.9.4",
......@@ -142,6 +141,7 @@
"react-native-restart": "0.0.27",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "4.1.0",
"react-native-sortables": "1.1.1",
"react-native-svg": "15.10.1",
"react-native-tab-view": "3.5.2",
"react-native-url-polyfill": "1.3.0",
......@@ -153,6 +153,7 @@
"redux-mock-store": "1.5.4",
"redux-persist": "6.0.0",
"redux-saga": "1.2.2",
"rn-qr-generator": "1.4.3",
"typed-redux-saga": "1.5.0",
"uniswap": "workspace:^",
"utilities": "workspace:^",
......@@ -168,9 +169,9 @@
"@faker-js/faker": "7.6.0",
"@react-native-community/datetimepicker": "8.2.0",
"@react-native-community/slider": "4.5.5",
"@storybook/addon-ondevice-controls": "8.4.2",
"@storybook/react": "8.4.2",
"@storybook/react-native": "8.4.2",
"@storybook/addon-ondevice-controls": "8.5.2",
"@storybook/react": "8.5.2",
"@storybook/react-native": "8.5.2",
"@tamagui/babel-plugin": "1.121.7",
"@testing-library/react-native": "13.0.0",
"@types/jest": "29.5.14",
......
#!/bin/bash
MAX_SIZE=20.75
MAX_SIZE=23
# Check OS type and use appropriate stat command
if [[ "$OSTYPE" == "darwin"* ]]; then
# MacOS
BUNDLE_SIZE=$(stat -f %z ios/main.jsbundle | awk '{print $1/1024/1024}')
# MacOS
BUNDLE_SIZE=$(stat -f %z ios/main.jsbundle | awk '{print $1/1024/1024}')
else
# Linux and others
BUNDLE_SIZE=$(stat --format=%s ios/main.jsbundle | awk '{print $1/1024/1024}')
# Linux and others
BUNDLE_SIZE=$(stat --format=%s ios/main.jsbundle | awk '{print $1/1024/1024}')
fi
if (( $(echo "$BUNDLE_SIZE > $MAX_SIZE" | bc -l) )); then
if (($(echo "$BUNDLE_SIZE > $MAX_SIZE" | bc -l))); then
echo "Bundle size ($BUNDLE_SIZE MB) exceeds limit ($MAX_SIZE MB)"
exit 1
else
......
......@@ -28,6 +28,7 @@ urls=(
"https://uniswap.org/app/wc?uri=wc:af098@2?relay-protocol=irn&symKey=51e"
"uniswap://app/fiatonramp?userAddress=$user_id&source=push"
"uniswap://app/tokendetails?currencyId=10-0x6fd9d7ad17242c41f7131d257212c54a0e816691&source=push"
"uniswap://app/tokendetails?currencyId=0-fwefe&source=push" # invalid currencyId
)
xcrun simctl terminate booted "$bundle_id"
......
......@@ -72,9 +72,9 @@ import { CurrencyId } from 'uniswap/src/types/currency'
import { getUniqueId } from 'utilities/src/device/getUniqueId'
import { datadogEnabled, isE2EMode } from 'utilities/src/environment/constants'
import { isTestEnv } from 'utilities/src/environment/env'
import { attachUnhandledRejectionHandler, setAttributesToDatadog } from 'utilities/src/logger/Datadog'
import { registerConsoleOverrides } from 'utilities/src/logger/console'
import { DDRumAction, DDRumTiming } from 'utilities/src/logger/datadogEvents'
import { attachUnhandledRejectionHandler, setAttributesToDatadog } from 'utilities/src/logger/datadog/Datadog'
import { DDRumAction, DDRumTiming } from 'utilities/src/logger/datadog/datadogEvents'
import { logger } from 'utilities/src/logger/logger'
import { isIOS } from 'utilities/src/platform'
import { useAsyncData } from 'utilities/src/react/hooks'
......@@ -82,10 +82,9 @@ import { AnalyticsNavigationContextProvider } from 'utilities/src/telemetry/trac
import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary'
// eslint-disable-next-line no-restricted-imports
import { usePersistedApolloClient } from 'wallet/src/data/apollo/usePersistedApolloClient'
import { initFirebaseAppCheck } from 'wallet/src/features/appCheck/appCheck'
import { useCurrentAppearanceSetting } from 'wallet/src/features/appearance/hooks'
import { selectAllowAnalytics } from 'wallet/src/features/telemetry/selectors'
import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testnetMode/hooks'
import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testnetMode/hooks/useTestnetModeForLoggingAndAnalytics'
import { TransactionHistoryUpdater } from 'wallet/src/features/transactions/TransactionHistoryUpdater'
import { WalletUniswapProvider } from 'wallet/src/features/transactions/contexts/WalletUniswapContext'
import { Account } from 'wallet/src/features/wallet/accounts/types'
......@@ -114,7 +113,6 @@ if (isE2EMode) {
initOneSignal()
initAppsFlyer()
initFirebaseAppCheck()
function App(): JSX.Element | null {
useEffect(() => {
......
......@@ -246,8 +246,8 @@ function useNavigateToFiatOnRamp(): (args: NavigateToFiatOnRampArgs) => void {
const dispatch = useDispatch()
return useCallback(
({ prefilledCurrency }: NavigateToFiatOnRampArgs): void => {
dispatch(openModal({ name: ModalName.FiatOnRampAggregator, initialState: { prefilledCurrency } }))
({ prefilledCurrency, isOfframp }: NavigateToFiatOnRampArgs): void => {
dispatch(openModal({ name: ModalName.FiatOnRampAggregator, initialState: { prefilledCurrency, isOfframp } }))
},
[dispatch],
)
......
......@@ -3,11 +3,11 @@ import React, { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingsStackNavigationProp } from 'src/app/navigation/types'
import { NotificationsBackgroundImage } from 'src/components/notifications/NotificationsBGImage'
import { promptPushPermission } from 'src/features/notifications/Onesignal'
import {
NotificationPermission,
useNotificationOSPermissionsEnabled,
} from 'src/features/notifications/hooks/useNotificationOSPermissionsEnabled'
import { usePromptPushPermission } from 'src/features/notifications/hooks/usePromptPushPermission'
import { DeprecatedButton, Flex } from 'ui/src'
import { BellOn } from 'ui/src/components/icons/BellOn'
import { GenericHeader } from 'uniswap/src/components/misc/GenericHeader'
......@@ -27,7 +27,7 @@ type NotificationsOSSettingsModalProps = {
*/
export function NotificationsOSSettingsModal({ navigation }: NotificationsOSSettingsModalProps): JSX.Element {
const { notificationPermissionsEnabled, checkNotificationPermissions } = useNotificationOSPermissionsEnabled()
const promptPushPermission = usePromptPushPermission()
const { t } = useTranslation()
const shouldNavigateToSettings = useMemo(() => {
......@@ -55,7 +55,7 @@ export function NotificationsOSSettingsModal({ navigation }: NotificationsOSSett
} else {
await checkNotificationPermissions()
}
}, [checkNotificationPermissions])
}, [checkNotificationPermissions, promptPushPermission])
const onClose = useCallback(() => {
if (shouldNavigateToSettings) {
......
......@@ -2,7 +2,6 @@ import { useCallback } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { closeModal } from 'src/features/modals/modalSlice'
import { selectModalState } from 'src/features/modals/selectModalState'
import { SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks'
import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains'
import { TokenList } from 'uniswap/src/features/dataApi/types'
import { ModalName } from 'uniswap/src/features/telemetry/constants'
......@@ -43,11 +42,11 @@ export function TokenWarningModalWrapper(): JSX.Element | null {
return null
}
const safetyLevel = currencyInfo.safetyLevel
const isBlocked = safetyLevel === SafetyLevel.Blocked || currencyInfo.safetyInfo?.tokenList === TokenList.Blocked
const tokenList = currencyInfo.safetyInfo?.tokenList
const isBlocked = tokenList === TokenList.Blocked
// If token is verified or warning was dismissed and not blocked, skip warning and proceed to SwapFlow
if (!isBlocked && (safetyLevel === SafetyLevel.Verified || tokenWarningDismissed)) {
if (!isBlocked && (tokenList === TokenList.Default || tokenWarningDismissed)) {
onAcknowledge?.()
onClose()
return null
......
......@@ -6,7 +6,7 @@ import { MobileState, mobilePersistedStateList, mobileReducer } from 'src/app/mo
import { rootMobileSaga } from 'src/app/saga'
import { fiatOnRampAggregatorApi } from 'uniswap/src/features/fiatOnRamp/api'
import { isNonJestDev } from 'utilities/src/environment/constants'
import { createDatadogReduxEnhancer } from 'utilities/src/logger/Datadog'
import { createDatadogReduxEnhancer } from 'utilities/src/logger/datadog/Datadog'
import { createStore } from 'wallet/src/state'
import { createMigrate } from 'wallet/src/state/createMigrate'
......
import { BarcodeScanningResult, CameraView, CameraViewProps, scanFromURLAsync, useCameraPermissions } from 'expo-camera'
import { BarcodeScanningResult, CameraView, CameraViewProps } from 'expo-camera'
import { PermissionStatus } from 'expo-modules-core'
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
......@@ -7,6 +7,8 @@ import DeviceInfo from 'react-native-device-info'
import { launchImageLibrary } from 'react-native-image-picker'
import { FadeIn, FadeOut } from 'react-native-reanimated'
import { Defs, LinearGradient, Path, Rect, Stop, Svg } from 'react-native-svg'
import RNQRGenerator from 'rn-qr-generator'
import { useCameraPermission } from 'src/components/QRCodeScanner/hooks/useCameraPermission'
import { DeprecatedButton, Flex, SpinningLoader, Text, ThemeName, useSporeColors } from 'ui/src'
import CameraScan from 'ui/src/assets/icons/camera-scan.svg'
import { Global, PhotoStacked } from 'ui/src/components/icons'
......@@ -55,8 +57,7 @@ export function QRCodeScanner(props: QRCodeScannerProps | WCScannerProps): JSX.E
const colors = useSporeColorsForTheme(theme)
const dimensions = useDeviceDimensions()
const [permission, requestPermission] = useCameraPermissions()
const [permission, requestPermission] = useCameraPermission()
const [isReadingImageFile, setIsReadingImageFile] = useState(false)
const [overlayLayout, setOverlayLayout] = useState<LayoutRectangle | null>()
......@@ -94,16 +95,25 @@ export function QRCodeScanner(props: QRCodeScannerProps | WCScannerProps): JSX.E
return
}
const result = (await scanFromURLAsync(uri, [BarcodeType.QR]))[0]
// TODO (WALL-6014): Migrate to expo-camera once Android issue is fixed
try {
const results = await RNQRGenerator.detect({ uri })
if (!result) {
if (results.values[0]) {
const data = results.values[0]
onScanCode(data)
} else {
Alert.alert(t('qrScanner.error.none'))
}
} catch (error) {
logger.error(`Cannot detect QR code in image: ${error}`, {
tags: { file: 'QRCodeScanner.tsx', function: 'onPickImageFilePress' },
})
Alert.alert(t('qrScanner.error.none'))
} finally {
setIsReadingImageFile(false)
return
}
handleBarcodeScanned(result)
}, [handleBarcodeScanned, isReadingImageFile, t])
}, [isReadingImageFile, onScanCode, t])
useEffect(() => {
const handlePermissionStatus = async (): Promise<void> => {
......
import { useCameraPermissions } from 'expo-camera'
import { usePreventLock } from 'src/features/lockScreen/hooks/usePreventLock'
import { useEvent } from 'utilities/src/react/hooks'
type UseCameraPermissionsResult = ReturnType<typeof useCameraPermissions>
/**
* Custom hook to handle camera permissions with Android-specific considerations.
*
* On Android, requesting permissions causes the app to briefly enter a background state.
* We use preventLock to ensure this temporary background state doesn't trigger any
* app lock mechanisms during the permission request flow.
*/
export const useCameraPermission = (): UseCameraPermissionsResult => {
const [permission, _requestPermission, ...rest] = useCameraPermissions()
const { preventLock } = usePreventLock()
const requestPermission = useEvent(async () => {
return preventLock(_requestPermission)
})
return [permission, requestPermission, ...rest] as const
}
......@@ -127,6 +127,7 @@ export function WalletConnectModal({
} catch (error) {
logger.error(error, {
tags: { file: 'WalletConnectModal', function: 'onScanCode' },
extra: { wcUri: supportedURI.value },
})
const title = t('walletConnect.error.general.title')
......
......@@ -86,13 +86,7 @@ type ProcessedRow =
| { type: 'footer'; data: SectionInfo }
function processSections(sections: SettingsSection[]): ProcessedRow[] {
const resultSize = sections.reduce((acc, section) => {
const dataLength = section.data.length
return acc + (section.subTitle ? 1 : 0) + dataLength
}, 0)
const result: ProcessedRow[] = new Array(resultSize)
let index = 0
const result: ProcessedRow[] = []
for (const section of sections) {
if (section.isHidden) {
......@@ -100,12 +94,12 @@ function processSections(sections: SettingsSection[]): ProcessedRow[] {
}
if (section.subTitle) {
result[index++] = {
result.push({
type: 'header',
data: {
section,
},
}
})
}
for (const data of section.data) {
......@@ -113,19 +107,19 @@ function processSections(sections: SettingsSection[]): ProcessedRow[] {
continue
}
result[index++] = {
result.push({
type: 'item',
data,
}
})
}
if (section.subTitle) {
result[index++] = {
result.push({
type: 'footer',
data: {
section,
},
}
})
}
}
......
......@@ -2,8 +2,7 @@ import React, { PropsWithChildren, memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import ContextMenu from 'react-native-context-menu-view'
import { borderRadii } from 'ui/src/theme'
import { SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks'
import { PortfolioBalance } from 'uniswap/src/features/dataApi/types'
import { PortfolioBalance, TokenList } from 'uniswap/src/features/dataApi/types'
import { useTokenContextMenu } from 'wallet/src/features/portfolio/useTokenContextMenu'
export const TokenBalanceItemContextMenu = memo(function _TokenBalanceItemContextMenu({
......@@ -16,7 +15,7 @@ export const TokenBalanceItemContextMenu = memo(function _TokenBalanceItemContex
const { menuActions, onContextMenuPress } = useTokenContextMenu({
currencyId: portfolioBalance.currencyInfo.currencyId,
isBlocked: portfolioBalance.currencyInfo.safetyLevel === SafetyLevel.Blocked,
isBlocked: portfolioBalance.currencyInfo.safetyInfo?.tokenList === TokenList.Blocked,
portfolioBalance,
tokenSymbolForNotification: t('walletConnect.request.details.label.token'),
})
......
......@@ -22,7 +22,7 @@ import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { useAppInsets } from 'uniswap/src/hooks/useAppInsets'
import { CurrencyId } from 'uniswap/src/types/currency'
import { MobileScreens } from 'uniswap/src/types/screens/mobile'
import { DDRumManualTiming } from 'utilities/src/logger/datadogEvents'
import { DDRumManualTiming } from 'utilities/src/logger/datadog/datadogEvents'
import { usePerformanceLogger } from 'utilities/src/logger/usePerformanceLogger'
import { isAndroid } from 'utilities/src/platform'
import { useValueAsRef } from 'utilities/src/react/useValueAsRef'
......
......@@ -4,8 +4,6 @@ import { useTokenDetailsContext } from 'src/components/TokenDetails/TokenDetails
import { DeprecatedButton, Flex, GeneratedIcon, useSporeColors } from 'ui/src'
import { SwapCoin } from 'ui/src/components/icons'
import { opacify, validColor } from 'ui/src/theme'
import { SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks'
import { useTokenBasicProjectPartsFragment } from 'uniswap/src/data/graphql/uniswap-data-api/fragments'
import { TokenList } from 'uniswap/src/features/dataApi/types'
import { FeatureFlags } from 'uniswap/src/features/gating/flags'
import { useFeatureFlag } from 'uniswap/src/features/gating/hooks'
......@@ -69,12 +67,9 @@ export function TokenDetailsActionButtons({
const { t } = useTranslation()
const isOffRampEnabled = useFeatureFlag(FeatureFlags.FiatOffRamp)
const { currencyId, currencyInfo, isChainEnabled, tokenColor } = useTokenDetailsContext()
const { currencyInfo, isChainEnabled, tokenColor } = useTokenDetailsContext()
const project = useTokenBasicProjectPartsFragment({ currencyId }).data?.project
const safetyLevel = project?.safetyLevel
const isBlocked = safetyLevel === SafetyLevel.Blocked || currencyInfo?.safetyInfo?.tokenList === TokenList.Blocked
const isBlocked = currencyInfo?.safetyInfo?.tokenList === TokenList.Blocked
const disabled = isBlocked || !isChainEnabled
......
import React from 'react'
import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { ScrollView, View } from 'react-native'
import { View } from 'react-native'
import { FlatList } from 'react-native-gesture-handler'
import { LinkButton, LinkButtonType } from 'src/components/TokenDetails/LinkButton'
import { useTokenDetailsContext } from 'src/components/TokenDetails/TokenDetailsContext'
import { getBlockExplorerIcon } from 'src/components/icons/BlockExplorerIcon'
......@@ -11,8 +12,8 @@ import { useTokenProjectUrlsPartsFragment } from 'uniswap/src/data/graphql/unisw
import { getChainInfo } from 'uniswap/src/features/chains/chainInfo'
import { ElementName } from 'uniswap/src/features/telemetry/constants'
import { TestID } from 'uniswap/src/test/fixtures/testIDs'
import { isDefaultNativeAddress } from 'uniswap/src/utils/currencyId'
import { ExplorerDataType, getExplorerLink } from 'uniswap/src/utils/linking'
import { isDefaultNativeAddress } from 'wallet/src/utils/currencyId'
import { getTwitterLink } from 'wallet/src/utils/linking'
export function TokenDetailsLinks(): JSX.Element {
......@@ -25,57 +26,63 @@ export function TokenDetailsLinks(): JSX.Element {
const explorerLink = getExplorerLink(chainId, address, ExplorerDataType.TOKEN)
const explorerName = getChainInfo(chainId).explorer.name
const links = useMemo(() => {
return [
{
Icon: getBlockExplorerIcon(chainId),
buttonType: LinkButtonType.Link,
element: ElementName.TokenLinkEtherscan,
label: explorerName,
testID: TestID.TokenLinkEtherscan,
value: explorerLink,
},
homepageUrl
? {
Icon: GlobeIcon,
buttonType: LinkButtonType.Link,
element: ElementName.TokenLinkWebsite,
label: t('token.links.website'),
testID: TestID.TokenLinkWebsite,
value: homepageUrl,
}
: null,
twitterName
? {
Icon: TwitterIcon,
buttonType: LinkButtonType.Link,
element: ElementName.TokenLinkTwitter,
label: t('token.links.twitter'),
testID: TestID.TokenLinkTwitter,
value: getTwitterLink(twitterName),
}
: null,
!isDefaultNativeAddress(address)
? {
buttonType: LinkButtonType.Copy,
element: ElementName.Copy,
label: t('common.text.contract'),
testID: TestID.TokenLinkCopy,
value: address,
}
: null,
].filter((item): item is NonNullable<typeof item> => Boolean(item))
}, [chainId, address, homepageUrl, twitterName, explorerName, explorerLink, t])
return (
<View style={{ marginHorizontal: -14 }}>
<Flex gap="$spacing8">
<Text color="$neutral2" mx="$spacing16" variant="subheading2">
{t('token.links.title')}
</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<Flex row gap="$spacing8" px="$spacing16">
<LinkButton
Icon={getBlockExplorerIcon(chainId)}
buttonType={LinkButtonType.Link}
element={ElementName.TokenLinkEtherscan}
label={explorerName}
testID={TestID.TokenLinkEtherscan}
value={explorerLink}
/>
{homepageUrl && (
<LinkButton
Icon={GlobeIcon}
buttonType={LinkButtonType.Link}
element={ElementName.TokenLinkWebsite}
label={t('token.links.website')}
testID={TestID.TokenLinkWebsite}
value={homepageUrl}
/>
)}
{twitterName && (
<LinkButton
Icon={TwitterIcon}
buttonType={LinkButtonType.Link}
element={ElementName.TokenLinkTwitter}
label={t('token.links.twitter')}
testID={TestID.TokenLinkTwitter}
value={getTwitterLink(twitterName)}
/>
)}
{!isDefaultNativeAddress(address) && (
<LinkButton
buttonType={LinkButtonType.Copy}
element={ElementName.Copy}
label={t('common.text.contract')}
testID={TestID.TokenLinkCopy}
value={address}
/>
)}
</Flex>
</ScrollView>
<Flex row gap="$spacing8" px="$spacing16">
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
data={links}
renderItem={({ item }) => <LinkButton {...item} />}
keyExtractor={(item) => item.testID}
/>
</Flex>
</Flex>
</View>
)
......
import type { Meta, StoryObj } from '@storybook/react'
import { CopyTextButton } from 'src/components/buttons/CopyTextButton'
const meta = {
title: 'Components/Buttons',
component: CopyTextButton,
} satisfies Meta<typeof CopyTextButton>
type Story = StoryObj<typeof meta>
const CopyTextButtonStory: Story = {
storyName: 'CopyTextButton',
args: {
copyText: 'You copied me!',
},
}
export default meta
export { CopyTextButtonStory }
......@@ -3,18 +3,18 @@ import {
TokenRankingsStat,
TokenStats,
} from '@uniswap/client-explore/dist/uniswap/explore/v1/service_pb'
import React, { useCallback, useMemo, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ListRenderItem, ListRenderItemInfo, StyleSheet, useWindowDimensions } from 'react-native'
import { FlatList } from 'react-native-gesture-handler'
import { SharedValue, useSharedValue } from 'react-native-reanimated'
import { useSelector } from 'react-redux'
import { AnimatedRef } from 'react-native-reanimated'
import Sortable from 'react-native-sortables'
import { useDispatch, useSelector } from 'react-redux'
import { FavoriteTokensGrid } from 'src/components/explore/FavoriteTokensGrid'
import { FavoriteWalletsGrid } from 'src/components/explore/FavoriteWalletsGrid'
import { SortButton } from 'src/components/explore/SortButton'
import { TokenItem } from 'src/components/explore/TokenItem'
import { TokenItemData } from 'src/components/explore/TokenItemData'
import { AutoScrollProps } from 'src/components/sortableGrid/types'
import { getTokenMetadataDisplayType } from 'src/features/explore/utils'
import { Flex, Loader, Text, TouchableArea, useSporeColors } from 'ui/src'
import { AnimatedBottomSheetFlashList } from 'ui/src/components/AnimatedFlashList/AnimatedFlashList'
......@@ -32,16 +32,17 @@ import { MobileEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { useAppInsets } from 'uniswap/src/hooks/useAppInsets'
import { buildCurrencyId, buildNativeCurrencyId } from 'uniswap/src/utils/currencyId'
import { DDRumManualTiming } from 'utilities/src/logger/datadogEvents'
import { DDRumManualTiming } from 'utilities/src/logger/datadog/datadogEvents'
import { usePerformanceLogger } from 'utilities/src/logger/usePerformanceLogger'
import { selectTokensOrderBy } from 'wallet/src/features/wallet/selectors'
import { setTokensOrderBy } from 'wallet/src/features/wallet/slice'
import { ExploreOrderBy, TokenMetadataDisplayType } from 'wallet/src/features/wallet/types'
const TOKEN_ITEM_SIZE = 68
const AMOUNT_TO_DRAW = 18
type ExploreSectionsProps = {
listRef: React.MutableRefObject<null>
listRef: AnimatedRef<FlatList>
}
type TokenItemDataWithMetadata = { tokenItemData: TokenItemData; tokenMetadataDisplayType: TokenMetadataDisplayType }
......@@ -49,11 +50,9 @@ type TokenItemDataWithMetadata = { tokenItemData: TokenItemData; tokenMetadataDi
export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element {
const { t } = useTranslation()
const insets = useAppInsets()
const scrollY = useSharedValue(0)
const visibleListHeight = useSharedValue(0)
const dimensions = useWindowDimensions()
// Top tokens sorting
const orderBy = useSelector(selectTokensOrderBy)
const { uiOrderBy, orderBy, onOrderByChange } = useOrderBy()
// Network filtering
const [selectedNetwork, setSelectedNetwork] = useState<UniverseChainId | null>(null)
......@@ -108,30 +107,18 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element
}
return (
// Pass onLayout callback to the list wrapper component as it returned
// incorrect values when it was passed to the list itself
<Flex
fill
animation="100ms"
onLayout={({
nativeEvent: {
layout: { height },
},
}): void => {
visibleListHeight.value = height
}}
>
<Flex fill animation="100ms">
<AnimatedBottomSheetFlashList
ref={listRef}
ListEmptyComponent={ListEmptyComponent}
ListHeaderComponent={
<ListHeaderComponent
listRef={listRef}
orderBy={orderBy}
scrollY={scrollY}
orderBy={uiOrderBy}
showLoading={isLoadingOrFetching}
selectedNetwork={selectedNetwork}
visibleListHeight={visibleListHeight}
onSelectNetwork={onSelectNetwork}
onOrderByChange={onOrderByChange}
/>
}
ListHeaderComponentStyle={styles.foreground}
......@@ -260,8 +247,9 @@ function tokenRankingStatsToTokenItemData(tokenRankingStat: TokenRankingsStat):
}
}
type FavoritesSectionProps = AutoScrollProps & {
type FavoritesSectionProps = {
showLoading: boolean
listRef: AnimatedRef<FlatList>
}
function FavoritesSection(props: FavoritesSectionProps): JSX.Element | null {
......@@ -348,37 +336,32 @@ function useTokenItems(
}
type ListHeaderProps = {
listRef: React.MutableRefObject<null>
scrollY: SharedValue<number>
visibleListHeight: SharedValue<number>
listRef: AnimatedRef<FlatList>
orderBy: ExploreOrderBy
showLoading: boolean
onOrderByChange: (orderBy: ExploreOrderBy) => void
}
const ListHeader = React.memo(function ListHeader({
listRef,
scrollY,
visibleListHeight,
orderBy,
showLoading,
onOrderByChange,
}: ListHeaderProps): JSX.Element {
const { t } = useTranslation()
return (
<Flex>
<FavoritesSection
showLoading={false}
scrollY={scrollY}
scrollableRef={listRef}
visibleHeight={visibleListHeight}
/>
<Sortable.Layer>
<FavoritesSection showLoading={showLoading} listRef={listRef} />
<Flex row alignItems="center" justifyContent="space-between" px="$spacing20">
<Text color="$neutral2" flexShrink={0} paddingEnd="$spacing8" variant="subheading1">
{t('explore.tokens.top.title')}
</Text>
<Flex flexShrink={1}>
<SortButton orderBy={orderBy} />
<SortButton orderBy={orderBy} onOrderByChange={onOrderByChange} />
</Flex>
</Flex>
</Flex>
</Sortable.Layer>
)
})
......@@ -402,13 +385,13 @@ const ListHeaderComponent = ({
listRef,
onSelectNetwork,
orderBy,
scrollY,
selectedNetwork,
visibleListHeight,
showLoading,
onOrderByChange,
}: ListHeaderProps & NetworkPillsProps): JSX.Element => {
return (
<>
<ListHeader listRef={listRef} orderBy={orderBy} scrollY={scrollY} visibleListHeight={visibleListHeight} />
<ListHeader listRef={listRef} orderBy={orderBy} showLoading={showLoading} onOrderByChange={onOrderByChange} />
<NetworkPills selectedNetwork={selectedNetwork} onSelectNetwork={onSelectNetwork} />
</>
)
......@@ -419,3 +402,32 @@ const ListEmptyComponent = (): JSX.Element => (
<Loader.Token repeat={5} />
</Flex>
)
function useOrderBy(): {
uiOrderBy: ExploreOrderBy
orderBy: ExploreOrderBy
onOrderByChange: (orderBy: ExploreOrderBy) => void
} {
const dispatch = useDispatch()
const orderBy = useSelector(selectTokensOrderBy)
// local state for immediate UI feedback
const [uiOrderBy, setUiOrderBy] = useState<ExploreOrderBy>(orderBy)
// When Redux orderBy changes, sync UI
useEffect(() => {
setUiOrderBy(orderBy)
}, [orderBy])
const onOrderByChange = useCallback(
(newTokensOrderBy: ExploreOrderBy) => {
setUiOrderBy(newTokensOrderBy)
requestAnimationFrame(() => {
dispatch(setTokensOrderBy({ newTokensOrderBy }))
})
},
[dispatch],
)
return { uiOrderBy, orderBy, onOrderByChange }
}
import { makeMutable } from 'react-native-reanimated'
import configureMockStore from 'redux-mock-store'
import FavoriteTokenCard, { FavoriteTokenCardProps } from 'src/components/explore/FavoriteTokenCard'
import { act, cleanup, fireEvent, render, waitFor } from 'src/test/test-utils'
......@@ -52,8 +51,6 @@ const touchableId = `token-box-${favoriteToken.symbol}`
const defaultProps: FavoriteTokenCardProps = {
currencyId: SAMPLE_CURRENCY_ID_1,
pressProgress: makeMutable(0),
dragActivationProgress: makeMutable(0),
setIsEditing: jest.fn(),
isEditing: false,
}
......
import React, { memo, useCallback } from 'react'
import { ViewProps } from 'react-native'
import ContextMenu from 'react-native-context-menu-view'
import { SharedValue } from 'react-native-reanimated'
import { useDispatch } from 'react-redux'
import { useTokenDetailsNavigation } from 'src/components/TokenDetails/hooks'
import RemoveButton from 'src/components/explore/RemoveButton'
import { useAnimatedCardDragStyle, useExploreTokenContextMenu } from 'src/components/explore/hooks'
import { useExploreTokenContextMenu } from 'src/components/explore/hooks'
import { disableOnPress } from 'src/utils/disableOnPress'
import { usePollOnFocusOnly } from 'src/utils/hooks'
import { AnimatedTouchableArea, Flex, Loader, Text, useIsDarkMode, useShadowPropsShort, useSporeColors } from 'ui/src'
import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex'
import { borderRadii, fonts, imageSizes, opacify } from 'ui/src/theme'
import { TokenLogo } from 'uniswap/src/components/CurrencyLogo/TokenLogo'
import { RelativeChange } from 'uniswap/src/components/RelativeChange/RelativeChange'
......@@ -26,26 +24,18 @@ import { useLocalizationContext } from 'uniswap/src/features/language/Localizati
import { SectionName } from 'uniswap/src/features/telemetry/constants'
import { getSymbolDisplayText } from 'uniswap/src/utils/currency'
import { NumberType } from 'utilities/src/format/types'
import { isIOS } from 'utilities/src/platform'
import { isNonPollingRequestInFlight } from 'wallet/src/data/utils'
export const FAVORITE_TOKEN_CARD_LOADER_HEIGHT = 114
export type FavoriteTokenCardProps = {
currencyId: string
pressProgress: SharedValue<number>
dragActivationProgress: SharedValue<number>
isEditing?: boolean
setIsEditing: (update: boolean) => void
} & ViewProps
function FavoriteTokenCard({
currencyId,
isEditing,
pressProgress,
dragActivationProgress,
setIsEditing,
...rest
}: FavoriteTokenCardProps): JSX.Element {
function FavoriteTokenCard({ currencyId, isEditing, setIsEditing, ...rest }: FavoriteTokenCardProps): JSX.Element {
const dispatch = useDispatch()
const { defaultChainId } = useEnabledChains()
const tokenDetailsNavigation = useTokenDetailsNavigation()
......@@ -98,78 +88,75 @@ function FavoriteTokenCard({
tokenDetailsNavigation.navigate(currencyId)
}
const animatedDragStyle = useAnimatedCardDragStyle(pressProgress, dragActivationProgress)
const shadowProps = useShadowPropsShort()
const priceLoading = isNonPollingRequestInFlight(networkStatus)
return (
<AnimatedFlex borderRadius="$rounded16" style={animatedDragStyle}>
<ContextMenu
actions={menuActions}
disabled={isEditing}
style={{ borderRadius: borderRadii.rounded16 }}
onPress={onContextMenuPress}
{...rest}
<ContextMenu
actions={menuActions}
disabled={isEditing}
style={{ borderRadius: borderRadii.rounded16 }}
onPress={onContextMenuPress}
{...rest}
>
<AnimatedTouchableArea
activeOpacity={isEditing ? 1 : undefined}
backgroundColor={isDarkMode ? '$surface2' : '$surface1'}
borderColor={opacify(0.05, colors.surface3.val)}
borderRadius="$rounded16"
overflow={isIOS ? 'hidden' : 'visible'}
borderWidth={isDarkMode ? '$none' : '$spacing1'}
m="$spacing4"
testID={`token-box-${token?.symbol}`}
onLongPress={disableOnPress}
onPress={onPress}
{...shadowProps}
>
<AnimatedTouchableArea
activeOpacity={isEditing ? 1 : undefined}
backgroundColor={isDarkMode ? '$surface2' : '$surface1'}
borderColor={opacify(0.05, colors.surface3.val)}
borderRadius="$rounded16"
borderWidth={isDarkMode ? '$none' : '$spacing1'}
m="$spacing4"
testID={`token-box-${token?.symbol}`}
onLongPress={disableOnPress}
onPress={onPress}
{...shadowProps}
>
<Flex alignItems="flex-start" gap="$spacing8" p="$spacing12">
<Flex row gap="$spacing4" justifyContent="space-between">
<Flex grow row alignItems="center" gap="$spacing8">
<TokenLogo
chainId={chainId ?? undefined}
name={token?.name ?? undefined}
size={imageSizes.image20}
symbol={token?.symbol ?? undefined}
url={token?.project?.logoUrl ?? undefined}
/>
<Text variant="body1">{getSymbolDisplayText(token?.symbol)}</Text>
</Flex>
<RemoveButton visible={isEditing} onPress={onRemove} />
</Flex>
<Flex gap="$spacing2">
{priceLoading ? (
<Loader.Box
height={fonts.heading3.lineHeight}
width={fonts.heading3.lineHeight * 3}
testID="loader/favorite/price"
/>
) : (
<Text adjustsFontSizeToFit numberOfLines={1} variant="heading3">
{priceFormatted}
</Text>
)}
{priceLoading ? (
<Loader.Box
height={fonts.subheading2.lineHeight}
width={fonts.subheading2.lineHeight * 3}
testID="loader/favorite/priceChange"
/>
) : (
<RelativeChange
arrowSize="$icon.16"
change={pricePercentChange ?? undefined}
semanticColor={true}
variant="subheading2"
/>
)}
<Flex alignItems="flex-start" gap="$spacing8" p="$spacing12">
<Flex row gap="$spacing4" justifyContent="space-between">
<Flex grow row alignItems="center" gap="$spacing8">
<TokenLogo
chainId={chainId ?? undefined}
name={token?.name ?? undefined}
size={imageSizes.image20}
symbol={token?.symbol ?? undefined}
url={token?.project?.logoUrl ?? undefined}
/>
<Text variant="body1">{getSymbolDisplayText(token?.symbol)}</Text>
</Flex>
<RemoveButton visible={isEditing} onPress={onRemove} />
</Flex>
<Flex gap="$spacing2">
{priceLoading ? (
<Loader.Box
height={fonts.heading3.lineHeight}
width={fonts.heading3.lineHeight * 3}
testID="loader/favorite/price"
/>
) : (
<Text adjustsFontSizeToFit numberOfLines={1} variant="heading3">
{priceFormatted}
</Text>
)}
{priceLoading ? (
<Loader.Box
height={fonts.subheading2.lineHeight}
width={fonts.subheading2.lineHeight * 3}
testID="loader/favorite/priceChange"
/>
) : (
<RelativeChange
arrowSize="$icon.16"
change={pricePercentChange ?? undefined}
semanticColor={true}
variant="subheading2"
/>
)}
</Flex>
</AnimatedTouchableArea>
</ContextMenu>
</AnimatedFlex>
</Flex>
</AnimatedTouchableArea>
</ContextMenu>
)
}
......
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FadeIn, useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
import { FlatList } from 'react-native-gesture-handler'
import { AnimatedRef, FadeIn } from 'react-native-reanimated'
import type { SortableGridDragEndCallback, SortableGridRenderItem } from 'react-native-sortables'
import Sortable from 'react-native-sortables'
import { useDispatch, useSelector } from 'react-redux'
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/loaders'
import { SortableGrid } from 'src/components/sortableGrid/SortableGrid'
import { AutoScrollProps, SortableGridChangeEvent, SortableGridRenderItem } from 'src/components/sortableGrid/types'
import { Flex } from 'ui/src'
import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex'
import { selectFavoriteTokens } from 'uniswap/src/features/favorites/selectors'
......@@ -15,17 +16,17 @@ import { setFavoriteTokens } from 'uniswap/src/features/favorites/slice'
const NUM_COLUMNS = 2
const ITEM_FLEX = { flex: 1 / NUM_COLUMNS }
type FavoriteTokensGridProps = AutoScrollProps & {
type FavoriteTokensGridProps = {
showLoading: boolean
listRef: AnimatedRef<FlatList>
}
/** Renders the favorite tokens section on the Explore tab */
export function FavoriteTokensGrid({ showLoading, ...rest }: FavoriteTokensGridProps): JSX.Element | null {
export function FavoriteTokensGrid({ showLoading, listRef, ...rest }: FavoriteTokensGridProps): JSX.Element | null {
const { t } = useTranslation()
const dispatch = useDispatch()
const [isEditing, setIsEditing] = useState(false)
const isTokenDragged = useSharedValue(false)
const favoriteCurrencyIds = useSelector(selectFavoriteTokens)
// Reset edit mode when there are no favorite tokens
......@@ -35,61 +36,47 @@ export function FavoriteTokensGrid({ showLoading, ...rest }: FavoriteTokensGridP
}
}, [favoriteCurrencyIds.length])
const handleOrderChange = useCallback(
({ data }: SortableGridChangeEvent<string>) => {
const handleDragEnd = useCallback<SortableGridDragEndCallback<string>>(
({ data }) => {
dispatch(setFavoriteTokens({ currencyIds: data }))
},
[dispatch],
)
const renderItem = useCallback<SortableGridRenderItem<string>>(
({ item: currencyId, pressProgress, dragActivationProgress }): JSX.Element => (
<FavoriteTokenCard
key={currencyId}
currencyId={currencyId}
dragActivationProgress={dragActivationProgress}
isEditing={isEditing}
pressProgress={pressProgress}
setIsEditing={setIsEditing}
/>
({ item: currencyId }): JSX.Element => (
<FavoriteTokenCard currencyId={currencyId} isEditing={isEditing} setIsEditing={setIsEditing} />
),
[isEditing],
)
const animatedStyle = useAnimatedStyle(() => ({
zIndex: isTokenDragged.value ? 1 : 0,
}))
return (
<AnimatedFlex entering={FadeIn} style={animatedStyle}>
<FavoriteHeaderRow
disabled={showLoading}
editingTitle={t('explore.tokens.favorite.title.edit')}
isEditing={isEditing}
title={t('explore.tokens.favorite.title.default')}
onPress={(): void => setIsEditing(!isEditing)}
/>
{showLoading ? (
<FavoriteTokensGridLoader />
) : (
<SortableGrid
{...rest}
activeItemOpacity={1}
animateContainerHeight={false}
data={favoriteCurrencyIds}
editable={isEditing}
numColumns={NUM_COLUMNS}
renderItem={renderItem}
onChange={handleOrderChange}
onDragStart={(): void => {
isTokenDragged.value = true
}}
onDrop={(): void => {
isTokenDragged.value = false
}}
<Sortable.Layer>
<AnimatedFlex entering={FadeIn}>
<FavoriteHeaderRow
disabled={showLoading}
editingTitle={t('explore.tokens.favorite.title.edit')}
isEditing={isEditing}
title={t('explore.tokens.favorite.title.default')}
onPress={(): void => setIsEditing(!isEditing)}
/>
)}
</AnimatedFlex>
{showLoading ? (
<FavoriteTokensGridLoader />
) : (
<Sortable.Grid
{...rest}
animateHeight
scrollableRef={listRef}
data={favoriteCurrencyIds}
sortEnabled={isEditing}
autoScrollActivationOffset={[75, 100]}
columns={NUM_COLUMNS}
renderItem={renderItem}
onDragEnd={handleDragEnd}
/>
)}
</AnimatedFlex>
</Sortable.Layer>
)
}
......
import { makeMutable } from 'react-native-reanimated'
import configureMockStore from 'redux-mock-store'
import FavoriteWalletCard, { FavoriteWalletCardProps } from 'src/components/explore/FavoriteWalletCard'
import { preloadedMobileState } from 'src/test/fixtures'
......@@ -28,9 +27,7 @@ const mockStore = configureMockStore()
const defaultProps: FavoriteWalletCardProps = {
address: SAMPLE_SEED_ADDRESS_1,
pressProgress: makeMutable(0),
isEditing: false,
dragActivationProgress: makeMutable(0),
setIsEditing: jest.fn(),
}
......
......@@ -2,17 +2,15 @@ import { memo, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { ViewProps } from 'react-native'
import ContextMenu from 'react-native-context-menu-view'
import { SharedValue } from 'react-native-reanimated'
import { useDispatch } from 'react-redux'
import { useEagerExternalProfileNavigation } from 'src/app/navigation/hooks'
import RemoveButton from 'src/components/explore/RemoveButton'
import { useAnimatedCardDragStyle } from 'src/components/explore/hooks'
import { disableOnPress } from 'src/utils/disableOnPress'
import { Flex, TouchableArea, useIsDarkMode, useShadowPropsShort, useSporeColors } from 'ui/src'
import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex'
import { borderRadii, iconSizes, opacify } from 'ui/src/theme'
import { useAvatar } from 'uniswap/src/features/address/avatar'
import { removeWatchedAddress } from 'uniswap/src/features/favorites/slice'
import { isIOS } from 'utilities/src/platform'
import { AccountIcon } from 'wallet/src/components/accounts/AccountIcon'
import { DisplayNameText } from 'wallet/src/components/accounts/DisplayNameText'
import { useDisplayName } from 'wallet/src/features/wallet/hooks'
......@@ -21,19 +19,10 @@ import { DisplayNameType } from 'wallet/src/features/wallet/types'
export type FavoriteWalletCardProps = {
address: Address
isEditing?: boolean
pressProgress: SharedValue<number>
dragActivationProgress: SharedValue<number>
setIsEditing: (update: boolean) => void
} & ViewProps
function FavoriteWalletCard({
address,
isEditing,
pressProgress,
dragActivationProgress,
setIsEditing,
...rest
}: FavoriteWalletCardProps): JSX.Element {
function FavoriteWalletCard({ address, isEditing, setIsEditing, ...rest }: FavoriteWalletCardProps): JSX.Element {
const { t } = useTranslation()
const dispatch = useDispatch()
const colors = useSporeColors()
......@@ -60,63 +49,60 @@ function FavoriteWalletCard({
]
}, [t])
const animatedDragStyle = useAnimatedCardDragStyle(pressProgress, dragActivationProgress)
const shadowProps = useShadowPropsShort()
return (
<AnimatedFlex style={animatedDragStyle}>
<ContextMenu
actions={menuActions}
<ContextMenu
actions={menuActions}
disabled={isEditing}
style={{ borderRadius: borderRadii.rounded16 }}
onPress={(e): void => {
// Emitted index based on order of menu action array
// remove favorite action
if (e.nativeEvent.index === 0) {
onRemove()
}
// Edit mode toggle action
if (e.nativeEvent.index === 1) {
setIsEditing(true)
}
}}
{...rest}
>
<TouchableArea
overflow={isIOS ? 'hidden' : 'visible'}
activeOpacity={isEditing ? 1 : undefined}
backgroundColor={isDarkMode ? '$surface2' : '$surface1'}
borderColor={opacify(0.05, colors.surface3.val)}
borderRadius="$rounded16"
borderWidth={isDarkMode ? '$none' : '$spacing1'}
disabled={isEditing}
style={{ borderRadius: borderRadii.rounded16 }}
onPress={(e): void => {
// Emitted index based on order of menu action array
// remove favorite action
if (e.nativeEvent.index === 0) {
onRemove()
}
// Edit mode toggle action
if (e.nativeEvent.index === 1) {
setIsEditing(true)
}
m="$spacing4"
testID="favorite-wallet-card"
onLongPress={disableOnPress}
onPress={(): void => {
navigate(address)
}}
onPressIn={async (): Promise<void> => {
await preload(address)
}}
{...rest}
{...shadowProps}
>
<TouchableArea
activeOpacity={isEditing ? 1 : undefined}
backgroundColor={isDarkMode ? '$surface2' : '$surface1'}
borderColor={opacify(0.05, colors.surface3.val)}
borderRadius="$rounded16"
borderWidth={isDarkMode ? '$none' : '$spacing1'}
disabled={isEditing}
m="$spacing4"
testID="favorite-wallet-card"
onLongPress={disableOnPress}
onPress={(): void => {
navigate(address)
}}
onPressIn={async (): Promise<void> => {
await preload(address)
}}
{...shadowProps}
>
<Flex row gap="$spacing4" justifyContent="space-between" p="$spacing12">
<Flex row shrink alignItems="center" gap="$spacing8">
{icon}
<DisplayNameText
displayName={displayName}
textProps={{
adjustsFontSizeToFit: displayName?.type === DisplayNameType.Address,
variant: 'body1',
}}
/>
</Flex>
<RemoveButton visible={isEditing} onPress={onRemove} />
<Flex row gap="$spacing4" justifyContent="space-between" p="$spacing12">
<Flex row shrink alignItems="center" gap="$spacing8">
{icon}
<DisplayNameText
displayName={displayName}
textProps={{
adjustsFontSizeToFit: displayName?.type === DisplayNameType.Address,
variant: 'body1',
}}
/>
</Flex>
</TouchableArea>
</ContextMenu>
</AnimatedFlex>
<RemoveButton visible={isEditing} onPress={onRemove} />
</Flex>
</TouchableArea>
</ContextMenu>
)
}
......
import { default as React, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FadeIn, useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
import { FlatList } from 'react-native-gesture-handler'
import { AnimatedRef, FadeIn } from 'react-native-reanimated'
import type { SortableGridDragEndCallback, SortableGridRenderItem } from 'react-native-sortables'
import Sortable from 'react-native-sortables'
import { useDispatch, useSelector } from 'react-redux'
import { FavoriteHeaderRow } from 'src/components/explore/FavoriteHeaderRow'
import FavoriteWalletCard from 'src/components/explore/FavoriteWalletCard'
import { Loader } from 'src/components/loading/loaders'
import { SortableGrid } from 'src/components/sortableGrid/SortableGrid'
import { AutoScrollProps, SortableGridChangeEvent, SortableGridRenderItem } from 'src/components/sortableGrid/types'
import { Flex } from 'ui/src'
import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex'
import { selectWatchedAddressSet } from 'uniswap/src/features/favorites/selectors'
......@@ -15,17 +16,17 @@ import { setFavoriteWallets } from 'uniswap/src/features/favorites/slice'
const NUM_COLUMNS = 2
const ITEM_FLEX = { flex: 1 / NUM_COLUMNS }
type FavoriteWalletsGridProps = AutoScrollProps & {
type FavoriteWalletsGridProps = {
showLoading: boolean
listRef: AnimatedRef<FlatList>
}
/** Renders the favorite wallets section on the Explore tab */
export function FavoriteWalletsGrid({ showLoading, ...rest }: FavoriteWalletsGridProps): JSX.Element {
export function FavoriteWalletsGrid({ showLoading, listRef, ...rest }: FavoriteWalletsGridProps): JSX.Element {
const { t } = useTranslation()
const dispatch = useDispatch()
const [isEditing, setIsEditing] = useState(false)
const isTokenDragged = useSharedValue(false)
const watchedWalletsSet = useSelector(selectWatchedAddressSet)
const watchedWalletsList = useMemo(() => Array.from(watchedWalletsSet), [watchedWalletsSet])
......@@ -36,61 +37,47 @@ export function FavoriteWalletsGrid({ showLoading, ...rest }: FavoriteWalletsGri
}
}, [watchedWalletsSet.size])
const handleOrderChange = useCallback(
({ data }: SortableGridChangeEvent<string>) => {
const handleDragEnd = useCallback<SortableGridDragEndCallback<string>>(
({ data }) => {
dispatch(setFavoriteWallets({ addresses: data }))
},
[dispatch],
)
const renderItem = useCallback<SortableGridRenderItem<string>>(
({ item: address, pressProgress, dragActivationProgress }): JSX.Element => (
<FavoriteWalletCard
key={address}
address={address}
dragActivationProgress={dragActivationProgress}
isEditing={isEditing}
pressProgress={pressProgress}
setIsEditing={setIsEditing}
/>
({ item: address }): JSX.Element => (
<FavoriteWalletCard address={address} isEditing={isEditing} setIsEditing={setIsEditing} />
),
[isEditing],
)
const animatedStyle = useAnimatedStyle(() => ({
zIndex: isTokenDragged.value ? 1 : 0,
}))
return (
<AnimatedFlex entering={FadeIn} style={animatedStyle}>
<FavoriteHeaderRow
editingTitle={t('explore.wallets.favorite.title.edit')}
isEditing={isEditing}
title={t('explore.wallets.favorite.title.default')}
disabled={showLoading}
onPress={(): void => setIsEditing(!isEditing)}
/>
{showLoading ? (
<FavoriteWalletsGridLoader />
) : (
<SortableGrid
{...rest}
activeItemOpacity={1}
animateContainerHeight={false}
data={watchedWalletsList}
editable={isEditing}
numColumns={NUM_COLUMNS}
renderItem={renderItem}
onChange={handleOrderChange}
onDragStart={(): void => {
isTokenDragged.value = true
}}
onDrop={(): void => {
isTokenDragged.value = false
}}
<Sortable.Layer>
<AnimatedFlex entering={FadeIn}>
<FavoriteHeaderRow
editingTitle={t('explore.wallets.favorite.title.edit')}
isEditing={isEditing}
title={t('explore.wallets.favorite.title.default')}
disabled={showLoading}
onPress={(): void => setIsEditing(!isEditing)}
/>
)}
</AnimatedFlex>
{showLoading ? (
<FavoriteWalletsGridLoader />
) : (
<Sortable.Grid
{...rest}
animateHeight
scrollableRef={listRef}
autoScrollActivationOffset={[75, 100]}
data={watchedWalletsList}
sortEnabled={isEditing}
columns={NUM_COLUMNS}
renderItem={renderItem}
onDragEnd={handleDragEnd}
/>
)}
</AnimatedFlex>
</Sortable.Layer>
)
}
......
......@@ -19,7 +19,7 @@ describe('SortButton', () => {
})
it('renders without error', async () => {
const tree = render(<SortButton orderBy={RankingType.Volume} />)
const tree = render(<SortButton orderBy={RankingType.Volume} onOrderByChange={() => {}} />)
await act(async () => {
jest.runAllTimers()
......@@ -46,7 +46,7 @@ describe('SortButton', () => {
describe.each(cases)('when ordering by $test', ({ orderBy, label }) => {
it(`renders ${label} as the selected option`, async () => {
const { queryByText } = render(<SortButton orderBy={orderBy} />)
const { queryByText } = render(<SortButton orderBy={orderBy} onOrderByChange={() => {}} />)
await act(async () => {
jest.runAllTimers()
})
......
import React, { memo, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux'
import { getTokensOrderByMenuLabel, getTokensOrderBySelectedLabel } from 'src/features/explore/utils'
import { Flex, Text, useSporeColors } from 'ui/src'
import {
......@@ -19,17 +18,16 @@ import { CustomRankingType, RankingType } from 'uniswap/src/data/types'
import { MobileEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { logger } from 'utilities/src/logger/logger'
import { setTokensOrderBy } from 'wallet/src/features/wallet/slice'
import { ExploreOrderBy } from 'wallet/src/features/wallet/types'
const MIN_MENU_ITEM_WIDTH = 220
interface FilterGroupProps {
orderBy: ExploreOrderBy
onOrderByChange: (orderBy: ExploreOrderBy) => void
}
function _SortButton({ orderBy }: FilterGroupProps): JSX.Element {
const dispatch = useDispatch()
function _SortButton({ orderBy, onOrderByChange }: FilterGroupProps): JSX.Element {
const { t } = useTranslation()
const colors = useSporeColors()
......@@ -86,6 +84,16 @@ function _SortButton({ orderBy }: FilterGroupProps): JSX.Element {
)
}, [])
const handleOrderByChange = useCallback(
(newOrderBy: ExploreOrderBy) => {
onOrderByChange(newOrderBy)
sendAnalyticsEvent(MobileEventName.ExploreFilterSelected, {
filter_type: newOrderBy,
})
},
[onOrderByChange],
)
const options = useMemo<MenuItemProp[]>(() => {
return menuActions.map((option, index) => {
return {
......@@ -98,15 +106,12 @@ function _SortButton({ orderBy }: FilterGroupProps): JSX.Element {
})
return
}
dispatch(setTokensOrderBy({ newTokensOrderBy: option.orderBy }))
sendAnalyticsEvent(MobileEventName.ExploreFilterSelected, {
filter_type: selectedMenuAction.orderBy,
})
handleOrderByChange(selectedMenuAction.orderBy)
},
render: () => <MenuItem active={option.active} icon={option.icon} label={option.title} />,
}
})
}, [MenuItem, dispatch, menuActions])
}, [MenuItem, menuActions, handleOrderByChange])
return (
<ActionSheetDropdown options={options} showArrow={false} styles={{ alignment: 'right' }}>
......
......@@ -13,7 +13,7 @@ import { Flex, useSporeColors } from 'ui/src'
import { GQLQueries } from 'uniswap/src/data/graphql/uniswap-data-api/queries'
import { ModalName } from 'uniswap/src/features/telemetry/constants'
import { useAppInsets } from 'uniswap/src/hooks/useAppInsets'
import { DDRumManualTiming } from 'utilities/src/logger/datadogEvents'
import { DDRumManualTiming } from 'utilities/src/logger/datadog/datadogEvents'
import { usePerformanceLogger } from 'utilities/src/logger/usePerformanceLogger'
import { isAndroid } from 'utilities/src/platform'
import { ScannerModalState } from 'wallet/src/components/QRCodeScanner/constants'
......
import { LayoutChangeEvent, MeasureLayoutOnSuccessCallback, StyleSheet } from 'react-native'
import Animated, { LayoutAnimationConfig, useAnimatedStyle } from 'react-native-reanimated'
import { useAutoScrollContext } from 'src/components/sortableGrid/contexts/AutoScrollContextProvider'
import { useLayoutContext } from 'src/components/sortableGrid/contexts/LayoutContextProvider'
import { SortableGridProvider } from 'src/components/sortableGrid/internal/SortableGirdProvider'
import SortableGridItem from 'src/components/sortableGrid/internal/SortableGridItem'
import { useItemOrderUpdater } from 'src/components/sortableGrid/internal/hooks'
import { defaultKeyExtractor, useStableCallback } from 'src/components/sortableGrid/internal/utils'
import {
ActiveItemDecorationSettings,
AutoScrollProps,
SortableGridChangeEvent,
SortableGridDragStartEvent,
SortableGridDropEvent,
SortableGridRenderItem,
} from 'src/components/sortableGrid/types'
type SortableGridProps<I> = AutoScrollProps &
Partial<ActiveItemDecorationSettings> & {
data: I[]
renderItem: SortableGridRenderItem<I>
numColumns?: number
editable?: boolean
animateContainerHeight?: boolean
keyExtractor?: (item: I, index: number) => string
onChange?: (e: SortableGridChangeEvent<I>) => void
onDragStart?: (e: SortableGridDragStartEvent<I>) => void
onDrop?: (e: SortableGridDropEvent<I>) => void
}
export function SortableGrid<I>({
data,
renderItem,
numColumns = 1,
keyExtractor = defaultKeyExtractor,
containerRef,
...rest
}: SortableGridProps<I>): JSX.Element {
const stableKeyExtractor = useStableCallback(keyExtractor)
const sharedProps = {
data,
numColumns,
keyExtractor: stableKeyExtractor,
}
return (
<SortableGridProvider {...sharedProps} {...rest}>
<SortableGridInner {...sharedProps} containerRef={containerRef} renderItem={renderItem} />
</SortableGridProvider>
)
}
type SortableGridInnerProps<I> = Pick<
SortableGridProps<I>,
'data' | 'renderItem' | 'numColumns' | 'keyExtractor' | 'containerRef'
>
function SortableGridInner<I>({
data,
renderItem,
containerRef,
numColumns = 1,
keyExtractor = defaultKeyExtractor,
}: SortableGridInnerProps<I>): JSX.Element {
const { containerHeight, containerWidth, appliedContainerHeight } = useLayoutContext()
const { gridContainerRef, containerStartOffset } = useAutoScrollContext()
useItemOrderUpdater(numColumns)
const handleGridMeasurement = ({
nativeEvent: {
layout: { height, width },
},
}: LayoutChangeEvent): void => {
if (containerHeight.value !== -1) {
return
}
// Measure container using onLayout only once, on the initial render
// (container dimensions will be updated from the context provider
// when data changes, so we don't want to re-measure the container)
if (containerHeight.value === -1) {
containerHeight.value = height
containerWidth.value = width
}
// Measure offset relative to the specifiec container (if containerRef
// is provided, otherwise assume that the grid component is the first
// child of the the parent container)
const onSuccess: MeasureLayoutOnSuccessCallback = (_, y) => {
containerStartOffset.value = y
}
const parentNode = containerRef?.current
const gridNode = gridContainerRef.current
if (parentNode && gridNode) {
gridNode.measureLayout(parentNode, onSuccess)
}
}
const handleHelperMeasurement = ({
nativeEvent: {
layout: { height },
},
}: LayoutChangeEvent): void => {
if (appliedContainerHeight.value === -1 && height === 0) {
return
}
appliedContainerHeight.value = height
}
const animatedContainerStyle = useAnimatedStyle(() => ({
width: containerWidth.value === -1 ? 'auto' : containerWidth.value,
height: containerHeight.value === -1 ? 'auto' : containerHeight.value,
}))
return (
<LayoutAnimationConfig skipExiting>
<Animated.View
ref={gridContainerRef}
style={[styles.gridContainer, animatedContainerStyle]}
onLayout={handleGridMeasurement}
>
{data.map((item, index) => {
const key = keyExtractor(item, index)
return (
<SortableGridItem key={key} item={item} itemKey={key} numColumns={numColumns} renderItem={renderItem} />
)
})}
</Animated.View>
{/* This dummy Animated.View is used only to determine if the containerHeight
from the animated style was applied. We can't use onLayout on the grid items wrapper component because it already has the same height as containerHeight
value, thus the onLayout callback won't be called again, because the size
of the component doesn't change. */}
<Animated.View style={[styles.helperView, animatedContainerStyle]} onLayout={handleHelperMeasurement} />
</LayoutAnimationConfig>
)
}
const styles = StyleSheet.create({
gridContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
},
helperView: {
opacity: 0,
pointerEvents: 'none',
position: 'absolute',
},
})
import { PropsWithChildren, createContext, useContext, useMemo, useRef } from 'react'
import { View } from 'react-native'
import { runOnJS, useAnimatedReaction, useDerivedValue, useSharedValue } from 'react-native-reanimated'
import { useDragContext } from 'src/components/sortableGrid/contexts/DragContextProvider'
import { useLayoutContext } from 'src/components/sortableGrid/contexts/LayoutContextProvider'
import { AUTO_SCROLL_THRESHOLD } from 'src/components/sortableGrid/internal/constants'
import { useStableCallback } from 'src/components/sortableGrid/internal/utils'
import { AutoScrollContextType, AutoScrollProps } from 'src/components/sortableGrid/types'
const AutoScrollContext = createContext<AutoScrollContextType | null>(null)
export function useAutoScrollContext(): AutoScrollContextType {
const context = useContext(AutoScrollContext)
if (!context) {
throw new Error('useAutoScrollContext must be used within a AutoScrollProvider')
}
return context
}
export type AutoScrollProviderProps = PropsWithChildren<Omit<AutoScrollProps, 'containerRef'>>
export function AutoScrollProvider({
children,
scrollableRef,
scrollY: scrollYValue,
visibleHeight: visibleHeightValue,
}: AutoScrollProviderProps): JSX.Element {
const { itemDimensions, targetContainerHeight } = useLayoutContext()
const { activeItemKey, activeItemPosition } = useDragContext()
/**
* VARIABLES
*/
// HELPER VARIABLES
const scrollTarget = useSharedValue(0)
const scrollDirection = useSharedValue(0) // 1 = down, -1 = up
const activeItemHeight = useDerivedValue(
() => (activeItemKey.value ? itemDimensions.value[activeItemKey.value]?.height : -1) ?? -1,
)
// REFS
const gridContainerRef = useRef<View>(null)
// MEASUREMENTS
// Values used to scroll the container to the proper offset
// (updated from the SortableGridInner component)
const containerStartOffset = useSharedValue(0)
const containerEndOffset = useDerivedValue(() => containerStartOffset.value + targetContainerHeight.value)
const startScrollOffset = useSharedValue(0)
const scrollOffsetDiff = useDerivedValue(() =>
activeItemKey.value === null ? 0 : scrollYValue.value - startScrollOffset.value,
)
/**
* HANDLERS
*/
const scrollToOffset = useStableCallback((offset: number) => {
const scrollable = scrollableRef.current
if (!scrollable || activeItemKey.value === null) {
return
}
if ('scrollTo' in scrollable) {
scrollable.scrollTo({ y: offset, animated: true })
} else {
scrollable.scrollToOffset({ offset, animated: true })
}
})
/**
* REACTIONS
*/
// Reset scroll properties when the active item changes
useAnimatedReaction(
() => activeItemKey.value,
() => {
// Reset when the active index changes
scrollDirection.value = 0
},
)
// AUTO SCROLL HANDLER
// Automatically scrolls the container when the active item is near the edge
useAnimatedReaction(
() => {
if (activeItemHeight.value === -1) {
return null
}
return {
itemAbsoluteY: activeItemPosition.value.y + containerStartOffset.value + scrollOffsetDiff.value,
activeHeight: activeItemHeight.value,
minOffset: containerStartOffset.value,
maxOffset: containerEndOffset.value - visibleHeightValue.value,
visibleHeight: visibleHeightValue.value,
scrollY: scrollYValue.value,
}
},
(props) => {
if (!props) {
return
}
const { itemAbsoluteY, scrollY, minOffset, maxOffset, activeHeight, visibleHeight } = props
let currentScrollTarget = scrollTarget.value
let currentScrollDirection = scrollDirection.value
/**
* |----------------------|
* | content above grid |
* |----------------------| <- minOffset (- threshold to scroll a bit above the grid)
* | invisible grid above |
* | (optional) | - if the scrollable container was scrolled down
* |----------------------|
* | visible grid part |
* |----------------------|
* | invisible grid below | - if the scrollable container was scrolled up enough
* | (optional) |
* |----------------------| <- maxOffset (+ threshold to scroll a bit below the grid)
* | content below grid |
* |----------------------|
*/
// If the active item is above the current scroll position (with small threshold
// to start scrolling earlier) and the scroll position is not at the top of the
// grid, scroll up
if (itemAbsoluteY < scrollY + AUTO_SCROLL_THRESHOLD && scrollY > minOffset - AUTO_SCROLL_THRESHOLD) {
currentScrollTarget = Math.max(minOffset - AUTO_SCROLL_THRESHOLD, scrollY - activeHeight)
currentScrollDirection = -1
}
// If the active item is below the current scroll position (with small threshold
// to start scrolling earlier) and the scroll position is not at the bottom of the
// grid, scroll down
else if (
itemAbsoluteY + activeHeight > scrollY + visibleHeight - AUTO_SCROLL_THRESHOLD &&
scrollY < maxOffset + AUTO_SCROLL_THRESHOLD
) {
currentScrollTarget = Math.min(maxOffset + AUTO_SCROLL_THRESHOLD, scrollY + activeHeight)
currentScrollDirection = 1
}
const scrollDiff = Math.abs(currentScrollTarget - scrollTarget.value)
if (
// Don't scroll if the difference is too small (limit JS thread updates that
// become laggy when too many are triggered) and the scroll direction is the same
// as before and the scroll target is still far enough from the min/max offset
(scrollDiff < 0.75 * activeHeight &&
currentScrollDirection === scrollDirection.value &&
currentScrollTarget > minOffset - AUTO_SCROLL_THRESHOLD &&
currentScrollTarget < maxOffset + AUTO_SCROLL_THRESHOLD) ||
// Don't scroll if the difference is too small and the target can be considered
// reached
Math.abs(scrollDiff) < 2
) {
return
}
scrollDirection.value = currentScrollDirection
scrollTarget.value = currentScrollTarget
runOnJS(scrollToOffset)(currentScrollTarget)
},
)
/**
* CONTEXT VALUE
*/
const contextValue = useMemo(
() => ({
gridContainerRef,
containerStartOffset,
containerEndOffset,
scrollOffsetDiff,
startScrollOffset,
scrollY: scrollYValue,
}),
[gridContainerRef, containerStartOffset, containerEndOffset, scrollOffsetDiff, startScrollOffset, scrollYValue],
)
return <AutoScrollContext.Provider value={contextValue}>{children}</AutoScrollContext.Provider>
}
import { createContext, useContext, useMemo } from 'react'
import { runOnJS, useAnimatedReaction, useDerivedValue, useSharedValue } from 'react-native-reanimated'
import { useLayoutContext } from 'src/components/sortableGrid/contexts/LayoutContextProvider'
import { useStableCallback } from 'src/components/sortableGrid/internal/utils'
import { DragContextProviderProps, DragContextType, Vector } from 'src/components/sortableGrid/types'
const DragContext = createContext<DragContextType | null>(null)
export function useDragContext(): DragContextType {
const context = useContext(DragContext)
if (!context) {
throw new Error('useDragContext must be used within a DragContextProvider')
}
return context
}
export function DragContextProvider<I>({
data,
itemKeys,
editable = true,
activeItemScale: activeItemScaleProp = 1.1,
activeItemOpacity: activeItemOpacityProp = 0.7,
activeItemShadowOpacity: activeItemShadowOpacityProp = 0.5,
onDragStart,
onDrop,
onChange,
keyExtractor,
children,
}: DragContextProviderProps<I>): JSX.Element {
const { keyToIndex } = useLayoutContext()
/**
* VARIABLES
*/
// ACTIVE ITEM
const activeItemKey = useSharedValue<string | null>(null)
const prevActiveItemKey = useSharedValue<string | null>(null)
const activeItemDropped = useSharedValue(false)
// DRAG ACTIVATION
const activationProgress = useSharedValue(0)
const activeItemPosition = useSharedValue<Vector>({ x: 0, y: 0 })
// ACTIVE ITEM DECORATION
const activeItemScale = useDerivedValue(() => activeItemScaleProp)
const activeItemOpacity = useDerivedValue(() => activeItemOpacityProp)
const activeItemShadowOpacity = useDerivedValue(() => activeItemShadowOpacityProp)
/**
* HANDLERS
*/
const handleDragStart = useStableCallback(async (key: string, keyToIdx: Record<string, number>) => {
const index = keyToIdx[key]
if (index === undefined) {
return
}
const item = data[index]
if (!onDragStart || !item) {
return
}
onDragStart({ index, item })
})
const handleDrop = useStableCallback((key: string, keyToIdx: Record<string, number>) => {
const index = keyToIdx[key]
if (index === undefined) {
return
}
const item = data[index]
if (!onDrop || index === undefined || !item) {
return
}
onDrop({ index, item })
})
const handleChange = useStableCallback(async (swappedKey: string, keyToIdx: Record<string, number>) => {
if (!onChange) {
return
}
const toIndex = keyToIdx[swappedKey]
if (toIndex === undefined) {
return
}
const fromIndex = itemKeys.indexOf(swappedKey)
const reorderedData: I[] = []
for (let i = 0; i < data.length; i++) {
const item = data[i]
if (!item) {
return
}
const itemKey = keyExtractor(item, i)
const index = keyToIdx[itemKey]
if (index === undefined) {
return
}
reorderedData[index] = item
}
onChange({ data: reorderedData, fromIndex, toIndex })
})
/**
* REACTIONS
*/
// Handle drag start and order change (on drag end)
useAnimatedReaction(
() => activeItemKey.value,
(key, prevKey) => {
if (key !== null && prevKey === null) {
runOnJS(handleDragStart)(key, keyToIndex.value)
} else if (key === null && prevKey !== null) {
runOnJS(handleChange)(prevKey, keyToIndex.value)
}
if (key !== null) {
prevActiveItemKey.value = key
}
},
[handleDragStart, handleChange],
)
// Handle drop (after animation of the active item is finished
// and the item is dropped in the new position)
useAnimatedReaction(
() => activeItemDropped.value,
(dropped) => {
if (dropped && prevActiveItemKey.value !== null) {
runOnJS(handleDrop)(prevActiveItemKey.value, keyToIndex.value)
}
},
[handleDrop],
)
/**
* CONTEXT VALUE
*/
const contextValue = useMemo(
() => ({
editable,
activeItemKey,
activeItemDropped,
activationProgress,
activeItemPosition,
activeItemScale,
activeItemOpacity,
activeItemShadowOpacity,
}),
[
editable,
activeItemKey,
activeItemDropped,
activationProgress,
activeItemPosition,
activeItemScale,
activeItemOpacity,
activeItemShadowOpacity,
],
)
return <DragContext.Provider value={contextValue}>{children}</DragContext.Provider>
}
import { PropsWithChildren, createContext, useContext, useEffect, useMemo, useRef } from 'react'
import {
SharedValue,
useAnimatedReaction,
useDerivedValue,
useSharedValue,
withDelay,
withTiming,
} from 'react-native-reanimated'
import { ITEM_ANIMATION_DURATION, OFFSET_EPS } from 'src/components/sortableGrid/internal/constants'
import { areArraysDifferent, getColumnIndex, getRowIndex } from 'src/components/sortableGrid/internal/utils'
import { Dimensions, Vector } from 'src/components/sortableGrid/types'
const EMPTY_ARRAY: unknown[] = []
const EMPTY_OBJECT = {}
export type LayoutContextType = {
// HELPER VALUES
initialRenderCompleted: SharedValue<boolean>
measuredItemsCount: SharedValue<number>
// DIMENSIONS
rowOffsets: SharedValue<number[]>
itemDimensions: SharedValue<Record<string, Dimensions>>
containerWidth: SharedValue<number>
containerHeight: SharedValue<number>
targetContainerHeight: SharedValue<number>
appliedContainerHeight: SharedValue<number>
columnWidth: SharedValue<number>
// KEY-INDEX MAPPINGS
keyToIndex: SharedValue<Record<string, number>>
indexToKey: SharedValue<string[]>
// POSITIONING
itemPositions: SharedValue<Record<string, Vector>>
}
const LayoutContext = createContext<LayoutContextType | null>(null)
export function useLayoutContext(): LayoutContextType {
const context = useContext(LayoutContext)
if (!context) {
throw new Error('useLayoutContext must be used within a LayoutContextProvider')
}
return context
}
export type LayoutContextProviderProps = PropsWithChildren<{
itemKeys: string[]
numColumns: number
animateContainerHeight?: boolean
}>
export function LayoutContextProvider({
itemKeys,
numColumns,
animateContainerHeight = true,
children,
}: LayoutContextProviderProps): JSX.Element {
/**
* VARIABLES
*/
// HELPER VALUES
const prevKeysRef = useRef<string[]>([])
const rowsCount = Math.ceil(itemKeys.length / numColumns)
const initialRenderCompleted = useSharedValue(false)
const appliedContainerHeight = useSharedValue(-1)
const measuredItemsCount = useSharedValue(0)
// DIMENSIONS
const rowOffsets = useSharedValue<number[]>([])
const itemDimensions = useSharedValue<Record<string, Dimensions>>({})
const containerWidth = useSharedValue(-1)
const containerHeight = useSharedValue(-1)
const targetContainerHeight = useSharedValue(-1)
const columnWidth = useDerivedValue(() => (containerWidth.value === -1 ? -1 : containerWidth.value / numColumns))
// KEY-INDEX MAPPINGS
const indexToKey = useSharedValue<string[]>([])
const keyToIndex = useDerivedValue(() => Object.fromEntries(indexToKey.value.map((key, index) => [key, index])))
// POSITIONING
const itemPositions = useDerivedValue<Record<string, Vector>>(() => {
// Return empty object if columnWidth is not yet calculated or if the number
// of rows is not yet known
if (columnWidth.value === -1 || rowOffsets.value.length < rowsCount) {
return EMPTY_OBJECT
}
// Calculate item positions based on their order in the grid
return Object.fromEntries(
Object.entries(indexToKey.value).map(([index, key]) => [
key,
{
x: columnWidth.value * getColumnIndex(parseInt(index, 10), numColumns),
y: rowOffsets.value[getRowIndex(parseInt(index, 10), numColumns)] ?? 0,
},
]),
)
}, [rowsCount, columnWidth, rowOffsets, indexToKey, numColumns])
/**
* EFFECTS
*/
// Update indexToKey when itemKeys change (only if the arrays are different in
// terms of their elements, not just their references - this prevents unnecessary
// value updates if the array is the new object but has all the same contents)
useEffect(() => {
if (areArraysDifferent(itemKeys, prevKeysRef.current)) {
indexToKey.value = itemKeys
prevKeysRef.current = itemKeys
}
}, [itemKeys, indexToKey])
// ITEM DIMENSIONS UPDATER
useAnimatedReaction(
() => measuredItemsCount.value,
(count) => {
// Re-create the item dimensions object if all items have been measured
// (this is done to prevent unnecessary object updates after each item measurement)
if (count === itemKeys.length) {
itemDimensions.value = { ...itemDimensions.value }
}
},
[itemKeys],
)
// ROW OFFSETS UPDATER
useAnimatedReaction(
() => ({
dimensions: itemDimensions.value,
idxToKey: indexToKey.value,
}),
({ dimensions, idxToKey }) => {
// Return an empty array if items haven't been measured yet
if (Object.keys(dimensions).length === 0) {
return EMPTY_ARRAY
}
const offsets = [0]
for (const [itemIndex, key] of Object.entries(idxToKey)) {
const rowIndex = getRowIndex(parseInt(itemIndex, 10), numColumns)
offsets[rowIndex + 1] = Math.max(
offsets[rowIndex + 1] ?? 0,
(offsets[rowIndex] ?? 0) + (dimensions[key]?.height ?? 0),
)
}
// Update row offsets only if they have changed
if (areArraysDifferent(offsets, rowOffsets.value, (a, b) => Math.abs(a - b) < OFFSET_EPS)) {
if (!rowOffsets.value.length) {
initialRenderCompleted.value = true
}
rowOffsets.value = offsets
}
return undefined
},
[numColumns],
)
useAnimatedReaction(
() => rowOffsets.value,
(offsets) => {
const newHeight = offsets[offsets.length - 1] ?? -1
targetContainerHeight.value = newHeight
if (newHeight === -1) {
return
}
const duration = animateContainerHeight ? ITEM_ANIMATION_DURATION : 0
// If container is expanded, animate its height immediately
if (newHeight > containerHeight.value) {
containerHeight.value = withTiming(newHeight, { duration })
}
// If container is shrunk, delay the animation to allow the items to disappear
else if (newHeight < containerHeight.value) {
const delay = (animateContainerHeight ? 0.25 : 1) * ITEM_ANIMATION_DURATION
containerHeight.value = withDelay(delay, withTiming(newHeight, { duration }))
}
// In all other
else {
containerHeight.value = newHeight
}
},
[animateContainerHeight],
)
/**
* CONTEXT VALUE
*/
const contextValue = useMemo<LayoutContextType>(
() => ({
initialRenderCompleted,
appliedContainerHeight,
measuredItemsCount,
rowOffsets,
itemDimensions,
containerWidth,
containerHeight,
targetContainerHeight,
columnWidth,
keyToIndex,
indexToKey,
itemPositions,
}),
[
initialRenderCompleted,
appliedContainerHeight,
measuredItemsCount,
rowOffsets,
itemDimensions,
containerWidth,
containerHeight,
targetContainerHeight,
columnWidth,
keyToIndex,
indexToKey,
itemPositions,
],
)
return <LayoutContext.Provider value={contextValue}>{children}</LayoutContext.Provider>
}
import { PropsWithChildren, useMemo } from 'react'
import {
AutoScrollProvider,
AutoScrollProviderProps,
} from 'src/components/sortableGrid/contexts/AutoScrollContextProvider'
import { DragContextProvider } from 'src/components/sortableGrid/contexts/DragContextProvider'
import {
LayoutContextProvider,
LayoutContextProviderProps,
} from 'src/components/sortableGrid/contexts/LayoutContextProvider'
import { DragContextProviderProps } from 'src/components/sortableGrid/types'
type SortableGridProviderProps<I> = PropsWithChildren<
Omit<LayoutContextProviderProps & DragContextProviderProps<I> & AutoScrollProviderProps, 'itemKeys'>
>
export function SortableGridProvider<I>({
children,
data,
numColumns,
editable,
animateContainerHeight,
activeItemScale,
activeItemOpacity,
activeItemShadowOpacity,
scrollableRef,
visibleHeight,
scrollY,
onChange,
onDragStart,
onDrop,
keyExtractor,
}: SortableGridProviderProps<I>): JSX.Element {
const itemKeys = useMemo(() => data.map(keyExtractor), [data, keyExtractor])
const sharedProps = {
itemKeys,
numColumns,
}
return (
<LayoutContextProvider {...sharedProps} animateContainerHeight={animateContainerHeight}>
<DragContextProvider
{...sharedProps}
activeItemOpacity={activeItemOpacity}
activeItemScale={activeItemScale}
activeItemShadowOpacity={activeItemShadowOpacity}
data={data}
editable={editable}
keyExtractor={keyExtractor}
onChange={onChange}
onDragStart={onDragStart}
onDrop={onDrop}
>
<AutoScrollProvider scrollY={scrollY} scrollableRef={scrollableRef} visibleHeight={visibleHeight}>
{children}
</AutoScrollProvider>
</DragContextProvider>
</LayoutContextProvider>
)
}
import { memo, useCallback, useEffect, useMemo } from 'react'
import { LayoutChangeEvent } from 'react-native'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import Animated, {
interpolate,
interpolateColor,
runOnUI,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withDelay,
withTiming,
} from 'react-native-reanimated'
import { useAutoScrollContext } from 'src/components/sortableGrid/contexts/AutoScrollContextProvider'
import { useDragContext } from 'src/components/sortableGrid/contexts/DragContextProvider'
import { useLayoutContext } from 'src/components/sortableGrid/contexts/LayoutContextProvider'
import {
ACTIVATE_PAN_ANIMATION_DELAY,
ITEM_ANIMATION_DURATION,
OFFSET_EPS,
TIME_TO_ACTIVATE_PAN,
} from 'src/components/sortableGrid/internal/constants'
import { useItemPosition } from 'src/components/sortableGrid/internal/hooks'
import { GridItemExiting } from 'src/components/sortableGrid/internal/layoutAnimations'
import { getItemZIndex } from 'src/components/sortableGrid/internal/utils'
import { SortableGridRenderItem } from 'src/components/sortableGrid/types'
type SortableGridItemProps<I> = {
item: I
itemKey: string
renderItem: SortableGridRenderItem<I>
numColumns: number
}
function SortableGridItem<I>({ item, itemKey, renderItem, numColumns }: SortableGridItemProps<I>): JSX.Element {
const {
measuredItemsCount,
targetContainerHeight,
initialRenderCompleted,
appliedContainerHeight,
itemDimensions,
itemPositions,
columnWidth,
} = useLayoutContext()
const {
activeItemScale,
activeItemOpacity,
activeItemShadowOpacity,
activeItemPosition,
activationProgress,
activeItemDropped,
activeItemKey,
editable,
} = useDragContext()
const { scrollY, startScrollOffset } = useAutoScrollContext()
const isTouched = useSharedValue(false)
const isActive = useDerivedValue(() => activeItemKey.value === itemKey)
const itemHeight = useDerivedValue(() => itemDimensions.value[itemKey]?.height ?? 0)
const pressProgress = useSharedValue(0)
const position = useItemPosition(itemKey)
const dragStartPosition = useSharedValue({ x: 0, y: 0 })
const targetItemPosition = useDerivedValue(() => itemPositions.value[itemKey])
useEffect(() => {
return (): void => {
// Remove item dimensions when the item is unmounted
runOnUI((key: string) => {
delete itemDimensions.value[key]
measuredItemsCount.value -= 1
// If was active, reset active item key
if (activeItemKey.value === key) {
activeItemKey.value = null
}
})(itemKey)
}
}, [itemKey, activeItemKey, itemDimensions, measuredItemsCount])
const measureItem = useCallback(
({
nativeEvent: {
layout: { width, height },
},
}: LayoutChangeEvent) => {
runOnUI((key: string) => {
// Store item dimensions without re-creating the dimensions object
if (!itemDimensions.value[key]) {
measuredItemsCount.value += 1
}
itemDimensions.value[key] = { width, height }
})(itemKey)
},
[itemKey, itemDimensions, measuredItemsCount],
)
const handleDragEnd = useCallback(() => {
'worklet'
isTouched.value = false
activeItemKey.value = null
pressProgress.value = withTiming(0, { duration: TIME_TO_ACTIVATE_PAN })
activationProgress.value = withTiming(0, { duration: TIME_TO_ACTIVATE_PAN }, () => {
activeItemDropped.value = true
})
}, [activationProgress, activeItemDropped, activeItemKey, isTouched, pressProgress])
const panGesture = useMemo(
() =>
Gesture.Pan()
.activateAfterLongPress(TIME_TO_ACTIVATE_PAN)
.onTouchesDown(() => {
isTouched.value = true
const progress = withDelay(
ACTIVATE_PAN_ANIMATION_DELAY,
withTiming(1, { duration: TIME_TO_ACTIVATE_PAN - ACTIVATE_PAN_ANIMATION_DELAY }),
)
pressProgress.value = progress
activationProgress.value = progress
})
.onStart(() => {
if (!isTouched.value) {
return
}
dragStartPosition.value = activeItemPosition.value = {
x: position.x.value ?? 0,
y: position.y.value ?? 0,
}
activeItemKey.value = itemKey
startScrollOffset.value = scrollY.value
activeItemDropped.value = false
})
.onUpdate((e) => {
if (!isActive.value) {
return
}
activeItemPosition.value = {
x: dragStartPosition.value.x + e.translationX,
y: dragStartPosition.value.y + e.translationY,
}
})
.onFinalize(handleDragEnd)
.enabled(editable),
[
editable,
activationProgress,
activeItemDropped,
activeItemKey,
activeItemPosition,
dragStartPosition,
handleDragEnd,
isActive,
isTouched,
itemKey,
position,
pressProgress,
scrollY,
startScrollOffset,
],
)
// ITEM POSITIONING AND ANIMATION
const animatedItemStyle = useAnimatedStyle(() => {
// INITIAL RENDER
// (relative placements - no absolute positioning yet)
// This ensures there is no blank space when grid items are being measured
if (!initialRenderCompleted.value || appliedContainerHeight.value === -1 || columnWidth.value === -1) {
return {
width: `${100 / numColumns}%`,
}
}
const x = position.x.value
const y = position.y.value
// ADDED ITEM AFTER INITIAL RENDER
// (item is not yet measured -> don't render it)
// This ensures the item is not misplaced when it is added to the grid
if (
x === null ||
y === null ||
// If the item bottom edge is rendered below the container bottom edge
(y + itemHeight.value - appliedContainerHeight.value > OFFSET_EPS &&
// And the container height is lower than the target height
targetContainerHeight.value - appliedContainerHeight.value > OFFSET_EPS &&
// And the item is not being dragged
!isActive.value)
) {
return {
pointerEvents: 'none',
position: 'absolute',
transform: [{ scale: 0.5 }],
opacity: 0,
width: columnWidth.value,
}
}
// ABSOLUTE POSITIONING
// (item is measured and rendered)
// This ensures the item is rendered in the correct position and responds
// to grid items order changes and drag events
return {
pointerEvents: 'auto',
position: 'absolute',
opacity: withTiming(1, { duration: ITEM_ANIMATION_DURATION }),
transform: [{ scale: withTiming(1, { duration: ITEM_ANIMATION_DURATION }) }],
top: y,
left: x,
width: columnWidth.value,
zIndex: getItemZIndex(isActive.value, pressProgress.value, { x, y }, targetItemPosition.value),
}
})
// ITEM DECORATION
// (only for the active item being dragged)
const animatedItemDecorationStyle = 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', `rgba(0, 0, 0, ${activeItemShadowOpacity.value})`],
),
}))
const content = useMemo(
() =>
renderItem({
item,
pressProgress,
dragActivationProgress: activationProgress,
}),
[item, renderItem, activationProgress, pressProgress],
)
return (
<Animated.View exiting={GridItemExiting} pointerEvents="box-none" style={animatedItemStyle} onLayout={measureItem}>
<GestureDetector gesture={panGesture}>
<Animated.View style={animatedItemDecorationStyle}>{content}</Animated.View>
</GestureDetector>
</Animated.View>
)
}
export default memo(SortableGridItem) as typeof SortableGridItem
export const ITEM_ANIMATION_DURATION = 300
export const TIME_TO_ACTIVATE_PAN = 500
export const ACTIVATE_PAN_ANIMATION_DELAY = 250
export const AUTO_SCROLL_THRESHOLD = 50
export const OFFSET_EPS = 1
import { SharedValue, useAnimatedReaction, useSharedValue, withTiming } from 'react-native-reanimated'
import { useAutoScrollContext } from 'src/components/sortableGrid/contexts/AutoScrollContextProvider'
import { useDragContext } from 'src/components/sortableGrid/contexts/DragContextProvider'
import { useLayoutContext } from 'src/components/sortableGrid/contexts/LayoutContextProvider'
import { getColumnIndex, getRowIndex } from 'src/components/sortableGrid/internal/utils'
export function useItemPosition(key: string): {
x: SharedValue<number | null>
y: SharedValue<number | null>
} {
const { itemPositions } = useLayoutContext()
const { activeItemKey, activeItemPosition } = useDragContext()
const { scrollOffsetDiff } = useAutoScrollContext()
const x = useSharedValue<number | null>(null)
const y = useSharedValue<number | null>(null)
useAnimatedReaction(
() => ({
position: itemPositions.value[key],
isActive: activeItemKey.value === key,
}),
({ position, isActive }) => {
if (!position || isActive) {
return
}
x.value = x.value === null ? position.x : withTiming(position.x)
y.value = y.value === null ? position.y : withTiming(position.y)
},
[key],
)
useAnimatedReaction(
() => ({
position: activeItemPosition.value,
offsetDiff: scrollOffsetDiff.value,
}),
({ position, offsetDiff }) => {
if (activeItemKey.value === key) {
x.value = position.x
y.value = position.y + offsetDiff
}
},
)
return { x, y }
}
export function useItemOrderUpdater(numColumns: number): void {
const { keyToIndex, indexToKey, rowOffsets, targetContainerHeight, itemDimensions } = useLayoutContext()
const { activeItemKey, activeItemPosition } = useDragContext()
const { scrollOffsetDiff } = useAutoScrollContext()
useAnimatedReaction(
() => ({
activeKey: activeItemKey.value,
activePosition: activeItemPosition.value,
offsetDiff: scrollOffsetDiff.value,
}),
({ activeKey, activePosition, offsetDiff }) => {
if (activeKey === null) {
return
}
const dimensions = itemDimensions.value[activeKey]
if (!dimensions) {
return
}
const centerY = activePosition.y + dimensions.height / 2 + offsetDiff
const centerX = activePosition.x + dimensions.width / 2
const activeIndex = keyToIndex.value[activeKey]
const itemsCount = indexToKey.value.length
if (activeIndex === undefined) {
return
}
const rowIndex = getRowIndex(activeIndex, numColumns)
const columnIndex = getColumnIndex(activeIndex, numColumns)
// Get active item bounding box
const yOffsetAbove = rowOffsets.value[rowIndex]
if (yOffsetAbove === undefined) {
return
}
const yOffsetBelow = rowOffsets.value[rowIndex + 1]
const xOffsetLeft = columnIndex * dimensions.width
const xOffsetRight = (columnIndex + 1) * dimensions.width
// Check if the center of the active item is over the top or bottom edge of the container
let dy = 0
if (yOffsetAbove > 0 && centerY < yOffsetAbove) {
dy = -1
} else if (yOffsetBelow !== undefined && yOffsetBelow < targetContainerHeight.value && centerY > yOffsetBelow) {
dy = 1
}
// Check if the center of the active item is over the left or right edge of the container
let dx = 0
if (xOffsetLeft > 0 && centerX < xOffsetLeft) {
dx = -1
} else if (columnIndex < numColumns - 1 && activeIndex < itemsCount && centerX > xOffsetRight) {
dx = 1
}
const indexOffset = dy * numColumns + dx
// Swap the active item with the item at the new index
const newIndex = activeIndex + indexOffset
if (newIndex === activeIndex || newIndex < 0 || newIndex >= itemsCount) {
return
}
// Swap the order of the current item and the active item
if (newIndex < activeIndex) {
indexToKey.value = [
...indexToKey.value.slice(0, newIndex),
activeKey,
...indexToKey.value.slice(newIndex, activeIndex),
...indexToKey.value.slice(activeIndex + 1),
]
} else {
indexToKey.value = [
...indexToKey.value.slice(0, activeIndex),
...indexToKey.value.slice(activeIndex + 1, newIndex + 1),
activeKey,
...indexToKey.value.slice(newIndex + 1),
]
}
},
[],
)
}
import { LayoutAnimation, withTiming } from 'react-native-reanimated'
import { ITEM_ANIMATION_DURATION } from 'src/components/sortableGrid/internal/constants'
export const GridItemExiting = (): LayoutAnimation => {
'worklet'
const animations = {
opacity: withTiming(0, {
duration: ITEM_ANIMATION_DURATION,
}),
transform: [
{
scale: withTiming(0.5, {
duration: ITEM_ANIMATION_DURATION,
}),
},
],
}
const initialValues = {
opacity: 1,
transform: [{ scale: 1 }],
}
return {
initialValues,
animations,
}
}
import { useCallback, useRef } from 'react'
import { Vector } from 'src/components/sortableGrid/types'
export function useStableCallback<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
C extends (...args: Array<any>) => any,
>(callback?: C): C {
const callbackRef = useRef(callback)
callbackRef.current = callback
return useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
(...args: Array<any>) => callbackRef.current?.(...args),
[],
) as C
}
export const areArraysDifferent = <T>(arr1: T[], arr2: T[], areEqual = (a: T, b: T): boolean => a === b): boolean => {
'worklet'
return arr1.length !== arr2.length || arr1.some((item, index) => !areEqual(item, arr2[index] as T))
}
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 getRowIndex = (index: number, numColumns: number): number => {
'worklet'
return Math.floor(index / numColumns)
}
export const getColumnIndex = (index: number, numColumns: number): number => {
'worklet'
return index % numColumns
}
export const getItemsInColumnCount = (index: number, numColumns: number, itemsCount: number): number => {
'worklet'
const columnIndex = getColumnIndex(index, numColumns)
return Math.floor(itemsCount / numColumns) + (columnIndex < itemsCount % numColumns ? 1 : 0)
}
export const getItemZIndex = (
isActive: boolean,
pressProgress: number,
position: Vector,
targetPosition?: Vector,
): number => {
'worklet'
if (isActive) {
return 3
}
if (pressProgress > 0) {
return 2
}
// If the item is being re-ordered but is not dragged
if (targetPosition && (position.x !== targetPosition.x || position.y !== targetPosition.y)) {
return 1
}
return 0
}
This diff is collapsed.
......@@ -7,6 +7,7 @@ import {
UNISWAP_WALLETCONNECT_URL,
} from 'src/features/deepLinking/constants'
import { UNISWAP_WEB_HOSTNAME } from 'uniswap/src/constants/urls'
import { isCurrencyIdValid } from 'uniswap/src/utils/currencyId'
import { logger } from 'utilities/src/logger/logger'
const UNISWAP_URL_SCHEME_WIDGET = 'uniswap://widget/'
......@@ -118,6 +119,9 @@ export function parseDeepLinkUrl(urlString: string): DeepLinkActionResult {
if (!currencyId) {
return logAndReturnError('No currencyId found', DeepLinkAction.TokenDetails, urlString, data)
}
if (!isCurrencyIdValid(currencyId)) {
return logAndReturnError('Invalid currencyId found', DeepLinkAction.TokenDetails, urlString, data)
}
return {
action: DeepLinkAction.TokenDetails,
data: { ...data, currencyId },
......
......@@ -283,6 +283,7 @@ export function* handleDeepLink(action: ReturnType<typeof openDeepLink>) {
break
}
case DeepLinkAction.TokenDetails: {
yield* put(closeAllModals())
yield* call(handleGoToTokenDetailsDeepLink, deepLinkAction.data.currencyId)
break
}
......@@ -295,6 +296,7 @@ export function* handleDeepLink(action: ReturnType<typeof openDeepLink>) {
} catch (error) {
yield* call(logger.error, error, {
tags: { file: 'handleDeepLinkSaga', function: 'handleDeepLink' },
extra: { coldStart: action.payload.coldStart, url: action.payload.url },
})
}
}
......@@ -344,6 +346,7 @@ export function* handleWalletConnectDeepLink(wcUri: string) {
} catch (error) {
logger.error(error, {
tags: { file: 'handleDeepLinkSaga', function: 'handleWalletConnectDeepLink' },
extra: { wcUri },
})
Alert.alert(i18n.t('walletConnect.error.general.title'), i18n.t('walletConnect.error.general.message'))
}
......
This diff is collapsed.
......@@ -2,7 +2,7 @@ import { PayloadAction } from '@reduxjs/toolkit'
import { closeModal, CloseModalParams, openModal, OpenModalParams } from 'src/features/modals/modalSlice'
import { takeEvery } from 'typed-redux-saga'
import { ModalName } from 'uniswap/src/features/telemetry/constants'
import { setAttributesToDatadog } from 'utilities/src/logger/Datadog'
import { setAttributesToDatadog } from 'utilities/src/logger/datadog/Datadog'
export function* modalWatcher() {
yield* takeEvery(openModal, handleOpenModalAction)
......
This diff is collapsed.
import { FiatOnRampCurrency } from 'uniswap/src/features/fiatOnRamp/types'
export type FiatOnRampModalState = { prefilledCurrency?: FiatOnRampCurrency }
export type FiatOnRampModalState = { prefilledCurrency?: FiatOnRampCurrency; isOfframp?: boolean }
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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