ci(release): publish latest release

parent e40445e9
* @uniswap/web-admins
IPFS hash of the deployment:
- CIDv0: `QmeVXtPfnqJ2PeigSXgwizJDYf2EmwfVrCE4Qi3dXhAbdL`
- CIDv1: `bafybeihqagapczekcmizn4zden7rrz6h4mfhtnd3dc3wa3wnatqc4eu6vm`
We are back with some new updates! Here’s the latest:
The latest release is always mirrored at [app.uniswap.org](https://app.uniswap.org).
Integrated Flashbots Protect: This is a private RPC provider that protects users from getting sandwiched on Mainnet.
You can also access the Uniswap Interface from an IPFS gateway.
**BEWARE**: The Uniswap interface uses [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) to remember your settings, such as which tokens you have imported.
**You should always use an IPFS gateway that enforces origin separation**, or our hosted deployment of the latest release at [app.uniswap.org](https://app.uniswap.org).
Your Uniswap settings are never remembered across different URLs.
IPFS gateways:
- https://bafybeihqagapczekcmizn4zden7rrz6h4mfhtnd3dc3wa3wnatqc4eu6vm.ipfs.dweb.link/
- https://bafybeihqagapczekcmizn4zden7rrz6h4mfhtnd3dc3wa3wnatqc4eu6vm.ipfs.cf-ipfs.com/
- [ipfs://QmeVXtPfnqJ2PeigSXgwizJDYf2EmwfVrCE4Qi3dXhAbdL/](ipfs://QmeVXtPfnqJ2PeigSXgwizJDYf2EmwfVrCE4Qi3dXhAbdL/)
### 5.67.4 (2025-01-23)
### Bug Fixes
* **web:** Phil/lp polling prod (#15462) 7533563
Max Balance Education: Introduces copy to educate users on why the Max button is disabled when they don't have enough of the network token to cover gas costs.
Other changes:
- Improved readability and color extraction of NFT Detail pages
- Improved fee detection of fee-on-transfer tokens
- More concise context menu on token detail pages
- Various bug fixes and performance improvements
web/5.67.4
\ No newline at end of file
mobile/1.43.1
\ No newline at end of file
......@@ -25,3 +25,6 @@ dist-ssr
*.sw?
.tamagui
# Sentry Config File
.env.sentry-build-plugin
......@@ -9,14 +9,17 @@
"@ethersproject/providers": "5.7.2",
"@metamask/rpc-errors": "6.2.1",
"@reduxjs/toolkit": "1.9.3",
"@sentry/browser": "7.80.0",
"@sentry/react": "7.80.0",
"@sentry/webpack-plugin": "2.10.3",
"@svgr/webpack": "8.0.1",
"@tamagui/core": "1.114.4",
"@types/uuid": "9.0.1",
"@uniswap/analytics-events": "2.40.0",
"@uniswap/uniswapx-sdk": "3.0.0-beta.1",
"@uniswap/universal-router-sdk": "4.10.0",
"@uniswap/v3-sdk": "3.21.0",
"@uniswap/v4-sdk": "1.15.0",
"@uniswap/universal-router-sdk": "4.7.0",
"@uniswap/v3-sdk": "3.19.0",
"@uniswap/v4-sdk": "1.12.0",
"dotenv-webpack": "8.0.1",
"ethers": "5.7.2",
"eventemitter3": "5.0.1",
......
......@@ -2,8 +2,6 @@ body,
html {
height: 100%;
max-width: 100vw;
font-feature-settings: 'liga' 0;
font-variant-ligatures: no-contextual;
}
#root {
......
......@@ -4,12 +4,11 @@ import 'symbol-observable' // Needed by `reduxed-chrome-storage` as polyfill, or
import { useEffect } from 'react'
import { I18nextProvider } from 'react-i18next'
import { RouteObject, RouterProvider, createHashRouter } from 'react-router-dom'
import { RouteObject, RouterProvider } 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 { DatadogAppNameTag } from 'src/app/datadog'
import { ClaimUnitagScreen } from 'src/app/features/onboarding/ClaimUnitagScreen'
import { Complete } from 'src/app/features/onboarding/Complete'
import {
......@@ -35,6 +34,7 @@ import { ScanToOnboard } from 'src/app/features/onboarding/scan/ScanToOnboard'
import { ScantasticContextProvider } from 'src/app/features/onboarding/scan/ScantasticContextProvider'
import { OnboardingRoutes, TopLevelRoutes } from 'src/app/navigation/constants'
import { setRouter, setRouterState } from 'src/app/navigation/state'
import { SentryAppNameTag, sentryCreateHashRouter } from 'src/app/sentry'
import { initExtensionAnalytics } from 'src/app/utils/analytics'
import { checksIfSupportsSidePanel } from 'src/app/utils/chrome'
import { PrimaryAppInstanceDebuggerLazy } from 'src/store/PrimaryAppInstanceDebuggerLazy'
......@@ -135,7 +135,7 @@ const allRoutes = [
},
]
const router = createHashRouter([
const router = sentryCreateHashRouter([
{
path: `/${TopLevelRoutes.Onboarding}`,
element: <OnboardingWrapper />,
......@@ -188,7 +188,7 @@ export default function OnboardingApp(): JSX.Element {
return (
<Trace>
<PersistGate persistor={getReduxPersistor()}>
<ExtensionStatsigProvider appName={DatadogAppNameTag.Onboarding}>
<ExtensionStatsigProvider appName={SentryAppNameTag.Onboarding}>
<I18nextProvider i18n={i18n}>
<SharedWalletProvider reduxStore={getReduxStore()}>
<ErrorBoundary>
......
......@@ -4,14 +4,14 @@ import 'src/app/Global.css'
import { useEffect } from 'react'
import { I18nextProvider, useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux'
import { RouterProvider, createHashRouter } from 'react-router-dom'
import { RouterProvider } 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 { DatadogAppNameTag } from 'src/app/datadog'
import { DappContextProvider } from 'src/app/features/dapp/DappContext'
import { SentryAppNameTag, initializeSentry, sentryCreateHashRouter } from 'src/app/sentry'
import { initExtensionAnalytics } from 'src/app/utils/analytics'
import { getReduxPersistor, getReduxStore } from 'src/store/store'
import { DeprecatedButton, Flex, Image, Text } from 'ui/src'
......@@ -25,11 +25,23 @@ 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 { getUniqueId } from 'utilities/src/device/getUniqueId'
import { logger } from 'utilities/src/logger/logger'
import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary'
import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testnetMode/hooks'
import { SharedWalletProvider } from 'wallet/src/providers/SharedWalletProvider'
const router = createHashRouter([
getUniqueId()
.then((userId) => {
initializeSentry(SentryAppNameTag.Popup, userId)
})
.catch((error) => {
logger.error(error, {
tags: { file: 'PopupApp.tsx', function: 'getUniqueId' },
})
})
const router = sentryCreateHashRouter([
{
path: '',
element: <PopupContent />,
......@@ -116,7 +128,7 @@ export default function PopupApp(): JSX.Element {
return (
<Trace>
<PersistGate persistor={getReduxPersistor()}>
<ExtensionStatsigProvider appName={DatadogAppNameTag.Popup}>
<ExtensionStatsigProvider appName={SentryAppNameTag.Popup}>
<I18nextProvider i18n={i18n}>
<SharedWalletProvider reduxStore={getReduxStore()}>
<ErrorBoundary>
......
......@@ -4,13 +4,12 @@ import 'src/app/Global.css'
import { useEffect, useRef, useState } from 'react'
import { I18nextProvider } from 'react-i18next'
import { useDispatch } from 'react-redux'
import { RouterProvider, createHashRouter } from 'react-router-dom'
import { RouterProvider } 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 { DatadogAppNameTag } from 'src/app/datadog'
import { AccountSwitcherScreen } from 'src/app/features/accounts/AccountSwitcherScreen'
import { DappContextProvider } from 'src/app/features/dapp/DappContext'
import { addRequest } from 'src/app/features/dappRequests/saga'
......@@ -30,6 +29,7 @@ import { useIsWalletUnlocked } from 'src/app/hooks/useIsWalletUnlocked'
import { AppRoutes, RemoveRecoveryPhraseRoutes, SettingsRoutes } from 'src/app/navigation/constants'
import { MainContent, WebNavigation } from 'src/app/navigation/navigation'
import { setRouter, setRouterState } from 'src/app/navigation/state'
import { SentryAppNameTag, initializeSentry, sentryCreateHashRouter } from 'src/app/sentry'
import { initExtensionAnalytics } from 'src/app/utils/analytics'
import {
DappBackgroundPortChannel,
......@@ -47,6 +47,7 @@ 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 { getUniqueId } from 'utilities/src/device/getUniqueId'
import { isDevEnv } from 'utilities/src/environment/env'
import { logger } from 'utilities/src/logger/logger'
import { ONE_SECOND_MS } from 'utilities/src/time/time'
......@@ -55,7 +56,17 @@ import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary
import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testnetMode/hooks'
import { SharedWalletProvider } from 'wallet/src/providers/SharedWalletProvider'
const router = createHashRouter([
getUniqueId()
.then((userId) => {
initializeSentry(SentryAppNameTag.Sidebar, userId)
})
.catch((error) => {
logger.error(error, {
tags: { file: 'SidebarApp.tsx', function: 'getUniqueId' },
})
})
const router = sentryCreateHashRouter([
{
path: '',
element: <SidebarWrapper />,
......@@ -247,7 +258,7 @@ export default function SidebarApp(): JSX.Element {
return (
<Trace>
<PersistGate persistor={getReduxPersistor()}>
<ExtensionStatsigProvider appName={DatadogAppNameTag.Sidebar}>
<ExtensionStatsigProvider appName={SentryAppNameTag.Sidebar}>
<I18nextProvider i18n={i18n}>
<SharedWalletProvider reduxStore={getReduxStore()}>
<ErrorBoundary>
......
......@@ -3,13 +3,12 @@ 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 { Outlet, RouterProvider, 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 { DatadogAppNameTag } from 'src/app/datadog'
import {
ClaimUnitagSteps,
OnboardingStepsProvider,
......@@ -24,6 +23,7 @@ import { UnitagCreateUsernameScreen } from 'src/app/features/unitags/UnitagCreat
import { UnitagIntroScreen } from 'src/app/features/unitags/UnitagIntroScreen'
import { UnitagClaimRoutes } from 'src/app/navigation/constants'
import { setRouter, setRouterState } from 'src/app/navigation/state'
import { SentryAppNameTag, initializeSentry, sentryCreateHashRouter } from 'src/app/sentry'
import { initExtensionAnalytics } from 'src/app/utils/analytics'
import { getReduxPersistor, getReduxStore } from 'src/store/store'
import { Flex } from 'ui/src'
......@@ -32,6 +32,7 @@ import { LocalizationContextProvider } from 'uniswap/src/features/language/Local
import Trace from 'uniswap/src/features/telemetry/Trace'
import { UnitagUpdaterContextProvider } from 'uniswap/src/features/unitags/context'
import i18n from 'uniswap/src/i18n'
import { getUniqueId } from 'utilities/src/device/getUniqueId'
import { logger } from 'utilities/src/logger/logger'
import { usePrevious } from 'utilities/src/react/hooks'
import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary'
......@@ -39,7 +40,17 @@ import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testne
import { useAccountAddressFromUrlWithThrow } from 'wallet/src/features/wallet/hooks'
import { SharedWalletProvider } from 'wallet/src/providers/SharedWalletProvider'
const router = createHashRouter([
getUniqueId()
.then((userId) => {
initializeSentry(SentryAppNameTag.UnitagClaim, userId)
})
.catch((error) => {
logger.error(error, {
tags: { file: 'UnitagClaimApp.tsx', function: 'getUniqueId' },
})
})
const router = sentryCreateHashRouter([
{
path: '',
element: <UnitagAppInner />,
......@@ -151,7 +162,7 @@ export default function UnitagClaimApp(): JSX.Element {
return (
<Trace>
<PersistGate persistor={getReduxPersistor()}>
<ExtensionStatsigProvider appName={DatadogAppNameTag.UnitagClaim}>
<ExtensionStatsigProvider appName={SentryAppNameTag.UnitagClaim}>
<I18nextProvider i18n={i18n}>
<SharedWalletProvider reduxStore={getReduxStore()}>
<ErrorBoundary>
......
import { datadogLogs } from '@datadog/browser-logs'
import { RumEvent, datadogRum } from '@datadog/browser-rum'
import { datadogRum } from '@datadog/browser-rum'
import { getDatadogEnvironment } from 'src/app/version'
import { config } from 'uniswap/src/config'
import {
DatadogIgnoredErrorsConfigKey,
DatadogIgnoredErrorsValType,
DatadogSessionSampleRateKey,
DatadogSessionSampleRateValType,
DynamicConfigs,
} from 'uniswap/src/features/gating/configs'
import { Experiments } from 'uniswap/src/features/gating/experiments'
import { WALLET_FEATURE_FLAG_NAMES } from 'uniswap/src/features/gating/flags'
import { FeatureFlags, WALLET_FEATURE_FLAG_NAMES, getFeatureFlagName } from 'uniswap/src/features/gating/flags'
import { getDynamicConfigValue } from 'uniswap/src/features/gating/hooks'
import { Statsig } from 'uniswap/src/features/gating/sdk/statsig'
import { getUniqueId } from 'utilities/src/device/getUniqueId'
import { datadogEnabled, localDevDatadogEnabled } from 'utilities/src/environment/constants'
import { logger } from 'utilities/src/logger/logger'
// In case Statsig is not available
const EXTENSION_DEFAULT_DATADOG_SESSION_SAMPLE_RATE = 10 // percent
export const enum DatadogAppNameTag {
Sidebar = 'sidebar',
Onboarding = 'onboarding',
ContentScript = 'content-script',
Background = 'background',
Popup = 'popup',
UnitagClaim = 'unitag-claim',
}
function beforeSend(event: RumEvent): boolean {
// otherwise DataDog will ignore error events
event.view.url = event.view.url.replace(/^chrome-extension:\/\/[a-z]{32}\//i, '')
if (event.error && event.type === 'error') {
if (event.error.source === 'console') {
return false
}
const ignoredErrors = getDynamicConfigValue<
DynamicConfigs.DatadogIgnoredErrors,
DatadogIgnoredErrorsConfigKey,
DatadogIgnoredErrorsValType
>(DynamicConfigs.DatadogIgnoredErrors, DatadogIgnoredErrorsConfigKey.Errors, [])
const ignoredError = ignoredErrors.find(({ messageContains }) => event.error?.message.includes(messageContains))
if (ignoredError && Math.random() > ignoredError.sampleRate) {
return false
}
Object.defineProperty(event.error, 'stack', {
value: event.error.stack?.replace(/chrome-extension:\/\/[a-z]{32}/gi, ''),
writable: false,
configurable: true,
})
}
return true
}
export async function initializeDatadog(appName: string): Promise<void> {
const datadogEnabled = Statsig.checkGate(getFeatureFlagName(FeatureFlags.Datadog))
logger.setWalletDatadogEnabled(datadogEnabled)
if (!datadogEnabled) {
return
}
const sessionSampleRate = getDynamicConfigValue<
DynamicConfigs.DatadogSessionSampleRate,
DatadogSessionSampleRateKey,
DatadogSessionSampleRateValType
>(
DynamicConfigs.DatadogSessionSampleRate,
DatadogSessionSampleRateKey.Rate,
EXTENSION_DEFAULT_DATADOG_SESSION_SAMPLE_RATE,
)
const sharedDatadogConfig = {
clientToken: config.datadogClientToken,
service: `extension-${getDatadogEnvironment()}`,
env: getDatadogEnvironment(),
version: process.env.VERSION,
trackingConsent: undefined,
}
datadogRum.init({
...sharedDatadogConfig,
applicationId: config.datadogProjectId,
sessionSampleRate: localDevDatadogEnabled ? 100 : sessionSampleRate,
sessionSampleRate: 100,
sessionReplaySampleRate: 0,
trackResources: true,
trackLongTasks: true,
trackUserInteractions: true,
enablePrivacyForActionName: true,
beforeSend,
beforeSend: (event) => {
// otherwise DataDog will ignore error events
event.view.url = event.view.url.replace(/^chrome-extension:\/\/[a-z]{32}\//i, '')
if (event.error && event.type === 'error') {
if (event.error.source === 'console') {
return false
}
const ignoredErrors = getDynamicConfigValue<
DynamicConfigs.DatadogIgnoredErrors,
DatadogIgnoredErrorsConfigKey,
DatadogIgnoredErrorsValType
>(DynamicConfigs.DatadogIgnoredErrors, DatadogIgnoredErrorsConfigKey.Errors, [])
const ignoredError = ignoredErrors.find(({ messageContains }) => event.error?.message.includes(messageContains))
if (ignoredError && Math.random() > ignoredError.sampleRate) {
return false
}
Object.defineProperty(event.error, 'stack', {
value: event.error.stack?.replace(/chrome-extension:\/\/[a-z]{32}/gi, ''),
writable: false,
configurable: true,
})
}
return true
},
})
// According to the Datadog RUM documentation:
// https://docs.datadoghq.com/real_user_monitoring/browser/setup/client?tab=rum#access-internal-context
// datadogRum.init() seems to be synchronous and internal context is immediately available.
// Local testing confirms this behavior, explaining why no "onInitialization" callback is needed.
const internalContext = datadogRum.getInternalContext()
const sessionIsSampled = internalContext?.session_id !== undefined
// we do not want to log anything if session is not sampled
if (sessionIsSampled) {
datadogLogs.init({
...sharedDatadogConfig,
site: 'datadoghq.com',
forwardErrorsToLogs: false,
})
logger.setWalletDatadogEnabled(true)
}
datadogLogs.init({
...sharedDatadogConfig,
site: 'datadoghq.com',
forwardErrorsToLogs: false,
})
try {
const userId = await getUniqueId()
......
......@@ -116,7 +116,7 @@ export default function AppRatingModal({ onClose }: AppRatingModalProps): JSX.El
}, [dispatch])
return (
<Modal isDismissible isModalOpen name={ModalName.AppRatingModal} backgroundColor="$surface1" onClose={close}>
<Modal isDismissible isModalOpen name={ModalName.TokenWarningModal} backgroundColor="$surface1" onClose={close}>
<TouchableArea p="$spacing16" position="absolute" right={0} top={0} zIndex={zIndices.default} onPress={close}>
<X color="$neutral2" size="$icon.20" />
</TouchableArea>
......
......@@ -70,8 +70,7 @@ export function ApproveRequestContent({
// To detect a revoke, both the transaction value and the parsed arg amount value must be zero
const isArgAmountZero = parsedTransactionData?.args.some(
(arg) =>
arg !== null && typeof arg === 'object' && !Array.isArray(arg) && arg._hex && BigNumber.from(arg._hex).isZero(),
(arg) => typeof arg === 'object' && arg._hex && BigNumber.from(arg._hex).isZero(),
)
const isRevoke = dappRequest.transaction.value === '0x0' && isArgAmountZero
......
......@@ -66,13 +66,6 @@ export function SignTypedDataRequestContent({ dappRequest }: SignTypedDataReques
message: EIP712Message | EIP712Message[keyof EIP712Message],
i = 1,
): Maybe<JSX.Element | JSX.Element[]> => {
if (message === null || message === undefined) {
return (
<Text color="$neutral1" variant="body4">
{String(message)}
</Text>
)
}
if (typeof message === 'string' && isAddress(message) && chainId) {
const href = getExplorerLink(chainId, message, ExplorerDataType.ADDRESS)
return <MaybeExplorerLinkedAddress address={message} link={href} />
......@@ -83,12 +76,6 @@ export function SignTypedDataRequestContent({ dappRequest }: SignTypedDataReques
{message.toString()}
</Text>
)
} else if (Array.isArray(message)) {
return (
<Text $platform-web={{ overflowWrap: 'anywhere' }} color="$neutral1" variant="body4">
{JSON.stringify(message)}
</Text>
)
} else if (typeof message === 'object') {
return Object.entries(message).map(([key, value], index) => (
<Flex key={`${key}-${index}`} flexDirection="row" gap="$spacing8">
......
......@@ -251,11 +251,6 @@ export function* handleSendTransaction(
options: { request: transactionRequest },
typeInfo: transactionTypeInfo ?? {
type: TransactionType.Unknown,
dappInfo: {
name: dappInfo.displayName,
address: request.transaction.to,
icon: dappInfo.iconUrl,
},
},
transactionOriginType: TransactionOriginType.External,
}
......
import { useCallback, useState } from 'react'
import { useCallback } from 'react'
import { UnitagClaimRoutes } from 'src/app/navigation/constants'
import { focusOrCreateUnitagTab } from 'src/app/navigation/utils'
import { Flex } from 'ui/src'
import { UnichainIntroModal } from 'uniswap/src/components/unichain/UnichainIntroModal'
import { PollingInterval } from 'uniswap/src/constants/misc'
import { AccountType } from 'uniswap/src/features/accounts/types'
import { UniverseChainId } from 'uniswap/src/features/chains/types'
import { CurrencyField } from 'uniswap/src/types/currency'
import { usePortfolioTotalValue } from 'uniswap/src/features/dataApi/balances'
import { IntroCardStack } from 'wallet/src/components/introCards/IntroCardStack'
import { useSharedIntroCards } from 'wallet/src/components/introCards/useSharedIntroCards'
import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext'
import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks'
export function HomeIntroCardStack(): JSX.Element | null {
const { navigateToSwapFlow } = useWalletNavigation()
const activeAccount = useActiveAccountWithThrow()
const isSignerAccount = activeAccount.type === AccountType.SignerMnemonic
const { data } = usePortfolioTotalValue({
address: activeAccount.address,
// Not needed often given usage, and will get updated from other sources
pollInterval: PollingInterval.Slow,
})
const navigateToUnitagClaim = useCallback(async () => {
await focusOrCreateUnitagTab(activeAccount.address, UnitagClaimRoutes.ClaimIntro)
}, [activeAccount.address])
const [showUnichainIntroModal, setShowUnichainIntroModal] = useState(false)
const { cards } = useSharedIntroCards({
showUnichainModal: () => setShowUnichainIntroModal(true),
navigateToUnitagClaim,
navigateToUnitagIntro: navigateToUnitagClaim, // No need to differentiate for extension
hasTokens: (data?.balanceUSD ?? 0) > 0,
})
// Don't show cards if there are none
......@@ -37,14 +37,6 @@ export function HomeIntroCardStack(): JSX.Element | null {
return (
<Flex py="$spacing4">
<IntroCardStack cards={cards} />
{showUnichainIntroModal && (
<UnichainIntroModal
openSwapFlow={() =>
navigateToSwapFlow({ openTokenSelector: CurrencyField.OUTPUT, outputChainId: UniverseChainId.Unichain })
}
onClose={() => setShowUnichainIntroModal(false)}
/>
)}
</Flex>
)
}
......@@ -6004,7 +6004,7 @@ exports[`ReceiveScreen renders without error 1`] = `
class="font_body _display-inline _boxSizing-border-box _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-neutral2 _fontFamily-f-family _wordWrap-break-word _lineHeight-f-lineHeigh1263414315 _textAlign-center _fontSize-f-size-micr111 _fontWeight-f-weight-bo3548"
data-disable-theme="true"
>
You can send and receive tokens and NFTs on all of our 13 supported networks.
You can send and receive tokens and NFTs on all of our 12 supported networks.
</span>
<span
class="t_sub_theme t_primary_Button _dsp_contents is_Theme"
......@@ -12040,7 +12040,7 @@ exports[`ReceiveScreen renders without error 1`] = `
class="font_body _display-inline _boxSizing-border-box _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-neutral2 _fontFamily-f-family _wordWrap-break-word _lineHeight-f-lineHeigh1263414315 _textAlign-center _fontSize-f-size-micr111 _fontWeight-f-weight-bo3548"
data-disable-theme="true"
>
You can send and receive tokens and NFTs on all of our 13 supported networks.
You can send and receive tokens and NFTs on all of our 12 supported networks.
</span>
<span
class="t_sub_theme t_primary_Button _dsp_contents is_Theme"
......
import { useCallback, useMemo, useRef } from 'react'
import { useSelector } from 'react-redux'
import { NavigationType, Outlet, ScrollRestoration, useLocation } from 'react-router-dom'
import { Outlet, ScrollRestoration, useLocation } from 'react-router-dom'
import { DappRequestQueue } from 'src/app/features/dappRequests/DappRequestQueue'
import { HomeScreen } from 'src/app/features/home/HomeScreen'
import { Locked } from 'src/app/features/lockScreen/Locked'
......@@ -84,7 +84,7 @@ export function WebNavigation(): JSX.Element {
const routerState = useRouterState()
if (routeName != null) {
towards = routeDirections[routeName]
const isBackwards = routerState?.historyAction === NavigationType.Pop
const isBackwards = routerState?.historyAction === 'POP'
if (isBackwards) {
const lastRoute = getAppRouteFromPathName(history[1] || '')
const previousDirection = lastRoute ? routeDirections[lastRoute] : 'right'
......
import { RouterState } from '@sentry/react/types/types'
import { useEffect, useState } from 'react'
import { Location, NavigationType, Router, createHashRouter } from 'react-router-dom'
interface RouterState {
historyAction: NavigationType
location: Location
}
import { Router } from 'react-router-dom'
import { sentryCreateHashRouter } from 'src/app/sentry'
/**
* Note this file is separate from SidebarApp on purpose!
......@@ -57,7 +54,7 @@ export function useRouterState(): RouterState | null {
}
// as far as i can tell, react-router-dom doesn't give us this type so have to work around
type Router = ReturnType<typeof createHashRouter>
type Router = ReturnType<typeof sentryCreateHashRouter>
let router: Router | null = null
......
import * as SentryBrowser from '@sentry/browser'
import * as Sentry from '@sentry/react'
import { setTag } from '@sentry/react'
import { useEffect } from 'react'
import {
createHashRouter,
createRoutesFromChildren,
matchRoutes,
useLocation,
useNavigationType,
} from 'react-router-dom'
import { getSentryEnvironment } from 'src/app/version'
import { config } from 'uniswap/src/config'
import { logger } from 'utilities/src/logger/logger'
import { beforeSend } from 'wallet/src/utils/sentry'
export const enum SentryAppNameTag {
Sidebar = 'sidebar',
Onboarding = 'onboarding',
ContentScript = 'content-script',
Background = 'background',
Popup = 'popup',
UnitagClaim = 'unitag-claim',
}
export function initializeSentry(appNameTag: SentryAppNameTag, sentryUserId: string): void {
if (__DEV__) {
return
}
Sentry.init({
environment: getSentryEnvironment(),
dsn: config.sentryDsn,
release: process.env.VERSION,
integrations: [
new Sentry.BrowserTracing({
// See docs for support of different versions of variation of react router
// https://docs.sentry.io/platforms/javascript/guides/react/configuration/integrations/react-router/
routingInstrumentation: Sentry.reactRouterV6Instrumentation(
useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
),
}),
],
beforeSend,
...sentrySampleRateOptions,
})
setTag('appName', appNameTag)
Sentry.setUser({ id: sentryUserId })
}
export function initSentryForBrowserScripts(appNameTag: SentryAppNameTag, sentryUserId: string): void {
if (__DEV__) {
return
}
// Wrapped in try/catch because in this context it can fail silently
try {
SentryBrowser.init({
environment: getSentryEnvironment(),
dsn: config.sentryDsn,
release: process.env.VERSION,
// TODO (EXT-528): Look into adding tracing integration
beforeSend,
...sentrySampleRateOptions,
})
} catch (e) {
logger.debug('sentry.ts', 'initSentryForBrowserScripts', 'Error in Sentry init', e)
}
setTag('appName', appNameTag)
if (sentryUserId) {
SentryBrowser.setUser({ id: sentryUserId })
}
}
const sentrySampleRateOptions = {
// Set tracesSampleRate to 1.0 to capture 100%
// of transactions for performance monitoring.
tracesSampleRate: 1.0,
// Capture Replay for 10% of all sessions,
// plus for 100% of sessions with an error
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
}
export const sentryCreateHashRouter = Sentry.wrapCreateBrowserRouter(createHashRouter)
......@@ -14,6 +14,16 @@ export function getStatsigEnvironmentTier(): StatsigEnvironmentTier {
return StatsigEnvironmentTier.PROD
}
export function getSentryEnvironment(): SentryEnvironment {
if (isDevEnv()) {
return SentryEnvironment.DEV
}
if (isBetaEnv()) {
return SentryEnvironment.BETA
}
return SentryEnvironment.PROD
}
export function getDatadogEnvironment(): DatadogEnvironment {
if (isDevEnv()) {
return DatadogEnvironment.DEV
......@@ -29,3 +39,9 @@ enum DatadogEnvironment {
BETA = 'beta',
PROD = 'prod',
}
enum SentryEnvironment {
DEV = 'development',
BETA = 'beta',
PROD = 'production',
}
......@@ -2,6 +2,7 @@ import 'symbol-observable' // Needed by `reduxed-chrome-storage` as polyfill, or
import { initStatSigForBrowserScripts } from 'src/app/StatsigProvider'
import { focusOrCreateOnboardingTab } from 'src/app/navigation/utils'
import { SentryAppNameTag, initSentryForBrowserScripts } from 'src/app/sentry'
import { initExtensionAnalytics } from 'src/app/utils/analytics'
import { initMessageBridge } from 'src/background/backgroundDappRequests'
import { backgroundStore } from 'src/background/backgroundStore'
......@@ -9,6 +10,7 @@ import { backgroundToSidePanelMessageChannel } from 'src/background/messagePassi
import { BackgroundToSidePanelRequestType } from 'src/background/messagePassing/types/requests'
import { setSidePanelBehavior, setSidePanelOptions } from 'src/background/utils/chromeSidePanelUtils'
import { readIsOnboardedFromStorage } from 'src/background/utils/persistedStateUtils'
import { getUniqueId } from 'utilities/src/device/getUniqueId'
import { logger } from 'utilities/src/logger/logger'
export const EXTENSION_ID = chrome.runtime.id
......@@ -16,6 +18,8 @@ export const EXTENSION_ID = chrome.runtime.id
initMessageBridge()
async function initApp(): Promise<void> {
const userId = await getUniqueId()
initSentryForBrowserScripts(SentryAppNameTag.Background, userId)
await initStatSigForBrowserScripts()
await initExtensionAnalytics()
......
......@@ -250,7 +250,7 @@ async function logError(
await contentScriptUtilityMessageChannel.sendMessage(message)
}
// These go to Amplitude instead of Datadog since they are informational
// These go to Amplitude instead of Sentry since they are informational
async function passAnalytics(message: string, tags: Record<string, string>): Promise<void> {
const logMessage: AnalyticsLog = {
type: ContentScriptUtilityMessageType.AnalyticsLog,
......
......@@ -4,13 +4,24 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import OnboardingApp from 'src/app/OnboardingApp'
import { initializeSentry, SentryAppNameTag } from 'src/app/sentry'
import { initializeReduxStore } from 'src/store/store'
import { ExtensionAppLocation, StoreSynchronization } from 'src/store/storeSynchronization'
import { getUniqueId } from 'utilities/src/device/getUniqueId'
import { logger } from 'utilities/src/logger/logger'
;(globalThis as any).regeneratorRuntime = undefined // eslint-disable-line @typescript-eslint/no-explicit-any
// The globalThis.regeneratorRuntime = undefined addresses a potentially unsafe-eval problem
// see https://github.com/facebook/regenerator/issues/378#issuecomment-802628326
getUniqueId()
.then((userId) => {
initializeSentry(SentryAppNameTag.Onboarding, userId)
})
.catch((error) => {
logger.error(error, {
tags: { file: 'SidebarApp.tsx', function: 'getUniqueId' },
})
})
async function initOnboarding(): Promise<void> {
await initializeReduxStore()
......
......@@ -4,12 +4,23 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import PopupApp from 'src/app/PopupApp'
import { initializeSentry, SentryAppNameTag } from 'src/app/sentry'
import { initializeReduxStore } from 'src/store/store'
import { getUniqueId } from 'utilities/src/device/getUniqueId'
import { logger } from 'utilities/src/logger/logger'
;(globalThis as any).regeneratorRuntime = undefined // eslint-disable-line @typescript-eslint/no-explicit-any
// The globalThis.regeneratorRuntime = undefined addresses a potentially unsafe-eval problem
// see https://github.com/facebook/regenerator/issues/378#issuecomment-802628326
getUniqueId()
.then((userId) => {
initializeSentry(SentryAppNameTag.Popup, userId)
})
.catch((error) => {
logger.error(error, {
tags: { file: 'popup.tsx', function: 'getUniqueId' },
})
})
async function initPopup(): Promise<void> {
await initializeReduxStore({ readOnly: true })
......
......@@ -4,12 +4,23 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import UnitagClaimApp from 'src/app/UnitagClaimApp'
import { SentryAppNameTag, initializeSentry } from 'src/app/sentry'
import { initializeReduxStore } from 'src/store/store'
import { getUniqueId } from 'utilities/src/device/getUniqueId'
import { logger } from 'utilities/src/logger/logger'
;(globalThis as any).regeneratorRuntime = undefined // eslint-disable-line @typescript-eslint/no-explicit-any
// The globalThis.regeneratorRuntime = undefined addresses a potentially unsafe-eval problem
// see https://github.com/facebook/regenerator/issues/378#issuecomment-802628326
getUniqueId()
.then((userId) => {
initializeSentry(SentryAppNameTag.UnitagClaim, userId)
})
.catch((error) => {
logger.error(error, {
tags: { file: 'unitagClaim.tsx', function: 'getUniqueId' },
})
})
async function initUnitagClaim(): Promise<void> {
await initializeReduxStore({ readOnly: true })
......
......@@ -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.15.0",
"version": "1.13.0",
"minimum_chrome_version": "116",
"icons": {
"16": "assets/icon16.png",
......
import { RankingType } from 'uniswap/src/data/types'
import { RankingType } from 'wallet/src/features/wallet/types'
// only add fields that are persisted
export const initialSchema = {
......
import { createReduxEnhancer } from '@sentry/react'
import { PreloadedState } from 'redux'
import { persistReducer, persistStore } from 'redux-persist'
import { localStorage } from 'redux-persist-webextension-storage'
......@@ -26,6 +27,18 @@ const persistConfig = {
const persistedReducer = enhancePersistReducer(persistReducer(persistConfig, extensionReducer))
const sentryReduxEnhancer = createReduxEnhancer({
// TODO(EXT-1022): uncomment this once we add an analytics opt-out setting.
// stateTransformer: (state: WebState): Maybe<WebState> => {
// Do not log the state if a user has opted out of analytics.
// if (state.telemetry.allowAnalytics) {
// return state
// } else {
// return null
// }
// },
})
const dataDogReduxEnhancer = createDatadogReduxEnhancer({
shouldLogReduxState: (state: ExtensionState): boolean => {
// Do not log the state if a user has opted out of analytics.
......@@ -40,7 +53,7 @@ const setupStore = (preloadedState?: PreloadedState<ExtensionState>): ReturnType
additionalSagas: [rootExtensionSaga],
middlewareBefore: __DEV__ ? [loggerMiddleware] : [],
middlewareAfter: [fiatOnRampAggregatorApi.middleware],
enhancers: [dataDogReduxEnhancer],
enhancers: [sentryReduxEnhancer, dataDogReduxEnhancer],
})
}
......
......@@ -7,6 +7,7 @@ const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'
const fs = require('fs')
const DotenvPlugin = require('dotenv-webpack')
const NodePolyfillPlugin = require('node-polyfill-webpack-plugin')
const { sentryWebpackPlugin } = require('@sentry/webpack-plugin')
const NODE_ENV = process.env.NODE_ENV || 'development'
const POLL_ENV = process.env.WEBPACK_POLLING_INTERVAL
......@@ -355,6 +356,12 @@ module.exports = (env) => {
},
],
}),
sentryWebpackPlugin({
authToken: env.SENTRY_AUTH_TOKEN,
org: 'uniswap-labs',
project: 'extension-wallet',
telemetry: process.env.NODE_ENV === 'production',
}),
],
...extras,
}
......
/** @type {Detox.DetoxConfig} */
module.exports = {
testRunner: {
args: {
'$0': 'jest',
config: 'e2e/jest.config.js'
},
jest: {
setupTimeout: 300000
}
},
apps: {
'ios.debug': {
type: "ios.app",
binaryPath: "ios/build/Build/Products/Debug-iphonesimulator/Uniswap.app",
build: "RN_SRC_EXT=e2e.js,e2e.ts xcodebuild -workspace ios/Uniswap.xcworkspace -scheme Uniswap -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build -UseModernBuildSystem=YES -arch x86_64"
},
'ios.release': {
type: 'ios.app',
binaryPath: "ios/build/Build/Products/Dev-iphonesimulator/Uniswap.app",
build: "RN_SRC_EXT=e2e.js,e2e.ts xcodebuild -workspace ios/Uniswap.xcworkspace -scheme Uniswap -configuration Dev -sdk iphonesimulator -derivedDataPath ios/build -UseModernBuildSystem=YES -arch x86_64"
},
'android.debug': {
type: 'android.apk',
binaryPath: 'android/app/build/outputs/apk/dev/debug/app-dev-debug.apk',
testBinaryPath: "android/app/build/outputs/apk/androidTest/dev/debug/app-dev-debug-androidTest.apk",
build: 'cd android && ./gradlew assembleDevDebug assembleAndroidTest -DtestBuildType=debug',
reversePorts: [
8081
]
},
'android.release': {
type: 'android.apk',
binaryPath: 'android/app/build/outputs/apk/dev/release/app-dev-release.apk',
testBinaryPath: "android/app/build/outputs/apk/androidTest/dev/release/app-dev-release-androidTest.apk",
build: 'cd android && ./gradlew assembleDevRelease assembleAndroidTest -DtestBuildType=release'
}
},
devices: {
simulator: {
type: 'ios.simulator',
device: {
type: "iPhone 15"
}
},
attached: {
type: 'android.attached',
device: {
adbName: '.*'
}
},
emulator: {
type: 'android.emulator',
device: {
avdName: 'Pixel_6_API_34'
}
}
},
configurations: {
"ios.sim.debug": {
device: "simulator",
app: "ios.debug"
},
"ios.sim.release": {
device: "simulator",
app: "ios.release"
},
"android.emu.debug": {
device: "emulator",
app: "android.debug"
},
"android.emu.release": {
device: "emulator",
app: "android.release"
}
}
};
baselineBranch: main
executionOrder:
continueOnFailure: true
appId: com.uniswap.mobile.dev
---
- runFlow: subflows/start.yaml
- tapOn: 'Create a wallet'
- waitForAnimationToEnd
- tapOn: 'Skip' # unitag
- waitForAnimationToEnd
- tapOn: 'Skip' # notifications
- waitForAnimationToEnd
- tapOn: 'Skip' # faceid
- runFlow: subflows/biometrics-confirm.yaml
# home screen
appId: com.uniswap.mobile.dev
env:
E2E_RECOVERY_PHRASE: ${E2E_RECOVERY_PHRASE}
---
- tapOn: 'Add an existing wallet'
- launchApp:
appId: 'com.uniswap.mobile.dev'
clearState: true
clearKeychain: true # optional: clear *entire* iOS keychain
- extendedWaitUntil:
visible: 'Create a wallet'
- tapOn: 'Create a wallet'
- waitForAnimationToEnd
- tapOn: 'Import a wallet'
- waitForAnimationToEnd
- inputText: ${E2E_RECOVERY_PHRASE}
- waitForAnimationToEnd
- tapOn: 'Continue'
- waitForAnimationToEnd
- tapOn: 'Continue'
- tapOn: 'Skip'
- waitForAnimationToEnd
- tapOn: 'Skip'
- waitForAnimationToEnd
- tapOn: 'Skip'
- runFlow: biometrics-confirm.yaml
- extendedWaitUntil:
visible:
id: 'confirm'
- tapOn:
id: 'confirm'
- waitForAnimationToEnd
- tapOn: 'Send'
- waitForAnimationToEnd
- 'back'
appId: com.uniswap.mobile.dev
---
- extendedWaitUntil:
visible:
id: 'confirm'
timeout: 10000
- tapOn:
id: 'confirm' # are you sure?
- waitForAnimationToEnd
appId: com.uniswap.mobile.dev
---
- launchApp:
permissions:
contacts: unset
notifications: unset
clearState: true
clearKeychain: true # optional: clear *entire* iOS keychain
appId: com.uniswap.mobile.dev
env:
E2E_RECOVERY_PHRASE: ${E2E_RECOVERY_PHRASE}
---
- runFlow: subflows/start.yaml
- runFlow: subflows/recover-fast.yaml
# Start of swap flow
- tapOn:
id: 'swap'
- tapOn:
id: 'choose-output-token-label'
- tapOn:
id: 'explore-search-input'
- inputText: btc
- tapOn:
id: 'token-option-8453-cbBTC'
- tapOn: '0 cbBTC'
- tapOn:
id: 'decimal-pad-0'
- tapOn:
id: 'decimal-pad-decimal'
- tapOn:
id: 'decimal-pad-0'
- tapOn:
id: 'decimal-pad-0'
- tapOn:
id: 'decimal-pad-0'
- tapOn:
id: 'decimal-pad-0'
- tapOn:
id: 'decimal-pad-0'
- tapOn:
id: 'decimal-pad-1'
- tapOn:
id: 'review-swap'
- tapOn: Show more
- tapOn:
id: 'swap'
- extendedWaitUntil:
visible: 'Swapped'
timeout: 10000
......@@ -150,7 +150,7 @@ Note: If you are indeed using an Apple Silicon Mac, we recommend setting up your
You should start with downloading Xcode if you don't already have it installed, since the file is so large. You can find it here: [developer.apple.com/xcode](https://developer.apple.com/xcode/)
You must use the [Required Xcode Version](https://github.com/Uniswap/universe/blob/main/apps/mobile/scripts/podinstall.sh#L5) to compile the app. [Older versions of xCode can be found here](https://developer.apple.com/download/all/?q=xcode).
You must use **XCode 15** to compile the app. [Older versions of xCode can be found here](https://developer.apple.com/download/all/?q=xcode).
#### Add Xcode Command Line Tools
......
......@@ -63,10 +63,10 @@ def reactNativeArchitectures() {
}
boolean isCI = System.getenv('CI') != null
boolean isE2E = System.getenv('E2E_MODE') != null
boolean isDetox = System.getenv('DETOX_MODE') != null
boolean sentryPropertiesAvailable = System.getenv('SENTRY_AUTH_TOKEN') != null && System.getenv('SENTRY_PROJECT') != null && System.getenv('SENTRY_ORG') != null
if (isCI && sentryPropertiesAvailable && !isE2E) {
if (isCI && sentryPropertiesAvailable && !isDetox) {
project.ext.sentryCli = [
logLevel: "info",
]
......@@ -85,13 +85,13 @@ if (isCI && sentryPropertiesAvailable && !isE2E) {
boolean datadogPropertiesAvailable = System.getenv('DATADOG_API_KEY') != null
if (isCI && datadogPropertiesAvailable && !isE2E) {
if (isCI && datadogPropertiesAvailable && !isDetox) {
apply from: "../../../../node_modules/@datadog/mobile-react-native/datadog-sourcemaps.gradle"
}
def devVersionName = "1.45"
def betaVersionName = "1.45"
def prodVersionName = "1.45"
def devVersionName = "1.43.1"
def betaVersionName = "1.43.1"
def prodVersionName = "1.43.1"
android {
ndkVersion rootProject.ext.ndkVersion
......@@ -104,6 +104,8 @@ android {
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
splits {
abi {
......@@ -249,20 +251,14 @@ dependencies {
implementation("androidx.core:core-performance:$corePerf")
implementation("androidx.core:core-performance-play-services:$corePerf")
implementation 'com.onesignal:OneSignal:4.8.9'
implementation 'com.github.statsig-io:android-sdk:4.36.0'
// This is required for the backported AndroidX Photo Picker on versions of Android below 30
implementation("androidx.activity:activity:1.9.+")
// For animated GIF support
implementation 'com.facebook.fresco:animated-gif:3.6.0'
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
implementation jscFlavor
}
androidTestImplementation('com.wix:detox:+') {
exclude module: "protobuf-lite"
}
}
apply from: file("${nodeModulesPath}/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)
package com.uniswap;
import com.wix.detox.Detox;
import com.wix.detox.config.DetoxConfig;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import androidx.test.rule.ActivityTestRule;
@RunWith(AndroidJUnit4.class)
@LargeTest
public class DetoxTest {
@Rule
public ActivityTestRule<MainActivity> mActivityRule = new ActivityTestRule<>(MainActivity.class, false, false);
@Test
public void runDetoxTests() {
DetoxConfig detoxConfig = new DetoxConfig();
detoxConfig.idlePolicyConfig.masterTimeoutSec = 90;
detoxConfig.idlePolicyConfig.idleResourceTimeoutSec = 60;
detoxConfig.rnContextLoadTimeoutSec = (BuildConfig.DEBUG ? 180 : 60);
Detox.runTests(mActivityRule, detoxConfig);
}
}
......@@ -5,6 +5,7 @@
<application
android:usesCleartextTraffic="true"
tools:ignore="GoogleAppIndexingWarning"
tools:targetApi="28">
tools:targetApi="28"
android:networkSecurityConfig="@xml/network_security_config">
</application>
</manifest>
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">localhost</domain>
</domain-config>
</network-security-config>
......@@ -9,10 +9,6 @@
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="com.google.android.gms.permission.AD_ID"/>
<!-- This permission which may be added by expo modules. Unless it's used app-wide, should not be included in our app per Play Store rules -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove" />
<application
android:name=".MainApplication"
android:label="@string/app_name"
......@@ -27,17 +23,6 @@
android:taskAffinity=""
android:excludeFromRecents="true">
<!-- Trigger Google Play services to install the backported photo picker module. -->
<service android:name="com.google.android.gms.metadata.ModuleDependencies"
android:enabled="false"
android:exported="false"
tools:ignore="MissingClass">
<intent-filter>
<action android:name="com.google.android.gms.metadata.MODULE_DEPENDENCIES" />
</intent-filter>
<meta-data android:name="photopicker_activity:0:required" android:value="" />
</service>
<meta-data
android:name="com.onesignal.messaging.default_notification_icon"
android:resource="@drawable/ic_stat_onesignal_default" />
......@@ -46,9 +31,6 @@
android:name="com.onesignal.NotificationAccentColor.DEFAULT"
android:value="@string/notification_accent_color" />
<meta-data android:name="com.onesignal.NotificationServiceExtension"
android:value="com.uniswap.notifications.NotificationExtension" />
<activity
android:name=".MainActivity"
android:label="@string/app_name"
......
package com.uniswap.notifications
import android.app.Application
import android.content.Context
import android.provider.Settings.Secure
import com.onesignal.OSNotificationReceivedEvent
import com.onesignal.OneSignal.OSRemoteNotificationReceivedHandler
import com.statsig.androidsdk.Statsig
import com.statsig.androidsdk.StatsigOptions
import com.statsig.androidsdk.StatsigUser
import com.uniswap.BuildConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* OneSignal extension used to intercept notifications, integrating with Statsig to gate and expose
* test groups.
*/
class NotificationExtension : OSRemoteNotificationReceivedHandler {
private val scope = CoroutineScope(Dispatchers.IO)
override fun remoteNotificationReceived(
context: Context?,
notificationReceivedEvent: OSNotificationReceivedEvent
) {
val notification = notificationReceivedEvent.notification
val additionalData = notification.additionalData
val notificationType = if (additionalData.has(FIELD_NOTIFICATION_TYPE)) additionalData.getString(FIELD_NOTIFICATION_TYPE) else null
val isGatedNotification = notificationType == TYPE_UNFUNDED_WALLET_REMINDER ||
notificationType == TYPE_PRICE_ALERT
if (isGatedNotification) {
scope.launch(Dispatchers.IO) {
if (!Statsig.isInitialized()) {
val options = StatsigOptions(api = STATSIG_PROXY_URL, eventLoggingAPI = STATSIG_PROXY_URL).apply {
setEnvironmentParameter(STATSIG_ENVIRONMENT_KEY_TIER, getStatsigTier())
}
val deviceId = Secure.getString(context!!.contentResolver, Secure.ANDROID_ID)
val user = StatsigUser(userID = deviceId)
user.custom = mapOf("app" to "mobile")
Statsig.initialize(
context!!.applicationContext as Application,
STATSIG_SDK_KEY,
user,
options
)
}
val enabled = when(notificationType) {
TYPE_UNFUNDED_WALLET_REMINDER -> Statsig.checkGate(FEATURE_GATE_UNFUNDED_WALLET)
TYPE_PRICE_ALERT -> Statsig.checkGate(FEATURE_GATE_PRICE_ALERT)
else -> true
}
// Passing null will skip the notification
notificationReceivedEvent.complete(if (enabled) notification else null)
}
} else {
notificationReceivedEvent.complete(notification)
}
}
private fun getStatsigTier(): String = when(BuildConfig.FLAVOR) {
"dev" -> "development"
"beta" -> "beta"
"prod" -> "production"
else -> "production"
}
companion object {
// fake value that gets replaced by the proxy
private const val STATSIG_SDK_KEY = "client-000000000000000000000000000000000000000000"
private const val STATSIG_PROXY_URL =
"https://gating.android.wallet.gateway.uniswap.org/v1/statsig-proxy"
private const val STATSIG_ENVIRONMENT_KEY_TIER = "tier"
private const val FEATURE_GATE_UNFUNDED_WALLET = "notification_unfunded_wallet"
private const val FEATURE_GATE_PRICE_ALERT = "notification_price_alert"
private const val FIELD_NOTIFICATION_TYPE = "notification_type"
private const val TYPE_UNFUNDED_WALLET_REMINDER = "unfunded_wallet_reminder"
private const val TYPE_PRICE_ALERT = "price_alert"
}
}
......@@ -8,7 +8,6 @@
<locale android:name="fr"/>
<locale android:name="ja"/>
<locale android:name="pt"/>
<locale android:name="vi"/>
<locale android:name="es-ES"/>
<locale android:name="es-US"/>
<locale android:name="es-419"/>
......
......@@ -19,7 +19,6 @@ buildscript {
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
dependencies {
classpath("com.android.tools.build:gradle")
......@@ -37,6 +36,11 @@ plugins {
}
allprojects {
repositories {
maven {
url = rootProject.file("../../../node_modules/detox/Detox-android")
}
}
project.pluginManager.withPlugin("com.facebook.react") {
react {
reactNativeDir = rootProject.file("../../../node_modules/react-native/")
......
......@@ -8,3 +8,6 @@ apply from: new File(["node", "--print", "require.resolve('../../../node_modules
useExpoModules()
include ':@sentry_react-native'
include ':detox'
project(':detox').projectDir = new File('../../../node_modules/detox/android/detox')
import { HomeBasicInteractions } from 'e2e/usecases/home/HomeBasicInteractions'
import { WatchWallet } from 'e2e/usecases/onboarding/WatchWallet'
describe('Home', () => {
beforeEach(async () => {
await device.launchApp()
await WatchWallet()
})
it('tests basic home screen interactions', HomeBasicInteractions)
})
import { CreateNewWallet } from 'e2e/usecases/onboarding/CreateNewWallet'
import { ImportWallet } from 'e2e/usecases/onboarding/ImportWallet'
import { WatchWallet } from 'e2e/usecases/onboarding/WatchWallet'
describe('Onboarding', () => {
beforeEach(async () => {
await device.launchApp({ newInstance: true })
})
afterEach(async () => {
await device.clearKeychain()
await device.uninstallApp()
await device.installApp()
})
it('creates a new wallet', CreateNewWallet)
it('watches wallet', WatchWallet)
it('imports a testing wallet using recovery phrase', ImportWallet)
})
# E2E Tests
The e2e tests use [detox](https://github.com/wix/Detox).
## Running tests
### iOS
Detox environment requires installation of the same environment as the main iOS application and additionally the iPhone 15 simulator.
The choice of this simulator is hardcoded in order to reflect e2e environment setup and is dictated by the github actions virtual machine on which the e2e tests will take place.
#### Debug mode
To run tests in debug mode, run bundler:
```
yarn mobile e2e:packager
```
Build debug testing app:
```
yarn mobile e2e:ios:build:debug
```
Run ios e2e tests in debug mode:
```
yarn mobile e2e:ios:test:debug
```
Useful perameters:
`--testNamePattern test-name` to run a single test, replace `test-name` with test file name without extension e.g.: `Swap` or `Onboarding`.
`--reuse` to start the test from a current app state. Useful for testing nested screen behaviour without going through onboarding and navigation steps.
#### Release mode
To run tests in release mode:
```
yarn mobile e2e:ios:test:release
```
It builds and runs tests in one go.
## Mocking
E2E tests should remain as close as possible to production, but sometimes mocking is necessary.
Only mocking entire files is supported at the moment, so you may need to reorganize functions. To mock a file, create a new one with the same name and extension `mock.ts` (e.g. `AnimatedHeader.ts` -> `AnimatedHeader.mock.ts`) in the same directory. The metro bundler will override any file that has a `mock.ts` equivalent in Detox runs.
Android native views based on jetpack compose and libraries utilizing long-running asynchronouse background processes like sentry are not supported by detox currently. Imports mocking is unfortunatelly not supported by detox yet. If such problems occur, the entire component using problematic library needs to be mocked or a component exposing only targeted library needs to be created and then it can be mocked, precisely replacing only targeted library.
To mock a component for specific platform follow this pattern:
iOS: `AnimatedHeader.ts` -> `AnimatedHeader.ios.mock.ts`
Android: `AnimatedHeader.ts` -> `AnimatedHeader.android.mock.ts`
Read more here https://wix.github.io/Detox/docs/guide/mocking/
import { WatchWallet } from 'e2e/usecases/onboarding/WatchWallet'
import { SwapBasicInteractions } from 'e2e/usecases/swap/SwapBasicInteractions'
describe('Swap', () => {
beforeAll(async () => {
await device.launchApp({ newInstance: true })
await WatchWallet()
})
it('tests swap screen interactions', SwapBasicInteractions)
})
import { WatchWallet } from 'e2e/usecases/onboarding/WatchWallet'
import { TokenDetailsBasicInteractions } from 'e2e/usecases/tokenDetails/TokenDetailsBasicInteractions'
describe('TokenDetails', () => {
beforeAll(async () => {
await device.launchApp({ newInstance: true })
await WatchWallet()
})
it('tests token details screen interactions', TokenDetailsBasicInteractions)
})
import { device } from 'detox'
import { permissions } from './utils/fixtures'
beforeAll(async () => {
await device.installApp()
await device.launchApp({
newInstance: false,
permissions,
})
})
/** @type {import('@jest/types').Config.InitialOptions} */
module.exports = {
rootDir: '..',
testMatch: ['<rootDir>/e2e/**/*.e2e.ts'],
testTimeout: 240000,
maxWorkers: 1,
globalSetup: 'detox/runners/jest/globalSetup',
globalTeardown: 'detox/runners/jest/globalTeardown',
reporters: ['detox/runners/jest/reporter'],
testEnvironment: 'detox/runners/jest/testEnvironment',
verbose: true,
moduleDirectories: ['node_modules', '<rootDir>']
};
import { by, element, expect } from 'detox'
import { TestWatchedWallet } from 'e2e/utils/fixtures'
import { TestID } from 'uniswap/src/test/fixtures/testIDs'
export async function HomeBasicInteractions(): Promise<void> {
await expect(element(by.text(TestWatchedWallet.displayName))).toBeVisible()
await expect(element(by.id(TestID.Swap))).toBeVisible()
await expect(element(by.id(TestID.SearchTokensAndWallets))).toBeVisible()
// opens AccountSwitcherModal by clicking on account avatar
await expect(element(by.id(TestID.AccountHeaderAvatar))).toBeVisible()
// checks if portfolio balance is visible
await expect(element(by.id(TestID.PortfolioBalance))).toBeVisible()
// copies wallet address from AccountSwitcherModal
await element(by.id(TestID.AccountHeaderCopyAddress)).tap()
// checks if notification toast is visible with title "Address copied"
await expect(element(by.id(TestID.NotificationToastTitle))).toBeVisible()
await expect(element(by.id(TestID.NotificationToastTitle))).toHaveText('Address copied')
// checks if list was rendered properly by checking if the first item is visible
await expect(element(by.id('token-list-item-0'))).toBeVisible()
// scrolls to the bottom of the token list
await element(by.id('token-list-item-0')).swipe('up')
// checks if only tabs headers are visible then scrolled to bottom
await expect(element(by.id(TestID.AccountHeaderAvatar))).not.toBeVisible()
await expect(element(by.id(TestID.PortfolioBalance))).not.toBeVisible()
// for some reason react-native-tab-view renders headers twice, thats why first matching item was picked
await expect(element(by.id('home-tab-Tokens')).atIndex(0)).toBeVisible()
await expect(element(by.id('home-tab-NFTs')).atIndex(0)).toBeVisible()
await expect(element(by.id('home-tab-Activity')).atIndex(0)).toBeVisible()
// checks if the first item of hidden list is not visible
await expect(element(by.id('token-list-item-0'))).not.toBeVisible()
// hidden item does not exist
await expect(element(by.id('token-list-item-25'))).not.toExist()
// taps on "show" button to show hidden elements
await element(by.id(TestID.ShowHiddenTokens)).tap()
// checks if first hidden element is visible
await expect(element(by.id('token-list-item-25'))).toExist()
// taps on "hide" button to show hidden elements
await element(by.id(TestID.ShowHiddenTokens)).tap()
// checks if first item of the hidden item is not visible again
await expect(element(by.id('token-list-item-25'))).not.toExist()
// switches to NFTs tab
await element(by.id('home-tab-NFTs')).atIndex(0).tap()
// checks is if tokens are visible
await expect(element(by.id('nfts-list-item-0'))).toBeVisible()
// switches to Activity tab
await element(by.id('home-tab-Activity')).atIndex(0).tap()
// checks is if tokens are visible
await expect(element(by.id('activity-list-item-0'))).toBeVisible()
// switches back to tokens tab
await element(by.id('home-tab-Tokens')).atIndex(0).tap()
// scrolls to the bottom of the token list
await element(by.id('token-list-item-16')).swipe('down')
// checks if list of tokens was rendered properly by checking first token visibility
await expect(element(by.id('token-list-item-0'))).toBeVisible()
}
import { by, element, expect } from 'detox'
import { TestWallet } from 'e2e/utils/fixtures'
import { TestID } from 'uniswap/src/test/fixtures/testIDs'
export async function CreateNewWallet(): Promise<void> {
// Selects "Create a new wallet" option on the landing screen
await element(by.id(TestID.CreateAccount)).tap()
// Skips unitag flow
await element(by.id(TestID.Skip)).tap()
// Taps "Let's keep it safe" on QRAnimation screen
await element(by.id(TestID.Next)).tap()
// Check is both manual and cloud backup options are available on BackupScreen
await expect(element(by.id(TestID.AddCloudBackup))).toBeVisible()
await expect(element(by.id(TestID.AddManualBackup))).toBeVisible()
// Picks "Manual backup" option
await element(by.id(TestID.AddManualBackup)).tap()
// Checks if ManualBackupScreen warning displays and taps "I'm ready" button
await expect(element(by.id(TestID.Confirm))).toBeVisible()
await element(by.id(TestID.Confirm)).tap()
// Taps continue on ManualBackupScreen
await element(by.id(TestID.Next)).tap()
// Taps continue on manual backup confirmation screen. It is replaced by mock because detox
// can't interact with native screens
await element(by.id(TestID.Continue)).tap()
// Skips notification setup by tapping "Maybe later" button
await element(by.id(TestID.Skip)).tap()
// Skips biometrics setup by tapping "Maybe later" button
await element(by.id(TestID.Skip)).tap()
// Confirms by tapping "Skip" on warning modal
await element(by.id(TestID.Confirm)).tap()
// Confirms if user successfuly finished create new wallet flow by checking if provided wallet name is
// displayed and other
await expect(element(by.text(TestWallet.name))).toBeVisible()
await expect(element(by.id(TestID.Swap))).toBeVisible()
await expect(element(by.id(TestID.SearchTokensAndWallets))).toBeVisible()
}
import { by, element, expect } from 'detox'
import { TestWallet } from 'e2e/utils/fixtures'
import { TestID } from 'uniswap/src/test/fixtures/testIDs'
export async function ImportWallet(): Promise<void> {
// Selects "Add an existing wallet" option on the landing screen
await element(by.id(TestID.ImportAccount)).tap()
// Picks Import a wallet by recovery phase option
await element(by.id(TestID.OnboardingImportSeedPhrase)).tap()
// Checks if recovery phase input is in focus and types recovery phrase in
await element(by.id(TestID.ImportAccountInput)).typeText(TestWallet.recoveryPhrase)
// Taps continue navigating to SelectWalletScreen
await element(by.id(TestID.Continue)).tap()
// Taps continue on SelectWalletScreen
await waitFor(element(by.id(`${TestID.WalletCard}-1`)))
.toBeVisible()
.withTimeout(10000)
await element(by.id(TestID.Next)).tap()
// Skips cloud backup step on BackupScreen by clicking "Maybe later"
await expect(element(by.id(TestID.AddCloudBackup))).toBeVisible()
await element(by.id(TestID.Next)).tap()
// Skips notification setup by tapping "Maybe later" button
await element(by.id(TestID.Skip)).tap()
// Skips biometrics setup by tapping "Maybe later" button
await element(by.id(TestID.Skip)).tap()
// Confirms by tapping "Skip" on warning modal
await element(by.id(TestID.Confirm)).tap()
// Confirms if user successfuly finished create new wallet flow by checking if provided wallet name is
// displayed and other
await expect(element(by.text(TestWallet.name))).toBeVisible()
await expect(element(by.id(TestID.Swap))).toBeVisible()
await expect(element(by.id(TestID.SearchTokensAndWallets))).toBeVisible()
}
import { by, element, expect } from 'detox'
import { TestWatchedWallet } from 'e2e/utils/fixtures'
import { TestID } from 'uniswap/src/test/fixtures/testIDs'
export async function WatchWallet(): Promise<void> {
// Selects "Add an existing wallet" option on the landing screen
await element(by.id(TestID.ImportAccount)).tap()
// Picks Watch a wallet option on ImportMethodScreen
await element(by.id(TestID.WatchWallet)).tap()
// Checks if wallet name is in focus and types recovery phrase in
await expect(element(by.id(TestID.ImportAccountInput))).toBeFocused()
await element(by.id(TestID.ImportAccountInput)).typeText(TestWatchedWallet.ens)
// Confirms the entered wallet name by tapping "continue"
await element(by.id(TestID.Next)).tap()
// Checks if Home screen is displayed with a proper user name
await expect(element(by.text(TestWatchedWallet.displayName))).toBeVisible()
await expect(element(by.id(TestID.Swap))).toBeVisible()
await expect(element(by.id(TestID.SearchTokensAndWallets))).toBeVisible()
}
import { by, element, expect } from 'detox'
import { TestWatchedWallet } from 'e2e/utils/fixtures'
import { TestID } from 'uniswap/src/test/fixtures/testIDs'
export async function SwapBasicInteractions(): Promise<void> {
// Navigate to swap screen
await element(by.id(TestID.Swap)).tap()
// Checks if currency input is selected
await expect(element(by.id(TestID.AmountInputIn))).toBeFocused()
// Checks if "Max" button is available
await expect(element(by.id(TestID.SetMaxInput))).toBeVisible()
// Opens token selector modal on Swap screen
await element(by.id(TestID.ChooseOutputToken)).tap()
// Picks usdc output token
await element(by.text('USDC')).atIndex(0).tap()
// Taps .98765432101 into the swap input
await element(by.id('decimal-pad-.')).tap()
await element(by.id('decimal-pad-9')).tap()
await element(by.id('decimal-pad-8')).tap()
await element(by.id('decimal-pad-7')).tap()
await element(by.id('decimal-pad-6')).tap()
await element(by.id('decimal-pad-5')).tap()
await element(by.id('decimal-pad-4')).tap()
await element(by.id('decimal-pad-3')).tap()
await element(by.id('decimal-pad-2')).tap()
await element(by.id('decimal-pad-1')).tap()
await element(by.id('decimal-pad-0')).tap()
await element(by.id('decimal-pad-1')).tap()
// Taps a backspace button leaving .9876543210 value in the input field
await element(by.id('decimal-pad-backspace')).tap()
// Checks if expected input expected value: ".9876543210"
await expect(element(by.id(TestID.AmountInputIn))).toHaveText('.9876543210')
// Checks if expected error is displayed
await expect(element(by.text('You don’t have enough ETH'))).toBeVisible()
// Checks if expected output expected value: "0"
await expect(element(by.id(TestID.AmountInputOut))).not.toHaveText('0')
// Swaps input and output currencies
await element(by.id(TestID.SwitchCurrenciesButton)).tap()
// Checks if expected input expected value: "0"
await expect(element(by.id(TestID.AmountInputIn))).not.toHaveText('0')
// Checks if expected error is displayed
await expect(element(by.text('You don’t have enough USDC'))).toBeVisible()
// Checks if expected output expected value: ".9876543210"
await expect(element(by.id(TestID.AmountInputOut))).toHaveText('.9876543210')
// Swaps input and output currencies
await element(by.id(TestID.SwitchCurrenciesButton)).tap()
// Selects currency output
await element(by.id(TestID.AmountInputOut)).tap()
// Clears the output field
await element(by.id(TestID.AmountInputOut)).clearText()
await element(by.id('decimal-pad-1')).tap()
await element(by.id('decimal-pad-2')).tap()
await element(by.id('decimal-pad-3')).tap()
// Checks if output has expected value: "123"
await expect(element(by.id(TestID.AmountInputOut))).toHaveText('123')
// Checks if expected input value to be cleared
await expect(element(by.id(TestID.AmountInputIn))).not.toHaveText('0')
// Checks dollar value to be visible
await expect(element(by.text('$123.00'))).toBeVisible()
// Swipes swap modal by dragging down SwapFormHeader
await element(by.id(TestID.SwapFormHeader)).swipe('down', 'fast', 0.75)
// Checks if Home screen is visible and not covered
await expect(element(by.text(TestWatchedWallet.displayName))).toBeVisible()
await expect(element(by.id(TestID.Swap))).toBeVisible()
await expect(element(by.id(TestID.SearchTokensAndWallets))).toBeVisible()
}
import { by, element, expect } from 'detox'
import { TestWatchedWallet } from 'e2e/utils/fixtures'
import { UniverseChainId } from 'uniswap/src/features/chains/types'
import { TestID } from 'uniswap/src/test/fixtures/testIDs'
export async function TokenDetailsBasicInteractions(): Promise<void> {
// Opens "explore" modal
await element(by.id(TestID.SearchTokensAndWallets)).tap()
// Types "Uniswap" into "explore" screen search bar
await element(by.id(TestID.ExploreSearchInput)).typeText('Uniswap')
// Opnes "Uniswap" Mainnet token details screen
await element(by.id(`${TestID.SearchTokenItem}-Uniswap-${UniverseChainId.Mainnet}`)).tap()
// checks if ethereum title is displayed
await expect(element(by.id(TestID.TokenDetailsHeaderText))).toHaveText('Uniswap')
// checks if portfolio balance is visible
await expect(element(by.id(TestID.PriceExplorerAnimatedNumber))).toBeVisible()
// checks if relative price indicator is visible
await expect(element(by.id(TestID.RelativePriceChange))).toBeVisible()
// opens header "more" button dropdown menu
await expect(element(by.id(TestID.TokenDetailsMoreButton))).toBeVisible()
// checks if send button is not available
await expect(element(by.id(TestID.Send))).not.toBeVisible()
// checks if price exploerer chart is rendered
await expect(element(by.id(TestID.PriceExplorerChart))).toBeVisible()
// checks if all time ranges renders properly
await element(by.id('token-details-chart-time-range-button-1H')).tap()
await expect(element(by.id(TestID.PriceExplorerChart))).toBeVisible()
await element(by.id('token-details-chart-time-range-button-1W')).tap()
await expect(element(by.id(TestID.PriceExplorerChart))).toBeVisible()
await element(by.id('token-details-chart-time-range-button-1M')).tap()
await expect(element(by.id(TestID.PriceExplorerChart))).toBeVisible()
await element(by.id('token-details-chart-time-range-button-1Y')).tap()
await expect(element(by.id(TestID.PriceExplorerChart))).toBeVisible()
await element(by.id('token-details-chart-time-range-button-1D')).tap()
await expect(element(by.id(TestID.PriceExplorerChart))).toBeVisible()
// checks if sell and buy buttons are visible
await expect(element(by.id(TestID.TokenDetailsBuyButton))).toBeVisible()
await expect(element(by.id(TestID.TokenDetailsSellButton))).not.toBeVisible()
// scrolls to the bottom of the token details screen
await element(by.id(TestID.PriceExplorerChart)).swipe('up')
// cheks if token detels share links are available
await expect(element(by.id(TestID.TokenLinkEtherscan))).toBeVisible()
await expect(element(by.id(TestID.TokenLinkWebsite))).toBeVisible()
await expect(element(by.id(TestID.TokenLinkTwitter))).toBeVisible()
// taps on buy button
await element(by.id(TestID.TokenDetailsBuyButton)).tap()
// checks if it is displayed as expected
await expect(element(by.id(`${TestID.ChooseInputToken}-label`))).toHaveText('ETH')
await expect(element(by.id(`${TestID.ChooseOutputToken}-label`))).toHaveText('UNI')
await expect(element(by.id(TestID.ChooseInputToken))).toBeVisible()
await expect(element(by.id(TestID.AmountInputOut))).toBeFocused()
await expect(element(by.id(TestID.AmountInputOut))).toHaveText('')
// closes swap modal
await element(by.id(TestID.SwapFormHeader)).swipe('down')
// tests descreption read more button
await expect(element(by.id(TestID.ReadMoreButton))).toHaveText('Read more')
await element(by.id(TestID.ReadMoreButton)).tap()
await element(by.id(TestID.TokenDetailsAboutHeader)).swipe('up')
// tests descreption read less button
await expect(element(by.id(TestID.ReadMoreButton))).toHaveText('Read less')
await element(by.id(TestID.ReadMoreButton)).tap()
// navigates back to home screen
await element(by.id(TestID.Back)).tap()
// checks if home screen is rendered
await expect(element(by.text(TestWatchedWallet.displayName))).toBeVisible()
await expect(element(by.id(TestID.Swap))).toBeVisible()
await expect(element(by.id(TestID.SearchTokensAndWallets))).toBeVisible()
}
export const TestWallet = {
name: 'Wallet 1',
recoveryPhrase: 'oak reduce strong borrow control funny library disagree radio clarify degree pistol',
}
export const TestWatchedWallet = {
ens: 'Spenciefy',
displayName: 'spencer',
}
......@@ -9,11 +9,5 @@
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
<key>OneSignal_app_groups_key</key>
<string>group.com.uniswap.mobile.onesignal</string>
<key>STATSIG_SDK_KEY</key>
<string>$(STATSIG_SDK_KEY)</string>
<key>BUNDLE_ID_SUFFIX</key>
<string>$(BUNDLE_ID_SUFFIX)</string>
</dict>
</plist>
// File copied from Onesignal docs: https://documentation.onesignal.com/docs/react-native-sdk-setup
import UserNotifications
import OneSignalExtension
import Statsig
class NotificationService: UNNotificationServiceExtension {
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
let bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
let userInfo = request.content.userInfo
// Fields per OneSignal docs
let custom = userInfo["custom"] as? [String: Any]
let additionalData = custom?["a"] as? [String: Any]
let notificationType = additionalData?[Constants.fieldNotificationType] as? String
let isGatedNotification = notificationType == Constants.typeUnfundedWallet
|| notificationType == Constants.typePriceAlert
if (!isGatedNotification) {
OneSignalExtension.didReceiveNotificationExtensionRequest(request, with: bestAttemptContent, withContentHandler: contentHandler)
return
}
func handleGatedNotification() {
let enabled: Bool
switch notificationType {
case Constants.typeUnfundedWallet:
enabled = Statsig.checkGate(Constants.gateUnfundedWallet)
case Constants.typePriceAlert:
enabled = Statsig.checkGate(Constants.gatePriceAlert)
default:
enabled = true
}
// Passing in empty notification content will skip the notif
OneSignalExtension.didReceiveNotificationExtensionRequest(
request,
with: enabled ? bestAttemptContent : UNMutableNotificationContent(),
withContentHandler: contentHandler)
}
if (!Statsig.isInitialized()) {
// The real sdk key is needed on iOS even though it's substituted in proxy
// Because the key is used to hash the feature gate names and wouldn't work properly otherwise
let statsigSdkKey = Bundle.main.object(forInfoDictionaryKey: "STATSIG_SDK_KEY") as? String ?? ""
let statsigUser = StatsigUser(
userID: UIDevice.current.identifierForVendor?.uuidString,
custom: [
"app": "mobile"
])
Statsig.initialize(
sdkKey: statsigSdkKey,
user: statsigUser,
options: StatsigOptions(
environment: StatsigEnvironment(tier: getStatsigEnvironemntTier()),
initializationURL: URL(string: "\(Constants.statsigProxyHost)/v1/statsig-proxy/initialize"),
eventLoggingURL: URL(string: "\(Constants.statsigProxyHost)/v1/statsig-proxy/rgstr")
)) { _errorMessage in
handleGatedNotification()
}
} else {
handleGatedNotification()
}
}
func getStatsigEnvironemntTier() -> String {
let bundleSuffix = Bundle.main.object(forInfoDictionaryKey: "BUNDLE_ID_SUFFIX") as? String
switch bundleSuffix {
case ".dev":
return "development"
case ".beta":
return "beta"
default:
return "production"
var contentHandler: ((UNNotificationContent) -> Void)?
var receivedRequest: UNNotificationRequest!
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.receivedRequest = request
self.contentHandler = contentHandler
self.bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
if let bestAttemptContent = bestAttemptContent {
/* DEBUGGING: Uncomment the 2 lines below to check this extension is executing
Note, this extension only runs when mutable-content is set
Setting an attachment or action buttons automatically adds this */
#if DEBUG
print("Running NotificationServiceExtension")
bestAttemptContent.body = "[Modified] " + bestAttemptContent.body
#endif
OneSignalExtension.didReceiveNotificationExtensionRequest(self.receivedRequest, with: bestAttemptContent, withContentHandler: self.contentHandler)
}
}
}
}
struct Constants {
static let statsigProxyHost = "https://gating.ios.wallet.gateway.uniswap.org"
static let fieldNotificationType = "notification_type"
static let typeUnfundedWallet = "unfunded_wallet_reminder"
static let typePriceAlert = "price_alert"
static let gateUnfundedWallet = "notification_unfunded_wallet"
static let gatePriceAlert = "notification_price_alert"
override func serviceExtensionTimeWillExpire() {
// Called just before the extension will be terminated by the system.
// Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
OneSignalExtension.serviceExtensionTimeWillExpireRequest(self.receivedRequest, with: self.bestAttemptContent)
contentHandler(bestAttemptContent)
}
}
}
......@@ -4,7 +4,7 @@
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.uniswap.mobile.onesignal</string>
<string>group.com.$(PRODUCT_NAME).onesignal</string>
</array>
</dict>
</plist>
......@@ -57,8 +57,6 @@ end
target 'OneSignalNotificationServiceExtension' do
use_frameworks! :linkage => :static
pod 'OneSignalXCFramework', '3.12.6'
pod 'Statsig', '1.49.0'
end
def prepare_target_commons
......
......@@ -1260,7 +1260,7 @@ PODS:
- FirebaseCore (~> 11.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- FirebaseAppCheckInterop (11.6.0)
- FirebaseAppCheckInterop (11.4.0)
- FirebaseAuth (11.2.0):
- FirebaseAppCheckInterop (~> 11.0)
- FirebaseAuthInterop (~> 11.0)
......@@ -1270,14 +1270,14 @@ PODS:
- GoogleUtilities/Environment (~> 8.0)
- GTMSessionFetcher/Core (~> 3.4)
- RecaptchaInterop (~> 100.0)
- FirebaseAuthInterop (11.6.0)
- FirebaseAuthInterop (11.4.0)
- FirebaseCore (11.2.0):
- FirebaseCoreInternal (~> 11.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/Logger (~> 8.0)
- FirebaseCoreExtension (11.4.1):
- FirebaseCore (~> 11.0)
- FirebaseCoreInternal (11.6.0):
- FirebaseCoreInternal (11.4.2):
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- FirebaseFirestore (11.2.0):
- FirebaseCore (~> 11.0)
......@@ -1299,7 +1299,7 @@ PODS:
- gRPC-Core (~> 1.65.0)
- leveldb-library (~> 1.22)
- nanopb (~> 3.30910.0)
- FirebaseSharedSwift (11.6.0)
- FirebaseSharedSwift (11.4.0)
- fmt (6.2.1)
- glog (0.3.5)
- GoogleUtilities/AppDelegateSwizzler (8.0.2):
......@@ -1434,9 +1434,9 @@ PODS:
- libwebp/sharpyuv (1.3.2)
- libwebp/webp (1.3.2):
- libwebp/sharpyuv
- MMKV (2.0.0):
- MMKVCore (~> 2.0.0)
- MMKVCore (2.0.0)
- MMKV (1.3.4):
- MMKVCore (~> 1.3.4)
- MMKVCore (1.3.4)
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
......@@ -2337,9 +2337,7 @@ PODS:
- React
- react-native-get-random-values (1.8.0):
- React-Core
- react-native-image-picker (7.2.3):
- glog
- RCT-Folly (= 2022.05.16.00)
- react-native-image-picker (7.0.1):
- React-Core
- react-native-mmkv (2.10.1):
- MMKV (>= 1.2.13)
......@@ -2612,7 +2610,6 @@ PODS:
- SocketRocket (0.6.1)
- sparkfabrik-react-native-idfa-aaid (1.2.0):
- React
- Statsig (1.49.0)
- UIImageColors (2.1.0)
- Yoga (1.14.0)
- ZXingObjC/Core (3.6.9)
......@@ -2730,7 +2727,6 @@ DEPENDENCIES:
- RNScreens (from `../../../node_modules/react-native-screens`)
- RNSVG (from `../../../node_modules/react-native-svg`)
- "sparkfabrik-react-native-idfa-aaid (from `../../../node_modules/@sparkfabrik/react-native-idfa-aaid`)"
- Statsig (= 1.49.0)
- UIImageColors (= 2.1.0)
- Yoga (from `../../../node_modules/react-native/ReactCommon/yoga`)
......@@ -2779,7 +2775,6 @@ SPEC REPOS:
- SDWebImage
- SDWebImageWebPCoder
- SocketRocket
- Statsig
- UIImageColors
- ZXingObjC
......@@ -3032,15 +3027,15 @@ SPEC CHECKSUMS:
FBReactNativeSpec: 91c0784dbf98ed9c434927ea46f41b780fe3a232
Firebase: 98e6bf5278170668a7983e12971a66b2cd57fc8c
FirebaseAppCheck: a6a1c1ca169d795212b9e70b5cfb880083a28e7c
FirebaseAppCheckInterop: 347aa09a805219a31249b58fc956888e9fcb314b
FirebaseAppCheckInterop: 1b9643ae2f1ee214488caa2f8e32b7bc2f0f3735
FirebaseAuth: 2a198b8cdbbbd457f08d74df7040feb0a0e7777a
FirebaseAuthInterop: a919d415797d23b7bfe195a04f322b86c65020ef
FirebaseAuthInterop: 9ac948965ac13ec9d8a080f39490ddb2bda30520
FirebaseCore: a282032ae9295c795714ded2ec9c522fc237f8da
FirebaseCoreExtension: f1bc67a4702931a7caa097d8e4ac0a1b0d16720e
FirebaseCoreInternal: d98ab91e2d80a56d7b246856a8885443b302c0c2
FirebaseCoreInternal: 35731192cab10797b88411be84940d2beb33a238
FirebaseFirestore: 62708adbc1dfcd6d165a7c0a202067b441912dc9
FirebaseFirestoreInternal: ad9b9ee2d3d430c8f31333a69b3b6737a7206232
FirebaseSharedSwift: a4e5dfca3e210633bb3a3dfb94176c019211948b
FirebaseSharedSwift: 505dae2d05969dbf6d43749a642bb1bf230f0252
fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9
glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
......@@ -3051,8 +3046,8 @@ SPEC CHECKSUMS:
leveldb-library: cc8b8f8e013647a295ad3f8cd2ddf49a6f19be19
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009
MMKV: f7d1d5945c8765f97f39c3d121f353d46735d801
MMKVCore: c04b296010fcb1d1638f2c69405096aac12f6390
MMKV: ed58ad794b3f88c24d604a5b74f3fba17fcbaf74
MMKVCore: a67a1cede26175c413176f404a7cedec43f96a0b
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
OneSignalXCFramework: ff1c970b7aeb4ac0fe48fb35393eb5d8bf378135
OpenTelemetrySwiftApi: 657da8071c2908caecce11548e006f779924ff9c
......@@ -3083,7 +3078,7 @@ SPEC CHECKSUMS:
react-native-compat: 100540c3cebb076da442cf058e375e8ca895ae28
react-native-context-menu-view: dcec18eb8882e20596dbb75802e7d19cb87dac02
react-native-get-random-values: a6ea6a8a65dc93e96e24a11105b1a9c8cfe1d72a
react-native-image-picker: b049e0ea9d6b1b58c06262e19f8b66c87ac7b760
react-native-image-picker: 1569cfade34b3a011191ce262423e6ce2f8db5a1
react-native-mmkv: dea675cf9697ad35940f1687e98e133e1358ef9f
react-native-netinfo: 129bd99f607a2dc5bb096168f3e5c150fd1f1c95
react-native-onesignal: ab800900cffeca4d9db70a05244013fc8a36ceb8
......@@ -3138,11 +3133,10 @@ SPEC CHECKSUMS:
SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d
SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17
sparkfabrik-react-native-idfa-aaid: 1b72a6264a2175473e309ffa6434db87c58af264
Statsig: 970abcd107e8e64bb68f6b8504a94c39d7f9e318
UIImageColors: d2ef3b0877d203cbb06489eeb78ea8b7788caabe
Yoga: 805bf71192903b20fc14babe48080582fee65a80
ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5
PODFILE CHECKSUM: 18445ed90dc0fd39adbfc715d3fbf6316e3013ad
PODFILE CHECKSUM: 525fd4a1c78879023ae05970b18e66b654c4c07a
COCOAPODS: 1.14.3
......@@ -22,7 +22,6 @@
<string>fr</string>
<string>ja</string>
<string>pt</string>
<string>vi</string>
<string>es-ES</string>
<string>es-US</string>
<string>es-419</string>
......@@ -63,7 +62,7 @@
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationCategoryType</key>
<string></string>
<string/>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>itms-apps</string>
......@@ -86,6 +85,8 @@
</dict>
<key>NSCameraUsageDescription</key>
<string>$(PRODUCT_NAME) Wallet needs access to your Camera to scan QR codes</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>$(PRODUCT_NAME) Wallet needs access to your Camera Roll to choose an avatar for your username</string>
<key>NSFaceIDUsageDescription</key>
<string>Enabling Face ID helps $(PRODUCT_NAME) Wallet keep your assets secure.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
......@@ -94,8 +95,6 @@
<string>$(PRODUCT_NAME) Wallet does not require access to your location.</string>
<key>NSMicrophoneUsageDescription</key>
<string>$(PRODUCT_NAME) Wallet does not require access to the microphone.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>$(PRODUCT_NAME) Wallet needs access to your Camera Roll to choose an avatar for your username</string>
<key>NSUbiquitousContainers</key>
<dict>
<key>iCloud.Uniswap</key>
......@@ -112,8 +111,6 @@
<array>
<string>TokenPriceConfigurationIntent</string>
</array>
<key>OneSignal_app_groups_key</key>
<string>group.com.uniswap.mobile.onesignal</string>
<key>OneSignal_suppress_launch_urls</key>
<true/>
<key>UIAppFonts</key>
......
......@@ -27,8 +27,8 @@
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.$(PRODUCT_NAME).onesignal</string>
<string>group.com.uniswap.widgets</string>
<string>group.com.uniswap.mobile.onesignal</string>
</array>
</dict>
</plist>
......@@ -17,6 +17,7 @@ const workspaceRoot = path.resolve(mobileRoot, '../..')
const watchFolders = [mobileRoot, `${workspaceRoot}/node_modules`, `${workspaceRoot}/packages`]
const detoxExtensions = process.env.DETOX_MODE === 'mocked' ? ['mock.tsx', 'mock.ts'] : []
const defaultConfig = getDefaultConfig(__dirname)
......@@ -28,7 +29,8 @@ const config = {
resolver: {
nodeModulesPaths: [`${workspaceRoot}/node_modules`],
assetExts: assetExts.filter((ext) => ext !== 'svg'),
sourceExts: [...sourceExts, 'svg', 'cjs'],
// detox mocking works properly only being spreaded at the beginning of sourceExts array
sourceExts: [...detoxExtensions, ...sourceExts, 'svg', 'cjs'],
},
transformer: {
getTransformOptions: async () => ({
......@@ -48,14 +50,11 @@ const config = {
watchFolders,
}
const IS_STORYBOOK_ENABLED = process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test'
// Checkout more useful options in the docs: https://github.com/storybookjs/react-native?tab=readme-ov-file#options
module.exports = withStorybook(mergeConfig(defaultConfig, config), {
// Set to false to remove storybook specific options
// you can also use a env variable to set this
enabled: IS_STORYBOOK_ENABLED,
onDisabledRemoveStorybook: true,
enabled: process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test',
// Path to your storybook config
configPath: path.resolve(__dirname, './.storybook'),
})
......@@ -23,6 +23,14 @@
"env:local:upload": "bash ../../scripts/uploadEnvLocal.sh xmznnx7ozuojy5lnohcmt73aee ../../.env.defaults.local",
"env:local:copy:swift": "python3 scripts/copy_env_vars_to_swift.py",
"e2e": "maestro test \".maestro/flows/$*\"",
"e2e:packager": "DETOX_MODE=mocked yarn start",
"e2e:android:build:debug": "DETOX_MODE=mocked detox build -c android.emu.debug",
"e2e:android:test:debug": "detox test -c android.emu.debug",
"e2e:android:build:release": "DETOX_MODE=mocked detox build -c android.emu.release",
"e2e:android:test:release": "DETOX_MODE=mocked detox test -c android.emu.release --cleanup --headless --record-logs all",
"e2e:ios:build:debug": "DETOX_MODE=mocked detox build -c ios.sim.debug",
"e2e:ios:test:debug": "detox test -c ios.sim.debug",
"e2e:ios:test:release": "DETOX_MODE=mocked detox build -c ios.sim.release && detox test -c ios.sim.release --cleanup --headless --record-logs all",
"firestore:deploy:rules": "firebase deploy --only firestore:rules",
"link:assets": "react-native-asset",
"graphql:generate:swift": "cd ios && ./Pods/Apollo/apollo-ios-cli generate",
......@@ -83,9 +91,9 @@
"@tanstack/react-query": "5.51.16",
"@uniswap/analytics": "1.7.0",
"@uniswap/analytics-events": "2.40.0",
"@uniswap/client-explore": "0.0.14",
"@uniswap/client-explore": "0.0.12",
"@uniswap/ethers-rs-mobile": "0.0.5",
"@uniswap/sdk-core": "7.1.0",
"@uniswap/sdk-core": "6.1.0",
"@walletconnect/core": "2.17.1",
"@walletconnect/react-native-compat": "2.17.1",
"@walletconnect/utils": "2.17.1",
......@@ -124,7 +132,7 @@
"react-native-gesture-handler": "2.19.0",
"react-native-get-random-values": "1.8.0",
"react-native-image-colors": "1.5.2",
"react-native-image-picker": "7.2.3",
"react-native-image-picker": "7.0.1",
"react-native-localize": "2.2.6",
"react-native-markdown-display": "7.0.0-alpha.2",
"react-native-mmkv": "2.10.1",
......@@ -174,6 +182,7 @@
"babel-plugin-module-resolver": "5.0.0",
"babel-plugin-react-native-web": "0.17.5",
"core-js": "2.6.12",
"detox": "20.23.0",
"eslint": "8.44.0",
"expo-modules-core": "1.11.13",
"jest": "29.7.0",
......
......@@ -15,7 +15,7 @@ import { SafeAreaProvider } from 'react-native-safe-area-context'
import { enableFreeze } from 'react-native-screens'
import { useDispatch, useSelector } from 'react-redux'
import { PersistGate } from 'redux-persist/integration/react'
import { DatadogProviderWrapper, MOBILE_DEFAULT_DATADOG_SESSION_SAMPLE_RATE } from 'src/app/DatadogProviderWrapper'
import { DatadogProviderWrapper } from 'src/app/DatadogProviderWrapper'
import { MobileWalletNavigationProvider } from 'src/app/MobileWalletNavigationProvider'
import { AppModals } from 'src/app/modals/AppModals'
import { NavigationContainer } from 'src/app/navigation/NavigationContainer'
......@@ -47,15 +47,10 @@ import { uniswapUrls } from 'uniswap/src/constants/urls'
import { BlankUrlProvider } from 'uniswap/src/contexts/UrlContext'
import { selectFavoriteTokens } from 'uniswap/src/features/favorites/selectors'
import { useAppFiatCurrencyInfo } from 'uniswap/src/features/fiatCurrency/hooks'
import {
DatadogSessionSampleRateKey,
DatadogSessionSampleRateValType,
DynamicConfigs,
} from 'uniswap/src/features/gating/configs'
import { DUMMY_STATSIG_SDK_KEY, StatsigCustomAppValue } from 'uniswap/src/features/gating/constants'
import { Experiments } from 'uniswap/src/features/gating/experiments'
import { FeatureFlags, WALLET_FEATURE_FLAG_NAMES } from 'uniswap/src/features/gating/flags'
import { getDynamicConfigValue, useFeatureFlag } from 'uniswap/src/features/gating/hooks'
import { useFeatureFlag } from 'uniswap/src/features/gating/hooks'
import { loadStatsigOverrides } from 'uniswap/src/features/gating/overrides/customPersistedOverrides'
import { Statsig, StatsigOptions, StatsigProvider, StatsigUser } from 'uniswap/src/features/gating/sdk/statsig'
import { LocalizationContextProvider } from 'uniswap/src/features/language/LocalizationContext'
......@@ -69,10 +64,9 @@ import { UnitagUpdaterContextProvider } from 'uniswap/src/features/unitags/conte
import i18n from 'uniswap/src/i18n'
import { CurrencyId } from 'uniswap/src/types/currency'
import { getUniqueId } from 'utilities/src/device/getUniqueId'
import { datadogEnabled, isE2EMode } from 'utilities/src/environment/constants'
import { datadogEnabled, isDetoxBuild } from 'utilities/src/environment/constants'
import { attachUnhandledRejectionHandler, setAttributesToDatadog } from 'utilities/src/logger/Datadog'
import { registerConsoleOverrides } from 'utilities/src/logger/console'
import { DDRumAction, DDRumTiming } from 'utilities/src/logger/datadogEvents'
import { logger } from 'utilities/src/logger/logger'
import { useAsyncData } from 'utilities/src/react/hooks'
import { AnalyticsNavigationContextProvider } from 'utilities/src/telemetry/trace/AnalyticsNavigationContext'
......@@ -98,9 +92,9 @@ if (__DEV__) {
loadErrorMessages()
}
// Log boxes on simulators can block e2e tap event when they cover buttons placed at
// Log boxes on simulators can block detox tap event when they cover buttons placed at
// the bottom of the screen and cause tests to fail.
if (isE2EMode) {
if (isDetoxBuild) {
LogBox.ignoreAllLogs()
}
......@@ -110,7 +104,7 @@ initFirebaseAppCheck()
function App(): JSX.Element | null {
useEffect(() => {
if (!__DEV__ && !isE2EMode) {
if (!__DEV__ && !isDetoxBuild) {
attachUnhandledRejectionHandler()
setAttributesToDatadog({ buildNumber: DeviceInfo.getBuildNumber() }).catch(() => undefined)
}
......@@ -127,8 +121,6 @@ function App(): JSX.Element | null {
const deviceId = useAsyncData(fetchAndSetDeviceId).data
const [datadogSessionSampleRate, setDatadogSessionSampleRate] = React.useState<number | undefined>(undefined)
const statSigOptions: {
user: StatsigUser
options: StatsigOptions
......@@ -142,22 +134,7 @@ function App(): JSX.Element | null {
api: uniswapUrls.statsigProxyUrl,
disableAutoMetricsLogging: true,
disableErrorLogging: true,
initCompletionCallback: () => {
loadStatsigOverrides()
// we should move this logic inside DatadogProviderWrapper once we migrate to @statsig/js-client
// https://docs.statsig.com/client/javascript-sdk/migrating-from-statsig-js/#initcompletioncallback
setDatadogSessionSampleRate(
getDynamicConfigValue<
DynamicConfigs.DatadogSessionSampleRate,
DatadogSessionSampleRateKey,
DatadogSessionSampleRateValType
>(
DynamicConfigs.DatadogSessionSampleRate,
DatadogSessionSampleRateKey.Rate,
MOBILE_DEFAULT_DATADOG_SESSION_SAMPLE_RATE,
),
)
},
initCompletionCallback: loadStatsigOverrides,
},
sdkKey: DUMMY_STATSIG_SDK_KEY,
user: {
......@@ -171,7 +148,7 @@ function App(): JSX.Element | null {
return (
<StatsigProvider {...statSigOptions}>
<DatadogProviderWrapper sessionSampleRate={datadogSessionSampleRate}>
<DatadogProviderWrapper>
<Trace>
<StrictMode>
<I18nextProvider i18n={i18n}>
......@@ -206,11 +183,6 @@ function AppOuter(): JSX.Element | null {
})
const jsBundleLoadedRef = useRef(false)
useEffect(() => {
// Dynamically load polyfills so that we save on bundle size and improve app startup time
import('src/polyfills/intl-delayed')
}, [])
/**
* Function called by the @shopify/react-native-performance PerformanceProfiler that returns a
* RenderPassReport. We then forward this report to Datadog, Amplitude, etc.
......@@ -219,13 +191,13 @@ function AppOuter(): JSX.Element | null {
if (datadogEnabled) {
const shouldLogJsBundleLoaded = report.timeToBootJsMillis && !jsBundleLoadedRef.current
if (shouldLogJsBundleLoaded) {
await DdRum.addAction(RumActionType.CUSTOM, DDRumAction.ApplicationStartJs, {
await DdRum.addAction(RumActionType.CUSTOM, 'application_start_js', {
loading_time: report.timeToBootJsMillis,
})
jsBundleLoadedRef.current = true
}
if (report.interactive) {
await DdRum.addTiming(DDRumTiming.ScreenInteractive)
await DdRum.addTiming('screenInteractive')
}
}
......
import {
BatchSize,
DatadogProvider,
DatadogProviderConfiguration,
DdRum,
SdkVerbosity,
TrackingConsent,
UploadFrequency,
} from '@datadog/mobile-react-native'
import { ErrorEventMapper } from '@datadog/mobile-react-native/lib/typescript/rum/eventMappers/errorEventMapper'
import { PropsWithChildren, default as React, useEffect } from 'react'
import { PropsWithChildren, default as React } from 'react'
import { getDatadogEnvironment } from 'src/utils/version'
import { config } from 'uniswap/src/config'
import {
......@@ -16,85 +17,64 @@ import {
DynamicConfigs,
} from 'uniswap/src/features/gating/configs'
import { getDynamicConfigValue } from 'uniswap/src/features/gating/hooks'
import { datadogEnabled, isE2EMode, isJestRun, localDevDatadogEnabled } from 'utilities/src/environment/constants'
import { datadogEnabled, isDetoxBuild, isJestRun, localDevDatadogEnabled } from 'utilities/src/environment/constants'
import { logger } from 'utilities/src/logger/logger'
// In case Statsig is not available
export const MOBILE_DEFAULT_DATADOG_SESSION_SAMPLE_RATE = 10 // percent
export const SESSION_SAMPLE_RATE = 10 // percent
// Configuration for Datadog's automatic monitoring features:
// - Error tracking: Captures and reports application errors
// - User interactions: Monitors user events and actions
// - Resource tracking: Traces network requests and API calls
// Note: Can buffer up to 100 RUM events before SDK initialization
// https://docs.datadoghq.com/real_user_monitoring/mobile_and_tv_monitoring/react_native/advanced_configuration/#delaying-the-initialization
const datadogAutoInstrumentation = {
trackErrors: datadogEnabled,
trackInteractions: datadogEnabled,
trackResources: datadogEnabled,
}
const datadogConfig = new DatadogProviderConfiguration(
config.datadogClientToken,
getDatadogEnvironment(),
config.datadogProjectId,
datadogEnabled, // trackInteractions
datadogEnabled, // trackResources
datadogEnabled, // trackErrors
localDevDatadogEnabled ? TrackingConsent.GRANTED : undefined,
)
async function initializeDatadog(sessionSamplingRate: number | undefined): Promise<void> {
const datadogConfig = {
clientToken: config.datadogClientToken,
env: getDatadogEnvironment(),
applicationId: config.datadogProjectId,
trackingConsent: undefined,
site: 'US1',
longTaskThresholdMs: 100,
nativeCrashReportEnabled: true,
verbosity: SdkVerbosity.INFO,
errorEventMapper: (event: ReturnType<ErrorEventMapper>): ReturnType<ErrorEventMapper> | null => {
const ignoredErrors = getDynamicConfigValue<
DynamicConfigs.DatadogIgnoredErrors,
DatadogIgnoredErrorsConfigKey,
DatadogIgnoredErrorsValType
>(DynamicConfigs.DatadogIgnoredErrors, DatadogIgnoredErrorsConfigKey.Errors, [])
Object.assign(datadogConfig, {
site: 'US1',
longTaskThresholdMs: 100,
nativeCrashReportEnabled: true,
verbosity: SdkVerbosity.INFO,
errorEventMapper: (event: ReturnType<ErrorEventMapper>) => {
const ignoredErrors = getDynamicConfigValue<
DynamicConfigs.DatadogIgnoredErrors,
DatadogIgnoredErrorsConfigKey,
DatadogIgnoredErrorsValType
>(DynamicConfigs.DatadogIgnoredErrors, DatadogIgnoredErrorsConfigKey.Errors, [])
const ignoredError = ignoredErrors.find(({ messageContains }) => event?.message.includes(messageContains))
if (ignoredError) {
return Math.random() < ignoredError.sampleRate ? event : null
}
const ignoredError = ignoredErrors.find(({ messageContains }) => event?.message.includes(messageContains))
if (ignoredError) {
return Math.random() < ignoredError.sampleRate ? event : null
}
return event
},
sessionSamplingRate,
}
return event
},
sessionSampleRate: SESSION_SAMPLE_RATE,
})
if (localDevDatadogEnabled) {
Object.assign(datadogConfig, {
sessionSamplingRate: 100,
uploadFrequency: UploadFrequency.FREQUENT,
batchSize: BatchSize.SMALL,
verbosity: SdkVerbosity.DEBUG,
trackingConsent: TrackingConsent.GRANTED,
})
}
await DatadogProvider.initialize(datadogConfig)
if (localDevDatadogEnabled) {
Object.assign(datadogConfig, {
sessionSamplingRate: 100,
resourceTracingSamplingRate: 100,
uploadFrequency: UploadFrequency.FREQUENT,
batchSize: BatchSize.SMALL,
verbosity: SdkVerbosity.DEBUG,
})
}
/**
* Wrapper component to provide Datadog to the app with our mobile app's
* specific configuration.
*/
export function DatadogProviderWrapper({
children,
sessionSampleRate,
}: PropsWithChildren<{ sessionSampleRate: number | undefined }>): JSX.Element {
useEffect(() => {
if (datadogEnabled && sessionSampleRate !== undefined) {
initializeDatadog(sessionSampleRate).catch(() => undefined)
}
}, [sessionSampleRate])
if (isE2EMode || isJestRun) {
export function DatadogProviderWrapper({ children }: PropsWithChildren): JSX.Element {
if (isDetoxBuild || isJestRun) {
return <>{children}</>
}
logger.setWalletDatadogEnabled(true)
return (
<DatadogProvider
configuration={datadogAutoInstrumentation}
configuration={datadogConfig}
onInitialization={async () => {
const sessionId = await DdRum.getCurrentSessionId()
// we do not want to log anything if session is not sampled
......
......@@ -4,7 +4,7 @@ import { useDispatch } from 'react-redux'
import { exploreNavigationRef } from 'src/app/navigation/navigation'
import { useAppStackNavigation } from 'src/app/navigation/types'
import { closeModal, openModal } from 'src/features/modals/modalSlice'
import { HomeScreenTabIndex } from 'src/screens/HomeScreen/HomeScreenTabIndex'
import { HomeScreenTabIndex } from 'src/screens/HomeScreenTabIndex'
import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains'
import { FeatureFlags } from 'uniswap/src/features/gating/flags'
import { useFeatureFlag } from 'uniswap/src/features/gating/hooks'
......
......@@ -3,7 +3,7 @@ import { useCallback, useContext } from 'react'
import { BackHandler } from 'react-native'
import { navigate as rootNavigate } from 'src/app/navigation/rootNavigation'
import { useAppStackNavigation, useExploreStackNavigation } from 'src/app/navigation/types'
import { HomeScreenTabIndex } from 'src/screens/HomeScreen/HomeScreenTabIndex'
import { HomeScreenTabIndex } from 'src/screens/HomeScreenTabIndex'
import { useTransactionListLazyQuery } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks'
import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains'
import { MobileScreens } from 'uniswap/src/types/screens/mobile'
......
......@@ -37,7 +37,7 @@ import { ExternalProfileScreen } from 'src/screens/ExternalProfileScreen'
import { FiatOnRampConnectingScreen } from 'src/screens/FiatOnRampConnecting'
import { FiatOnRampScreen } from 'src/screens/FiatOnRampScreen'
import { FiatOnRampServiceProvidersScreen } from 'src/screens/FiatOnRampServiceProviders'
import { HomeScreen } from 'src/screens/HomeScreen/HomeScreen'
import { HomeScreen } from 'src/screens/HomeScreen'
import { ImportMethodScreen } from 'src/screens/Import/ImportMethodScreen'
import { OnDeviceRecoveryScreen } from 'src/screens/Import/OnDeviceRecoveryScreen'
import { OnDeviceRecoveryViewSeedPhraseScreen } from 'src/screens/Import/OnDeviceRecoveryViewSeedPhraseScreen'
......@@ -64,7 +64,6 @@ import { SettingsCloudBackupPasswordConfirmScreen } from 'src/screens/SettingsCl
import { SettingsCloudBackupPasswordCreateScreen } from 'src/screens/SettingsCloudBackupPasswordCreateScreen'
import { SettingsCloudBackupProcessingScreen } from 'src/screens/SettingsCloudBackupProcessingScreen'
import { SettingsCloudBackupStatus } from 'src/screens/SettingsCloudBackupStatus'
import { SettingsNotificationsScreen } from 'src/screens/SettingsNotificationsScreen'
import { SettingsPrivacyScreen } from 'src/screens/SettingsPrivacyScreen'
import { SettingsScreen } from 'src/screens/SettingsScreen'
import { SettingsViewSeedPhraseScreen } from 'src/screens/SettingsViewSeedPhraseScreen'
......@@ -134,7 +133,6 @@ function SettingsStackGroup(): JSX.Element {
<SettingsStack.Screen component={SettingsCloudBackupStatus} name={MobileScreens.SettingsCloudBackupStatus} />
<SettingsStack.Screen component={SettingsAppearanceScreen} name={MobileScreens.SettingsAppearance} />
<SettingsStack.Screen component={SettingsPrivacyScreen} name={MobileScreens.SettingsPrivacy} />
<SettingsStack.Screen component={SettingsNotificationsScreen} name={MobileScreens.SettingsNotifications} />
</SettingsStack.Navigator>
)
}
......
......@@ -5,7 +5,7 @@ import {
useNavigation,
} from '@react-navigation/native'
import { NativeStackNavigationProp, NativeStackScreenProps } from '@react-navigation/native-stack'
import { HomeScreenTabIndex } from 'src/screens/HomeScreen/HomeScreenTabIndex'
import { HomeScreenTabIndex } from 'src/screens/HomeScreenTabIndex'
import { ImportType, OnboardingEntryPoint } from 'uniswap/src/types/onboarding'
import {
FiatOnRampScreens,
......@@ -62,7 +62,6 @@ export type SettingsStackParamList = {
[MobileScreens.SettingsCloudBackupStatus]: { address: Address }
[MobileScreens.SettingsHelpCenter]: undefined
[MobileScreens.SettingsLanguage]: undefined
[MobileScreens.SettingsNotifications]: undefined
[MobileScreens.SettingsPrivacy]: undefined
[MobileScreens.SettingsViewSeedPhrase]: { address: Address; walletNeedsRestore?: boolean }
[MobileScreens.SettingsWallet]: { address: Address }
......
/* eslint-disable max-lines */
import { RankingType } from 'uniswap/src/data/types'
import { FiatCurrency } from 'uniswap/src/features/fiatCurrency/constants'
import { Language } from 'uniswap/src/features/language/constants'
import { ModalName } from 'uniswap/src/features/telemetry/constants'
import { SwapProtectionSetting } from 'wallet/src/features/wallet/slice'
import { RankingType } from 'wallet/src/features/wallet/types'
// only add fields that are persisted
export const initialSchema = {
......
......@@ -24,7 +24,7 @@ import Trace from 'uniswap/src/features/telemetry/Trace'
import { ElementNameType } from 'uniswap/src/features/telemetry/constants'
import { TestID } from 'uniswap/src/test/fixtures/testIDs'
import { CurrencyId } from 'uniswap/src/types/currency'
import { isE2EMode } from 'utilities/src/environment/constants'
import { isDetoxBuild } from 'utilities/src/environment/constants'
import { logger } from 'utilities/src/logger/logger'
import { isAndroid } from 'utilities/src/platform'
......@@ -126,7 +126,7 @@ export const PriceExplorerInner = memo(function _PriceExplorerInner(): JSX.Eleme
const { convertFiatAmount } = useLocalizationContext()
const conversionRate = convertFiatAmount(1).amount
const shouldShowAnimatedDot =
(selectedDuration === HistoryDuration.Day || selectedDuration === HistoryDuration.Hour) && !isE2EMode
(selectedDuration === HistoryDuration.Day || selectedDuration === HistoryDuration.Hour) && !isDetoxBuild
const additionalPadding = shouldShowAnimatedDot ? 40 : 0
const { lastPricePoint, convertedPriceHistory } = useMemo(() => {
......
query TokenPriceHistory(
$contract: ContractInput!
$duration: HistoryDuration = DAY
$maxHistoryLength: Int = 1000
) {
tokenProjects(contracts: [$contract]) {
id
......@@ -14,7 +13,7 @@ query TokenPriceHistory(
pricePercentChange24h {
value
}
priceHistory(duration: $duration, maxLength: $maxHistoryLength) {
priceHistory(duration: $duration) {
timestamp
value
}
......@@ -33,7 +32,7 @@ query TokenPriceHistory(
pricePercentChange24h: pricePercentChange(duration: DAY) {
value
}
priceHistory(duration: $duration, maxLength: $maxHistoryLength) {
priceHistory(duration: $duration) {
timestamp
value
}
......
......@@ -14,7 +14,8 @@ export const TIME_RANGES = [
[HistoryDuration.Week, i18n.t('token.priceExplorer.timeRangeLabel.week'), ElementName.TimeFrame1W],
[HistoryDuration.Month, i18n.t('token.priceExplorer.timeRangeLabel.month'), ElementName.TimeFrame1M],
[HistoryDuration.Year, i18n.t('token.priceExplorer.timeRangeLabel.year'), ElementName.TimeFrame1Y],
[HistoryDuration.Max, i18n.t('common.all'), ElementName.TimeFrameAll],
// TODO (MOB-3585): fix performance issue with All time range and re-enable
// [HistoryDuration.Max, i18n.t('token.priceExplorer.timeRangeLabel.all'), ElementName.TimeFrameAll],
] as const
export const NUM_GRAPHS = TIME_RANGES.length
import { BigNumber } from 'ethers'
import React, { PropsWithChildren } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { ScrollView } from 'react-native-gesture-handler'
import { LinkButton } from 'src/components/buttons/LinkButton'
......@@ -9,7 +9,7 @@ import { TextVariantTokens, iconSizes } from 'ui/src/theme'
import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains'
import { UniverseChainId } from 'uniswap/src/features/chains/types'
import { toSupportedChainId } from 'uniswap/src/features/chains/utils'
import { useENSName } from 'uniswap/src/features/ens/api'
import { useENS } from 'uniswap/src/features/ens/useENS'
import { EthMethod, EthTransaction } from 'uniswap/src/types/walletConnect'
import { getValidAddress } from 'uniswap/src/utils/addresses'
import { ExplorerDataType, getExplorerLink } from 'uniswap/src/utils/linking'
......@@ -38,7 +38,7 @@ type AddressButtonProps = {
}
const AddressButton = ({ address, chainId, ...rest }: AddressButtonProps): JSX.Element => {
const { data: name } = useENSName(address)
const { name } = useENS(chainId, address, false)
const colors = useSporeColors()
const { defaultChainId } = useEnabledChains()
const supportedChainId = toSupportedChainId(chainId) ?? defaultChainId
......@@ -55,23 +55,6 @@ const AddressButton = ({ address, chainId, ...rest }: AddressButtonProps): JSX.E
)
}
type KeyValueRowProps = {
objKey: string
} & PropsWithChildren
const KeyValueRow = ({ objKey, children }: KeyValueRowProps): JSX.Element => {
return (
<Flex key={objKey} row alignItems="flex-start" gap="$spacing8">
<Text color="$neutral2" py="$spacing4" variant="body3">
{objKey}
</Text>
<Flex shrink gap="$spacing16" py="$spacing4">
{children}
</Flex>
</Flex>
)
}
const MAX_TYPED_DATA_PARSE_DEPTH = 3
// recursively parses typed data objects and adds margin to left
......@@ -81,31 +64,45 @@ const getParsedObjectDisplay = (chainId: number, obj: any, depth = 0): JSX.Eleme
return <Text variant="body3">...</Text>
}
if (Array.isArray(obj) || obj === null || obj === undefined || typeof obj !== 'object') {
return <Text variant="body3">{Array.isArray(obj) ? JSON.stringify(obj) : String(obj)}</Text>
}
return (
<Flex gap="$spacing4">
{Object.keys(obj).map((objKey) => {
const childValue = obj[objKey]
// Special case for address strings
if (typeof childValue === 'string' && getValidAddress(childValue, true)) {
if (typeof childValue === 'object') {
return (
<Flex key={objKey} gap="$spacing4">
<Text color="$neutral2" variant="body3">
{objKey}
</Text>
{getParsedObjectDisplay(chainId, childValue, depth + 1)}
</Flex>
)
}
if (typeof childValue === 'string') {
return (
<KeyValueRow key={objKey} objKey={objKey}>
<Flex>
<AddressButton address={childValue} chainId={chainId} textVariant="body3" />
<Flex key={objKey} row alignItems="flex-start" gap="$spacing8">
<Text color="$neutral2" py="$spacing4" variant="body3">
{objKey}
</Text>
<Flex shrink gap="$spacing16">
{getValidAddress(childValue, true) ? (
<Flex py="$spacing4">
<AddressButton address={childValue} chainId={chainId} textVariant="body3" />
</Flex>
) : (
<Text py="$spacing4" variant="body3">
{childValue}
</Text>
)}
</Flex>
</KeyValueRow>
</Flex>
)
}
return (
<KeyValueRow key={objKey} objKey={objKey}>
{getParsedObjectDisplay(chainId, childValue, depth + 1)}
</KeyValueRow>
)
// TODO: [MOB-216] handle array child types
return null
})}
</Flex>
)
......
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSettingsStackNavigation } from 'src/app/navigation/types'
import { DeprecatedButton, Flex, Text, TouchableArea } from 'ui/src'
......@@ -7,16 +7,32 @@ import { iconSizes } from 'ui/src/theme'
import { AccountType } from 'uniswap/src/features/accounts/types'
import { MobileScreens } from 'uniswap/src/types/screens/mobile'
import { AddressDisplay } from 'wallet/src/components/accounts/AddressDisplay'
import { useAccountsList } from 'wallet/src/features/wallet/hooks'
import { SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types'
import { useAccounts } from 'wallet/src/features/wallet/hooks'
const DEFAULT_ACCOUNTS_TO_DISPLAY = 6
export function WalletSettings(): JSX.Element {
const { t } = useTranslation()
const navigation = useSettingsStackNavigation()
const allAccounts = useAccountsList()
const addressToAccount = useAccounts()
const [showAll, setShowAll] = useState(false)
const allAccounts = useMemo(() => {
const accounts = Object.values(addressToAccount)
const _mnemonicWallets = accounts
.filter((a): a is SignerMnemonicAccount => a.type === AccountType.SignerMnemonic)
.sort((a, b) => {
return a.derivationIndex - b.derivationIndex
})
const _viewOnlyWallets = accounts
.filter((a) => a.type === AccountType.Readonly)
.sort((a, b) => {
return a.timeImportedMs - b.timeImportedMs
})
return [..._mnemonicWallets, ..._viewOnlyWallets]
}, [addressToAccount])
const toggleViewAll = (): void => {
setShowAll(!showAll)
}
......
......@@ -21,8 +21,6 @@ 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 { usePerformanceLogger } from 'utilities/src/logger/usePerformanceLogger'
import { isAndroid } from 'utilities/src/platform'
import { useValueAsRef } from 'utilities/src/react/useValueAsRef'
import { InformationBanner } from 'wallet/src/components/banners/InformationBanner'
......@@ -78,8 +76,6 @@ export const TokenBalanceListInner = forwardRef<FlatList<TokenBalanceListRow>, T
const colors = useSporeColors()
const insets = useAppInsets()
usePerformanceLogger(DDRumManualTiming.RenderTokenBalanceList, [])
const { rows, balancesById } = useTokenBalanceListContext()
const { onContentSizeChange, adaptiveFooter } = useAdaptiveFooter(containerProps?.contentContainerStyle)
......
import React, { memo } from 'react'
import { useTokenDetailsContext } from 'src/components/TokenDetails/TokenDetailsContext'
import { Flex, flexStyles, Text } from 'ui/src'
import { Flex, flexStyles, Text, TouchableArea } from 'ui/src'
import { TokenLogo } from 'uniswap/src/components/CurrencyLogo/TokenLogo'
import WarningIcon from 'uniswap/src/components/warnings/WarningIcon'
import { SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks'
import {
useTokenBasicInfoPartsFragment,
useTokenBasicProjectPartsFragment,
} from 'uniswap/src/data/graphql/uniswap-data-api/fragments'
import { fromGraphQLChain } from 'uniswap/src/features/chains/utils'
import { FeatureFlags } from 'uniswap/src/features/gating/flags'
import { useFeatureFlag } from 'uniswap/src/features/gating/hooks'
import { TestID } from 'uniswap/src/test/fixtures/testIDs'
export const TokenDetailsHeader = memo(function _TokenDetailsHeader(): JSX.Element {
const { currencyId } = useTokenDetailsContext()
const tokenProtectionEnabled = useFeatureFlag(FeatureFlags.TokenProtection)
const { currencyId, openTokenWarningModal } = useTokenDetailsContext()
const token = useTokenBasicInfoPartsFragment({ currencyId }).data
const project = useTokenBasicProjectPartsFragment({ currencyId }).data.project
const shouldShowWarningIcon =
!tokenProtectionEnabled &&
(project?.safetyLevel === SafetyLevel.StrongWarning || project?.safetyLevel === SafetyLevel.Blocked)
return (
<Flex gap="$spacing12" mx="$spacing16">
<TokenLogo
......@@ -33,6 +44,12 @@ export const TokenDetailsHeader = memo(function _TokenDetailsHeader(): JSX.Eleme
>
{token?.name ?? ''}
</Text>
{shouldShowWarningIcon && (
<TouchableArea onPress={openTokenWarningModal}>
<WarningIcon safetyLevel={project?.safetyLevel} size="$icon.20" strokeColorOverride="$neutral3" />
</TouchableArea>
)}
</Flex>
</Flex>
)
......
......@@ -55,17 +55,10 @@ const TokenDetailsMarketData = memo(function _TokenDetailsMarketData(): JSX.Elem
const tokenMarket = useTokenMarketPartsFragment({ currencyId }).data?.market
const projectMarkets = useTokenProjectMarketsPartsFragment({ currencyId }).data.project?.markets
const price = projectMarkets?.[0]?.price?.value || tokenMarket?.price?.value || undefined
const marketCap = projectMarkets?.[0]?.marketCap?.value
const volume = tokenMarket?.volume?.value
const rawPriceHigh52W = projectMarkets?.[0]?.priceHigh52W?.value || tokenMarket?.priceHigh52W?.value || undefined
const rawPriceLow52W = projectMarkets?.[0]?.priceLow52W?.value || tokenMarket?.priceLow52W?.value || undefined
// Use current price for 52w high/low if it exceeds the bounds
const priceHight52W =
price !== undefined && rawPriceHigh52W !== undefined ? Math.max(price, rawPriceHigh52W) : rawPriceHigh52W
const priceLow52W =
price !== undefined && rawPriceLow52W !== undefined ? Math.min(price, rawPriceLow52W) : rawPriceLow52W
const priceHight52W = projectMarkets?.[0]?.priceHigh52W?.value ?? tokenMarket?.priceHigh52W?.value
const priceLow52W = projectMarkets?.[0]?.priceLow52W?.value ?? tokenMarket?.priceLow52W?.value
const fullyDilutedValuation = projectMarkets?.[0]?.fullyDilutedValuation?.value
return (
......@@ -143,7 +136,6 @@ export const TokenDetailsStats = memo(function _TokenDetailsStats(): JSX.Element
includeFrench: language === Language.French,
includeJapanese: language === Language.Japanese,
includePortuguese: language === Language.Portuguese,
includeVietnamese: language === Language.Vietnamese,
includeChineseSimplified: language === Language.ChineseSimplified,
includeChineseTraditional: language === Language.ChineseTraditional,
},
......@@ -158,7 +150,6 @@ export const TokenDetailsStats = memo(function _TokenDetailsStats(): JSX.Element
descriptions?.descriptionTranslations?.descriptionFrFr ||
descriptions?.descriptionTranslations?.descriptionJaJp ||
descriptions?.descriptionTranslations?.descriptionPtPt ||
descriptions?.descriptionTranslations?.descriptionViVn ||
descriptions?.descriptionTranslations?.descriptionZhHans ||
descriptions?.descriptionTranslations?.descriptionZhHant
......
......@@ -66,7 +66,7 @@ function TokenOptionItemWrapper({
option={option}
quantity={option.quantity}
quantityFormatted={formatNumberOrString({ value: option.quantity, type: NumberType.TokenTx })}
showWarnings={false}
showWarnings={true}
tokenWarningDismissed={tokenWarningDismissed}
onPress={onPress}
/>
......
......@@ -11,7 +11,6 @@ import {
ethToken,
tokenMarket,
tokenProject,
tokenProjectMarket,
} from 'uniswap/src/test/fixtures'
import { queryResolvers } from 'uniswap/src/test/utils'
import { getSymbolDisplayText } from 'uniswap/src/utils/currency'
......@@ -32,16 +31,7 @@ jest.mock('@react-navigation/native', () => {
const mockStore = configureMockStore()
const favoriteToken = ethToken({
project: {
...tokenProject(),
markets: [
{
...tokenProjectMarket(),
price: amount({ value: 76543.21 }),
pricePercentChange24h: amount({ value: 6.54 }),
},
],
},
project: tokenProject(),
market: tokenMarket({
price: amount({ value: 12345.67 }),
pricePercentChange: amount({ value: 4.56 }),
......@@ -74,12 +64,12 @@ describe('FavoriteTokenCard', () => {
it('renders loader', async () => {
const { queryByTestId } = render(<FavoriteTokenCard {...defaultProps} />, { resolvers })
const loaderPrice = queryByTestId('loader/favorite/price')
const loaderPriceChange = queryByTestId('loader/favorite/priceChange')
const loader = queryByTestId('loader/favorite')
expect(loaderPrice).toBeTruthy()
expect(loaderPriceChange).toBeTruthy()
// loading
expect(loader).toBeTruthy()
// loading finished
await waitFor(() => {
expect(queryByTestId(touchableId)).toBeTruthy()
})
......@@ -89,8 +79,8 @@ describe('FavoriteTokenCard', () => {
describe('when token data is available', () => {
const cases = [
{ test: 'symbol', value: getSymbolDisplayText(favoriteToken.symbol)! },
{ test: 'price', value: '$76,543.21' },
{ test: 'relative price change', value: '6.54%' },
{ test: 'price', value: '$12,345.67' },
{ test: 'relative price change', value: '4.56%' },
]
it.each(cases)('renders correct $test', async ({ value }) => {
......@@ -101,22 +91,6 @@ describe('FavoriteTokenCard', () => {
})
})
it('falls back to token price if token project price is not available', async () => {
const { resolvers: modifiedResolvers } = queryResolvers({
token: () => ({
...favoriteToken,
project: { ...favoriteToken.project, markets: [] },
}),
})
const { queryByText } = render(<FavoriteTokenCard {...defaultProps} />, { resolvers: modifiedResolvers })
await waitFor(() => {
expect(queryByText('$12,345.67')).toBeTruthy()
expect(queryByText('4.56%')).toBeTruthy()
})
})
it('navigates to the token details screen when pressed', async () => {
const { findByTestId } = render(<FavoriteTokenCard {...defaultProps} />, { resolvers })
......
......@@ -6,17 +6,15 @@ 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 { Loader } from 'src/components/loading/loaders'
import { disableOnPress } from 'src/utils/disableOnPress'
import { usePollOnFocusOnly } from 'src/utils/hooks'
import { AnimatedTouchableArea, Flex, Loader, Text, useIsDarkMode, useShadowPropsShort, useSporeColors } from 'ui/src'
import { AnimatedTouchableArea, Flex, 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 { borderRadii, imageSizes, opacify } from 'ui/src/theme'
import { TokenLogo } from 'uniswap/src/components/CurrencyLogo/TokenLogo'
import { PollingInterval } from 'uniswap/src/constants/misc'
import {
FavoriteTokenCardQuery,
useFavoriteTokenCardQuery,
} from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks'
import { useFavoriteTokenCardQuery } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks'
import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains'
import { fromGraphQLChain } from 'uniswap/src/features/chains/utils'
import { currencyIdToContractInput } from 'uniswap/src/features/dataApi/utils'
......@@ -68,10 +66,8 @@ function FavoriteTokenCard({
// Mirror behavior in top tokens list, use first chain the token is on for the symbol
const chainId = fromGraphQLChain(token?.chain) ?? defaultChainId
// Coingecko price is more accurate but lacks long tail tokens
// Uniswap price comes from Uniswap pools, which may be updated less frequently
const { price, pricePercentChange } = getCoingeckoPrice(token) ?? getUniswapPrice(token)
const priceFormatted = convertFiatAmountFormatted(price, NumberType.FiatTokenPrice)
const price = convertFiatAmountFormatted(token?.market?.price?.value, NumberType.FiatTokenPrice)
const pricePercentChange = token?.market?.pricePercentChange?.value
const onRemove = useCallback(() => {
if (currencyId) {
......@@ -102,7 +98,9 @@ function FavoriteTokenCard({
const shadowProps = useShadowPropsShort()
const priceLoading = isNonPollingRequestInFlight(networkStatus)
if (isNonPollingRequestInFlight(networkStatus)) {
return <Loader.Favorite height={FAVORITE_TOKEN_CARD_LOADER_HEIGHT} />
}
return (
<AnimatedFlex borderRadius="$rounded16" style={animatedDragStyle}>
......@@ -140,31 +138,15 @@ function FavoriteTokenCard({
<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"
/>
)}
<Text adjustsFontSizeToFit numberOfLines={1} variant="heading3">
{price}
</Text>
<RelativeChange
arrowSize="$icon.16"
change={pricePercentChange ?? undefined}
semanticColor={true}
variant="subheading2"
/>
</Flex>
</Flex>
</AnimatedTouchableArea>
......@@ -173,29 +155,4 @@ function FavoriteTokenCard({
)
}
function getCoingeckoPrice(token?: FavoriteTokenCardQuery['token']): {
price: number | undefined
pricePercentChange: number | undefined
} | null {
const market = token?.project?.markets?.[0]
if (!market?.price?.value || !market?.pricePercentChange24h?.value) {
return null
}
return {
price: market.price.value,
pricePercentChange: market.pricePercentChange24h.value,
}
}
function getUniswapPrice(token?: FavoriteTokenCardQuery['token']): {
price: number | undefined
pricePercentChange: number | undefined
} {
return {
price: token?.market?.price?.value,
pricePercentChange: token?.market?.pricePercentChange?.value,
}
}
export default memo(FavoriteTokenCard)
......@@ -96,10 +96,10 @@ export function FavoriteTokensGrid({ showLoading, ...rest }: FavoriteTokensGridP
function FavoriteTokensGridLoader(): JSX.Element {
return (
<Flex row>
<Flex mx="$spacing4" style={ITEM_FLEX}>
<Flex m="$spacing4" style={ITEM_FLEX}>
<Loader.Favorite contrast height={FAVORITE_TOKEN_CARD_LOADER_HEIGHT} />
</Flex>
<Flex mx="$spacing4" style={ITEM_FLEX}>
<Flex m="$spacing4" style={ITEM_FLEX}>
<Loader.Favorite contrast height={FAVORITE_TOKEN_CARD_LOADER_HEIGHT} />
</Flex>
</Flex>
......
import { SortButton } from 'src/components/explore/SortButton'
import { act, render } from 'src/test/test-utils'
import { CustomRankingType, RankingType } from 'uniswap/src/data/types'
import { ExploreOrderBy } from 'wallet/src/features/wallet/types'
import { CustomRankingType, ExploreOrderBy, RankingType } from 'wallet/src/features/wallet/types'
jest.mock('react-native-context-menu-view', () => {
// Use the actual implementation of `react-native-context-menu-view` as the mock implementation
......
......@@ -15,12 +15,11 @@ import {
import { iconSizes } from 'ui/src/theme'
import { ActionSheetDropdown } from 'uniswap/src/components/dropdowns/ActionSheetDropdown'
import { MenuItemProp } from 'uniswap/src/components/modals/ActionSheetModal'
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'
import { CustomRankingType, ExploreOrderBy, RankingType } from 'wallet/src/features/wallet/types'
const MIN_MENU_ITEM_WIDTH = 220
......
......@@ -9,11 +9,11 @@ import { Flex, Loader } from 'ui/src'
import { ProtectionResult, SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks'
import { ALL_NETWORKS_ARG } from 'uniswap/src/data/rest/base'
import { useTokenRankingsQuery } from 'uniswap/src/data/rest/tokenRankings'
import { RankingType } from 'uniswap/src/data/types'
import { UniverseChainId } from 'uniswap/src/features/chains/types'
import { fromGraphQLChain } from 'uniswap/src/features/chains/utils'
import { TokenList } from 'uniswap/src/features/dataApi/types'
import { SearchResultType, TokenSearchResult } from 'uniswap/src/features/search/SearchResult'
import { RankingType } from 'wallet/src/features/wallet/types'
const MAX_TOKEN_RESULTS_AMOUNT = 8
......
......@@ -28,14 +28,14 @@ export function useWalletSearchResults(
address: dotEthAddress,
name: dotEthName,
loading: dotEthLoading,
} = useENS({ nameOrAddress: querySkippedIfValidAddress, autocompleteDomain: true })
} = useENS(UniverseChainId.Mainnet, querySkippedIfValidAddress, true)
// Search for exact match for ENS if not a valid address
const {
address: ensAddress,
name: ensName,
loading: ensLoading,
} = useENS({ nameOrAddress: querySkippedIfValidAddress, autocompleteDomain: false })
} = useENS(UniverseChainId.Mainnet, querySkippedIfValidAddress, false)
// Search for matching Unitag by name
const { unitag: unitagByName, loading: unitagLoading } = useUnitagByName(query)
......
......@@ -12,8 +12,6 @@ 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 { usePerformanceLogger } from 'utilities/src/logger/usePerformanceLogger'
import { isAndroid } from 'utilities/src/platform'
import { ScannerModalState } from 'wallet/src/components/QRCodeScanner/constants'
import { useActivityData } from 'wallet/src/features/activity/useActivityData'
......@@ -59,8 +57,6 @@ export const ActivityTab = memo(
onPressEmptyState: onPressReceive,
})
usePerformanceLogger(DDRumManualTiming.RenderActivityTabList, [])
const refreshControl = useMemo(() => {
return (
<RefreshControl
......
......@@ -7,13 +7,10 @@ import { FundWalletModal } from 'src/components/home/introCards/FundWalletModal'
import { openModal } from 'src/features/modals/modalSlice'
import { Flex } from 'ui/src'
import { Buy, ShieldCheck, UniswapLogo } from 'ui/src/components/icons'
import { UnichainIntroModal } from 'uniswap/src/components/unichain/UnichainIntroModal'
import { AccountType } from 'uniswap/src/features/accounts/types'
import { UniverseChainId } from 'uniswap/src/features/chains/types'
import { ElementName, ModalName, WalletEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { OnboardingCardLoggingName } from 'uniswap/src/features/telemetry/types'
import { CurrencyField } from 'uniswap/src/types/currency'
import { ImportType, OnboardingEntryPoint } from 'uniswap/src/types/onboarding'
import { MobileScreens, OnboardingScreens, UnitagScreens } from 'uniswap/src/types/screens/mobile'
import {
......@@ -24,17 +21,16 @@ import {
} from 'wallet/src/components/introCards/IntroCard'
import { INTRO_CARD_MIN_HEIGHT, IntroCardStack } from 'wallet/src/components/introCards/IntroCardStack'
import { useSharedIntroCards } from 'wallet/src/components/introCards/useSharedIntroCards'
import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext'
import { selectHasViewedWelcomeWalletCard } from 'wallet/src/features/behaviorHistory/selectors'
import { setHasViewedWelcomeWalletCard } from 'wallet/src/features/behaviorHistory/slice'
import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks'
type OnboardingIntroCardStackProps = {
isLoading?: boolean
showEmptyWalletState: boolean
hasTokens: boolean
}
export function OnboardingIntroCardStack({
showEmptyWalletState,
hasTokens,
isLoading = false,
}: OnboardingIntroCardStackProps): JSX.Element | null {
const { t } = useTranslation()
......@@ -47,8 +43,6 @@ export function OnboardingIntroCardStack({
const welcomeCardTitle = t('onboarding.home.intro.welcome.title')
const hasViewedWelcomeWalletCard = useSelector(selectHasViewedWelcomeWalletCard)
const { navigateToSwapFlow } = useWalletNavigation()
const navigateToUnitagClaim = useCallback(() => {
navigate(MobileScreens.UnitagStack, {
screen: UnitagScreens.ClaimUnitag,
......@@ -68,15 +62,14 @@ export function OnboardingIntroCardStack({
)
}, [dispatch, address])
const [showFundModal, setShowFundModal] = useState(false)
const [showUnichainIntroModal, setShowUnichainIntroModal] = useState(false)
const { cards: sharedCards } = useSharedIntroCards({
showUnichainModal: () => setShowUnichainIntroModal(true),
hasTokens,
navigateToUnitagClaim,
navigateToUnitagIntro,
})
const [showFundModal, setShowFundModal] = useState(false)
const cards = useMemo((): IntroCardProps[] => {
const output: IntroCardProps[] = []
......@@ -85,7 +78,7 @@ export function OnboardingIntroCardStack({
return output
}
if (showEmptyWalletState) {
if (!hasTokens) {
output.push({
loggingName: OnboardingCardLoggingName.FundWallet,
graphic: {
......@@ -149,7 +142,7 @@ export function OnboardingIntroCardStack({
}
return output
}, [hasBackups, showEmptyWalletState, hasViewedWelcomeWalletCard, isSignerAccount, sharedCards, t, welcomeCardTitle])
}, [hasBackups, hasTokens, hasViewedWelcomeWalletCard, isSignerAccount, sharedCards, t, welcomeCardTitle])
const handleSwiped = useCallback(
(_card: IntroCardProps, index: number) => {
......@@ -167,31 +160,15 @@ export function OnboardingIntroCardStack({
[cards, dispatch, hasViewedWelcomeWalletCard, welcomeCardTitle],
)
const UnichainIntroModalInstance = useMemo((): JSX.Element => {
return (
<UnichainIntroModal
openSwapFlow={() =>
navigateToSwapFlow({ openTokenSelector: CurrencyField.OUTPUT, outputChainId: UniverseChainId.Unichain })
}
onClose={() => setShowUnichainIntroModal(false)}
/>
)
}, [navigateToSwapFlow])
if (cards.length) {
return (
<Flex pt="$spacing12">
{isLoading ? <Flex height={INTRO_CARD_MIN_HEIGHT} /> : <IntroCardStack cards={cards} onSwiped={handleSwiped} />}
{showFundModal && <FundWalletModal onClose={() => setShowFundModal(false)} />}
{showUnichainIntroModal && UnichainIntroModalInstance}
</Flex>
)
}
if (showUnichainIntroModal) {
return UnichainIntroModalInstance
}
return null
}
import React from 'react'
import { SvgProps } from 'react-native-svg'
import { useIsDarkMode } from 'ui/src'
import { IconSizeTokens } from 'ui/src/theme'
import { UNIVERSE_CHAIN_LOGO } from 'uniswap/src/assets/chainLogos'
import { getChainInfo } from 'uniswap/src/features/chains/chainInfo'
import { useBlockExplorerLogo } from 'uniswap/src/features/chains/logos'
import { UniverseChainId } from 'uniswap/src/features/chains/types'
type IconComponentProps = SvgProps & { size?: IconSizeTokens | number }
......@@ -11,10 +12,11 @@ const iconsCache = new Map<UniverseChainId, React.FC<IconComponentProps>>()
function buildIconComponent(chainId: UniverseChainId): React.FC<IconComponentProps> {
const explorer = getChainInfo(chainId).explorer
const explorerLogos = UNIVERSE_CHAIN_LOGO[chainId].explorer
const Component = ({ size }: IconComponentProps): JSX.Element => {
const Logo = useBlockExplorerLogo(chainId)
return <Logo size={size} />
const isDarkMode = useIsDarkMode()
return isDarkMode ? <explorerLogos.logoDark size={size} /> : <explorerLogos.logoLight size={size} />
}
Component.displayName = `BlockExplorerIcon_${explorer.name}`
iconsCache.set(chainId, Component)
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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