ci(release): publish latest release

parent aa1efb7f
diff --git a/dist/esm/Popover.mjs b/dist/esm/Popover.mjs
index ed2dc72f632ed2adab30a9b7c25e79d955ffac5d..6c4f2046942c45908b8f37f66c2e56ef2af1238d 100644
--- a/dist/esm/Popover.mjs
+++ b/dist/esm/Popover.mjs
@@ -10,12 +10,11 @@ import { FloatingOverrideContext } from "@tamagui/floating";
import { FocusScope } from "@tamagui/focus-scope";
import { composeEventHandlers, withStaticProperties } from "@tamagui/helpers";
import { Popper, PopperAnchor, PopperArrow, PopperContent, PopperContentFrame, PopperContext, usePopperContext } from "@tamagui/popper";
-import { Portal, resolveViewZIndex } from "@tamagui/portal";
+import { Portal } from "@tamagui/portal";
import { RemoveScroll } from "@tamagui/remove-scroll";
import { Sheet, SheetController } from "@tamagui/sheet";
import { YStack } from "@tamagui/stacks";
import { useControllableState } from "@tamagui/use-controllable-state";
-import { StackZIndexContext } from "@tamagui/z-index-stack";
import * as React from "react";
import { Platform, ScrollView } from "react-native-web";
import { useFloatingContext } from "./useFloatingContext.mjs";
@@ -49,14 +48,13 @@ const POPOVER_SCOPE = "PopoverScope",
composedTriggerRef = useComposedRefs(forwardedRef, context.triggerRef);
if (!props.children) return null;
const trigger = /* @__PURE__ */jsx(View, {
- "aria-expanded": context.open,
- "data-state": getState(context.open),
- ...rest,
- ref: composedTriggerRef,
- onPress: composeEventHandlers(props.onPress, context.onOpenToggle)
- });
- if (anchorTo) {
- const virtualRef = {
+ "aria-expanded": context.open,
+ "data-state": getState(context.open),
+ ...rest,
+ ref: composedTriggerRef,
+ onPress: composeEventHandlers(props.onPress, context.onOpenToggle)
+ }),
+ virtualRef = React.useMemo(() => anchorTo ? {
current: {
getBoundingClientRect: () => isWeb ? DOMRect.fromRect(anchorTo) : anchorTo,
...(!isWeb && {
@@ -64,16 +62,13 @@ const POPOVER_SCOPE = "PopoverScope",
measureInWindow: c => c(anchorTo?.x, anchorTo?.y, anchorTo?.width, anchorTo?.height)
})
}
- };
- return /* @__PURE__ */jsx(PopperAnchor, {
- virtualRef,
- __scopePopper: __scopePopover || POPOVER_SCOPE,
- children: trigger
- });
- }
+ } : null, [context.anchorTo, anchorTo?.x, anchorTo?.y, anchorTo?.x, anchorTo?.height, anchorTo?.width]);
return context.hasCustomAnchor ? trigger : /* @__PURE__ */jsx(PopperAnchor, {
+ ...(virtualRef && {
+ virtualRef
+ }),
__scopePopper: __scopePopover || POPOVER_SCOPE,
- asChild: !0,
+ asChild: rest.asChild,
children: trigger
});
}),
@@ -143,7 +138,7 @@ function PopoverContentPortal(props) {
const {
__scopePopover
} = props,
- zIndex = props.zIndex,
+ zIndex = props.zIndex ?? 15e4,
context = usePopoverContext(__scopePopover),
popperContext = usePopperContext(__scopePopover || POPOVER_SCOPE),
themeName = useThemeName(),
@@ -156,7 +151,6 @@ function PopoverContentPortal(props) {
adaptContext,
children: props.children
})), /* @__PURE__ */jsx(Portal, {
- stackZIndex: !0,
zIndex,
children: /* @__PURE__ */jsxs(Theme, {
forceClassName: !0,
@@ -164,10 +158,7 @@ function PopoverContentPortal(props) {
children: [!!context.open && !context.breakpointActive && /* @__PURE__ */jsx(YStack, {
fullscreen: !0,
onPress: composeEventHandlers(props.onPress, context.onOpenToggle)
- }), /* @__PURE__ */jsx(StackZIndexContext, {
- zIndex: resolveViewZIndex(zIndex),
- children: contents
- })]
+ }), contents]
})
});
}
@@ -395,4 +386,4 @@ const PopoverSheetController = ({
return context.open === !1 ? !1 : isAdapted;
};
export { Popover, PopoverAnchor, PopoverArrow, PopoverClose, PopoverContent, PopoverContext, PopoverTrigger, usePopoverContext };
-//# sourceMappingURL=Popover.mjs.map
+
diff --git a/dist/esm/useStackedZIndex.mjs b/dist/esm/useStackedZIndex.mjs
index a78e5c3ef76034648d57483eb7fabd944815e466..4935bcfcdc35c8e7a494a79c6d507aa526672819 100644
--- a/dist/esm/useStackedZIndex.mjs
+++ b/dist/esm/useStackedZIndex.mjs
@@ -1,31 +1,23 @@
-import { useContext, useEffect, useId, useMemo } from "react";
-import { ZIndexHardcodedContext, ZIndexStackContext } from "./context.mjs";
-const ZIndicesByContext = {},
+import { useEffect, useId, useMemo } from "react";
+const CurrentPortalZIndices = {},
useStackedZIndex = props => {
const {
stackZIndex,
- zIndex: zIndexProp
+ zIndex: zIndexProp = 1e3
} = props,
id = useId(),
- stackingContextLevel = useContext(ZIndexStackContext),
- hardcoded = useContext(ZIndexHardcodedContext);
- ZIndicesByContext[stackingContextLevel] ||= {};
- const stackContext = ZIndicesByContext[stackingContextLevel],
zIndex = useMemo(() => {
- if (typeof zIndexProp == "number") return zIndexProp;
if (stackZIndex) {
- if (hardcoded) return hardcoded + 1;
- const highest = Object.values(stackContext).reduce((acc, cur) => Math.max(acc, cur), 0),
- found = stackingContextLevel * 5e3 + highest + 1;
- return typeof stackZIndex == "number" ? stackZIndex + found : found;
+ const highest = Object.values(CurrentPortalZIndices).reduce((acc, cur) => Math.max(acc, cur), 0);
+ return typeof stackZIndex == "number" ? Math.max(stackZIndex, highest + 1) : highest + 1;
}
- return 1;
- }, [stackingContextLevel, zIndexProp, stackZIndex]);
+ if (zIndexProp) return zIndexProp;
+ }, [stackZIndex]);
return useEffect(() => {
- if (stackZIndex) return stackContext[id] = zIndex, () => {
- delete stackContext[id];
+ if (typeof stackZIndex == "number") return CurrentPortalZIndices[id] = stackZIndex, () => {
+ delete CurrentPortalZIndices[id];
};
- }, [zIndex]), zIndex;
+ }, [stackZIndex]), zIndex;
};
export { useStackedZIndex };
//# sourceMappingURL=useStackedZIndex.mjs.map
diff --git a/dist/esm/useStackedZIndex.mjs.map b/dist/esm/useStackedZIndex.mjs.map
index 655e7d2cc4a905722f7f54eb9bd53c2a2e50a4c2..d41e5caa0cbd0ece2b5e15aa5ab65f178a6e4e57 100644
--- a/dist/esm/useStackedZIndex.mjs.map
+++ b/dist/esm/useStackedZIndex.mjs.map
@@ -1 +1 @@
-{"version":3,"names":["useContext","useEffect","useId","useMemo","ZIndexHardcodedContext","ZIndexStackContext","ZIndicesByContext","useStackedZIndex","props","stackZIndex","zIndex","zIndexProp","id","stackingContextLevel","hardcoded","stackContext","highest","Object","values","reduce","acc","cur","Math","max","found"],"sources":["../../src/useStackedZIndex.tsx"],"sourcesContent":[null],"mappings":"AAAA,SAASA,UAAA,EAAYC,SAAA,EAAWC,KAAA,EAAOC,OAAA,QAAe;AACtD,SAASC,sBAAA,EAAwBC,kBAAA,QAA0B;AAG3D,MAAMC,iBAAA,GAA4D,CAAC;EAEtDC,gBAAA,GAAoBC,KAAA,IAG3B;IACJ,MAAM;QAAEC,WAAA;QAAaC,MAAA,EAAQC;MAAW,IAAIH,KAAA;MACtCI,EAAA,GAAKV,KAAA,CAAM;MACXW,oBAAA,GAAuBb,UAAA,CAAWK,kBAAkB;MACpDS,SAAA,GAAYd,UAAA,CAAWI,sBAAsB;IAEnDE,iBAAA,CAAkBO,oBAAoB,MAAM,CAAC;IAC7C,MAAME,YAAA,GAAeT,iBAAA,CAAkBO,oBAAoB;MAErDH,MAAA,GAASP,OAAA,CAAQ,MAAM;QAC3B,IAAI,OAAOQ,UAAA,IAAe,UACxB,OAAOA,UAAA;QAET,IAAIF,WAAA,EAAa;UACf,IAAIK,SAAA,EACF,OAAOA,SAAA,GAAY;UAGrB,MAAME,OAAA,GAAUC,MAAA,CAAOC,MAAA,CAAOH,YAAY,EAAEI,MAAA,CAC1C,CAACC,GAAA,EAAKC,GAAA,KAAQC,IAAA,CAAKC,GAAA,CAAIH,GAAA,EAAKC,GAAG,GAC/B,CACF;YAGMG,KAAA,GAAQX,oBAAA,GAAuB,MAAOG,OAAA,GAAU;UAGtD,OAAO,OAAOP,WAAA,IAAgB,WAAWA,WAAA,GAAce,KAAA,GAAQA,KAAA;QACjE;QAEA,OAAO;MACT,GAAG,CAACX,oBAAA,EAAsBF,UAAA,EAAYF,WAAW,CAAC;IAElD,OAAAR,SAAA,CAAU,MAAM;MACd,IAAIQ,WAAA,EACF,OAAAM,YAAA,CAAaH,EAAE,IAAIF,MAAA,EACZ,MAAM;QACX,OAAOK,YAAA,CAAaH,EAAE;MACxB;IAEJ,GAAG,CAACF,MAAM,CAAC,GAEJA,MAAA;EACT","ignoreList":[]}
\ No newline at end of file
+{"version":3,"names":["useEffect","useId","useMemo","CurrentPortalZIndices","useStackedZIndex","props","stackZIndex","zIndex","zIndexProp","id","highest","Object","values","reduce","acc","cur","Math","max"],"sources":["../../src/useStackedZIndex.tsx"],"sourcesContent":[null],"mappings":"AAAA,SAASA,SAAA,EAAWC,KAAA,EAAOC,OAAA,QAAe;AAE1C,MAAMC,qBAAA,GAAgD,CAAC;EAE1CC,gBAAA,GAAoBC,KAAA,IAG3B;IACJ,MAAM;QAAEC,WAAA;QAAaC,MAAA,EAAQC,UAAA,GAAa;MAAK,IAAIH,KAAA;MAC7CI,EAAA,GAAKR,KAAA,CAAM;MAEXM,MAAA,GAASL,OAAA,CAAQ,MAAM;QAC3B,IAAII,WAAA,EAAa;UACf,MAAMI,OAAA,GAAUC,MAAA,CAAOC,MAAA,CAAOT,qBAAqB,EAAEU,MAAA,CACnD,CAACC,GAAA,EAAKC,GAAA,KAAQC,IAAA,CAAKC,GAAA,CAAIH,GAAA,EAAKC,GAAG,GAC/B,CACF;UACA,OAAI,OAAOT,WAAA,IAAgB,WAClBU,IAAA,CAAKC,GAAA,CAAIX,WAAA,EAAaI,OAAA,GAAU,CAAC,IAGnCA,OAAA,GAAU;QACnB;QACA,IAAIF,UAAA,EACF,OAAOA,UAAA;MAEX,GAAG,CAACF,WAAW,CAAC;IAEhB,OAAAN,SAAA,CAAU,MAAM;MACd,IAAI,OAAOM,WAAA,IAAgB,UACzB,OAAAH,qBAAA,CAAsBM,EAAE,IAAIH,WAAA,EACrB,MAAM;QACX,OAAOH,qBAAA,CAAsBM,EAAE;MACjC;IAEJ,GAAG,CAACH,WAAW,CAAC,GAETC,MAAA;EACT","ignoreList":[]}
* @uniswap/web-admins
We are back with some new updates! Here’s the latest:
IPFS hash of the deployment:
- CIDv0: `Qmb2Gd1x82BBWBGVASZiryc8pFJWiJm4rSddWExnKHpkka`
- CIDv1: `bafybeif4okadgppbk3up2camp6aughhbr6gievbj3wskn6s57vzmoxf4im`
Honeypot Detection: Using Blockaid’s data, we now identify potentially malicious honeypot tokens and show users a token warning when interacting with them.
The latest release is always mirrored at [app.uniswap.org](https://app.uniswap.org).
New pink!: Our accent pink color has been updated across the app for better accessibility and better vibes.
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://bafybeif4okadgppbk3up2camp6aughhbr6gievbj3wskn6s57vzmoxf4im.ipfs.dweb.link/
- [ipfs://Qmb2Gd1x82BBWBGVASZiryc8pFJWiJm4rSddWExnKHpkka/](ipfs://Qmb2Gd1x82BBWBGVASZiryc8pFJWiJm4rSddWExnKHpkka/)
### 5.79.3 (2025-04-23)
Other changes:
- Removed balances from spam tokens, to clean up the portfolio view
- Various bug fixes and performance improvements
mobile/1.49
\ No newline at end of file
web/5.79.3
\ No newline at end of file
......@@ -15,7 +15,8 @@
"@tamagui/core": "1.125.17",
"@types/uuid": "9.0.1",
"@uniswap/analytics-events": "2.42.0",
"@uniswap/uniswapx-sdk": "3.0.0-beta.3",
"@uniswap/client-embeddedwallet": "0.0.16",
"@uniswap/uniswapx-sdk": "3.0.0-beta.7",
"@uniswap/universal-router-sdk": "4.19.5",
"@uniswap/v3-sdk": "3.25.2",
"@uniswap/v4-sdk": "1.21.2",
......@@ -23,7 +24,6 @@
"eslint-plugin-rulesdir": "0.2.2",
"ethers": "5.7.2",
"eventemitter3": "5.0.1",
"framer-motion": "10.17.6",
"i18next": "23.10.0",
"node-polyfill-webpack-plugin": "2.0.1",
"react": "18.3.1",
......@@ -56,7 +56,7 @@
"@pmmmwh/react-refresh-webpack-plugin": "0.5.11",
"@testing-library/dom": "10.4.0",
"@testing-library/react": "16.1.0",
"@types/chrome": "0.0.254",
"@types/chrome": "0.0.304",
"@types/jest": "29.5.14",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.1",
......@@ -78,7 +78,6 @@
"mini-css-extract-plugin": "2.9.1",
"react-refresh": "0.14.0",
"serve": "14.2.4",
"statsig-js": "4.41.0",
"style-loader": "3.3.2",
"swc-loader": "0.2.6",
"tamagui-loader": "1.125.17",
......
......@@ -31,3 +31,15 @@ html {
-webkit-mask-position: -50%;
}
}
@keyframes cloud-float-animation {
0% {
transform: translateY(-8px);
}
50% {
transform: translateY(8px);
}
100% {
transform: translateY(-8px);
}
}
import { Suspense, lazy } from 'react'
import { Flex } from 'ui/src'
import { Flag } from 'ui/src/components/icons/Flag'
import { Modal } from 'uniswap/src/components/modals/Modal'
import { ModalName } from 'uniswap/src/features/telemetry/constants'
import { useBooleanState } from 'utilities/src/react/useBooleanState'
const DevMenuScreen = lazy(() =>
import('src/app/features/settings/DevMenuScreen').then((module) => ({ default: module.DevMenuScreen })),
)
export function DevMenuModal(): JSX.Element {
const { value: isOpen, setTrue: openModal, setFalse: closeModal } = useBooleanState(false)
return (
<>
<Flex
$platform-web={{
position: 'fixed',
}}
bottom="$spacing24"
p="$spacing4"
left="$spacing24"
zIndex={Number.MAX_SAFE_INTEGER}
borderWidth={1}
borderColor="$neutral2"
borderRadius="$rounded4"
cursor="pointer"
backgroundColor="$surface1"
hoverStyle={{
backgroundColor: '$surface1Hovered',
}}
onPress={openModal}
>
<Flag size="$icon.24" color="$neutral2" />
</Flex>
{isOpen && (
<Modal name={ModalName.FeatureFlags} onClose={closeModal}>
<Suspense>
<DevMenuScreen />
</Suspense>
</Modal>
)}
</>
)
}
......@@ -13,6 +13,7 @@ import { Complete } from 'src/app/features/onboarding/Complete'
import {
CreateOnboardingSteps,
ImportOnboardingSteps,
ImportPasskeySteps,
OnboardingStepsProvider,
ResetSteps,
ScanOnboardingSteps,
......@@ -25,6 +26,7 @@ import { PasswordCreate } from 'src/app/features/onboarding/create/PasswordCreat
import { TestMnemonic } from 'src/app/features/onboarding/create/TestMnemonic'
import { ViewMnemonic } from 'src/app/features/onboarding/create/ViewMnemonic'
import { ImportMnemonic } from 'src/app/features/onboarding/import/ImportMnemonic'
import { InitiatePasskeyAuth } from 'src/app/features/onboarding/import/InitiatePasskeyAuth'
import { SelectImportMethod } from 'src/app/features/onboarding/import/SelectImportMethod'
import { SelectWallets } from 'src/app/features/onboarding/import/SelectWallets'
import { IntroScreen } from 'src/app/features/onboarding/intro/IntroScreen'
......@@ -101,6 +103,21 @@ const allRoutes = [
/>
),
},
{
path: OnboardingRoutes.ImportPasskey,
element: (
<OnboardingStepsProvider
key={OnboardingRoutes.ImportPasskey}
steps={{
[ImportPasskeySteps.InitiatePasskeyAuth]: <InitiatePasskeyAuth />,
// TODO(WALL-6383): modify this flow to ask user to verify their seed phrase.
[ImportOnboardingSteps.Password]: <PasswordImport flow={ExtensionOnboardingFlow.Import} />,
[ImportOnboardingSteps.Select]: <SelectWallets flow={ExtensionOnboardingFlow.Import} />,
[ImportOnboardingSteps.Complete]: <Complete flow={ExtensionOnboardingFlow.Import} />,
}}
/>
),
},
{
path: OnboardingRoutes.Import,
element: (
......
import { useEffect, useState } from 'react'
import { getStatsigEnvironmentTier } from 'src/app/version'
import Statsig from 'statsig-js' // Use JS package for browser
import { config } from 'uniswap/src/config'
import { uniswapUrls } from 'uniswap/src/constants/urls'
import { StatsigProviderWrapper } from 'uniswap/src/features/gating/StatsigProviderWrapper'
import { StatsigCustomAppValue } from 'uniswap/src/features/gating/constants'
import { StatsigOptions, StatsigProvider, StatsigUser } from 'uniswap/src/features/gating/sdk/statsig'
import { StatsigClient, StatsigUser } from 'uniswap/src/features/gating/sdk/statsig'
import { statsigBaseConfig } from 'uniswap/src/features/gating/statsigBaseConfig'
import { initializeDatadog } from 'uniswap/src/utils/datadog'
import { getUniqueId } from 'utilities/src/device/getUniqueId'
import { logger } from 'utilities/src/logger/logger'
import { useAsyncData } from 'utilities/src/react/hooks'
async function getStatsigUser(): Promise<StatsigUser> {
......@@ -27,6 +27,7 @@ export function ExtensionStatsigProvider({
appName: string
}): JSX.Element {
const { data: storedUser } = useAsyncData(getStatsigUser)
const [initFinished, setInitFinished] = useState(false)
const [user, setUser] = useState<StatsigUser>({
userID: undefined,
custom: {
......@@ -34,7 +35,6 @@ export function ExtensionStatsigProvider({
},
appVersion: process.env.VERSION,
})
const [initFinished, setInitFinished] = useState(false)
useEffect(() => {
if (storedUser && initFinished) {
......@@ -42,31 +42,23 @@ export function ExtensionStatsigProvider({
}
}, [storedUser, initFinished])
const options: StatsigOptions = {
environment: {
tier: getStatsigEnvironmentTier(),
},
api: uniswapUrls.statsigProxyUrl,
disableAutoMetricsLogging: true,
disableErrorLogging: true,
initCompletionCallback: () => {
setInitFinished(true)
initializeDatadog(appName).catch(() => undefined)
},
const onStatsigInit = (): void => {
setInitFinished(true)
initializeDatadog(appName).catch(() => undefined)
}
return (
<StatsigProvider options={options} sdkKey={config.statsigApiKey} user={user} waitForInitialization={false}>
<StatsigProviderWrapper user={user} onInit={onStatsigInit}>
{children}
</StatsigProvider>
</StatsigProviderWrapper>
)
}
export async function initStatSigForBrowserScripts(): Promise<void> {
await Statsig.initialize(config.statsigApiKey, await getStatsigUser(), {
api: uniswapUrls.statsigProxyUrl,
environment: {
tier: getStatsigEnvironmentTier(),
},
const statsigClient = new StatsigClient(config.statsigApiKey, await getStatsigUser(), statsigBaseConfig)
await statsigClient.initializeAsync().catch((error) => {
logger.error(error, {
tags: { file: 'StatsigProvider.tsx', function: 'initStatSigForBrowserScripts' },
})
})
}
......@@ -227,7 +227,7 @@ function DappRequestFooter({
<Flex gap="$spacing8" mt="$spacing8">
{!hasSufficientGas && (
<Flex pb="$spacing8">
<Text color="$DEP_accentWarning" variant="body3">
<Text color="$statusWarning" variant="body3">
{t('swap.warning.insufficientGas.title', {
currencySymbol: nativeBalance?.currency?.symbol ?? '',
})}
......
......@@ -126,13 +126,13 @@ export function* getAccountRequest(request: RequestAccountRequest, senderTabInfo
const accountInfo = yield* call(saveAccount, senderTabInfo)
if (!accountInfo) {
const errorReponse: ErrorResponse = {
const errorResponse: ErrorResponse = {
type: DappResponseType.ErrorResponse,
error: serializeError(providerErrors.unauthorized()),
requestId: request.requestId,
}
yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, errorReponse)
yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, errorResponse)
} else {
const { dappUrl, activeAccount, connectedAddresses, chainId, providerUrl } = accountInfo
......
......@@ -15,6 +15,8 @@ import {
changeChainSaga,
confirmRequest,
confirmRequestNoDappInfo,
handleGetCallsStatus,
handleSendCalls,
handleSendTransaction,
handleSignMessage,
handleSignTypedData,
......@@ -33,6 +35,8 @@ import {
ErrorResponse,
GetAccountRequest,
GetAccountRequestSchema,
GetCallsStatusRequest,
GetCallsStatusRequestSchema,
GetChainIdRequest,
GetChainIdRequestSchema,
GetPermissionsRequest,
......@@ -43,6 +47,8 @@ import {
RequestPermissionsRequestSchema,
RevokePermissionsRequest,
RevokePermissionsRequestSchema,
SendCallsRequest,
SendCallsRequestSchema,
SignMessageRequest,
SignMessageRequestSchema,
SignTypedDataRequest,
......@@ -174,6 +180,18 @@ function* dappRequestApproval({
yield* call(handleSignTypedData, validatedRequest, confirmedRequest.senderTabInfo, confirmedRequest.dappInfo)
break
}
case DappRequestType.SendCalls: {
const validatedRequest: SendCallsRequest = SendCallsRequestSchema.parse(confirmedRequest.dappRequest)
yield* call(handleSendCalls, validatedRequest, confirmedRequest.senderTabInfo, confirmedRequest.dappInfo)
break
}
case DappRequestType.GetCallsStatus: {
const validatedRequest: GetCallsStatusRequest = GetCallsStatusRequestSchema.parse(
confirmedRequest.dappRequest,
)
yield* call(handleGetCallsStatus, validatedRequest, confirmedRequest.senderTabInfo, confirmedRequest.dappInfo)
break
}
// Add more request types here
}
} else if (type === confirmRequestNoDappInfo.type) {
......
/* eslint-disable max-lines */
import { Provider, TransactionResponse } from '@ethersproject/providers'
import { providerErrors, rpcErrors, serializeError } from '@metamask/rpc-errors'
import { createAction } from '@reduxjs/toolkit'
......@@ -12,6 +13,10 @@ import {
DappRequestType,
DappResponseType,
ErrorResponse,
GetCallsStatusRequest,
GetCallsStatusResponse,
SendCallsRequest,
SendCallsResponse,
SendTransactionResponse,
SignMessageRequest,
SignMessageResponse,
......@@ -26,7 +31,13 @@ import { AppRoutes, HomeQueryParams } from 'src/app/navigation/constants'
import { navigate } from 'src/app/navigation/state'
import { dappResponseMessageChannel } from 'src/background/messagePassing/messageChannels'
import { call, put, select, take } from 'typed-redux-saga'
import { hexadecimalStringToInt, toSupportedChainId } from 'uniswap/src/features/chains/utils'
import {
chainIdToHexadecimalString,
hexadecimalStringToInt,
toSupportedChainId,
} from 'uniswap/src/features/chains/utils'
import { FeatureFlags, getFeatureFlagName } from 'uniswap/src/features/gating/flags'
import { getStatsigClient } from 'uniswap/src/features/gating/sdk/statsig'
import { pushNotification } from 'uniswap/src/features/notifications/slice'
import { AppNotificationType } from 'uniswap/src/features/notifications/types'
import {
......@@ -213,7 +224,9 @@ function* handleRequest(requestParams: DappRequestNoDappInfo) {
isConnectedToDapp &&
(ACCOUNT_REQUEST_TYPES.includes(requestParams.dappRequest.type) ||
ACCOUNT_INFO_TYPES.includes(requestParams.dappRequest.type) ||
requestParams.dappRequest.type === DappRequestType.RevokePermissions)
requestParams.dappRequest.type === DappRequestType.RevokePermissions ||
requestParams.dappRequest.type === DappRequestType.SendCalls || // temporarily until we have a real implementation
requestParams.dappRequest.type === DappRequestType.GetCallsStatus)
if (shouldAutoConfirmRequest) {
yield* put(confirmRequest({ ...requestParams, dappInfo }))
......@@ -407,3 +420,86 @@ export function* handleUniswapOpenSidebarRequest(request: UniswapOpenSidebarRequ
}
yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, response)
}
/**
* Handle wallet_sendCalls request
* This method allows dapps to send a batch of calls to the wallet
*/
export function* handleSendCalls(request: SendCallsRequest, { id }: SenderTabInfo) {
const eip5792MethodsEnabled = getStatsigClient().checkGate(getFeatureFlagName(FeatureFlags.Eip5792Methods)) ?? false
if (!eip5792MethodsEnabled) {
const errorResponse: ErrorResponse = {
type: DappResponseType.ErrorResponse,
error: serializeError(rpcErrors.methodNotSupported()),
requestId: request.requestId,
}
yield* call(dappResponseMessageChannel.sendMessageToTab, id, errorResponse)
return
}
// Mock response data
// TODO: Implement actual response data
const response: SendCallsResponse = {
type: DappResponseType.SendCallsResponse,
requestId: request.requestId,
response: {
id: request.sendCallsParams.id || 'mock-batch-id (will be txID or `id` from request)',
capabilities: request.sendCallsParams.capabilities || {},
},
}
yield* call(dappResponseMessageChannel.sendMessageToTab, id, response)
}
/**
* Handle wallet_getCallsStatus request
* This method returns the status of a call batch that was sent via wallet_sendCalls
*/
export function* handleGetCallsStatus(request: GetCallsStatusRequest, { id }: SenderTabInfo, dappInfo: DappInfo) {
const eip5792MethodsEnabled = getStatsigClient().checkGate(getFeatureFlagName(FeatureFlags.Eip5792Methods)) ?? false
if (!eip5792MethodsEnabled) {
const errorResponse: ErrorResponse = {
type: DappResponseType.ErrorResponse,
error: serializeError(rpcErrors.methodNotSupported()),
requestId: request.requestId,
}
yield* call(dappResponseMessageChannel.sendMessageToTab, id, errorResponse)
return
}
// Mock response data
// TODO: Implement actual response data
const response: GetCallsStatusResponse = {
type: DappResponseType.GetCallsStatusResponse,
requestId: request.requestId,
response: {
version: '1.0',
id: request.batchId,
chainId: dappInfo.lastChainId ? chainIdToHexadecimalString(dappInfo.lastChainId) : '0x1',
status: 100,
receipts: [
{
logs: [
{
address: '0x1234567890123456789012345678901234567890',
data: '0x0000000000000000000000000000000000000000000000000000000000000001',
topics: ['0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'],
},
],
status: '0x1', // Success
blockHash: '0x0000000000000000000000000000000000000000000000000000000000000000',
blockNumber: '0x1',
gasUsed: '0x5208', // 21000
transactionHash: '0x0000000000000000000000000000000000000000000000000000000000000000',
},
],
capabilities: {},
},
}
yield* call(dappResponseMessageChannel.sendMessageToTab, id, response)
}
/* eslint-disable import/no-unused-modules */
import { EthereumRpcErrorSchema } from 'src/app/features/dappRequests/types/ErrorTypes'
import { GetCallsStatusResultSchema, SendCallsParamsSchema, SendCallsResultSchema } from 'wallet/src/features/dappRequests/types'
import {
EthersTransactionRequestSchema,
EthersTransactionResponseSchema,
......@@ -7,7 +8,7 @@ import {
import { NonfungiblePositionManagerCallSchema } from 'src/app/features/dappRequests/types/NonfungiblePositionManagerTypes'
import { UniversalRouterCallSchema } from 'src/app/features/dappRequests/types/UniversalRouterTypes'
import { HomeTabs } from 'src/app/navigation/constants'
import { MessageSchema } from 'src/background/messagePassing/messageTypes'
import { MessageSchema } from 'uniswap/src/extension/messagePassing/messageTypes'
import { PermissionRequestSchema, PermissionSchema } from 'src/contentScript/WindowEthereumRequestTypes'
import { z } from 'zod'
......@@ -26,6 +27,8 @@ export enum DappRequestType {
SignTransaction = 'SignTransaction',
SignTypedData = 'SignTypedData',
UniswapOpenSidebar = 'UniswapOpenSidebar',
SendCalls = 'SendCalls',
GetCallsStatus = 'GetCallsStatus',
}
export enum DappResponseType {
......@@ -41,6 +44,8 @@ export enum DappResponseType {
SignTypedDataResponse = 'SignTypedDataResponse',
SignMessageResponse = 'SignMessageResponse',
UniswapOpenSidebarResponse = 'UniswapOpenSidebarResponse',
SendCallsResponse = 'SendCallsResponse',
GetCallsStatusResponse = 'GetCallsStatusResponse',
}
// SCHEMAS + TYPES
......@@ -255,6 +260,30 @@ export const ErrorResponseSchema = BaseDappResponseSchema.extend({
})
export type ErrorResponse = z.infer<typeof ErrorResponseSchema>
export const SendCallsRequestSchema = BaseDappRequestSchema.extend({
type: z.literal(DappRequestType.SendCalls),
sendCallsParams: SendCallsParamsSchema
})
export type SendCallsRequest = z.infer<typeof SendCallsRequestSchema>
export const GetCallsStatusRequestSchema = BaseDappRequestSchema.extend({
type: z.literal(DappRequestType.GetCallsStatus),
batchId: z.string(),
})
export type GetCallsStatusRequest = z.infer<typeof GetCallsStatusRequestSchema>
export const SendCallsResponseSchema = BaseDappResponseSchema.extend({
type: z.literal(DappResponseType.SendCallsResponse),
response: SendCallsResultSchema
})
export type SendCallsResponse = z.infer<typeof SendCallsResponseSchema>
export const GetCallsStatusResponseSchema = BaseDappResponseSchema.extend({
type: z.literal(DappResponseType.GetCallsStatusResponse),
response: GetCallsStatusResultSchema
})
export type GetCallsStatusResponse = z.infer<typeof GetCallsStatusResponseSchema>
export const DappRequestSchema = z.union([
ChangeChainRequestSchema,
GetAccountRequestSchema,
......@@ -268,6 +297,8 @@ export const DappRequestSchema = z.union([
SignTypedDataRequestSchema,
SignTransactionRequestSchema,
UniswapOpenSidebarRequestSchema,
SendCallsRequestSchema,
GetCallsStatusRequestSchema,
])
const DappResponseSchema = z.union([
......@@ -282,6 +313,8 @@ const DappResponseSchema = z.union([
SignTransactionResponseSchema,
SendTransactionResponseSchema,
UniswapOpenSidebarResponseSchema,
SendCallsResponseSchema,
GetCallsStatusResponseSchema,
])
export type DappRequest = z.infer<typeof DappRequestSchema>
......@@ -389,3 +422,18 @@ export function isWrapRequest(request: SendTransactionRequest): request is WrapS
return WrapSendTransactionRequestSchema.safeParse(request).success
}
export function isSendCallsRequest(request: DappRequest): request is SendCallsRequest {
return SendCallsRequestSchema.safeParse(request).success
}
export function isGetCallsStatusRequest(request: DappRequest): request is GetCallsStatusRequest {
return GetCallsStatusRequestSchema.safeParse(request).success
}
export function isValidSendCallsResponse(response: unknown): response is SendCallsResponse {
return SendCallsResponseSchema.safeParse(response).success
}
export function isValidGetCallsStatusResponse(response: unknown): response is GetCallsStatusResponse {
return GetCallsStatusResponseSchema.safeParse(response).success
}
......@@ -7,7 +7,6 @@ import { ActivityTab } from 'src/app/components/tabs/ActivityTab'
import { NftsTab } from 'src/app/components/tabs/NftsTab'
import AppRatingModal from 'src/app/features/appRating/AppRatingModal'
import { useAppRating } from 'src/app/features/appRating/hooks/useAppRating'
import { ForceUpgradeModal } from 'src/app/features/forceUpgrade/ForceUpgradeModal'
import { PortfolioActionButtons } from 'src/app/features/home/PortfolioActionButtons'
import { PortfolioHeader } from 'src/app/features/home/PortfolioHeader'
import { TokenBalanceList } from 'src/app/features/home/TokenBalanceList'
......@@ -199,7 +198,6 @@ export const HomeScreen = memo(function _HomeScreen(): JSX.Element {
</Text>
)}
{appRatingModalVisible && <AppRatingModal onClose={onAppRatingModalClose} />}
<ForceUpgradeModal />
</Flex>
)
})
......
......@@ -9,10 +9,10 @@ import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard'
import { PortfolioBalance, TokenList } from 'uniswap/src/features/dataApi/types'
import { ElementName } from 'uniswap/src/features/telemetry/constants'
import { HiddenTokenInfoModal } from 'uniswap/src/features/transactions/modals/HiddenTokenInfoModal'
import { ExpandoRow } from 'wallet/src/components/ExpandoRow/ExpandoRow'
import { InformationBanner } from 'wallet/src/components/banners/InformationBanner'
import { ContextMenu } from 'wallet/src/components/menu/ContextMenu'
import { isError, isNonPollingRequestInFlight } from 'wallet/src/data/utils'
import { HiddenTokensRow } from 'wallet/src/features/portfolio/HiddenTokensRow'
import { PortfolioEmptyState } from 'wallet/src/features/portfolio/PortfolioEmptyState'
import { TokenBalanceItem } from 'wallet/src/features/portfolio/TokenBalanceItem'
import {
......@@ -152,9 +152,9 @@ const TokenBalanceItemRow = memo(function TokenBalanceItemRow({ item }: { item:
if (item === HIDDEN_TOKEN_BALANCES_ROW) {
return (
<>
<HiddenTokensRow
<ExpandoRow
isExpanded={hiddenTokensExpanded}
numHidden={hiddenTokensCount}
label={t('hidden.tokens.info.text.button', { numHidden: hiddenTokensCount })}
onPress={(): void => {
setHiddenTokensExpanded(!hiddenTokensExpanded)
}}
......
......@@ -8,9 +8,9 @@ import { Flex, Square } from 'ui/src'
import { Person } from 'ui/src/components/icons'
import { iconSizes } from 'ui/src/theme'
import Trace from 'uniswap/src/features/telemetry/Trace'
import { ClaimUnitagContent } from 'uniswap/src/features/unitags/ClaimUnitagContent'
import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension'
import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext'
import { ClaimUnitagContent } from 'wallet/src/features/unitags/ClaimUnitagContent'
export function ClaimUnitagScreen(): JSX.Element {
const { t } = useTranslation()
......
......@@ -14,6 +14,10 @@ export enum SelectImportMethodSteps {
SelectMethod = 'selectMethod',
}
export enum ImportPasskeySteps {
InitiatePasskeyAuth = 'initiatePasskeyAuth',
}
export enum ImportOnboardingSteps {
Mnemonic = 'mnemonic',
Password = 'password',
......@@ -52,7 +56,7 @@ export type Step =
| ScanOnboardingSteps
| ClaimUnitagSteps
| SelectImportMethodSteps
| ImportPasskeySteps
export type OnboardingStepsContextState = {
step: Step
going?: 'forward' | 'backward'
......
import { useEffect, useState } from 'react'
import { useDispatch } from 'react-redux'
import { Outlet } from 'react-router-dom'
import { DevMenuModal } from 'src/app/core/DevMenuModal'
import { StorageWarningModal } from 'src/app/features/warnings/StorageWarningModal'
import { ONBOARDING_BACKGROUND_DARK, ONBOARDING_BACKGROUND_LIGHT } from 'src/assets'
import { onboardingMessageChannel } from 'src/background/messagePassing/messageChannels'
import { OnboardingMessageType } from 'src/background/messagePassing/types/ExtensionMessages'
import { Flex, Image, useIsDarkMode } from 'ui/src'
import { syncAppWithDeviceLanguage } from 'uniswap/src/features/settings/slice'
import { isProdEnv } from 'utilities/src/environment/env'
import { OnboardingContextProvider } from 'wallet/src/features/onboarding/OnboardingContext'
import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testnetMode/hooks/useTestnetModeForLoggingAndAnalytics'
......@@ -32,7 +34,9 @@ export function OnboardingWrapper(): JSX.Element {
return (
<OnboardingContextProvider>
{!isProdEnv() && <DevMenuModal />}
<StorageWarningModal isOnboarding={true} />
<Flex
alignItems="center"
backgroundColor={isHighlighted ? '$DEP_accentSoft' : '$transparent'}
......
import { useTranslation } from 'react-i18next'
import { OptionCard } from 'src/app/components/buttons/OptionCard'
import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen'
import { IMPORT_PASSKEY_STATE_KEY } from 'src/app/features/onboarding/import/InitiatePasskeyAuth'
import { OnboardingRoutes, TopLevelRoutes } from 'src/app/navigation/constants'
import { navigate } from 'src/app/navigation/state'
import { Flex, Square } from 'ui/src'
......@@ -38,7 +39,12 @@ export function SelectImportMethod(): JSX.Element {
Icon={Passkey}
title={t('onboarding.import.selectMethod.passkey.title')}
subtitle={t('onboarding.import.selectMethod.passkey.subtitle')}
onPress={() => {}}
onPress={(): void =>
navigate(`/${TopLevelRoutes.Onboarding}/${OnboardingRoutes.ImportPasskey}`, {
replace: true,
state: { [IMPORT_PASSKEY_STATE_KEY]: true },
})
}
/>
<OptionCard
......
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useMemo } from 'react'
import { useSelector } from 'react-redux'
import { RecipientPanel } from 'src/app/features/send/SendFormScreen/RecipientPanel'
import { ReviewButton } from 'src/app/features/send/SendFormScreen/ReviewButton'
......@@ -7,7 +7,7 @@ import { Modal } from 'uniswap/src/components/modals/Modal'
import { selectHasDismissedLowNetworkTokenWarning } from 'uniswap/src/features/behaviorHistory/selectors'
import { UniverseChainId } from 'uniswap/src/features/chains/types'
import Trace from 'uniswap/src/features/telemetry/Trace'
import { ModalName, SectionName, WalletEventName } from 'uniswap/src/features/telemetry/constants'
import { ModalName, SectionName, UniswapEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { InsufficientNativeTokenWarning } from 'uniswap/src/features/transactions/InsufficientNativeTokenWarning/InsufficientNativeTokenWarning'
import {
......@@ -17,10 +17,11 @@ import {
import { useUSDCValue } from 'uniswap/src/features/transactions/hooks/useUSDCPrice'
import { useUSDTokenUpdater } from 'uniswap/src/features/transactions/hooks/useUSDTokenUpdater'
import { BlockedAddressWarning } from 'uniswap/src/features/transactions/modals/BlockedAddressWarning'
import { LowNativeBalanceModal } from 'uniswap/src/features/transactions/swap/modals/LowNativeBalanceModal'
import { LowNativeBalanceModal } from 'uniswap/src/features/transactions/modals/LowNativeBalanceModal'
import { useIsBlocked } from 'uniswap/src/features/trm/hooks'
import { CurrencyField } from 'uniswap/src/types/currency'
import { createTransactionId } from 'uniswap/src/utils/createTransactionId'
import { useBooleanState } from 'utilities/src/react/useBooleanState'
import { useSendContext } from 'wallet/src/features/transactions/contexts/SendContext'
import { GasFeeRow } from 'wallet/src/features/transactions/send/GasFeeRow'
import { SendAmountInput } from 'wallet/src/features/transactions/send/SendAmountInput'
......@@ -34,7 +35,11 @@ export function SendFormScreen(): JSX.Element {
const colors = useSporeColors()
const hasDismissedLowNetworkTokenWarning = useSelector(selectHasDismissedLowNetworkTokenWarning)
const [showMaxTransferModal, setShowMaxTransferModal] = useState(false)
const {
value: showMaxTransferModal,
setTrue: handleShowMaxTransferModal,
setFalse: handleHideMaxTransferModal,
} = useBooleanState(false)
const {
derivedSendInfo,
......@@ -105,12 +110,12 @@ export function SendFormScreen(): JSX.Element {
const onPressReview = useCallback(() => {
if (!hasDismissedLowNetworkTokenWarning && isMax && currencyInInfo?.currency.isNative) {
sendAnalyticsEvent(WalletEventName.LowNetworkTokenInfoModalOpened, { location: 'send' })
setShowMaxTransferModal(true)
sendAnalyticsEvent(UniswapEventName.LowNetworkTokenInfoModalOpened, { location: 'send' })
handleShowMaxTransferModal()
return
}
goToReview()
}, [goToReview, isMax, hasDismissedLowNetworkTokenWarning, setShowMaxTransferModal, currencyInInfo])
}, [goToReview, isMax, hasDismissedLowNetworkTokenWarning, handleShowMaxTransferModal, currencyInInfo])
const onSetExactAmount = useCallback(
(amount: string) => {
......@@ -126,15 +131,11 @@ export function SendFormScreen(): JSX.Element {
[updateSendForm],
)
const hideLowNativeBalanceWarning = useCallback(() => {
setShowMaxTransferModal(false)
}, [setShowMaxTransferModal])
const onAcknowledgeLowNativeBalanceWarning = useCallback(() => {
hideLowNativeBalanceWarning()
handleHideMaxTransferModal()
goToReview()
}, [hideLowNativeBalanceWarning, goToReview])
}, [handleHideMaxTransferModal, goToReview])
const onHideTokenSelector = useCallback(() => {
updateSendForm({ selectingCurrencyField: undefined })
......@@ -162,7 +163,7 @@ export function SendFormScreen(): JSX.Element {
</Modal>
<LowNativeBalanceModal
isOpen={showMaxTransferModal}
onClose={hideLowNativeBalanceWarning}
onClose={handleHideMaxTransferModal}
onAcknowledge={onAcknowledgeLowNativeBalanceWarning}
/>
<Flex fill gap="$spacing12">
......
......@@ -10,6 +10,10 @@ import { getLanguageInfo, useCurrentLanguageInfo } from 'uniswap/src/features/la
import { setCurrentLanguage } from 'uniswap/src/features/settings/slice'
import i18n from 'uniswap/src/i18n'
/**
* When modifying this component, take into consideration that this is used
* both as a full screen page in the Sidebar, and as a modal in the Onboarding page.
*/
export function DevMenuScreen(): JSX.Element {
const { t } = useTranslation()
const dispatch = useDispatch()
......
import { PropsWithChildren } from 'react'
import { Flex, Image, ImageProps, useIsDarkMode, useWindowDimensions } from 'ui/src'
import { PropsWithChildren, useMemo } from 'react'
import { Flex, useIsDarkMode } from 'ui/src'
import {
UNITAGS_ADRIAN_DARK,
UNITAGS_ADRIAN_LIGHT,
......@@ -19,117 +19,44 @@ import {
UNITAGS_SPENCER_LIGHT,
} from 'ui/src/assets'
import { zIndexes } from 'ui/src/theme'
import { IconCloud } from 'uniswap/src/components/IconCloud/IconCloud'
// Makes it easier to change later if needed
const MODIFIER = 1
// TODO WALL-5162 replace this static background with one using unitag components and interactable
export function UnitagClaimBackground({ children, blurAll }: PropsWithChildren<{ blurAll: boolean }>): JSX.Element {
export function UnitagClaimBackground({ children }: PropsWithChildren<{ blurAll: boolean }>): JSX.Element {
const isDarkMode = useIsDarkMode()
const { height, width } = useWindowDimensions()
const heightFactor = height * MODIFIER
const widthFactor = width * MODIFIER
const blurAllValue = 'blur(10px)'
const imageProps: ImageProps = {
position: 'absolute',
objectFit: 'contain',
resizeMode: 'contain',
filter: blurAll ? blurAllValue : undefined,
}
const unitags = useMemo(
() =>
isDarkMode
? [
{ logoUrl: UNITAGS_ADRIAN_DARK },
{ logoUrl: UNITAGS_ANDREW_DARK },
{ logoUrl: UNITAGS_BRYAN_DARK },
{ logoUrl: UNITAGS_CALLIL_DARK },
{ logoUrl: UNITAGS_FRED_DARK },
{ logoUrl: UNITAGS_MAGGIE_DARK },
{ logoUrl: UNITAGS_PHIL_DARK },
{ logoUrl: UNITAGS_SPENCER_DARK },
]
: [
{ logoUrl: UNITAGS_ADRIAN_LIGHT },
{ logoUrl: UNITAGS_ANDREW_LIGHT },
{ logoUrl: UNITAGS_BRYAN_LIGHT },
{ logoUrl: UNITAGS_CALLIL_LIGHT },
{ logoUrl: UNITAGS_FRED_LIGHT },
{ logoUrl: UNITAGS_MAGGIE_LIGHT },
{ logoUrl: UNITAGS_PHIL_LIGHT },
{ logoUrl: UNITAGS_SPENCER_LIGHT },
],
[isDarkMode],
)
return (
<Flex height="100%" width="100%">
<Flex centered height="100%" width="100%" zIndex={zIndexes.default}>
<Flex centered height="100%" width="100%">
<Flex centered zIndex={zIndexes.default}>
{children}
</Flex>
<Flex position="absolute" height="100%" width="100%" zIndex={zIndexes.background}>
<Image
{...imageProps}
src={isDarkMode ? UNITAGS_MAGGIE_DARK : UNITAGS_MAGGIE_LIGHT}
height={0.188 * heightFactor}
width={0.253 * widthFactor}
top={-0.045 * heightFactor}
left={-0.015 * widthFactor}
/>
<Image
{...imageProps}
src={isDarkMode ? UNITAGS_SPENCER_DARK : UNITAGS_SPENCER_LIGHT}
height={0.166 * heightFactor}
width={0.239 * widthFactor}
top={0.057 * heightFactor}
ml="auto"
mr="auto"
right={0}
left={0}
transform={`translate(${0.005 * widthFactor}px, 0px)`}
/>
<Image
{...imageProps}
src={isDarkMode ? UNITAGS_ADRIAN_DARK : UNITAGS_ADRIAN_LIGHT}
height={0.203 * heightFactor}
width={0.248 * widthFactor}
top={-0.05 * heightFactor}
right={-0.072 * widthFactor}
/>
<Image
{...imageProps}
src={isDarkMode ? UNITAGS_ANDREW_DARK : UNITAGS_ANDREW_LIGHT}
height={0.214 * heightFactor}
width={0.26 * widthFactor}
top={0}
bottom={0}
mt="auto"
mb="auto"
left={-0.15 * widthFactor}
filter={blurAll ? blurAllValue : 'blur(2px)'}
/>
<Image
{...imageProps}
src={isDarkMode ? UNITAGS_CALLIL_DARK : UNITAGS_CALLIL_LIGHT}
height={0.189 * heightFactor}
width={0.206 * widthFactor}
bottom={0.05 * heightFactor}
left={-0.01 * widthFactor}
/>
<Image
{...imageProps}
src={isDarkMode ? UNITAGS_PHIL_DARK : UNITAGS_PHIL_LIGHT}
height={0.19 * heightFactor}
width={0.266 * widthFactor}
bottom={-0.08 * heightFactor}
ml="auto"
mr="auto"
right={0}
left={0}
transform={`translate(${-0.015 * widthFactor}px, 0px)`}
filter={blurAll ? blurAllValue : 'blur(2px)'}
/>
<Image
{...imageProps}
src={isDarkMode ? UNITAGS_FRED_DARK : UNITAGS_FRED_LIGHT}
height={0.206 * heightFactor}
width={0.209 * widthFactor}
bottom={0.044 * heightFactor}
right={-0.009 * widthFactor}
/>
<Image
{...imageProps}
src={isDarkMode ? UNITAGS_BRYAN_DARK : UNITAGS_BRYAN_LIGHT}
height={0.206 * heightFactor}
width={0.209 * widthFactor}
top={0}
bottom={0}
mt="auto"
mb="auto"
right={-0.085 * widthFactor}
transform={`translate(0px, ${0.012 * heightFactor}px)`}
filter={blurAll ? blurAllValue : 'blur(4px)'}
/>
</Flex>
<IconCloud data={unitags} minItemSize={150} maxItemSize={175} />
</Flex>
)
}
......@@ -7,8 +7,8 @@ import { Flex, Square } from 'ui/src'
import { Person } from 'ui/src/components/icons'
import { iconSizes } from 'ui/src/theme'
import Trace from 'uniswap/src/features/telemetry/Trace'
import { ClaimUnitagContent, ClaimUnitagContentProps } from 'uniswap/src/features/unitags/ClaimUnitagContent'
import { ExtensionScreens, ExtensionUnitagClaimScreens } from 'uniswap/src/types/screens/extension'
import { ClaimUnitagContent, ClaimUnitagContentProps } from 'wallet/src/features/unitags/ClaimUnitagContent'
import { useAccountAddressFromUrlWithThrow } from 'wallet/src/features/wallet/hooks'
type onNavigateContinueType = Exclude<ClaimUnitagContentProps['onNavigateContinue'], undefined>
......
......@@ -6,13 +6,14 @@ export enum TopLevelRoutes {
}
export enum OnboardingRoutes {
SelectImportMethod = 'select-import-method',
Import = 'import',
Create = 'create',
Claim = 'claim',
Scan = 'scan',
Create = 'create',
Import = 'import',
ImportPasskey = 'import-passkey',
Reset = 'reset',
ResetScan = 'reset-scan',
Scan = 'scan',
SelectImportMethod = 'select-import-method',
UnsupportedBrowser = 'unsupported-browser',
}
......
import { AnimatePresence, Variants, motion } from 'framer-motion'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useMemo, useRef } from 'react'
import { useSelector } from 'react-redux'
import { NavigationType, Outlet, ScrollRestoration, useLocation } from 'react-router-dom'
import { DappRequestQueue } from 'src/app/features/dappRequests/DappRequestQueue'
import { ForceUpgradeModal } from 'src/app/features/forceUpgrade/ForceUpgradeModal'
import { HomeScreen } from 'src/app/features/home/HomeScreen'
import { Locked } from 'src/app/features/lockScreen/Locked'
import { NotificationToastWrapper } from 'src/app/features/notifications/NotificationToastWrapper'
......@@ -11,10 +11,10 @@ import { useIsWalletUnlocked } from 'src/app/hooks/useIsWalletUnlocked'
import { HideContentsWhenSidebarBecomesInactive } from 'src/app/navigation/HideContentsWhenSidebarBecomesInactive'
import { SideBarNavigationProvider } from 'src/app/navigation/SideBarNavigationProvider'
import { AppRoutes } from 'src/app/navigation/constants'
import { RouterState, subscribeToRouterState, useRouterState } from 'src/app/navigation/state'
import { useRouterState } from 'src/app/navigation/state'
import { focusOrCreateOnboardingTab } from 'src/app/navigation/utils'
import { isOnboardedSelector } from 'src/app/utils/isOnboardedSelector'
import { Flex, SpinningLoader, styled } from 'ui/src'
import { AnimatePresence, Flex, SpinningLoader, styled } from 'ui/src'
import { TestnetModeBanner } from 'uniswap/src/components/banners/TestnetModeBanner'
import { useIsChromeWindowFocusedWithTimeout } from 'uniswap/src/extension/useIsChromeWindowFocused'
import { useAsyncData, usePrevious } from 'utilities/src/react/hooks'
......@@ -72,29 +72,7 @@ const getAppRouteFromPathName = (pathname: string): AppRoutes | null => {
return null
}
const animationVariant: Variants = {
initial: (dir: Direction) => ({
x: isVertical(dir) ? 0 : dir === 'right' ? -30 : 30,
y: !isVertical(dir) ? 0 : dir === 'down' ? -15 : 15,
opacity: 0,
zIndex: 1,
}),
animate: {
x: 0,
y: 0,
opacity: 1,
zIndex: 1,
},
exit: (dir: Direction) => ({
x: isVertical(dir) ? 0 : dir === 'left' ? -30 : 30,
y: !isVertical(dir) ? 0 : dir === 'up' ? -15 : 15,
opacity: 0,
zIndex: 0,
}),
}
export function WebNavigation(): JSX.Element {
const [isTransitioning, setIsTransitioning] = useState(false)
const isLoggedIn = useIsWalletUnlocked()
const { pathname } = useLocation()
const history = useRef<string[]>([]).current
......@@ -118,50 +96,36 @@ export function WebNavigation(): JSX.Element {
// Only restore scroll if path on latest re-render is different from the previous path.
const prevPathname = usePrevious(pathname)
const shouldRestoreScroll = pathname !== prevPathname
useEffect(() => {
// We're using subscribeToRouterState subscriber to detect, whether we will
// navigate to another page, which will lead to the start of the animation.
subscribeToRouterState(({ historyAction, location }: RouterState) => {
const trimmedPathname = location.pathname.replace('/', '') as AppRoutes
if (historyAction !== NavigationType.Replace && Object.values(AppRoutes).includes(trimmedPathname)) {
setIsTransitioning(true)
}
})
}, [])
const childrenMemo = useMemo(() => {
return (
<OverflowControlledFlex isTransitioning={isTransitioning}>
<AnimatePresence initial={false}>
<MotionFlex
key={pathname}
variants={animationVariant}
custom={towards}
initial="initial"
animate="animate"
exit="exit"
onAnimationComplete={() => {
setIsTransitioning(false)
}}
>
<Flex fill grow overflow="visible">
<TestnetModeBanner />
{isLoggedIn === null ? (
<Loading />
) : isLoggedIn === true ? (
<HideContentsWhenSidebarBecomesInactive>
<LoggedIn />
</HideContentsWhenSidebarBecomesInactive>
) : (
<LoggedOut />
)}
</Flex>
</MotionFlex>
</AnimatePresence>
</OverflowControlledFlex>
<AnimatePresence custom={{ towards }} initial={false}>
<AnimatedPane
key={pathname}
animation={[
isVertical(towards) ? 'quicker' : '100ms',
{
opacity: {
overshootClamping: true,
},
},
]}
>
<Flex fill grow overflow="visible">
<TestnetModeBanner />
{isLoggedIn === null ? (
<Loading />
) : isLoggedIn === true ? (
<HideContentsWhenSidebarBecomesInactive>
<LoggedIn />
</HideContentsWhenSidebarBecomesInactive>
) : (
<LoggedOut />
)}
</Flex>
</AnimatedPane>
</AnimatePresence>
)
}, [isLoggedIn, pathname, towards, isTransitioning])
}, [isLoggedIn, pathname, towards])
return (
<SideBarNavigationProvider>
......@@ -169,12 +133,21 @@ export function WebNavigation(): JSX.Element {
<NotificationToastWrapper />
{shouldRestoreScroll && <ScrollRestoration />}
{childrenMemo}
<ForceUpgradeModal />
</WalletUniswapProvider>
</SideBarNavigationProvider>
)
}
const MotionFlex = styled(motion(Flex), {
function Loading(): JSX.Element {
return (
<Flex centered grow>
<SpinningLoader />
</Flex>
)
}
const AnimatedPane = styled(Flex, {
zIndex: 1,
fill: true,
position: 'absolute',
......@@ -185,30 +158,22 @@ const MotionFlex = styled(motion(Flex), {
minHeight: '100vh',
mx: 'auto',
width: '100%',
})
function OverflowControlledFlex({
children,
isTransitioning,
}: React.PropsWithChildren & { isTransitioning: boolean }): JSX.Element {
if (!isTransitioning) {
return <Flex fill>{children}</Flex>
}
return (
<Flex fill overflow="hidden">
{children}
</Flex>
)
}
// TODO(EXT-994): improve this loading screen.
function Loading(): JSX.Element {
return (
<Flex centered grow>
<SpinningLoader />
</Flex>
)
}
variants: {
towards: (dir: Direction) => ({
enterStyle: {
opacity: 0,
zIndex: 1,
},
exitStyle: {
zIndex: 0,
x: isVertical(dir) ? 0 : dir === 'left' ? 30 : -30,
y: !isVertical(dir) ? 0 : dir === 'up' ? 15 : -15,
opacity: 0,
},
}),
} as const,
})
const isVertical = (dir: Direction): boolean => dir === 'up' || dir === 'down'
......
import { useEffect, useState } from 'react'
import { Location, NavigationType, Router, createHashRouter } from 'react-router-dom'
export interface RouterState {
interface RouterState {
historyAction: NavigationType
location: Location
}
......@@ -30,7 +30,7 @@ export function setRouterState(next: RouterState): void {
listeners.forEach((l) => l(next))
}
export function subscribeToRouterState(listener: RouterStateListener): () => void {
function subscribeToRouterState(listener: RouterStateListener): () => void {
listeners.add(listener)
if (state) {
......
/**
* Helper function to detect if user is using arc chromium browser
* Will not work until stylesheets are loaded
* Will not work until some time after (eg 1s) stylesheets are loaded
* @returns true if user is using arc browser
*/
function isArcBrowser(): boolean {
export function isArcBrowser(): boolean {
return !!getComputedStyle(document.documentElement).getPropertyValue('--arc-palette-background')
}
......
import { isBetaEnv, isDevEnv } from 'utilities/src/environment/env'
import { StatsigEnvironmentTier } from 'wallet/src/version'
// TODO: Add to analytics package and remove
export const EXTENSION_ORIGIN_APPLICATION = 'extension'
export function getStatsigEnvironmentTier(): StatsigEnvironmentTier {
if (isDevEnv()) {
return StatsigEnvironmentTier.DEV
}
if (isBetaEnv()) {
return StatsigEnvironmentTier.BETA
}
return StatsigEnvironmentTier.PROD
}
......@@ -5,14 +5,31 @@ import { focusOrCreateOnboardingTab } from 'src/app/navigation/utils'
import { initExtensionAnalytics } from 'src/app/utils/analytics'
import { initMessageBridge } from 'src/background/backgroundDappRequests'
import { backgroundStore } from 'src/background/backgroundStore'
import { backgroundToSidePanelMessageChannel } from 'src/background/messagePassing/messageChannels'
import { BackgroundToSidePanelRequestType } from 'src/background/messagePassing/types/requests'
import {
backgroundToSidePanelMessageChannel,
contentScriptUtilityMessageChannel,
} from 'src/background/messagePassing/messageChannels'
import {
BackgroundToSidePanelRequestType,
ContentScriptUtilityMessageType,
} from 'src/background/messagePassing/types/requests'
import { setSidePanelBehavior, setSidePanelOptions } from 'src/background/utils/chromeSidePanelUtils'
import { readIsOnboardedFromStorage } from 'src/background/utils/persistedStateUtils'
import { logger } from 'utilities/src/logger/logger'
let isArcBrowser = false
initMessageBridge()
async function setSidebarState(isOnboarded: boolean): Promise<void> {
if (isOnboarded) {
await enableSidebar()
} else {
await disableSidebar()
await focusOrCreateOnboardingTab()
}
}
async function initApp(): Promise<void> {
await initStatSigForBrowserScripts()
await initExtensionAnalytics()
......@@ -20,12 +37,7 @@ async function initApp(): Promise<void> {
// Enables or disables sidebar based on onboarding status
// Injected script will reject any requests if not onboarded
backgroundStore.addOnboardingChangedListener(async (isOnboarded) => {
if (isOnboarded) {
await enableSidebar()
} else {
await disableSidebar()
await focusOrCreateOnboardingTab()
}
await setSidebarState(isOnboarded)
})
await backgroundStore.init()
......@@ -42,16 +54,33 @@ chrome.runtime.onInstalled.addListener(async () => {
await checkAndHandleOnboarding()
})
// on arc browser, show unsupported browser page (lives on onboarding flow)
// this is because arc doesn't support the sidebar
contentScriptUtilityMessageChannel.addMessageListener(
ContentScriptUtilityMessageType.ArcBrowserCheck,
async (message) => {
isArcBrowser = !!message.isArcBrowser
if (message.isArcBrowser) {
await disableSidebar()
} else {
// ensure that we reenable the sidebar if arc styles are not detected
// this ensures that funky edge cases (eg sites that define arc styles) don't cause the sidebar to be disabled on accident
await enableSidebar()
}
},
)
// Utility Functions
async function checkAndHandleOnboarding(): Promise<void> {
const isOnboarded = await readIsOnboardedFromStorage()
if (!isOnboarded) {
await disableSidebar()
if (isArcBrowser) {
await focusOrCreateOnboardingTab()
} else {
await enableSidebar()
return
}
const isOnboarded = await readIsOnboardedFromStorage()
await setSidebarState(isOnboarded)
}
async function enableSidebar(): Promise<void> {
......
......@@ -152,16 +152,6 @@ export function initMessageBridge(): void {
},
)
contentScriptUtilityMessageChannel.addMessageListener(ContentScriptUtilityMessageType.FocusOnboardingTab, () => {
focusOrCreateOnboardingTab().catch((error) =>
logger.error(error, {
tags: {
file: 'backgroundDappRequests.ts',
function: 'contentScriptUtilityMessageListener',
},
}),
)
})
contentScriptUtilityMessageChannel.addMessageListener(ContentScriptUtilityMessageType.FocusOnboardingTab, () => {
focusOrCreateOnboardingTab().catch((error) =>
logger.error(error, {
......
......@@ -13,6 +13,10 @@ import {
ErrorResponseSchema,
GetAccountRequest,
GetAccountRequestSchema,
GetCallsStatusRequest,
GetCallsStatusRequestSchema,
GetCallsStatusResponse,
GetCallsStatusResponseSchema,
GetChainIdRequest,
GetChainIdRequestSchema,
GetPermissionsRequest,
......@@ -29,6 +33,10 @@ import {
RevokePermissionsRequestSchema,
RevokePermissionsResponse,
RevokePermissionsResponseSchema,
SendCallsRequest,
SendCallsRequestSchema,
SendCallsResponse,
SendCallsResponseSchema,
SendTransactionRequest,
SendTransactionRequestSchema,
SendTransactionResponse,
......@@ -50,11 +58,7 @@ import {
UniswapOpenSidebarResponse,
UniswapOpenSidebarResponseSchema,
} from 'src/app/features/dappRequests/types/DappRequestTypes'
import {
MessageParsers,
TypedPortMessageChannel,
TypedRuntimeMessageChannel,
} from 'src/background/messagePassing/platform'
import { TypedPortMessageChannel, TypedRuntimeMessageChannel } from 'src/background/messagePassing/platform'
import {
HighlightOnboardingTabMessage,
HighlightOnboardingTabMessageSchema,
......@@ -65,6 +69,8 @@ import {
import {
AnalyticsLog,
AnalyticsLogSchema,
ArcBrowserCheckMessage,
ArcBrowserCheckMessageSchema,
BackgroundToSidePanelRequestType,
ContentScriptUtilityMessageType,
DappRequestMessage,
......@@ -83,6 +89,7 @@ import {
UpdateConnectionRequest,
UpdateConnectionRequestSchema,
} from 'src/background/messagePassing/types/requests'
import { MessageParsers } from 'uniswap/src/extension/messagePassing/platform'
enum MessageChannelName {
DappContentScript = 'DappContentScript',
......@@ -161,6 +168,8 @@ type ContentScriptToBackgroundMessageSchemas = {
[DappRequestType.SignTransaction]: SignTransactionRequest
[DappRequestType.SignTypedData]: SignTypedDataRequest
[DappRequestType.UniswapOpenSidebar]: UniswapOpenSidebarRequest
[DappRequestType.SendCalls]: SendCallsRequest
[DappRequestType.GetCallsStatus]: GetCallsStatusRequest
}
const contentScriptToBackgroundMessageParsers: MessageParsers<
DappRequestType,
......@@ -181,6 +190,8 @@ const contentScriptToBackgroundMessageParsers: MessageParsers<
[DappRequestType.SignTypedData]: (message): SignTypedDataRequest => SignTypedDataRequestSchema.parse(message),
[DappRequestType.UniswapOpenSidebar]: (message): UniswapOpenSidebarRequest =>
UniswapOpenSidebarRequestSchema.parse(message),
[DappRequestType.SendCalls]: (message): SendCallsRequest => SendCallsRequestSchema.parse(message),
[DappRequestType.GetCallsStatus]: (message): GetCallsStatusRequest => GetCallsStatusRequestSchema.parse(message),
}
function createContentScriptToBackgroundMessageChannel(): TypedRuntimeMessageChannel<
......@@ -207,6 +218,8 @@ type DappResponseMessageSchemas = {
[DappResponseType.SignTransactionResponse]: SignTransactionResponse
[DappResponseType.SignTypedDataResponse]: SignTypedDataResponse
[DappResponseType.UniswapOpenSidebarResponse]: UniswapOpenSidebarResponse
[DappResponseType.SendCallsResponse]: SendCallsResponse
[DappResponseType.GetCallsStatusResponse]: GetCallsStatusResponse
}
const dappResponseMessageParsers: MessageParsers<DappResponseType, DappResponseMessageSchemas> = {
[DappResponseType.AccountResponse]: (message): AccountResponse => AccountResponseSchema.parse(message),
......@@ -228,6 +241,9 @@ const dappResponseMessageParsers: MessageParsers<DappResponseType, DappResponseM
SignTypedDataResponseSchema.parse(message),
[DappResponseType.UniswapOpenSidebarResponse]: (message): UniswapOpenSidebarResponse =>
UniswapOpenSidebarResponseSchema.parse(message),
[DappResponseType.SendCallsResponse]: (message): SendCallsResponse => SendCallsResponseSchema.parse(message),
[DappResponseType.GetCallsStatusResponse]: (message): GetCallsStatusResponse =>
GetCallsStatusResponseSchema.parse(message),
}
function createDappResponseMessageChannel(): TypedRuntimeMessageChannel<DappResponseType, DappResponseMessageSchemas> {
......@@ -259,6 +275,7 @@ function createExternalDappMessageChannel(): TypedRuntimeMessageChannel<
}
type ContentScriptUtilityMessageSchemas = {
[ContentScriptUtilityMessageType.ArcBrowserCheck]: ArcBrowserCheckMessage
[ContentScriptUtilityMessageType.FocusOnboardingTab]: FocusOnboardingMessage
[ContentScriptUtilityMessageType.ErrorLog]: ErrorLog
[ContentScriptUtilityMessageType.AnalyticsLog]: AnalyticsLog
......@@ -267,6 +284,8 @@ const contentScriptUtilityMessageParsers: MessageParsers<
ContentScriptUtilityMessageType,
ContentScriptUtilityMessageSchemas
> = {
[ContentScriptUtilityMessageType.ArcBrowserCheck]: (message): ArcBrowserCheckMessage =>
ArcBrowserCheckMessageSchema.parse(message),
[ContentScriptUtilityMessageType.FocusOnboardingTab]: (message): FocusOnboardingMessage =>
FocusOnboardingMessageSchema.parse(message),
[ContentScriptUtilityMessageType.ErrorLog]: (message): ErrorLog => ErrorLogSchema.parse(message),
......
import { Message } from 'src/background/messagePassing/messageTypes'
import { Message } from 'uniswap/src/extension/messagePassing/messageTypes'
type MessageValidator<T extends Message> = (message: unknown) => message is T
......
/* eslint-disable @typescript-eslint/no-explicit-any */
import { MessageParsers } from 'uniswap/src/extension/messagePassing/platform'
import { logger } from 'utilities/src/logger/logger'
const EXTENSION_CONTEXT_INVALIDATED_CHROMIUM_ERROR = 'Extension context invalidated.'
......@@ -97,9 +98,6 @@ class ChromeMessageChannel {
}
}
export type MessageParsers<T extends string, R extends { [key in T]: { type: key } }> = {
[key in T]: (message: unknown) => R[key]
}
abstract class TypedMessageChannel<
T extends string,
R extends { [key in T]: { type: key } },
......
import { MessageSchema } from 'src/background/messagePassing/messageTypes'
import { MessageSchema } from 'uniswap/src/extension/messagePassing/messageTypes'
import { z } from 'zod'
export enum OnboardingMessageType {
......
import { DappRequestSchema } from 'src/app/features/dappRequests/types/DappRequestTypes'
import { MessageSchema } from 'src/background/messagePassing/messageTypes'
import { MessageSchema } from 'uniswap/src/extension/messagePassing/messageTypes'
import { z } from 'zod'
// ENUMS
// Requests from content scripts to the extension (non-dapp requests)
export enum ContentScriptUtilityMessageType {
ArcBrowserCheck = 'ArcBrowserCheck',
FocusOnboardingTab = 'FocusOnboardingTab',
ErrorLog = 'Error',
AnalyticsLog = 'AnalyticsLog',
......@@ -20,6 +21,13 @@ export const ErrorLogSchema = MessageSchema.extend({
})
export type ErrorLog = z.infer<typeof ErrorLogSchema>
export const ArcBrowserCheckMessageSchema = MessageSchema.extend({
type: z.literal(ContentScriptUtilityMessageType.ArcBrowserCheck),
isArcBrowser: z.boolean(),
})
export type ArcBrowserCheckMessage = z.infer<typeof ArcBrowserCheckMessageSchema>
export const AnalyticsLogSchema = MessageSchema.extend({
type: z.literal(ContentScriptUtilityMessageType.AnalyticsLog),
message: z.string(),
......
import { EthersTransactionRequestSchema } from 'src/app/features/dappRequests/types/EthersTypes'
import { HexadecimalNumberSchema } from 'src/app/features/dappRequests/types/utilityTypes'
import { HomeTabs } from 'src/app/navigation/constants'
import { GetCallsStatusParamsSchema, SendCallsParamsSchema } from 'wallet/src/features/dappRequests/types'
import { ZodIssueCode, z } from 'zod'
/**
......@@ -251,7 +252,6 @@ export const WalletGetPermissionsRequestSchema = EthereumRequestWithIdSchema.ext
})
export type WalletGetPermissionsRequest = z.infer<typeof WalletGetPermissionsRequestSchema>
// WalletGetCapabilitiesRequestSchema
export const WalletGetCapabilitiesRequestSchema = EthereumRequestWithIdSchema.extend({
method: z.literal('wallet_getCapabilities'),
params: z.array(z.unknown()),
......@@ -302,3 +302,65 @@ export const UniswapOpenSidebarRequestSchema = EthereumRequestWithIdSchema.exten
})
export type UniswapOpenSidebarRequest = z.infer<typeof UniswapOpenSidebarRequestSchema>
export const WalletSendCallsRequestSchema = EthereumRequestWithIdSchema.extend({
method: z.literal('wallet_sendCalls'),
params: z.array(z.unknown()),
}).transform((data) => {
const { requestId, method, params } = data
if (params.length < 1) {
throw new z.ZodError([
{
message: 'Params array must contain at least one element',
path: ['params'],
code: ZodIssueCode.custom,
},
])
}
const parseResult = SendCallsParamsSchema.safeParse(params[0])
if (!parseResult.success) {
throw new Error('First element of the array must match SendCallsParamsSchema')
}
const sendCallsParams = parseResult.data
return {
requestId,
method,
params,
sendCallsParams,
}
})
export type WalletSendCallsRequest = z.infer<typeof WalletSendCallsRequestSchema>
export const WalletGetCallsStatusRequestSchema = EthereumRequestWithIdSchema.extend({
method: z.literal('wallet_getCallsStatus'),
params: z.array(z.unknown()),
}).transform((data) => {
const { requestId, method, params } = data
if (params.length < 1) {
throw new z.ZodError([
{
message: 'Params array must contain at least one element',
path: ['params'],
code: ZodIssueCode.custom,
},
])
}
const batchId = GetCallsStatusParamsSchema.parse(params[0])
return {
requestId,
method,
params,
batchId,
}
})
export type WalletGetCallsStatusRequest = z.infer<typeof WalletGetCallsStatusRequestSchema>
......@@ -41,11 +41,16 @@ import { logger } from 'utilities/src/logger/logger'
import { arraysAreEqual } from 'utilities/src/primitives/array'
import { walletContextValue } from 'wallet/src/features/wallet/context'
import { isArcBrowser } from 'src/app/utils/chrome'
import { getIsDefaultProviderFromStorage } from 'src/app/utils/provider'
import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants'
import { getValidAddress } from 'uniswap/src/utils/addresses'
import { ONE_SECOND_MS } from 'utilities/src/time/time'
import { ZodError } from 'zod'
// arc styles aren't available on load
const ARC_STYLE_INJECTION_DELAY = ONE_SECOND_MS
let _provider: JsonRpcProvider | undefined
let _chainId: string | undefined
let connectedAddresses: Address[] | undefined
......@@ -276,3 +281,20 @@ addWindowMessageListener<WindowEthereumConfigRequest>(
undefined,
{ removeAfterHandled: true },
)
// check for arc stylesheet properties on load
// notify background script if arc browser detected so we can disable the extension
window.addEventListener('load', () => {
// if styles aren't available at all, then we cannot check for the arc styles
const isStylesAvailable = document.documentElement && !!getComputedStyle(document.documentElement).length
if (!isStylesAvailable) {
return
}
setTimeout(async () => {
await contentScriptUtilityMessageChannel.sendMessage({
type: ContentScriptUtilityMessageType.ArcBrowserCheck,
isArcBrowser: isArcBrowser(),
})
}, ARC_STYLE_INJECTION_DELAY)
})
......@@ -24,6 +24,8 @@ import {
EthSignTypedDataV4RequestSchema,
PersonalSignRequest,
PersonalSignRequestSchema,
WalletGetCallsStatusRequest,
WalletGetCallsStatusRequestSchema,
WalletGetCapabilitiesRequest,
WalletGetCapabilitiesRequestSchema,
WalletGetCapabilitiesResponse,
......@@ -33,6 +35,8 @@ import {
WalletRequestPermissionsRequestSchema,
WalletRevokePermissionsRequest,
WalletRevokePermissionsRequestSchema,
WalletSendCallsRequest,
WalletSendCallsRequestSchema,
WalletSwitchEthereumChainRequest,
WalletSwitchEthereumChainRequestSchema,
} from 'src/contentScript/WindowEthereumRequestTypes'
......@@ -211,6 +215,32 @@ export class ExtensionEthMethodHandler extends BaseMethodHandler<WindowEthereumR
source?.postMessage(message)
})
dappResponseMessageChannel.addMessageListener(DappResponseType.SendCallsResponse, (message) => {
const source = getPendingResponseInfo(
this.requestIdToSourceMap,
message.requestId,
DappResponseType.SendCallsResponse,
)?.source
source?.postMessage({
requestId: message.requestId,
result: message.response,
})
})
dappResponseMessageChannel.addMessageListener(DappResponseType.GetCallsStatusResponse, (message) => {
const source = getPendingResponseInfo(
this.requestIdToSourceMap,
message.requestId,
DappResponseType.GetCallsStatusResponse,
)?.source
source?.postMessage({
requestId: message.requestId,
result: message.response,
})
})
}
private isAuthorized(): boolean {
......@@ -229,6 +259,7 @@ export class ExtensionEthMethodHandler extends BaseMethodHandler<WindowEthereumR
this.setProvider(new JsonRpcProvider(providerUrl))
}
// eslint-disable-next-line complexity
async handleRequest(request: WindowEthereumRequest, source: MessageEventSource | null): Promise<void> {
switch (request.method) {
case ExtensionEthMethods.eth_chainId: {
......@@ -323,6 +354,24 @@ export class ExtensionEthMethodHandler extends BaseMethodHandler<WindowEthereumR
await this.handleEthSignTypedData(parsedRequest, source)
break
}
case ExtensionEthMethods.wallet_sendCalls: {
if (!this.isAuthorized()) {
postUnauthorizedError(source, request.requestId)
return
}
const parsedRequest = WalletSendCallsRequestSchema.parse(request)
await this.handleWalletSendCalls(parsedRequest, source)
break
}
case ExtensionEthMethods.wallet_getCallsStatus: {
if (!this.isAuthorized()) {
postUnauthorizedError(source, request.requestId)
return
}
const parsedRequest = WalletGetCallsStatusRequestSchema.parse(request)
await this.handleWalletGetCallsStatus(parsedRequest, source)
break
}
}
}
......@@ -506,6 +555,43 @@ export class ExtensionEthMethodHandler extends BaseMethodHandler<WindowEthereumR
result: capabilities,
})
}
/**
* Handle wallet_sendCalls request
* This method allows dapps to send a batch of calls to the wallet
*/
async handleWalletSendCalls(request: WalletSendCallsRequest, source: MessageEventSource | null): Promise<void> {
this.requestIdToSourceMap.set(request.requestId, {
type: DappResponseType.SendCallsResponse,
source,
})
await contentScriptToBackgroundMessageChannel.sendMessage({
type: DappRequestType.SendCalls,
requestId: request.requestId,
sendCallsParams: request.sendCallsParams,
})
}
/**
* Handle wallet_getCallsStatus request
* This method returns the status of a call batch that was sent via wallet_sendCalls
*/
async handleWalletGetCallsStatus(
request: WalletGetCallsStatusRequest,
source: MessageEventSource | null,
): Promise<void> {
this.requestIdToSourceMap.set(request.requestId, {
type: DappResponseType.GetCallsStatusResponse,
source,
})
await contentScriptToBackgroundMessageChannel.sendMessage({
type: DappRequestType.GetCallsStatus,
requestId: request.requestId,
batchId: request.batchId,
})
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
......
......@@ -11,6 +11,8 @@ export enum ExtensionEthMethods {
wallet_requestPermissions = 'wallet_requestPermissions',
wallet_revokePermissions = 'wallet_revokePermissions',
wallet_getCapabilities = 'wallet_getCapabilities',
wallet_sendCalls = 'wallet_sendCalls',
wallet_getCallsStatus = 'wallet_getCallsStatus',
eth_signTypedData_v4 = 'eth_signTypedData_v4',
}
......
......@@ -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.19.0",
"version": "1.20.0",
"minimum_chrome_version": "116",
"icons": {
"16": "assets/icon16.png",
......@@ -61,7 +61,9 @@
],
"externally_connectable": {
"ids": [],
"matches": []
"matches": [
"THIS WILL BE OVERWRITTEN DURING THE BUILD PROCESS - See webpack.config.js"
]
},
"commands": {
"_execute_action": {
......
......@@ -15,7 +15,7 @@ export default function PrimaryAppInstanceDebugger(): JSX.Element | null {
borderRadius: '5px',
width: '5px',
height: '5px',
zIndex: 999999999999999,
zIndex: Number.MAX_SAFE_INTEGER,
background: isPrimaryAppInstance ? 'green' : 'red',
color: 'white',
}}
......
......@@ -332,6 +332,13 @@ module.exports = (env) => {
description: EXTENSION_DESCRIPTION,
version: EXTENSION_VERSION,
name: EXTENSION_NAME_POSTFIX ? manifest.name + ' ' + EXTENSION_NAME_POSTFIX : manifest.name,
externally_connectable: {
...manifest.externally_connectable,
matches:
BUILD_ENV === 'prod'
? ['https://app.uniswap.org/*']
: ['https://app.uniswap.org/*', 'https://ew.unihq.org/*', 'https://*.ew.unihq.org/*'],
},
},
null,
2,
......
......@@ -47,7 +47,7 @@ Note: If you are indeed using an Apple Silicon Mac, we recommend setting up your
### Packages and Software
1. Install `homebrew`. We’ll be using Homebrew to install many of the other required tools through the command line. Open a terminal and Copy and paste the command from [brew.sh](https://brew.sh/) into your terminal and run it
2. Install `nvm` [Node Version Manager](https://github.com/nvm-sh/nvm) While not required, it makes it easy to install Node and switch between different versions. A minimum Node version of 18 (verify version in `.nvmrc`) is required to use this repository.
2. Install `nvm` [Node Version Manager](https://github.com/nvm-sh/nvm) While not required, it makes it easy to install Node and switch between different versions. Use the version of `node` specified in `.nvmrc`.
- Copy the curl command listed under _Install & Update Script_ on [this page](https://github.com/nvm-sh/nvm#install--update-script) and run it in your terminal.
- To make sure nvm installed correctly, try running `nvm -v` (you may need to re-source your shell with `source {base config}`). It should return a version number. If it returns something like `zsh: command not found: nvm`, it hasn’t been installed correctly.
......
......@@ -72,9 +72,9 @@ if (isCI && datadogPropertiesAvailable && !isE2E) {
apply from: "../../../../node_modules/@datadog/mobile-react-native/datadog-sourcemaps.gradle"
}
def devVersionName = "1.49"
def betaVersionName = "1.49"
def prodVersionName = "1.49"
def devVersionName = "1.50"
def betaVersionName = "1.50"
def prodVersionName = "1.50"
android {
ndkVersion rootProject.ext.ndkVersion
......
......@@ -45,7 +45,7 @@
android:name=".MainActivity"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode"
android:launchMode="singleTask"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize"
android:exported="true"
android:supportsRtl="true"
......
......@@ -126,7 +126,7 @@ class GoogleDriveApiHelper {
} else {
continuation.resumeWith(Result.failure(Exception("Oauth process has been interrupted")))
Log.d("Activity intent", "Indent null")
Log.d("Activity intent", "Intent null")
}
}
......
......@@ -56,6 +56,9 @@ class SeedPhraseInputViewManager : ViewGroupManager<ComposeView>() {
putString(FIELD_MNEMONIC_ID, it)
}
sendEvent(id, EVENT_MNEMONIC_STORED, bundle)
},
onSubmitError = {
sendEvent(id, EVENT_SUBMIT_ERROR)
}
)
......@@ -110,6 +113,12 @@ class SeedPhraseInputViewManager : ViewGroupManager<ComposeView>() {
"captured" to EVENT_HEIGHT_MEASURED
)
),
EVENT_SUBMIT_ERROR to mapOf(
"phasedRegistrationNames" to mapOf(
"bubbled" to EVENT_SUBMIT_ERROR,
"captured" to EVENT_SUBMIT_ERROR
)
),
)
}
......@@ -157,6 +166,7 @@ class SeedPhraseInputViewManager : ViewGroupManager<ComposeView>() {
private const val EVENT_INPUT_VALIDATED = "onInputValidated"
private const val EVENT_MNEMONIC_STORED = "onMnemonicStored"
private const val EVENT_HEIGHT_MEASURED = "onHeightMeasured"
private const val EVENT_SUBMIT_ERROR = "onSubmitError"
private const val COMMAND_HANDLE_SUBMIT = "handleSubmit"
private const val COMMAND_FOCUS = "focus"
private const val COMMAND_BLUR = "blur"
......
......@@ -21,6 +21,7 @@ class SeedPhraseInputViewModel(
private val ethersRs: RnEthersRs,
private val onInputValidated: (canSubmit: Boolean) -> Unit,
private val onMnemonicStored: (mnemonicId: String) -> Unit,
private val onSubmitError: () -> Unit,
) : ViewModel() {
sealed interface Status {
......@@ -143,6 +144,7 @@ class SeedPhraseInputViewModel(
if (status is Status.Error) {
onInputValidated(false)
onSubmitError()
}
}
......@@ -151,6 +153,7 @@ class SeedPhraseInputViewModel(
val generatedId = ethersRs.generateAddressForMnemonic(mnemonic, derivationIndex = 0)
if (generatedId != mnemonicIdForRecovery) {
status = Status.Error(MnemonicError.WrongRecoveryPhrase)
onSubmitError()
} else {
storeMnemonic(mnemonic)
}
......@@ -168,5 +171,5 @@ class SeedPhraseInputViewModel(
private const val MIN_LENGTH = 12
private const val MAX_LENGTH = 24
}
}
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -11,11 +11,34 @@
@implementation AppDelegate
static NSString *const hasLaunchedOnceKey = @"HasLaunchedOnce";
/**
* Handles keychain cleanup on first run of the app.
* A migration flag is persisted in the keychain to avoid clearing the keychain for existing users, while the first run flag is saved in NSUserDefaults, which is cleared every install.
*/
- (void)handleKeychainCleanup {
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
BOOL isFirstRun = ![defaults boolForKey:hasLaunchedOnceKey];
BOOL canClearKeychainOnReinstall = [KeychainUtils getCanClearKeychainOnReinstall];
if (canClearKeychainOnReinstall && isFirstRun) {
[KeychainUtils clearKeychain];
}
if (!canClearKeychainOnReinstall || isFirstRun) {
[defaults setBool:YES forKey:hasLaunchedOnceKey];
[KeychainUtils setCanClearKeychainOnReinstall];
}
}
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
// Must be first line in startup routine
[ReactNativePerformance onAppStarted];
[self handleKeychainCleanup];
[FIRApp configure];
// This is needed so universal links opened from OneSignal notifications navigate to the proper page.
......
......@@ -15,6 +15,7 @@ RCT_EXPORT_VIEW_PROPERTY(onInputValidated, RCTDirectEventBlock);
RCT_EXPORT_VIEW_PROPERTY(onMnemonicStored, RCTDirectEventBlock);
RCT_EXPORT_VIEW_PROPERTY(onPasteStart, RCTDirectEventBlock);
RCT_EXPORT_VIEW_PROPERTY(onPasteEnd, RCTDirectEventBlock);
RCT_EXPORT_VIEW_PROPERTY(onSubmitError, RCTDirectEventBlock);
RCT_EXPORT_VIEW_PROPERTY(onHeightMeasured, RCTDirectEventBlock);
RCT_EXPORT_VIEW_PROPERTY(testID, NSString?)
RCT_EXTERN_METHOD(handleSubmit: (nonnull NSNumber *)node)
......
......@@ -72,6 +72,12 @@ class SeedPhraseInputView: UIView {
set { vc.rootView.viewModel.onPasteEnd = newValue }
get { return vc.rootView.viewModel.onPasteEnd }
}
@objc
var onSubmitError: RCTDirectEventBlock {
set { vc.rootView.viewModel.onSubmitError = newValue }
get { return vc.rootView.viewModel.onSubmitError }
}
@objc
func focus() {
......
......@@ -63,6 +63,7 @@ class SeedPhraseInputViewModel: ObservableObject {
@Published var onMnemonicStored: RCTDirectEventBlock = { _ in }
@Published var onPasteStart: RCTDirectEventBlock = { _ in }
@Published var onPasteEnd: RCTDirectEventBlock = { _ in }
@Published var onSubmitError: RCTDirectEventBlock = { _ in }
@Published var onHeightMeasured: RCTDirectEventBlock = { _ in }
private var lastWordValidationTimer: Timer?
......@@ -88,7 +89,8 @@ class SeedPhraseInputViewModel: ObservableObject {
let mnemonic = trimInput(value: normalized)
let words = mnemonic.components(separatedBy: " ")
let valid = rnEthersRS.validateMnemonic(mnemonic: mnemonic)
error = nil
if (words.count < minCount) {
status = .error
error = .notEnoughWords
......@@ -101,6 +103,10 @@ class SeedPhraseInputViewModel: ObservableObject {
} else {
submitMnemonic(mnemonic: mnemonic)
}
if (error != nil) {
onSubmitError([:])
}
}
private func submitMnemonic(mnemonic: String) {
......@@ -114,9 +120,10 @@ class SeedPhraseInputViewModel: ObservableObject {
} else {
status = .error
error = .wrongRecoveryPhrase
onSubmitError([:])
}
}, reject: { code, message, error in
// TODO gary update ethers library to catch exception or send in reject
onSubmitError([:])
print("SeedPhraseInputView model error while generating address: \(message ?? "")")
})
} else {
......@@ -131,7 +138,7 @@ class SeedPhraseInputViewModel: ObservableObject {
onMnemonicStored(["mnemonicId": String(describing: mnemonicId ?? "")])
},
reject: { code, message, error in
// TODO gary update ethers library to catch exception or send in reject
onSubmitError([:])
print("SeedPhraseInputView model error while storing mnemonic: \(message ?? "")")
}
)
......
//
// KeychainConstants.swift
// Uniswap
//
// Created by Thomas Thachil on 2/3/25.
//
let prefix = "com.uniswap.mobile"
let mnemonicPrefix = ".mnemonic."
let privateKeyPrefix = ".privateKey."
let entireMnemonicPrefix = prefix + mnemonicPrefix
let entirePrivateKeyPrefix = prefix + privateKeyPrefix
import CryptoKit
import Foundation
@objcMembers
class KeychainUtils: NSObject {
private static let CAN_CLEAR_KEYCHAIN_ON_REINSTALL_FLAG = "can_clear_keychain_on_reinstall"
private static let keychain = KeychainSwift(keyPrefix: prefix)
@objc static func clearKeychain() {
keychain.clear()
}
@objc static func getCanClearKeychainOnReinstall() -> Bool {
return (keychain.getBool(CAN_CLEAR_KEYCHAIN_ON_REINSTALL_FLAG) == true)
}
@objc static func setCanClearKeychainOnReinstall() {
keychain.set(
true, forKey: CAN_CLEAR_KEYCHAIN_ON_REINSTALL_FLAG, withAccess: .accessibleWhenUnlockedThisDeviceOnly)
}
}
......@@ -14,12 +14,6 @@
import Foundation
import CryptoKit
// TODO: [MOB-200] move constants to another file
let prefix = "com.uniswap.mobile"
let mnemonicPrefix = ".mnemonic."
let privateKeyPrefix = ".privateKey."
let entireMnemonicPrefix = prefix + mnemonicPrefix
let entirePrivateKeyPrefix = prefix + privateKeyPrefix
enum RNEthersRSError: String, Error {
case storeMnemonicError = "storeMnemonicError"
......
b5838bdddb0692a13d0a061696f7ea1af4f7b16152b8824052498e1a494a4988
\ No newline at end of file
......@@ -4,19 +4,82 @@ This Framework contains autogenerated code that is generated from running Apollo
## Generating the Swift GraphQL Schema
`yarn mobile ios:prebuild`
```
yarn mobile ios:prebuild
```
This command will:
1. Clean any existing generated Swift GraphQL files
2. Regenerate the Swift GraphQL files based on the schema
3. Automatically add the generated files to the Xcode project
## Verifying Schema Changes
When the schema is changed, please verify the build process still works.
Run `yarn mobile ios` and ensure it builds successfully. This will implicitly run `yarn mobile ios:prebuild` which will clean and regenerate the swift files.
Run `yarn mobile ios` and ensure it builds successfully. This will implicitly run `yarn mobile ios:prebuild` which will clean and regenerate the Swift files.
## Troubleshooting
If you encounter build errors after updating the GraphQL schema:
1. **Missing file references**: If Xcode complains about missing files, run `yarn mobile ios:prebuild` again to ensure all files are properly added to the project.
2. **Script failures**: If the automatic file addition fails, you may see a warning. In this case, try running the script manually:
```
cd apps/mobile && ruby ./scripts/update_apollo_files_in_xcode.rb
```
3. **Manual file addition**: If the script consistently fails, you can add files manually (see the "Adding Generated Files Manually" section below).
4. **Clean build**: Sometimes a clean build helps resolve reference issues:
```
cd apps/mobile/ios && xcodebuild clean
```
If an error does occur, check the errors via building in XCode for more detailed logs. Then either remove freshly deleted file references or add files to the project that may be required. If needed, adapt the Swift usages of the schema.
5. **Check for schema errors**: Ensure there are no errors in your GraphQL schema or queries.
## Adding Generated Files
## Adding Generated Files Manually
To add new graphql queries or fragments to Swift:
If you need to manually add new GraphQL queries or fragments to Swift:
1. Ensure the file is listed in `apps/mobile/ios/apollo-codegen-config.json`'s `"operationSearchPaths"` and `"schemaSearchPaths"`
2. Add the needed generated files to the XCode project. To add new files, right-click the `WidgetsCore` folder in XCode, and select `add files to "Uniswap"...`. Then select the fragments, operations, and schema folder and keep create groups checked. Then click add.
2. Add the needed generated files to the XCode project. To add new files:
1. Right-click the `WidgetsCore` folder in XCode
2. Select `add files to "Uniswap"...`
3. Select the `MobileSchema` folder
4. Keep `Action: Reference files in place` selected
5. Keep `Groups: Create Groups` selected
6. Keep `WidgetsCore` checked
7. Click Finish
## Implementation Details
The automation for adding generated files to Xcode uses a Ruby script (`scripts/update_apollo_files_in_xcode.rb`) that:
1. Removes any existing MobileSchema group from the Xcode project
2. Creates a new MobileSchema group with the proper structure
3. Scans the MobileSchema directory for Swift files
4. Adds files to the appropriate groups in the Xcode project
5. Updates the build phases to include the new files
### Requirements
The script requires the `xcodeproj` Ruby gem:
```
gem install xcodeproj
```
Note: This should already have been installed via the `mobile` app's setup instructions.
### Debugging
If the script fails, you can run it with debugging output:
```
DEBUG=1 ruby ./scripts/update_apollo_files_in_xcode.rb
```
......@@ -90,10 +90,6 @@ jest.mock('react-native/Libraries/Linking/Linking', () => ({
getInitialURL: jest.fn(),
}))
jest.mock('openai')
jest.mock("react-native-bootsplash", () => {
return {
hide: jest.fn().mockResolvedValue(),
......
......@@ -32,7 +32,7 @@
"graphql:generate:swift": "cd ios && ./Pods/Apollo/apollo-ios-cli generate",
"check:circular": "../../scripts/check-circular-imports.sh ./src/app/App.tsx 1",
"ios": "yarn ios:prebuild && SKIP_BUNDLING=1 react-native run-ios",
"ios:prebuild": "yarn graphql:generate:swift && cd ios/WidgetsCore/MobileSchema && rm -rf !(README.md) && cd ../../.. && yarn graphql:generate:swift && yarn env:local:copy:swift",
"ios:prebuild": "yarn graphql:generate:swift && yarn env:local:copy:swift && ruby ./scripts/update_apollo_files_in_xcode.rb",
"ios:smol": "SKIP_BUNDLING=1 react-native run-ios --simulator=\"iPhone SE (3rd generation)\"",
"ios:dev:release": "react-native run-ios --configuration Dev",
"ios:beta": "react-native run-ios --configuration Beta",
......@@ -122,7 +122,6 @@
"fuse.js": "6.5.3",
"i18next": "23.10.0",
"lodash": "4.17.21",
"openai": "4.79.1",
"react": "18.3.1",
"react-freeze": "1.0.3",
"react-i18next": "14.1.0",
......@@ -148,7 +147,7 @@
"react-native-restart": "0.0.27",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "4.1.0",
"react-native-sortables": "1.1.1",
"react-native-sortables": "1.5.1",
"react-native-svg": "15.10.1",
"react-native-tab-view": "3.5.2",
"react-native-url-polyfill": "1.3.0",
......
This diff is collapsed.
import React from 'react'
import 'react-native'
import mockRNLocalize from 'react-native-localize/mock'
import { act } from 'react-test-renderer'
import App from 'src/app/App'
import { render } from 'src/test/test-utils'
jest.mock('react-native-localize', () => mockRNLocalize)
it('renders correctly', async () => {
render(<App />)
await act(async () => {
// Wait for component cleanup
})
})
......@@ -4,7 +4,7 @@ import { DdRum, RumActionType } from '@datadog/mobile-react-native'
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'
import { PerformanceProfiler, RenderPassReport } from '@shopify/react-native-performance'
import { MMKVWrapper } from 'apollo3-cache-persist'
import { default as React, StrictMode, useCallback, useEffect, useLayoutEffect, useRef } from 'react'
import { default as React, StrictMode, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import { I18nextProvider } from 'react-i18next'
import { LogBox, NativeModules, StatusBar } from 'react-native'
import appsFlyer from 'react-native-appsflyer'
......@@ -35,7 +35,7 @@ import { setDatadogUserWithUniqueId } from 'src/features/datadog/user'
import { NotificationToastWrapper } from 'src/features/notifications/NotificationToastWrapper'
import { initOneSignal } from 'src/features/notifications/Onesignal'
import { OneSignalUserTagField } from 'src/features/notifications/constants'
import { DevAIAssistantScreen, DevOpenAIProvider } from 'src/features/openai/DevAIGate'
import { statsigMMKVStorageProvider } from 'src/features/statsig/statsigMMKVStorageProvider'
import { shouldLogScreen } from 'src/features/telemetry/directLogScreens'
import { selectCustomEndpoint } from 'src/features/tweaks/selectors'
import {
......@@ -46,14 +46,12 @@ import {
} from 'src/features/widgets/widgets'
import { loadLocaleData } from 'src/polyfills/intl-delayed'
import { useAppStateTrigger } from 'src/utils/useAppStateTrigger'
import { getStatsigEnvironmentTier } from 'src/utils/version'
import { flexStyles, useIsDarkMode } from 'ui/src'
import { TestnetModeBanner } from 'uniswap/src/components/banners/TestnetModeBanner'
import { config } from 'uniswap/src/config'
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 { StatsigProviderWrapper } from 'uniswap/src/features/gating/StatsigProviderWrapper'
import {
DatadogSessionSampleRateKey,
DatadogSessionSampleRateValType,
......@@ -63,8 +61,7 @@ import { 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, getFeatureFlag } 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 { StatsigUser, Storage, getStatsigClient } from 'uniswap/src/features/gating/sdk/statsig'
import { LocalizationContextProvider } from 'uniswap/src/features/language/LocalizationContext'
import { useCurrentLanguageInfo } from 'uniswap/src/features/language/hooks'
import { clearNotificationQueue } from 'uniswap/src/features/notifications/slice'
......@@ -136,48 +133,34 @@ function App(): JSX.Element | null {
const [datadogSessionSampleRate, setDatadogSessionSampleRate] = React.useState<number | undefined>(undefined)
const statSigOptions: {
user: StatsigUser
options: StatsigOptions
sdkKey: string
waitForInitialization: boolean
} = {
options: {
environment: {
tier: getStatsigEnvironmentTier(),
},
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,
),
)
},
},
sdkKey: config.statsigApiKey,
user: {
Storage._setProvider(statsigMMKVStorageProvider)
const statsigUser: StatsigUser = useMemo(
() => ({
...(deviceId ? { userID: deviceId } : {}),
custom: {
app: StatsigCustomAppValue.Mobile,
},
},
waitForInitialization: true,
}),
[deviceId],
)
const onStatsigInit = (): void => {
setDatadogSessionSampleRate(
getDynamicConfigValue<
DynamicConfigs.DatadogSessionSampleRate,
DatadogSessionSampleRateKey,
DatadogSessionSampleRateValType
>(
DynamicConfigs.DatadogSessionSampleRate,
DatadogSessionSampleRateKey.Rate,
MOBILE_DEFAULT_DATADOG_SESSION_SAMPLE_RATE,
),
)
}
return (
<StatsigProvider {...statSigOptions}>
<StatsigProviderWrapper user={statsigUser} storageProvider={statsigMMKVStorageProvider} onInit={onStatsigInit}>
<DatadogProviderWrapper sessionSampleRate={datadogSessionSampleRate}>
<Trace>
<StrictMode>
......@@ -196,7 +179,7 @@ function App(): JSX.Element | null {
</StrictMode>
</Trace>
</DatadogProviderWrapper>
</StatsigProvider>
</StatsigProviderWrapper>
)
}
......@@ -245,7 +228,7 @@ function AppOuter(): JSX.Element | null {
// Datadog has a limited set of accepted symbols in feature flags
// https://docs.datadoghq.com/real_user_monitoring/guide/setup-feature-flag-data-collection/?tab=reactnative#feature-flag-naming
flagKey.replaceAll('-', '_'),
Statsig.checkGateWithExposureLoggingDisabled(flagKey),
getStatsigClient().checkGate(flagKey),
).catch(() => undefined)
}
......@@ -254,7 +237,7 @@ function AppOuter(): JSX.Element | null {
// Datadog has a limited set of accepted symbols in feature flags
// https://docs.datadoghq.com/real_user_monitoring/guide/setup-feature-flag-data-collection/?tab=reactnative#feature-flag-naming
`experiment_${experiment.replaceAll('-', '_')}`,
Statsig.getExperimentWithExposureLoggingDisabled(experiment).getGroupName(),
getStatsigClient().getExperiment(experiment).groupName,
).catch(() => undefined)
}
......@@ -286,17 +269,15 @@ function AppOuter(): JSX.Element | null {
<DataUpdaters />
<NavigationContainer>
<MobileWalletNavigationProvider>
<DevOpenAIProvider>
<WalletUniswapProvider>
<BottomSheetModalProvider>
<AppModals />
<PerformanceProfiler onReportPrepared={onReportPrepared}>
<AppInner />
</PerformanceProfiler>
</BottomSheetModalProvider>
</WalletUniswapProvider>
<NotificationToastWrapper />
</DevOpenAIProvider>
<WalletUniswapProvider>
<BottomSheetModalProvider>
<AppModals />
<PerformanceProfiler onReportPrepared={onReportPrepared}>
<AppInner />
</PerformanceProfiler>
</BottomSheetModalProvider>
</WalletUniswapProvider>
<NotificationToastWrapper />
</MobileWalletNavigationProvider>
</NavigationContainer>
</UnitagUpdaterContextProvider>
......@@ -349,7 +330,6 @@ function AppInner(): JSX.Element {
return (
<>
<DevAIAssistantScreen />
<OfflineBanner />
<TestnetModeBanner />
<AppStackNavigator />
......
import { PersistState } from 'redux-persist'
import { PersistState, REHYDRATE } from 'redux-persist'
import { SagaIterator } from 'redux-saga'
import { monitoredSagas } from 'src/app/monitoredSagas'
import { cloudBackupsManagerSaga } from 'src/features/CloudBackup/saga'
import { appRatingWatcherSaga } from 'src/features/appRating/saga'
......@@ -14,15 +15,17 @@ import { telemetrySaga } from 'src/features/telemetry/saga'
import { restoreMnemonicCompleteWatcher } from 'src/features/wallet/saga'
import { walletConnectSaga } from 'src/features/walletConnect/saga'
import { signWcRequestSaga } from 'src/features/walletConnect/signWcRequestSaga'
import { call, delay, select, spawn } from 'typed-redux-saga'
import { call, fork, join, select, spawn, take } from 'typed-redux-saga'
import { appLanguageWatcherSaga } from 'uniswap/src/features/language/saga'
import { apolloClientRef } from 'wallet/src/data/apollo/usePersistedApolloClient'
import { transactionWatcher } from 'wallet/src/features/transactions/transactionWatcherSaga'
const REHYDRATION_STATUS_POLLING_INTERVAL = 50
// These sagas are not persisted, so we can run them before rehydration
const nonPersistedSagas = [appStateSaga, splashScreenSaga, biometricsSaga]
// All regular sagas must be included here
const sagas = [
lockScreenSaga,
appLanguageWatcherSaga,
appRatingWatcherSaga,
cloudBackupsManagerSaga,
......@@ -34,30 +37,54 @@ const sagas = [
signWcRequestSaga,
telemetrySaga,
walletConnectSaga,
appStateSaga,
splashScreenSaga,
biometricsSaga,
lockScreenSaga,
]
export function* rootMobileSaga() {
// wait until redux-persist has finished rehydration
while (true) {
if (yield* select((state: { _persist?: PersistState }): boolean | undefined => state._persist?.rehydrated)) {
break
}
yield* delay(REHYDRATION_STATUS_POLLING_INTERVAL)
// Start non-persisted sagas
for (const s of nonPersistedSagas) {
yield* spawn(s)
}
// Fork the rehydration process to run in parallel
const rehydrationTask = yield* fork(waitForRehydration)
// Initialize Apollo client in parallel
const apolloClient = yield* call(apolloClientRef.onReady)
// Wait for rehydration to complete
yield* join(rehydrationTask)
// Start regular sagas after rehydration is complete
for (const s of sagas) {
yield* spawn(s)
}
const apolloClient = yield* call(apolloClientRef.onReady)
// Start transaction watcher with Apollo client
yield* spawn(transactionWatcher, { apolloClient })
// Start monitored sagas
for (const m of Object.values(monitoredSagas)) {
yield* spawn(m.wrappedSaga)
}
}
function* waitForRehydration() {
// First check if already rehydrated (might have happened before saga started)
const alreadyRehydrated = yield* call(getIsRehydrated)
if (alreadyRehydrated) {
return
}
// Wait for the persist/REHYDRATE action that sets the rehydrated flag
while (true) {
yield* take(REHYDRATE)
const isRehydrated = yield* call(getIsRehydrated)
if (isRehydrated) {
break
}
}
}
function* getIsRehydrated(): SagaIterator<boolean | undefined> {
return yield* select((state: { _persist?: PersistState }): boolean | undefined => state._persist?.rehydrated)
}
......@@ -5,8 +5,8 @@ import 'react-native-reanimated'
import { QRCodeScanner } from 'src/components/QRCodeScanner/QRCodeScanner'
import { getSupportedURI, URIType } from 'src/components/Requests/ScanSheet/util'
import { Flex, Text, TouchableArea, useIsDarkMode } from 'ui/src'
import Scan from 'ui/src/assets/icons/receive.svg'
import ScanQRIcon from 'ui/src/assets/icons/scan.svg'
import { QrCode } from 'ui/src/components/icons'
import { useSporeColorsForTheme } from 'ui/src/hooks/useSporeColors'
import { iconSizes } from 'ui/src/theme'
import { Modal } from 'uniswap/src/components/modals/Modal'
......@@ -94,7 +94,7 @@ export function RecipientScanModal({ onSelectRecipient, onClose }: Props): JSX.E
>
<Flex row alignItems="center" gap="$spacing12">
{currentScreenState === ScannerModalState.ScanQr ? (
<Scan color={colors.neutral1.get()} height={iconSizes.icon24} width={iconSizes.icon24} />
<QrCode color="$neutral1" size="$icon.24" />
) : (
<ScanQRIcon color={colors.neutral1.get()} height={iconSizes.icon24} width={iconSizes.icon24} />
)}
......
......@@ -151,7 +151,7 @@ function UwULinkErc20SendModalContent({
<NetworkFee chainId={chainId} gasFee={gasFee} />
</Flex>
{!hasSufficientGasFunds && (
<Text color="$DEP_accentWarning" pt="$spacing8" textAlign="center" variant="body3">
<Text color="$statusWarning" pt="$spacing8" textAlign="center" variant="body3">
{t('walletConnect.request.error.insufficientFunds', {
currencySymbol: nativeCurrency?.symbol,
})}
......
......@@ -167,6 +167,8 @@ export function WalletConnectRequestModal({ onClose, request }: Props): JSX.Elem
request,
}),
)
} else if (request.type === EthMethod.SendCalls) {
// TODO: Implement
} else {
dispatch(
signWcRequestActions.trigger({
......@@ -245,6 +247,11 @@ export function WalletConnectRequestModal({ onClose, request }: Props): JSX.Elem
)
}
if (request.type === EthMethod.SendCalls) {
// TODO: Implement
return null
}
return (
<ModalWithOverlay
confirmationButtonText={
......
......@@ -117,7 +117,7 @@ export function WalletConnectRequestModalContent({
{!hasSufficientFunds && (
<SectionContainer>
<Text color="$DEP_accentWarning" variant="body2">
<Text color="$statusWarning" variant="body2">
{t('walletConnect.request.error.insufficientFunds', {
currencySymbol: nativeCurrency?.symbol,
})}
......@@ -130,12 +130,12 @@ export function WalletConnectRequestModalContent({
backgroundColor="$statusWarning2"
icon={
<AlertTriangleFilled
color={colors.DEP_accentWarning.val}
color={colors.statusWarning.val}
height={iconSizes.icon16}
width={iconSizes.icon16}
/>
}
textColor="$DEP_accentWarning"
textColor="$statusWarning"
title={t('walletConnect.request.error.network')}
/>
) : (
......@@ -185,7 +185,7 @@ function WarningSection({
if (!isTransactionRequest(request)) {
return (
<Flex centered row alignSelf="center" gap="$spacing8">
<AlertTriangleFilled color={colors.DEP_accentWarning.val} height={iconSizes.icon16} width={iconSizes.icon16} />
<AlertTriangleFilled color={colors.statusWarning.val} height={iconSizes.icon16} width={iconSizes.icon16} />
<Text color="$neutral2" fontStyle="italic" variant="body3">
{t('walletConnect.request.warning.general.message')}
</Text>
......
......@@ -18,8 +18,8 @@ import { useWalletConnect } from 'src/features/walletConnect/useWalletConnect'
import { pairWithWalletConnectURI } from 'src/features/walletConnect/utils'
import { addRequest } from 'src/features/walletConnect/walletConnectSlice'
import { Flex, Text, TouchableArea, useIsDarkMode } from 'ui/src'
import Scan from 'ui/src/assets/icons/receive.svg'
import ScanQRIcon from 'ui/src/assets/icons/scan.svg'
import { QrCode } from 'ui/src/components/icons'
import { useSporeColorsForTheme } from 'ui/src/hooks/useSporeColors'
import { iconSizes } from 'ui/src/theme'
import { Modal } from 'uniswap/src/components/modals/Modal'
......@@ -179,7 +179,7 @@ export function WalletConnectModal({
contractManager,
})
dispatch(addRequest(wcRequest))
dispatch(addRequest(wcRequest.request))
onClose()
} catch (_) {
......@@ -292,7 +292,7 @@ export function WalletConnectModal({
>
<Flex row alignItems="center" gap="$spacing12">
{isScanningQr ? (
<Scan color={colors.neutral1.val} height={iconSizes.icon24} width={iconSizes.icon24} />
<QrCode color="$neutral1" size="$icon.24" />
) : (
<ScanQRIcon color={colors.neutral1.val} height={iconSizes.icon24} width={iconSizes.icon24} />
)}
......
import { parseEther } from 'ethers/lib/utils'
import { WalletConnectRequest } from 'src/features/walletConnect/walletConnectSlice'
import { AssetType } from 'uniswap/src/entities/assets'
import { DynamicConfigs, UwuLinkConfigKey } from 'uniswap/src/features/gating/configs'
import {
DynamicConfigs,
UwULinkAllowlist,
UwULinkAllowlistItem,
UwuLinkConfigKey,
} from 'uniswap/src/features/gating/configs'
import { useDynamicConfigValue } from 'uniswap/src/features/gating/hooks'
import { isUwULinkAllowlistType } from 'uniswap/src/features/gating/typeGuards'
import {
EthMethod,
EthTransaction,
......@@ -17,23 +23,6 @@ import { getTokenSendRequest } from 'wallet/src/features/transactions/send/hooks
import { SendCurrencyParams } from 'wallet/src/features/transactions/send/types'
import { Account } from 'wallet/src/features/wallet/accounts/types'
// This type must match the format in statsig dynamic config for uwulink
// https://console.statsig.com/5HjUux4OvSGzgqWIfKFt8i/dynamic_configs/uwulink_config
type UwULinkAllowlistItem = {
chainId: number
address: string
name: string
logo?: {
dark?: string
light?: string
}
}
type UwULinkAllowlist = {
contracts: UwULinkAllowlistItem[]
tokenRecipients: UwULinkAllowlistItem[]
}
const UWULINK_MAX_TXN_VALUE = '0.001'
export const UNISWAP_URL_SCHEME_UWU_LINK = 'uniswap://uwulink?'
......@@ -65,18 +54,7 @@ export function useUwuLinkContractAllowlist(): UwULinkAllowlist {
contracts: [],
tokenRecipients: [],
},
(x: unknown) => {
const hasFields =
x !== null && typeof x === 'object' && Object.hasOwn(x, 'contracts') && Object.hasOwn(x, 'tokenRecipients')
if (!hasFields) {
return false
}
const castedObj = x as { contracts: unknown; tokenRecipients: unknown }
return Array.isArray(castedObj.contracts) && Array.isArray(castedObj.tokenRecipients)
},
isUwULinkAllowlistType,
)
}
......
import { useTranslation } from 'react-i18next'
import { Flex, Separator, Text, TouchableArea } from 'ui/src'
import { AnglesDownUp, SortVertical } from 'ui/src/components/icons'
export function HiddenWalletsDivider({
numHidden,
isExpanded,
onPress,
}: {
padded?: boolean
numHidden: number
isExpanded: boolean
onPress: () => void
}): JSX.Element {
const { t } = useTranslation()
return (
<TouchableArea activeOpacity={1} mx="$spacing16" onPress={onPress}>
<Flex row alignItems="center" justifyContent="space-between" py="$spacing8">
<Flex centered grow row gap="$spacing12">
<Separator />
<Flex centered row gap="$gap4">
<Text color="$neutral2" textAlign="center" variant="body3">
{t('settings.section.wallet.hidden.row.title', { numHidden })}
</Text>
<Flex centered justifyContent="center">
{isExpanded ? (
<AnglesDownUp color="$neutral2" size="$icon.16" />
) : (
<SortVertical color="$neutral2" size="$icon.16" />
)}
</Flex>
</Flex>
<Separator />
</Flex>
</Flex>
</TouchableArea>
)
}
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux'
import { HiddenWalletsDivider } from 'src/components/Settings/HiddenWalletsDivider'
import { openModal } from 'src/features/modals/modalSlice'
import { Flex, TouchableArea } from 'ui/src'
import { RotatableChevron } from 'ui/src/components/icons'
......@@ -8,6 +8,7 @@ import { iconSizes } from 'ui/src/theme'
import { AccountType } from 'uniswap/src/features/accounts/types'
import { ModalName } from 'uniswap/src/features/telemetry/constants'
import { AddressDisplay } from 'wallet/src/components/accounts/AddressDisplay'
import { ExpandoRow } from 'wallet/src/components/ExpandoRow/ExpandoRow'
import { useAccountsList, useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks'
const DEFAULT_ACCOUNTS_TO_DISPLAY = 3
......@@ -17,6 +18,7 @@ interface Account {
}
export function WalletSettings(): JSX.Element {
const { t } = useTranslation()
const dispatch = useDispatch()
const allAccounts = useAccountsList()
const [showAll, setShowAll] = useState(false)
......@@ -69,9 +71,10 @@ export function WalletSettings(): JSX.Element {
<>
{renderAccountRow(activeAccount)}
<HiddenWalletsDivider
<ExpandoRow
isExpanded={showAll}
numHidden={allAccounts.length - 1}
label={t('settings.section.wallet.hidden.row.title', { numHidden: allAccounts.length - 1 })}
mx="$spacing16"
onPress={(): void => toggleViewAll()}
/>
......
......@@ -23,9 +23,9 @@ import { DDRumManualTiming } from 'utilities/src/logger/datadog/datadogEvents'
import { usePerformanceLogger } from 'utilities/src/logger/usePerformanceLogger'
import { isAndroid } from 'utilities/src/platform'
import { useValueAsRef } from 'utilities/src/react/useValueAsRef'
import { ExpandoRow } from 'wallet/src/components/ExpandoRow/ExpandoRow'
import { InformationBanner } from 'wallet/src/components/banners/InformationBanner'
import { isError, isNonPollingRequestInFlight } from 'wallet/src/data/utils'
import { HiddenTokensRow } from 'wallet/src/features/portfolio/HiddenTokensRow'
import { TokenBalanceItem } from 'wallet/src/features/portfolio/TokenBalanceItem'
import {
HIDDEN_TOKEN_BALANCES_ROW,
......@@ -305,9 +305,10 @@ const HiddenTokensRowWrapper = memo(function HiddenTokensRowWrapper(): JSX.Eleme
return (
<Flex grow>
<HiddenTokensRow
<ExpandoRow
isExpanded={hiddenTokensExpanded}
numHidden={hiddenTokensCount}
label={t('hidden.tokens.info.text.button', { numHidden: hiddenTokensCount })}
mx="$spacing16"
onPress={(): void => {
setHiddenTokensExpanded(!hiddenTokensExpanded)
}}
......
......@@ -10,7 +10,7 @@ import { FORCurrencyOrBalance, FiatOnRampCurrency } from 'uniswap/src/features/f
import { getUnsupportedFORTokensWithBalance, isSupportedFORCurrency } from 'uniswap/src/features/fiatOnRamp/utils'
import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext'
import { useDismissedTokenWarnings } from 'uniswap/src/features/tokens/slice/hooks'
import { ListSeparatorToggle } from 'uniswap/src/features/transactions/TransactionDetails/TransactionDetails'
import { ListSeparatorToggle } from 'uniswap/src/features/transactions/TransactionDetails/ListSeparatorToggle'
import { CurrencyId } from 'uniswap/src/types/currency'
import { NumberType } from 'utilities/src/format/types'
......
......@@ -32,6 +32,8 @@ import { useExploreSearchQuery } from 'uniswap/src/data/graphql/uniswap-data-api
import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains'
import { UniverseChainId } from 'uniswap/src/features/chains/types'
import { toGraphQLChain } from 'uniswap/src/features/chains/utils'
import { FeatureFlags } from 'uniswap/src/features/gating/flags'
import { useFeatureFlag } from 'uniswap/src/features/gating/hooks'
import { SearchContext } from 'uniswap/src/features/search/SearchContext'
import {
NFTCollectionSearchResult,
......@@ -54,6 +56,7 @@ export function SearchResultsSection({
}): JSX.Element {
const { t } = useTranslation()
const { defaultChainId } = useEnabledChains()
const tokenSearchV2Enabled = useFeatureFlag(FeatureFlags.TokenSearchV2)
// Search for matching tokens
const {
......@@ -66,6 +69,7 @@ export function SearchResultsSection({
searchQuery,
nftCollectionsFilter: { nameQuery: searchQuery },
chains: selectedChain ? [toGraphQLChain(selectedChain)] : undefined,
tokenSearchV2Enabled,
},
})
......
......@@ -14,7 +14,6 @@ import { SwirlyArrowDown } from 'ui/src/components/icons'
import { spacing, zIndexes } from 'ui/src/theme'
import {
Chain,
ContractInput,
HomeScreenTokensQuery,
useHomeScreenTokensQuery,
} from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks'
......@@ -22,6 +21,7 @@ import { fromGraphQLChain } from 'uniswap/src/features/chains/utils'
import { useAppFiatCurrency } from 'uniswap/src/features/fiatCurrency/hooks'
import { DynamicConfigs, HomeScreenExploreTokensConfigKey } from 'uniswap/src/features/gating/configs'
import { useDynamicConfigValue } from 'uniswap/src/features/gating/hooks'
import { isContractInputArrayType } from 'uniswap/src/features/gating/typeGuards'
import { MobileEventName } from 'uniswap/src/features/telemetry/constants'
import { useAppInsets } from 'uniswap/src/hooks/useAppInsets'
import { MobileScreens } from 'uniswap/src/types/screens/mobile'
......@@ -48,14 +48,14 @@ export const HomeExploreTab = memo(
Chain.Ethereum,
(x): x is Chain => Object.values(Chain).includes(x as Chain),
)
const recommendedTokens = useDynamicConfigValue(
DynamicConfigs.HomeScreenExploreTokens,
HomeScreenExploreTokensConfigKey.Tokens,
[] as ContractInput[],
(x): x is ContractInput[] =>
Array.isArray(x) &&
x.every((val) => typeof val.chain === 'string' && (!val.address || typeof val.address === 'string')),
[],
isContractInputArrayType,
)
const { onContentSizeChange } = useAdaptiveFooter(containerProps?.contentContainerStyle)
const { data } = useHomeScreenTokensQuery({ variables: { contracts: recommendedTokens, chain: ethChainId } })
......
......@@ -22,7 +22,8 @@ import { fromUniswapWebAppLink } from 'uniswap/src/features/chains/utils'
import { DynamicConfigs, UwuLinkConfigKey } from 'uniswap/src/features/gating/configs'
import { FeatureFlags, 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 { getStatsigClient } from 'uniswap/src/features/gating/sdk/statsig'
import { isUwULinkAllowlistType } from 'uniswap/src/features/gating/typeGuards'
import { BACKEND_NATIVE_CHAIN_ADDRESS_STRING } from 'uniswap/src/features/search/utils'
import { MobileEventName, ModalName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
......@@ -312,7 +313,7 @@ function* _sendAnalyticsEvent(deepLinkAction: DeepLinkActionResult, coldStart: b
}
export function* handleGoToFiatOnRampDeepLink() {
const disableForKorea = Statsig.checkGate(getFeatureFlagName(FeatureFlags.DisableFiatOnRampKorea))
const disableForKorea = getStatsigClient().checkGate(getFeatureFlagName(FeatureFlags.DisableFiatOnRampKorea))
if (disableForKorea) {
navigate(ModalName.KoreaCexTransferInfoModal)
} else {
......@@ -379,7 +380,7 @@ export function* parseAndValidateUserAddress(userAddress: string | null) {
function* handleScantasticDeepLink(scantasticQueryParams: string): Generator {
const params = parseScantasticParams(scantasticQueryParams)
const scantasticEnabled = Statsig.checkGate(getFeatureFlagName(FeatureFlags.Scantastic))
const scantasticEnabled = getStatsigClient().checkGate(getFeatureFlagName(FeatureFlags.Scantastic))
if (!params || !scantasticEnabled) {
Alert.alert(i18n.t('walletConnect.error.scantastic.title'), i18n.t('walletConnect.error.scantastic.message'), [
......@@ -402,10 +403,15 @@ function* handleUwuLinkDeepLink(uri: string): Generator {
const uwulinkData = parseUwuLinkDataFromDeeplink(decodedUri)
const parsedUwulinkRequest: UwULinkRequest = JSON.parse(uwulinkData)
const uwuLinkAllowList = getDynamicConfigValue(DynamicConfigs.UwuLink, UwuLinkConfigKey.Allowlist, {
contracts: [],
tokenRecipients: [],
})
const uwuLinkAllowList = getDynamicConfigValue(
DynamicConfigs.UwuLink,
UwuLinkConfigKey.Allowlist,
{
contracts: [],
tokenRecipients: [],
},
isUwULinkAllowlistType,
)
const isAllowed = isAllowedUwuLinkRequest(parsedUwulinkRequest, uwuLinkAllowList)
......@@ -437,7 +443,7 @@ function* handleUwuLinkDeepLink(uri: string): Generator {
contractManager,
})
yield* put(addRequest(uwuLinkTxnRequest))
yield* put(addRequest(uwuLinkTxnRequest.request))
} catch {
Alert.alert(i18n.t('walletConnect.error.uwu.title'), i18n.t('walletConnect.error.uwu.scan'), [
{
......
......@@ -17,7 +17,7 @@ import {
setLockScreenVisibility,
setManualRetryRequired,
} from 'src/features/lockScreen/lockScreenSlice'
import { hideSplashScreen } from 'src/features/splashScreen/splashScreenSlice'
import { onSplashScreenHidden } from 'src/features/splashScreen/splashScreenSlice'
import { call, put, select, takeEvery, takeLatest } from 'typed-redux-saga'
//------------------------------
......@@ -28,7 +28,7 @@ export function* lockScreenSaga(): SagaIterator {
// setup initial lock screen state on app load if required
yield* call(setupInitialLockScreenState)
// handle when splash screen is hidden
yield* takeLatest(hideSplashScreen.type, onSplashScreenHide)
yield* takeLatest(onSplashScreenHidden.type, onSplashScreenHide)
// handle when app state changes
yield* takeLatest(transitionAppState.type, onAppStateTransition)
// handle authentication status change in dedicated saga
......
import { useContext } from 'react'
import { OpenAIContext } from 'src/features/openai/OpenAIContext'
import { Flex } from 'ui/src'
import { CommentDots } from 'ui/src/components/icons'
export function AIAssistantOverlay(): JSX.Element {
const { open } = useContext(OpenAIContext)
return (
<>
<Flex position="absolute" right={62} top={66} zIndex="$popover" onPress={open}>
<CommentDots color="$accent1" size="$icon.28" />
</Flex>
</>
)
}
import { useContext, useEffect, useRef, useState } from 'react'
import { ScrollView as NativeScrollView } from 'react-native'
import { Message, OpenAIContext } from 'src/features/openai/OpenAIContext'
import { Button, Flex, Input, ScrollView, SpinningLoader, Text } from 'ui/src'
import { ArrowUpCircle, UniswapLogo } from 'ui/src/components/icons'
import { fonts, spacing } from 'ui/src/theme'
import { Modal } from 'uniswap/src/components/modals/Modal'
import { useBottomSheetSafeKeyboard } from 'uniswap/src/components/modals/useBottomSheetSafeKeyboard'
import { useAvatar } from 'uniswap/src/features/address/avatar'
import { AccountIcon } from 'wallet/src/components/accounts/AccountIcon'
import { useActiveAccountAddress } from 'wallet/src/features/wallet/hooks'
export function AIAssistantScreen(): JSX.Element {
const scrollRef = useRef<NativeScrollView>(null)
const inputRef = useRef<Input>(null)
const { messages, sendMessage, isOpen, isLoading, close } = useContext(OpenAIContext)
const [input, setInput] = useState('')
const [optimisticMessage, setOptimisticMessage] = useState<Message>()
const address = useActiveAccountAddress() || undefined
const { avatar } = useAvatar(address)
const { keyboardHeight } = useBottomSheetSafeKeyboard()
useEffect(() => {
setOptimisticMessage(undefined)
}, [messages])
const handleSendMessage = (): void => {
setOptimisticMessage({ text: input, role: 'user', buttons: [] })
setInput('')
inputRef.current?.clear()
sendMessage(input)
}
return (
<>
{isOpen && (
<Modal fullScreen name="account-edit-modal" onClose={close}>
<Flex grow animation="quicker" pb={keyboardHeight > 0 ? keyboardHeight - spacing.spacing20 : '$spacing12'}>
<ScrollView
ref={scrollRef}
flex={1}
flexGrow={1}
onContentSizeChange={() => scrollRef.current?.scrollToEnd()}
>
<Flex gap="$spacing8" justifyContent="flex-end" pb="$spacing16" px="$spacing16">
{[...messages, ...(optimisticMessage ? [optimisticMessage] : [])].map((message, index) => (
<Flex key={index}>
<Flex row gap="$spacing8">
{message.role === 'assistant' && <UniswapLogo color="$accent1" size={24} />}
<Flex fill alignItems={message.role === 'assistant' ? 'flex-start' : 'flex-end'}>
<Flex
backgroundColor="$surface2"
borderBottomLeftRadius="$rounded20"
borderBottomRightRadius="$rounded20"
borderTopLeftRadius={message.role === 'assistant' ? '$none' : '$rounded20'}
borderTopRightRadius={message.role === 'assistant' ? '$rounded20' : '$none'}
p={8}
>
<Text color={message.role === 'assistant' ? '$neutral1' : '$neutral1'} variant="body1">
{message.text}
</Text>
</Flex>
</Flex>
{message.role === 'user' && address && (
<AccountIcon address={address} avatarUri={avatar} size={24} />
)}
</Flex>
<Flex row flexWrap="wrap" gap="$spacing4">
{message.buttons.map((button, buttonIndex) => (
<Button key={buttonIndex} onPress={(): void => {}}>
<Text variant="body3">{button.text}</Text>
</Button>
))}
</Flex>
</Flex>
))}
</Flex>
</ScrollView>
<Flex
backgroundColor="$surface1"
borderColor="$surface3"
borderRadius="$rounded20"
borderWidth="$spacing1"
mx="$spacing16"
>
<Input
ref={inputRef}
autoFocus
backgroundColor="transparent"
fontSize={fonts.body2.fontSize}
height="auto"
pl="$spacing12"
placeholder="Type here"
pr="$spacing36"
py="$spacing8"
onChangeText={setInput}
onSubmitEditing={handleSendMessage}
/>
<Flex position="absolute" right={8} top={8}>
{isLoading ? (
<SpinningLoader color="$accent1" size={28} />
) : (
<ArrowUpCircle color="$neutral3" size="$icon.28" onPress={handleSendMessage} />
)}
</Flex>
</Flex>
</Flex>
</Modal>
)}
</>
)
}
import { PropsWithChildren, ReactNode, useEffect, useState } from 'react'
import { FeatureFlags } from 'uniswap/src/features/gating/flags'
import { useFeatureFlag } from 'uniswap/src/features/gating/hooks'
/**
* Note: It seems like the RN bundler can only compile code within
* the same file to determine if a package should be bundled or not.
*/
import { INCLUDE_PROTOTYPE_FEATURES } from 'react-native-dotenv'
const enabledInEnv = INCLUDE_PROTOTYPE_FEATURES === 'true' || process.env.INCLUDE_PROTOTYPE_FEATURES === 'true'
/**
* Dynamically imported AIAssistantOverlay to allow for dev testing without
* adding the openai package in production.
*/
export function DevAIAssistantOverlay(): JSX.Element | null {
const openAIAssistantEnabled = useFeatureFlag(FeatureFlags.OpenAIAssistant)
const [Component, setComponent] = useState<React.FC | null>(null)
const enabled = enabledInEnv && openAIAssistantEnabled
useEffect(() => {
if (enabled) {
const getComponent = async (): Promise<void> => {
const { AIAssistantOverlay } = await import('src/features/openai/AIAssistantOverlay')
setComponent((): React.FC => AIAssistantOverlay)
}
getComponent().catch(() => {})
}
}, [enabled])
return enabled ? Component ? <Component /> : null : null
}
type ProviderComponentType = ({ children }: { children: ReactNode }) => JSX.Element
/**
* Dynamically imported OpenAIProvider to allow for dev testing without
* adding the openai package in production.
*/
export const DevOpenAIProvider = ({ children }: PropsWithChildren): JSX.Element => {
const openAIAssistantEnabled = useFeatureFlag(FeatureFlags.OpenAIAssistant)
const [OpenAIProvider, setOpenAIProvider] = useState<ProviderComponentType>()
const enabled = enabledInEnv && openAIAssistantEnabled
useEffect(() => {
if (enabled) {
const getComponent = async (): Promise<void> => {
const { OpenAIContextProvider } = await import('src/features/openai/OpenAIContext')
setOpenAIProvider((): ProviderComponentType => OpenAIContextProvider)
}
getComponent().catch(() => {})
}
}, [enabled])
return OpenAIProvider && enabled ? <OpenAIProvider>{children}</OpenAIProvider> : <>{children}</>
}
/**
* Dynamically imported AIAssistantScreen to allow for dev testing without
* adding the openai package in production.
*/
export function DevAIAssistantScreen(): JSX.Element | null {
const openAIAssistantEnabled = useFeatureFlag(FeatureFlags.OpenAIAssistant)
const [Component, setComponent] = useState<React.FC | null>(null)
const enabled = enabledInEnv && openAIAssistantEnabled
useEffect(() => {
if (enabled) {
const getComponent = async (): Promise<void> => {
const { AIAssistantScreen } = await import('src/features/openai/AIAssistantScreen')
setComponent((): React.FC => AIAssistantScreen)
}
getComponent().catch(() => {})
}
}, [enabled])
return enabled ? Component ? <Component /> : null : null
}
This diff is collapsed.
import OpenAI from 'openai'
import { tools } from 'src/features/openai/functions'
import { config } from 'uniswap/src/config'
import { logger } from 'utilities/src/logger/logger'
export const ASSISTANT_ID = 'asst_PlaX9ILXiyV3cjsMEIUV6xbw'
export const openai = new OpenAI({
apiKey: config.openaiApiKey,
})
// eslint-disable-next-line import/no-unused-modules
export function setupAssistant(): void {
openai.beta.assistants
.update(ASSISTANT_ID, {
description: `
You are a helpful assistant for a crypto wallet app that allows users to swap on the decentralized exchange Uniswap. You will help answer questions for the user and help them use the app more effectively. Assume that the user is asking about tokens on the Ethereum blockchain unless specified otherwise. Do not include links or urls in responses. You can address the user by their username`,
tools,
})
.catch((error) => logger.debug('assistant.ts', 'setupAssistant', `Error fetching assistant: ${error}`))
}
import OpenAI from 'openai'
import { AppearanceSettingType } from 'wallet/src/features/appearance/slice'
export enum FunctionName {
BackupCloud = 'backupCloud',
BackupManual = 'backupManual',
GetTopTokens = 'getTopTokens',
GetTokenDetails = 'getTokenDetails',
GetWalletPortfolioBalances = 'getWalletPortfolioBalances',
GetSwapWarning = 'getSwapWarning',
StartSend = 'startSend',
StartSwap = 'startSwap',
SearchTokens = 'searchTokens',
SearchRecipients = 'searchRecipients',
SettingChangeAppearance = 'settingChangeAppearance',
NavigateToFiatOnramp = 'navigateToFiatOnramp',
}
export type PossibleFunctionArgs = {
address?: string
chain?: string
chainId?: number
pageSize?: number
sortBy?: string
text?: string
inputTokenAddress?: string
inputTokenAmount?: number
inputTokenUSD?: number
outputTokenAddress?: string
outputTokenAmount?: number
isSwappingAll?: boolean
recipientAddress?: string
appearanceSettingType?: string
}
export const tools: OpenAI.Beta.Assistants.AssistantTool[] = [
{
type: 'function',
function: {
name: FunctionName.BackupCloud,
description:
'Takes the user to a screen where they can back up their recovery phrase to the iCloud or Google Drive, encrypted by a password that the user will input.',
},
},
{
type: 'function',
function: {
name: FunctionName.BackupManual,
description:
"Takes the user to a screen with the user's recovery phrase that allows the user to write it down or copy it to be saved elsewhere",
},
},
{
type: 'function',
function: {
name: FunctionName.GetTopTokens,
parameters: {
type: 'object',
properties: {
chain: {
type: 'string',
description:
'An enum string for the ethereum chain to search on. The possible values are ARBITRUM, ETHEREUM, OPTIMISM, POLYGON, BNB, BASE, BLAST. It should be defaulted to ETHEREUM.',
},
sortBy: {
type: 'string',
description:
'An enum string for the field to sort by, descending. The possible values are TOTAL_VALUE_LOCKED, MARKET_CAP, VOLUME, POPULARITY.',
},
pageSize: {
type: 'number',
description: 'The number of results that should be returned.',
},
},
required: ['chain', 'sortBy', 'pageSize'],
},
description: 'Retrieves a sorted list of tokens for a specific chain',
},
},
{
type: 'function',
function: {
name: FunctionName.GetTokenDetails,
parameters: {
type: 'object',
properties: {
chain: {
type: 'string',
description:
'An enum string for the ethereum chain to search on. The possible values are ARBITRUM, ETHEREUM, OPTIMISM, POLYGON, BNB, BASE, BLAST. It should be defaulted to ETHEREUM.',
},
address: {
type: 'string',
description: 'The hexadecimal string representing the contract address for the specific token',
},
},
required: ['chain', 'address'],
},
description: 'Fetches details for a specific token on a specific chain',
},
},
{
type: 'function',
function: {
name: FunctionName.GetWalletPortfolioBalances,
description:
'Retrieves the portfolio balances of tokens for the user. Each balance is grouped by token for a specific chain.',
},
},
{
type: 'function',
function: {
name: FunctionName.GetSwapWarning,
description: 'Returns the current warning message for the swap the user is trying to make, if there is one.',
},
},
{
type: 'function',
function: {
name: FunctionName.SearchTokens,
parameters: {
type: 'object',
properties: {
text: {
type: 'string',
description: 'The text to search for in the token name or symbol',
},
chain: {
type: 'string',
description:
'An enum string for the ethereum chain to search on. The possible values are ARBITRUM, ETHEREUM, OPTIMISM, POLYGON, BNB, BASE, BLAST. If no value is passed, it will search on all chains.',
},
},
required: ['text'],
},
description: 'Searches for tokens based on the text provided',
},
},
{
type: 'function',
function: {
name: FunctionName.SearchRecipients,
parameters: {
type: 'object',
properties: {
text: {
type: 'string',
description: 'The text to search for in the user wallet address or username',
},
},
required: ['text'],
},
description:
'Searches for recipient wallet addresses to send tokens to, can search for ENS username or Unitag username. It will return the recipient wallet address and username if available.',
},
},
{
type: 'function',
function: {
name: FunctionName.SettingChangeAppearance,
parameters: {
type: 'object',
properties: {
setting: {
appearanceSettingType: 'string',
description: `The setting value for controlling dark mode. Possible values are: ${Object.values(
AppearanceSettingType,
).join(', ')}`,
},
},
description: 'Changes the appearance of the app to the specified theme e.g. dark mode or light mode',
},
},
},
{
type: 'function',
function: {
name: FunctionName.StartSwap,
parameters: {
type: 'object',
properties: {
chainId: {
type: 'number',
description:
'The hexadecimal string representing the chain address to swap the tokens on, converted to a number. These are the chain ids based on name: ArbitrumOne = 42161, Base = 8453, Optimism = 10, Polygon = 137, Blast = 81457, Bnb = 56.',
},
inputTokenAddress: {
type: 'string',
description:
'The hexadecimal string representing the contract address for the specific input token the user would like to swap.',
},
outputTokenAddress: {
type: 'string',
description:
'The hexadecimal string representing the contract address for the specific output token the user would like to swap for.',
},
inputTokenAmount: {
type: 'number',
description: 'The amount of input token the user would like to swap for the output token.',
},
outputTokenAmount: {
type: 'number',
description: 'The amount of output token the user would like to receive for the input token.',
},
isSwappingAll: {
type: 'boolean',
description:
'A boolean value that indicates if the user is swapping all of their owned input token. This is purely a helper flag and the inputTokenAmount variable is still needed.',
},
},
required: ['chainId'],
},
description:
'Navigates the user to a screen where they can swap one token for another on a specific chain on the Uniswap exchange protocol. At least one of inputTokenAddress or outputTokenAddress should be filled out, and both could be filled out. At least one of inputTokenAmount or outputTokenAmount should be provided, but both cannot be. Token amounts should be limited to a max of 10 significant digits, rounded. When swapping a certain percentage or ratio of the user’s input token, check their wallet portfolo for the amount.',
},
},
{
type: 'function',
function: {
name: FunctionName.StartSend,
parameters: {
type: 'object',
properties: {
chainId: {
type: 'number',
description:
'The hexadecimal string representing the chain address to send the tokens, converted to a number. These are the chain ids based on name: ArbitrumOne = 42161, Base = 8453, Optimism = 10, Polygon = 137, Blast = 81457, Bnb = 56.',
},
inputTokenAddress: {
type: 'string',
description:
'The hexadecimal string representing the contract address for the specific input token the user would like to send to the recipient.',
},
recipientAddress: {
type: 'string',
description: 'The hexadecimal string representing the wallet address for the recipient',
},
inputTokenAmount: {
type: 'number',
description: 'The amount of input token the user would like to send to the recipient.',
},
inputTokenUSD: {
type: 'number',
description:
'The equivalent amount in USD of input token the user would like to send to the recipient. Can be used in place of inputTokenAmount',
},
isSwappingAll: {
type: 'boolean',
description: 'A boolean value that indicates if the user is swapping all of their owned input token',
},
},
required: ['chainId', 'inputTokenAddress', 'recipientAddress', 'inputTokenAmount'],
},
description: 'Navigates the user to a screen where the user can send tokens to another wallet address',
},
},
{
type: 'function',
function: {
name: FunctionName.NavigateToFiatOnramp,
description:
'Navigates the user to a screen where they can buy crypto with fiat. This is helpful when the user does not have any crypto in their wallet or needs more for gas fees.',
},
},
]
......@@ -5,7 +5,7 @@ import { Button, Flex } from 'ui/src'
import { WarningLabel } from 'uniswap/src/components/modals/WarningModal/types'
import { AccountType } from 'uniswap/src/features/accounts/types'
import { selectHasDismissedLowNetworkTokenWarning } from 'uniswap/src/features/behaviorHistory/selectors'
import { WalletEventName } from 'uniswap/src/features/telemetry/constants'
import { UniswapEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { NativeCurrency } from 'uniswap/src/features/tokens/NativeCurrency'
import { useTransactionModalContext } from 'uniswap/src/features/transactions/TransactionModal/TransactionModalContext'
......@@ -62,7 +62,7 @@ export function SendFormButton({
}
if (!hasDismissedLowNetworkTokenWarning && isMax && currencyInInfo?.currency.isNative) {
sendAnalyticsEvent(WalletEventName.LowNetworkTokenInfoModalOpened, { location: 'send' })
sendAnalyticsEvent(UniswapEventName.LowNetworkTokenInfoModalOpened, { location: 'send' })
setShowMaxTransferModal(true)
return
}
......
......@@ -25,7 +25,7 @@ import {
TransactionScreen,
useTransactionModalContext,
} from 'uniswap/src/features/transactions/TransactionModal/TransactionModalContext'
import { LowNativeBalanceModal } from 'uniswap/src/features/transactions/swap/modals/LowNativeBalanceModal'
import { LowNativeBalanceModal } from 'uniswap/src/features/transactions/modals/LowNativeBalanceModal'
import { CurrencyField } from 'uniswap/src/types/currency'
import { createTransactionId } from 'uniswap/src/utils/createTransactionId'
import { useSendContext } from 'wallet/src/features/transactions/contexts/SendContext'
......
......@@ -2,8 +2,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native'
import { Flex, Text, TouchableArea, useSporeColors } from 'ui/src'
import InfoCircleFilled from 'ui/src/assets/icons/info-circle-filled.svg'
import { Flex, Text, TouchableArea } from 'ui/src'
import { AlertCircle } from 'ui/src/components/icons'
import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions'
import { iconSizes, spacing } from 'ui/src/theme'
......@@ -21,7 +20,6 @@ import {
DecimalPadInputRef,
} from 'uniswap/src/features/transactions/DecimalPadInput/DecimalPadInput'
import { InsufficientNativeTokenWarning } from 'uniswap/src/features/transactions/InsufficientNativeTokenWarning/InsufficientNativeTokenWarning'
import { useTransactionModalContext } from 'uniswap/src/features/transactions/TransactionModal/TransactionModalContext'
import { useUSDCValue } from 'uniswap/src/features/transactions/hooks/useUSDCPrice'
import { useUSDTokenUpdater } from 'uniswap/src/features/transactions/hooks/useUSDTokenUpdater'
import { BlockedAddressWarning } from 'uniswap/src/features/transactions/modals/BlockedAddressWarning'
......@@ -44,10 +42,8 @@ const TRANSFER_DIRECTION_BUTTON_BORDER_WIDTH = spacing.spacing4
export function SendTokenForm(): JSX.Element {
const { t } = useTranslation()
const colors = useSporeColors()
const { fullHeight } = useDeviceDimensions()
const { walletNeedsRestore, openWalletRestoreModal } = useTransactionModalContext()
const { updateSendForm, derivedSendInfo, warnings, gasFee } = useSendContext()
const [currencyFieldFocused, setCurrencyFieldFocused] = useState(true)
......@@ -96,14 +92,6 @@ export function SendTokenForm(): JSX.Element {
const { isBlocked: isRecipientBlocked } = useIsBlocked(recipient)
const isBlocked = isActiveBlocked || isRecipientBlocked
const onRestorePress = (): void => {
if (!openWalletRestoreModal) {
throw new Error('Invalid call to `onRestorePress` with missing `openWalletRestoreModal`')
}
setCurrencyFieldFocused(false)
openWalletRestoreModal()
}
const onTransferWarningClick = (): void => {
dismissNativeKeyboard()
setShowWarningModal(true)
......@@ -325,32 +313,6 @@ export function SendTokenForm(): JSX.Element {
{recipient && (
<RecipientInputPanel recipientAddress={recipient} onShowRecipientSelector={onShowRecipientSelector} />
)}
{walletNeedsRestore && (
<TouchableArea disabled={!openWalletRestoreModal} onPress={onRestorePress}>
<Flex
grow
row
alignItems="center"
alignSelf="stretch"
backgroundColor="$surface2"
borderBottomColor="$surface1"
borderBottomLeftRadius="$rounded20"
borderBottomRightRadius="$rounded20"
borderBottomWidth={1}
gap="$spacing8"
p="$spacing12"
>
<InfoCircleFilled
color={colors.DEP_accentWarning.val}
height={iconSizes.icon20}
width={iconSizes.icon20}
/>
<Text color="$DEP_accentWarning" variant="subheading2">
{t('send.warning.restore')}
</Text>
</Flex>
</TouchableArea>
)}
{isBlocked ? (
<BlockedAddressWarning
grow
......
import BootSplash from 'react-native-bootsplash'
import { SagaIterator } from 'redux-saga'
import {
hideSplashScreen as hideSplashScreenAction,
dismissSplashScreen,
onSplashScreenHidden,
selectSplashScreenDismissRequested,
selectSplashScreenIsHidden,
} from 'src/features/splashScreen/splashScreenSlice'
import { call, select, takeLatest } from 'typed-redux-saga'
import { call, put, select, takeLatest } from 'typed-redux-saga'
//------------------------------
// SplashScreen saga
......@@ -14,11 +16,20 @@ export function* splashScreenSaga(): SagaIterator {
if (yield* select(selectSplashScreenIsHidden)) {
return
}
yield* takeLatest(hideSplashScreenAction.type, hideSplashScreen)
// if the splash screen was dismissed before the
// saga was started, we need to hide it immediately
if (yield* select(selectSplashScreenDismissRequested)) {
yield* call(hideSplashScreen)
}
// otherwise, we need to wait for the splash screen to be dismissed
// via a dispatch of the dismissSplashScreen action
yield* takeLatest(dismissSplashScreen.type, hideSplashScreen)
}
function* hideSplashScreen(): SagaIterator {
// ensures smooth fade out
yield* call(async () => new Promise(requestAnimationFrame))
yield* call(BootSplash.hide, { fade: true })
// on hide, we need to set the visibility to hidden
yield* put(onSplashScreenHidden())
}
......@@ -12,23 +12,28 @@ enum SplashScreenVisibility {
// eslint-disable-next-line import/no-unused-modules
export interface SplashScreenState {
visibility: SplashScreenVisibility
dismissRequested: boolean
}
const initialState: SplashScreenState = {
visibility: SplashScreenVisibility.INIT,
dismissRequested: false,
}
const splashScreenSlice = createSlice({
name: 'splashScreen',
initialState,
reducers: {
hideSplashScreen: (state) => {
dismissSplashScreen: (state) => {
state.dismissRequested = true
},
onSplashScreenHidden: (state) => {
state.visibility = SplashScreenVisibility.HIDDEN
},
},
})
export const { hideSplashScreen } = splashScreenSlice.actions
export const { dismissSplashScreen, onSplashScreenHidden } = splashScreenSlice.actions
export const splashScreenReducer = splashScreenSlice.reducer
//------------------------------
......@@ -40,3 +45,6 @@ const selectSplashScreen = (state: { splashScreen: SplashScreenState }): SplashS
export const selectSplashScreenIsHidden = (state: { splashScreen: SplashScreenState }): boolean =>
selectSplashScreen(state) === SplashScreenVisibility.HIDDEN
export const selectSplashScreenDismissRequested = (state: { splashScreen: SplashScreenState }): boolean =>
state.splashScreen.dismissRequested
import { useDispatch } from 'react-redux'
import { hideSplashScreen } from 'src/features/splashScreen/splashScreenSlice'
import { dismissSplashScreen } from 'src/features/splashScreen/splashScreenSlice'
import { useEvent } from 'utilities/src/react/hooks'
/**
......@@ -10,6 +10,6 @@ export function useHideSplashScreen(): () => void {
const dispatch = useDispatch()
return useEvent(() => {
dispatch(hideSplashScreen())
dispatch(dismissSplashScreen())
})
}
import { MMKV } from 'react-native-mmkv'
const mmkv = new MMKV()
export const statsigMMKVStorageProvider = {
isReady: (): boolean => true,
isReadyResolver: (): null => null,
getProviderName: (): string => 'MMKV',
getAllKeys: (): string[] => mmkv.getAllKeys(),
getItem: (key: string): string | null => mmkv.getString(key) ?? null,
setItem: (key: string, value: string): void => mmkv.set(key, value),
removeItem: (key: string): void => mmkv.delete(key),
}
......@@ -7,6 +7,7 @@ import { useNavigationHeader } from 'src/utils/useNavigationHeader'
import { Person } from 'ui/src/components/icons'
import { UnitagEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { ClaimUnitagContent } from 'uniswap/src/features/unitags/ClaimUnitagContent'
import { ImportType, OnboardingEntryPoint } from 'uniswap/src/types/onboarding'
import {
MobileScreens,
......@@ -19,7 +20,6 @@ import {
useCreateOnboardingAccountIfNone,
useOnboardingContext,
} from 'wallet/src/features/onboarding/OnboardingContext'
import { ClaimUnitagContent } from 'wallet/src/features/unitags/ClaimUnitagContent'
type Props = NativeStackScreenProps<UnitagStackParamList, UnitagScreens.ClaimUnitag>
......
/* eslint-disable max-lines */
import { AnyAction } from '@reduxjs/toolkit'
import { IWalletKit, WalletKit, WalletKitTypes } from '@reown/walletkit'
import { Core } from '@walletconnect/core'
import '@walletconnect/react-native-compat'
import { PendingRequestTypes, ProposalTypes } from '@walletconnect/types'
import { PendingRequestTypes, ProposalTypes, SessionTypes } from '@walletconnect/types'
import { buildApprovedNamespaces, getSdkError } from '@walletconnect/utils'
import { Alert } from 'react-native'
import { EventChannel, eventChannel } from 'redux-saga'
......@@ -13,7 +14,9 @@ import {
getAccountAddressFromEIP155String,
getChainIdFromEIP155String,
getSupportedWalletConnectChains,
parseGetCallsStatusRequest,
parseGetCapabilitiesRequest,
parseSendCallsRequest,
parseSignRequest,
parseTransactionRequest,
} from 'src/features/walletConnect/utils'
......@@ -28,6 +31,8 @@ import { call, fork, put, select, take } from 'typed-redux-saga'
import { config } from 'uniswap/src/config'
import { ALL_CHAIN_IDS, UniverseChainId } from 'uniswap/src/features/chains/types'
import { getChainLabel } from 'uniswap/src/features/chains/utils'
import { FeatureFlags, getFeatureFlagName } from 'uniswap/src/features/gating/flags'
import { getStatsigClient } from 'uniswap/src/features/gating/sdk/statsig'
import { pushNotification } from 'uniswap/src/features/notifications/slice'
import { AppNotificationType } from 'uniswap/src/features/notifications/types'
import i18n from 'uniswap/src/i18n'
......@@ -213,6 +218,8 @@ function* handleSessionProposal(proposal: ProposalTypes.Struct) {
EthMethod.SignTypedData,
EthMethod.SignTypedDataV4,
EthMethod.GetCapabilities,
EthMethod.SendCalls,
EthMethod.GetCallsStatus,
],
events: [EthEvent.AccountsChanged, EthEvent.ChainChanged],
accounts,
......@@ -258,6 +265,16 @@ function* handleSessionProposal(proposal: ProposalTypes.Struct) {
}
}
function getAccountAddressFromWCSession(requestSession: SessionTypes.Struct) {
const namespaces = Object.values(requestSession.namespaces)
const eip155Account = namespaces[0]?.accounts[0]
return eip155Account ? getAccountAddressFromEIP155String(eip155Account) : undefined
}
const eip5792Methods = [EthMethod.GetCallsStatus, EthMethod.SendCalls, EthMethod.GetCapabilities].map((m) =>
m.valueOf(),
)
function* handleSessionRequest(sessionRequest: PendingRequestTypes.Struct) {
const { topic, params, id } = sessionRequest
const { request: wcRequest, chainId: wcChainId } = params
......@@ -265,43 +282,107 @@ function* handleSessionRequest(sessionRequest: PendingRequestTypes.Struct) {
const chainId = getChainIdFromEIP155String(wcChainId)
const requestSession = wcWeb3Wallet.engine.signClient.session.get(topic)
const accountAddress = getAccountAddressFromWCSession(requestSession)
const dapp = requestSession.peer.metadata
if (!chainId) {
throw new Error('WalletConnect 2.0 session request has invalid chainId')
}
if (!accountAddress) {
throw new Error('WalletConnect 2.0 session has no eip155 account')
}
if (eip5792Methods.includes(method)) {
const eip5792MethodsEnabled = getStatsigClient().checkGate(getFeatureFlagName(FeatureFlags.Eip5792Methods)) ?? false
if (!eip5792MethodsEnabled) {
yield* call([wcWeb3Wallet, wcWeb3Wallet.respondSessionRequest], {
topic,
response: {
id,
jsonrpc: '2.0',
error: getSdkError('WC_METHOD_UNSUPPORTED'),
},
})
return
}
}
switch (method) {
case EthMethod.EthSign:
case EthMethod.PersonalSign:
case EthMethod.SignTypedData:
case EthMethod.SignTypedDataV4: {
const { account, request } = parseSignRequest(method, topic, id, chainId, dapp, requestParams)
yield* put(
addRequest({
account,
request,
}),
)
const request = parseSignRequest(method, topic, id, chainId, dapp, requestParams)
yield* put(addRequest(request))
break
}
case EthMethod.EthSendTransaction: {
const { account, request } = parseTransactionRequest(method, topic, id, chainId, dapp, requestParams)
yield* put(
addRequest({
account,
request,
}),
)
const request = parseTransactionRequest(method, topic, id, chainId, dapp, requestParams)
yield* put(addRequest(request))
break
}
case EthMethod.SendCalls: {
// capabilities as part of sendCallsRequest is subject to change
const { capabilities } = parseSendCallsRequest(topic, id, chainId, dapp, requestParams, accountAddress)
// Mock response data
const response = {
id,
capabilities,
}
yield* call([wcWeb3Wallet, wcWeb3Wallet.respondSessionRequest], {
topic,
response: {
id,
jsonrpc: '2.0',
result: response,
},
})
break
}
case EthMethod.GetCapabilities: {
const namespaces = Object.values(requestSession.namespaces)
const eip155Account = namespaces[0]?.accounts[0]
const accountAddress = eip155Account ? getAccountAddressFromEIP155String(eip155Account) : undefined
case EthMethod.GetCallsStatus: {
const { id: batchId } = parseGetCallsStatusRequest(topic, id, chainId, dapp, requestParams, accountAddress)
// Mock response data
const response = {
version: '1.0',
id: batchId,
chainId,
status: 100,
receipts: [
{
logs: [
{
address: '0x1234567890123456789012345678901234567890',
data: '0x0000000000000000000000000000000000000000000000000000000000000001',
topics: ['0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'],
},
],
status: '0x1', // Success
blockHash: '0x0000000000000000000000000000000000000000000000000000000000000000',
blockNumber: '0x1',
gasUsed: '0x5208', // 21000
transactionHash: '0x0000000000000000000000000000000000000000000000000000000000000000',
},
],
capabilities: {},
}
yield* call([wcWeb3Wallet, wcWeb3Wallet.respondSessionRequest], {
topic,
response: {
id,
jsonrpc: '2.0',
result: response,
},
})
break
}
case EthMethod.GetCapabilities: {
const { account } = parseGetCapabilitiesRequest(method, topic, id, dapp, requestParams)
if (account !== accountAddress) {
// Reject unauthorized wallet_getCapabilities request
......@@ -314,17 +395,17 @@ function* handleSessionRequest(sessionRequest: PendingRequestTypes.Struct) {
error: getSdkError('UNAUTHORIZED_METHOD'),
},
})
} else {
yield* call([wcWeb3Wallet, wcWeb3Wallet.respondSessionRequest], {
topic,
response: {
id,
jsonrpc: '2.0',
// TODO: This would be where we add any changes in capabilities object (when decided)
result: {},
},
})
}
yield* call([wcWeb3Wallet, wcWeb3Wallet.respondSessionRequest], {
topic,
response: {
id,
jsonrpc: '2.0',
// TODO: This would be where we add any changes in capabilities object (when decided)
result: {},
},
})
break
}
default:
......
......@@ -6,10 +6,13 @@ import {
SignRequest,
TransactionRequest,
WalletCapabilitiesRequest,
WalletGetCallsStatusRequest,
WalletSendCallsRequest,
} from 'src/features/walletConnect/walletConnectSlice'
import { UniverseChainId } from 'uniswap/src/features/chains/types'
import { toSupportedChainId } from 'uniswap/src/features/chains/utils'
import { EthMethod, EthSignMethod } from 'uniswap/src/types/walletConnect'
import { GetCallsStatusParams, SendCallsParams } from 'wallet/src/features/dappRequests/types'
/**
* Construct WalletConnect 2.0 session namespaces to complete a new pairing. Used when approving a new pairing request.
......@@ -73,6 +76,49 @@ export const getAccountAddressFromEIP155String = (account: string): Address | nu
return address
}
/**
* Creates a base WalletConnect request object with common properties
*
* @param method The request method type
* @param topic WalletConnect session ID
* @param internalId WalletConnect request ID
* @param account Account address
* @param chainId Chain ID for the request
* @param dapp Dapp metadata
* @returns Base request object with common properties
*/
function createBaseRequest<T extends EthMethod>(
method: T,
topic: string,
internalId: number,
account: Address,
dapp: SignClientTypes.Metadata,
): {
type: T
sessionId: string
internalId: string
account: Address
dapp: {
name: string
url: string
icon: string | null
source: 'walletconnect'
}
} {
return {
type: method,
sessionId: topic,
internalId: String(internalId),
account,
dapp: {
name: dapp.name,
url: dapp.url,
icon: dapp.icons[0] ?? null,
source: 'walletconnect',
},
}
}
/**
* Formats SignRequest object from WalletConnect 2.0 request parameters
*
......@@ -91,25 +137,13 @@ export const parseSignRequest = (
chainId: UniverseChainId,
dapp: SignClientTypes.Metadata,
requestParams: WalletKitTypes.SessionRequest['params']['request']['params'],
): { account: Address; request: SignRequest } => {
): SignRequest => {
const { address, rawMessage, message } = getAddressAndMessageToSign(method, requestParams)
return {
account: address,
request: {
type: method,
sessionId: topic,
internalId: String(internalId),
rawMessage,
message,
account: address,
chainId,
dapp: {
name: dapp.name,
url: dapp.url,
icon: dapp.icons[0] ?? null,
source: 'walletconnect',
},
},
...createBaseRequest(method, topic, internalId, address, dapp),
chainId,
rawMessage,
message,
}
}
......@@ -133,31 +167,19 @@ export const parseTransactionRequest = (
chainId: UniverseChainId,
dapp: SignClientTypes.Metadata,
requestParams: WalletKitTypes.SessionRequest['params']['request']['params'],
): { account: Address; request: TransactionRequest } => {
): TransactionRequest => {
// Omit gasPrice and nonce in tx sent from dapp since it is calculated later
const { from, to, data, gasLimit, value } = requestParams[0]
return {
account: from,
request: {
type: method,
sessionId: topic,
internalId: String(internalId),
transaction: {
to,
from,
value,
data,
gasLimit,
},
account: from,
chainId,
dapp: {
name: dapp.name,
url: dapp.url,
icon: dapp.icons[0] ?? null,
source: 'walletconnect',
},
...createBaseRequest(method, topic, internalId, from, dapp),
chainId,
transaction: {
to,
from,
value,
data,
gasLimit,
},
}
}
......@@ -170,7 +192,7 @@ export const parseTransactionRequest = (
* @param {number} internalId id for the WalletConnect request
* @param {SignClientTypes.Metadata} dapp metadata for the dapp requesting capabilities
* @param {[string, string[]?]} requestParams parameters of the request [Wallet Address, [Chain IDs]?]
* @returns {{account: Address, request: WalletCapabilitiesRequest}} formatted request object
* @returns {WalletCapabilitiesRequest} formatted request object
*/
export const parseGetCapabilitiesRequest = (
method: EthMethod.GetCapabilities,
......@@ -178,27 +200,52 @@ export const parseGetCapabilitiesRequest = (
internalId: number,
dapp: SignClientTypes.Metadata,
requestParams: [string, string[]?],
): { account: Address; request: WalletCapabilitiesRequest } => {
): WalletCapabilitiesRequest => {
const [address, chainIds] = requestParams
const parsedChainIds = chainIds
?.map((chainId) => toSupportedChainId(chainId))
.filter((c): c is UniverseChainId => Boolean(c))
return {
account: address,
request: {
type: method,
sessionId: topic,
internalId: String(internalId),
account: address,
chainIds: parsedChainIds,
dapp: {
name: dapp.name,
url: dapp.url,
icon: dapp.icons[0] ?? null,
source: 'walletconnect',
},
},
...createBaseRequest(method, topic, internalId, address, dapp), // 0 as chainId since it's not specific to a chain
chainIds: parsedChainIds,
}
}
export const parseSendCallsRequest = (
topic: string,
internalId: number,
chainId: number,
dapp: SignClientTypes.Metadata,
requestParams: [SendCallsParams],
account: Address,
): WalletSendCallsRequest => {
const sendCallsParam = requestParams[0]
const requestId = sendCallsParam.id || 'mock-batch-id (will be txID or `id` from request)'
return {
...createBaseRequest(EthMethod.SendCalls, topic, internalId, sendCallsParam.from ?? account, dapp),
chainId,
calls: sendCallsParam.calls,
capabilities: sendCallsParam.capabilities || {},
account: sendCallsParam.from ?? account,
id: requestId,
version: sendCallsParam.version,
}
}
export const parseGetCallsStatusRequest = (
topic: string,
internalId: number,
chainId: number,
dapp: SignClientTypes.Metadata,
requestParams: [GetCallsStatusParams],
account: Address,
): WalletGetCallsStatusRequest => {
const requestId = requestParams[0]
return {
...createBaseRequest(EthMethod.GetCallsStatus, topic, internalId, account, dapp),
chainId,
id: requestId,
}
}
......
......@@ -32,7 +32,6 @@ import {
useScrollSync,
} from 'src/components/layout/TabHelpers'
import { selectSomeModalOpen } from 'src/features/modals/selectSomeModalOpen'
import { DevAIAssistantOverlay } from 'src/features/openai/DevAIGate'
import { useHideSplashScreen } from 'src/features/splashScreen/useHideSplashScreen'
import { useWalletRestore } from 'src/features/wallet/hooks'
import { HomeScreenQuickActions } from 'src/screens/HomeScreen/HomeScreenQuickActions'
......@@ -474,7 +473,6 @@ export function HomeScreen(props?: AppStackScreenProp<MobileScreens.Home>): JSX.
return (
<Screen edges={['left', 'right']} onLayout={hideSplashScreen}>
<DevAIAssistantOverlay />
<View style={TAB_STYLES.container}>
<Animated.View style={headerContainerStyle} onLayout={handleHeaderLayout}>
{contentHeader}
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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