ci(release): publish latest release

parent ac6fb2e6
* @uniswap/web-admins
......@@ -5,7 +5,7 @@ An open source repository for all Uniswap front end interfaces maintained by Uni
## Interfaces
- Web: [app.uniswap.org](https://app.uniswap.org)
- Wallet (mobile + extension): [wallet.uniswap.org](https://wallet.uniswap.org)
- Wallet: [wallet.uniswap.org](https://wallet.uniswap.org)
## Socials / Contact
......@@ -31,7 +31,6 @@ For instructions per application or package, see the README published for each a
- [Web](apps/web/README.md)
- [Mobile](apps/mobile/README.md)
- [Extension](apps/extension/README.md)
## Releases
......@@ -44,7 +43,7 @@ Translations for our applications are done through [crowdin](https://crowdin.com
| App | Coverage |
| ------- | -------- |
| web | [![Crowdin](https://badges.crowdin.net/uniswap-interface/localized.svg)](https://crowdin.com/project/uniswap-interface) |
| wallet | [![Crowdin](https://badges.crowdin.net/uniswap-wallet/localized.svg)](https://crowdin.com/project/uniswap-wallet) |
| mobile | [![Crowdin](https://badges.crowdin.net/uniswap-wallet/localized.svg)](https://crowdin.com/project/uniswap-wallet) |
## 🗂 Directory Structure
......
- UI fixes across various pages
- UniswapX UI/UX improvements
- Internal code organization improvements
\ No newline at end of file
IPFS hash of the deployment:
- CIDv0: `QmUCDVLJAiPuTxPSYYZ7fTUCkpFx5jDMToQLSqzJfAWUzY`
- CIDv1: `bafybeicw7t2v6psn5oqwpfsghubz5ixmt2wigocqu4mpesjctiimaosqnu`
The latest release is always mirrored at [app.uniswap.org](https://app.uniswap.org).
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://bafybeicw7t2v6psn5oqwpfsghubz5ixmt2wigocqu4mpesjctiimaosqnu.ipfs.dweb.link/
- https://bafybeicw7t2v6psn5oqwpfsghubz5ixmt2wigocqu4mpesjctiimaosqnu.ipfs.cf-ipfs.com/
- [ipfs://QmUCDVLJAiPuTxPSYYZ7fTUCkpFx5jDMToQLSqzJfAWUzY/](ipfs://QmUCDVLJAiPuTxPSYYZ7fTUCkpFx5jDMToQLSqzJfAWUzY/)
### 5.40.2 (2024-07-24)
### Bug Fixes
* **web:** use tx hash for block explorer link (#10446) 84c4455
extension/1.1.0
\ No newline at end of file
web/5.40.2
\ No newline at end of file
ignores: [
# Dependencies that depcheck thinks are unused but are actually used
"react-native-web",
"jest-environment-jsdom",
"webpack-cli",
# Dependencies that depcheck thinks are missing but are actually present or never used
## Internal packages / workspaces
"src",
"tsconfig",
# Webpack plugins
"@svgr/webpack",
"tamagui-loader",
"esbuild-loader",
"swc-loader",
## Testing
"@testing-library/dom",
]
module.exports = {
root: true,
extends: ['@uniswap/eslint-config/native'],
ignorePatterns: ['node_modules', 'dist', '.turbo', 'build', '.eslintrc.js', 'webpack.config.js', 'webpack.dev.config.js', 'manifest.json'],
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 2018,
sourceType: 'module',
},
overrides: [
{
files: ['*.ts', '*.tsx'],
rules: {
'no-relative-import-paths/no-relative-import-paths': [
'error',
{
allowSameFolder: false,
},
],
},
},
],
rules: {},
}
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dev
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.tamagui
# Sentry Config File
.env.sentry-build-plugin
# Uniswap Extension
## Developer Quickstart
### Running the extension locally
To run the extension, run the following from the top level of the monorepo:
```bash
yarn
yarn extension start
```
### Environment variables
You need to get the environment variables from 1password in order to get full functionality. Run the command `yarn extension env:local:download` to copy them to your root folder.
### Loading the extension into Chrome
1. Go to **chrome://extensions**
2. At the top right, turn on **Developer mode**
3. Click **Load unpacked**
4. Find and select the extension folder (apps/extension/dev)
## Running the extension locally with an absolute path (for testing scantastic)
Our scantastic API requires a consistent origin header so the build must be loaded from an absolute path. This works because Chrome generates a consistent ID for the extension based on the path it was loaded from.
To run the extension, run the following from the top level of the monorepo:
Mac:
```bash
yarn
yarn extension start:absolute
```
Windows:
```bash
yarn
yarn extension start:absolute:windows
```
1. Go to **chrome://extensions**
2. At the top right, turn on **Developer mode**
3. Click **Load unpacked**
4. Find and select the extension folder with an absolute path (`/Users/Shared/stretch` on Mac and `C:/ProgramData/stretch` on Windows)
5. Your chrome extension url should be `chrome-extension://ceofpnbcmdjbibjjdniemjemmgaibeih` on Mac and `chrome-extension://ffogefanhjekjafbpofianlhkonejcoe` on Windows. The backend allows this origin and the ID will be consistently generated based off an absolute path that is consistent on all machines.
## Migrations
We use `redux-persist` to persist the Redux state between user sessions. Most of this state is shared between the mobile app and the extension. Please review the [Wallet Migrations README](../../packages/wallet/src/state//README.md) for details on how to write migrations when you add or remove anything from the Redux state structure.
import 'utilities/src/logger/mocks'
import { chrome } from 'jest-chrome'
import { AppearanceSettingType } from 'wallet/src/features/appearance/slice'
import { TextEncoder, TextDecoder } from 'util';
process.env.IS_UNISWAP_EXTENSION = true
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;
const ignoreLogs = {
error: [
// We need to use _persist property to ensure that the state is properly
// rehydrated (https://github.com/Uniswap/universe/pull/7502/files#r1566259088)
'Unexpected key "_persist" found in previous state received by the reducer.'
]
}
// Ignore certain logs that are expected during tests.
Object.entries(ignoreLogs).forEach(([method, messages]) => {
const key = method
const originalMethod = console[key]
console[key] = ((...args) => {
if (messages.some((message) => args.some((arg) => typeof arg === 'string' && arg.startsWith(message)))) {
return
}
originalMethod(...args)
})
})
globalThis.matchMedia =
globalThis.matchMedia ||
((query) => {
const reducedMotion = query.match(/prefers-reduced-motion: ([a-zA-Z0-9-]+)/)
return {
// Needed for reanimated to disable reduced motion warning in tests
matches: reducedMotion ? reducedMotion[1] === 'no-preference' : false,
addListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}
})
require('react-native-reanimated').setUpTests()
global.chrome = chrome
jest.mock('src/app/navigation/utils', () => ({
useExtensionNavigation: () => ({
navigateTo: jest.fn(),
navigateBack: jest.fn(),
})
}))
jest.mock('wallet/src/features/focus/useIsFocused', () => {
return jest.fn().mockReturnValue(true)
})
const mockAppearanceSetting = AppearanceSettingType.System
jest.mock('wallet/src/features/appearance/hooks', () => {
return {
useCurrentAppearanceSetting: () => mockAppearanceSetting,
}
})
jest.mock('wallet/src/features/appearance/hooks', () => {
return {
useSelectedColorScheme: () => 'light',
}
})
const preset = require('../../config/jest-presets/jest/jest-preset')
const fileExtensions = [
'eot',
'gif',
'jpeg',
'jpg',
'otf',
'png',
'ttf',
'woff',
'woff2',
'mp4',
]
module.exports = {
...preset,
preset: 'jest-expo',
transform: {
'^.+\\.(t|j)sx?$': [
'babel-jest',
{
configFile: './src/test/babel.config.js',
}
],
},
moduleNameMapper: {
...preset.moduleNameMapper,
'^react-native$': 'react-native-web',
},
moduleFileExtensions: [
'web.js',
'web.jsx',
'web.ts',
'web.tsx',
...fileExtensions,
...preset.moduleFileExtensions,
],
resolver: "<rootDir>/src/test/jest-resolver.js",
displayName: 'Extension Wallet',
collectCoverageFrom: [
'src/app/**/*.{js,ts,tsx}',
'src/background/**/*.{js,ts,tsx}',
'src/contentScript/**/*.{js,ts,tsx}',
'!src/**/*.stories.**',
'!**/node_modules/**',
],
coverageThreshold: {
global: {
lines: 0,
},
},
setupFiles: [
'../../config/jest-presets/jest/setup.js',
'./jest-setup.js',
'../../node_modules/react-native-gesture-handler/jestSetup.js',
],
}
{
"name": "@uniswap/extension",
"version": "0.0.0",
"browserslist": "last 2 chrome versions",
"dependencies": {
"@apollo/client": "3.10.4",
"@ethersproject/providers": "5.7.2",
"@metamask/rpc-errors": "6.2.1",
"@reduxjs/toolkit": "1.9.3",
"@sentry/browser": "7.80.0",
"@sentry/react": "7.80.0",
"@sentry/webpack-plugin": "2.10.3",
"@svgr/webpack": "8.0.1",
"@tamagui/core": "1.95.1",
"@types/uuid": "9.0.1",
"@uniswap/analytics-events": "2.34.0",
"@uniswap/sdk-core": "5.3.0",
"@uniswap/universal-router-sdk": "2.2.0",
"@uniswap/v3-sdk": "3.13.0",
"dotenv-webpack": "8.0.1",
"ethers": "5.7.2",
"eventemitter3": "5.0.1",
"i18next": "23.10.0",
"node-polyfill-webpack-plugin": "2.0.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-i18next": "14.1.0",
"react-native": "0.73.6",
"react-native-gesture-handler": "2.15.0",
"react-native-reanimated": "npm:react-native-reanimated@3.8.1",
"react-native-svg": "15.1.0",
"react-native-web": "0.19.10",
"react-qr-code": "2.0.12",
"react-redux": "8.0.5",
"react-router-dom": "6.10.0",
"redux": "4.2.1",
"redux-logger": "3.0.6",
"redux-persist": "6.0.0",
"redux-persist-webextension-storage": "1.0.2",
"redux-saga": "1.2.2",
"symbol-observable": "4.0.0",
"typed-redux-saga": "1.5.0",
"ua-parser-js": "1.0.37",
"ui": "workspace:^",
"uniswap": "workspace:^",
"utilities": "workspace:^",
"uuid": "9.0.0",
"wallet": "workspace:^",
"zod": "3.22.4"
},
"devDependencies": {
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.10",
"@testing-library/dom": "^7.11.0",
"@testing-library/react": "13.4.0",
"@types/chrome": "0.0.254",
"@types/jest": "29.5.0",
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"@types/redux-logger": "3.0.9",
"@types/redux-persist-webextension-storage": "1.0.3",
"@types/ua-parser-js": "0.7.31",
"@uniswap/eslint-config": "workspace:^",
"@welldone-software/why-did-you-render": "8.0.1",
"clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^11.0.0",
"esbuild-loader": "^3.0.1",
"eslint": "8.44.0",
"jest": "29.7.0",
"jest-chrome": "0.8.0",
"jest-environment-jsdom": "29.5.0",
"jest-extended": "4.0.1",
"mini-css-extract-plugin": "^2.7.6",
"react-refresh": "^0.14.0",
"serve": "^14.2.0",
"statsig-js": "4.41.0",
"swc-loader": "^0.2.3",
"tamagui-loader": "1.95.1",
"typescript": "5.3.3",
"webpack": "5.90.0",
"webpack-cli": "^5.0.1",
"webpack-dev-server": "^4.13.1"
},
"private": true,
"scripts": {
"build:production": "webpack --node-env=production --env BUILD_ENV=prod BUILD_NUM=${BUILD_NUM:-0}",
"check:deps:usage": "depcheck",
"env:local:download": "bash ../../scripts/downloadEnvLocal.sh web-local-envs ../../.env",
"env:local:upload": "bash ../../scripts/uploadEnvLocal.sh web-local-envs ../../.env",
"format": "../../scripts/prettier.sh",
"lint": "eslint . --ext ts,tsx --max-warnings=0",
"lint:fix": "eslint . --ext ts,tsx --fix",
"start": "webpack serve --config webpack.config.js",
"start:absolute": "yarn start:absolute:mac",
"start:absolute:mac": "yarn start --output-path /Users/Shared/stretch",
"start:absolute:windows": "yarn start --output-path C:/ProgramData/stretch",
"test": "jest",
"snapshots": "jest -u",
"typecheck": "tsc -b"
}
}
body,
html {
height: 100%;
max-width: 100vw;
}
#root {
height: 100vh;
display: flex;
scrollbar-width: 'thin';
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes shine {
from {
-webkit-mask-position: 150%;
}
to {
-webkit-mask-position: -50%;
}
}
import { render } from '@testing-library/react'
import OnboardingApp from 'src/app/OnboardingApp'
import { initializeReduxStore } from 'src/store/store'
describe('OnboardingApp', () => {
it('renders without error', async () => {
await initializeReduxStore()
render(<OnboardingApp />)
})
})
import '@tamagui/core/reset.css'
import 'src/app/Global.css'
import 'symbol-observable' // Needed by `reduxed-chrome-storage` as polyfill, order matters
import { useEffect } from 'react'
import { I18nextProvider } from 'react-i18next'
import { RouteObject, RouterProvider } from 'react-router-dom'
import { PersistGate } from 'redux-persist/integration/react'
import { ExtensionStatsigProvider } from 'src/app/StatsigProvider'
import { GraphqlProvider } from 'src/app/apollo'
import { ErrorElement } from 'src/app/components/ErrorElement'
import { Complete } from 'src/app/features/onboarding/Complete'
import {
CreateOnboardingSteps,
ImportOnboardingSteps,
OnboardingStepsProvider,
ResetSteps,
ScanOnboardingSteps,
} from 'src/app/features/onboarding/OnboardingSteps'
import { OnboardingWrapper } from 'src/app/features/onboarding/OnboardingWrapper'
import { PasswordImport } from 'src/app/features/onboarding/PasswordImport'
import { NameWallet } from 'src/app/features/onboarding/create/NameWallet'
import { PasswordCreate } from 'src/app/features/onboarding/create/PasswordCreate'
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 { SelectWallets } from 'src/app/features/onboarding/import/SelectWallets'
import { IntroScreen } from 'src/app/features/onboarding/intro/IntroScreen'
import { IntroScreenBetaWaitlist } from 'src/app/features/onboarding/intro/IntroScreenBetaWaitlist'
import { UnsupportedBrowserScreen } from 'src/app/features/onboarding/intro/UnsupportedBrowserScreen'
import { ResetComplete } from 'src/app/features/onboarding/reset/ResetComplete'
import { OTPInput } from 'src/app/features/onboarding/scan/OTPInput'
import { ScanToOnboard } from 'src/app/features/onboarding/scan/ScanToOnboard'
import { ScantasticContextProvider } from 'src/app/features/onboarding/scan/ScantasticContextProvider'
import { OnboardingRoutes, TopLevelRoutes } from 'src/app/navigation/constants'
import { navigate, setRouter, setRouterState } from 'src/app/navigation/state'
import { sentryCreateHashRouter } from 'src/app/sentry'
import { initExtensionAnalytics } from 'src/app/utils/analytics'
import { checksIfSupportsSidePanel } from 'src/app/utils/chrome'
import { PrimaryAppInstanceDebuggerLazy } from 'src/store/PrimaryAppInstanceDebuggerLazy'
import { getReduxPersistor, getReduxStore } from 'src/store/store'
import { FeatureFlags } from 'uniswap/src/features/gating/flags'
import { useFeatureFlag } from 'uniswap/src/features/gating/hooks'
import Trace from 'uniswap/src/features/telemetry/Trace'
import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { UnitagUpdaterContextProvider } from 'uniswap/src/features/unitags/context'
import i18n from 'uniswap/src/i18n/i18n'
import { ExtensionOnboardingFlow } from 'uniswap/src/types/screens/extension'
import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary'
import { LocalizationContextProvider } from 'wallet/src/features/language/LocalizationContext'
import { SharedProvider } from 'wallet/src/provider'
const supportsSidePanel = checksIfSupportsSidePanel()
const unsupportedRoute: RouteObject = {
path: '',
element: <UnsupportedBrowserScreen />,
}
const allRoutes = [
{
path: '',
element: <IntroScreenBehindFeatureFlag />,
},
{
path: OnboardingRoutes.UnsupportedBrowser,
element: <UnsupportedBrowserScreen />,
},
{
path: OnboardingRoutes.Create,
element: (
<MaybeRedirectToScantastic>
<OnboardingStepsProvider
key={OnboardingRoutes.Create}
steps={{
[CreateOnboardingSteps.Password]: <PasswordCreate />,
[CreateOnboardingSteps.ViewMnemonic]: <ViewMnemonic />,
[CreateOnboardingSteps.TestMnemonic]: <TestMnemonic />,
[CreateOnboardingSteps.Naming]: <NameWallet />,
[CreateOnboardingSteps.Complete]: <Complete flow={ExtensionOnboardingFlow.New} />,
}}
/>
</MaybeRedirectToScantastic>
),
},
{
path: OnboardingRoutes.Import,
element: (
<MaybeRedirectToScantastic>
<OnboardingStepsProvider
key={OnboardingRoutes.Import}
steps={{
[ImportOnboardingSteps.Mnemonic]: <ImportMnemonic />,
[ImportOnboardingSteps.Password]: <PasswordImport flow={ExtensionOnboardingFlow.Import} />,
[ImportOnboardingSteps.Select]: <SelectWallets flow={ExtensionOnboardingFlow.Import} />,
[ImportOnboardingSteps.Complete]: <Complete flow={ExtensionOnboardingFlow.Import} />,
}}
/>
</MaybeRedirectToScantastic>
),
},
{
path: OnboardingRoutes.Scan,
element: <ScantasticFlow key={OnboardingRoutes.Scan} />,
},
{
path: OnboardingRoutes.ResetScan,
element: <ScantasticFlow key={OnboardingRoutes.ResetScan} isResetting />,
},
{
path: OnboardingRoutes.Reset,
element: (
<MaybeRedirectToScantastic>
<OnboardingStepsProvider
key={OnboardingRoutes.Reset}
isResetting
steps={{
[ResetSteps.Mnemonic]: <ImportMnemonic />,
[ResetSteps.Password]: <PasswordImport flow={ExtensionOnboardingFlow.Import} />,
[ResetSteps.Select]: <SelectWallets flow={ExtensionOnboardingFlow.Import} />,
[ResetSteps.Complete]: <ResetComplete />,
}}
/>
</MaybeRedirectToScantastic>
),
},
]
const router = sentryCreateHashRouter([
{
path: `/${TopLevelRoutes.Onboarding}`,
element: <OnboardingWrapper />,
errorElement: <ErrorElement />,
children: !supportsSidePanel ? [unsupportedRoute] : allRoutes,
},
])
function ScantasticFlow({ isResetting = false }: { isResetting?: boolean }): JSX.Element {
return (
<OnboardingStepsProvider
ContainerComponent={ScantasticContextProvider}
isResetting={isResetting}
steps={{
[ScanOnboardingSteps.Scan]: <ScanToOnboard />,
[ScanOnboardingSteps.OTP]: <OTPInput />,
[ScanOnboardingSteps.Password]: <PasswordImport allowBack={false} flow={ExtensionOnboardingFlow.Scantastic} />,
[ScanOnboardingSteps.Select]: <SelectWallets flow={ExtensionOnboardingFlow.Scantastic} />,
[ScanOnboardingSteps.Complete]: isResetting ? (
<ResetComplete />
) : (
<Complete flow={ExtensionOnboardingFlow.Scantastic} />
),
}}
/>
)
}
function IntroScreenBehindFeatureFlag(): JSX.Element {
const scantasticOnboardingOnly = useFeatureFlag(FeatureFlags.ScantasticOnboardingOnly)
return scantasticOnboardingOnly ? <IntroScreenBetaWaitlist /> : <IntroScreen />
}
function MaybeRedirectToScantastic({ children }: { children: JSX.Element }): JSX.Element | null {
const scantasticOnboardingOnly = useFeatureFlag(FeatureFlags.ScantasticOnboardingOnly)
if (scantasticOnboardingOnly) {
navigate(`/${TopLevelRoutes.Onboarding}`, { replace: true })
return null
}
return children
}
/**
* Note: we are using a pattern here to avoid circular dependencies, because
* this is the root of the app and it imports all sub-pages, we need to push the
* router/router state to a different file so it can be imported by those pages
*/
router.subscribe((state) => {
setRouterState(state)
})
setRouter(router)
export default function OnboardingApp(): JSX.Element {
// initialize analytics on load
useEffect(() => {
async function initAndLogLoad(): Promise<void> {
await initExtensionAnalytics()
sendAnalyticsEvent(ExtensionEventName.OnboardingLoad)
}
initAndLogLoad().catch(() => undefined)
}, [])
return (
<Trace>
<PersistGate persistor={getReduxPersistor()}>
<ExtensionStatsigProvider>
<I18nextProvider i18n={i18n}>
<SharedProvider reduxStore={getReduxStore()}>
<ErrorBoundary>
<GraphqlProvider>
<LocalizationContextProvider>
<UnitagUpdaterContextProvider>
<PrimaryAppInstanceDebuggerLazy />
<RouterProvider router={router} />
</UnitagUpdaterContextProvider>
</LocalizationContextProvider>
</GraphqlProvider>
</ErrorBoundary>
</SharedProvider>
</I18nextProvider>
</ExtensionStatsigProvider>
</PersistGate>
</Trace>
)
}
import '@tamagui/core/reset.css'
import 'src/app/Global.css'
import { useEffect, useRef, useState } from 'react'
import { I18nextProvider } from 'react-i18next'
import { useDispatch } from 'react-redux'
import { RouterProvider, ScrollRestoration } from 'react-router-dom'
import { PersistGate } from 'redux-persist/integration/react'
import { ExtensionStatsigProvider } from 'src/app/StatsigProvider'
import { GraphqlProvider } from 'src/app/apollo'
import { ErrorElement } from 'src/app/components/ErrorElement'
import { TraceUserProperties } from 'src/app/components/Trace/TraceUserProperties'
import { AccountSwitcherScreen } from 'src/app/features/accounts/AccountSwitcherScreen'
import { DappContextProvider } from 'src/app/features/dapp/DappContext'
import { addRequest } from 'src/app/features/dappRequests/saga'
import { ReceiveScreen } from 'src/app/features/receive/ReceiveScreen'
import { DevMenuScreen } from 'src/app/features/settings/DevMenuScreen'
import { SettingsPrivacyScreen } from 'src/app/features/settings/SettingsPrivacyScreen'
import { RemoveRecoveryPhraseVerify } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseVerify'
import { RemoveRecoveryPhraseWallets } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseWallets'
import { SettingsViewRecoveryPhraseScreen } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/ViewRecoveryPhraseScreen'
import { SettingsScreen } from 'src/app/features/settings/SettingsScreen'
import { SettingsScreenWrapper } from 'src/app/features/settings/SettingsScreenWrapper'
import { SettingsChangePasswordScreen } from 'src/app/features/settings/password/SettingsChangePasswordScreen'
import { SwapFlowScreen } from 'src/app/features/swap/SwapFlowScreen'
import { TransferFlowScreen } from 'src/app/features/transfer/TransferFlowScreen'
import { useIsWalletUnlocked } from 'src/app/hooks/useIsWalletUnlocked'
import { MainContent, WebNavigation } from 'src/app/navigation'
import { AppRoutes, RemoveRecoveryPhraseRoutes, SettingsRoutes } from 'src/app/navigation/constants'
import { setRouter, setRouterState } from 'src/app/navigation/state'
import { SentryAppNameTag, initializeSentry, sentryCreateHashRouter } from 'src/app/sentry'
import { initExtensionAnalytics } from 'src/app/utils/analytics'
import { getLocalUserId } from 'src/app/utils/storage'
import {
DappBackgroundPortChannel,
createBackgroundToSidePanelMessagePort,
} from 'src/background/messagePassing/messageChannels'
import { BackgroundToSidePanelRequestType } from 'src/background/messagePassing/types/requests'
import { PrimaryAppInstanceDebuggerLazy } from 'src/store/PrimaryAppInstanceDebuggerLazy'
import { getReduxPersistor, getReduxStore } from 'src/store/store'
import Trace from 'uniswap/src/features/telemetry/Trace'
import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { UnitagUpdaterContextProvider } from 'uniswap/src/features/unitags/context'
import i18n from 'uniswap/src/i18n/i18n'
import { isDevEnv } from 'utilities/src/environment'
import { logger } from 'utilities/src/logger/logger'
import { ONE_SECOND_MS } from 'utilities/src/time/time'
import { useInterval } from 'utilities/src/time/timing'
import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary'
import { LocalizationContextProvider } from 'wallet/src/features/language/LocalizationContext'
import { syncAppWithDeviceLanguage } from 'wallet/src/features/language/slice'
import { SharedProvider } from 'wallet/src/provider'
getLocalUserId()
.then((userId) => {
initializeSentry(SentryAppNameTag.Sidebar, userId)
})
.catch((error) => {
logger.error(error, {
tags: { file: 'SidebarApp.tsx', function: 'getLocalUserId' },
})
})
const router = sentryCreateHashRouter([
{
path: '',
element: <SidebarWrapper />,
errorElement: <ErrorElement />,
children: [
{
path: '',
element: <MainContent />,
},
{
path: AppRoutes.AccountSwitcher,
element: <AccountSwitcherScreen />,
},
{
path: AppRoutes.Settings,
element: <SettingsScreenWrapper />,
children: [
{
path: '',
element: <SettingsScreen />,
},
{
path: SettingsRoutes.ChangePassword,
element: <SettingsChangePasswordScreen />,
},
isDevEnv()
? {
path: SettingsRoutes.DevMenu,
element: <DevMenuScreen />,
}
: {},
{
path: SettingsRoutes.ViewRecoveryPhrase,
element: <SettingsViewRecoveryPhraseScreen />,
},
{
path: SettingsRoutes.RemoveRecoveryPhrase,
children: [
{
path: RemoveRecoveryPhraseRoutes.Wallets,
element: <RemoveRecoveryPhraseWallets />,
},
{
path: RemoveRecoveryPhraseRoutes.Verify,
element: <RemoveRecoveryPhraseVerify />,
},
],
},
{
path: SettingsRoutes.Privacy,
element: <SettingsPrivacyScreen />,
},
],
},
{
path: AppRoutes.Transfer,
element: <TransferFlowScreen />,
},
{
path: AppRoutes.Swap,
element: <SwapFlowScreen />,
},
{
path: AppRoutes.Receive,
element: <ReceiveScreen />,
},
],
},
])
const PORT_PING_INTERVAL = 5 * ONE_SECOND_MS
function useDappRequestPortListener(): void {
const dispatch = useDispatch()
const [currentPortChannel, setCurrentPortChannel] = useState<DappBackgroundPortChannel | undefined>()
const [windowId, setWindowId] = useState<string | undefined>()
useEffect(() => {
chrome.windows.getCurrent((window) => {
setWindowId(window.id?.toString())
})
return () => currentPortChannel?.port.disconnect()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (windowId === undefined || currentPortChannel) {
return
}
try {
const port = chrome.runtime.connect({ name: windowId.toString() })
const portChannel = createBackgroundToSidePanelMessagePort(port)
portChannel.addMessageListener(BackgroundToSidePanelRequestType.DappRequestReceived, (message) => {
const { dappRequest, senderTabInfo, isSidebarClosed } = message
dispatch(
addRequest({
dappRequest,
senderTabInfo,
isSidebarClosed,
}),
)
})
port.onDisconnect.addListener(() => {
sendAnalyticsEvent(ExtensionEventName.SidebarClosed)
setCurrentPortChannel(undefined)
})
setCurrentPortChannel(portChannel)
} catch (error) {
logger.error(error, {
tags: { file: 'SidebarApp.tsx', function: 'useDappRequestPortListener' },
})
}
}, [dispatch, windowId, currentPortChannel])
useInterval(() => {
try {
// Need to send general ping message, no type-safety needed
currentPortChannel?.port.postMessage('statusPing')
} catch (error) {
currentPortChannel?.port.disconnect()
setCurrentPortChannel(undefined)
logger.error(error, {
tags: { file: 'SidebarApp.tsx', function: 'useDappRequestPortListener' },
})
}
}, PORT_PING_INTERVAL)
}
function SidebarWrapper(): JSX.Element {
const dispatch = useDispatch()
useDappRequestPortListener()
useEffect(() => {
dispatch(syncAppWithDeviceLanguage())
}, [dispatch])
return (
<>
<ScrollRestoration />
<WebNavigation />
</>
)
}
/**
* Note: we are using a pattern here to avoid circular dependencies, because
* this is the root of the app and it imports all sub-pages, we need to push the
* router/router state to a different file so it can be imported by those pages
*/
router.subscribe((state) => {
setRouterState(state)
})
setRouter(router)
export default function SidebarApp(): JSX.Element {
// initialize analytics on load
useEffect(() => {
initExtensionAnalytics().catch(() => undefined)
}, [])
const isLoggedIn = useIsWalletUnlocked()
const hasSentLoginEvent = useRef(false)
useEffect(() => {
if (isLoggedIn !== null && !hasSentLoginEvent.current) {
hasSentLoginEvent.current = true
sendAnalyticsEvent(ExtensionEventName.SidebarLoad, { locked: !isLoggedIn })
}
}, [isLoggedIn])
return (
<Trace>
<PersistGate persistor={getReduxPersistor()}>
<ExtensionStatsigProvider>
<I18nextProvider i18n={i18n}>
<SharedProvider reduxStore={getReduxStore()}>
<ErrorBoundary>
<GraphqlProvider>
<LocalizationContextProvider>
<UnitagUpdaterContextProvider>
<TraceUserProperties />
<DappContextProvider>
<PrimaryAppInstanceDebuggerLazy />
<RouterProvider router={router} />
</DappContextProvider>
</UnitagUpdaterContextProvider>
</LocalizationContextProvider>
</GraphqlProvider>
</ErrorBoundary>
</SharedProvider>
</I18nextProvider>
</ExtensionStatsigProvider>
</PersistGate>
</Trace>
)
}
import { getLocalUserId } from 'src/app/utils/storage'
import { getStatsigEnvironmentTier } from 'src/app/version'
import Statsig from 'statsig-js' // Use JS package for browser
import { uniswapUrls } from 'uniswap/src/constants/urls'
import { DUMMY_STATSIG_SDK_KEY, StatsigCustomAppValue } from 'uniswap/src/features/gating/constants'
import { StatsigOptions, StatsigProvider, StatsigUser } from 'uniswap/src/features/gating/sdk/statsig'
import { useAsyncData } from 'utilities/src/react/hooks'
async function getStatsigUser(): Promise<StatsigUser> {
return {
userID: await getLocalUserId(),
appVersion: process.env.VERSION,
custom: {
app: StatsigCustomAppValue.Extension,
},
}
}
export function ExtensionStatsigProvider({ children }: { children: React.ReactNode }): JSX.Element {
const { data: user } = useAsyncData(getStatsigUser)
const nonNullUser: StatsigUser = user ?? {
userID: undefined,
custom: {
app: StatsigCustomAppValue.Extension,
},
appVersion: process.env.VERSION,
}
const options: StatsigOptions = {
environment: {
tier: getStatsigEnvironmentTier(),
},
api: uniswapUrls.statsigProxyUrl,
disableAutoMetricsLogging: true,
disableErrorLogging: true,
}
return (
<StatsigProvider options={options} sdkKey={DUMMY_STATSIG_SDK_KEY} user={nonNullUser} waitForInitialization={false}>
{children}
</StatsigProvider>
)
}
export async function initStatSigForBrowserScripts(): Promise<void> {
await Statsig.initialize(DUMMY_STATSIG_SDK_KEY, await getStatsigUser(), {
api: uniswapUrls.statsigProxyUrl,
environment: {
tier: getStatsigEnvironmentTier(),
},
})
}
import { ApolloProvider } from '@apollo/client'
import { PropsWithChildren } from 'react'
import { localStorage } from 'redux-persist-webextension-storage'
// eslint-disable-next-line no-restricted-imports
import { usePersistedApolloClient } from 'wallet/src/data/apollo/usePersistedApolloClient'
// Extension local storage has 10 MB limit, so we want to be very careful to leave enough space for the redux store + any other data that we might want to store in local storage
const MAX_CACHE_SIZE_IN_BYTES = 1024 * 1024 * 5 // 5 MB
export function GraphqlProvider({ children }: PropsWithChildren<unknown>): JSX.Element {
const apolloClient = usePersistedApolloClient({
storageWrapper: localStorage,
maxCacheSizeInBytes: MAX_CACHE_SIZE_IN_BYTES,
})
if (!apolloClient) {
return <></>
}
return <ApolloProvider client={apolloClient}>{children}</ApolloProvider>
}
import { PropsWithChildren } from 'react'
import { useTranslation } from 'react-i18next'
import { Flex, Text, Tooltip } from 'ui/src'
type Side = 'top' | 'right' | 'bottom' | 'left'
type Alignment = 'start' | 'end'
type AlignedPlacement = `${Side}-${Alignment}`
export function ComingSoon({
children,
placement = 'bottom-end',
}: PropsWithChildren & {
placement?: Side | AlignedPlacement
}): JSX.Element {
const { t } = useTranslation()
return (
<Tooltip delay={20} placement={placement}>
<Tooltip.Trigger>
<Flex grow flex={1}>
{children}
</Flex>
</Tooltip.Trigger>
<Tooltip.Content px="$none" py="$none">
<Flex p="$spacing12">
<Text color="$neutral2" variant="body3">
{t('settings.setting.beta.tooltip')}
</Text>
</Flex>
</Tooltip.Content>
</Tooltip>
)
}
import { PropsWithChildren } from 'react'
import { useRouteError } from 'react-router-dom'
export function ErrorElement({ children }: PropsWithChildren<unknown>): JSX.Element {
const error = useRouteError()
if (!error) {
return <>{children}</>
}
// Need to throw here to propagate to the ErrorBoundary
throw error
}
import { forwardRef } from 'react'
import { Input as TamaguiInput, InputProps as TamaguiInputProps } from 'ui/src'
import { inputStyles } from 'ui/src/components/input/utils'
import { fonts } from 'ui/src/theme/fonts'
export type InputProps = {
large?: boolean
hideInput?: boolean
centered?: boolean
} & TamaguiInputProps
export type Input = TamaguiInput
export const Input = forwardRef<Input, InputProps>(function _Input(
{ large = false, hideInput = false, centered = false, ...rest }: InputProps,
ref,
): JSX.Element {
return (
<TamaguiInput
ref={ref}
backgroundColor={large ? '$surface1' : '$surface2'}
borderColor="$surface3"
borderRadius="$rounded16"
borderWidth={1}
focusStyle={inputStyles.inputFocus}
fontSize={fonts.subheading2.fontSize}
height="auto"
hoverStyle={inputStyles.inputHover}
placeholderTextColor="$neutral3"
px={centered ? '$spacing60' : '$spacing24'}
py={large ? '$spacing20' : '$spacing16'}
secureTextEntry={hideInput}
textAlign={centered ? 'center' : 'left'}
width="100%"
{...rest}
/>
)
})
import { useCallback, useEffect, useMemo } from 'react'
import { CopyButton } from 'src/app/components/buttons/CopyButton'
import { Flex, Text, useMedia } from 'ui/src'
import { logger } from 'utilities/src/logger/logger'
const ROW_SIZE = 3
export const MnemonicViewer = ({ mnemonic }: { mnemonic?: string[] }): JSX.Element => {
const media = useMedia()
const px = media.xxs ? '$spacing12' : '$spacing32'
const onCopyPress = useCallback(async () => {
if (!mnemonic) {
return
}
const mnemonicString = mnemonic.join(' ')
try {
if (mnemonicString) {
await navigator.clipboard.writeText(mnemonicString)
}
} catch (error) {
logger.error(error, {
tags: { file: 'MnemonicViewer.tsx', function: 'onCopyPress' },
})
}
}, [mnemonic])
useEffect(() => {
return () => {
navigator.clipboard.writeText('').catch((error) => {
logger.error(error, {
tags: { file: 'MnemonicViewer.tsx', function: 'MnemonicViewer#useEffect' },
})
})
}
}, [])
const rows = useMemo(() => {
if (!mnemonic) {
return null
}
const elements = []
for (let i = 0; i < mnemonic.length; i += ROW_SIZE) {
elements.push(<SeedPhraseRow key={i} startIndex={i + 1} words={mnemonic.slice(i, i + ROW_SIZE)} />)
}
return elements
}, [mnemonic])
return (
<Flex
backgroundColor="$surface2"
borderColor="$surface3"
borderRadius="$rounded20"
borderWidth={1}
gap="$spacing12"
position="relative"
px={px}
py="$spacing24"
width="100%"
>
{rows}
<Flex alignItems="center" left="50%" position="absolute" top={-16} transform="translateX(-50%)">
<CopyButton onCopyPress={onCopyPress} />
</Flex>
</Flex>
)
}
function SeedPhraseRow({ words, startIndex }: { words: string[]; startIndex: number }): JSX.Element {
return (
<Flex fill row>
{words.map((word, index) => (
<SeedPhraseWord key={index} index={index + startIndex} word={word} />
))}
</Flex>
)
}
function SeedPhraseWord({ index, word }: { index: number; word: string }): JSX.Element {
const media = useMedia()
const fontVariant = 'body3'
const gap = media.xxs ? '$spacing4' : '$spacing8'
return (
<Flex fill row flexBasis={0} gap={gap}>
<Text color="$neutral3" minWidth={16} variant={fontVariant}>
{index}
</Text>
<Text variant={fontVariant}>{word}</Text>
</Flex>
)
}
import { StrictMode } from 'react'
// TODO(EXT-1229): We had to remove `React.StrictMode` because it's not
// currently supported by Reanimated Web. We should consider re-enabling
// once Reanimated fixes this.
export function OptionalStrictMode(props: { children: React.ReactNode }): JSX.Element {
return process.env.ENABLE_STRICT_MODE ? <StrictMode>{props.children}</StrictMode> : <>{props.children}</>
}
import { forwardRef } from 'react'
import { TextInput } from 'react-native'
import { Input, InputProps } from 'src/app/components/Input'
import { Button, Flex, FlexProps, IconProps, Text } from 'ui/src'
import { Eye, EyeOff } from 'ui/src/components/icons'
import { PasswordStrength, getPasswordStrengthTextAndColor } from 'wallet/src/utils/password'
export const PADDING_STRENGTH_INDICATOR = 76
const iconProps: IconProps = {
color: '$neutral3',
size: '$icon.20',
}
const hoverStyle: FlexProps = {
backgroundColor: 'transparent',
}
interface PasswordInputProps extends InputProps {
passwordStrength?: PasswordStrength
hideInput: boolean
onToggleHideInput?: (hideInput: boolean) => void
}
export const PasswordInput = forwardRef<TextInput, PasswordInputProps>(function PasswordInput(
{ passwordStrength, hideInput, onToggleHideInput, value, ...inputProps },
ref,
): JSX.Element {
return (
<Flex row alignItems="center" position="relative" width="100%">
<Input ref={ref} hideInput={hideInput} minHeight="$spacing24" pr="$spacing48" value={value} {...inputProps} />
{passwordStrength !== undefined ? (
<StrengthIndicator strength={passwordStrength} />
) : (
onToggleHideInput && (
<Button
backgroundColor="$transparent"
hoverStyle={hoverStyle}
position="absolute"
pressStyle={hoverStyle}
right="$spacing8"
onPress={(): void => onToggleHideInput(!hideInput)}
>
{hideInput ? <Eye {...iconProps} /> : <EyeOff {...iconProps} />}
</Button>
)
)}
</Flex>
)
})
function StrengthIndicator({ strength }: { strength: PasswordStrength }): JSX.Element | null {
if (strength === PasswordStrength.NONE) {
return null
}
const { text, color } = getPasswordStrengthTextAndColor(strength)
return (
<Flex position="absolute" right="$spacing24">
<Text color={color} variant="buttonLabel4">
{text}
</Text>
</Flex>
)
}
import { useEffect } from 'react'
import { useColorScheme } from 'react-native'
import { ExtensionUserPropertyName, setUserProperty } from 'uniswap/src/features/telemetry/user'
// eslint-disable-next-line no-restricted-imports
import { analytics } from 'utilities/src/telemetry/analytics/analytics'
import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks'
import { useGatingUserPropertyUsernames } from 'wallet/src/features/gating/userPropertyHooks'
import { useCurrentLanguage } from 'wallet/src/features/language/hooks'
import {
useActiveAccount,
useHideSmallBalancesSetting,
useHideSpamTokensSetting,
useSignerAccounts,
useViewOnlyAccounts,
} from 'wallet/src/features/wallet/hooks'
/** Component that tracks UserProperties during the lifetime of the app */
export function TraceUserProperties(): null {
const colorScheme = useColorScheme()
const viewOnlyAccounts = useViewOnlyAccounts()
const activeAccount = useActiveAccount()
const signerAccounts = useSignerAccounts()
const hideSmallBalances = useHideSmallBalancesSetting()
const hideSpamTokens = useHideSpamTokensSetting()
const currentLanguage = useCurrentLanguage()
const appFiatCurrencyInfo = useAppFiatCurrencyInfo()
useGatingUserPropertyUsernames()
useEffect(() => {
setUserProperty(ExtensionUserPropertyName.AppVersion, chrome.runtime.getManifest().version)
return () => {
analytics.flushEvents()
}
}, [])
useEffect(() => {
setUserProperty(ExtensionUserPropertyName.DarkMode, colorScheme === 'dark')
}, [colorScheme])
useEffect(() => {
setUserProperty(ExtensionUserPropertyName.WalletSignerCount, signerAccounts.length)
setUserProperty(
ExtensionUserPropertyName.WalletSignerAccounts,
signerAccounts.map((account) => account.address),
)
}, [signerAccounts])
useEffect(() => {
setUserProperty(ExtensionUserPropertyName.WalletViewOnlyCount, viewOnlyAccounts.length)
}, [viewOnlyAccounts])
useEffect(() => {
if (!activeAccount) {
return
}
setUserProperty(ExtensionUserPropertyName.ActiveWalletAddress, activeAccount.address)
setUserProperty(ExtensionUserPropertyName.ActiveWalletType, activeAccount.type)
setUserProperty(ExtensionUserPropertyName.IsHideSmallBalancesEnabled, hideSmallBalances)
setUserProperty(ExtensionUserPropertyName.IsHideSpamTokensEnabled, hideSpamTokens)
}, [activeAccount, hideSmallBalances, hideSpamTokens])
useEffect(() => {
setUserProperty(ExtensionUserPropertyName.Language, currentLanguage)
}, [currentLanguage])
useEffect(() => {
setUserProperty(ExtensionUserPropertyName.Currency, appFiatCurrencyInfo.code)
}, [appFiatCurrencyInfo])
return null
}
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AnimatePresence, Flex, Text, TouchableArea } from 'ui/src'
import { Check, CopySheets } from 'ui/src/components/icons'
import { iconSizes, zIndices } from 'ui/src/theme'
export function CopyButton({ onCopyPress }: { onCopyPress: () => Promise<void> }): JSX.Element {
const { t } = useTranslation()
const [valueCopied, setValueCopied] = useState(false)
const onPress = async (): Promise<void> => {
await onCopyPress()
setValueCopied(true)
}
return (
<Flex row gap="$spacing24">
<TouchableArea borderRadius="$rounded20" zIndex={zIndices.fixed} onPress={onCopyPress}>
<Flex
row
alignItems="center"
backgroundColor="$surface1"
borderColor={valueCopied ? '$statusSuccess' : '$surface3'}
borderRadius="$rounded20"
borderWidth="$spacing1"
gap="$spacing4"
justifyContent="center"
paddingEnd="$spacing16"
px="$spacing8"
py="$spacing8"
shadowColor="$shadowColor"
shadowOffset={{ width: 0, height: 0 }}
shadowOpacity={0.1}
shadowRadius={4}
// fixed width means no resize on the animation to copied
width={84}
onPress={onPress}
>
<AnimatePresence exitBeforeEnter initial={false}>
{/* note there's various x/y adjustments here due to visual imbalance of icons/text */}
<Flex
key={valueCopied ? 'copy' : 'copied'}
row
alignItems="center"
animateEnterExit="fadeInDownOutDown"
animation="100ms"
gap="$spacing8"
justifyContent="center"
// copied check icon is less wide, content needs to move left to balance
x={valueCopied ? -1 : 0}
>
{valueCopied ? (
// check icon is a bit smaller and to the right
<Check color="$statusSuccess" size={iconSizes.icon12 + 2} x={2} />
) : (
<CopySheets color="$neutral2" size={iconSizes.icon12} />
)}
<Text
color={valueCopied ? '$statusSuccess' : '$neutral2'}
cursor="pointer"
flexShrink={1}
variant="buttonLabel4"
x={valueCopied ? -2 : 0}
y={0.5}
>
{valueCopied ? t('common.button.copied') : t('common.button.copy')}
</Text>
</Flex>
</AnimatePresence>
</Flex>
</TouchableArea>
</Flex>
)
}
import { useExtensionNavigation } from 'src/app/navigation/utils'
import { Flex, GeneratedIcon, IconProps, Text, TouchableArea } from 'ui/src'
import { BackArrow } from 'ui/src/components/icons'
import { iconSizes } from 'ui/src/theme'
export function ScreenHeader({
onBackClick,
title,
rightColumn,
Icon = BackArrow,
}: {
title?: JSX.Element | string
onBackClick?: () => void
rightColumn?: JSX.Element
Icon?: GeneratedIcon | ((props: IconProps) => JSX.Element)
}): JSX.Element {
const { navigateBack } = useExtensionNavigation()
return (
<Flex row alignItems="center" px="$spacing8" py="$spacing4" width="100%">
<TouchableArea onPress={onBackClick ?? navigateBack}>
<Icon color="$neutral2" size="$icon.24" />
</TouchableArea>
{/* When there's no right column, we adjust the margin to match the icon width. This is so that the title is centered on the screen. */}
<Flex centered fill mr={rightColumn ? '$none' : iconSizes.icon24} py="$spacing8">
{/* // Render empty string if no title to account for Text element added padding for consistent size*/}
<Text variant="subheading1">{title ?? ' '}</Text>
</Flex>
{rightColumn && <Flex>{rightColumn}</Flex>}
</Flex>
)
}
import { Flex } from 'ui/src'
import { LoadingSpinnerInner, LoadingSpinnerOuter } from 'ui/src/components/icons'
const SPINNER_HEIGHT = 80
export function LoadingSpinner(): JSX.Element {
return (
<>
<Flex height={SPINNER_HEIGHT} position="relative" width={80}>
<Flex bottom={0} left={0} position="absolute" right={0} top={0}>
<LoadingSpinnerOuter color="$DEP_brandedAccentSoft" size={80} />
</Flex>
<Flex
bottom={0}
left={0}
position="absolute"
right={0}
style={{ animation: `spin ${SPIN_SPEED_MS}ms linear infinite` }}
top={0}
>
<LoadingSpinnerInner color="$accent1" size={80} />
</Flex>
</Flex>
</>
)
}
const SPIN_SPEED_MS = 1000
import { SkeletonBox } from 'src/app/components/loading/SkeletonBox'
import { Flex } from 'ui/src'
import { WALLET_PREVIEW_CARD_HEIGHT } from 'wallet/src/components/WalletPreviewCard/WalletPreviewCard'
export function SelectWalletsSkeleton({ repeat = 3 }: { repeat?: number }): JSX.Element {
return (
<Flex fill gap="$spacing12">
{new Array(repeat).fill(null).map((_, i, { length }) => (
<WalletSkeleton key={i} opacity={(length - i) / length} />
))}
</Flex>
)
}
function WalletSkeleton({ opacity }: { opacity: number }): JSX.Element {
return (
<Flex
row
alignItems="center"
borderColor="$surface3"
borderRadius="$rounded20"
borderWidth={1}
height={WALLET_PREVIEW_CARD_HEIGHT}
justifyContent="flex-start"
opacity={opacity}
overflow="hidden"
px="$spacing16"
py="$spacing16"
style={{
boxShadow: 'rgba(0, 0, 0, 0.05) 0px 0px 8px',
}}
>
<Flex fill row alignItems="center" gap="$spacing12">
<Flex backgroundColor="$neutral3" borderRadius="$roundedFull" height={32} opacity={0.5} width={32} />
<Flex grow alignItems="flex-start" gap="$spacing2">
<SkeletonBox height={21} width={150} />
<SkeletonBox height={21} width={95} />
</Flex>
</Flex>
</Flex>
)
}
.skeleton-box {
display: inline-block;
height: 1em;
position: relative;
overflow: hidden;
}
.skeleton-box::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
transform: translateX(-100%);
background-image: linear-gradient(
-75deg,
rgba(240, 240, 240, 0) 0,
rgba(240, 240, 240, 0.2) 20%,
rgba(240, 240, 240, 0.5) 60%,
rgba(240, 240, 240, 0)
);
animation: skeleton-box-shimmer 1s linear infinite;
content: '';
}
.t_dark .skeleton-box::after {
background-image: linear-gradient(
-75deg,
rgba(30, 30, 30, 0) 0,
rgba(30, 30, 30, 0.2) 20%,
rgba(30, 30, 30, 0.5) 60%,
rgba(30, 30, 30, 0)
);
}
@keyframes skeleton-box-shimmer {
100% {
transform: translateX(100%);
}
}
import 'src/app/components/loading/SkeletonBox.css'
/**
* Unlike the `ui/src/Skeleton`, this `SkeletonBox` animation does not run in the main thread, so it won't be choppy if the main thread is busy.
*/
export function SkeletonBox({
width = '100%',
height,
borderRadius = '5px',
}: {
width?: number | string
height: number | string
borderRadius?: string
}): JSX.Element {
return <div className="skeleton-box" style={{ width, height, borderRadius }} />
}
import { t } from 'i18next'
import { useDispatch } from 'react-redux'
import { Button, Flex, Text, useSporeColors } from 'ui/src'
import { MessageStar } from 'ui/src/components/icons'
import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal'
import { uniswapUrls } from 'uniswap/src/constants/urls'
import { ModalName } from 'uniswap/src/features/telemetry/constants'
import { selectExtensionBetaFeedbackState } from 'wallet/src/features/behaviorHistory/selectors'
import { ExtensionBetaFeedbackState, setExtensionBetaFeedbackState } from 'wallet/src/features/behaviorHistory/slice'
import { useAppSelector } from 'wallet/src/state'
export function FeedbackRequestModal(): JSX.Element {
const dispatch = useDispatch()
const colors = useSporeColors()
const onDismiss = (): void => {
dispatch(setExtensionBetaFeedbackState(ExtensionBetaFeedbackState.Shown))
}
const openFeedbackUrl = (): void => {
// eslint-disable-next-line security/detect-non-literal-fs-filename
window.open(uniswapUrls.extensionFeedbackFormUrl, '_blank')
onDismiss()
}
const isOpen = useAppSelector(selectExtensionBetaFeedbackState) === ExtensionBetaFeedbackState.ReadyToShow
return (
<BottomSheetModal
alignment="center"
backgroundColor={colors.surface1.val}
isModalOpen={isOpen}
name={ModalName.ExtensionBetaFeedbackModal}
onClose={onDismiss}
>
<Flex alignItems="center" gap="$spacing12" pt="$spacing12">
<Flex backgroundColor="$accent2" borderRadius="$rounded12" p="$spacing12">
<MessageStar color="$accent1" size="$icon.24" />
</Flex>
<Flex alignItems="center" gap="$spacing12" pb="$spacing16" pt="$spacing8" px="$spacing4">
<Text color="$neutral1" textAlign="center" variant="subheading2">
{t('extension.feedback.title')}
</Text>
<Text color="$neutral2" textAlign="center" variant="body3">
{t('extension.feedback.description')}
</Text>
</Flex>
<Flex fill row gap="$spacing12" width="100%">
<Button flex={1} textAlign="center" theme="tertiary" width="100%" onPress={onDismiss}>
<Text color="$neutral2" variant="buttonLabel3">
{t('common.button.later')}
</Text>
</Button>
<Button flex={1} textAlign="center" theme="accentSecondary" width="100%" onPress={openFeedbackUrl}>
<Text color="$accent1" variant="buttonLabel3">
{t('common.button.share')}
</Text>
</Button>
</Flex>
</Flex>
</BottomSheetModal>
)
}
import { ReactNode } from 'react'
import { Anchor, Button, Flex, Text, TouchableArea, useSporeColors } from 'ui/src'
import { X } from 'ui/src/components/icons'
import { zIndices } from 'ui/src/theme'
import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal'
import { ModalNameType } from 'uniswap/src/features/telemetry/constants'
export interface BottomModalProps {
name: ModalNameType
isOpen: boolean
showCloseButton?: boolean
onDismiss?: () => void
icon: ReactNode
title: string
description: string
buttonText: string
buttonTheme?: 'primary' | 'secondary' | 'tertiary'
onButtonPress?: () => void
linkText?: string
linkUrl?: string
}
export function InfoModal({
name,
isOpen,
showCloseButton,
onDismiss,
icon,
title,
description,
buttonText,
buttonTheme,
onButtonPress,
linkText,
linkUrl,
}: React.PropsWithChildren<BottomModalProps>): JSX.Element {
const colors = useSporeColors()
return (
<BottomSheetModal
alignment="bottom"
backgroundColor={colors.surface1.val}
isModalOpen={isOpen}
name={name}
onClose={onDismiss}
>
{showCloseButton && (
<TouchableArea
p="$spacing16"
position="absolute"
right={0}
top={0}
zIndex={zIndices.default}
onPress={onDismiss}
>
<X color="$neutral2" size="$icon.16" />
</TouchableArea>
)}
<Flex alignItems="center" gap="$spacing8" pt="$spacing16">
{icon}
<Flex alignItems="center" gap="$spacing8" pb="$spacing16" pt="$spacing8" px="$spacing4">
<Text color="$neutral1" textAlign="center" variant="subheading2">
{title}
</Text>
<Text color="$neutral2" textAlign="center" variant="body3">
{description}
</Text>
</Flex>
<Button size="medium" theme={buttonTheme} width="100%" onPress={onButtonPress}>
{buttonText}
</Button>
{linkText && linkUrl && (
<Anchor href={linkUrl} lineHeight={16} p="$spacing12" target="_blank" textDecorationLine="none">
<Text color="$neutral2" textAlign="center" variant="buttonLabel4">
{linkText}
</Text>
</Anchor>
)}
</Flex>
</BottomSheetModal>
)
}
import { memo } from 'react'
import { ScrollView } from 'ui/src'
import { useActivityData } from 'wallet/src/features/activity/useActivityData'
export const ActivityTab = memo(function _ActivityTab({ address }: { address: Address }): JSX.Element {
const { maybeEmptyComponent, renderActivityItem, sectionData } = useActivityData({
owner: address,
})
if (maybeEmptyComponent) {
return maybeEmptyComponent
}
return (
<ScrollView showsVerticalScrollIndicator={false} width="100%">
{/* `sectionData` will be either an array of transactions or an array of loading skeletons */}
{(sectionData ?? []).map((item, index) => renderActivityItem({ item, index }))}
</ScrollView>
)
})
import { SharedEventName } from '@uniswap/analytics-events'
import { memo, useCallback } from 'react'
import { ContextMenu, Flex } from 'ui/src'
import { fromGraphQLChain } from 'uniswap/src/features/chains/utils'
import { ElementName, SectionName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { UniverseChainId } from 'uniswap/src/types/chains'
import { NftsList } from 'wallet/src/components/nfts/NftsList'
import { selectNftsVisibility } from 'wallet/src/features/favorites/selectors'
import { NFTViewer } from 'wallet/src/features/images/NFTViewer'
import { ESTIMATED_NFT_LIST_ITEM_SIZE } from 'wallet/src/features/nfts/constants'
import { NFTItem } from 'wallet/src/features/nfts/types'
import { useNFTContextMenu } from 'wallet/src/features/nfts/useNftContextMenu'
import { getIsNftHidden } from 'wallet/src/features/nfts/utils'
import { useAppSelector } from 'wallet/src/state'
export const NftsTab = memo(function _NftsTab({ owner }: { owner: Address }): JSX.Element {
const renderNFTItem = useCallback(
(item: NFTItem) => {
const onPress = (): void => {
sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, {
element: ElementName.NftItem,
section: SectionName.HomeNFTsTab,
})
}
return <NftView item={item} owner={owner} onPress={onPress} />
},
[owner],
)
return (
<NftsList
emptyStateStyle={defaultEmptyStyle}
errorStateStyle={defaultEmptyStyle}
owner={owner}
renderNFTItem={renderNFTItem}
/>
)
})
function NftView({ owner, item, onPress }: { owner: Address; item: NFTItem; onPress: () => void }): JSX.Element {
const { menuActions } = useNFTContextMenu({
contractAddress: item.contractAddress,
tokenId: item.tokenId,
owner,
isSpam: item.isSpam,
chainId: fromGraphQLChain(item.chain) ?? UniverseChainId.Mainnet,
})
const menuOptions = menuActions.map((action) => ({
label: action.title,
onPress: action.onPress,
Icon: action.Icon,
destructive: action.destructive,
}))
const nftVisibility = useAppSelector(selectNftsVisibility)
const hidden = getIsNftHidden({
contractAddress: item.contractAddress,
tokenId: item.tokenId,
isSpam: item.isSpam,
nftVisibility,
})
const itemId = `${item.chain}-${item.contractAddress}-${item.tokenId}-${hidden}`
return (
<Flex grow shrink p="$spacing4">
<ContextMenu itemId={itemId} menuOptions={menuOptions}>
<Flex
alignItems="center"
aspectRatio={1}
backgroundColor="$surface3"
borderRadius="$rounded12"
overflow="hidden"
width="100%"
onPress={onPress}
>
<NFTViewer
showSvgPreview
contractAddress={item.contractAddress}
imageDimensions={item.imageDimensions}
limitGIFSize={ESTIMATED_NFT_LIST_ITEM_SIZE}
placeholderContent={item.name || item.collectionName}
squareGridView={true}
tokenId={item.tokenId}
uri={item.imageUrl ?? ''}
/>
</Flex>
</ContextMenu>
</Flex>
)
}
const defaultEmptyStyle = {
minHeight: 100,
paddingVertical: '$spacing12',
width: '100%',
}
import { SpaceTokens } from 'ui/src'
export const SCREEN_ITEM_HORIZONTAL_PAD = '$spacing12' satisfies SpaceTokens
export enum GlobalErrorEvent {
ReduxStorageExceeded = 'ReduxStorageExceeded',
}
import EventEmitter from 'eventemitter3'
import { GlobalErrorEvent } from 'src/app/events/constants'
class GlobalEventEmitter extends EventEmitter<GlobalErrorEvent> {}
export const globalEventEmitter = new GlobalEventEmitter()
import { SharedEventName } from '@uniswap/analytics-events'
import { BaseSyntheticEvent, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux'
import { EditLabelModal } from 'src/app/features/accounts/EditLabelModal'
import { removeAllDappConnectionsForAccount } from 'src/app/features/dapp/actions'
import { ContextMenu, Flex, MenuContentItem, Text, TouchableArea } from 'ui/src'
import { CopySheets, Edit, TrashFilled, TripleDots } from 'ui/src/components/icons'
import { iconSizes } from 'ui/src/theme'
import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { setClipboard } from 'uniswap/src/utils/clipboard'
import { NumberType } from 'utilities/src/format/types'
import { AddressDisplay } from 'wallet/src/components/accounts/AddressDisplay'
import { WarningModal } from 'wallet/src/components/modals/WarningModal/WarningModal'
import { usePortfolioTotalValue } from 'wallet/src/features/dataApi/balances'
import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext'
import { pushNotification } from 'wallet/src/features/notifications/slice'
import { AppNotificationType, CopyNotificationType } from 'wallet/src/features/notifications/types'
import { WarningSeverity } from 'wallet/src/features/transactions/WarningModal/types'
import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga'
import { useActiveAccountWithThrow, useDisplayName, useSignerAccounts } from 'wallet/src/features/wallet/hooks'
import { DisplayNameType } from 'wallet/src/features/wallet/types'
type AccountItemProps = {
address: Address
onAccountSelect?: () => void
}
export function AccountItem({ address, onAccountSelect }: AccountItemProps): JSX.Element {
const { t } = useTranslation()
const dispatch = useDispatch()
const { data, loading, error } = usePortfolioTotalValue({ address })
const { balanceUSD } = data || {}
const { convertFiatAmountFormatted } = useLocalizationContext()
const formattedBalance = convertFiatAmountFormatted(balanceUSD, NumberType.PortfolioBalance)
const [showEditLabelModal, setShowEditLabelModal] = useState(false)
const displayName = useDisplayName(address)
const hasDisplayName = displayName?.type === DisplayNameType.Unitag || displayName?.type === DisplayNameType.ENS
const accounts = useSignerAccounts()
const activeAccount = useActiveAccountWithThrow()
const activeAccountDisplayName = useDisplayName(activeAccount.address)
const [showRemoveWalletModal, setShowRemoveWalletModal] = useState(false)
const onRemoveWallet = useCallback(async () => {
const accountForDeletion = accounts.find((account) => account.address === address)
if (!accountForDeletion) {
return
}
await removeAllDappConnectionsForAccount(accountForDeletion)
dispatch(
editAccountActions.trigger({
type: EditAccountAction.Remove,
accounts: [accountForDeletion],
}),
)
}, [accounts, address, dispatch])
const onPressCopyAddress = useCallback(
async (e: BaseSyntheticEvent) => {
// We have to manually prevent click-through because the way the context menu is inside of a TouchableArea in this component it
// means that without it the TouchableArea handler will get called
// TODO(EXT-1325): Use a different ContextMenu component that works inside a TouchableArea
e.preventDefault()
e.stopPropagation()
await setClipboard(address)
dispatch(
pushNotification({
type: AppNotificationType.Copied,
copyType: CopyNotificationType.Address,
}),
)
sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, {
element: ElementName.CopyAddress,
modal: ModalName.AccountSwitcher,
})
},
[address, dispatch],
)
const menuOptions = useMemo((): MenuContentItem[] => {
return [
// hide edit label if account has unitag or ENS
...(!hasDisplayName
? [
{
label: t('account.wallet.menu.edit.title'),
onPress: (e: BaseSyntheticEvent): void => {
// We have to manually prevent click-through because the way the context menu is inside of a TouchableArea in this component it
// means that without it the TouchableArea handler will get called
e.preventDefault()
e.stopPropagation()
setShowEditLabelModal(true)
},
Icon: Edit,
},
]
: []),
{
label: t('account.wallet.menu.copy.title'),
onPress: onPressCopyAddress,
Icon: CopySheets,
},
{
label: t('account.wallet.menu.remove.title'),
onPress: (e: BaseSyntheticEvent): void => {
// We have to manually prevent click-through because the way the context menu is inside of a TouchableArea in this component it
// means that without it the TouchableArea handler will get called
e.preventDefault()
e.stopPropagation()
setShowRemoveWalletModal(true)
},
textProps: { color: '$statusCritical' },
Icon: TrashFilled,
iconProps: { color: '$statusCritical' },
},
]
}, [hasDisplayName, onPressCopyAddress, t])
return (
<>
{showRemoveWalletModal && (
<WarningModal
caption={t('account.recoveryPhrase.remove.mnemonic.description', {
walletName: activeAccountDisplayName?.name ?? '',
})}
closeText={t('common.button.cancel')}
confirmText={t('common.button.continue')}
icon={<TrashFilled color="$statusCritical" size="$icon.24" strokeWidth="$spacing1" />}
modalName={ModalName.RemoveWallet}
severity={WarningSeverity.High}
title={t('account.wallet.remove.title', { name: displayName?.name ?? '' })}
onClose={() => setShowRemoveWalletModal(false)}
onConfirm={onRemoveWallet}
/>
)}
{showEditLabelModal && <EditLabelModal address={address} onClose={() => setShowEditLabelModal(false)} />}
<TouchableArea
hoverable
backgroundColor="$surface1"
borderRadius="$rounded16"
cursor="pointer"
p="$spacing12"
onPress={onAccountSelect}
>
<Flex centered fill group row gap="$spacing8" justifyContent="space-between">
<AddressDisplay
address={address}
captionVariant="body3"
showViewOnlyBadge={false}
size={iconSizes.icon40}
variant="subheading2"
/>
<Flex>
<Text
$group-hover={{ opacity: 0 }}
color="$neutral2"
opacity={1}
position="absolute"
right={0}
top="50%"
transform="translateY(-50%)"
variant="body3"
>
{loading || error ? '' : formattedBalance}
</Text>
<ContextMenu closeOnClick itemId={address} menuOptions={menuOptions} onLeftClick>
<Flex
$group-hover={{ opacity: 1 }}
borderRadius="$roundedFull"
hoverStyle={{ backgroundColor: '$surface2Hovered' }}
opacity={0}
p="$spacing4"
>
<TripleDots color="$neutral2" size="$icon.16" />
</Flex>
</ContextMenu>
</Flex>
</Flex>
</TouchableArea>
</>
)
}
import { AccountSwitcherScreen } from 'src/app/features/accounts/AccountSwitcherScreen'
import { preloadedExtensionState } from 'src/test/fixtures/redux'
import { cleanup, render } from 'src/test/test-utils'
const preloadedState = preloadedExtensionState()
const SAMPLE_DAPP = 'http://example.com'
jest.mock('src/app/features/dapp/DappContext', () => {
const real = jest.requireActual('src/app/features/dapp/DappContext')
return { ...real, useDappContext: jest.fn(() => ({ dappUrl: SAMPLE_DAPP })) }
})
jest.mock('src/app/features/dapp/hooks', () => {
const { ACCOUNT, ACCOUNT3 } = require('wallet/src/test/fixtures')
return { useDappConnectedAccounts: jest.fn(() => [ACCOUNT, ACCOUNT3]) }
})
describe(AccountSwitcherScreen, () => {
it('renders correctly', async () => {
const tree = render(<AccountSwitcherScreen />, { preloadedState })
expect(tree).toMatchSnapshot()
cleanup()
})
})
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { OpaqueColorValue } from 'react-native'
import { Button, Flex, Text, getUniconColors, useIsDarkMode } from 'ui/src'
import { iconSizes, opacify } from 'ui/src/theme'
import { TextInput } from 'uniswap/src/components/input/TextInput'
import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal'
import { ModalName } from 'uniswap/src/features/telemetry/constants'
import { shortenAddress } from 'uniswap/src/utils/addresses'
import { AccountIcon } from 'wallet/src/components/accounts/AccountIcon'
import { SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types'
type CreateWalletModalProps = {
pendingWallet?: SignerMnemonicAccount
onCancel: () => void
onConfirm: (walletLabel: string) => void
}
// Expects a pending account to be created before opening this modal
export function CreateWalletModal({ pendingWallet, onCancel, onConfirm }: CreateWalletModalProps): JSX.Element | null {
const { t } = useTranslation()
const isDark = useIsDarkMode()
const [inputText, setInputText] = useState<string>('')
const nextDerivationIndex = pendingWallet?.derivationIndex
const onboardingAccountAddress = pendingWallet?.address
const onPressConfirm = useCallback(() => {
onConfirm(inputText)
}, [inputText, onConfirm])
const placeholderText = nextDerivationIndex
? t('account.wallet.create.placeholder', { index: nextDerivationIndex + 1 })
: ''
const { color: uniconColor } = onboardingAccountAddress
? getUniconColors(onboardingAccountAddress, isDark)
: { color: '' }
// Cast because Button component doesnt acccept sytling outside of theme color values for hover and press states
const hoverAndPressButtonStyle = useMemo(() => {
return {
backgroundColor: opacify(15, uniconColor) as unknown as OpaqueColorValue,
}
}, [uniconColor])
return (
<BottomSheetModal name={ModalName.AccountEditLabel} onClose={onCancel}>
<Flex centered fill borderRadius="$rounded16" gap="$spacing24" mt="$spacing16">
<Flex centered gap="$spacing12" width="100%">
{onboardingAccountAddress && <AccountIcon address={onboardingAccountAddress} size={iconSizes.icon48} />}
<Flex borderColor="$surface3" borderRadius="$rounded16" borderWidth="$spacing1" width="100%">
<TextInput
autoFocus
borderRadius="$rounded16"
placeholder={placeholderText}
py="$spacing12"
textAlign="center"
value={inputText}
width="100%"
onChangeText={setInputText}
/>
</Flex>
{onboardingAccountAddress && (
<Text color="$neutral3" variant="body3">
{shortenAddress(onboardingAccountAddress)}
</Text>
)}
</Flex>
<Flex centered fill row gap="$spacing12" justifyContent="space-between" width="100%">
<Button color="$neutral1" flex={1} flexBasis={1} size="small" theme="secondary" onPress={onCancel}>
{t('common.button.cancel')}
</Button>
<Button
flex={1}
flexBasis={1}
hoverStyle={hoverAndPressButtonStyle}
pressStyle={hoverAndPressButtonStyle}
size="small"
style={{ color: uniconColor, backgroundColor: opacify(10, uniconColor) }}
onPress={onPressConfirm}
>
{t('common.button.create')}
</Button>
</Flex>
</Flex>
</BottomSheetModal>
)
}
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux'
import { Button, Flex, Text } from 'ui/src'
import { iconSizes } from 'ui/src/theme'
import { TextInput } from 'uniswap/src/components/input/TextInput'
import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal'
import { ModalName } from 'uniswap/src/features/telemetry/constants'
import { shortenAddress } from 'utilities/src/addresses'
import { AccountIcon } from 'wallet/src/components/accounts/AccountIcon'
import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga'
import { useDisplayName } from 'wallet/src/features/wallet/hooks'
import { DisplayNameType } from 'wallet/src/features/wallet/types'
type EditLabelModalProps = {
address: Address
onClose: () => void
}
export function EditLabelModal({ address, onClose }: EditLabelModalProps): JSX.Element {
const { t } = useTranslation()
const dispatch = useDispatch()
const displayName = useDisplayName(address)
const defaultText = displayName?.type === DisplayNameType.Local ? displayName.name : ''
const [inputText, setInputText] = useState<string>(defaultText)
const [isfocused, setIsFocused] = useState(false)
const onConfirm = useCallback(async () => {
await dispatch(
editAccountActions.trigger({
type: EditAccountAction.Rename,
address,
newName: inputText,
}),
)
onClose()
}, [address, dispatch, inputText, onClose])
return (
<BottomSheetModal name={ModalName.AccountEditLabel} onClose={onClose}>
<Flex centered fill borderRadius="$rounded16" gap="$spacing24" mt="$spacing16">
<Flex centered gap="$spacing12" width="100%">
<AccountIcon address={address} size={iconSizes.icon48} />
<Flex borderColor="$surface3" borderRadius="$rounded16" borderWidth="$spacing1" width="100%">
<TextInput
autoFocus
borderRadius="$rounded16"
placeholder={isfocused ? '' : t('account.wallet.edit.label.input.placeholder')}
textAlign="center"
value={inputText}
width="100%"
onBlur={() => setIsFocused(false)}
onChangeText={setInputText}
onFocus={() => setIsFocused(true)}
/>
</Flex>
<Text color="$neutral3" variant="body2">
{shortenAddress(address)}
</Text>
</Flex>
<Flex centered fill row gap="$spacing12" justifyContent="space-between" width="100%">
<Button color="$neutral1" flex={1} flexBasis={1} size="small" theme="secondary" onPress={onClose}>
{t('common.button.cancel')}
</Button>
<Button flex={1} flexBasis={1} size="small" theme="accentSecondary" onPress={onConfirm}>
{t('common.button.save')}
</Button>
</Flex>
</Flex>
</BottomSheetModal>
)
}
import { createContext, ReactNode, useContext, useEffect, useState } from 'react'
import { useDispatch } from 'react-redux'
import { useDappConnectedAccounts, useDappLastChainId } from 'src/app/features/dapp/hooks'
import { isConnectedAccount } from 'src/app/features/dapp/utils'
import { extractBaseUrl } from 'src/app/features/dappRequests/utils'
import { closePopup, PopupName } from 'src/app/features/popups/slice'
import { backgroundToSidePanelMessageChannel } from 'src/background/messagePassing/messageChannels'
import { BackgroundToSidePanelRequestType } from 'src/background/messagePassing/types/requests'
import { WalletChainId } from 'uniswap/src/types/chains'
import { useActiveAccountAddress } from 'wallet/src/features/wallet/hooks'
type DappContextState = {
dappUrl: string
dappIconUrl?: string
isConnected: boolean
lastChainId?: WalletChainId
}
const DappContext = createContext<DappContextState | undefined>(undefined)
export function DappContextProvider({ children }: { children: ReactNode }): JSX.Element {
const [dappUrl, setDappUrl] = useState('')
const [dappIconUrl, setDappIconUrl] = useState<string | undefined>(undefined)
const activeAddress = useActiveAccountAddress()
const connectedAccounts = useDappConnectedAccounts(dappUrl)
const lastChainId = useDappLastChainId(dappUrl)
const dispatch = useDispatch()
const isConnected = !!activeAddress && isConnectedAccount(connectedAccounts, activeAddress)
useEffect(() => {
const updateDappInfo = (): void => {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const tab = tabs[0]
if (tab) {
setDappUrl(extractBaseUrl(tab?.url) || '')
setDappIconUrl(tab.favIconUrl)
}
})
}
updateDappInfo()
return backgroundToSidePanelMessageChannel.addMessageListener(
BackgroundToSidePanelRequestType.TabActivated,
async (_message) => {
updateDappInfo()
dispatch(closePopup(PopupName.Connect))
},
)
}, [setDappIconUrl, setDappUrl, dispatch])
const value = { dappUrl, dappIconUrl, isConnected, lastChainId }
return <DappContext.Provider value={value}>{children}</DappContext.Provider>
}
export function useDappContext(): DappContextState {
const context = useContext(DappContext)
if (context === undefined) {
throw new Error('useDappContext must be used within a DappContextProvider')
}
return context
}
import { dappStore } from 'src/app/features/dapp/store'
import { externalDappMessageChannel } from 'src/background/messagePassing/messageChannels'
import {
ExtensionChainChange,
ExtensionToDappRequestType,
UpdateConnectionRequest,
} from 'src/background/messagePassing/types/requests'
import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils'
import { WalletChainId } from 'uniswap/src/types/chains'
import { Account } from 'wallet/src/features/wallet/accounts/types'
import { getProviderSync } from 'wallet/src/features/wallet/context'
export async function saveDappChain(dappUrl: string, chainId: WalletChainId): Promise<void> {
dappStore.updateDappLatestChainId(dappUrl, chainId)
const provider = getProviderSync(chainId)
const response: ExtensionChainChange = {
type: ExtensionToDappRequestType.SwitchChain,
providerUrl: provider.connection.url,
chainId: chainIdToHexadecimalString(chainId),
}
await externalDappMessageChannel.sendMessageToTabUrl(dappUrl, response)
}
export async function saveDappConnection(dappUrl: string, account: Account): Promise<void> {
dappStore.saveDappActiveAccount(dappUrl, account)
await updateConnectionFromExtension(dappUrl)
}
export async function removeDappConnection(dappUrl: string, account?: Account): Promise<void> {
dappStore.removeDappConnection(dappUrl, account)
await updateConnectionFromExtension(dappUrl)
}
async function updateConnectionFromExtension(dappUrl: string): Promise<void> {
const connectedWallets = dappStore.getDappOrderedConnectedAddresses(dappUrl) ?? []
const response: UpdateConnectionRequest = {
type: ExtensionToDappRequestType.UpdateConnections,
addresses: connectedWallets,
}
await externalDappMessageChannel.sendMessageToTabUrl(dappUrl, response)
}
export async function updateDappConnectedAddressFromExtension(address: Address): Promise<void> {
dappStore.updateDappConnectedAddress(address)
const connectedDapps = dappStore.getConnectedDapps(address)
for (const dappUrl of connectedDapps) {
await updateConnectionFromExtension(dappUrl)
}
}
export async function removeAllDappConnectionsForAccount(account: Account): Promise<void> {
const connectedDapps = dappStore.getConnectedDapps(account.address)
for (const dappUrl of connectedDapps) {
await removeDappConnection(dappUrl, account)
}
}
export async function removeAllDappConnectionsFromExtension(): Promise<void> {
const dappUrls = dappStore.getDappUrls()
for (const dappUrl of dappUrls) {
const response: UpdateConnectionRequest = {
type: ExtensionToDappRequestType.UpdateConnections,
addresses: [],
}
await externalDappMessageChannel.sendMessageToTabUrl(dappUrl, response)
}
dappStore.removeAllDappConnections()
}
import { JsonRpcProvider } from '@ethersproject/providers'
import { providerErrors, serializeError } from '@metamask/rpc-errors'
import { changeChain } from 'src/app/features/dapp/changeChain'
import { dappStore } from 'src/app/features/dapp/store'
import { DappResponseType } from 'src/app/features/dappRequests/types/DappRequestTypes'
import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils'
import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { WalletChainId } from 'uniswap/src/types/chains'
// Mock dependencies
jest.mock('@ethersproject/providers')
jest.mock('@metamask/rpc-errors')
jest.mock('src/app/features/dapp/store')
jest.mock('uniswap/src/features/telemetry/send')
jest.mock('uniswap/src/features/chains/utils')
describe('changeChain', () => {
const mockRequestId = 'test-request-id'
const mockProviderUrl = 'http://localhost:8545'
const mockChainId = 1 as WalletChainId
let mockProvider: JsonRpcProvider
beforeEach(() => {
jest.clearAllMocks()
mockProvider = {
connection: {
url: mockProviderUrl,
},
} as JsonRpcProvider
})
it('should return an error response if updatedChainId is null', () => {
const response = changeChain({
activeConnectedAddress: undefined,
dappUrl: undefined,
provider: mockProvider,
requestId: mockRequestId,
updatedChainId: null,
})
expect(response).toEqual({
type: DappResponseType.ErrorResponse,
error: serializeError(
providerErrors.custom({
code: 4902,
message: 'Uniswap Wallet does not support switching to this chain.',
}),
),
requestId: mockRequestId,
})
})
it('should return an error response if provider is null', () => {
const response = changeChain({
activeConnectedAddress: undefined,
dappUrl: undefined,
provider: null,
requestId: mockRequestId,
updatedChainId: mockChainId,
})
expect(response).toEqual({
type: DappResponseType.ErrorResponse,
error: serializeError(providerErrors.unauthorized()),
requestId: mockRequestId,
})
})
it('should update dappStore and send analytics event if dappUrl is provided', () => {
const mockDappUrl = 'http://example.com'
const response = changeChain({
activeConnectedAddress: '0xAddress',
dappUrl: mockDappUrl,
provider: mockProvider,
requestId: mockRequestId,
updatedChainId: mockChainId,
})
expect(dappStore.updateDappLatestChainId).toHaveBeenCalledWith(mockDappUrl, mockChainId)
expect(sendAnalyticsEvent).toHaveBeenCalledWith(ExtensionEventName.DappChangeChain, {
dappUrl: mockDappUrl,
chainId: mockChainId,
activeConnectedAddress: '0xAddress',
})
expect(response).toEqual({
type: DappResponseType.ChainChangeResponse,
requestId: mockRequestId,
providerUrl: mockProviderUrl,
chainId: chainIdToHexadecimalString(mockChainId),
})
})
it('should not update dappStore if dappUrl is not provided', () => {
const response = changeChain({
activeConnectedAddress: '0xAddress',
dappUrl: undefined,
provider: mockProvider,
requestId: mockRequestId,
updatedChainId: mockChainId,
})
expect(dappStore.updateDappLatestChainId).not.toHaveBeenCalled()
expect(response).toEqual({
type: DappResponseType.ErrorResponse,
error: serializeError(providerErrors.unauthorized()),
requestId: mockRequestId,
})
})
})
import { JsonRpcProvider } from '@ethersproject/providers'
import { providerErrors, serializeError } from '@metamask/rpc-errors'
import { dappStore } from 'src/app/features/dapp/store'
import {
ChangeChainResponse,
DappResponseType,
ErrorResponse,
} from 'src/app/features/dappRequests/types/DappRequestTypes'
import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils'
import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { WalletChainId } from 'uniswap/src/types/chains'
export function changeChain({
activeConnectedAddress,
dappUrl,
provider,
requestId,
updatedChainId,
}: {
activeConnectedAddress: Address | undefined
dappUrl: string | undefined
provider: JsonRpcProvider | undefined | null
requestId: string
updatedChainId: WalletChainId | null
}): ChangeChainResponse | ErrorResponse {
if (!updatedChainId) {
return {
type: DappResponseType.ErrorResponse,
error: serializeError(
providerErrors.custom({
code: 4902,
message: 'Uniswap Wallet does not support switching to this chain.',
}),
),
requestId,
}
}
if (!provider) {
return {
type: DappResponseType.ErrorResponse,
error: serializeError(providerErrors.unauthorized()),
requestId,
}
}
if (dappUrl) {
dappStore.updateDappLatestChainId(dappUrl, updatedChainId)
sendAnalyticsEvent(ExtensionEventName.DappChangeChain, {
dappUrl: dappUrl ?? '',
chainId: updatedChainId,
activeConnectedAddress: activeConnectedAddress ?? '',
})
return {
type: DappResponseType.ChainChangeResponse,
requestId,
providerUrl: provider.connection.url,
chainId: chainIdToHexadecimalString(updatedChainId),
}
}
return {
type: DappResponseType.ErrorResponse,
error: serializeError(providerErrors.unauthorized()),
requestId,
}
}
import {
useDappConnectedAccounts,
useDappInfo,
useDappLastChainId,
useDappStateUpdated,
} from 'src/app/features/dapp/hooks'
import { DappState, dappStore } from 'src/app/features/dapp/store'
import { act, renderHook, waitFor } from 'src/test/test-utils'
import { UniverseChainId } from 'uniswap/src/types/chains'
import { ACCOUNT, ACCOUNT2, ACCOUNT3, SAMPLE_SEED_ADDRESS_1, SAMPLE_SEED_ADDRESS_3 } from 'wallet/src/test/fixtures'
const SAMPLE_DAPP = 'http://example.com'
const SAMPLE_DAPP_2 = 'http://uniswap.org'
const dappState: DappState = {
[SAMPLE_DAPP]: {
lastChainId: UniverseChainId.ArbitrumOne,
connectedAccounts: [ACCOUNT, ACCOUNT2],
activeConnectedAddress: SAMPLE_SEED_ADDRESS_1,
},
[SAMPLE_DAPP_2]: {
lastChainId: UniverseChainId.Base,
connectedAccounts: [ACCOUNT, ACCOUNT3],
activeConnectedAddress: SAMPLE_SEED_ADDRESS_3,
},
}
const mockAddListener = jest.fn()
const mockGet = jest.fn(() => {
return Promise.resolve({ dappState })
})
Object.defineProperty(global, 'chrome', {
value: {
runtime: { lastError: undefined },
storage: {
local: {
get: mockGet,
set: jest.fn(),
onChanged: {
addListener: mockAddListener,
},
},
},
},
})
describe('Dapp hooks', () => {
let onChangeListener: (changes: { dappState: chrome.storage.StorageChange }) => void
beforeAll(async () => {
await dappStore.init()
onChangeListener = mockAddListener.mock.calls[0][0]
})
it('useDappStateUpdated should update state when chrome storage changes', () => {
const { result } = renderHook(() => useDappStateUpdated())
expect(result.current).toBe(false)
act(() => {
onChangeListener({ dappState: { newValue: dappState } })
})
expect(result.current).toBe(true)
})
it('useDappInfo should return undefined when dappUrl is undefined', async () => {
const { result } = renderHook(() => useDappInfo(undefined))
await waitFor(() => expect(result.current).toBeUndefined())
})
it('useDappInfo should return DappInfo when dappUrl is defined', async () => {
const { result } = renderHook(() => useDappInfo(SAMPLE_DAPP))
await waitFor(() =>
expect(result.current).toEqual({
lastChainId: UniverseChainId.ArbitrumOne,
connectedAccounts: [ACCOUNT, ACCOUNT2],
activeConnectedAddress: SAMPLE_SEED_ADDRESS_1,
}),
)
})
it('useDappLastChainId should return undefined when dappUrl is undefined', async () => {
const { result } = renderHook(() => useDappLastChainId(undefined))
await waitFor(() => expect(result.current).toBeUndefined())
})
it('useDappLastChainId should return lastChainId when dappUrl is defined', async () => {
const { result } = renderHook(() => useDappLastChainId(SAMPLE_DAPP_2))
await waitFor(() => expect(result.current).toBe(UniverseChainId.Base))
})
it('useDappConnectedAccounts should return empty array when dappUrl is undefined', async () => {
const { result } = renderHook(() => useDappConnectedAccounts(undefined))
await waitFor(() => expect(result.current).toEqual([]))
})
it('useDappConnectedAccounts should return connected accounts when dappUrl is defined', async () => {
const { result } = renderHook(() => useDappConnectedAccounts(SAMPLE_DAPP))
await waitFor(() => expect(result.current).toEqual([ACCOUNT, ACCOUNT2]))
})
})
import { useEffect, useReducer, useState } from 'react'
import { DappInfo, DappStoreEvent, dappStore } from 'src/app/features/dapp/store'
import { WalletChainId } from 'uniswap/src/types/chains'
import { Account } from 'wallet/src/features/wallet/accounts/types'
// exported to be used in tests
export function useDappStateUpdated(): boolean {
const [state, dispatch] = useReducer((v) => !v, false)
useEffect(() => {
const onUpdate = (): void => dispatch()
dappStore.addListener(DappStoreEvent.DappStateUpdated, onUpdate)
return () => {
dappStore.removeListener(DappStoreEvent.DappStateUpdated, onUpdate)
}
}, [dispatch])
return state
}
export function useDappInfo(dappUrl: string | undefined): DappInfo | undefined {
const [info, setInfo] = useState<DappInfo>()
const dappStateUpdated = useDappStateUpdated()
useEffect(() => {
setInfo(dappStore.getDappInfo(dappUrl))
}, [dappUrl, dappStateUpdated])
return info
}
export function useDappLastChainId(dappUrl: string | undefined): WalletChainId | undefined {
return useDappInfo(dappUrl)?.lastChainId
}
export function useDappConnectedAccounts(dappUrl: string | undefined): Account[] {
return useDappInfo(dappUrl)?.connectedAccounts || []
}
import { dappStore } from 'src/app/features/dapp/store'
import { call } from 'typed-redux-saga'
import { logger } from 'utilities/src/logger/logger'
// Initialize Dapp Store
export function* initDappStore() {
logger.debug('dappStoreSaga', 'initDappStore', 'Initializing Dapp Store')
yield* call(dappStore.init)
}
import EventEmitter from 'eventemitter3'
import { getOrderedConnectedAddresses, isConnectedAccount } from 'src/app/features/dapp/utils'
import { UniverseChainId, WalletChainId } from 'uniswap/src/types/chains'
import { Account } from 'wallet/src/features/wallet/accounts/types'
const STATE_STORAGE_KEY = 'dappState'
export interface DappInfo {
lastChainId: WalletChainId
connectedAccounts: Account[]
activeConnectedAddress: Address
}
export interface DappState {
[dappUrl: string]: DappInfo
}
const initialDappState: DappState = {}
let state: DappState
// Event Emitter
export enum DappStoreEvent {
DappStateUpdated = 'DappStateUpdated',
}
class DappStoreEventEmitter extends EventEmitter<DappStoreEvent> {}
const dappStoreEventEmitter = new DappStoreEventEmitter()
// Init
let initPromise: Promise<void> | undefined
async function init(): Promise<void> {
if (!initPromise) {
initPromise = initInternal()
}
return initPromise
}
async function initInternal(): Promise<void> {
state = (await chrome.storage.local.get([STATE_STORAGE_KEY]))?.[STATE_STORAGE_KEY] || initialDappState
chrome.storage.local.onChanged.addListener((changes) => {
if (changes.dappState) {
state = changes.dappState.newValue
dappStoreEventEmitter.emit(DappStoreEvent.DappStateUpdated, state)
}
})
}
// Sequential syncing of state to local storage
let dappStateSyncPromise = Promise.resolve()
let dappStateChangesNeedSync = false
function queueDappStateSync(): void {
if (!dappStateChangesNeedSync) {
dappStateChangesNeedSync = true
dappStateSyncPromise = dappStateSyncPromise.then((): Promise<void> => {
dappStateChangesNeedSync = false
return chrome.storage.local.set({ [STATE_STORAGE_KEY]: state })
})
}
}
/** Returns all dapp URLs that are connected to a particular address. */
function getConnectedDapps(address: Address): string[] {
return Object.entries(state)
.filter(([_, dappInfo]) => isConnectedAccount(dappInfo.connectedAccounts, address))
.map(([dappUrl]) => dappUrl)
}
/** Returns connected addresses with the currently connected address listed first. */
function getDappOrderedConnectedAddresses(dappUrl: string): string[] | undefined {
const dappInfo = state[dappUrl]
if (!dappInfo) {
return undefined
}
const { connectedAccounts, activeConnectedAddress } = dappInfo
return getOrderedConnectedAddresses(connectedAccounts, activeConnectedAddress)
}
function getDappInfo(dappUrl: string | undefined): DappInfo | undefined {
return dappUrl ? state[dappUrl] : undefined
}
function getDappInfoIfConnected(dappUrl: string | undefined): DappInfo | undefined {
const dappInfo = getDappInfo(dappUrl)
return dappInfo && dappInfo.connectedAccounts.length > 0 ? dappInfo : undefined
}
function getDappUrls(): string[] {
return Object.keys(state)
}
// Update the connected address for all dapps
function updateDappConnectedAddress(address: Address): void {
// Never directly mutate state, as some of its fields could have `writable: false`
state = Object.fromEntries(
Object.entries(state).map(([key, dappUrlState]) => {
if (isConnectedAccount(dappUrlState.connectedAccounts, address)) {
return [key, { ...dappUrlState, activeConnectedAddress: address }]
}
return [key, dappUrlState]
}),
)
queueDappStateSync()
}
function updateDappLatestChainId(dappUrl: string, chainId: WalletChainId): void {
// Never directly mutate state, as some of its fields could have `writable: false`
state = Object.fromEntries(
Object.entries(state).map(([key, dappUrlState]) => {
if (key === dappUrl) {
return [key, { ...dappUrlState, lastChainId: chainId }]
}
return [key, dappUrlState]
}),
)
queueDappStateSync()
}
function saveDappActiveAccount(dappUrl: string, account: Account): void {
// Never directly mutate state, as some of its fields could have `writable: false`
state = {
...state,
[dappUrl]: {
lastChainId: state[dappUrl]?.lastChainId ?? UniverseChainId.Mainnet,
activeConnectedAddress: account.address,
connectedAccounts: ((): Account[] => {
const currConnectedAccounts = state[dappUrl]?.connectedAccounts || []
const isConnectionNew = !isConnectedAccount(currConnectedAccounts, account.address)
if (isConnectionNew) {
return [...currConnectedAccounts, account]
}
return currConnectedAccounts
})(),
},
}
queueDappStateSync()
}
/**
* Remove a dapp connection
* @param dappUrl extracted url for dapp
* @param account target account to remove connection. If undefined, will remove all accounts
* @returns
*/
function removeDappConnection(dappUrl: string, account?: Account): void {
// Never directly mutate state, as some of its fields could have `writable: false`
state = ((): DappState => {
const dappUrlState = state[dappUrl]
if (!dappUrlState) {
return state
}
const updatedAccounts = account
? dappUrlState.connectedAccounts?.filter((existingAccount) => existingAccount.address !== account.address)
: []
const activeConnected = updatedAccounts[0]
if (activeConnected) {
return {
...state,
[dappUrl]: {
...dappUrlState,
connectedAccounts: updatedAccounts,
activeConnectedAddress: activeConnected.address,
},
}
} else {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [dappUrl]: _, ...restState } = state
return restState
}
})()
queueDappStateSync()
}
function removeAllDappConnections(): void {
state = {}
queueDappStateSync()
}
export const dappStore = {
getConnectedDapps,
getDappInfo,
getDappInfoIfConnected,
getDappOrderedConnectedAddresses,
getDappUrls,
init,
removeAllDappConnections,
removeDappConnection,
saveDappActiveAccount,
addListener: dappStoreEventEmitter.addListener.bind(dappStoreEventEmitter),
removeListener: dappStoreEventEmitter.removeListener.bind(dappStoreEventEmitter),
updateDappConnectedAddress,
updateDappLatestChainId,
}
import {
getActiveConnectedAccount,
getOrderedConnectedAddresses,
isConnectedAccount,
} from 'src/app/features/dapp/utils'
import { Account } from 'wallet/src/features/wallet/accounts/types'
import {
ACCOUNT,
ACCOUNT2,
ACCOUNT3,
SAMPLE_SEED_ADDRESS_1,
SAMPLE_SEED_ADDRESS_2,
SAMPLE_SEED_ADDRESS_3,
} from 'wallet/src/test/fixtures'
describe('isConnectedAccount', () => {
it('returns true if the account is connected', () => {
const accounts: Account[] = [ACCOUNT, ACCOUNT2]
expect(isConnectedAccount(accounts, SAMPLE_SEED_ADDRESS_1)).toBe(true)
})
it('returns false if the account is not connected', () => {
const accounts: Account[] = [ACCOUNT]
expect(isConnectedAccount(accounts, SAMPLE_SEED_ADDRESS_2)).toBe(false)
})
})
describe('getActiveConnectedAccount', () => {
const accounts: Account[] = [ACCOUNT, ACCOUNT2]
it('returns the account for the given address', () => {
const result = getActiveConnectedAccount(accounts, SAMPLE_SEED_ADDRESS_2)
expect(result).toEqual(ACCOUNT2)
})
it('throws an error if the address is not in the list', () => {
expect(() => {
getActiveConnectedAccount(accounts, SAMPLE_SEED_ADDRESS_3)
}).toThrow('The activeConnectedAddress must be in the list of connectedAccounts.')
})
})
describe('getOrderedConnectedAddresses', () => {
const accounts: Account[] = [ACCOUNT, ACCOUNT2, ACCOUNT3]
it('places the active address first', () => {
const activeAddress = SAMPLE_SEED_ADDRESS_2
const expectedOrder = [SAMPLE_SEED_ADDRESS_2, SAMPLE_SEED_ADDRESS_1, SAMPLE_SEED_ADDRESS_3]
const result = getOrderedConnectedAddresses(accounts, activeAddress)
expect(result).toEqual(expectedOrder)
})
it('returns the same order if the active address is already first', () => {
const activeAddress = SAMPLE_SEED_ADDRESS_1
const expectedOrder = [SAMPLE_SEED_ADDRESS_1, SAMPLE_SEED_ADDRESS_2, SAMPLE_SEED_ADDRESS_3]
const result = getOrderedConnectedAddresses(accounts, activeAddress)
expect(result).toEqual(expectedOrder)
})
it('handles cases where the active address is not in the list', () => {
const activeAddress = '0xabc' // Not in the accounts list
const expectedOrder = [SAMPLE_SEED_ADDRESS_1, SAMPLE_SEED_ADDRESS_2, SAMPLE_SEED_ADDRESS_3] // Original order since active address is not found
const result = getOrderedConnectedAddresses(accounts, activeAddress)
expect(result).toEqual(expectedOrder)
})
})
import { bubbleToTop } from 'utilities/src/primitives/array'
import { Account } from 'wallet/src/features/wallet/accounts/types'
export function isConnectedAccount(connectedAccounts: Account[], address: Address): boolean {
return connectedAccounts.some((account) => account.address === address)
}
/** Gets the Account for a specific address. The address param must be in the list of connectedAccounts. */
export function getActiveConnectedAccount(connectedAccounts: Account[], activeConnectedAddress: Address): Account {
const activeConnectedAccount = connectedAccounts.find((account) => account.address === activeConnectedAddress)
if (!activeConnectedAccount) {
throw new Error('The activeConnectedAddress must be in the list of connectedAccounts.')
}
return activeConnectedAccount
}
/** Returns all connected addresses with the currently connected address listed first. */
export function getOrderedConnectedAddresses(connectedAccounts: Account[], activeConnectedAddress: Address): Address[] {
const connectedAddresses = connectedAccounts.map((account) => account.address)
return bubbleToTop(connectedAddresses, (address) => address === activeConnectedAddress)
}
import { providerErrors, serializeError } from '@metamask/rpc-errors'
import { PropsWithChildren, createContext, useContext, useEffect, useRef, useState } from 'react'
import { useDispatch } from 'react-redux'
import {
confirmRequest,
confirmRequestNoDappInfo,
isDappRequestWithDappInfo,
rejectRequest,
} from 'src/app/features/dappRequests/saga'
import { DappRequestStoreItem } from 'src/app/features/dappRequests/slice'
import { DappResponseType } from 'src/app/features/dappRequests/types/DappRequestTypes'
import { extractBaseUrl } from 'src/app/features/dappRequests/utils'
import { useAppSelector } from 'src/store/store'
import { TransactionTypeInfo } from 'wallet/src/features/transactions/types'
import { Account } from 'wallet/src/features/wallet/accounts/types'
import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks'
interface DappRequestQueueContextValue {
forwards: boolean // direction of sliding animation
increasing: boolean // direction of number increasing animation
request: DappRequestStoreItem | undefined
currentAccount: Account // Account the request is going to (not necessarily the active account)
dappUrl: string
dappIconUrl: string
currentIndex: number
totalRequestCount: number
onPressNext: () => void
onPressPrevious: () => void
onConfirm: (request: DappRequestStoreItem, transactionTypeInfo?: TransactionTypeInfo) => Promise<void>
onCancel: (request: DappRequestStoreItem) => Promise<void>
}
const DappRequestQueueContext = createContext<DappRequestQueueContextValue | undefined>(undefined)
export function DappRequestQueueProvider({ children }: PropsWithChildren): JSX.Element {
const dispatch = useDispatch()
const [currentIndex, setCurrentIndex] = useState(0)
// Show the top most pending request
const pendingRequests = useAppSelector((state) => state.dappRequests.pending)
const request = pendingRequests[currentIndex]
const totalRequestCount = pendingRequests.length
const activeAccount = useActiveAccountWithThrow()
// values to help with animations
const [forwards, setForwards] = useState(true)
const [increasing, setIncreasing] = useState(true)
const prevTotalRequestCountRef = useRef(totalRequestCount)
useEffect(() => {
if (totalRequestCount > prevTotalRequestCountRef.current) {
setIncreasing(true)
}
if (totalRequestCount < prevTotalRequestCountRef.current) {
setIncreasing(false)
}
prevTotalRequestCountRef.current = totalRequestCount
}, [totalRequestCount])
const dappUrl = extractBaseUrl(request?.senderTabInfo.url) || ''
const dappIconUrl = request?.senderTabInfo?.favIconUrl || ''
let currentAccount = activeAccount
if (request?.dappInfo) {
const { activeConnectedAddress, connectedAccounts } = request.dappInfo
const connectedAccount = connectedAccounts.find((account) => account.address === activeConnectedAddress)
if (connectedAccount) {
currentAccount = connectedAccount
}
}
const onConfirm = async (
requestToConfirm: DappRequestStoreItem,
transactionTypeInfo?: TransactionTypeInfo,
): Promise<void> => {
const requestWithTxInfo = {
...requestToConfirm,
transactionTypeInfo,
}
if (isDappRequestWithDappInfo(requestWithTxInfo)) {
await dispatch(confirmRequest(requestWithTxInfo))
} else {
await dispatch(confirmRequestNoDappInfo(requestWithTxInfo))
}
setCurrentIndex((prev) => Math.max(0, prev - 1))
}
const onCancel = async (requestToCancel: DappRequestStoreItem): Promise<void> => {
await dispatch(
rejectRequest({
senderTabInfo: requestToCancel.senderTabInfo,
errorResponse: {
requestId: requestToCancel.dappRequest.requestId,
type: DappResponseType.ErrorResponse,
error: serializeError(providerErrors.userRejectedRequest()),
},
}),
)
setCurrentIndex((prev) => Math.max(0, prev - 1))
}
const onPressNext = (): void => {
setForwards(true)
setCurrentIndex((prev) => Math.min(prev + 1, totalRequestCount - 1))
}
const onPressPrevious = (): void => {
setForwards(false)
setCurrentIndex((prev) => Math.max(0, prev - 1))
}
const value = {
forwards,
increasing,
currentIndex,
totalRequestCount,
request,
dappUrl,
dappIconUrl,
currentAccount,
onConfirm,
onCancel,
onPressNext,
onPressPrevious,
}
return <DappRequestQueueContext.Provider value={value}>{children}</DappRequestQueueContext.Provider>
}
export function useDappRequestQueueContext(): DappRequestQueueContextValue {
const context = useContext(DappRequestQueueContext)
if (context === undefined) {
throw new Error('useDappRequestQueueContext must be used within a DappRequestQueueProvider')
}
return context
}
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { JsonRpcProvider } from '@ethersproject/providers'
import { providerErrors, serializeError } from '@metamask/rpc-errors'
import { saveDappConnection } from 'src/app/features/dapp/actions'
import { DappInfo, dappStore } from 'src/app/features/dapp/store'
import { getOrderedConnectedAddresses } from 'src/app/features/dapp/utils'
import { SenderTabInfo } from 'src/app/features/dappRequests/slice'
import {
AccountResponse,
DappRequest,
DappResponseType,
ErrorResponse,
GetAccountRequest,
RequestAccountRequest,
} from 'src/app/features/dappRequests/types/DappRequestTypes'
import { extractBaseUrl } from 'src/app/features/dappRequests/utils'
import { dappResponseMessageChannel } from 'src/background/messagePassing/messageChannels'
import { call, put } from 'typed-redux-saga'
import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils'
import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { UniverseChainId, WalletChainId } from 'uniswap/src/types/chains'
import { pushNotification } from 'wallet/src/features/notifications/slice'
import { AppNotificationType } from 'wallet/src/features/notifications/types'
import { getProvider } from 'wallet/src/features/wallet/context'
import { selectActiveAccount } from 'wallet/src/features/wallet/selectors'
import { appSelect } from 'wallet/src/state'
function getAccountResponse(
chainId: WalletChainId,
dappRequest: DappRequest,
provider: JsonRpcProvider,
dappInfo: DappInfo,
): AccountResponse {
const orderedConnectedAddresses = getOrderedConnectedAddresses(
dappInfo.connectedAccounts,
dappInfo.activeConnectedAddress,
)
return {
type: DappResponseType.AccountResponse,
requestId: dappRequest.requestId,
connectedAddresses: orderedConnectedAddresses,
chainId: chainIdToHexadecimalString(chainId),
providerUrl: provider.connection.url,
}
}
function sendAccountResponseAnalyticsEvent(
senderUrl: string,
chainId: WalletChainId,
dappInfo: DappInfo,
accountResponse: AccountResponse,
): void {
const dappUrl = extractBaseUrl(senderUrl)
sendAnalyticsEvent(ExtensionEventName.DappConnect, {
dappUrl: dappUrl ?? '',
chainId,
activeConnectedAddress: dappInfo.activeConnectedAddress,
connectedAddresses: accountResponse.connectedAddresses,
})
}
/**
* Gets the active account, and returns the account address, chainId, and providerUrl.
* Chain id + provider url are from the last connected chain for the dApp and wallet. If this has not been set, it will be the default chain and provider.
*/
export function* getAccount(
dappRequest: GetAccountRequest | RequestAccountRequest,
{ id, url }: SenderTabInfo,
dappInfo: DappInfo,
) {
const chainId = dappInfo.lastChainId
const provider = yield* call(getProvider, chainId)
const response = getAccountResponse(chainId, dappRequest, provider, dappInfo)
sendAccountResponseAnalyticsEvent(url, chainId, dappInfo, response)
yield* call(dappResponseMessageChannel.sendMessageToTab, id, response)
}
/**
* Saves the active account as connected to the dapp and parses out necessary data
* Triggers a notification for new connections
*/
export function* saveAccount({ url, favIconUrl }: SenderTabInfo) {
const activeAccount = yield* appSelect(selectActiveAccount)
const dappUrl = extractBaseUrl(url)
const dappInfo = yield* call(dappStore.getDappInfo, dappUrl)
if (!dappUrl || !activeAccount) {
return
}
yield* call(saveDappConnection, dappUrl, activeAccount)
// No dapp info means that this is a first time connection request
if (!dappInfo) {
yield* put(
pushNotification({
type: AppNotificationType.DappConnected,
dappIconUrl: favIconUrl,
}),
)
}
const chainId = dappInfo?.lastChainId ?? UniverseChainId.Mainnet
const provider = yield* call(getProvider, chainId)
const connectedAddresses = (dappUrl && (yield* call(dappStore.getDappOrderedConnectedAddresses, dappUrl))) || []
return {
dappUrl,
activeAccount,
connectedAddresses,
chainId,
providerUrl: provider.connection.url,
}
}
/**
* Gets the active account, and returns the account address, chainId, and providerUrl.
* Chain id + provider url are from the last connected chain for the dApp and wallet. If this has not been set, it will be the default chain and provider.
*/
export function* getAccountRequest(request: RequestAccountRequest, senderTabInfo: SenderTabInfo) {
const accountInfo = yield* call(saveAccount, senderTabInfo)
if (!accountInfo) {
const errorReponse: ErrorResponse = {
type: DappResponseType.ErrorResponse,
error: serializeError(providerErrors.unauthorized()),
requestId: request.requestId,
}
yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, errorReponse)
} else {
const { dappUrl, activeAccount, connectedAddresses, chainId, providerUrl } = accountInfo
const accountResponse: AccountResponse = {
type: DappResponseType.AccountResponse,
requestId: request.requestId,
connectedAddresses,
chainId: chainIdToHexadecimalString(chainId),
providerUrl,
}
yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, accountResponse)
sendAnalyticsEvent(ExtensionEventName.DappConnectRequest, {
dappUrl,
chainId,
activeConnectedAddress: activeAccount.address,
connectedAddresses,
})
}
}
import { DappInfo } from 'src/app/features/dapp/store'
import { SenderTabInfo } from 'src/app/features/dappRequests/slice'
import {
ChainIdResponse,
DappResponseType,
GetChainIdRequest,
} from 'src/app/features/dappRequests/types/DappRequestTypes'
import { dappResponseMessageChannel } from 'src/background/messagePassing/messageChannels'
import { call } from 'typed-redux-saga'
import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils'
import { UniverseChainId } from 'uniswap/src/types/chains'
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function* getChainId(request: GetChainIdRequest, { id }: SenderTabInfo, dappInfo: DappInfo) {
const response: ChainIdResponse = {
type: DappResponseType.ChainIdResponse,
requestId: request.requestId,
chainId: chainIdToHexadecimalString(dappInfo.lastChainId),
}
yield* call(dappResponseMessageChannel.sendMessageToTab, id, response)
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function* getChainIdNoDappInfo(request: GetChainIdRequest, { id }: SenderTabInfo) {
// Sending mainnet as default chain for unconnected dapps
const response: ChainIdResponse = {
type: DappResponseType.ChainIdResponse,
requestId: request.requestId,
chainId: chainIdToHexadecimalString(UniverseChainId.Mainnet),
}
yield* call(dappResponseMessageChannel.sendMessageToTab, id, response)
}
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { rpcErrors, serializeError } from '@metamask/rpc-errors'
import { logger } from 'ethers'
import { removeDappConnection } from 'src/app/features/dapp/actions'
import { DappInfo } from 'src/app/features/dapp/store'
import { saveAccount } from 'src/app/features/dappRequests/accounts'
import { SenderTabInfo } from 'src/app/features/dappRequests/slice'
import {
DappResponseType,
ErrorResponse,
GetPermissionsRequest,
GetPermissionsResponse,
RequestPermissionsRequest,
RequestPermissionsResponse,
RevokePermissionsRequest,
RevokePermissionsResponse,
} from 'src/app/features/dappRequests/types/DappRequestTypes'
import { extractBaseUrl } from 'src/app/features/dappRequests/utils'
import { dappResponseMessageChannel } from 'src/background/messagePassing/messageChannels'
import { Permission } from 'src/contentScript/WindowEthereumRequestTypes'
import { ExtensionEthMethods } from 'src/contentScript/methodHandlers/requestMethods'
import { call, put } from 'typed-redux-saga'
import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils'
import { pushNotification } from 'wallet/src/features/notifications/slice'
import { AppNotificationType } from 'wallet/src/features/notifications/types'
export function getPermissions(dappUrl: string | undefined, connectedAddresses: Address[] | undefined): Permission[] {
const permissions: Permission[] = []
const isDappConnected = connectedAddresses && connectedAddresses.length > 0
if (isDappConnected && dappUrl) {
// Safe to assume the eth_accounts permission can be added here,
// since dappInfo will only exist if it as been approved already
permissions.push({
invoker: dappUrl,
parentCapability: ExtensionEthMethods.eth_accounts,
caveats: [],
})
}
return permissions
}
export function* handleGetPermissionsRequest(
request: GetPermissionsRequest,
{ id, url }: SenderTabInfo,
dappInfo?: DappInfo,
) {
const permissions: Permission[] = []
if (dappInfo) {
permissions.push({
invoker: url,
parentCapability: ExtensionEthMethods.eth_accounts,
caveats: [],
})
}
const response: GetPermissionsResponse = {
type: DappResponseType.GetPermissionsResponse,
requestId: request.requestId,
permissions,
}
yield* call(dappResponseMessageChannel.sendMessageToTab, id, response)
}
export function* handleRequestPermissions(request: RequestPermissionsRequest, senderTabInfo: SenderTabInfo) {
const requestedPermissions = Object.keys(request.permissions)
if (requestedPermissions.includes(ExtensionEthMethods.eth_accounts)) {
// Pre-emptively save the dapp connection, to avoid double-approval when dapp calls eth_requestAccounts
const accountInfo = yield* call(saveAccount, senderTabInfo)
const accounts = accountInfo && {
connectedAddresses: accountInfo.connectedAddresses,
chainId: chainIdToHexadecimalString(accountInfo.chainId),
providerUrl: accountInfo.providerUrl,
}
const permissions: Permission[] = [
{
invoker: senderTabInfo.url,
parentCapability: ExtensionEthMethods.eth_accounts,
caveats: [],
},
]
const response: RequestPermissionsResponse = {
type: DappResponseType.RequestPermissionsResponse,
requestId: request.requestId,
permissions,
accounts,
}
yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, response)
} else {
logger.info('saga.ts', 'handleRequestPermissions', 'Unknown permissions requested', requestedPermissions)
yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, {
type: DappResponseType.ErrorResponse,
error: serializeError(rpcErrors.methodNotFound()),
requestId: request.requestId,
} satisfies ErrorResponse)
}
}
export function* handleRevokePermissions(request: RevokePermissionsRequest, senderTabInfo: SenderTabInfo) {
const revokedPermissions = Object.keys(request.permissions)
if (revokedPermissions.includes(ExtensionEthMethods.eth_accounts)) {
const dappUrl = extractBaseUrl(senderTabInfo.url)
if (!dappUrl) {
return
}
yield* call(removeDappConnection, dappUrl, undefined)
yield* put(pushNotification({ type: AppNotificationType.DappDisconnected, dappIconUrl: senderTabInfo.favIconUrl }))
yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, {
type: DappResponseType.RevokePermissionsResponse,
requestId: request.requestId,
} satisfies RevokePermissionsResponse)
} else {
logger.info('saga.ts', 'handleRevokePermissions', 'Unknown permissions requested', revokedPermissions)
yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, {
type: DappResponseType.ErrorResponse,
error: serializeError(rpcErrors.methodNotFound()),
requestId: request.requestId,
} satisfies ErrorResponse)
}
}
import { useTranslation } from 'react-i18next'
import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent'
import { Flex, Text } from 'ui/src'
export function ConnectionRequestContent(): JSX.Element {
const { t } = useTranslation()
return (
<DappRequestContent
showAllNetworks
confirmText={t('common.button.connect')}
title={t('dapp.request.connect.title')}
>
<Flex
backgroundColor="$surface2"
borderColor="$surface3"
borderRadius="$rounded12"
borderWidth="$spacing1"
p="$spacing12"
>
<Text color="$neutral2" variant="body4">
{t('dapp.request.connect.helptext')}
</Text>
</Flex>
</DappRequestContent>
)
}
import { useTranslation } from 'react-i18next'
import { useDappLastChainId } from 'src/app/features/dapp/hooks'
import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent'
import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext'
import {
ApproveSendTransactionRequest,
DappRequest as DappRequestBaseType,
DappRequestType,
} from 'src/app/features/dappRequests/types/DappRequestTypes'
import { Flex, Text } from 'ui/src'
import { iconSizes } from 'ui/src/theme'
import { CurrencyLogo } from 'uniswap/src/components/CurrencyLogo/CurrencyLogo'
import { LearnMoreLink } from 'uniswap/src/components/text/LearnMoreLink'
import { uniswapUrls } from 'uniswap/src/constants/urls'
import { CurrencyInfo } from 'uniswap/src/features/dataApi/types'
import { buildCurrencyId } from 'uniswap/src/utils/currencyId'
import { GasFeeResult } from 'wallet/src/features/gas/types'
import { useCurrencyInfo } from 'wallet/src/features/tokens/useCurrencyInfo'
import { TransactionType, TransactionTypeInfo } from 'wallet/src/features/transactions/types'
function useDappRequestTokenRecipientInfo(request: DappRequestBaseType, dappUrl: string): Maybe<CurrencyInfo> {
const activeChain = useDappLastChainId(dappUrl)
const type = request.type
const to = type === DappRequestType.SendTransaction ? request.transaction.to : undefined
const identifier =
activeChain && type === DappRequestType.SendTransaction && to ? buildCurrencyId(activeChain, to) : undefined
return useCurrencyInfo(identifier)
}
function parseSpenderAddress(data: string): string {
// Check if the data is of the correct length for "approve(address,uint256)"
// It should have 10 characters for "0x" + function selector and 64 characters for each parameter
if (data.length !== 10 + 64 * 2) {
throw new Error('Invalid data length')
}
// The first argument (address) starts 10 characters in (after "0x" + 8 characters for function selector)
// and spans the next 64 characters, but the first 24 are padding zeros for the 40-character address
const addressHex = data.slice(34, 74) // From position 34 to 74 to capture the address
// Validate if the address hex is correctly formatted
if (!/^[0-9a-fA-F]{40}$/.test(addressHex)) {
throw new Error('Invalid characters in hex string')
}
return `0x${addressHex}`
}
interface ApproveRequestContentProps {
transactionGasFeeResult: GasFeeResult
dappRequest: ApproveSendTransactionRequest
onCancel: () => Promise<void>
onConfirm: (transactionTypeInfo?: TransactionTypeInfo) => Promise<void>
}
export function ApproveRequestContent({
dappRequest,
transactionGasFeeResult,
onCancel,
onConfirm,
}: ApproveRequestContentProps): JSX.Element {
const { t } = useTranslation()
const { dappUrl } = useDappRequestQueueContext()
const tokenInfo = useDappRequestTokenRecipientInfo(dappRequest, dappUrl)
const tokenSymbol = tokenInfo?.currency.symbol
const spender = parseSpenderAddress(dappRequest.transaction.data)
const transactionTypeInfo: TransactionTypeInfo | undefined = dappRequest.transaction.to
? {
type: TransactionType.Approve,
tokenAddress: dappRequest.transaction.to,
spender,
}
: undefined
const onConfirmWithTransactionTypeInfo = (): Promise<void> => onConfirm(transactionTypeInfo)
return (
<DappRequestContent
showNetworkCost
confirmText={t('dapp.request.approve.action')}
headerIcon={<CurrencyLogo hideNetworkLogo currencyInfo={tokenInfo} size={iconSizes.icon40} />}
title={tokenSymbol ? t('dapp.request.approve.title', { tokenSymbol }) : t('dapp.request.approve.fallbackTitle')}
transactionGasFeeResult={transactionGasFeeResult}
onCancel={onCancel}
onConfirm={onConfirmWithTransactionTypeInfo}
>
<Flex
backgroundColor="$surface2"
borderColor="$surface3"
borderRadius="$rounded12"
borderWidth="$spacing1"
gap="$spacing4"
p="$spacing12"
>
<Text color="$neutral2" variant="body4">
{t('dapp.request.approve.helptext')}
</Text>
<LearnMoreLink textVariant="body4" url={uniswapUrls.helpArticleUrls.approvalsExplainer} />
</Flex>
</DappRequestContent>
)
}
import { BigNumber } from 'ethers'
export const CONTRACT_BALANCE = BigNumber.from(2).pow(255)
export const ETH_ADDRESS = '0x0000000000000000000000000000000000000000'
export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
export const MAX_UINT256 = BigNumber.from(2).pow(256).sub(1)
export const MAX_UINT160 = BigNumber.from(2).pow(160).sub(1)
export const SENDER_AS_RECIPIENT = '0x0000000000000000000000000000000000000001'
export const ROUTER_AS_RECIPIENT = '0x0000000000000000000000000000000000000002'
export const OPENSEA_CONDUIT_SPENDER_ID = 0
export const SUDOSWAP_SPENDER_ID = 1
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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